The html generator builds your docs into a static website: one
server-rendered page per document, with search, a sidebar, light and dark
themes, and client-side navigation. This page is the reference;
Customizing the site walks through
changing its look.
Target: html · Package: @doc-kit/generator-react · Reads: Page JSX · Writes: <page>.html, all.html, 404.html, assets/
npm install --save-dev @doc-kit/cli @doc-kit/generator-react npx doc-kit generate -t html -t orama-db -i "docs/**/*.md" -o out
orama-db adds the search index
for the search box. The pages load their scripts as modules, so serve out/
over HTTP (npx doc-kit preview) rather than opening it from disk.
Publishing your docs covers hosting.
| Path | Contents |
|---|---|
<page>.html | One page per input document, at its path from the input glob's base: docs/api/fs.md becomes api/fs.html. |
all.html | Every module page's content on one page, while generateAllPage is on. |
404.html | The not-found page, while the jsx-ast option generateNotFoundPage is on. |
assets/ | The client scripts, stylesheet and fonts. The Vite adapter names each after a hash of its content, so hosts can cache them. |
<module>/<section>.html | One page per section, when section-pages runs in the same build. |
It then copies the paths in pathsToCopy into the output (see
Shared options).
Options go in the html section of the
configuration file:
export default { global: { project: 'Kittens', repository: 'my-org/kittens', }, html: { title: '{project} docs', editURL: 'https://github.com/{repository}/edit/{ref}/docs{path}.md', }, };
string<title>, which
follows the page's own title and a |. Placeholders take configuration
values, such as {project}; {version} is the version without a v
(22.11.0). Set it to '' to title pages with their own title alone.
Default: '{project} {version} Documentation'.string{path}
is the page's path without an extension (/api/fs; on section pages, their
module's), {version} the version with a v (v22.11.0), and other
placeholders, such as {repository} and {ref}, take configuration values.
Default: none, which leaves the link out.string{version} is each
release's label, such as v22.x, and {path} the page's path.
Default: '{baseURL}{path}.html'.booleanbaseURL instead of relative ones. Default: false.booleanorama-db.json that orama-db writes to the site root.
Default: true when target includes orama-db, false otherwise.Objectstringstring[][].stringtemplate.html.Object#theme/* aliases replace parts of the page. See
Theme slots. Default: the six built-in #theme/*
components.Object{}.Object{}.booleanall.html: every module page's content on
one page, in the default sidebar's order, without section pages and the
index page. Default: true.ObjectPaths in stylesheets, templatePath and imports resolve against the
working directory. Build them with join(import.meta.dirname, ...) to make
them work wherever doc-kit runs from.
The generator also reads these
global options. Set
them under global to share them with other generators, or under html for
the site alone.
| Option | What it does for the site |
|---|---|
output | The directory the site is written to. |
project | The site's name: the default logo, {project} in titles, and the version selector's label. |
repository | Adds a GitHub link to the navigation bar, and fills {repository} in templates. |
ref | Fills {ref} in templates, such as editURL. |
baseURL | The site's public URL, for useAbsoluteURLs and {baseURL} in pageURL. |
version | {version} in titles, and the version selector's current entry. It defaults to the version of Node.js running doc-kit, so set it or drop {version} from title. |
changelog | The releases the version selector lists. Without one, there's no selector unless the remote configuration lists versions. |
minify | Minifies each page's HTML. The bundler minifies the client assets on its own (see Vite adapter). |
pathsToCopy | Files and directories copied into the output after the pages: a string keeps the path's name, an object maps sources to output paths, and missing paths are skipped. Setting it replaces the default, ['assets', 'public', 'static']. |
navigation configures the sidebar and the navigation bar. Each key is
optional, and leaving one out keeps that part's default:
export default { html: { navigation: { sidebar: [ { groupName: 'Reference', items: [ { label: '`kittens`', link: '/api/kittens' }, { label: 'Plugins', link: '/api/plugins', items: [ { label: 'Plugins', link: '/api/plugins' }, { label: 'Writing a plugin', link: '/api/writing-plugins' }, ], }, ], }, ], navbar: [ { text: 'Blog', link: 'https://example.com/blog', target: '_blank' }, ], showCrossLinks: true, }, }, };
sidebar is a list of groups, each { groupName, items }. Each item has:
stringstring/api/kittens.
It resolves against the page being rendered, so it follows
useAbsoluteURLs, and it's highlighted on its own page. Links starting with
http:// or https:// are used as written.Object[]items renders as a
disclosure, open on the pages it lists. Its label opens and closes it and
isn't a link, so list the item's own page again as its first child, as
Plugins does above.When section-pages splits a module, the module's sections are nested under
its item while you're on it, with the module repeated as the first child. An
item with items of its own keeps those instead.
Without a sidebar, the site has one "API Documentation" group listing every
page, alphabetically by title, with pages named index, synopsis and
documentation first.
Each navbar item has:
A site-absolute link shows as the current section on its page and on the pages
under it, so /api does on /api/fs. The bar also holds the logo, the search
box (see showSearchBox), the theme switch and, with a repository, a GitHub
link.
With showCrossLinks on, each page ends with links to the previous and next
items of the sidebar: its groups' top-level items, in order. It needs a
sidebar. A module split into section pages always gets these links, on its
own page and its sections', stepping through them in document order.
The sidebar shows a version selector when there are versions to list: the
releases in changelog, or the versions of the
remote configuration. Each entry links to the current
page in that version through pageURL. A page lists only the versions from
the major in its added (or introduced_in) metadata onwards.
remoteConfigUrl points at a JSON file the browser fetches once per visit, so
published docs can show news and list newer releases without a rebuild. Both
keys are optional:
{ "websiteBanners": { "index": { "text": "Kittens 3 is out!", "link": "https://example.com/blog/kittens-3", "startDate": "2026-11-01", "endDate": "2026-12-01" }, "v2": { "text": "Kittens 2 reaches its end of life in December.", "type": "warning" } }, "versions": [ { "url": "https://example.com/docs/v3.x{path}.html", "label": "v3.x", "major": 3 }, { "url": "https://example.com/docs/v2.x{path}.html", "label": "v2.x", "major": 2 } ] }
Objectindex shows on
every version's docs, and v<major>, such as v2, only on that major's.
When both are active, index wins.Object[]{ url, label, major }, with {path} in url standing for the page's
path.A banner the reader dismisses stays hidden until its text changes. If the file is on another origin, its host must allow cross-origin requests.
meta and links take one attribute object per tag, so you can repeat a tag
and pick name or property per tag. A true value renders a valueless
attribute, such as crossorigin; false, null and undefined leave the
attribute out. html holds raw markup for anything else.
export default { html: { head: { meta: [{ property: 'og:image', content: 'https://example.com/og.png' }], links: [ { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, { rel: 'preconnect', href: 'https://fonts.example.com', crossorigin: true, }, ], html: ['<meta name="theme-color" content="#000000" />'], }, }, };
The built-in template writes the <title>, og:title and og:type itself,
and the stylesheet bundles its own fonts, so none of them need an entry.
stylesheets are bundled into the site's stylesheet after the built-in one, so
their rules and custom properties win. They go through the bundler like any
other CSS, so @import and url() work. The built-in stylesheet reads these
custom properties:
| Property | Sets | Default |
|---|---|---|
--color-brand-100 to --color-brand-900 | The accent color, lightest to darkest, for links, focus rings and active states, in light and dark mode. | A neutral grey |
--font-open-sans | The text's font stack. | Open Sans |
--font-ibm-plex-mono | The code's font stack. | IBM Plex Mono |
Dark mode sets data-theme="dark" on <html>, from the reader's choice in the
theme switch or their system setting, so scope dark-only rules to
[data-theme='dark']. Colors and fonts
has a worked example.
templatePath points at the HTML file every page is rendered into. It's
evaluated as a JavaScript template literal, so a ${...} placeholder takes any
expression, and a literal backtick or ${ must be escaped with a backslash.
Start from a copy of the built-in template, at
node_modules/@doc-kit/generator-react/src/html/template.html.
These variables are in scope:
string'File system | Node.js 22.11.0 Documentation'.stringhead option.string<head>, or pages load no
script and no stylesheet.stringstring<script> in <head>.string<script type="speculationrules">. Links within the site navigate
client-side instead.string<body>.string'../'), or
absolute with useAbsoluteURLs. On 404.html and all.html, which can be
served from any path, it's '/' unless useAbsoluteURLs is on. Prefix your
own files with it, as in ${root}favicon.svg.ObjectObjecthtml configuration.For example, to add a canonical link and give the home page a title of its own:
<title>${metadata.api === 'index' ? 'Kittens' : title}</title> <link rel="canonical" href="https://example.com/docs${metadata.path}.html" />
The filled-in template is the page as written, minified first when minify is
on.
Every page is put together from six components, each imported through a
#theme/* alias. Point an alias in imports at a component of your own to
replace that part; the aliases you leave out keep their defaults.
| Alias | Default | Props |
|---|---|---|
#theme/Layout | Layout: the whole page, around the content | metadata, headings, readingTime, children |
#theme/Navigation | NavBar: the logo, links, search box, theme switch and GitHub link | metadata |
#theme/Logo | ProjectName: project as plain text | none |
#theme/Sidebar | SideBar: the page links and the version selector | metadata |
#theme/Metabar | MetaBar: the table of contents, reading time, and "Edit this page" link | metadata, headings, readingTime |
#theme/Footer | NoOp: nothing | none |
import { join } from 'node:path'; export default { html: { imports: { '#theme/Footer': join(import.meta.dirname, 'theme/Footer.jsx'), }, }, };
- Components are Preact components written in JSX.
Imports of
reactresolve topreact/compat, and components can import CSS files and CSS modules. - An alias can point at a package, such as
'@node-core/ui-components/Common/NodejsLogo'. With the Vite adapter, a relative path resolves against Vite'sroot, the working directory by default. - The default layout renders the navigation bar, sidebar, metabar and footer
slots, and the default navigation bar renders
#theme/Logo. A component of your own renders only the slots it imports, so a custom#theme/Navigationthat leaves out#theme/Logohas no logo. - To wrap or reuse a built-in component, import it from
@doc-kit/generator-react/html/ui/components/, such asLayout/index.jsx.
#theme/Layout receives:
Objectapi, path, basename, its
frontmatter fields (such as added, or fields of your own), and, on section
pages, chunk, describing the module and section it was split from. On
404.html, api is '404'.Object[]{ depth, value, slug, stability, data } entries.'5 min read'. Only set while the jsx-ast option showReadingTime is on,
and never on all.html.ComponentChildrenPage layouts shows a layout that gives some pages a shape of their own.
Pages written in MDX (.mdx files, or any page with mdx: true in its
frontmatter) can use JSX components. components registers yours by tag name,
next to the built-in ones. Each entry is either a module specifier, for the
module's default export, or { name, source, isDefaultExport }, where
isDefaultExport: false imports the export called name. The source can be
an alias defined in imports, a package, or an absolute path:
import { join } from 'node:path'; export default { html: { components: { Hero: '#theme/Hero', Stats: { name: 'Stats', source: '#theme/stats', isDefaultExport: false }, }, imports: { '#theme/Hero': join(import.meta.dirname, 'theme/Hero.jsx'), '#theme/stats': join(import.meta.dirname, 'theme/stats.jsx'), }, }, };
A page then uses them as tags, such as <Hero title="Kittens" />. Each page
imports only the components it uses.
These are built in and need no entry: AlertBox, Badge, Blockquote,
ChangeHistory, CodeBox, CodeTabs, DataTag, FunctionSignature, the
MDXTooltip family, the ArrowUpRightIcon icon, and DocumentationIndex,
which lists every page with its stability and description.
Components render to static HTML at build time. To make one interactive, wrap
its export with withIsland, which hydrates it in the browser as an island:
import withIsland from '@doc-kit/generator-react/html/ui/islands/withIsland.jsx'; import { useState } from 'react'; const Counter = ({ start = 0 }) => { const [count, setCount] = useState(start); return <button onClick={() => setCount(count + 1)}>{count}</button>; }; export default withIsland(Counter, { name: 'Counter', on: { idle: true } });
withIsland(Component, options) takes:
A condition set to true takes no value:
| Condition | Hydrates the island |
|---|---|
idle: true | Once the page has loaded and the browser is idle |
load: true | Once the page has loaded |
visible: true | Once it scrolls into view |
interaction: true | On the first click or touch inside it (a string names other events) |
media: '(min-width: 768px)' | Once the media query matches |
'save-data': 'false' | Only if the browser doesn't ask to save data (or, set to true, only if it does) |
An island with several conditions waits for all of them, and on: {}
hydrates it right away.
Props are written into the page as JSON, so pass data, not functions. Children are rendered on the server and kept as they are. Interactive components in the guide walks through building one.
Components read build-time values from #theme/config, a module the generator
writes for each build. Import only what you need, and the rest is left out of
the bundle:
import { project, version, pages } from '#theme/config';
| Export | Value |
|---|---|
version | The documented version as a semver object: version.version is '22.11.0', version.major is 22. |
versions | The version selector's entries, { url, label, major }, with {path} left in url. |
editURL | editURL with everything but {path} filled in, or undefined. |
pages | [name, path] pairs for every module page, in the default sidebar's order. |
documentationIndex | What <DocumentationIndex /> lists: { api, name, index, description } for every module page but index. index is the stability, if any. |
chunks | Each split module's section pages, by the module's path: { label, items }, nested by heading depth. Empty without section pages. |
languageDisplayNameMap | A Map from arrays of a language's names and aliases to its display name, for code block labels. |
server | true in the server build, false in the browser. |
Every other option of the html section is exported under its own name too,
including the global options it inherits, such as project, repository,
baseURL, useAbsoluteURLs, navigation, showSearchBox and
remoteConfigUrl. The exceptions are changelog, index, imports,
virtualImports, stylesheets, components, head and bundler.
repository, baseURL and remoteConfigUrl are undefined when unset.
For values of your own, virtualImports adds modules next to it. Each key is a
module ID, and each value the module's source:
export default { html: { virtualImports: { '#theme/site': `export const chat = 'https://chat.example.com';`, }, }, };
A component then imports it like any other module:
import { chat } from '#theme/site'.
The site moves between its own pages client-side, through the Navigation API: following a link fetches the next page, prefetched when the link is hovered or pressed, and swaps it into the current document. Scripts, stylesheets, fonts, the search index and the remote configuration stay loaded, and back and forward, scrolling and focus behave as they do for full loads. Links outside the site, files that aren't pages, pages that load other scripts or stylesheets (such as a newer deployment's), and browsers without the Navigation API get regular navigations.
When you customize the site, keep in mind that:
- The whole
<body>is replaced, and scripts in it don't run. Of the<head>, only the<title>and the page-specific tags follow the page:<meta>tags, and<link>tags other than stylesheets and preloads (such ascanonical). - Islands are unmounted and hydrated again on every page. Anything shared across pages belongs in module scope, which lasts for the whole visit. An island that scrolls keeps its position when the next page renders it too.
- Once the document has navigated client-side,
<html>has adata-navigatedattribute, for styles that only belong on the first page, such as entry animations. - Analytics can count client-side page views through the Navigation API's
navigatesuccessevent.
The bundler option takes a small adapter, so the site can be built with Vite
(the default) or any other bundler.
Worker threads receive a copy of the configuration, and functions can't be
copied. A configuration that sets bundler, the Vite adapter included, must
build with threads: 1 (or --threads 1). With more threads, the build
fails with a DataCloneError.
An adapter is an object with three methods. The generator calls buildServer
and buildClient once each and compile once per page, then renders the pages
from the compiled modules across the worker threads.
buildServer(options)FunctionBundles the component library for Node.js and returns a promise of thefile:URL of the built module.Attributesentry:stringThe library's source: re-exports of every component a page may render, Preact'shandFragment, andrenderToStringAsync.virtualImports:ObjectIn-memory modules to make available, by module ID,#theme/configincluded.outDir:stringA temporary directory to write the library to, which the generator removes afterwards.config:ObjectThe resolvedhtmlconfiguration.compile(code, fileName)FunctionTurns one page module, written in JSX, into plain JavaScript Node.js can import, and returns a promise of it. Compile JSX with the classic runtime, to calls of the_jsxand_Fragmentbindings the module imports from the library, so the page and the library share one Preact.buildClient(options)FunctionBundles the client entry intoconfig.outputand returns a promise of the assets every page loads:{ scripts, preloads, stylesheets, fonts }, as paths relative to the output directory.preloadsare the chunks the scripts import statically, rendered asmodulepreloadhints, andfonts(optional) are fonts to preload.Attributes
Both builds must compile the components' JSX for Preact, handle CSS imports and
CSS modules, resolve the config.imports aliases, and serve the
virtualImports. In the server build, #theme/config exports server as
true; in the client build, false. Also:
- Bundle the server library into one self-contained module, with no external dependencies: the page modules import it from a temporary directory.
- Put the client's CSS in stylesheets that every page loads up front, rather than splitting it per chunk. Islands hydrate after the page is on screen, so CSS that arrives with their chunks would style it late.
- Name client files after a hash of their content, so hosts can cache them indefinitely.
An adapter can live in your project, without changes to doc-kit. Pass it as
bundler, with threads: 1:
export const createWebpackBundler = webpackOptions => ({ async buildServer({ entry, virtualImports, outDir, config }) { // Run webpack's Node.js target on `entry`, serving `virtualImports` and // the `config.imports` aliases, write one self-contained module under // `outDir`, and return its `file:` URL. }, async compile(code, fileName) { // Transform the JSX (classic runtime, pragma `_jsx`, fragment pragma // `_Fragment`) and return the module's source. }, async buildClient({ entry, virtualImports, config }) { // Run webpack's browser target into `config.output` and return // { scripts, preloads, stylesheets } as output-relative paths. }, });
Without a bundler, the generator builds with Vite. To configure Vite, create
the adapter yourself with createViteBundler, passing it a Vite configuration:
import { createViteBundler } from '@doc-kit/generator-react/html/bundlers/vite'; import myVitePlugin from './my-vite-plugin.mjs'; export default { threads: 1, html: { bundler: createViteBundler({ plugins: [myVitePlugin()], define: { 'process.env.ANALYTICS_ID': JSON.stringify('UA-XXXXX'), }, }), }, };
Your configuration is merged in first, then the generator sets what coordinates its builds:
- Replaced:
configFile,base,appType,publicDir, the build's inputs, output directory, output format and asset file names,build.write,build.emptyOutDir,build.copyPublicDir,build.watch,build.lib, CSS code splitting (off in the client build), the server-side rendering settings, the JSX settings (Preact's automatic runtime), andcss.transformer, which is always Lightning CSS (its own options, undercss.lightningcss, are yours). The server build, which never ships, also hasbuild.manifestandbuild.minifyoff. - Merged:
resolve, with the aliases and conditions the generator adds. Yourpluginsrun after the generator's virtual-module plugin, so they can transform every module of both builds. - Yours, with these defaults:
Attributes
Vite builds the client from a module entry, not from the HTML pages, so
plugins never see the pages and transformIndexHtml hooks don't run. Change
the pages through the HTML template instead.