# ADR-0001: Render templates with stdlib `str.format_map`, not Jinja

- **Status:** Accepted
- **Date:** 2026-05-15
- **Deciders:** maintainer
- **Related:** RFC-0001, `docs/architecture/overview.md` ("Rendering: stdlib, not Jinja")

## Context

The kit needs to render two kinds of files into a user's vault:

1. **Verbatim files** — page templates with Obsidian Templater syntax
   (`{{date}}`, `{{title}}`), SKILL.md files, scripts, seed pages.
   Templater uses double-brace `{{ }}` and must reach the user's vault
   unchanged so Obsidian can process it at note-creation time.
2. **Interpolated files** — a small fixed set of root-level files
   (`AGENTS.md`, `CORE.md`, `identity.md`, `frontmatter.schema.yaml`,
   `.gitignore`) that need values like `{vault_name}` and
   `{recipe_name}` substituted at install time.

The set of files that need interpolation is small, enumerable, and stable.
The interpolation logic itself is trivial — flat dict, no loops, no
conditionals, no filters. Managed-region content (lists of installed
primitives) is generated by Python functions and inserted as plain strings.

We have to pick a rendering layer once and live with the consequences:
every primitive author will encounter it, every template will be touched
by it, and the user's eventual Templater files will pass through it.

Prior art surveyed:

- **Jinja2** is the obvious default. Powerful, widely known, well-tested.
- **Mako, Cheetah, etc.** — extra cost, no win over Jinja.
- **Python stdlib `str.Template`** — `$name` syntax, no expressions.
- **Python stdlib `str.format_map`** — `{name}` syntax, no expressions,
  no filters, runs on a flat dict.

## Decision

> **We will use stdlib `str.format_map` with a `SafeDict` subclass for
> interpolation, and copy all other files byte-for-byte. No Jinja, no
> third-party templating engine.**

Mechanics:

- A module-level constant `INTERPOLATED_FILES = {"AGENTS.md", "CORE.md",
  "identity.md", "frontmatter.schema.yaml", ".gitignore"}` is the
  allowlist.
- `render.render_tree(src, dest, context, journal)` walks `src`. For
  each file: if its basename is in `INTERPOLATED_FILES`, render via
  `str.format_map(SafeDict(context))`; otherwise copy via
  `shutil.copyfile`.
- `SafeDict` returns the original `{key}` token when the key is missing,
  so single-brace tokens that happen to appear in interpolated files
  (regex patterns, code samples) survive untouched.
- Managed-region content is generated programmatically by region
  renderers (`render_ontologies_region()`, etc.) and supplied as
  pre-built strings in the context dict.

## Consequences

### Positive

- **One fewer runtime dep.** Jinja2 is ~150 KB of installed weight,
  pulls MarkupSafe, and is one more thing to install for non-engineer
  users. Stdlib has zero cost.
- **No delimiter collision.** Obsidian Templater's `{{date}}` and
  `{{title}}` are double-brace; stdlib uses single-brace. The two
  cannot collide. No `{% raw %}` blocks, no escaping rituals.
- **Trivial to audit.** Reading `render.py` once tells you everything
  about how templates render. There is no DSL.
- **Forces simplicity.** No conditionals or loops in templates means
  primitive authors put logic in Python, where it's testable, not in
  markdown, where it isn't.

### Negative

- **No template inheritance.** If a future primitive needs to share
  boilerplate across page templates, it has to duplicate or use a Python
  function to generate the string. We're accepting this on the bet that
  page templates stay small.
- **No filters.** Anything beyond `f"{x}"` (uppercase, plural-ization,
  date formatting) goes into Python. So far there are no cases that
  need this.
- **The `INTERPOLATED_FILES` allowlist must be maintained.** If we ever
  add a new file that needs interpolation, we have to add it here.
  Mitigation: the list is short and stable; new entries require a one-
  line PR.

### Neutral / monitor

- If primitives proliferate beyond what we expect (>50 content-types),
  re-evaluate whether template inheritance would pay for Jinja's weight.
  Trigger: three open issues asking for it within one release cycle.

## Alternatives considered

### Alt 1: Jinja2

The default for Python templating. Loses on three grounds:

1. **Dependency weight.** Non-engineer users on fresh Python installs
   sometimes hit C-extension or wheel issues with MarkupSafe; every
   extra dep is a thing that can fail to install.
2. **Delimiter collision risk.** Jinja's `{{ ... }}` is the same as
   Obsidian Templater's. We could re-delimit Jinja with `block_start_string`,
   but that produces a foot-gun every time someone copies a Jinja snippet
   from the web.
3. **Power we don't need.** Conditionals, loops, filters, macros — the
   kit's templates don't use any of these. Buying capability we won't
   use means primitive authors have to read Jinja docs to understand
   templates that didn't need a DSL.

### Alt 2: `str.Template` (PEP 292)

Stdlib, simple, `$name` syntax. Loses to `str.format_map` because
`$name` collides with shell-style expansion in some files we render
(`.gitignore` doesn't, but a future file might), and `format_map` has
the `SafeDict` escape hatch built into the spec (`__missing__`).

### Alt 3: Roll our own regex-based substituter

About 20 lines. Loses because `format_map` already exists, is stdlib,
is documented in every Python intro, and handles edge cases (escaping
`{{` to literal `{`) for us.

## References

- [`str.format_map`](https://docs.python.org/3/library/stdtypes.html#str.format_map)
- [PEP 3101 — Advanced String Formatting](https://peps.python.org/pep-3101/)
- [Obsidian Templater syntax](https://silentvoid13.github.io/Templater/)
- Migration RFC `docs/rfc/0001-v2-architecture.md` (Task 7)
