πŸ₯¦Ndiro
Chapter 6 of 13

Server-side rendering with Jinja

Jinja is the template language Flask uses to produce HTML on the server. A template is HTML with holes; render_template() fills the holes from the keyword arguments the route passes and returns the finished string. By the time the browser sees anything, every trace of Jinja is gone β€” which is why "view source" on this site shows plain HTML.

The three delimiters

{{ nutrient.label }}        ← expression: evaluate and insert
{% if ai_enabled %} … {% endif %}   ← statement: control flow
{# never reaches the browser #}     ← comment (unlike <!-- HTML comments -->, which do)

The values come from the route. Here's the pair β€” server side and template side β€” for the log page:

app.py β†—
return render_template('log.html', user=g.user,
                       nutrient=config.resolve_nutrient(g.user),
                       fiber_guide=config.FIBER_GUIDE,
                       ai_enabled=bool(config.OPENAI_API_KEY),
                       photos_enabled=bool(config.S3_BUCKET))
templates/log.html β†—
{% if ai_enabled %}
<div class="ai-row">
    <button type="button" class="btn subtle" id="estimateBtn">…</button>
</div>
{% endif %}

Note what that conditional achieves: when the operator hasn't configured an OpenAI key, the AI buttons don't render at all. Not hidden with CSS β€” absent from the HTML. The server knows things at render time that the browser shouldn't have to discover (or shouldn't be told), and this is the mechanism.

Inheritance: one skin, many pages

Templates form a class-like hierarchy. base.html defines the skeleton and marks the variation points as named blocks; each page extends it and overrides only what it needs:

templates/log.html β†—
{% extends "base.html" %}
{% block title %}Ndiro β€” Log{% endblock %}
{% block styles %} …page-specific CSS… {% endblock %}
{% block content %} …the page body… {% endblock %}
{% block scripts %} …the page's JS… {% endblock %}

That's why every page shares the theme tokens, the corner menu, and the brand pill without repeating a line of them. There's also composition via include, and Ndiro uses it for something subtle: the review page and the public share view render the same chart-and-feed partial, _review_core.html, with different data URLs. The share view has no edit buttons and no AI affordances not because someone remembered to strip them, but because the shared partial never had them β€” reuse as a security guarantee.

One more supporting piece: a context processor in app.py (inject_build) adds build_commit_short to every template's context automatically β€” that's how the running commit shows up in the menu on all pages without every route passing it.

Autoescaping: the invisible bodyguard

The most important Jinja fact for a reviewer. When {{ meal.description }} inserts a value, Jinja HTML-escapes it: < becomes &lt;, " becomes &quot;. So a meal named <script>alert(1)</script> renders as those literal characters instead of executing β€” the cross-site-scripting (XSS) attack dies by default. The repo's tests exercise exactly this: tests/test_m9_status.py injects a script tag through the commit title and asserts it comes out inert.

The escape hatch, a |safe filter, marks a value as trusted markup and skips escaping. This codebase never uses it on user data β€” treat any new |safe in a diff as a finding until proven otherwise.

tojson: the one sanctioned bridge to JavaScript

Sometimes the server needs to hand a data structure to the page's JavaScript. Interpolating into a script with plain {{ … }} is wrong (HTML escaping isn't JS escaping); building JSON by string concatenation is worse. The right tool:

templates/log.html β†—
<script>
    const FIBER_GUIDE = {{ fiber_guide|tojson }};
    const NUTRIENT = {{ nutrient|tojson }};   // {key, label, unit, goal, …}
</script>

|tojson serializes the Python value as JSON and escapes it safely for a script context. The rendered page simply contains const NUTRIENT = {"key": "fiber_g", …}; β€” server state becomes a JS constant. This is the seam from the anatomy chapter made concrete: config crosses the boundary once, at render time, through this filter.

Server-side or client-side?

With two places to compute things, the split rule in this codebase is: identity, configuration, and anything security-relevant render server-side; anything derived from fetched data or local time renders client-side. The nutrient label is Jinja (the server resolved it); the "Today β€” Thu, Sep 3" heading is JavaScript (only the browser knows the user's timezone β€” the server clock is deliberately never trusted for user-local dates, a rule you'll find as invariant #10 in CLAUDE.md).

🐘 From your world

A template is a prepared statement for HTML. Autoescaping is parameter binding: values can never be confused for syntax. String concatenation into HTML is exactly SQL-injection-by-concatenation, same failure mode, same fix. And |safe is the raw-query escape hatch every ORM has β€” legitimate, rare, and where the audits go.

πŸ”¬ Try it

Log a meal whose description is <b>bold?</b>. It displays as literal text with the angle brackets visible. Then find it in devtools β†’ Elements and note the text node β€” the markup arrived escaped. (This particular render happens in JavaScript via textContent, the DOM-side twin of autoescaping β€” the JavaScript chapter picks up that thread.)

πŸ