npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@waveso/docs

v0.12.0

Published

Zero parser bytes in the browser: markdown docs for Next.js, built to hast in Node and rendered as your components

Readme

@waveso/docs

npm license React 19 Next.js 16


Why Wave Docs

Point it at a folder of .md files and you get a documentation site: routing, navigation, a table of contents, syntax highlighting, search and redirects.

The design decision everything else follows from: markdown becomes hast in Node, at build time. A hast tree is plain serialisable JSON, so Next renders it inside a Server Component and the browser receives a tree of nodes and a component map — never unified, never remark-parse, never Shiki.

content/*.md ──▶ source ──▶ render ──▶ { hast, toc, frontmatter }
                 (Node)     (Node)              │
                                          RSC payload
                                                │
                                        <DocContent hast={…} />

Three things follow from that shape, and they are the reasons to choose this over the alternatives.

Nothing is stringified to HTML. The pipeline stops at hast, so the output stays data. You map h2, a, img, pre and callout onto your own components, and nothing is ever handed to dangerouslySetInnerHTML.

Table-of-contents anchors cannot drift. Heading ids are read off the same pass that annotated the document, rather than recomputed by a second parse. Two sections called "Install" get #install and #install-1, and the TOC links match — by construction, not by coincidence.

Broken internal links fail the build. [auth](./api/auth.md) is the right way to link between markdown files: it resolves on GitHub and in every editor preview, and it 404s once published. Those links are rewritten to routes and their targets checked, so the failure lands in CI instead of in production.

Installation

pnpm add @waveso/docs

That is the whole installation. react and react-dom are required peers; next is optional, needed only by @waveso/docs/next.

Zod is not a peer. It ships as a dependency of this package, so your project's Zod — version 3, version 4, or none at all — is irrelevant and nothing conflicts. When you extend the built-in schema, take z from here rather than from your own install:

import { docFrontmatterSchema, z } from '@waveso/docs/frontmatter';

That is not a style preference. .extend() produces a schema only as trustworthy as the instance that built it, and re-exporting ours means the extension is built from the same module object by construction rather than by luck. Your own Zod stays yours, for everything else in your app.

There is no tailwindcss peer and no Tailwind involved. The stylesheet is plain CSS with wave-docs-* class names. It was declared as an optional peer once, which blocked npm install outright for any project on Tailwind 3 — npm still range-checks an optional peer that happens to be installed.

There is no image-size peer either. An imageResolver you write is welcome to read dimensions with it — but it is your dependency, in your own package.json. Declaring it here installed nothing and did not make await import('image-size') resolve for you; it only looked like it helped.

What it costs

Every figure below is a ceiling, and pnpm size fails the build if the measurement passes it — in CI and again in prepublishOnly. So these are numbers this package is held to, not numbers somebody remembered to update.

| | At most | | --- | --- | | Everything the quick start ships, gzipped | 14.9 KB | | Search dialog and router wiring | 9.8 KB | | Navigation: one sidebar, open and closed | 3.1 KB | | Table of contents | 1 KB | | Copy-button runtime | 1.1 KB | | hast over the wire vs HTML, prose page | 1.20× | | hast over the wire vs HTML, code and tables | 1.12× | | Highlighting vs no highlighting | 2.00× |

The first row is the honest total: a reader who lands on a page of your documentation downloads under 14.9 KB gzipped of JavaScript from this package, and that is the whole of it. No markdown parser and no syntax highlighter reach the browser at all — those run in Node at build time. Drop the search dialog and it is under 4 KB.

The one real cost is the middle pair: shipping a tree instead of a string is about 20% more brotli on a prose page, and about 12% on a page with code and tables, where Shiki's token spans dominate both representations equally. That is the price of never handing markup to dangerouslySetInnerHTML, and it is the first number a skeptical reviewer should ask for.

size-budget.json holds a second, looser ceiling per entry with a note explaining what to do when it is hit — and a build fails if the table above ever promises worse than that file enforces.

Quick start

Three route files, and each one earns its place. [...slug] does not match /docs itself, so the index needs its own page.tsx — an optional catch-all ([[...slug]]) does match, but leaves /docs/index live and serving byte-identical HTML with no canonical between them. The third serves the search index, which the layout's search trigger reads.

Create the route once, in a module every route file imports:

// lib/docs.ts
import { createDocsRoute } from '@waveso/docs/next';

export const docs = createDocsRoute({ contentDir: 'content/docs' });
// app/docs/[...slug]/page.tsx
import { docs } from '@/lib/docs';

export default docs.Page;
export const generateStaticParams = docs.generateStaticParams;
export const generateMetadata = docs.generateMetadata;
export const dynamicParams = false;
// app/docs/page.tsx
import { docs } from '@/lib/docs';

export default docs.IndexPage;
export const generateMetadata = docs.generateMetadata;
// app/docs/layout.tsx
import '@waveso/docs/styles.css';
import { docs } from '@/lib/docs';

export default docs.Layout;
// app/docs/search-index.json/route.ts
import { docs } from '@/lib/docs';

export const GET = docs.searchIndex;
export const dynamic = 'force-static';
content/docs/
  index.md
  getting-started.md
  api/
    meta.json
    authentication.md

That is a working documentation site: routing, a navigation sidebar that opens and closes, a table of contents, syntax highlighting, search and a skip link.

The search route is in the quick start rather than in a section further down because docs.Layout renders the search trigger by default — leave the route out and a reader gets a control that opens onto "Search is unavailable". If you genuinely do not want search, export default function Layout(props) { return docs.Layout({ ...props, search: false }) } drops both the trigger and this file. See Search for tuning.

[!IMPORTANT] dynamicParams must be written out as false. Route segment config is parsed statically before the module runs, so export const dynamicParams = docs.dynamicParams fails next build. Without it, Next invokes the route on a server at request time for every unlisted URL, to produce a 404 that was already knowable at build time — and output: 'export' refuses to build at all.

Entry points

There is no root export. Every entry point is a subpath, so an import always names the file it came from.

| Subpath | Environment | Contents | | --- | --- | --- | | @waveso/docs/next | Node | createDocsRoute, createDocsSitemap, createDocsRedirects | | @waveso/docs/source | Node | createDocsSource, resolveDocsConfig | | @waveso/docs/render | Node | createDocsRenderer, resolveMarkdownLink | | @waveso/docs/highlighter | Node | createDocsHighlighter, DEFAULT_DOCS_LANGS, DEFAULT_DOCS_THEMES | | @waveso/docs/search-index | Node | extractSearchRecords, buildSearchIndex | | @waveso/docs/react/<name> | Browser + RSC | Ten subpaths, one component each — see Components | | @waveso/docs/frontmatter | Any | docFrontmatterSchema, parseFrontmatter, z | | @waveso/docs/types | Any | Every shared type. Type-only | | @waveso/docs/errors | Any | DocsErrorCode, DocsError, isDocsError, DOCS_ERROR_PREFIX | | @waveso/docs/styles.css | — | The stylesheet |

The Node-only subpaths carry "browser": null, so importing one from client code fails with a located module not found rather than resolving.

That is about weight, not about node:fs — and the distinction matters, because three of the five would bundle perfectly happily. render, highlighter and search-index require no Node builtins at all; the markdown pipeline runs wherever JavaScript does, and Shiki is loaded through its JavaScript regex engine rather than WASM on purpose. What a bundler would do with them is succeed, and ship unified, remark-parse and every Shiki grammar to a reader — the exact outcome this package exists to prevent, arriving with no error to notice. Only source and next genuinely need the filesystem.

entry-runtime.test.ts asserts each set exactly, so a new builtin three modules deep fails the build instead of silently ruling out a non-Node runtime.

Layout tokens

Five custom properties size the shell, all layered so an unlayered :root of your own still wins. All six are public API: a name changes only in a release that carries the migration.

| Token | Default | Controls | | --- | --- | --- | | --wave-docs-measure | 46rem | Prose column width. none opts out | | --wave-docs-sidebar-width | 16rem | The navigation's width | | --wave-docs-trigger-width | 1.25rem | The trigger's button. The strip around it is this plus 4px a side | | --wave-docs-toc-width | 15rem | Table-of-contents track | | --wave-docs-chrome-offset | 0rem | Where our sticky chrome starts, below a bar of yours |

--wave-docs-shell-width was removed in 0.7.0, and it is the sidebar's edge that replaced it. It capped the whole shell and centred it, which put the sidebar's inline start 480px in from the screen on a 2560px display — and, worse, left a closed navigation parked in the centring margin instead of off the page. The reading column is the thing that should not stretch, so --wave-docs-measure caps it and it centres itself in its track; the sidebar keeps the page's inline start edge at every width, which is what makes "closed" mean off the screen by construction.

--wave-docs-header-height is gone in 0.7.0, and renaming it to one thing would have lost the other. It sized the header and it was the offset both sticky columns parked below. The header is gone, and so is every shape that sat above the content, so the sizing half has nothing left to size. The offset half is the one that matters and it is --wave-docs-chrome-offset: --wave-docs-chrome-offset: 4rem starts our sidebar and our table of contents 4rem down, and feeds the scroll padding that keeps an anchored heading clear of your bar.

The default is 0rem rather than 0, and the unit is load-bearing: it is read inside calc(100dvh - …) on both sticky columns, and calc(100dvh - 0) is invalid at computed-value time — a unitless zero kills the max-height instead of resolving to no change.

The shell has three breakpoints, in rem so they scale with the reader's base font size: the sidebar appears at 64rem, the table of contents at 80rem, and the whole grid stops growing at 100rem. 64rem is arithmetic rather than taste — a 16rem sidebar plus a 46rem measure plus two 1.5rem gutters is 65rem, so anything narrower introduces the sidebar exactly where it starts eating the measure it frames.

Set --wave-docs-font-sans: inherit to hand the whole package your own typeface.

Every subpath is enumerated in exports — there is no wildcard. A name that is not documented here is not importable, and that is a guarantee rather than an intention: manifest.test.ts enumerates the runtime exports of every built subpath and fails the build on one this README does not mention.

Components

Every component takes data as props, and every module that imports from next/* is named for it — next-nav for usePathname, next-search for useRouter, next-link for next/link itself — so the exception is visible in the file list rather than three imports deep. Everything else has next/link and next/image injected. That keeps the renderer host-agnostic and testable without a router. DocsSearch is the one exception, and it exists precisely so that the exception is ours rather than yours: it is the fifteen-line wrapper you would otherwise write around SearchDialog.

| Component | Subpath | Notes | | --- | --- | --- | | DocContent | react/doc-content | Renders a hast tree, inside .wave-docs-prose. Server Component | | DocsHero | react/hero | A landing page's header. title, description, actions, Link, externalLabel. Server Component | | DocsSidebar | react/sidebar | Takes pathname as a prop, not from next/navigation. icons controls the marker column — see Sidebar icons | | DocsExplore | react/explore | "Where to go next": a question per row and the page that answers it. docs.Page renders it when a page declares explore | | DocsPager | react/pager | Links to the pages either side of this one. docs.Page renders it; pager: false on the route omits it | | DocsToc | react/toc | Scrollspy via IntersectionObserver. label, topLabel, rootMargin, className | | DocsSearch | react/next-search | SearchDialog, wired to Next's router. What you want | | DocsLink | react/next-link | next/link, adapted — pass it as Link when composing by hand | | SearchDialog | react/search-dialog | ⌘K, arrow keys, focus trap. Host-agnostic | | Callout | react/callout | Note · tip · important · warning · caution. CALLOUT_TYPES is the list | | YouTube | react/youtube | Click-to-load facade. title, playLabel, hideLabel{title} interpolates the first | | SkipLink | react/skip-link | Targets docs.Page's <main>; DOCS_CONTENT_ID is that id. docs.Layout renders one | | createMarkdownComponents | react/markdown-components | The element → component map. defaultMarkdownComponents is the unwired one |

DocsToc's rootMargin is the IntersectionObserver margin that decides how far above the viewport a heading counts as current; the default keeps the highlight on the section you are reading rather than the one about to arrive. topLabel is the back-to-top link at the end — it fades in once the reader is about a third of a screen down and fades out again on the way back, on a scroll timeline rather than a scroll listener, so the component ships no extra bytes to do it. Where that timeline cannot run — an engine without scroll-driven animations, a page too short to scroll, or a host that scrolls an inner pane rather than the document — the link is simply always there.

Where to go next

A sidebar is a structure; this is a router. It says why a reader would go somewhere, which no tree of titles can. A page opts in from its frontmatter:

---
title: How it fits together
explore:
  - question: How a person is recognised across servers
    href: ./identity.md
  - question: What happens when the network fails
    href: ./delivery.md
  - question: Where the source lives
    href: https://github.com/example/repo
    title: GitHub
---

| Field | Type | What it is | | --- | --- | --- | | question | string | What the reader might want to know. The row's left half | | href | string | Where the answer is. Checked against the same allowlist as every other link | | title | string | The link's text. Defaults to the destination's title in the navigation |

The link text comes from the navigation, so renaming a page updates every block pointing at it — the same source the sidebar and the pager read. Name a title only where the tree cannot answer: an external link, or a page kept out of the navigation. An href that resolves to neither stops the build and says which of the two fixes to reach for, rather than rendering a URL where a sentence should be.

It renders above the pager: this is the semantic answer, the pager is the linear one.

It wears the panel — a framed block with a header and an inset surface, styled by .wave-docs-panel, .wave-docs-panel__header, .wave-docs-panel__title, .wave-docs-panel__actions and .wave-docs-panel__body. That is a shared primitive rather than this component's furniture, so a code frame can wear it next without a second copy of the same rules. The two radii are not independent: the inner one is the outer minus the panel's padding, or the corners run at different curvatures and the surface reads as pasted onto the frame instead of set into it. --wave-docs-radius-lg is picked so the arithmetic lands on --wave-docs-radius.

The panel also exports --wave-docs-panel-inset for anything placed inside __body: the header's title sits at the frame's padding, but body content sits at that padding plus the body's own border, so the two columns miss each other by a pixel per border unless the inset is used.

DocsExplore takes steps (each with question, href and a resolved title), plus heading, Link, externalLabel and className. The heading defaults to 'Where to go next' and is explore in labels. Under a 40rem container the question and its answer stack instead of sharing a row.

The pager

docs.Page renders it under every page, so most sites never touch it. The order is the navigation's — flattened from the same tree DocsSidebar renders — so a pager that disagrees with the sidebar beside it is impossible. Nothing is authored: a page gets one by being in the tree, and a page outside it (a draft, or a route you render yourself) gets none.

Separators and external links are not stops. A separator is a label with nowhere to go, and a "next page" that lands on npm has ended the sequence rather than continued it. A directory with an index.md contributes its own page before its children, which is the order its rows appear in.

| Prop | Type | Default | What it is | | --- | --- | --- | --- | | previous | NavStop | — | The stop before this page. Omit at the beginning | | next | NavStop | — | The stop after it. Omit at the end | | Link | DocsLinkComponent | <a> | Client-side router link | | previousLabel | string | 'Previous' | Above the previous page's title | | nextLabel | string | 'Next' | Above the next page's title | | label | string | 'Pagination' | Accessible name for the landmark | | className | string | — | Extra classes |

With neither neighbour it renders nothing at all, rather than an empty landmark. Set pager: false on createDocsRoute to omit it everywhere, and the three strings through labelspreviousPage, nextPage, pagination.

Sidebar icons

Every row in the sidebar carries a marker at its head: a folder on a group, a page on a page, an arrow on a link that leaves your site. Weight and a chevron were the only difference before, and where categories and pages interleave that is not enough to scan.

Three glyphs ship. No icon set does, and none ever will — this package is mounted inside applications that already have one, and a second vocabulary beside theirs is worse than none. Your own icons come in by name:

# content/reference/index.md
---
title: Reference
icon: book
---
// content/reference/meta.json — for a directory with no index page,
// and for hand-written links
{ "title": "Reference", "icon": "book", "pages": [{ "title": "npm", "href": "https://npmjs.com", "icon": "package" }] }
import { DocsSidebar } from '@waveso/docs/react/sidebar';
import type { DocNavNode } from '@waveso/docs/types';

// Yours: `lucide-react`, your design system, or hand-written. Rendered with no
// props, in a 1rem box — `currentColor` and `100%` keep it in line with the
// built-ins and with the row it sits on.
const Book = () => (
  <svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" stroke="currentColor">
    <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V2H6.5A2.5 2.5 0 0 0 4 4.5v15Z" />
  </svg>
);

export function Nav({ nav, pathname }: { nav: DocNavNode[]; pathname: string }) {
  return <DocsSidebar nav={nav} pathname={pathname} icons={{ book: Book }} />;
}

A name with no entry in the map falls back to the built-in marker for that node's type — a typo in one file leaves a folder where a book should be, not a hole in the column. The component is rendered with no props, in a 1rem box, and the built-ins use currentColor, so anything following those two conventions sits in line with them.

icons={false} removes the column entirely. The external-link mark moves back to the trailing edge there: turning off a decorative column is not consent to drop a warning that a link leaves your site.

The two components the adapter injects take a little more than an <a> and an <img>. DocsLinkProps adds prefetch — passed straight to next/link, where false disables the hover and viewport paths both, so it is a stronger switch in the App Router than the name suggests. DocsImageProps carries src, alt, width and height — the four next/image refuses to render without — and adds sizes, loading, decoding and fetchPriority, forwarded to it; markdown carries none of them, so they come from your imageResolver or from a components override. decoding defaults to async, and loading to lazy — except on an image the author marked eager, which is usually the page's largest element.

Layout

export default docs.Layout — the one line from the quick start — is a Server Component that renders the whole shell: skip link, sidebar, search trigger, and the grid that arranges them. It reads the navigation tree and the search index URL itself, so there is nothing to fetch and nothing to pass.

It renders no header, and that is deliberate. Two kinds of site use this package: documentation mounted inside an application that already has a header, a navbar and its own search, and documentation that is the whole site. A full-width sticky bar of ours serves the second and fights the first — two stacked bars competing for the viewport's top edge, and two search boxes on one page, one of which knows nothing about the documentation.

So the sidebar is the chrome. It is a real grid item at every width, and it is the same shell on a phone and on a desktop.

Nothing this package renders is anchored to the viewport. The sidebar is a grid item, the trigger is a flex child of it, and the scrim is position: absolute inside .wave-docs-layout — so every one of them resolves against a box this package owns and your layout placed. position: fixed is the thing to avoid, and the reason is specific: a fixed element is anchored to the viewport you share with it, your navbar is in the same viewport, and neither can detect the other. The search dialog is the one exception and always was — top-layer, present only while open, and nothing collides with something that is not there.

Your layout stays a Server Component. The two pieces that need a client — the navigation's usePathname, the search dialog — carry their own 'use client' boundaries inside the package.

Your own chrome goes around docs.Layout, in the layout file you already write — the same place <html> and <body> live. Call it instead of re-exporting it when you want to wrap it, configure it, or both:

import type { ReactNode } from 'react';
import '@waveso/docs/styles.css';
import { docs } from '@/lib/docs';

/** Yours: the header, theme toggle and repository link the rest of the site has. */
declare function SiteHeader(): ReactNode;

export default function DocsLayout({ children }: { children: ReactNode }) {
  return (
    <>
      <SiteHeader />
      <docs.Layout search={{ placeholder: 'Search the docs' }}>
        {children}
      </docs.Layout>
    </>
  );
}

If that header of yours is sticky, say how tall it is once and the shell's sticky columns start below it:

:root {
  --wave-docs-chrome-offset: 4rem;
}

| Prop | Type | Default | | | --- | --- | --- | --- | | children | ReactNode | — | What docs.Page returns — the <main> and the TOC, as two siblings | | search | boolean \| DocsSearchProps | true | The search trigger. An object configures the dialog | | labels | DocsLabels | the route's | Overrides createDocsRoute's labels, key by key |

Three props, and one of them is children. That is deliberate, and it is the difference between this and an eleven-slot layout: everything else a docs shell gets asked for is already reachable. An announcement banner goes above <docs.Layout> in your own layout, because this does not own <body>. A content footer goes inside children. Sidebar links, social icons and separators are DocNavNodes you author in meta.json. A theme toggle and a repository link go in the layout you write around this one.

title and actions were removed in 0.7.0, with the header they lived in. title was a brand slot, and a brand belongs to the index page's own title — content, authored and translatable, part of what the reader came for. The argument for actions was that the header bar was the one region nothing else could reach; there is no header bar, and the host wraps docs.Layout exactly as it already wraps <html> and <body>, so there is no region only this package can reach. The one place a host cannot reach through this prop list is inside the sidebar, and the answer to that is composing the primitives yourself.

search takes anything DocsSearch takes except indexUrl, which stays derived from your basePath. You do not need to repeat miniSearchOptions here to match createDocsRoute — the route's own value is forwarded, so the object that built the index is the object that queries it.

labels belongs on createDocsRoute — see Translating the chrome — and this prop overrides it key by key, for a site with two shells or a section in another language.

One sidebar, open and closed

There is no mobile version. The sidebar is a shell holding two things in a row:

.wave-docs-shell                         the query container
└─ .wave-docs-layout                     the grid
   ├─ .wave-docs-layout__sidebar         paints nothing, and moves
   │  ├─ …__sidebar-nav                  the surface, and the one border
   │  └─ …__sidebar-trigger              the strip — paints nothing at rest
   ├─ .wave-docs-layout__sidebar-scrim
   ├─ .wave-docs-layout__main
   └─ .wave-docs-layout__toc

Pressing the trigger translates the shell by calc(var(--wave-docs-trigger-width) - 100%) — "minus all of me, plus the trigger back" — so the navigation goes entirely off the page and the trigger's outer edge lands exactly on the inline start edge. The navigation's width appears nowhere in that expression, so the two cannot drift apart. The trigger rides on the navigation's outer edge because it is the next flex item, not because a number says so.

One navigation in the DOM at every width: one landmark, one copy of the links in the payload, nothing to keep in step.

It adapts to its container, not to your screen

There is not one width-based @media query in this package. Every breakpoint is @container, and that is not a stylistic preference — it is the difference between a docs theme and a component you can mount inside something else.

@media asks how wide the screen is. If you put this in a 700px panel on a 1920px monitor, @media says "wide", the sidebar takes its 16rem column, and the reading column comes out around 60px. @container asks how wide the box you gave it is, which is the question with an answer.

Two shapes fall out of that:

  • Push, in a container 64rem or wider: the navigation sits beside the article, and opening or closing it changes the article's width.
  • Cover, below that: the navigation sits on top of the article behind a scrim, and the article's measure never changes when you toggle.

Same markup, same classes, same control, same translate. The only thing that differs is whether the article gets out of the way.

Cover mode is a real overlay, so it ships what an overlay owes a reader: inert on everything the navigation covers, Escape to close, click-the-scrim to dismiss, and focus moving into the navigation and back out again. Those five were the browser's while this was a <dialog>; they are hand-written now, and skipping them is how an overlay becomes a keyboard trap in the wrong direction.

--wave-docs-sidebar-mode is how the component knows which shape it is in: the stylesheet declares it, the component reads it back with getComputedStyle. matchMedia cannot answer a container query, and duplicating the breakpoint in JavaScript is how the two drift.

Three states, and no flash

The server renders no data-state at all. That absence means "nobody has chosen yet", and CSS resolves it per mode — closed where the navigation would cover the article, open where it would sit beside it. So the first paint is already right at both shapes, with no JavaScript and nothing to correct. Once a reader presses the trigger their choice is explicit and wins at every width.

This replaced a <dialog> drawer in 0.7.0. Below 64rem the navigation used to be a modal opened by a second control, with display: contents above it so the same DOM could serve as the desktop column. It bought focus trapping, Escape and a scroll lock from the browser, and it worked before hydration. It cost two controls for one piece of navigation, a drawer that painted over the tree it contained, and a scroll-into-view that could never run on a phone — a closed <dialog> has no layout, so the current page was always below the fold. A closed sidebar is moved, not hidden, so that last one is structurally impossible now.

Composing it yourself

docs.Layout is one opinion, not a tax. The components underneath are exported individually and take data as props, so a shell of your own is DocsSidebar + DocsToc + SkipLink + DocsSearch with your own CSS — and docs.getPage(segments) gives you the parts a custom page needs:

// The catch-all page, written out instead of re-exporting `docs.Page`.
import { notFound } from 'next/navigation';
import { DocContent } from '@waveso/docs/react/doc-content';
import { DocsToc } from '@waveso/docs/react/toc';
import { docs } from '@/lib/docs';

export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
  const { slug } = await params;
  const doc = await docs.getPage(slug ?? []);
  if (!doc) notFound();

  return (
    <>
      <main className="wave-docs-layout__main" id="docs-content" tabIndex={-1}>
        <DocContent hast={doc.hast} />
      </main>
      {doc.toc.length === 0 ? null : (
        <aside className="wave-docs-layout__toc">
          <DocsToc entries={doc.toc} />
        </aside>
      )}
    </>
  );
}

docs.Page returns exactly this shape: the <main> and the table of contents as two siblings, not one wrapped element. They land as direct children of the grid, which is what puts them in separate columns — so if you compose your own page inside docs.Layout, return a fragment rather than a wrapper.

The two class names are load-bearing, and they are the part of this that is easy to leave off. wave-docs-layout__main carries min-width: 0, without which a wide table pushes the whole document into horizontal scroll (measured: 1048px of document inside a 1024px viewport). wave-docs-layout__toc is what the grid reserves its third track with, via :has() — unclassed, the table of contents auto-places into the next row underneath the sidebar above 80rem, and renders inline on a phone instead of being hidden. Both names are public API and change only in a release that carries the migration, so they are safe to write by hand.

The null is load-bearing too: :has() matches an empty <aside> exactly as well as a full one, so a page with no headings would give up 15rem to nothing.

Frontmatter

---
title: Authentication             # required
description: Bearer tokens.       # <meta name="description"> and search
label: Auth                       # sidebar label, when the title is too long
draft: true                       # excluded from nav, search and static params
order: 10                         # sort weight where there is no meta.json
aliases: [old-auth, legacy/auth]  # former URLs → permanent redirects
actions:                          # calls to action — and the opt-in for a hero
  - label: Quick start
    href: /getting-started
  - label: GitHub
    href: https://github.com/waveso/docs
    variant: secondary
---

title is required on every .md file in the tree. A file without one fails the build rather than shipping an untitled page.

draft is deliberately not tied to NODE_ENV. Preview deployments are production builds, so branching on it would hide drafts in exactly the place reviewers look — drive includeDrafts from your own environment check instead.

The hero

actions is the only thing that turns a page into a landing page. Declare it and title and description become a page header — a large heading, the description as a tagline under it, and these links beneath that. Leave it off and the page is exactly what it was: description stays a <meta> tag and the title is the first thing in the prose.

Each action takes a label, an href and an optional variant of primary or secondary. Omit the variant and the first action is the primary and the rest are secondary, which is the shape every landing page has. An href that leaves the site gets target="_blank", rel="noreferrer" and a screen-reader suffix; mailto: and tel: do not, because they open no tab. Unsafe hrefs fail the build rather than reaching an <a>.

That is the whole of the adaptation for the two shapes this package serves. Documentation that is the entire site puts a hero on its index. Documentation mounted at /docs inside an application that already has a marketing page leaves actions off and gets an ordinary page. There is no mode, no standalone flag and nothing to configure — the opt-in lives in the file that wants it.

⚠️ A hero page must not also write its own # Title. render normally prepends an <h1> from frontmatter.title; on a hero page the hero renders that heading instead, because the tagline and the actions have to sit beneath it. Writing one in the body as well ships two h1s — the same duplication titleHeading has always warned about.

The background is a rotated line grid under scrims painted in the page's own colour rather than behind a mask: a soft ellipse over the words, a bottom fade, and a vignette that closes at the corners. The vignette is an inset box-shadow rather than a gradient — a radial-gradient is only ever a circle or an ellipse, and this one needs a corner radius. border-radius is that knob, and corner-shape: superellipse(3) makes it a squircle where the browser supports it. Alpha compositing of a solid blends where mask layers multiply, so the falloff is smooth instead of compounding into a shoulder. It is drawn with repeating-linear-gradient rather than an inlined SVG — no data URI in the stylesheet. The lines are --wave-docs-hero-grid and --wave-docs-hero-grid-strong, which are Wave 200 and Wave 300 from @waveso/ui in the light theme and Wave 900 and Wave 800 in the dark one. They are tokens of their own rather than the border colours: the grid is decoration behind a mask that leaves it near-invisible where the words are, and a border is a boundary a reader has to be able to see.

Your own fields

Pass a frontmatterSchema and every DocFile and RenderedDoc carries your fields, inferred, with no type argument anywhere:

// content/docs-schema.ts — one module, imported by every route file
import { docFrontmatterSchema, z } from '@waveso/docs/frontmatter';

export const frontmatterSchema = docFrontmatterSchema.extend({
  audience: z.enum(['user', 'operator']).exactOptional(),
});
import { createDocsRoute } from '@waveso/docs/next';
import { frontmatterSchema } from '@/content/docs-schema';

const docs = createDocsRoute({ contentDir: 'content/docs', frontmatterSchema });

const doc = await docs.getPage(['api', 'auth']);
doc?.frontmatter.audience; // 'user' | 'operator' | undefined
doc?.frontmatter.title; //    string

Any Standard Schema validator works — Zod, Valibot, ArkType. The field is typed StandardSchemaV1<unknown, TFrontmatter> rather than as a Zod type, so the package does not dictate your validator; a schema you hand over is never re-wrapped by the Zod in here. The z above is re-exported from this package precisely so that extending docFrontmatterSchema needs no install and no matching version.

Four things are worth knowing before you write one.

Let the type be inferred — never name it. Naming it explicitly and omitting the schema type-checks and then lies, because nothing validates the type you named:

// ⚠️ Compiles. Every extra field is `undefined` at runtime, typed as present.
const docs = createDocsRoute<MyFrontmatter>({ contentDir: 'content/docs' });

Unknown keys are stripped, by Zod and by every other validator worth using. Declare every field you intend to read — under the base schema, a page with audience: operator parses fine and silently loses the value. docFrontmatterSchema.extend(…) keeps the built-ins; a z.object({ … }) written from scratch does not.

The package's own fields survive a schema that forgets them. title drives the <h1> fallback and <title>, draft the visibility filter, aliases the redirects, order and label the sidebar. These are parsed from the raw YAML and merged over your schema's output, so a custom schema can only ever add fields — it cannot drop or corrupt the ones the package reads itself.

That is a runtime guarantee, not a compile-time one, and the difference matters: TFrontmatter extends DocFrontmatter constrains only title, because the rest are optional. A z.object({ title, audience }) type-checks perfectly and used to strip draft and aliases on the way through — publishing every draft, submitting them to Google, and silently returning no redirects at all. Prefer docFrontmatterSchema.extend(…) anyway: you then get the built-in fields in your inferred type, rather than merely at runtime.

Export the schema from one module. The filesystem scan is memoised per resolved config, and two schema objects count as the same schema only when they are the same object. Build one inline in each route file and each file pays for its own scan.

A page the schema rejects fails the build, naming the file, every bad path, and whose schema rejected it:

Invalid frontmatter in api/auth.md:
  - audience: Invalid option: expected one of "user"|"operator"
Fix the YAML block at the top of that file, or the `frontmatterSchema` in your docs config.

Navigation

One optional meta.json per directory controls order and labelling. Chosen over numeric filename prefixes because a filename cannot express separators, external links or a directory title.

{
  "title": "API Reference",
  "pages": [
    "index",
    "authentication",
    "---Advanced---",
    "...webhooks",
    "...",
    { "title": "Status page", "href": "https://status.example.com" }
  ]
}

| Entry | Meaning | | --- | --- | | "authentication" | A file or subdirectory in this directory, in this position | | "---Advanced---" | A non-interactive separator with the enclosed label | | "..." | Everything not named explicitly. At most one per file | | "...webhooks" | Expand the webhooks subdirectory inline, with no group wrapper | | { "title", "href" } | An arbitrary link. external is inferred from the href |

Omit pages entirely and the directory sorts by frontmatter order, then title — exactly what a lone "..." does. Naming an entry that resolves to nothing fails the build, with the meta.json path, the offending entry and the list of available names.

A group heading takes its meta.json title, else its index.md label, else its index.md title, else the directory name humanised.

Markdown support

GFM (tables, strikethrough, task lists, autolinks), GitHub alert syntax (> [!NOTE]<callout type="note">), heading ids and permalinks, dual-theme Shiki highlighting, and lone images unwrapped out of their paragraph.

Raw HTML in the source is dropped, not passed through. rehype-raw is not in the chain — on its own it happily reparses <script> back into the tree.

A bare YouTube URL on its own line becomes a click-to-load facade: one ~15 KB thumbnail instead of ~717 KB of embed and player JavaScript on page load. A labelled link keeps its label and stays a link.

Eighteen grammars load by default — what technical documentation actually contains:

typescript  tsx  javascript  jsx  json  shellscript  css  html
markdown    yaml  diff  sql  python  go  rust  prisma  ini  toml

A ```cfg fence (or ```conf) uses the ini grammar, because the fence an author types follows the filename — nobody writes ```ini above a file called server.cfg.

Anything outside that set falls back to plain text rather than throwing. Pass langs to change the set, or highlighter to supply your own. Fence languages are matched case-insensitively, so ```JSON and ```Bash highlight like their lowercase spellings rather than silently shipping monochrome.

Code blocks

Every highlighted fence is wrapped in a <figure> with a copy button. Add a title and it gets a bar:

```ts title="app/page.tsx"
export default function Page() {
  return <h1>Hello</h1>;
}
```

The title lands in three places at once — the caption, the button's accessible name (Copy code from app/page.tsx, rather than eight controls all called "Copy code"), and the search index.

Anything else in the meta string is left alone, so {1,3-5} and showLineNumbers pass through to Shiki untouched. A title= that is not double-quoted fails the build naming the document, because the alternative is a caption that silently truncates at the first space.

The copy button is one delegated listener for the whole page, mounted by DocContent — not a client component per code block. A page with no fences ships none of it. And it is visibility: hidden until that listener attaches, so a reader with JavaScript disabled sees no button and finds no dead tab stop where a control should be.

The <figure> carries data-lang (the folded language, so ```JSON gives json). No badge is rendered by default; one rule turns it on:

.wave-docs-code[data-lang]::before {
  content: attr(data-lang);
}

Keeping it in CSS is deliberate — a real element would enter the search index and textContent, so every code block would pollute search results with its language name and the copy button would copy it.

Fences you render yourself

excludeLangs tells Shiki to leave a language alone, so the <pre> reaches your own component untouched — for diagrams, or anything that is not really code:

import { createDocsRoute } from '@waveso/docs/next';

// In `lib/docs.ts`, beside the rest of your configuration.
export const docs = createDocsRoute({
  contentDir: 'content/docs',
  excludeLangs: ['mermaid'],
});

Those fences are deliberately not framed: a copy button on a rendered diagram copies its source, which is not what the reader clicked. They still get the same background, border and horizontal scroll as a highlighted block, so excludeLangs on its own produces a page that looks deliberate rather than unstyled.

To render them, map pre:

import { isValidElement, type ReactNode } from 'react';

/** Yours: a `'use client'` component wrapping whichever renderer you like. */
declare function Mermaid(props: { children: string }): ReactNode;

function textOf(node: ReactNode): string {
  if (typeof node === 'string') return node;
  if (Array.isArray(node)) return node.map(textOf).join('');
  if (isValidElement<{ children?: ReactNode }>(node)) {
    return textOf(node.props.children);
  }
  return '';
}

export const components = {
  pre: (props: { children?: ReactNode }) => {
    const child = props.children;
    const className = isValidElement<{ className?: string | string[] }>(child)
      ? child.props.className
      : undefined;

    /*
     * ⚠️ AN ARRAY, NOT A STRING. An excluded fence never reached Shiki, so its
     * `<code>` still carries hast's `["language-mermaid"]` — Shiki's own
     * output is a string. A `className === 'language-mermaid'` check compiles,
     * reads correctly, and silently never matches, so every diagram renders as
     * its own source.
     */
    const languages = Array.isArray(className) ? className : [className];

    if (languages.includes('language-mermaid')) {
      return <Mermaid>{textOf(props.children)}</Mermaid>;
    }
    return <pre {...props} />;
  },
};

Pass it as components to createDocsRoute, or to DocContent directly.

Mermaid is yours — a 'use client' component wrapping whichever renderer you like. This package deliberately does not ship one: several hundred kilobytes of client JavaScript with its own CVE history, behind an option most sites never set, in a package with three peer dependencies against Fumadocs' eighteen.

Images

Absolute and external sources just work. Put the file in public/ and write ![](/diagram.png).

![Architecture](/diagram.png)          ✅ served from public/
![Logo](https://example.com/logo.png)  ✅ external
![Architecture](./diagram.png)         ⛔️ needs an imageResolver

A relative source is a different thing. Nothing in public/ corresponds to it, and the browser would resolve it against the route — so /docs/guide and /docs/guide/setup request two different files from byte-identical markdown. Rather than ship that, a relative source with no imageResolver fails the build, naming the file and offering both fixes.

An imageResolver receives the source already folded against the markdown file's directory (./diagram.png in guides/deploying.md arrives as guides/diagram.png) and returns a public URL plus intrinsic dimensions — which next/image requires and markdown does not carry:

import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { imageSize } from 'image-size';
import { createDocsRoute } from '@waveso/docs/next';

createDocsRoute({
  contentDir: 'content/docs',
  imageResolver: async (src) => {
    const { width, height } = imageSize(
      await readFile(path.join('content/docs', src)),
    );
    return { src: `/docs-assets/${src}`, width, height };
  },
});

A source that climbs above the content root fails the build whether or not a resolver is configured.

Theming

Every colour is a --wave-docs-* custom property. Redefine the ones you want in your own :root, after the import:

@import '@waveso/docs/styles.css';

:root {
  --wave-docs-accent: oklch(0.55 0.2 265);
  --wave-docs-bg-subtle: oklch(0.98 0.004 265);
}

Corners

Three tiers, all derived from one root, so a box's radius is decided by what kind of box it is rather than by how big it happens to be.

| Token | What takes it | | --- | --- | | --wave-docs-radius-sm | Inline chips, small controls, and focus rings drawn on those | | --wave-docs-radius | Controls, overlays, and the panel's inset surface | | --wave-docs-radius-lg | Every block in the reading flow, and the panel's outer edge |

Retune all three from one line. --wave-docs-radius-base is the root and the other three are calc() off it, so a host already running @waveso/ui points this package at their scale and every corner follows — including any theme that moves it:

:root {
  --wave-docs-radius-base: var(--radius);
}

The numbers are @waveso/ui's to begin with. A page running both should not show two radius scales a few pixels apart, and taking theirs is how that is guaranteed rather than kept in step by hand.

⚠️ --wave-docs-radius-step is not decoration. The panel's inset surface takes the base radius and its frame takes --wave-docs-radius-lg, which is the base plus one step — so the two corners are concentric only while the frame's padding is that same step, which is why it is paid out of the token rather than written as 4px. Move the root and both stay true; hard-code the padding and they drift the first time anyone retunes the scale.

Where the browser supports corner-shape, every corner this package draws becomes a squircle and the root moves up, because a squircle reads tighter than a circular arc at the same radius. @waveso/ui makes the same move to the same value. Elsewhere it is an ordinary rounded corner at the original scale — an enhancement, never a dependency. The shaping is scoped to elements this package owns rather than applied with *: this stylesheet is mounted inside somebody else's page, and reshaping the host's corners is the same trespass as claiming html.

That works because everything this stylesheet declares lives in a @layertheme for the tokens, base for element resets, components for the classes — and unlayered CSS outranks every layer regardless of specificity.

The distinction matters. The dark tokens are declared as :root[data-theme='dark'], which is specificity (0,2,0). Outside a layer, an unlayered :root at (0,1,0) would lose no matter where it was loaded — the cascade never reaches source order — and overriding would mean writing :root:root:root. Layered, source order settles it and a plain :root is enough.

Dark mode is opt-in

| On <html> | Result | | --- | --- | | nothing | Light | | class="dark" | Dark | | data-theme="dark" | Dark | | data-theme="system" | Follows prefers-color-scheme |

.dark is honoured because next-themes defaults to attribute="class" and never sets data-theme.

This is deliberate, and it is a change. The tokens used to switch on prefers-color-scheme alone. But the stylesheet styles the docs subtree, not the page — so on a light-only site with a /docs section, a visitor whose OS was in dark mode got the near-white foreground ramp on the host's white background: 1.23:1, i.e. invisible. A stylesheet cannot assume it owns the page it is dropped into, so it now switches only when the host says to.

If your site really does follow the OS and has no theme toggle, say so once:

<html lang="en" data-theme="system">

To restyle rather than retheme, override the classes — .wave-docs-prose, .wave-docs-skip-link, and the rest — from your own unlayered CSS.

[!NOTE] If you retheme, re-check contrast. src/styles.test.ts asserts every foreground/background pair the shipped tokens compose clears WCAG 1.4.3 (4.5:1); none of the text here is "large" in the WCAG sense, so 3:1 is never enough.

Search

Build-time index, client-side dialog, MiniSearch. Records are section-scoped — one per h2h6 — so a hit deep-links to the right heading instead of dropping the reader at the top of a 2,000-word page.

Nothing to set up: docs.Layout renders the trigger, and the route file in the quick start serves the index. The index is a route rather than a build script, so it is rebuilt by the same next build that builds your pages, and in next dev it re-reads the disk per request — a page you add is searchable on the next keystroke, with no restart and no script to remember.

Outside docs.Layout, <DocsSearch indexUrl={docs.searchIndexUrl} /> puts the trigger wherever it belongs. DocsSearch carries its own 'use client' boundary, so the layout around it stays a Server Component.

docs.searchIndexUrl is derived from your basePath, so it is right whether the docs are mounted at /, at /docs or under a nested prefix. Pass it rather than a literal.

MiniSearch is import()ed and the index fetched on hover, focus or first open — never on page load.

[!WARNING] export const dynamic = 'force-static' is not optional, and it has to be a literal — route segment config is parsed out of the module before any of it runs, exactly like dynamicParams. Without it Next marks the route ƒ (Dynamic) and re-renders your whole corpus on every request, from markdown that output tracing did not put in the deployment bundle. On a serverless host that does not degrade, it throws — at the reader, inside the dialog. The build prints no warning, so the handler detects it and throws with code: 'search-index-dynamic', naming the file to fix.

Under output: 'export' the same route is written out as a plain docs/search-index.json. Both modes are asserted by a real next build in this repository's CI.

Caching

The response carries cache-control: public, max-age=0, must-revalidate and a strong ETag, replacing Next's default of a year of s-maxage with no validator — which, on a URL that never changes, is a CDN serving a stale index until someone purges it by hand. next start does not honour If-None-Match itself (it answers 200 with the full body); a CDN or reverse proxy in front of it does.

If your site sets Next's own basePath config, prefix indexUrl yourself: Next applies it to <Link> and to navigation, but never to a client fetch().

The dialog's props

DocsSearch takes everything SearchDialog does except navigate and Link, which the Next adapter wires. docs.Layout's search={{ … }} takes all of it except indexUrl, which it derives.

| Prop | Type | Default | | | --- | --- | --- | --- | | indexUrl | string | — | Where the index is served. Pass docs.searchIndexUrl | | pageSize | number | 20 | Results rendered at a time. Not a cap — another page loads as the reader nears the end | | minQueryLength | number | 2 | Shortest query that runs | | debounceMs | number | 120 | Input debounce | | className | string | — | Extra classes for the trigger button | | triggerLabel | string | 'Search' | The trigger's text | | placeholder | string | 'Search documentation' | The input's placeholder | | dialogLabel | string | 'Search documentation' | The dialog's accessible name | | hintLabel | string | 'Start typing to search the documentation.' | Before anything is typed | | shortQueryLabel | string | 'Keep typing — {min} characters or more.' | Below minQueryLength. {min} is that number | | loadingLabel | string | 'Loading the search index…' | While the index is fetched | | errorLabel | string | 'Search is unavailable right now. Try reloading the page.' | When it cannot be | | emptyLabel | string | 'No results for “{query}”.' | No matches. {query} is what was typed | | selectLabel | string | 'Select' | Footer hint beside | | openLabel | string | 'Open' | Footer hint beside | | closeLabel | string | 'Close' | The footer's dismiss button, beside Esc | | resultCountLabels | Partial<Record<Intl.LDMLPluralRule, string>> | { one: '{count} result', other: '{count} results' } | The live region, by plural category | | locale | string | <html lang>, then 'en' | Language tag for those plural rules | | miniSearchOptions | Partial<Options<SearchRecord>> | — | See Tuning |

The dialog's footer carries the three keyboard hints and the dismiss control. The key-caps beside them — Esc — are glyphs and are not translatable; the three props above are the verbs, which are.

Under (hover: none) and (pointer: coarse) the two hints are hidden, on the same reasoning as the trigger's ⌘K: an instruction to press a key is one a reader on a phone cannot follow. closeLabel's button is deliberately not hidden with them — on exactly those devices it is the only pointer route out of the dialog.

pageSize replaced maxResults in 0.4.0, and the meaning changed with the name: maxResults was a hard ceiling of 8 that made results unreachable on a six-page site, and the live region announced the slice as though it were the total. pageSize is a window — every match is reachable by scrolling, and the count announced is the real one.

resultCountLabels is keyed by plural category rather than being a singular and a plural, because most languages are not English: Polish takes four forms and Arabic six. Intl.PluralRules picks, and a category you do not list falls back to other.

There is no hotkey prop. The shortcut is ⌘K on Apple platforms and Ctrl-K elsewhere, and it is not configurable.

What gets indexed

The whole section, not a preview of it. extractSearchRecords once truncated text to 300 characters before indexing, which dropped roughly 80% of a normal corpus — and because the default combineWith: 'AND' requires every term to land in the same record, a two-word query against a page that plainly contained both words returned nothing. Indexing and display are now separate concerns: the full text is searchable, and storeFields carries only what the dialog renders.

Drafts are excluded, and code blocks are skipped — after Shiki a fence is hundreds of token spans that index as a bag of punctuation. Inline code is kept, because useMemo is exactly the sort of thing people search for.

CJK and other scripts

Tokenisation uses Intl.Segmenter where available, so Chinese, Japanese and Thai — which do not delimit words with spaces — index and query as words rather than as whole clauses. Without it, search('安装') matched nothing on a page that was entirely about 安装.

Tuning

Both halves of the seam take the same overrides and they must agree — an index built with one tokenize and queried with another matches nothing at all, silently. So the option has one name on both sides:

import { createDocsRoute } from '@waveso/docs/next';

export const docs = createDocsRoute({
  contentDir: 'content/docs',
  miniSearchOptions: { searchOptions: { fuzzy: 0.1, prefix: true } },
});
import { DocsSearch } from '@waveso/docs/react/next-search';
import { docs } from '@/lib/docs';

export function Search() {
  return (
    <DocsSearch
      indexUrl={docs.searchIndexUrl}
      miniSearchOptions={{ searchOptions: { fuzzy: 0.1, prefix: true } }}
    />
  );
}

fuzzy, prefix, combineWith and boost are MiniSearch query defaults, so they nest under searchOptions; fields, storeFields, tokenize and processTerm sit at the top level. The nesting is easy to get wrong and wrong is silent — a stray fuzzy at the top level is simply never read — so both examples above are type-checked in CI.

Functions need a client boundary

tokenize and processTerm are functions, and docs.Layout cannot hand a function to the dialog. The layout is a Server Component and the dialog is a Client Component, so props crossing between them are serialised — React refuses a function outright and next build fails while prerendering, with "Functions cannot be passed directly to Client Components".

So docs.Layout forwards the serialisable half of miniSearchOptionsfields, storeFields, boost, and everything under searchOptions that is not a callback — and refuses the rest by name. It does not quietly drop them: an index built with a processTerm the query does not share matches nothing at all and says nothing, which is the exact failure the forwarding exists to prevent.

Function tuning means taking the boundary yourself, so the function is a module import on both sides rather than a prop between them:

// lib/search-terms.ts — one function, imported by both halves
export function stripDashes(term: string): string {
  return term.replace(/-/g, '');
}
// components/docs-search.tsx
'use client';

import { DocsSearch } from '@waveso/docs/react/next-search';
import { stripDashes } from '@/lib/search-terms';

export function DocsSearchTrigger({ indexUrl }: { indexUrl: string }) {
  return (
    <DocsSearch
      indexUrl={indexUrl}
      miniSearchOptions={{ processTerm: stripDashes }}
    />
  );
}

Add the same function to your createDocsRoute call — miniSearchOptions: { processTerm: stripDashes } — so the index is built with it. Then turn the built-in trigger off and render yours in the layout you write around docs.Layout, in app/docs/layout.tsx:

import '@waveso/docs/styles.css';
import type { ReactNode } from 'react';
import { DocsSearchTrigger } from '@/components/docs-search';
import { docs } from '@/lib/docs';

export default function DocsLayout({ children }: { children: ReactNode }) {
  return (
    <>
      <DocsSearchTrigger indexUrl={docs.searchIndexUrl} />
      <docs.Layout search={false}>{children}</docs.Layout>
    </>
  );
}

search={false} omits the built-in trigger so yours is the only one, and it is also why the refusal is scoped to the forward: the route keeps the function for the index it builds on the server, and nothing crosses to the client but a string. To put your trigger inside the sidebar rather than above it, compose the shell yourselfdocs.Layout has no slot for it, deliberately.

Building the index yourself

Only if the route cannot express what you need — a second index per locale, say, or an artifact consumed by something other than the dialog:

import { buildSearchIndex, extractSearchRecords } from '@waveso/docs/search-index';
import { docs } from '@/lib/docs';

const rendered = await docs.renderAll();
const json = buildSearchIndex(rendered.flatMap((doc) => extractSearchRecords(doc)));

docs.searchIndex is exactly this, served — asserted byte-for-byte by a test, so the escape hatch cannot drift from the route.

Plugins

Two slots, at the two positions that are actually useful:

import type { Plugin } from 'unified';
import { createDocsRoute } from '@waveso/docs/next';

// Whatever you install — `remark-math` and `rehype-katex` here.
declare const remarkMath: Plugin;
declare const rehypeKatex: Plugin;

export const mathDocs = createDocsRoute({
  contentDir: 'content/docs',
  remarkPlugins: [remarkMath],
  rehypePlugins: [rehypeKatex],
});

remarkPlugins attach after GFM and before link resolution, so anything they emit is folded, contained and asserted exactly like authored markdown — a plugin writing [x](../other/page.md) gets the same resolution an author would, and one writing ![i](./x.png) throws without an imageResolver for the same reason.

rehypePlugins attach after heading ids and permalinks exist and before Shiki, so a code fence is still <pre><code class="language-ts"> with the author's text in it rather than several hundred token spans. Fences named by excludeLangs are not disguised yet either, so a plugin sees every code block the same way.

There is no after-Shiki slot. Code-block internals belong to Shiki's own transformers, and the honest documentation for an after-Shiki hook would be a list of things you must not do.

The table of contents is captured last, after your plugins and after everything else, so it describes the same document the search index does. A plugin that adds or removes a heading changes both together; there is no validation pass because there is nothing to validate.

[!NOTE] The pipeline is built and frozen once and shared by every file, so a plugin holding state accumulates it across the whole build rather than per document. Keep them pure, or key what they hold on the vfile.

Configuration

interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
  contentDir: string;        // relative paths resolve against process.cwd()
  basePath?: string;         // default '/docs'; '/' normalis