Skip to content
All articles

Creating templates for plym

17 minutes read

Creating templates for plym

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> — with plym-index or plym-post set as the class on <body>.
  • Title, description, favicon, and the canonical link.
  • OpenGraph and Twitter card tags, plus article:published_time and article:modified_time.
  • <link rel="alternate" type="text/markdown"> pointing at the site's llms.txt.
  • <link rel="preload" as="image" fetchpriority="high"> for the cover, when a post has one.
  • JSON-LD: a BlogPosting block on every post, and a FAQPage block 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. Use post.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_jsonld or post.faq_jsonld in 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.content without the safe filter — 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 with StrictUndefined, so a misspelled variable is a blank space, not an error.
  • Declaring operator-only keys (prism.enabled, prism.languages, site identity, injection snippets) in template.yaml — this fails config validation and prevents startup.
  • Writing a literal font-weight: 600 or 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 your template.yaml may not declare — the property is absent rather than empty, font-weight goes 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.

Frequently asked questions

Can I customise the design & layout of my plym template?
Yes, you can customise every bit of CSS. plym engine expects you to handle context objects it passes. The UI and layout are independent.