Components¶
Two files, one stem¶
A component is a pair of sibling files sharing a stem (issues.py beside issues.html):
components/
issues.py ← sited signal declarations + handler functions
issues.html ← real HTML: Jinja for server state, :/@ shorthand for client behavior
The split earns its keep (the project tried a single .pyx file first): each half gets its language's native editor tooling. Python gets highlighting, navigation, and type checking; the template gets HTML tooling. The compiler loads the pair (load_front_end(stem)) and treats them as one source.
The .py half¶
from __future__ import annotations # required — markers are never evaluated at runtime
from situ import Local, Server, Url
search: Local[str] = "" # a signal: name, site, default
issues: Server[IssuesFacade] # a server resource behind a typed facade
def clear() -> None: # a handler: no await → compiled to client JS
global search # names the signal this handler writes
search = ""
- Signals are module-level annotated assignments. The marker (
Local/Url/Server/Synced) is a transparentAnnotatedalias: to a type checker,Local[str]isstr, so handler bodies type-check against real types. The compiler reads the site from the annotation's outer name. - Handlers are module-level functions. Whether one runs on the client or the server is derived from the handler's own body; see Handlers & commands.
global nameon a bare-name signal write is ordinary CPython (the emitter emits nothing for it), and required: without it the name is a local of the handler, in Python and in the compiler alike, and assigning a signal's name that way is aCompileError.- Component
.pyfiles are type-checked by ty/pyrefly/mypy but excluded from ruff reflowing in this repo: the hand-aligned signal table is part of the authoring surface.
The .html half¶
The template is real HTML with two vocabularies side by side:
- Jinja (
{% for %},{{ x }}) renders server state once per render, on the server. - Binders and events (
:text,:bind,:show,:each,@click, …) bind client state and are compiled into the island. Full table: Binders & events.
The unified idiom is an opt-in experiment that collapses the two into one interpolation, deriving server-vs-live from each signal's site.
The two runtime rules¶
The island wires binders at boot on exactly two roots; server commands re-render exactly one of them:
<body>
<header> ← bound once at boot; SURVIVES region swaps
search box, dialogs, toasts — anything that must outlive a re-render
</header>
<div data-region> ← bound at boot AND re-bound after every command swap
everything a server command may re-render
</div>
</body>
- Every reactive element lives inside
<header>or the single<div data-region>. An element outside both is never bound and silently inert. data-regionmust be the first attribute on that<div>. The region splitter and the boot targeting depend on it.
Don't nest a <header> inside the region
Both roots get bound at boot. An element inside both receives two listeners, so a toggle handler (x = not x) cancels itself.
Pages, templates, and static files¶
- A mount renders your page template (Jinja, from Litestar's
TemplateConfigor situ's own env in the Flask adapter); situ ships a minimalpage.htmldefault (situ.templates_dir()). The page includes the#siting-signalsbootstrap, loads/static/_rt.js, then the mount'sisland.js. situ.static_dir()holds the shim; serve it at/static. Kit users also servesitu_ui.STATIC(load order:_rt.js→widgets.js→ island).meta={"name": ...}is passed through to your page template; the demos use it for the header chrome.
Compiled once, inspectable always¶
A mount compiles its component pair once (cached); editing a component means restarting the app (a watch mode is on the roadmap). The result is an EmittedApp whose parts you can see. Every demo page renders its own inspector: the source pair, the signal→transport table, each handler's classification and route, and the served island. Reading the generated code is the intended way to trust it.