Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
Trail

Trail

The static site builder behind Peios Learn.

Single-page view · as markdown

What is Trail

Trail / Getting started

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.

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 is the exhaustive grammar; Anatomy of a site 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 #

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
SiteOne trail.toml at the root, the front page, and the products.
ProductA documented thing (trail.product/) with its own colour, monogram and landing page.
GroupingHow a product's pages are organised: anthologies, topics, books and shelves.
ArticleOne .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 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 covers all of it; The output surface 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 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.

If you want to understand the layout before writing anything, read Anatomy of a site, then the Structuring a site section in order.

If you are writing pages in a site that already exists, Writing is the section you want — frontmatter, links, images, code blocks and the rest.

Quick start

Trail / Getting started

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:

$ mkdir acme-docs && cd acme-docs
# 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.

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:

$ mkdir -p docs.product
# 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:

$ mkdir -p docs.product/1--guides.topic
<!-- 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 #

$ trail build
built 6 pages (1 products) → ./dist

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

$ find dist -type f -not -path 'dist/assets/*' -not -path 'dist/pagefind/*'
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 #

$ trail serve
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 ⌘K (or Ctrl K) 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:

<!-- 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.
  • 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 — the whole tree, level by level, with a real example.
  • Products — the structuring section in order: products, anthologies, topics, books.
  • Articles and frontmatter — every frontmatter key and what it does.
  • Configuring the site — the base URL, accent colour, navigation links, edit links and the rest of trail.toml.

Anatomy of a site

Trail / Getting started

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 is the same material as an exhaustive table; Structuring a site covers each kind of container in depth.

The whole shape #

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.

ShapeExampleWhere
<slug>.productpekit.productOnly at the site root.
<order>--<slug>.<kind>2--writing.topicAnthologies, topics, books, shelves.
<order>--<slug>300--tokens, 2--modelTopic subfolders and book chapters — no suffix.
<order>--<slug>.md100--sids.mdArticles.
<order>--<slug>.link3--faq.linkAliases; see Link aliases.

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.

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.

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 pageLinks to it land on
Site/—
Product/pekitits own landing page
Anthology/peios/security-fundamentalsits own landing page
Book/peios/…/pgss (the cover)the cover
Articleits own URLitself
Topicno page(topics are not link targets)
Topic subfolderno page(not a link target, except from a .link)
Book chapterno page(not a link target)
Shelfno 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.

DirectoryMay contain
The site roottrail.toml, *.product/, the output directory, files named by the config
A producttrail.toml, .antho/, .topic/, .book/, .shelf/
An anthologytrail.toml, .antho/, .topic/, .book/, .shelf/
A shelftrail.toml, the same as its parent minus shelves — a shelf never holds a shelf
A topictrail.toml, .md articles, <order>--<slug>/ subfolders, .link files, images
A topic subfoldertrail.toml, .md articles, .link files, images
A booktrail.toml, .md articles, <order>--<slug>/ chapters, .link files, images
A book chaptertrail.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.

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 — the product level in depth, and when to add another one.
  • Anthologies and shelves — grouping a large product.
  • Topics and folders — the everyday container.
  • Books — numbered, ordered documents with chapters and appendices.

Products

Trail / Structuring a site

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.

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

The slug becomes the URL prefix (/pekit) and the first segment of every ~link 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:

# 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
titleThe 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.
monogramA 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.
colorA #rrggbb hex colour. Any other form is a build error.
descriptionOne 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.

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.

What a product contains #

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

  • Anthologies (.antho) — a titled landing page with its own children, for products big enough to need a second level.
  • Topics (.topic) — a set of articles read in any order. The everyday container.
  • Books (.book) — a formal, ordered document with a cover page, numbered sections and appendices.
  • Shelves (.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:

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

Large ones add a layer:

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.

# 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.

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

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 segmentPageSidebarNests
Anthology (.antho)yesits own landing page—in products and anthologies
Shelf (.shelf)nono — 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.

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:

# 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.

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:

/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.

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:

# 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:

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

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.

"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:

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:

# 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.

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.

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:

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 whenUse a book when
Articles can be read in any orderThe document is read in order, or cited by section
No page needs to say "§2.4"Sections need stable numbers
Grouping is for navigationThe whole thing is one document with a cover
One level of subfolders is enoughNesting goes deeper than two levels

Almost all documentation is topics. Books are for specifications, formal references and manuals — see Books.

Books

Trail / Structuring a site

A book is a document read in order and cited by section: a specification, a formal reference, a manual. Where a topic 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:

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:

# 1--pgss.book/trail.toml
title = "Peios Generic System Standards"
short = "PGSS"
description = "Cross-platform system standards from the Peios Project"
Key
titleRequired. The full name, shown on the cover and in cards.
descriptionRequired. The standfirst on the cover, the card text, and the llms.txt entry.
shortOptional. 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_refOptional. Phrases that auto-link to this book, and which may carry a § section suffix in prose. See Inline references.

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:

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 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 — 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.

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 exist. A book that claims a phrase can be cited by section from anywhere in the site:

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

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:

# 3--signing.link
target = "~pekit/running/signing-and-provenance"
title = "Signing and provenance"
Key
targetRequired. A ~ page reference, in exactly the grammar body links use. It must start with ~.
titleOptional. 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 aMay target
Topican article, or a whole topic subfolder
Topic subfolderan article
Book or chapteran 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:

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
# 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 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

An article is a single .md file named <order>--<slug>.md: a YAML frontmatter block, then 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 #

KeyRequired
titleyesThe page's name: its h1, its breadcrumb, its sidebar entry, its search-result heading, its og:title.
typein topicsThe learn taxonomy — concept, how-to, reference on this site. An error inside a book.
descriptionnoOne sentence. Becomes the page's meta description and social-preview text.
relatednoPage references shown as a "Related Content" list at the foot of the page.
inline_refnoPhrases that auto-link to this page wherever prose states them.
updatednoAn ISO YYYY-MM-DD date.
reading_minutesnoOverrides 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;
  • 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 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 ~:

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:

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.

updated #

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

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.

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 with GitHub extensions: tables, strikethrough, footnotes, and > [!NOTE]-style admonitions. On top of that, trail adds:

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

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:

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 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

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:

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 #

~<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:

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

all of these resolve to it:

~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 #

TargetableReference
A productyes~pekit
An anthologyyes~peios/security-fundamentals
A book coveryes~peios/pgss
An articleyes~pekit/reference/cli
A topicnolink to an article in it
A topic subfolderno(except as a .link target)
A book chapternolink to an article in it
A shelfnoit 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:

[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 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:

# an article's frontmatter — no leading ~
related:
  - pekit/running/invocation
# 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.

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:

MeansWith --allow-dangling-links
Unknown productthe first segment is not a product slugwarning
No matchnothing in that product ends with those segmentswarning
Ambiguoustwo or more pages matchstill 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 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. 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.

2--logon.topic/
├── 100--overview.md
└── logon-flow.svg
![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:

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:

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:

<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>:

![Three services exchanging tokens](logon-flow.svg "How a logon token reaches the session service")
<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:

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:

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 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 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 work as they do in any markdown, with an info string naming the language:

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

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

<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 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 as its highlighter, with the extended syntax collection from two-face — the same definitions 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:

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:

```
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 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 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 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:

> [!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:

MarkerRenders asFor
> [!NOTE]NoteAn aside worth knowing, not worth interrupting for.
> [!TIP]TipA shortcut or a better way.
> [!IMPORTANT]ImportantSomething a reader who skims will regret missing.
> [!WARNING]WarningSomething that will go wrong.
> [!CAUTION]CautionSomething 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:

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.

:::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
:::tabsOpens 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 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:

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

Any mermaid 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 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

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:

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

Everything else declares in its trail.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 onLinks to
An articlethat article
A productthe product's landing page
An anthologythe anthology's landing page
A bookthe book's cover — and § may reach a section
A topicthe topic's first article
A topic subfolderthe folder's first article
A book chapterthe 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:

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 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:

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:

'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:

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 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

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

trail build #

$ 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.

$ 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, 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:

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.

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::

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.

--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 #

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

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

$ 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.

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:

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.

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.

Configuring the site

Trail / Building

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.

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 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 #

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 #

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 #

[[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. 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 #

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 #

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 the link points at the original's source file, since that is the file to edit.

Injecting your own head HTML #

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 #

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:

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 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

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.

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:

/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 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.

/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:

# 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:

<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 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.

# 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.

{
  "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 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 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 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 — 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. 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.

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.

Passthrough files #

Anything named by the root config's passthrough 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

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:

trail build --cbpath 8f3a91c
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 pagesbreadcrumbs, sidebars, cards, the pager, ~ references in prose
§ section citations and inline references
Images that live in the treethe ones trail publishes for you
Assetsthe stylesheet, the search script, mermaid, your custom.css, the favicon
The search indexso a result inside the copy stays inside it
The whole markdown surface.md mirrors, print.md, llms.txt, site.json — the copy is mostly for machine readers, so these matter most
passthrough entriescopied 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 fullhttps://…, and a leading-slash path you typed yourself — trail emits those exactly as written rather than second-guessing them
edit_urlit points at your repository, not at the site
robots.txt and sitemap.xmlsee 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:

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:

- 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.

Theming

Trail / Building

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 #

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
SystemFollow the operating system's preference. The default.
LightPinned light, whatever the system says.
DarkPinned 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
--bgPage background.
--surfaceCards, menus, the sidebar — translucent over the background.
--textBody text.
--text-mutedSecondary text: crumbs, meta lines, captions.
--border, --border-strongHairlines and emphasised edges.
--accentThe site accent, as resolved for the current theme.
--pcThe product colour in scope. Falls back to --accent.
--glow, --glow-mixThe accent glow behind heroes.
--headlineThe gradient headings are painted with.
--tile-mix, --tile-fg-mixHow strongly monogram tiles take their product colour.
--card-shadowCard elevation.
--menu-bgThe products dropdown, which must be opaque.
--code-comment, --code-keyword, --code-string, --code-constant, --code-function, --code-typeThe 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.

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 #

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:

: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 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

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 #

⌘K — or Ctrl K, 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 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.
  • ↑ and ↓ move; Enter opens; Esc 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.

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 Enter runs the highlighted one.

Command
/themeCycles system → light → dark, exactly as the header button does.
/theme <name>Goes straight to system, light or dark.
/cachebust, /cbOpens the page you are on in the cache-busting copy — 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.

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 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 ⌘C 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 keep one panel open at a time, independently per group.
  • Mermaid diagrams 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.

"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 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

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 is the same material as a walkthrough.

The name shapes #

ShapeExampleWhat
<slug>.productpekit.productA product. Site root only; no order prefix.
<order>--<slug>.antho1--security.anthoAn anthology.
<order>--<slug>.topic4--reference.topicA topic.
<order>--<slug>.book2--pgss.bookA book.
<order>--<slug>.shelf3--tooling.shelfA shelf.
<order>--<slug>300--tokensA topic subfolder or a book chapter. No suffix.
<order>--<slug>.md100--sids.mdAn article.
<order>--<slug>.link3--faq.linkA link alias.
<slug>.<ext>logon-flow.svgA co-located image. No order prefix.
trail.tomlConfiguration, 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, 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:

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.

<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.

DirectorySubdirectoriesFiles
Site root*.product, the output dirtrail.toml; whatever favicon, custom_css, head_html and passthrough name
Product.antho, .topic, .book, .shelftrail.toml
Anthology.antho, .topic, .book, .shelftrail.toml
Shelf.antho, .topic, .book — never another .shelftrail.toml
Topic<order>--<slug> subfolderstrail.toml, .md, .link, images
Topic subfolder(none — one level only)trail.toml, .md, .link, images
Book<order>--<slug> chapterstrail.toml, .md, .link, images
Book chapter<order>--<slug> chapters, to any depthtrail.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 #

LevelRequired?Keys
Site rootrequiredsee the trail.toml reference
Productrequiredtitle, monogram, color, description, inline_ref
Anthologyrequiredtitle, description, inline_ref
Bookrequiredtitle, description, short, inline_ref
Topicoptionaltitle, inline_ref
Topic subfolderoptionaltitle, inline_ref
Book chapteroptionaltitle, inline_ref
Shelfoptionaltitle 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 #

NameWhereWhy
printarticle slugsevery container publishes a /print page
assetsproduct slugscollides with /assets in the output
pagefindproduct slugscollides 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:

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:

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.

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

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.

KeyTypeDefault
sitenamestringrequiredThe site's identity: header wordmark (last word accented), <title> suffix, search placeholder, og:site_name.
titlestringrequiredThe front page's headline; its og:title and the base for its meta description.
descriptionstringrequiredThe front page's standfirst and meta description; the first line of llms.txt.
footerstringrequiredThe line at the foot of every page.
urlstringnoneThe site's public base URL, e.g. https://learn.peios.org. Trailing slashes are trimmed.
featuredarray of strings[]Product slugs, in order. Sorts products, and selects which appear on the front page.
accent#rrggbbtheme defaultThe site accent colour.
accent_dark#rrggbbderivedThe dark-mode accent. Derived by mixing accent 75% toward white when absent.
custom_csspathnoneA stylesheet shipped at /assets/custom.css, loaded after the built-in one.
faviconpathnoneShipped at the output root under its own name, linked from every page.
head_htmlpathnoneA file injected verbatim at the end of every page's <head>.
edit_urlstringnone"Edit this page" URL template. Must contain {path}.
navarray of tables[]Header links. See below.
passthrougharray of paths[]Root entries copied into the output verbatim. See below.
product_themingbooltrueWhether each product tints its own pages.
built_by_trailbooltrueWhether "Built with Trail." appears under the footer.
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:

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]] #

KeyType
labelstringrequired. The link text.
urlstringrequired. An external URL, a root-relative path, or a ~ page reference.

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.

KeyType
titlestringrequired. The product's display name.
monogramstringrequired. The short string on the product's tile.
color#rrggbbrequired. The product accent.
descriptionstringrequired. One sentence: the card text, the page standfirst, the llms.txt entry.
inline_refarray of stringsPhrases linking to the product's landing page.
title = "pekit"
monogram = "pk"
color = "#22c55e"
description = "The recipe-driven build and packaging tool."
inline_ref = ["pekit"]

An anthology #

Required in every .antho directory.

KeyType
titlestringrequired.
descriptionstringrequired. The card text, the page standfirst, the llms.txt entry.
inline_refarray of stringsPhrases linking to the anthology's landing page.

A book #

Required in every .book directory.

KeyType
titlestringrequired.
descriptionstringrequired.
shortstringAn abbreviation used where the full title will not fit: the sidebar heading, the card pill, the line above the cover title.
inline_refarray of stringsPhrases linking to the book's cover. These may carry a § section suffix in prose.
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.

KeyType
titlestringOverrides the slug-derived title.
inline_refarray of stringsPhrases linking to the container's first article. Declaring them on a container with no articles is an error.
title = "Reference"

A shelf #

Optional, and takes title only.

KeyType
titlestringOverrides 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.

Frontmatter reference

Trail / Reference

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.

---
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 #

KeyTypeTopic articleBook article
titlestringrequiredrequired
typestringrequirederror
descriptionstringoptionaloptional
relatedarray of stringsoptionaloptional
inline_refarray of stringsoptionaloptional
updatedISO dateoptionaloptional
reading_minutesintegeroptionaloptional

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;
  • 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 fails the build on any article without one. Alias pages are exempt.

related #

Page references in the ~ grammar, without the leading ~:

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:

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.

updated #

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

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 #

MessageCause
… has no frontmatter (must start with '---')The file does not open with ---.
… has unterminated frontmatterNo 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 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

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

Synopsis #

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]pathThe site root — the directory holding trail.toml. Default .
FlagValue
--out <DIR>pathWhere 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 segmentAlso publish a cache-busting copy of the whole site under PATH, with every link inside it rewritten to stay there.

On success:

built 1227 pages (8 products) → ./dist

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

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:

FlagValue
--addr <ADDR>socket addressDefault 127.0.0.1:8724.
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 0The build succeeded.
Exit non-zeroThe build failed; the error is on stderr.
stdoutThe build summary line, and serve's serving/rebuild lines.
stderrErrors 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 publishedThe 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:

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:

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 allowedThe 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 NTwo siblings with the same order prefix.
duplicate article slug '…'Also raised for shelf contents flattened into their parent.
chapter '…' contains no articlesA 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 rootLikewise 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:

$ 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.

Peios Learn — documentation for the Peios project.

Built with Trail.