Appearance
Auto Nav
Auto-generate VitePress navigation and sidebar config from the file system.
When to use
Use this pattern when maintaining nav and sidebar config manually becomes tedious or error-prone. The folder structure becomes the single source of truth: add or move a markdown file and the navigation updates automatically at build time.
The pattern
A build-time script scans markdown files, extracts titles from frontmatter (falling back to formatted filenames), groups pages by top-level directory, and produces both nav and sidebar config objects.
Set titles in frontmatter
Every markdown file should specify its display title in frontmatter. This is how the navigation gets correct names for abbreviations, proper nouns, and anything that simple title-casing would get wrong:
md
---
title: "CSS Custom Properties"
---If a file has no title in its frontmatter, the filename is title-cased as a fallback (e.g., getting-started.md becomes "Getting Started"). This works for simple names but will produce wrong results for abbreviations like CSS or API, so always set the title explicitly.
Generate navigation config
Create .vitepress/nav.js:
js
import { globSync } from "glob";
import matter from "gray-matter";
import { readFileSync } from "fs";
import { basename } from "path";
const EXCLUDE = [
"node_modules/**",
".vitepress/**",
"index.md",
];
export function generateNav({ exclude = [] } = {}) {
const allExclude = [...EXCLUDE, ...exclude];
const files = globSync("**/*.md", { ignore: allExclude });
const pages = files.map((f) => ({
segments: f.replace(/\.md$/, "").split("/"),
title: extractTitle(f),
link: "/" + f.replace(/\.md$/, "").replace(/\/index$/, "/"),
}));
const sections = groupByTopLevel(pages);
return {
nav: buildNav(sections),
sidebar: buildSidebar(sections),
};
}
function extractTitle(filePath) {
try {
const { data } = matter(readFileSync(filePath, "utf-8"));
return data.title || formatFilename(filePath);
} catch {
return formatFilename(filePath);
}
}
function formatFilename(filePath) {
return basename(filePath, ".md")
.split("-")
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
}
function groupByTopLevel(pages) {
const groups = new Map();
for (const page of pages) {
const key = page.segments[0];
if (!groups.has(key)) {
groups.set(key, {
name: formatSegment(key),
pages: [],
});
}
groups.get(key).pages.push(page);
}
// Sort groups alphabetically by name
return [...groups.entries()]
.sort(([, a], [, b]) => a.name.localeCompare(b.name))
.map(([key, group]) => ({ key, ...group }));
}
function formatSegment(segment) {
return segment
.split("-")
.map((w) => w[0].toUpperCase() + w.slice(1))
.join(" ");
}
function buildNav(sections) {
return sections.map((section) => {
// Single-page section (top-level file, not a directory)
if (section.pages.length === 1 && section.pages[0].segments.length === 1) {
const page = section.pages[0];
return { text: page.title, link: page.link };
}
// Multi-page section — link to the first page alphabetically
const sorted = [...section.pages].sort((a, b) =>
a.title.localeCompare(b.title),
);
return {
text: section.name,
link: sorted[0].link,
};
});
}
function buildSidebar(sections) {
const sidebar = {};
for (const section of sections) {
// Skip single top-level files (no sidebar needed)
if (section.pages.length === 1 && section.pages[0].segments.length === 1) {
continue;
}
const key = `/${section.key}/`;
sidebar[key] = buildSidebarGroup(section.pages, section.name);
}
return sidebar;
}
function buildSidebarGroup(pages, sectionName) {
// Group pages by subdirectory
const subgroups = new Map();
const topLevel = [];
for (const page of pages) {
if (page.segments.length <= 2) {
// Direct child of this section
topLevel.push({ text: page.title, link: page.link });
} else {
// Nested in a subdirectory
const subdir = page.segments[1];
if (!subgroups.has(subdir)) {
subgroups.set(subdir, []);
}
subgroups.get(subdir).push({ text: page.title, link: page.link });
}
}
// Sort top-level pages alphabetically
topLevel.sort((a, b) => a.text.localeCompare(b.text));
// Build sidebar items: top-level pages + collapsible subgroups
const items = [...topLevel];
const sortedSubgroups = [...subgroups.entries()].sort(([a], [b]) =>
a.localeCompare(b),
);
for (const [subdir, subPages] of sortedSubgroups) {
subPages.sort((a, b) => a.text.localeCompare(b.text));
items.push({
text: formatSegment(subdir),
collapsed: true,
items: subPages,
});
}
return [{ text: sectionName, items }];
}Wire it into VitePress config
In .vitepress/config.js, call generateNav and spread the results:
js
import { defineConfig } from "vitepress";
import { generateNav } from "./nav.js";
const { nav, sidebar } = generateNav({
// Additional patterns to exclude beyond the defaults
exclude: ["drafts/**", "archive/**"],
});
export default defineConfig({
title: "My Docs",
themeConfig: { nav, sidebar },
});How it works
- Scan --
globSyncfinds all markdown files, excludingnode_modules,.vitepress, the rootindex.md, and any custom patterns. - Extract titles -- Each file's frontmatter
titleis used if present; otherwise the filename is converted to title case as a fallback. - Group -- Pages are grouped by their top-level directory. A file at
guide/getting-started.mdbelongs to the "Guide" section. - Build nav -- Each section becomes a nav entry. Single top-level files link directly; sections with multiple pages link to the first page alphabetically.
- Build sidebar -- Each multi-page section gets a sidebar group. Pages nested in subdirectories become collapsible sub-sections, all sorted alphabetically.
- Sort -- Sections, pages, and subgroups are sorted alphabetically so ordering is predictable regardless of file-system order.
Exclude co-located artifact files
A file having a URL doesn't make it a topic. Some files exist only to support a topic page and should stay out of the generated nav even though they are reachable:
- Co-located artifacts, keyed off the established extension conventions:
*.test.*,*.spec.*,*.example.*,*.prompt.*, and*.template.*. These are linked from the page that documents them, not browsed on their own. Keying off the extension (rather than a single marker) lets a.spec.mdstay discoverable by a spec runner while staying out of the nav. - Generated viewer pages that only render a source file (for example, pages with
layout: code-only). They make sense only alongside the.mdthat links them.
Filter both out where pages are collected:
js
const ARTIFACT = /\.(test|spec|example|prompt|template)\./;
const pages = files
.filter(f => !ARTIFACT.test(basename(f)))
.filter(f => extractLayout(f) !== "code-only")
.map(f => ({ /* ... */ }));See Linked Assets's "Keep wrappers out of the sidebar" for the code-only wrapper case.
Test that nothing falls out
When the nav is derived from the file system, a refactor (a moved or renamed file, a too-aggressive exclude) can silently drop a page, and nothing fails. If a separate hand-maintained index or listing page also exists, the two can drift apart without anyone noticing. Guard both with one source of truth.
Factor the page-discovery rules into a single side-effect-free function that returns the canonical list of intended pages, then assert every surface covers that list, both directions:
- the generated nav links every intended page, and links nothing outside the list (so an artifact or
code-onlypage can never reappear in the nav); and - any hand-maintained index links the same set, both directions.
The nav coverage test in this repo is embedded below with VitePress's <<< snippet import, so this example is the exact file that runs in CI and cannot drift from it:
js
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { repoPathToUrl, DOCS_BASE } from "./url.js";
import { buildSidebar, indexedPages } from "./nav.js";
// Strip the docs-site origin + base path so a repo path maps to the same
// site-root-relative link the sidebar emits (`/skills/git/worktrees`). Using
// `repoPathToUrl` (covered by url.test.js) as an independent oracle means this
// test cross-checks the sidebar's link format rather than trusting it.
const BASE_PATH = new URL(DOCS_BASE).pathname.replace(/\/$/, "");
function navLink(repoPath) {
return new URL(repoPathToUrl(repoPath)).pathname.slice(BASE_PATH.length);
}
function sidebarLinks() {
const links = new Set();
for (const group of buildSidebar()) {
links.add(group.link);
for (const item of group.items) {
links.add(item.link);
}
}
return links;
}
// The left nav must equal the indexed-page set in both directions: every
// indexed page is reachable from the nav, and the nav surfaces nothing else.
// This is the regression guard behind issues #622 (a page fell out of the
// listing), #626 (a `.prompt.` page that shouldn't be in the nav), and #624
// (an `.example.` page that shouldn't be in the nav).
describe("buildSidebar matches the indexed-page set", () => {
const expected = new Set(indexedPages().map(navLink));
const actual = sidebarLinks();
test("the nav links every indexed page (coverage)", () => {
const missing = [...expected].filter(link => !actual.has(link)).sort();
assert.deepEqual(
missing,
[],
`the left nav is missing links for indexed pages:\n${missing.join("\n")}`,
);
});
test("the nav links nothing outside the indexed set (no artifacts)", () => {
const strays = [...actual].filter(link => !expected.has(link)).sort();
assert.deepEqual(
strays,
[],
`the left nav links pages that are not indexed:\n${strays.join("\n")}`,
);
});
});
// Spot-check the specific pages the recent issues called out, so a future
// change can't quietly re-add them to the nav.
describe("artifact pages stay out of the nav", () => {
const links = sidebarLinks();
test("#626: the spec judge prompt is not in the nav", () => {
assert.ok(
!links.has(navLink("skills/testing/spec-judge.prompt.md")),
"skills/testing/spec-judge.prompt.md should not appear in the nav",
);
});
test("#624: the style-tiles worked example is not in the nav", () => {
assert.ok(
!links.has(navLink("skills/design/style-tiles.example.md")),
"skills/design/style-tiles.example.md should not appear in the nav",
);
});
});Keep the check side-effect free so a test can import it without booting the site. This is the exact failure mode behind a "missing links from the index" bug: the index and the nav each claimed to list every page, but neither was checked against a shared definition of what "every page" meant.
One gotcha when co-locating the test next to nav.js in .vitepress/: the node --test runner skips dot directories during automatic discovery, so a .vitepress/*.test.js file silently never runs. Point the test script at it explicitly (for example, node --test && node --test .vitepress/*.test.js) so the coverage test can't quietly fall out of the suite.
Checks
- [ ] File system is the single source of truth -- no manual nav/sidebar config
- [ ] Titles extracted from frontmatter with filename fallback
- [ ] Alphabetical sort applied to sections, pages, and subgroups
- [ ] Nested folders produce collapsible sidebar sections
- [ ] Exclude patterns are configurable via the
excludeoption - [ ] Co-located artifact files (
*.test.*,*.spec.*,*.example.*,*.prompt.*,*.template.*) andlayout: code-onlyviewers are kept out of the nav - [ ] One canonical page list is the single source of truth, and a test asserts every surface (nav, and any hand-maintained index) covers it both directions
- [ ] Frontmatter
titleis the authoritative source for display names - [ ] Build completes without errors (
npm run docs:build)