Skip to content
Raymond Berger edited this page Sep 11, 2026 · 3 revisions

order: 7

Jinja Templates

This page gives the basic facts about Jinja in Open Library. It tells you what Jinja is, why we are moving to it, how to convert a Templetor template, and how to test the result. For deep details about translated text, see the i18n guide.

What is Jinja?

Jinja is a template engine for Python. A template is a text file with placeholders. Python code sends data to the template. The engine puts the data into the placeholders and makes HTML.

Jinja is an industry standard. Many Python projects use it. Read the official documentation at https://jinja.palletsprojects.com/.

Open Library uses two template engines today:

Templetor Jinja
Status Legacy. Most current pages use it. Preferred for new work.
File names *.html *.html.jinja
Source web.py framework jinja2 library

Why we move to Jinja

Templetor is old and rare. Only the web.py framework uses it. Few tools understand its syntax, so editors, linters, and formatters give almost no help.

Jinja is more standard and more reliable to work with:

  • Many editors, linters, and formatters support it.
  • It escapes values by default. This prevents XSS bugs.
  • Bad data stops the render with a clear error. It does not make broken HTML.

We move to Jinja over time. There is no large migration project and no deadline. The rules:

  • New UI work uses Jinja.
  • Convert an old Templetor file when you change it.

Why there is no automatic converter

We built an experimental converter for this. It lives in draft pull request #12941. It is not merged, so treat it as an experiment. But its test runs taught us what converts cleanly and what does not.

The converter processed all ~370 Templetor templates and macros. Results:

  • About 94% produced output that Jinja accepts as-is.
  • About 250 places needed manual fixes. The converter marked them with comments.
  • 22 templates did not compile even after conversion.

These numbers tell us two things. Most Templetor syntax maps cleanly to Jinja. But a tool cannot do all the work, because some patterns have no Jinja form at all. We may be able to automatically convert some simple templates one day, but we are not at that point yet. For now, a person converts each file. Treat each conversion as normal refactoring work.

Patterns with no Jinja form

Jinja cannot express these patterns. Do not try to convert them. Move the logic into Python instead, and pass the results to the template as arguments.

Pattern Example Fix
List or dict comprehension [e for e in items if e] Build the list in Python
Item assignment context['k'] = v Compute in Python; pass the value
Lambda function sorted(xs, key=lambda x: x[1]) Sort in Python
Type or attribute tests isinstance(a, str), hasattr(d, 'x') Test in Python
Side effects results.append(x) Collect results in Python
$while loop $while n > 0: Jinja has no while loop. Restructure or compute in Python
$try / $except Error handling Jinja has no try/except. Handle errors in Python

Notes on loops:

  • $for converts to {% for %}. loop.index, loop.first, and loop.last exist in both engines.
  • Templetor loop.parity has no Jinja form. Use {{ loop.cycle('odd', 'even') }}.
  • $continue and $break need the jinja2.ext.loopcontrols extension. Our environment does not enable it. Avoid them.

Templates that fail automatic conversion

A re-run of the draft converter against this codebase in August 2026 gives the list below. These 19 templates produce output that Jinja cannot parse. The causes are small: stray characters, bad expressions, or block nesting mistakes. One error can hide another, because Jinja stops at the first error it finds. So a template can hold more failures than the table shows. Fix one error and check again until the file parses. Then finish the conversion by hand.

"Manual-fix flags" is the number of places the converter marked for manual work.

Template Lines Manual-fix flags First parse problem
type/edition/view.html 568 36 Bad expression
type/work/view.html 568 36 Bad expression
books/edit/edition.html 684 3 Stray \
work_search.html 184 4 Stray ?
diff.html 160 4 {% elif %} outside its block
lib/nav_head.html 137 0 Bad expression
search/work_search_selected_facets.html 113 27 Bad expression
account/create.html 114 0 $def with argument parsing
books/edit/excerpts.html 103 0 Bad expression
recentchanges/render.html 67 0 Stray '
history/sources.html 80 2 $def with argument parsing
widget.html 50 1 {% elif %} outside its block
lists/export_as_bibtex.html 48 0 Bad expression
lists/showcase.html 45 0 Stray %
lib/exports.html 36 1 Bad expression
site/footer.html 29 0 Stray #
books/RelatedWorksCarousel.html 23 3 Bad expression
subjects/notfound.html 18 0 Bad expression
showgoogle_books.html 12 0 Bad expression

Tips for priority:

  • The two book pages (type/edition/view.html, type/work/view.html) are the core pages of the site. They are also the largest jobs.
  • lib/nav_head.html renders the header on every page. Test it with care.
  • search/work_search_selected_facets.html needs many manual fixes for its size.
  • The small files near the end of the table are good first conversions.

You can also try the draft converter yourself on any template. The code lives in pull request #12941. It is not merged and not supported, so treat its output as a starting point, not a finished conversion.

Expect a few cleanups in its output:

  • The converter wraps every template in a {% macro name(...) %} block. Remove this wrapper. It exists only so the output can compile as a standalone file. A real conversion renders the template directly with render_jinja_template().
  • The converter copies websafe() calls unchanged. Replace them yourself. See the escaping note below.
  • The converter can silently corrupt expressions with brackets or lists. For example, it turned $cond(name in ["a", "b"], "x", None) into invalid Jinja. Diff-check every line where it rewrote an expression.

How to convert a template

Use this syntax map:

Task Templetor Jinja
Output a value $name {{ name }}
Output raw HTML $:value {{ value | safe }}
Condition $if x: / $elif y: / $else: {% if x %} / {% elif y %} / {% else %} ... {% endif %}
Loop $for b in books: {% for b in books %} ... {% endfor %}
Assign a variable $ x = expr {% set x = expr %}
Declare parameters $def with (a, b=None) No header. Pass arguments at render time.
Comment $# note {# note #}
Literal dollar sign $$ $
Inline condition $cond(x, a, b) {{ a if x else b }}
Translate $_("text") {{ _('text') }} or {% trans %}text{% endtrans %}
Translate with plural $ungettext(s1, s2, n) {{ ngettext(s1, s2, n) }}

Conversion steps:

  1. Copy the .html file. Give the copy the extension .html.jinja.
  2. Convert the syntax with the map above.
  3. Move logic out of the template when you can. Put it in Python. Send the results to the template as arguments.
  4. Keep translated English strings exactly the same. Then existing translations still match.
  5. Change the render call. See How to render.
  6. Run the tests. See How to test.

Know these differences before you start:

  • Jinja escapes values by default. Templetor escapes nothing until you call websafe(). Replace websafe(x) with {{ x | force_escape }}. Do not use the built-in escape filter here. It does nothing when autoescape is already on. Add | safe only for HTML that you trust. When you convert $: to | safe, check the value first. If it holds user data, let Jinja escape it.
  • Translated strings need named placeholders, for example %(name)s. Positional %s does not work.
  • Jinja templates get no automatic variables such as page, user, or ctx. Pass all data as arguments.
  • Undefined variables raise an error at render time.
  • Variables set inside a {% for %} or {% if %} block do not exist after the block ends. Templetor does not have this limit. Use {% set ns = namespace(total=0) %} objects, or compute the value before the loop.
  • Jinja reads attributes with getattr first, then item lookup. So {{ d.key }} works for both objects and dicts.
  • Calls to Templetor macros ($:macros.Name(...)) do not work in Jinja. Import Jinja macros with {% from "file.html.jinja" import name %}, or render the Templetor macro from Python.

How to render a Jinja template

From Python, use the helper in openlibrary/core/jinja.py:

from openlibrary.core.jinja import render_jinja_template

html = render_jinja_template("interstitial.html.jinja", url=url, wait=5)

The loader looks in openlibrary/templates/ and openlibrary/macros/. Always include the file extension.

From inside a Templetor template:

$:render_jinja_template("my_partial.html.jinja", foo="bar")

How to test

Run pre-commit on your changed files. djLint formats and lints all .jinja files:

pre-commit run --files openlibrary/templates/my_template.html.jinja

Run the Python tests:

make test-py-uv

Two test suites cover Jinja:

If you added or changed English strings, regenerate the POT file:

docker compose run --rm home python ./scripts/i18n-messages extract

Best practices from recent migrations

Recent conversions (/openlibrary/templates/search/work_search_facets.html.jinja, /openlibrary/templates/reading_goals/reading_goal_progress.html.jinja) showed these pitfalls. They do not overlap with the patterns above — they are about how to structure the new Jinja code, not what Jinja can express.

  • Use a macro for a reusable piece. If the HTML needs inputs like goal and year, define {% macro goal_form(goal, year) %} in /openlibrary/templates/reading_goals/reading_goal_form.html.jinja and call it with {{ goal_form(entry.goal, entry.year) }}. Do not use {% set %} plus {% include %} to pass the inputs — it hides the interface.

  • Do not pollute the outer scope. {% set goal = entry.goal %} before an {% include %} stays for the next loop iteration. Use {% with goal=entry.goal %}{% include %}{% endwith %} or a macro call. The macro form is preferred.

  • Do not put code at the top of a macro file. The environment in /openlibrary/core/jinja.py uses StrictUndefined. Top-level code like {{ goal_form(goal, year) }} runs when another template does {% from "/openlibrary/templates/reading_goals/reading_goal_form.html.jinja" import goal_form %} and fails with year is undefined. Keep macro files to definitions only.

  • Do not make a thin Python wrapper. If the helper only does return render_jinja_template("/openlibrary/templates/reading_goals/yearly_goal_dialog.html.jinja", year=year), delete it and call render_jinja_template directly in the Templetor file that needs it, for example /openlibrary/templates/account/view.html.

  • Make the unused-template check pass. New *.html.jinja files must be git added and referenced by a quoted literal render_jinja_template("/openlibrary/templates/reading_goals/yearly_goal_dialog.html.jinja", ...) or {% from "/openlibrary/templates/reading_goals/reading_goal_form.html.jinja" import goal_form %}. Otherwise scripts/check_unused_templates.py reports them as unused.

  • Update the translation template when you move strings. When you move _() from a template to Python, run docker compose run --rm home python ./scripts/i18n-messages extract and verify /openlibrary/i18n/messages.pot has no extra leading or trailing whitespace.

  • Use a data helper, not a render helper. For a list, make one helper that collects data. ReadingGoalProgressPartial in /openlibrary/plugins/openlibrary/partials.py collects YearlyGoal and calls render_jinja_template("reading_goals/reading_goal_progress.html.jinja", entries=entries). For the carousel, get_carousel_card_data() returns a dict. Then CarouselCardPartial.generate_async() and /openlibrary/templates/books/custom_carousel.html call render_jinja_template(..., **data). Do not make @public def render_carousel_card() that calls get_jinja_env().get_template().render().

  • Do not split a tag across if/else. Write <img {% if lazy %} data-lazy="..." src="placeholder" {% else %} src="..." {% endif %} /> inside one tag. Do not close the tag in each branch. This keeps test_all_jinja_templates_render_valid_html in /openlibrary/tests/core/test_jinja.py valid.

  • Keep macro files short. Use one or two lines for the header comment, for example {# Renders loan expiry if present. #}. Do not copy the old Templetor source into the header. Keep macros to one line where possible; see /openlibrary/macros/TruncateString.html.jinja for an example. /openlibrary/core/jinja.py already sets trim_blocks and lstrip_blocks, so {{-/{%- on each line is not necessary.

Examples

Clone this wiki locally