FairGarden Docs
Build-time optimized documentation infrastructure for React and Next.js. Live demos, API references and search are computed once in your build, so readers get plain HTML that enhances itself.
pnpm install @fairgarden/docs'use client';
import * as React from 'react';
import { Slider } from '@base-ui/react/slider';
import { Flower } from '@/components/Flower';
import styles from './PetalSlider.module.css';
interface PetalSliderProps {
/** Petal count the slider starts at. */
defaultPetals?: number;
}
export function PetalSlider({ defaultPetals = 8 }: PetalSliderProps) {
const [petals, setPetals] = React.useState<number>(defaultPetals);
return (
<div className={styles.root}>
<Flower petals={petals} label={`A flower with ${petals} petals`} />
<Slider.Root min={3} max={16} value={petals} onValueChange={setPetals}>
<output className={styles.value}>{petals} petals</output>
<Slider.Control className={styles.control}>
<Slider.Track className={styles.track}>
<Slider.Indicator className={styles.indicator} />
<Slider.Thumb className={styles.thumb} aria-label="Petals" />
</Slider.Track>
</Slider.Control>
</Slider.Root>
</div>
);
}
PetalSlider.module.css.root { display: grid; justify-items: center; gap: var(--size-4); } .value { display: block; text-align: center; font-weight: var(--font-weight-6); color: var(--purple-12); } .control { display: flex; align-items: center; width: min(280px, 60vw); padding-block: var(--size-3); touch-action: none; } .track { width: 100%; height: var(--size-1); border-radius: var(--radius-round); background-color: var(--purple-5); } .indicator { border-radius: var(--radius-round); background-color: var(--purple-9); } .thumb { width: var(--size-4); height: var(--size-4); border-radius: var(--radius-round); background-color: var(--purple-9); border: var(--border-size-2) solid var(--purple-1); } /* Base UI renders the thumb as a `div` around a visually hidden range input, and the input is what takes focus, so the ring follows the thumb's focused descendant. */ .thumb:has(:focus-visible) { outline: var(--border-size-2) solid var(--purple-9); outline-offset: var(--border-size-2); }
Measured on real documentation
These patterns were first applied to the Base UI docs, which this package was originally developed to power. Profiling the change showed:
- −58% HTML parse time, from 18.4 ms to 7.8 ms
- −37% HTML and CSS render time, from 43 ms to 27 ms
- −23% Largest Contentful Paint on a page with six demos
- 0 s Total Blocking Time, with no long tasks
Everything a docs site needs, computed ahead of time
Syntax highlighters are large and TypeScript parsing is slow, so both run in your bundler and ship their results as JSON. Every piece is a separate import, so you can adopt them one at a time.
Live demos with real source
One createDemo call renders a component beside its highlighted source, with a tab for every file it imports and an optional TypeScript to JavaScript toggle.
API reference from TypeScript
Props, defaults and JSDoc descriptions are extracted from your types at build time, and committed as a types.md you can review in a pull request.
Search without a server
Titles, sections, props and exports are collected from your pages at compile time, then indexed and queried in the browser with Orama. Press ⌘K or Ctrl+K to try it.
Highlighting with no runtime
Code is highlighted once by the bundler and shipped as compressed HAST. A precomputed block downloads no highlighter and no grammars.
Live editing
Readers can edit a demo and watch it re-render. Transpiling runs in a Web Worker, and a broken edit keeps the last good preview on screen.
Pages that settle once
Deferred content swaps in together instead of as a cascade of layout shifts, and large content streams in progressively.
One line in, a full demo out
The demo at the top of this page is the file below. createDemo needs only the module URL and a component. A build-time loader follows the imports, highlights every file, can derive the JavaScript version, and writes the result back into the call, so the page renders from data instead of running a highlighter.
// What you write
import { createDemo } from '../createDemo';
import { PetalSlider } from './PetalSlider';
export const DemoPetalSlider = createDemo(import.meta.url, PetalSlider);// What the bundler receives (simplified)
export const DemoPetalSlider = createDemo(
import.meta.url,
PetalSlider,
{
precompute: {
Default: {
fileName: 'PetalSlider.tsx',
source: { hastCompressed: '…' },
extraFiles: {
'PetalSlider.module.css': { source: { hastCompressed: '…' } },
},
transforms: {
js: { fileName: 'PetalSlider.jsx', hasDelta: true },
},
},
},
},
);- Bind your UI once.
createDemoFactory({ DemoContent })turns your own components into thecreateDemothat every demo calls. - Write one line per demo.
import.meta.urlis the identity, so names, slugs and file links come from the folder structure. - Let the loader do the work.
withFairGardenDocsregisters webpack and Turbopack loaders that highlight, transform and cache each demo by its dependencies, and hot reload it when one of them changes. - Render from JSON. At runtime the page only converts precomputed HAST into React elements.
Your TypeScript is the API reference
Pass a component, hook or function to createTypes. Its props, types and JSDoc comments become the reference shown here, extracted while the site builds.
import { createTypes } from '@/functions/createTypes';
import { Flower } from './Flower';
export const TypesFlower = createTypes(import.meta.url, Flower);- Documents components, hooks, functions and classes
- Picks up data attributes and CSS variables from enums
- Writes a
types.mdnext to the page, so API changes show up in code review
A flat flower that fans its petals evenly around the center. It mainly serves as the component documented by the landing page demos.
| Prop | Type | Description |
|---|---|---|
| label | | Accessible name. The flower is treated as decorative when omitted. |
| color | | Petal color, taken from the Radix color scales. |
| petals | | Number of petals fanned evenly around the center. |
| size | | Width and height of the flower in pixels. |
FlowerColor
type FlowerColor = 'purple' | 'plum' | 'crimson' | 'amber'FlowerProps
type FlowerProps = {
/**
* Number of petals fanned evenly around the center.
* @default 8
*/
petals?: number;
/**
* Petal color, taken from the Radix color scales.
* @default 'purple'
*/
color?: FlowerColor;
/**
* Width and height of the flower in pixels.
* @default 160
*/
size?: number;
/** Accessible name. The flower is treated as decorative when omitted. */
label?: string;
}Let readers change the code
Any demo can be editable. Change a petals value or a color in this one, and the garden re-renders as you type.
- Source is transpiled off the main thread, in a Web Worker shared by the page
- A broken edit keeps the last good preview, with the error beside it
- The editing engine loads on demand, so read-only blocks never ship it
import * as React from 'react';
import { Flower } from '@/components/Flower';
import styles from './garden.module.css';
export default function Garden() {
return (
<div className={styles.bed}>
<Flower petals={5} color="crimson" size={96} />
<Flower petals={8} color="purple" size={128} />
<Flower petals={12} color="amber" size={96} />
</div>
);
}
Point at the lines that matter
Mark lines with comments in your source. The first block is the file as written. The second is what readers get: the comments are gone, the marked line is highlighted, and everything outside the focused region folds away until they expand it.
// What you write
import * as React from 'react';
import { Flower } from './Flower';
import { PetalInput } from './PetalInput';
import styles from './Garden.module.css';
interface GardenProps {
/** Petal count each flower starts with. */
defaultPetals?: number;
}
export function Garden({ defaultPetals = 8 }: GardenProps) {
const [petals, setPetals] = React.useState(defaultPetals);
const reset = () => setPetals(defaultPetals);
return (
<section className={styles.root}>
<h2 className={styles.title}>Garden</h2>
{/* @focus-start @padding 1 */}
<div className={styles.bed}>
<Flower petals={petals} /> {/* @highlight */}
<Flower petals={petals} color="plum" />
</div>
<PetalInput value={petals} onChange={setPetals} />
{/* @focus-end */}
<button type="button" className={styles.reset} onClick={reset}>
Reset
</button>
</section>
);
}// What readers see
import * as React from 'react';
import { Flower } from './Flower';
import { PetalInput } from './PetalInput';
import styles from './Garden.module.css';
interface GardenProps {
/** Petal count each flower starts with. */
defaultPetals?: number;
}
export function Garden({ defaultPetals = 8 }: GardenProps) {
const [petals, setPetals] = React.useState(defaultPetals);
const reset = () => setPetals(defaultPetals);
return (
<section className={styles.root}>
<h2 className={styles.title}>Garden</h2>
<div className={styles.bed}>
<Flower petals={petals} />
<Flower petals={petals} color="plum" />
</div>
<PetalInput value={petals} onChange={setPetals} />
<button type="button" className={styles.reset} onClick={reset}>
Reset
</button>
</section>
);
}Every file, readable by crawlers
Highlighted code is mostly <span> markup, and it outweighs the code it colors several times over. Ship that for every file of every demo and a page outgrows a crawler's budget, so the choice becomes bloat or only the visible tab. Here the HTML carries code as plain text in <pre><code>, and the highlighted version swaps in after hydration, as each block nears the viewport.
| Initial HTML | Plain text | Highlighted |
|---|---|---|
| A 27-line demo | 1.33 KB | 4.79 KB |
| A very large file | 49 KB | 203 KB |
That leaves room for every file and every variant of a demo, so search engines and AI agents can read all of it. Opt in with fallbackUsesExtraFiles and fallbackUsesAllVariants. View this page's source to check: both files of the demo at the top are there as plain text, including the stylesheet behind its second tab.
Read the semantic HTML convention or see where the sizes come from.
Still just markdown
Every page is an MDX file that reads fine on GitHub. A demo is described in a sentence, imported directly above where it is used, and followed by a link to its source. On the site the demo renders in place and the link disappears.
Drag the slider and the flower redraws with that many petals.
import { DemoPetalSlider } from './demos/petal-slider';
<DemoPetalSlider />
[See Demo](./demos/petal-slider/)Links point at real files, so they work on GitHub and in your editor, and become clean URLs when the site builds.
Read the markdown-first convention or read this page on GitHub.
Explore the documentation
Everything is documented with the library itself, from the first createDemo call to the internals of the loaders.
-
System design, data flows and the decisions behind them.
-
CodeHighlighter,CodeProviderand the contexts around them. -
useCode,useDemo,useTypes,useSearchand friends. -
createDemo,createTypesandcreateSitemap, bound to your UI. -
Loaders, transformers and enhancers that run at build time.
-
Built factories, prop compression and coordinated streaming.
-
Markdown first, semantic HTML and predictable file names.
-
The CLI that keeps generated files honest in CI.
Questions
Yes. withFairGardenDocs registers every loader as both a Turbopack rule and a webpack loader. The Turbopack loaders need Next.js 15.5 or later.
For a precomputed block, the plain text of the code in the HTML and a compressed highlighted tree that is decoded when the block is about to be seen. Grammars, the editing engine and transforms are fetched only by blocks that need them.
Yes. Hooks such as useDemo and useCode return state and rendered nodes, and you write the markup and CSS. Your components are bound once in a factory, so every demo picks up a change.
No. The same components can load and highlight code at build time, on the server in a React Server Component, or in the browser through CodeProvider.
No. The loaders track every file a demo imports, the TypeScript sources behind an API table and the markdown behind search, so an edit hot reloads like any other change. Results are cached by file contents, and a worker keeps the TypeScript language service warm between files.
It has been in development since June 2025, first to power Base UI's documentation. The package ships 0.x releases so that breaking changes can keep improving the developer experience on the way to a stable 1.0.0.
Start with one demo
Install the package, wrap your Next.js config, and turn a single component into a demo. The rest can follow.