Creating templates for plym
17 minutes read
plym supports custom templates which are easy to build, and are fully customizable. Read this guide to start creating templates for your plym blog. If you are an agent, use content negotiation to receive the markdown format of this page.
1. Overview
A plym template is a directory of Jinja2 and CSS files used to produce HTML for the blog index and individual posts.
Templates in plym are strictly body fragments. You design what goes inside the <body> tag. plym wraps that fragment in a skeleton it owns, and fills the <head> for you:
- The document shell —
<!DOCTYPE>,<html lang>,<head>,<body>— withplym-indexorplym-postset as the class on<body>. - Title, description, favicon, and the canonical link.
- OpenGraph and Twitter card tags, plus
article:published_timeandarticle:modified_time. <link rel="alternate" type="text/markdown">pointing at the site'sllms.txt.<link rel="preload" as="image" fetchpriority="high">for the cover, when a post has one.- JSON-LD: a
BlogPostingblock on every post, and aFAQPageblock when the post carries FAQs. <meta name="plym-render">— the render stamp, described in section 3.- CSS bundling, minification and inlining, self-hosted webfonts, optional Prism syntax-highlighting assets, and operator-configured head/body snippet injection.
Two Jinja environments run per page. The skeleton is rendered with StrictUndefined, so a missing variable there raises. Your template is not. A typo in your fragment renders as an empty string and ships silently, which makes the variable tables in section 3 worth reading closely.
2. File Layout
Template directories live in plym/templates/ and are referenced by directory name via the template key in config.yaml. Use lowercase kebab-case names by convention.
plym/templates/<template-name>/
├── index.html
├── post.html
├── template.yaml
└── css/
└── base.css
| File / Directory | Status | Purpose |
|---|---|---|
index.html |
Required | Body fragment for the blog index. Rendering fails with a template-not-found error if missing. |
post.html |
Required | Body fragment for a single post. |
css/ |
Optional (expected in practice) | All *.css files inside are concatenated in alphabetical order, minified, and inlined. If absent, the template ships no styles. |
template.yaml |
Optional | Design defaults shipped with the template (fonts, colors, Prism theme). |
3. Context Variables
Global Context
Available in all templates: site and debug (Boolean, true when plym runs in debug mode). Post renders additionally receive render_stamp.
| Variable | Type | Nullable | Description |
|---|---|---|---|
site.name |
String | No | The name of the site. |
site.description |
String | Yes | Site description. Used as the index page's meta description, falling back to site.name. |
site.website |
String | No | The marketing domain URL. |
site.blog_home |
String | No | Public URL where plym is mounted. |
site.blog_prefix |
String | No | Normalized mount prefix: leading slash, no trailing slash (e.g. /blog), or empty string when mounted at the domain root. Root is now the default. |
site.language |
String | No | ISO language code. |
site.template |
String | No | Name of the active template. |
site.pagination.page_size |
Integer | No | Number of posts per index page. |
site.colors.<color> |
String | No | Values for primary, secondary, accent, background. |
site.fonts.heading / site.fonts.body |
Object | No | A font slot, no longer a plain string. Printing it emits a model repr into your HTML; read .family or .weights instead. |
site.fonts.<slot>.family |
String | No | Google Fonts family name, unquoted (Inter). The --font-<slot> CSS variable is the quoted form. |
site.fonts.<slot>.weights |
Dict | No | Role name to CSS weight, e.g. {'bold': 600, 'black': 900}. Never empty. |
site.prism.enabled |
Boolean | No | Whether syntax highlighting assets are shipped (operator-controlled, off by default). |
site.prism.theme |
String | No | Prism theme name. |
site.favicon |
String | Yes | URL to the favicon. |
site.logo |
String | Yes | URL to the logo image. |
site.public_blog_url() |
Method | No | Returns blog_home without trailing slash, prefixed with https:// when it lacks a scheme. Includes the mount prefix. |
site.public_origin() |
Method | No | Scheme and host only, no prefix or path. |
site.absolute_url(path) |
Method | No | Returns path unchanged if it already has a scheme, otherwise prefixes public_origin(). |
render_stamp |
String | No | Post renders only. A 16-character digest of your template files, the bundled CSS, the Prism JS and the site config. plym writes it into <meta name="plym-render"> and reads it back out of generated files to find pages rendered against an older template or config. You do not need to emit it. |
Index Context (posts)
Available only in index.html: a list of published-post dicts for the current page. Pagination is driven by the ?page= query parameter; no page number, total count, or next/previous variables are passed to the template.
| Variable | Type | Nullable | Description |
|---|---|---|---|
post.id |
Integer | No | Database id. |
post.slug |
String | No | URL-friendly identifier. |
post.path |
String | No | The post's route relative to the mount prefix: <category-slug>/<slug> when categorised, otherwise just <slug>. Build index links from this, not from post.slug. |
post.title |
String | No | Title of the post. |
post.status |
String | No | Always published on the public index. |
post.excerpt |
String | Yes | Short summary. |
post.cover |
String | Yes | Cover image URL exactly as stored — relative paths stay relative here. |
post.canonical_url |
String | Yes | Operator-supplied canonical override, if any. |
post.category |
Dict | Yes | id, name, slug, weight. Null for uncategorised posts. |
post.weight |
Integer | Yes | Manual ordering weight. |
post.author.id |
Integer | No | Author id. |
post.author.display_name |
String | No | Name of the writer. |
post.author.avatar_url |
String | Yes | URL to the author's avatar. |
post.author.links |
List | No | List of dicts with type and url. Empty when the author has none. |
post.published_at |
Datetime | Yes | Publish date. Format with strftime. |
post.created_at / post.updated_at |
Datetime | No | Record timestamps. |
post.reading_time |
Integer | No | Estimated reading time in minutes. |
post.tags |
List | No | List of dicts with id, name, and slug. |
Post Context (post)
Available only in post.html. This is not a superset of the index context — it is built separately and omits id, status, weight, and created_at.
| Variable | Type | Nullable | Description |
|---|---|---|---|
post.slug |
String | No | URL-friendly identifier. |
post.path |
String | No | Same category-aware route as the index context: <category-slug>/<slug> or <slug>. |
post.category |
Dict | Yes | id, name, slug, weight. Null for uncategorised posts and always null in preview renders. |
post.title |
String | No | Title of the post (post.name is an alias). |
post.content |
String | No | Rendered HTML of the post body. Must be emitted with the safe filter. |
post.excerpt |
String | Yes | Short summary. |
post.cover |
String | Yes | Cover image URL, resolved to an absolute URL against public_origin(). This differs from the index context, where cover is left as stored. |
post.canonical |
String | No | Canonical URL: the operator override if set, otherwise public_blog_url()/path — the category-aware path, not the bare slug. |
post.canonical_url |
String | Yes | Raw operator-supplied canonical override. |
post.author.display_name |
String | No | Name of the writer (no id in this context). |
post.author.avatar_url |
String | Yes | URL to the author's avatar. |
post.author.links |
List | No | List of dicts with type and url; feeds sameAs in the author's JSON-LD. |
post.reading_time |
Integer | No | Estimated reading time in minutes. |
post.published_at |
Datetime | Yes | Publish date. Format with strftime. |
post.updated_at |
Datetime | Yes | Last update date. |
post.tags |
List | No | List of dicts with id, name, and slug. |
post.toc |
List | No | Recursive list of dicts covering H2–H4. Each node carries level, id, name and children; python-markdown also adds html and data-toc-label, which you can ignore. |
post.faqs |
List | No | List of dicts with id, question, answer. Empty list when the post has none. |
post.article_jsonld |
String | No | Pre-serialised BlogPosting JSON-LD. The skeleton already emits this. It is exposed for inspection, not for you to print again. |
post.faq_jsonld |
String | Yes | Pre-serialised FAQPage JSON-LD, or null when the post has no FAQs. Also already emitted by the skeleton. |
A Note on Routes
plym serves posts from two routes: /{slug} and /{category}/{slug}, both relative to blog_prefix. post.path is the one variable that returns the right half for either case, which is why every link in your template should be built as {{ site.blog_prefix }}/{{ post.path }}. Append .md to either route, or send Accept: text/markdown, and plym serves the raw markdown source instead.
4. Design Defaults (template.yaml)
Templates can declare baseline design tokens. These are deep-merged under the operator's config.yaml, so operator values win key-by-key. Only the fields below are permitted — any other key (including prism.enabled or prism.languages, which are operator-only) fails validation at startup.
fonts:
heading:
family: Inter
weights:
bold: 600
black: 900
body:
family: Merriweather
weights:
regular: 400
colors:
primary: "#1a1a1a"
secondary: "#6b6b6b"
accent: "#E9793A"
background: "#ffffff"
prism:
theme: tomorrow
Note: syntax highlighting itself is opt-in by the operator (prism.enabled, default false). A template can only choose the default theme.
How to declare font weights in template.yaml
A font slot takes one of two forms. The bare family name is the original spelling and still valid:
fonts:
heading: Inter
Or a family plus the weights you want downloaded:
fonts:
heading:
family: Inter
weights:
bold: 600
black: 900
Role names are labels, not numbers. bold: 600 says "this template's bold is 600", and bold: 800 is equally legal. The vocabulary is closed to five names — light, regular, medium, bold, black — which also caps a slot at five weights. Anything else fails validation at startup with the legal names printed in the error. Each weight is an integer from 1 to 1000, and each role you declare becomes one CSS variable: --wght-<slot>-<role>.
Family names accept letters, digits and spaces only. Google's embed URLs spell spaces as +, so Playfair+Display is accepted and folded to Playfair Display. A hyphen, or a family string with weights baked in the old Google way (Lato:wght@400;700), now fails at startup instead of silently falling back to system fonts.
Use expanded block mappings, never inline {family: Inter} flow mappings.
What happens when a slot declares no weights
Leave weights off a slot and plym keeps the pair it has always shipped: heading 600 and 900, body 400. That is a compatibility shim for every template written before weights were configurable, and it is marked for removal at the next major version. After that, an undeclared slot gets the engine default of one weight per slot — heading 600, body 400. Declare your weights now and the removal changes nothing for you.
The shim reads the merged config, so a slot with weights from either side is a slot the shim leaves alone. An operator swapping only the family (heading: Roboto) keeps your roles, because a bare string is expanded to {family: ...} before the merge rather than replacing the whole slot.
5. CSS and Styling
Do not hardcode hex values, font families or font weights in your CSS. Use the CSS variables automatically generated and injected by plym. The final bundle is assembled in this order — color variables, font and weight variables, self-hosted webfont @font-face rules, Prism theme CSS (only when Prism is enabled), then your template's *.css files alphabetically — minified, and inlined as a single <style> block in <head>. When Prism is enabled, its core + language JS is inlined in a <script> before </body>.
plym sets plym-index or plym-post as the class on the <body> element itself, so you can scope styles per page type.
| CSS Variable | Maps To | Example Usage |
|---|---|---|
var(--color-primary) |
config.colors.primary |
color: var(--color-primary); |
var(--color-secondary) |
config.colors.secondary |
color: var(--color-secondary); |
var(--color-accent) |
config.colors.accent |
color: var(--color-accent); |
var(--color-background) |
config.colors.background |
background: var(--color-background); |
var(--font-heading) |
config.fonts.heading.family |
font-family: var(--font-heading), sans-serif; |
var(--font-body) |
config.fonts.body.family |
font-family: var(--font-body), serif; |
var(--wght-heading-<role>) |
config.fonts.heading.weights.<role> |
font-weight: var(--wght-heading-bold); |
var(--wght-body-<role>) |
config.fonts.body.weights.<role> |
font-weight: var(--wght-body-regular); |
The font variables already contain the quoted family name, so append only generic fallbacks.
Only declared roles produce a variable. There is no bare --wght-heading, and a role the config never mentions is simply absent from :root.
That absence has teeth. A missing custom property makes font-weight invalid at computed-value time, which resolves to unset — so the element inherits the body weight rather than keeping the one it had. Give any role you cannot guarantee a fallback:
.card-title { font-weight: var(--wght-heading-bold, 600); }
plym's own admonition and tab chrome is written exactly that way, because a template may legally declare heading weights without a bold.
Available Font Weights
In short: the weights you declare are the weights you get, and nothing else. plym asks Google Fonts for exactly the roles in your template.yaml, subsets them to basic Latin (ASCII letters, digits, punctuation) and self-hosts the result. Characters outside that range fall back to system fonts. Italic faces are never downloaded, so <em> is always browser-synthesised, and so is any weight you did not ask for.
Slots that share a family are merged into one request, so pointing heading and body at the same family downloads the union of their weights once.
Every extra weight is bytes in front of the first paint, and the cost is not linear. Measured against plym's own subset:
| Request | Faces served | Files | Payload |
|---|---|---|---|
| Inter 600 | 1 | 1 | 16.4 KB |
| Inter 600, 900 | 2 | 1 | 29.8 KB |
| Inter 300, 400, 500, 600, 900 | 5 | 1 | 29.8 KB |
| Merriweather 400 | 1 | 1 | 32.7 KB |
| Merriweather 400, 700 | 2 | 1 | 47.9 KB |
| Lato 400 | 1 | 1 | 8.3 KB |
| Lato 300, 400, 500, 700, 900 | 4 | 4 | 34.3 KB |
Two things fall out of that table. Inter and Merriweather are variable fonts, so they arrive as one file whatever you ask for: the jump from one weight to two nearly doubles the payload, and the jump from two to five is free. Lato is a static family, one file per weight, so its cost climbs with every role you add. Look at the last row again. It asked for five weights and got four, because Lato has no 500.
What happens when a family lacks a weight you asked for
A missing face is a warning, not an error. Google's css2 endpoint answers a request for a weight it cannot serve with a 200 and that face quietly absent, so plym checks every requested weight against the response and logs the gap along with what the family actually offers:
webfonts: Lato has no 500 face; the family offers 100, 300, 400, 700, 900 — keeping 300, 400, 700, 900
When a family fails outright — a timeout, or a name that passes the character rules but matches nothing on Google Fonts — the build logs the same diagnostic and carries on without it. Your CSS then falls through to whatever generic sits after var(--font-heading), which is the reason to always put one there. Read the build log after changing fonts. Nothing on the page will tell you.
6. Markdown Target Elements
Your CSS must account for the HTML output generated by plym's markdown renderer.
| Markdown Element | HTML Output Example |
|---|---|
| Headings (H2–H4) | <h2 id="slug"><a class="toclink" href="#slug">Text</a></h2> |
| Fenced Code | <pre><code class="language-python">...</code></pre> |
| Inline Code | <code>...</code> |
| Images | <img src="url" alt="alt" loading="lazy" decoding="async"> |
Gallery / carousel (custom ```gallery fence, one image per line) |
<div class="plym-gallery"><img src="url" alt="alt" loading="lazy" decoding="async">...</div> |
| Tables | Standard <table> markup. |
| Task Lists | <li class="task-list-item"><input type="checkbox" disabled checked>...</li> |
Strikethrough (~~text~~) |
<del>strike</del> |
Subscript (~text~) |
<sub>text</sub> |
| Footnotes | <sup id="fnref:1"><a href="#fn:1">...</a></sup> |
Rendered HTML passes through a sanitizer before it reaches post.content. The allowlist covers standard prose, tables, <figure>, <details>/<summary>, <picture>/<source>, and disabled checkbox inputs; class and id survive on every element. Inline style attributes, <script>, <iframe> and event handlers do not. Outbound links get rel="noopener noreferrer" added. Style the classes plym emits rather than expecting authors to inline anything.
7. Minimum Viable Template
Below is a functional starting point for a template.
index.html
<header><a href="{{ site.blog_prefix or '/' }}"><h1>{{ site.name }}</h1></a></header>
<main>
{% for post in posts %}
<article>
<h2><a href="{{ site.blog_prefix }}/{{ post.path }}">{{ post.title }}</a></h2>
{% if post.excerpt %}<p>{{ post.excerpt }}</p>{% endif %}
<small>
{{ post.author.display_name }}
{% if post.category %} · {{ post.category.name }}{% endif %}
{% if post.published_at %} · {{ post.published_at.strftime('%b %d, %Y') }}{% endif %}
</small>
</article>
{% else %}
<p>Nothing here yet.</p>
{% endfor %}
</main>
post.html
<header><a href="{{ site.blog_prefix or '/' }}">{{ site.name }}</a></header>
<article>
<h1>{{ post.title }}</h1>
{% if post.cover %}<img src="{{ post.cover }}" alt="">{% endif %}
{{ post.content | safe }}
</article>
css/base.css
body {
max-width: 720px;
margin: 0 auto;
padding: 1rem;
color: var(--color-primary);
background: var(--color-background);
}
a { color: var(--color-accent); }
img { max-width: 100%; height: auto; }
pre { overflow-x: auto; padding: 1rem; }
8. Performance and Accessibility Budgets
Templates submitted to the main plym repository must meet the following budgets. These are review policy — they are not enforced by the build.
Performance Targets
| Metric | Threshold |
|---|---|
| Lighthouse Performance | ≥ 95 |
| LCP (Simulated 3G) | < 1.5 s |
| Minified CSS Total | < 30 KB |
Inlined <style> block |
< 50 KB |
| Third-party Requests | 0 (self-hosted webfonts under {blog_prefix}/webfonts/ are the only extra same-origin fetches) |
Those webfonts are the largest thing a template can add to a first paint, and the weights you declare decide how large. Check the byte table in section 5 before you add a fourth role.
Accessibility Rules
| Requirement | Detail |
|---|---|
| Interactivity | All interactive elements must be keyboard reachable with visible focus rings. |
| ARIA Labels | Required on icon-only buttons or links. |
| Contrast | WCAG AA contrast (≥ 4.5:1) for body text against background. |
| Motion | Must respect prefers-reduced-motion: reduce. |
9. Anti-Patterns
- Linking to posts as
{{ site.blog_prefix }}/{{ post.slug }}— correct for uncategorised posts, a 404 for every categorised one. Usepost.path. - Writing
<html>,<head>, or<meta>tags in template files — plym does not detect this; it silently produces a malformed nested document. - Re-emitting
post.article_jsonldorpost.faq_jsonldin your body fragment — the skeleton already wrote both into<head>, and duplicate structured data is a validation error. - Requesting external assets (CDNs, JS frameworks, font files) via
<link>or<script>— violates the zero-third-party budget. - Rendering
post.contentwithout thesafefilter — autoescaping is on, so the post body will display as escaped HTML source text. - Assuming nullable variables (e.g.
post.cover,post.excerpt,post.category,site.favicon,site.logo,post.published_at) are always present without{% if %}guards. Your fragment is not rendered withStrictUndefined, so a misspelled variable is a blank space, not an error. - Declaring operator-only keys (
prism.enabled,prism.languages, site identity, injection snippets) intemplate.yaml— this fails config validation and prevents startup. - Writing a literal
font-weight: 600or a quoted family name in template CSS — plym generates--font-<slot>and--wght-<slot>-<role>for exactly this, and a literal outlives every config change that was supposed to move it. - Reading a
--wght-*variable with no fallback for a role yourtemplate.yamlmay not declare — the property is absent rather than empty,font-weightgoes invalid at computed-value time, and the element inherits instead of holding its weight. - Declaring all five roles because all five exist — on a static family that is five files, and a role no rule in your CSS ever reads is a download nobody looks at.