ADR-0013 — Packages and projects share one catalogue shape: two tracks, version lines and demos

Packages and projects declare one catalogue entry, with a Use track, a Maintain track and version lines, so every entry is built the same way.

Context

ADR-0010 gives every package and project a root folder, content/docs/<project-slug>/, but leaves open what goes inside it (AGENTS.md §1). The home page (ADR-0014) lists the whole collection, so it needs that answer first. Four facts shape it:

  • Packages have versions. A package can have several released versions in use and an unreleased one in progress. Invariant 3 requires every package page to say which version it documents.
  • Two kinds of reader. People who use a package or project need install steps, guides and APIs. The people who maintain it need architecture, local setup, testing and the release process. The owner decided on 2026-10-31 that each project has both, as two tracks.
  • Demos. Some packages, npm ones in particular, need a working demo next to their docs.
  • Private projects. The site will hold private projects behind sign-in (WorkOS, in a later ADR). The owner decided on 2026-10-31 that a signed-out visitor sees nothing of them: no name, no count, no placeholder.

What Fumadocs does today, read from the installed source:

  • getSidebarTabs (fumadocs-ui/dist/components/sidebar/tabs) turns every folder with root: true, nested ones included, into one entry in a single flat switcher. It cannot show package → version → track as levels.
  • DocsLayout takes any tree and an explicit tabs list or false (fumadocs-ui/dist/layouts/docs/index.d.ts), so a package route can be given only its own subtree and its own switches.
  • meta.json files are validated by metaSchema in src/lib/source.ts, which can be extended.

Decision

  1. Every root folder declares a catalogue entry in its meta.json, validated by an extended metaSchema:

    {
      "title": "lodestar",
      "description": "Keyboard-first command palette for React.",
      "root": true,
      "catalog": {
        "kind": "package",
        "ecosystem": "npm",
        "visibility": "public",
        "repository": "https://github.com/example/lodestar",
        "registry": "https://www.npmjs.com/package/lodestar",
        "featured": false
      }
    }

    kind is package or project. ecosystem is required for packages: npm, composer, pypi, go, cargo or other. visibility accepts only public until the private-documentation ADR adds private, so the build fails on a private entry instead of publishing it. featured puts a package with a demo forward on the home page. The Documentation project has no catalog and is not listed.

  2. Two tracks in every root folder: use/ and maintain/. Use is for people using the package or project: install, guides, API, demo. Maintain is for the people who look after it: architecture, local setup, tests, releasing, contributing, its own decisions. A track may be missing; the switch then hides it. For a public package or project both tracks are public. For a private project both will be private.

  3. A package's Use track has one folder per documented major version, called a line: use/v3/, use/v2/, and use/next/ for an unreleased major. Each line's meta.json sets version (the exact version it documents, read from the package's manifest, such as 3.2.0 or 4.0.0-beta.3) and channel: latest, next or legacy. A released package has exactly one latest line and at most one next. A package with only next is unreleased. The lifecycle is in ../state/package-version-lifecycle.mdx.

  4. The Maintain track is not versioned. It describes the repository's default branch. Projects are unversioned by default: their use/ holds pages directly. A project that ships releases may use lines like a package.

  5. URLs follow the folders: /docs/<slug> is the overview (the root folder's index.mdx); /docs/<slug>/use/<line>/… and /docs/<slug>/maintain/… are the tracks. /docs/<slug>/use/latest/… is a stable alias that redirects to the line marked latest, so READMEs and registry pages can link without going stale. The redirect lives in src/proxy.ts, which already rewrites /docs/… paths.

  6. Navigation inside a package is scoped. On package and project routes, DocsLayout receives only the current track and line as its tree. The top of the sidebar holds a track switch (Use | Maintain) and a version menu that keeps the reader on the same page when it exists in the chosen line. Pages in a next line open with an "Unreleased" banner and pages in a legacy line with an "Older version" banner. Each banner names the exact version and links to the same page in latest. The site-wide switcher of root folders is replaced by the home page catalogue and search; the Documentation project keeps its default layout.

  7. Demos are pages in a line, use/<line>/demo.mdx, that embed a self-contained HTML file built by the package's own repository, through ADR-0011's sandboxed embed. The file lives at public/demos/<slug>/<line>/index.html, so each line demos the version it documents. This decision depends on ADR-0011. Until ADR-0011 is approved, no demo is published.

  8. Search and llms.txt carry the shape. Each Algolia record gains package, kind, ecosystem, track, line and channel. Search ranks latest and Maintain pages first and labels next and legacy results. Each page's Markdown opens with the version it documents. llms.txt lists every line, labelled.

  9. The build checks the shape and fails on: a missing or invalid catalog; a private entry; two latest or two next lines; released lines without a latest; a line without a version.

Decisions 2 and the signed-out rule in Context are the owner's (2026-10-31). Decisions 1, 3–9 are proposed here for the owner to confirm.

Options considered

ConcernOptionProsConsVerdict
AudiencesA — two tracks per root folderOne place per project; a reader switches tracks without losing contextEach folder has two trees to keep currentChosen (owner)
AudiencesB — tracks, Maintain always privateInternal notes never publicOutside contributors can't read how to contribute or releaseRejected (owner)
AudiencesC — a separate signed-in maintainer hubWorks across projectsA project's two kinds of docs live apartRejected (owner)
VersionsA — a folder per major line, plus nextMatches semver: pages change at majors; Fumadocs needs nothing new; a line can be deleted cleanlyMinor-version differences inside a line are noted in the textProposed
VersionsB — a folder per exact versionEvery release exactly preservedDozens of near-identical copies; search full of duplicatesRejected
VersionsC — latest only, older versions from git tags at build timeNo copies in the treeA build step that checks out tags; consumers can't edit old lines in placeRejected
VersionsD — a separate content source per versionStrong isolationOne defineDocs per version; heavy for a few packagesRejected
Maintain versioningA — unversioned, the default branchMaintainers work on the default branch; one copyReleasing notes for an old line live in that line's Use pages or the changelogProposed
Maintain versioningB — versioned like UseExact for old branchesDuplicates rarely changing pagesRejected
DemosA — HTML built by the package repo, sandboxed embed (ADR-0011)Each line demos its own version; the site takes no dependency on the package; isolated from the siteDepends on ADR-0011; each package repo must build a demo fileProposed
DemosB — the package as a site dependency, demo as an MDX componentShares the site's ReactA dependency (and an ADR) per package and version; versions collide in one node_modulesRejected
DemosC — third-party sandboxes (StackBlitz, CodeSandbox)No build work hereA third party on every demo page; slow; needs a DFD crossing and an ADRRejected
Stable linksA — use/latest/… alias redirectREADME links never go staleOne more rule in src/proxy.tsProposed
Stable linksB — noneNothing to buildLinks to v2 pages break when v2 is removedRejected
NavigationA — scoped DocsLayout per package with track switch and version menuMatches the shape; uses tree and tabs Fumadocs already acceptsCustom sidebar header to buildProposed
NavigationB — default flat switcher with nested root: true foldersNo codeEvery track and line becomes a separate entry in one long listRejected

Consequences

Positive

  • Every package and project reads the same way (product principle 2), and the home page can list them from data instead of hand-written links.
  • Unreleased docs can be published early without misleading anyone: the banner and the label in search say so.
  • A private entry cannot be published by mistake: the schema rejects it.

Negative / accepted trade-offs

  • Consuming repositories must follow the folder shape; PROJECT_AGENTS.md changes with this ADR.
  • Releasing a major means renaming next to v<major> and moving latest in one change.
  • Sidebar header, version menu, banners, alias redirect and build checks are new code.
  • Demos wait for ADR-0011.

Content invariants

  • 1 Private stays private: kept, and enforced by the build (decision 1, decision 9). Nothing private enters content/docs/ until its own ADR.
  • 2 Checked against the code: each line's version is read from the package's manifest by the consuming repository.
  • 3 Every package page states its version: the line's version is shown in the page header, in the banner and in the Markdown version.
  • 4 Examples run: each example belongs to a line and runs against that line's version.
  • 5 Readable as Markdown: unchanged; lines and tracks are ordinary pages.

Security considerations

  • Private content. The schema accepts public only. The future private-documentation ADR must keep private entries out of the build output, the search index, llms.txt, static.json, per-page Markdown and Open Graph images, and return the same 404 for an unknown and a private URL to a signed-out visitor, so a URL does not confirm a project exists.
  • Search records. The new fields (package, kind, ecosystem, track, line, channel) are public metadata about public pages; nothing else is added. Readable by anyone with the search-only key, like every record.
  • Demos. Third-party code built by a package repository runs only inside ADR-0011's sandboxed frame, with no access to the site's origin, cookies or storage.
  • Alias redirect. It maps use/latest to a line folder name from the build's page tree, never to a value taken from the request, so it cannot be turned into an open redirect.

Implementation notes

  • Extend metaSchema with catalog, version and channel: src/lib/catalog-schema.ts, a Standard Schema around Fumadocs' metaSchema, used by src/lib/source.ts.
  • A catalogue helper in src/lib/ that reads root folders into typed entries (kind, ecosystem, lines, tracks, demo) for the home page, search and navigation: src/lib/catalog.ts.
  • Scoped DocsLayout for package and project routes: track switch, version menu, banners. Typed root folders from src/lib/catalog-plugin.ts; src/components/docs/.
  • src/proxy.ts: the use/latest alias and the redirects for removed lines (src/lib/catalog-routes.ts).
  • Search records in src/app/static.json/route.ts: the new fields, and a scope tag per page. Ranking is not applied: it needs a change to the live index settings, which waits for the owner.
  • llms.txt and per-page Markdown: the version line.
  • Build checks (decision 9), in the loader plugin; the build fails with one line per problem, naming the file.
  • Tests — manual checks until ADR-0007 picks a runner:
    • bunx next build with sample content (three entries, kept out of content/docs/) passes. Each failure stops it with its message: two latest lines, a private entry, a root folder without catalog, a line without version, legacy without latest, and a package without ecosystem.
    • Two next lines, a line without channel, a non-https URL and line order were checked against the functions with a throwaway bun test script outside the repository: 6 tests, 32 assertions, all passing.
    • On bunx next start: use/latest/… and .md give 307 to the latest line, a removed line (v9/…) gives 307 to the same path in latest, real pages give 200, and an unknown package gives 404. Markdown starts with the "Documents" line; llms.txt labels lines; static.json carries the tags.
  • bun run lint, bun run types:check and bunx next build pass. Type check and build pass. Lint does not run: typescript-eslint does not support TypeScript 7. This predates this ADR, and fixing it needs its own ADR.

Docs to update on implementation

  • ../app-flow/package-navigation.mdx — to match the build
  • ../state/package-version-lifecycle.mdx
  • ../architecture/system.mdx — the catalogue helper and scoped layout
  • ../dfd/system.mdx — the new search record fields
  • ../api/ — llms.txt, per-page Markdown and static.json contracts
  • PROJECT_AGENTS.md and AGENTS.md §1, §6, §10 — the folder shape consumers follow
  • README.md — project structure
  • ./index.mdx — index row, next free number
  • ./index.mdx — status on approval

On this page

12 min read