docs-overlay 0.2.1fumadocs 0.2.3docusaurus 0.2.2cli 0.3.0

Write the diff, not the docs.

Versioned documentation where each version folder holds only what actually changed — an override, a new page, a rename, or a tombstone. The oldest folder is the complete tree; everything after it is an overlay. Two adapters — Fumadocs and Docusaurus — on an engine that depends on neither.

npm install docs-overlay docs-overlay-fumadocs

Cutting a release

One git mv, and the release is out.

Snapshot versioning copies the whole tree every time. An overlay records only the change, so cutting a version costs nothing and duplicates nothing.

Snapshot versioning

cp -r docs/ versioned_docs/version-11.15.0/
  • ~190 files copied, a ~1.2 MB commit
  • The sidebar duplicated alongside them
  • A typo now lives in every copy
  • Deleting a page means editing frozen folders by hand

Overlay versioning

git mv content/docs/next content/docs/11.15.0
mkdir content/docs/next
  • Git records it as renames — a zero-byte content diff
  • The emptied channel inherits everything again
  • Nothing was duplicated, so nothing can drift
  • Removals and renames are declarative frontmatter

~190 files0 bytes

The adapters

Two adapters, one engine.

The engine knows nothing about either framework, and the two integrations are genuinely different shapes. docs-overlay-fumadocs re-projects the source Fumadocs already read, and writes nothing: three files, and the third one you already have.

import { pageSchema } from "fumadocs-core/source/schema";
import { defineConfig, defineDocs } from "fumadocs-mdx/config";
import { withOverlay } from "docs-overlay-fumadocs/schema";

export const docs = defineDocs({
  dir: "content/docs",
  docs: { schema: withOverlay(pageSchema) }
});

export default defineConfig({});

withOverlay() is not optional

pageSchema is a zod object in strip mode, so an overlay: key in frontmatter is dropped before it reaches your page data. Skip withOverlay() and everything appears to work — the site builds, pages render, search runs — except that no directive has any effect, with no error to explain why.

One loader() serves every version. One page tree, one search index — and a relative link such as ./b.md resolves inside the version it was written in.

overlaySource()

Wraps the source Fumadocs already built. A single loader() then serves every version — one page tree, one search index.

resolveRoute()

Turns the catch-all params into a decision: page, redirect, gone, or not-found — with inheritedFrom naming the version that wrote the page.

staticParams()

Generates every routable URL, aliases and removed pages included. source.generateParams() knows only pages, and on a static host a missing route is a 404, not a redirect.

versionTabs() · switchVersion()

A version picker that falls back cleanly when the page does not exist in the version being switched to.

searchTagsOf()

Scopes the search index per version. Without it, a page served by five versions returns five identical results.

findOrphanPages() · diagnostics

An unreachable page or a broken directive fails the build instead of shipping quietly to the reader.

On Docusaurus, the build writes the tree instead

Docusaurus reads its versions from fixed paths on disk, inside the docs plugin’s own factory, before any hook could intervene — so there is no moment at which an overlay could resolve inheritance on the fly. docs-overlay-docusaurus plans the tree it expects, and docs-overlay-cli writes it as a prebuild step. The URLs come out identical, so nothing linked from outside your site moves.

npm install -D docs-overlay docs-overlay-cli docs-overlay-docusaurus

Source — committed, diffs only

content/docs/
 1.0.0/ the complete tree
 2.0.0/ only what changed
 next/ work in progress
  • The only tree anybody edits
  • One file per page, whatever the version count
  • Renames and removals are frontmatter

Generated — gitignored, rewritten each build

versions.json
versioned_docs/version-*/
versioned_sidebars/*.json
.docs-overlay/current/
  • Exactly what Docusaurus expects, at the paths it hardcodes
  • The same URLs as before, so no external link moves
  • Pages copied byte for byte, never re-emitted

content/docs/prebuild: docs-overlay materializedocusaurus build

docs/ becomes build output, which will surprise every contributor — so put docs-overlay materialize --check in CI. It turns an edit made there into a failed build instead of one that disappears without a trace.

Authoring

Four operations, all declarative.

A version folder says what it changes. Nothing else is written down, and nothing has to be repeated.

Change a page

The new file, nothing else.

---
title: Intro
---

Add a page

A file the older versions do not have.

---
title: New API
---

Rename a page

The old slug keeps answering — with a redirect from this version on.

---
title: New API
overlay:
  renamedFrom: guide/old-api
---

Delete a page

A tombstone at the same path. Readers get an explanation, not a 404.

---
title: Old API
overlay:
  deleted: true
  replacedBy: guide/new-api
---

All four are expressed in the version that introduces them, so a published folder is never touched again. Which means a release pull request is reviewable: grep -rl 'deleted: true' content/docs/3.0.0/ lists exactly what disappears.

The command line

Four commands, and none of them are a build.

cut, check and prune need only version folders, so a Fumadocs site or a plain repository of Markdown uses them exactly as a Docusaurus site does. materialize is the one that writes.

docs-overlay cut 2.0.0

The channel folder becomes that version and comes back empty, inheriting everything again. A git mv, so the content diff is zero bytes.

docs-overlay check

The engine's diagnostics in seconds, with no framework build: duplicate slugs, a tombstone with nothing to remove, a redirect that goes nowhere.

docs-overlay prune

Drops the files a version repeats byte for byte from what it inherits. The resolved site is identical afterwards — the slug is simply served by inheritance.

docs-overlay materialize

Writes the tree Docusaurus reads, as a prebuild step. --check writes nothing and fails when the generated tree is out of date.

Why

What you get for it.

Framework-agnostic core

The engine is a plain TypeScript library: no npm dependency, no node: built-in, no framework — the Fumadocs and Docusaurus adapters sit on top of it. An architecture test and a packaged run with no node_modules at all keep it that way.

No versions.json

The list of versions is the list of folders, ordered by semver, with declared channels sorted last. Nothing to keep in sync, nothing that can drift.

URLs that survive a migration

latestAtRoot gives the Docusaurus URL shape — /docs/guide/a is the newest release, /docs/11.13.0/guide/a an older one. No external link breaks.

Problems fail the build

An unreachable page, a rename pointing nowhere, a navigation list that lost an entry: each is a diagnostic you can turn into a failed build rather than a reader's dead end.

Incremental by design

getDependents() answers which versions a changed file actually feeds, so invalidate() rebuilds those and nothing else.

Built for a monorepo

Packages released on separate schedules cannot share one version list. Scopes let each carry its own versions, its own channel and its own overlay chain, all behind a single loader().

Its own proof

You are reading documentation served by docs-overlay.

The releases and the nextchannel are listed in the sidebar. The channel folder holds only the pages an unreleased change has rewritten — often none at all. Every other page it serves is the release's file of the same name, and says which version wrote it.

Switch between the versions

Ship your next version as a diff.

Four packages, an ESM-only install, and a site that already knows how to serve every version — on Fumadocs or on Docusaurus.

npm install docs-overlay docs-overlay-fumadocs