Writing documentation
The documentation is Markdown in the repository's docs/ directory, built into this site by Roots itself. docs/ is a Roots project: the Markdown files are the pages, docs/site/ is the app that shows them, docs/demos/ holds the live examples. You write Markdown and example components; everything else is generated.
$ bin/roots docs dev # write with live reload: http://localhost:5173/
$ bin/roots docs check # links, anchors, navigation, that examples exist: no build
$ bin/roots docs build # every version → docs/dist/site/
$ bin/roots docs preview # serve docs/dist/site/ the way GitHub Pages doesHow a page is built #
x.md ──► Markdown compiler ──► site/gen/pages/x.vue ──► the ordinary build ──► app.js + one chunk per page
(src/md, in Lisp) a generated component (bundle, CSS) │
▼
v/0.4.0/x.html ◄── rendered in Node, once per route, as static HTML- Every page is a compiled component and a lazy route of one Roots app (
roots/router,roots/ui). - At build time every route is rendered to an HTML file, so a page is readable without JavaScript and its first paint does not wait for the app. In the browser the app mounts over that HTML when the page's chunk has arrived and the static page has been painted; the reader stays where they were (a heading the address names, the place before a reload).
- Code is highlighted while the page is generated. Nothing parses Markdown or code in the browser.
- Rendering runs the site's own code (the app and the demos) in Node on the machine that builds it, with that person's access: the same trust a build already needs, because macros run project code too. Build only documentation you trust.
- Everything you type is data:
{{ x }},<script>andv-if=in Markdown are shown as text. The only HTML that means something is<kbd>,<sub>,<sup>,<br>and<details>with<summary>.
Add a page #
Write
docs/guide/my-topic.md. File names are lowercase letters, digits and dashes: the file's path is the page's URL (/guide/my-topic).List it in
docs/nav.lisp, in the group where it belongs:(:title "Getting started" :pages ("index" "guide/getting-started" "guide/my-topic"))Run
bin/roots docs check. A Markdown file that is neither listed nor under:unpublishedis reported (M130), and so is every link to a page or heading that does not exist (M133, M134).
While you write (docs dev) those are warnings. docs check and docs build refuse to publish with a link that leads nowhere (M133, M134), a code fence that is never closed (M104) or a page that disappeared without a redirect (M131); --lenient publishes anyway, --strict refuses on every warning.
The first # heading is the page's title. Optional front matter, at the top of the file, overrides it and sets a few more things:
---
title: My topic
nav: Topic # a shorter label for the sidebar
description: One sentence for search results and link previews.
layout: wide # no "On this page" column: for pages of wide tables
since: 0.5.0 # a "New in 0.5.0" mark under the title
toc: 2-4 # which heading levels "On this page" lists (2-3; false: none)
draft: true # shown by `docs dev` only: not built, not listed, not in the sitemap
order: 2 # the page's place in a group that takes a whole directory
search: false # not in the search index
noindex: true # not in sitemap.xml, and marked noindex
---The block can be copied as it is: a # comment after a value is not part of the value (quote a value that contains #). These are all the keys; another one is reported (M106), and so is a layout that is not doc or wide (M125).
A group of nav.lisp can also take a whole directory, (:title "Guide" :dir "guide"): every Markdown file in it is listed, so a new page is one new file. Such pages are ordered by order, then by title. A group that ends up without pages is left out and reported (M124).
Conventions #
| Thing | Rule |
|---|---|
| Links between pages | relative links to the .md file, as on GitHub: [the router](../runtime.md#7-router-runtimerouter). They are checked and become client-side links. |
| Heading anchors | GitHub's, without a leading section number: ## 2. The API is #the-api. The numbered form #2-the-api keeps working as an alias, so links written for GitHub resolve here too. ## Title {#my-id} sets one by hand. |
| Links to generated pages | by site path: [the reference](/reference/router). |
| Links to other files of the repository | relative, as on GitHub (../examples/todo); they lead to the repository. |
| Images | , a file of the repository by its relative path (png, jpg, gif, svg, webp, avif). The build copies it into the version. An image of another site is not shown, and reported (M136): the pages' Content-Security-Policy allows the site's own images only. |
| Code | fenced blocks with a language: ts js vue html lisp sh css sql json swift kotlin; no language for diagrams and output. Indented code blocks are not supported (M103). |
| Notes | GitHub alerts: > [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]. |
| Variables | %version%, %commit% and %repo% are replaced in running text (not inside code): this is Roots 0.4.0. Write \%version% for the literal text. |
| Annotations | an HTML comment on its own line annotates the next block: <!-- since: 0.5.0 -->, <!-- id: my-anchor -->. It is invisible on GitHub. |
| Ids | lowercase letters, digits and dashes (M108 otherwise). An address may spell an id as the thing is written (#createRouter, #B125): it is matched without regard to case. |
| Characters | type them: é, →, ©. The common named references (→, ©, é …) are decoded; an unknown one is shown as written and reported (M109). |
Everything degrades to plain Markdown on GitHub: the files stay readable there.
Add a live example #
A live example is a real component under docs/demos/. A link that stands alone in a paragraph, with a title that starts with example, shows it running with its source below:
[A counter](demos/counter.vue "example")<script setup lang="ts">
import { computed, ref } from "roots/runtime"
const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>
<template>
<Box class="counter flex-row items-center gap-3">
<Button label="−" aria-label="One less" @click="count--" />
<Text class="counter-value">{{ count }}</Text>
<Button label="+" aria-label="One more" @click="count++" />
<Text class="counter-note">twice that is {{ doubled }}</Text>
</Box>
</template>
<style>
.counter { flex-wrap: wrap; }
.counter-value { min-width: 2ch; text-align: center; font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.counter-note { color: var(--roots-muted); }
</style>The page imports the file as a component and shows the text of the same file, so the code you read is the code that runs. A broken example breaks the build: one that does not compile, at its own file and line; one that throws while its page is rendered, with the page and the file (S103). On GitHub the line is an ordinary link to the source file.
Rules for a demo:
- One name per demo. The file's base name is unique in the whole site (M143).
- Styles start with the demo's name. Roots has no scoped styles: every
<style>block lands in the one stylesheet. So each selector of a demo starts with a class that begins with the demo's name:.counter-valueincounter.vue(M144 otherwise). - No inline styles for layout. The static HTML of a page cannot carry a
styleattribute (the pages' Content-Security-Policy forbids it), so use classes. - DOM access belongs in
onMounted. Pages are rendered at build time, where there is nodocument. An example that cannot render without a browser at all takes the optionclient.
Options after example:
| Option | Effect |
|---|---|
tab=Label | adjacent blocks with tab= form one tab set (code fences take it too: ```ts tab=TypeScript) |
source=closed | the source is folded away under "Source" |
source=none | no source |
source=only | the source without the running component |
client | not rendered at build time: the frame says "runs in the browser" until the app is live |
app | the link names a directory whose main.ts exports create(), returning an app of its own; files=a.ts,B.vue chooses the files shown. This is how the router has a live example: an app with its own in-memory router. |
region=name | show only the lines between // #region name and // #endregion |
height=sm|md|lg|xl | a minimum height for the stage |
Tabs, as they are written and as they look:
```ts tab=TypeScript
const count = ref(0)
```
```lisp tab=Lisp
(defvar count (ref 0))
```const count = ref(0)(defvar count (ref 0))An excerpt of a file that is not run uses the title source. It is read from the file at build time, so it cannot go stale silently:
[The routes](demos/router-app/main.ts "source region=routes")
[resolve-per-target-file](../src/build/resolve.lisp "source lines=54-60 starts=\"(defun\"")A line range needs starts=: the text its first line must contain. When the file changes above the range, the build fails (M141) instead of showing the wrong lines.
The reference pages #
The pages under Reference are not written: nav.lisp asks for them with (:generated :api "roots/router" :path "reference/router"), and the generator reads the runtime's sources: every (export …) of the module with its declaration and docstring, every kit component with its props. (:generated :cli …), (:generated :config …) and (:generated :diagnostics …) come from the command table, the schema of roots.lisp and a scan for coded messages. To improve an entry, improve the docstring in the source.
Declarations are printed by the compiler that builds the runtime, in TypeScript's spelling: &key parameters are the one options object a caller passes, a class and an object of roots/platform list their members (router.push(), theme.set()), each with an anchor and an entry in the search index. :see (("Label" "/path#id") …) on the entry adds links to where the module is explained.
Search #
The index is built with the pages: every heading is a result, and so is every entry of a reference page. Two things in nav.lisp shape what a reader finds:
(:title "Guide" :rank 1.3 :groups …) ; on a section, or on one page: ("performance" :rank 0.6)
:synonyms (("hot reload" "live reload") ("dropdown" "select")):rank weighs a page's results (guides before history and internals). :synonyms says what the pages call the thing a reader types; add a pair when a search that should find a page finds nothing.
Versions #
Every published version of Roots keeps its documentation. A built site is a directory:
dist/site/
index.html build.html guide/… the newest version, at addresses without a version
v/0.4.0/ v/0.3.3/ … one complete copy per version, never rewritten
versions.json what the version menu of every version reads
404.html sitemap.xml robots.txt llms.txtA version directory uses relative URLs only, so it works at any path, and old versions are never rebuilt: the version menu of an old version learns about newer ones from
versions.json.The newest version is also served without
v/…in the address. Those are the addresses to link to; every page names its unversioned address as canonical.A version directory says what it is (its commit, its date, whether it is an archive) in its own
pages.json;versions.jsonis written from the directories that are there.The newest version in the directory is the one at the root. Building an older version into it (a patch release of an older line) writes its
v/<version>/and leaves the root alone.
To publish a new version:
- Commit the release (
VERSIONsays which version the working tree is). bin/roots docs build --out ../roots-pageswrites the site into a checkout of the pages branch: the newv/<version>/, the pages at the root,versions.json. Other version directories are left as they are.- Look at it (
bin/roots docs preview --out ../roots-pages), commit and push that checkout. Nothing is pushed by the toolchain. - Add the version to
docs/versions.lispwith the commit it was built from, so that it can be built again from Git should its directory ever be lost:(:version "0.5.0" :commit "1a2b3c4" :date "2027-01-15")
docs/versions.lisp is the list of versions that can be built from their commits. A version whose directory is missing is built; one that is there is left alone (--rebuild builds the listed ones again). How a listed version is built depends on its commit:
- A commit that has the documentation site (
docs/nav.lispand the toolchain'sroots docs) is built from its own tree, by its own toolchain: the commit is checked out into a temporary worktree and itsbin/roots docs build --only-versionwrites the version exactly as it was published, with its navigation, its reference pages and its live examples. The first build of such a version compiles that toolchain (a minute). - A commit from before the site, or an entry with
:legacy t, is built as an archive: the Markdown of that commit in the current site, without live examples (the version menu says "archived"). The entries of 0.3.3 and 0.2.1 are of this kind.
An archive never replaces a full version: --rebuild stops (M172) when the directory of a listed version was published in full and its entry could only produce an archive.
--base /path/ and --origin https://host build for another address than the one in docs/roots.lisp (canonical links, the sitemap, the site's 404 page; everything else in a version is relative).
When a page moves, add a redirect to nav.lisp so that old addresses keep working:
:redirects (("runtime" "guide/runtime") ("performance#method" "performance/method"))A page that existed in the previous version and is gone without a redirect stops roots docs build (M131). An address cut back to its directory (/guide/) leads to the first page under it.
Checks and tests #
bin/roots docs checkruns everything that needs no build;--strictmakes warnings errors. It reads the demos but does not compile or run them: a demo that does not compile, or throws while its page is rendered, stopsbin/roots docs build.bin/test-system roots-md/testsandbin/test-system roots-site/teststest the Markdown compiler and the site generator.docs/site/tests/run.shbuilds the site and opens every page of every version in Chromium, with and without JavaScript: no console message, no broken link, no broken example, no id twice, the same page height before and after the app starts, no sideways scrolling at 1440, 390 and 360 pixels. Then it does what a reader does: follows links to headings (each must land below the sticky header, with the focus), searches with the keyboard, uses the examples, changes the version, reads on a phone without JavaScript, and measures the contrast of the code colours.BROWSER=firefox docs/site/tests/run.shruns the reader's part in Firefox.
What the site is made of #
| Part | Where |
|---|---|
| Markdown compiler (blocks, inlines, highlighting, template text) | src/md/ |
| Generator, reference pages, search index, pre-rendering, versions, preview server | src/site/ |
| The app: shell, layout, search, version menu, example frame, styles | docs/site/ |
| Navigation, versions, project file | docs/nav.lisp, docs/versions.lisp, docs/roots.lisp |
| Live examples | docs/demos/ |