Every page in this documentation — including the one you are reading — is a
markdown file under content/, turned into a native route by mdRoutes (from
brustjs/routes). At build time each file is rendered to HTML, compiled into
a jinja template like any other native page, and served by Rust; embedded
component tags become live islands or behavior components. This page is the
dogfood: it embeds two working demos further down.
mdRoutes
import { mdRoutes } from 'brustjs/routes'
const routes = mdRoutes(contentDir, options?)
mdRoutes(contentDir, opts) scans a directory of .md files and returns
Route[] — ordinary native: true routes you spread into defineRoutes.
| Option | Type | Default | Description |
|---|---|---|---|
prefix |
string |
'/' |
URL prefix the pages mount under. Trailing slashes are normalized (/docs/ ≡ /docs). |
layout |
ComponentType |
— | Optional layout component. When set, mdRoutes returns one parent route { path: prefix, Component: layout, children: […leaves] }; the layout owns the document shell and renders each page at its <Outlet/>. Must be a named component. |
components |
Record<string, ComponentType> |
{} |
Registry for <Name /> tags embedded in page bodies (see below). |
Without layout, the returned leaves carry full prefixed paths; with it, the
children carry prefix-relative paths under the single parent. Each leaf is a
native route with a synthetic named component and a generated loader that
exposes the frontmatter head fields as { __md: { title, description } } —
on a chained route those merge into the layout's loader data like any
nested-route chain (this site's layout reads them for
<title> and the meta description).
File → URL mapping
A file's path relative to contentDir is its URL under prefix:
| File | URL (prefix /docs) |
|---|---|
index.md |
/docs |
installation.md |
/docs/installation |
guide/index.md |
/docs/guide |
query/where.md |
/docs/query/where |
With the default prefix '/', index.md is /.
Frontmatter
A leading --- … --- block. The parser is a deliberate YAML subset (no
yaml dependency): key: value lines with double-quoted strings (JSON
escapes), single-quoted strings (no escapes), bare strings, numbers, and
booleans. Nested maps use the inline-braces form only — indented child
keys throw with a file:line error.
---
title: Markdown Pages # <title>, sidebar, search
description: One-line summary. # <meta name="description">
nav: { group: "Guides", order: 7 } # sidebar bucket + sort key
---
Files without a frontmatter block get an empty one; the title then falls back to the file stem in navigation.
mdNav
import { mdNav } from 'brustjs/routes'
const groups = mdNav(contentDir) // MdNavGroup[]
mdNav returns the navigation model for a content dir:
interface MdNavGroup {
group: string | null // frontmatter nav.group; null = ungrouped
items: { title: string; path: string; order?: number }[]
}
Pages are sorted by nav.order (missing order sorts last) then title, and
bucketed by nav.group — ungrouped pages land in a group: null bucket.
Two things to know:
- Sorting is global, not per group. Group order follows the first
appearance of each group in the sorted page sequence, so a group's position
is set by its lowest-ordered page. This site numbers
nav.ordersequentially across all groups to keep the sidebar sections in reading order. - Paths use the prefix the dir was mounted under by
mdRoutes(recorded whenroutes.tsxruns;'/'ifmdRouteswas never called for that dir).
This site's sidebar and prev/next pager are both mdNav output, computed in
the layout route's loader.
Embedding components
A line consisting of a self-closing, capitalized tag — at the top level of the page, outside any code fence — embeds a component:
<DemoCounter start={3} />
<DemoCounter start={0} hydrate="visible" />
<DemoCounter csr />
<DemoBadge />
Props are literals:
| Form | Example | Value |
|---|---|---|
p="str" |
label="docs" |
The string, verbatim (no escapes). |
p={…} |
start={42}, live={true} |
The {…} content, JSON.parsed — numbers, booleans, null. |
p={{…}} |
cfg={{"a":1}} |
A JSON object (or array). |
flag |
csr |
true. |
Two attribute names are reserved and pulled out of the props:
hydrate="load|idle|visible|interaction"— island hydration timing (defaultload).csr— client-side-render only: the host div ships empty (no SSR HTML) and the island renders entirely in the browser.
Malformed attributes, duplicate names, unbalanced braces, and non-self-closing
registry tags are build errors with file:line. And the strict corollary:
any bare line opening with a capitalized self-closing tag must resolve in
the registry — a literal example like the fence above fails the build unless
it sits inside a code fence (fences shield everything).
Islands vs behavior components
The same tag grammar embeds two different kinds of component, told apart by the component's source file:
- A React island (an ordinary React component) compiles to an island host
— server-rendered HTML plus a hydration marker — exactly as if the page were
TSX.
hydrateandcsrapply. - A behavior component (a file with
export const behavior, see Native Interactivity) is inlined fully statically at build time, with itsx-datadirective auto-injected; the react-free directive chunk binds it in the browser.hydrate/csrare errors here, props must be string or number literals (they substitute into the static body — a body referencing anything non-literal is a hard build error), and a literalx-dataon the component's root is rejected.
Live demos
The next two lines of this page's source are component tags. First
<DemoCounter start={3} /> — a React island with useState, server-rendered
then hydrated on load:
And <DemoBadge /> — a behavior component: the button below is static HTML in
the jinja template, wired by a react-free chunk through x-on-click and
x-text:
The three-identity rule
The md tag name, the key in the components registry, and the component's
default import ident in the routes entry must all be the same name — the
build resolves the tag through the routes entry's imports:
// routes.tsx
import DemoBadge from './components/DemoBadge' // 1. import ident
import DemoCounter from './components/DemoCounter'
mdRoutes('content', {
prefix: '/docs',
layout: DocsLayout,
components: { DemoBadge, DemoCounter }, // 2. registry key
})
// 3. <DemoCounter … /> on a line in the .md file
A registry key with no matching default import fails the build with an error naming all three identities.
Braces are safe
Native templates are minijinja, so {{ … }} and {% … %} in markdown could
collide with template syntax. They don't: after markdown rendering, every
jinja delimiter that came from your content is neutralized so it renders back
as literal text — only the injected component-host markup keeps live template
syntax. Which is why this page can show you island host internals and raw
jinja in a fence:
<div data-brust-island="DemoCounter_1a2b3c4d"
data-brust-props="{{ island_0_props }}"
data-brust-hydrate="load">{{ island_0_html | safe }}</div>
{% raw %} … {% endraw %}
…and inline too: {{ island_0_props }} renders literally in prose. No escaping required, anywhere in a page.
Markdown features
GFM (tables, strikethrough, task lists) via marked. Headings get slugified
ids automatically (duplicates deduped with -2, -3, …) — this site's
search index links straight to them. Code fences are highlighted server-side
by shiki with dual github-light/github-dark
CSS-variable themes; shiki is an optional dependency — without it fences
degrade to escaped <pre><code> with one build warning, and an unknown
language degrades silently per fence.
The frozen manifest
brust build writes <out-dir>/md-manifest.json next to the compiled
templates:
{
"version": 1,
"contentDir": "content",
"entries": [
{
"relPath": "markdown-pages.md",
"templateName": "Md_markdown_pages_md_4af0…",
"urlPath": "/docs/markdown-pages",
"frontmatter": { "title": "Markdown Pages", "nav": { "group": "Guides", "order": 7 } }
}
]
}
A prebuilt dist boots its md routes from this manifest — the markdown
content directory does not need to ship with the deploy. mdNav reads it too,
recomputing each urlPath from relPath plus the live prefix so links can
never diverge from the routes.
In dev and source mode the filesystem is the truth instead: edits to an
existing .md file hot-reload, but adding or removing a file needs a
restart — the route table is frozen at boot, and the dev server warns
md routes changed — restart required once when it notices.
Static export (--ssg)
brust build --ssg prerenders the site to plain HTML after the build:
brust build index.ts --ssg # → dist/static
brust build index.ts --ssg --ssg-out site # custom output dir
It boots the just-built dist once on an ephemeral port, crawls every
statically-renderable route, and writes <path>/index.html per page (/ →
index.html, /docs/intro → docs/intro/index.html). Routes are skipped
when they cannot be a static file:
| Skipped | Why |
|---|---|
/blog/{slug} |
dynamic param — the page set is unknown at build time |
/files/{*rest} |
wildcard |
sse routes |
a stream is not a file |
websocket routes |
likewise |
Everything else must answer 200 — any other status fails the whole export
and removes the partial output, so a broken page can never ship silently.
Assets are copied preserving the live server's URL shape: island chunks under
/_brust/islands/, CSS under /_brust/css/, and public/ mapped to the
root.
Root path only: every generated URL is root-absolute. Deploy the export at
a domain root (docs.example.com), not under a subpath — example.com/docs/
would 404 every asset.
Cloudflare Pages
The export is a plain directory — no functions, no adapter. For this site:
| Setting | Value |
|---|---|
| Build command | bun install && bun run build:ssg (whatever runs brust build … --ssg) |
| Build output directory | dist/static |
The same directory works on any static host that serves <path>/index.html
for <path> (Pages, Netlify, nginx try_files).
Next
Shipping the server build (and when to prefer it over static): Deployment.