Conventions
The operating rules for this scaffold. Read once, apply mechanically thereafter.
1. The filesystem IS the nav
Every directory under docs/pages/ becomes a top tab. Inside a tab, the
folder tree is mirrored recursively into the sidebar โ you never maintain a
central nav list, and nothing needs an index.html.
- Drop a folder
docs/pages/<x>/with any.htmlinside โ a new tabXappears, opening to its first page. No config, no index required. - A subfolder becomes a collapsible divider; the loose
.htmlfiles in a folder are pages that sit alongside its subfolders, at every level. - An
index.htmlis optional: if a folder has one, it's that folder's clickable default page (the tab or divider opens to it); if not, the divider is just a label. - Move or rename a file/folder โ its nav entry and URL follow. Recurses to any depth:
tab โ divider โ page, mirrored straight from the filesystem.
So dropping a nested folder of HTML "just works" with zero config. Directories and files
whose names start with _ or . are skipped by the scanner โ that's
how _lib/, _tab.json, and _site.json stay out of the
nav.
2. _tab.json is for overrides only
Each tab may carry a docs/pages/<tab>/_tab.json. It never
adds pages โ it only adjusts how discovered pages are presented. All fields are
optional:
| Field | Effect |
|---|---|
label | Tab text in the header (default: title-cased directory name). |
key | CSS accent class for the tab (default: directory name). |
landing | Page the tab opens to, relative to the tab dir (default: index.html, else the first page). |
order_priority | Smaller = further left in the tab strip (default: 100). |
sections | Curated sidebar groups with explicit order/labels (see ยง6). Omit (the default) to mirror the folder tree recursively. |
external_layout | true = the tab's pages own their chrome; the wrap script leaves them alone (see ยง10). |
pinned | true = always visible in the tab strip. Unpinned tabs sit in a middle strip that scrolls sideways when there are many (default: false). |
group | Dropdown label. Tabs sharing one group collapse into a single dropdown tab, placed where its first member would sit (default: none). |
host | Directory name of another tab that owns this folder's pages. The folder gets no tab of its own and shows wherever the host's sections point at it โ move a folder in the nav without moving it on disk, so URLs keep working (default: none). |
If alphabetical order is fine and the directory name title-cases well, skip the file entirely.
3. Two-to-five tabs is the sweet spot
One tab feels undercooked; six or more crowds the bar. Pick 2โ5 top-level
directories. Tab order is by order_priority (then directory name) โ set it
explicitly in each _tab.json rather than relying on the default.
4. The root landing is docs/index.html
The bare URL / serves docs/index.html directly โ there is no
redirect plugin and no magic. Edit that file to be your welcome page, or replace its body
with a link hub. It is wrapped with the same chrome as every other page (its breadcrumb
root is the site name; its right-TOC is suppressed).
5. Author inside <main class="doc-body">
Every page you write is a normal HTML document whose authored content lives in a single
<main class="doc-body">โฆ</main> block, with a <title>
in the head. The wrap script extracts exactly that block and re-renders the full page
around it. Rules that make a page behave:
- The
<title>sets the browser title and the sidebar/breadcrumb label. Don't add the site-name suffix yourself โ the wrapper does. - Give section headings an
id(<h2 id="overview">). The on-this-page TOC is built from<h2 id>and<h3 id>. - Page-specific
<style>in the head and trailing<script>blocks are preserved across re-wraps. <meta name="nav-title" content="Short label">in the head gives the sidebar a shorter label than the<title>, shown verbatim.<meta name="nav-hidden" content="true">keeps a page out of the sidebar only: it still builds, deploys, is searchable and reachable by URL. To keep a page off the published site, use ยง11 instead.
6. The chrome is baked โ automatically while you serve
The header/sidebar/breadcrumbs/TOC aren't in your authored files; they're generated
around your <main> block. The header, breadcrumbs and right-TOC are written
into each page; the sidebar and the search are drawn in the browser from two files the same
step writes, assets/nav.json and assets/search-index.json. Adding a
page therefore only changes those two files and the new page, not every sibling. Both files
are committed, so a fresh clone or a static host serves the full site with no bake step.
The dev server does this for you:
it bakes on startup and re-bakes on every change, so dropping or editing files "just works"
โ no manual step. (Disable with NO_AUTOWRAP=1 ./run_dev.sh if you want manual
control.)
When the server isn't running (e.g. in CI, or before committing), bake manually:
python3 docs/_lib/_wrap_handwritten.py
It is idempotent and only writes files that actually changed: running it
twice produces no further change, and it won't double-escape titles or stack footers. A page
is recognised by its <main class="doc-body"> block, so it's safe to repeat.
Curated sidebar sections (optional)
By default the sidebar mirrors the folder tree (ยง1) โ no config. When you want explicit
labels or a pinned order, add sections to _tab.json. Each section
names a directory (relative to the tab) and may pin an order of file stems:
"sections": [
{"label": "Getting Started", "dir": "getting-started", "key": "guide"},
{"label": "Reference", "dir": "reference", "key": "guide",
"order": ["concepts", "api"]}
]
A section with "dir": "" points at the tab root itself โ that's how a flat
tab pins a custom page order, as both tabs in this site do. Drop sections and
the tab reverts to the zero-config recursive mirror.
7. assets/ and _lib/ are invisible to the nav
The scanner only looks under docs/pages/, and skips _/.
names. So docs/assets/ (CSS/JS/images), docs/_lib/ (the engine),
and the _*.json config files never become tabs. Reference assets with a path
relative to the page; the wrap script computes the correct ../ prefix for the
layout's own asset links.
8. Cross-page links use relative paths
Link between pages with ordinary relative hrefs ending in .html:
<a href="../recipes/add-a-tab.html">Add a tab</a>
When you move a directory, cross-boundary links break. There's no strict-build checker
shipped, so grep for the old path after a move:
grep -rn 'old-folder/' docs/pages/.
9. Branding lives in docs/_site.json
Site name, tagline, footer, and logo target are read from _site.json by
the layout and nav code โ never hardcode them in _lib/. Change the name there,
re-wrap, and the logo, <title> suffix, breadcrumb root, and footer all
update together.
Set base_url (the public URL the site is published at) to switch on absolute
OpenGraph tags โ og:url and og:image โ so shared links unfurl with a
preview card. Left empty, the relative <meta name="description"> and
og:title/og:description tags are still emitted; only the absolute
ones are skipped.
10. external_layout for self-chromed pages
Some pages own their full markup โ a Reveal.js deck, a generated report, an embedded
app. Mark their tab with "external_layout": true in _tab.json;
the wrap script then skips that tab entirely, leaving its HTML untouched while the tab
still appears in the header. See Recipes โ Include an HTML deck.
11. Private pages: .gitignore is the switch
A page or directory under docs/pages/ that git ignores is private: the wrap
script leaves it out of the committed assets/nav.json and
assets/search-index.json and lists it only in their twins
assets/nav.local.json and assets/search-index.local.json, which the
dev server serves instead. So it shows up on your local server and never on the published
site. A scratch or discussion area is one directory plus one .gitignore line:
mkdir docs/pages/guide/tmp
echo 'docs/pages/guide/tmp/' >> .gitignore
Keep the *.local.json twins out of git as well (this repo's
.gitignore does): they carry the private pages' titles and text.
12. Live mounts
A symlinked directory under docs/pages/ is another repo's pages, mounted in
place: they are wrapped, listed and searchable like any other page. A mount is always
private (ยง11) โ the link exists only on your machine.