My Doc-Site / Pages / Guide / Conventions

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.

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:

FieldEffect
labelTab text in the header (default: title-cased directory name).
keyCSS accent class for the tab (default: directory name).
landingPage the tab opens to, relative to the tab dir (default: index.html, else the first page).
order_prioritySmaller = further left in the tab strip (default: 100).
sectionsCurated sidebar groups with explicit order/labels (see ยง6). Omit (the default) to mirror the folder tree recursively.
external_layouttrue = the tab's pages own their chrome; the wrap script leaves them alone (see ยง10).
pinnedtrue = always visible in the tab strip. Unpinned tabs sit in a middle strip that scrolls sideways when there are many (default: false).
groupDropdown label. Tabs sharing one group collapse into a single dropdown tab, placed where its first member would sit (default: none).
hostDirectory 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:

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.

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.

My Doc-Site docs