The unified idiom¶
Opt in per mount
The unified idiom is enabled with unified=True on any mount (mount_component / mount_tree), scaffolded by situ new --unified, and proven by the /mail and /form demos. It now covers interpolation, the ~ projection, a site-derived :if / :else, and composition (a tree of unified components). It stays opt-in: the base dialect is unchanged and not going anywhere. A few things are out of scope (see Current limits).
The problem it removes¶
In the base dialect you hand-sort every template token by where it executes: Jinja {{ t.subject }} (server, frozen) sits one attribute away from :text="count" (client, live). Situ derives that execution-location axis everywhere else. A different axis matters: what crosses the wire, versus what stays live in the browser. That pair governs payload, exposure, and latency.
The unified idiom dissolves the first axis and makes the second legible in plain source text; no editor tooling required.
One interpolation¶
<span>filtering: { search }</span> <!-- depends on a Local → live client binder -->
<button>{ t.sender } — { t.subject }</button> <!-- row/server state → server-rendered -->
Write { expr } everywhere. The compiler reads the expression's dependencies and their sites: touching any Local/Synced signal → a live binder; touching only server/url/row state → server-rendered. You stop choosing between {{ }} and :text. ({ } is JSX/Svelte's glyph, picked because Jinja owns {{ }}.)
The wire projection¶
# in the component .py — the projection's name is a TypedDict declaring each field's type
from typing import TypedDict
class Message(TypedDict):
id: int
subject: str
sender: str
unread: bool
<ul :each="t in mailbox ~ Message[id, subject, sender, unread]">
<li :show="search.lower() in t.subject.lower()"
:class="open: open_id == t.id">
<button @click="open_id = t.id; open_msg(t.id)">
{ t.sender } — { t.subject } { "●" if t.unread else "" }
</button>
</li>
</ul>
<div data-region class="reading-pane">
<article :if="open_message"><pre>{ open_message.body }</pre></article>
<p :else>Select a message.</p>
</div>
The ~ Message[...] clause is the complete list of per-row fields allowed to cross to the browser, and its name is the row's type: Message refers to a TypedDict in the .py, so t.id is decoded to an int and open_id == t.id is a real integer comparison. (The named projection is exactly the data-row="Message" declaration a hand-written server row makes; the desugarer emits it for you.) The desugarer emits the server loop with data-* attributes for exactly the projected fields; referencing anything else is a build failure (real output). The row variable is yours to name: :each="m in mailbox ~ Message[...]" reads m.subject.
CompileError: t.body is referenced in the :each over 'mailbox', but 'body' is
not in its client projection ~[id, subject]. It never crosses to the browser.
Add it to the projection (it will be shipped), or load it on demand via a
server read into a data-region.
So the classic leak (render every message body and CSS-hide all but one) is impossible to compile. The mail demo browser-verifies the contract: no body text in the initial DOM, client search filtering with zero requests, exactly one body fetched per open via the open_msg command.
Every way of reading a row is checked¶
A fence with a bypass is not a fence, so the rule covers the whole loop body. Every mention of the row variable in any code region must be a literal read of a projected field: a binder attribute (either quote style), a {{ … }} interpolation, or a Jinja {% … %} block. Each of these is a CompileError:
{% set leaked = t.body %}{{ leaked }} <!-- laundered through a Jinja block -->
{{ t['body'] }} <!-- subscript instead of attribute -->
{% for c in t.body %}{{ c }}{% endfor %} <!-- iterated -->
{% if t.body %}…{% endif %} <!-- tested -->
{{ t }} <!-- the whole row -->
{{ t[key] }} <!-- a key the compiler can't resolve -->
The last two are rejected because they cannot be checked field-by-field: handing over the row object, or a field chosen at render time, would ship everything the projection exists to hold back. Plain text is not a code region: a literal t.body in prose renders as that string and is fine.
(Several of these compiled before 2026-07-13, and three of them leaked the field's full value into the HTML. They were found by probing every access path; the two the tests covered were not the only ones. See notes/34.)
Conditionals: a site-derived :if / :else¶
:if="test" is the conditional analogue of the interpolation: its transport follows the sites of the names in test. A test that reads a Local/Synced signal is a live client toggle: it lowers to :show, so the element stays in the DOM and shows/hides reactively, zero network. A test over only server/url state is server-rendered: it lowers to a Jinja {% if %} that omits the element when false. A bare :else on the immediately-following element (whitespace only between) pairs with it.
<article :if="open_message">…</article> <!-- server test → {% if %} (omitted when false) -->
<p :else>Select a message.</p>
<div :if="expanded">…</div> <!-- Local test → :show (live toggle, no network) -->
You write one :if; the compiler picks where it runs and the semantics that fit (omit vs hide). Like everything else in the idiom, it ships no new client runtime: both lowerings reuse primitives the emitter already compiles.
Composition¶
A tree of components (<Tag/> children resolved with components= / mount_tree) can be authored entirely in the unified idiom: pass unified=True and every component in the tree is desugared before the splice. A reusable <Field> uses { value } and :if exactly as a top-level component does; the /form demo is a unified tree.
Why a new construct¶
The projection is written at the data source, so wire cost is readable off the template. It doubles as a per-row schema (the fields and, eventually, their types, groundwork for derived typed templates). It turns an OWASP-catalogued defect class (excessive data exposure through over-rendering) into a compile error. It is the one construct in situ's surface with no template-language ancestor; the lineage is SQL's π and GraphQL selection sets, moved into the template.
Current limits¶
- A
:eachmust carry its~projection: the wire cost is explicit, and that is the point. A plain:each(no~) is aCompileErrorin the unified idiom. - Server-side recursion (a Jinja
{% macro %}rendered once, as/counter_treeuses) is out of scope. Client recursion is:treeand works unchanged; a component that needs server macro recursion stays in the base dialect. Like page chrome, some things keep Jinja. - An HTML comment must not contain Jinja
{% %}/{{ }}: Jinja parses those even inside comments, failing the render.{ expr }is fine (single-brace is literal text to Jinja). - The interpolation translator covers the common expression subset and fails closed on the rest.
- A client interpolation SSRs empty and fills at boot (no first-paint value yet).
- A purely server-rendered list (no client binders inside) is ordinary SSR and needs no projection.