ADR-0023: Two-Pattern Template-Emission Model¶
Status: Accepted Date: 2026-05-12 Supersedes: N/A Related: #1042 (drop jinja2 umbrella), #1044 (template inventory)
Context¶
Post-#1042 the framework no longer ships Jinja2. Every rendering call site is now pure Python. During the migration each site picked its own substitution strategy:
string.Template—compliance/renderer.py,core/expander.py,llm_executor.py(3 sites)- F-strings —
agent_commands/template_strings.py(1 site) - F-strings + a local
_esc()helper —form_renderer,detail_renderer,table_renderer,pdf_viewer_renderer,journey_reporter,consent_banner,provider_html(7 sites)
Three patterns is too many. An agent reading two renderers in succession has to context-switch between substitution models even though both are pure Python. The convergence question: pick one pattern, or rationalise why we have more than one.
Decision¶
We keep two patterns, distinguished by who authors the template:
- Pattern A — framework code emits HTML/markdown: f-strings
-
the canonical
dazzle.render.html.esc(value, *, quote=False)helper. -
Pattern B — framework code executes a user-authored template: stdlib
string.Templatewith$var/${var}placeholders.
The choice is mechanical: who writes the template string?
- Dazzle framework engineer writes it → Pattern A.
- Downstream user writes it (in a DSL block, a vocab manifest, a project-supplied wrapper HTML file) → Pattern B.
A single shared helper dazzle.render.html.esc(value, *, quote=False)
serves Pattern A. Pre-v0.67.94 the same shape was redefined locally
in 7 files; the convergence collapses those to one definition + one
import per file.
Consequences¶
Why not "converge everything on string.Template"?¶
The natural user intuition is "string.Template feels more Pythonic
— let's use it everywhere". This is wrong for the dominant case
(framework HTML emission) for three reasons:
-
string.Templateis logic-free by construction. It supports$varsubstitution only — no conditionals, no loops, no nested structures. The agent_commandsimprove.md.j2Python port hasif cmd.signals_consume:,if cmd.batch_compatible:,for kind in cmd.signals_emit:— real Python control flow over ~200 lines of markdown. Converting tostring.Templatewould split each template into many sub-templates with Python orchestration that pre-formats each conditional block as a string before substituting. The orchestration is the work; the substitution is trivial. F-strings keep both visible together. -
F-strings get mypy. Variable references inside f-strings are typed-checkable;
string.Templateplaceholders are runtime-only. A typo like$titelin a template fails on.substitute(...), never at lint. -
The hand-rolled HTML pattern handles the most complex cases. The PDF viewer's panel iteration, journey reporter's collapsible sessions, consent banner's category fieldsets — all have logic that wouldn't fit
string.Template. If the most complex cases must be f-strings, forcing the simple ones into a different model creates inconsistency for no benefit.
Why keep string.Template at all (Pattern B)?¶
Three call sites are special — they don't execute templates the framework wrote; they execute templates a downstream user wrote:
core/expander.pyreads theexpansion.bodyfield of aVocabEntrydefined in the project'slocal_vocab/manifest.yml.llm_executor.pyreads theprompt_templatefield of anLLMIntentSpecdeclared in DSL.compliance/renderer.pyreads the project'scompliance/templates/document.htmlbrand wrapper.
For these, the template-author is a downstream user, not a
framework engineer. We can't ask them to write Python f-strings —
they don't have a Python file to put them in. string.Template gives
them a flat, safe, well-understood substitution format that fits the
authoring surface (a YAML field, a DSL string, an HTML file).
We deliberately gave up Jinja's loops/filters/conditionals in those three contexts (#1047, #1048, #1050 documented the breaking changes). That trade-off stands: vocab macros and LLM prompts and PDF brand wrappers can use flat substitution; if a user needs logic, they factor it into multiple entries or pre-compose the value in the calling DSL.
Why one helper esc(value, *, quote=False), not two¶
(escape / escape_attr)?
The 7 pre-existing local helpers all had the same signature
_esc(value, *, quote=False) with ~150 call sites passing
quote=True for attribute context. Renaming to two functions would
require rewriting every call site. We tried it during the
convergence and an auto-translation mishandled multi-line
arguments. Keeping the existing signature lets the convergence be a
near-mechanical "delete-local + add-import" edit per file.
A future rename to escape(value) / escape_attr(value) is a
trivial search-and-replace. We're not committing to the current
signature — we're picking the smallest-blast-radius convergence
move.
Implementation¶
- New:
src/dazzle/render/html.pyexportingesc(value, *, quote=False). Single source of truth. - Migrated (v0.67.94): the 7 HTML emitters delete their local
_escdefinition andimport html as _html_modline, and addfrom dazzle.render.html import esc as _escin the imports block. Every call site stays unchanged. - Not migrated:
compliance/renderer.py,core/expander.py,llm_executor.py. These remain onstring.Templateper Pattern B. - Documented: this ADR.
dazzle/render/html.pydocstring references the two-pattern model.
Agent Guidance¶
When adding a new renderer, decide:
Who writes the template I'm executing?
-
If it's Dazzle framework code (you, an agent, a framework engineer): use f-strings. Import
from dazzle.render.html import esc as _escand escape every interpolated value. Pattern A. -
If it's a downstream user (in a DSL block, a YAML manifest, an HTML file under the project's
templates/): usestring.Template(template_str).substitute(**kwargs). Pattern B.
Don't mix the two within one renderer. If a Pattern A renderer
needs to interpolate user data, escape that data with _esc —
never expose it as a placeholder the user can populate.
Alternatives Considered¶
-
Converge everything on
string.Template. Rejected — loses conditionals/loops in framework-written templates; forces verbose Python orchestration around tiny substitution calls. -
Converge everything on f-strings, drop
string.Templatefor the user-template call sites too. Rejected — would require downstream users to write Python in places that currently take text data (DSL prompt strings, vocab manifest YAML, project HTML). Removes a legitimate non-Python authoring surface. -
Split into
escape(value)andescape_attr(value)(no kwarg). Deferred — the signature improvement is real but the migration risk is higher than the benefit. Pick it up as a separate ADR if/when the call-site readability matters more than the migration cost.
References¶
- v0.67.92 / #1042 —
jinja2dropped frompyproject.toml. - v0.67.93 — theme support restored via typed
AppChrome. - v0.67.94 — this convergence shipped (shared
eschelper). dev_docs/2026-05-12-jinja2-retirement-postmortem.md— the agent-cognition hypothesis evaluation that motivated this ADR.