Tibi

Routing

Tibi is file-based: every .tibi under pages/ becomes a route. The router is the in-house tibi-match trie wrapped in a tower::Service, so the same route table feeds tibi build (SSG), tibi serve (per-request), and any custom main.rs mounting Tibi into Axum / Hyper / Warp.

File → URL

pages/
  index.tibi          → /
  about.tibi          → /about
  blog/
    _layout.tibi      → wraps every sibling + descendant
    index.tibi        → /blog
    [slug].tibi       → /blog/:slug          (single-segment param)
  @[page].tibi        → /@:page              (param inside a segment)
    [...rest].tibi    → /blog/*              (catchall, swallows the rest)
  api/
    notes/[id].ts     → /api/notes/:id       (server endpoint, see Server routes)
  _404.tibi           → renders when no route matches
  _500.tibi           → renders when a handler errors

Conventions:

  • index.tibi in any directory maps to that directory's URL (no /index suffix).
  • [name] = single-segment dynamic param. Reject with a 404 if tibi-match can't insert (e.g. [*slug] — use the catchall form below instead).
  • @[name], [name].json, v[name]-beta = a param with static text around it, inside one segment (one param per segment). pages/@[page].tibi answers /@vinny with param_page = "vinny". A static segment beats it, and it beats a plain [slug], so /@vinny and /vinny can go to different pages.
  • [...name] = catchall; matches one or more remaining segments. Also answers the parent / when no other sibling claims it.
  • _layout.tibi at any level wraps every sibling + descendant via the layout's <slot/>. Override semantics: a nested _layout.tibi REPLACES any ancestor _layout — they don't compose. So pages/_layout.tibi only wraps the root page (and any subdir page that didn't define its own), and pages/blog/_layout.tibi fully owns the /blog chrome. To make nested layouts compose (child inside parent), set nested_layouts = true under [router] in .tibi-config, or give a single layout inherit_layout = true in its frontmatter.
  • Pages can pick a named layout per-request by setting result.layout = "name" in frontmatter. The router resolves layouts/name.tibi (walking up from the page file) and wraps the page in it, ignoring the file-based _layout.tibi. Use this for conditional chrome — e.g. result.layout = ctx.user ? 'app' : 'marketing'. Set the value to "" to opt out entirely and render bare even if a _layout would otherwise apply.
  • Layout resolution order, highest priority first: (1) result.layout from frontmatter, (2) nearest ancestor _layout.tibi, (3) layouts/default.tibi walking up from the page file. Drop a single layouts/default.tibi in your project and every otherwise-layoutless page picks it up automatically.
  • _404 / _500 never appear in the URL table — they're picked when nothing else matches / a render errors.
  • Folder named components is excluded from the route walk so pages/blog/components/Sidebar.tibi stays importable but doesn't get a URL.

Per-request route context

When tibi serve handles a request, it builds a RouteCtx Tibi threads through every render layer:

{ path: "/blog/hello", route_pattern: "/blog/{slug}", params: { slug: "hello" }, query: { tab: "team" }, url: "https://docs.test/blog/hello?tab=team", origin: "https://docs.test", hash: "" // server-side; the client fills from location.hash }

The same shape lands in three places so authors can pick whichever surface fits the task:

1 — Frontmatter (ctx.route)

Every frontmatter language sees the route under ctx.route. Encoded as JSON via the existing TIBI_CTX env var, so JS / TS / Python / Rust each read the same shape without per-language glue.

---py import json data = json.loads(open(f"_data/posts/{ctx['route']['params']['slug']}.json").read()) result = { 'title': data['title'], 'body_html': data['body_html'] } --- <template> <article> <h1>Routing - Tibi</h1> <div>{body_html}</div> </article> </template>

At build time (tibi build), ctx.route is None — SSG can't know dynamic params in advance. Read it defensively if a page can render in both modes.

2 — Auto-injected template tokens

The compiler auto-merges these into the template substitution dict so authors can write them as {token} directly with no frontmatter glue:

  • /docs/routing — the request path.
  • /docs/routing — the tibi-match pattern (e.g. /blog/{slug}).
  • https://tibi.fi/docs/routing — the full request URL.
  • https://tibi.fi — scheme + host only (e.g. https://tibi.fi). Useful for emitting copy-pasteable commands that hit the same box the visitor is on (e.g. curl https://tibi.fi/install.sh | sh). The scheme honours the proxy-standard X-Forwarded-Proto header, so deploys behind a TLS-terminating proxy (Cloudflare, Traefik, Coolify) render https:// rather than the plain-http scheme the container itself sees.
  • {param_name} — one entry per matched path param.
  • {query_name} — one entry per query string key (last write wins on repeats).
<template> <header> <h1>{param_slug}</h1> <p>Section: {query_tab}</p> </header> </template>

Frontmatter-set values win on a name clash. So result.path = 'CUSTOM' in your frontmatter overrides the auto-injected /docs/routing.

2½ — Auto-SEO from frontmatter

Five well-known keys auto-emit <head> tags so every page gets per-route metadata without touching the layout:

  • title → <title> + og:title + twitter:title
  • description → <meta name="description"> + og:description + twitter:description
  • canonical → <link rel="canonical"> + og:url
  • image → og:image + twitter:image
  • theme_color → <meta name="theme-color">
---js result = { title: 'Install — Tibi', description: 'Drop the Tibi CLI onto $PATH in one curl.', canonical: 'https://tibi.fi/install', image: 'https://tibi.fi/og-install.png', }; --- <template><p>...</p></template>

Synthesised tags land in the page's <head> block, which the renderer appends AFTER the layout's <head>. Because browsers honour last-wins on duplicate <title> / <meta> tags, page-level SEO deterministically overrides whatever defaults the layout declared.

Keys stay available for normal Routing - Tibi template substitution too — useful for echoing the title inside the page body.

3 — Reactive client-side $route

Inside <script tibi>, the runtime exposes $route as a reactive proxy. Effects that read any field re-fire on every navigation (browser back / forward, history.pushState / replaceState, hashchange). The subscription auto-unsubscribes when the component unmounts.

<script tibi> // Re-runs whenever path / params / query / hash change. effect(() => { console.log('navigated to', $route.path, $route.query); }); // Computed values on top of the route work as expected. const isActive = computed(() => $route.path.startsWith('/blog')); </script>

Template directives consume the same proxy:

<a :class="{ active: $route.path === '/about' }" href="/about">About</a> <p t-show="$route.query.debug">Debug: {{$route.url}}</p>

Imperative API: window.Tibi.route + $router

The reactive proxy is a thin wrapper over window.Tibi.route. Use it directly for non-reactive code (event handlers, plain JS, third-party integration). Inside <script tibi> the same surface is reachable via $router, which mirrors common framework verbs (Vue / Next / Nuxt / React Router / SvelteKit) so existing patterns paste in unchanged.

  • Tibi.route.snapshot() — current frozen snapshot. Same shape as the server's RouteCtx.
  • Tibi.route.subscribe(fn) — invoke fn(snapshot) on every navigation. Returns an unsubscribe.
  • Tibi.route.push(url, overrides?) — history.pushState + notify. Optional overrides set route_pattern / params when you have them.
  • Tibi.route.replace(url, overrides?) — same, via replaceState.
  • $router.push / .replace / .navigate / .goto / .back / .forward / .go — verb aliases on the imperative handle. navigate + goto both delegate to push.
  • Tibi.route.path / .route_pattern / .params / .query / .hash / .url / .origin — getter properties returning fresh values.

Field aliases (other-framework muscle memory)

Both $route and $router (and Tibi.route directly) expose the same payload under several alias names so you don't have to relearn anything:

  • pathname → path (Next / React Router / window.location)
  • fullPath → url (Vue Router / Nuxt)
  • asPath → url (Next pages router)
  • pattern / routeId → route_pattern (SvelteKit-style)
  • search → "?k=v&…" string (Web Platform / React Router)
  • searchParams → URLSearchParams (Web Platform / Next App Router)
// Pick whichever spelling matches your prior framework — same payload. $route.pathname // === $route.path $router.pathname // same again Tibi.route.fullPath // === Tibi.route.url Tibi.route.searchParams.get('tab')

Tracking nav from anywhere

The runtime monkey-patches History.prototype.pushState and replaceState so any code that mutates history — Tibi.route.push, third-party routers, hand-rolled history.* calls — drives subscribers. Pop / back / forward is covered by the standard popstate event; hash changes by hashchange.

Client doesn't know route_pattern

After a client-side navigation, $route.path / .query / .hash reflect location, but route_pattern + params are empty (tibi-match lives on the server). Pass them via the optional second arg if you want them populated:

Tibi.route.push('/blog/hello', { route_pattern: '/blog/{slug}', params: { slug: 'hello' }, });

Redirects + global aliases

Per-route redirects live in .tibi-config and fire ahead of the file-based router:

[[redirects]] from = "/old-blog" to = "/blog" status = 301

Useful for sunsetting old URLs without keeping the old .tibi file around.

Custom Tower stack (escape hatch)

The no-main.rs path (tibi serve) covers the common case. Apps that want extra middleware drop down to the underlying surface:

let routes = tibi::scan_pages("pages")?; let matcher = tibi::build_matcher(&routes)?; let app = axum::Router::new() .layer(MyAuthLayer::new()) .fallback(handler); async fn handler(req: Request) -> Response { let path = req.uri().path(); if let Ok(m) = matcher.at(path) { let route = &routes[*m.value]; let ctx = tibi::RouteCtx { /* … */ }; let page = tibi::render_route_with_ctx(route, Some(&ctx), |_, _| Ok(()))?; return Html(tibi::render_compiled(&page)).into_response(); } StatusCode::NOT_FOUND.into_response() }

render_route_with_ctx + RouteCtx are re-exported from both tibi and tibi_router; the no-ctx render_route still works for SSG / build-time use.

Client-side navigation

[router] client = true # navigate without full page loads nested_layouts = true # compose pages/_layout.tibi + pages/edit/_layout.tibi

With client = true, clicking a same-origin link fetches the next page and swaps only what sits inside the deepest layout the two pages share. Everything in those layouts stays on screen untouched: component state, typed input, open menus, scroll positions inside panels, websocket subscriptions. An editor with tabs under pages/edit/_layout.tibi keeps its draft while the tab content changes. The swap runs inside document.startViewTransition where supported, so the CSS in the next section animates it.

  • The server marks each layout's slot with <!--tibi-outlet:ID--> comments; they don't affect CSS or selectors.
  • The page's <title>, description, canonical / Open Graph tags and page stylesheet are updated. Scripts of the incoming page run once; class definitions and the runtime are not re-run.
  • Tibi.route / $route update with the new path, pattern and params. Back and forward re-fetch and restore the scroll position.
  • It falls back to a normal page load when the pages share no layout, the next page needs client modules not in the current import map, the response isn't HTML, or anything fails.
  • Skipped for modified clicks, target links, download, /api/ and /__tibi/ paths, and links with data-tibi-reload. A page can force full loads with <meta name="tibi-reload">.
  • Navigate from code with Tibi.nav.go(url) or $router.navigate(url). tibi:navigate and tibi:navigated events fire on window.

View Transitions

The View Transitions API ships in Chromium-based browsers and Safari. Tibi cooperates in two ways, both pure-CSS to opt into:

  • Cross-document (regular link navigation): the browser handles it natively. Add @view-transition { navigation: auto; } to your global stylesheet and same-origin navigations cross-fade by default. Customise per-element via view-transition-name: foo + ::view-transition-old(foo) / ::view-transition-new(foo) animations.
  • Same-document (dev HMR soft-reload): the runtime wraps every body / head swap in document.startViewTransition, so the same CSS rules apply when an edit triggers a hot-reload. Authors see the transition exactly as visitors will. Browsers without the API fall back to the immediate swap.
/* global.css */ @view-transition { navigation: auto; } ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.18s; }

No flag, no plugin, no JS wiring required from the author. Pure CSS contract.