{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "colophon docs",
  "home_page_url": "https://docs.colophon.blog",
  "feed_url": "https://docs.colophon.blog/feed.json",
  "items": [
    {
      "id": "https://docs.colophon.blog/start/",
      "url": "https://docs.colophon.blog/start/",
      "title": "colophon documentation",
      "summary": "End-user guides for authoring, theming and publishing a colophon site.",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/README.md — do not edit by hand. --\u003e\n\u003cp\u003eEnd-user guides for authoring, theming and publishing a colophon site.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ca href=\"/start/content/\"\u003eAuthoring content\u003c/a\u003e\u003c/strong\u003e — frontmatter, supported Markdown, the rich blocks\n(maths, diagrams, callouts, code) and how they render, wikilinks, embeds and images.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ca href=\"/start/themes/\"\u003eThemes\u003c/a\u003e\u003c/strong\u003e — selecting a theme, per-environment theme overrides, writing or\noverriding a theme, the template variables, and progressive enhancement.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ca href=\"/guides/seo/\"\u003eSEO \u0026amp; social\u003c/a\u003e\u003c/strong\u003e — the \u003ccode\u003eseo:\u003c/code\u003e frontmatter block and the canonical / Open Graph /\nTwitter / JSON-LD metadata colophon emits.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ca href=\"/guides/image-generation/\"\u003eImage \u0026amp; audio generation\u003c/a\u003e\u003c/strong\u003e — \u003ccode\u003egen:\u003c/code\u003e image prompts, AI or recorded\npost audio (podcast feeds), providers, the \u003ccode\u003e--generate-ai\u003c/code\u003e step, the kill switch, and pruning.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ca href=\"/guides/personas/\"\u003eAuthors \u0026amp; personas\u003c/a\u003e\u003c/strong\u003e — the \u003cstrong\u003eauthor\u003c/strong\u003e (the shown byline + h-card) vs the\n\u003cstrong\u003epersona\u003c/strong\u003e (a hidden, shareable writing voice), and the \u003ccode\u003epersona context\u003c/code\u003e command that emits\nwrite-as context (style guide + relevant exemplars) for an AI author.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ca href=\"/start/publishing/\"\u003ePublishing\u003c/a\u003e\u003c/strong\u003e — environments vs publishers, credentials, and routing\nassets to an object store.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ca href=\"/guides/syndication/\"\u003eSyndication (POSSE)\u003c/a\u003e\u003c/strong\u003e — cross-post to Mastodon/Bluesky/anywhere with\n\u003ccode\u003ecolophon syndicate\u003c/code\u003e: the ledger, gating, and the \u003ccode\u003ecommand\u003c/code\u003e/\u003ccode\u003emastodon\u003c/code\u003e/\u003ccode\u003ebluesky\u003c/code\u003e/\u003ccode\u003ebridgy\u003c/code\u003e drivers.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ca href=\"/guides/skills/\"\u003eAgent skills\u003c/a\u003e\u003c/strong\u003e \u003cem\u003e(design)\u003c/em\u003e — the planned authoring skills (seo, draft, tag,\nsocial…) and the prompt packs that drive them.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ca href=\"/guides/howto/\"\u003eHow-to guides\u003c/a\u003e\u003c/strong\u003e — short zero-to-published recipes: federate via Bridgy Fed, show\nwebmentions, syndicate to Mastodon/Bluesky. Each notes whether it's shipped or planned.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ca href=\"/reference/changelog/\"\u003eChangelog\u003c/a\u003e\u003c/strong\u003e — user-facing changes per release, each pointing at the guide that\ndocuments it in full.\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eDesign notes and the roadmap live in \u003ca href=\"PLAN.md\"\u003ePLAN.md\u003c/a\u003e and \u003ca href=\"/internals/\"\u003edesign/\u003c/a\u003e.\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/README.md\"\u003e\u003ccode\u003edocs/README.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-30T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/start/content/",
      "url": "https://docs.colophon.blog/start/content/",
      "title": "Authoring content",
      "summary": "A colophon post is a Markdown file with a YAML frontmatter block. Files come from one or more sources (a content/ folder, an Obsidian vault, …); the source's folder structure…",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/content.md — do not edit by hand. --\u003e\n\u003cp\u003eA colophon post is a Markdown file with a YAML \u003cem\u003efrontmatter\u003c/em\u003e block. Files come from one or\nmore \u003cstrong\u003esources\u003c/strong\u003e (a \u003ccode\u003econtent/\u003c/code\u003e folder, an Obsidian vault, …); the source's folder structure\nbecomes the site's URL structure.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-markdown\"\u003e---\ntitle: My first post\ndate: 2026-06-15\ndescription: A one-line summary for feeds and link previews.\ntags: [notes, colophon]\ndraft: false\n---\n\nWrite the body in **Markdown**.\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"frontmatter-fields\"\u003eFrontmatter fields\u003c/h2\u003e\n\u003cp\u003eAll fields are optional unless noted.\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eField\u003c/th\u003e\n\u003cth\u003eMeaning\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etitle\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePost title. If omitted, falls back to a leading \u003ccode\u003e# heading\u003c/code\u003e or the file name.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003edate\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePublish date (\u003ccode\u003eYYYY-MM-DD\u003c/code\u003e). If omitted (Obsidian), the file's modified time is used.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etype\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePage type (\u003ccode\u003epost\u003c/code\u003e, \u003ccode\u003epage\u003c/code\u003e, or a custom value). Overrides the date-based default — see \u003ca href=\"#page-types\"\u003ePage types\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eslug\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eOverrides the final URL segment (otherwise derived from the file path).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ealiases\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eOld/alternate URL paths that redirect here (e.g. after a rename) — see \u003ca href=\"#redirects-aliases\"\u003eRedirects\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003edescription\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eSummary for feeds, \u003ccode\u003e\u0026lt;meta name=\u0026quot;description\u0026quot;\u0026gt;\u003c/code\u003e and \u003ccode\u003eog:description\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etags\u003c/code\u003e, \u003ccode\u003ecategories\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eLists for organisation/feeds.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eauthor\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe byline — an \u003ccode\u003eauthors/\u0026lt;id\u0026gt;.yaml\u003c/code\u003e id. Defaults to the first author, else \u0026quot;Anonymous\u0026quot;. See \u003ca href=\"/guides/personas/\"\u003eAuthors \u0026amp; personas\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003epersona\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe hidden writing \u003cem\u003evoice\u003c/em\u003e (a \u003ccode\u003epersonas/\u0026lt;id\u0026gt;.yaml\u003c/code\u003e id) used by the agent; never shown.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehero\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eBanner image shown at the top of the post. A path, an Obsidian \u003ccode\u003e\u0026quot;[[image.png]]\u0026quot;\u003c/code\u003e, or a \u003ccode\u003e\u0026quot;gen:\u0026lt;prompt\u0026gt;\u0026quot;\u003c/code\u003e to generate one — see \u003ca href=\"/guides/image-generation/\"\u003eImage generation\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eimage\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePreview/social-card image (\u003ccode\u003eog:image\u003c/code\u003e + index thumbnail). Accepts a path or a \u003ccode\u003e\u0026quot;gen:\u0026lt;prompt\u0026gt;\u0026quot;\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehero_alt\u003c/code\u003e, \u003ccode\u003eimage_alt\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eAlt text for those images. Empty = decorative (\u003ccode\u003ealt=\u0026quot;\u0026quot;\u003c/code\u003e); set it when the image carries meaning.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehero_fit\u003c/code\u003e, \u003ccode\u003eimage_fit\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eHow the image fills its box — CSS \u003ccode\u003eobject-fit\u003c/code\u003e: \u003ccode\u003ecover\u003c/code\u003e (crop, default), \u003ccode\u003econtain\u003c/code\u003e (letterbox), \u003ccode\u003efill\u003c/code\u003e, \u003ccode\u003escale-down\u003c/code\u003e, \u003ccode\u003enone\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehero_position\u003c/code\u003e, \u003ccode\u003eimage_position\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eWhich part shows when cropping — CSS \u003ccode\u003eobject-position\u003c/code\u003e, e.g. \u003ccode\u003etop\u003c/code\u003e or \u003ccode\u003e50% 20%\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eaudio\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eSpoken (TTS) reading of the post. Omit to follow the site default (on when a speech provider is configured); set \u003ccode\u003etrue\u003c/code\u003e/\u003ccode\u003efalse\u003c/code\u003e to force it. Needs \u003ccode\u003egeneration.speech\u003c/code\u003e. See \u003ca href=\"/guides/image-generation/\"\u003eImage \u0026amp; audio generation\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eaudio_file\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eAttach a pre-recorded audio file (a path or \u003ccode\u003e[[embed]]\u003c/code\u003e) instead of generating one — no AI. Wins over \u003ccode\u003eaudio\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eaudio_voice\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eOverride the reading voice id (generated audio only); else the author's/persona's \u003ccode\u003evoice\u003c/code\u003e, else the site default.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eattachments\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eDownloadable files shipped with the post (scripts, archives, datasets, PDFs…). A list of paths or \u003ccode\u003e{path, label, feed}\u003c/code\u003e mappings — see \u003ca href=\"#attachments-downloads\"\u003eAttachments (downloads)\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003esyndication\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eURLs where this post also lives (e.g. a Mastodon/Bluesky copy you cross-posted). A list of absolute URLs, rendered as mf2 \u003ccode\u003eu-syndication\u003c/code\u003e \u0026quot;Also posted on…\u0026quot; links. The \u003ccode\u003ecolophon syndicate\u003c/code\u003e ledger also feeds these automatically.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003esyndicate\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePOSSE control for \u003ccode\u003ecolophon syndicate\u003c/code\u003e: \u003ccode\u003efalse\u003c/code\u003e opts this post out; a list (\u003ccode\u003e[mastodon]\u003c/code\u003e) picks a subset of the environment's targets; absent = all of them.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003esyndicate_text\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eOptional custom blurb for the syndicated copy (else the driver derives one from the title/summary).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003elang\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePer-post language (BCP-47, e.g. \u003ccode\u003efr\u003c/code\u003e), overriding the site \u003ccode\u003elang\u003c/code\u003e. Emitted as \u003ccode\u003e\u0026lt;html lang\u0026gt;\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eglossary\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003efalse\u003c/code\u003e turns off automatic \u003ca href=\"#glossary\"\u003eglossary\u003c/a\u003e decoration for this post; an explicit \u003ccode\u003e\u0026lt;abbr\u0026gt;\u003c/code\u003e still works.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003edraft\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etrue\u003c/code\u003e keeps the post out of production builds (shown in preview/serve).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003epublish\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eObsidian whitelist flag, honoured when a source sets \u003ccode\u003epublish_required: true\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003epublish_after\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eEmbargo: not published until this time (ISO 8601, e.g. \u003ccode\u003e2026-07-01T09:00:00Z\u003c/code\u003e).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003epredecessor\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe slug (or bare filename) of the post that \u003cem\u003eimmediately precedes\u003c/em\u003e this one in a series — see \u003ca href=\"#post-series\"\u003ePost series\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eseries\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eOptional series \u003cstrong\u003etitle\u003c/strong\u003e. Latest-wins: the newest post in the chain that sets it names the series.\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eSlugs are normalised: each path segment is lower-cased and non-alphanumerics collapse to\nsingle hyphens, so \u003ccode\u003eArchive/My Post.md\u003c/code\u003e → \u003ccode\u003earchive/my-post\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"page-types\"\u003ePage types\u003c/h2\u003e\n\u003cp\u003eEvery entry has a \u003cstrong\u003etype\u003c/strong\u003e that decides how it's placed and which theme template renders it:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eA \u003cstrong\u003e\u003ccode\u003epost\u003c/code\u003e\u003c/strong\u003e is chronological — listed on the index, included in feeds, and shown on its tag\npages.\u003c/li\u003e\n\u003cli\u003eA \u003cstrong\u003e\u003ccode\u003epage\u003c/code\u003e\u003c/strong\u003e is standing chrome — surfaced in the theme's nav menu instead, and kept out of\nthe list and feeds (e.g. About, Now).\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eBy default the type is inferred: an entry \u003cstrong\u003ewith a date\u003c/strong\u003e is a \u003ccode\u003epost\u003c/code\u003e, one \u003cstrong\u003ewithout\u003c/strong\u003e is a\n\u003ccode\u003epage\u003c/code\u003e. Set \u003ccode\u003etype:\u003c/code\u003e in frontmatter to override that, or to use a custom type:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e---\ntitle: Side Projects\ntype: project        # a custom type; styled by a theme\u0026#39;s project.html if it has one\n---\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003etype: page\u003c/code\u003e makes a \u003cem\u003edated\u003c/em\u003e entry standing (nav, not feeds); \u003ccode\u003etype: post\u003c/code\u003e makes a \u003cem\u003edateless\u003c/em\u003e\nentry a listed post.\u003c/li\u003e\n\u003cli\u003eA custom type (e.g. \u003ccode\u003eproject\u003c/code\u003e) is listed like a post, but a theme can give it its own look —\nsee \u003ca href=\"/start/themes/#page-types\"\u003eThemes → Page types\u003c/a\u003e. This \u003ccode\u003etype\u003c/code\u003e is unrelated to \u003ccode\u003eseo.type\u003c/code\u003e\n(the schema.org type).\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"post-series\"\u003ePost series\u003c/h2\u003e\n\u003cp\u003eA post can declare that it follows an earlier post with a single \u003cstrong\u003ebackward\u003c/strong\u003e link, and\ncolophon reconstructs the whole ordered series from those links — adding \u0026quot;Part N of M\u0026quot; /\nprevious / next navigation to \u003cstrong\u003eevery\u003c/strong\u003e member.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e---\ntitle: \u0026#34;Building a Widget, Part Two\u0026#34;\ndate: 2026-06-11\npredecessor: building-a-widget-part-one   # the post just before this one\n---\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003epredecessor:\u003c/code\u003e\u003c/strong\u003e pins the slug (or bare filename, resolved like a \u003ccode\u003e[[wikilink]]\u003c/code\u003e) of the\nimmediately preceding post. It's a single linear chain — a post is in at most one series.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eseries:\u003c/code\u003e\u003c/strong\u003e is the optional title. It's \u003cstrong\u003elatest-wins\u003c/strong\u003e: the name is taken from the \u003cem\u003enewest\u003c/em\u003e\npost in the chain that sets it; if no member sets it, the series is untitled.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eBecause colophon rebuilds the whole site every time, a backward pointer is enough — \u003cstrong\u003eyou never\nedit old posts\u003c/strong\u003e. Publishing Part Two (which points back at Part One) is what gives Part One its\nforward link to Part Two; the engine walks the chain and regenerates both. The series renders\noldest→newest, and the current post is highlighted in the list.\u003c/p\u003e\n\u003cp\u003eThemes get per-post variables (\u003ccode\u003eseries_name\u003c/code\u003e, \u003ccode\u003eseries_total\u003c/code\u003e, \u003ccode\u003eseries_index\u003c/code\u003e,\n\u003ccode\u003eseries_parts\u003c/code\u003e, \u003ccode\u003eseries_prev\u003c/code\u003e, \u003ccode\u003eseries_next\u003c/code\u003e) — set only for posts in a series of two or more —\nand a \u003ccode\u003eseries\u003c/code\u003e flag on each post-list item. The bundled \u003cstrong\u003epress\u003c/strong\u003e theme shows the series in the\nleft rail and marks series entries on the index; other themes can adopt the variables.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003ecolophon doctor\u003c/code\u003e warns (without failing) when a \u003ccode\u003epredecessor:\u003c/code\u003e doesn't resolve to a known post,\nwhen the links form a cycle, or when two posts name the same predecessor (a branch).\u003c/p\u003e\n\u003ch2 id=\"markdown-support\"\u003eMarkdown support\u003c/h2\u003e\n\u003cp\u003ecolophon parses \u003ca href=\"https://github.github.com/gfm/\"\u003eGitHub Flavored Markdown\u003c/a\u003e — tables,\nstrikethrough, task lists, autolinks — plus automatic heading IDs (so \u003ccode\u003e## My Heading\u003c/code\u003e is\nlinkable as \u003ccode\u003e#my-heading\u003c/code\u003e).\u003c/p\u003e\n\u003ch3 id=\"the-raw-block-contract-progressive-enhancement\"\u003eThe raw-block contract (progressive enhancement)\u003c/h3\u003e\n\u003cp\u003eRich blocks are rendered as \u003cstrong\u003esemantic HTML that carries its raw source as text\u003c/strong\u003e, tagged by\ntype. colophon itself loads \u003cstrong\u003eno JavaScript\u003c/strong\u003e — it only guarantees the markup. A theme then\nchooses how to present each block: a no-JS/minimal theme shows readable raw text; the default\ntheme upgrades it with \u003ca href=\"https://highlightjs.org/\"\u003ehighlight.js\u003c/a\u003e,\n\u003ca href=\"https://katex.org/\"\u003eKaTeX\u003c/a\u003e and \u003ca href=\"https://mermaid.js.org/\"\u003eMermaid\u003c/a\u003e.\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eYou write\u003c/th\u003e\n\u003cth\u003ecolophon emits\u003c/th\u003e\n\u003cth\u003eEnhanced by\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e```go … ```\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;pre\u0026gt;\u0026lt;code class=\u0026quot;language-go\u0026quot;\u0026gt;…\u0026lt;/code\u0026gt;\u0026lt;/pre\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ea syntax highlighter\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e```mermaid … ```\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;pre class=\u0026quot;mermaid\u0026quot;\u0026gt;…\u0026lt;/pre\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eMermaid\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e$E=mc^2$\u003c/code\u003e (inline)\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;span class=\u0026quot;math math-inline\u0026quot;\u0026gt;E=mc^2\u0026lt;/span\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eKaTeX\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e$$ … $$\u003c/code\u003e (display)\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;div class=\u0026quot;math math-display\u0026quot;\u0026gt;…\u0026lt;/div\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eKaTeX\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026gt; [!note] Title\u003c/code\u003e …\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;div class=\u0026quot;callout callout-note\u0026quot;\u0026gt;…\u0026lt;/div\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eCSS only (no JS)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026gt; [!quote] Attribution\u003c/code\u003e …\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;figure class=\u0026quot;pullquote\u0026quot;\u0026gt;\u0026lt;blockquote\u0026gt;…\u0026lt;/blockquote\u0026gt;\u0026lt;figcaption\u0026gt;…\u0026lt;/figcaption\u0026gt;\u0026lt;/figure\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eCSS only (no JS)\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eNotes:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eMaths\u003c/strong\u003e is matched on a single line. A currency heuristic leaves prose like \u003ccode\u003e$5 and $10\u003c/code\u003e\nalone. The LaTeX source is preserved verbatim, so it is readable even without KaTeX.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCallouts\u003c/strong\u003e use Obsidian syntax — a blockquote whose first line is \u003ccode\u003e[!type] Optional Title\u003c/code\u003e. The body is normal Markdown. Types map to colours via CSS classes\n(\u003ccode\u003enote\u003c/code\u003e/\u003ccode\u003einfo\u003c/code\u003e, \u003ccode\u003etip\u003c/code\u003e/\u003ccode\u003esuccess\u003c/code\u003e, \u003ccode\u003ewarning\u003c/code\u003e, \u003ccode\u003edanger\u003c/code\u003e, \u003ccode\u003eexample\u003c/code\u003e, …).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePull-quotes\u003c/strong\u003e are the \u003ccode\u003e[!quote]\u003c/code\u003e callout type — they render as a semantic \u003ccode\u003e\u0026lt;figure\u0026gt;\u003c/code\u003e with\nthe text after \u003ccode\u003e[!quote]\u003c/code\u003e as the attribution \u003ccode\u003e\u0026lt;figcaption\u0026gt;\u003c/code\u003e (omit it for an unattributed\nquote). The \u003cstrong\u003epress\u003c/strong\u003e theme styles this as a large display epigraph; other themes can target\n\u003ccode\u003e.pullquote\u003c/code\u003e. Plain blockquotes (\u003ccode\u003e\u0026gt;\u003c/code\u003e without \u003ccode\u003e[!quote]\u003c/code\u003e) are unchanged.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eMermaid\u003c/strong\u003e uses the diagram source as the element's text, so it degrades to a readable\ndescription without the library.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003eTip — preview every feature in your theme.\u003c/strong\u003e \u003ccode\u003ecolophon serve --showcase\u003c/code\u003e injects a built-in\n\u003ccode\u003e/showcase/\u003c/code\u003e page (embedded in the binary, never written to your content) that renders every one\nof these blocks — callouts, pull-quotes, tables, maths, diagrams, media, attachments, glossary —\nin your active theme, with the source shown alongside. Handy when writing or styling a theme.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch3 id=\"links-wikilinks-and-images\"\u003eLinks, wikilinks and images\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eStandard Markdown links and images work as usual.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eWikilinks\u003c/strong\u003e resolve across every source at build time — a vault note can link to a post\nin \u003ccode\u003econtent/\u003c/code\u003e and vice versa: \u003ccode\u003e[[note]]\u003c/code\u003e, \u003ccode\u003e[[note|alias]]\u003c/code\u003e, \u003ccode\u003e[[note#heading]]\u003c/code\u003e. An\nunresolved link degrades to plain text rather than breaking.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTags\u003c/strong\u003e (\u003ccode\u003etags:\u003c/code\u003e frontmatter) render on each post and on the index, linked to a generated\npage per tag at \u003ccode\u003e/tags/\u0026lt;tag\u0026gt;/\u003c/code\u003e that lists every post sharing it — so tags become sideways\nnavigation across entries.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eEmbeds\u003c/strong\u003e (\u003ccode\u003e![[image.png]]\u003c/code\u003e, \u003ccode\u003e![[image.png|alt]]\u003c/code\u003e) resolve attachments vault-wide and are\ncopied next to the page.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e![](relative.png)\u003c/code\u003e images are copied beside the page so the relative \u003ccode\u003esrc\u003c/code\u003e resolves;\nexternal (\u003ccode\u003ehttps://…\u003c/code\u003e) images are left untouched.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGenerated images\u003c/strong\u003e — \u003ccode\u003e![alt](\u0026lt;gen:a prompt here\u0026gt;)\u003c/code\u003e produces the image with an AI provider\nand caches it. Wrap the prompt in \u003ccode\u003e\u0026lt;…\u0026gt;\u003c/code\u003e when it contains spaces. See\n\u003ca href=\"/guides/image-generation/\"\u003eImage generation\u003c/a\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eVideo \u0026amp; audio embeds\u003c/strong\u003e — an image embed whose target is a media file renders as a player,\nnot a broken image. \u003ccode\u003e![A short demo](demo.mp4)\u003c/code\u003e becomes a \u003ccode\u003e\u0026lt;video controls\u0026gt;\u003c/code\u003e; \u003ccode\u003e![](clip.mp3)\u003c/code\u003e\nbecomes an \u003ccode\u003e\u0026lt;audio controls\u0026gt;\u003c/code\u003e. The file is copied/routed exactly like an image (so object\nstorage works the same), and the embed's alt text becomes the player's \u003ccode\u003earia-label\u003c/code\u003e. See\n\u003ca href=\"#embedding-video-and-audio\"\u003eEmbedding video and audio\u003c/a\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"images-and-object-storage\"\u003eImages and object storage\u003c/h3\u003e\n\u003cp\u003eBy default images are co-located with the page and served relatively. A site can \u003cstrong\u003eroute\u003c/strong\u003e\nimages (or any path glob) to an object store (e.g. Cloudflare R2) instead — see the publisher\nconfiguration. When routing is active the build rewrites those image URLs to the store's\npublic base, so the page references \u003ccode\u003ehttps://assets.example.com/…\u003c/code\u003e while the bytes are\nuploaded to the store rather than your HTML host.\u003c/p\u003e\n\u003ch3 id=\"embedding-video-and-audio\"\u003eEmbedding video and audio\u003c/h3\u003e\n\u003cp\u003eThere is \u003cstrong\u003eno new syntax\u003c/strong\u003e — use the markdown image embed you already know, pointing at a media\nfile. colophon recognises the extension and renders a player instead of an \u003ccode\u003e\u0026lt;img\u0026gt;\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-markdown\"\u003e![A short demo](demo.mp4)     \u0026lt;!-- → \u0026lt;video controls\u0026gt;, with the alt as its aria-label --\u0026gt;\n![[demo.mp4]]                  \u0026lt;!-- Obsidian embeds work too --\u0026gt;\n![](interview.mp3)            \u0026lt;!-- → \u0026lt;audio controls\u0026gt; --\u0026gt;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eVideo\u003c/strong\u003e: \u003ccode\u003e.mp4\u003c/code\u003e, \u003ccode\u003e.webm\u003c/code\u003e, \u003ccode\u003e.mov\u003c/code\u003e, \u003ccode\u003e.m4v\u003c/code\u003e, \u003ccode\u003e.ogv\u003c/code\u003e. \u003cstrong\u003eAudio\u003c/strong\u003e: \u003ccode\u003e.mp3\u003c/code\u003e, \u003ccode\u003e.m4a\u003c/code\u003e, \u003ccode\u003e.aac\u003c/code\u003e,\n\u003ccode\u003e.oga\u003c/code\u003e, \u003ccode\u003e.ogg\u003c/code\u003e, \u003ccode\u003e.wav\u003c/code\u003e, \u003ccode\u003e.flac\u003c/code\u003e, \u003ccode\u003e.opus\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eThe file is discovered, copied beside the page, and \u003cstrong\u003erouted to object storage\u003c/strong\u003e exactly like\nan image — self-hosting \u0026quot;just works\u0026quot;, including via R2.\u003c/li\u003e\n\u003cli\u003eA direct \u003cstrong\u003eexternal\u003c/strong\u003e file URL plays too (e.g. \u003ccode\u003e![](https://cdn.example.com/clip.mp4)\u003c/code\u003e); it is\nleft untouched and not copied.\u003c/li\u003e\n\u003cli\u003eThis is independent of \u003ccode\u003eaudio_file:\u003c/code\u003e/\u003ccode\u003eaudio:\u003c/code\u003e, which attach a single \u003cem\u003epodcast-style reading\u003c/em\u003e of\nthe whole post (with the themed player and feed enclosure). Inline embeds are just media in the\nbody.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003eBig files belong in object storage or a CDN, not your Git host. Route a \u003ccode\u003e**/*.mp4\u003c/code\u003e glob to R2\n(see the publisher config) and the embed URL is rewritten automatically.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch3 id=\"attachments-downloads\"\u003eAttachments (downloads)\u003c/h3\u003e\n\u003cp\u003eList downloadable files in frontmatter and colophon copies/routes them like images and renders a\n\u003cstrong\u003eDownloads\u003c/strong\u003e block on the post. Each entry is either a bare path or a \u003ccode\u003e{path, label, feed}\u003c/code\u003e\nmapping:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e---\ntitle: Release Notes\nattachments:\n  - changelog.txt                                   # label defaults to the file name\n  - { path: build.sh, label: \u0026#34;Build script\u0026#34;, description: \u0026#34;Sets up the toolchain\u0026#34; }\n  - { path: dataset.zip, label: \u0026#34;Dataset\u0026#34;, description: \u0026#34;Raw measurements\u0026#34;, feed: true }\n---\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003ePaths resolve \u003cstrong\u003erelative to the post\u003c/strong\u003e (same rules as an image embed); \u003ccode\u003e[[embed]]\u003c/code\u003e works too.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003elabel\u003c/code\u003e sets the link text (defaults to the file name); \u003ccode\u003edescription\u003c/code\u003e adds a one-line note\nbeneath it. The file's \u003cstrong\u003esize\u003c/strong\u003e and a short \u003cstrong\u003efiletype\u003c/strong\u003e badge (ZIP, PDF, MP4…) are shown\nautomatically.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003efeed: true\u003c/code\u003e also lists the file as a feed enclosure/attachment (see below). Without it, the\nfile is downloadable on the page but stays out of the feeds.\u003c/li\u003e\n\u003cli\u003ePosts with attachments get a small paperclip marker in the listing (alongside the audio\nspeaker), in the press and contrib themes.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eAttachments in feeds.\u003c/strong\u003e A post's audio reading and any \u003ccode\u003efeed: true\u003c/code\u003e attachment are emitted to\nthe syndication feeds so podcast/feed clients can fetch them:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eJSON Feed\u003c/strong\u003e — every item appears in \u003ccode\u003eattachments\u003c/code\u003e (multiple allowed).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAtom\u003c/strong\u003e — each is a \u003ccode\u003e\u0026lt;link rel=\u0026quot;enclosure\u0026quot;\u0026gt;\u003c/code\u003e (multiple allowed).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eRSS\u003c/strong\u003e — carries a single \u003ccode\u003e\u0026lt;enclosure\u0026gt;\u003c/code\u003e per the spec: the audio reading wins, else the first\n\u003ccode\u003efeed: true\u003c/code\u003e attachment.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"slide-decks\"\u003eSlide decks\u003c/h3\u003e\n\u003cp\u003eA post can be projected into a \u003cstrong\u003ethemed slide deck\u003c/strong\u003e — published at \u003ccode\u003e…/\u0026lt;slug\u0026gt;/slides/\u003c/code\u003e, linked from\nthe post's Downloads box, and flagged with a slides marker in the listing (alongside the audio and\nattachment markers). It's \u003cstrong\u003ederived\u003c/strong\u003e from the post: headings become slides (or bullets), prose\nbecomes speaker notes, and other blocks render on the slide. With JavaScript it's a keyboard/swipe\npresentation (\u003ckbd\u003eP\u003c/kbd\u003e = presenter notes, \u003ckbd\u003eF\u003c/kbd\u003e = fullscreen); with JS off the same file\nreads as a long-form document.\u003c/p\u003e\n\u003cp\u003eSet the site default in \u003ccode\u003ecolophon.yaml\u003c/code\u003e (\u003ccode\u003eslides.enabled\u003c/code\u003e), then opt a post in or out:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e---\ntitle: A Short Talk\nslides: true                 # or the block form below\n# slides:\n#   enabled: true\n#   split: [h2]              # slide boundaries (a list). default: every heading.\n---\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003esplit\u003c/code\u003e\u003c/strong\u003e lists the boundaries: \u003ccode\u003eh1\u003c/code\u003e–\u003ccode\u003eh6\u003c/code\u003e, \u003ccode\u003ehr\u003c/code\u003e, \u003ccode\u003esplitslide\u003c/code\u003e, the block kinds \u003ccode\u003eimage\u003c/code\u003e/\u003ccode\u003etable\u003c/code\u003e/\n\u003ccode\u003ecode\u003c/code\u003e/\u003ccode\u003emath\u003c/code\u003e/\u003ccode\u003ediagram\u003c/code\u003e/\u003ccode\u003eaudio\u003c/code\u003e/\u003ccode\u003evideo\u003c/code\u003e, and \u003ccode\u003etext:\u0026lt;match\u0026gt;\u003c/code\u003e (split before a block whose text begins\nwith the match). The default splits on every heading; narrow it (e.g. \u003ccode\u003e[h2]\u003c/code\u003e) to fold deeper\nheadings into bullets.\u003c/li\u003e\n\u003cli\u003eThe post's \u003ccode\u003eslides:\u003c/code\u003e \u003cstrong\u003eoverwrites\u003c/strong\u003e the site default by key (it does not deep-merge): a key you set\nreplaces that value, keys you omit inherit.\u003c/li\u003e\n\u003cli\u003eA site-wide \u003ccode\u003eslides.enabled: true\u003c/code\u003e applies to \u003cstrong\u003elisted content\u003c/strong\u003e (posts and custom types); standing\n\u003cstrong\u003epages\u003c/strong\u003e (About, etc.) don't get a deck from the default — they opt in with their own \u003ccode\u003eslides: true\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eAn \u003cstrong\u003eenvironment\u003c/strong\u003e can override the site default (\u003ccode\u003eslides: { enabled, split }\u003c/code\u003e under the environment),\ne.g. decks \u003cstrong\u003eon in preview, off in production\u003c/strong\u003e.\u003c/li\u003e\n\u003cli\u003eThree inline markers mirror the \u003ccode\u003e\u0026lt;tts\u0026gt;\u003c/code\u003e family: \u003ccode\u003e\u0026lt;splitslide\u0026gt;\u003c/code\u003e forces a break, \u003ccode\u003e\u0026lt;slide\u0026gt;…\u0026lt;/slide\u0026gt;\u003c/code\u003e\nmakes one verbatim slide, and \u003ccode\u003e\u0026lt;noslide\u0026gt;…\u0026lt;/noslide\u0026gt;\u003c/code\u003e stays in the post but is kept out of the deck.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"multiple-languages-translations\"\u003eMultiple languages (translations)\u003c/h3\u003e\n\u003cp\u003ePublish the same post in several languages. Enable the languages on the site, then add a translation\nwith a \u003ccode\u003e.\u0026lt;lang\u0026gt;.md\u003c/code\u003e filename:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# colophon.yaml\nsites:\n  - lang: en                 # the default language (stays at the normal URL)\n    languages: [en, es, fr]  # the languages you publish in\n\u003c/code\u003e\u003c/pre\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003econtent/posts/my-post.md       → English   →  /posts/my-post/\ncontent/posts/my-post.es.md    → Spanish   →  /es/posts/my-post/\ncontent/posts/my-post.fr.md    → French    →  /fr/posts/my-post/\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003eTranslations are linked by their \u003cstrong\u003ebase slug\u003c/strong\u003e (\u003ccode\u003emy-post\u003c/code\u003e); each can set its own \u003ccode\u003etitle\u003c/code\u003e,\n\u003ccode\u003edescription\u003c/code\u003e, hero, even \u003ccode\u003eslug\u003c/code\u003e. The default language stays at the normal path; others are\npublished under a \u003cstrong\u003e\u003ccode\u003e/\u0026lt;lang\u0026gt;/\u003c/code\u003e\u003c/strong\u003e prefix.\u003c/li\u003e\n\u003cli\u003eEvery translation emits \u003cstrong\u003e\u003ccode\u003ehreflang\u003c/code\u003e alternates\u003c/strong\u003e (plus \u003ccode\u003ex-default\u003c/code\u003e) so search engines serve the\nright language, and the \u003cstrong\u003epress\u003c/strong\u003e theme shows a \u003cstrong\u003elanguage selector\u003c/strong\u003e in the post header.\u003c/li\u003e\n\u003cli\u003eA small, dismissible banner offers a reader their preferred language (from the browser) when the\npost is available in it — it never force-redirects.\u003c/li\u003e\n\u003cli\u003eEach translation is a normal post, so it gets its own spoken reading, feeds, glossary and deck.\u003c/li\u003e\n\u003cli\u003eA \u003ccode\u003e.\u0026lt;lang\u0026gt;\u003c/code\u003e is only treated as a language when \u003ccode\u003e\u0026lt;lang\u0026gt;\u003c/code\u003e is in \u003ccode\u003elanguages\u003c/code\u003e — a file like\n\u003ccode\u003emy.notes.md\u003c/code\u003e is unaffected.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"how-file-references-resolve\"\u003eHow file references resolve\u003c/h3\u003e\n\u003cp\u003eTwo kinds of reference resolve differently:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003ePer-post references\u003c/strong\u003e — markdown embeds/images, and a post's \u003ccode\u003ehero\u003c/code\u003e/\u003ccode\u003eimage\u003c/code\u003e — resolve\nagainst \u003cem\u003ethat post's own source\u003c/em\u003e (its driver's rules: a vault searches its scan roots and the\nvault, an \u003ccode\u003emd-dir\u003c/code\u003e resolves dir-relative). They stay driver-relative so a missing embed is a\nreal error, not silently masked by another source.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eProject-level references\u003c/strong\u003e — an author \u003ccode\u003eavatar\u003c/code\u003e — resolve across \u003cem\u003eevery\u003c/em\u003e content source and\nthen fall back to the \u003cstrong\u003eproject root\u003c/strong\u003e. The same \u003ccode\u003eavatar: assets/me.png\u003c/code\u003e therefore works whether\nthe file lives in a content dir, a vault, or the project's own \u003ccode\u003eassets/\u003c/code\u003e — portable across\ndrivers.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003ccode\u003ecolophon doctor\u003c/code\u003e dry-resolves every \u003cem\u003edefined\u003c/em\u003e reference through the same machinery and warns when\none can't be sourced (a likely broken link). An \u003cem\u003eundefined\u003c/em\u003e reference is fine — it just means none\nwas wanted. \u003ccode\u003edata:\u003c/code\u003e/\u003ccode\u003ehttp(s)://\u003c/code\u003e references always pass through untouched.\u003c/p\u003e\n\u003ch2 id=\"redirects-aliases\"\u003eRedirects (aliases)\u003c/h2\u003e\n\u003cp\u003eWhen you rename a post (or want short links), list the old paths in \u003ccode\u003ealiases:\u003c/code\u003e so the old URLs\nkeep working:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e---\ntitle: A Renamed Post\nslug: renamed\naliases:\n  - old-name            # /old-name/        → /posts/renamed/\n  - 2020/legacy-post    # nested paths fine\n---\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eEach alias is normalised like a slug (lower-cased, non-alphanumerics → hyphens, \u003ccode\u003e/\u003c/code\u003e kept). For\neach, the build emits:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003ea \u003cstrong\u003emeta-refresh stub\u003c/strong\u003e at \u003ccode\u003e\u0026lt;alias\u0026gt;/index.html\u003c/code\u003e → the post (works on \u003cem\u003eany\u003c/em\u003e static host),\u003c/li\u003e\n\u003cli\u003ea line in a root \u003cstrong\u003e\u003ccode\u003e_redirects\u003c/code\u003e\u003c/strong\u003e file, and\u003c/li\u003e\n\u003cli\u003ea root \u003cstrong\u003e\u003ccode\u003e.nojekyll\u003c/code\u003e\u003c/strong\u003e (so GitHub Pages serves the stubs and the \u003ccode\u003e_search/\u003c/code\u003e index).\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eHow that becomes a redirect depends on the host: \u003cstrong\u003eCloudflare Pages, Netlify and GitLab Pages\u003c/strong\u003e\nread \u003ccode\u003e_redirects\u003c/code\u003e and serve a real \u003cstrong\u003e301\u003c/strong\u003e; \u003cstrong\u003eS3 static-website\u003c/strong\u003e hosting gets a 301 too (colophon\nsets the object redirect header on publish); plain object stores (R2, bare S3/MinIO) and GitHub\nPages fall back to the client-side meta-refresh stub. Either way the old URL resolves.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eCollisions\u003c/strong\u003e are resolved deterministically with a warning: an alias that matches a real page is\nignored (the page wins), and if two posts claim the same alias the newest wins.\u003c/p\u003e\n\u003ch2 id=\"glossary\"\u003eGlossary\u003c/h2\u003e\n\u003cp\u003eDrop a \u003ccode\u003eglossary.yaml\u003c/code\u003e (term → definition) at the project root and colophon publishes it as\n\u003ccode\u003eglossary.json\u003c/code\u003e; a JS-enabled theme then \u003cstrong\u003eautomatically\u003c/strong\u003e decorates the first occurrence of\neach term in your prose with an accessible pop-over (a \u0026quot;dictionary stanza\u0026quot; with the term and\nits definition). It is never rendered as a page, and it degrades gracefully — the text-only\ntheme just shows the words plain.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# glossary.yaml\nAPI: \u0026#34;Application Programming Interface — the contract one program exposes for another to call.\u0026#34;\nSSG: \u0026#34;Static Site Generator — renders content into static HTML served as-is.\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eYou write naturally — no markup needed. When you \u003cem\u003edo\u003c/em\u003e want control over a specific word, three\ncontrols are available (the syntactic sugar):\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eYou want…\u003c/th\u003e\n\u003cth\u003eWrite…\u003c/th\u003e\n\u003cth\u003eEffect\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003eTurn the whole post off\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eglossary: false\u003c/code\u003e in frontmatter\u003c/td\u003e\n\u003ctd\u003eNo automatic matching. Explicit \u003ccode\u003e\u0026lt;abbr\u0026gt;\u003c/code\u003e forces still work.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eForce\u003c/strong\u003e a specific word\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;abbr\u0026gt;API\u0026lt;/abbr\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eAlways decorated, even mid-post or in an opted-out post — the same \u003ccode\u003e\u0026lt;abbr\u0026gt;\u003c/code\u003e auto-match produces. An \u003ccode\u003e\u0026lt;abbr title=\u0026quot;…\u0026quot;\u0026gt;\u003c/code\u003e you write yourself is left alone.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eSuppress\u003c/strong\u003e one word\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;noabbr\u0026gt;Go\u0026lt;/noabbr\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThat occurrence is left plain (use it when a term is also a common word). The mirror of \u003ccode\u003e\u0026lt;abbr\u0026gt;\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eDecoration always skips code, links, headings, your own \u003ccode\u003e\u0026lt;abbr title=\u0026quot;…\u0026quot;\u0026gt;\u003c/code\u003e and anything inside\n\u003ccode\u003e\u0026lt;noabbr\u0026gt;\u003c/code\u003e, and only the \u003cstrong\u003efirst\u003c/strong\u003e occurrence of a term is auto-decorated, so a post is never\npeppered with repeats.\u003c/p\u003e\n\u003ch2 id=\"sources\"\u003eSources\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003emd-dir\u003c/code\u003e\u003c/strong\u003e — a directory of Markdown files (default: \u003ccode\u003econtent/\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eobsidian\u003c/code\u003e\u003c/strong\u003e — an Obsidian vault, read in place. By convention it publishes only notes\nwith \u003ccode\u003epublish: true\u003c/code\u003e (unless the source sets \u003ccode\u003epublish_required: false\u003c/code\u003e), derives a missing\ntitle from a leading \u003ccode\u003e# heading\u003c/code\u003e or the file name, and a missing date from the file's\nmodified time.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eMultiple sources are merged into one site; deletions and renames flow through the build's\nreconciliation, so the output always matches the inputs.\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/content.md\"\u003e\u003ccode\u003edocs/content.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-29T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/start/publishing/",
      "url": "https://docs.colophon.blog/start/publishing/",
      "title": "Publishing",
      "summary": "colophon separates what/where (environments) from how (publishers).",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/publishing.md — do not edit by hand. --\u003e\n\u003cp\u003ecolophon separates \u003cstrong\u003ewhat/where\u003c/strong\u003e (environments) from \u003cstrong\u003ehow\u003c/strong\u003e (publishers).\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eA \u003cstrong\u003epublisher\u003c/strong\u003e is a deploy mechanism: copy to a folder, upload to Cloudflare Pages, push\nto an object store. Publishers are pure mechanism and carry no policy.\u003c/li\u003e\n\u003cli\u003eAn \u003cstrong\u003eenvironment\u003c/strong\u003e is a named build+deploy profile: which publishers to deploy to, whether\nto include drafts, and optional overrides (title, base_url, \u003cstrong\u003etheme\u003c/strong\u003e).\u003c/li\u003e\n\u003c/ul\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003epublishers:\n  - id: local\n    driver: local\n    path: ./dist\n\nenvironments:\n  - name: production\n    publish: [local]\n    allow_publish: false   # safety latch: requires --allow-publish to deploy\n\u003c/code\u003e\u003c/pre\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon publish --env production --allow-publish\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"publishers\"\u003ePublishers\u003c/h2\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eDriver\u003c/th\u003e\n\u003cth\u003ePurpose\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003elocal\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eCopy the built tree to a directory (offline preview / diffing).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ecloudflare-pages\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eDeploy the site to Cloudflare Pages (direct upload).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ecloudflare-r2\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eUpload files to Cloudflare R2 (S3 + R2 control-plane: public-URL discovery, \u003ccode\u003e--create\u003c/code\u003e expose).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003es3\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eUpload files to any S3-compatible store (MinIO, B2, Wasabi, Amazon S3) — pure data plane, no SDK.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etigris\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe \u003ccode\u003es3\u003c/code\u003e driver with Tigris (Fly.io) defaults — needs only a bucket.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003egit\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eForce-push the built tree to a branch of any git remote (GitHub/GitLab/Codeberg Pages, mirrors, self-hosted).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003egithub-pages\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe \u003ccode\u003egit\u003c/code\u003e driver with GitHub-friendly defaults (branch \u003ccode\u003egh-pages\u003c/code\u003e).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ecommand\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eRun any CLI against the built tree (surge, Netlify, Vercel, rsync, …) — the escape hatch.\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003ch3 id=\"configuration-and-interpolation\"\u003eConfiguration and interpolation\u003c/h3\u003e\n\u003cp\u003ecolophon has \u003cstrong\u003etwo distinct interpolation layers\u003c/strong\u003e — they look similar (\u003ccode\u003e{…}\u003c/code\u003e) but resolve at\ndifferent times, and every driver supports the first:\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e1. Config interpolation — \u003ccode\u003e{env:VAR}\u003c/code\u003e (all drivers, all config).\u003c/strong\u003e Any string value in the\nconfig may reference the environment, resolved \u003cem\u003ebefore the YAML is parsed\u003c/em\u003e, so it works in any\nsetting of any publisher (or anywhere else in the config):\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eForm\u003c/th\u003e\n\u003cth\u003eResolves to\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e{env:VAR}\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ethe value of \u003ccode\u003eVAR\u003c/code\u003e, or empty if unset\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e{env:VAR:-default}\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ethe value of \u003ccode\u003eVAR\u003c/code\u003e, or \u003ccode\u003edefault\u003c/code\u003e if unset\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eValues come from the process environment and from \u003ccode\u003e.env\u003c/code\u003e / \u003ccode\u003e.env.defaults\u003c/code\u003e (loaded first; a real\nenv var wins over a \u003ccode\u003e.env\u003c/code\u003e entry). \u003ccode\u003ecolophon env\u003c/code\u003e lists every \u003ccode\u003e{env:VAR}\u003c/code\u003e a project references,\nset or not. This is how non-secret settings stay flexible while \u003cstrong\u003esecrets stay in the\nenvironment\u003c/strong\u003e — you never write a token into config:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003epublishers:\n  - id: r2\n    driver: cloudflare-r2\n    bucket: \u0026#34;{env:R2_BUCKET:-my-assets}\u0026#34;          # default when unset\n    account_id: \u0026#34;{env:CLOUDFLARE_ACCOUNT_ID}\u0026#34;     # required; empty if unset\n    public_url: \u0026#34;{env:R2_PUBLIC_URL:-}\u0026#34;           # optional; empty default\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003e2. Command interpolation — \u003ccode\u003e{dir}\u003c/code\u003e, \u003ccode\u003e{public_url}\u003c/code\u003e, … (the \u003ccode\u003ecommand\u003c/code\u003e driver only).\u003c/strong\u003e The\n\u003ccode\u003ecommand\u003c/code\u003e publisher additionally interpolates its \u003ccode\u003ecommand\u003c/code\u003e argv \u003cem\u003eat publish time\u003c/em\u003e with runtime\nvalues (the materialised directory, the manifest path, …) and the publisher's own settings — see\n\u003ca href=\"#run-any-cli-the-command-publisher\"\u003eRun any CLI\u003c/a\u003e. The two layers compose: \u003ccode\u003e{env:VAR}\u003c/code\u003e is\nsubstituted when the config loads, then \u003ccode\u003e{placeholder}\u003c/code\u003e when the command runs, so a single\n\u003ccode\u003ecommand\u003c/code\u003e entry can use both.\u003c/p\u003e\n\u003cp\u003ePer-driver settings and their interpolation are documented in each driver's README — linked from\nthe \u003ca href=\"#publishers\"\u003ePublishers\u003c/a\u003e table targets below and listed in\n\u003ca href=\"#secrets-and-permissions\"\u003eSecrets and permissions\u003c/a\u003e.\u003c/p\u003e\n\u003ch3 id=\"secrets-and-permissions\"\u003eSecrets and permissions\u003c/h3\u003e\n\u003cp\u003eDeploy credentials are \u003cstrong\u003enever\u003c/strong\u003e read from config — they come from the environment, so they\nnever pass through the agent or the YAML:\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003ePublisher\u003c/th\u003e\n\u003cth\u003eSecret env vars\u003c/th\u003e\n\u003cth\u003eToken permission\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ecloudflare-pages\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eCLOUDFLARE_API_TOKEN\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eAccount → Cloudflare Pages → \u003cstrong\u003eEdit\u003c/strong\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ecloudflare-r2\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eR2_ACCESS_KEY_ID\u003c/code\u003e / \u003ccode\u003eR2_SECRET_ACCESS_KEY\u003c/code\u003e (or \u003ccode\u003eAWS_*\u003c/code\u003e)\u003c/td\u003e\n\u003ctd\u003eR2 → \u003cstrong\u003eObject Read \u0026amp; Write\u003c/strong\u003e (+ bucket-create for \u003ccode\u003e--create\u003c/code\u003e)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003es3\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eAWS_ACCESS_KEY_ID\u003c/code\u003e / \u003ccode\u003eAWS_SECRET_ACCESS_KEY\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eObject read \u0026amp; write (+ bucket-create for \u003ccode\u003e--create\u003c/code\u003e)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etigris\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eTIGRIS_ACCESS_KEY_ID\u003c/code\u003e / \u003ccode\u003eTIGRIS_SECRET_ACCESS_KEY\u003c/code\u003e (or \u003ccode\u003eAWS_*\u003c/code\u003e)\u003c/td\u003e\n\u003ctd\u003eTigris access key (\u003ccode\u003etid_\u003c/code\u003e/\u003ccode\u003etsec_\u003c/code\u003e) — Editor\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003egit\u003c/code\u003e / \u003ccode\u003egithub-pages\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eGITHUB_TOKEN\u003c/code\u003e / \u003ccode\u003eGH_TOKEN\u003c/code\u003e / \u003ccode\u003eGIT_TOKEN\u003c/code\u003e (HTTPS remotes only)\u003c/td\u003e\n\u003ctd\u003eRepo contents → \u003cstrong\u003ewrite\u003c/strong\u003e (e.g. a GitHub fine-grained PAT or \u003ccode\u003eGITHUB_TOKEN\u003c/code\u003e in Actions). SSH remotes use the agent — no token.\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eNon-secret settings (account id, bucket, project) may use \u003ccode\u003e{env:VAR}\u003c/code\u003e\n\u003ca href=\"#configuration-and-interpolation\"\u003econfig interpolation\u003c/a\u003e. Each driver's README documents its\nsettings and interpolation:\n\u003ca href=\"../internal/publish/local/README.md\"\u003elocal\u003c/a\u003e,\n\u003ca href=\"../internal/publish/cloudflare/README.md\"\u003ecloudflare-pages\u003c/a\u003e,\n\u003ca href=\"../internal/publish/r2/README.md\"\u003ecloudflare-r2\u003c/a\u003e,\n\u003ca href=\"../internal/publish/s3/README.md\"\u003es3 / tigris\u003c/a\u003e,\n\u003ca href=\"../internal/publish/git/README.md\"\u003egit / github-pages\u003c/a\u003e,\n\u003ca href=\"../internal/publish/command/README.md\"\u003ecommand\u003c/a\u003e.\u003c/p\u003e\n\u003ch3 id=\"provisioning-with---create\"\u003eProvisioning with \u003ccode\u003e--create\u003c/code\u003e\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003ecolophon publish --env \u0026lt;name\u0026gt; --create\u003c/code\u003e provisions destinations before deploying:\n\u003ccode\u003ecloudflare-pages\u003c/code\u003e creates the Pages project; \u003ccode\u003ecloudflare-r2\u003c/code\u003e / \u003ccode\u003es3\u003c/code\u003e / \u003ccode\u003etigris\u003c/code\u003e create the\nbucket. All are idempotent — an existing destination is left untouched.\u003c/p\u003e\n\u003cp\u003eFor the object stores, \u003ccode\u003e--create\u003c/code\u003e also sets a \u003cstrong\u003eCORS policy\u003c/strong\u003e allowing cross-origin \u003ccode\u003eGET\u003c/code\u003e/\u003ccode\u003eHEAD\u003c/code\u003e\nfrom any origin (via the S3 \u003ccode\u003ePutBucketCors\u003c/code\u003e API — the only way to configure CORS on R2, which has\nno dashboard for it). This matters when assets are fetched with \u003ccode\u003efetch()\u003c/code\u003e or imported as an ES\nmodule rather than via an \u003ccode\u003e\u0026lt;img\u0026gt;\u003c/code\u003e/\u003ccode\u003e\u0026lt;script\u0026gt;\u003c/code\u003e tag — notably a \u003cstrong\u003erouted search index\u003c/strong\u003e (below): a\ncross-origin \u003ccode\u003e\u0026lt;img\u0026gt;\u003c/code\u003e needs no CORS, but \u003ccode\u003efetch()\u003c/code\u003e/\u003ccode\u003eimport()\u003c/code\u003e do. The step is best-effort: if a\nstore doesn't support \u003ccode\u003ePutBucketCors\u003c/code\u003e, the publish warns and continues, and you set CORS manually.\u003c/p\u003e\n\u003ch3 id=\"generic-s3--minio--backblaze--wasabi\"\u003eGeneric S3 / MinIO / Backblaze / Wasabi\u003c/h3\u003e\n\u003cp\u003eThe \u003ccode\u003es3\u003c/code\u003e driver is plain S3 (SigV4) with no control-plane code — point it at any S3-compatible\nstore with an \u003ccode\u003eendpoint\u003c/code\u003e and \u003ccode\u003eregion\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003epublishers:\n  - id: s3\n    driver: s3\n    bucket: my-assets\n    endpoint: \u0026#34;https://s3.us-east-1.amazonaws.com\u0026#34;   # or http://localhost:9000 (MinIO)\n    region: us-east-1\n    public_url: \u0026#34;https://my-assets.s3.us-east-1.amazonaws.com\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eCredentials come from \u003ccode\u003eAWS_ACCESS_KEY_ID\u003c/code\u003e / \u003ccode\u003eAWS_SECRET_ACCESS_KEY\u003c/code\u003e. \u003ccode\u003epublish --create\u003c/code\u003e creates\nthe bucket (idempotent). \u003ccode\u003epublic_url\u003c/code\u003e is how colophon learns the public base URL — there's no\ncontrol-plane lookup, so set it (a route with no resolvable URL stays inactive).\u003c/p\u003e\n\u003ch3 id=\"tigris-flyio\"\u003eTigris (Fly.io)\u003c/h3\u003e\n\u003cp\u003e\u003ca href=\"https://www.tigrisdata.com/\"\u003eTigris\u003c/a\u003e is Fly.io's global object store, and it's \u003cstrong\u003eplain S3\u003c/strong\u003e —\ncolophon talks to it with the same client as any S3 store, so \u003cstrong\u003eno \u003ccode\u003eflyctl\u003c/code\u003e / Fly SDK /\ncontrol-plane token is involved.\u003c/strong\u003e The \u003ccode\u003etigris\u003c/code\u003e driver is the \u003ccode\u003es3\u003c/code\u003e driver with the endpoint\n(\u003ccode\u003ehttps://t3.storage.dev\u003c/code\u003e) and region (\u003ccode\u003eauto\u003c/code\u003e) defaulted, so it needs only a bucket:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003epublishers:\n  - id: assets\n    driver: tigris\n    bucket: my-blog-assets\n    public_url: \u0026#34;https://my-blog-assets.t3.storage.dev\u0026#34;   # the bucket\u0026#39;s public/CDN domain\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eCredentials come from \u003ccode\u003eTIGRIS_ACCESS_KEY_ID\u003c/code\u003e / \u003ccode\u003eTIGRIS_SECRET_ACCESS_KEY\u003c/code\u003e (the \u003ccode\u003etid_\u003c/code\u003e/\u003ccode\u003etsec_\u003c/code\u003e\nkeys, falling back to \u003ccode\u003eAWS_*\u003c/code\u003e). \u003ccode\u003epublish --create\u003c/code\u003e creates the bucket. Two things are one-time\n\u003cstrong\u003edashboard\u003c/strong\u003e settings (Tigris has no data-plane API for them, which is what keeps publishing\nSDK-free): \u003cstrong\u003emake the bucket public\u003c/strong\u003e to serve a site from it, and optionally \u003cstrong\u003eattach a custom\ndomain\u003c/strong\u003e (a CNAME to \u003ccode\u003e\u0026lt;bucket\u0026gt;.t3.storage.dev\u003c/code\u003e). Newer accounts serve public content from\n\u003ccode\u003et3.tigrisfiles.io\u003c/code\u003e — set \u003ccode\u003epublic_url\u003c/code\u003e to whatever the bucket actually serves at.\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003eProvisioning credentials is separate: \u003ccode\u003eflyctl storage create\u003c/code\u003e issues a bucket + keys, but\nthat's a one-time setup step, not part of \u003ccode\u003ecolophon publish\u003c/code\u003e. colophon only consumes the keys\nfrom the environment.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch2 id=\"git-based-hosting-github--gitlab--codeberg-pages\"\u003eGit-based hosting (GitHub / GitLab / Codeberg Pages)\u003c/h2\u003e\n\u003cp\u003eThe \u003ccode\u003egit\u003c/code\u003e driver publishes by \u003cstrong\u003eforce-pushing the built tree as a single orphan commit\u003c/strong\u003e to a\nnominated branch of a git remote. Whatever serves that branch — GitHub Pages, GitLab Pages,\nCodeberg Pages, a mirror, a self-hosted bare repo — then serves the site. It uses\n\u003ca href=\"https://github.com/go-git/go-git\"\u003ego-git\u003c/a\u003e (pure Go), so \u003cstrong\u003eno \u003ccode\u003egit\u003c/code\u003e binary is required\u003c/strong\u003e.\u003c/p\u003e\n\u003cp\u003eBecause each publish is a fresh orphan commit, the branch always mirrors exactly the current\nbuild — there's no history to drift and no stale files to prune. It never touches your working\ntree: the build is staged in a temp repo and pushed from there.\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eSetting\u003c/th\u003e\n\u003cth\u003eDefault\u003c/th\u003e\n\u003cth\u003ePurpose\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003erepo\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cem\u003e(required)\u003c/em\u003e\u003c/td\u003e\n\u003ctd\u003eRemote URL (\u003ccode\u003ehttps://…\u003c/code\u003e, \u003ccode\u003egit@host:owner/repo\u003c/code\u003e, \u003ccode\u003essh://…\u003c/code\u003e) or local path.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ebranch\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003emain\u003c/code\u003e (\u003ccode\u003egh-pages\u003c/code\u003e for \u003ccode\u003egithub-pages\u003c/code\u003e)\u003c/td\u003e\n\u003ctd\u003eThe branch to force-push.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003epublic_url\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cem\u003e(provider-derived)\u003c/em\u003e\u003c/td\u003e\n\u003ctd\u003eThe site's canonical URL. Auto-derived for known hosts (below); set it for a custom domain.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ecommit_author\u003c/code\u003e / \u003ccode\u003ecommit_email\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003ecolophon\u003c/code\u003e / \u003ccode\u003ecolophon@users.noreply.github.com\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eAuthor of the publish commit.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ecommit_message\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003ecolophon: publish \u0026lt;timestamp\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eCommit message.\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003ccode\u003epublic_url\u003c/code\u003e is auto-derived from the remote for known hosts, so you usually don't set it:\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eHost\u003c/th\u003e\n\u003cth\u003eRepo\u003c/th\u003e\n\u003cth\u003eDerived URL\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003egithub.com\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eme/blog\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003ehttps://me.github.io/blog/\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003egithub.com\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eme/me.github.io\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003ehttps://me.github.io/\u003c/code\u003e (user/org site)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003egitlab.com\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eme/site\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003ehttps://me.gitlab.io/site/\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ecodeberg.org\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eme/pages\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003ehttps://me.codeberg.page/\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eAnything else (a self-hosted host, a custom domain via a \u003ccode\u003eCNAME\u003c/code\u003e) resolves no URL — set\n\u003ccode\u003epublic_url\u003c/code\u003e explicitly.\u003c/p\u003e\n\u003ch3 id=\"github-pages\"\u003eGitHub Pages\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003epublishers:\n  - id: pages\n    driver: github-pages              # branch defaults to gh-pages\n    repo: \u0026#34;git@github.com:me/blog.git\u0026#34;   # SSH: pushes via your ssh-agent\n\nenvironments:\n  - name: production\n    publish: [pages]\n    allow_publish: true\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eIn GitHub Actions, use an HTTPS remote and the workflow token instead of SSH:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e  - id: pages\n    driver: github-pages\n    repo: \u0026#34;https://github.com/me/blog.git\u0026#34;   # GITHUB_TOKEN → push over HTTPS\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThen point the repo's \u003cstrong\u003eSettings → Pages\u003c/strong\u003e at the \u003ccode\u003egh-pages\u003c/code\u003e branch.\u003c/p\u003e\n\u003ch3 id=\"gitlab-pages\"\u003eGitLab Pages\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e  - id: pages\n    driver: git\n    repo: \u0026#34;git@gitlab.com:me/site.git\u0026#34;\n    branch: pages                      # match your .gitlab-ci.yml `pages` job source\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eGitLab serves Pages from a CI job, so the branch is whatever your \u003ccode\u003epages:\u003c/code\u003e job builds from.\u003c/p\u003e\n\u003ch3 id=\"codeberg-pages\"\u003eCodeberg Pages\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e  - id: pages\n    driver: git\n    repo: \u0026#34;git@codeberg.org:me/pages.git\u0026#34;\n    branch: pages                      # Codeberg serves the `pages` branch\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch3 id=\"any-git-remote\"\u003eAny git remote\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003egit\u003c/code\u003e is not GitHub-specific — push to a mirror, a self-hosted Forgejo/Gitea, or a local bare\nrepo (handy for tests). Set \u003ccode\u003epublic_url\u003c/code\u003e since the host is unknown:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e  - id: mirror\n    driver: git\n    repo: \u0026#34;git@git.example.com:web/site.git\u0026#34;\n    branch: deploy\n    public_url: \u0026#34;https://www.example.com\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003eAuthentication\u003c/strong\u003e follows the remote scheme: an \u003ccode\u003ehttps://\u003c/code\u003e remote uses a token from\n\u003ccode\u003eGITHUB_TOKEN\u003c/code\u003e / \u003ccode\u003eGH_TOKEN\u003c/code\u003e / \u003ccode\u003eGIT_TOKEN\u003c/code\u003e; an \u003ccode\u003egit@…\u003c/code\u003e / \u003ccode\u003essh://\u003c/code\u003e remote uses your SSH agent; a\nlocal path needs neither.\u003c/p\u003e\n\u003ch2 id=\"run-any-cli-the-command-publisher\"\u003eRun any CLI: the \u003ccode\u003ecommand\u003c/code\u003e publisher\u003c/h2\u003e\n\u003cp\u003eWhen no built-in driver fits, the \u003ccode\u003ecommand\u003c/code\u003e driver runs an arbitrary CLI against the built tree\n— so any deploy tool that takes a directory (surge, Netlify, Vercel, Wrangler, exe.dev, Azure\nSWA, \u003ccode\u003ersync\u003c/code\u003e, \u003ccode\u003escp\u003c/code\u003e, \u003ccode\u003eaws s3 sync\u003c/code\u003e, a bespoke script) is a publisher with no driver of its own.\u003c/p\u003e\n\u003cp\u003ecolophon materialises the (routed) tree to a temp directory and runs your command there, with\nthe directory as its working dir, the parent environment inherited (so the tool's own token var\nflows through), \u003ccode\u003eCOLOPHON_*\u003c/code\u003e context injected, and \u003ccode\u003eCI=true\u003c/code\u003e. A non-zero exit fails the publish.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003epublishers:\n  - id: surge\n    driver: command\n    command: [\u0026#34;surge\u0026#34;, \u0026#34;{dir}\u0026#34;, \u0026#34;myblog.surge.sh\u0026#34;]   # argv list — never a shell\n    public_url: \u0026#34;https://myblog.surge.sh\u0026#34;            # SURGE_TOKEN comes from the env\n\n  - id: rsync\n    driver: command\n    host: \u0026#34;deploy@example.com:/var/www/blog\u0026#34;          # any custom setting → {host}\n    command: [\u0026#34;rsync\u0026#34;, \u0026#34;-az\u0026#34;, \u0026#34;--delete\u0026#34;, \u0026#34;{dir}/\u0026#34;, \u0026#34;{host}\u0026#34;]\n    public_url: \u0026#34;https://www.example.com\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe command is an \u003cstrong\u003eargv list executed directly — never through a shell\u003c/strong\u003e, so there's no shell\ninjection surface. For a one-liner with pipes or \u003ccode\u003e\u0026amp;\u0026amp;\u003c/code\u003e, make the shell explicit:\n\u003ccode\u003e[\u0026quot;sh\u0026quot;, \u0026quot;-c\u0026quot;, \u0026quot;aws s3 sync {dir} s3://bucket --cache-control max-age=3600\u0026quot;]\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eInterpolation.\u003c/strong\u003e Every argument is interpolated with \u003ccode\u003e{placeholder}\u003c/code\u003e tokens drawn from your own\npublisher settings plus colophon runtime values (which win on a clash): \u003ccode\u003e{dir}\u003c/code\u003e / \u003ccode\u003e{output_dir}\u003c/code\u003e\n(the materialised tree, also the CWD), \u003ccode\u003e{manifest}\u003c/code\u003e (a JSON file classifying each path as\npage/asset/feed/… with content-type and size, written \u003cem\u003ebeside\u003c/em\u003e the tree so it isn't published\nunless you reference it), \u003ccode\u003e{public_url}\u003c/code\u003e, \u003ccode\u003e{id}\u003c/code\u003e, \u003ccode\u003e{file_count}\u003c/code\u003e, and any setting you declare\n(\u003ccode\u003e{host}\u003c/code\u003e, \u003ccode\u003e{domain}\u003c/code\u003e, \u003ccode\u003e{project}\u003c/code\u003e, …). Unknown placeholders error, so a typo fails loudly.\nPer-environment \u003ccode\u003eoverrides\u003c/code\u003e vary any setting, so one \u003ccode\u003ecommand\u003c/code\u003e publisher can target staging vs\nproduction. The same values are exposed as \u003ccode\u003eCOLOPHON_OUTPUT_DIR\u003c/code\u003e / \u003ccode\u003eCOLOPHON_MANIFEST\u003c/code\u003e /\n\u003ccode\u003eCOLOPHON_PUBLIC_URL\u003c/code\u003e / … env vars.\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003eSecrets stay in the environment.\u003c/strong\u003e colophon never injects a token into the command line — the\nchild inherits the environment and the target tool reads its own \u003ccode\u003e$SURGE_TOKEN\u003c/code\u003e / \u003ccode\u003e$VERCEL_TOKEN\u003c/code\u003e\nthere, matching colophon's env-only rule and the deploy-CLI best practice of keeping credentials\nout of argv (where they'd leak into process listings and shell history). The command runs with\nyour privileges from your own config — same trust as a Makefile — and is gated behind\n\u003ccode\u003e--allow-publish\u003c/code\u003e like every deploy.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch2 id=\"per-environment-overrides\"\u003ePer-environment overrides\u003c/h2\u003e\n\u003cp\u003eAn environment can override any publisher \u003cem\u003esetting\u003c/em\u003e via \u003ccode\u003eoverrides\u003c/code\u003e, keyed by publisher id —\nso one publisher definition serves several environments. For example, a single \u003ccode\u003elocal\u003c/code\u003e\npublisher can write a \u003cstrong\u003edistinct output directory per environment\u003c/strong\u003e (handy for previewing a\ndifferent theme side by side) without defining a publisher per env:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003epublishers:\n  - id: local\n    driver: local\n    path: ./dist            # default output dir\n\nenvironments:\n  - name: dist\n    publish: [local]        # → ./dist\n  - name: text\n    publish: [local]\n    theme: minimal\n    overrides:\n      local:\n        path: ./dist-text   # same publisher, a different dir for this env\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eOverrides also carry per-environment publisher tweaks like a Cloudflare Pages \u003ccode\u003ebranch\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"redirects-aliases\"\u003eRedirects (aliases)\u003c/h2\u003e\n\u003cp\u003eA post's \u003ccode\u003ealiases:\u003c/code\u003e (\u003ca href=\"/start/content/#redirects-aliases\"\u003eAuthoring → Redirects\u003c/a\u003e) produce, at build\ntime, a meta-refresh stub per old URL, a root \u003ccode\u003e_redirects\u003c/code\u003e file, and a root \u003ccode\u003e.nojekyll\u003c/code\u003e. How that\nbecomes a redirect depends on the host:\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eHost\u003c/th\u003e\n\u003cth\u003eResult\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003eCloudflare Pages, Netlify, GitLab Pages\u003c/td\u003e\n\u003ctd\u003ereal \u003cstrong\u003e301\u003c/strong\u003e from \u003ccode\u003e_redirects\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eS3 static-website hosting\u003c/td\u003e\n\u003ctd\u003ereal \u003cstrong\u003e301\u003c/strong\u003e — the \u003ccode\u003es3\u003c/code\u003e publisher sets the object redirect header on publish\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eCloudflare R2, bare S3/MinIO, local\u003c/td\u003e\n\u003ctd\u003eclient-side \u003cstrong\u003emeta-refresh\u003c/strong\u003e stub (no in-bucket redirects)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eGitHub Pages\u003c/td\u003e\n\u003ctd\u003eclient-side \u003cstrong\u003emeta-refresh\u003c/strong\u003e stub; the \u003ccode\u003e.nojekyll\u003c/code\u003e is what keeps \u003ccode\u003e_search/\u003c/code\u003e and the stubs from being stripped\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eSo redirects work everywhere; hosts that support server-side rules get a true 301, the rest fall\nback to the (always-emitted) stub. \u003ccode\u003ecolophon doctor\u003c/code\u003e warns about alias conflicts before you ship.\u003c/p\u003e\n\u003ch2 id=\"websub-real-time-feeds\"\u003eWebSub (real-time feeds)\u003c/h2\u003e\n\u003cp\u003e\u003ca href=\"https://www.w3.org/TR/websub/\"\u003eWebSub\u003c/a\u003e lets subscribers get your new posts pushed instantly\ninstead of polling. List one or more public hubs and colophon does both halves: it advertises them\nin every feed (\u003ccode\u003e\u0026lt;link rel=\u0026quot;hub\u0026quot;\u0026gt;\u003c/code\u003e in RSS/Atom, a \u003ccode\u003ehubs\u003c/code\u003e entry in JSON Feed, plus \u003ccode\u003erel=\u0026quot;self\u0026quot;\u003c/code\u003e), and\n\u003cstrong\u003epings them after each successful publish\u003c/strong\u003e so the hub re-fetches and fans out to subscribers.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    federation:\n      websub:\n        hubs:\n          - https://pubsubhubbub.appspot.com/   # Google\u0026#39;s public hub (or run your own)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe ping is best-effort: it runs only on a real deploy (a public \u003ccode\u003ebase_url\u003c/code\u003e, not gated), and a hub\nthat's slow or down only logs a \u003ccode\u003eWEBSUB … ping failed\u003c/code\u003e line — it never fails the publish. No hubs\nconfigured → nothing is advertised or pinged.\u003c/p\u003e\n\u003ch2 id=\"on-site-search\"\u003eOn-site search\u003c/h2\u003e\n\u003cp\u003ecolophon builds a \u003cstrong\u003estatic search index\u003c/strong\u003e at build time — a sharded, content-addressed BM25 index\na tiny browser reader queries client-side (no server, no service). Enable it per site with the\n\u003ccode\u003esearch\u003c/code\u003e stanza:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    search:\n      mode: lexical     # off (default) | lexical\n      fuzzy: true       # opt-in typo tolerance (trigram + Levenshtein); roughly doubles the index\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe string shorthand \u003ccode\u003esearch: lexical\u003c/code\u003e still works (equivalent to \u003ccode\u003emode: lexical\u003c/code\u003e, no fuzzy).\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003emode\u003c/code\u003e\u003c/strong\u003e — \u003ccode\u003elexical\u003c/code\u003e turns search on; omitted/\u003ccode\u003eoff\u003c/code\u003e leaves it out entirely (no index, no box).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003efuzzy\u003c/code\u003e\u003c/strong\u003e — when on, a query token that finds no exact/\u003cstrong\u003eprefix\u003c/strong\u003e match falls back to\ntypo-tolerant matching (so \u0026quot;wikilnk\u0026quot; finds \u0026quot;wikilinks\u0026quot;). It's opt-in because the trigram index\nit needs roughly doubles the index size — a cost a low-bandwidth search shouldn't pay unasked.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eResults are \u003cstrong\u003eprefix-matched\u003c/strong\u003e by default (\u0026quot;wiki\u0026quot; → \u0026quot;wikilinks\u0026quot;), with query-aware highlighting\nand an occurrence count. The index + reader ship only when search is on, under \u003ccode\u003e_search/\u003c/code\u003e; a theme\nrenders the box (the \u003ccode\u003epress\u003c/code\u003e, \u003ccode\u003epress-gazette\u003c/code\u003e and \u003ccode\u003epress-broadsheet\u003c/code\u003e themes include one). \u003ccode\u003ecolophon search \u0026quot;\u0026lt;query\u0026gt;\u0026quot;\u003c/code\u003e queries\nthe same engine from the CLI (always fuzzy), in text or \u003ccode\u003e--json\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eFor large sites, the index can be \u003cstrong\u003erouted to an object store\u003c/strong\u003e to keep it off a Pages-style file\nbudget — see \u003ca href=\"#routing-the-search-index\"\u003eRouting the search index\u003c/a\u003e below (it also covers the CORS\nthat a cross-origin index needs, set automatically by \u003ccode\u003e--create\u003c/code\u003e).\u003c/p\u003e\n\u003ch2 id=\"routing-assets-to-an-object-store\"\u003eRouting assets to an object store\u003c/h2\u003e\n\u003cp\u003eShipping large or numerous images with a Pages/Workers deployment can exhaust its file\nbudget. \u003cstrong\u003eRouting\u003c/strong\u003e sends matching paths to a different publisher — typically images to an\nobject store — and rewrites their URLs to that store's public base.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    routing:\n      - match: \u0026#34;**/assets/**\u0026#34;          # glob; ** crosses slashes, * does not\n        publisher: r2                  # rewrite target inherited from the r2 publisher\n\npublishers:\n  - id: r2\n    driver: cloudflare-r2\n    bucket: \u0026#34;{env:R2_BUCKET:-my-assets}\u0026#34;\n    account_id: \u0026#34;{env:CLOUDFLARE_ACCOUNT_ID}\u0026#34;\n    public_url: \u0026#34;{env:R2_PUBLIC_URL:-}\u0026#34;  # optional; auto-discovered for R2 (see below)\n\nenvironments:\n  - name: production\n    publish: [cf, r2]    # HTML to Pages, routed images to R2\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eHow it works:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eBuild\u003c/strong\u003e rewrites every routed image reference to the route's URL + path, so the HTML\npoints at the object store.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePublish\u003c/strong\u003e partitions the tree: the route's publisher (\u003ccode\u003er2\u003c/code\u003e) receives only the matched\nfiles; every other publisher (\u003ccode\u003ecf\u003c/code\u003e) receives the unrouted remainder.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eThe route's URL is resolved as: the route's own \u003ccode\u003ebase_url\u003c/code\u003e, else the target publisher's\n\u003ccode\u003epublic_url\u003c/code\u003e, else — on \u003ccode\u003epublish\u003c/code\u003e, for Cloudflare R2 with a \u003ccode\u003eCLOUDFLARE_API_TOKEN\u003c/code\u003e — the\nbucket's \u003cstrong\u003eauto-discovered\u003c/strong\u003e URL (a connected custom domain, preferring the shortest, else\nthe \u003ccode\u003er2.dev\u003c/code\u003e managed URL). So \u003ccode\u003epublish --create\u003c/code\u003e provisions the bucket, enables \u003ccode\u003er2.dev\u003c/code\u003e,\nand images serve from it with no URL in config; connect a custom domain later and the next\npublish prefers it automatically.\u003c/p\u003e\n\u003cp\u003eA rule is \u003cstrong\u003einactive until a URL resolves \u003cem\u003eand\u003c/em\u003e its publisher is deploying\u003c/strong\u003e in the\nenvironment. With nothing resolvable, routing is a no-op: images stay co-located and the\nwhole tree goes to the default publisher — so local builds and previews work with no object\nstore configured.\u003c/p\u003e\n\u003ch3 id=\"routing-the-search-index\"\u003eRouting the search index\u003c/h3\u003e\n\u003cp\u003eThe static search index (\u003ccode\u003e_search/**\u003c/code\u003e) can be routed to the object store too, to keep it off a\nPages-style file budget:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003erouting:\n  - match: \u0026#34;_search/**\u0026#34;\n    publisher: r2\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eWhen routed, colophon points the browser reader at the store's URL automatically (the\n\u003ccode\u003esearch_base\u003c/code\u003e it emits follows the route). Because the reader loads the index with \u003ccode\u003efetch()\u003c/code\u003e and\nimports \u003ccode\u003esearch.js\u003c/code\u003e as a module — neither CORS-exempt — the bucket must allow cross-origin \u003ccode\u003eGET\u003c/code\u003e;\n\u003ccode\u003epublish --create\u003c/code\u003e sets that policy for you (see \u003ca href=\"#provisioning-with---create\"\u003eProvisioning\u003c/a\u003e).\nUnrouted, the index stays on the same origin and no CORS is involved.\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/publishing.md\"\u003e\u003ccode\u003edocs/publishing.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-28T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/start/themes/",
      "url": "https://docs.colophon.blog/start/themes/",
      "title": "Themes",
      "summary": "A theme turns colophon's page data into HTML. Themes are pongo2 templates (Jinja2/Django syntax) plus static assets. Three themes ship built in:",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/themes.md — do not edit by hand. --\u003e\n\u003cp\u003eA theme turns colophon's page data into HTML. Themes are\n\u003ca href=\"https://github.com/flosch/pongo2\"\u003epongo2\u003c/a\u003e templates (Jinja2/Django syntax) plus static\nassets. Three themes ship built in:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003edefault\u003c/code\u003e\u003c/strong\u003e — full-featured: hero banners, index thumbnails, and vendored\nhighlight.js / KaTeX / Mermaid for code, maths and diagrams, plus self-hosted web fonts.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003epress\u003c/code\u003e\u003c/strong\u003e — colophon.blog's brand theme. Literary-modern (Fraunces over Inter), light \u0026amp;\ndark, drifting glow, ink-blob title reveal, feed popouts. It \u003cem\u003einherits\u003c/em\u003e \u003ccode\u003edefault\u003c/code\u003e (see\n\u003ca href=\"#base-themes-inheriting-another-theme\"\u003ebase themes\u003c/a\u003e), so it reuses the same vendored\nlibraries and fonts without shipping its own copy. The home-page lede under the title comes\nfrom the site's optional \u003ccode\u003etagline:\u003c/code\u003e (presentational, distinct from the SEO \u003ccode\u003edescription:\u003c/code\u003e);\nunset renders no lede.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eminimal\u003c/code\u003e\u003c/strong\u003e — plain, readable text. No JavaScript and no web fonts; rich blocks show as\ntheir raw source (the \u003ca href=\"/start/content/#the-raw-block-contract-progressive-enhancement\"\u003eraw-block contract\u003c/a\u003e).\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eMore themes (\u003ccode\u003eflux\u003c/code\u003e, \u003ccode\u003esignal\u003c/code\u003e, \u003ccode\u003eobsidian\u003c/code\u003e) live in \u003ca href=\"#community-themes-contribthemes\"\u003e\u003ccode\u003econtrib/themes/\u003c/code\u003e\u003c/a\u003e\nand are installed by copying them into your project.\u003c/p\u003e\n\u003ch2 id=\"selecting-a-theme\"\u003eSelecting a theme\u003c/h2\u003e\n\u003cp\u003eSet it on the site, and optionally override it per environment — handy for previewing a theme\nbefore promoting it to production:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    theme: default        # site default\n\nenvironments:\n  - name: production\n    # inherits theme: default\n  - name: text\n    theme: minimal        # this environment builds with the minimal theme\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003ePrecedence: \u003cstrong\u003eenvironment \u003ccode\u003etheme\u003c/code\u003e \u0026gt; site \u003ccode\u003etheme\u003c/code\u003e \u0026gt; \u003ccode\u003edefault\u003c/code\u003e\u003c/strong\u003e. Build or serve an environment\nto see its theme:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon build --env text     # builds public/ with the minimal theme\ncolophon serve                # serves every environment, each with its own theme\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"inspecting-and-ejecting-themes\"\u003eInspecting and ejecting themes\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon themes list            # default, minimal, press\ncolophon themes eject minimal   # copies the built-in into themes/minimal/ to edit\ncolophon themes eject default   # full default theme, incl. its vendored libraries\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003eeject\u003c/code\u003e writes a built-in theme to \u003ccode\u003ethemes/\u0026lt;name\u0026gt;/\u003c/code\u003e in your project; the on-disk copy then\noverrides the built-in (use \u003ccode\u003e--force\u003c/code\u003e to overwrite an existing directory). It's the easiest\nway to start customising — eject, then edit only the files you care about. Ejecting an\noverlay theme (e.g. \u003ccode\u003epress\u003c/code\u003e) writes only \u003cem\u003eits own\u003c/em\u003e files; the base theme's inherited assets\nstay in the binary and still resolve at build, so the eject stays small.\u003c/p\u003e\n\u003ch2 id=\"supplying-your-own-theme\"\u003eSupplying your own theme\u003c/h2\u003e\n\u003cp\u003ePut files under \u003ccode\u003ethemes/\u0026lt;name\u0026gt;/\u003c/code\u003e in your project root and set \u003ccode\u003etheme: \u0026lt;name\u0026gt;\u003c/code\u003e (or eject one\nto start from). Files there \u003cstrong\u003eoverride the built-in default per file\u003c/strong\u003e, so you only write\nwhat you want to change:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ethemes/\n  mytheme/\n    page.html      # overrides the post template\n    style.css      # overrides the stylesheet\n    logo.svg       # a new static asset, copied to the output root\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eAn unknown theme name with no \u003ccode\u003ethemes/\u0026lt;name\u0026gt;/\u003c/code\u003e directory falls back to the \u003ccode\u003edefault\u003c/code\u003e theme.\u003c/p\u003e\n\u003ch3 id=\"base-themes-inheriting-another-theme\"\u003eBase themes (inheriting another theme)\u003c/h3\u003e\n\u003cp\u003eA theme can inherit another theme's templates and static assets by declaring a base. For an\non-disk theme this is automatic: any \u003ccode\u003ethemes/\u0026lt;name\u0026gt;/\u003c/code\u003e directory \u003cstrong\u003einherits \u003ccode\u003edefault\u003c/code\u003e\u003c/strong\u003e, so it\nonly needs the files it changes (this is why dropping in a single \u003ccode\u003estyle.css\u003c/code\u003e works). A\nbuilt-in theme inherits explicitly via a one-line \u003ccode\u003ebase\u003c/code\u003e file naming the base theme — the\nbuilt-in \u003ccode\u003epress\u003c/code\u003e theme contains \u003ccode\u003ebase\u003c/code\u003e → \u003ccode\u003edefault\u003c/code\u003e, so it reuses the default's vendored\nlibraries and fonts and supplies only its own \u003ccode\u003epage.html\u003c/code\u003e, \u003ccode\u003eindex.html\u003c/code\u003e and \u003ccode\u003estyle.css\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eResolution order, highest precedence first: your project's \u003ccode\u003ethemes/\u0026lt;name\u0026gt;/\u003c/code\u003e → the theme's\nown files → its base theme's files. The \u003ccode\u003ebase\u003c/code\u003e marker is never copied to the output.\u003c/p\u003e\n\u003ch3 id=\"community-themes-contribthemes\"\u003eCommunity themes (\u003ccode\u003econtrib/themes/\u003c/code\u003e)\u003c/h3\u003e\n\u003cp\u003eThe colophon repo ships extra themes under \u003ccode\u003econtrib/themes/\u003c/code\u003e that are \u003cstrong\u003enot\u003c/strong\u003e baked into the\nbinary. To use one, copy it into your project and select it:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecp -r contrib/themes/flux myblog/themes/flux\n# then, in colophon.yaml:  theme: flux\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eBecause on-disk themes inherit \u003ccode\u003edefault\u003c/code\u003e, a contrib theme only carries its own templates and\n\u003ccode\u003estyle.css\u003c/code\u003e; the vendored libraries and fonts come from the built-in \u003ccode\u003edefault\u003c/code\u003e at build time.\u003c/p\u003e\n\u003ch3 id=\"theme-files\"\u003eTheme files\u003c/h3\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eFile\u003c/th\u003e\n\u003cth\u003eRole\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003epage.html\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eRenders a single entry (post or page). \u003cstrong\u003eRequired\u003c/strong\u003e — the default for every page type.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eindex.html\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eRenders the site index (post list).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;type\u0026gt;.html\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cem\u003eOptional.\u003c/em\u003e Renders entries of page type \u003ccode\u003e\u0026lt;type\u0026gt;\u003c/code\u003e (e.g. \u003ccode\u003eproject.html\u003c/code\u003e); falls back to \u003ccode\u003epage.html\u003c/code\u003e. See \u003ca href=\"#page-types\"\u003ePage types\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003efavicon.svg\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eDefault site icon (override per-site with \u003ccode\u003efavicon:\u003c/code\u003e pointing at a project file).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etheme.yaml\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cem\u003eOptional.\u003c/em\u003e Theme metadata — a \u003ccode\u003edescription\u003c/code\u003e, and \u003ccode\u003eimage.genai.system_prompt\u003c/code\u003e (the house style for \u003ca href=\"/guides/image-generation/#house-style-theme-system-prompt\"\u003egenerated images\u003c/a\u003e). Not copied to the output.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cem\u003eanything else\u003c/em\u003e\u003c/td\u003e\n\u003ctd\u003eAny non-\u003ccode\u003e.html\u003c/code\u003e file is copied verbatim to the output root (CSS, JS, fonts, images).\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eStatic assets keep their relative path: \u003ccode\u003ethemes/mytheme/vendor/app.js\u003c/code\u003e is written to\n\u003ccode\u003e/vendor/app.js\u003c/code\u003e and referenced as \u003ccode\u003e{{ base_path }}vendor/app.js\u003c/code\u003e.\u003c/p\u003e\n\u003ch3 id=\"page-types\"\u003ePage types\u003c/h3\u003e\n\u003cp\u003eEvery entry has a \u003cstrong\u003etype\u003c/strong\u003e. By default it's derived from whether the entry has a date — a\ndated entry is a \u003ccode\u003epost\u003c/code\u003e (chronological: listed on the index, in feeds, on tag pages), a\ndateless one is a \u003ccode\u003epage\u003c/code\u003e (standing chrome: surfaced in the nav menu, not in the list/feeds).\nAn author can override this with a \u003ccode\u003etype:\u003c/code\u003e in frontmatter (see\n\u003ca href=\"/start/content/#page-types\"\u003eAuthoring → page types\u003c/a\u003e), including custom types like \u003ccode\u003eproject\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eAs a theme author you don't have to do anything: \u003cstrong\u003eevery type renders with \u003ccode\u003epage.html\u003c/code\u003e\u003c/strong\u003e unless\nyou opt in. When you want a type to look different, you have two ways — pick whichever suits.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e1. A dedicated template\u003c/strong\u003e — add \u003ccode\u003ethemes/\u0026lt;theme\u0026gt;/\u0026lt;type\u0026gt;.html\u003c/code\u003e. An entry of that type renders\nwith it; any type without its own file falls back to \u003ccode\u003epage.html\u003c/code\u003e. The file is an ordinary\nsingle-entry template and receives the \u003cstrong\u003esame variables as \u003ccode\u003epage.html\u003c/code\u003e\u003c/strong\u003e (see the table below).\nFor example, to give \u003ccode\u003etype: project\u003c/code\u003e entries a bespoke layout:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e{# themes/mytheme/project.html — renders entries with `type: project` #}\n\u0026lt;!doctype html\u0026gt;\n\u0026lt;html lang=\u0026#34;{{ lang }}\u0026#34;\u0026gt;\n\u0026lt;head\u0026gt;\n  \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt;\u0026lt;title\u0026gt;{{ meta_title }}\u0026lt;/title\u0026gt;\n  \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;{{ base_path }}style.css\u0026#34;\u0026gt;{{ seo_head|safe }}\n\u0026lt;/head\u0026gt;\n\u0026lt;body\u0026gt;\n  \u0026lt;article class=\u0026#34;project\u0026#34;\u0026gt;\n    \u0026lt;h1\u0026gt;{{ title }}\u0026lt;/h1\u0026gt;\n    {% if image %}\u0026lt;img class=\u0026#34;project-shot\u0026#34; src=\u0026#34;{{ image }}\u0026#34; alt=\u0026#34;{{ title }}\u0026#34;\u0026gt;{% endif %}\n    {{ content|safe }}\n    {% if tags %}\u0026lt;footer\u0026gt;{% for t in tags %}\u0026lt;a href=\u0026#34;{{ t.url }}\u0026#34;\u0026gt;{{ t.name }}\u0026lt;/a\u0026gt; {% endfor %}\u0026lt;/footer\u0026gt;{% endif %}\n  \u0026lt;/article\u0026gt;\n\u0026lt;/body\u0026gt;\n\u0026lt;/html\u0026gt;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003e2. Branch inside \u003ccode\u003epage.html\u003c/code\u003e\u003c/strong\u003e — the \u003ccode\u003epage_type\u003c/code\u003e variable holds the resolved type, so one\ntemplate can switch on it without a separate file:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e{% if page_type == \u0026#34;project\u0026#34; %}\n  \u0026lt;span class=\u0026#34;badge\u0026#34;\u0026gt;Project\u0026lt;/span\u0026gt;\n{% elif page_type == \u0026#34;page\u0026#34; %}\n  {# a standing page — maybe hide the date/reading-time line #}\n{% else %}\n  \u0026lt;time\u0026gt;{{ date }}\u0026lt;/time\u0026gt; · {{ read_time }} min\n{% endif %}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003ePlacement.\u003c/strong\u003e A custom type is \u003cem\u003elisted\u003c/em\u003e (post-like) by default; the built-in \u003ccode\u003epage\u003c/code\u003e is the only\n\u003cem\u003estanding\u003c/em\u003e (nav) type. So \u003ccode\u003etype: page\u003c/code\u003e makes a dated entry standing (it appears in \u003ccode\u003enav_pages\u003c/code\u003e,\nnot in the index list or feeds), and \u003ccode\u003etype: post\u003c/code\u003e makes a dateless one listed. You don't render\nthe nav/list yourself per type — the build routes entries into \u003ccode\u003enav_pages\u003c/code\u003e (standing) vs \u003ccode\u003epages\u003c/code\u003e\n(listed) for you; your per-type template only styles the single entry.\u003c/p\u003e\n\u003ch2 id=\"the-templating-language\"\u003eThe templating language\u003c/h2\u003e\n\u003cp\u003eTemplates are \u003ca href=\"https://github.com/flosch/pongo2\"\u003epongo2\u003c/a\u003e — Jinja2/Django syntax:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e{{ value }}\u003c/code\u003e prints a value (HTML-escaped by default).\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e{{ value|safe }}\u003c/code\u003e prints pre-rendered HTML \u003cstrong\u003ewithout\u003c/strong\u003e escaping — required for \u003ccode\u003econtent\u003c/code\u003e,\n\u003ccode\u003efeed_head\u003c/code\u003e and \u003ccode\u003eseo_head\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e{% if x %}…{% elif y %}…{% else %}…{% endif %}\u003c/code\u003e and \u003ccode\u003e{% for item in list %}…{% endfor %}\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eFilters chain with \u003ccode\u003e|\u003c/code\u003e, e.g. \u003ccode\u003e{{ title|default:site_title }}\u003c/code\u003e, \u003ccode\u003e{{ tags|length }}\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e{# comment #}\u003c/code\u003e (keep it on one line — pongo2 rejects a newline inside \u003ccode\u003e{# … #}\u003c/code\u003e).\u003c/li\u003e\n\u003c/ul\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003eAlways prefix internal links with \u003ccode\u003e{{ base_path }}\u003c/code\u003e\u003c/strong\u003e (\u003ccode\u003e{{ base_path }}style.css\u003c/code\u003e,\n\u003ccode\u003e{{ base_path }}{{ p.url }}\u003c/code\u003e). \u003ccode\u003ebase_path\u003c/code\u003e makes the theme work whether the site is served\nfrom \u003ccode\u003e/\u003c/code\u003e or a sub-path.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch2 id=\"template-variables\"\u003eTemplate variables\u003c/h2\u003e\n\u003ch3 id=\"pagehtml-a-single-post\"\u003e\u003ccode\u003epage.html\u003c/code\u003e (a single post)\u003c/h3\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eVariable\u003c/th\u003e\n\u003cth\u003eDescription\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003esite_title\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe site title.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etitle\u003c/code\u003e, \u003ccode\u003edate\u003c/code\u003e, \u003ccode\u003edescription\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePost metadata (\u003ccode\u003edate\u003c/code\u003e is a date; may be empty).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003emeta_title\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePre-resolved \u003ccode\u003e\u0026lt;title\u0026gt;\u003c/code\u003e text (SEO title → title → site title).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003econtent\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe rendered post HTML. Output with \u003ccode\u003e{{ content|safe }}\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ebase_path\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eURL prefix for internal links (always starts and ends with \u003ccode\u003e/\u003c/code\u003e).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ebase_url\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eAbsolute site root, for canonical/social URLs.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003efeed_head\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;link rel=\u0026quot;alternate\u0026quot;\u0026gt;\u003c/code\u003e feed-discovery tags. Output with \u003ccode\u003e{{ feed_head|safe }}\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eseo_head\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eFull SEO \u003ccode\u003e\u0026lt;head\u0026gt;\u003c/code\u003e: canonical, robots, Open Graph, Twitter, JSON-LD. \u003ccode\u003e{{ seo_head|safe }}\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eanalytics_head\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eAnalytics provider markup (statsfactory beacon and/or GA loader). Output once before \u003ccode\u003e\u0026lt;/body\u0026gt;\u003c/code\u003e with \u003ccode\u003e{{ analytics_head|safe }}\u003c/code\u003e. Empty when the site configures no analytics. See \u003ca href=\"#analytics\"\u003eAnalytics\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eglossary_head\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eGlossary styles + decorator \u003ccode\u003e\u0026lt;link\u0026gt;\u003c/code\u003e/\u003ccode\u003e\u0026lt;script\u0026gt;\u003c/code\u003e. Output once before \u003ccode\u003e\u0026lt;/body\u0026gt;\u003c/code\u003e with \u003ccode\u003e{{ glossary_head|safe }}\u003c/code\u003e. Empty unless the page uses a glossary term. See \u003ca href=\"#glossary\"\u003eGlossary\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003elang\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe page's BCP-47 language tag — put it on \u003ccode\u003e\u0026lt;html lang=\u0026quot;{{ lang }}\u0026quot;\u0026gt;\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003efavicon\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eFavicon filename, or empty.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehero\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eHero banner URL (page-relative, or absolute when routed), or empty.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehero_alt\u003c/code\u003e, \u003ccode\u003ehero_style\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eHero alt text, and a ready \u003ccode\u003eobject-fit\u003c/code\u003e/\u003ccode\u003eobject-position\u003c/code\u003e style string (may be empty). Use as \u003ccode\u003ealt=\u0026quot;{{ hero_alt }}\u0026quot;\u003c/code\u003e{% if hero_style %} \u003ccode\u003estyle=\u0026quot;{{ hero_style }}\u0026quot;\u003c/code\u003e{% endif %}.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eimage\u003c/code\u003e, \u003ccode\u003eimage_abs\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePreview image href; absolute preview URL for \u003ccode\u003eog:image\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eimage_alt\u003c/code\u003e, \u003ccode\u003eimage_style\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eCard-image alt text and \u003ccode\u003eobject-fit\u003c/code\u003e style (the index list items carry \u003ccode\u003eimage_alt\u003c/code\u003e/\u003ccode\u003eimage_style\u003c/code\u003e too).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etags\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eList of \u003ccode\u003e{name, url}\u003c/code\u003e — linked tag chips. Prefix nothing; \u003ccode\u003eurl\u003c/code\u003e is ready to use.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ecategory\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePrimary category string (first category, else first tag, else empty).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eread_time\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eEstimated reading time in whole minutes (integer).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etoc\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eList of \u003ccode\u003e{level, id, text}\u003c/code\u003e headings, for a table of contents.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003epage_type\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe resolved page type (\u003ccode\u003epost\u003c/code\u003e, \u003ccode\u003epage\u003c/code\u003e, or a custom value) — for branching within a shared template.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003edraft\u003c/code\u003e, \u003ccode\u003eembargoed\u003c/code\u003e, \u003ccode\u003eembargo_until\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePreview-only flags for not-yet-public posts.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehas_code\u003c/code\u003e, \u003ccode\u003ehas_math\u003c/code\u003e, \u003ccode\u003ehas_mermaid\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eTrue when the post uses that block type — load the matching library only when set.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eauthor_name\u003c/code\u003e, \u003ccode\u003eauthor_initials\u003c/code\u003e, \u003ccode\u003eauthor_bio\u003c/code\u003e, \u003ccode\u003eauthor_url\u003c/code\u003e, \u003ccode\u003eauthor_avatar\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eAuthor h-card fields for the byline (empty when unset). \u003ccode\u003eauthor_avatar\u003c/code\u003e is a ready-to-use \u003ccode\u003esrc\u003c/code\u003e: a file-path avatar is published to \u003ccode\u003e/assets/\u0026lt;name\u0026gt;\u003c/code\u003e and emitted root-anchored (or as the object-store URL when routed); \u003ccode\u003edata:\u003c/code\u003e/\u003ccode\u003ehttp(s)://\u003c/code\u003e and \u003ccode\u003egravatar\u003c/code\u003e avatars resolve to a URL that passes through.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehas_audio\u003c/code\u003e, \u003ccode\u003eaudio\u003c/code\u003e, \u003ccode\u003eaudio_type\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eTrue when the post has an audio reading (recorded or generated TTS); \u003ccode\u003eaudio\u003c/code\u003e is its URL and \u003ccode\u003eaudio_type\u003c/code\u003e its MIME. See \u003ca href=\"#audio-video--downloads\"\u003eAudio, video \u0026amp; downloads\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eaudio_listen\u003c/code\u003e, \u003ccode\u003eaudio_play\u003c/code\u003e, \u003ccode\u003eaudio_pause\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eLocalised player UI strings (figcaption + play/pause aria-labels), in the page's language. Present only when \u003ccode\u003ehas_audio\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehas_attachments\u003c/code\u003e, \u003ccode\u003eattachments\u003c/code\u003e, \u003ccode\u003eattachments_html\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eDownloads. \u003ccode\u003eattachments_html\u003c/code\u003e is a ready-to-drop-in, no-JS block (\u003ccode\u003e{{ attachments_html|safe }}\u003c/code\u003e); \u003ccode\u003eattachments\u003c/code\u003e is the structured list — \u003ccode\u003e{url, label, description, name, type, type_label, size, bytes}\u003c/code\u003e — if you'd rather build your own. \u003ccode\u003ehas_attachments\u003c/code\u003e is the flag. See \u003ca href=\"#audio-video--downloads\"\u003eAudio, video \u0026amp; downloads\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ementions_enabled\u003c/code\u003e, \u003ccode\u003ehas_mentions\u003c/code\u003e, \u003ccode\u003ementions\u003c/code\u003e, \u003ccode\u003ementions_html\u003c/code\u003e, \u003ccode\u003ementions_attrs\u003c/code\u003e, \u003ccode\u003ementions_src\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eWebmentions (replies/likes/reposts). What's populated depends on the site's \u003ccode\u003edisplay.mode\u003c/code\u003e — see \u003ca href=\"#webmentions-responses\"\u003eWebmentions\u003c/a\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehas_syndication\u003c/code\u003e, \u003ccode\u003esyndication\u003c/code\u003e, \u003ccode\u003esyndication_html\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u0026quot;Also posted on…\u0026quot; links from the post's \u003ccode\u003esyndication:\u003c/code\u003e frontmatter (absolute URLs). \u003ccode\u003esyndication_html\u003c/code\u003e is a no-JS drop-in of mf2 \u003ccode\u003eu-syndication\u003c/code\u003e links (\u003ccode\u003e{{ syndication_html|safe }}\u003c/code\u003e, empty when none); \u003ccode\u003esyndication\u003c/code\u003e is the raw URL list to build your own.\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003ch3 id=\"indexhtml-the-post-list-and-per-tag-pages\"\u003e\u003ccode\u003eindex.html\u003c/code\u003e (the post list, and per-tag pages)\u003c/h3\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eVariable\u003c/th\u003e\n\u003cth\u003eDescription\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003elang\u003c/code\u003e, \u003ccode\u003esite_title\u003c/code\u003e, \u003ccode\u003ebase_path\u003c/code\u003e, \u003ccode\u003ebase_url\u003c/code\u003e, \u003ccode\u003efeed_head\u003c/code\u003e, \u003ccode\u003efavicon\u003c/code\u003e, \u003ccode\u003eanalytics_head\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eAs above. (Listing pages carry no prose, so no \u003ccode\u003eglossary_head\u003c/code\u003e.)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eheading\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ePage heading — the site title on the home page, or \u003ccode\u003eTagged “\u0026lt;name\u0026gt;”\u003c/code\u003e on a tag page.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etagline\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe site's optional \u003ccode\u003etagline:\u003c/code\u003e, for a hero lede under the title. Empty when unset — guard with \u003ccode\u003e{% if tagline %}\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eseo_head\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eThe listing's SEO \u003ccode\u003e\u0026lt;head\u0026gt;\u003c/code\u003e block (canonical, Open Graph/Twitter, JSON-LD). Emit with \u003ccode\u003e{{ seo_head|safe }}\u003c/code\u003e.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003efeeds\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eList of \u003ccode\u003e{label, href}\u003c/code\u003e for subscribe links.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003epages\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eList of posts: \u003ccode\u003e{title, url, date, draft, embargoed, embargo_until, image, image_alt, image_style, audio, has_audio, has_attachments, tags, series}\u003c/code\u003e. Prefix \u003ccode\u003eurl\u003c/code\u003e with \u003ccode\u003ebase_path\u003c/code\u003e. Use \u003ccode\u003ehas_audio\u003c/code\u003e/\u003ccode\u003ehas_attachments\u003c/code\u003e to flag entries with media (see below).\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003ch2 id=\"enhancing-rich-blocks\"\u003eEnhancing rich blocks\u003c/h2\u003e\n\u003cp\u003ecolophon emits the raw-block markup; \u003cstrong\u003ehow to enhance it is entirely the theme's choice\u003c/strong\u003e.\nThe \u003ccode\u003edefault\u003c/code\u003e theme loads vendored libraries from \u003ccode\u003ethemes/default/vendor/\u003c/code\u003e, gated on the\n\u003ccode\u003ehas_*\u003c/code\u003e flags so a page only pulls in what it uses:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e{% if has_math %}\n\u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;{{ base_path }}vendor/katex/katex.min.css\u0026#34;\u0026gt;\n\u0026lt;script defer src=\u0026#34;{{ base_path }}vendor/katex/katex.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt;\n\u0026lt;script\u0026gt;/* render every .math element with katex */\u0026lt;/script\u0026gt;\n{% endif %}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eYour theme is free to do something else with the same markup: load the libraries from a CDN,\nswap in a different highlighter, or — like the \u003ccode\u003eminimal\u003c/code\u003e theme — do nothing and let the raw\ntext stand. The markup contract (\u003ccode\u003e\u0026lt;pre class=\u0026quot;mermaid\u0026quot;\u0026gt;\u003c/code\u003e, \u003ccode\u003e\u0026lt;span class=\u0026quot;math …\u0026quot;\u0026gt;\u003c/code\u003e,\n\u003ccode\u003e\u0026lt;pre\u0026gt;\u0026lt;code class=\u0026quot;language-…\u0026quot;\u0026gt;\u003c/code\u003e, \u003ccode\u003e\u0026lt;div class=\u0026quot;callout …\u0026quot;\u0026gt;\u003c/code\u003e) does not change.\u003c/p\u003e\n\u003ch2 id=\"audio-video--downloads\"\u003eAudio, video \u0026amp; downloads\u003c/h2\u003e\n\u003cp\u003ecolophon resolves media; the theme decides how it looks. There are four touch-points.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e1. Inline video/audio embeds.\u003c/strong\u003e A body embed pointing at a media file (\u003ccode\u003e![](demo.mp4)\u003c/code\u003e,\n\u003ccode\u003e![](clip.mp3)\u003c/code\u003e) is rendered for you as \u003ccode\u003e\u0026lt;video class=\u0026quot;post-video\u0026quot; controls …\u0026gt;\u003c/code\u003e or\n\u003ccode\u003e\u0026lt;audio class=\u0026quot;post-inline-audio\u0026quot; controls …\u0026gt;\u003c/code\u003e. You only need to \u003cstrong\u003estyle\u003c/strong\u003e them — make them\nresponsive in your prose:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-css\"\u003e.prose .post-video { display: block; max-width: 100%; height: auto; }\n.prose .post-inline-audio { display: block; width: 100%; }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003e2. The audio reading + player.\u003c/strong\u003e When \u003ccode\u003ehas_audio\u003c/code\u003e is set, a post has a podcast-style reading\n(recorded \u003ccode\u003eaudio_file:\u003c/code\u003e or generated TTS). The build emits a shared, dependency-free\n\u003ccode\u003eplayer.js\u003c/code\u003e to the site root whenever any page has audio. Opt in with the markup contract — a\ncontainer marked \u003ccode\u003edata-audioplayer\u003c/code\u003e with the source and localised labels, plus the script:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e{% if has_audio %}\n\u0026lt;figure class=\u0026#34;post-audio\u0026#34; data-audioplayer data-src=\u0026#34;{{ audio }}\u0026#34;\n        data-label-play=\u0026#34;{{ audio_play }}\u0026#34; data-label-pause=\u0026#34;{{ audio_pause }}\u0026#34;\u0026gt;\n  \u0026lt;figcaption\u0026gt;{{ audio_listen }}\u0026lt;/figcaption\u0026gt;\n  \u0026lt;audio controls preload=\u0026#34;none\u0026#34; src=\u0026#34;{{ audio }}\u0026#34;\u0026gt;\u0026lt;/audio\u0026gt;   \u0026lt;!-- no-JS fallback --\u0026gt;\n\u0026lt;/figure\u0026gt;\n{% endif %}\n...\n{% if has_audio %}\u0026lt;script defer src=\u0026#34;{{ base_path }}player.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt;{% endif %}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003eplayer.js\u003c/code\u003e progressively enhances the \u003ccode\u003e\u0026lt;figure\u0026gt;\u003c/code\u003e into a play/pause control with a scrubbable\nwaveform (a \u003ccode\u003e\u0026lt;src\u0026gt;.json\u003c/code\u003e peaks sidecar when present, else peaks decoded from the audio in-browser\non first play and cached, else live Web Audio, else idle); with JS off the native \u003ccode\u003e\u0026lt;audio\u0026gt;\u003c/code\u003e still\nplays. Style the enhanced parts via \u003ccode\u003e.post-audio.ap-ready\u003c/code\u003e,\n\u003ccode\u003e.ap-toggle\u003c/code\u003e, \u003ccode\u003e.ap-wave\u003c/code\u003e, \u003ccode\u003e.ap-time\u003c/code\u003e — see any bundled theme's CSS.\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003eThe \u003cem\u003econtent\u003c/em\u003e of a generated reading is shaped by authoring hints — type-aware cues for\ncode/diagrams/tables, and \u003ccode\u003e\u0026lt;notts\u0026gt;\u003c/code\u003e/\u003ccode\u003e\u0026lt;tts\u0026gt;\u003c/code\u003e to hide or force text. Those are an author concern,\ndocumented in \u003ca href=\"/start/content/\"\u003eAuthoring content\u003c/a\u003e; a theme doesn't handle them.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003cstrong\u003e3. The downloads block.\u003c/strong\u003e The engine renders the whole Downloads list for you — a no-JS,\nsemantic fragment with stable classes. Drop it in wherever you like (the bundled themes put it\nbelow the author box) and style it with CSS:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e{{ attachments_html|safe }}   {# empty when the post has none, so no guard needed #}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eIt emits \u003ccode\u003e.post-downloads \u0026gt; .downloads-title + .downloads-list \u0026gt; .dl-item \u0026gt; a.dl\u003c/code\u003e, each row with\n\u003ccode\u003e.dl-ico\u003c/code\u003e (paperclip), \u003ccode\u003e.dl-main\u003c/code\u003e (\u003ccode\u003e.dl-label\u003c/code\u003e + optional \u003ccode\u003e.dl-desc\u003c/code\u003e) and \u003ccode\u003e.dl-meta\u003c/code\u003e\n(\u003ccode\u003e.dl-type\u003c/code\u003e badge + \u003ccode\u003e.dl-size\u003c/code\u003e). Style those classes to taste.\u003c/p\u003e\n\u003cp\u003ePrefer your own markup? Ignore the fragment and loop the structured \u003ccode\u003eattachments\u003c/code\u003e list instead —\neach entry has \u003ccode\u003eurl\u003c/code\u003e, \u003ccode\u003elabel\u003c/code\u003e, \u003ccode\u003edescription\u003c/code\u003e, \u003ccode\u003ename\u003c/code\u003e, \u003ccode\u003etype\u003c/code\u003e, \u003ccode\u003etype_label\u003c/code\u003e, \u003ccode\u003esize\u003c/code\u003e, \u003ccode\u003ebytes\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e{% if attachments %}\u0026lt;ul class=\u0026#34;my-downloads\u0026#34;\u0026gt;\n  {% for f in attachments %}\n  \u0026lt;li\u0026gt;\u0026lt;a href=\u0026#34;{{ f.url }}\u0026#34; download\u0026gt;{{ f.label }}\u0026lt;/a\u0026gt;\n      {% if f.description %}\u0026lt;p\u0026gt;{{ f.description }}\u0026lt;/p\u0026gt;{% endif %}\n      \u0026lt;span\u0026gt;{{ f.type_label }} · {{ f.size }}\u0026lt;/span\u0026gt;\u0026lt;/li\u0026gt;\n  {% endfor %}\n\u0026lt;/ul\u0026gt;{% endif %}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003e4. Listing markers.\u003c/strong\u003e On \u003ccode\u003eindex.html\u003c/code\u003e, each \u003ccode\u003epages\u003c/code\u003e entry carries \u003ccode\u003ehas_audio\u003c/code\u003e and\n\u003ccode\u003ehas_attachments\u003c/code\u003e. The press and contrib themes show a small inert speaker / paperclip mark in\nthe row's top-right corner so readers can spot posts with media at a glance — copy that pattern,\nor surface it however suits your design:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e{% if p.has_audio or p.has_attachments %}\u0026lt;span class=\u0026#34;post-flags\u0026#34;\u0026gt;\n  {% if p.has_audio %}\u0026lt;span class=\u0026#34;pf\u0026#34; aria-label=\u0026#34;Has an audio reading\u0026#34;\u0026gt;…speaker svg…\u0026lt;/span\u0026gt;{% endif %}\n  {% if p.has_attachments %}\u0026lt;span class=\u0026#34;pf\u0026#34; aria-label=\u0026#34;Has downloadable files\u0026#34;\u0026gt;…paperclip svg…\u0026lt;/span\u0026gt;{% endif %}\n\u0026lt;/span\u0026gt;{% endif %}\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"microformats2-indieweb\"\u003eMicroformats2 (IndieWeb)\u003c/h2\u003e\n\u003cp\u003eThe bundled themes annotate posts with \u003ca href=\"https://microformats.org/wiki/microformats2\"\u003emicroformats2\u003c/a\u003e\n— invisible class names that let other software (IndieWeb readers, Webmention senders, Bridgy)\nunderstand your content. It's pure markup: no JS, no config (the old \u003ccode\u003emicroformats\u003c/code\u003e toggle was\nremoved — it's always on). If you write your own theme, mirror the pattern so it stays parseable:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003ePost page: the post container is \u003ccode\u003eh-entry\u003c/code\u003e, with \u003ccode\u003ep-name\u003c/code\u003e (title), \u003ccode\u003edt-published\u003c/code\u003e (the \u003ccode\u003e\u0026lt;time\u0026gt;\u003c/code\u003e),\n\u003ccode\u003ee-content\u003c/code\u003e (the rendered body), a \u003ccode\u003eu-url\u003c/code\u003e permalink (\u003ccode\u003e{{ permalink }}\u003c/code\u003e is the absolute URL), and a\nnested \u003ccode\u003ep-author h-card\u003c/code\u003e (\u003ccode\u003ep-name\u003c/code\u003e + \u003ccode\u003eu-url\u003c/code\u003e on the author, \u003ccode\u003eu-photo\u003c/code\u003e on the avatar).\u003c/li\u003e\n\u003cli\u003eListing/index: the list is \u003ccode\u003eh-feed\u003c/code\u003e, each entry \u003ccode\u003eh-entry\u003c/code\u003e, the title link \u003ccode\u003eu-url p-name\u003c/code\u003e, the date\n\u003ccode\u003edt-published\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eIdentity: author profile links carry \u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eWhen a theme splits the title/byline away from the body (e.g. a hero block), carry the stray\nproperties into the \u003ccode\u003eh-entry\u003c/code\u003e root as hidden elements (\u003ccode\u003e\u0026lt;data class=\u0026quot;p-name\u0026quot; value=\u0026quot;…\u0026quot;\u0026gt;\u003c/code\u003e, a hidden\n\u003ccode\u003e\u0026lt;time class=\u0026quot;dt-published\u0026quot;\u0026gt;\u003c/code\u003e) — see the \u003ccode\u003esignal\u003c/code\u003e/\u003ccode\u003eobsidian\u003c/code\u003e contrib themes.\u003c/p\u003e\n\u003ch2 id=\"webmentions-responses\"\u003eWebmentions (responses)\u003c/h2\u003e\n\u003cblockquote\u003e\n\u003cp\u003eShipped. All bundled + contrib themes carry the responses block; configure\n\u003ccode\u003efederation.indieweb.webmention.display.mode\u003c/code\u003e (and run \u003ccode\u003ecolophon webmention fetch\u003c/code\u003e) to populate it.\nSee \u003ca href=\"/guides/webmentions/\"\u003eShow webmentions\u003c/a\u003e.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eWebmentions are replies/likes/reposts from other sites, shown under a post. \u003cstrong\u003eThe engine never\ndecides how they render — it only exposes the data\u003c/strong\u003e, and the site picks a \u003ccode\u003edisplay.mode\u003c/code\u003e\n(\u003ccode\u003efederation.indieweb.webmention.display.mode\u003c/code\u003e). What the engine populates depends on that mode:\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eVariable\u003c/th\u003e\n\u003cth\u003e\u003ccode\u003elive\u003c/code\u003e\u003c/th\u003e\n\u003cth\u003e\u003ccode\u003easset\u003c/code\u003e\u003c/th\u003e\n\u003cth\u003e\u003ccode\u003edisabled\u003c/code\u003e\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ementions_enabled\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etrue\u003c/code\u003e (unless the post sets \u003ccode\u003ewebmentions: false\u003c/code\u003e)\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etrue\u003c/code\u003e (unless opted out)\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003e\u003ccode\u003efalse\u003c/code\u003e\u003c/strong\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehas_mentions\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003efalse\u003c/code\u003e — \u003cstrong\u003enot known at build\u003c/strong\u003e (JS fills the count)\u003c/td\u003e\n\u003ctd\u003eaccurate, from the synced list\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003efalse\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ementions\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eempty — no build-time data\u003c/td\u003e\n\u003ctd\u003estructured list (bake your own)\u003c/td\u003e\n\u003ctd\u003eempty\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ementions_html\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eempty\u003c/td\u003e\n\u003ctd\u003eengine-rendered drop-in block\u003c/td\u003e\n\u003ctd\u003eempty\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ementions_src\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ethe receiver's client-fetch endpoint\u003c/td\u003e\n\u003ctd\u003eyour published \u003ccode\u003e_mentions/\u0026lt;post\u0026gt;.json\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eunset\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003elive\u003c/code\u003e\u003c/strong\u003e — the browser fetches the receiver directly; most realtime, no rebuild, but no\nbuild-time data (so no count, no baking) and nothing for no-JS readers.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003easset\u003c/code\u003e\u003c/strong\u003e — the browser fetches \u003cem\u003eyour\u003c/em\u003e curated \u003ccode\u003e_mentions/\u003c/code\u003e asset (refreshed out-of-band by\n\u003ccode\u003ewebmention publish\u003c/code\u003e); because that list also exists at build, the engine fills \u003ccode\u003ementions\u003c/code\u003e /\n\u003ccode\u003ementions_html\u003c/code\u003e / \u003ccode\u003ehas_mentions\u003c/code\u003e, so a theme \u003cstrong\u003emay bake\u003c/strong\u003e instead of relying on JS.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003edisabled\u003c/code\u003e\u003c/strong\u003e — nothing is exposed or shipped, regardless of the per-post setting.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eAlways guard the block on \u003ccode\u003ementions_enabled\u003c/code\u003e and keep it a \u003cstrong\u003esibling of the content element\u003c/strong\u003e, never\ninside it (so it's never read aloud by the TTS reading — same rule as the author card/downloads).\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eProgressive-enhancement block (what \u003ccode\u003epress\u003c/code\u003e uses).\u003c/strong\u003e Bake the asset-mode HTML server-side, then let\nthe engine-emitted \u003ccode\u003ementions.js\u003c/code\u003e refresh it (or, in \u003ccode\u003elive\u003c/code\u003e mode, populate it):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-django\"\u003e{% if mentions_enabled %}\n\u0026lt;section class=\u0026#34;responses\u0026#34; {{ mentions_attrs|safe }} aria-label=\u0026#34;Responses\u0026#34;\u0026gt;{{ mentions_html|safe }}\u0026lt;/section\u0026gt;\n{% endif %}\n…\n{% if mentions_enabled %}\u0026lt;script defer src=\u0026#34;{{ base_path }}mentions.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt;{% endif %}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003ementions_attrs\u003c/code\u003e is the engine-owned \u003ccode\u003edata-mentions*\u003c/code\u003e wiring (the source URL, plus \u003ccode\u003edata-mentions-live\u003c/code\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003etarget + blocklist in \u003ccode\u003elive\u003c/code\u003e mode) — drop it in and the same markup works across modes and reader\ndrivers. With JS off (and \u003ccode\u003easset\u003c/code\u003e mode) the baked \u003ccode\u003ementions_html\u003c/code\u003e still shows; with JS on, \u003ccode\u003ementions.js\u003c/code\u003e\nfetches and renders/refreshes.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eBuild your own from the structured list\u003c/strong\u003e (\u003ccode\u003easset\u003c/code\u003e mode), e.g. to restyle or split by type:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-django\"\u003e{% if has_mentions %}\u0026lt;section class=\u0026#34;responses\u0026#34;\u0026gt;\n  {% for m in mentions %}\n  \u0026lt;article class=\u0026#34;response {{ m.type }} h-cite\u0026#34;\u0026gt;\n    {% if m.author.photo %}\u0026lt;img class=\u0026#34;u-photo\u0026#34; src=\u0026#34;{{ m.author.photo }}\u0026#34; alt=\u0026#34;\u0026#34;\u0026gt;{% endif %}\n    \u0026lt;a class=\u0026#34;p-author h-card u-url\u0026#34; href=\u0026#34;{{ m.author.url }}\u0026#34;\u0026gt;{{ m.author.name }}\u0026lt;/a\u0026gt;\n    {% if m.content %}\u0026lt;div class=\u0026#34;p-content\u0026#34;\u0026gt;{{ m.content }}\u0026lt;/div\u0026gt;{% endif %}\n    \u0026lt;a class=\u0026#34;u-url\u0026#34; href=\u0026#34;{{ m.url }}\u0026#34;\u0026gt;\u0026lt;time class=\u0026#34;dt-published\u0026#34;\u0026gt;{{ m.published }}\u0026lt;/time\u0026gt;\u0026lt;/a\u0026gt;\n  \u0026lt;/article\u0026gt;\n  {% endfor %}\n\u0026lt;/section\u0026gt;{% endif %}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eEach \u003ccode\u003ementions\u003c/code\u003e item is \u003ccode\u003e{type (like|repost|reply|mention), author{name,url,photo}, url, content, published}\u003c/code\u003e. A \u003cstrong\u003eno-JS / text theme\u003c/strong\u003e (e.g. \u003ccode\u003eminimal\u003c/code\u003e) just uses \u003ccode\u003easset\u003c/code\u003e mode + \u003ccode\u003e{{ mentions_html|safe }}\u003c/code\u003e\nand skips the script entirely.\u003c/p\u003e\n\u003ch3 id=\"silo-icons\"\u003eSilo icons\u003c/h3\u003e\n\u003cp\u003eResponses, the \u0026quot;Also posted on…\u0026quot; (\u003ccode\u003eu-syndication\u003c/code\u003e) links, and the author h-card links show a small\n\u003cstrong\u003ebrand icon\u003c/strong\u003e when colophon recognises the source silo (Bluesky, Mastodon, GitHub, X, Reddit,\nHacker News, Threads, Flickr, LinkedIn, Tumblr, GitLab — else a generic website globe). These are\nglyphs in a tiny webfont, \u003cstrong\u003e\u003ccode\u003esilos.woff2\u003c/code\u003e\u003c/strong\u003e, that the engine emits at the site root (when responses\nare active) and renders into \u003ccode\u003e\u0026lt;span class=\u0026quot;silo\u0026quot;\u0026gt;\u003c/code\u003e. A theme just declares the face and styles the\nspan:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-css\"\u003e@font-face { font-family: \u0026#34;Colophon Silos\u0026#34;; src: url(\u0026#34;silos.woff2\u0026#34;) format(\u0026#34;woff2\u0026#34;); font-display: swap; }\n.silo { font-family: \u0026#34;Colophon Silos\u0026#34;; line-height: 1; }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe font is curated from Font Awesome (Brands + Solid) by\n\u003ca href=\"../contrib/scripts/silo-font/\"\u003e\u003ccode\u003econtrib/scripts/silo-font\u003c/code\u003e\u003c/a\u003e — re-run it to add a network or change\nthe set. Detection (\u003ccode\u003ehost → silo\u003c/code\u003e) lives in \u003ccode\u003einternal/build/mentions.go\u003c/code\u003e + \u003ccode\u003eassets/mentions.js\u003c/code\u003e, kept\nin sync with the font's \u003ccode\u003esilos.json\u003c/code\u003e codepoints.\u003c/p\u003e\n\u003ch2 id=\"analytics\"\u003eAnalytics\u003c/h2\u003e\n\u003cp\u003ecolophon owns the analytics clients; a theme's only job is to \u003cstrong\u003einclude\u003c/strong\u003e them. When a site\nconfigures a provider (statsfactory and/or Google Analytics — see \u003ca href=\"/guides/analytics/\"\u003eanalytics\u003c/a\u003e),\nthe build writes that provider's loader to the site root — \u003ccode\u003eanalytics-sf.js\u003c/code\u003e for the cookieless\nstatsfactory beacon, \u003ccode\u003eanalytics-ga.js\u003c/code\u003e for the Google Analytics loader — and exposes the\nmatching \u003ccode\u003e\u0026lt;script\u0026gt;\u003c/code\u003e markup (with each page's dimensions) as the \u003ccode\u003eanalytics_head\u003c/code\u003e variable.\u003c/p\u003e\n\u003cp\u003eA theme leverages both providers with one line, just before \u003ccode\u003e\u0026lt;/body\u0026gt;\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e{% if analytics_head %}{{ analytics_head|safe }}{% endif %}\n\u0026lt;/body\u0026gt;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe theme never names a provider: \u003ccode\u003eanalytics_head\u003c/code\u003e already contains whichever loaders are\nenabled (statsfactory, GA, both, or — when the site configures none — nothing, leaving the line\ninert). Every built-in and contrib theme includes it; a JS-enabled custom theme should too.\u003c/p\u003e\n\u003ch2 id=\"glossary\"\u003eGlossary\u003c/h2\u003e\n\u003cp\u003eIf the site ships a \u003ccode\u003eglossary.yaml\u003c/code\u003e (see \u003ca href=\"/start/content/#glossary\"\u003eauthoring\u003c/a\u003e) and a page uses a\nterm, the build publishes the data + a small decorator and exposes a \u003ccode\u003eglossary_head\u003c/code\u003e variable.\nA theme opts in with one line before \u003ccode\u003e\u0026lt;/body\u0026gt;\u003c/code\u003e (right where the analytics line goes):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e{% if glossary_head %}{{ glossary_head|safe }}{% endif %}\n\u0026lt;/body\u0026gt;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThat's it — no per-theme CSS or JS needed:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eThe \u003cstrong\u003edecorator and styles are engine-provided\u003c/strong\u003e (\u003ccode\u003eglossary.js\u003c/code\u003e + \u003ccode\u003eglossary.css\u003c/code\u003e, written to\nthe site root). The decorator wraps terms in \u003ccode\u003e\u0026lt;abbr class=\u0026quot;gloss\u0026quot; data-gloss=\u0026quot;…\u0026quot;\u0026gt;\u003c/code\u003e and shows\nan accessible pop-over on hover/focus (\u003ccode\u003erole=\u0026quot;tooltip\u0026quot;\u003c/code\u003e + \u003ccode\u003earia-describedby\u003c/code\u003e, Escape to\ndismiss).\u003c/li\u003e\n\u003cli\u003eThe default styling \u003cstrong\u003eadapts to your theme\u003c/strong\u003e through the token contract: it uses\n\u003ccode\u003e--accent\u003c/code\u003e for the underline and \u003ccode\u003e--elevated\u003c/code\u003e/\u003ccode\u003e--text\u003c/code\u003e/\u003ccode\u003e--muted\u003c/code\u003e/\u003ccode\u003e--border\u003c/code\u003e + \u003ccode\u003e--serif\u003c/code\u003e/\u003ccode\u003e--sans\u003c/code\u003e\nfor the dictionary-stanza card (with neutral fallbacks for token-less themes).\u003c/li\u003e\n\u003cli\u003eTo customise the look, \u003cstrong\u003eoverride \u003ccode\u003e.gloss\u003c/code\u003e and \u003ccode\u003e.gloss-tip\u003c/code\u003e\u003c/strong\u003e (and \u003ccode\u003e.gloss-tip .gloss-term\u003c/code\u003e\n/ \u003ccode\u003e.gloss-def\u003c/code\u003e) in your own stylesheet — e.g. \u003ccode\u003epress\u003c/code\u003e gives \u003ccode\u003e.gloss\u003c/code\u003e a light wavy underline.\u003c/li\u003e\n\u003cli\u003eA \u003cstrong\u003etext-only / no-JS theme\u003c/strong\u003e simply omits the line: terms stay plain readable text, so the\npage still works correctly without the decorator (this is what \u003ccode\u003eminimal\u003c/code\u003e does).\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"build-a-theme--step-by-step\"\u003eBuild a theme — step by step\u003c/h2\u003e\n\u003cp\u003eThe fastest route is to start from a built-in and change only what you want. A new theme\ninherits \u003ccode\u003edefault\u003c/code\u003e, so you can ship as little as one CSS file.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e1. Create the theme directory\u003c/strong\u003e in your project and point a build at it:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003emkdir -p themes/mytheme\n\u003c/code\u003e\u003c/pre\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# colophon.yaml\nsites:\n  - id: main\n    theme: mytheme          # or set it on one environment to preview first\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003e2. Add a stylesheet.\u003c/strong\u003e With nothing else present, \u003ccode\u003emytheme\u003c/code\u003e uses the \u003ccode\u003edefault\u003c/code\u003e templates\nand your CSS:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-css\"\u003e/* themes/mytheme/style.css */\nbody { font-family: Georgia, serif; max-width: 42rem; margin: 2rem auto; }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon serve            # open the printed URL; edits live-reload\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThat alone is a working theme. Everything below is optional, added when you want more control.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Take over the post template.\u003c/strong\u003e Copy a built-in as a starting point, then edit it:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon themes eject default   # writes themes/default/ — copy what you need into mytheme/\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eA minimal \u003ccode\u003ethemes/mytheme/page.html\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e\u0026lt;!doctype html\u0026gt;\n\u0026lt;html lang=\u0026#34;{{ lang }}\u0026#34;\u0026gt;\n\u0026lt;head\u0026gt;\n  \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt;\n  \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1\u0026#34;\u0026gt;\n  \u0026lt;title\u0026gt;{{ meta_title }}\u0026lt;/title\u0026gt;\n  \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;{{ base_path }}style.css\u0026#34;\u0026gt;\n  {{ feed_head|safe }}\n  {{ seo_head|safe }}\n\u0026lt;/head\u0026gt;\n\u0026lt;body\u0026gt;\n  \u0026lt;header\u0026gt;\u0026lt;a href=\u0026#34;{{ base_path }}\u0026#34;\u0026gt;{{ site_title }}\u0026lt;/a\u0026gt;\u0026lt;/header\u0026gt;\n  \u0026lt;article\u0026gt;\n    \u0026lt;h1\u0026gt;{{ title }}\u0026lt;/h1\u0026gt;\n    {% if date %}\u0026lt;time\u0026gt;{{ date }}\u0026lt;/time\u0026gt;{% endif %}\n    {% if read_time %}\u0026lt;span\u0026gt;· {{ read_time }} min read\u0026lt;/span\u0026gt;{% endif %}\n    {{ content|safe }}\n    {% if tags %}\u0026lt;footer\u0026gt;{% for t in tags %}\u0026lt;a href=\u0026#34;{{ t.url }}\u0026#34;\u0026gt;{{ t.name }}\u0026lt;/a\u0026gt; {% endfor %}\u0026lt;/footer\u0026gt;{% endif %}\n  \u0026lt;/article\u0026gt;\n\u0026lt;/body\u0026gt;\n\u0026lt;/html\u0026gt;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003e4. Add the index\u003c/strong\u003e (\u003ccode\u003ethemes/mytheme/index.html\u003c/code\u003e) — the post list, the nav menu (standing\npages like About), and per-tag pages:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-html\"\u003e\u0026lt;!doctype html\u0026gt;\n\u0026lt;html lang=\u0026#34;{{ lang }}\u0026#34;\u0026gt;\n\u0026lt;head\u0026gt;\u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt;\u0026lt;title\u0026gt;{{ heading }}\u0026lt;/title\u0026gt;\n  \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;{{ base_path }}style.css\u0026#34;\u0026gt;{{ feed_head|safe }}\u0026lt;/head\u0026gt;\n\u0026lt;body\u0026gt;\n  {% if nav_pages %}\u0026lt;nav\u0026gt;{% for n in nav_pages %}\u0026lt;a href=\u0026#34;{{ n.url }}\u0026#34;\u0026gt;{{ n.title }}\u0026lt;/a\u0026gt; {% endfor %}\u0026lt;/nav\u0026gt;{% endif %}\n  \u0026lt;h1\u0026gt;{{ heading }}\u0026lt;/h1\u0026gt;\n  \u0026lt;ul\u0026gt;\n  {% for p in pages %}\n    \u0026lt;li\u0026gt;\u0026lt;a href=\u0026#34;{{ base_path }}{{ p.url }}\u0026#34;\u0026gt;{{ p.title }}\u0026lt;/a\u0026gt;\n        {% if p.date %}\u0026lt;small\u0026gt;{{ p.date }}\u0026lt;/small\u0026gt;{% endif %}\u0026lt;/li\u0026gt;\n  {% endfor %}\n  \u0026lt;/ul\u0026gt;\n\u0026lt;/body\u0026gt;\n\u0026lt;/html\u0026gt;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003enav_pages\u003c/code\u003e is the list of standing pages (\u003ccode\u003e{title, url}\u003c/code\u003e); \u003ccode\u003epages\u003c/code\u003e is the chronological posts.\nThe build sorts entries into these two buckets by \u003ca href=\"#page-types\"\u003epage type\u003c/a\u003e — you just render\nthem. (Add the same \u003ccode\u003enav_pages\u003c/code\u003e block to \u003ccode\u003epage.html\u003c/code\u003e so the menu appears on entries too.)\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e5. (Optional) Tailor specific page types.\u003c/strong\u003e Add a \u003ccode\u003e\u0026lt;type\u0026gt;.html\u003c/code\u003e template, or branch on the\n\u003ccode\u003epage_type\u003c/code\u003e variable inside \u003ccode\u003epage.html\u003c/code\u003e, to give a type its own look. See\n\u003ca href=\"#page-types\"\u003ePage types\u003c/a\u003e. Skip this and every entry just uses \u003ccode\u003epage.html\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e6. Decide how rich blocks render.\u003c/strong\u003e Do nothing (raw text shows, like \u003ccode\u003eminimal\u003c/code\u003e), or enhance\nthem with the \u003ccode\u003ehas_*\u003c/code\u003e gates as shown in \u003ca href=\"#enhancing-rich-blocks\"\u003eEnhancing rich blocks\u003c/a\u003e. The\nvendored libraries are inherited from \u003ccode\u003edefault\u003c/code\u003e, so \u003ccode\u003e{{ base_path }}vendor/katex/…\u003c/code\u003e resolves\nwith no extra files in your theme.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e7. Add your own assets.\u003c/strong\u003e Any non-\u003ccode\u003e.html\u003c/code\u003e file under \u003ccode\u003ethemes/mytheme/\u003c/code\u003e is copied to the\noutput root, keeping its path: \u003ccode\u003ethemes/mytheme/logo.svg\u003c/code\u003e → \u003ccode\u003e/logo.svg\u003c/code\u003e, referenced as\n\u003ccode\u003e{{ base_path }}logo.svg\u003c/code\u003e. Self-host fonts the same way and \u003ccode\u003e@import\u003c/code\u003e them from your CSS.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e8. Build and ship:\u003c/strong\u003e\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon build --env production    # writes public/ with your theme\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eChecklist: every internal \u003ccode\u003ehref\u003c/code\u003e/\u003ccode\u003esrc\u003c/code\u003e starts with \u003ccode\u003e{{ base_path }}\u003c/code\u003e; \u003ccode\u003econtent\u003c/code\u003e, \u003ccode\u003efeed_head\u003c/code\u003e\nand \u003ccode\u003eseo_head\u003c/code\u003e use \u003ccode\u003e|safe\u003c/code\u003e; \u003ccode\u003e{# comments #}\u003c/code\u003e stay on one line. To contribute a theme back, drop\nit in \u003ca href=\"#community-themes-contribthemes\"\u003e\u003ccode\u003econtrib/themes/\u003c/code\u003e\u003c/a\u003e following the existing ones.\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/themes.md\"\u003e\u003ccode\u003edocs/themes.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-27T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/seo/",
      "url": "https://docs.colophon.blog/guides/seo/",
      "title": "SEO \u0026 social metadata",
      "summary": "colophon gives every post correct search and social metadata by default — canonical URL, description, Open Graph + Twitter cards, schema.org JSON-LD, and robots — derived from…",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/seo.md — do not edit by hand. --\u003e\n\u003cp\u003ecolophon gives every post correct search and social metadata \u003cstrong\u003eby default\u003c/strong\u003e — canonical URL,\ndescription, Open Graph + Twitter cards, schema.org JSON-LD, and robots — derived from the\npost's existing fields (title, description, tags, date, image, persona). An optional \u003ccode\u003eseo:\u003c/code\u003e\nfrontmatter block overrides any of it, and is the precise target an\n\u003ca href=\"/guides/skills/\"\u003eauthoring skill\u003c/a\u003e can fill in.\u003c/p\u003e\n\u003ch2 id=\"the-seo-block\"\u003eThe \u003ccode\u003eseo:\u003c/code\u003e block\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003eseo:\n  title:        # \u0026lt;title\u0026gt; / og:title; ≤60 chars (else: the post title)\n  description:  # meta description; 140–160 chars (else: description / excerpt)\n  keywords: []  # focus terms (else: the post\u0026#39;s tags)\n  canonical:    # absolute URL override (else: base_url + slug)\n  noindex: false# robots noindex (drafts \u0026amp; embargoed posts are noindex regardless)\n  image:        # absolute social-image URL override (else: the `image` field)\n  type:         # schema.org @type (default: BlogPosting)\n  social:       # copy tuned for sharing, when it should differ from the search copy\n    title:\n    description:\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eEvery field maps to exactly one piece of output, so what you set is what gets rendered.\u003c/p\u003e\n\u003ch2 id=\"what-gets-emitted-page-head\"\u003eWhat gets emitted (page \u003ccode\u003e\u0026lt;head\u0026gt;\u003c/code\u003e)\u003c/h2\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eOutput\u003c/th\u003e\n\u003cth\u003eSource\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;link rel=\u0026quot;canonical\u0026quot;\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eseo.canonical\u003c/code\u003e → \u003ccode\u003ebase_url\u003c/code\u003e + slug\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;meta name=\u0026quot;description\u0026quot;\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eseo.description\u003c/code\u003e → \u003ccode\u003edescription\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;meta name=\u0026quot;robots\u0026quot;\u0026gt;\u003c/code\u003e noindex\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eseo.noindex\u003c/code\u003e, or any draft/embargoed post\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;meta name=\u0026quot;keywords\u0026quot;\u0026gt;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eseo.keywords\u003c/code\u003e → tags\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eOpen Graph (\u003ccode\u003eog:type/site_name/url/title/description/image/locale\u003c/code\u003e)\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eseo\u003c/code\u003e → post fields; \u003ccode\u003eog:title\u003c/code\u003e/\u003ccode\u003edescription\u003c/code\u003e prefer \u003ccode\u003eseo.social.*\u003c/code\u003e; \u003ccode\u003eog:image\u003c/code\u003e is \u003ccode\u003eseo.image\u003c/code\u003e → the \u003ccode\u003eimage\u003c/code\u003e field → the \u003ccode\u003ehero\u003c/code\u003e cover art; \u003ccode\u003eog:locale\u003c/code\u003e from the page/site \u003ccode\u003elang\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003earticle:published_time\u003c/code\u003e / \u003ccode\u003emodified_time\u003c/code\u003e / \u003ccode\u003etag\u003c/code\u003e / \u003ccode\u003eauthor\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ethe post date, tags, and persona\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eTwitter card (\u003ccode\u003esummary_large_image\u003c/code\u003e when an image exists, else \u003ccode\u003esummary\u003c/code\u003e)\u003c/td\u003e\n\u003ctd\u003ethe resolved image\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003e\u0026lt;script type=\u0026quot;application/ld+json\u0026quot;\u0026gt;\u003c/code\u003e \u003cstrong\u003eBlogPosting\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eheadline, description, image, dates, keywords, \u003ccode\u003eauthor\u003c/code\u003e (persona → Person/Organization), \u003ccode\u003epublisher\u003c/code\u003e (site)\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eThe JSON-LD author comes from the post's \u003cstrong\u003epersona\u003c/strong\u003e (\u003ccode\u003epersona:\u003c/code\u003e frontmatter, else the first\nconfigured persona): an \u003ccode\u003eindividual\u003c/code\u003e persona renders as a \u003ccode\u003ePerson\u003c/code\u003e, a \u003ccode\u003ebrand\u003c/code\u003e persona as an\n\u003ccode\u003eOrganization\u003c/code\u003e, with the persona's first h-card URL as \u003ccode\u003eauthor.url\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"defaults-vs-overrides\"\u003eDefaults vs. overrides\u003c/h2\u003e\n\u003cp\u003eYou never need an \u003ccode\u003eseo:\u003c/code\u003e block — a plain post already produces all of the above from its\ntitle/description/tags/date/image/persona. Use \u003ccode\u003eseo:\u003c/code\u003e to:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003egive search a tighter \u003ccode\u003etitle\u003c/code\u003e/\u003ccode\u003edescription\u003c/code\u003e than the on-page ones,\u003c/li\u003e\n\u003cli\u003ewrite punchier \u003ccode\u003esocial:\u003c/code\u003e copy for shares,\u003c/li\u003e\n\u003cli\u003eset an explicit \u003ccode\u003ecanonical\u003c/code\u003e (e.g. a syndicated original),\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003enoindex\u003c/code\u003e a page, or point \u003ccode\u003eimage\u003c/code\u003e at an external social card.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eGenerating a good \u003ccode\u003eseo:\u003c/code\u003e block from the article is what the planned \u003cstrong\u003eseo skill\u003c/strong\u003e\n(\u003ca href=\"/guides/skills/\"\u003eskills.md\u003c/a\u003e) does.\u003c/p\u003e\n\u003ch2 id=\"listing-pages-home-tags-authors\"\u003eListing pages (home, tags, authors)\u003c/h2\u003e\n\u003cp\u003eThe home page and every generated listing — \u003ccode\u003e/tags/\u0026lt;tag\u0026gt;/\u003c/code\u003e and \u003ccode\u003e/authors/\u0026lt;id\u0026gt;/\u003c/code\u003e — also carry\ntheir own metadata: a canonical URL, \u003ccode\u003edescription\u003c/code\u003e, website-flavoured Open Graph + Twitter\ncards, \u003ccode\u003eog:locale\u003c/code\u003e, and schema.org JSON-LD (a \u003ccode\u003eBlog\u003c/code\u003e for the home page, a \u003ccode\u003eCollectionPage\u003c/code\u003e for\ntag/author listings). These draw on two optional site-level fields:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    title: My Blog\n    base_url: https://example.com\n    description: One line that becomes the home page\u0026#39;s meta/OG description.\n    image: /assets/social.png   # default share image (absolute URL, or resolved against base_url)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eBoth are optional — unset simply omits the corresponding tags. \u003ccode\u003edescription\u003c/code\u003e feeds the listing\npages' \u003ccode\u003e\u0026lt;meta name=\u0026quot;description\u0026quot;\u0026gt;\u003c/code\u003e and the JSON-LD; \u003ccode\u003eimage\u003c/code\u003e is their default \u003ccode\u003eog:image\u003c/code\u003e/\n\u003ccode\u003etwitter:image\u003c/code\u003e. Per-tag and per-author listings reuse the same \u003ccode\u003edescription\u003c/code\u003e/\u003ccode\u003eimage\u003c/code\u003e with their\nown heading as the title.\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/seo.md\"\u003e\u003ccode\u003edocs/seo.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-26T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/image-generation/",
      "url": "https://docs.colophon.blog/guides/image-generation/",
      "title": "Image \u0026 audio generation",
      "summary": "colophon can generate images from a text prompt with an AI provider, anywhere it accepts an image: a post's hero:/image: frontmatter and Markdown body embeds. A generated image is…",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/image-generation.md — do not edit by hand. --\u003e\n\u003cp\u003ecolophon can generate images from a text prompt with an AI provider, anywhere it\naccepts an image: a post's \u003ccode\u003ehero:\u003c/code\u003e/\u003ccode\u003eimage:\u003c/code\u003e frontmatter and Markdown body embeds. A\ngenerated image is cached on disk (and committed with your content), so it is produced\nonce and then ships through the normal \u003ca href=\"/start/content/#images-and-object-storage\"\u003easset pipeline\u003c/a\u003e\nlike any other image — including routing to an object store.\u003c/p\u003e\n\u003cp\u003eThe feature is \u003cstrong\u003eopt-in\u003c/strong\u003e and off until you configure a provider.\u003c/p\u003e\n\u003ch2 id=\"writing-a-gen-reference\"\u003eWriting a \u003ccode\u003egen:\u003c/code\u003e reference\u003c/h2\u003e\n\u003cp\u003eAnywhere you'd put an image path, write \u003ccode\u003egen:\u003c/code\u003e followed by the prompt.\u003c/p\u003e\n\u003cp\u003eIn frontmatter:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e---\ntitle: On Patience\nhero: \u0026#34;gen:a weathered fisherman waiting by a still lake at dawn, photoreal\u0026#34;\nhero_alt: \u0026#34;A fisherman waiting by a calm lake at first light\u0026#34;\nimage: \u0026#34;gen:a minimalist book cover, muted teal palette\u0026#34;\n---\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eIn the body — \u003cstrong\u003ewrap the prompt in \u003ccode\u003e\u0026lt;…\u0026gt;\u003c/code\u003e whenever it contains spaces\u003c/strong\u003e (plain Markdown\nimage syntax stops at the first space):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-markdown\"\u003e![A fisherman at dawn](\u0026lt;gen:a weathered fisherman by a still lake at dawn, photoreal\u0026gt;)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe alt text works exactly as for any image — write it for meaning, leave it empty for\npurely decorative banners.\u003c/p\u003e\n\u003ch3 id=\"tuning-a-single-image\"\u003eTuning a single image\u003c/h3\u003e\n\u003cp\u003eAppend query-style parameters to the prompt:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-markdown\"\u003e![cover](\u0026lt;gen:a quiet mountain road in fog?aspect=16:9\u0026gt;)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003easpect\u003c/code\u003e (e.g. \u003ccode\u003e1:1\u003c/code\u003e, \u003ccode\u003e16:9\u003c/code\u003e, \u003ccode\u003e9:16\u003c/code\u003e) is the common one; available parameters depend on\nthe provider. Per-reference parameters override the \u003ccode\u003edefaults:\u003c/code\u003e in your config.\u003c/p\u003e\n\u003ch3 id=\"reuse\"\u003eReuse\u003c/h3\u003e\n\u003cp\u003eTwo references with the \u003cstrong\u003esame prompt, model and parameters\u003c/strong\u003e resolve to the \u003cstrong\u003esame\u003c/strong\u003e\ngenerated file — so a hero and an in-body embed sharing one prompt are generated once.\nChange the prompt, the model, or a parameter and you get a new image.\u003c/p\u003e\n\u003ch2 id=\"configuration\"\u003eConfiguration\u003c/h2\u003e\n\u003cp\u003eAdd a \u003ccode\u003egeneration:\u003c/code\u003e block to \u003ccode\u003ecolophon.yaml\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003egeneration:\n  image:\n    provider: minimax            # which service to use (see table below)\n    model: \u0026#34;\u0026#34;                    # optional — defaults per provider\n    defaults:                    # optional — params applied to every prompt\n      aspect: \u0026#34;16:9\u0026#34;\n    output_dir: \u0026#34;\u0026#34;               # optional — default: content/assets/generated\n    concurrency: 5               # optional — max images generated at once\n    system_prompt: \u0026#34;\u0026#34;            # optional — house style; overrides the theme\u0026#39;s (see below)\n    # api_key: \u0026#34;{env:MINIMAX_API_KEY}\u0026#34;   # optional — see \u0026#34;API keys\u0026#34; below\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch3 id=\"providers\"\u003eProviders\u003c/h3\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003e\u003ccode\u003eprovider\u003c/code\u003e\u003c/th\u003e\n\u003cth\u003eDefault model\u003c/th\u003e\n\u003cth\u003eAPI key (environment variable)\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003egoogle\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003egemini-3.1-flash-image\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eGEMINI_API_KEY\u003c/code\u003e (or \u003ccode\u003eGOOGLE_API_KEY\u003c/code\u003e)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eminimax\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eimage-01\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eMINIMAX_API_KEY\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003eopenai\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003egpt-image-1\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eOPENAI_API_KEY\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003exai\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003egrok-imagine-image-quality\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eXAI_API_KEY\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003etogether\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cem\u003e(set \u003ccode\u003emodel:\u003c/code\u003e)\u003c/em\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eTOGETHER_API_KEY\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003edeepinfra\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cem\u003e(set \u003ccode\u003emodel:\u003c/code\u003e)\u003c/em\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eDEEPINFRA_API_KEY\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ecustom\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cem\u003e(set \u003ccode\u003emodel:\u003c/code\u003e)\u003c/em\u003e\u003c/td\u003e\n\u003ctd\u003eset \u003ccode\u003eapi_key:\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003ccode\u003ecustom\u003c/code\u003e targets any OpenAI-compatible images endpoint — also set \u003ccode\u003ebase_url:\u003c/code\u003e and\n\u003ccode\u003eapi_path:\u003c/code\u003e (e.g. \u003ccode\u003e/images/generations\u003c/code\u003e).\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003exai\u003c/code\u003e is Grok Imagine: xAI's OpenAI-compatible images endpoint. Set\n\u003ccode\u003emodel: grok-imagine-image\u003c/code\u003e for the cheaper tier, and use \u003ccode\u003easpect\u003c/code\u003e as usual (it is\nsent as xAI's \u003ccode\u003easpect_ratio\u003c/code\u003e):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003egeneration:\n  image:\n    provider: xai                # Grok Imagine — reads XAI_API_KEY\n    defaults:\n      aspect: \u0026#34;16:9\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch3 id=\"api-keys\"\u003eAPI keys\u003c/h3\u003e\n\u003cp\u003eKeys are read from the environment, never required in the file:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eIf you set \u003ccode\u003eapi_key:\u003c/code\u003e (typically \u003ccode\u003e\u0026quot;{env:VAR}\u0026quot;\u003c/code\u003e), that value is used.\u003c/li\u003e\n\u003cli\u003eOtherwise colophon reads the provider's default environment variable from the table.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eEither way the secret stays in your shell / \u003ccode\u003e.env\u003c/code\u003e / CI secrets, not in \u003ccode\u003ecolophon.yaml\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"house-style-theme-system-prompt\"\u003eHouse style (theme system prompt)\u003c/h2\u003e\n\u003cp\u003eSo you don't repeat the same look in every prompt, a \u003cstrong\u003ehouse style\u003c/strong\u003e is applied to every\ngenerated image — describe only the \u003cem\u003esubject\u003c/em\u003e in each post and let the style come from the\ntheme. The style is resolved in this order (first wins):\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003ePer-reference\u003c/strong\u003e \u003ccode\u003e?systemprompt=…\u003c/code\u003e (below)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSite config\u003c/strong\u003e — \u003ccode\u003egeneration.image.system_prompt\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTheme\u003c/strong\u003e — \u003ccode\u003eimage.genai.system_prompt\u003c/code\u003e in the theme's \u003ccode\u003etheme.yaml\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTheme\u003c/strong\u003e — the theme's \u003ccode\u003edescription\u003c/code\u003e (a gentle fallback)\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eA theme sets its style in \u003ccode\u003etheme.yaml\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# themes/\u0026lt;name\u0026gt;/theme.yaml\ndescription: \u0026#34;A clean editorial broadsheet theme.\u0026#34;\nimage:\n  genai:\n    system_prompt: \u0026#34;editorial illustration, muted palette, soft depth, no text or lettering\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eSo \u003ccode\u003ehero: \u0026quot;gen:a lighthouse on a rocky coast\u0026quot;\u003c/code\u003e is generated \u003cem\u003ein the theme's style\u003c/em\u003e with no\nextra wording in the post.\u003c/p\u003e\n\u003cp\u003ePer image, you can override or switch it off with the reserved \u003ccode\u003esystemprompt\u003c/code\u003e parameter:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e?systemprompt=bold woodcut print, two-tone\u003c/code\u003e — use this style instead for this image\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e?systemprompt=none\u003c/code\u003e (or \u003ccode\u003enil\u003c/code\u003e, or empty) — no house style for this image\u003c/li\u003e\n\u003c/ul\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-markdown\"\u003e![A lighthouse, in woodcut](\u0026lt;gen:a lighthouse on a rocky coast?systemprompt=bold woodcut, two-tone\u0026gt;)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe house style is part of the cache identity, so changing it (in the theme, the config, or a\npost) regenerates the affected images.\u003c/p\u003e\n\u003ch2 id=\"audio-readings-podcasts\"\u003eAudio readings (podcasts)\u003c/h2\u003e\n\u003cp\u003eA post can carry an audio reading — generated by AI or a file you recorded yourself. Both\nbecome a player in the theme and a podcast \u003ccode\u003e\u0026lt;enclosure\u0026gt;\u003c/code\u003e in the feeds (RSS/Atom/JSON).\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# pre-recorded — no AI, just attach a file (copied like a hero image)\naudio_file: episode.mp3\n# AI text-to-speech: omit to use the site default; set true/false to force per-post\naudio: true\naudio_voice: \u0026#34;English_Graceful_Lady\u0026#34;   # optional; else the author\u0026#39;s/persona\u0026#39;s voice, else the default\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003eaudio:\u003c/code\u003e is optional and three-state: \u003cstrong\u003eomit it\u003c/strong\u003e to follow the site default (read aloud when\na speech provider is configured), or set \u003ccode\u003etrue\u003c/code\u003e/\u003ccode\u003efalse\u003c/code\u003e to force it for this post. \u003ccode\u003eaudio_file\u003c/code\u003e\nwins if both are set. The reading voice resolves: post \u003ccode\u003eaudio_voice\u003c/code\u003e → the\nauthor's (or persona's) \u003ccode\u003evoice:\u003c/code\u003e → the \u003ccode\u003egeneration.speech\u003c/code\u003e default.\u003c/p\u003e\n\u003cp\u003eConfigure the speech provider alongside the image one:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003egeneration:\n  speech:\n    provider: minimax        # MiniMax t2a (more providers later)\n    model: speech-2.6-hd\n    voice: \u0026#34;English_Graceful_Lady\u0026#34;\n    format: mp3              # default — the podcast-portable, now-patent-free choice\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eAuthors/personas can carry a default voice (e.g. a cloned voice id):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# authors/sam.yaml\nid: sam\nname: Sam Avery\nvoice: \u0026#34;English_Graceful_Lady\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThemes get \u003ccode\u003ehas_audio\u003c/code\u003e (bool) and \u003ccode\u003eaudio\u003c/code\u003e (URL) to render a player or filter audio posts. The\npress theme shows a themed play/pause + \u003cstrong\u003escrubbable waveform\u003c/strong\u003e player under the byline.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eWaveform.\u003c/strong\u003e The player derives the waveform from the audio itself, in the browser (Web Audio\n\u003ccode\u003edecodeAudioData\u003c/code\u003e), on first play — so generated readings cost a single TTS call, not a second one,\nand the computed peaks are cached in \u003ccode\u003elocalStorage\u003c/code\u003e for instant redraws. For a \u003cstrong\u003erecorded\u003c/strong\u003e file you\ncan still ship a precomputed \u003ccode\u003eepisode.mp3.json\u003c/code\u003e of the form \u003ccode\u003e{\u0026quot;peaks\u0026quot;:[0.1, 0.7, …]}\u003c/code\u003e (values 0–1)\nnext to it — e.g. produced with \u003ccode\u003effmpeg\u003c/code\u003e/\u003ccode\u003eaudiowaveform\u003c/code\u003e — and it renders instantly (paused,\npre-play); a \u003ccode\u003e.wav\u003c/code\u003e is read directly at build. Until peaks exist, the player shows a resting shape\nor a live Web Audio visualiser (same-origin). When audio is routed cross-origin to an object store,\nthe in-browser decode and any peaks fetch need CORS (a GET policy) on the bucket.\u003c/p\u003e\n\u003ch3 id=\"what-gets-read-aloud\"\u003eWhat gets read aloud\u003c/h3\u003e\n\u003cp\u003eBlocks that don't translate to speech — code, math, tables, diagrams — aren't read verbatim\n(reading code or LaTeX aloud is gibberish). By default each is replaced with a short spoken\ncue (\u0026quot;Here, the post shows a Go code example.\u0026quot;), the first cue adds \u0026quot;Visit the post to view\nit.\u0026quot;, and a closing note is appended. Prose, headings, lists, blockquotes and callouts are\nread normally. Tune it per type:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003egeneration:\n  speech:\n    transcript:\n      blocks:                # cue | drop (silent) | keep (read the text)\n        code: cue\n        math_display: cue\n        math_inline: drop\n        table: cue\n        diagram: cue\n        inline_code: spell   # spell | keep | drop — \u0026#34;spell\u0026#34; voices symbols (\u0026#34;/etc\u0026#34; → \u0026#34;slash etc\u0026#34;)\n      wrap_up: true          # append the closing \u0026#34;visit the post\u0026#34; note\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003eAcronyms\u003c/strong\u003e in your \u003ca href=\"/start/content/#glossary\"\u003eglossary\u003c/a\u003e are read as their expansion so they're\nspoken as words, not letters — \u003ccode\u003eSSH\u003c/code\u003e → \u0026quot;Secure Shell\u0026quot;, \u003ccode\u003eDDD\u003c/code\u003e → \u0026quot;Domain Driven Design\u0026quot;. Only\nentries that \u003cem\u003elook\u003c/em\u003e like acronym expansions qualify (an all-caps term with a short Title-Case\ndefinition whose letters spell the acronym), so an ordinary term like \u003ccode\u003eRust\u003c/code\u003e is left alone.\nTurn it off with \u003ccode\u003egeneration.speech.transcript.expand_acronyms: false\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eFor finer control, wrap content in the body:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e\u0026lt;notts\u0026gt;…\u0026lt;/notts\u0026gt;\u003c/code\u003e — shown on the page but \u003cstrong\u003enever spoken\u003c/strong\u003e (e.g. an aside, a pronunciation-\nhostile string).\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e\u0026lt;tts\u0026gt;…\u0026lt;/tts\u0026gt;\u003c/code\u003e — \u003cstrong\u003ealways read\u003c/strong\u003e verbatim, overriding the rules above (e.g. a short code\nsnippet you \u003cem\u003edo\u003c/em\u003e want spoken).\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eBoth the injected speech (cues, hint, wrap-up, inline-code symbol names) \u003cstrong\u003eand the player UI\u003c/strong\u003e\n(the \u0026quot;Listen\u0026quot; caption and Play/Pause labels) are \u003cstrong\u003elocalised to the post's language\u003c/strong\u003e (\u003ccode\u003elang:\u003c/code\u003e\nfrontmatter, else the site \u003ccode\u003elang\u003c/code\u003e). English, Spanish, French, German, Italian and Mandarin ship\nbuilt in; add or override any language with a project \u003ccode\u003ei18n/tts/replacements.json\u003c/code\u003e (same shape\nas the built-in table). A missing language or field falls back to English.\u003c/p\u003e\n\u003cp\u003e(No LLM is involved — cues are fixed text. An abstractive \u003cem\u003esummary\u003c/em\u003e reading would need a\ntext-LLM provider, which colophon doesn't have yet.)\u003c/p\u003e\n\u003ch2 id=\"turning-generation-onoff\"\u003eTurning generation on/off\u003c/h2\u003e\n\u003cp\u003eThree nested switches, all defaulting \u003cstrong\u003eon\u003c/strong\u003e, all behaving the same way: when off, no new\nassets of that kind are generated (no provider/API calls) even with \u003ccode\u003e--generate-ai\u003c/code\u003e, while\neverything already generated and committed keeps being served.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003egeneration:\n  enabled: true          # master — turns ALL generation off in one line\n  image:\n    enabled: true        # just images\n  speech:\n    enabled: true        # just audio (also the per-post audio default — see below)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003egeneration.speech.enabled\u003c/code\u003e does double duty: it's the audio guard \u003cstrong\u003eand\u003c/strong\u003e the default for a\npost's \u003ccode\u003eaudio:\u003c/code\u003e field. With a speech provider configured and speech enabled, \u003cstrong\u003eevery post\nreads aloud by default\u003c/strong\u003e; a post opts out with \u003ccode\u003eaudio: false\u003c/code\u003e. Without a provider, audio is\noff regardless (so speech effectively self-disables). Image generation has no per-post\nequivalent (images are explicit \u003ccode\u003egen:\u003c/code\u003e references), so \u003ccode\u003eimage.enabled\u003c/code\u003e is purely the guard.\u003c/p\u003e\n\u003ch2 id=\"cleaning-up-the-cache\"\u003eCleaning up the cache\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003ecolophon doctor\u003c/code\u003e reports cached generated assets that no content references any more (after\nan edited prompt, a changed style/model/voice, or a deleted post). \u003ccode\u003ecolophon doctor --prune\u003c/code\u003e\ndeletes them (and their sidecars).\u003c/p\u003e\n\u003ch2 id=\"image-post-processing\"\u003eImage post-processing\u003c/h2\u003e\n\u003cp\u003eSome providers bake black letterbox bars into images. colophon removes them deterministically\nafter generation (\u003ccode\u003egeneration.image.postprocess.trim_letterbox\u003c/code\u003e, on by default): it crops\nsolid black borders and re-frames to the requested aspect. It's a no-op on clean images and\nneeds no configuration; set it to \u003ccode\u003efalse\u003c/code\u003e to disable.\u003c/p\u003e\n\u003ch2 id=\"generating-images\"\u003eGenerating images\u003c/h2\u003e\n\u003cp\u003eGeneration is a separate, explicit step — ordinary builds never call the provider:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-bash\"\u003ecolophon build --generate-ai\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThis produces only the images that \u003cstrong\u003earen't already cached\u003c/strong\u003e, writing each into\n\u003ccode\u003eoutput_dir\u003c/code\u003e alongside a \u003ccode\u003e.json\u003c/code\u003e sidecar that records the prompt, provider, model,\nparameters and date. Already-cached images are reused with no API call. Generations run\nin parallel (up to \u003ccode\u003econcurrency\u003c/code\u003e, default 5 — lower it if your provider rate-limits you).\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003e--generate-ai\u003c/code\u003e is also a flag on \u003ccode\u003ecolophon publish\u003c/code\u003e, so a deploy can generate any\nstill-uncached media first: \u003ccode\u003ecolophon publish --env production --generate-ai\u003c/code\u003e. (The same\napplies to TTS audio readings — without the flag, an uncached reading is skipped with a\n\u003ccode\u003ebuild --generate-ai to create it\u003c/code\u003e hint.)\u003c/p\u003e\n\u003cp\u003eThen \u003cstrong\u003ecommit the generated files\u003c/strong\u003e (\u003ccode\u003econtent/assets/generated/\u003c/code\u003e by default). The cache\nis part of your content: it makes builds reproducible and means a plain \u003ccode\u003ecolophon build\u003c/code\u003e\nor \u003ccode\u003ecolophon publish\u003c/code\u003e — including in CI — needs no API key and incurs no cost.\u003c/p\u003e\n\u003cp\u003eIf a \u003ccode\u003egen:\u003c/code\u003e reference has no cached image and you build \u003cem\u003ewithout\u003c/em\u003e \u003ccode\u003e--generate-ai\u003c/code\u003e (or the\nprovider errors, or no provider is configured), colophon \u003cstrong\u003ewarns and skips\u003c/strong\u003e it — the\nbuild still succeeds, the image is just absent, exactly like any other missing asset.\n\u003ccode\u003ecolophon doctor\u003c/code\u003e will not flag \u003ccode\u003egen:\u003c/code\u003e references as broken.\u003c/p\u003e\n\u003ch2 id=\"good-to-know\"\u003eGood to know\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eFile format follows the provider.\u003c/strong\u003e colophon names each file by its actual content\n(e.g. MiniMax returns JPEG, so you get \u003ccode\u003e.jpg\u003c/code\u003e), so the served \u003ccode\u003eContent-Type\u003c/code\u003e is always\ncorrect.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eChanging the model or a default reuses nothing\u003c/strong\u003e — it's a new prompt identity, so the\nold image stays in the cache until you delete it. There is no automatic pruning yet.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eProvider model names change.\u003c/strong\u003e Pin \u003ccode\u003emodel:\u003c/code\u003e if you need stability; the defaults track\neach provider's current recommended image model.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/image-generation.md\"\u003e\u003ccode\u003edocs/image-generation.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-25T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/personas/",
      "url": "https://docs.colophon.blog/guides/personas/",
      "title": "Authors \u0026 personas",
      "summary": "colophon separates who is shown from how it's written:",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/personas.md — do not edit by hand. --\u003e\n\u003cp\u003ecolophon separates \u003cstrong\u003ewho is shown\u003c/strong\u003e from \u003cstrong\u003ehow it's written\u003c/strong\u003e:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eAn \u003cstrong\u003eauthor\u003c/strong\u003e is the \u003cstrong\u003ebyline readers see\u003c/strong\u003e — an identity (a person, or a brand name).\u003c/li\u003e\n\u003cli\u003eA \u003cstrong\u003epersona\u003c/strong\u003e is a \u003cstrong\u003ehidden writing voice\u003c/strong\u003e the agent writes in — never shown, and\n\u003cstrong\u003eshareable across authors\u003c/strong\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eA post carries up to two fields: \u003ccode\u003eauthor:\u003c/code\u003e (the byline) and \u003ccode\u003epersona:\u003c/code\u003e (the voice). The\nvoice is purely an authoring aid; nothing about a persona is rendered.\u003c/p\u003e\n\u003ch2 id=\"authors\"\u003eAuthors\u003c/h2\u003e\n\u003cp\u003eAuthors live in \u003ccode\u003eauthors/\u0026lt;id\u0026gt;.yaml\u003c/code\u003e and supply the byline, author page, feed author and\nJSON-LD \u003ccode\u003eauthor\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003eid: ada                       # the id (defaults to the file stem, e.g. authors/ada.yaml)\nname: \u0026#34;Ada Lovelace\u0026#34;          # the byline shown to readers\nbio: \u0026#34;Writes about distributed systems.\u0026#34;\navatar: assets/avatar.png     # see below — a file, a data:/https:// URL, or `gravatar`\nurls: [\u0026#34;https://example.com\u0026#34;]\nemail: ada@example.com\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe \u003ccode\u003eavatar\u003c/code\u003e may be:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003ea \u003cstrong\u003efile under a content source\u003c/strong\u003e (e.g. \u003ccode\u003eassets/avatar.png\u003c/code\u003e, resolved the same way a\nmarkdown image embed is — the first source that can open it wins). It is published once to\n\u003ccode\u003e/assets/\u0026lt;name\u0026gt;\u003c/code\u003e and the byline/topbar \u003ccode\u003esrc\u003c/code\u003e is root-anchored, so it renders from every page\ndepth (and is rewritten to the object-store URL when an \u003ccode\u003eassets/**\u003c/code\u003e route is active).\u003c/li\u003e\n\u003cli\u003ea \u003cstrong\u003e\u003ccode\u003edata:\u003c/code\u003e URI\u003c/strong\u003e (a self-contained inline image),\u003c/li\u003e\n\u003cli\u003ea fully-qualified \u003cstrong\u003e\u003ccode\u003ehttp(s)://\u003c/code\u003e URL\u003c/strong\u003e (a hosted image), or\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGravatar\u003c/strong\u003e — \u003ccode\u003eavatar: gravatar\u003c/code\u003e uses this author's \u003ccode\u003eemail:\u003c/code\u003e, or \u003ccode\u003eavatar: \u0026quot;gravatar:me@example.com\u0026quot;\u003c/code\u003e\ncarries the address inline. It resolves to the author's Gravatar image. Append Gravatar options\nafter a \u003ccode\u003e?\u003c/code\u003e to override the defaults (\u003ccode\u003es=200\u0026amp;d=mp\u003c/code\u003e), e.g. \u003ccode\u003e\u0026quot;gravatar:me@example.com?d=identicon\u0026amp;s=256\u0026quot;\u003c/code\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003ccode\u003edata:\u003c/code\u003e, \u003ccode\u003ehttp(s)://\u003c/code\u003e and resolved \u003ccode\u003egravatar\u003c/code\u003e avatars pass through untouched; a file path that no\nsource can open is warned about (no broken \u003ccode\u003esrc\u003c/code\u003e is emitted).\u003c/p\u003e\n\u003cp\u003eA post names one with \u003ccode\u003eauthor: ada\u003c/code\u003e. If a post sets no \u003ccode\u003eauthor:\u003c/code\u003e, the \u003cstrong\u003efirst configured\nauthor\u003c/strong\u003e is the default; with no authors at all, the byline is \u003cstrong\u003e\u0026quot;Anonymous\u0026quot;\u003c/strong\u003e (a post\nwithout an author still builds — it's just unattributed).\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon authors               # list bylines (alias: colophon author)\ncolophon author show ada       # one author\u0026#39;s full h-card\ncolophon authors --json        # machine-readable (for a skill/agent)\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"personas-the-writing-voice\"\u003ePersonas (the writing voice)\u003c/h2\u003e\n\u003cp\u003ePersonas live in \u003ccode\u003epersonas/\u0026lt;id\u0026gt;.yaml\u003c/code\u003e and are \u003cstrong\u003eonly\u003c/strong\u003e a voice — a style/character the agent\nwrites in, plus the references it may draw on:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003eid: technical\nname: \u0026#34;Senior engineer\u0026#34;        # a human label (not shown)\nstyle:\n  guide: \u0026#34;Plain, precise, technical. Short sentences. No hype. Senior-engineer perspective.\u0026#34;\n  references:\n    - \u0026#34;https://example.com/glossary\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe same persona can be used by \u003cstrong\u003edifferent authors\u003c/strong\u003e — Ada and Grace can both publish in the\n\u003ccode\u003etechnical\u003c/code\u003e voice under their own bylines. A persona's \u003cem\u003ecorpus\u003c/em\u003e is every post written in it,\nregardless of author, so the voice stays consistent and the exemplar pool grows.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon persona list            # id, label, and whether a style guide is set\ncolophon persona list --json     # machine-readable (for a skill/agent)\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"write-as-context\"\u003eWrite-as context\u003c/h2\u003e\n\u003cp\u003ecolophon does \u003cstrong\u003enot\u003c/strong\u003e generate prose. It emits \u003cem\u003econtext\u003c/em\u003e and the calling agent does the\nwriting. \u003ccode\u003epersona context\u003c/code\u003e returns a voice's style guide and references plus the most relevant\n\u003cstrong\u003eexemplars\u003c/strong\u003e drawn from the posts written in that voice:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon persona context technical --topic \u0026#34;raft leader election\u0026#34;\ncolophon persona context technical --topic \u0026#34;raft\u0026#34; --tag distributed --top-k 5 --json\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003eWith \u003ccode\u003e--topic\u003c/code\u003e, exemplars are ranked by relevance (a pure-Go BM25 over the persona's posts —\nno embeddings, no API key). Without a topic, the most recent posts are returned.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e--tag\u003c/code\u003e (repeatable) narrows the corpus to exemplars carrying that tag.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e--top-k\u003c/code\u003e sets how many exemplars to emit (default 3).\u003c/li\u003e\n\u003cli\u003eThe persona id is optional when there is a single persona (or one named \u003ccode\u003edefault\u003c/code\u003e).\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"finding-where-to-write--what-exists\"\u003eFinding where to write \u0026amp; what exists\u003c/h2\u003e\n\u003cp\u003eTwo commands round out the authoring toolbox (both take \u003ccode\u003e--json\u003c/code\u003e):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon sources               # where each source\u0026#39;s content lives + how a post is marked live\ncolophon posts                 # existing entries: slug, title, type, author, persona, tags\ncolophon posts --tag go --author ada   # filter, e.g. to find cross-reference targets\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"creating--previewing\"\u003eCreating \u0026amp; previewing\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003ecolophon new post|page\u003c/code\u003e validates the author and persona, derives a \u003cstrong\u003eunique pinned slug\u003c/strong\u003e,\nwrites a frontmatter skeleton to the right source, and reports the disk path and URL — then a\nperson \u003cem\u003eor\u003c/em\u003e an agent fills the body (colophon never generates prose):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon new post \u0026#34;Raft leader election\u0026#34; --author ada --persona technical --tag distributed\n# wrote:  content/posts/raft-leader-election.md\n# slug:   posts/raft-leader-election\n# url:    /posts/raft-leader-election/   (preview: colophon serve)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003e--author\u003c/code\u003e / \u003ccode\u003e--persona\u003c/code\u003e are validated against \u003ccode\u003eauthors/*.yaml\u003c/code\u003e / \u003ccode\u003epersonas/*.yaml\u003c/code\u003e (errors\nlist the valid ids); both are optional.\u003c/li\u003e\n\u003cli\u003ethe slug derives from the title and is made unique — \u003ccode\u003e--unique=hash\u003c/code\u003e (default) appends a\nshort id on a collision, \u003ccode\u003e--unique=counter\u003c/code\u003e appends \u003ccode\u003e-2\u003c/code\u003e; \u003ccode\u003e--slug\u003c/code\u003e sets it explicitly.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e--in \u0026lt;source\u0026gt;\u003c/code\u003e chooses the source to write into; \u003ccode\u003e--print\u003c/code\u003e emits to stdout instead of writing.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003ePreview, and jump straight to a page:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon serve --open=latest     # opens the newest post in the browser\ncolophon serve --open=sitemap    # also: home | atom | rss | json | robots | \u0026lt;slug\u0026gt;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003eserve\u003c/code\u003e also prints the home/latest/sitemap/feed URLs at startup, so an agent can read them\nwithout a browser.\u003c/p\u003e\n\u003cp\u003eThis is the core of the agent write-as flow: an agent picks a \u003cstrong\u003evoice\u003c/strong\u003e (persona) for style\nand an \u003cstrong\u003eauthor\u003c/strong\u003e for the byline, fetches the write-as context, drafts in that voice, previews,\nand publishes — all through the CLI, with deploy secrets resolved server-side and never passed\nto the agent.\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003eRetrieval is built in memory on each call (zero state). A persisted/​semantic index is a\nfuture option; the command shape stays the same.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/personas.md\"\u003e\u003ccode\u003edocs/personas.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-24T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/syndication/",
      "url": "https://docs.colophon.blog/guides/syndication/",
      "title": "Syndication (POSSE)",
      "summary": "your blog and pushes copies to social accounts (\"silos\") that link back. colophon does this with colophon syndicate: it walks your published posts, sends each to the configured…",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/syndication.md — do not edit by hand. --\u003e\n\u003cp\u003e\u003cstrong\u003ePOSSE\u003c/strong\u003e — \u003cem\u003ePublish on your Own Site, Syndicate Elsewhere\u003c/em\u003e — keeps the canonical copy of a post on\nyour blog and pushes copies to social accounts (\u0026quot;silos\u0026quot;) that link back. colophon does this with\n\u003ccode\u003ecolophon syndicate\u003c/code\u003e: it walks your published posts, sends each to the configured \u003cstrong\u003esyndicators\u003c/strong\u003e,\nand records the result in a committed \u003cstrong\u003eledger\u003c/strong\u003e so re-runs never double-post. The recorded silo\nURLs render on the post as \u003ccode\u003eu-syndication\u003c/code\u003e \u0026quot;Also posted on…\u0026quot; chips, each with the silo's brand icon\nand network name (e.g. \u0026quot;Bluesky\u0026quot;) — the same \u003ca href=\"/start/themes/#silo-icons\"\u003esilo icons\u003c/a\u003e used by responses.\u003c/p\u003e\n\u003cp\u003eThis page is the full reference; the \u003ca href=\"/guides/howto/\"\u003ehow-to guides\u003c/a\u003e are quick per-silo recipes.\u003c/p\u003e\n\u003ch2 id=\"how-it-works\"\u003eHow it works\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ecolophon publish   --env production --allow-publish   # 1. canonical post goes live first\ncolophon syndicate --env production --allow-publish    # 2. push copies to the silos, record the ledger\n\u003c/code\u003e\u003c/pre\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003ePublish first.\u003c/strong\u003e Syndication links back to the canonical URL, and some drivers (Bridgy) fetch\nthe live page — so the post must be deployed before you syndicate.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003esyndicate\u003c/code\u003e\u003c/strong\u003e gathers eligible posts (type \u003ccode\u003epost\u003c/code\u003e, not draft, not opted out), works out each\none's targets, and for every \u003ccode\u003e(post, target)\u003c/code\u003e \u003cem\u003enot already in the ledger\u003c/em\u003e, calls the driver.\u003c/li\u003e\n\u003cli\u003eEach driver returns the \u003cstrong\u003esilo URL\u003c/strong\u003e, which is written to \u003ccode\u003e.colophon/syndication.json\u003c/code\u003e and, on the\nnext build, rendered as a \u003ccode\u003eu-syndication\u003c/code\u003e link.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"editing-a-syndicated-copy-when-the-post-changes\"\u003eEditing a syndicated copy when the post changes\u003c/h3\u003e\n\u003cp\u003eThe ledger stores a \u003cstrong\u003econtent fingerprint\u003c/strong\u003e (title, summary, custom syndication text, link, tags)\nalongside each silo URL. On a later run, if a post's content has changed, colophon brings the\nexisting silo copy up to date rather than skipping it — but \u003cem\u003ehow\u003c/em\u003e depends on what the silo allows:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eMastodon\u003c/strong\u003e edits the status \u003cstrong\u003ein place\u003c/strong\u003e (\u003ccode\u003ePUT\u003c/code\u003e), preserving its likes/replies/permalink. This\nhappens automatically on any content change.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eBluesky can't edit a card in place\u003c/strong\u003e — its AppView ignores record edits, so an edit is\ninvisible. The only way to change a card is an \u003cstrong\u003eatomic swap\u003c/strong\u003e (delete + recreate at the \u003cem\u003esame\u003c/em\u003e\nrkey): the card re-indexes and the \u003cstrong\u003epermalink is kept\u003c/strong\u003e, but it's a new record, so the post's\n\u003cstrong\u003elikes/reposts/replies reset\u003c/strong\u003e and the timestamp updates. Because that's lossy, automatic\nedit-on-change \u003cstrong\u003eskips Bluesky\u003c/strong\u003e with a note — it's only done on an explicit \u003cstrong\u003e\u003ccode\u003e--resync\u003c/code\u003e\u003c/strong\u003e (below).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eBridgy\u003c/strong\u003e / \u003cstrong\u003ecommand\u003c/strong\u003e can't edit a published copy; their copies are left as-is with a note.\u003c/li\u003e\n\u003cli\u003eAn entry with \u003cstrong\u003eno recorded silo URL\u003c/strong\u003e (e.g. an old Bridgy post) is \u003cstrong\u003eskipped\u003c/strong\u003e, not failed.\u003c/li\u003e\n\u003cli\u003eA post with an \u003cstrong\u003eunchanged\u003c/strong\u003e fingerprint is skipped — edits only fire on a real change.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eUpgrade/backfill:\u003c/strong\u003e a ledger written by an older colophon has no fingerprints. The first run\nafter upgrading \u003cstrong\u003ebackfills\u003c/strong\u003e the current fingerprint for each entry \u003cem\u003ewithout editing anything\u003c/em\u003e\n(there's nothing to compare against), so only genuine changes \u003cem\u003eafter\u003c/em\u003e that trigger an edit. The\nrun reports \u003ccode\u003ebackfilled=N\u003c/code\u003e; commit the updated ledger.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003e--resync\u003c/code\u003e:\u003c/strong\u003e a one-shot that brings \u003cstrong\u003eevery\u003c/strong\u003e already-syndicated copy to the post's current\ncontent, ignoring fingerprints. Use it to catch up copies created/changed before adopting this\nfeature — the backfill deliberately won't, since it can't tell which were stale. On \u003ccode\u003e--resync\u003c/code\u003e,\nMastodon re-edits in place and \u003cstrong\u003eBluesky does the atomic swap\u003c/strong\u003e (so its cards refresh, accepting\nthe engagement reset). Reports \u003ccode\u003eupdated=N\u003c/code\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eIt performs \u003cstrong\u003eirreversible external actions\u003c/strong\u003e (posting to real accounts), so it's fenced:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eOnly an environment's \u003ccode\u003esyndicate:\u003c/code\u003e targets ever fire — a \u003ccode\u003epreview\u003c/code\u003e/\u003ccode\u003edraft\u003c/code\u003e env that omits the key\n\u003cstrong\u003enever\u003c/strong\u003e posts.\u003c/li\u003e\n\u003cli\u003eA gated env (\u003ccode\u003eallow_publish: false\u003c/code\u003e, typically production) needs \u003ccode\u003e--allow-publish\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003e--dry-run\u003c/code\u003e shows exactly what would post and writes nothing.\u003c/li\u003e\n\u003cli\u003eIf the ledger file is missing, a real run \u003cstrong\u003erefuses to start\u003c/strong\u003e (it would re-post your whole back\ncatalogue) unless you pass \u003ccode\u003e--allow-publish\u003c/code\u003e to seed it.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"the-ledger--commit-it\"\u003eThe ledger — commit it\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003e.colophon/syndication.json\u003c/code\u003e is authoritative: it's how colophon knows a post is already syndicated.\n\u003cstrong\u003eCommit it to your repo.\u003c/strong\u003e Without it, a fresh CI runner would treat every post as new and re-post\neverything. It maps post → driver → \u003ccode\u003e{url, syndicated_at}\u003c/code\u003e; \u003ccode\u003eencoding/json\u003c/code\u003e sorts keys so diffs stay\nclean.\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ccode\u003e.gitignore\u003c/code\u003e gotcha:\u003c/strong\u003e to commit the ledger out of an otherwise-ignored \u003ccode\u003e.colophon/\u003c/code\u003e, ignore the\ndirectory's \u003cem\u003econtents\u003c/em\u003e, not the directory — git can't re-include a file under a wholly-ignored dir:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-gitignore\"\u003e/.colophon/*                       # build trees, caches\n!/.colophon/syndication.json       # …but keep the ledger\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003e/.colophon/\u003c/code\u003e (trailing slash) followed by a \u003ccode\u003e!\u003c/code\u003e negation \u003cstrong\u003esilently fails\u003c/strong\u003e — the ledger stays\nignored. \u003ccode\u003ecolophon init\u003c/code\u003e scaffolds the correct form.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch3 id=\"persisting-the-ledger-in-ci-commit-it-back\"\u003ePersisting the ledger in CI (commit it back)\u003c/h3\u003e\n\u003cp\u003eThe catch on GitHub Actions (or any CI): \u003ccode\u003ecolophon syndicate\u003c/code\u003e \u003cem\u003ewrites\u003c/em\u003e the ledger in the runner, but\nthat runner is thrown away. Unless the workflow \u003cstrong\u003ecommits the updated ledger back to the repo\u003c/strong\u003e, the\nnext run checks out the old ledger and \u003cstrong\u003ere-posts everything\u003c/strong\u003e. So a CI syndication step is two\nparts — syndicate, then commit back:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003epermissions:\n  contents: write            # needed to push the ledger back\nconcurrency:\n  group: deploy              # serialise: two overlapping runs must not both post before committing\njobs:\n  deploy:\n    steps:\n      # … build + publish (the post must be live before you syndicate) …\n      - name: Syndicate\n        env:\n          MASTODON_TOKEN: ${{ secrets.MASTODON_TOKEN }}\n          BLUESKY_APP_PASSWORD: ${{ secrets.BLUESKY_APP_PASSWORD }}\n        run: colophon syndicate --env production --allow-publish\n      - name: Commit syndication ledger\n        run: |\n          git diff --quiet .colophon/syndication.json \u0026amp;\u0026amp; exit 0\n          git config user.name  \u0026#34;github-actions[bot]\u0026#34;\n          git config user.email \u0026#34;github-actions[bot]@users.noreply.github.com\u0026#34;\n          git add .colophon/syndication.json\n          git commit -m \u0026#34;chore(syndication): update ledger [skip ci]\u0026#34;\n          git push\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThree things make this safe:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003econtents: write\u003c/code\u003e\u003c/strong\u003e — the default workflow is read-only; the commit-back needs write.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003e[skip ci]\u003c/code\u003e\u003c/strong\u003e in the commit message — so pushing the ledger doesn't trigger the workflow again\n(it would self-terminate anyway once the ledger is current, but this avoids the extra run).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003econcurrency:\u003c/code\u003e\u003c/strong\u003e — serialises runs, so two in-flight deploys can't each syndicate before either\ncommits, which would double-post.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003ccode\u003ecolophon init\u003c/code\u003e scaffolds these as commented steps in the Deploy workflow. \u003cem\u003e(Alternative, not yet\nbuilt: keeping the ledger in the asset store like the \u003ccode\u003e_mentions/\u003c/code\u003e pipeline, avoiding commit-back —\nat the cost of versioning. Commit-back is the supported path today.)\u003c/em\u003e\u003c/p\u003e\n\u003ch3 id=\"per-post-controls-frontmatter\"\u003ePer-post controls (frontmatter)\u003c/h3\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eFrontmatter\u003c/th\u003e\n\u003cth\u003eEffect\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003esyndicate: false\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eDon't syndicate this post at all.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003esyndicate: [bsky]\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eOnly these targets (a subset of the env's \u003ccode\u003esyndicate:\u003c/code\u003e list).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cem\u003e(omitted)\u003c/em\u003e\u003c/td\u003e\n\u003ctd\u003eAll of the environment's targets.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003esyndicate_text: \u0026quot;…\u0026quot;\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eA custom blurb for the silo copy (else the driver uses the title).\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003esyndication: [url, …]\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eManually-added \u0026quot;Also posted on…\u0026quot; URLs, shown alongside ledger ones.\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003ch3 id=\"configuration-shape\"\u003eConfiguration shape\u003c/h3\u003e\n\u003cp\u003eSyndicators are configured like sources/publishers — \u003ccode\u003e{id, driver, …settings}\u003c/code\u003e — under the site, and\neach environment lists the ids it may post to:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    federation:\n      syndication:\n        - { id: mastodon, driver: mastodon, instance: https://hachyderm.io, token: \u0026#34;{env:MASTODON_TOKEN}\u0026#34; }\n        - { id: bsky,     driver: bluesky,  handle: me.bsky.social, app_password: \u0026#34;{env:BLUESKY_APP_PASSWORD}\u0026#34; }\nenvironments:\n  - name: production\n    syndicate: [mastodon, bsky]   # preview/draft omit this → never syndicate\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eSecrets (tokens, app passwords) \u003cstrong\u003eonly\u003c/strong\u003e come from the environment via \u003ccode\u003e{env:VAR}\u003c/code\u003e — never written as\nliterals.\u003c/p\u003e\n\u003ch3 id=\"scheduling\"\u003eScheduling\u003c/h3\u003e\n\u003cp\u003e\u003ccode\u003esyndicate\u003c/code\u003e is idempotent (the ledger guards it), so it's safe to run after every publish, or on a\ncron. A typical CI step runs \u003ccode\u003epublish\u003c/code\u003e then \u003ccode\u003esyndicate\u003c/code\u003e for production.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"drivers\"\u003eDrivers\u003c/h2\u003e\n\u003cp\u003eFour drivers, picked by \u003ccode\u003edriver:\u003c/code\u003e. All share the harness above (ledger, gating, \u003ccode\u003e--dry-run\u003c/code\u003e); they\ndiffer only in \u003cstrong\u003ehow the silo post is created\u003c/strong\u003e and \u003cstrong\u003ewhere the auth lives\u003c/strong\u003e.\u003c/p\u003e\n\u003ch3 id=\"command--run-any-program-you-own-the-integration\"\u003e\u003ccode\u003ecommand\u003c/code\u003e — run any program (you own the integration)\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eFor:\u003c/strong\u003e any target without a built-in driver — a silo's CLI, a webhook, an internal system, a\nnotifier. Maximum flexibility; colophon holds no silo credentials.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eHow:\u003c/strong\u003e runs your program once per post. The post is passed as environment variables\n(\u003ccode\u003eCOLOPHON_POST_URL\u003c/code\u003e, \u003ccode\u003e_TITLE\u003c/code\u003e, \u003ccode\u003e_SUMMARY\u003c/code\u003e, \u003ccode\u003e_TEXT\u003c/code\u003e, \u003ccode\u003e_TAGS\u003c/code\u003e, \u003ccode\u003e_KEY\u003c/code\u003e, \u003ccode\u003e_PUBLISHED\u003c/code\u003e) and as JSON on\nstdin. The \u003cstrong\u003efirst line of stdout\u003c/strong\u003e is taken as the silo URL (print nothing for fire-and-forget); a\nnon-zero exit is a failure. Post content is never interpolated into the command, so it can't inject\nshell.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003efederation:\n  syndication:\n    - { id: silo, driver: command, command: \u0026#34;./bin/post-to-silo\u0026#34; }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003e#!/usr/bin/env bash\n# bin/post-to-silo — receives one post via env/stdin, prints the created URL\nset -euo pipefail\ncurl -fsS -X POST https://silo.example/api/posts \\\n  -H \u0026#34;Authorization: Bearer $SILO_TOKEN\u0026#34; \\\n  --data-urlencode \u0026#34;text=${COLOPHON_POST_TITLE} ${COLOPHON_POST_URL}\u0026#34; | jq -r .url\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch3 id=\"mastodon--post-to-a-mastodon-account-you-control\"\u003e\u003ccode\u003emastodon\u003c/code\u003e — post to a Mastodon account you control\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eFor:\u003c/strong\u003e cross-posting to your own Mastodon (any instance). You hold the access token.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eHow:\u003c/strong\u003e \u003ccode\u003ePOST \u0026lt;instance\u0026gt;/api/v1/statuses\u003c/code\u003e with \u003ccode\u003eAuthorization: Bearer \u0026lt;token\u0026gt;\u003c/code\u003e. The status text is\nthe blurb (custom text, else the title) plus the canonical link; Mastodon auto-links it and renders a\npreview card. The status's \u003ccode\u003eurl\u003c/code\u003e is recorded. Text is trimmed to 500 chars but the link is always\npreserved.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003efederation:\n  syndication:\n    - id: mastodon\n      driver: mastodon\n      instance: https://hachyderm.io\n      token: \u0026#34;{env:MASTODON_TOKEN}\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003eSet up:\u003c/strong\u003e on your instance, \u003cem\u003ePreferences → Development → New application\u003c/em\u003e with the \u003ccode\u003ewrite:statuses\u003c/code\u003e\nscope; copy the access token into \u003ccode\u003eMASTODON_TOKEN\u003c/code\u003e.\u003c/p\u003e\n\u003ch3 id=\"bluesky--post-to-a-bluesky-account-you-control\"\u003e\u003ccode\u003ebluesky\u003c/code\u003e — post to a Bluesky account you control\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eFor:\u003c/strong\u003e cross-posting to your own Bluesky. You hold an app password.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eHow:\u003c/strong\u003e AT-proto — \u003ccode\u003ecreateSession\u003c/code\u003e (handle + app password) gets a token, then \u003ccode\u003ecreateRecord\u003c/code\u003e writes\nan \u003ccode\u003eapp.bsky.feed.post\u003c/code\u003e with an \u003cstrong\u003eexternal embed card\u003c/strong\u003e linking back to the canonical post. Returns\nthe \u003ccode\u003ehttps://bsky.app/profile/\u0026lt;handle\u0026gt;/post/\u0026lt;id\u0026gt;\u003c/code\u003e permalink. Text (custom or title) is capped at 300\ncharacters; the link-back is the card.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003efederation:\n  syndication:\n    - id: bsky\n      driver: bluesky\n      handle: me.bsky.social\n      app_password: \u0026#34;{env:BLUESKY_APP_PASSWORD}\u0026#34;\n      # service: https://bsky.social   # optional; default\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003eSet up:\u003c/strong\u003e \u003cem\u003eSettings → Privacy and security → App passwords → Add\u003c/em\u003e (don't use your main password);\ncopy it into \u003ccode\u003eBLUESKY_APP_PASSWORD\u003c/code\u003e.\u003c/p\u003e\n\u003ch3 id=\"bridgy--let-bridgy-post-for-you-no-credentials-in-colophon\"\u003e\u003ccode\u003ebridgy\u003c/code\u003e — let Bridgy post for you (no credentials in colophon)\u003c/h3\u003e\n\u003cp\u003e\u003cstrong\u003eFor:\u003c/strong\u003e cross-posting \u003cem\u003ewithout\u003c/em\u003e colophon holding any silo tokens — \u003ca href=\"https://brid.gy\"\u003eBridgy\u003c/a\u003e holds\nyour account auth. Good when you'd rather not manage credentials, or you syndicate to several\nnetworks through one mechanism.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eHow:\u003c/strong\u003e colophon sends Bridgy a \u003cem\u003epublish webmention\u003c/em\u003e — \u003ccode\u003esource\u003c/code\u003e = your post, \u003ccode\u003etarget\u003c/code\u003e =\n\u003ccode\u003ehttps://brid.gy/publish/\u0026lt;network\u0026gt;\u003c/code\u003e. Bridgy fetches your post's microformats2 (which colophon emits)\nand creates the silo post on your behalf, returning its URL. The post must be \u003cstrong\u003elive\u003c/strong\u003e (Bridgy\nfetches it), and you must have \u003cstrong\u003econnected the account at brid.gy first\u003c/strong\u003e.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003efederation:\n  syndication:\n    - { id: mast-via-bridgy, driver: bridgy, network: mastodon }\n    - { id: bsky-via-bridgy, driver: bridgy, network: bluesky }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003eSet up:\u003c/strong\u003e connect your silo account(s) at \u003ca href=\"https://brid.gy\"\u003ehttps://brid.gy\u003c/a\u003e and follow its instructions for your\ndomain. No tokens go in colophon.\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ccode\u003ebridgy\u003c/code\u003e driver vs Bridgy Fed:\u003c/strong\u003e different things. This driver is \u003cem\u003esyndication\u003c/em\u003e (POSSE copies to\naccounts you have). \u003ca href=\"/guides/bridgy-fed/\"\u003eBridgy \u003cstrong\u003eFed\u003c/strong\u003e\u003c/a\u003e makes your \u003cem\u003esite itself\u003c/em\u003e followable from\nthe fediverse (no silo account) — that's federation, configured under \u003ccode\u003ewebmention\u003c/code\u003e, not here.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch2 id=\"choosing-a-driver\"\u003eChoosing a driver\u003c/h2\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eYou want…\u003c/th\u003e\n\u003cth\u003eUse\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003eDirect control, own the token, one account\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003emastodon\u003c/code\u003e / \u003ccode\u003ebluesky\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eNo credentials in colophon; already use Bridgy; several networks\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003ebridgy\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eA silo with no built-in driver, a webhook, or custom logic\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003ecommand\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eYou can mix them — list several syndicators and put their ids in the env's \u003ccode\u003esyndicate:\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"see-also\"\u003eSee also\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eQuick recipes: \u003ca href=\"/guides/syndicate-mastodon/\"\u003eMastodon\u003c/a\u003e · \u003ca href=\"/guides/syndicate-bluesky/\"\u003eBluesky\u003c/a\u003e ·\n\u003ca href=\"/guides/syndicate-command/\"\u003ecommand\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/guides/webmentions/\"\u003eWebmentions\u003c/a\u003e — replies/likes back on your posts (the receive side).\u003c/li\u003e\n\u003cli\u003eDesign rationale: \u003ca href=\"/internals/federation/\"\u003edesign/federation.md\u003c/a\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/syndication.md\"\u003e\u003ccode\u003edocs/syndication.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-23T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/analytics/",
      "url": "https://docs.colophon.blog/guides/analytics/",
      "title": "Analytics \u0026 telemetry",
      "summary": "colophon has two separate, independent privacy-respecting surfaces. telemetry is the app; analytics is the site. Neither switch affects the other.",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/analytics.md — do not edit by hand. --\u003e\n\u003cp\u003ecolophon has two \u003cstrong\u003eseparate, independent\u003c/strong\u003e privacy-respecting surfaces. \u003ccode\u003etelemetry\u003c/code\u003e is the\n\u003cstrong\u003eapp\u003c/strong\u003e; \u003ccode\u003eanalytics\u003c/code\u003e is the \u003cstrong\u003esite\u003c/strong\u003e. Neither switch affects the other.\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003e\u003c/th\u003e\n\u003cth\u003e\u003cstrong\u003eSite analytics\u003c/strong\u003e\u003c/th\u003e\n\u003cth\u003e\u003cstrong\u003eApp telemetry\u003c/strong\u003e\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003eAnswers\u003c/td\u003e\n\u003ctd\u003e\u0026quot;how is \u003cem\u003emy blog\u003c/em\u003e doing?\u0026quot;\u003c/td\u003e\n\u003ctd\u003e\u0026quot;how is \u003cem\u003ecolophon\u003c/em\u003e used?\u0026quot;\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eOwner\u003c/td\u003e\n\u003ctd\u003ethe \u003cstrong\u003esite owner\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003ethe colophon \u003cstrong\u003emaintainer\u003c/strong\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eSurface\u003c/td\u003e\n\u003ctd\u003ea web beacon in deployed pages\u003c/td\u003e\n\u003ctd\u003ethe binary reporting its own runs\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eDestination\u003c/td\u003e\n\u003ctd\u003ethe site owner's statsfactory\u003c/td\u003e\n\u003ctd\u003ethe maintainer's (release-baked) statsfactory\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eConfig\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003esites[].analytics\u003c/code\u003e (per site)\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etelemetry\u003c/code\u003e (top level)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eSwitch\u003c/td\u003e\n\u003ctd\u003eeach provider's own config\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etelemetry.enabled\u003c/code\u003e (this only)\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eBoth are off unless configured.\u003c/p\u003e\n\u003ch2 id=\"site-analytics-reader-beacon\"\u003eSite analytics (reader beacon)\u003c/h2\u003e\n\u003cp\u003ePer-site, one block per provider — your data, your instance:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    analytics:\n      statsfactory:                       # cookieless, DNT-respecting\n        server_url: \u0026#34;{env:STATSFACTORY_SERVER_URL:-}\u0026#34;\n        app_key: \u0026#34;{env:STATSFACTORY_APP_KEY:-}\u0026#34;\n      google_analytics:                   # GA4 — sets cookies, brings its own consent duties\n        measurement_id: \u0026#34;{env:GA_MEASUREMENT_ID:-}\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eEach provider is independent and inert until configured. The \u003cstrong\u003estatsfactory\u003c/strong\u003e beacon is a\n~2 KB dependency-free \u003ccode\u003eanalytics-sf.js\u003c/code\u003e written once to the site root and referenced by every\npage. It:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003esends \u003ccode\u003epage_view\u003c/code\u003e on load and \u003ccode\u003epage_engagement\u003c/code\u003e (active milliseconds, as the metric value)\non hide/unload;\u003c/li\u003e\n\u003cli\u003eis \u003cstrong\u003ecookieless\u003c/strong\u003e (session id in \u003ccode\u003esessionStorage\u003c/code\u003e, per tab);\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ehonours Do-Not-Track / Global Privacy Control\u003c/strong\u003e — sends nothing when either is set.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eIts public per-page dimensions are \u003ccode\u003epost.slug\u003c/code\u003e, \u003ccode\u003epost.type\u003c/code\u003e, \u003ccode\u003epost.author\u003c/code\u003e, \u003ccode\u003epost.tags\u003c/code\u003e, plus\n\u003ccode\u003epage.path\u003c/code\u003e and \u003ccode\u003ereferrer\u003c/code\u003e. The statsfactory ingest key is a \u003cstrong\u003epublic \u003ccode\u003esf_live_\u003c/code\u003e key\u003c/strong\u003e, safe to\nembed in pages. The \u003cstrong\u003ehidden persona is never sent to the beacon\u003c/strong\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eGoogle Analytics\u003c/strong\u003e (GA4) ships its own loader asset, \u003ccode\u003eanalytics-ga.js\u003c/code\u003e, which injects\nGoogle's \u003ccode\u003egtag.js\u003c/code\u003e. Each provider's asset is written to the site root \u003cstrong\u003eonly when that provider\nis enabled\u003c/strong\u003e — \u003ccode\u003eanalytics-sf.js\u003c/code\u003e for statsfactory, \u003ccode\u003eanalytics-ga.js\u003c/code\u003e for GA, both if both,\nnothing if neither.\u003c/p\u003e\n\u003cp\u003eEvery built-in and contrib theme includes the beacon by rendering \u003ccode\u003e{{ analytics_head|safe }}\u003c/code\u003e\nbefore \u003ccode\u003e\u0026lt;/body\u0026gt;\u003c/code\u003e; a JS-enabled custom theme should too (see \u003ca href=\"/start/themes/#analytics\"\u003ethemes\u003c/a\u003e).\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003eGoogle Analytics sets cookies and carries consent obligations the cookieless beacon does\nnot — enable it only if that fits your privacy posture.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch3 id=\"injecting-site-credentials\"\u003eInjecting site credentials\u003c/h3\u003e\n\u003cp\u003eValues usually come from \u003ccode\u003e{env:VAR}\u003c/code\u003e placeholders. colophon loads two dot-env files from the\nproject root before interpolation and \u003cstrong\u003enever overrides a variable already set in the real\nenvironment\u003c/strong\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ereal environment (e.g. CI secrets)  \u0026gt;  .env (local, gitignored)  \u0026gt;  .env.defaults (committed)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eSo: commit your statsfactory endpoint + public key in \u003ccode\u003e.env.defaults\u003c/code\u003e, override per-machine in\na local \u003ccode\u003e.env\u003c/code\u003e, and override in CI via repository Variables/Secrets.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eGitHub Actions\u003c/strong\u003e — \u003ccode\u003ecolophon init\u003c/code\u003e scaffolds \u003ccode\u003e.github/workflows/deploy.yml\u003c/code\u003e. Set under\n\u003cem\u003eSettings → Secrets and variables → Actions\u003c/em\u003e:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eVariables\u003c/strong\u003e (public): \u003ccode\u003eSTATSFACTORY_SERVER_URL\u003c/code\u003e, \u003ccode\u003eSTATSFACTORY_APP_KEY\u003c/code\u003e — the ingest key is\npublic, so a Variable (not a Secret) is right.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSecrets\u003c/strong\u003e (private): deploy credentials — \u003ccode\u003eCLOUDFLARE_API_TOKEN\u003c/code\u003e, \u003ccode\u003eCLOUDFLARE_ACCOUNT_ID\u003c/code\u003e,\n\u003ccode\u003eR2_ACCESS_KEY_ID\u003c/code\u003e, \u003ccode\u003eR2_SECRET_ACCESS_KEY\u003c/code\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"app-telemetry-colophons-own-usage\"\u003eApp telemetry (colophon's own usage)\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003ecolophon build\u003c/code\u003e and \u003ccode\u003ecolophon publish\u003c/code\u003e report colophon's \u003cem\u003eown\u003c/em\u003e operation — never your content\n— to the maintainer, so usage is understood. It is anonymous (a \u003ccode\u003edistinct_id\u003c/code\u003e that is a SHA-256\nhash cached at \u003ccode\u003e.colophon/telemetry.id\u003c/code\u003e; the raw value is never stored or sent), and\nfire-and-forget — it never blocks or fails a command, and \u003ccode\u003ecolophon serve\u003c/code\u003e previews emit\nnothing.\u003c/p\u003e\n\u003cp\u003eCredentials default to values \u003cstrong\u003ebaked into the binary at release\u003c/strong\u003e, so a released colophon\nreports by default (opt-out); a source/dev build has no baked creds and reports nothing. To\nbuild a release with telemetry:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ego build -ldflags \u0026#34;\\\n  -X github.com/jmylchreest/colophon/internal/telemetry.DefaultServerURL=https://stats.example.com \\\n  -X github.com/jmylchreest/colophon/internal/telemetry.DefaultAppKey=sf_live_xxxxxxxx\u0026#34; \\\n  ./cmd/colophon\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003ecolophon's release workflow (\u003ccode\u003e.github/workflows/build-release.yml\u003c/code\u003e) bakes these from the\nrepository's \u003ccode\u003eCOLOPHON_TELEMETRY_*\u003c/code\u003e secrets/variables, alongside the version (from the git tag),\nso tagged binaries are versioned and report by default. A project may override the destination\n(e.g. to self-host the maintainer role) under \u003ccode\u003etelemetry.statsfactory\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"event-model\"\u003eEvent model\u003c/h2\u003e\n\u003cp\u003estatsfactory dimensions are arbitrary and defined at ingest time, so these compose into pivot\nand breakdown views.\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eSurface\u003c/th\u003e\n\u003cth\u003eEvent\u003c/th\u003e\n\u003cth\u003eValue\u003c/th\u003e\n\u003cth\u003eKey dimensions\u003c/th\u003e\n\u003cth\u003eAnswers\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003eSite\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003epage_view\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e—\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003epost.slug\u003c/code\u003e, \u003ccode\u003epost.type\u003c/code\u003e, \u003ccode\u003epost.tags\u003c/code\u003e, \u003ccode\u003epost.author\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003emost popular posts\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eSite\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003epage_engagement\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eactive ms\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003epost.slug\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eengagement time per post\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eApp\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003ebuild\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003epage count\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etheme\u003c/code\u003e, \u003ccode\u003eenv\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ebuilds over time\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eApp\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003esource_indexed\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003edoc count\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003esource.type\u003c/code\u003e, \u003ccode\u003esource.id\u003c/code\u003e, \u003ccode\u003eenv\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003edocument count × source type\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eApp\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003epublish\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003euploaded\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003epublisher.type\u003c/code\u003e, \u003ccode\u003epublisher.id\u003c/code\u003e, \u003ccode\u003estatus\u003c/code\u003e, \u003ccode\u003eenv\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003epublished docs/executions × publisher type\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003ch2 id=\"opting-out--summary\"\u003eOpting out — summary\u003c/h2\u003e\n\u003cp\u003eApp telemetry and site analytics are independent — each is disabled on its own:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eApp telemetry:\u003c/strong\u003e \u003ccode\u003etelemetry.enabled: false\u003c/code\u003e, \u003ccode\u003eCOLOPHON_TELEMETRY=off\u003c/code\u003e, or\n\u003ccode\u003etelemetry.statsfactory.enabled: false\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eA site analytics provider:\u003c/strong\u003e omit it, or set its \u003ccode\u003eenabled: false\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eReaders\u003c/strong\u003e opt out of the beacon automatically via Do-Not-Track / Global Privacy Control.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/analytics.md\"\u003e\u003ccode\u003edocs/analytics.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-22T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/skills/",
      "url": "https://docs.colophon.blog/guides/skills/",
      "title": "Agent skills \u0026 prompt packs (design)",
      "summary": "colophon is the context provider; the agent (LLM) does the writing. A skill is a small, deterministic wrapper that:",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/skills.md — do not edit by hand. --\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003eStatus: design.\u003c/strong\u003e This describes the planned authoring skills and the prompts colophon\nwould furnish them with. The \u003cem\u003econtracts\u003c/em\u003e they target — the frontmatter schema\n(\u003ca href=\"/guides/seo/\"\u003eSEO\u003c/a\u003e, tags, persona) and the \u003ccode\u003emarkdown.Document\u003c/code\u003e round-trip — already exist.\nThe skills themselves are not built yet.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch2 id=\"the-model\"\u003eThe model\u003c/h2\u003e\n\u003cp\u003ecolophon is the \u003cstrong\u003econtext provider\u003c/strong\u003e; the agent (LLM) does the writing. A skill is a small,\ndeterministic wrapper that:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003egathers \u003cstrong\u003econtext\u003c/strong\u003e — the article body, the persona's style guide + retrieved corpus\nexemplars, and the site's facts (title, base_url, existing tags),\u003c/li\u003e\n\u003cli\u003efurnishes a \u003cstrong\u003eprompt pack\u003c/strong\u003e — a system prompt with a best-practice rubric and the output\nschema,\u003c/li\u003e\n\u003cli\u003eforces \u003cstrong\u003estructured output\u003c/strong\u003e — the model returns a validated object (a frontmatter block\nor body fragment), never free text,\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003emerges\u003c/strong\u003e it back via \u003ccode\u003emarkdown.Document\u003c/code\u003e so the body is byte-preserved and only the\nintended fields change.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eThe frontmatter schema is the contract: because every field has exactly one rendering\neffect, the model can see the consequence of everything it writes, and nothing it can't set\naffects the output.\u003c/p\u003e\n\u003ch2 id=\"shared-infrastructure\"\u003eShared infrastructure\u003c/h2\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003ePiece\u003c/th\u003e\n\u003cth\u003eRole\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003ePersona context\u003c/strong\u003e (\u003ccode\u003ecolophon persona context\u003c/code\u003e)\u003c/td\u003e\n\u003ctd\u003eEmits the persona's style guide + top-K corpus exemplars (BM25/embedding retrieval). Every writing skill prepends it so output matches the author's voice.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eStructured output\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eEach skill defines a JSON Schema; the runtime validates and re-prompts on mismatch.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003e\u003ccode\u003emarkdown.Document\u003c/code\u003e\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eParse → patch frontmatter/body → re-marshal, preserving the body and unrelated fields.\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eSurface\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eA CLI verb (\u003ccode\u003ecolophon \u0026lt;skill\u0026gt; \u0026lt;file\u0026gt;\u003c/code\u003e) and the matching MCP tool, sharing one implementation.\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003ch2 id=\"skill-catalogue\"\u003eSkill catalogue\u003c/h2\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eSkill\u003c/th\u003e\n\u003cth\u003eProduces\u003c/th\u003e\n\u003cth\u003eConsumes\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eseo\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003ethe \u003ccode\u003eseo:\u003c/code\u003e block\u003c/td\u003e\n\u003ctd\u003ebody + tags + persona\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003edraft\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003ebody from a brief/outline\u003c/td\u003e\n\u003ctd\u003ebrief + persona context\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eoutline\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003ea heading skeleton\u003c/td\u003e\n\u003ctd\u003etopic + persona\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eexpand\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003efills a section\u003c/td\u003e\n\u003ctd\u003esurrounding body + persona\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eretitle\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etitle\u003c/code\u003e + \u003ccode\u003eslug\u003c/code\u003e candidates\u003c/td\u003e\n\u003ctd\u003ebody\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003etag\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etags\u003c/code\u003e suggestions\u003c/td\u003e\n\u003ctd\u003ebody + the site's existing tag vocabulary\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003esocial\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eseo.social\u003c/code\u003e + syndication copy\u003c/td\u003e\n\u003ctd\u003ebody + target network\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003ealt-text\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e![alt]\u003c/code\u003e for images/embeds\u003c/td\u003e\n\u003ctd\u003ethe image + nearby text\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003esummary\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003edescription\u003c/code\u003e / TL;DR\u003c/td\u003e\n\u003ctd\u003ebody\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eAll are \u003cstrong\u003esuggest-by-default\u003c/strong\u003e: they write a block you review, never silently overwrite\neditorial fields. \u003ccode\u003e--apply\u003c/code\u003e patches in place.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"prompt-pack-seo\"\u003ePrompt pack: \u003ccode\u003eseo\u003c/code\u003e\u003c/h2\u003e\n\u003cp\u003eThe flagship, since its contract (\u003ca href=\"/guides/seo/\"\u003eseo.md\u003c/a\u003e) and templating now exist.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eInputs furnished\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003ethe rendered article text (HTML stripped),\u003c/li\u003e\n\u003cli\u003ethe resolved page facts: site title, \u003ccode\u003ebase_url\u003c/code\u003e + slug (→ canonical), date, existing tags,\u003c/li\u003e\n\u003cli\u003epersona style guide (so the title/description sound like the author, not generic SEO mush).\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eOutput schema\u003c/strong\u003e — the \u003ccode\u003eseo:\u003c/code\u003e object (\u003ccode\u003etitle\u003c/code\u003e, \u003ccode\u003edescription\u003c/code\u003e, \u003ccode\u003ekeywords\u003c/code\u003e, \u003ccode\u003ecanonical\u003c/code\u003e,\n\u003ccode\u003enoindex\u003c/code\u003e, \u003ccode\u003eimage\u003c/code\u003e, \u003ccode\u003etype\u003c/code\u003e, \u003ccode\u003esocial{title,description}\u003c/code\u003e). Forced via structured output.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eSystem prompt (sketch)\u003c/strong\u003e\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eYou write SEO metadata for a blog post, in the author\u0026#39;s voice (style guide below).\nReturn ONLY the seo object. Follow these rules:\n\n- title: ≤60 characters. Front-load the primary keyword. Match the post\u0026#39;s actual content\n  and search intent. Voice = the author\u0026#39;s, not clickbait.\n- description: 140–160 characters. A genuine summary that earns the click; no teasing,\n  no \u0026#34;in this post\u0026#34;. Unique to this page.\n- keywords: 4–8 focus terms a reader would actually search; no stuffing.\n- social.title / social.description: only if a punchier share-optimised version helps;\n  otherwise omit and the search copy is reused.\n- canonical / noindex / image / type: set only when you have a specific reason; otherwise\n  omit and colophon\u0026#39;s defaults apply.\n\nNever invent facts not in the article. Prefer the author\u0026#39;s existing tags as keyword seeds.\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003eInputs block (sketch)\u003c/strong\u003e\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e## Author style guide\n{{ persona.style.guide }}\n\n## Site\ntitle: {{ site.title }}   url: {{ canonical }}   existing tags: {{ all_tags }}\n\n## Article\n{{ body_text }}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThe model returns e.g.:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003eseo:\n  title: \u0026#34;Rendering math, diagrams and code from one Markdown file\u0026#34;\n  description: \u0026#34;How colophon turns a single note into a page with KaTeX, Mermaid and\n    highlighted code — and degrades to readable text without JavaScript.\u0026#34;\n  keywords: [static site generator, markdown, katex, mermaid, progressive enhancement]\n  social:\n    title: \u0026#34;One Markdown file → math, diagrams, code\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003ecolophon seo --apply post.md\u003c/code\u003e merges that under \u003ccode\u003eseo:\u003c/code\u003e, body untouched; the next build\nrenders the canonical/OG/Twitter/JSON-LD from it.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"prompt-pack-draft--outline--expand\"\u003ePrompt pack: \u003ccode\u003edraft\u003c/code\u003e / \u003ccode\u003eoutline\u003c/code\u003e / \u003ccode\u003eexpand\u003c/code\u003e\u003c/h2\u003e\n\u003cp\u003eThe writing skills. Each prepends \u003cstrong\u003epersona context\u003c/strong\u003e so output is in-voice, and takes a\nbrief or the surrounding body.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ccode\u003eoutline\u003c/code\u003e\u003c/strong\u003e — input: a topic + angle. Output: a heading tree (\u003ccode\u003e##\u003c/code\u003e/\u003ccode\u003e###\u003c/code\u003e) with one-line\nintents per section. Rubric: match the persona's typical structure; no body prose yet.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ccode\u003edraft\u003c/code\u003e\u003c/strong\u003e — input: an outline (or brief) + persona context. Output: the markdown body.\nRubric: the author's voice and formatting conventions (callouts, code fences, length);\ncite only what's in the references; leave \u003ccode\u003e[[wikilink]]\u003c/code\u003e placeholders for cross-links rather\nthan inventing URLs.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ccode\u003eexpand\u003c/code\u003e\u003c/strong\u003e — input: a section heading + the surrounding body. Output: that section's prose\nonly. Rubric: continuity with the existing voice and tense; no repetition of nearby points.\u003c/p\u003e\n\u003chr\u003e\n\u003ch2 id=\"prompt-pack-tag-social-alt-text-summary\"\u003ePrompt pack: \u003ccode\u003etag\u003c/code\u003e, \u003ccode\u003esocial\u003c/code\u003e, \u003ccode\u003ealt-text\u003c/code\u003e, \u003ccode\u003esummary\u003c/code\u003e\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003etag\u003c/strong\u003e — input: body + the \u003cstrong\u003esite's existing tag vocabulary\u003c/strong\u003e. Output: 3–6 tags, \u003cem\u003ereusing\nexisting tags where they fit\u003c/em\u003e (avoid near-duplicate taxonomy), only proposing new ones when\nwarranted. This keeps tag pages (\u003ca href=\"/start/content/\"\u003econtent.md\u003c/a\u003e) coherent.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003esocial\u003c/strong\u003e — input: body + target (Mastodon/Bluesky/X/LinkedIn). Output: \u003ccode\u003eseo.social\u003c/code\u003e plus\na per-network post for \u003ccode\u003esyndicate\u003c/code\u003e. Rubric: each network's norms (length, hashtags, link\nplacement) and the author's voice.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ealt-text\u003c/strong\u003e — input: an image + the paragraph around it. Output: concise, descriptive alt\ntext (not \u0026quot;image of\u0026quot;), for accessibility and image SEO.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003esummary\u003c/strong\u003e — input: body. Output: a \u003ccode\u003edescription\u003c/code\u003e (and optionally a longer TL;DR callout).\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003ch2 id=\"why-this-shape\"\u003eWhy this shape\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eOne contract, many skills.\u003c/strong\u003e Every skill writes into the same typed frontmatter the\ntemplates already render, so adding a skill never needs a templating change.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eVoice-preserving.\u003c/strong\u003e Persona context is the common prefix, so SEO copy, drafts and social\nposts all sound like the same author.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eReviewable \u0026amp; reversible.\u003c/strong\u003e Structured output + \u003ccode\u003emarkdown.Document\u003c/code\u003e round-trip means a\nskill patches exactly the fields it owns and nothing else; suggest-by-default keeps a human\nin the loop for editorial fields.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/skills.md\"\u003e\u003ccode\u003edocs/skills.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-21T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/howto/",
      "url": "https://docs.colophon.blog/guides/howto/",
      "title": "How-to guides",
      "summary": "Short, zero-to-published recipes. The design behind them is in internals/federation and internals/webmention.",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/howto/README.md — do not edit by hand. --\u003e\n\u003cp\u003eShort, zero-to-published recipes. The design behind them is in\n\u003ca href=\"/internals/federation/\"\u003e../design/federation.md\u003c/a\u003e and \u003ca href=\"/internals/webmention/\"\u003e../design/webmention.md\u003c/a\u003e.\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eGuide\u003c/th\u003e\n\u003cth\u003eStatus\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ca href=\"/guides/bridgy-fed/\"\u003eFederate via Bridgy Fed\u003c/a\u003e — be followable from Mastodon/Bluesky\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eworks today\u003c/strong\u003e (uses the mf2 + feeds colophon already emits)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ca href=\"/guides/webmentions/\"\u003eShow webmentions\u003c/a\u003e — replies/likes on your posts\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eshipped\u003c/strong\u003e (\u003ccode\u003ecolophon webmention fetch/publish\u003c/code\u003e + display modes)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ca href=\"/guides/syndicate-command/\"\u003eSyndicate with a command\u003c/a\u003e — POSSE to any target\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eshipped\u003c/strong\u003e (the \u003ccode\u003ecommand\u003c/code\u003e driver)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ca href=\"/guides/syndicate-mastodon/\"\u003eSyndicate to Mastodon\u003c/a\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eshipped\u003c/strong\u003e (native \u003ccode\u003emastodon\u003c/code\u003e driver)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ca href=\"/guides/syndicate-bluesky/\"\u003eSyndicate to Bluesky\u003c/a\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eshipped\u003c/strong\u003e (native \u003ccode\u003ebluesky\u003c/code\u003e driver)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eSyndicate via Bridgy — \u003ca href=\"/guides/syndication/#bridgy--let-bridgy-post-for-you-no-credentials-in-colophon\"\u003eno-credentials POSSE\u003c/a\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eshipped\u003c/strong\u003e (\u003ccode\u003ebridgy\u003c/code\u003e driver)\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cblockquote\u003e\n\u003cp\u003eSyndication ships in full: the harness (ledger, env/per-post gating, \u003ccode\u003e--dry-run\u003c/code\u003e) plus the\n\u003ccode\u003ecommand\u003c/code\u003e, \u003ccode\u003emastodon\u003c/code\u003e, \u003ccode\u003ebluesky\u003c/code\u003e, and \u003ccode\u003ebridgy\u003c/code\u003e drivers. The complete reference (every driver, how\neach works, when to use which) is \u003ca href=\"/guides/syndication/\"\u003e../syndication.md\u003c/a\u003e.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/howto/README.md\"\u003e\u003ccode\u003edocs/howto/README.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-20T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/bridgy-fed/",
      "url": "https://docs.colophon.blog/guides/bridgy-fed/",
      "title": "How to federate via Bridgy Fed",
      "summary": "Bridgy Fed makes your site itself followable from Mastodon and Bluesky: people follow @yourdomain, your posts federate, and replies come back as webmentions — without you…",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/howto/bridgy-fed.md — do not edit by hand. --\u003e\n\u003cblockquote\u003e\n\u003cp\u003eStatus: \u003cstrong\u003eworks today.\u003c/strong\u003e Bridgy Fed needs only microformats2 + a feed (and \u003ccode\u003erel=me\u003c/code\u003e), all of\nwhich colophon already emits — no colophon code beyond what's shipped.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003e\u003ca href=\"https://fed.brid.gy\"\u003eBridgy Fed\u003c/a\u003e makes your \u003cem\u003esite itself\u003c/em\u003e followable from Mastodon and Bluesky:\npeople follow \u003ccode\u003e@yourdomain\u003c/code\u003e, your posts federate, and replies come back as webmentions — without\nyou running an ActivityPub server or even having a Mastodon/Bluesky account.\u003c/p\u003e\n\u003ch2 id=\"steps\"\u003eSteps\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003ePublish your site\u003c/strong\u003e with colophon as usual. It already emits:\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003eh-entry\u003c/code\u003e/\u003ccode\u003eh-card\u003c/code\u003e/\u003ccode\u003eh-feed\u003c/code\u003e microformats2, an RSS/Atom/JSON feed, and \u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e identity\nlinks. An author's \u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e (all of their \u003ccode\u003eurls:\u003c/code\u003e) is emitted in the \u003ccode\u003e\u0026lt;head\u0026gt;\u003c/code\u003e of \u003cstrong\u003etheir\nposts and their author feed page\u003c/strong\u003e (\u003ccode\u003e/authors/\u0026lt;id\u0026gt;/\u003c/code\u003e) — your IndieWeb identity URL is that\nauthor page, not the bare domain (the home page lists every author, so it carries no single\nidentity).\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAdd a webmention endpoint pointing at Bridgy Fed\u003c/strong\u003e so it can receive interactions for you:\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003efederation:\n  indieweb:\n    webmention:\n      receiver: https://fed.brid.gy/webmention   # emitted as \u0026lt;link rel=\u0026#34;webmention\u0026#34;\u0026gt; on every page\n\u003c/code\u003e\u003c/pre\u003e\ncolophon emits the \u003ccode\u003e\u0026lt;link rel=\u0026quot;webmention\u0026quot;\u0026gt;\u003c/code\u003e discovery tag site-wide when \u003ccode\u003ereceiver\u003c/code\u003e is set — no\nmanual theme edit needed.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eEnrol\u003c/strong\u003e at \u003ca href=\"https://fed.brid.gy\"\u003ehttps://fed.brid.gy\u003c/a\u003e and follow its current instructions for your domain (it\nverifies your site, then your handle becomes \u003ccode\u003e@yourdomain@yourdomain\u003c/code\u003e). Bridgy Fed's onboarding\nchanges over time, so use its docs as the source of truth: \u003ca href=\"https://fed.brid.gy/docs\"\u003ehttps://fed.brid.gy/docs\u003c/a\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eDone.\u003c/strong\u003e Fediverse/Bluesky users can follow you; new posts federate from your feed; replies\narrive at the webmention endpoint (see \u003ca href=\"/guides/webmentions/\"\u003eShow webmentions\u003c/a\u003e to display them).\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"notes\"\u003eNotes\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eThis is \u003cstrong\u003efederation\u003c/strong\u003e, not syndication: there's no separate silo account — your site \u003cem\u003eis\u003c/em\u003e the\naccount. For posting copies \u003cem\u003eto\u003c/em\u003e your own Mastodon/Bluesky accounts instead, see the syndication\nguides.\u003c/li\u003e\n\u003cli\u003eForward-only is fine: you can be followable without displaying replies; add webmention display\nwhen you want the conversation on your page.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/howto/bridgy-fed.md\"\u003e\u003ccode\u003edocs/howto/bridgy-fed.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-19T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/webmentions/",
      "url": "https://docs.colophon.blog/guides/webmentions/",
      "title": "How to show webmentions (replies, likes, reposts)",
      "summary": "Webmentions let other sites' replies/likes/reposts appear under your posts — \"comments without a database.\" A static site can't receive POSTs, so a hosted receiver…",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/howto/webmentions.md — do not edit by hand. --\u003e\n\u003cblockquote\u003e\n\u003cp\u003eStatus: \u003cstrong\u003eshipped.\u003c/strong\u003e The full flow works: the \u003ccode\u003e\u0026lt;link rel=\u0026quot;webmention\u0026quot;\u0026gt;\u003c/code\u003e tag, \u003ccode\u003erel=me\u003c/code\u003e,\nmicroformats2, \u003ccode\u003ewebmention send\u003c/code\u003e, and the receive/display layer — \u003ccode\u003ewebmention fetch\u003c/code\u003e/\u003ccode\u003epublish\u003c/code\u003e, the\n\u003ccode\u003edisplay.mode\u003c/code\u003e (live/asset/disabled), the themed responses block, and a committed glob blocklist\nwith the \u003ccode\u003ecolophon-moderate-mentions\u003c/code\u003e skill. See \u003ca href=\"/internals/webmention/\"\u003e../design/webmention.md\u003c/a\u003e.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eWebmentions let other sites' replies/likes/reposts appear under your posts — \u0026quot;comments without a\ndatabase.\u0026quot; A static site can't receive POSTs, so a hosted receiver (\u003ca href=\"https://webmention.io\"\u003ewebmention.io\u003c/a\u003e)\ncollects them and colophon pulls them in at build/refresh time.\u003c/p\u003e\n\u003ch2 id=\"steps\"\u003eSteps\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eSign in to \u003ca href=\"https://webmention.io\"\u003ewebmention.io\u003c/a\u003e\u003c/strong\u003e via IndieAuth, which needs \u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e on the\n\u003cstrong\u003eexact URL you sign in with\u003c/strong\u003e, linking \u003cem\u003ebidirectionally\u003c/em\u003e to a provider it can authenticate\n(GitHub is the easy path). colophon emits an author's \u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e (all of their \u003ccode\u003eurls:\u003c/code\u003e) in the\n\u003ccode\u003e\u0026lt;head\u0026gt;\u003c/code\u003e of \u003cstrong\u003etheir posts and their author feed page\u003c/strong\u003e, so:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eSign in with your \u003cstrong\u003eauthor feed page\u003c/strong\u003e — \u003ccode\u003ehttps://example.com/authors/\u0026lt;your-id\u0026gt;/\u003c/code\u003e — not the bare\ndomain (the home page lists all authors, so it has no \u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003eMake the link \u003cstrong\u003ebidirectional\u003c/strong\u003e: set your GitHub profile's \u003cem\u003ewebsite\u003c/em\u003e field to that \u003cstrong\u003esame\u003c/strong\u003e\nauthor-page URL (colophon already emits \u003ccode\u003erel=\u0026quot;me\u0026quot;\u003c/code\u003e → your GitHub from your \u003ccode\u003eurls:\u003c/code\u003e).\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003ewebmention.io then gives you:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003ea receiver endpoint: \u003ccode\u003ehttps://webmention.io/yourdomain/webmention\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003ean \u003cstrong\u003eAPI token\u003c/strong\u003e (for reading your mentions back).\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eConfigure it\u003c/strong\u003e (token via env, never in config):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003efederation:\n  indieweb:\n    webmention:\n      receiver: https://webmention.io/yourdomain/webmention  # emitted as \u0026lt;link rel=\u0026#34;webmention\u0026#34;\u0026gt; (shipped)\n      driver: jf2                                            # reader driver (read API); planned\n      display:\n        mode: asset                                          # live | asset | disabled (planned)\n# export WEBMENTION_IO_TOKEN=...   (CI secret)\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eBuild\u003c/strong\u003e — colophon emits \u003ccode\u003e\u0026lt;link rel=\u0026quot;webmention\u0026quot;\u0026gt;\u003c/code\u003e site-wide today; the per-post responses block\n(and \u003ccode\u003efetch\u003c/code\u003e/\u003ccode\u003epublish\u003c/code\u003e below) are the planned display layer.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003ePull mentions in:\u003c/strong\u003e\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon webmention fetch        # writes _mentions/\u0026lt;post\u0026gt;.json (the display data)\ncolophon webmention publish      # pushes only _mentions/ to your asset host (R2), on its own schedule\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eJS-rendered themes fetch that asset live, so a scheduled \u003ccode\u003ewebmention publish\u003c/code\u003e keeps responses\nfresh \u003cstrong\u003ewithout rebuilding the site\u003c/strong\u003e. (No-JS/text themes show them as of the last build.)\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eSend webmentions\u003c/strong\u003e when \u003cem\u003eyou\u003c/em\u003e link to others, so you show up in their comments \u003cem\u003e(shipped)\u003c/em\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon webmention send --env production   # run AFTER publish; the source URLs must be live\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eIt scans the built output's outbound links (per page's canonical URL), discovers each target's\nendpoint, and POSTs. A sent-cache (\u003ccode\u003e.colophon/cache/webmention-sent.json\u003c/code\u003e) makes re-runs send only\nnew links and re-ping removed ones. \u003ccode\u003e--dry-run\u003c/code\u003e reports without sending.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e(Optional) Social replies via Bridgy\u003c/strong\u003e — connect your silo accounts at \u003ca href=\"https://brid.gy\"\u003ehttps://brid.gy\u003c/a\u003e; it\nbackfeeds replies/likes from Mastodon/Bluesky to your webmention.io endpoint, so they appear the\nsame way. No extra colophon config.\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"notes\"\u003eNotes\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eSelf-hosting: webmention.io is open source, or use a JF2-compatible receiver — point \u003ccode\u003esource:\u003c/code\u003e at\nits API (\u003ccode\u003edriver: jf2\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003ePrivacy/spam: drop spam with a committed glob blocklist at \u003ccode\u003e.colophon/webmention-block.yml\u003c/code\u003e\n(by domain/url/author/content/type), applied at \u003ccode\u003efetch\u003c/code\u003e and shipped to the browser in \u003ccode\u003elive\u003c/code\u003e mode.\nThe \u003ccode\u003ecolophon-moderate-mentions\u003c/code\u003e skill helps distill spam into small rules. Treat displayed\nthird-party content accordingly.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/howto/webmentions.md\"\u003e\u003ccode\u003edocs/howto/webmentions.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-18T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/syndicate-command/",
      "url": "https://docs.colophon.blog/guides/syndicate-command/",
      "title": "How to syndicate with a command (POSSE, any target)",
      "summary": "POSSE = Publish on your Own Site, Syndicate Elsewhere. The command driver runs a program of your choice once per new post, so you can cross-post anywhere (a silo's CLI, a webhook,…",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/howto/syndicate-command.md — do not edit by hand. --\u003e\n\u003cblockquote\u003e\n\u003cp\u003eStatus: \u003cstrong\u003eshipped.\u003c/strong\u003e The syndication harness — the ledger, gating, \u003ccode\u003e--dry-run\u003c/code\u003e, and the\n\u003ccode\u003ecommand\u003c/code\u003e driver — works today. Native \u003ccode\u003emastodon\u003c/code\u003e/\u003ccode\u003ebluesky\u003c/code\u003e drivers are planned\n(\u003ca href=\"/internals/federation/\"\u003e../design/federation.md\u003c/a\u003e); the \u003ccode\u003ecommand\u003c/code\u003e driver lets you wire up any\ntarget now.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003ePOSSE = Publish on your Own Site, Syndicate Elsewhere. The \u003ccode\u003ecommand\u003c/code\u003e driver runs a program of your\nchoice once per new post, so you can cross-post anywhere (a silo's CLI, a webhook, a notifier)\nwithout a built-in driver. colophon records each result in a committed ledger, so re-runs never\ndouble-post.\u003c/p\u003e\n\u003ch2 id=\"steps\"\u003eSteps\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eWrite a command\u003c/strong\u003e that posts one entry. colophon passes the post as environment variables\n(\u003ccode\u003eCOLOPHON_POST_URL\u003c/code\u003e, \u003ccode\u003e_TITLE\u003c/code\u003e, \u003ccode\u003e_SUMMARY\u003c/code\u003e, \u003ccode\u003e_TEXT\u003c/code\u003e, \u003ccode\u003e_TAGS\u003c/code\u003e, \u003ccode\u003e_KEY\u003c/code\u003e, \u003ccode\u003e_PUBLISHED\u003c/code\u003e) and as JSON\non stdin. Print the \u003cstrong\u003ecreated URL\u003c/strong\u003e as the first line of stdout (or print nothing for\nfire-and-forget). A non-zero exit is a failure.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003e#!/usr/bin/env bash\n# bin/post-to-silo — receives one post via env, prints the silo URL\nset -euo pipefail\nid=$(curl -fsS -X POST https://silo.example/api/posts \\\n       -H \u0026#34;Authorization: Bearer $SILO_TOKEN\u0026#34; \\\n       --data-urlencode \u0026#34;text=${COLOPHON_POST_TITLE} ${COLOPHON_POST_URL}\u0026#34; | jq -r .url)\necho \u0026#34;$id\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eConfigure a syndicator\u003c/strong\u003e (\u003ccode\u003edriver: command\u003c/code\u003e) and allow it on the env that should post:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    federation:\n      syndication:\n        - { id: silo, driver: command, command: \u0026#34;./bin/post-to-silo\u0026#34; }\nenvironments:\n  - name: production\n    syndicate: [silo]      # only this env cross-posts; preview/draft omit it → never post\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003ePreview, then post\u003c/strong\u003e (run after \u003ccode\u003epublish\u003c/code\u003e, so the canonical URL is live):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon syndicate --env production --dry-run        # shows what would post, writes nothing\ncolophon syndicate --env production --allow-publish  # posts new entries, records the ledger\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eCommit the ledger\u003c/strong\u003e (\u003ccode\u003e.colophon/syndication.json\u003c/code\u003e) — it's authoritative. Without it a fresh\nrunner would re-post everything, so a real run refuses to start with no ledger unless you pass\n\u003ccode\u003e--allow-publish\u003c/code\u003e to seed it.\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eThe recorded silo URLs render on each post as mf2 \u003ccode\u003eu-syndication\u003c/code\u003e \u0026quot;Also posted on…\u0026quot; links.\u003c/p\u003e\n\u003ch2 id=\"notes\"\u003eNotes\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eSafety:\u003c/strong\u003e only an env's \u003ccode\u003esyndicate:\u003c/code\u003e targets fire; a gated env (\u003ccode\u003eallow_publish: false\u003c/code\u003e) needs\n\u003ccode\u003e--allow-publish\u003c/code\u003e; \u003ccode\u003e--dry-run\u003c/code\u003e never posts or writes. Post content is passed via env/stdin, never\ninterpolated into the command, so it can't inject shell.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePer post:\u003c/strong\u003e \u003ccode\u003esyndicate: false\u003c/code\u003e to skip one, \u003ccode\u003esyndicate: [silo]\u003c/code\u003e to choose targets,\n\u003ccode\u003esyndicate_text:\u003c/code\u003e for a custom blurb.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSecrets\u003c/strong\u003e (like \u003ccode\u003eSILO_TOKEN\u003c/code\u003e) come from the environment, never the config.\u003c/li\u003e\n\u003cli\u003ePrefer a managed native account? The \u003ccode\u003emastodon\u003c/code\u003e/\u003ccode\u003ebluesky\u003c/code\u003e drivers (planned) will hold the auth\nfor you; until then \u003ccode\u003ecommand\u003c/code\u003e covers any target.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/howto/syndicate-command.md\"\u003e\u003ccode\u003edocs/howto/syndicate-command.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-17T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/syndicate-mastodon/",
      "url": "https://docs.colophon.blog/guides/syndicate-mastodon/",
      "title": "How to syndicate to Mastodon (POSSE)",
      "summary": "POSSE = Publish on your Own Site, Syndicate Elsewhere: the post is canonical on your blog, and a copy is cross-posted to Mastodon linking back to it.",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/howto/syndicate-mastodon.md — do not edit by hand. --\u003e\n\u003cblockquote\u003e\n\u003cp\u003eStatus: \u003cstrong\u003eshipped.\u003c/strong\u003e The \u003ccode\u003emastodon\u003c/code\u003e driver, \u003ccode\u003ecolophon syndicate\u003c/code\u003e, and the ledger work today.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003ePOSSE = Publish on your Own Site, Syndicate Elsewhere: the post is canonical on your blog, and a\ncopy is cross-posted to Mastodon linking back to it.\u003c/p\u003e\n\u003ch2 id=\"steps\"\u003eSteps\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eHave a Mastodon account\u003c/strong\u003e on any instance (e.g. \u003ccode\u003ehachyderm.io\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCreate an access token:\u003c/strong\u003e on your instance, \u003cstrong\u003ePreferences → Development → New application\u003c/strong\u003e;\ngive it the \u003cstrong\u003e\u003ccode\u003ewrite:statuses\u003c/code\u003e\u003c/strong\u003e (and \u003ccode\u003ewrite:media\u003c/code\u003e for images) scope; create it; copy the\n\u003cstrong\u003eaccess token\u003c/strong\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eExport the token\u003c/strong\u003e as a CI secret: \u003ccode\u003eexport MASTODON_TOKEN=...\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eConfigure a syndicator\u003c/strong\u003e (\u003ccode\u003edriver: mastodon\u003c/code\u003e):\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    federation:\n      syndication:\n        - id: mastodon\n          driver: mastodon\n          instance: https://hachyderm.io\n          token: \u0026#34;{env:MASTODON_TOKEN}\u0026#34;   # never a literal\nenvironments:\n  - name: production\n    syndicate: [mastodon]     # only this env cross-posts; preview/draft never do\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePublish, then syndicate\u003c/strong\u003e (syndicate runs after the canonical URL is live):\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon publish  --env production --allow-publish\ncolophon syndicate --env production --allow-publish\n\u003c/code\u003e\u003c/pre\u003e\nThe Mastodon post URL is recorded in the syndication ledger and shown as an \u0026quot;Also posted on…\u0026quot;\n(\u003ccode\u003eu-syndication\u003c/code\u003e) link on your post. Re-running is idempotent (the ledger prevents double-posting).\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"notes\"\u003eNotes\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eCommit the syndication ledger\u003c/strong\u003e (\u003ccode\u003e.colophon/syndication.json\u003c/code\u003e) — it's authoritative; a fresh CI\nrunner without it would re-post. \u003ccode\u003esyndicate\u003c/code\u003e refuses to run blind without it.\u003c/li\u003e\n\u003cli\u003ePer post: \u003ccode\u003esyndicate: [mastodon]\u003c/code\u003e to choose targets, \u003ccode\u003esyndicate: false\u003c/code\u003e to skip, \u003ccode\u003esyndicate_text:\u003c/code\u003e\nfor a custom blurb. Long posts are truncated with a link back.\u003c/li\u003e\n\u003cli\u003eReplies/boosts on the Mastodon copy can flow back to your post via Bridgy backfeed — see\n\u003ca href=\"/guides/webmentions/\"\u003eShow webmentions\u003c/a\u003e.\u003c/li\u003e\n\u003cli\u003eNo token to manage? Use \u003ccode\u003edriver: bridgy\u003c/code\u003e with \u003ccode\u003enetwork: mastodon\u003c/code\u003e instead (Bridgy holds the auth).\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/howto/syndicate-mastodon.md\"\u003e\u003ccode\u003edocs/howto/syndicate-mastodon.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-16T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/guides/syndicate-bluesky/",
      "url": "https://docs.colophon.blog/guides/syndicate-bluesky/",
      "title": "How to syndicate to Bluesky (POSSE)",
      "summary": "POSSE = Publish on your Own Site, Syndicate Elsewhere: the post is canonical on your blog, and a copy is cross-posted to Bluesky linking back to it.",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/howto/syndicate-bluesky.md — do not edit by hand. --\u003e\n\u003cblockquote\u003e\n\u003cp\u003eStatus: \u003cstrong\u003eshipped.\u003c/strong\u003e The \u003ccode\u003ebluesky\u003c/code\u003e driver, \u003ccode\u003ecolophon syndicate\u003c/code\u003e, and the ledger work today.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003ePOSSE = Publish on your Own Site, Syndicate Elsewhere: the post is canonical on your blog, and a\ncopy is cross-posted to Bluesky linking back to it.\u003c/p\u003e\n\u003ch2 id=\"steps\"\u003eSteps\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eHave a Bluesky account\u003c/strong\u003e — note your handle (e.g. \u003ccode\u003eme.bsky.social\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCreate an app password:\u003c/strong\u003e \u003cstrong\u003eSettings → Privacy and security → App passwords → Add\u003c/strong\u003e (don't use\nyour main password). Copy it.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eExport it\u003c/strong\u003e as a CI secret: \u003ccode\u003eexport BLUESKY_APP_PASSWORD=...\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eConfigure a syndicator\u003c/strong\u003e (\u003ccode\u003edriver: bluesky\u003c/code\u003e):\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    federation:\n      syndication:\n        - id: bluesky\n          driver: bluesky\n          handle: me.bsky.social\n          app_password: \u0026#34;{env:BLUESKY_APP_PASSWORD}\u0026#34;   # never a literal\nenvironments:\n  - name: production\n    syndicate: [bluesky]      # only this env cross-posts; preview/draft never do\n\u003c/code\u003e\u003c/pre\u003e\n\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePublish, then syndicate:\u003c/strong\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-sh\"\u003ecolophon publish  --env production --allow-publish\ncolophon syndicate --env production --allow-publish\n\u003c/code\u003e\u003c/pre\u003e\ncolophon authenticates (handle + app password → AT-proto session), creates the post (with a\nlink card back to the canonical), records the Bluesky URL in the ledger, and renders it as an\n\u0026quot;Also posted on…\u0026quot; (\u003ccode\u003eu-syndication\u003c/code\u003e) link. Idempotent via the ledger.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"notes\"\u003eNotes\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eBluesky's limit is \u003cstrong\u003e300 characters\u003c/strong\u003e — long posts are truncated with a link back; set\n\u003ccode\u003esyndicate_text:\u003c/code\u003e per post for a custom blurb.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCommit the syndication ledger\u003c/strong\u003e (\u003ccode\u003e.colophon/syndication.json\u003c/code\u003e); without it a fresh runner would\nre-post, so \u003ccode\u003esyndicate\u003c/code\u003e refuses to run blind.\u003c/li\u003e\n\u003cli\u003eReplies/likes/reposts on the Bluesky copy can flow back to your post via Bridgy backfeed — see\n\u003ca href=\"/guides/webmentions/\"\u003eShow webmentions\u003c/a\u003e.\u003c/li\u003e\n\u003cli\u003ePrefer not to manage credentials? Use \u003ccode\u003edriver: bridgy\u003c/code\u003e with \u003ccode\u003enetwork: bluesky\u003c/code\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/howto/syndicate-bluesky.md\"\u003e\u003ccode\u003edocs/howto/syndicate-bluesky.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-15T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/",
      "url": "https://docs.colophon.blog/cli/",
      "title": "CLI reference",
      "summary": "Every colophon command, generated from the binary's --help output.",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eThe complete command surface, one page per command. Everything below is emitted by the binary itself (\u003ccode\u003ecolophon --help\u003c/code\u003e), so it always matches the release it was generated from.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/cli/init/\"\u003ecolophon init\u003c/a\u003e — Scaffold a new colophon project\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/new/\"\u003ecolophon new\u003c/a\u003e — Scaffold a new post (dated, chronological) (subcommands: post, page)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/build/\"\u003ecolophon build\u003c/a\u003e — Build the site into public/ (prints next pending embargo)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/next-build-time/\"\u003ecolophon next-build-time\u003c/a\u003e — Print the next pending publish_after timestamp (for CI scheduling)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/serve/\"\u003ecolophon serve\u003c/a\u003e — Serve every environment locally with live reload\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/publish/\"\u003ecolophon publish\u003c/a\u003e — Build and deploy/mirror to publishers (gated)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/themes/\"\u003ecolophon themes\u003c/a\u003e — List the built-in themes (subcommands: list, eject)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/authors/\"\u003ecolophon authors\u003c/a\u003e — List authors (the bylines) (subcommands: list, show)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/persona/\"\u003ecolophon persona\u003c/a\u003e — List personas (subcommands: list, context)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/sources/\"\u003ecolophon sources\u003c/a\u003e — Show where content lives and how posts are marked publishable\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/posts/\"\u003ecolophon posts\u003c/a\u003e — List content entries (for editing and cross-referencing)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/search/\"\u003ecolophon search\u003c/a\u003e — Search content (lexical or semantic)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/skills/\"\u003ecolophon skills\u003c/a\u003e — Show which agent harnesses are present and the install status of each skill (subcommands: detect, install, list, uninstall)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/webmention/\"\u003ecolophon webmention\u003c/a\u003e — Notify the sites your live posts link to (run after publish) (subcommands: send, fetch, publish)\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/syndicate/\"\u003ecolophon syndicate\u003c/a\u003e — Cross-post (POSSE) to the environment's configured syndicators\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/doctor/\"\u003ecolophon doctor\u003c/a\u003e — Validate the project config and report problems\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/cli/env/\"\u003ecolophon env\u003c/a\u003e — List the environment variables this project uses\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"colophon---help\"\u003ecolophon --help\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon \u0026lt;command\u0026gt; [flags]\n\nA themed Markdown blog generator with pluggable publishers\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\nCommands:\n  init [\u0026lt;dir\u0026gt;] [flags]\n    Scaffold a new colophon project\n\n  new post \u0026lt;title\u0026gt; [flags]\n    Scaffold a new post (dated, chronological)\n\n  new page \u0026lt;title\u0026gt; [flags]\n    Scaffold a new standing page (nav menu, no date)\n\n  build [flags]\n    Build the site into public/ (prints next pending embargo)\n\n  next-build-time [flags]\n    Print the next pending publish_after timestamp (for CI scheduling)\n\n  serve [flags]\n    Serve every environment locally with live reload\n\n  publish --env=ENV,... [flags]\n    Build and deploy/mirror to publishers (gated)\n\n  themes list\n    List the built-in themes\n\n  themes eject \u0026lt;name\u0026gt; [flags]\n    Copy a built-in theme into themes/\u0026lt;name\u0026gt;/ to customise\n\n  authors (author) list [flags]\n    List authors (the bylines)\n\n  authors (author) show \u0026lt;author\u0026gt; [flags]\n    Show one author\u0026#39;s full details\n\n  persona (personas) list [flags]\n    List personas\n\n  persona (personas) context [\u0026lt;persona\u0026gt;] [flags]\n    Emit style guide + top-K exemplars for AI-assisted writing\n\n  sources (source) [flags]\n    Show where content lives and how posts are marked publishable\n\n  posts (post) [flags]\n    List content entries (for editing and cross-referencing)\n\n  search [\u0026lt;query\u0026gt;] [flags]\n    Search content (lexical or semantic)\n\n  skills detect [flags]\n    Show which agent harnesses are present and the install status of each skill\n\n  skills install [flags]\n    Install/update the skills into detected harnesses (or --harness/--dir)\n\n  skills list\n    List the skills embedded in this binary\n\n  skills uninstall [flags]\n    Remove colophon-managed skills from detected harnesses (or --harness/--dir)\n\n  webmention send [flags]\n    Notify the sites your live posts link to (run after publish)\n\n  webmention fetch [flags]\n    Pull received mentions from the configured receiver into the local cache\n\n  webmention publish [flags]\n    Fetch mentions and deploy only _mentions/ (refresh responses without a full\n    re-upload)\n\n  syndicate [flags]\n    Cross-post (POSSE) to the environment\u0026#39;s configured syndicators\n\n  doctor [flags]\n    Validate the project config and report problems\n\n  env [flags]\n    List the environment variables this project uses\n\nRun \u0026#34;colophon \u0026lt;command\u0026gt; --help\u0026#34; for more information on a command.\n\u003c/code\u003e\u003c/pre\u003e\n",
      "date_published": "2001-12-14T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/init/",
      "url": "https://docs.colophon.blog/cli/init/",
      "title": "colophon init",
      "summary": "Scaffold a new colophon project",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eScaffold a new colophon project\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon init [\u0026lt;dir\u0026gt;] [flags]\n\nScaffold a new colophon project\n\nArguments:\n  [\u0026lt;dir\u0026gt;]    Target directory\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\n      --force      Overwrite an existing colophon.yaml\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-13T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/new/",
      "url": "https://docs.colophon.blog/cli/new/",
      "title": "colophon new",
      "summary": "Scaffold a new post or page (validated author/persona, unique slug)",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eScaffold a new post or page (validated author/persona, unique slug)\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon new \u0026lt;command\u0026gt; [flags]\n\nScaffold a new post or page (validated author/persona, unique slug)\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\nCommands:\n  new post \u0026lt;title\u0026gt; [flags]\n    Scaffold a new post (dated, chronological)\n\n  new page \u0026lt;title\u0026gt; [flags]\n    Scaffold a new standing page (nav menu, no date)\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-new-post\"\u003ecolophon new post\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon new post \u0026lt;title\u0026gt; [flags]\n\nScaffold a new post (dated, chronological)\n\nArguments:\n  \u0026lt;title\u0026gt;    Entry title\n\nFlags:\n  -h, --help              Show context-sensitive help.\n      --version           Print version and exit\n\n      --author=STRING     Byline author id (validated; default: first author /\n                          Anonymous)\n      --persona=STRING    Writing-voice persona id (validated; optional)\n      --tag=TAG,...       Tags\n      --slug=STRING       Explicit slug (else derived from the title and made\n                          unique)\n      --unique=\u0026#34;hash\u0026#34;     Slug collision strategy: hash | counter\n      --in=STRING         Source id to write into (default: the first source)\n      --print             Print the file to stdout instead of writing it\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-new-page\"\u003ecolophon new page\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon new page \u0026lt;title\u0026gt; [flags]\n\nScaffold a new standing page (nav menu, no date)\n\nArguments:\n  \u0026lt;title\u0026gt;    Entry title\n\nFlags:\n  -h, --help              Show context-sensitive help.\n      --version           Print version and exit\n\n      --author=STRING     Byline author id (validated; default: first author /\n                          Anonymous)\n      --persona=STRING    Writing-voice persona id (validated; optional)\n      --tag=TAG,...       Tags\n      --slug=STRING       Explicit slug (else derived from the title and made\n                          unique)\n      --unique=\u0026#34;hash\u0026#34;     Slug collision strategy: hash | counter\n      --in=STRING         Source id to write into (default: the first source)\n      --print             Print the file to stdout instead of writing it\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-12T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/build/",
      "url": "https://docs.colophon.blog/cli/build/",
      "title": "colophon build",
      "summary": "Build the site into public/ (prints next pending embargo)",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eBuild the site into public/ (prints next pending embargo)\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon build [flags]\n\nBuild the site into public/ (prints next pending embargo)\n\nFlags:\n  -h, --help           Show context-sensitive help.\n      --version        Print version and exit\n\n      --env=STRING     Build a named environment (applies its overrides)\n  -v, --verbose        Log each step (sources, files, feeds)\n      --generate-ai    Generate uncached AI media (gen: images and TTS audio)\n                       via the configured providers\n      --regenerate     Force a fresh render of generated media even if cached\n                       (re-voice audio / re-roll images); implies work only with\n                       --generate-ai\n      --no-backoff     Don\u0026#39;t retry rate-limited generation; fail fast and warn\n                       instead of backing off\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-11T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/next-build-time/",
      "url": "https://docs.colophon.blog/cli/next-build-time/",
      "title": "colophon next-build-time",
      "summary": "Print the next pending publish_after timestamp (for CI scheduling)",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003ePrint the next pending publish_after timestamp (for CI scheduling)\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon next-build-time [flags]\n\nPrint the next pending publish_after timestamp (for CI scheduling)\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\n      --json       Output JSON\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-10T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/serve/",
      "url": "https://docs.colophon.blog/cli/serve/",
      "title": "colophon serve",
      "summary": "Serve every environment locally with live reload",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eServe every environment locally with live reload\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon serve [flags]\n\nServe every environment locally with live reload\n\nFlags:\n  -h, --help            Show context-sensitive help.\n      --version         Print version and exit\n\n      --addr=\u0026#34;:8080\u0026#34;    Address to listen on\n      --open=STRING     Open a target in the browser: latest | home | sitemap |\n                        atom | rss | json | robots | \u0026lt;slug\u0026gt;\n      --showcase        Inject a built-in /showcase/ page (embedded in the\n                        binary, never written to content) demonstrating every\n                        markdown/style feature in the active theme\n  -v, --verbose         Log each rebuild and attach source locations\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-09T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/publish/",
      "url": "https://docs.colophon.blog/cli/publish/",
      "title": "colophon publish",
      "summary": "Build and deploy/mirror to publishers (gated)",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eBuild and deploy/mirror to publishers (gated)\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon publish --env=ENV,... [flags]\n\nBuild and deploy/mirror to publishers (gated)\n\nFlags:\n  -h, --help             Show context-sensitive help.\n      --version          Print version and exit\n\n      --env=ENV,...      Environment to publish; repeat for several\n      --allow-publish    Deploy environments that set allow_publish: false\n      --create           Create the destination (e.g. a Pages project) if it\n                         doesn\u0026#39;t exist\n      --generate-ai      Generate uncached AI media (gen: images and TTS audio)\n                         via the configured providers before deploying\n      --regenerate       Force a fresh render of generated media even if cached\n                         (re-voice audio / re-roll images); needs --generate-ai\n      --no-backoff       Don\u0026#39;t retry rate-limited generation; fail fast and warn\n                         instead of backing off\n  -v, --verbose          Log each step (sources, files, publisher actions)\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-08T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/themes/",
      "url": "https://docs.colophon.blog/cli/themes/",
      "title": "colophon themes",
      "summary": "List built-in themes or eject one to customise",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eList built-in themes or eject one to customise\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon themes \u0026lt;command\u0026gt; [flags]\n\nList built-in themes or eject one to customise\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\nCommands:\n  themes list\n    List the built-in themes\n\n  themes eject \u0026lt;name\u0026gt; [flags]\n    Copy a built-in theme into themes/\u0026lt;name\u0026gt;/ to customise\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-themes-list\"\u003ecolophon themes list\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon themes list\n\nList the built-in themes\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-themes-eject\"\u003ecolophon themes eject\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon themes eject \u0026lt;name\u0026gt; [flags]\n\nCopy a built-in theme into themes/\u0026lt;name\u0026gt;/ to customise\n\nArguments:\n  \u0026lt;name\u0026gt;    Built-in theme to copy (e.g. default, minimal)\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\n      --force      Overwrite an existing themes/\u0026lt;name\u0026gt;/ directory\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-07T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/authors/",
      "url": "https://docs.colophon.blog/cli/authors/",
      "title": "colophon authors",
      "summary": "List authors (the bylines) or show one",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eList authors (the bylines) or show one\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon authors (author) \u0026lt;command\u0026gt; [flags]\n\nList authors (the bylines) or show one\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\nCommands:\n  authors (author) list [flags]\n    List authors (the bylines)\n\n  authors (author) show \u0026lt;author\u0026gt; [flags]\n    Show one author\u0026#39;s full details\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-authors-list\"\u003ecolophon authors list\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon authors (author) list [flags]\n\nList authors (the bylines)\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\n      --json       Output JSON\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-authors-show\"\u003ecolophon authors show\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon authors (author) show \u0026lt;author\u0026gt; [flags]\n\nShow one author\u0026#39;s full details\n\nArguments:\n  \u0026lt;author\u0026gt;    Author id\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\n      --json       Output JSON\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-06T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/persona/",
      "url": "https://docs.colophon.blog/cli/persona/",
      "title": "colophon persona",
      "summary": "List writing voices or emit write-as context",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eList writing voices or emit write-as context\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon persona (personas) \u0026lt;command\u0026gt; [flags]\n\nList writing voices or emit write-as context\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\nCommands:\n  persona (personas) list [flags]\n    List personas\n\n  persona (personas) context [\u0026lt;persona\u0026gt;] [flags]\n    Emit style guide + top-K exemplars for AI-assisted writing\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-persona-list\"\u003ecolophon persona list\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon persona (personas) list [flags]\n\nList personas\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\n      --json       Output JSON\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-persona-context\"\u003ecolophon persona context\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon persona (personas) context [\u0026lt;persona\u0026gt;] [flags]\n\nEmit style guide + top-K exemplars for AI-assisted writing\n\nArguments:\n  [\u0026lt;persona\u0026gt;]    Persona id (defaults to the only persona, or \u0026#39;default\u0026#39;)\n\nFlags:\n  -h, --help            Show context-sensitive help.\n      --version         Print version and exit\n\n      --topic=STRING    Topic/outline to retrieve exemplars for (ranked by\n                        relevance)\n      --tag=TAG,...     Only draw exemplars tagged with this tag; repeatable\n      --top-k=3         Max number of exemplars to emit\n      --length=INT      Per-exemplar character cap (0 = the default); ignored\n                        with --full\n      --full            Emit each exemplar\u0026#39;s full body (still bounded by\n                        --budget)\n      --budget=10000    Total character budget across all exemplars\n      --json            Output JSON\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-05T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/sources/",
      "url": "https://docs.colophon.blog/cli/sources/",
      "title": "colophon sources",
      "summary": "Show where content lives and how posts are marked publishable",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eShow where content lives and how posts are marked publishable\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon sources (source) [flags]\n\nShow where content lives and how posts are marked publishable\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\n      --json       Output JSON\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-04T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/posts/",
      "url": "https://docs.colophon.blog/cli/posts/",
      "title": "colophon posts",
      "summary": "List content entries (for editing and cross-referencing)",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eList content entries (for editing and cross-referencing)\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon posts (post) [flags]\n\nList content entries (for editing and cross-referencing)\n\nFlags:\n  -h, --help              Show context-sensitive help.\n      --version           Print version and exit\n\n      --author=STRING     Only entries with this author id\n      --persona=STRING    Only entries with this persona id\n      --tag=TAG,...       Only entries carrying any of these tags\n      --json              Output JSON\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-03T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/search/",
      "url": "https://docs.colophon.blog/cli/search/",
      "title": "colophon search",
      "summary": "Search content (lexical or semantic)",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eSearch content (lexical or semantic)\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon search [\u0026lt;query\u0026gt;] [flags]\n\nSearch content (lexical or semantic)\n\nArguments:\n  [\u0026lt;query\u0026gt;]    Search query\n\nFlags:\n  -h, --help        Show context-sensitive help.\n      --version     Print version and exit\n\n      --limit=20    Maximum results\n      --json        Output JSON\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-02T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/skills/",
      "url": "https://docs.colophon.blog/cli/skills/",
      "title": "colophon skills",
      "summary": "Install colophon's authoring skills into a detected agent harness",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eInstall colophon's authoring skills into a detected agent harness\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon skills \u0026lt;command\u0026gt; [flags]\n\nInstall colophon\u0026#39;s authoring skills into a detected agent harness\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\nCommands:\n  skills detect [flags]\n    Show which agent harnesses are present and the install status of each skill\n\n  skills install [flags]\n    Install/update the skills into detected harnesses (or --harness/--dir)\n\n  skills list\n    List the skills embedded in this binary\n\n  skills uninstall [flags]\n    Remove colophon-managed skills from detected harnesses (or --harness/--dir)\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-skills-detect\"\u003ecolophon skills detect\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon skills detect [flags]\n\nShow which agent harnesses are present and the install status of each skill\n\nFlags:\n  -h, --help          Show context-sensitive help.\n      --version       Print version and exit\n\n      --dir=STRING    Inspect a specific skills directory instead of detecting\n                      harnesses\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-skills-install\"\u003ecolophon skills install\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon skills install [flags]\n\nInstall/update the skills into detected harnesses (or --harness/--dir)\n\nFlags:\n  -h, --help                   Show context-sensitive help.\n      --version                Print version and exit\n\n      --harness=HARNESS,...    Install only for these harness ids\n                               (claude,codex,opencode,cursor,copilot,gemini)\n      --dir=STRING             Install into a specific directory instead of\n                               detected harnesses\n      --all                    Install for every supported harness, detected or\n                               not\n      --claude=\u0026#34;ask\u0026#34;           How to install for Claude Code:\n                               ask|marketplace|files|skip\n      --force                  Overwrite locally-modified or unmanaged skills\n      --dry-run                Show what would change without writing\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-skills-list\"\u003ecolophon skills list\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon skills list\n\nList the skills embedded in this binary\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-skills-uninstall\"\u003ecolophon skills uninstall\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon skills uninstall [flags]\n\nRemove colophon-managed skills from detected harnesses (or --harness/--dir)\n\nFlags:\n  -h, --help                   Show context-sensitive help.\n      --version                Print version and exit\n\n      --harness=HARNESS,...    Uninstall only for these harness ids\n      --dir=STRING             Uninstall from a specific directory\n      --all                    Consider every supported harness, detected or not\n      --force                  Also remove locally-modified or unmanaged skills\n      --dry-run                Show what would change without deleting\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-12-01T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/webmention/",
      "url": "https://docs.colophon.blog/cli/webmention/",
      "title": "colophon webmention",
      "summary": "Send webmentions to the sites your live posts link to (run after publish)",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eSend webmentions to the sites your live posts link to (run after publish)\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon webmention \u0026lt;command\u0026gt; [flags]\n\nSend webmentions to the sites your live posts link to (run after publish)\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\nCommands:\n  webmention send [flags]\n    Notify the sites your live posts link to (run after publish)\n\n  webmention fetch [flags]\n    Pull received mentions from the configured receiver into the local cache\n\n  webmention publish [flags]\n    Fetch mentions and deploy only _mentions/ (refresh responses without a full\n    re-upload)\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-webmention-send\"\u003ecolophon webmention send\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon webmention send [flags]\n\nNotify the sites your live posts link to (run after publish)\n\nFlags:\n  -h, --help                Show context-sensitive help.\n      --version             Print version and exit\n\n      --env=\u0026#34;production\u0026#34;    Environment whose built output to scan\n      --dry-run             Discover endpoints and report, but do not POST\n  -v, --verbose             Log each link and endpoint\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-webmention-fetch\"\u003ecolophon webmention fetch\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon webmention fetch [flags]\n\nPull received mentions from the configured receiver into the local cache\n\nFlags:\n  -h, --help             Show context-sensitive help.\n      --version          Print version and exit\n\n      --domain=STRING    Domain to fetch mentions for (default: the site\n                         base_url host)\n  -v, --verbose          Log each post that received mentions\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"colophon-webmention-publish\"\u003ecolophon webmention publish\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon webmention publish [flags]\n\nFetch mentions and deploy only _mentions/ (refresh responses without a full\nre-upload)\n\nFlags:\n  -h, --help                Show context-sensitive help.\n      --version             Print version and exit\n\n      --env=\u0026#34;production\u0026#34;    Environment to refresh mentions on\n      --allow-publish       Deploy environments that set allow_publish: false\n      --domain=STRING       Domain to fetch mentions for (default: the site\n                            base_url host)\n  -v, --verbose             Log each step\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-11-30T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/syndicate/",
      "url": "https://docs.colophon.blog/cli/syndicate/",
      "title": "colophon syndicate",
      "summary": "Cross-post (POSSE) to the environment's configured syndicators",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eCross-post (POSSE) to the environment's configured syndicators\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon syndicate [flags]\n\nCross-post (POSSE) to the environment\u0026#39;s configured syndicators\n\nFlags:\n  -h, --help                Show context-sensitive help.\n      --version             Print version and exit\n\n      --env=\u0026#34;production\u0026#34;    Environment to syndicate\n      --allow-publish       Run for environments gated by allow_publish:\n                            false (required to post)\n      --dry-run             Show what would be syndicated; post nothing,\n                            write no ledger\n      --resync              Re-edit every already-syndicated copy to the post\u0026#39;s\n                            current content, ignoring fingerprints (one-shot;\n                            only drivers that can edit, e.g. mastodon/bluesky)\n  -v, --verbose             Log each candidate\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-11-29T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/doctor/",
      "url": "https://docs.colophon.blog/cli/doctor/",
      "title": "colophon doctor",
      "summary": "Validate the project config and report problems",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eValidate the project config and report problems\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon doctor [flags]\n\nValidate the project config and report problems\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\n      --prune      Delete orphaned generated assets (AI images/audio no content\n                   references)\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-11-28T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/cli/env/",
      "url": "https://docs.colophon.blog/cli/env/",
      "title": "colophon env",
      "summary": "List the environment variables this project uses",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eList the environment variables this project uses\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-text\"\u003eUsage: colophon env [flags]\n\nList the environment variables this project uses\n\nFlags:\n  -h, --help       Show context-sensitive help.\n      --version    Print version and exit\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from the binary's \u003ccode\u003e--help\u003c/code\u003e output by \u003ccode\u003etools/gendocs\u003c/code\u003e.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-11-27T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/reference/config/",
      "url": "https://docs.colophon.blog/reference/config/",
      "title": "Configuration reference",
      "summary": "The annotated colophon.yaml reference: every option, default and shape in one file.",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/colophon.reference.yaml — do not edit by hand. --\u003e\n\u003cp\u003eEvery \u003ccode\u003ecolophon.yaml\u003c/code\u003e option in one annotated file. It is \u003cstrong\u003enot\u003c/strong\u003e a starter config — \u003ccode\u003ecolophon init\u003c/code\u003e writes a lean one — but the single place where every knob, default and shape is written down. Copy the bits you need.\u003c/p\u003e\n\u003cp\u003eAny string value may interpolate the environment with \u003ccode\u003e{env:VAR}\u003c/code\u003e or \u003ccode\u003e{env:VAR:-fallback}\u003c/code\u003e; deploy secrets are always read from the environment and never stored in the file.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# ─────────────────────────────────────────────────────────────────────────────\n# colophon.yaml — ANNOTATED REFERENCE\n#\n# Not a starter config (`colophon init` writes a lean one). This shows every\n# option in one place, with a few list entries set side-by-side so the shapes\n# are clear. Copy the bits you need.\n#\n# Markers used in comments below:\n#   [required]   must be set; no default\n#   [optional]   may be omitted; the shown value is the default\n#   [inherits]   only meaningful inside a profile — when omitted, the value is\n#                taken from that modality\u0026#39;s default block (see \u0026#34;generation\u0026#34;)\n#\n# Any string may interpolate the environment: {env:VAR} or {env:VAR:-fallback}.\n# Deploy secrets are read from the environment at build time and are NEVER stored\n# in this file.\n# ─────────────────────────────────────────────────────────────────────────────\n\n\n# ═══ sites ═══════════════════════════════════════════════════════════════════\n# One or more sites built from the shared content. Most projects have exactly one.\nsites:\n  - id: main                                   # [required] stable id, used in URLs/serve paths\n    title: \u0026#34;My Blog\u0026#34;                           # [required]\n    base_url: \u0026#34;{env:SITE_URL:-http://localhost:8080}\u0026#34;  # [required] canonical origin\n    theme: press                               # [optional] default: the bundled \u0026#34;default\u0026#34; theme\n    lang: en                                   # [optional] site default BCP-47 language (default \u0026#34;en\u0026#34;)\n    languages: [en, es]                        # [optional] enable multi-language posts. A file\n                                               #   \u0026lt;slug\u0026gt;.es.md is the Spanish translation of \u0026lt;slug\u0026gt;.md,\n                                               #   published at /es/\u0026lt;slug\u0026gt;/, linked by hreflang + a\n                                               #   theme language selector. The default lang stays at /.\n    personas: [default]                        # [optional] writing voices available to the agent\n    federation:\n      feeds: [rss, atom, json]                 # [optional] default: none; emit at least one\n    # search accepts a bare mode string OR a block. Bare form: `search: lexical`.\n    search:\n      mode: lexical                            # [optional] lexical | semantic | off (default off)\n      fuzzy: true                              # [optional] default false; trigram+Levenshtein typo tolerance\n    # Derived slide decks. Site defaults; a post overrides either key in its `slides:`\n    # frontmatter (shallow/by-key — a key replaces this value, omitted keys inherit).\n    slides:\n      enabled: false                           # [optional] default off; a post opts in with `slides: true`\n      split: [h2]                              # [optional] slide boundaries (a list). default: every heading.\n                                               #   targets: h1..h6, hr, splitslide, image, table, code,\n                                               #   math, diagram, audio, video, text:\u0026lt;match\u0026gt;\n    # Reader analytics for THIS site. One block per provider; inert until configured.\n    analytics:\n      statsfactory:\n        server_url: \u0026#34;{env:STATSFACTORY_SERVER_URL:-}\u0026#34;\n        app_key: \u0026#34;{env:STATSFACTORY_APP_KEY:-}\u0026#34;\n      # google_analytics:                      # GA4 — sets cookies, brings its own consent duties\n      #   measurement_id: \u0026#34;{env:GA_MEASUREMENT_ID:-}\u0026#34;\n    # Routing rewrites matching output paths to a publisher\u0026#39;s object store instead of\n    # shipping them with the page host (which has a file/size budget). Inert until that\n    # publisher resolves a public URL, so local builds keep assets co-located.\n    routing:\n      - match: \u0026#34;**assets/**\u0026#34;                   # co-located post assets + the root /assets tree\n        publisher: r2\n      - match: \u0026#34;_search/**\u0026#34;                    # keep the search index off the page-host budget\n        publisher: r2\n\n\n# ═══ sources ═════════════════════════════════════════════════════════════════\n# Where content comes from. Driver selects the implementation; the rest is\n# driver-specific. List several and their content is merged.\nsources:\n  - id: content                                # [required]\n    driver: md-dir                             # [required] a plain Markdown directory\n    path: ./content\n  - id: vault                                  # a second source, merged with the first\n    driver: obsidian\n    vault: \u0026#34;{env:OBSIDIAN_VAULT:-}\u0026#34;            # vault root; empty → contributes nothing\n    path: \u0026#34;{env:BLOG_PATH:-}\u0026#34;                  # vault-relative folder(s); empty → whole vault\n    tag: \u0026#34;{env:BLOG_TAGS:-}\u0026#34;                   # publish by tag(s); empty → use publish_required\n    publish_required: false\n\n\n# ═══ publishers ══════════════════════════════════════════════════════════════\n# Pure mechanism: HOW to deploy. WHAT/WHERE is decided by environments below.\n# Deploy credentials always come from the environment, never from these fields.\npublishers:\n  - id: local                                  # offline build target, for diffing output\n    driver: local\n    path: ./dist\n  - id: cf                                     # HTML/static host\n    driver: cloudflare-pages\n    project: \u0026#34;{env:CF_PAGES_PROJECT:-my-blog}\u0026#34;\n    account_id: \u0026#34;{env:CLOUDFLARE_ACCOUNT_ID}\u0026#34;\n  - id: r2                                     # S3-compatible object store (routing target above)\n    driver: cloudflare-r2\n    bucket: \u0026#34;{env:R2_BUCKET:-my-blog-assets}\u0026#34;\n    account_id: \u0026#34;{env:CLOUDFLARE_ACCOUNT_ID}\u0026#34;\n    public_url: \u0026#34;{env:R2_PUBLIC_URL:-}\u0026#34;        # empty → auto-discovered on publish\n\n\n# ═══ environments ════════════════════════════════════════════════════════════\n# Named build+deploy profiles. No name is privileged. An environment picks\n# publishers, toggles drafts, and may override a few site fields + select\n# generation profiles (see \u0026#34;generation\u0026#34;). Build/deploy with --env \u0026lt;name\u0026gt;.\nenvironments:\n  - name: production                           # [required]\n    publish: [cf, r2]                          # [required] publisher ids to deploy to\n    allow_publish: false                       # [optional] default true; false → require --allow-publish\n    base_url: \u0026#34;{env:BASE_URL:-}\u0026#34;               # [optional] override the site base_url here\n    overrides:                                 # [optional] per-publisher Settings overrides, keyed by id\n      cf: { branch: main }\n\n  - name: preview\n    publish: [cf, r2]\n    include_drafts: true                       # [optional] default false\n    title: \u0026#34;My Blog (preview)\u0026#34;                 # [optional] override the site title\n    image_profile: draft                       # [optional] use the cheap image profile in this env\n    speech_profile: minimax                    # [optional] use the minimax voice profile in this env\n    slides:                                    # [optional] override the site slides defaults here\n      enabled: true                            #   e.g. decks on in preview, off in production\n    overrides:\n      cf: { branch: preview }\n\n  - name: dist                                 # local copy; ungated, no creds\n    publish: [local]\n    include_drafts: true\n\n  - name: text                                 # preview an alternate theme before promoting it\n    publish: [local]\n    include_drafts: true\n    theme: minimal                             # [optional] override the site theme\n    overrides:\n      local: { path: ./dist-text }\n\n\n# ═══ generation ══════════════════════════════════════════════════════════════\n# Optional AI media generation. Two modalities: image (satisfies `gen:` refs) and\n# speech (reads posts with `audio: true`). Empty → the feature is off.\n#\n# Each modality has a DEFAULT block plus named PROFILES (a map). A profile inherits\n# every field from the default block and overrides only what it lists. The default\n# block is the implicit profile named \u0026#34;default\u0026#34;.\n#\n# Which profile renders a given asset (narrowest scope wins):\n#\n#   default block\n#     ◀ environment   speech_profile: / image_profile:\n#       ◀ post         audio_profile: / image_profile:   (frontmatter)\n#         ◀ per-ref    \u0026lt;gen:hero?profile=…\u0026gt;              (image refs only)\n#           ◀ post scalar overrides (audio_voice, …)\n#\n# Merge rule: scalars REPLACE, maps DEEP-MERGE (e.g. image `defaults`). Voice/model\n# ids are provider-specific, so a profile that changes provider should also set them.\ngeneration:\n  enabled: true                                # [optional] default true; master off-switch for all AI gen\n\n  # ── images ──────────────────────────────────────────────────────────────────\n  image:\n    enabled: true                              # [optional] default true; per-modality switch\n    provider: google                           # [required] google|minimax|openai|xai|together|deepinfra|custom\n    model: gemini-3.1-flash-image              # [optional] default: the provider profile\u0026#39;s default model\n    api_key: \u0026#34;{env:GEMINI_API_KEY}\u0026#34;            # [optional] default: the provider\u0026#39;s env var (kept out of config)\n    base_url: \u0026#34;\u0026#34;                               # [optional] override endpoint (required for `custom`)\n    api_path: \u0026#34;\u0026#34;                               # [optional] override request path (OpenAI-compatible hosts)\n    output_dir: content/assets/generated       # [optional] default shown; cache + sidecars (committed)\n    concurrency: 5                             # [optional] default 5; max parallel generations\n    reuse: exact                               # [optional] exact (re-render on provider/model change) | content\n    system_prompt: \u0026#34;\u0026#34;                          # [optional] house style; default: the theme\u0026#39;s\n    defaults:                                  # [optional] tuning params applied to every request\n      aspect: \u0026#34;16:9\u0026#34;\n    postprocess:\n      trim_letterbox: true                     # [optional] default true; strip baked-in letterbox bars\n    profiles:                                  # [optional] named alternates; each inherits the block above\n      draft:                                   # cheap/fast — e.g. for previews\n        provider: together                     # [inherits] all other fields from the default image block\n        model: black-forest-labs/FLUX.1-schnell\n      poster:                                  # premium — e.g. for hero art\n        provider: openai\n        model: gpt-image-1\n        defaults: { quality: high }            # deep-merged over the default `defaults` (aspect kept)\n\n  # ── speech ──────────────────────────────────────────────────────────────────\n  speech:\n    enabled: true                              # [optional] default true; also the per-post `audio:` default\n    provider: elevenlabs                       # [required] elevenlabs | minimax\n    voice: \u0026#34;{env:ELEVENLABS_VOICE:-Ee4WTXzxagFpoj4PUkHV}\u0026#34;  # [optional] default: provider profile\u0026#39;s default voice\n    model: \u0026#34;\u0026#34;                                  # [optional] default: provider profile\u0026#39;s default model\n    api_key: \u0026#34;{env:ELEVENLABS_API_KEY}\u0026#34;        # [optional] default: the provider\u0026#39;s env var\n    base_url: \u0026#34;\u0026#34;                               # [optional] override endpoint\n    api_path: \u0026#34;\u0026#34;                               # [optional] override request path\n    output_dir: content/assets/generated       # [optional] default shown; clip cache + sidecars\n    concurrency: 5                             # [optional] default 5; max parallel clips\n    reuse: exact                               # [optional] exact | content (reuse a prior voice\u0026#39;s reading)\n    pronunciation_dict: en_GB                  # [optional] built-in name (contrib/pronunciation: en_GB, es_ES) or a\n                                               #   YAML path. A naked ref applies to the SITE DEFAULT LANGUAGE only;\n                                               #   per-language: {en: en_GB, es: es_ES} (BCP-47 keys, es-MX → es).\n                                               #   A language with no entry gets no dictionary.\n    transcript:                                # [optional] how post content becomes spoken text\n      wrap_up: true                            # [optional] default true; append a closing \u0026#34;visit the post\u0026#34; note\n      expand_acronyms: true                    # [optional] default true; read SSH as \u0026#34;Secure Shell\u0026#34;\n      blocks:                                  # [optional] per block-type handling: cue|drop|keep|spell\n        code: cue                              #   (defaults: code/table/diagram/math_display→cue,\n        table: cue                             #    math_inline→drop, inline_code→spell, else keep)\n    profiles:                                  # [optional] named alternates; each inherits the block above\n      minimax:                                 # a different provider needs its own voice\n        provider: minimax\n        voice: English_Trustworth_Man          # [inherits] pronunciation_dict/transcript/etc from default\n      narrator-fast:                           # same provider, faster/cheaper model + a different voice\n        model: eleven_turbo_v2_5\n        voice: \u0026#34;{env:ELEVENLABS_NARRATOR_VOICE:-}\u0026#34;\n\n\n# ═══ telemetry ═══════════════════════════════════════════════════════════════\n# The colophon APP\u0026#39;s own anonymous usage reporting (build/source/publisher TYPES —\n# never your content), separate from the site analytics above. Governs only itself.\ntelemetry:\n  enabled: true                                # [optional] default true; false (or COLOPHON_TELEMETRY=off) disables\n\n\n# ═══ per-post selection (frontmatter — NOT this file) ════════════════════════\n# Posts pick a profile in their own frontmatter. The selector keys are identical\n# at every scope (config block, environment, frontmatter): speech_profile /\n# image_profile.\n#\n#   ---\n#   audio: true\n#   speech_profile: minimax          # → generation.speech.profiles.minimax\n#   audio_voice: SomeOtherVoiceId    # still wins over the profile\u0026#39;s voice\n#   image_profile: poster            # → generation.image.profiles.poster (hero + inline images)\n#   ---\n#\n#   ![cover](\u0026lt;gen:hero?profile=poster\u0026gt;)   # per-image override, beats image_profile\n\u003c/code\u003e\u003c/pre\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/colophon.reference.yaml\"\u003e\u003ccode\u003edocs/colophon.reference.yaml\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-11-26T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/reference/changelog/",
      "url": "https://docs.colophon.blog/reference/changelog/",
      "title": "Changelog",
      "summary": "User-facing changes by release. Each entry points at the guide where the feature is documented in full (or where it should be, when end-user docs catch up).",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/changelog.md — do not edit by hand. --\u003e\n\u003cp\u003eUser-facing changes by release. Each entry points at the guide where the feature is documented\nin full (or where it should be, when end-user docs catch up).\u003c/p\u003e\n\u003ch2 id=\"v0033\"\u003ev0.0.33\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003exAI (Grok Imagine) image provider.\u003c/strong\u003e \u003ccode\u003egeneration.image.provider: xai\u003c/code\u003e targets xAI's\nOpenAI-compatible images endpoint (default model \u003ccode\u003egrok-imagine-image-quality\u003c/code\u003e, key from\n\u003ccode\u003eXAI_API_KEY\u003c/code\u003e); the standard \u003ccode\u003easpect\u003c/code\u003e param is sent as xAI's \u003ccode\u003easpect_ratio\u003c/code\u003e. See\n\u003ca href=\"/guides/image-generation/#providers\"\u003eImage \u0026amp; audio generation → Providers\u003c/a\u003e.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003ePer-language pronunciation dictionaries.\u003c/strong\u003e \u003ccode\u003epronunciation_dict:\u003c/code\u003e now takes either a naked ref\n(\u003ccode\u003een_GB\u003c/code\u003e) — which applies to the \u003cstrong\u003esite default language only\u003c/strong\u003e, no longer to every language — or a\nmap keyed by BCP-47 tag: \u003ccode\u003epronunciation_dict: {en: en_GB, es: es_ES}\u003c/code\u003e (matched exact-then-base, so\n\u003ccode\u003ees-MX\u003c/code\u003e uses \u003ccode\u003ees\u003c/code\u003e). A language with no entry gets no dictionary, so an English dict never rewrites a\nSpanish reading. A Spanish dict (\u003ccode\u003ees_ES\u003c/code\u003e) is now bundled alongside \u003ccode\u003een_GB\u003c/code\u003e. For ElevenLabs, each dict\nsyncs as its own account dictionary (named \u003ccode\u003ecolophon:\u0026lt;site\u0026gt;/\u0026lt;ref\u0026gt;\u003c/code\u003e); a previously-synced dictionary\nis adopted when its rules are unchanged. Spoken block cues (\u0026quot;Here, the post shows a code example…\u0026quot;)\nnow also follow a translation's language detected from its \u003ccode\u003e\u0026lt;slug\u0026gt;.\u0026lt;lang\u0026gt;.md\u003c/code\u003e filename, not just an\nexplicit frontmatter \u003ccode\u003elang:\u003c/code\u003e. Default-language readings keep their content identity — no re-render.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eMulti-language posts (translations).\u003c/strong\u003e Set \u003ccode\u003elanguages:\u003c/code\u003e on the site and add a \u003ccode\u003e\u0026lt;slug\u0026gt;.\u0026lt;lang\u0026gt;.md\u003c/code\u003e\nfile (e.g. \u003ccode\u003emy-post.es.md\u003c/code\u003e) to publish a translation at \u003ccode\u003e/\u0026lt;lang\u0026gt;/\u0026lt;slug\u0026gt;/\u003c/code\u003e, linked to the original by\nits base slug. Translations emit \u003ccode\u003ehreflang\u003c/code\u003e alternates (with \u003ccode\u003ex-default\u003c/code\u003e); the \u003cstrong\u003epress\u003c/strong\u003e theme shows\na \u003cstrong\u003elanguage selector\u003c/strong\u003e in the post header and a dismissible \u0026quot;available in your language\u0026quot; banner\ndriven by the browser's preference (no forced redirect). Each translation is a normal post with its\nown reading/feeds/glossary/deck. See \u003ca href=\"/start/content/#multiple-languages-translations\"\u003eAuthoring content → Multiple languages\u003c/a\u003e.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eSlide decks: styled by the site theme + a fuller reader.\u003c/strong\u003e A published deck now links the active\ntheme's stylesheet and renders content in the theme's \u003ccode\u003e.prose\u003c/code\u003e class, so quotes, callouts, code,\ntables and Mermaid look like the site and theme authors can style \u003ccode\u003e.slide*\u003c/code\u003e themselves. The reader\ngained: \u003cstrong\u003etouch/swipe\u003c/strong\u003e navigation and on-screen \u003cstrong\u003eprev/next\u003c/strong\u003e buttons; on-screen \u003cstrong\u003epresenter\u003c/strong\u003e and\n\u003cstrong\u003efullscreen\u003c/strong\u003e toggles (so they work without a keyboard); a \u003cstrong\u003elight/dark\u003c/strong\u003e toggle (reusing the\ntheme's \u003ccode\u003edata-theme\u003c/code\u003e); a large \u003cstrong\u003emobile presenter card\u003c/strong\u003e (the notes fill the phone as a teleprompter\nwhile the slide shows on the big screen); and an \u003cstrong\u003eautocue\u003c/strong\u003e that auto-scrolls each slide's notes and\nauto-advances at a reading pace — adjustable live with \u003ccode\u003e+\u003c/code\u003e/\u003ccode\u003e−\u003c/code\u003e (or the on-screen slower/faster\nbuttons), shown in the counter and remembered. Stop with Back, restart from the button. Mermaid\nrenders lazily per slide (it can't measure a hidden one), and a \u003ccode\u003e\u0026lt;base href\u0026gt;\u003c/code\u003e fixes co-located\nasset URLs in the deck.\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"v0032\"\u003ev0.0.32\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eSlide decks render the post's content well by default.\u003c/strong\u003e A \u003cstrong\u003ecover slide\u003c/strong\u003e (title, description,\nauthor avatar/initials) leads; content is \u003cstrong\u003epaginated\u003c/strong\u003e to fit (blocks pack onto a slide, overflow\nspills to a continuation slide, an oversized code block truncates with a link back to the post,\nimages/video scale to fit); \u003cstrong\u003emath, diagrams and syntax highlighting hydrate\u003c/strong\u003e from the published\n\u003ccode\u003e/vendor\u003c/code\u003e assets; media (images/audio/video) stays \u003cstrong\u003eon the slide\u003c/strong\u003e, not in notes; callouts and\npull-quotes are styled. Prose paragraphs become the \u003cstrong\u003epresenter notes\u003c/strong\u003e (shown in presenter mode);\neverything else is on the slide — never both. The Downloads-box \u003cstrong\u003eSlides\u003c/strong\u003e link opens the deck in a\n\u003cstrong\u003enew tab\u003c/strong\u003e. New keys: \u003cstrong\u003eEnter\u003c/strong\u003e plays/pauses the slide's media, \u003cstrong\u003eEsc\u003c/strong\u003e closes the deck (back to\nthe post).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSlide decks (\u003ccode\u003eslides:\u003c/code\u003e).\u003c/strong\u003e A post can be projected into a themed slide deck, published at\n\u003ccode\u003e…/\u0026lt;slug\u0026gt;/slides/\u003c/code\u003e, linked from the Downloads box, and flagged with a marker in the listing. It's\nderived from the post (headings → slides/bullets, prose → speaker notes, other blocks on the\nslide); with JS it's a keyboard/swipe presentation (presenter notes, fullscreen), and with JS off\nthe same file reads as a long-form document. Configure with \u003ccode\u003eslides.enabled\u003c/code\u003e/\u003ccode\u003eslides.split\u003c/code\u003e at the\nsite level and override per post (\u003ccode\u003eslides: true\u003c/code\u003e/\u003ccode\u003efalse\u003c/code\u003e or the block form; overwrites by key).\nSplit targets: \u003ccode\u003eh1\u003c/code\u003e–\u003ccode\u003eh6\u003c/code\u003e, \u003ccode\u003ehr\u003c/code\u003e, \u003ccode\u003esplitslide\u003c/code\u003e, \u003ccode\u003eimage\u003c/code\u003e/\u003ccode\u003etable\u003c/code\u003e/\u003ccode\u003ecode\u003c/code\u003e/\u003ccode\u003emath\u003c/code\u003e/\u003ccode\u003ediagram\u003c/code\u003e/\u003ccode\u003eaudio\u003c/code\u003e/\n\u003ccode\u003evideo\u003c/code\u003e, and \u003ccode\u003etext:\u0026lt;match\u0026gt;\u003c/code\u003e. Inline markers \u003ccode\u003e\u0026lt;splitslide\u0026gt;\u003c/code\u003e, \u003ccode\u003e\u0026lt;slide\u0026gt;…\u0026lt;/slide\u0026gt;\u003c/code\u003e and \u003ccode\u003e\u0026lt;noslide\u0026gt;… \u0026lt;/noslide\u0026gt;\u003c/code\u003e mirror the \u003ccode\u003e\u0026lt;tts\u0026gt;\u003c/code\u003e family. See \u003ca href=\"/start/content/#slide-decks\"\u003eAuthoring content → Slide decks\u003c/a\u003e.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"v0031\"\u003ev0.0.31\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eBluesky: refresh a card via an atomic swap, only on \u003ccode\u003e--resync\u003c/code\u003e.\u003c/strong\u003e The earlier \u0026quot;edit in place\u0026quot;\nfor Bluesky (v0.0.29) was a no-op — Bluesky's AppView ignores record edits, so the public card\nnever changed. colophon now refreshes a Bluesky card by atomically deleting and recreating the\nrecord at the \u003cstrong\u003esame rkey\u003c/strong\u003e (\u003ccode\u003eapplyWrites\u003c/code\u003e): the card re-indexes and the \u003cstrong\u003epermalink is kept\u003c/strong\u003e,\nbut it's a new record so \u003cstrong\u003elikes/reposts/replies reset\u003c/strong\u003e and the timestamp updates. Because that's\nlossy, it runs \u003cstrong\u003eonly on \u003ccode\u003e--resync\u003c/code\u003e\u003c/strong\u003e (an explicit opt-in); automatic edit-on-change now \u003cstrong\u003eskips\u003c/strong\u003e\nBluesky with a note. \u003cstrong\u003eMastodon\u003c/strong\u003e still edits in place automatically (no engagement loss).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eAccessibility: a sweep toward WCAG AAA\u003c/strong\u003e (see the \u003ccode\u003ewcag-aaa-compliance\u003c/code\u003e decision). Engine: code\nblocks, Mermaid and display math are keyboard-focusable scroll regions (2.1.1); tables are wrapped\nin a focusable \u003ccode\u003e.table-scroll\u003c/code\u003e (semantics preserved, no \u003ccode\u003edisplay:block\u003c/code\u003e hack); GFM task-list\ncheckboxes get an \u003ccode\u003earia-label\u003c/code\u003e. Press theme: a visible keyboard-focus indicator on every control;\n\u003ccode\u003erole=\u0026quot;img\u0026quot;\u003c/code\u003e on the audio/attachment markers; the home page hero moved inside \u003ccode\u003e\u0026lt;main\u0026gt;\u003c/code\u003e; and a\ncontrast pass — \u003ccode\u003e--muted\u003c/code\u003e/\u003ccode\u003e--faint\u003c/code\u003e raised to ≥7:1 and a new \u003ccode\u003e--link\u003c/code\u003e token (≥7:1) for accent\ntext (links, inline code, badges, pull-quote attribution), with \u003ccode\u003e--accent\u003c/code\u003e kept for decoration.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"v0030\"\u003ev0.0.30\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eSyndication card descriptions fall back to a body excerpt.\u003c/strong\u003e A post with no \u003ccode\u003edescription:\u003c/code\u003e\nfrontmatter previously syndicated with an empty summary (a bare Bluesky/Mastodon card);\n\u003ccode\u003ebuild.Entries\u003c/code\u003e now mirrors the page — explicit \u003ccode\u003edescription:\u003c/code\u003e, else a short excerpt of the\nrendered body. Also fixes empty descriptions in feeds for such posts. Re-run\n\u003ccode\u003ecolophon syndicate --resync\u003c/code\u003e once to push the new descriptions onto existing cards.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003esyndicate\u003c/code\u003e skips, doesn't fail, an entry with no recorded silo URL.\u003c/strong\u003e Ledger entries posted\nvia a fire-and-forget driver (Bridgy) have no editable handle; \u003ccode\u003e--resync\u003c/code\u003e now reports them as\n\u003ccode\u003eskipped\u003c/code\u003e with a note instead of erroring the whole run non-zero.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"v0029\"\u003ev0.0.29\u003c/h2\u003e\n\u003ch3 id=\"content--themes\"\u003eContent \u0026amp; themes\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003ePull-quotes / epigraphs.\u003c/strong\u003e A \u003ccode\u003e\u0026gt; [!quote] Attribution\u003c/code\u003e callout renders as a semantic\n\u003ccode\u003e\u0026lt;figure class=\u0026quot;pullquote\u0026quot;\u0026gt;\u003c/code\u003e with the attribution as \u003ccode\u003e\u0026lt;figcaption\u0026gt;\u003c/code\u003e (omit it for an unattributed\nquote); the \u003cstrong\u003epress\u003c/strong\u003e theme styles it as a large display quote. See\n\u003ca href=\"/start/content/#markdown-support\"\u003eAuthoring content → Markdown support\u003c/a\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGlossary reference links.\u003c/strong\u003e A \u003ccode\u003eglossary.yaml\u003c/code\u003e term can carry reference links, rendered as\ncitation-style superscripts after the decorated term. See\n\u003ca href=\"/start/content/#glossary\"\u003eAuthoring content → Glossary\u003c/a\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTables are styled in press\u003c/strong\u003e (borders, padding, header underline, row hover, horizontal\nscroll on narrow screens). goldmark's per-column alignment is preserved.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003ecolophon serve --showcase\u003c/code\u003e.\u003c/strong\u003e Injects a built-in \u003ccode\u003e/showcase/\u003c/code\u003e page — embedded in the binary,\nnever written to your content — that renders \u003cem\u003eevery\u003c/em\u003e content feature (callouts, pull-quotes,\ntables, maths, diagrams, image/video/audio embeds, attachments, glossary, post hero/description/\naudio reading) in your active theme, with the source shown alongside. The single living\nreference for what a theme can style.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"generation-image--speech\"\u003eGeneration (image \u0026amp; speech)\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eNamed generation profiles.\u003c/strong\u003e Each modality (\u003ccode\u003egeneration.image\u003c/code\u003e / \u003ccode\u003egeneration.speech\u003c/code\u003e) takes a\ndefault block plus named \u003ccode\u003eprofiles:\u003c/code\u003e that inherit it and override only what they set. Select a\nprofile with the same key at every scope — \u003ccode\u003eimage_profile:\u003c/code\u003e / \u003ccode\u003espeech_profile:\u003c/code\u003e — on an\n\u003cem\u003eenvironment\u003c/em\u003e, in a \u003cem\u003epost's frontmatter\u003c/em\u003e, or per image via \u003ccode\u003e\u0026lt;gen:…?profile=name\u0026gt;\u003c/code\u003e; narrowest\nscope wins. Fully annotated in \u003ca href=\"/reference/config/\"\u003e\u003ccode\u003ecolophon.reference.yaml\u003c/code\u003e\u003c/a\u003e; see also\n\u003ca href=\"/guides/image-generation/\"\u003eImage \u0026amp; audio generation\u003c/a\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eProvider-agnostic pronunciation dictionaries\u003c/strong\u003e with a bundled British dict (\u003ccode\u003epronunciation_dict: en_GB\u003c/code\u003e): \u003ccode\u003eipa:\u003c/code\u003e entries render to each provider's phoneme mechanism (ElevenLabs uploads a\nversioned dictionary; MiniMax sends them inline), \u003ccode\u003esay:\u003c/code\u003e entries substitute as plain text.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSpeech: headings pause.\u003c/strong\u003e A heading now ends on a sentence boundary in the spoken reading, so\nit no longer runs into the next paragraph.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSpeech: the waveform is precomputed\u003c/strong\u003e from the same audio (no second render) and shipped as the\n\u003ccode\u003e\u0026lt;audio\u0026gt;.json\u003c/code\u003e sidecar; the in-browser visualiser is the fallback when it's absent.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"syndication-posse\"\u003eSyndication (POSSE)\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003eEdit a syndicated copy when the post changes.\u003c/strong\u003e The ledger stores a content fingerprint; a\nlater run edits the existing silo copy in place (Mastodon \u003ccode\u003ePUT\u003c/code\u003e, Bluesky \u003ccode\u003eputRecord\u003c/code\u003e) instead of\nskipping — keeping its permalink/likes/replies. Bridgy/command can't edit and are left as-is. The\nfirst run after upgrading \u003cstrong\u003ebackfills\u003c/strong\u003e fingerprints without editing.\u003c/p\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ccode\u003ecolophon syndicate --resync\u003c/code\u003e.\u003c/strong\u003e A one-shot that re-edits every already-syndicated copy to its\ncurrent content, ignoring fingerprints — to catch up copies created before the feature. Entries\nwith no recorded silo URL (e.g. posted via Bridgy) are skipped, not failed.\u003c/p\u003e\n\u003cp\u003eSee \u003ca href=\"/guides/syndication/#editing-a-syndicated-copy-when-the-post-changes\"\u003eSyndication (POSSE) → Editing a syndicated copy\u003c/a\u003e.\u003c/p\u003e\n\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/changelog.md\"\u003e\u003ccode\u003edocs/changelog.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-11-25T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/internals/",
      "url": "https://docs.colophon.blog/internals/",
      "title": "Internals \u0026 design notes",
      "summary": "Engineering design documents for colophon's subsystems — how search, federation, webmentions and the Obsidian source work inside.",
      "content_html": "\u003c!-- Generated by tools/gendocs from the colophon CLI — do not edit by hand. --\u003e\n\u003cp\u003eDesign documents for the systems behind colophon. These are \u003cstrong\u003eengineering notes\u003c/strong\u003e, kept for the curious and for contributors — they explain why the subsystems are shaped the way they are, and can run ahead of or behind the shipped code.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ca href=\"/internals/search/\"\u003eStatic search\u003c/a\u003e — the fully static lexical engine, index shards and the browser reader.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/internals/federation/\"\u003eFederation\u003c/a\u003e — IndieWeb, POSSE and syndication architecture.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/internals/webmention/\"\u003eWebmention\u003c/a\u003e — sending, receiving, caching and displaying mentions.\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/internals/obsidian/\"\u003eObsidian\u003c/a\u003e — publishing straight from a vault.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThe roadmap itself lives in the repository: \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/PLAN.md\"\u003edocs/PLAN.md\u003c/a\u003e.\u003c/p\u003e\n",
      "date_published": "2001-11-24T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/internals/federation/",
      "url": "https://docs.colophon.blog/internals/federation/",
      "title": "Design: federation (IndieWeb, POSSE, syndication)",
      "summary": "Goal: let a colophon blog participate fully in the social web — be followable, get replies/likes back, and cross-post to silos — while staying a static site. The organising…",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/design/federation.md — do not edit by hand. --\u003e\n\u003cdiv class=\"callout callout-note\" data-callout=\"note\"\u003e\n\u003cdiv class=\"callout-title\"\u003eInternal design note\u003c/div\u003e\n\u003cdiv class=\"callout-body\"\u003e\n\u003cp\u003eThis 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.\u003c/p\u003e\n\u003c/div\u003e\n\u003c/div\u003e\n\u003cblockquote\u003e\n\u003cp\u003eStatus: \u003cstrong\u003esubstrate shipped, the rest designed.\u003c/strong\u003e Built: microformats2 (h-entry/h-card/h-feed),\n\u003ccode\u003erel=me\u003c/code\u003e, RSS/Atom/JSON feeds, and \u003ccode\u003ealiases\u003c/code\u003e redirects (URL stability). Webmention is detailed in\n\u003ca href=\"/internals/webmention/\"\u003ewebmention.md\u003c/a\u003e; this is the umbrella — the posture, the reader/syndicator abstractions,\nPOSSE, WebSub, and the cross-cutting concerns. No POSSE/WebSub code yet. Relates to PLAN §10.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eGoal: let a colophon blog participate fully in the social web — be followable, get replies/likes\nback, and cross-post to silos — while staying a \u003cstrong\u003estatic site\u003c/strong\u003e. The organising principle:\u003c/p\u003e\n\u003ch2 id=\"posture-be-a-source-not-a-server\"\u003ePosture: be a source, not a server\u003c/h2\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eBe a…\u003c/th\u003e\n\u003cth\u003eMeans\u003c/th\u003e\n\u003cth\u003eStatic-friendly?\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eSource\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eemit mf2 + feeds + discovery tags; thin post-publish \u0026quot;notify\u0026quot;/\u0026quot;syndicate\u0026quot; steps; let hosted relays do the rest\u003c/td\u003e\n\u003ctd\u003e✅ our lane\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eServer\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003erun an ActivityPub/AT actor, a receiving endpoint, a reader/Microsub\u003c/td\u003e\n\u003ctd\u003e❌ needs a live server\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eBridgy Fed needs only \u003cstrong\u003emf2 + webmention, or an RSS/Atom feed\u003c/strong\u003e — both already emitted — to make\nthe \u003cem\u003esite itself\u003c/em\u003e followable from Mastodon/Bluesky. The heavy lifting is offloaded to relays; we\nemit standards and run small, decoupled senders.\u003c/p\u003e\n\u003ch2 id=\"abstractions-only-where-mechanisms-diverge\"\u003eAbstractions only where mechanisms diverge\u003c/h2\u003e\n\u003cp\u003ecolophon has two existing naming conventions, split by \u003cem\u003eshape\u003c/em\u003e: a \u003cstrong\u003elist of pluggable\ndestinations\u003c/strong\u003e uses \u003ccode\u003edriver\u003c/code\u003e (publishers and sources are both \u003ccode\u003e{id, driver, settings}\u003c/code\u003e), while the\n\u003cstrong\u003esingle external service that produces/serves content\u003c/strong\u003e for a modality uses \u003ccode\u003eprovider\u003c/code\u003e\n(generation). Federation adds one of each — and matches the convention by shape:\u003c/p\u003e\n\u003ch3 id=\"1-reader-driver--reading-webmentions-back\"\u003e1. Reader (\u003ccode\u003edriver\u003c/code\u003e) — reading webmentions back\u003c/h3\u003e\n\u003cp\u003eThere's one receiver per site, so this is the \u003cem\u003esingle-service\u003c/em\u003e shape → \u003ccode\u003eprovider\u003c/code\u003e, like generation.\nThe Webmention spec standardises \u003cem\u003ereceiving\u003c/em\u003e, not \u003cem\u003ereading back\u003c/em\u003e, so each receiver exposes a\ndifferent read API. Model it as a \u003ccode\u003eReader\u003c/code\u003e interface + a \u003ccode\u003edriver\u003c/code\u003e (default \u003ccode\u003ejf2\u003c/code\u003e,\nplus a \u003ccode\u003ecustom\u003c/code\u003e JF2 source for self-hosted/compatible), selected by config.\nBridgy \u003cem\u003ebackfeed\u003c/em\u003e arrives in your receiver as ordinary webmentions, so it is read through the same\nReader driver — not a separate abstraction. Full detail in \u003ca href=\"/internals/webmention/\"\u003ewebmention.md\u003c/a\u003e.\u003c/p\u003e\n\u003ch3 id=\"2-syndicator-driver--posse-cross-posting\"\u003e2. Syndicator (\u003ccode\u003edriver\u003c/code\u003e) — POSSE (cross-posting)\u003c/h3\u003e\n\u003cp\u003ePOSSE matters for anyone with real social reach, and Bridgy-only POSSE gives little control over\nper-network formatting/threading and depends on a relay — so native syndication is a first-class\ngoal. Each target is a \u003ccode\u003eSyndicator\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003etype Syndicator interface { Syndicate(ctx, post) (siloURL string, err error) }\n//   drivers (mirroring publishers/sources):\n//     mastodon – instance URL + access token (env); statuses + media API\n//     bluesky  – handle + app password (env); AT-proto createRecord + blob upload\n//     bridgy   – POST to brid.gy/publish, parse the created silo URL from the response\n//     command  – run a user command; stdout = silo URL (empty stdout = fire-and-forget webhook)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eSyndication is the \u003cem\u003elist-of-destinations\u003c/em\u003e shape, so it uses \u003cstrong\u003e\u003ccode\u003edriver\u003c/code\u003e\u003c/strong\u003e — each entry is\n\u003ccode\u003e{ id, driver, …settings }\u003c/code\u003e, byte-for-byte the \u003ccode\u003ePublisherConfig\u003c/code\u003e/\u003ccode\u003eSourceConfig\u003c/code\u003e shape (\u003ccode\u003eid\u003c/code\u003e +\n\u003ccode\u003edriver\u003c/code\u003e + remaining settings). \u003ccode\u003edriver\u003c/code\u003e is the concrete mechanism (\u003ccode\u003emastodon\u003c/code\u003e, \u003ccode\u003ebluesky\u003c/code\u003e,\n\u003ccode\u003ebridgy\u003c/code\u003e, \u003ccode\u003ecommand\u003c/code\u003e); \u003ccode\u003eid\u003c/code\u003e is an arbitrary handle that \u003ccode\u003esyndicate:\u003c/code\u003e (per-env and per-post)\nreferences. A site may configure \u003cstrong\u003emany\u003c/strong\u003e syndicators (the \u003ccode\u003esyndication:\u003c/code\u003e list, like the publishers\nlist). Bridgy is simply \u003ccode\u003edriver: bridgy\u003c/code\u003e with a \u003ccode\u003enetwork:\u003c/code\u003e field naming the silo to publish to —\nnot a special \u0026quot;via\u0026quot;. (It really is \u0026quot;publishers, for silos.\u0026quot;)\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eThe \u003ccode\u003ecommand\u003c/code\u003e syndicator is also a publish webhook.\u003c/strong\u003e Mirroring the \u003ccode\u003ecommand\u003c/code\u003e \u003cem\u003epublisher\u003c/em\u003e, it\nruns a user-defined command per post with interpolated placeholders (\u003ccode\u003e{url}\u003c/code\u003e canonical, \u003ccode\u003e{title}\u003c/code\u003e,\n\u003ccode\u003e{slug}\u003c/code\u003e, \u003ccode\u003e{summary}\u003c/code\u003e, \u003ccode\u003e{tags}\u003c/code\u003e, \u003ccode\u003e{json}\u003c/code\u003e = path to a metadata file) and env for secrets — so a\nuser can wire up anything (a Discord/Slack webhook, a Bluesky CLI, an n8n flow, a custom API). The\ntrick that makes it both a syndicator and a generic hook: \u003cstrong\u003estdout is the silo URL.\u003c/strong\u003e If the command\nprints a URL it's recorded in the ledger and rendered as \u003ccode\u003eu-syndication\u003c/code\u003e; if it prints nothing it's\na \u003cstrong\u003efire-and-forget publish webhook\u003c/strong\u003e. No separate hook system — the same interface covers both.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eBridgy is transparent for receiving, a syndicator for sending\u003c/strong\u003e — the two are unrelated:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cem\u003eBackfeed (inbound):\u003c/em\u003e Bridgy polls your connected silo accounts on its own schedule and POSTs\nwebmentions to your receiver. colophon never calls Bridgy; replies arrive and are read via the\nReader. \u003cstrong\u003eNot modeled\u003c/strong\u003e — it's invisible infrastructure.\u003c/li\u003e\n\u003cli\u003e\u003cem\u003ePOSSE (outbound):\u003c/em\u003e Bridgy does \u003cstrong\u003enot\u003c/strong\u003e auto-publish new posts, so automating cross-posting means\ncolophon \u003cstrong\u003eactively\u003c/strong\u003e POSTs to \u003ccode\u003ebrid.gy/publish\u003c/code\u003e and records the returned silo URL. That's an\nexplicit syndication action → it's a \u003ccode\u003eSyndicator\u003c/code\u003e driver like the rest. (Implementation note:\nBridgy verifies the source links to \u003ccode\u003ebrid.gy/publish/{silo}\u003c/code\u003e, so the driver includes that link\nin the source it sends.)\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eSo \u003ccode\u003edriver: bridgy\u003c/code\u003e buys cross-posting to networks without a native driver (or without holding\ntheir API tokens yourself), at the cost of per-network formatting control.\u003c/p\u003e\n\u003cp\u003eSyndication runs as a \u003cstrong\u003epost-publish step\u003c/strong\u003e (\u003ccode\u003ecolophon syndicate\u003c/code\u003e, after the canonical URL is live),\ndecoupled and best-effort like webmention send — it never blocks the deploy.\u003c/p\u003e\n\u003ch2 id=\"the-syndication-ledger--and-why-its-different-from-the-webmention-cache\"\u003eThe syndication ledger — and why it's different from the webmention cache\u003c/h2\u003e\n\u003cp\u003eA sidecar ledger (e.g. \u003ccode\u003e.colophon/syndication.json\u003c/code\u003e) maps \u003ccode\u003epost → {network: {url, time}}\u003c/code\u003e. It is\nthe idempotency key (don't repost on rebuild), the \u003ccode\u003eu-syndication\u003c/code\u003e data (\u0026quot;Also posted on…\u0026quot;), and the\nbackfeed-pairing key.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eCrucial contrast with webmentions:\u003c/strong\u003e the webmention export is \u003cem\u003eregenerable\u003c/em\u003e (re-fetch from the\nreceiver), so an empty CI runner is fine. The syndication ledger is \u003cstrong\u003eauthoritative and NOT\nregenerable\u003c/strong\u003e — you cannot reliably re-derive \u0026quot;which Mastodon post is the copy of this entry.\u0026quot; So:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eThe ledger must be durable\u003c/strong\u003e — committed to the repo (it's small and append-mostly) or kept in\npersistent storage. A fresh runner \u003cem\u003ewithout\u003c/em\u003e it would \u003cstrong\u003ere-POSSE everything (double-post)\u003c/strong\u003e.\u003c/li\u003e\n\u003cli\u003eTherefore \u003ccode\u003esyndicate\u003c/code\u003e must \u003cstrong\u003erefuse to run, or run dry, when the ledger is absent/stale\u003c/strong\u003e unless\nexplicitly forced — the opposite of the webmention \u0026quot;graceful when empty\u0026quot; rule.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThis is the single biggest operational gotcha in the whole federation surface; the design must make\ndouble-posting structurally hard (commit the ledger; idempotent against it; \u003ccode\u003e--dry-run\u003c/code\u003e default in\nCI without a ledger).\u003c/p\u003e\n\u003ch2 id=\"what-does-not-need-an-abstraction\"\u003eWhat does NOT need an abstraction\u003c/h2\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003ePiece\u003c/th\u003e\n\u003cth\u003eWhy\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003eWebmention \u003cstrong\u003esend\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eone spec-standard algorithm (discover endpoint, POST)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003erel=webmention\u003c/code\u003e, \u003ccode\u003erel=hub\u003c/code\u003e tags\u003c/td\u003e\n\u003ctd\u003econfig strings\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eBridgy backfeed\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003elands in your receiver → read via the Reader\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003eWebSub ping\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eone protocol; the hub is a config URL\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003ch2 id=\"websub-instant-feed-push\"\u003eWebSub (instant feed push)\u003c/h2\u003e\n\u003cp\u003eEmit \u003ccode\u003e\u0026lt;link rel=\u0026quot;hub\u0026quot; href=\u0026quot;…\u0026quot;\u0026gt;\u003c/code\u003e in the feeds and \u003cstrong\u003eping the hub on publish\u003c/strong\u003e so readers and\naggregators update immediately instead of polling. Thin: a discovery tag + one POST in the\npost-publish step. Hubs are hosted (Superfeedr, websubhub.com). No provider abstraction.\u003c/p\u003e\n\u003ch2 id=\"cross-cutting-considerations-apply-regardless-of-which-pieces-ship\"\u003eCross-cutting considerations (apply regardless of which pieces ship)\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eWhere work runs.\u003c/strong\u003e Static = no server; every action is a build/CI step or a hosted relay.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eState \u0026amp; idempotency.\u003c/strong\u003e The syndication ledger (durable, committed) and webmention sent-cache;\nnever repeat actions on a rebuild; a rebuild is not a new post.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eLoops \u0026amp; dedup.\u003c/strong\u003e Don't webmention-loop; dedup backfed responses; \u003ccode\u003eu-syndication\u003c/code\u003e ties copies to\nthe canonical.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eEdits/deletes.\u003c/strong\u003e Default: post-once; silo copies are point-in-time and don't track edits\n(optional propagation later). Canonical is the source of truth; \u003ccode\u003ealiases\u003c/code\u003e keep old URLs resolving.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eIdentity \u0026amp; SEO.\u003c/strong\u003e \u003ccode\u003erel=me\u003c/code\u003e, \u003ccode\u003eu-syndication\u003c/code\u003e, canonical URLs; copies cite the canonical to avoid\nduplicate-content problems.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSecrets.\u003c/strong\u003e Per-network tokens, Bridgy OAuth (their side), webmention.io token, WebSub — all\nenv-only; more features = more CI-secret surface.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePrivacy/moderation.\u003c/strong\u003e Displaying backfed third-party content (avatars, replies) → block/allow\nlists, avatar caching/proxying, opt-in, spam handling.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFailure/decoupling.\u003c/strong\u003e Network steps flake/throttle → best-effort, non-blocking, retryable, and\ndecoupled from the content build (the separate publish pipeline).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePer-network formatting.\u003c/strong\u003e Char limits (Mastodon ~500 instance-variable, Bluesky 300), link-back,\nhashtags, media + alt, threading long posts; per-post custom syndication text.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSelective syndication.\u003c/strong\u003e \u003ccode\u003esyndicate:\u003c/code\u003e frontmatter chooses targets (opt-in/out) per post.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eDisplay freshness.\u003c/strong\u003e JS-rendered mentions are \u003cem\u003enot\u003c/em\u003e tied to page regeneration: the browser\nfetches the \u003ccode\u003e_mentions/\u003c/code\u003e asset live, so a scheduled \u003ccode\u003ewebmention publish\u003c/code\u003e (refresh that asset, no\nsite rebuild) updates them near-live. Only the no-JS \u003cem\u003ebake\u003c/em\u003e path is as-fresh-as-the-last-build.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"environments-read-everywhere-write-only-where-enabled\"\u003eEnvironments: read everywhere, write only where enabled\u003c/h2\u003e\n\u003cp\u003eFederation splits cleanly across environments, and the split is a \u003cstrong\u003esafety property\u003c/strong\u003e:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eWebmention reading is site-domain-scoped, so it's shared.\u003c/strong\u003e webmention.io keys mentions by your\n\u003cem\u003eproduction\u003c/em\u003e target URLs, so the \u003ccode\u003ewebmention\u003c/code\u003e config lives at the \u003cstrong\u003esite\u003c/strong\u003e level and every\nenvironment inherits it. A \u003cstrong\u003epreview build reads the same production mentions\u003c/strong\u003e (previewing how\nreal responses look) — no separate receiver, no extra config.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSyndication is environment-gated and off by default\u003c/strong\u003e, like \u003ccode\u003eallow_publish\u003c/code\u003e. Which syndicators\nfire is an \u003cem\u003eenvironment\u003c/em\u003e decision (\u003ccode\u003eenvironments[].syndicate: [ids]\u003c/code\u003e); an env that omits it —\nnotably \u003cstrong\u003epreview/draft\u003c/strong\u003e — never cross-posts. \u003ccode\u003ecolophon syndicate\u003c/code\u003e also takes the same kind of\ndeploy latch. This makes double-posting (or POSSEing a draft) structurally impossible from preview.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eSo: \u003cstrong\u003eread in every environment, write only where explicitly enabled.\u003c/strong\u003e\u003c/p\u003e\n\u003ch2 id=\"config-sketch\"\u003eConfig sketch\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    federation:\n      feeds: [rss, atom, json]\n      websub:\n        hub: https://pubsubhubbub.superfeedr.com     # rel=hub + ping on publish\n      indieweb:\n        webmention:\n          endpoint: https://webmention.io/blog.example.com/webmention   # advertised rel=webmention\n          source:   https://webmention.io/api/mentions.jf2              # read API (JF2 reader)\n      syndication:                             # a list — many syndicators per site (id + driver + settings)\n        - { id: mastodon, driver: mastodon, instance: https://hachyderm.io }   # token from env MASTODON_TOKEN\n        - { id: bluesky,  driver: bluesky,  handle: me.bsky.social }           # app password from env\n        - { id: discord,  driver: command,  command: \u0026#34;curl -sf -X POST $DISCORD_WEBHOOK -d @{json}\u0026#34; }  # webhook: no stdout → fire-and-forget\n        - { id: twitter,  driver: bridgy,   network: twitter }                 # via brid.gy/publish/twitter\n\nenvironments:\n  - name: production\n    publish: [cf, r2]\n    syndicate: [mastodon, bluesky, discord, twitter]   # which syndicators fire here\n  - name: preview\n    publish: [cf-preview]\n    # no `syndicate:` → preview never cross-posts, but still reads the production webmentions above\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003ePer post: \u003ccode\u003esyndicate: [mastodon, bluesky]\u003c/code\u003e (else all the env's configured ids), \u003ccode\u003esyndicate: false\u003c/code\u003e\nto opt out, and an optional custom blurb (\u003ccode\u003esyndicate_text:\u003c/code\u003e). Resolved silo URLs are written to the\nledger and rendered as \u003ccode\u003eu-syndication\u003c/code\u003e links on the post; manually-added \u003ccode\u003esyndication:\u003c/code\u003e frontmatter\nis honoured too. (\u003ccode\u003ewebmention\u003c/code\u003e sits at the site level, so every environment — preview included —\nreads the same production mentions; \u003ccode\u003esyndicate:\u003c/code\u003e is per-environment, so only the envs that list it\never post.)\u003c/p\u003e\n\u003ch2 id=\"phasing\"\u003ePhasing\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eTier 1 — cheap static + thin send:\u003c/strong\u003e \u003ccode\u003erel=webmention\u003c/code\u003e tag, \u003ccode\u003eu-syndication\u003c/code\u003e, WebSub \u003ccode\u003erel=hub\u003c/code\u003e +\nping, webmention send. Unlocks Bridgy + Bridgy Fed + instant feeds with little stateful code.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTier 2 — receive/display:\u003c/strong\u003e webmention \u003ccode\u003efetch\u003c/code\u003e + \u003ccode\u003e_mentions/\u003c/code\u003e assets (\u003ca href=\"/internals/webmention/\"\u003ewebmention.md\u003c/a\u003e).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTier 3 — POSSE:\u003c/strong\u003e the \u003ccode\u003eSyndicator\u003c/code\u003e abstraction + sidecar ledger + \u003ccode\u003ecolophon syndicate\u003c/code\u003e, with\n\u003ccode\u003ebridgy\u003c/code\u003e, \u003ccode\u003emastodon\u003c/code\u003e, \u003ccode\u003ebluesky\u003c/code\u003e, \u003ccode\u003ecommand\u003c/code\u003e drivers and per-network formatting.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"out-of-scope\"\u003eOut of scope\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eRunning an ActivityPub/AT actor or a reader/Microsub (delegate to Bridgy Fed / hosted readers).\u003c/li\u003e\n\u003cli\u003eA self-hosted webmention receiver (use webmention.io hosted or self-host \u003cem\u003eit\u003c/em\u003e).\u003c/li\u003e\n\u003cli\u003ePropagating edits/deletes to silo copies (point-in-time by default).\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/design/federation.md\"\u003e\u003ccode\u003edocs/design/federation.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-11-23T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/internals/obsidian/",
      "url": "https://docs.colophon.blog/internals/obsidian/",
      "title": "Design: publishing from Obsidian",
      "summary": "Goal: a \"publish / preview\" flow from an Obsidian vault with the smallest integration footprint, keeping colophon a plain CLI that reads markdown and publishes.",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/design/obsidian.md — do not edit by hand. --\u003e\n\u003cdiv class=\"callout callout-note\" data-callout=\"note\"\u003e\n\u003cdiv class=\"callout-title\"\u003eInternal design note\u003c/div\u003e\n\u003cdiv class=\"callout-body\"\u003e\n\u003cp\u003eThis 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.\u003c/p\u003e\n\u003c/div\u003e\n\u003c/div\u003e\n\u003cblockquote\u003e\n\u003cp\u003eStatus: \u003cstrong\u003ethin design\u003c/strong\u003e · relates to PLAN §7 (Sources)\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eGoal: a \u0026quot;publish / preview\u0026quot; flow from an Obsidian vault with the \u003cstrong\u003esmallest integration\nfootprint\u003c/strong\u003e, keeping colophon a plain CLI that reads markdown and publishes.\u003c/p\u003e\n\u003ch2 id=\"key-constraint-ci-cannot-see-the-live-vault\"\u003eKey constraint: CI cannot see the live vault\u003c/h2\u003e\n\u003cp\u003eA vault lives on your machine (and Obsidian Sync). A CI/CD runner has no access to it.\nSo any CI-based publish requires the publishable content to \u003cstrong\u003ereach a git repo the\nrunner can clone\u003c/strong\u003e. The important realization:\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003ecolophon's \u003ccode\u003eobsidian\u003c/code\u003e source reads \u003ccode\u003e.md\u003c/code\u003e files from a directory — it does \u003cstrong\u003enot\u003c/strong\u003e\nrequire the Obsidian app. A checked-out repo folder is just as readable as a live\nvault.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eSo \u0026quot;give CI access to the vault\u0026quot; = \u0026quot;commit the publishable markdown to a repo.\u0026quot; The\nObsidian → colophon normalization (publish-flag filter, wikilink resolution) then runs\nin CI, at build time, by colophon — on the committed \u003ccode\u003e.md\u003c/code\u003e files.\u003c/p\u003e\n\u003ch2 id=\"two-deployment-models-same-source-driver\"\u003eTwo deployment models (same source driver)\u003c/h2\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003e\u003c/th\u003e\n\u003cth\u003eLocal\u003c/th\u003e\n\u003cth\u003eGit + CI\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003eWhere colophon runs\u003c/td\u003e\n\u003ctd\u003eyour machine\u003c/td\u003e\n\u003ctd\u003eCI runner\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eReads\u003c/td\u003e\n\u003ctd\u003ethe live vault path\u003c/td\u003e\n\u003ctd\u003ethe committed repo snapshot\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eSecrets (\u003ccode\u003eCLOUDFLARE_API_TOKEN\u003c/code\u003e)\u003c/td\u003e\n\u003ctd\u003eon the device\u003c/td\u003e\n\u003ctd\u003eCI secret\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eDevices\u003c/td\u003e\n\u003ctd\u003edesktop only\u003c/td\u003e\n\u003ctd\u003eany (mobile Obsidian Git can push)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eHistory\u003c/td\u003e\n\u003ctd\u003enone implicit\u003c/td\u003e\n\u003ctd\u003eevery publish is a commit\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eTrigger\u003c/td\u003e\n\u003ctd\u003ea button / \u003ccode\u003ecolophon publish\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003egit push\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eBoth use the \u003cstrong\u003esame \u003ccode\u003eobsidian\u003c/code\u003e source\u003c/strong\u003e (reads a folder of \u003ccode\u003e.md\u003c/code\u003e). The only difference\nis where colophon runs and where the files are. \u003ccode\u003eserve\u003c/code\u003e (local, hot-reload) is the\ninstant-preview path in both models.\u003c/p\u003e\n\u003ch2 id=\"the-chain-git--ci\"\u003eThe chain (git + CI)\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003eObsidian (write, publish: true)\n   │  commit + push the publishable subset   ← Obsidian Git plugin (existing)\n   ▼\nGit repo  ──on push──▶  CI: colophon publish --env \u0026lt;production|preview\u0026gt;\n                              (secrets in CI; obsidian source reads committed md)\n   ▼\nCloudflare Pages   (PR/branch → preview env; main → production)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eThis composes with existing colophon pieces: environments→branches→CF environments,\n\u003ccode\u003epublish_after\u003c/code\u003e + \u003ccode\u003enext-build-time\u003c/code\u003e (a scheduled CI run publishes embargoed posts when\ndue), and per-deploy \u003ccode\u003eprune\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"the-button\"\u003eThe \u0026quot;button\u0026quot;\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eNo custom plugin (recommended start):\u003c/strong\u003e use the existing \u003cstrong\u003eObsidian Git\u003c/strong\u003e plugin's\ncommit+push. colophon needs nothing.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eThin status plugin (polish, later):\u003c/strong\u003e a small plugin that triggers commit+push then\nshows the resulting deploy URL/status. It never touches secrets or runs colophon.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"what-colophon-provides\"\u003eWhat colophon provides\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003eobsidian\u003c/code\u003e source (done): folder read + \u003ccode\u003epublish: true\u003c/code\u003e whitelist; folder structure →\nslug; deletes/renames flow through build reconciliation.\u003c/li\u003e\n\u003cli\u003eA documented CI workflow (~15 lines) — to add.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003enext-build-time\u003c/code\u003e (done) for scheduled embargo publishing.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"open--dependencies\"\u003eOpen / dependencies\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eVault privacy:\u003c/strong\u003e commit only a \u003ccode\u003eBlog/\u003c/code\u003e subfolder or rely on the publish-flag; private\nvaults → private repo.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eWikilinks\u003c/strong\u003e (\u003ccode\u003e[[note]]\u003c/code\u003e, \u003ccode\u003e[[note|alias]]\u003c/code\u003e): resolved at build via a cross-document\nlink map. \u003cem\u003eIn progress.\u003c/em\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eEmbeds\u003c/strong\u003e (\u003ccode\u003e![[image.png]]\u003c/code\u003e, \u003ccode\u003e![[note]]\u003c/code\u003e): image embeds depend on the \u003cstrong\u003easset\npipeline\u003c/strong\u003e (PLAN §6a) to copy/host the file; note transclusion is a later step. Until\nthen, note links resolve and embeds are left untouched.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/design/obsidian.md\"\u003e\u003ccode\u003edocs/design/obsidian.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-11-22T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/internals/search/",
      "url": "https://docs.colophon.blog/internals/search/",
      "title": "Design: static search",
      "summary": "Goal: public-site search that is fully static (no server, no external service), low bandwidth (never load the whole index into the browser), and incremental-friendly (a content…",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/design/search.md — do not edit by hand. --\u003e\n\u003cdiv class=\"callout callout-note\" data-callout=\"note\"\u003e\n\u003cdiv class=\"callout-title\"\u003eInternal design note\u003c/div\u003e\n\u003cdiv class=\"callout-body\"\u003e\n\u003cp\u003eThis 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.\u003c/p\u003e\n\u003c/div\u003e\n\u003c/div\u003e\n\u003cblockquote\u003e\n\u003cp\u003eStatus: \u003cstrong\u003ev1 implemented\u003c/strong\u003e · relates to PLAN §8 (search), §9 (publishers). The lexical engine\n(module \u003ccode\u003egithub.com/jmylchreest/colophon/search\u003c/code\u003e), the browser reader, the build emit, the\n\u003ccode\u003ecolophon search\u003c/code\u003e CLI, and the press theme box are built; fuzzy and semantic remain designed\nseams. Replaced the \u003ccode\u003eSearchCmd\u003c/code\u003e stub (\u003ccode\u003eSyncCmd\u003c/code\u003e still stubbed).\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eGoal: public-site search that is \u003cstrong\u003efully static\u003c/strong\u003e (no server, no external service), \u003cstrong\u003elow\nbandwidth\u003c/strong\u003e (never load the whole index into the browser), and \u003cstrong\u003eincremental-friendly\u003c/strong\u003e (a\ncontent edit rewrites a handful of files, not the index). The same engine powers the\n\u003ccode\u003ecolophon search\u003c/code\u003e CLI and persona exemplar retrieval.\u003c/p\u003e\n\u003cp\u003eThe design borrows its \u003cem\u003earchitecture\u003c/em\u003e from \u003ca href=\"https://github.com/Pagefind/pagefind\"\u003ePagefind\u003c/a\u003e\n(MIT — reviewed, not vendored) — build-from-output, a sharded word index, per-page fragments,\ntwo-stage fetch — but uses \u003cstrong\u003eour own format\u003c/strong\u003e and a \u003cstrong\u003evanilla-JS reader\u003c/strong\u003e, so we own every byte\nand track no private binary spec.\u003c/p\u003e\n\u003ch2 id=\"why-not-just-use-pagefind\"\u003eWhy not just use Pagefind\u003c/h2\u003e\n\u003cp\u003ePagefind is excellent but is a Rust/WASM toolchain — bundling it breaks colophon's\nsingle-binary principle, and there is \u003cstrong\u003eno Go port of its indexer\u003c/strong\u003e (even Hugo, a Go SSG, shells\nout to the Rust binary). Targeting Pagefind's on-disk format from Go is worse: it's an internal,\nversioned, CBOR layout with an index↔WASM version handshake — we'd be the sole maintainer of a\nreverse-engineered emitter chasing every release. So: borrow the ideas, own the format.\u003c/p\u003e\n\u003ch2 id=\"surfaces-and-engine-sharing\"\u003eSurfaces and engine sharing\u003c/h2\u003e\n\u003cp\u003eOne analyzer + one BM25 definition, three surfaces:\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eSurface\u003c/th\u003e\n\u003cth\u003eWhere\u003c/th\u003e\n\u003cth\u003eConsumes\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003ePublic site search\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003ebrowser, vanilla JS\u003c/td\u003e\n\u003ctd\u003ethe static sharded index we emit\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003e\u003ccode\u003ecolophon search\u003c/code\u003e\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eGo CLI\u003c/td\u003e\n\u003ctd\u003ethe same in-memory inverted index, server-side\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003ePersona exemplars\u003c/strong\u003e (§8)\u003c/td\u003e\n\u003ctd\u003eGo\u003c/td\u003e\n\u003ctd\u003ethe same analyzer + BM25 (zero-config default)\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eThe browser \u003cstrong\u003enever\u003c/strong\u003e runs Go/Bleve/WASM — it reads our static files with a small JS scorer.\nBleve's on-disk (Scorch) format has no JS reader and isn't shippable; if Bleve is used at all\nit stays \u003cstrong\u003ebuild/CLI-side only\u003c/strong\u003e. The reusable core is \u003cstrong\u003ehand-rolled and stdlib-only\u003c/strong\u003e (see\nPackaging) so the inverted index \u003cem\u003eis\u003c/em\u003e the shared structure across all three surfaces.\u003c/p\u003e\n\u003cp\u003eThe one hard correctness rule: the \u003cstrong\u003eanalyzer must be identical\u003c/strong\u003e in the Go builder and the JS\nreader, or a query for \u0026quot;running\u0026quot; won't match an indexed \u0026quot;run\u0026quot;. The analyzer is therefore\nspecified once (below) and implemented twice against that spec.\u003c/p\u003e\n\u003ch2 id=\"packaging--module-boundary\"\u003ePackaging \u0026amp; module boundary\u003c/h2\u003e\n\u003cp\u003eA \u003cstrong\u003eseparate Go module in this repo\u003c/strong\u003e, colophon-branded, wired with a root \u003ccode\u003ego.work\u003c/code\u003e:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003egithub.com/jmylchreest/colophon            (the SSG — application module)\ngithub.com/jmylchreest/colophon/search     (the engine — its own go.mod)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eSeparate module\u003c/strong\u003e, not an in-\u003ccode\u003ecolophon\u003c/code\u003e package: gives the engine its \u003cstrong\u003eown lean \u003ccode\u003ego.mod\u003c/code\u003e\u003c/strong\u003e\n(a \u003ccode\u003ego get …/search\u003c/code\u003e must not drag in pongo2 / goldmark / koanf / go-git) and \u003cstrong\u003eindependent\nversion tags\u003c/strong\u003e (\u003ccode\u003esearch/vX.Y.Z\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNot under \u003ccode\u003einternal/\u003c/code\u003e\u003c/strong\u003e — that's compiler-private; reuse needs a public path.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003ego.work\u003c/code\u003e\u003c/strong\u003e (committed at the repo root, listing \u003ccode\u003e.\u003c/code\u003e and \u003ccode\u003e./search\u003c/code\u003e) so colophon builds\nagainst the local copy with no published tag, while co-development stays one-repo / one-PR.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eHand-rolled, zero-dependency core\u003c/strong\u003e — the engine ships its own inverted index + analyzer,\nstdlib only. This is what makes it attractive to adopt. (colophon may still use Bleve\n\u003cem\u003eseparately\u003c/em\u003e, CLI-side, but the published engine does not depend on it.)\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThe reusable unit is \u003cstrong\u003ethree artifacts bound by one spec\u003c/strong\u003e:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eGo builder/query module\u003c/strong\u003e (\u003ccode\u003e…/colophon/search\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eJS reader\u003c/strong\u003e — a single dependency-free ES module (\u003ccode\u003esearch.js\u003c/code\u003e), also publishable to npm.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eThe format + analyzer spec\u003c/strong\u003e — the real public contract. This document is the \u003cem\u003erationale\u003c/em\u003e;\nthe \u003cstrong\u003enormative, language-neutral specification\u003c/strong\u003e lives in\n\u003ca href=\"../../search/SPEC.md\"\u003e\u003ccode\u003esearch/SPEC.md\u003c/code\u003e\u003c/a\u003e (with \u003ccode\u003esearch/README.md\u003c/code\u003e as the adopter entry point),\nprecise enough to implement a conformant reader or builder in any language against the\ncommitted test vectors.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"engine-api-source--and-fs-agnostic\"\u003eEngine API (source- and FS-agnostic)\u003c/h3\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-go\"\u003epackage search\n\n// A Doc is anything indexable — the engine knows nothing about colophon pages.\ntype Doc struct {\n    ID    string            // stable, caller-provided (colophon uses the page URL/slug)\n    URL   string            // result link\n    Title string\n    Body  string            // already-extracted plain text\n    Meta  map[string]string // shown in the result card; not indexed unless requested\n}\n\ntype BuildOptions struct {\n    Analyzer  Analyzer      // default: SimpleAnalyzer (below)\n    ShardFunc ShardFunc     // default: fixed lexical ranges\n    BM25      Params        // k1, b\n}\n\n// Build writes the static index (manifest + shards + fragments) to dst. dst is an\n// abstraction (a dir on disk for most users; colophon routes it through a publisher).\nfunc Build(docs iter.Seq[Doc], dst Writer, opts BuildOptions) (Manifest, error)\n\n// Open mounts an emitted index for server-side querying (the CLI surface).\nfunc Open(fsys fs.FS) (*Index, error)\nfunc (*Index) Search(q string, limit int) ([]Result, error)\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003eWriter\u003c/code\u003e is a minimal \u003ccode\u003ePut(name string, b []byte) error\u003c/code\u003e — the same shape as the publisher\n\u003ccode\u003eFileWriter\u003c/code\u003e, so colophon can emit straight through routing, and a standalone user can write to\na directory.\u003c/p\u003e\n\u003ch2 id=\"the-analyzer-the-contract\"\u003eThe analyzer (the contract)\u003c/h2\u003e\n\u003cp\u003eSpecified once; implemented identically in Go and JS. \u003cstrong\u003ev1 is deliberately trivial\u003c/strong\u003e to make\nparity self-evident and keep the core \u003cstrong\u003estdlib-only\u003c/strong\u003e:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eLowercase (Unicode-aware: Go \u003ccode\u003eunicode.ToLower\u003c/code\u003e / JS \u003ccode\u003etoLowerCase\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003eSplit on any run of non-(letter|number) → tokens (Go \u003ccode\u003eunicode.IsLetter/IsNumber\u003c/code\u003e via\n\u003ccode\u003estrings.FieldsFunc\u003c/code\u003e / JS \u003ccode\u003e/[^\\p{L}\\p{N}]+/u\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eNo NFC normalization, no stop-words, no stemming\u003c/strong\u003e in v1.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eThat's it — two implementations of one pure function \u003ccode\u003eAnalyze(string) []string\u003c/code\u003e. A shared\ngolden-vector fixture (\u003ccode\u003etestdata/analyzer.json\u003c/code\u003e: input → expected tokens) is run by \u003cstrong\u003eboth\u003c/strong\u003e the\nGo and JS test suites, so drift is caught mechanically.\u003c/p\u003e\n\u003cp\u003eNFC is deferred deliberately: stdlib Go has no NFC, and adding it would pull in \u003ccode\u003ex/text\u003c/code\u003e —\nagainst the zero-dep goal. The consequence is that \u003cem\u003edecomposed\u003c/em\u003e Unicode (e.g. \u003ccode\u003ee\u003c/code\u003e+combining\naccent) tokenizes differently from \u003cem\u003ecomposed\u003c/em\u003e (\u003ccode\u003eé\u003c/code\u003e); content from normal editors is composed, and\nthe golden fixture stays ASCII to avoid encoding ambiguity. NFC + a matched Go/JS stemmer arrive\ntogether behind an analyzer-id bump (\u003ccode\u003esimple-1\u003c/code\u003e → \u003ccode\u003e…-2\u003c/code\u003e), which a stale reader can detect.\u003c/p\u003e\n\u003cp\u003eStemming (e.g. a matched Go+JS Snowball/Porter2 pair) and stop words are \u003cstrong\u003edeferred\u003c/strong\u003e — added\nonly as a matched pair, behind a version bump of the analyzer id recorded in the manifest.\u003c/p\u003e\n\u003ch2 id=\"index-format\"\u003eIndex format\u003c/h2\u003e\n\u003cp\u003eEmitted as plain static files under a configurable base (default \u003ccode\u003e/_search/\u003c/code\u003e):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e_search/\n  manifest.json                 # the mutable root — small, short-TTL\n  index/\u0026lt;range\u0026gt;.\u0026lt;hash\u0026gt;.json.gz  # postings shards — immutable, content-addressed\n  fragment/\u0026lt;docid\u0026gt;.\u0026lt;hash\u0026gt;.json  # per-result cards — immutable, content-addressed\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003e\u003ccode\u003emanifest.json\u003c/code\u003e\u003c/strong\u003e — routing + scoring constants, loaded once (a few KB):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-json\"\u003e{\n  \u0026#34;v\u0026#34;: 1,\n  \u0026#34;analyzer\u0026#34;: \u0026#34;simple-1\u0026#34;,\n  \u0026#34;bm25\u0026#34;: { \u0026#34;k1\u0026#34;: 1.2, \u0026#34;b\u0026#34;: 0.75 },\n  \u0026#34;docs\u0026#34;: 412,\n  \u0026#34;avgdl\u0026#34;: 680.4,\n  \u0026#34;shards\u0026#34;: [\n    { \u0026#34;lo\u0026#34;: \u0026#34;a\u0026#34;,  \u0026#34;hi\u0026#34;: \u0026#34;cz\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;index/a-cz.7c1e9b.json.gz\u0026#34; },\n    { \u0026#34;lo\u0026#34;: \u0026#34;d\u0026#34;,  \u0026#34;hi\u0026#34;: \u0026#34;gz\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;index/d-gz.2f4a01.json.gz\u0026#34; }\n  ],\n  \u0026#34;fragments\u0026#34;: { \u0026#34;...\u0026#34;: \u0026#34;docid → fragment/\u0026lt;docid\u0026gt;.\u0026lt;hash\u0026gt;.json\u0026#34; }\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003cstrong\u003eA postings shard\u003c/strong\u003e — \u003ccode\u003eterm → [[docId, termFreq]]\u003c/code\u003e (positions omitted in v1 → no phrase search):\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-json\"\u003e{ \u0026#34;tiger\u0026#34;: \u0026#91;[7,1],[88,2]], \u0026#34;tigris\u0026#34;: \u0026#91;[7,3]] }\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eDocIds in postings are \u003cstrong\u003esmall integers\u003c/strong\u003e interned from the stable string ID via a table in the\nmanifest — compact in postings, while the \u003cem\u003einterning is deterministic from sorted stable IDs\u003c/em\u003e\n(see below). Fragments are keyed by the string docId.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eA fragment\u003c/strong\u003e — everything needed to render one result, fetched only for shown hits:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-json\"\u003e{ \u0026#34;url\u0026#34;: \u0026#34;/posts/tigris/\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;Publishing to Tigris\u0026#34;, \u0026#34;excerpt\u0026#34;: \u0026#34;…\u0026#34;, \u0026#34;meta\u0026#34;: {\u0026#34;type\u0026#34;:\u0026#34;post\u0026#34;} }\n\u003c/code\u003e\u003c/pre\u003e\n\u003ch2 id=\"extensibility-shared-substrate--pluggable-index-types\"\u003eExtensibility: shared substrate + pluggable index types\u003c/h2\u003e\n\u003cp\u003eThe format separates a \u003cstrong\u003esubstrate\u003c/strong\u003e (shared by every search mode) from \u003cstrong\u003eindex types\u003c/strong\u003e layered\nover it. This is the seam that lets fuzzy and semantic search be added later as \u003cem\u003eadditive\nartifacts\u003c/em\u003e, never a reformat.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eSubstrate (always present):\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eDoc identity\u003c/strong\u003e — the stable string ID ↔ interned int table (in the manifest).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eFragments\u003c/strong\u003e — per-doc result cards (\u003ccode\u003efragment/…\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eManifest\u003c/strong\u003e — the mutable root, listing which index types are present and where their shards live.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eIndex types\u003c/strong\u003e (each optional; each sharded + content-addressed + deterministic the same way):\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eType\u003c/th\u003e\n\u003cth\u003eMaps\u003c/th\u003e\n\u003cth\u003eStatus\u003c/th\u003e\n\u003cth\u003eAdds\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003elexical\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eterm → [docId, tf] (BM25)\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003ev1\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eindex/\u003c/code\u003e shards\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003efuzzy\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003etrigram → [termId]\u003c/td\u003e\n\u003ctd\u003eopt-in, additive\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etrigram/\u003c/code\u003e shards\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003esemantic\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003edocId/chunk → vector (+ ANN)\u003c/td\u003e\n\u003ctd\u003efuture, additive\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003evector/\u003c/code\u003e shards\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eThe manifest gains one optional section per present type; the Go query layer and JS reader\ndispatch on what's there. Turning a type on is a build flag plus more emitted files — the\nsubstrate, the postings format, and existing files are untouched. \u003ccode\u003eBuildOptions\u003c/code\u003e gains \u003ccode\u003eFuzzy bool\u003c/code\u003e (and later \u003ccode\u003eSemantic …\u003c/code\u003e) accordingly.\u003c/p\u003e\n\u003ch3 id=\"fuzzy--typo-tolerance-n-gram--levenshtein\"\u003eFuzzy / typo-tolerance (n-gram + Levenshtein)\u003c/h3\u003e\n\u003cp\u003eThe optional \u003ccode\u003efuzzy\u003c/code\u003e type is a \u003cstrong\u003echaracter-trigram index\u003c/strong\u003e (\u003ccode\u003etrigram → terms\u003c/code\u003e), sharded by\ntrigram range like everything else. Query path, when enabled and an exact match yields too few\nhits: decompose the query term into trigrams → fetch those trigram shards → gather candidate\nterms by trigram overlap → keep those within a bounded \u003cstrong\u003eLevenshtein\u003c/strong\u003e distance (computed in JS\nover the small candidate set) → fetch the candidates' postings shards → BM25, optionally\ndown-weighted by edit distance. Trigrams derive from the \u003cem\u003esame\u003c/em\u003e analyzer output, so there's no\nnew analysis contract. Opt-in because it roughly doubles index size.\u003c/p\u003e\n\u003cp\u003eTwo near-free relatives of lexical-range sharding: \u003cstrong\u003eprefix/autocomplete\u003c/strong\u003e (shards are sorted, so\na prefix hits one/few shards via binary search) and \u003cstrong\u003esubstring\u003c/strong\u003e (falls out of the trigram index).\u003c/p\u003e\n\u003ch2 id=\"sharding--fixed-lexical-ranges\"\u003eSharding — fixed lexical ranges\u003c/h2\u003e\n\u003cp\u003eShards are bucketed by \u003cstrong\u003efixed, stable lexical term ranges\u003c/strong\u003e (\u003ccode\u003ea–cz\u003c/code\u003e, \u003ccode\u003ed–gz\u003c/code\u003e, …), \u003cstrong\u003enot\u003c/strong\u003e\nPagefind's fixed-\u003cem\u003ecount\u003c/em\u003e chunks. Rationale: fixed-count chunks shift their split points as\nvocabulary grows, cascading rewrites across many shards; fixed ranges mean a new term lands in\nits existing bucket and only that bucket changes. Cost: uneven shard sizes — handled by a\ndeterministic rule that \u003cstrong\u003esub-splits only over-large ranges\u003c/strong\u003e (e.g. \u003ccode\u003ea\u003c/code\u003e → \u003ccode\u003eaa–am\u003c/code\u003e, \u003ccode\u003ean–az\u003c/code\u003e),\nwhich is itself stable given the same vocabulary.\u003c/p\u003e\n\u003ch2 id=\"determinism--incrementality\"\u003eDeterminism \u0026amp; incrementality\u003c/h2\u003e\n\u003cp\u003eThe point: an edit should rewrite \u003cstrong\u003eas few files as possible\u003c/strong\u003e, so the incremental publisher\n(content-hash diff + orphan prune, already built) uploads almost nothing and the CDN caches the\nrest forever. Five composing rules:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eContent-addressed filenames\u003c/strong\u003e — every shard and fragment is named by a hash of its bytes.\nUnchanged content → identical name → publisher sees no change → no upload; and the file can be\nserved \u003ccode\u003eCache-Control: immutable, max-age=1y\u003c/code\u003e. The \u003cstrong\u003emanifest is the only mutable file\u003c/strong\u003e (it\nmaps logical keys → current hashes): a \u003cem\u003emutable root over an immutable, content-addressed\ntree\u003c/em\u003e (git's model).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eStable doc IDs, never positional.\u003c/strong\u003e Postings key off a stable per-doc ID (colophon: the\npage URL). Adding/removing a post must not renumber the others. Integer interning for\ncompactness is assigned by \u003cstrong\u003esorted stable-ID order recorded in the manifest\u003c/strong\u003e — deterministic\nand stateless (no committed id-map; honors §8 \u0026quot;regenerable, not committed\u0026quot;). \u003cem\u003e(Note: a pure\ninsert still renumbers ints after it; if that churn proves costly we revisit with a\nprev-manifest-seeded allocator. v1 keeps it stateless.)\u003c/em\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eStable shard boundaries\u003c/strong\u003e (fixed lexical ranges, above) — vocabulary growth doesn't reshuffle.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eCanonical serialization\u003c/strong\u003e — sorted keys, stable number formatting, fixed field order, and\n\u003cstrong\u003egzip with mtime=0 + fixed level\u003c/strong\u003e. Without this, \u0026quot;unchanged\u0026quot; content re-hashes every build\n(gzip embeds a timestamp by default). This is what makes the content-addressing actually hold.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePostings/presentation split\u003c/strong\u003e — volatile display data (excerpt, title styling, meta) lives\nin \u003cstrong\u003efragments\u003c/strong\u003e; postings are just \u003ccode\u003eterm → [id, tf]\u003c/code\u003e. A cosmetic edit touches one fragment and\n\u003cstrong\u003ezero shards\u003c/strong\u003e; a body edit touches that fragment plus only the shards for the terms that\nactually changed.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003cstrong\u003eInherent churn (accepted):\u003c/strong\u003e the manifest (small, by design); hot-term shards (\u003ccode\u003ethe\u003c/code\u003e, \u003ccode\u003eand\u003c/code\u003e)\non most edits (a few files, not the index). \u003cstrong\u003eOrphans\u003c/strong\u003e (superseded content-addressed files) are\nremoved by the publisher's existing \u003ccode\u003edelete_orphaned\u003c/code\u003e; the manifest is marked \u003ccode\u003eProtected\u003c/code\u003e so it\nis never deleted mid-swap.\u003c/p\u003e\n\u003cp\u003eTypical \u0026quot;edit one post\u0026quot; outcome: \u003cstrong\u003e1 new fragment + 1 changed manifest + a few hot-term shards\u003c/strong\u003e,\neverything else byte-identical and skipped.\u003c/p\u003e\n\u003ch3 id=\"multi-deployment-sharing--the-protection-trade-off\"\u003eMulti-deployment sharing \u0026amp; the protection trade-off\u003c/h3\u003e\n\u003cp\u003eSeveral deployments (sites × environments) can publish to \u003cstrong\u003eone\u003c/strong\u003e object store. Only the manifest\nis per-deployment — its name is a short hash of \u003ccode\u003e(siteID, env)\u003c/code\u003e (\u003ccode\u003emanifest-\u0026lt;hash\u0026gt;.json\u003c/code\u003e; the bare\n\u003ccode\u003emanifest.json\u003c/code\u003e is the no-site/no-env default) — so the mutable roots don't collide. The\ncontent-addressed shards/fragments are \u003cstrong\u003eshared\u003c/strong\u003e: identical content across deployments dedupes to\none object, and each deployment's reader loads its own manifest (told via \u003ccode\u003edata-search-manifest\u003c/code\u003e).\u003c/p\u003e\n\u003cp\u003eTo make that safe, the whole \u003ccode\u003e_search/\u003c/code\u003e prefix is exempt from orphan-deletion: each publisher's\n\u003ccode\u003eProtected(name)\u003c/code\u003e returns true for it, and the incremental planner deletes a deployed object only\nwhen it is \u003cem\u003eboth\u003c/em\u003e absent from the current build \u003cem\u003eand\u003c/em\u003e not protected. So a deployment never prunes\nanother's shards or manifest — it only writes its own manifest and adds shards.\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003eTODO (revisit): garbage collection for \u003ccode\u003e_search/\u003c/code\u003e.\u003c/strong\u003e The cost of the protection is that\nsuperseded shards/fragments (and the pre-hash \u003ccode\u003emanifest.json\u003c/code\u003e) accumulate, never auto-pruned —\ntiny (~200 B each) and deduped, but unbounded over time. A proper fix is a \u003cstrong\u003emark-and-sweep GC\u003c/strong\u003e:\nread \u003cem\u003eevery\u003c/em\u003e live \u003ccode\u003emanifest-*.json\u003c/code\u003e in the store, union the shards/fragments they reference, and\ndelete the \u003ccode\u003e_search/\u003c/code\u003e objects nothing references. This can't be the per-publish \u003ccode\u003edelete_orphaned\u003c/code\u003e\n(which only sees one deployment's set); it wants to be an explicit pass (e.g. \u003ccode\u003ecolophon search gc\u003c/code\u003e\n/ \u003ccode\u003epublish --gc\u003c/code\u003e). Related: the protected-prefix list is currently \u003cstrong\u003ehardcoded and duplicated\u003c/strong\u003e\nacross the r2/s3/local \u003ccode\u003eProtected\u003c/code\u003e methods — worth centralizing (and possibly making\nconfigurable) at the same time.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003ch2 id=\"browser-query-flow\"\u003eBrowser query flow\u003c/h2\u003e\n\u003cp\u003eThe whole reader is ~a screen of dependency-free JS:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eFetch \u003ccode\u003emanifest.json\u003c/code\u003e once (cache in memory).\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eanalyze()\u003c/code\u003e the query (the shared analyzer).\u003c/li\u003e\n\u003cli\u003eFor each term, binary-search \u003ccode\u003eshards\u003c/code\u003e for its range, fetch \u003cstrong\u003eonly that shard\u003c/strong\u003e (dedupe + cache).\u003c/li\u003e\n\u003cli\u003eBM25 over the loaded postings:\n\u003ccode\u003eidf = ln(1 + (N − df + 0.5)/(df + 0.5))\u003c/code\u003e,\n\u003ccode\u003escore += idf · tf·(k1+1) / (tf + k1·(1 − b + b·dl/avgdl))\u003c/code\u003e\n(\u003ccode\u003eN\u003c/code\u003e, \u003ccode\u003eavgdl\u003c/code\u003e, per-shard \u003ccode\u003edf\u003c/code\u003e, per-doc \u003ccode\u003edl\u003c/code\u003e all come from the manifest/shard).\u003c/li\u003e\n\u003cli\u003eSort, take top-\u003ccode\u003elimit\u003c/code\u003e, fetch \u003cstrong\u003eonly those\u003c/strong\u003e fragments, render.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eMemory at any instant = manifest + the shards the query touched + the visible fragments. Never\nthe whole index.\u003c/p\u003e\n\u003ch2 id=\"progressive-enhancement--theme-integration\"\u003eProgressive enhancement \u0026amp; theme integration\u003c/h2\u003e\n\u003cp\u003eSearch ships \u003cstrong\u003eonly when enabled\u003c/strong\u003e (like the glossary ships only when used) and is a\n\u003cstrong\u003eprogressive enhancement\u003c/strong\u003e (consistent with the raw-block contract): the search box degrades to\na plain link to an archive/index page without JS, and enhances to live search when \u003ccode\u003esearch.js\u003c/code\u003e\nloads. The \u003ccode\u003esearch.js\u003c/code\u003e + CSS are theme/engine-emitted assets; the index files are emitted by the\nbuild (and routable like any other output — so the index can even live on an object store while\nHTML is on Pages).\u003c/p\u003e\n\u003ch2 id=\"semantic--a-future-index-type-over-the-same-substrate\"\u003eSemantic — a future index type over the same substrate\u003c/h2\u003e\n\u003cp\u003ev1 is \u003cstrong\u003elexical only\u003c/strong\u003e, but semantic is designed-in as the \u003ccode\u003esemantic\u003c/code\u003e index type (above), not a\nparallel system. It emits per-chunk \u003cstrong\u003eembedding vectors\u003c/strong\u003e as content-addressed, sharded files\nreusing the \u003cem\u003esame\u003c/em\u003e doc identity and fragments — added alongside lexical, never instead.\u003c/p\u003e\n\u003cp\u003eThe only genuinely new cost is \u003cstrong\u003equery-time embedding\u003c/strong\u003e: the browser needs a model or a query\nendpoint; the \u003cem\u003eindex\u003c/em\u003e slots into the existing file model. Scaling options that fit the sharded\ndesign: small corpora load all vectors (brute-force cosine); larger ones use IVF-style \u003cstrong\u003ecentroid\nprefiltering\u003c/strong\u003e (centroids in the manifest → fetch only the nearest clusters' vector shards), or\nthe §8 pure-Go \u003cstrong\u003eHNSW\u003c/strong\u003e behind the \u003ccode\u003eRetriever\u003c/code\u003e interface. The CLI semantic path (§8 \u003ccode\u003evectors.f32\u003c/code\u003e)\nis the same vectors consumed in Go. None of this is in v1 — but the substrate makes it additive.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eEmbedder-parity contract\u003c/strong\u003e (the embedding analog of the analyzer contract): the build-side\nembedder (Go) and the query-side embedder (browser) must be the \u003cstrong\u003esame model\u003c/strong\u003e, or query vectors\nwon't share the doc vectors' space. The recommended future embedder is therefore \u003cstrong\u003estatic\nembeddings (Model2Vec / \u003ccode\u003epotion-*\u003c/code\u003e)\u003c/strong\u003e: a token→vector lookup table that is implementable\nidentically in Go and JS from one shared weights file (golden-vector tested, like the analyzer),\nruns in pure code with \u003cstrong\u003eno ONNX/WASM runtime on either side\u003c/strong\u003e, and is a few MB rather than ~30MB.\nIt trades ~10–20% quality vs a MiniLM transformer — acceptable because semantic is used as a\n\u003cstrong\u003ehybrid recall/rerank assist over lexical BM25\u003c/strong\u003e, not a replacement. A full transformers.js\nMiniLM stays the higher-quality opt-in fallback.\u003c/p\u003e\n\u003ch2 id=\"key-decisions\"\u003eKey decisions\u003c/h2\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eDecision\u003c/th\u003e\n\u003cth\u003eChoice\u003c/th\u003e\n\u003cth\u003eRationale\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003eBuild vs runtime\u003c/td\u003e\n\u003ctd\u003eBuild-time static index\u003c/td\u003e\n\u003ctd\u003eFully static, no server (§8)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eFormat\u003c/td\u003e\n\u003ctd\u003eOur own JSON(.gz)\u003c/td\u003e\n\u003ctd\u003eOwn every byte; no Pagefind-format/version coupling\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eBrowser runtime\u003c/td\u003e\n\u003ctd\u003eVanilla JS scorer\u003c/td\u003e\n\u003ctd\u003eNo WASM; fine at blog/medium scale; ours to maintain\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eArchitecture\u003c/td\u003e\n\u003ctd\u003eSubstrate + pluggable index types\u003c/td\u003e\n\u003ctd\u003eFuzzy/semantic become additive, not a reformat\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eIndex shape\u003c/td\u003e\n\u003ctd\u003eSharded inverted (BM25)\u003c/td\u003e\n\u003ctd\u003eNever load the whole index; low bandwidth\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eFuzzy\u003c/td\u003e\n\u003ctd\u003eOpt-in trigram index + Levenshtein filter\u003c/td\u003e\n\u003ctd\u003eTypo tolerance without bloating the default index\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eSharding\u003c/td\u003e\n\u003ctd\u003eFixed lexical ranges\u003c/td\u003e\n\u003ctd\u003eStable boundaries → minimal rewrites on edit\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eFilenames\u003c/td\u003e\n\u003ctd\u003eContent-addressed\u003c/td\u003e\n\u003ctd\u003eIncremental publish + immutable CDN caching\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eDoc IDs\u003c/td\u003e\n\u003ctd\u003eStable (URL-derived)\u003c/td\u003e\n\u003ctd\u003eEdits don't renumber → postings stay stable\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eSerialization\u003c/td\u003e\n\u003ctd\u003eCanonical, gzip mtime=0\u003c/td\u003e\n\u003ctd\u003eIdentical content → identical bytes (determinism)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eAnalyzer\u003c/td\u003e\n\u003ctd\u003eSimple (no stemming) v1\u003c/td\u003e\n\u003ctd\u003eTrivial Go/JS parity; golden-vector tested\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003ePackaging\u003c/td\u003e\n\u003ctd\u003eSeparate module, same repo, \u003ccode\u003ego.work\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eDep isolation + own tags; co-dev stays cheap\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eEngine deps\u003c/td\u003e\n\u003ctd\u003eHand-rolled, stdlib-only\u003c/td\u003e\n\u003ctd\u003eAdoptable library; Bleve (if any) stays CLI-side\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eSemantic\u003c/td\u003e\n\u003ctd\u003eCLI-side only\u003c/td\u003e\n\u003ctd\u003ePublic semantic needs a model at query time (§8)\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003ch2 id=\"acceptance-criteria\"\u003eAcceptance criteria\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cinput disabled=\"\" type=\"checkbox\" aria-label=\"Not completed\"\u003e \u003ccode\u003esearch.Build\u003c/code\u003e emits manifest + shards + fragments to a \u003ccode\u003eWriter\u003c/code\u003e; re-running on unchanged\ninput produces \u003cstrong\u003ebyte-identical\u003c/strong\u003e shard/fragment files (determinism).\u003c/li\u003e\n\u003cli\u003e\u003cinput disabled=\"\" type=\"checkbox\" aria-label=\"Not completed\"\u003e Editing one doc changes only its fragment, the manifest, and the shards for its changed\nterms — all other files byte-identical.\u003c/li\u003e\n\u003cli\u003e\u003cinput disabled=\"\" type=\"checkbox\" aria-label=\"Not completed\"\u003e A query loads the manifest + only the shards for its terms (verified by fetch count), and\nfetches fragments only for displayed results.\u003c/li\u003e\n\u003cli\u003e\u003cinput disabled=\"\" type=\"checkbox\" aria-label=\"Not completed\"\u003e BM25 ranking from the JS reader matches the Go \u003ccode\u003eIndex.Search\u003c/code\u003e ranking on a shared fixture.\u003c/li\u003e\n\u003cli\u003e\u003cinput disabled=\"\" type=\"checkbox\" aria-label=\"Not completed\"\u003e The shared analyzer golden-vector fixture passes in \u003cstrong\u003eboth\u003c/strong\u003e Go and JS suites.\u003c/li\u003e\n\u003cli\u003e\u003cinput disabled=\"\" type=\"checkbox\" aria-label=\"Not completed\"\u003e \u003ccode\u003ecolophon search --json\u003c/code\u003e returns ranked results from the same engine (replaces the stub).\u003c/li\u003e\n\u003cli\u003e\u003cinput disabled=\"\" type=\"checkbox\" aria-label=\"Not completed\"\u003e With JS disabled, the search UI degrades to a working archive link.\u003c/li\u003e\n\u003cli\u003e\u003cinput disabled=\"\" type=\"checkbox\" aria-label=\"Not completed\"\u003e \u003ccode\u003ego get github.com/jmylchreest/colophon/search\u003c/code\u003e pulls a lean module (no SSG deps).\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"files-to-create\"\u003eFiles to create\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003esearch/go.mod\u003c/code\u003e, root \u003ccode\u003ego.work\u003c/code\u003e — the module + workspace.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003esearch/analyzer.go\u003c/code\u003e (+ \u003ccode\u003etestdata/analyzer.json\u003c/code\u003e) — the analyzer spec + golden vectors.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003esearch/index.go\u003c/code\u003e — inverted index, BM25, \u003ccode\u003eBuild\u003c/code\u003e, sharding, canonical serialization.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003esearch/query.go\u003c/code\u003e — \u003ccode\u003eOpen\u003c/code\u003e / \u003ccode\u003eIndex.Search\u003c/code\u003e (CLI surface).\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003esearch/format.go\u003c/code\u003e — manifest/shard/fragment types + content-addressing.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003esearch/search.js\u003c/code\u003e (+ test) — the browser reader; shares the analyzer + golden vectors.\u003c/li\u003e\n\u003cli\u003ecolophon side: \u003ccode\u003einternal/build/search.go\u003c/code\u003e (extract page text → \u003ccode\u003eDoc\u003c/code\u003es → \u003ccode\u003eBuild\u003c/code\u003e via a routed\nWriter), wire \u003ccode\u003eSearchCmd\u003c/code\u003e to \u003ccode\u003esearch.Open(...).Search\u003c/code\u003e, theme assets + a \u003ccode\u003esearch\u003c/code\u003e partial.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"releasing--using-it-elsewhere\"\u003eReleasing / using it elsewhere\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003e./search\u003c/code\u003e is its own Go module (\u003ccode\u003egithub.com/jmylchreest/colophon/search\u003c/code\u003e) kept in this repo;\ncolophon builds against it locally via the \u003ccode\u003ego.work\u003c/code\u003e workspace, so it needs no published tag\nfor development. For external consumers it's published from the monorepo as a \u003cstrong\u003enested module\u003c/strong\u003e:\nthe release workflow tags \u003ccode\u003esearch/v\u0026lt;colophon-version\u0026gt;\u003c/code\u003e whenever \u003ccode\u003e./search\u003c/code\u003e has changed since its\nlast tag (mirroring the colophon release version — gaps are expected). Others then\n\u003ccode\u003ego get github.com/jmylchreest/colophon/search@search/v\u0026lt;version\u0026gt;\u003c/code\u003e. Those \u003ccode\u003esearch/v*\u003c/code\u003e tags don't\ntrigger the binary-release workflow (its trigger is \u003ccode\u003ev*\u003c/code\u003e). If the engine ever warrants a\nstandalone identity (e.g. \u003ccode\u003egithub.com/jmylchreest/n\u003c/code\u003e), it would move to its own repo, since a\nmodule's path must match its repository URL.\u003c/p\u003e\n\u003ch2 id=\"out-of-scope-future\"\u003eOut of scope (future)\u003c/h2\u003e\n\u003cp\u003eStemming/stop-words (matched Go+JS pair, analyzer-id bump); positions → phrase/proximity;\nfilters/facets + sorts; sub-splitting heuristics tuning; in-browser semantic; a published npm\npackage for \u003ccode\u003esearch.js\u003c/code\u003e.\u003c/p\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/design/search.md\"\u003e\u003ccode\u003edocs/design/search.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-11-21T00:00:00Z"
    },
    {
      "id": "https://docs.colophon.blog/internals/webmention/",
      "url": "https://docs.colophon.blog/internals/webmention/",
      "title": "Design: webmention",
      "summary": "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…",
      "content_html": "\u003c!-- Generated by tools/gendocs from docs/design/webmention.md — do not edit by hand. --\u003e\n\u003cdiv class=\"callout callout-note\" data-callout=\"note\"\u003e\n\u003cdiv class=\"callout-title\"\u003eInternal design note\u003c/div\u003e\n\u003cdiv class=\"callout-body\"\u003e\n\u003cp\u003eThis 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.\u003c/p\u003e\n\u003c/div\u003e\n\u003c/div\u003e\n\u003cblockquote\u003e\n\u003cp\u003eStatus: \u003cstrong\u003ebuilt\u003c/strong\u003e · relates to PLAN §10 (Federation \u0026amp; IndieWeb), §6 (build pipeline). The whole\ndesign ships: the \u003ccode\u003e\u0026lt;link rel=\u0026quot;webmention\u0026quot;\u0026gt;\u003c/code\u003e tag; \u003ccode\u003ewebmention send\u003c/code\u003e (sent-cache); \u003ccode\u003ewebmention fetch\u003c/code\u003e (jf2 reader → \u003ccode\u003e_mentions/\u003c/code\u003e cache); per-site \u003ccode\u003edisplay.mode\u003c/code\u003e (live/asset/disabled) with the\nthemed responses block + \u003ccode\u003ementions.js\u003c/code\u003e; \u003ccode\u003ewebmention publish\u003c/code\u003e (decoupled \u003ccode\u003e_mentions/\u003c/code\u003e-only deploy);\nand the committed glob blocklist + \u003ccode\u003ecolophon-moderate-mentions\u003c/code\u003e skill. Code in\n\u003ccode\u003einternal/webmention/\u003c/code\u003e, \u003ccode\u003einternal/build/{mentions,webmention}.go\u003c/code\u003e, \u003ccode\u003einternal/cli/webmention.go\u003c/code\u003e.\nDeferred: semantic moderation and searchable mentions (see those sections).\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eGoal: let a colophon site participate in \u003ca href=\"https://www.w3.org/TR/webmention/\"\u003eWebmention\u003c/a\u003e — the\nW3C standard for \u0026quot;site A notified site B that it linked to / replied to / liked B's post\u0026quot; —\nwhile staying \u003cstrong\u003efully static\u003c/strong\u003e (no server, no database) and keeping received data in \u003cstrong\u003eclean,\nseparate assets\u003c/strong\u003e rather than mixed into the author's content. Two halves: \u003cstrong\u003esending\u003c/strong\u003e (we tell\nothers we linked them) and \u003cstrong\u003ereceiving + display\u003c/strong\u003e (others' mentions appear under our posts).\u003c/p\u003e\n\u003cp\u003eIt deliberately reuses five patterns colophon already has, rather than inventing new ones — see\n\u003ca href=\"#fit-with-existing-design\"\u003eFit with existing design\u003c/a\u003e.\u003c/p\u003e\n\u003ch2 id=\"why-a-separate-command-not-part-of-build\"\u003eWhy a separate command (not part of build)\u003c/h2\u003e\n\u003cp\u003eThe two operations run at different points in the lifecycle, so neither belongs inside \u003ccode\u003ebuild\u003c/code\u003e:\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eOperation\u003c/th\u003e\n\u003cth\u003eWhen\u003c/th\u003e\n\u003cth\u003eWhy\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003e\u003ccode\u003ecolophon webmention send\u003c/code\u003e\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eafter\u003c/strong\u003e \u003ccode\u003epublish\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003ethe source URL must be live so the receiver can fetch it back and verify the link\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003e\u003ccode\u003ecolophon webmention fetch\u003c/code\u003e\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003ebefore\u003c/strong\u003e \u003ccode\u003ebuild\u003c/code\u003e, or standalone/scheduled\u003c/td\u003e\n\u003ctd\u003epulls received mentions into the local cache so a bake build can read them\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003e\u003ccode\u003ecolophon webmention publish\u003c/code\u003e\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eon its own cadence\u003c/strong\u003e (e.g. cron)\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003efetch\u003c/code\u003e + push \u003cstrong\u003eonly\u003c/strong\u003e the \u003ccode\u003e_mentions/\u003c/code\u003e prefix to the object store, decoupled from the site build — see \u003ca href=\"#separate-publish-pipeline\"\u003eSeparate publish pipeline\u003c/a\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eSo \u003ccode\u003ewebmention\u003c/code\u003e is a command group alongside \u003ccode\u003ebuild\u003c/code\u003e/\u003ccode\u003epublish\u003c/code\u003e. A typical CI flow:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ecolophon webmention fetch     # refresh received mentions → committed cache\ncolophon build                # emit _mentions/ assets (+ bake for no-JS themes)\ncolophon publish --env production --allow-publish\ncolophon webmention send      # now that the new post is live, notify the sites it links to\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003efetch\u003c/code\u003e is also fine to run on a schedule (cron) to keep mentions fresh without a content change.\u003c/p\u003e\n\u003ch2 id=\"receiving-data-as-a-separate-asset\"\u003eReceiving: data as a separate asset\u003c/h2\u003e\n\u003cp\u003eWe don't run a server, so we can't accept inbound POSTs. Mentions are received by a \u003cstrong\u003ehosted\nreceiver\u003c/strong\u003e — \u003ca href=\"https://webmention.io\"\u003ewebmention.io\u003c/a\u003e (free; the page advertises it via\n\u003ccode\u003e\u0026lt;link rel=\u0026quot;webmention\u0026quot;\u0026gt;\u003c/code\u003e). \u003ccode\u003efetch\u003c/code\u003e reads them back through its JSON API (token in \u003ccode\u003e{env:…}\u003c/code\u003e,\nnever config) and normalises them into \u003cstrong\u003eone JSON file per post\u003c/strong\u003e, served as its own asset\nnamespace — exactly mirroring the \u003ccode\u003e_search/\u003c/code\u003e index:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003e_mentions/\u0026lt;post-path\u0026gt;.json     e.g. _mentions/posts/hello-world.json\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eEach file is a small, normalised list (not the raw webmention.io payload):\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-json\"\u003e{\n  \u0026#34;target\u0026#34;: \u0026#34;https://blog.example.com/posts/hello-world/\u0026#34;,\n  \u0026#34;updated\u0026#34;: \u0026#34;2026-06-22T10:00:00Z\u0026#34;,\n  \u0026#34;mentions\u0026#34;: [\n    { \u0026#34;type\u0026#34;: \u0026#34;like\u0026#34;,   \u0026#34;author\u0026#34;: {\u0026#34;name\u0026#34;: \u0026#34;Ada\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;https://ada.example\u0026#34;, \u0026#34;photo\u0026#34;: \u0026#34;https://…/ada.jpg\u0026#34;},\n      \u0026#34;url\u0026#34;: \u0026#34;https://ada.example/likes/1\u0026#34;, \u0026#34;published\u0026#34;: \u0026#34;2026-06-21T09:00:00Z\u0026#34; },\n    { \u0026#34;type\u0026#34;: \u0026#34;reply\u0026#34;,  \u0026#34;author\u0026#34;: {\u0026#34;name\u0026#34;: \u0026#34;Bob\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;https://bob.example\u0026#34;, \u0026#34;photo\u0026#34;: \u0026#34;…\u0026#34;},\n      \u0026#34;url\u0026#34;: \u0026#34;https://bob.example/notes/2\u0026#34;, \u0026#34;published\u0026#34;: \u0026#34;…\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;Nice post!\u0026#34; }\n  ]\n}\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003etype\u003c/code\u003e is normalised to \u003ccode\u003elike | repost | reply | mention\u003c/code\u003e (from the sender's mf2 / wm-property).\u003c/li\u003e\n\u003cli\u003eauthor fields come from the sender's \u003ccode\u003eh-card\u003c/code\u003e — which is \u003cem\u003ewhy\u003c/em\u003e mf2 shipped first.\u003c/li\u003e\n\u003cli\u003eThe build emits these to the output tree under \u003ccode\u003e_mentions/\u003c/code\u003e, \u003cstrong\u003erouted to R2\u003c/strong\u003e like \u003ccode\u003e_search/\u003c/code\u003e,\nand they get \u003cstrong\u003eCORS for free\u003c/strong\u003e from \u003ccode\u003epublish --create\u003c/code\u003e (same \u003ccode\u003eGET/HEAD\u003c/code\u003e rule the JS search\nindex already relies on for cross-origin fetch).\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003efetch\u003c/code\u003e writes a local cache (\u003ccode\u003e.colophon/cache/webmentions/\u003c/code\u003e). This cache is \u003cstrong\u003enot\u003c/strong\u003e treated\nlike the generated-image cache — see below.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eOne JSON-per-post (no sharded manifest like search) because a post page already knows its own key.\u003c/p\u003e\n\u003ch3 id=\"not-reproducible--and-thats-fine\"\u003eNot reproducible — and that's fine\u003c/h3\u003e\n\u003cp\u003eUnlike the gen-image cache (a deterministic function of the prompt, committed for reproducible\nbuilds), received mentions are \u003cstrong\u003eexternal, time-varying state\u003c/strong\u003e — other people's posts. The local\n\u003ccode\u003e.colophon/cache/webmentions/\u003c/code\u003e is therefore a \u003cstrong\u003ederived export, not preserved state\u003c/strong\u003e: webmention.io\nis the source of truth and \u003ccode\u003efetch\u003c/code\u003e \u003cstrong\u003efully regenerates the export from it every run\u003c/strong\u003e.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003efetch\u003c/code\u003e queries the whole domain — \u003ccode\u003eGET /api/mentions.jf2?domain=\u0026lt;domain\u0026gt;\u0026amp;token={env:…}\u003c/code\u003e returns\nthe complete current set (JF2, newest-first), paged via \u003ccode\u003eper-page\u003c/code\u003e+\u003ccode\u003epage\u003c/code\u003e — then buckets by\n\u003ccode\u003etarget\u003c/code\u003e and writes one JSON per post, replacing whatever was there. Properties of that:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eStateless / idempotent.\u003c/strong\u003e A fresh CI runner with an empty cache just rebuilds it from the API\n(needs only network + token). \u0026quot;Empty\u0026quot; means \u0026quot;rebuild it,\u0026quot; never \u0026quot;lost data.\u0026quot;\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSelf-reconciling.\u003c/strong\u003e Because each run takes the full current set, deleted/edited mentions\ncorrect themselves — no stale entries to prune. (\u003ccode\u003esince\u003c/code\u003e/\u003ccode\u003esince_id\u003c/code\u003e enable delta fetches later\nif volume ever warrants; full-regenerate is the simple default.)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGraceful when empty.\u003c/strong\u003e Until \u003ccode\u003efetch\u003c/code\u003e runs, a missing \u003ccode\u003e_mentions/\u0026lt;post\u0026gt;.json\u003c/code\u003e renders nothing\nand never fails the build — mentions are always additive chrome.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eThe JS path removes the build dependency entirely.\u003c/strong\u003e A JS-rendering theme ships only a\nplaceholder; the browser fetches the separately-published \u003ccode\u003e_mentions/\u003c/code\u003e from R2 (see\n\u003ca href=\"#separate-publish-pipeline\"\u003eSeparate publish pipeline\u003c/a\u003e), refreshed out-of-band.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eSo committing the JSON to the repo is \u003cem\u003eoptional\u003c/em\u003e (it only helps a baked build show mentions with no\nnetwork, at the cost of churny commits); the default is \u0026quot;regenerated by \u003ccode\u003efetch\u003c/code\u003e in CI / on a\nschedule,\u0026quot; not \u0026quot;committed.\u0026quot;\u003c/p\u003e\n\u003ch3 id=\"deletions--pruning-full-replace-not-merge\"\u003eDeletions \u0026amp; pruning (full-replace, not merge)\u003c/h3\u003e\n\u003cp\u003e\u0026quot;Self-reconciling\u0026quot; only holds if regenerate is a \u003cstrong\u003efull replace of the \u003ccode\u003e_mentions/\u003c/code\u003e namespace\u003c/strong\u003e,\nnot a per-post upsert — otherwise a mention webmention.io has deleted lingers in a stale file.\nConcretely:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003eA deleted mention isn't in the next \u003ccode\u003efetch\u003c/code\u003e result, so the rewritten per-post file omits it.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eZero-mentions edge:\u003c/strong\u003e when a post loses \u003cem\u003eall\u003c/em\u003e its mentions there's nothing to write for it.\n\u003ccode\u003efetch\u003c/code\u003e must therefore \u003cstrong\u003eclear the namespace and rewrite wholesale\u003c/strong\u003e so that post ends up with\n\u003cstrong\u003eno file\u003c/strong\u003e (not a leftover from a prior run), and \u003cstrong\u003e\u003ccode\u003epublish\u003c/code\u003e must prune\u003c/strong\u003e objects no longer\npresent from the store — which the existing incremental publisher already does (the \u003ccode\u003ePruner\u003c/code\u003e:\n\u0026quot;only changed files upload, orphans are pruned\u0026quot;). The stale object is deleted from R2.\u003c/li\u003e\n\u003cli\u003eThe JS path then \u003cstrong\u003e404s → renders nothing\u003c/strong\u003e; a baked rebuild finds no file → renders nothing.\u003c/li\u003e\n\u003cli\u003eThis is \u003cstrong\u003eeventually consistent\u003c/strong\u003e: a deletion persists until the next \u003ccode\u003efetch\u003c/code\u003e+\u003ccode\u003epublish\u003c/code\u003e (JS) or\nrebuild (bake). The scheduled \u003ccode\u003ewebmention publish\u003c/code\u003e keeps that window short without a site build.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eImplication for \u003ccode\u003epublish\u003c/code\u003e: it must apply orphan pruning \u003cstrong\u003escoped to the \u003ccode\u003e_mentions/\u003c/code\u003e prefix\u003c/strong\u003e so a\nmentions refresh never deletes content objects (and vice versa).\u003c/p\u003e\n\u003ch2 id=\"display-a-per-site-mode-live--asset--disabled\"\u003eDisplay: a per-site \u003ccode\u003emode\u003c/code\u003e (\u003ccode\u003elive\u003c/code\u003e / \u003ccode\u003easset\u003c/code\u003e / \u003ccode\u003edisabled\u003c/code\u003e)\u003c/h2\u003e\n\u003cp\u003eHow responses reach the page is a \u003cstrong\u003eper-site setting\u003c/strong\u003e (\u003ccode\u003efederation.indieweb.webmention.display.mode\u003c/code\u003e).\nThere are exactly \u003cstrong\u003ethree\u003c/strong\u003e values — there is \u003cem\u003eno\u003c/em\u003e separate \u0026quot;baked\u0026quot; mode (baking is a theme choice\n\u003cem\u003ewithin\u003c/em\u003e \u003ccode\u003easset\u003c/code\u003e, see below). The mode decides \u003cstrong\u003ewhere the browser fetches from\u003c/strong\u003e and \u003cstrong\u003ewhether the\nengine ships anything at all\u003c/strong\u003e; both active modes use the same \u003ccode\u003ementions.js\u003c/code\u003e + the same\n\u003ca href=\"#moderation-a-distilled-committed-blocklist\"\u003emoderation pipeline\u003c/a\u003e.\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eMode\u003c/th\u003e\n\u003cth\u003eWhat ships per page\u003c/th\u003e\n\u003cth\u003eBrowser fetches from\u003c/th\u003e\n\u003cth\u003eBuild-time data?\u003c/th\u003e\n\u003cth\u003eFreshness\u003c/th\u003e\n\u003cth\u003ePrivacy\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003e\u003ccode\u003elive\u003c/code\u003e\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eplaceholder + JS\u003c/td\u003e\n\u003ctd\u003ethe \u003cstrong\u003ereceiver directly\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eno\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003enext page load\u003c/td\u003e\n\u003ctd\u003evisitor hits the receiver\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003e\u003ccode\u003easset\u003c/code\u003e\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003eplaceholder + JS (+ optional bake)\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eour \u003ccode\u003e_mentions/\u003c/code\u003e on R2\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003eyes\u003c/strong\u003e (synced list)\u003c/td\u003e\n\u003ctd\u003ethe refresh cron\u003c/td\u003e\n\u003ctd\u003eself-hosted\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003cstrong\u003e\u003ccode\u003edisabled\u003c/code\u003e\u003c/strong\u003e\u003c/td\u003e\n\u003ctd\u003e\u003cstrong\u003enothing\u003c/strong\u003e (zero counts)\u003c/td\u003e\n\u003ctd\u003e—\u003c/td\u003e\n\u003ctd\u003e—\u003c/td\u003e\n\u003ctd\u003e—\u003c/td\u003e\n\u003ctd\u003e—\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003e\u003cstrong\u003ePer-page toggle.\u003c/strong\u003e When the mode is active (\u003ccode\u003elive\u003c/code\u003e/\u003ccode\u003easset\u003c/code\u003e), webmentions are \u003cstrong\u003eon by default for\nevery post\u003c/strong\u003e, opt-out per page via frontmatter (\u003ccode\u003ewebmentions: false\u003c/code\u003e). In \u003ccode\u003edisabled\u003c/code\u003e the page setting\nis ignored — nothing is embedded or shipped anywhere, \u003ccode\u003ehas_mentions\u003c/code\u003e is false and \u003ccode\u003ementions\u003c/code\u003e is empty.\u003c/p\u003e\n\u003ch3 id=\"live--js-straight-to-the-receiver-no-fetchpublishbuild\"\u003e\u003ccode\u003elive\u003c/code\u003e — JS straight to the receiver (no fetch/publish/build)\u003c/h3\u003e\n\u003cp\u003eThe engine ships, on each enabled page, an \u003cstrong\u003eengine-provided placeholder\u003c/strong\u003e + the shared \u003ccode\u003ementions.js\u003c/code\u003e;\nthe browser calls the receiver's read API directly (webmention.io exposes a public, CORS-enabled,\ntoken-free read endpoint). \u003cstrong\u003eA new mention shows on the next page load\u003c/strong\u003e — no \u003ccode\u003efetch\u003c/code\u003e, no \u003ccode\u003epublish\u003c/code\u003e,\nno rebuild, no asset to host. Lowest-infra, most realtime.\u003c/p\u003e\n\u003cp\u003eCrucially, in \u003ccode\u003elive\u003c/code\u003e the engine has \u003cstrong\u003eno build-time data\u003c/strong\u003e — so \u003ccode\u003ehas_mentions\u003c/code\u003e/count are \u003cstrong\u003enot known\nat build\u003c/strong\u003e. The placeholder ships whenever the page is enabled (not gated on a count), and JS fills\nin the count (and hides the section if it resolves to zero). So themes \u003cstrong\u003ecannot bake\u003c/strong\u003e in \u003ccode\u003elive\u003c/code\u003e mode.\u003c/p\u003e\n\u003cp\u003eBecause a reader speaks a specific read API, the \u003cstrong\u003eJS is parameterised by the reader driver\u003c/strong\u003e: the\ndriver declares a small \u003cstrong\u003eclient descriptor\u003c/strong\u003e (endpoint URL template, query params, and the mapping\nfrom its response → our normalised shape). One shared \u003ccode\u003ementions.js\u003c/code\u003e consumes the descriptor and\nhandles any JF2-shaped endpoint (webmention.io and compatibles — all one \u003ccode\u003ejf2\u003c/code\u003e driver); a service\nwith an exotic API would be a different driver shipping its own client module. So \u003cem\u003e\u0026quot;the fetch JS is\nprovided by the specific driver\u0026quot;\u003c/em\u003e — yes, via the descriptor, without forking the renderer per service.\u003c/p\u003e\n\u003cp\u003eModeration still applies: the \u003cstrong\u003edistilled glob blocklist is shipped to the client\u003c/strong\u003e (it's\nspam-hiding, not a secret) and filtered in-browser. Semantic rules can't run client-side (no\nembeddings in the browser), so a site that needs semantic moderation should use \u003ccode\u003easset\u003c/code\u003e. Trade-offs\nto accept: every visitor's browser hits a third party (their uptime/rate-limits become yours; a\nprivacy leak), and no-JS/RSS readers see nothing.\u003c/p\u003e\n\u003ch3 id=\"asset--js-against-our-published--mentions-with-optional-build-time-bake\"\u003e\u003ccode\u003easset\u003c/code\u003e — JS against our published \u003ccode\u003e_mentions/\u003c/code\u003e, with optional build-time bake\u003c/h3\u003e\n\u003cp\u003eThe default is the same placeholder + \u003ccode\u003ementions.js\u003c/code\u003e, but pointed at \u003cem\u003eour\u003c/em\u003e server-curated\n\u003ccode\u003e_mentions/\u0026lt;key\u0026gt;.json\u003c/code\u003e on R2. Freshness comes from a scheduled \u003cstrong\u003e\u003ccode\u003ewebmention publish\u003c/code\u003e\u003c/strong\u003e — \u003ccode\u003efetch\u003c/code\u003e +\npush of \u003cstrong\u003eonly\u003c/strong\u003e the \u003ccode\u003e_mentions/\u003c/code\u003e prefix, \u003cstrong\u003eno site build\u003c/strong\u003e (see\n\u003ca href=\"#separate-publish-pipeline\"\u003eSeparate publish pipeline\u003c/a\u003e) — near-realtime to whatever cron cadence you\npick, with full server-side moderation (glob + semantic), self-hosting, and driver-neutrality.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eBecause the synced mentions list already exists at build time in this mode, the engine also exposes\nit to the template\u003c/strong\u003e (\u003ccode\u003ementions\u003c/code\u003e/\u003ccode\u003ementions_html\u003c/code\u003e/\u003ccode\u003ehas_mentions\u003c/code\u003e) so a theme that \u003cem\u003ewants\u003c/em\u003e to\n\u003cstrong\u003ehard-code / bake\u003c/strong\u003e them into the HTML can — instead of, or alongside, the JS placeholder. This is\nplausible precisely because we already have the built/synced list; it's a \u003cstrong\u003etheme capability, not a\nmode\u003c/strong\u003e. Baked output is then as-of-last-build (the JS placeholder is what stays cron-fresh), and it's\nthe no-JS escape hatch for text-first themes like \u003ccode\u003eminimal\u003c/code\u003e.\u003c/p\u003e\n\u003ch3 id=\"template-surface\"\u003eTemplate surface\u003c/h3\u003e\n\u003cp\u003eRendering remains a \u003cstrong\u003etemplate responsibility\u003c/strong\u003e — the engine only \u003cstrong\u003eexposes the data\u003c/strong\u003e, exactly as\n\u003ccode\u003eattachments\u003c/code\u003e/\u003ccode\u003eattachments_html\u003c/code\u003e do:\u003c/p\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eVar\u003c/th\u003e\n\u003cth\u003e\u003ccode\u003elive\u003c/code\u003e\u003c/th\u003e\n\u003cth\u003e\u003ccode\u003easset\u003c/code\u003e\u003c/th\u003e\n\u003cth\u003e\u003ccode\u003edisabled\u003c/code\u003e\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ehas_mentions\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eunknown at build → \u003ccode\u003efalse\u003c/code\u003e (JS fills count)\u003c/td\u003e\n\u003ctd\u003eaccurate (from synced list)\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003efalse\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ementions\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eempty (no build-time data)\u003c/td\u003e\n\u003ctd\u003estructured \u003ccode\u003e[{type, author{name,url,photo}, url, content, published}]\u003c/code\u003e — bake your own\u003c/td\u003e\n\u003ctd\u003eempty\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ementions_html\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eempty\u003c/td\u003e\n\u003ctd\u003eengine-rendered drop-in block (empty when none)\u003c/td\u003e\n\u003ctd\u003eempty\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ementions_src\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003edriver client-descriptor endpoint\u003c/td\u003e\n\u003ctd\u003eour \u003ccode\u003e_mentions/\u0026lt;key\u0026gt;.json\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003eunset\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003e\u003ccode\u003ementions_enabled\u003c/code\u003e\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etrue\u003c/code\u003e unless page opted out\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003etrue\u003c/code\u003e unless page opted out\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003efalse\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003cp\u003eBundled themes drop the placeholder + \u003ccode\u003ementions.js\u003c/code\u003e for the JS modes; \u003ccode\u003eminimal\u003c/code\u003e (no-JS) uses\n\u003ccode\u003easset\u003c/code\u003e + the baked \u003ccode\u003ementions_html\u003c/code\u003e. The mode is overridable per site.\u003c/p\u003e\n\u003ch2 id=\"audio--tts-mentions-are-never-spoken\"\u003eAudio / TTS: mentions are never spoken\u003c/h2\u003e\n\u003cp\u003eA real hazard for the bake path — handled by an invariant. The TTS reading is generated from the\n\u003cstrong\u003epost's markdown body\u003c/strong\u003e (\u003ccode\u003eregisterTTS(slug, html, …)\u003c/code\u003e, where \u003ccode\u003ehtml\u003c/code\u003e is the converted content)\n\u003cem\u003ebefore\u003c/em\u003e the theme runs. Mentions are \u003cstrong\u003etheme chrome\u003c/strong\u003e rendered as a sibling of \u003ccode\u003e{{ content }}\u003c/code\u003e\n— like the author card, downloads and tags, none of which are spoken. So:\u003c/p\u003e\n\u003cblockquote\u003e\n\u003cp\u003e\u003cstrong\u003eInvariant:\u003c/strong\u003e mentions render \u003cem\u003eoutside\u003c/em\u003e the content / \u003ccode\u003ee-content\u003c/code\u003e body the TTS extractor reads.\nThemes must keep the mentions block a sibling of the content element, never inside it.\u003c/p\u003e\n\u003c/blockquote\u003e\n\u003cp\u003eThis means baked mentions are excluded from audio for free (the TTS source predates theming); the\nguard just stops a theme from accidentally nesting them into the content element.\u003c/p\u003e\n\u003ch2 id=\"sending\"\u003eSending\u003c/h2\u003e\n\u003cp\u003e\u003ccode\u003ecolophon webmention send\u003c/code\u003e works off the \u003cstrong\u003ebuilt output\u003c/strong\u003e (or the deployed URLs):\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eScan each published post's HTML for outbound links (\u003ccode\u003ehttp(s)://\u003c/code\u003e to other origins).\u003c/li\u003e\n\u003cli\u003eFor each target, discover its endpoint — an HTTP \u003ccode\u003eLink: rel=\u0026quot;webmention\u0026quot;\u003c/code\u003e header, else a\n\u003ccode\u003e\u0026lt;link rel=\u0026quot;webmention\u0026quot;\u0026gt;\u003c/code\u003e / \u003ccode\u003e\u0026lt;a rel=\u0026quot;webmention\u0026quot;\u0026gt;\u003c/code\u003e in the body.\u003c/li\u003e\n\u003cli\u003ePOST \u003ccode\u003esource=\u0026lt;post URL\u0026gt;\u0026amp;target=\u0026lt;their URL\u0026gt;\u003c/code\u003e to the endpoint.\u003c/li\u003e\n\u003cli\u003eMaintain a per-post \u003cstrong\u003esent-cache\u003c/strong\u003e of the link set last sent. On re-run, send only \u003cstrong\u003enew\u003c/strong\u003e\ntargets — and \u003cstrong\u003ere-send to dropped targets\u003c/strong\u003e so that if you edit a post to remove a link, the\nreceiver re-checks, sees the link gone, and removes its mention (the spec's update/delete path).\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eThe sent-cache is what makes \u0026quot;we changed the context after sending\u0026quot; correct rather than silent.\u003c/p\u003e\n\u003ch2 id=\"the-mutability-question-and-how-it-plays-with-existing-choices\"\u003eThe mutability question (and how it plays with existing choices)\u003c/h2\u003e\n\u003cp\u003eThree cases, by what changes:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003ePost \u003cem\u003econtent\u003c/em\u003e changes\u003c/strong\u003e → \u003cstrong\u003efine.\u003c/strong\u003e Received mentions target the \u003cem\u003eURL\u003c/em\u003e, not the prose;\nedits are normal. Senders replied to the address.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePost \u003cem\u003eURL/slug\u003c/em\u003e changes\u003c/strong\u003e → \u003cstrong\u003ethe real gotcha.\u003c/strong\u003e webmention.io holds mentions under the old\nURL; the new page finds none; inbound links rot. Mitigations: (1) keep slugs stable\n(link-rot discipline); (2) once the backlog \u003cstrong\u003e\u003ccode\u003ealiases:\u003c/code\u003e / redirects\u003c/strong\u003e feature lands, \u003ccode\u003efetch\u003c/code\u003e\nqueries the old keys too and the old URL 302s to the new; (3) \u003ccode\u003edoctor\u003c/code\u003e warns when a post that\nhas cached mentions changes slug. \u003cstrong\u003eThis makes \u003ccode\u003ealiases:\u003c/code\u003e a soft prerequisite for robust\nwebmention\u003c/strong\u003e and is the main cross-feature dependency.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eA sender deletes/edits a mention, or we drop a sent link\u003c/strong\u003e → reconciled by re-running:\n\u003ccode\u003efetch\u003c/code\u003e always returns the current set (deletions vanish); \u003ccode\u003esend\u003c/code\u003e's sent-cache re-pings dropped\ntargets. JS display is always-current; baked display reconciles on the next fetch+build.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003eSpam/moderation:\u003c/strong\u003e inbound mentions can be junk — handled by a declarative, committed blocklist\nplus an optional moderation skill; see \u003ca href=\"#moderation-a-distilled-committed-blocklist\"\u003eModeration\u003c/a\u003e.\u003c/p\u003e\n\u003ch2 id=\"moderation-a-distilled-committed-blocklist\"\u003eModeration: a distilled, committed blocklist\u003c/h2\u003e\n\u003cp\u003eInbound mentions are third-party content, so the author needs a way to drop spam/abuse — and it has\nto \u003cstrong\u003esurvive \u003ccode\u003efetch\u003c/code\u003e's full regenerate\u003c/strong\u003e (editing the generated \u003ccode\u003e_mentions/\u003c/code\u003e JSON is pointless; the\nnext fetch overwrites it). So moderation is \u003cstrong\u003edeclarative and committed\u003c/strong\u003e, applied as a filter step\nover the normalised list, and reused by every display mode.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eThe blocklist is rules over normalised mention attributes, glob-matched.\u003c/strong\u003e Matchable fields:\n\u003ccode\u003edomain\u003c/code\u003e, \u003ccode\u003eurl\u003c/code\u003e, \u003ccode\u003eauthor.name\u003c/code\u003e, \u003ccode\u003eauthor.url\u003c/code\u003e, \u003ccode\u003econtent\u003c/code\u003e, \u003ccode\u003etype\u003c/code\u003e. A bare string is shorthand for\n\u003ccode\u003edomain\u003c/code\u003e/\u003ccode\u003eauthor.url\u003c/code\u003e; the structured form targets a field:\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003e# .colophon/webmention-block.yml  (committed)\n- \u0026#34;*.spam.example\u0026#34;            # shorthand: domain/author.url glob\n- author.url: \u0026#34;https://troll.example/*\u0026#34;\n- content: \u0026#34;*free crypto*\u0026#34;\n- domain: \u0026#34;*.cn.example\u0026#34;\n\u003c/code\u003e\u003c/pre\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eOne filter pipeline, two execution sites.\u003c/strong\u003e Applied \u003cstrong\u003eserver-side in \u003ccode\u003efetch\u003c/code\u003e\u003c/strong\u003e (full power) for\n\u003ccode\u003easset\u003c/code\u003e/\u003ccode\u003ebaked\u003c/code\u003e; for \u003ccode\u003elive\u003c/code\u003e the \u003cstrong\u003edistilled blocklist is shipped to the client\u003c/strong\u003e and the same glob\nrules run in \u003ccode\u003ementions.js\u003c/code\u003e (it's spam-hiding, not a secret). Glob rules run in both places;\nsemantic rules (below) are server-only.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSemantic moderation (future).\u003c/strong\u003e When the semantic subsystem lands, a rule kind scores a\nmention's \u003ccode\u003econtent\u003c/code\u003e against a concept (\u0026quot;spam\u0026quot;/\u0026quot;abuse\u0026quot;) via embeddings + a threshold, slotting into\nthe same server-side pipeline. Not available client-side, so semantic-moderated sites use\n\u003ccode\u003easset\u003c/code\u003e/\u003ccode\u003ebaked\u003c/code\u003e. (Ties into [decision \u003ccode\u003esearch\u003c/code\u003e] — the shared embedding subsystem.)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eA \u003ccode\u003emoderate-mentions\u003c/code\u003e skill\u003c/strong\u003e (an agent skill alongside the other authoring skills) scans the\ncurrent mention set, flags likely spam/abuse, and either auto-filters or \u003cstrong\u003epresents a decision\nlist\u003c/strong\u003e for the author to confirm. Crucially it \u003cstrong\u003edistills\u003c/strong\u003e confirmed cases into \u003cem\u003egeneralised\u003c/em\u003e\nglob (and later semantic) rules appended to the blocklist — so the list stays \u003cstrong\u003esmall and\neffective\u003c/strong\u003e rather than an ever-growing pile of individual URLs.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eReceiver-side delete\u003c/strong\u003e (e.g. webmention.io's dashboard) remains the quick one-off: delete there,\nthe next \u003ccode\u003efetch\u003c/code\u003e won't return it. The committed blocklist is the version-controlled, reproducible\npath; per-mention approval queues are deferred.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"separate-publish-pipeline\"\u003eSeparate publish pipeline\u003c/h2\u003e\n\u003cp\u003eThe \u003ccode\u003e_mentions/\u003c/code\u003e assets are published \u003cstrong\u003eindependently of the content build/deploy\u003c/strong\u003e, so you can\nupdate one without the other. colophon already partitions output by path at publish time (the\nrouter sends \u003ccode\u003e_search/**\u003c/code\u003e to the R2 publisher while content goes to Pages); the same machinery\npublishes \u003cem\u003eonly\u003c/em\u003e the \u003ccode\u003e_mentions/**\u003c/code\u003e prefix.\u003c/p\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode\u003ecolophon webmention publish --env production    # fetch + write _mentions/ + push ONLY that prefix to R2\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eSo the two cadences are decoupled:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eContent pipeline\u003c/strong\u003e (\u003ccode\u003ebuild\u003c/code\u003e → \u003ccode\u003epublish\u003c/code\u003e): ships HTML + JS placeholders. Never depends on the\nmentions cache; a fresh runner is fine.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eWebmention pipeline\u003c/strong\u003e (\u003ccode\u003ewebmention publish\u003c/code\u003e, e.g. hourly cron): refreshes \u003ccode\u003e_mentions/\u003c/code\u003e on R2.\nThe JS path picks it up in the browser with no site rebuild.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThis is the strongest reason JS is the default render path — it's the only one that benefits from\nthe decoupling (a baked theme still needs a content rebuild to reflect new mentions, so baking\nsuits low-frequency / no-JS sites). Implementation reuses the per-publisher routing\n(\u003ccode\u003erouter.Owns\u003c/code\u003e/\u003ccode\u003eKeep\u003c/code\u003e) plus a publish that only materialises the \u003ccode\u003e_mentions/\u003c/code\u003e tree.\u003c/p\u003e\n\u003ch2 id=\"search-mentions-as-down-ranked-results-optional-asset-mode\"\u003eSearch: mentions as down-ranked results (optional, \u003ccode\u003easset\u003c/code\u003e mode)\u003c/h2\u003e\n\u003cp\u003eReplies carry real text (\u0026quot;someone said X about post Y\u0026quot;), so indexing them into the site's lexical\nsearch is sensible — and plausible, because we already hold the normalised list. Design:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eOnly \u003ccode\u003easset\u003c/code\u003e mode.\u003c/strong\u003e A static index needs the data at build time; \u003ccode\u003elive\u003c/code\u003e has none. So\nsearch-indexed mentions are an \u003ccode\u003easset\u003c/code\u003e-mode / \u003ccode\u003efetch\u003c/code\u003e-driven feature (off in \u003ccode\u003elive\u003c/code\u003e/\u003ccode\u003edisabled\u003c/code\u003e).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eOnly text-bearing kinds.\u003c/strong\u003e Index \u003ccode\u003ereply\u003c/code\u003e/\u003ccode\u003emention\u003c/code\u003e (they have \u003ccode\u003econtent\u003c/code\u003e); skip \u003ccode\u003elike\u003c/code\u003e/\u003ccode\u003erepost\u003c/code\u003e\n(nothing to match). Each indexed doc carries \u003ccode\u003econtent\u003c/code\u003e, \u003ccode\u003eauthor\u003c/code\u003e, the \u003cstrong\u003etarget post\u003c/strong\u003e it lives\nunder, and \u003ccode\u003ekind: mention\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eA separate, down-ranked shard.\u003c/strong\u003e Rather than mixing mention docs into the content index (which\nwould churn it on every mentions refresh), emit a \u003cstrong\u003esecond search shard\u003c/strong\u003e for mentions, merged\nclient-side. This keeps it on the \u003cstrong\u003ewebmention publish cadence\u003c/strong\u003e (consistent with \u003ccode\u003e_mentions/\u003c/code\u003e\ndecoupling) and lets the UI treat it differently: a \u003cstrong\u003erank penalty\u003c/strong\u003e plus grouping so mention\nhits sort \u003cstrong\u003ebelow\u003c/strong\u003e content hits — or render in a distinct \u0026quot;Mentions\u0026quot; group \u003cstrong\u003eappended to the\nbottom\u003c/strong\u003e of the results list. Configurable; default down-ranked-and-grouped.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eA hit links on-site:\u003c/strong\u003e to the post's responses anchor (keeps the reader on the site), citing the\nmention's source URL. Freshness is as-of-last index refresh.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eBlocklist-gated.\u003c/strong\u003e Mentions are filtered through the same \u003ca href=\"#moderation-a-distilled-committed-blocklist\"\u003emoderation pipeline\u003c/a\u003e\n\u003cem\u003ebefore\u003c/em\u003e indexing — blocked mentions are neither displayed nor searchable.\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eThis reuses the internal pure-Go BM25 index (decision \u003ccode\u003esearch\u003c/code\u003e): add a \u003ccode\u003ekind\u003c/code\u003e field + a rank weight,\nemit the extra shard, and teach the search UI to group/penalise \u003ccode\u003ekind: mention\u003c/code\u003e. Marked a \u003cstrong\u003elater\nenhancement\u003c/strong\u003e (after Tier 2 display), gated by an explicit \u003ccode\u003esearch_index: true\u003c/code\u003e under \u003ccode\u003ewebmention\u003c/code\u003e.\u003c/p\u003e\n\u003ch2 id=\"fit-with-existing-design\"\u003eFit with existing design\u003c/h2\u003e\n\u003cdiv class=\"table-scroll\" tabindex=\"0\"\u003e\n\u003ctable\u003e\n\u003cthead\u003e\n\u003ctr\u003e\n\u003cth\u003eConcern\u003c/th\u003e\n\u003cth\u003eReused pattern\u003c/th\u003e\n\u003c/tr\u003e\n\u003c/thead\u003e\n\u003ctbody\u003e\n\u003ctr\u003e\n\u003ctd\u003eSeparate JSON asset, R2-routed, cross-origin\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003e_search/\u003c/code\u003e index + \u003ccode\u003epublish --create\u003c/code\u003e CORS\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eBrowser fetch + render, no-JS fallback\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003esearch-ui.js\u003c/code\u003e / \u003ccode\u003eplayer.js\u003c/code\u003e progressive enhancement\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eTemplate data exposure (\u003ccode\u003ementions\u003c/code\u003e/\u003ccode\u003ementions_html\u003c/code\u003e)\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003eattachments\u003c/code\u003e/\u003ccode\u003eattachments_html\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003ePer-path publish partitioning\u003c/td\u003e\n\u003ctd\u003ethe router's \u003ccode\u003e_search/**\u003c/code\u003e → R2 split (\u003ccode\u003erouter.Owns\u003c/code\u003e/\u003ccode\u003eKeep\u003c/code\u003e)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eRead token from environment, not config\u003c/td\u003e\n\u003ctd\u003eall secrets via \u003ccode\u003e{env:…}\u003c/code\u003e\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eConfig wiring\u003c/td\u003e\n\u003ctd\u003e\u003ccode\u003efederation.indieweb.webmention.receiver\u003c/code\u003e (already present, unread)\u003c/td\u003e\n\u003c/tr\u003e\n\u003ctr\u003e\n\u003ctd\u003eParsing mention authors\u003c/td\u003e\n\u003ctd\u003ethe mf2 \u003ccode\u003eh-card\u003c/code\u003e/\u003ccode\u003eh-entry\u003c/code\u003e just shipped\u003c/td\u003e\n\u003c/tr\u003e\n\u003c/tbody\u003e\n\u003c/table\u003e\n\u003c/div\u003e\n\u003ch2 id=\"config\"\u003eConfig\u003c/h2\u003e\n\u003cpre tabindex=\"0\"\u003e\u003ccode class=\"language-yaml\"\u003esites:\n  - id: main\n    federation:\n      indieweb:\n        webmention:\n          receiver: https://webmention.io/blog.example.com/webmention  # the rel=webmention endpoint\n          # token read from env, e.g. WEBMENTION_IO_TOKEN, never written here\n          driver: jf2                  # reader driver (read API + client descriptor); default jf2\n          display:\n            mode: asset                # live | asset | disabled   (see Display)\n          # blocklist lives in .colophon/webmention-block.yml (committed), not inline\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003e\u003ccode\u003emode: live\u003c/code\u003e needs nothing else; \u003ccode\u003easset\u003c/code\u003e uses the \u003ccode\u003ewebmention fetch\u003c/code\u003e/\u003ccode\u003epublish\u003c/code\u003e pipeline (and can be\nbaked at build by the theme); \u003ccode\u003edisabled\u003c/code\u003e ships nothing. Per page: \u003ccode\u003ewebmentions: false\u003c/code\u003e opts a post\nout when the mode is active.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003e\u0026lt;link rel=\u0026quot;webmention\u0026quot; href=\u0026quot;{{ receiver }}\u0026quot;\u0026gt;\u003c/code\u003e is emitted in every page \u003ccode\u003e\u0026lt;head\u0026gt;\u003c/code\u003e when a receiver\nis configured (the discovery tag senders look for).\u003c/p\u003e\n\u003ch2 id=\"key-decisions\"\u003eKey decisions\u003c/h2\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003eSeparate \u003ccode\u003ewebmention\u003c/code\u003e command\u003c/strong\u003e (\u003ccode\u003esend\u003c/code\u003e after publish, \u003ccode\u003efetch\u003c/code\u003e before build, \u003ccode\u003epublish\u003c/code\u003e on its\nown cadence) — not folded into \u003ccode\u003ebuild\u003c/code\u003e/\u003ccode\u003epublish\u003c/code\u003e, because of the live-URL ordering constraint\nand the decoupled-refresh goal.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eReceived data is a separate \u003ccode\u003e_mentions/\u003c/code\u003e asset\u003c/strong\u003e (R2-routed, CORS via \u003ccode\u003e--create\u003c/code\u003e), never mixed\ninto content — mirrors \u003ccode\u003e_search/\u003c/code\u003e. The cache is \u003cstrong\u003enot reproducible\u003c/strong\u003e (external state); builds\nare \u003cstrong\u003egraceful when it's empty\u003c/strong\u003e, and freshness comes from \u003ccode\u003efetch\u003c/code\u003e/\u003ccode\u003epublish\u003c/code\u003e, not the repo.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eOne JSON per post\u003c/strong\u003e, normalised (\u003ccode\u003etype\u003c/code\u003e/\u003ccode\u003eauthor\u003c/code\u003e/\u003ccode\u003eurl\u003c/code\u003e/\u003ccode\u003econtent\u003c/code\u003e), not raw receiver payload.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eDisplay is a per-site \u003ccode\u003emode\u003c/code\u003e\u003c/strong\u003e — \u003ccode\u003elive\u003c/code\u003e | \u003ccode\u003easset\u003c/code\u003e | \u003ccode\u003edisabled\u003c/code\u003e (there is \u003cstrong\u003eno\u003c/strong\u003e separate baked\nmode). \u003ccode\u003elive\u003c/code\u003e = browser→receiver direct (most realtime, client-side glob blocklist, \u003cstrong\u003eno\nbuild-time data\u003c/strong\u003e so no count/bake, no no-JS); \u003ccode\u003easset\u003c/code\u003e = browser→our R2 asset refreshed by a cron\n\u003ccode\u003epublish\u003c/code\u003e (full moderation, self-hosted) \u003cstrong\u003eand\u003c/strong\u003e the synced list is exposed at build so a theme\n\u003cem\u003emay\u003c/em\u003e bake it (baking is a theme capability within \u003ccode\u003easset\u003c/code\u003e, not a mode); \u003ccode\u003edisabled\u003c/code\u003e = nothing\nships, zero counts, page toggle ignored. Per-page opt-out via \u003ccode\u003ewebmentions: false\u003c/code\u003e. The engine\nexposes \u003ccode\u003ementions\u003c/code\u003e/\u003ccode\u003ementions_html\u003c/code\u003e/\u003ccode\u003ehas_mentions\u003c/code\u003e/\u003ccode\u003ementions_src\u003c/code\u003e/\u003ccode\u003ementions_enabled\u003c/code\u003e; the theme\nrenders. \u003ccode\u003elive\u003c/code\u003e mode's fetch JS is parameterised by the \u003cstrong\u003ereader driver's client descriptor\u003c/strong\u003e.\n4b. \u003cstrong\u003eModeration is a declarative, committed blocklist\u003c/strong\u003e of glob rules over mention attributes\n(\u003ccode\u003e.colophon/webmention-block.yml\u003c/code\u003e), re-applied every \u003ccode\u003efetch\u003c/code\u003e (and shipped to the client in \u003ccode\u003elive\u003c/code\u003e\nmode). Future \u003cstrong\u003esemantic\u003c/strong\u003e rules run server-side; a \u003cstrong\u003e\u003ccode\u003emoderate-mentions\u003c/code\u003e skill\u003c/strong\u003e distills\nconfirmed spam into small, general rules. Survives full-regenerate because it's declarative.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eTTS invariant\u003c/strong\u003e: mentions live outside the content body the speech extractor reads, so they're\nnever spoken — true for both paths.\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSeparate publish pipeline\u003c/strong\u003e: \u003ccode\u003ewebmention publish\u003c/code\u003e pushes only \u003ccode\u003e_mentions/\u003c/code\u003e to the store, so\nmentions and content update independently (reuses per-publisher path routing).\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eSent-cache\u003c/strong\u003e drives correct re-send on link changes; \u003cstrong\u003e\u003ccode\u003ealiases:\u003c/code\u003e\u003c/strong\u003e is the soft dependency\nfor surviving URL changes.\u003c/li\u003e\n\u003cli\u003ewebmention.io as the receiver (no self-hosted endpoint) — per PLAN §10/§14.\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch2 id=\"acceptance-criteria\"\u003eAcceptance criteria\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003ecolophon webmention send\u003c/code\u003e discovers endpoints and POSTs for outbound links; re-run is a no-op\nunless links changed; dropped links trigger a delete-style re-send.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003ecolophon webmention fetch\u003c/code\u003e writes normalised JSON into the local cache; \u003ccode\u003ewebmention publish\u003c/code\u003e\npushes only \u003ccode\u003e_mentions/**\u003c/code\u003e to the store, independent of a content deploy.\u003c/li\u003e\n\u003cli\u003eA build with an \u003cstrong\u003eempty\u003c/strong\u003e cache succeeds and shows no mentions (graceful); a build with the cache\npresent exposes \u003ccode\u003ementions\u003c/code\u003e/\u003ccode\u003ementions_html\u003c/code\u003e to templates and emits \u003ccode\u003e_mentions/\u003c/code\u003e assets.\u003c/li\u003e\n\u003cli\u003eA mention deleted on webmention.io disappears after the next \u003ccode\u003efetch\u003c/code\u003e+\u003ccode\u003epublish\u003c/code\u003e; a post that drops\nto \u003cstrong\u003ezero\u003c/strong\u003e mentions has its \u003ccode\u003e_mentions/\u0026lt;post\u0026gt;.json\u003c/code\u003e removed and the orphan pruned from the store\n(JS path 404s → renders nothing). Pruning is scoped to the \u003ccode\u003e_mentions/\u003c/code\u003e prefix.\u003c/li\u003e\n\u003cli\u003eA JS-enhanced theme shows likes/reposts/replies; with JS off the post is unaffected; \u003ccode\u003eminimal\u003c/code\u003e\nbakes them statically. Mentions never appear in a post's TTS audio.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003edisplay.mode: live\u003c/code\u003e renders mentions with \u003cstrong\u003eno\u003c/strong\u003e \u003ccode\u003efetch\u003c/code\u003e/\u003ccode\u003epublish\u003c/code\u003e/rebuild (browser → receiver),\nhonouring the shipped glob blocklist client-side, with no build-time count; \u003ccode\u003easset\u003c/code\u003e renders from\nthe published \u003ccode\u003e_mentions/\u003c/code\u003e (and exposes the synced list so a theme may bake it at build);\n\u003ccode\u003edisabled\u003c/code\u003e ships nothing (zero counts, \u003ccode\u003ewebmentions:\u003c/code\u003e page setting ignored). \u003ccode\u003ewebmentions: false\u003c/code\u003e\nopts a single post out when the mode is active.\u003c/li\u003e\n\u003cli\u003eA blocklisted mention (glob over domain/url/author/content) never appears in any mode; the\nblocklist survives a full \u003ccode\u003efetch\u003c/code\u003e regenerate (it's committed, not edited into the export).\u003c/li\u003e\n\u003cli\u003eSecrets come only from the environment; a site with no \u003ccode\u003ewebmention\u003c/code\u003e config emits nothing.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"files-to-create-when-built\"\u003eFiles to create (when built)\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003e\u003ccode\u003einternal/webmention/\u003c/code\u003e — endpoint discovery + sender (sent-cache) + reader \u003cstrong\u003edriver\u003c/strong\u003e (server\nfetch \u003cstrong\u003e+ client descriptor\u003c/strong\u003e) + normaliser + the \u003cstrong\u003eblocklist filter pipeline\u003c/strong\u003e.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003einternal/cli/webmention.go\u003c/code\u003e — the \u003ccode\u003ewebmention {send,fetch,publish}\u003c/code\u003e command group; \u003ccode\u003epublish\u003c/code\u003e\nsupports the incremental (changed-mention-posts-only) re-render for \u003ccode\u003ebaked\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003ebuild: expose \u003ccode\u003ementions\u003c/code\u003e/\u003ccode\u003ementions_html\u003c/code\u003e/\u003ccode\u003ehas_mentions\u003c/code\u003e/\u003ccode\u003ementions_src\u003c/code\u003e to templates, emit\n\u003ccode\u003e_mentions/\u003c/code\u003e assets + the \u003ccode\u003e\u0026lt;link rel=\u0026quot;webmention\u0026quot;\u0026gt;\u003c/code\u003e head tag; in \u003ccode\u003elive\u003c/code\u003e mode emit the driver\nclient descriptor + the distilled blocklist for client-side filtering.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003einternal/render/themes/*/…\u003c/code\u003e — a \u003ccode\u003edata-mentions\u003c/code\u003e placeholder + engine-emitted \u003ccode\u003ementions.js\u003c/code\u003e\n(parameterised by the driver descriptor; applies glob blocklist in \u003ccode\u003elive\u003c/code\u003e) and a pongo-baked\nblock (text themes), kept outside the content/\u003ccode\u003ee-content\u003c/code\u003e element.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003econtrib/skills/\u003c/code\u003e (+ wiring) — a \u003ccode\u003emoderate-mentions\u003c/code\u003e skill that flags spam/abuse and distills\nconfirmed cases into blocklist rules.\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch2 id=\"out-of-scope-future\"\u003eOut of scope (future)\u003c/h2\u003e\n\u003cul\u003e\n\u003cli\u003eSelf-hosted webmention receiver (PLAN §14 leaves this open; webmention.io is the v1 choice).\u003c/li\u003e\n\u003cli\u003eRich moderation UI / per-mention approval.\u003c/li\u003e\n\u003cli\u003eSending \u003cem\u003eas\u003c/em\u003e specific post types (replies/likes from colophon itself) — colophon publishes\narticles; it links, it doesn't (yet) author reply-posts.\u003c/li\u003e\n\u003cli\u003eFediverse/Bridgy Fed backfeed — a later layer that reuses this send/receive + mf2 substrate.\u003c/li\u003e\n\u003c/ul\u003e\n\u003chr\u003e\n\u003cp\u003e\u003cem\u003eGenerated from \u003ca href=\"https://github.com/jmylchreest/colophon/blob/main/docs/design/webmention.md\"\u003e\u003ccode\u003edocs/design/webmention.md\u003c/code\u003e\u003c/a\u003e — edit it there.\u003c/em\u003e\u003c/p\u003e\n",
      "date_published": "2001-11-20T00:00:00Z"
    }
  ]
}
