# Trail

> The static site builder behind Peios Learn.

---

# What is Trail

_Trail / Getting started_

> Trail is the static site builder behind Peios Learn — a single Rust binary that turns a directory tree of markdown into a searchable, themed documentation site, with the structure coming from the filesystem rather than a config file.

**Trail** is a static site generator for documentation. You give it a directory of markdown files arranged into named folders; it gives you a complete website — every page rendered, cross-linked, indexed for search, mirrored as markdown for machines, and bundled into printable single-page views.

This site is built with it. Everything you are reading, including this page, comes out of one `trail build`.

## The one idea: the tree is the site

Trail has no table-of-contents file, no `sidebar.json`, no list of pages to keep in sync with the pages themselves. **The directory tree is the navigation.** A folder named `2--writing.topic` is a topic called "Writing" that sorts second; the `.md` files inside it are its articles, in the order their own numeric prefixes give them.

```text
learn/
├── trail.toml                        # the site
└── trail.product/                    # a product
    ├── trail.toml
    ├── 1--getting-started.topic/     # a topic, sorted first
    │   ├── 100--what-is-trail.md     # an article, sorted first
    │   └── 200--quick-start.md
    └── 2--structuring-a-site.topic/
        └── 100--products.md
```

Add a file, and the page exists, appears in the sidebar, joins the search index, and gets a markdown mirror. Rename a folder, and the section moves. Nothing else has to be told.

The trade is that names carry meaning. `100--quick-start.md` is not a decorative filename: `100` is the sort key and `quick-start` is the URL segment. [Directory and file names](/trail/reference/directory-names.md) is the exhaustive grammar; [Anatomy of a site](/trail/getting-started/anatomy-of-a-site.md) is the guided tour.

## Strict by default

The second principle is that **mistakes are build failures, not silent surprises**. A documentation site that quietly drops a page is worse than one that refuses to build.

- A key that `trail.toml` does not define is an error, not an ignored line.
- A file trail does not recognise — a stray `notes.txt` in a topic, a `README.md` without an order prefix — is an error, not a file left out of the site.
- A `~link` whose target no longer exists fails the build. Every broken link in the site is reported at once, so one run tells you the whole story.
- An ambiguous `~link` — one matching two pages — always fails, even under `--allow-dangling-links`. Trail will not guess which page you meant.
- A frontmatter `updated:` that is not an ISO date, a colour that is not `#rrggbb`, a `.link` alias pointing at another alias: all errors.

The escape hatches are deliberate and few. `--allow-dangling-links` downgrades missing targets to warnings while you are mid-refactor; that is the whole list.

## One binary, no runtime

Trail is a single Rust binary with everything compiled in: the templates, the stylesheet, the fonts and their licences, the search engine, the syntax highlighter, the diagram renderer. There is no theme directory to install beside it, no Node toolchain, no `npm install` step, and nothing to fetch at build time.

The output is equally plain. `dist/` is a directory of static files — HTML, CSS, WOFF2, JSON, a WASM search core — that any static host will serve as-is. There is no server component and no build step at request time.

## What one build does

```text
trail.toml + the tree
  → load          parse every trail.toml and every frontmatter block, validate names
  → resolve       .link aliases, ~link targets, inline-reference phrases, images
  → render        one HTML page per article, product, anthology and book cover
  → export        .md mirrors, /print bundles, llms.txt, site.json, sitemap, robots
  → index         the Pagefind search bundle, built from the rendered pages
  → prune         delete anything in dist/ this build did not write
```

Loading is where nearly all the errors live: by the time rendering starts, the site model is known to be well-formed. Pruning is why `dist/` belongs entirely to trail — a page you deleted from the source disappears from the output on the next successful build, along with superseded search index fragments and stale images.

Builds are fast enough not to think about. This site — over twelve hundred pages — builds from scratch in a little over three seconds.

## What a site is made of

Four kinds of thing, from the outside in:

| | What it is |
|---|---|
| **Site** | One `trail.toml` at the root, the front page, and the products. |
| **Product** | A documented thing (`trail.product/`) with its own colour, monogram and landing page. |
| **Grouping** | How a product's pages are organised: [anthologies](/trail/structuring-a-site/anthologies-and-shelves.md), [topics](/trail/structuring-a-site/topics-and-folders.md), [books](/trail/structuring-a-site/books.md) and shelves. |
| **Article** | One `.md` file: YAML frontmatter, then markdown. |

The distinction that matters most is **topic versus book**. A topic is a set of articles you can read in any order — a how-to section, a concept section. A book is a formal, ordered document — a specification, a reference manual — with a cover page, dotted section numbers (`2.1.3`), chapters that nest arbitrarily deep, and lettered appendices. They render differently because they are read differently.

## What trail gives readers

Beyond the pages themselves, every build produces:

- **Search** — a ⌘K modal over a [Pagefind](https://pagefind.app) index, loaded lazily so pages carry no search cost until someone searches. Results carry the query through to the page, which highlights every match.
- **Three-state theming** — light, dark, or follow the system, remembered per reader. Syntax highlighting, diagrams and images all follow it.
- **Print and machine views** — every topic, book, anthology and product is also one long page at `/print`, one markdown file at `/print.md`, and every individual page is mirrored at its own URL with `.md` appended.
- **The reading furniture** — a scroll-spying "On this page" list, copy buttons on code blocks, heading permalinks, adjustable text size, previous/next links, a mobile drawer, and a back-to-top button.

[The reading experience](/trail/building/the-reading-experience.md) covers all of it; [The output surface](/trail/building/the-output-surface.md) covers exactly what lands in `dist/`.

## What trail is not

- **Not a general-purpose SSG.** There is no plugin system, no shortcode API, no way to add a page type. The templates are compiled in and are not user-replaceable; [theming](/trail/building/theming.md) happens through CSS custom properties and an optional stylesheet.
- **Not versioned.** One build produces one version of the site. Hosting several versions means several builds at several paths.
- **Not incremental.** Every build renders every page. It is fast enough that this has never mattered.
- **Not a subpath host.** Sites are served from the root of a domain; internal links are root-relative.

## Where to start

If you want a working site in five minutes, go to the [Quick start](/trail/getting-started/quick-start.md).

If you want to understand the layout before writing anything, read [Anatomy of a site](/trail/getting-started/anatomy-of-a-site.md), then the [Structuring a site](/trail/structuring-a-site/products.md) section in order.

If you are writing pages in a site that already exists, [Writing](/trail/writing/articles-and-frontmatter.md) is the section you want — frontmatter, links, images, code blocks and the rest.

---

# Quick start

_Trail / Getting started_

> Build a documentation site from an empty directory — a root trail.toml, one product, one topic, one article — then serve it with live reload and watch it grow.

This page takes you from an empty directory to a served, searchable documentation site in about five minutes. You will write four files, run `trail build`, look at what it produced, then switch to `trail serve` and add a second page while it is running.

## Prerequisites

- **A `trail` binary.** Trail is a single Rust binary; build it from its source directory with `cargo build --release` and the result is `target/release/trail`. Put it on your `PATH`, or call it by path — the examples below assume `trail` is on your `PATH`.
- **Nothing else.** The templates, stylesheet, fonts, syntax highlighter, diagram renderer and search engine are all compiled into the binary.

## Create the site

A trail site is a directory with a `trail.toml` in it. Make one:

```console
$ mkdir acme-docs && cd acme-docs
```

```toml
# acme-docs/trail.toml
sitename = "Acme Docs"
title = "Documentation for Acme"
description = "Guides and reference for the Acme toolchain."
footer = "Acme — docs."
```

Those four keys are the only required ones. `sitename` is the wordmark in the header (its last word is accented); `title` and `description` are the front page's headline and standfirst, and feed the site's `<meta>` tags. Everything else — the accent colour, the base URL, header links, a favicon — is optional and covered in [Configuring the site](/trail/building/configuring-the-site.md).

> [!NOTE]
> The site root may contain **only** `trail.toml`, `*.product` directories, the build output directory, and any files named by the config (a favicon, a custom stylesheet). Anything else is a build error. This is deliberate: a stray file in the root is far more likely to be a mistake than a plan.

## Add a product

Every page belongs to a **product** — a documented thing with its own landing page, colour and monogram. Products are directories named `<slug>.product`:

```console
$ mkdir -p docs.product
```

```toml
# acme-docs/docs.product/trail.toml
title = "Acme CLI"
monogram = "ac"
color = "#3b82f6"
description = "The command-line interface to Acme."
```

All four keys are required. The directory name gives the URL (`/docs`) and the slug you link by; `title` is what readers see. The `monogram` is the two-or-so characters shown on the product's tile on the front page, and `color` tints the product's cards, sidebar and headings.

## Add a topic and an article

A **topic** is a set of articles read in any order. Topics are directories named `<order>--<slug>.topic`:

```console
$ mkdir -p docs.product/1--guides.topic
```

```markdown
<!-- acme-docs/docs.product/1--guides.topic/100--hello.md -->
---
title: Hello
type: how-to
description: The first page.
---

Hello from **Acme**.
```

Three things are going on in that filename and that frontmatter block:

- **`1--guides.topic`** — the `1` sorts this topic among its siblings and never appears in a URL; `guides` is the URL segment; `.topic` is the kind. With no `trail.toml` of its own the topic's title is derived from the slug ("Guides").
- **`100--hello.md`** — same grammar for articles. Numbering in hundreds is a convention, not a rule: it leaves room to insert `150--` later without renumbering.
- **`title:` and `type:`** are the two required frontmatter keys. `description:` is optional but worth writing — it becomes the page's meta description and its social-preview text.

## Build it

```console
$ trail build
```

```text
built 6 pages (1 products) → ./dist
```

Six pages from one article, because trail writes more than the pages you authored:

```console
$ find dist -type f -not -path 'dist/assets/*' -not -path 'dist/pagefind/*'
```

```text
dist/index.html                     the front page
dist/404.html                       the not-found page
dist/docs/index.html                the product landing page
dist/docs/guides/hello/index.html   your article
dist/docs/print/index.html          all of Acme CLI on one page
dist/docs/guides/print/index.html   all of Guides on one page
dist/docs.md                        the product, as markdown
dist/docs/guides/hello.md           your article, as markdown
dist/docs/print.md                  all of Acme CLI, as one markdown file
dist/docs/guides/print.md           all of Guides, as one markdown file
dist/llms.txt                       the site's map for language models
dist/site.json                      the site's structure, machine-readable
dist/robots.txt
```

Plus `dist/assets/` (the stylesheet and fonts) and `dist/pagefind/` (the search index and its WASM core). Note that there is no page at `/docs/guides` — a topic has no page of its own. It shows up as a card on the product page, and links to it land on its first article.

`dist/` belongs to trail: at the end of every successful build it deletes anything in there that the build did not write.

## Serve it

```console
$ trail serve
```

```text
serving ./dist at http://127.0.0.1:8724 (watching . for changes)
```

`trail serve` builds the site, serves it, and watches the source tree. Save a file and the browser reloads itself; break something and the error prints in the terminal while the last good output keeps being served.

Open the site and try it: press <kbd>⌘K</kbd> (or <kbd>Ctrl K</kbd>) to search, and use the theme button in the header to switch between light, dark and system.

## Add a second page

Leave the server running and add another article beside the first:

```markdown
<!-- acme-docs/docs.product/1--guides.topic/200--installing.md -->
---
title: Installing
type: how-to
description: How to install Acme.
related:
  - docs/guides/hello
---

See [Hello](~docs/guides/hello) first.
```

Two new things:

- **`~docs/guides/hello`** is a page reference, not a URL. The first segment names a product; the rest matches the end of a page's path. Move or rename the target and the link still resolves as long as its slug is unchanged; delete it and the build fails rather than shipping a dead link. See [Links and references](/trail/writing/links-and-references.md).
- **`related:`** takes the same references (without the `~`) and renders a "Related content" list at the foot of the page.

Save, and the page appears in the sidebar next to Hello.

## Where to go next

- [Anatomy of a site](/trail/getting-started/anatomy-of-a-site.md) — the whole tree, level by level, with a real example.
- [Products](/trail/structuring-a-site/products.md) — the structuring section in order: products, anthologies, topics, books.
- [Articles and frontmatter](/trail/writing/articles-and-frontmatter.md) — every frontmatter key and what it does.
- [Configuring the site](/trail/building/configuring-the-site.md) — the base URL, accent colour, navigation links, edit links and the rest of `trail.toml`.

---

# Anatomy of a site

_Trail / Getting started_

> The whole tree, level by level — how directory and file names become URLs, ordering and titles, which trail.toml lives where, and what may legally sit at each level.

A trail site is a directory tree in which every name means something. This page walks that tree from the root down, and explains the naming grammar all of it shares. [Directory and file names](/trail/reference/directory-names.md) is the same material as an exhaustive table; [Structuring a site](/trail/structuring-a-site/products.md) covers each kind of container in depth.

## The whole shape

```text
learn/                                  the site root
├── trail.toml                           the site config — required
├── dist/                                the build output — trail's to manage
│
├── peios.product/                       a product  →  /peios
│   ├── trail.toml                       required: title, monogram, color, description
│   ├── 1--security-fundamentals.antho/  an anthology  →  /peios/security-fundamentals
│   │   ├── trail.toml                   required: title, description
│   │   ├── 1--identity.topic/           a topic  →  /peios/security-fundamentals/identity
│   │   │   ├── trail.toml               optional: title, inline_ref
│   │   │   ├── 100--sids.md             an article  →  …/identity/sids
│   │   │   ├── 200--principals.md
│   │   │   ├── 300--tokens/             a subfolder  →  …/identity/tokens/…
│   │   │   │   ├── trail.toml           optional
│   │   │   │   └── 100--issuing.md      →  …/identity/tokens/issuing
│   │   │   └── diagram.svg              an image, published beside its article
│   │   └── 2--pgss.book/                a book  →  /peios/security-fundamentals/pgss
│   │       ├── trail.toml               required: title, description; optional: short
│   │       ├── 1--scope.md              §1  →  …/pgss/scope
│   │       ├── 2--model/                a chapter, §2
│   │       │   ├── trail.toml           optional
│   │       │   └── 1--objects.md        §2.1
│   │       └── a1--glossary.md          Appendix A
│   └── 2--tooling.shelf/                a shelf — a heading, not a URL segment
│       └── 1--pekit.topic/              →  /peios/pekit   (the shelf adds nothing)
│
└── pekit.product/                       another product  →  /pekit
```

Everything below the root is optional. A site with one product, one topic and one article is a complete site; this site's `trail.product` is exactly that shape, four levels shallower than `peios.product`.

## The naming grammar

Three name shapes appear throughout the tree.

| Shape | Example | Where |
|---|---|---|
| `<slug>.product` | `pekit.product` | Only at the site root. |
| `<order>--<slug>.<kind>` | `2--writing.topic` | Anthologies, topics, books, shelves. |
| `<order>--<slug>` | `300--tokens`, `2--model` | Topic subfolders and book chapters — no suffix. |
| `<order>--<slug>.md` | `100--sids.md` | Articles. |
| `<order>--<slug>.link` | `3--faq.link` | Aliases; see [Link aliases](/trail/structuring-a-site/link-aliases.md). |

The parts:

- **`<order>`** is a non-negative integer used only for sorting among siblings. It never appears in a URL or a title. Gaps are fine and normal — numbering articles `100`, `200`, `300` leaves room to insert. Two siblings sharing an order is an error, because the resulting page order would depend on nothing you can see.
- **`<slug>`** is lowercase kebab-case: ASCII lowercase letters, digits and hyphens, not starting or ending with a hyphen. It becomes the URL segment, and it is what you write in a `~link`.
- **`<kind>`** is `antho`, `topic`, `book` or `shelf`.

Inside books, an order may also be written `a<N>--` (`a1--glossary.md`). Those are **appendices**: lettered `A`, `B`, `C` instead of numbered, and sorted after every numbered sibling at their level. See [Books](/trail/structuring-a-site/books.md).

## URLs

A page's URL is its product slug followed by the slug of every container between the product and the page — with two exceptions.

```text
peios.product/1--security-fundamentals.antho/1--identity.topic/100--sids.md
  →  /peios/security-fundamentals/identity/sids
```

The exceptions:

- **A shelf contributes no segment.** It is a heading on its parent's page, nothing more. A topic inside `2--tooling.shelf/` lives at `/peios/<topic>`, exactly as if the shelf were not there.
- **Order prefixes contribute nothing.** `1--identity.topic` gives the segment `identity`.

Which containers have a page of their own is worth knowing, because it decides what you can link to:

| | Has a page | Links to it land on |
|---|---|---|
| Site | `/` | — |
| Product | `/pekit` | its own landing page |
| Anthology | `/peios/security-fundamentals` | its own landing page |
| Book | `/peios/…/pgss` (the cover) | the cover |
| Article | its own URL | itself |
| Topic | no page | *(topics are not link targets)* |
| Topic subfolder | no page | *(not a link target, except from a `.link`)* |
| Book chapter | no page | *(not a link target)* |
| Shelf | no page | *(not a link target)* |

Topics, subfolders and chapters are real structure — they appear in sidebars, cards and contents lists, and links from those surfaces open their first article — but they have no page you can address, so a `~link` cannot point at one. Point at the article you actually mean.

## Titles

Titles come from three places, in order of precedence:

1. **An article's `title:` frontmatter**, which is required.
2. **A container's `trail.toml`**, where the `title` key is required for products, anthologies and books, and optional for topics, subfolders, chapters and shelves.
3. **The slug**, hyphens to spaces and each word capitalised: `the-two-gates` becomes "The Two Gates".

The derived form is good enough surprisingly often; write a `trail.toml` when it is not — acronyms and casing it cannot guess ("Peiso", "The Console"), or when the container needs `inline_ref` phrases.

## What may sit where

Trail rejects anything it does not recognise, so it is worth knowing what each level tolerates.

| Directory | May contain |
|---|---|
| The site root | `trail.toml`, `*.product/`, the output directory, files named by the config |
| A product | `trail.toml`, `.antho/`, `.topic/`, `.book/`, `.shelf/` |
| An anthology | `trail.toml`, `.antho/`, `.topic/`, `.book/`, `.shelf/` |
| A shelf | `trail.toml`, the same as its parent minus shelves — a shelf never holds a shelf |
| A topic | `trail.toml`, `.md` articles, `<order>--<slug>/` subfolders, `.link` files, images |
| A topic subfolder | `trail.toml`, `.md` articles, `.link` files, images |
| A book | `trail.toml`, `.md` articles, `<order>--<slug>/` chapters, `.link` files, images |
| A book chapter | `trail.toml`, `.md` articles, `<order>--<slug>/` chapters, `.link` files, images |

Only the article-holding levels take files: `.md` articles, `.link` aliases and images may sit in a topic, a subfolder, a book or a chapter, and nowhere else. A product, anthology or shelf directory holds `trail.toml` and subdirectories, full stop.

Images sit beside the articles that use them and are published at the URL of the directory they were found in; see [Images](/trail/writing/images.md).

Two slugs are reserved: `print` (every container publishes a `/print` page) may not be an article slug, and `assets` and `pagefind` may not be product slugs.

## Ordering

Siblings sort by their `<order>` prefix, ascending. Within a book, appendix entries (`a<N>--`) sort after every numbered entry at the same level, and they are lettered rather than numbered.

Products are the exception: they have no order prefix. They sort by the root config's `featured` list first, in that list's order, then everything else by title.

## Where to go next

- [Products](/trail/structuring-a-site/products.md) — the product level in depth, and when to add another one.
- [Anthologies and shelves](/trail/structuring-a-site/anthologies-and-shelves.md) — grouping a large product.
- [Topics and folders](/trail/structuring-a-site/topics-and-folders.md) — the everyday container.
- [Books](/trail/structuring-a-site/books.md) — numbered, ordered documents with chapters and appendices.

---

# Products

_Trail / Structuring a site_

> A product is the top-level division of a trail site — a documented thing with its own landing page, colour, monogram and URL prefix. What goes in one, what its trail.toml needs, and how products are ordered.

A **product** is the outermost division of a trail site: one documented thing, with its own landing page, its own colour, and its own URL prefix. Products are the only children the site root will accept, so every page in the site belongs to exactly one.

On this site, `pekit`, `provium` and `trail` are products. So is `peios` itself — products are not necessarily software packages, they are simply the largest chunk of documentation a reader would think of as one subject.

## The directory

A product is a directory named `<slug>.product` at the site root. It has no order prefix.

```text
learn/
├── trail.toml
├── pekit.product/          →  /pekit
└── trail.product/          →  /trail
```

The slug becomes the URL prefix (`/pekit`) and the first segment of every [`~link`](/trail/writing/links-and-references.md) into that product. It must be lowercase kebab-case, and it may not be `assets` or `pagefind` — those collide with the output directories trail writes.

## The config

Every product needs a `trail.toml`, and all four keys are required:

```toml
# pekit.product/trail.toml
title = "pekit"
monogram = "pk"
color = "#22c55e"

description = "The recipe-driven build and packaging tool — declarative recipes in; built, tested, packaged software out."
```

| Key | |
|---|---|
| `title` | The product's display name, used everywhere the product is named. Unlike other containers, this is not derived from the slug — a product always states it. |
| `monogram` | A very short string — usually two characters — shown on the product's tile, on the front-page card and at the top of the product page. Initials or a shortened name. |
| `color` | A `#rrggbb` hex colour. Any other form is a build error. |
| `description` | One sentence, shown on the front-page card, under the product's name on its own page, and in `llms.txt`. It is also the product page's meta description. |

One optional key, `inline_ref`, claims phrases that auto-link to the product page wherever they appear in prose; see [Inline references](/trail/writing/inline-references.md).

## What the colour does

`color` is the product's accent, and by default it displaces the site accent on every page belonging to that product: card borders and hover states, the tile, sidebar highlights, heading numbers, RFC-2119 keywords, focus rings.

Setting `product_theming = false` in the root `trail.toml` turns this off site-wide, and everything tints from the site accent instead. Products still keep their monogram and card, they just stop colouring their pages. See [Theming](/trail/building/theming.md).

## What a product contains

A product directory holds its `trail.toml` and nothing but grouping directories:

- [**Anthologies**](/trail/structuring-a-site/anthologies-and-shelves.md) (`.antho`) — a titled landing page with its own children, for products big enough to need a second level.
- [**Topics**](/trail/structuring-a-site/topics-and-folders.md) (`.topic`) — a set of articles read in any order. The everyday container.
- [**Books**](/trail/structuring-a-site/books.md) (`.book`) — a formal, ordered document with a cover page, numbered sections and appendices.
- [**Shelves**](/trail/structuring-a-site/anthologies-and-shelves.md) (`.shelf`) — a titled heading grouping some of the above on the product page. No URL segment of its own.

Articles, images and `.link` files may **not** sit directly in a product directory; they live inside topics and books. A product with nothing in it is legal and simply renders an empty landing page.

Small products go straight to topics:

```text
pekit.product/
├── trail.toml
├── 1--getting-started.topic/
├── 2--recipes.topic/
├── 3--running.topic/
└── 4--reference.topic/
```

Large ones add a layer:

```text
peios.product/
├── trail.toml
├── 1--security-fundamentals.antho/
├── 2--using-peios.antho/
├── 3--advanced-peios.antho/
├── 4--developing-for-peios.antho/
└── 5--peiso.antho/
```

Both shapes are ordinary. Reach for an anthology when a product's topic list has grown long enough that readers have to hunt through it.

## The product page

Every product gets a landing page at `/<slug>`, built from the config and its children:

- A hero with the monogram tile, the title and the description.
- A "Documentation" section listing every child as a card. Anthologies and books get a card with their description; topics get a card listing their articles — all of them when there are four or fewer, otherwise the first three and a "See all N articles" link — with a count and the topic's total reading time.
- Each shelf becomes a subheading above its own row of cards. Runs of items outside any shelf render as an untitled row, in place.

## Ordering

Products have no order prefix. They sort by the root `trail.toml`'s `featured` list — in that list's order — and then everything not listed, by title.

```toml
# learn/trail.toml
featured = ["peios", "pekit", "provium", "universal-directory", "trail"]
```

That list does more than sort. **The front page shows only featured products.** An unfeatured product is still built, still searchable, still linkable, and still in the header's products menu — it just does not get a card on the front page. Naming a product that does not exist in `featured` is a build error.

## Roll-ups

A product totals up what is beneath it. Its reading time is the sum of every article's, and its `updated` date is the most recent `updated:` in the whole product. Anthologies, topics, books and chapters do the same at their own level. See [Articles and frontmatter](/trail/writing/articles-and-frontmatter.md).

## When to add a product

A product is a heavy division: its own colour, its own landing page, its own URL prefix, and a separate card on the front page. Add one when readers would go looking for the subject by name and would not expect to find it filed under something else.

Within a single product, the lighter divisions — anthologies for a second level of landing pages, shelves for a heading, topics for a set of articles — are almost always the better answer.

---

# Anthologies and shelves

_Trail / Structuring a site_

> Two ways to group a product's contents — an anthology is a second landing page with its own URL, a shelf is a heading on the page above it. When to reach for each, and how deep they nest.

Once a product has more than a handful of topics, its landing page becomes a wall of cards. Trail offers two ways to break that up, and the difference between them is exactly one thing: **whether the grouping gets a page of its own**.

| | URL segment | Page | Sidebar | Nests |
|---|---|---|---|---|
| **Anthology** (`.antho`) | yes | its own landing page | — | in products and anthologies |
| **Shelf** (`.shelf`) | no | no — a heading on its parent's page | — | in products and anthologies, never in a shelf |

Reach for a shelf when the topics belong together but readers should still see them all at once. Reach for an anthology when the group is big enough to deserve its own front door.

## Anthologies

An anthology is a directory named `<order>--<slug>.antho`. It has a landing page at its own URL, listing everything inside it exactly as a product page lists its own children.

```text
peios.product/
├── trail.toml
├── 1--security-fundamentals.antho/     →  /peios/security-fundamentals
│   ├── trail.toml
│   ├── 1--identity.topic/              →  /peios/security-fundamentals/identity
│   └── 2--pgss.book/                   →  /peios/security-fundamentals/pgss
└── 2--using-peios.antho/               →  /peios/using-peios
```

Its `trail.toml` requires two keys:

```toml
# 1--security-fundamentals.antho/trail.toml
title = "Security fundamentals"
description = "How Peios decides who may do what: identity, tokens, and the two gates every request passes."
```

`title` is the heading and the breadcrumb; `description` is the standfirst on the page, the text on the card that links to it from the product page, and the entry in `llms.txt`. The optional `inline_ref` key claims phrases pointing at this anthology's page; see [Inline references](/trail/writing/inline-references.md).

An anthology may contain anthologies, topics, books and shelves — the same set a product may contain. Anthologies nesting inside anthologies is legal and unbounded, and each level adds a URL segment:

```text
/peios/developing-for-peios/apis/filesystem/open
      └─ anthology ──────┘ └ antho ┘ └ topic ─┘ └ article
```

In practice two levels is plenty. Deeper than that and readers stop being able to guess where anything lives.

### The anthology page

The page mirrors a product page one level down: a breadcrumb trail back to the product (and through any parent anthologies), the title, the description, and a "Topics" section of cards. Nested anthologies and books get a description card; topics get a card listing their articles.

## Shelves

A shelf is a directory named `<order>--<slug>.shelf`. It is presentation only: **a shelf contributes no URL segment, has no page, and never appears in a breadcrumb.** Its whole job is to put a subheading above a row of cards on the page it sits in.

```text
peios.product/
└── 3--tooling.shelf/            (no URL of its own)
    ├── trail.toml
    ├── 1--pekit.topic/          →  /peios/pekit
    └── 2--provium.topic/        →  /peios/provium
```

Note the URLs: the topics live directly under the product, exactly as they would if the shelf directory were not there.

Its `trail.toml` is optional and carries at most a title:

```toml
# 3--tooling.shelf/trail.toml
title = "Tooling"
```

Without one, the title is derived from the slug — `3--tooling.shelf` becomes "Tooling" anyway, so most shelves need no config at all. A shelf may **not** declare `inline_ref`: it has no page to link to, so the phrase would have nowhere to land. Declare it on the shelf's items instead.

### How shelves render

On the parent's page, children render in order. A run of items outside any shelf renders as an untitled row of cards; each shelf renders as its title followed by its own row:

```text
Documentation

  [ Getting started ]  [ Concepts ]          ← loose items, no heading

  Tooling                                    ← shelf title
  [ pekit ]  [ provium ]

  Specifications                             ← another shelf
  [ PGSS ]  [ PCDS ]
```

Because a shelf's items share their parent's URL space, their slugs must be unique across the whole parent — two shelves cannot both contain a `getting-started` topic, since both would want `/peios/getting-started`. Trail rejects that at build time.

### The rules

- A shelf may sit in a product or an anthology.
- A shelf may contain anthologies, topics and books.
- **A shelf may never contain another shelf.** Nested headings on a card grid are not a structure worth having; if you need the second level, use an anthology.
- A shelf directory holds `trail.toml` and subdirectories only — no articles, no images.

## Choosing

Start flat. A product with four or five topics needs neither of these.

When the list gets long, ask whether the groups are worth a page. "Tooling" as a heading over two topic cards is a shelf. "Security fundamentals" — nine topics, two specifications, and a description worth reading — is an anthology.

The cost of an anthology is a URL segment on every page beneath it and one more click for the reader. The cost of a shelf is nothing at all, which is why it is usually the right first move.

---

# Topics and folders

_Trail / Structuring a site_

> The topic is trail's everyday container — a set of articles read in any order, with an optional single level of subfolders. Where the sidebar comes from, why a topic has no page, and where links to one land.

A **topic** is a set of articles on one subject, read in any order. It is the container most pages live in, and the one to reach for unless you specifically need a [book](/trail/structuring-a-site/books.md).

"Getting started", "Concepts", "Configuration", "Reference" — those are topics. The reader arrives at one of the articles from search or a link, reads it, and moves on; the sidebar is there for when they want to see what else is nearby.

## The directory

A topic is a directory named `<order>--<slug>.topic`, sitting in a product, an anthology or a shelf. Its articles are the `.md` files inside it:

```text
4--reference.topic/                 (the topic; no page of its own)
├── trail.toml                      optional
├── 100--recipe-format.md           →  /pekit/reference/recipe-format
├── 200--supporting-files.md        →  /pekit/reference/supporting-files
└── 300--cli.md                     →  /pekit/reference/cli
```

The `4` orders this topic among its siblings; `reference` is the URL segment. Articles carry the same `<order>--<slug>` grammar, and their order prefixes decide reading order — the sidebar, the previous/next links, and the order they appear in the topic's single-page view.

Numbering in hundreds is the convention across this site. It is not a rule — `1--`, `2--`, `3--` works identically — but it means you can insert `150--` between two articles without renaming anything.

## The config

A topic's `trail.toml` is optional, and holds at most two keys:

```toml
# 4--reference.topic/trail.toml
title = "Reference"
```

Without it, the title is derived from the slug: `4--reference.topic` becomes "Reference", `2--writing-tests.topic` becomes "Writing tests". Write the file when that derivation is wrong — an acronym, a proper noun, unusual casing — or when the topic needs `inline_ref` phrases, which claim text that should auto-link to the topic's first article. See [Inline references](/trail/writing/inline-references.md).

## A topic has no page

This is the one surprising thing about topics. There is no page at `/pekit/reference` — the topic is not a destination, it is a grouping.

That has three consequences:

- **A topic appears as a card** on its product's or anthology's page, listing its articles (all of them when there are four or fewer, otherwise the first three plus a "See all" link) with a count and its total reading time.
- **Links land on the first article.** Every surface that shows a topic — its card heading, its "See all" link, the sidebar heading on an article page — points at the topic's first article in reading order.
- **A topic is not a `~link` target.** `~pekit/reference` resolves nothing, because `reference` is not the last segment of any page's path. Link to the article you mean: `~pekit/reference/cli`.

What *does* exist at the topic's path is its single-page view: `/pekit/reference/print` renders every article in the topic as one long page, and `/pekit/reference/print.md` is the same thing as markdown. See [The output surface](/trail/building/the-output-surface.md).

## Subfolders

A topic that has grown past a comfortable sidebar can group runs of articles into **subfolders** — plain `<order>--<slug>` directories, with no type suffix:

```text
2--concepts.topic/
├── 100--objects-and-identity.md          →  /ud/concepts/objects-and-identity
├── 200--the-schema-model.md              →  /ud/concepts/the-schema-model
└── 300--replication/                     a subfolder
    ├── trail.toml                        optional
    ├── 100--topology.md                  →  /ud/concepts/replication/topology
    └── 200--conflicts.md                 →  /ud/concepts/replication/conflicts
```

A subfolder:

- **adds a URL segment**, unlike a shelf;
- **has no page of its own**, like a topic — links from the sidebar open its first article;
- **renders as a collapsible group in the sidebar**, expanded automatically when the article being read is inside it;
- **may hold `trail.toml`, articles, `.link` files and images — but not another subfolder.** Subfolders are exactly one level deep. A topic needing more nesting than that is either two topics or a book.

Its optional `trail.toml` takes the same `title` and `inline_ref` keys a topic's does, and its title derives from its slug the same way.

An empty subfolder is tolerated and simply not shown, so you can create the directory before its articles exist.

## The sidebar

On an article page, the sidebar shows the article's own topic: the topic title (linking to its first article) above the topic's contents in reading order, with subfolders as collapsible groups and the current article marked. It shows one topic — not the whole product — because the topic is the unit a reader is working within.

Below it, "On this page" lists the article's own `h2` and `h3` headings, and highlights the one currently in view.

## Reading order and the pager

The prev/next links at the foot of an article step through the whole topic in reading order, subfolder contents flattened in place. The last article of one topic does not lead to the first of the next; the pager is a topic-local device.

## Topic or book?

| Use a topic when | Use a book when |
|---|---|
| Articles can be read in any order | The document is read in order, or cited by section |
| No page needs to say "§2.4" | Sections need stable numbers |
| Grouping is for navigation | The whole thing is one document with a cover |
| One level of subfolders is enough | Nesting goes deeper than two levels |

Almost all documentation is topics. Books are for specifications, formal references and manuals — see [Books](/trail/structuring-a-site/books.md).

---

# Books

_Trail / Structuring a site_

> A book is a formal, ordered document — a specification or manual — with a cover page, dotted section numbers derived from directory names, chapters nesting to any depth, and lettered appendices.

A **book** is a document read in order and cited by section: a specification, a formal reference, a manual. Where a [topic](/trail/structuring-a-site/topics-and-folders.md) is a set of related pages, a book is one document that happens to be split across pages.

Books get three things topics do not: a cover page, stable section numbers derived from the directory structure, and appendices.

## The directory

A book is a directory named `<order>--<slug>.book`, sitting in a product, an anthology or a shelf:

```text
1--pgss.book/                    →  /demo/pgss   (the cover)
├── trail.toml
├── 1--introduction.md           §1   →  /demo/pgss/introduction
├── 2--logon/                    §2   a chapter
│   ├── trail.toml
│   ├── 1--overview.md           §2.1 →  /demo/pgss/logon/overview
│   └── 2--flow.md               §2.2
└── a1--references.md            Appendix A
```

Its `trail.toml` requires a title and a description:

```toml
# 1--pgss.book/trail.toml
title = "Peios Generic System Standards"
short = "PGSS"
description = "Cross-platform system standards from the Peios Project"
```

| Key | |
|---|---|
| `title` | Required. The full name, shown on the cover and in cards. |
| `description` | Required. The standfirst on the cover, the card text, and the `llms.txt` entry. |
| `short` | Optional. An abbreviation — usually an acronym — used where the full title will not fit: the sidebar heading, the pill on the book's card, and the line above the title on the cover. |
| `inline_ref` | Optional. Phrases that auto-link to this book, and which may carry a `§` section suffix in prose. See [Inline references](/trail/writing/inline-references.md). |

## Section numbers come from the directory names

This is the part worth reading twice. **A book's section numbers are its order prefixes**, joined with dots along the path from the book root:

```text
2--logon/            →  §2
  1--overview.md     →  §2.1
  3--errors/         →  §2.3
    1--codes.md      →  §2.3.1
```

Two consequences follow:

- **Number books from 1, not 100.** The hundreds convention used for [topic articles](/trail/structuring-a-site/topics-and-folders.md) would produce §100 and §200. In a book the prefix is the published section number, so `1--`, `2--`, `3--` is what you want.
- **Gaps show through, on purpose.** If `2--` is deleted the book goes §1, §3 — because in a specification, section numbers are addresses. Renumbering to close a gap silently invalidates every citation anyone has ever written; leaving the gap is the honest outcome. Renumber deliberately, or not at all.

Inside an article, `h2` and `h3` headings continue the numbering as real text: in §2.1, the first `h2` is §2.1.1 and its first `h3` is §2.1.1.1. Headings deeper than `h3` are not numbered. The numbers are rendered into the heading itself rather than drawn with CSS counters, so they can be searched, selected and copied.

## Chapters

A chapter is a plain `<order>--<slug>` directory — no type suffix, exactly like a [topic subfolder](/trail/structuring-a-site/topics-and-folders.md) — except that chapters may nest as deeply as the document needs.

A chapter:

- **contributes a URL segment and a section number**;
- **has no page of its own** — links to it open its first article;
- **must contain at least one article, somewhere beneath it.** An empty chapter is a build error, because it would appear in the contents as a number leading nowhere;
- takes an optional `trail.toml` with `title` and `inline_ref`, with the title otherwise derived from the slug.

## Appendices

An entry whose order is written `a<N>--` is an **appendix**: lettered instead of numbered, and sorted after every numbered sibling at its level.

```text
1--pgss.book/
├── 1--introduction.md      §1
├── 2--logon/               §2
├── a1--references.md       Appendix A
└── a2--glossary/           Appendix B
    └── 1--terms.md         §B.1
```

- Letters run `A`, `B`, … `Z`, `AA`, `AB`, and so on.
- The numbering is by `a<N>` order, not position: `a1--` is A, `a2--` is B. Appendix numbering starts at `a1`; `a0--` is an error.
- Appendices work at **any** level, not just the book root. An appendix chapter inside a numbered chapter gives §2.A, and its articles §2.A.1.
- Where the entry itself is displayed — the sidebar, the contents — it is labelled "Appendix A" in full. In section references it is just the letter.

## The cover page

A book's URL is its cover: the product and anthology breadcrumbs, the `short` name if it has one, the title, the description, and the book's total reading time and last-updated date. The contents are the sidebar tree beside it — the whole book, chapters as collapsible groups, section numbers on every entry.

The tree remembers which chapters you opened, per book, as you move between pages.

## Frontmatter is different inside a book

Book articles use a reduced frontmatter set. `title:` is still required; `description:`, `related:`, `inline_ref:`, `updated:` and `reading_minutes:` all still work.

**`type:` is an error inside a book.** The concept/how-to/reference taxonomy describes how to approach a page, and inside a formal document that question is already answered — you read it in order. Trail rejects the key rather than letting it sit there meaning nothing.

## Citing sections

Books are the reason [inline references](/trail/writing/inline-references.md) exist. A book that claims a phrase can be cited by section from anywhere in the site:

```markdown
Every token MUST carry a lifetime, per PGSS §2.1.
```

"PGSS" links to the book; "PGSS §2.1" links to the exact section, resolving through the book's index of section numbers — including numbers that belong to a heading inside an article, not just to the article itself. Within a book's own pages, a bare `§2.1` resolves the same way against the surrounding book.

Because a section number is an address, this is where the "gaps show through" rule earns its keep.

## When a book is the wrong answer

A book is a commitment: numbered sections that readers may cite, an order that means something, and a cover page. Most documentation is not that. If your sections would never be cited by number, and the pages can be read in any order, you want a topic.

---

# Link aliases

_Trail / Structuring a site_

> A .link file places an existing page in a second location — same content, second URL, canonical pointing home. How to write one, what it may target, and why the alias stays out of the search index.

Some pages belong in two places. A "Signing and provenance" article that lives in pekit's *Running* topic is also the page a reader of the security anthology goes looking for; a specification's terminology section is also an appendix of the manual next to it.

A **`.link` file** places an existing page at a second location. The content is not copied — it is the same article, rendered again at a second URL, with its canonical URL still pointing at the original.

## Writing one

A link is a file named `<order>--<slug>.link`, holding TOML:

```toml
# 3--signing.link
target = "~pekit/running/signing-and-provenance"
title = "Signing and provenance"
```

| Key | |
|---|---|
| `target` | Required. A [`~` page reference](/trail/writing/links-and-references.md), in exactly the grammar body links use. It must start with `~`. |
| `title` | Optional. The title the aliased page carries *here*. Without it, the title is derived from the link's own slug. |

The `<order>--<slug>` prefix works exactly as it does for an article: the order places the alias among its siblings, and the slug becomes its URL segment. The alias does not inherit the target's slug, its order, or its title — it is a new entry in a new place that happens to render the target's body.

## Where a link may live

`.link` files sit wherever articles sit: in a topic, in a topic subfolder, in a book, or in a book chapter. They may not sit in a product, an anthology or a shelf.

What each may target:

| In a | May target |
|---|---|
| Topic | an article, **or a whole topic subfolder** |
| Topic subfolder | an article |
| Book or chapter | an article |

Aliasing a subfolder from a topic clones the entire folder into that topic's URL space — every article in it becomes an alias of its own original, under the new folder's slug. It is the one case where a single link file produces more than one page.

A link may never target another link. That would make the canonical chain ambiguous, so trail rejects it.

## What the alias page is

The aliased page renders exactly like the original: same body, same images, same headings. What differs:

- **Its URL and title** are the link's, not the target's.
- **Its canonical URL is the original's.** Search engines are told which of the two URLs is the real one, so the duplicate does not compete with it.
- **It is not in the search index.** Searching finds the original; the alias exists for people navigating the tree.
- **It claims no `inline_ref` phrases.** The original keeps them — a phrase must have exactly one destination.
- **"Edit this page" points at the original's source file**, because that is the file to edit.
- Inside a book, an alias gets a section number from its own order prefix like any other entry, may be an appendix (`a<N>--`), and carries no `type:` or `description:` — book entries never do.

## Example

Pekit's signing article lives in its *Running* topic. The security anthology wants it too:

```text
pekit.product/3--running.topic/
└── 600--signing-and-provenance.md        →  /pekit/running/signing-and-provenance

peios.product/1--security-fundamentals.antho/4--supply-chain.topic/
├── 100--overview.md
└── 200--pekit-signing.link               →  /peios/security-fundamentals/supply-chain/pekit-signing
```

```toml
# 200--pekit-signing.link
target = "~pekit/running/signing-and-provenance"
title = "Signing in pekit"
```

Both URLs work. Both show the same prose. Search returns the pekit one. Editing either takes you to the same source file.

## When not to use one

An alias is right when one piece of writing genuinely belongs in two navigational places. It is the wrong tool for:

- **A cross-reference.** If you just want to point at another page, write a [`~link`](/trail/writing/links-and-references.md) in the prose, or add it to the article's `related:` list.
- **A page that should differ between the two locations.** An alias is the same article; there is no way to vary it. Write two articles.
- **A rename.** Aliases are not redirects — the old URL still has to exist as a `.link` file forever. If a page has moved for good, move it and fix the links; trail will tell you which ones broke.

---

# Articles and frontmatter

_Trail / Writing_

> Every frontmatter key an article may carry — title, type, description, related, inline_ref, updated and reading_minutes — what each one actually does, and how reading times and dates roll up the tree.

An article is a single `.md` file named `<order>--<slug>.md`: a YAML frontmatter block, then markdown.

```markdown
---
title: Quick start
type: how-to
description: Build and package a tiny "hello" program with pekit, from an empty directory.
related:
  - pekit/getting-started/what-is-pekit
  - pekit/recipes/anatomy
---

This page takes you from an empty directory to a working `.peipkg`…
```

The frontmatter is required and must be the first thing in the file: a line of exactly `---`, the YAML, and a closing `---`. A file that does not start with `---`, or whose block is never closed, is a build error. So is any key not listed below — a typo'd `descripton:` fails the build rather than silently doing nothing.

## The keys

| Key | Required | |
|---|---|---|
| `title` | yes | The page's name: its `h1`, its breadcrumb, its sidebar entry, its search-result heading, its `og:title`. |
| `type` | in topics | The learn taxonomy — `concept`, `how-to`, `reference` on this site. **An error inside a [book](/trail/structuring-a-site/books.md).** |
| `description` | no | One sentence. Becomes the page's meta description and social-preview text. |
| `related` | no | Page references shown as a "Related Content" list at the foot of the page. |
| `inline_ref` | no | Phrases that auto-link to this page wherever prose states them. |
| `updated` | no | An ISO `YYYY-MM-DD` date. |
| `reading_minutes` | no | Overrides the estimated reading time. |

### title

The only key every article must have, in topics and books alike. It is not derived from the slug — unlike a topic or chapter title, an article states its own.

### type

Required on every article in a topic, and rejected on every article in a book.

Trail does not constrain the value or display it on the page; it is carried through to `site.json` for tooling that wants to filter by it. What it is really for is the author: deciding whether a page is a **concept** (explaining how something works), a **how-to** (walking through a task) or a **reference** (exhaustive, looked-up rather than read) is a genuinely useful discipline, and pages that cannot answer the question are usually two pages.

Inside a book the question is already answered — a specification is read in order — so trail rejects the key rather than let it sit there meaning nothing.

### description

Optional, but write one. It becomes:

- the page's `<meta name="description">` and `og:description`, which is what search engines and chat apps show;
- the standfirst line in the page's [markdown mirror](/trail/building/the-output-surface.md);
- the `description` field in `site.json`.

It is not displayed on the page itself, and it is not the in-site search snippet — Pagefind builds those from the body text around the match.

Build with [`--strict`](/trail/reference/cli.md) to make a missing description a build failure. Alias pages are exempt, since the original carries it.

### related

A list of page references, in the same grammar body links use but **without** the leading `~`:

```yaml
related:
  - pekit/running/invocation
  - peios/package-management/overview
```

They render as a "Related Content" list under the article, and they appear in the page's markdown mirror and in `site.json`. Resolution is as strict as a body link: an ambiguous reference always fails the build, and a missing one fails unless `--allow-dangling-links` is set, in which case it warns and drops from the list.

### inline_ref

Phrases that should become links to this article wherever they appear in the site's prose:

```yaml
inline_ref:
  - PCDS GUID
  - PCDS GUIDs
```

Every occurrence in ordinary prose, anywhere on the site, links here — except on this page itself. Phrases are claimed site-wide and exclusively; two pages claiming the same phrase is a build error. See [Inline references](/trail/writing/inline-references.md).

### updated

An ISO date, and validated as one — `2026-05-01`, not `May 2026` and not `01/05/2026`.

```yaml
updated: 2026-05-01
```

It shows in the page's meta line, and it becomes the page's `<lastmod>` in `sitemap.xml`. It is deliberately manual: file modification times are noise (a whitespace fix is not an update, and a checkout rewrites them all), and trail does not read git history. An article with no `updated:` simply does not show one.

### reading_minutes

Trail estimates reading time from the body's word count at 200 words a minute, rounded up, with a floor of one minute. Code blocks count — they are read, just differently.

Set `reading_minutes:` when the estimate is wrong: a dense reference table reads far slower than its word count suggests, and a page that is mostly a long code listing far faster.

```yaml
reading_minutes: 30
```

## Roll-ups

Reading times and dates add up the tree. A subfolder totals its articles; a topic totals its articles and subfolders; a book totals its chapters; an anthology totals its topics and books; a product totals everything in it.

- **Reading time** is the sum, so a book's cover can say how long the whole book takes and a topic card can say how long the topic is.
- **`updated`** is the *most recent* date anywhere beneath, so a container reflects its freshest page.

Roll-ups are computed after `.link` aliases are resolved, so an aliased article counts in the totals of the place it was linked into.

## The body

Everything after the closing `---` is [CommonMark](https://commonmark.org) with GitHub extensions: tables, strikethrough, footnotes, and `> [!NOTE]`-style [admonitions](/trail/writing/admonitions-tabs-and-diagrams.md). On top of that, trail adds:

- [`~page` references](/trail/writing/links-and-references.md) — link by page identity instead of URL.
- [Co-located images](/trail/writing/images.md) — `![alt](diagram.svg)` beside the article, with dimensions filled in and captions turned into figures.
- [Syntax-highlighted code blocks](/trail/writing/code-blocks.md) with copy buttons.
- [Tab groups and mermaid diagrams](/trail/writing/admonitions-tabs-and-diagrams.md).
- [Inline references, `§` section citations and RFC-2119 keyword highlighting](/trail/writing/inline-references.md).

A few things happen to headings automatically: `h2` and `h3` become the "On this page" list, every heading gets a stable id and a permalink anchor, and inside a book they carry their section numbers as real text. Ids come from the heading text, with a numeric suffix if two headings on a page would otherwise collide.

Tables are wrapped in their own horizontally-scrolling container, so a wide table never makes the whole page scroll sideways.

## Naming and ordering

The filename is not decoration:

```text
200--quick-start.md
 │        └── the URL segment, and what you write in a ~link
 └── the sort key among siblings — never shown, never in a URL
```

Two files sharing an order in the same directory is an error. Numbering in hundreds leaves room to insert later; inside a [book](/trail/structuring-a-site/books.md) the prefix is the published section number, so number from 1 there instead.

`print` is a reserved slug — every container publishes a `/print` page — so `100--print.md` is rejected.

---

# Links and references

_Trail / Writing_

> The ~ reference grammar — link to a page by its identity rather than its URL, with the shortest unambiguous suffix. How resolution works, what can be a target, and what the build does when a link breaks.

Trail links pages by **identity**, not by URL. Instead of writing a path that breaks the moment a page moves, you write a `~` reference naming the product and enough of the page's slugs to be unambiguous:

```markdown
See [Commands and targets](~pekit/running/commands-and-targets) first.
```

Reorganise the tree — move that topic into an anthology, wrap it in a shelf, renumber it — and the link still resolves. Delete the target, and the build fails and tells you every page that pointed at it.

## The grammar

```text
~<product-slug>[/<segment>]…[#<fragment>]
```

- The **first segment is always a product slug**. There is no relative form and no site-wide search; every reference says which product it means.
- The **remaining segments match the end of a page's path.** Trail compares them against each page's slugs — the anthology slugs, the topic slug, any subfolder or chapter slugs, and the page's own — and matches if your segments are the tail of that list.
- Exactly one page must match. Zero or several is an error.
- A **fragment** may follow, and is appended to the resolved URL: `~pekit/reference/cli#global-flags`.

The rule of thumb is: **write the shortest thing that is unambiguous**, and add segments from the left only when you have to.

### A worked example

Given this page:

```text
/peios/security-fundamentals/identity/tokens/issuing
        └ anthology ───────┘ └ topic ┘ └folder┘ └page┘
```

all of these resolve to it:

```text
~peios/issuing
~peios/tokens/issuing
~peios/identity/tokens/issuing
~peios/security-fundamentals/identity/tokens/issuing
```

`~peios/issuing` is the one to write — until a second page in `peios` is also called `issuing`, at which point that reference becomes ambiguous, the build fails, and you add a segment.

Note that the deeper segments are for *disambiguation only*. A chapter or subfolder slug can appear in a reference even though it is not a page you could link to on its own.

## What can be a target

| | Targetable | Reference |
|---|---|---|
| A product | yes | `~pekit` |
| An anthology | yes | `~peios/security-fundamentals` |
| A book cover | yes | `~peios/pgss` |
| An article | yes | `~pekit/reference/cli` |
| A topic | **no** | link to an article in it |
| A topic subfolder | **no** | *(except as a `.link` target)* |
| A book chapter | **no** | link to an article in it |
| A shelf | **no** | it has no page at all |

Topics, subfolders and chapters have no page of their own, so there is nothing for a reference to land on. Link to the article you actually mean.

## Ordinary links still work

A `~` at the start of a destination is what makes it a reference. Everything else is left alone:

```markdown
[the CommonMark spec](https://spec.commonmark.org)     external, untouched
[the sitemap](/sitemap.xml)                            root-relative, untouched
[this section](#the-grammar)                           same-page fragment
```

Root-relative links are the escape hatch for the handful of URLs that are not pages — `/print` views, `/llms.txt`, `/site.json`. They are not checked, so they are also the way to ship a broken link. Prefer `~` wherever the target is a page.

`~` is only meaningful in a *link* destination. In an [image](/trail/writing/images.md) destination it is an error, with a message saying so: images are files, addressed relative to the article.

## Where else references are used

The same grammar appears in three other places:

```yaml
# an article's frontmatter — no leading ~
related:
  - pekit/running/invocation
```

```toml
# a .link alias file — with the ~
target = "~pekit/running/signing-and-provenance"

# the root trail.toml's nav — with the ~, or an external URL
[[nav]]
label = "Specs"
url = "~peios/pgss"
```

The `related:` list is the odd one out in dropping the `~`; everything else keeps it.

## When a link breaks

Every link in the site is checked on every build, and all the failures are reported together — one build tells you the whole story rather than stopping at the first bad reference.

```text
Error: 3 broken links:
in article '/pekit/recipes/anatomy': link '~pekit/running/invokation' matches no page in product 'pekit'
in article '/peios/identity/sids': link '~pekti/overview' names unknown product 'pekti'
in article '/pekit/reference/cli': link '~pekit/targets' is ambiguous; candidates:
  ~pekit/running/commands-and-targets/targets
  ~pekit/reference/recipe-format/targets
```

There are three failure kinds, and they are not treated alike:

| | Means | With `--allow-dangling-links` |
|---|---|---|
| **Unknown product** | the first segment is not a product slug | warning |
| **No match** | nothing in that product ends with those segments | warning |
| **Ambiguous** | two or more pages match | **still fatal** |

Ambiguity is never downgradable. A missing target is a link that does nothing; an ambiguous one is a link that goes somewhere *specific and possibly wrong*, and only the author knows which page was meant.

`--allow-dangling-links` is for the middle of a refactor, when you have moved a hundred pages and want to see the site before fixing every reference. It is not a setting to leave on.

## References in print views and mirrors

The [single-page and markdown views](/trail/building/the-output-surface.md) rewrite references so they still work away from the site:

- In a **`/print` bundle**, a reference to a page inside the same bundle becomes an in-document anchor, so the whole document navigates itself. A reference to a page outside it becomes an absolute URL when the site config has a `url`, and stays root-relative otherwise.
- In a **`.md` mirror**, a reference becomes the target's own `.md` URL, so a reader following links through the markdown surface stays in it.

---

# Images

_Trail / Writing_

> Images live beside the articles that use them and are referenced by relative path. Dimensions, captions and figures, what happens to an unreferenced file, and which formats trail recognises.

Images live **beside the articles that use them**. There is no assets directory to maintain and no separate URL scheme to remember: drop the file in the same folder as the `.md`, and reference it by name.

```text
2--logon.topic/
├── 100--overview.md
└── logon-flow.svg
```

```markdown
![The logon sequence](logon-flow.svg)
```

The image publishes at the URL of the directory it was found in, keeping its own file name — here `/peios/logon/logon-flow.svg`. Move the folder and the image moves with it.

## Referencing

Destinations resolve **relative to the article's own `.md` file**, exactly as an editor's markdown preview resolves them. Relative paths may climb out of a subfolder with `..`, so a diagram shared by a folder's worth of articles can sit one level up:

```text
2--concepts.topic/
├── shared-diagram.svg
└── 300--replication/
    ├── 100--topology.md          ![](../shared-diagram.svg)
    └── 200--conflicts.md         ![](../shared-diagram.svg)
```

Three kinds of destination are left exactly as written and never checked: absolute URLs (`https://…`), root-relative paths (`/…`) and `data:` URIs.

A `~` reference in an image destination is a build error. `~` names pages, not files.

## Where images may sit

Anywhere articles may sit: a topic, a topic subfolder, a book, or a book chapter. Not in a product, anthology or shelf directory — those hold only `trail.toml` and subdirectories.

The file name must be a valid slug plus a **lowercase** extension: `logon-flow.svg`, not `Logon Flow.SVG`. Recognised extensions are:

```text
png   jpg   jpeg   gif   svg   webp   avif
```

Anything else in an article directory is an "unexpected file" error — which is the point. A `.pdf` or a `.xlsx` sitting in a content folder is almost always a mistake, and trail would rather say so than quietly ignore it.

## Dimensions

Trail reads each raster image's header at build time and emits real `width` and `height` attributes:

```html
<img src="/peios/logon/screenshot.png" alt="The logon prompt" width="1280" height="720">
```

The browser can then reserve the right space before the file arrives, so text does not jump around as images load. SVGs get no dimensions — they scale — and neither does any file whose header cannot be read.

## Captions and figures

An image with a **title** — the quoted string after the destination — that sits alone in its own paragraph becomes a `<figure>` with a `<figcaption>`:

```markdown
![Three services exchanging tokens](logon-flow.svg "How a logon token reaches the session service")
```

```html
<figure>
  <img src="/peios/logon/logon-flow.svg" alt="Three services exchanging tokens">
  <figcaption>How a logon token reaches the session service</figcaption>
</figure>
```

The two strings do different jobs and should say different things. The **alt text** describes the image for someone who cannot see it. The **caption** is prose everyone reads, and can say what the diagram is *for*.

An image without a title, or one sharing its paragraph with other text, renders as an ordinary inline `<img>`.

## Unreferenced images

Only images some article actually references are copied into the output. One that nothing references prints a warning and is left out:

```text
warning: image '/…/2--logon.topic/old-diagram.png' is not referenced by any article and was not published
```

A warning rather than an error, deliberately: adding the file before the article that will use it is a normal way to work. But a warning that stays warning is a file to delete.

A referenced image that does not exist is the other way round — a broken link, fatal unless `--allow-dangling-links` downgrades it:

```text
in article '/peios/logon/overview': image 'logon-flow.svg' not found (resolved relative to the article's folder)
```

## In the other views

Images work in the [markdown mirrors and `/print` bundles](/trail/building/the-output-surface.md) too: since those serve an article's body away from its own folder, relative destinations are rewritten to the image's published URL.

## Diagrams

For flowcharts, sequence diagrams and the like, consider a [mermaid code block](/trail/writing/admonitions-tabs-and-diagrams.md) instead of an image file. Mermaid diagrams are text — diffable, editable, and they follow the reader's light or dark theme, which a checked-in PNG cannot.

---

# Code blocks

_Trail / Writing_

> Fenced code blocks are highlighted at build time into themed classes, wrapped with a copy button, and labelled with their language. Which languages the bundled highlighter knows, and what happens to the ones it does not.

Fenced code blocks work as they do in any markdown, with an info string naming the language:

````markdown
```rust
fn main() {
    println!("hello");
}
```
````

Trail highlights that at build time and wraps it with a copy button:

```html
<div class="code-block" data-language="rust">
  <button class="copy-code" type="button" data-copy-code>…</button>
  <pre><code><span class="hl-storage hl-type hl-function">fn</span> …</code></pre>
</div>
```

## Highlighting is classes, not colours

Highlighting happens once, when the site is built — there is no highlighter shipped to the browser and no flash of unstyled code.

What is emitted is **class names**, not inline colours: `hl-keyword`, `hl-string`, `hl-comment` and so on, prefixed so they cannot collide with the theme's own classes. Those classes resolve through the same CSS custom properties as everything else on the page, which is why code follows the reader's light or dark choice instead of freezing one palette into the markup. [Theming](/trail/building/theming.md) covers the tokens involved.

## The copy button

Every code block gets one. It sits in the top-right corner, appears on hover (and is always visible on touch devices), copies the block's text to the clipboard, and briefly says so.

Nothing is stripped from what it copies, so a `$` prompt in a shell example is copied along with the command. Write shell examples without prompts if you would rather they paste cleanly.

## Which languages are highlighted

Trail bundles [syntect](https://github.com/trishume/syntect) as its highlighter, with the extended syntax collection from [`two-face`](https://codeberg.org/CosmicHarper/two-face) — the same definitions [bat](https://github.com/sharkdp/bat) ships, a little over two hundred of them. The info string is matched against a syntax's name or one of its file extensions, case-insensitively, so both `rust` and `rs` work.

Everything you are likely to reach for is covered:

```text
bash / sh / fish / zsh   asm         awk          cmd / bat          c / c++ / c#
clojure    cmake         coffeescript   crystal    css / scss / sass / less / stylus
d          dart          diff         dockerfile  dotenv     elixir   elm
erlang     f#            f90          gd          gitconfig / gitignore
glsl       go / gomod    graphql      dot         groovy     haskell  hcl / terraform
html       http          idris        ini         java       js       jq
json / jsonnet           julia        kotlin      latex / bibtex      lean
lisp       llvm          lua          make        markdown   matlab   nginx
nim        nix           objective-c  ocaml       odin       orgmode  pascal
perl       php           protobuf     puppet      purescript python   qml
r          racket        rego         robot       ruby / haml / slim   rust
scala      solidity      sql          svelte      swift      systemverilog / verilog / vhdl
tcl        toml          typescript / tsx        typst       vim      vue
wgsl       xml / svg     yaml         zig
```

…alongside the config and system formats that turn up in documentation: `crontab`, `fstab`, `passwd`, `resolv`, `syslog`, `ssh_config`, `known_hosts`, `.htaccess`, `requirements.txt`, `csv` / `tsv`, `manpage`, `strace`, `email`.

**Still absent:** `powershell`, and the shell-session forms (`console`, `shell-session`). A `console` block renders as plain text — which is usually what you want anyway, since a `$` prompt is not shell syntax.

An info string the set does not recognise is **not** an error and does not fail the build: the block renders as plain escaped code, with the same wrapper, the same copy button, and its language still recorded on it as `data-language`.

The definitions' licences ship with every build at `/assets/LICENSE-Syntaxes.txt`.

## Blocks with no language

A fence with no info string renders as escaped plain text, with the wrapper and copy button intact. Use one for output, trees and anything that is not a language:

````markdown
```
built 1227 pages (8 products) → ./dist
```
````

Across this site the convention is `text` for that. The highlighter does not recognise `text` as a language, so it renders identically to a bare fence while reading more explicitly in the source.

Indented code blocks (four spaces) are supported too, and are treated as having no language.

## Inline code

Inline `` `code` `` is ordinary markdown and is not highlighted. It is also excluded from [inline reference](/trail/writing/inline-references.md) matching: a claimed phrase inside backticks stays plain text, so writing `` `PCDS GUID` `` when you mean the literal string does not produce a link.

## In the other views

Code blocks survive into the [markdown mirrors and `/print` bundles](/trail/building/the-output-surface.md) as ordinary fenced blocks with their info strings intact — the mirrors reproduce your source, not the rendered HTML.

---

# Admonitions, tabs and diagrams

_Trail / Writing_

> Three block-level extensions on top of markdown — GitHub-style callouts, tabbed alternatives for per-platform instructions, and mermaid diagrams rendered from text.

Three constructs on top of ordinary markdown handle the things prose alone does badly: setting something apart, showing the same instruction several ways, and drawing.

## Admonitions

Admonitions use GitHub's alert syntax — a blockquote whose first line is a marker:

```markdown
> [!IMPORTANT]
> **Pekit supersedes the old `peipkg-build` recipe system.** A pekit recipe
> is the current and only supported way to build a package.
```

Five kinds exist, and no others:

| Marker | Renders as | For |
|---|---|---|
| `> [!NOTE]` | Note | An aside worth knowing, not worth interrupting for. |
| `> [!TIP]` | Tip | A shortcut or a better way. |
| `> [!IMPORTANT]` | Important | Something a reader who skims will regret missing. |
| `> [!WARNING]` | Warning | Something that will go wrong. |
| `> [!CAUTION]` | Caution | Something that will go irreversibly wrong. |

The body is full markdown — lists, code blocks, links, all of it.

A blockquote whose first line looks like a marker but is not one of the five is a **build error**, not silently-rendered text:

```text
unknown admonition type '[!WARN]' (expected NOTE, TIP, IMPORTANT, WARNING or CAUTION)
```

That is deliberate. A typo'd marker renders as a quotation with `[!WARN]` sitting at the top of it, which nobody proofreading their own page ever notices.

An ordinary blockquote — no marker — is still an ordinary blockquote.

## Tab groups

Tab groups show the same instruction several ways, so a page does not have to repeat itself per platform, per shell, or per package manager.

````markdown
:::tabs
:::tab Linux
Install with your package manager:

```sh
sudo apt install trail
```

:::tab macOS

```sh
brew install trail
```

:::
````

The grammar is three markers, each alone on its own line:

| Marker | |
|---|---|
| `:::tabs` | Opens a group. |
| `:::tab <label>` | Starts a tab. The label is everything after the marker. |
| `:::` | Closes the group. |

The first tab is shown; the rest are hidden until selected. Each panel's body is full markdown, so a tab can hold code blocks, lists, admonitions and images.

Rules, all of them enforced at build time:

- A group must be closed — an unclosed `:::tabs` is an error.
- A group must contain at least one `:::tab`, and each `:::tab` needs a label.
- Nothing but blank lines may appear between `:::tabs` and its first `:::tab`.
- Groups cannot nest.
- `:::tab` outside a group, or `:::` with no group open, is an error.

Markers inside fenced code blocks are left alone, which is how this page can show the syntax without triggering it.

> [!NOTE]
> Tab panels are hidden with the `hidden` attribute, so a reader searching the page with their browser's find-in-page will not see text in the tabs they have not opened. The site's own [search](/trail/building/the-reading-experience.md) indexes all of it. When printed, every panel is expanded and the buttons are dropped, so nothing is lost on paper.

## Diagrams

A code block tagged `mermaid` is rendered as a diagram in the browser:

````markdown
```mermaid
sequenceDiagram
    Client->>Gate: request + token
    Gate->>Registry: check claims
    Registry-->>Gate: allow
    Gate-->>Client: 200
```
````

Any [mermaid](https://mermaid.js.org) diagram type works — flowcharts, sequence diagrams, state diagrams, ER diagrams, Gantt charts, and the rest.

The mermaid library is a large script, so **it is only shipped to pages that actually contain a diagram**. A site with no diagrams anywhere never emits it at all.

The diagram is drawn in the reader's current theme, light or dark, chosen when the page loads. Switching theme afterwards re-styles the rest of the page but leaves already-drawn diagrams as they were until the page is reloaded.

### Diagram or image?

Prefer mermaid when the diagram is boxes, arrows and labels. It is plain text in your source: diffable, reviewable, editable by whoever is next in the file, and it follows the reader's theme.

Reach for an [image](/trail/writing/images.md) when the thing is genuinely pictorial — a screenshot, a photograph, an illustration, or a diagram whose layout matters more than mermaid will let you control.

---

# Inline references

_Trail / Writing_

> A page can claim the phrases that mean it, and every occurrence in the site's prose becomes a link to it. Plus § section citations into books, and automatic RFC-2119 keyword highlighting.

Some terms should always be links. If there is one page explaining what a PCDS GUID is, then every "PCDS GUID" written anywhere on the site ought to point at it — and nobody wants to write that link by hand a hundred times, or notice the ninety-nine places it was forgotten.

An **inline reference** is a phrase a page claims. Trail then links every occurrence of that phrase in the site's prose to the claiming page, automatically.

## Declaring a phrase

Declaration is **self-service**: a thing declares the phrases that mean it, in its own file. There is no central glossary to maintain.

An article declares in its frontmatter:

```yaml
---
title: Globally unique identifiers
type: reference
inline_ref:
  - PCDS GUID
  - PCDS GUIDs
---
```

Everything else declares in its `trail.toml`:

```toml
# 2--pgss.book/trail.toml
title = "Peios Generic System Standards"
short = "PGSS"
description = "Cross-platform system standards from the Peios Project"
inline_ref = ["PGSS", "Peios Generic System Standards"]
```

Phrases are matched **literally**, so plurals and alternative spellings are separate claims. List them all.

## Where a phrase lands

| Declared on | Links to |
|---|---|
| An article | that article |
| A product | the product's landing page |
| An anthology | the anthology's landing page |
| A book | the book's cover — and `§` may reach a section |
| A topic | the topic's first article |
| A topic subfolder | the folder's first article |
| A book chapter | the chapter's first article |

The pattern is: the declarer's own page, or — for the containers that have no page — the first article inside it. A container that declares a phrase but holds no articles to land on is a build error.

**A shelf may not declare a phrase at all**, since it has neither a page nor articles of its own. Declare it on the shelf's items.

## Where phrases are matched

In ordinary prose, and only there. Matching skips:

- **Headings**, whose ids are derived from their text;
- **Link text** — a phrase inside an existing link is left alone, so a deliberate link is never rewritten;
- **Image alt text**;
- **Code blocks and inline code** — writing `` `PCDS GUID` `` when you mean the literal string produces no link;
- **The claiming page itself**, which never links to itself.

Two more rules make matching predictable:

- **Whole words only.** A phrase surrounded by letters, digits or underscores does not match. "PCDS GUIDs" does not match inside "PCDS GUIDsomething".
- **Longest wins.** When two claimed phrases overlap at the same position, the longer one is used — so "PCDS GUID" beats "PCDS", and a phrase does not get chopped up by a shorter one inside it.

## Phrases are exclusive

A phrase belongs to exactly one declarer, site-wide. Two things claiming the same phrase is a build error:

```text
inline_ref phrase 'PCDS' is claimed by both book '/peios/pcds' and article '/peios/formats/pcds-overview'
```

That is the whole point: if two pages both want to own a term, the ambiguity is real and a reader would have been sent to the wrong one. Decide which page is the destination.

Empty phrases, phrases with leading or trailing whitespace, and phrases containing `§` are also errors. [Link aliases](/trail/structuring-a-site/link-aliases.md) never claim phrases — the original keeps them.

## Choosing phrases well

Inline references are powerful in the way find-and-replace is powerful. A phrase that is too common turns the site into a sea of blue.

- **Claim terms of art, not ordinary words.** "PCDS GUID" is a term. "identity" is a word.
- **Claim what a reader would want defined.** The test is whether someone meeting the phrase mid-sentence would benefit from a link.
- **Prefer the specific form.** Claiming "GUID" catches every passing mention; claiming "PCDS GUID" catches the ones that mean your page.
- **Remember plurals and expansions** — they are separate claims, and a term claimed in only one of its forms links inconsistently, which reads worse than not linking at all.

## Section citations with §

A phrase claimed by a **book** can be extended with a section number in prose:

```markdown
Every token MUST carry a lifetime, per PGSS §2.1.
```

"PGSS §2.1" becomes one link to that exact section. The suffix is a single space (or non-breaking space), the `§` sign, and a section label — digits, dots and appendix letters: `§2`, `§2.1.3`, `§A`, `§B.2`. A sentence-ending full stop is not part of the label.

Every book carries an index of its section numbers, covering:

- **articles**, by their section number (`§2.1` → that article's page);
- **chapters**, by theirs (`§2` → the chapter's first article);
- **headings inside articles** — the `h2` and `h3` numbers trail generates (`§2.1.3` → that article, at that heading).

So a citation can be as precise as a subsection of a page.

A `§` label that does not resolve is a build error — with the phrase still linked to the book, so the page is not broken while you fix it:

```text
'PGSS §9.9' does not match any section of '/peios/pgss'
```

Like a broken `~link`, it is downgraded to a warning by `--allow-dangling-links`.

### Bare § inside a book

Within a book's own pages, a bare `§` reference resolves against the surrounding book, with no phrase needed:

```markdown
The requirements of §2.2.1 apply, except as noted in §A.
```

Unlike the phrase-qualified form, **a bare `§` that does not resolve stays plain text and says nothing.** That is not an oversight: specifications routinely cite sections of *other* documents, and a build that failed on every "§9.9 of RFC 3986" would be unusable. The qualified form names its book and so can insist; the bare form cannot, so it does not.

## RFC-2119 keywords

Requirement keywords are highlighted everywhere on the site, with no configuration and nothing to declare:

> MUST · MUST NOT · REQUIRED · SHALL · SHALL NOT · SHOULD · SHOULD NOT · RECOMMENDED · NOT RECOMMENDED · MAY · OPTIONAL

They must be written in capitals, as [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) intends — the capitals are what distinguishes a requirement from the ordinary English word. Matching is whole-word and longest-first, so "MUST NOT" highlights as one keyword rather than as "MUST" followed by a stray "NOT", and it skips the same places phrase matching skips: headings, links, and code.

The styling is a weight and the product's accent colour, not a badge. It is meant to make a requirement scannable in a page of prose, and it works in ordinary articles as well as in books.

---

# Building and serving

_Trail / Building_

> The two commands — trail build writes a static site into dist/, trail serve adds a watching dev server with live reload. What each flag does, what gets pruned, and how failures behave.

Trail has two commands. `build` writes the site; `serve` writes it and then serves it, rebuilding as you edit.

## trail build

```console
$ trail build [ROOT] [--out DIR] [--strict] [--allow-dangling-links] [--render-llms-full] [--cbpath PATH]
```

`ROOT` is the directory containing `trail.toml`, defaulting to the current directory. Output goes to `ROOT/dist` unless `--out` says otherwise.

```console
$ trail build
built 1227 pages (8 products) → ./dist
```

The page count is HTML pages: articles, product and anthology landings, book covers, `/print` bundles, the front page and the 404 page. The markdown mirrors, `llms.txt`, `site.json`, the sitemap and the search index are not counted as pages.

### The output directory belongs to trail

At the end of every **successful** build, trail deletes everything in the output directory it did not write on that run, then removes any directory left empty. Pages for articles you deleted, superseded search-index fragments, images you stopped referencing: all gone.

Two consequences:

- **Do not keep anything in `dist/` by hand.** A `CNAME`, a `_headers` file, a stray favicon — trail will delete it. Anything that must ship belongs in the source tree and named by [`passthrough`](/trail/building/configuring-the-site.md), which copies it in on every build.
- **A failed build prunes nothing.** The previous output is left completely intact, which is what makes the dev server able to keep serving a working site while you fix an error.

Files whose contents have not changed are left untouched rather than rewritten, so modification times stay stable for rsync-style deploys.

### When a build fails

Loading errors — a bad name, an unknown config key, a malformed frontmatter block — stop the build at the first problem, with the context chained on:

```text
Error: loading product 'pekit'

Caused by:
    0: loading topic in '2--recipes.topic'
    1: loading article 'anatomy'
    2: ./pekit.product/2--recipes.topic/100--anatomy.md has unterminated frontmatter
```

Link errors are different: they are **collected across the whole site** and reported together, because fixing them one build at a time would be miserable.

```text
Error: 3 broken links:
in article '/pekit/recipes/anatomy': link '~pekit/running/invokation' matches no page in product 'pekit'
…
```

Some problems are warnings on stderr rather than errors, and do not stop the build: an image nothing references, and (with `--allow-dangling-links`) missing link targets.

## The flags

### `--allow-dangling-links`

Downgrades missing `~link` and `related:` targets from errors to warnings, and does the same for missing images and unresolvable `§` citations. **Ambiguous references stay fatal** — trail will not pick between two candidate pages.

It is for the middle of a large reorganisation, when you want to see the site before every reference has caught up. Shipping with it on means shipping dead links.

### `--strict`

Fails the build when any article has no `description:`:

```text
Error: --strict: 2 articles without a description:
  article '/pekit/recipes/sources' has no description
  article '/pekit/reference/cli' has no description
```

Descriptions become meta descriptions and social-preview text, so a page without one is a page that presents badly everywhere outside the site. Alias pages are exempt — the original carries the description.

Use it in CI; leave it off while drafting.

### `--render-llms-full`

Writes an extra copy of each unit's `print.md` as `llms-full.txt` beside it. Purely a discovery fallback for agents that probe for that filename instead of reading `llms.txt`, which always points at `print.md` anyway. Off by default, because it doubles a large part of the output for no new information.

### `--cbpath PATH`

Publishes a second copy of the whole site under `PATH`, with every link inside it rewritten to stay inside it. Name it after something that changes on every deploy — a commit hash — and you have a URL nothing can have cached, so what comes back is necessarily this build. See [The cache-busting copy](/trail/building/the-cache-busting-copy.md).

### `--out DIR`

Writes the site somewhere other than `ROOT/dist`. The chosen directory is excluded from the site scan, so a build into a directory inside the site root does not turn the previous build into content.

## trail serve

```console
$ trail serve [ROOT] [--addr ADDR] [build flags]
```

`serve` takes every `build` flag, plus `--addr` (default `127.0.0.1:8724`).

```console
$ trail serve
serving ./dist at http://127.0.0.1:8724 (watching . for changes)
```

It builds the site, serves the output directory over HTTP, and watches the source tree. On a change it debounces for 200 ms of quiet — editors write in bursts — rebuilds, and tells every open page to reload itself over a server-sent-events stream. Changes inside the output directory are ignored, so the build cannot trigger itself, as are paths with a dot-prefixed component, so a `.git` write does not either.

```text
rebuilt 1227 pages
```

**A broken site does not kill the server.** A failed initial build or rebuild prints the error and keeps serving the last good output:

```text
rebuild failed (still serving the last good build): loading product 'pekit': …
```

Fix the file and the next successful rebuild reloads the page.

Two details worth knowing:

- **The live-reload script is only ever injected by `serve`.** Output written by `trail build` never contains it.
- **Misses serve the built `404.html` with a real 404 status**, exactly as a static host configured for that file would — so you can check the not-found page locally.

## Deploying

The output directory is plain static files. Any host that serves a directory will do.

Two things to configure on the host:

- **`404.html` as the not-found page.** Trail writes it; the host has to be told to use it.
- **Directory indexes.** Pages are written as `<path>/index.html`, so the URL `/pekit/reference/cli` needs to serve `/pekit/reference/cli/index.html`. Nearly every static host does this by default.

Set `url` in the root `trail.toml` before deploying: without it there is no `sitemap.xml`, no `Sitemap:` line in `robots.txt`, and canonical and `og:url` tags stay relative. See [Configuring the site](/trail/building/configuring-the-site.md).

If the host's caching is aggressive enough that you cannot tell a fresh deploy from a stale one — and on GitHub Pages you cannot, because the headers are not yours to set — build with `--cbpath "${GITHUB_SHA::8}"` and you get a URL per deploy that nothing has ever cached. See [The cache-busting copy](/trail/building/the-cache-busting-copy.md).

---

# Configuring the site

_Trail / Building_

> The root trail.toml — the four required keys, the base URL and what it unlocks, featured products, header navigation, favicon, edit links, and injecting your own HTML into every head.

One `trail.toml` at the site root configures the whole site. It is the only place these settings live — there is no per-environment config, no CLI equivalent for most of them, and unknown keys are build errors rather than typos you find out about later.

```toml
sitename = "Peios Learn"
title = "Documentation for everything Peios"
url = "https://learn.peios.org"
accent = "#3b82f6"
description = "Concepts, guides, and reference for the Peios operating system and every project around it — from the kernel to the toolchain that builds it."

featured = ["peios", "pekit", "provium", "universal-directory", "trail"]

footer = "Peios Learn — documentation for the Peios project."
```

[The `trail.toml` reference](/trail/reference/trail-toml.md) is the exhaustive table; this page is what each setting is *for*.

## The four required keys

`sitename`, `title`, `description` and `footer` must all be present.

**`sitename`** is the site's identity: the wordmark in the header, the suffix on every page's `<title>`, the search box's placeholder, and `og:site_name`. Its **last word is accented** in the wordmark — "Peios *Learn*" — so a two-word name reads best. A one-word name simply gets no accent.

**`title`** and **`description`** are the front page's headline and standfirst, and its meta description and social-preview text. The description is also the first line of `llms.txt`, so write it as a summary of the whole site rather than a slogan.

**`footer`** is the line at the foot of every page. Beneath it, "Built with Trail." appears unless `built_by_trail = false` turns it off.

## The base URL

```toml
url = "https://learn.peios.org"
```

Optional, and worth setting before you deploy. Without it:

- **no `sitemap.xml` is written**, because sitemaps require absolute URLs, and `robots.txt` gets no `Sitemap:` line;
- **canonical URLs stay root-relative** rather than absolute;
- **`og:url` is omitted**, since a relative one is meaningless to the crawlers reading it;
- **`/print` bundles link outward relatively**, which is right on the site and wrong on paper.

Trailing slashes are trimmed, so `https://learn.peios.org/` and `https://learn.peios.org` behave identically.

> [!NOTE]
> Trail builds for the **root of a domain**. Internal links are root-relative (`/pekit/reference/cli`), so serving the site from a subpath is not supported.

## Featured products

```toml
featured = ["peios", "pekit", "provium", "universal-directory", "trail"]
```

The list does two jobs: it orders products, and it decides which appear on the front page.

- Products in the list sort first, in the list's order; everything else follows, by title.
- **Only listed products get a card on the front page.** An unlisted product is still built, still searchable, still linkable, and still in the header's products menu.
- Naming a product that does not exist is a build error.

## Header navigation

```toml
[[nav]]
label = "Specs"
url = "~peios/pgss"

[[nav]]
label = "Source"
url = "https://github.com/peios/peios"
```

Each `[[nav]]` block adds a link to the header, beside the products menu. On narrow screens they move inside that menu.

Keep the `[[nav]]` blocks at the **end of the file**: in TOML every key after a table header belongs to that table, so an ordinary key written below them becomes a nav field and fails the build.

`url` may be an external URL, a root-relative path, or a [`~` page reference](/trail/writing/links-and-references.md). References are resolved **strictly, at load time** — even with `--allow-dangling-links`, a broken nav link fails the build. Site chrome appears on every page; it must never dangle.

## Favicon

```toml
favicon = "favicon.png"
```

A path relative to the site root. The file is copied to the output root under its own name and linked from every page. The file must exist, or the build fails.

Naming a file here is also what makes it *legal* to keep in the site root, which otherwise accepts only `trail.toml` and `*.product` directories.

## Edit links

```toml
edit_url = "https://github.com/peios/learn/edit/main/{path}"
```

Adds an "Edit this page" link to the foot of every article. `{path}` is replaced with the article's source file path relative to the site root — the placeholder is required, and a template without it is a build error.

For an [alias page](/trail/structuring-a-site/link-aliases.md) the link points at the *original's* source file, since that is the file to edit.

## Injecting your own head HTML

```toml
head_html = "head.html"
```

A path to a file whose contents are injected verbatim at the end of every page's `<head>`: analytics snippets, extra meta tags, a webfont, a verification token.

It is inserted unescaped and unchecked, at the end of the head, so it can override earlier tags. It is the escape hatch for everything trail does not have a setting for — and, like all escape hatches, the thing to reach for last.

## Files trail does not build

```toml
passthrough = ["CNAME", ".well-known"]
```

Some files have to be in the published site without trail having any opinion about them: a `CNAME` for a custom domain, a `.well-known/` directory, a search-engine verification file. `passthrough` names them, and each one is copied into the output exactly as it is — directories whole.

This matters most when the site root **is** a repository root. Trail accepts only `trail.toml` and `*.product` directories there, so a `CNAME` sitting beside them would otherwise fail the build. Naming it here makes it legal *and* publishes it.

Two details worth knowing:

- Entries are copied **after** everything trail generates, so naming `robots.txt` replaces the generated one — useful, and a foot-gun if you name `assets`.
- Dot-entries like `.github/` are already skipped by the root scan, so they only need naming here if you want them **published**. `.well-known/` does; `.github/` does not.

> [!TIP]
> A repository whose root is a trail site cannot hold a `README.md` — it is an unexpected root file. Put it at `.github/README.md`, which GitHub renders on the repository page and trail skips.

## Theming

Four keys control colour:

```toml
accent = "#3b82f6"
accent_dark = "#7aa8ff"     # optional; derived from accent if absent
custom_css = "custom.css"   # optional
product_theming = true      # default
```

`accent` is the site's colour. `accent_dark` overrides its dark-mode variant, which is otherwise derived by mixing the accent toward white. `custom_css` names a stylesheet shipped at `/assets/custom.css` and loaded after the built-in one. `product_theming = false` stops each product tinting its own pages.

Both colours must be `#rrggbb`, and setting `accent_dark` without `accent` is an error. [Theming](/trail/building/theming.md) covers what these actually reach.

## What may live in the site root

The site root accepts `trail.toml`, `*.product` directories, the build output directory, dot-entries (`.git` and friends), and **whatever `favicon`, `custom_css`, `head_html` and `passthrough` name**. A setting naming `assets/site.css` makes the whole `assets/` directory legal at the root — it is the first path component that is tolerated. Anything else is a build error.

That last clause is the reason those three settings take paths rather than inline content: naming a file is what makes it part of the site.

---

# The output surface

_Trail / Building_

> Everything a build writes into dist/ — HTML pages, single-page /print views, markdown mirrors of every page, llms.txt, site.json, sitemap.xml, robots.txt, the 404 page, and the search bundle.

A build writes rather more than the pages you authored. This page is the full inventory of what lands in the output directory and what each part is for.

```text
dist/
├── index.html                        the front page
├── 404.html                          the not-found page
├── robots.txt
├── sitemap.xml                       only when the site config has a `url`
├── llms.txt                          the site's map, for language models
├── site.json                         the site's structure, machine-readable
├── pekit.md                          a product, as markdown
├── pekit/
│   ├── index.html                    the product landing page
│   ├── print/index.html              all of pekit on one page
│   ├── print.md                      all of pekit as one markdown file
│   └── reference/
│       ├── cli/index.html            an article
│       ├── cli.md                    the same article, as markdown
│       ├── print/index.html          all of the Reference topic on one page
│       └── print.md
├── assets/                           style.css, search.js, fonts, licences
└── pagefind/                         the search index and its WASM core
```

## HTML pages

One page per article, product, anthology and book cover, plus the front page, the 404 page and one `/print` page per unit. Each is written as `<path>/index.html`, so `/pekit/reference/cli` needs a host that serves directory indexes — nearly all of them do.

Topics, subfolders, chapters and shelves have no page. The count `trail build` prints is exactly this set.

## Single-page views: /print

Every **unit** — a product, an anthology, a topic or a book — also publishes its whole contents as one page:

```text
/pekit/print                  every article in pekit, in reading order
/pekit/reference/print        every article in the Reference topic
/peios/pgss/print             the whole book
```

Each article becomes a section with its title, its breadcrumb, and a heading id unique within the bundle. The page is styled for reading and for printing: browser chrome, the site header and footer, the pager and the tab buttons all drop out when it goes to paper, and tab panels expand so nothing is hidden.

Links are rewritten so the document works away from the site: a link to a page **inside** the same bundle becomes an in-document anchor, and one **outside** it becomes an absolute URL when the site config has a `url`, staying root-relative otherwise.

Every article page carries a "Single page" link in its breadcrumbs, pointing at its unit's bundle **anchored at that article** — so a reader can go from a page to the whole document without losing their place.

Two things do not carry over into a bundle: [inline reference phrases and `§` citations](/trail/writing/inline-references.md) are not linked there. RFC-2119 keywords still are.

A unit with no articles gets no bundle.

## Markdown mirrors

**Every page is also available as markdown at its own URL with `.md` appended.**

```text
/pekit/reference/cli      →  /pekit/reference/cli.md
/pekit                    →  /pekit.md
/pekit/reference/print    →  /pekit/reference/print.md
```

A mirror is the article's *source*, not a re-derivation of the HTML — the same headings, the same fenced code blocks with their info strings, the same tables — with three rewrites:

- `~` references become the target's own `.md` URL, so following links keeps you in the markdown surface;
- relative image destinations become the images' published URLs, since the mirror is served away from the article's folder;
- inside a book, `h2`/`h3` headings gain their section numbers as text.

Each one opens with the title, the breadcrumb trail, and the description:

```markdown
# Installing

_Acme CLI / Guides_

> How to install Acme.

See [Hello](/docs/guides/hello.md) first.

Related content:

- [Hello](/docs/guides/hello.md)
```

Every HTML page also advertises its mirror in the head:

```html
<link rel="alternate" type="text/markdown" href="/pekit/reference/cli.md">
```

## llms.txt

A map of the site at the root, following the [llms.txt](https://llmstxt.org) convention: the sitename and description, a note that every page has a `.md` twin, and then one section per product listing its units by their `print.md` bundles.

```text
# Peios Learn

> Concepts, guides, and reference for the Peios operating system…

Every page on this site is also available as markdown at the same URL with
`.md` appended. Each section below links whole units as single markdown
files. The full machine-readable structure is at /site.json.

## pekit

- [pekit overview](/pekit.md): The recipe-driven build and packaging tool…
- [All pekit docs in one file](/pekit/print.md)
- [Getting started](/pekit/getting-started/print.md): 2 articles
```

The `--render-llms-full` flag additionally writes each unit's `print.md` as `llms-full.txt` beside it, for agents that probe for that filename rather than reading `llms.txt`. It is off by default: it duplicates a large part of the output and adds nothing new.

## site.json

The whole site model as JSON: the site's own metadata, then products, and inside them the full tree of anthologies, topics, folders, books, chapters and articles.

```json
{
  "sitename": "Peios Learn",
  "url": "https://learn.peios.org",
  "llms": "/llms.txt",
  "products": [
    {
      "slug": "pekit",
      "path": "/pekit",
      "md": "/pekit.md",
      "print": "/pekit/print.md",
      "title": "pekit",
      "monogram": "pk",
      "color": "#22c55e",
      "description": "…",
      "items": [
        {
          "kind": "topic",
          "slug": "reference",
          "path": "/pekit/reference",
          "print": "/pekit/reference/print.md",
          "title": "Reference",
          "entry": "/pekit/reference/recipe-format",
          "children": [
            {
              "slug": "cli",
              "path": "/pekit/reference/cli",
              "md": "/pekit/reference/cli.md",
              "title": "Command-line reference",
              "type": "reference",
              "description": "…",
              "related": ["/pekit/running/invocation"]
            }
          ]
        }
      ]
    }
  ]
}
```

Every node carries the URLs of its own alternative representations, so a consumer can walk the structure and fetch exactly the parts it wants. Book entries add `number` and, for appendices, `appendix: true`; [alias pages](/trail/structuring-a-site/link-aliases.md) add `original`.

## sitemap.xml and robots.txt

`robots.txt` is always written, allowing everything, with a `Sitemap:` line when the site config has a `url`.

`sitemap.xml` is written **only** when it has one, because sitemaps require absolute URLs. It lists every HTML page — the front page, products, anthologies, book covers, articles and `/print` bundles — each with a `<lastmod>` taken from that page's `updated:` date, or for a container, from the most recent one beneath it. [Alias pages](/trail/structuring-a-site/link-aliases.md) are left out: their canonical is the original.

## The 404 page

`404.html` is a real page in the site's theme, carrying `<meta name="robots" content="noindex">`. Trail writes it; your host has to be told to use it. `trail serve` already does, and returns a real 404 status with it.

## Search

`/pagefind/` holds a [Pagefind](https://pagefind.app) index built from the rendered pages: the index chunks, the WASM search core, and the `pagefind.js` module the theme's search script imports.

The front page is not indexed. Neither are [alias pages](/trail/structuring-a-site/link-aliases.md) — the original covers the same content — nor the parts of a page marked as chrome: breadcrumbs, the meta line, the Related Content list, the pager and the edit link. Each indexed page carries its breadcrumb trail as metadata, which is what search results show under the title.

## Assets

`/assets/` holds the stylesheet, the search script, the two variable fonts with their licences, and `LICENSE-Syntaxes.txt` — the licences of the bundled syntax-highlighting definitions, which ask to travel with them. `mermaid.min.js` and its licence join them only if some article on the site contains a [diagram](/trail/writing/admonitions-tabs-and-diagrams.md). A configured `custom_css` ships as `/assets/custom.css`; a configured `favicon` ships at the output root under its own name.

Images are published at the URL of the directory they were found in — but only the ones some article actually references. See [Images](/trail/writing/images.md).

## The cache-busting copy

`--cbpath <PATH>` adds one more entry to the output: `dist/<PATH>/`, holding everything above a second time, with every link inside it rewritten to stay inside it. It is how you get a URL that no cache can have seen. See [The cache-busting copy](/trail/building/the-cache-busting-copy.md).

## Passthrough files

Anything named by the root config's [`passthrough`](/trail/building/configuring-the-site.md) is copied in verbatim — a `CNAME`, a `.well-known/` directory, a verification file — keeping its path relative to the site root. It is written after everything else, so a passthrough named for a generated file (`robots.txt`) replaces it.

Passthrough files are tracked like any other output, so they survive the prune between builds.

---

# The cache-busting copy

_Trail / Building_

> What --cbpath publishes, why a second copy of the whole site is the only version of the idea that works, what carries the path prefix and what deliberately does not, and how to wire it into a deploy.

Static hosts serve your pages with cache headers you do not control. GitHub Pages sets its own; so does every CDN in front of it. For the minutes or hours those headers claim, a reader — or, far more often, an automated fetcher — can be served the *previous* build with nothing to tell them apart.

`--cbpath` answers that by publishing a second, complete copy of the site under a path you name:

```sh
trail build --cbpath 8f3a91c
```

```text
built 1227 pages (8 products) → ./dist
cache-busting copy → ./dist/8f3a91c/
```

Now `/8f3a91c/` is the site's front page and `/8f3a91c/pekit/reference/cli` is that article. Name the copy after something that changes on every deploy — a commit hash — and every URL inside it has never been requested by anyone, so nothing anywhere can have it cached. What you get back is necessarily this build.

## Why a copy and not a redirect

The obvious cheap version — one busted URL that redirects, or a byte-for-byte copy of the tree — does not work, and fails in a way you cannot see.

Every URL trail emits is site-absolute: `/pekit/reference/cli`, `/assets/style.css`, `/pagefind/pagefind.js`. A plain copy at `/8f3a91c/` would serve you a fresh HTML document for the one page you asked for, and that document would then load the **cached** stylesheet, the **cached** search script, and every link out of it would land back in the cached tree. One click and you are reading the old build again, with nothing on the page to say so.

So the copy is not copied. It is built, with the path baked into every URL it emits.

## What carries the prefix

| | |
|---|---|
| Every link between pages | breadcrumbs, sidebars, cards, the pager, `~` references in prose |
| `§` section citations and [inline references](/trail/writing/inline-references.md) | |
| Images that live in the tree | the ones trail publishes for you |
| Assets | the stylesheet, the search script, mermaid, your `custom.css`, the favicon |
| The search index | so a result inside the copy stays inside it |
| The whole [markdown surface](/trail/building/the-output-surface.md) | `.md` mirrors, `print.md`, `llms.txt`, `site.json` — the copy is mostly *for* machine readers, so these matter most |
| `passthrough` entries | copied into the copy as well as the site |

And what does not:

| | |
|---|---|
| `<link rel="canonical">` | names the **real** page, so the copy never competes with it |
| URLs you wrote out in full | `https://…`, and a leading-slash path you typed yourself — trail emits those exactly as written rather than second-guessing them |
| `edit_url` | it points at your repository, not at the site |
| `robots.txt` and `sitemap.xml` | see below |

## Search engines

The copy is a complete duplicate of the site at a path that changes on every deploy — exactly the thing a search engine should never index. Trail makes sure it isn't:

- every page in the copy carries `<meta name="robots" content="noindex, follow">`;
- every page names the real page as its canonical;
- the copy has no `sitemap.xml`, and the site's sitemap does not mention it.

There is deliberately **no** `Disallow` in `robots.txt`. Some automated fetchers honour `robots.txt` on a URL you handed them directly, and blocking the copy would defeat the point of having it.

## The path

One plain segment, made of letters, digits, `-`, `_` and `.`, not starting with a dot. It must not be a path the site already uses — a product slug, `assets`, `pagefind`, `site.json`, a `passthrough` entry — or the copy would bury it. Trail checks before it builds anything:

```text
Error: --cbpath 'assets' is already a path in the site; the copy would bury it — pick a name the site does not use
```

## What it costs

The output roughly doubles: twice the pages, twice the mirrors, a second search index. On a large site that is real — Peios Learn goes from 115 MB to 232 MB, and its deploy artifact from 27 MB to 54 MB. Build time roughly doubles too. Nothing about the site itself gets slower; the copy is only ever fetched by someone who asks for it by name.

## In a deploy

The point is to inject the commit, so the path is new on every push:

```yaml
- name: Build site
  run: trail build --cbpath "${GITHUB_SHA::8}"
```

On a `push`, `GITHUB_SHA` is the commit that `actions/checkout` just checked out, so the path and the content it names cannot disagree. The first eight characters are plenty to be unique per deploy and short enough to paste by hand.

Each deploy replaces the output wholesale, so only the current commit's copy exists at any moment — the previous one is pruned along with everything else the build did not write. Do not bookmark one.

## Finding it as a reader

The site itself learns where its copy is, and the search box gets a command for it. Type `/cb` (or `/cachebust`) and you land on the same page you were reading, in the copy. Type it again from inside the copy and you come back out. See [the reading experience](/trail/building/the-reading-experience.md).

---

# Theming

_Trail / Building_

> How a trail site is coloured — the site accent, per-product tints, the three-state light/dark system, the CSS custom properties everything resolves through, and the custom stylesheet escape hatch.

Trail ships one theme, compiled into the binary. There is no template directory to override and no theme to install. What you do control is colour — through configuration for the ordinary cases, and through CSS custom properties for everything else.

## The three colour settings

```toml
accent = "#3b82f6"          # the site's colour
accent_dark = "#7aa8ff"     # optional; derived from accent if absent
product_theming = true      # default
```

**`accent`** is the site's colour: links, focus rings, the wordmark's accented word, the hero glow, the front-page cards. Without it the theme's own default is used.

**`accent_dark`** is the dark-mode variant. Without it, trail derives one by mixing the accent 75% toward white — a light accent reads badly on a dark background, and most brand colours need lifting. Set it explicitly when the derived one is wrong. Setting it *without* `accent` is a build error.

Both must be `#rrggbb`.

**Each product also has a `color`**, and by default it displaces the site accent on every page belonging to that product: cards, the monogram tile, sidebar highlights, heading numbers, RFC-2119 keywords, focus rings. `product_theming = false` turns this off site-wide — every per-product tint collapses to the site accent, and products keep only their monogram and their card.

## Three-state light and dark

Every reader is in one of three states, and the header toggle cycles between them:

| State | |
|---|---|
| **System** | Follow the operating system's preference. The default. |
| **Light** | Pinned light, whatever the system says. |
| **Dark** | Pinned dark, whatever the system says. |

The choice is stored per reader in their browser and applied **before first paint**, so a reader who has chosen dark never gets a flash of white.

In CSS this is three blocks: `:root` carries the light tokens; `:root[data-theme="dark"]` overrides them when the toggle has pinned dark; and `@media (prefers-color-scheme: dark) :root:not([data-theme="light"])` overrides them for system-dark readers who have not pinned light.

> [!IMPORTANT]
> If you write custom CSS with colours in it, follow the same three-state pattern. Defining a colour only inside a `prefers-color-scheme` block means the header toggle cannot override it, and a reader who pins light on a dark machine gets your dark colour on trail's light page.

## The tokens

Everything resolves through CSS custom properties, so overriding a token restyles every place it is used.

| Token | |
|---|---|
| `--bg` | Page background. |
| `--surface` | Cards, menus, the sidebar — translucent over the background. |
| `--text` | Body text. |
| `--text-muted` | Secondary text: crumbs, meta lines, captions. |
| `--border`, `--border-strong` | Hairlines and emphasised edges. |
| `--accent` | The site accent, as resolved for the current theme. |
| `--pc` | The **product colour** in scope. Falls back to `--accent`. |
| `--glow`, `--glow-mix` | The accent glow behind heroes. |
| `--headline` | The gradient headings are painted with. |
| `--tile-mix`, `--tile-fg-mix` | How strongly monogram tiles take their product colour. |
| `--card-shadow` | Card elevation. |
| `--menu-bg` | The products dropdown, which must be opaque. |
| `--code-comment`, `--code-keyword`, `--code-string`, `--code-constant`, `--code-function`, `--code-type` | The syntax-highlighting palette. |

`--pc` is the one to know. It is set inline on every element scoped to a product, which is how one stylesheet tints five products differently — and why `product_theming = false` can collapse them all with a single rule.

## Syntax highlighting follows the theme

Code is highlighted at build time into **class names**, not inline colours: `hl-comment`, `hl-keyword`, `hl-string` and so on. Those classes resolve through the six `--code-*` tokens above, which are redefined in each theme state — which is why code goes light and dark with the rest of the page. Override those six tokens to change the code palette; see [Code blocks](/trail/writing/code-blocks.md).

## Fonts

Two variable fonts ship with the site and are served from `/assets/fonts/`: **Inter** for body text and **Space Grotesk** for headings. Both are self-hosted — no third-party font request — and their licences ship beside them, as the SIL Open Font License requires.

To use different fonts, add `@font-face` rules and a `font-family` override in your custom stylesheet. The built-in fonts are still emitted; there is no setting to suppress them.

## The custom stylesheet

```toml
custom_css = "custom.css"
```

The named file ships at `/assets/custom.css` and is loaded **after** the built-in stylesheet, on every page.

Overriding tokens goes furthest, and survives changes to trail's own CSS:

```css
:root {
  --accent: #0f766e;
  --card-shadow: none;
}
:root[data-theme="dark"] { --accent: #5eead4; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --accent: #5eead4; }
}
```

Beyond that it is ordinary CSS against trail's own class names — `.card`, `.sidebar`, `.admonition`, `.code-block` and the rest. Those class names are not a stable interface: they are what the compiled templates happen to emit today, and a trail upgrade may change them. Token overrides are the durable half.

## Injecting into the head

For anything that is not a stylesheet — a webfont link, an analytics snippet, extra meta tags — [`head_html`](/trail/building/configuring-the-site.md) names a file injected verbatim at the end of every page's `<head>`.

## What you cannot change

- **The templates.** They are compiled into the binary and are not user-replaceable. Page structure is trail's.
- **The layout**, beyond what CSS can reach.
- **The set of page types.** There is no way to add a new kind of page.

That is the trade trail makes: no plugin system and no theme directory, in exchange for a single binary with nothing to install and one appearance to maintain.

---

# The reading experience

_Trail / Building_

> Everything the theme gives a reader — search, three-state theming, the sidebar and On this page, text size, copy buttons, permalinks, prev/next, the single-page link and the mobile drawer.

None of this needs configuring. It is what every trail site does, and it is here so you know what your readers have — and so you can write pages that make use of it.

## Search

<kbd>⌘K</kbd> — or <kbd>Ctrl K</kbd>, and the pills in the header say which, based on the reader's platform — opens a search modal from any page. So does clicking the search box in the header or the one on the front page.

Search is powered by a [Pagefind](https://pagefind.app) index built at build time. **The engine and its index chunks load lazily, on the first open**, so a reader who never searches pays nothing for it.

- Results show the page title, its breadcrumb trail, and an excerpt with the matches highlighted.
- Up to eight results are shown; the status line says how many more there were.
- <kbd>↑</kbd> and <kbd>↓</kbd> move; <kbd>Enter</kbd> opens; <kbd>Esc</kbd> or a click on the backdrop closes.
- **The query is carried through to the page**, which highlights every occurrence of each term in the article body and scrolls the first into view. Matches inside code blocks and breadcrumbs are skipped.

What is indexed is the article body. Chrome — breadcrumbs, the meta line, Related Content, the pager, the edit link — is excluded, and so is the front page and every [alias page](/trail/structuring-a-site/link-aliases.md).

## Commands

A query beginning with `/` is a command rather than a search. Typing `/` on its own lists the ones this page has; typing more narrows the list, and <kbd>Enter</kbd> runs the highlighted one.

| Command | |
|---|---|
| `/theme` | Cycles **system → light → dark**, exactly as the header button does. |
| `/theme <name>` | Goes straight to `system`, `light` or `dark`. |
| `/cachebust`, `/cb` | Opens the page you are on in [the cache-busting copy](/trail/building/the-cache-busting-copy.md) — a URL nothing has cached. From inside the copy it takes you back out to the real page. Offered only when the build published a copy. |

Nothing needs enabling. The commands a page offers are the ones that can do something there, which is why `/cb` appears on a site built with `--cbpath` and nowhere else.

## Light, dark and system

The header's theme button cycles **system → light → dark**, and its icon shows which state is active. The choice is remembered in the reader's browser and applied before first paint, so there is no flash of the wrong theme.

"System" is the default and follows the operating system's preference live. See [Theming](/trail/building/theming.md).

## Navigation

**The sidebar** on an article page shows the article's own topic — its title above its contents in reading order, subfolders as collapsible groups (expanded automatically around the current page), and the current article marked. In a [book](/trail/structuring-a-site/books.md) it is the whole book as a tree with section numbers, chapters collapsible, and the trail to the current page opened. Chapters a reader opens by hand stay open as they move through the book, for that tab.

**"On this page"** lists the article's `h2` and `h3` headings in a right-hand column, and highlights the one currently in view as the reader scrolls. When the window is too narrow for a third column it moves into the sidebar. An article with no headings gets no list.

**Every heading has a permalink** — a `#` that appears on hover and links to that heading's stable id.

**Previous and next** links at the foot of an article step through the whole topic, or the whole book, in reading order.

**Breadcrumbs** run from the product down through anthologies, the topic and any subfolder.

**A back-to-top button** appears once the page has been scrolled past about 600 pixels.

## Text size

Three buttons at the top of the right-hand column set the prose to small, medium or large. The choice is remembered per reader.

It scales the **article text only** — the sidebar, header and chrome stay put. Making the whole page bigger costs a reader half their screen; making the prose bigger is what they actually wanted. On narrow screens the controls ride into the drawer with the rest of the sidebar.

## Code and content

- **Every code block has a copy button**, top-right, appearing on hover and always visible on touch devices. It says "Copied" briefly, and falls back to telling the reader to press <kbd>⌘C</kbd> if the browser refuses clipboard access.
- **Syntax highlighting follows the theme**, because it is class-based rather than baked-in colours.
- **Tables scroll inside their own container**, so a wide table never makes the whole page scroll sideways.
- **Images carry their real dimensions**, so text does not jump around as they load.
- **[Tab groups](/trail/writing/admonitions-tabs-and-diagrams.md)** keep one panel open at a time, independently per group.
- **[Mermaid diagrams](/trail/writing/admonitions-tabs-and-diagrams.md)** are drawn in the reader's theme, and the library is only shipped to pages that contain one.

## Page furniture

**A meta line** under the title gives the estimated reading time and, when the article has an `updated:` date, when it last changed. Containers total theirs up, so a book cover says how long the whole book takes.

**A "Single page" link** in the breadcrumbs opens the whole topic or book as one page, anchored at the article the reader was on. It is the right link for someone who wants to read the lot, print it, or search the whole document at once. See [The output surface](/trail/building/the-output-surface.md).

**"Edit this page"** appears at the foot of every article when the site config sets `edit_url`, pointing at the article's source file.

**A "Related Content" list** appears above the pager when the article's frontmatter has a `related:` list.

## On small screens

The sidebar becomes a drawer, opened by the menu button in the header and closed by the overlay, the button again, or following any link inside it. "On this page" and the text-size controls ride in with it, so nothing is lost on a phone.

## On paper

Every page prints. The header, footer, search modal, pager, "Single page" link and back-to-top button all drop out; tab groups expand so every panel is visible, each labelled with its tab's name.

For printing a whole topic or book, use its [`/print` view](/trail/building/the-output-surface.md) rather than printing pages one at a time.

## Without JavaScript

The pages are static HTML and read fine with scripts disabled. What is lost is the interactive layer: search, the theme toggle, text size, copy buttons, tab switching, the drawer, scroll-spying and diagram rendering. Content, navigation links, the sidebar tree and every `~link` work regardless.

---

# Directory and file names

_Trail / Reference_

> The complete naming grammar — order prefixes, slugs, type suffixes, appendix orders — plus exactly what each kind of directory may contain, the reserved names, and the sorting and duplicate rules.

Trail derives the whole site from the tree, so every name in it is meaningful and every unrecognised name is a build error. This page is the exhaustive grammar. [Anatomy of a site](/trail/getting-started/anatomy-of-a-site.md) is the same material as a walkthrough.

## The name shapes

| Shape | Example | What |
|---|---|---|
| `<slug>.product` | `pekit.product` | A product. Site root only; no order prefix. |
| `<order>--<slug>.antho` | `1--security.antho` | An anthology. |
| `<order>--<slug>.topic` | `4--reference.topic` | A topic. |
| `<order>--<slug>.book` | `2--pgss.book` | A book. |
| `<order>--<slug>.shelf` | `3--tooling.shelf` | A shelf. |
| `<order>--<slug>` | `300--tokens` | A topic subfolder or a book chapter. No suffix. |
| `<order>--<slug>.md` | `100--sids.md` | An article. |
| `<order>--<slug>.link` | `3--faq.link` | A [link alias](/trail/structuring-a-site/link-aliases.md). |
| `<slug>.<ext>` | `logon-flow.svg` | A co-located [image](/trail/writing/images.md). No order prefix. |
| `trail.toml` | | Configuration, at every level. |

### `<order>`

A non-negative integer, parsed as written. `1`, `100` and `007` are all valid and all mean their numeric value.

- It is a **sort key only** — never shown, never part of a URL — **except inside a [book](/trail/structuring-a-site/books.md)**, where it *is* the published section number.
- Gaps are fine and normal.
- **Two siblings sharing an order is an error**, since their relative order would then depend on nothing visible.
- Anything not parseable as an integer is an error: no `1.5`, no `-1`, no `first`.

### `a<N>` — appendix orders

**Inside a book only.** An order written `a1`, `a2`, … marks an appendix entry, which is lettered rather than numbered and sorts after every numbered sibling at its level.

- Letters are bijective base 26: `a1` → A, `a26` → Z, `a27` → AA.
- The letter comes from the number, not from position, so gaps show through here too.
- `a0` is an error: appendix orders start at `a1`.
- Outside a book, an `a<N>--` prefix is just a non-numeric order prefix, and therefore an error.

### `<slug>`

Lowercase kebab-case:

```text
ASCII lowercase letters, digits and hyphens
not empty
not starting or ending with a hyphen
```

`getting-started`, `pgss`, `rfc-2119` are fine. `Getting_Started`, `-draft`, `tokens/`, `café` are not.

The slug is the URL segment, and it is what you write in a [`~link`](/trail/writing/links-and-references.md).

### `<kind>`

Exactly one of `antho`, `topic`, `book`, `shelf`. Any other suffix is an error, as is a grouping directory with no suffix at all in a place where one is expected.

## What each directory may contain

Anything not listed is a build error. Entries whose name starts with `.` are skipped everywhere, so `.git`, `.DS_Store` and editor droppings are invisible to trail.

| Directory | Subdirectories | Files |
|---|---|---|
| **Site root** | `*.product`, the output dir | `trail.toml`; whatever `favicon`, `custom_css`, `head_html` and `passthrough` name |
| **Product** | `.antho`, `.topic`, `.book`, `.shelf` | `trail.toml` |
| **Anthology** | `.antho`, `.topic`, `.book`, `.shelf` | `trail.toml` |
| **Shelf** | `.antho`, `.topic`, `.book` — never another `.shelf` | `trail.toml` |
| **Topic** | `<order>--<slug>` subfolders | `trail.toml`, `.md`, `.link`, images |
| **Topic subfolder** | *(none — one level only)* | `trail.toml`, `.md`, `.link`, images |
| **Book** | `<order>--<slug>` chapters | `trail.toml`, `.md`, `.link`, images |
| **Book chapter** | `<order>--<slug>` chapters, to any depth | `trail.toml`, `.md`, `.link`, images |

The dividing line: **directories that hold groupings hold no files but `trail.toml`; directories that hold articles hold no grouping directories.**

## `trail.toml` by level

| Level | Required? | Keys |
|---|---|---|
| Site root | **required** | see [the `trail.toml` reference](/trail/reference/trail-toml.md) |
| Product | **required** | `title`, `monogram`, `color`, `description`, `inline_ref` |
| Anthology | **required** | `title`, `description`, `inline_ref` |
| Book | **required** | `title`, `description`, `short`, `inline_ref` |
| Topic | optional | `title`, `inline_ref` |
| Topic subfolder | optional | `title`, `inline_ref` |
| Book chapter | optional | `title`, `inline_ref` |
| Shelf | optional | `title` only — **`inline_ref` is an error** |

Unknown keys are errors at every level. Where `title` is optional and absent, it is derived from the slug: hyphens to spaces, each word capitalised (`the-two-gates` → "The Two Gates").

## Reserved names

| Name | Where | Why |
|---|---|---|
| `print` | article slugs | every container publishes a `/print` page |
| `assets` | product slugs | collides with `/assets` in the output |
| `pagefind` | product slugs | collides with `/pagefind` in the output |

## Images

A co-located image is `<slug>.<ext>` where the stem is a valid slug and the extension is **lowercase** and one of:

```text
png   jpg   jpeg   gif   svg   webp   avif
```

Anything else in an article directory falls through to the "unexpected file" error.

## Sorting

Siblings sort by order, ascending. Inside a book, appendix entries sort after every numbered entry at the same level, whatever their numbers.

Products are the exception: no order prefix, so they sort by the root config's `featured` list first (in that list's order), then the rest by title.

## Duplicates

Within one container, both order and slug must be unique:

```text
items 'concepts' and 'guides' share order 1
duplicate article slug 'overview'
```

The noun is `item` for a container's mixed children (topics, books, anthologies, shelves), and `article` inside a topic subfolder or where only articles can sit. Slugs are reported without their order prefixes.

Shelf contents are checked **flattened into their parent**, because a shelf adds no URL segment — two shelves in one product cannot both hold a `getting-started` topic, since both would want the same URL.

## URLs

A page's URL is the product slug, then the slug of every container between it and the page, with two exceptions: shelves contribute nothing, and order prefixes contribute nothing.

```text
peios.product/1--security.antho/2--tokens.topic/300--issuing/100--flow.md
  →  /peios/security/tokens/issuing/flow
```

Pages are written to disk as `<path>/index.html`.

---

# trail.toml reference

_Trail / Reference_

> Every key of every trail.toml — the site root, products, anthologies, books, topics, subfolders, chapters and shelves — with types, defaults, validation rules and what each one reaches.

`trail.toml` appears at every level of the tree, with a different schema at each. **Unknown keys are build errors everywhere**, so a typo fails loudly instead of doing nothing.

## The site root

Required at the site root. Four keys are mandatory.

| Key | Type | Default | |
|---|---|---|---|
| `sitename` | string | **required** | The site's identity: header wordmark (last word accented), `<title>` suffix, search placeholder, `og:site_name`. |
| `title` | string | **required** | The front page's headline; its `og:title` and the base for its meta description. |
| `description` | string | **required** | The front page's standfirst and meta description; the first line of `llms.txt`. |
| `footer` | string | **required** | The line at the foot of every page. |
| `url` | string | none | The site's public base URL, e.g. `https://learn.peios.org`. Trailing slashes are trimmed. |
| `featured` | array of strings | `[]` | Product slugs, in order. Sorts products, and selects which appear on the front page. |
| `accent` | `#rrggbb` | theme default | The site accent colour. |
| `accent_dark` | `#rrggbb` | derived | The dark-mode accent. Derived by mixing `accent` 75% toward white when absent. |
| `custom_css` | path | none | A stylesheet shipped at `/assets/custom.css`, loaded after the built-in one. |
| `favicon` | path | none | Shipped at the output root under its own name, linked from every page. |
| `head_html` | path | none | A file injected verbatim at the end of every page's `<head>`. |
| `edit_url` | string | none | "Edit this page" URL template. Must contain `{path}`. |
| `nav` | array of tables | `[]` | Header links. See below. |
| `passthrough` | array of paths | `[]` | Root entries copied into the output verbatim. See below. |
| `product_theming` | bool | `true` | Whether each product tints its own pages. |
| `built_by_trail` | bool | `true` | Whether "Built with Trail." appears under the footer. |

```toml
sitename = "Peios Learn"
title = "Documentation for everything Peios"
description = "Concepts, guides, and reference for the Peios operating system…"
url = "https://learn.peios.org"
accent = "#3b82f6"
featured = ["peios", "pekit", "provium", "universal-directory", "trail"]
footer = "Peios Learn — documentation for the Peios project."

[[nav]]
label = "Specs"
url = "~peios/pgss"
```

### Validation

- `accent` and `accent_dark` must be `#rrggbb`. `accent_dark` without `accent` is an error.
- `custom_css`, `favicon` and `head_html` are paths relative to the site root, and **the file must exist**. Naming one is also what makes it legal to keep in the site root; the first path component is what is tolerated there.
- `edit_url` must contain the literal `{path}`, which is replaced with the article's source path relative to the site root.
- Every slug in `featured` must name an existing product.

### `passthrough`

Root entries trail neither builds nor understands, copied into the output as they are:

```toml
passthrough = ["CNAME", ".well-known", "ads.txt"]
```

Each entry is a plain relative path naming a file or a directory that exists in the site root; directories are copied whole, nesting included. Naming an entry is also what makes it **legal** to keep in the site root, exactly as `favicon` and friends do — so this is how a repository that is also a trail site keeps its `CNAME` beside its `trail.toml`.

Errors: an entry that is empty, absolute, or contains `..`; one naming something that does not exist; one naming the build output directory.

Passthrough entries are copied **last**, after everything trail generates, so an entry deliberately named after a generated file replaces it. Naming `robots.txt` is a supported way to ship your own; naming `assets` would bury the stylesheet, and trail will not stop you.

### `[[nav]]`

| Key | Type | |
|---|---|---|
| `label` | string | **required.** The link text. |
| `url` | string | **required.** An external URL, a root-relative path, or a [`~` page reference](/trail/writing/links-and-references.md). |

References here are resolved **strictly at load time** — a broken nav link fails the build even under `--allow-dangling-links`, because site chrome appears on every page.

> [!IMPORTANT]
> `[[nav]]` blocks must come **last in the file**. TOML gives every key after a table header to that table, so a `footer =` written below them is read as a nav item's field and fails with `unknown field 'footer', expected 'label' or 'url'`. Put every plain key above the first `[[nav]]`.

A shelf is not a link target, so a nav entry cannot point at one — aim at the anthology above it, or a page inside it.

### What `url` unlocks

Without it: no `sitemap.xml`, no `Sitemap:` line in `robots.txt`, no `og:url`, relative canonical URLs, and relative outward links in `/print` bundles. Set it before deploying.

## A product

Required in every `<slug>.product` directory.

| Key | Type | |
|---|---|---|
| `title` | string | **required.** The product's display name. |
| `monogram` | string | **required.** The short string on the product's tile. |
| `color` | `#rrggbb` | **required.** The product accent. |
| `description` | string | **required.** One sentence: the card text, the page standfirst, the `llms.txt` entry. |
| `inline_ref` | array of strings | Phrases linking to the product's landing page. |

```toml
title = "pekit"
monogram = "pk"
color = "#22c55e"
description = "The recipe-driven build and packaging tool."
inline_ref = ["pekit"]
```

## An anthology

Required in every `.antho` directory.

| Key | Type | |
|---|---|---|
| `title` | string | **required.** |
| `description` | string | **required.** The card text, the page standfirst, the `llms.txt` entry. |
| `inline_ref` | array of strings | Phrases linking to the anthology's landing page. |

## A book

Required in every `.book` directory.

| Key | Type | |
|---|---|---|
| `title` | string | **required.** |
| `description` | string | **required.** |
| `short` | string | An abbreviation used where the full title will not fit: the sidebar heading, the card pill, the line above the cover title. |
| `inline_ref` | array of strings | Phrases linking to the book's cover. These may carry a `§` section suffix in prose. |

```toml
title = "Peios Generic System Standards"
short = "PGSS"
description = "Cross-platform system standards from the Peios Project"
inline_ref = ["PGSS", "Peios Generic System Standards"]
```

## A topic, subfolder or chapter

Optional. Both keys are optional.

| Key | Type | |
|---|---|---|
| `title` | string | Overrides the slug-derived title. |
| `inline_ref` | array of strings | Phrases linking to the container's **first article**. Declaring them on a container with no articles is an error. |

```toml
title = "Reference"
```

## A shelf

Optional, and takes `title` only.

| Key | Type | |
|---|---|---|
| `title` | string | Overrides the slug-derived title. |

**`inline_ref` on a shelf is an error.** A shelf has no page and no articles of its own, so the phrase would have nowhere to land — declare it on the shelf's items instead.

## Derived titles

Wherever `title` is optional and absent, it comes from the slug: hyphens become spaces and each word is capitalised. `2--writing-tests.topic` becomes "Writing tests"; `the-two-gates` becomes "The Two Gates". Write the key when that is wrong — acronyms, proper nouns, unusual casing.

## `inline_ref` everywhere

The key means the same thing at every level: a list of phrases that should become links to this thing wherever they appear in the site's prose. Phrases are exclusive site-wide — two declarers claiming one phrase is an error — and must not be empty, carry leading or trailing whitespace, or contain `§`. See [Inline references](/trail/writing/inline-references.md).

---

# Frontmatter reference

_Trail / Reference_

> Every key an article's YAML frontmatter may carry, its type, whether it is required, what it feeds, and how it is validated — with the differences between topic articles and book articles set out.

Every article begins with a YAML frontmatter block: a line of exactly `---`, the keys, and a closing `---`. It must be the first thing in the file. **Unknown keys are build errors.**

```markdown
---
title: Command-line reference
type: reference
description: Every pekit flag with its value form, the capability matrix, and exit codes.
related:
  - pekit/running/invocation
  - pekit/reference/recipe-format
inline_ref:
  - pekit CLI
updated: 2026-05-01
reading_minutes: 25
---
```

## The keys

| Key | Type | Topic article | Book article |
|---|---|---|---|
| `title` | string | **required** | **required** |
| `type` | string | **required** | **error** |
| `description` | string | optional | optional |
| `related` | array of strings | optional | optional |
| `inline_ref` | array of strings | optional | optional |
| `updated` | ISO date | optional | optional |
| `reading_minutes` | integer | optional | optional |

### `title`

The page's name: its `h1`, breadcrumb, sidebar entry, search-result heading and `og:title`. Never derived — an article always states its own.

### `type`

The learn taxonomy. Required on articles in topics; **an error on articles in books**, where the question it answers is already settled by the document's order.

Trail does not constrain the value and does not display it on the page. It is carried into `site.json` as the article's `type`. This site uses `concept`, `how-to` and `reference`.

### `description`

One sentence. It feeds:

- `<meta name="description">` and `og:description`;
- the standfirst line of the page's [markdown mirror](/trail/building/the-output-surface.md);
- the `description` field in `site.json`.

It is not shown on the page, and it is not the in-site search snippet — Pagefind builds those from body text.

[`--strict`](/trail/reference/cli.md) fails the build on any article without one. [Alias pages](/trail/structuring-a-site/link-aliases.md) are exempt.

### `related`

Page references in the [`~` grammar](/trail/writing/links-and-references.md), **without the leading `~`**:

```yaml
related:
  - pekit/running/invocation
  - peios/package-management/overview
```

They render as a "Related Content" list under the article, appear in the markdown mirror, and become a list of resolved paths in `site.json`.

Resolution is as strict as a body link: **ambiguous references always fail the build**; missing ones fail unless `--allow-dangling-links` downgrades them to a warning, in which case they are dropped from the list.

### `inline_ref`

Phrases that should link to this article wherever they appear in the site's prose:

```yaml
inline_ref:
  - PCDS GUID
  - PCDS GUIDs
```

Matching is literal, whole-word and longest-first, and skips headings, links, image alt text, code, and the claiming page itself. A phrase belongs to exactly one declarer site-wide; a collision is a build error, as is an empty phrase, one with leading or trailing whitespace, or one containing `§`. Alias pages claim nothing. See [Inline references](/trail/writing/inline-references.md).

### `updated`

An ISO `YYYY-MM-DD` date, validated as such:

```text
updated: '2026/05/01' is not an ISO date (expected YYYY-MM-DD)
```

It appears in the page's meta line and becomes the page's `<lastmod>` in `sitemap.xml`. Containers take the most recent date beneath them.

Deliberately manual: file modification times are noise, and trail does not read git history.

> [!TIP]
> Both components must be zero-padded to two digits: `2026-05-01`, never `2026-5-1`. No quoting is needed — an unquoted date works.

### `reading_minutes`

An integer overriding the estimate, which is otherwise the body's word count at 200 words a minute, rounded up, with a floor of 1. Code blocks count toward the word count.

Containers sum their children's values, so a book cover reports the whole book.

## Errors

| Message | Cause |
|---|---|
| `… has no frontmatter (must start with '---')` | The file does not open with `---`. |
| `… has unterminated frontmatter` | No closing `---`. |
| `parsing frontmatter of …` | Invalid YAML, a missing required key, or an unknown key. |
| `updated: '…' is not an ISO date (expected YYYY-MM-DD)` | A malformed date. |

Inside a book, `type:` produces an unknown-key error, since `type` is not part of the book article schema.

## Frontmatter and `.link` aliases

An [alias page](/trail/structuring-a-site/link-aliases.md) has no frontmatter of its own — it renders the target's body. It takes its title from the `.link` file (or from its own slug), its canonical URL from the original, and it claims no `inline_ref` phrases. Inside a book it also loses `type` and `description`, as every book entry does.

---

# Command-line reference

_Trail / Reference_

> Every trail command and flag, with defaults, exit status, what goes to stdout and stderr, and the complete list of build-time errors and warnings.

Trail has two commands, `build` and `serve`, plus clap's own `help`. Everything below is the complete flag surface; there are no others.

## Synopsis

```text
trail build [OPTIONS] [ROOT]
trail serve [OPTIONS] [ROOT]
trail help  [COMMAND]
```

`trail --version` prints the version; `trail --help` and `trail <command> --help` print usage.

## trail build

Builds the site into the output directory.

| Argument | | |
|---|---|---|
| `[ROOT]` | path | The site root — the directory holding `trail.toml`. Default `.` |

| Flag | Value | |
|---|---|---|
| `--out <DIR>` | path | Where the site is written. Default `ROOT/dist`. Excluded from the site scan, so a build into a directory inside the root is safe. |
| `--allow-dangling-links` | — | Downgrade missing `~link` and `related:` targets, missing images, and unresolvable `§` citations from errors to warnings. **Ambiguous references stay fatal.** |
| `--strict` | — | Fail the build when any article has no `description:`. Alias pages are exempt. |
| `--render-llms-full` | — | Also write each unit's `print.md` as `llms-full.txt` beside it. |
| `--cbpath <PATH>` | one path segment | Also publish [a cache-busting copy](/trail/building/the-cache-busting-copy.md) of the whole site under `PATH`, with every link inside it rewritten to stay there. |

On success:

```text
built 1227 pages (8 products) → ./dist
```

With `--cbpath`, a second line names the copy:

```text
built 1227 pages (8 products) → ./dist
cache-busting copy → ./dist/8f3a91c/
```

The page count is the site's; the copy holds the same number again and is not added in.

The count is HTML pages: articles, product and anthology landings, book covers, `/print` pages, the front page and `404.html`. Markdown mirrors, `llms.txt`, `site.json`, the sitemap and the search bundle are not counted.

## trail serve

Builds the site, serves the output over HTTP, and rebuilds on change. Takes every `build` flag, plus:

| Flag | Value | |
|---|---|---|
| `--addr <ADDR>` | socket address | Default `127.0.0.1:8724`. |

```text
serving ./dist at http://127.0.0.1:8724 (watching . for changes)
rebuilt 1227 pages
```

Behaviour:

- Changes are debounced for 200 ms; changes inside the output directory, and paths with a dot-prefixed component, are ignored.
- Pages are served with a live-reload script that reloads them over server-sent events after each successful rebuild. **`trail build` output never contains it.**
- Misses serve the built `404.html` with a real 404 status.
- A failed initial build or rebuild prints the error and keeps serving the last good output — the process does not exit.

## Exit status and streams

| | |
|---|---|
| Exit `0` | The build succeeded. |
| Exit non-zero | The build failed; the error is on stderr. |
| stdout | The build summary line, and `serve`'s serving/rebuild lines. |
| stderr | Errors and warnings. |

`trail serve` runs until interrupted.

## Warnings

These print on stderr and do **not** fail the build:

| Warning | |
|---|---|
| `image '…' is not referenced by any article and was not published` | The file stays out of the output. |
| `in article '…': link '~…' matches no page in product '…'` | Only with `--allow-dangling-links`. |
| `in article '…' (related): …` | A `related:` reference that did not resolve, with `--allow-dangling-links`. |
| `in article '…': image '…' not found …` | With `--allow-dangling-links`. |
| `initial build failed (serving previous output): …` | `serve` only. |
| `rebuild failed (still serving the last good build): …` | `serve` only. |

## Errors

Loading errors stop at the first problem, with the chain of contexts:

```text
Error: loading product 'pekit'

Caused by:
    0: loading topic in '2--recipes.topic'
    1: in article '100--anatomy.md'
    2: updated: '2026-5-1' is not an ISO date (expected YYYY-MM-DD)
```

Link errors are collected across the whole site and reported together:

```text
Error: 3 broken links:
in article '/pekit/recipes/anatomy': link '~pekit/running/invokation' matches no page in product 'pekit'
…
```

### The common ones

| Message | |
|---|---|
| `unexpected file '…' in site root: only trail.toml is allowed` | The root takes `trail.toml`, `*.product/`, the output dir, and files named by the config. |
| `unexpected directory '…' in site root: only *.product directories are allowed` | |
| `unexpected grouping type '.…' on '…' in a product (expected .antho, .book, .topic or .shelf)` | |
| `a shelf cannot contain another shelf ('…')` | |
| `grouping directory '…' is missing its '<order>--' prefix` | |
| `'…' has a non-numeric order prefix '…'` | |
| `'…' is not a valid slug (lowercase kebab-case)` | |
| `items '…' and '…' share order N` | Two siblings with the same order prefix. |
| `duplicate article slug '…'` | Also raised for shelf contents flattened into their parent. |
| `chapter '…' contains no articles` | A book chapter must lead somewhere. |
| `'print' is a reserved slug (every section has a /print page)` | |
| `'…' is a reserved product slug (it collides with the '/…' output directory)` | `assets` and `pagefind`. |
| `… has no frontmatter (must start with '---')` | |
| `… has unterminated frontmatter` | |
| `parsing frontmatter of …` | Bad YAML, a missing required key, or an unknown key. |
| `updated: '…' is not an ISO date (expected YYYY-MM-DD)` | |
| `color '…' is not a #rrggbb hex color` | |
| `accent_dark without accent: set the base accent color too` | |
| `edit_url must contain a {path} placeholder …` | |
| `featured product '…' does not exist (no ….product directory)` | |
| `favicon names '…', which does not exist in the site root` | Likewise `custom_css` and `head_html`. |
| `link '~…' is ambiguous; candidates: …` | Never downgradable. |
| `unknown admonition type '[!…]' (expected NOTE, TIP, IMPORTANT, WARNING or CAUTION)` | |
| `unclosed ':::tabs' block (close it with ':::')` | And the other tab-group errors. |
| `inline_ref phrase '…' is claimed by both … and …` | |
| `'… §…' does not match any section of '…'` | Downgradable. |
| `image '…': ~references name pages, not files — use a path relative to the article` | |
| `--cbpath '…' is already a path in the site; the copy would bury it …` | Checked before anything is built. |
| `--cbpath '…' must be a single path segment, with no '/'` | Likewise the empty, dot-leading and bad-character forms. |

## Installing

Trail is a single Rust binary. Build it from its source directory:

```console
$ cargo build --release
```

The result is `target/release/trail`. It carries the templates, stylesheet, fonts, syntax definitions, search engine and diagram renderer inside it, so the binary alone is the whole tool — copy it onto your `PATH` and nothing else needs installing.
