A theme turns colophon's page data into HTML. Themes are pongo2 templates (Jinja2/Django syntax) plus static assets. Three themes ship built in:
default— full-featured: hero banners, index thumbnails, and vendored highlight.js / KaTeX / Mermaid for code, maths and diagrams, plus self-hosted web fonts.press— colophon.blog's brand theme. Literary-modern (Fraunces over Inter), light & dark, drifting glow, ink-blob title reveal, feed popouts. It inheritsdefault(see base themes), so it reuses the same vendored libraries and fonts without shipping its own copy. The home-page lede under the title comes from the site's optionaltagline:(presentational, distinct from the SEOdescription:); unset renders no lede.minimal— plain, readable text. No JavaScript and no web fonts; rich blocks show as their raw source (the raw-block contract).
More themes (flux, signal, obsidian) live in contrib/themes/
and are installed by copying them into your project.
Selecting a theme
Set it on the site, and optionally override it per environment — handy for previewing a theme before promoting it to production:
sites:
- id: main
theme: default # site default
environments:
- name: production
# inherits theme: default
- name: text
theme: minimal # this environment builds with the minimal theme
Precedence: environment theme > site theme > default. Build or serve an environment
to see its theme:
colophon build --env text # builds public/ with the minimal theme
colophon serve # serves every environment, each with its own theme
Inspecting and ejecting themes
colophon themes list # default, minimal, press
colophon themes eject minimal # copies the built-in into themes/minimal/ to edit
colophon themes eject default # full default theme, incl. its vendored libraries
eject writes a built-in theme to themes/<name>/ in your project; the on-disk copy then
overrides the built-in (use --force to overwrite an existing directory). It's the easiest
way to start customising — eject, then edit only the files you care about. Ejecting an
overlay theme (e.g. press) writes only its own files; the base theme's inherited assets
stay in the binary and still resolve at build, so the eject stays small.
Supplying your own theme
Put files under themes/<name>/ in your project root and set theme: <name> (or eject one
to start from). Files there override the built-in default per file, so you only write
what you want to change:
themes/
mytheme/
page.html # overrides the post template
style.css # overrides the stylesheet
logo.svg # a new static asset, copied to the output root
An unknown theme name with no themes/<name>/ directory falls back to the default theme.
Base themes (inheriting another theme)
A theme can inherit another theme's templates and static assets by declaring a base. For an
on-disk theme this is automatic: any themes/<name>/ directory inherits default, so it
only needs the files it changes (this is why dropping in a single style.css works). A
built-in theme inherits explicitly via a one-line base file naming the base theme — the
built-in press theme contains base → default, so it reuses the default's vendored
libraries and fonts and supplies only its own page.html, index.html and style.css.
Resolution order, highest precedence first: your project's themes/<name>/ → the theme's
own files → its base theme's files. The base marker is never copied to the output.
Community themes (contrib/themes/)
The colophon repo ships extra themes under contrib/themes/ that are not baked into the
binary. To use one, copy it into your project and select it:
cp -r contrib/themes/flux myblog/themes/flux
# then, in colophon.yaml: theme: flux
Because on-disk themes inherit default, a contrib theme only carries its own templates and
style.css; the vendored libraries and fonts come from the built-in default at build time.
Theme files
| File | Role |
|---|---|
page.html |
Renders a single entry (post or page). Required — the default for every page type. |
index.html |
Renders the site index (post list). |
<type>.html |
Optional. Renders entries of page type <type> (e.g. project.html); falls back to page.html. See Page types. |
favicon.svg |
Default site icon (override per-site with favicon: pointing at a project file). |
theme.yaml |
Optional. Theme metadata — a description, and image.genai.system_prompt (the house style for generated images). Not copied to the output. |
| anything else | Any non-.html file is copied verbatim to the output root (CSS, JS, fonts, images). |
Static assets keep their relative path: themes/mytheme/vendor/app.js is written to
/vendor/app.js and referenced as {{ base_path }}vendor/app.js.
Page types
Every entry has a type. By default it's derived from whether the entry has a date — a
dated entry is a post (chronological: listed on the index, in feeds, on tag pages), a
dateless one is a page (standing chrome: surfaced in the nav menu, not in the list/feeds).
An author can override this with a type: in frontmatter (see
Authoring → page types), including custom types like project.
As a theme author you don't have to do anything: every type renders with page.html unless
you opt in. When you want a type to look different, you have two ways — pick whichever suits.
1. A dedicated template — add themes/<theme>/<type>.html. An entry of that type renders
with it; any type without its own file falls back to page.html. The file is an ordinary
single-entry template and receives the same variables as page.html (see the table below).
For example, to give type: project entries a bespoke layout:
{# themes/mytheme/project.html — renders entries with `type: project` #}
<!doctype html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8"><title>{{ meta_title }}</title>
<link rel="stylesheet" href="{{ base_path }}style.css">{{ seo_head|safe }}
</head>
<body>
<article class="project">
<h1>{{ title }}</h1>
{% if image %}<img class="project-shot" src="{{ image }}" alt="{{ title }}">{% endif %}
{{ content|safe }}
{% if tags %}<footer>{% for t in tags %}<a href="{{ t.url }}">{{ t.name }}</a> {% endfor %}</footer>{% endif %}
</article>
</body>
</html>
2. Branch inside page.html — the page_type variable holds the resolved type, so one
template can switch on it without a separate file:
{% if page_type == "project" %}
<span class="badge">Project</span>
{% elif page_type == "page" %}
{# a standing page — maybe hide the date/reading-time line #}
{% else %}
<time>{{ date }}</time> · {{ read_time }} min
{% endif %}
Placement. A custom type is listed (post-like) by default; the built-in page is the only
standing (nav) type. So type: page makes a dated entry standing (it appears in nav_pages,
not in the index list or feeds), and type: post makes a dateless one listed. You don't render
the nav/list yourself per type — the build routes entries into nav_pages (standing) vs pages
(listed) for you; your per-type template only styles the single entry.
The templating language
Templates are pongo2 — Jinja2/Django syntax:
{{ value }}prints a value (HTML-escaped by default).{{ value|safe }}prints pre-rendered HTML without escaping — required forcontent,feed_headandseo_head.{% if x %}…{% elif y %}…{% else %}…{% endif %}and{% for item in list %}…{% endfor %}.- Filters chain with
|, e.g.{{ title|default:site_title }},{{ tags|length }}. {# comment #}(keep it on one line — pongo2 rejects a newline inside{# … #}).
Always prefix internal links with
{{ base_path }}({{ base_path }}style.css,{{ base_path }}{{ p.url }}).base_pathmakes the theme work whether the site is served from/or a sub-path.
Template variables
page.html (a single post)
| Variable | Description |
|---|---|
site_title |
The site title. |
title, date, description |
Post metadata (date is a date; may be empty). |
meta_title |
Pre-resolved <title> text (SEO title → title → site title). |
content |
The rendered post HTML. Output with {{ content|safe }}. |
base_path |
URL prefix for internal links (always starts and ends with /). |
base_url |
Absolute site root, for canonical/social URLs. |
feed_head |
<link rel="alternate"> feed-discovery tags. Output with {{ feed_head|safe }}. |
seo_head |
Full SEO <head>: canonical, robots, Open Graph, Twitter, JSON-LD. {{ seo_head|safe }}. |
analytics_head |
Analytics provider markup (statsfactory beacon and/or GA loader). Output once before </body> with {{ analytics_head|safe }}. Empty when the site configures no analytics. See Analytics. |
glossary_head |
Glossary styles + decorator <link>/<script>. Output once before </body> with {{ glossary_head|safe }}. Empty unless the page uses a glossary term. See Glossary. |
lang |
The page's BCP-47 language tag — put it on <html lang="{{ lang }}">. |
favicon |
Favicon filename, or empty. |
hero |
Hero banner URL (page-relative, or absolute when routed), or empty. |
hero_alt, hero_style |
Hero alt text, and a ready object-fit/object-position style string (may be empty). Use as alt="{{ hero_alt }}"{% if hero_style %} style="{{ hero_style }}"{% endif %}. |
image, image_abs |
Preview image href; absolute preview URL for og:image. |
image_alt, image_style |
Card-image alt text and object-fit style (the index list items carry image_alt/image_style too). |
tags |
List of {name, url} — linked tag chips. Prefix nothing; url is ready to use. |
category |
Primary category string (first category, else first tag, else empty). |
read_time |
Estimated reading time in whole minutes (integer). |
toc |
List of {level, id, text} headings, for a table of contents. |
page_type |
The resolved page type (post, page, or a custom value) — for branching within a shared template. |
draft, embargoed, embargo_until |
Preview-only flags for not-yet-public posts. |
has_code, has_math, has_mermaid |
True when the post uses that block type — load the matching library only when set. |
author_name, author_initials, author_bio, author_url, author_avatar |
Author h-card fields for the byline (empty when unset). author_avatar is a ready-to-use src: a file-path avatar is published to /assets/<name> and emitted root-anchored (or as the object-store URL when routed); data:/http(s):// and gravatar avatars resolve to a URL that passes through. |
has_audio, audio, audio_type |
True when the post has an audio reading (recorded or generated TTS); audio is its URL and audio_type its MIME. See Audio, video & downloads. |
audio_listen, audio_play, audio_pause |
Localised player UI strings (figcaption + play/pause aria-labels), in the page's language. Present only when has_audio. |
has_attachments, attachments, attachments_html |
Downloads. attachments_html is a ready-to-drop-in, no-JS block ({{ attachments_html|safe }}); attachments is the structured list — {url, label, description, name, type, type_label, size, bytes} — if you'd rather build your own. has_attachments is the flag. See Audio, video & downloads. |
mentions_enabled, has_mentions, mentions, mentions_html, mentions_attrs, mentions_src |
Webmentions (replies/likes/reposts). What's populated depends on the site's display.mode — see Webmentions. |
has_syndication, syndication, syndication_html |
"Also posted on…" links from the post's syndication: frontmatter (absolute URLs). syndication_html is a no-JS drop-in of mf2 u-syndication links ({{ syndication_html|safe }}, empty when none); syndication is the raw URL list to build your own. |
index.html (the post list, and per-tag pages)
| Variable | Description |
|---|---|
lang, site_title, base_path, base_url, feed_head, favicon, analytics_head |
As above. (Listing pages carry no prose, so no glossary_head.) |
heading |
Page heading — the site title on the home page, or Tagged “<name>” on a tag page. |
tagline |
The site's optional tagline:, for a hero lede under the title. Empty when unset — guard with {% if tagline %}. |
seo_head |
The listing's SEO <head> block (canonical, Open Graph/Twitter, JSON-LD). Emit with {{ seo_head|safe }}. |
feeds |
List of {label, href} for subscribe links. |
pages |
List of posts: {title, url, date, draft, embargoed, embargo_until, image, image_alt, image_style, audio, has_audio, has_attachments, tags, series}. Prefix url with base_path. Use has_audio/has_attachments to flag entries with media (see below). |
Enhancing rich blocks
colophon emits the raw-block markup; how to enhance it is entirely the theme's choice.
The default theme loads vendored libraries from themes/default/vendor/, gated on the
has_* flags so a page only pulls in what it uses:
{% if has_math %}
<link rel="stylesheet" href="{{ base_path }}vendor/katex/katex.min.css">
<script defer src="{{ base_path }}vendor/katex/katex.min.js"></script>
<script>/* render every .math element with katex */</script>
{% endif %}
Your theme is free to do something else with the same markup: load the libraries from a CDN,
swap in a different highlighter, or — like the minimal theme — do nothing and let the raw
text stand. The markup contract (<pre class="mermaid">, <span class="math …">,
<pre><code class="language-…">, <div class="callout …">) does not change.
Audio, video & downloads
colophon resolves media; the theme decides how it looks. There are four touch-points.
1. Inline video/audio embeds. A body embed pointing at a media file (,
) is rendered for you as <video class="post-video" controls …> or
<audio class="post-inline-audio" controls …>. You only need to style them — make them
responsive in your prose:
.prose .post-video { display: block; max-width: 100%; height: auto; }
.prose .post-inline-audio { display: block; width: 100%; }
2. The audio reading + player. When has_audio is set, a post has a podcast-style reading
(recorded audio_file: or generated TTS). The build emits a shared, dependency-free
player.js to the site root whenever any page has audio. Opt in with the markup contract — a
container marked data-audioplayer with the source and localised labels, plus the script:
{% if has_audio %}
<figure class="post-audio" data-audioplayer data-src="{{ audio }}"
data-label-play="{{ audio_play }}" data-label-pause="{{ audio_pause }}">
<figcaption>{{ audio_listen }}</figcaption>
<audio controls preload="none" src="{{ audio }}"></audio> <!-- no-JS fallback -->
</figure>
{% endif %}
...
{% if has_audio %}<script defer src="{{ base_path }}player.js"></script>{% endif %}
player.js progressively enhances the <figure> into a play/pause control with a scrubbable
waveform (a <src>.json peaks sidecar when present, else peaks decoded from the audio in-browser
on first play and cached, else live Web Audio, else idle); with JS off the native <audio> still
plays. Style the enhanced parts via .post-audio.ap-ready,
.ap-toggle, .ap-wave, .ap-time — see any bundled theme's CSS.
The content of a generated reading is shaped by authoring hints — type-aware cues for code/diagrams/tables, and
<notts>/<tts>to hide or force text. Those are an author concern, documented in Authoring content; a theme doesn't handle them.
3. The downloads block. The engine renders the whole Downloads list for you — a no-JS, semantic fragment with stable classes. Drop it in wherever you like (the bundled themes put it below the author box) and style it with CSS:
{{ attachments_html|safe }} {# empty when the post has none, so no guard needed #}
It emits .post-downloads > .downloads-title + .downloads-list > .dl-item > a.dl, each row with
.dl-ico (paperclip), .dl-main (.dl-label + optional .dl-desc) and .dl-meta
(.dl-type badge + .dl-size). Style those classes to taste.
Prefer your own markup? Ignore the fragment and loop the structured attachments list instead —
each entry has url, label, description, name, type, type_label, size, bytes:
{% if attachments %}<ul class="my-downloads">
{% for f in attachments %}
<li><a href="{{ f.url }}" download>{{ f.label }}</a>
{% if f.description %}<p>{{ f.description }}</p>{% endif %}
<span>{{ f.type_label }} · {{ f.size }}</span></li>
{% endfor %}
</ul>{% endif %}
4. Listing markers. On index.html, each pages entry carries has_audio and
has_attachments. The press and contrib themes show a small inert speaker / paperclip mark in
the row's top-right corner so readers can spot posts with media at a glance — copy that pattern,
or surface it however suits your design:
{% if p.has_audio or p.has_attachments %}<span class="post-flags">
{% if p.has_audio %}<span class="pf" aria-label="Has an audio reading">…speaker svg…</span>{% endif %}
{% if p.has_attachments %}<span class="pf" aria-label="Has downloadable files">…paperclip svg…</span>{% endif %}
</span>{% endif %}
Microformats2 (IndieWeb)
The bundled themes annotate posts with microformats2
— invisible class names that let other software (IndieWeb readers, Webmention senders, Bridgy)
understand your content. It's pure markup: no JS, no config (the old microformats toggle was
removed — it's always on). If you write your own theme, mirror the pattern so it stays parseable:
- Post page: the post container is
h-entry, withp-name(title),dt-published(the<time>),e-content(the rendered body), au-urlpermalink ({{ permalink }}is the absolute URL), and a nestedp-author h-card(p-name+u-urlon the author,u-photoon the avatar). - Listing/index: the list is
h-feed, each entryh-entry, the title linku-url p-name, the datedt-published. - Identity: author profile links carry
rel="me".
When a theme splits the title/byline away from the body (e.g. a hero block), carry the stray
properties into the h-entry root as hidden elements (<data class="p-name" value="…">, a hidden
<time class="dt-published">) — see the signal/obsidian contrib themes.
Webmentions (responses)
Shipped. All bundled + contrib themes carry the responses block; configure
federation.indieweb.webmention.display.mode(and runcolophon webmention fetch) to populate it. See Show webmentions.
Webmentions are replies/likes/reposts from other sites, shown under a post. The engine never
decides how they render — it only exposes the data, and the site picks a display.mode
(federation.indieweb.webmention.display.mode). What the engine populates depends on that mode:
| Variable | live |
asset |
disabled |
|---|---|---|---|
mentions_enabled |
true (unless the post sets webmentions: false) |
true (unless opted out) |
false |
has_mentions |
false — not known at build (JS fills the count) |
accurate, from the synced list | false |
mentions |
empty — no build-time data | structured list (bake your own) | empty |
mentions_html |
empty | engine-rendered drop-in block | empty |
mentions_src |
the receiver's client-fetch endpoint | your published _mentions/<post>.json |
unset |
live— the browser fetches the receiver directly; most realtime, no rebuild, but no build-time data (so no count, no baking) and nothing for no-JS readers.asset— the browser fetches your curated_mentions/asset (refreshed out-of-band bywebmention publish); because that list also exists at build, the engine fillsmentions/mentions_html/has_mentions, so a theme may bake instead of relying on JS.disabled— nothing is exposed or shipped, regardless of the per-post setting.
Always guard the block on mentions_enabled and keep it a sibling of the content element, never
inside it (so it's never read aloud by the TTS reading — same rule as the author card/downloads).
Progressive-enhancement block (what press uses). Bake the asset-mode HTML server-side, then let
the engine-emitted mentions.js refresh it (or, in live mode, populate it):
{% if mentions_enabled %}
<section class="responses" {{ mentions_attrs|safe }} aria-label="Responses">{{ mentions_html|safe }}</section>
{% endif %}
…
{% if mentions_enabled %}<script defer src="{{ base_path }}mentions.js"></script>{% endif %}
mentions_attrs is the engine-owned data-mentions* wiring (the source URL, plus data-mentions-live
- target + blocklist in
livemode) — drop it in and the same markup works across modes and reader drivers. With JS off (andassetmode) the bakedmentions_htmlstill shows; with JS on,mentions.jsfetches and renders/refreshes.
Build your own from the structured list (asset mode), e.g. to restyle or split by type:
{% if has_mentions %}<section class="responses">
{% for m in mentions %}
<article class="response {{ m.type }} h-cite">
{% if m.author.photo %}<img class="u-photo" src="{{ m.author.photo }}" alt="">{% endif %}
<a class="p-author h-card u-url" href="{{ m.author.url }}">{{ m.author.name }}</a>
{% if m.content %}<div class="p-content">{{ m.content }}</div>{% endif %}
<a class="u-url" href="{{ m.url }}"><time class="dt-published">{{ m.published }}</time></a>
</article>
{% endfor %}
</section>{% endif %}
Each mentions item is {type (like|repost|reply|mention), author{name,url,photo}, url, content, published}. A no-JS / text theme (e.g. minimal) just uses asset mode + {{ mentions_html|safe }}
and skips the script entirely.
Silo icons
Responses, the "Also posted on…" (u-syndication) links, and the author h-card links show a small
brand icon when colophon recognises the source silo (Bluesky, Mastodon, GitHub, X, Reddit,
Hacker News, Threads, Flickr, LinkedIn, Tumblr, GitLab — else a generic website globe). These are
glyphs in a tiny webfont, silos.woff2, that the engine emits at the site root (when responses
are active) and renders into <span class="silo">. A theme just declares the face and styles the
span:
@font-face { font-family: "Colophon Silos"; src: url("silos.woff2") format("woff2"); font-display: swap; }
.silo { font-family: "Colophon Silos"; line-height: 1; }
The font is curated from Font Awesome (Brands + Solid) by
contrib/scripts/silo-font — re-run it to add a network or change
the set. Detection (host → silo) lives in internal/build/mentions.go + assets/mentions.js, kept
in sync with the font's silos.json codepoints.
Analytics
colophon owns the analytics clients; a theme's only job is to include them. When a site
configures a provider (statsfactory and/or Google Analytics — see analytics),
the build writes that provider's loader to the site root — analytics-sf.js for the cookieless
statsfactory beacon, analytics-ga.js for the Google Analytics loader — and exposes the
matching <script> markup (with each page's dimensions) as the analytics_head variable.
A theme leverages both providers with one line, just before </body>:
{% if analytics_head %}{{ analytics_head|safe }}{% endif %}
</body>
The theme never names a provider: analytics_head already contains whichever loaders are
enabled (statsfactory, GA, both, or — when the site configures none — nothing, leaving the line
inert). Every built-in and contrib theme includes it; a JS-enabled custom theme should too.
Glossary
If the site ships a glossary.yaml (see authoring) and a page uses a
term, the build publishes the data + a small decorator and exposes a glossary_head variable.
A theme opts in with one line before </body> (right where the analytics line goes):
{% if glossary_head %}{{ glossary_head|safe }}{% endif %}
</body>
That's it — no per-theme CSS or JS needed:
- The decorator and styles are engine-provided (
glossary.js+glossary.css, written to the site root). The decorator wraps terms in<abbr class="gloss" data-gloss="…">and shows an accessible pop-over on hover/focus (role="tooltip"+aria-describedby, Escape to dismiss). - The default styling adapts to your theme through the token contract: it uses
--accentfor the underline and--elevated/--text/--muted/--border+--serif/--sansfor the dictionary-stanza card (with neutral fallbacks for token-less themes). - To customise the look, override
.glossand.gloss-tip(and.gloss-tip .gloss-term/.gloss-def) in your own stylesheet — e.g.pressgives.glossa light wavy underline. - A text-only / no-JS theme simply omits the line: terms stay plain readable text, so the
page still works correctly without the decorator (this is what
minimaldoes).
Build a theme — step by step
The fastest route is to start from a built-in and change only what you want. A new theme
inherits default, so you can ship as little as one CSS file.
1. Create the theme directory in your project and point a build at it:
mkdir -p themes/mytheme
# colophon.yaml
sites:
- id: main
theme: mytheme # or set it on one environment to preview first
2. Add a stylesheet. With nothing else present, mytheme uses the default templates
and your CSS:
/* themes/mytheme/style.css */
body { font-family: Georgia, serif; max-width: 42rem; margin: 2rem auto; }
colophon serve # open the printed URL; edits live-reload
That alone is a working theme. Everything below is optional, added when you want more control.
3. Take over the post template. Copy a built-in as a starting point, then edit it:
colophon themes eject default # writes themes/default/ — copy what you need into mytheme/
A minimal themes/mytheme/page.html:
<!doctype html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ meta_title }}</title>
<link rel="stylesheet" href="{{ base_path }}style.css">
{{ feed_head|safe }}
{{ seo_head|safe }}
</head>
<body>
<header><a href="{{ base_path }}">{{ site_title }}</a></header>
<article>
<h1>{{ title }}</h1>
{% if date %}<time>{{ date }}</time>{% endif %}
{% if read_time %}<span>· {{ read_time }} min read</span>{% endif %}
{{ content|safe }}
{% if tags %}<footer>{% for t in tags %}<a href="{{ t.url }}">{{ t.name }}</a> {% endfor %}</footer>{% endif %}
</article>
</body>
</html>
4. Add the index (themes/mytheme/index.html) — the post list, the nav menu (standing
pages like About), and per-tag pages:
<!doctype html>
<html lang="{{ lang }}">
<head><meta charset="utf-8"><title>{{ heading }}</title>
<link rel="stylesheet" href="{{ base_path }}style.css">{{ feed_head|safe }}</head>
<body>
{% if nav_pages %}<nav>{% for n in nav_pages %}<a href="{{ n.url }}">{{ n.title }}</a> {% endfor %}</nav>{% endif %}
<h1>{{ heading }}</h1>
<ul>
{% for p in pages %}
<li><a href="{{ base_path }}{{ p.url }}">{{ p.title }}</a>
{% if p.date %}<small>{{ p.date }}</small>{% endif %}</li>
{% endfor %}
</ul>
</body>
</html>
nav_pages is the list of standing pages ({title, url}); pages is the chronological posts.
The build sorts entries into these two buckets by page type — you just render
them. (Add the same nav_pages block to page.html so the menu appears on entries too.)
5. (Optional) Tailor specific page types. Add a <type>.html template, or branch on the
page_type variable inside page.html, to give a type its own look. See
Page types. Skip this and every entry just uses page.html.
6. Decide how rich blocks render. Do nothing (raw text shows, like minimal), or enhance
them with the has_* gates as shown in Enhancing rich blocks. The
vendored libraries are inherited from default, so {{ base_path }}vendor/katex/… resolves
with no extra files in your theme.
7. Add your own assets. Any non-.html file under themes/mytheme/ is copied to the
output root, keeping its path: themes/mytheme/logo.svg → /logo.svg, referenced as
{{ base_path }}logo.svg. Self-host fonts the same way and @import them from your CSS.
8. Build and ship:
colophon build --env production # writes public/ with your theme
Checklist: every internal href/src starts with {{ base_path }}; content, feed_head
and seo_head use |safe; {# comments #} stay on one line. To contribute a theme back, drop
it in contrib/themes/ following the existing ones.
Generated from docs/themes.md — edit it there.