Skip to content

Templates & CSS

When a transform returns a full document (the document behaviour), HTML and Typst output are wrapped in a Jinja2 template. Fragment output — for example when you select a single element with --xpath, or when opm chunk transforms a section — is not a complete HTML document; the template supplies the shell.

HTML templates

Pass a template with --template, or set a default with [transform.web] template in opm.toml. Chunk pages use chunking.template instead. If none is given, a packaged default template is used. opm init copies one editable shell into templates/ — whichever suits the vocabulary, journal for JATS and chapbook otherwise — plus the Typst and Word templates. Pass --templates for the alternatives as well: the chapbook (reading), journal (scholarly article: masthead, TOC rail, one measure) and handbook (docs) HTML shells, and the tufte and bootstrap demo shells. Swapping one in is a one-line edit to opm.toml. Edit them in place: they are yours once copied.

With --example, --templates adds those shells beside the ones the example already ships, and never overwrites a shell of its own — an example's copy is often customised.

A bare opm init on a terminal asks about them after the starting point, so the flag is mostly for scripted runs. A piped or scripted opm init is never asked anything and gets the one shell it wires up.

opm transform data/sample.xml \
  --template templates/tufte.html.j2 --css styles/main.css -o out.html

-t print uses its own shell:

  1. --template
  2. [transform.print] template
  3. Packaged default_print.html.j2 (not [transform.web] template)
[transform.print]
template = "templates/print.html.j2"

The DocBook example ships a worked print shell under examples/docbook/templates/print.html.j2.

The same Jinja file can wrap both a full-document transform and chunked pages. The contents of <head> have to work for both pipelines, which differ in how CSS arrives.

Document vs fragment output

A model with behaviour="document" (typically the TEI / DocBook root) emits a complete HTML tree:

<html>
  <head>
    <meta charset="utf-8">
    <style type="text/css">/* ODD-generated CSS */</style>
  </head>
  <body>…transformed content…</body>
</html>

Before the Jinja template runs, that tree is split:

Variable Full-document transform (opm transform of the root)
head_html Inner HTML of <head> — charset meta and the ODD stylesheet
content_html Inner HTML of <body>
odd_css Empty (the same CSS is already inside head_html, so it is not passed twice)

opm chunk does not start at that root. It transforms each selected division (div, DocBook section, a reconstructed page, …). Those elements use block / heading / pb-observable models and produce a fragment — a <div> or similar, not <html>. There is no <head> to extract:

Variable Chunked / --xpath fragment
head_html Empty string
content_html The serialized fragment
odd_css ODD-generated stylesheet text — the template must emit it

JSON chunk files expose the same split as head and odd_css keys: head is empty for normal section chunks.

A template that works in both cases therefore always renders head_html and odd_css:

<head>
  <meta charset="utf-8">
  {{ head_html | safe }}
  {% if odd_css %}
  <style type="text/css">{{ odd_css }}</style>
  {% endif %}
</head>

On a full document, head_html already contains the ODD <style> and the odd_css branch is skipped. On a chunk page, head_html is empty and odd_css supplies the classes the fragment uses (tei-title, …).

{{ head_html | safe }} on a chunk page is a no-op. Leave it in so the template can also wrap opm transform.

Template variables

A document or chunk template receives:

Variable Contents
content_html The transformed document body, or the chunk/fragment markup
head_html Inner HTML of the transform <head>, or empty for fragments (see above)
odd_css ODD-generated stylesheet text when it is not already in head_html
context The project's own [context] values — see Template context
lang Document language (defaults to en)
chunk Chunk metadata (id, file, prev, next, …) when rendering via opm chunk
fragments Named HTML or text from [chunking.fragments] (chunk templates only)
parameters The [transform.parameters] map, as also bound to XPath $parameters

A minimal template:

<!DOCTYPE html>
<html lang="{{ lang|default('en') }}">
  <head>
    <meta charset="utf-8">
    {{ head_html | safe }}
    {% if odd_css %}<style type="text/css">{{ odd_css }}</style>{% endif %}
    {% if context.webcomponents_url %}<script type="module" src="{{ context.webcomponents_url }}"></script>{% endif %}
  </head>
  <body>
    {% if fragments is defined and fragments.title %}
    <p>{{ fragments.title | striptags }}</p>
    {% endif %}
    {{ content_html | safe }}
  </body>
</html>

Template context

Everything a template needs beyond the transform output comes through one variable, context, filled from a [context] table in opm.toml:

[context]
site_name = "The Serafin Letters"
show_downloads = true
nav = [
    { label = "Home", url = "/" },
    { label = "About", url = "/about" },
]
<h1>{{ context.site_name }}</h1>
<nav>
  {% for item in context.nav %}<a href="{{ item.url }}">{{ item.label }}</a>{% endfor %}
</nav>
{% if context.show_downloads %}<a href="{{ chunk.file }}.pdf">PDF</a>{% endif %}

The same context reaches document templates, chunk templates, the collection index template, and Typst templates. To vary it by output type, add a [transform.<type>.context] table — it overlays [context] for that type only:

[context]
site_name = "The Serafin Letters"

[transform.typst.context]
site_name = "The Serafin Letters — print edition"
paper = "a5"

Two kinds of CSS

  • ODD-generated CSS — produced at compile time from <outputRendition> rules and linked stylesheets in the ODD (see ODD files). It styles the classes the transform emits. How it reaches the page is described above: inside head_html for document output, via odd_css for fragments.
  • Base override (--css / [transform] css) — replaces those packaged base rules for every output mode. It is compiled into the ODD stylesheet rather than layered after it, and forms part of the ODD cache key. Use it to restyle what the runtime emits; for a project's own design CSS use [chunking] assets, which can also carry the images and fonts that stylesheet references. Note that [transform.epub] css is not a per-mode version of this key: it adds a stylesheet to the EPUB package on top of everything else.

Web components

With web components mode enabled (--webcomponents or [transform.web] webcomponents = true), alternate behaviours emit <pb-alternate> and context.webcomponents_url is filled in with the pinned default bundle, so the template can load the tei-publisher pb-components bundle:

{% if context.webcomponents_url %}
<script type="module" src="{{ context.webcomponents_url }}"></script>
{% endif %}

This integrates output with the TEI Publisher web component ecosystem. The URL is an ordinary template value with a default, not a setting of its own: give webcomponents_url in [context] or [transform.web.context] and yours wins — useful to serve the bundle from your own host, or to pin a different version.

Typst and DOCX templates

  • Typst uses .typ.j2 Jinja2 templates configured under [transform.typst]. opm init copies book.typ.j2 (TEI) or docbook.typ.j2 (DocBook). If none is given, the packaged default_document.typ.j2 is used.
  • DOCX uses a binary .docx file as a style template (not Jinja2), configured under [transform.docx] or passed with --template. opm init copies templates/default.docx; a one-off transform falls back to that same packaged file — see Output formats.