Skip to content

Composition

situ has a full component model — props and events, slots, provide/inject, per-instance state, scoped CSS, recursion — with one unusual property: all of it resolves at compile time. A component tree is spliced into one FrontEnd before emission, producing one page and one island. There is no instance tree, reconciler, or per-component runtime.

The payoff is a tested invariant: the island emitted from a component tree is byte-identical to the one from the equivalent hand-written monolith (the repo's golden-bytes test pins the 5-component issue tracker and the 24-component kit gallery). Decomposing a page costs zero bytes.

Placing a component

A PascalCase tag in a template is a component host:

<Confirm :open="dialog_open" @confirmed="save"/>

<Card>
  <template slot="title">Storage</template>
  You are using 48 GB of 100 GB.
</Card>

Resolution: the Context

Tags resolve through an explicit, immutable registry — never by accident:

from situ import Context, mount_tree
import situ_ui

ctx = Context.from_dir(COMPONENTS)                     # scan *.html siblings; snake_case → PascalTag
ctx = situ_ui.kit().merge(ctx)                         # the whole component kit, registered once
ctx = ctx.with_override("Toast", HERE / "fancy_toast") # re-point one tag (also great for test doubles)

mount_tree(path="/board", root=COMPONENTS / "board", components=ctx,
           template="index.html", meta=META)

mount_component takes the same components= / overrides= (the older explicit children={...} dict still works, deprecated). An unresolved tag is a hard CompileError — and you don't have to wait for boot to see it:

python -m situ.check demos/issues/components/issues --from-dir demos/issues/components
python -m situ.check demos/ui_gallery/components/gallery --kit

situ check statically resolves every tag a tree reaches, reporting every unresolved host. Wire it into your lint loop (this repo runs it inside make lint over eight trees).

Props down, events up

A child declares its contract with two transparent markers:

# confirm.py (the child)
from situ import Prop, Emit

open: Prop[bool]                 # parent → child data
confirmed: Emit[None]            # child → parent event

def ok() -> None:
    confirmed()                  # a handler may raise its Emit

The parent binds them at the placement site: <Confirm :open="dialog_open" @confirmed="save"/>. At splice time the parent's expression is substituted into the child as an AST, and the emit name rewrites to the parent's handler. Missing, extra, duplicate, or unused bindings are CompileErrors.

Names are checked; cross-file types are not

Prop[str] type-checks the child's own body, but the parent↔child match is by name only — a Local[str] bound to a Prop[dict] passes the checkers and surfaces in e2e. Known limitation, stated in the markers' docstring.

Slots

<slot> / <slot name="x"> in the child; content (and <template slot="x"> blocks) in the paired host. Slot content is spliced verbatim in the parent's scope — a @click="close_dialog()" you pass into a <Dialog> calls your handler, by construction. Content given to a slot-less child fails closed. Nested slotted components resolve correctly (each consumes its own <template slot> blocks).

Provide / Inject

A hierarchical value channel without prop-threading:

theme: Provide[str] = "dark"      # an ancestor declares it
theme: Inject[str]                # any descendant reads it like a signal

In the flat island a Provide is simply a shared cell and an Inject a compile-time-verified alias — so reactivity (toggle the provider, every injector re-renders) falls out for free, and an Inject with no ancestor Provide is a CompileError. Demo: /theme.

Per-instance state: monomorphization

A component may own its state (Locals + handlers, no Prop/Emit) — an accordion section, a stepper, a tally. Place it twice and each placement gets its own state:

<Tally/>
<Tally/>      <!-- independent counts -->

The compiler monomorphizes at splice time — countcount__Tally0 / count__Tally1, handler bodies cloned and rewritten — like C++ template instantiation, still one island. The suffix is applied only when a component is actually placed more than once, which is what keeps single placements byte-identical. Works transitively (a stateful child reached through a layout component, placed twice — demo /nested) and per-row (a stateful child under a top-level :each gets its state routed into the row reconciler).

Scoped CSS

A child component may ship its own <style>; the compiler stamps data-sc="Tag" on the elements it owns and rewrites each selector with a zero-specificity :where([data-sc="Tag"]) clause, hoisting the block once. Styles reach the component's root and descendants; slot content and nested children are parent markup and keep the parent's styling. The mechanism is stamped attributes plus ordinary CSS — it needs no Shadow DOM and no runtime support. A top-level component's <style> fails closed. Demo: /scoped.

Depth, cycles, and recursion

  • Components compose to any finite depth — a <Dialog> built from <Card> + <Btn>, placed by the page (demo /dialog); props and events thread through every level.
  • A placement cycle (A > B > A, or self-placement) is caught statically before splicing, with the path named. Composition is a finite tree.
  • Data-driven recursion is a different thing and fully supported: a component that uses :tree internally renders itself to any depth from the data — one <Comment> definition is the whole reply thread (demo /thread).

The envelope

Everything above is the prop-driven, finite envelope, and every exit from it fails closed with an instructive error: a bare (colon-less) prop on a nested host, an own-Local child under an intermediate's :each, a cycle, a top-level <style>. The strictness is the feature — see Compile errors.