Skip to content

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 ​

  1. Scan -- globSync finds all markdown files, excluding node_modules, .vitepress, the root index.md, and any custom patterns.
  2. Extract titles -- Each file's frontmatter title is used if present; otherwise the filename is converted to title case as a fallback.
  3. Group -- Pages are grouped by their top-level directory. A file at guide/getting-started.md belongs to the "Guide" section.
  4. Build nav -- Each section becomes a nav entry. Single top-level files link directly; sections with multiple pages link to the first page alphabetically.
  5. Build sidebar -- Each multi-page section gets a sidebar group. Pages nested in subdirectories become collapsible sub-sections, all sorted alphabetically.
  6. 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.md stay 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 .md that 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-only page 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 exclude option
  • [ ] Co-located artifact files (*.test.*, *.spec.*, *.example.*, *.prompt.*, *.template.*) and layout: code-only viewers 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 title is the authoritative source for display names
  • [ ] Build completes without errors (npm run docs:build)