Architecture
.tibi source file gets there.Tibi is a compiler, not a framework
The distinction matters because it explains what does not ship to the browser. There is no virtual DOM, no diffing pass, no hydration shim wrapping every element, no framework runtime users program against at execution time. The Rust compiler reads .tibi files at build time and emits three things: a rendered HTML document with declarative shadow DOM templates baked in, a content-addressed bundle of customElements.define calls (one per unique component, deduped across the whole site), and a thin per-component reactive scope factory cached as a separate JS asset. Pages reference the bundles via <script src>; first paint runs against fully-rendered HTML, hydration upgrades the custom elements in place. That's the entire shape.
tibi serve reuses the same compiler in-process for SSR — the routing, store, channel, and cron surfaces below are the runtime side. A pure SSG deploy keeps none of them in the dist.
The compile pipeline
tibi::compile_page(entry) walks a .tibi file on disk through:
- Parse. Split the source into
frontmatter/<template>/<style>/<script>blocks. The frontmatter fence may carry an optional language tag (---js/---py/---ts/---rust). - Evaluate frontmatter. Shell out to the matching interpreter, capture
resultas JSON, substitute its top-level keys into the template as{token}values. Pages and components both run their own. - Resolve components. Uppercase tags in the template (
<Greeting/>) resolve to sibling.tibifiles. The resolver walks parent directories looking for the first ancestor with acomponents/subdir, so a top-levelcomponents/serves every page below it. - Web-Component-ify. Each component call is rewritten to a
<tibi-{kebab-tag}>host carrying a<template shadowrootmode="open">with the child's<style>+ rendered template. The browser attaches a Declarative Shadow Root before JS lands. Light children sit outside the template and project through<slot>. - Scope page-level CSS. The page's own elements get a
[data-t="..."]attribute selector (8-char FNV-1a hash); component CSS lives inside its own shadow root and never enters the page-global pool. - Aggregate scripts. The page emits one
customElements.defineper unique component tag (deduped), the sharedwindow.__tibiEschelper, and the page's own<script>body wrapped in an IIFE. Each component class observes its host's attribute set and re-renders its shadow on attribute changes.
Compiling without a filesystem
Every file the compiler reads goes through a SourceProvider. FsSource reads from disk and backs compile_page; MapSource serves sources from memory; the *_in entry points (compile_page_with_ctx_in, render_route_with_ctx_in) take any provider. tibi-project-store builds on this for hosted projects: a ProjectStore trait with in-memory and directory backends, ProjectPath validation (relative paths only, no .., no hidden files apart from .tibi-config, an allow-list of file types, no symlinks followed), per-project size limits, and StoreSource, which hands a store straight to the compiler. tibi_router::scan_page_paths builds routes from a list of stored paths with the same rules as a disk scan. Pair it with FrontmatterPolicy::Deny (see Frontmatter) when the sources are untrusted. Vendored plugin components under .tibi-cache/ are not visible through a store.
Routing
tibi::scan_pages("pages") walks the directory deterministically and produces a Vec<Route> in tibi-match syntax (the in-house router crate that replaced the third-party matchit dep):
pages/index.tibi→/pages/about.tibi→/aboutpages/blog/index.tibi→/blogpages/blog/[slug].tibi→/blog/{slug}pages/files/[...rest].tibi→/files/{*rest}
_layout.tibi at any directory level wraps every sibling and descendant via <slot/>. _404.tibi and _500.tibi are fallbacks. components/ subdirs are excluded from routing.
Render modes
- SSG:
tibi buildwritesdist/<path>/index.htmlper static route. Dynamic + fallback routes are reported as skipped. - SSR: the Axum handler in your app calls
tibi::render_route(route)per request. - Hybrid: SSG output + per-island props inlined into
<script type="application/json" id="q-props-N">blocks. Island JS reads its own props from there on hydration; no fetch round-trip.
Client runtime
tibi-runtime ships two embedded JS payloads in every served page:
window.Tibi.store— IDB-backed reactive store. Keys use a `namespace:rest` convention; each[[store]] namespace = "..." ttl_secs = Nentry in.tibi-configpins the default TTL for keys under that namespace, so per-call{ ttl: N }overrides are usually unnecessary.ttl: 0(the default for un-declared namespaces) = memory-only;ttl > 0= persist with lazy eviction.window.Tibi.ws— singleton multiplexed WebSocket.subscribe(channel, fn)opens the shared socket lazily, sends an{op:"subscribe",channel}op frame, JSON-decodes inbound{channel,data}wrappers, routes by channel name, exponential-backoff reconnect (max 5s) with auto-resubscription. Includes an auto-subscribe to thehmrchannel that reloads on"reload".window.Tibi.ssealiases the same surface for source-level back-compat.
Server channels
tibi::SseRegistry is a clone-cheap registry of named tokio::sync::broadcast channels. The transport is a single multiplexed WebSocket at GET /__tibi/ws: browsers open one socket per tab, send {op:"subscribe",channel} / {op:"unsubscribe",channel} op frames, and receive every push wrapped as {channel,data}. Nginx / Traefik / Cloudflare pass WS upgrades through cleanly, so the per-channel buffering games SSE needed are gone.
Channels are declared by the components that consume them via the __channels frontmatter convention — at tibi serve boot, Tibi walks pages/ + the sibling components/, evaluates every frontmatter once, collects every __channels entry (deduped by name), and spawns one tokio interval per channel that pushes a payload to the registry on each tick:
Built-in payload kinds: unix_secs, iso8601, counter, { static: "value" }. For custom payloads (database row, HTTP fetch, computed from app state), apps wrap their own tower stack and call tibi::SseRegistry::send(channel, payload) directly — the registry is transport-agnostic and accepts opaque strings, so any push pattern works.
Scheduled jobs
Each [[cron]] entry in .tibi-config spawns one tokio task at boot. The schedule supports 5-field (min hour dom mon dow) and 6-field (sec min hour dom mon dow) cron expressions; the referenced file lives under [server] crons_dir (default crons/) and runs through the same evaluator the component frontmatter uses (node / python3 / rustc, picked from the file extension). Side effects (HTTP fetches, file IO, database writes) work via each language's stdlib — Tibi injects no runtime helpers into the cron script, so the same file runs cleanly outside Tibi for testing.
tibi-script (component DX)
Components opt in via <script tibi>. The compiler walks the template for declarative bindings, rewrites the markup, and splices a runtime helper prelude in front of the user body. Plain <script> blocks pass through untouched.
- Element refs.
quid="name"auto-resolves to$namein the script. The compiler also auto-assignsquid="__qN"to elements that carry directives but no manual quid. - Reactivity.
state(initial)returns a writeable signal (x.value);computed(fn)derives one;effect(fn)re-runs whenever any signal it read changes. - Bindings. Use the template directives below (
{{expr}},:attr,t-show,t-bind) — the compiler emits the underlying DOM-binding helpers for you. The runtime ships them as__q-prefixed internals (__ttext/__tattr/__tshow/__tbind) so user-declared identifiers likeconst text = ...orconst { attr } = ...never collide with the helpers. - Template directives (compiler rewrites at build):
{{expr}}— reactive text interpolation.@event="handler"— listener (auto-removed on unmount). Bare-identifier handlers are called with the event; everything else runs inline with$eventin scope.:attr="expr"— reactive attribute (set / remove based on truthiness).t-show="expr"— togglesdisplay.t-bind="ref"— two-way input binding.t-if="expr"+ siblingt-else-if="expr"+t-else— conditional chain. Consecutive sibling elements collapse into a single coordinator that mounts at most one branch (the first whose condition is truthy, or thet-elsefallback).t-for="item of list"— list render. Children are extracted at compile time into a JS template-literal render fn ({{item.x}}tokens become${{__escHtml(__read(item.x))}}). The runtime reconciles slot-by-slot: an unchanged rendered HTML string skips the DOM swap entirely, so a parent re-render that produces the same list does not tear down or re-init any nested Custom Elements. Addt-key="item.id"for keyed identity across reorders / inserts / removals (Vue / React style).
- Lifecycle.
onMount(fn)+onUnmount(fn);on($el, event, fn)for manual listener registration;observe(callback, options?, target?)wrapsMutationObserver;emit(name, detail?)dispatches a composed CustomEvent off the host. - Route.
$routeexposes a reactive view of the current URL — see the Routing page for the full surface (frontmatterctx.route, auto template tokens,Tibi.routeimperative API).
Plugins
The TibiPlugin trait has three default-method hooks:
transform_tibi(&mut TibiAst, &TransformCtx)— mutate the AST per fileemit_assets(&mut AssetBundle)— contribute extra CSS / JS per renderregister_channels(&mut SseRegistry)— register extra named server channels at startup (in addition to the ones components declare via their__channelsfrontmatter)
Each plugin declares its target_abi(). PluginRegistry::register refuses on mismatch — a plugin compiled against an older runtime fails fast instead of subtly misbehaving. Reference plugins ship for Tailwind and MDX.