Tibi

.tibi file format

Anatomy of a .tibi file. The single-file source of every page and every component. Read this if you're hand-authoring (or letting an LLM author) .tibi sources — the parser is strict about the wrapper blocks and the script attribute, and a missing wrapper is the most common authoring mistake.

The rules in one block

  • Required: exactly one <template>…</template> block. Everything you want rendered as HTML lives inside it. A file without <template> fails to compile (missing required <template>…</template> block).
  • Optional: at most one <style>…</style> block — scoped CSS for the component.
  • Optional: at most one <script>…</script> block. Plain <script> = passthrough; <script tibi> opts into the reactive surface (state / computed / effect / template directives). Only code scripts count: <script src>, data scripts (type="application/json" and similar) and scripts in the <head> block are passed through as written.
  • Optional: a <head>…</head> block, injected into the page's <head> as written: <title>, <meta>, <link>, external <script src>. In a _layout.tibi it applies to every page; a page's own head comes first.
  • Script attributes: lang="ts" writes the script in TypeScript, and scripts may import npm packages and local modules. See Client modules.
  • Value props: <Child .items="list" .onpick="pick"/> passes objects, arrays and functions instead of strings. See Shared state.
  • Template attribute: <template shadow="none"> renders a component into the light DOM instead of a shadow root (for editors, popovers, global CSS).
  • Optional: a frontmatter block opening the file, fenced by ---. Tag the fence to pick the language: ---js, ---ts, ---py, ---rs (or bare --- = Rust default).

Block order

If present, blocks appear in this order top-to-bottom: frontmatter → <template> → <style> → <script>. The parser doesn't strictly enforce the relative order of template / style / script (it pulls each by tag), but conventional order keeps files diff-friendly.

Minimal valid page

The smallest legal .tibi file. Put it under pages/ and the file-based router serves it.

<template> <h1>Hello</h1> </template>

Page with style

<template> <main> <h1>Hello</h1> <p class="muted">Subhead.</p> </main> </template> <style> main { width: min(56rem, 100%); } .muted { color: gray; } </style>

Component with props + slot

Components live under components/ at the project root (sibling of pages/). Use them from any template with an uppercase tag — <Greeting name="Alice"/>. Each compiles to a real <tibi-{kebab-tag}> Web Component with declarative shadow DOM, so styles never leak.

<!-- components/Greeting.tibi --> <template> <section class="hi"> <h1>Hi {name}</h1> <slot/> </section> </template> <style> .hi { padding: 1rem; border: 1px solid currentColor; } </style>

String attributes flow in via {name} tokens (HTML-escaped at substitution time). Children passed in the paired-tag form (<Greeting>…children…</Greeting>) project through <slot/>.

Interactivity: <script tibi>

The single most common authoring mistake is using reactive helpers without the tibi attribute on the script tag. The compiler only emits the state / computed / effect / text / attr / show / bind / onMount / onUnmount / emit prelude when the opening tag is literally <script tibi>. A plain <script> tag with reactive code throws ReferenceError: state is not defined at runtime.

<template> <button @click="count.value += 1"> Clicked {{ count }} times </button> </template> <script tibi> const count = state(0); </script>

Template directives the compiler rewrites at build:

  • {{expr}} — reactive text interpolation.
  • @event="handler" — event listener (auto-removed on unmount).
  • :attr="expr" — reactive attribute (set / remove on truthiness). :class merges with a static class and accepts a string, an array or { name: condition }, inside t-for items too.
  • t-show="expr" — toggles display.
  • t-bind="ref" — two-way input binding.
  • t-if / t-else-if / t-else — conditional sibling chain. Inactive branches are kept out of the first paint with an inline display:none (so author display rules can't reveal them) and removed from the DOM once the script runs.
  • t-for="item of list" — list render. Rows can hold {{ }}, :attr, t-show, @event, .prop bindings and further t-for loops; in a nested loop, handlers and bindings see the inner item and idx.
  • quid="name" — surfaces the element as $name inside the script.

Component props at runtime

<script tibi> exposes a reactive props proxy that reads the host element's attributes. Pair with effect / computed to react when the parent flips an attr:

<template> <p>Hello, {{ greeting }}!</p> </template> <script tibi> // Plain string read — same as el.getAttribute('name'). const greeting = computed(() => `${props.name ?? 'world'}`); // Typed coercion helpers: const count = computed(() => props.num('count') ?? 0); const open = computed(() => props.bool('open')); const items = computed(() => props.json('items') ?? []); effect(() => console.log('open ->', open.value)); </script>

Reads tracked by effect re-fire when attributeChangedCallback bumps internally — no manual subscribe needed. props.has('flag') mirrors el.hasAttribute. A camelCase name also reads its kebab-case attribute (props.sectionId reads section-id, since HTML lowercases attribute names), and a prop that isn't set is undefined, so props.x ?? fallback works. Templates read a bare prop name as props.name, unless the script declares its own variable of that name, which then wins (as in plain JavaScript). All values are HTML attribute strings; the helpers (str / num / bool / json) coerce. props.str('name') is identical to props.name — just there for symmetry with the typed accessors.

Plain <script> (no tibi attr)

For escape-hatch DOM code or third-party libraries that don't need the reactive runtime. Tibi runs the script once with el bound to the host element and root bound to its shadow root (component scope only). Query into the shadow with root.querySelector(...).

<template> <span class="time">—</span> </template> <script> const t = root.querySelector('.time'); setInterval(() => { t.textContent = new Date().toISOString(); }, 1000); </script>

Frontmatter

Open a file with ---<lang> to drop a compile-time script. Its top-level result object's keys become {token} substitutions in the template that follows. See Frontmatter for the full per-language contract.

---js const fs = require('fs'); result = { page_count: String(fs.readdirSync(ctx.pages_dir).length), }; --- <template> <p>This site has {page_count} pages.</p> </template>

Component shape: full example

Every section a component can carry — frontmatter, template, style, reactive script — in one file.

---py # Compile-time frontmatter: runs once per render. Top-level keys land # in the template as {token} substitutions. import datetime result = { 'built_at': datetime.datetime.utcnow().isoformat() + 'Z' } --- <template> <article> <p class="stamp">Built {built_at}</p> <p>Clicked {{ count }} times</p> <button @click="count.value += 1">Bump</button> </article> </template> <style> article { padding: 1rem; border-radius: 0.5rem; } .stamp { color: gray; } </style> <script tibi> const count = state(0); onMount(() => emit('mounted')); </script>

Server channels via __channels

Components subscribe to server-pushed channels by declaring them in their own frontmatter via a reserved __channels key. Tibi picks them up at boot, spawns one tick task per channel, and the component subscribes client-side.

---js result = { __channels: [{ name: 'time', interval_ms: 1000, payload: 'iso8601' }], }; --- <template> <time class="now">{{ stamp }}</time> </template> <script tibi> const stamp = state('—'); Tibi.ws.subscribe('time', (msg) => { stamp.value = String(msg); }); </script>

For agents / LLMs: validate via MCP

The bundled MCP server exposes two tools tailored to remote authoring:

  • tibi_file_format — returns this spec as text. Call it before hand-writing a .tibi file.
  • tibi_validate — accepts a { "source": "<raw .tibi contents>" } argument and returns either OK or one issue per line. Runs the real parser plus a heuristic that flags the most common LLM mistake: using state / effect / onMount in a <script> tag that's missing the tibi attribute. More than one code <script> is an error, since only the first would run.

Common mistakes

  • HTML at the top level, no <template> wrapper. The parser refuses the file. Wrap everything in <template>…</template>.
  • Reactive code in a plain <script>. Add the tibi attribute: <script tibi>. Otherwise state / effect / onMount / etc. aren't in scope.
  • Frontmatter opened but not closed. Every ----fenced block needs a matching --- on its own line.
  • Lowercase component tag. <greeting/> is a regular HTML element; only uppercase tags (<Greeting/>) resolve to .tibi files.
  • Component file outside components/. The resolver walks up looking for the first ancestor with a components/ subdir — files nested elsewhere don't get picked up.
  • Multiple <template> / <style> / <script> blocks. Each file has at most one of each.