Skip to content

feat(render): add Jinja template engine for Python and TypeScript - #504

Merged
dmealing merged 10 commits into
mainfrom
fm/mo-jinja-templates
Oct 28, 2026
Merged

dmealing merged 10 commits into
mainfrom
fm/mo-jinja-templates

Conversation

@dmealing

@dmealing dmealing commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

What Changed

  • Python (jinja2, optional jinja extra) and TypeScript (nunjucks) can now render and verify Jinja templates. The engine is picked per port from the template file extension (.jinja/.j2/.jinja2) or an explicit engine option, never from metadata, so metamodelVersion does not move (new ADR-0061). The build-time payload check (ERR_VAR_NOT_ON_PAYLOAD) now covers Jinja in verify, in the render-helper generator, and at runtime. Mustache remains the default and its generated output is unchanged.
  • New shared corpora, fixtures/jinja-render-conformance/ (15 cases) and fixtures/jinja-verify-conformance/ (31 cases), hold both ports to the same output and findings. The shared-corpus count is now 34.
  • An include chain stays in one engine: a Mustache partial whose file is Jinja, or a Jinja include of a Mustache file, is refused with the new ERR_TEMPLATE_ENGINE_UNSUPPORTED code, in verify and at render time.
  • Java, Kotlin and C# do not render Jinja. They refuse a Jinja body with ERR_TEMPLATE_ENGINE_UNSUPPORTED (added to ERROR-CODES.json), including a Mustache partial that exists only as a Jinja file.
  • The two engine differences outside the corpus vocabulary that remain are listed in ADR-0061 and on the templates page: looping over a null, and a string filter over already-escaped text.
  • Docs, port pages, the agent-context prompt skills and their conformance goldens, and the site payload are updated. CHANGELOG.md has an Unreleased entry with no version.
  • Also corrects the header comment in codegen-ts/src/reference/prisma-schema.ts, which named a prismaChecksFileName option that PrismaSchemaFileOptions does not have.

Closes #483

Out of scope, by decision: Jinja in Java/Kotlin/C#, and the Jinja-to-Mustache migration analyzer.

Follow-ups outside this repo

  • Website (separate repo): the prompt-construction and templates pages say templates are Mustache only. They should say that Python and TypeScript also render Jinja bodies (ADR-0061), and the llms.txt the site serves should be refreshed from docs/llms/.

Notes for the reviewer

  • Gate runs: 1 no-mistakes run. Its review, test and lint fix rounds are the no-mistakes(...) commits plus chore(site). The test step was approved as an exception: the configured test command passes, but its live-validation verdict is inconclusive because a library render tier has no product to drive.
  • Before the gate: /code-review high returned 10 findings. 8 were fixed in fix(render): Jinja review fixes. 2 were not changed: the root entry imports nunjucks, which resolves to its browser build via its browser field; and ProviderChain.engineOf reads twice, because a shortcut would trust providers that report an engine for refs they don't hold.
  • Overlap with feat(cli): install shared-model dependencies from npm and Python packages in meta deps sync #501: both PRs edit docs/CONFORMANCE.md. If feat(cli): install shared-model dependencies from npm and Python packages in meta deps sync #501 merges first, the resolution is docs-only.
  • Testing: targeted suites, run one at a time: TS render, codegen-ts render-helper and framework-provider, CLI verify, Python render, render-helper and CLI verify, Java render and TemplateVerify, C# Render and Cli verify. Plus the gate's ts-fast, ts-unit and gates lanes.
Validation

Risk Assessment

✅ Low: Fix round correctly adds format-aware escape/e/forceescape filters in Python matching nunjucks semantics, uses the matched extension for engine_of, removes unused jinja_reference_line, and strengthens the restore test; counts in docs match 15 fixtures.

Testing

  • The configured test command (scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains) passes on the final head. Its first run failed only on stale agent-context-conformance goldens, and those are regenerated and committed.
  • Escape filters (| e, | escape, | forceescape) in html and json formats were run in both ports and match the new corpus cases. The Python symlink engine cases were run too.
  • Test was approved as an exception: the live-validation verdict is inconclusive, because a library render tier has no running product to drive. Behaviour is covered by the Jinja conformance corpora and the unit suites in TypeScript and Python.
  • Outcome: passed with the approved Test exception, in 1 gate run.

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → fixed ✅
  • ✅ fixed: server/python/src/metaobjects/render/jinja_render.py:562 - Python's | escape / | e filter skips the request's format escaper and diverges from TypeScript without erroring. render_jinja turns jinja2 autoescape off and routes escaping through finalize. jinja2's built-in escape filter still returns a markupsafe Markup, and finalize passes that through untouched. Probe output, {{ t | e }} with t='a"b<': html format gives Python a&amp;#34;b&amp;lt; and TS a&amp;quot;b&amp;lt;; text format gives Python a&amp;#34;b&amp;lt; and TS a&#34;b&lt;. With t='a"b\n' in json format, Python gives a&amp;#34;b followed by a raw newline, which is not a valid JSON string; TS gives a\&#34;b\n. TS is right because nunjucks' escape filter calls the swapped lib.escape. | e is a common idiom in existing Jinja templates, so this breaks the docs' claim that every {{ … }} value goes through the format's escaper (docs/features/templates-and-payloads.md Escaping bullet, ADR-0061) and the claim that the two ports render alike. Fix: register escape, e and forceescape filters on the Environment that apply escapers.escape(format_, _stringify(v)) and return Markup, leaving Markup/_Rendered values as they are. Add a jinja-render-conformance case using | e in html and json formats so both ports are held to it.
  • ✅ fixed: server/python/src/metaobjects/render/filesystem_provider.py:419 - FilesystemProvider.engine_of takes the engine from found.suffix, and found is the symlink-RESOLVED path. The engine therefore follows the symlink target's extension, not the extension that was matched. Example: prompts/welcome.jinja -&gt; ../shared/welcome.txt is read as Jinja text but reported as Mustache. The reverse case, a .mustache link to a .j2 file, renders Mustache text as Jinja. The TypeScript FilesystemProvider and the CLI FileProvider both use the matched candidate extension, so the ports disagree. Fix: have _find return the matched extension next to the path, as the TS find() does, and pass that extension to engine_for_extension.
  • ✅ fixed: server/typescript/packages/render/test/jinja-engine.test.ts:96 - The test "a render leaves the engine's own escape function as it found it" cannot fail if restoration breaks. Its second call is itself a Jinja render that installs its own escaper (identity for text), so it returns "<" even when renderJinjaText never restores nunjucks.lib.escape or Compiler.prototype.compileCapture. The behavior under test is that the module-level nunjucks state is restored, so an adopter's own nunjucks environment in the same process is unaffected. The independent oracle is nunjucks' documented default HTML autoescape. Replace the second assertion with a plain new nunjucks.Environment(null, { autoescape: true }).renderString(&#34;{{ a }}&#34;, { a: &#34;&lt;&#34; }) that must equal &amp;lt;, or assert nunjucks.lib.escape is the same function before and after. Either version rejects a missing finally restore.
  • ✅ fixed: server/python/src/metaobjects/render/jinja_verify.py:521 - Simplification: the Python jinja_reference_line (and its helper _spelling_of) is exported in __all__ and unit-tested, but no production code calls it. The Python CLI's _verify_templates prints ref &#39;&lt;ref&gt;&#39; — CODE: path with no file:line location for Mustache or Jinja. The TS counterpart exists because meta verify prints a file:line location; Python has no such consumer, and the intent requires nothing here. Recommend removing it and its test, or wiring it into the Python CLI's drift line if a location is wanted. The remedy is the author's choice.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - resolved (approved exception: live validation inconclusive)
  • Stale fixtures/agent-context-conformance goldens: this branch edited the prompts skill without regenerating them, which failed the ts-unit lane. They were regenerated with server/typescript/packages/sdk/scripts/regen-agent-context-conformance.ts and committed in no-mistakes(test): Regenerate stale agent-context-conformance prompt-skill goldens (11 files). The sdk suite then passed (380 pass, 0 fail), and the configured test command passes.
  • An unscoped bun test over the whole of server/typescript returned no result within about 50 minutes. Its cause was not identified, and the finding was declined: that run is not the configured gate command, the project already says to run the server suite per package, and every per-package run passes.
  • Live validation of the review fixes: escape filters in html and json formats (TS and Python), and the Python symlink engine selection, all pass.
  • The final live-validation verdict is inconclusive, because a library has no running product to drive. Test was approved as an exception for that reason.

Commands: scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains (passes), per-package bun test for server/typescript/packages, sdk goldens regen then bun test.

✅ **Document** - passed

✅ No issues found.

✅ **Lint** - 1 issue found → fixed
  • The site payload is true gate failed because examples/showcase/site-payload.json still counted 32 shared corpora; the two new Jinja corpora make it 34. It was regenerated with bun run site:payload and committed in chore(site): refresh site payload corpus count to 34. The gates lane passes on re-check.
✅ **Push** - passed

✅ No issues found.

…check (#483)

A template.prompt or template.output body may now be a Jinja file
(.jinja / .j2 / .jinja2) instead of a .mustache one. The engine comes from
the file or an explicit `engine` option, never from the metadata, so
@textref and the metamodel stay language-neutral (ADR-0061).

- Python renders with jinja2 (optional `jinja` extra); TypeScript with
  nunjucks (BSD-2; has include/import/extends, a walkable AST and an
  escaping hook).
- verify and the render-helper generator walk the parsed template and check
  every payload reference: ERR_VAR_NOT_ON_PAYLOAD as before, plus
  ERR_TEMPLATE_NOT_STATIC for what cannot be settled statically and
  ERR_TEMPLATE_SYNTAX for a body that does not parse. A generated helper over
  a Jinja body passes the engine and field tree to render, so the check also
  runs at runtime.
- New shared corpora: fixtures/jinja-render-conformance (12) and
  fixtures/jinja-verify-conformance (30), identical in both ports.
- Java, Kotlin and C# render Mustache only and now refuse a Jinja-only body
  with ERR_TEMPLATE_ENGINE_UNSUPPORTED, in their providers and in verify.
- Mustache stays the default; generated Mustache helpers are byte-identical;
  metamodelVersion does not move.

Docs: ADR-0061, templates-and-payloads, the prompts skill references, port
pages, llms.txt / llms-full.txt, CONFORMANCE (33 corpora), CHANGELOG.
# Conflicts:
#	AGENTS.md
#	docs/CONFORMANCE.md
…reads only .mustache

Pre-gate simplifier pass: one MAX_DEPTH constant per port, the Jinja renderer
reuses the Mustache renderer's scalar printer, and verify names the engine by
its constant.

Fix found in review: the TypeScript FilesystemProvider used the default
extension as its 'not given' sentinel, so an explicit ".mustache" still fell
back to Jinja files, against its own doc comment and the Python provider. It
now falls back only when no extension is passed. Regression test in both ports.
The reference header listed a 'prismaChecksFileName' option. That is the
helper that derives the checks file's name from 'fileName', not an option of
PrismaSchemaFileOptions. The header now names the options the type declares:
filter, target, fileName, checks, provider and header.
…ilures

From the pre-gate code review (each reproduced first):

- Escaping. Python escaped a macro's or call block's output a second time,
  and both ports did so for a block `set`. Rendered blocks now print as
  already escaped (Python: a marked concat; TypeScript: the capture is
  compiled as a safe string). New corpus case jinja-render-escaping-blocks.
- One engine per include chain. A Mustache partial whose file is Jinja was
  spliced in as text, and a Jinja include of a Mustache file was read as
  Jinja. Both are now refused with ERR_TEMPLATE_ENGINE_UNSUPPORTED in verify
  and at render time, in TypeScript and Python.
- Java and C#: a Mustache partial that exists only as a Jinja file crashed
  verify with the provider's exception; it is now that finding.
- Python: a chained lookup on a missing key renders empty (as nunjucks), any
  jinja2 failure is a RenderError, and a dataclass or plain-object payload is
  read by its fields instead of being replaced with an empty context.
- TypeScript: an unknown explicit `engine` is refused instead of falling back
  to Mustache, matching Python.
- Verify: positional attribute arguments of sum, min, max and unique are
  checked, as the keyword form already was. New corpus case
  jinja-verify-filter-attribute-positional.

The two remaining engine differences (looping over null; a string filter
over already-escaped text) are listed in ADR-0061 and the templates page.
Corpus counts are now 13 render and 31 verify cases.
… Fixed three stale spots: the Python render line in templates-and-payloads.md, the verify --templates row in cli.md, and a Jinja pointer in spec/conformance-tests.md. Nothing unresolved.
@dmealing
dmealing merged commit cffa1ae into main Oct 28, 2026
1 check passed
@dmealing
dmealing deleted the fm/mo-jinja-templates branch October 28, 2026 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Jinja templates: a second template engine or a Jinja-to-Mustache migration path

1 participant