Skip to content

ADR-012: Partials are .jinja files outside the render root

Status: accepted

Context

Any template much above fifty files repeats itself: the same badge block, the same licence header, the same workflow step. Jinja's answer is {% import %} and {% include %}, and MiniJinja supports both — but they resolve names through a loader, and git-tpl registered none. Every import failed with "tried to include non-existing template".

Three questions had to be answered together, because the answer to each constrains the others.

Where does a loader read from? Not the filesystem. A template revision is pinned, and a loader that read ./macros.jinja relative to the process's working directory would break invariant 2 the first time two people rendered from different directories. It must read the Git tree, at the resolved revision — the same bytes everything else renders from.

Is a loader an extension point? ADR-003 closes the filter set and states there is no plugin point, because templates are untrusted input. A loader looks superficially like a hole in that. It is not: it resolves a name to bytes already committed in the template repository and hands them to the same parser that already reads every .jinja file. It executes nothing, and it reaches nothing a template could not already put in a file. A template that can {% import %} its own macros.jinja can do exactly what it could do by pasting the macro into every file. Invariant 5 is untouched.

How does a macro definition avoid being rendered into the project? This is the question that shaped the decision. template/macros.jinja would be walked like any other file and written out as macros — a stray file in every generated project. Avoiding that needs a rule about which files are output and which are not.

The obvious candidates were a manifest key (partials = [...]), a naming convention (_macros.jinja), and a convention directory (template/_partials/). Each adds a concept, a skip rule in the tree walk, and a way to get it wrong — a file that is silently neither rendered nor loadable.

Decision

A partial is any .jinja blob in the template repository that lives outside the render root, named by its path relative to the repository root.

template.toml
macros.jinja          importable as "macros.jinja"
macros/rust.jinja     importable as "macros/rust.jinja"
data/licenses.toml    not importable — not .jinja; `[data]` reads this
README.md             the template's own readme
template/             the render root
  README.md.jinja     {% import "macros.jinja" as m %}

The render root already separates output from not-output: root in template.toml names the subtree that gets rendered, and everything else in the repository — the manifest, the data files, the template's own README — is already never emitted. Partials join that set. The tree walk only ever sees the root subtree, so there is no skip rule: a partial cannot reach the project because the renderer never sees it.

The .jinja restriction bounds what is read eagerly, and keeps parsing data files the business of [data], which knows their formats.

A file inside the root is output, never a partial. Giving one file both meanings is the ambiguity this decision exists to avoid.

Names are plain paths. The <prefix>: namespace is deliberately left free for template inheritance, where a child must be able to name its parent's file explicitly ({% extends "parent:base.html.jinja" %}) rather than resolve to itself.

Partials are collected once per run, into an owned map, before anything renders. MiniJinja's loader must be Send + Sync + 'static, which the libgit2 backend's repository handle is not, so borrowing the repository into the loader is not possible. Reading up front is also what makes the set enumerable, which the diagnostics need.

The one environment constructor in src/eval.rs takes the partials, so a macro importable from a .jinja file is importable from a computed expression too. Two environments would be two sets of rules for the same syntax.

A miss returns Ok(None) from the loader rather than an error, because that is what {% include "x" ignore missing %} is defined against. The names that do exist are appended to the diagnostic instead — the failure is nearly always a typo, or a path written relative to the render root.

Consequences

Templates can share macros. The repeated badge block is written once.

Nothing about the on-disk format changed. No new manifest key, no reserved filename, no new directory. A template that has no partials behaves exactly as before, and the loader is never consulted.

Determinism holds: the loadable set comes from list_tree, which is in Git-canonical order, into a BTreeMap. A partial is pinned to the same revision as the files that import it, --dirty included, so editing a macro changes the rendered tree and advances the ref — which is the behaviour that makes the change visible at all.

Every .jinja file outside the root is now read on every run, whether or not anything imports it. They are small and there are few of them; the alternative is a lazy loader, which the borrow rules above rule out.

A binary file named .jinja outside the root is now an error (tpl::render::partial_not_utf8) rather than being ignored. It was already an authoring mistake; it now says so.

The static graph analysis in src/graph.rs does not follow imports. It is a parse, and undeclared_variables never crosses a template boundary — a name a macro references is resolved inside the macro's own scope, not in the manifest's dependency graph. A missing partial is a render-time failure with a render-time diagnostic.

This is a prerequisite for template inheritance, which needs the same loader backed by layered trees.