docs-overlay

Architecture

The core/adapter boundary, and how it is kept honest.

Flowchart with 5 nodesdocs-overlay-fumadocs to docs-overlay; docs-overlay-docusaurus to docs-overlay; docs-overlay-cli to docs-overlay; docs-overlay-cli to docs-overlay-docusaurus; your adapter to docs-overlay.docs-overlay-fumadocsdocs-overlaydocs-overlay-docusaurusdocs-overlay-cliyour adapter
Mermaid source
flowchart TB
  F["docs-overlay-fumadocs"] --> C["docs-overlay"]
  D["docs-overlay-docusaurus"] --> C
  L["docs-overlay-cli"] --> C
  L -.-> D
  Y["your adapter"] --> C

The dependency direction is one-way and enforced by tests, not by convention. Adding support for another framework means writing an adapter; it must never require a change to the core.

The dotted line is the one edge that is not a hard dependency: the command line declares docs-overlay-docusaurus as an optional peer and imports it lazily, so cut and prune work on a repository that has never heard of Docusaurus. Nothing points upwards anywhere on this drawing, and that is the whole rule.

What belongs where

The question to ask before adding anything: is this part of versioned documentation itself, or part of a documentation framework?

CoreAdapter
versions, ordering, inheritance chainsURLs, baseUrl, basePath, trailing slashes
pages, slugs, resolutionpage trees, sidebars, tabs
aliases, redirects as slug → slugHTTP status codes, next.config redirects
tombstones and their metadataReact components, layouts
metadata as an opaque payloadthe meaning of pages: [], "...", "!x", root: true
caching, dependency graphsearch indexing and filtering
filesystem access, MDX compilation

The rules the core lives by

Zero runtime dependencies, no Node built-ins. docs-overlay runs in a browser, a worker or an edge runtime. The semver comparator is sixty hand-written lines rather than a dependency.

Synchronous. A Fumadocs StaticSource is already a materialised array, and making the core async would spread await across resolution, caching and the graph for no gain. A future filesystem source resolves its I/O before constructing entries.

Metadata is opaque. ResolvedPage.meta is a generic the core never inspects, and directives are read through an injected readDirectives. This is what keeps compiled MDX, structuredData and Fumadocs' navigation grammar out of the package.

It never throws on bad content. Problems become Diagnostics. A broken page must not take down a dev server; whether an error diagnostic should fail a build is the caller's decision.

Metadata is inherited whole, never merged. Understanding what is inside a meta.json is an adapter's job — the core only ever applies inheritance to it, per directory. Merging is delegated through a MetaMerger the adapter supplies.

The guards

Two things fail the build if the rule is broken:

  • packages/core/test/architecture.test.ts — asserts the manifest declares no dependency, and scans every shipped source file for a forbidden import. It lives outside src/ because it needs node:fs, which is exactly what it forbids there.
  • .oxlintrc.json — a no-restricted-imports override scoped to packages/core/src/**.

Plus npm run verify:independence, which packs the core, unpacks it into a directory with no node_modules at all, and runs it there. TypeScript alone would not catch a stray fumadocs-core import in this monorepo, because npm hoists it and the compiler resolves it happily — that is precisely why the text scan and the sandbox exist.

Adapter contract

An adapter consumes DocumentationSource, the deliberately small stable interface:

interface DocumentationSource<M = unknown> {
  getVersions(): readonly Version[];
  getPages(version: VersionId): readonly ResolvedPage<M>[];
  getPage(version: VersionId, slug: Slug | SlugKey): ResolvedPage<M> | undefined;
}

Overlay extends it with resolution, metadata, redirects, the dependency graph and invalidation. See Writing an adapter.

On this page