@waveso/docs 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +592 -88
  3. package/dist/code-frame.d.ts +29 -0
  4. package/dist/code-frame.js +41 -0
  5. package/dist/code-meta.d.ts +48 -0
  6. package/dist/code-meta.js +72 -0
  7. package/dist/docs-content-id.d.ts +19 -0
  8. package/dist/docs-content-id.js +19 -0
  9. package/dist/docs-error.d.ts +19 -0
  10. package/dist/docs-error.js +28 -0
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/frontmatter.d.ts +39 -7
  14. package/dist/frontmatter.js +51 -24
  15. package/dist/highlighter.d.ts +2 -2
  16. package/dist/highlighter.js +3 -2
  17. package/dist/map-pooled.d.ts +26 -0
  18. package/dist/map-pooled.js +45 -0
  19. package/dist/meta.d.ts +7 -3
  20. package/dist/meta.js +61 -15
  21. package/dist/next.d.ts +182 -35
  22. package/dist/next.js +177 -49
  23. package/dist/plugins/rehype-capture-toc.js +52 -20
  24. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  25. package/dist/plugins/rehype-code-frame.js +88 -0
  26. package/dist/plugins/rehype-code-language.d.ts +24 -0
  27. package/dist/plugins/rehype-code-language.js +54 -0
  28. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  29. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  30. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  31. package/dist/plugins/rehype-flatten-roots.js +39 -0
  32. package/dist/plugins/remark-doc-links.d.ts +12 -1
  33. package/dist/plugins/remark-doc-links.js +147 -20
  34. package/dist/react/code-runtime.d.ts +14 -0
  35. package/dist/react/code-runtime.js +161 -0
  36. package/dist/react/doc-content.d.ts +39 -2
  37. package/dist/react/doc-content.js +42 -10
  38. package/dist/react/layout.d.ts +44 -0
  39. package/dist/react/layout.js +65 -0
  40. package/dist/react/markdown-components.js +71 -6
  41. package/dist/react/nav.d.ts +28 -0
  42. package/dist/react/nav.js +70 -0
  43. package/dist/react/nearest-scroll-top.d.ts +45 -0
  44. package/dist/react/nearest-scroll-top.js +44 -0
  45. package/dist/react/next-link.d.ts +34 -0
  46. package/dist/react/next-link.js +30 -0
  47. package/dist/react/next-nav.d.ts +11 -0
  48. package/dist/react/next-nav.js +32 -0
  49. package/dist/react/next-search.d.ts +22 -0
  50. package/dist/react/next-search.js +52 -0
  51. package/dist/react/search-dialog.d.ts +35 -7
  52. package/dist/react/search-dialog.js +55 -33
  53. package/dist/react/shell-labels.d.ts +43 -0
  54. package/dist/react/shell-labels.js +27 -0
  55. package/dist/react/sidebar.d.ts +38 -3
  56. package/dist/react/sidebar.js +104 -12
  57. package/dist/react/skip-link.d.ts +1 -9
  58. package/dist/react/skip-link.js +6 -5
  59. package/dist/react/toc.d.ts +12 -4
  60. package/dist/react/toc.js +46 -12
  61. package/dist/react/youtube.d.ts +31 -5
  62. package/dist/react/youtube.js +76 -52
  63. package/dist/render.d.ts +78 -10
  64. package/dist/render.js +137 -54
  65. package/dist/route-path.d.ts +46 -0
  66. package/dist/route-path.js +51 -0
  67. package/dist/search-index.d.ts +22 -21
  68. package/dist/search-index.js +27 -78
  69. package/dist/search-options.d.ts +32 -1
  70. package/dist/search-options.js +66 -3
  71. package/dist/section-boundary.d.ts +17 -0
  72. package/dist/section-boundary.js +43 -0
  73. package/dist/sitemap-limit.d.ts +34 -0
  74. package/dist/sitemap-limit.js +37 -0
  75. package/dist/source.d.ts +12 -22
  76. package/dist/source.js +165 -72
  77. package/dist/styles.css +1117 -125
  78. package/dist/types.d.ts +52 -29
  79. package/package.json +70 -34
package/README.md CHANGED
@@ -11,6 +11,35 @@
11
11
 
12
12
  </div>
13
13
 
14
+ <br />
15
+
16
+ <!--
17
+ ⚠️ ABSOLUTE `raw.githubusercontent.com` URLS, PINNED TO A TAG.
18
+
19
+ npm rewrites relative markdown image paths onto its own CDN, but it does NOT
20
+ rewrite `<source srcset>` inside a `<picture>` — so a relative path here shows
21
+ a broken image on npmjs.com. And pinning to `main` rather than a tag means an
22
+ old version's README displays a future product: someone reading 0.3.0 in 2027
23
+ would see whatever the shell looks like then.
24
+
25
+ `pnpm shoot` regenerates these from the real site build; CI runs
26
+ `pnpm shoot --check` on any pull request touching the stylesheet, the React
27
+ layer or the site.
28
+ -->
29
+ <picture>
30
+ <source
31
+ media="(prefers-color-scheme: dark)"
32
+ srcset="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.3.0/docs/media/hero-dark.png"
33
+ />
34
+ <img
35
+ src="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.3.0/docs/media/hero-light.png"
36
+ alt="A documentation page rendered by @waveso/docs: a navigation sidebar, prose with syntax-highlighted code frames, and a table of contents."
37
+ width="100%"
38
+ />
39
+ </picture>
40
+
41
+ <p align="center"><em>The default page, with no CSS of your own. <a href="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.3.0/docs/media/search.png">Search dialog →</a></em></p>
42
+
14
43
  ---
15
44
 
16
45
  ## Why Wave Docs
@@ -41,15 +70,44 @@ Three things follow from that shape, and they are the reasons to choose this ove
41
70
  pnpm add @waveso/docs
42
71
  ```
43
72
 
44
- `react`, `react-dom` and `zod` are required peers; `next` and `tailwindcss` are optional install only what you use.
73
+ That is the whole installation. `react` and `react-dom` are required peers; `next` is optional, needed only by `@waveso/docs/next`.
74
+
75
+ **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:
76
+
77
+ ```ts
78
+ import { docFrontmatterSchema, z } from '@waveso/docs/frontmatter';
79
+ ```
80
+
81
+ 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.
82
+
83
+ **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.
84
+
85
+ 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.
86
+
87
+ ## What it costs
88
+
89
+ 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.
90
+
91
+ | | At most |
92
+ | --- | --- |
93
+ | Everything the quick start ships, gzipped | 13.0 KB |
94
+ | Search dialog and router wiring | 9.0 KB |
95
+ | Navigation: sidebar and mobile drawer | 2.2 KB |
96
+ | Table of contents | 0.9 KB |
97
+ | Copy-button runtime | 0.9 KB |
98
+ | hast over the wire vs HTML, prose page | 1.20× |
99
+ | hast over the wire vs HTML, code and tables | 1.12× |
100
+ | Highlighting vs no highlighting | 2.00× |
45
101
 
46
- The `zod` floor is `4.4.3`, not `^4.0.0`: the built-in frontmatter schema calls `.exactOptional()` at module scope, so an earlier 4.x throws on import of `@waveso/docs/frontmatter` with nothing in the message naming zod.
102
+ The first row is the honest total: a reader who lands on a page of your documentation downloads under 13 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.
47
103
 
48
- There is no `image-size` peer. An `imageResolver` you write is welcome to read dimensions with it but it is your dependency, in your own `package.json`. It was declared here as an optional peer, which installs nothing and therefore does not make `await import('image-size')` resolve for you; the declaration only looked like it helped.
104
+ 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.
105
+
106
+ `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.
49
107
 
50
108
  ## Quick start
51
109
 
52
- **Two route files are required.** `[...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.
110
+ **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.
53
111
 
54
112
  Create the route once, in a module every route file imports:
55
113
 
@@ -78,6 +136,22 @@ export default docs.IndexPage;
78
136
  export const generateMetadata = docs.generateMetadata;
79
137
  ```
80
138
 
139
+ ```tsx
140
+ // app/docs/layout.tsx
141
+ import '@waveso/docs/styles.css';
142
+ import { docs } from '@/lib/docs';
143
+
144
+ export default docs.Layout;
145
+ ```
146
+
147
+ ```ts
148
+ // app/docs/search-index.json/route.ts
149
+ import { docs } from '@/lib/docs';
150
+
151
+ export const GET = docs.searchIndex;
152
+ export const dynamic = 'force-static';
153
+ ```
154
+
81
155
  ```
82
156
  content/docs/
83
157
  index.md
@@ -87,10 +161,12 @@ content/docs/
87
161
  authentication.md
88
162
  ```
89
163
 
90
- That is a working documentation site.
164
+ That is a working documentation site: routing, a navigation sidebar, a table of contents, syntax highlighting, search, a mobile drawer and a skip link.
165
+
166
+ 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](#search) for tuning.
91
167
 
92
168
  > [!IMPORTANT]
93
- > `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 renders unlisted URLs on demand `/docs/typo` reaches the filesystem, `readFile` throws, and Next answers **500** where it should answer 404.
169
+ > `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.
94
170
 
95
171
  ## Entry points
96
172
 
@@ -100,73 +176,127 @@ There is no root export. Every entry point is a subpath, so an import always nam
100
176
  | --- | --- | --- |
101
177
  | `@waveso/docs/next` | Node | `createDocsRoute`, `createDocsSitemap`, `createDocsRedirects` |
102
178
  | `@waveso/docs/source` | Node | `createDocsSource`, `resolveDocsConfig` |
103
- | `@waveso/docs/render` | Node | `createDocsRenderer` |
104
- | `@waveso/docs/highlighter` | Node | `createDocsHighlighter`, `DEFAULT_DOCS_LANGS` |
105
- | `@waveso/docs/search-index` | Node | `extractSearchRecords`, `buildSearchIndex`, `writeSearchIndex` |
106
- | `@waveso/docs/react/*` | Browser + RSC | See [Components](#components) |
107
- | `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter` |
108
- | `@waveso/docs/search-options` | Any | `SEARCH_INDEX_OPTIONS` |
179
+ | `@waveso/docs/render` | Node | `createDocsRenderer`, `resolveMarkdownLink` |
180
+ | `@waveso/docs/highlighter` | Node | `createDocsHighlighter`, `DEFAULT_DOCS_LANGS`, `DEFAULT_DOCS_THEMES` |
181
+ | `@waveso/docs/search-index` | Node | `extractSearchRecords`, `buildSearchIndex` |
182
+ | `@waveso/docs/react/<name>` | Browser + RSC | Nine components, one per subpath — see [Components](#components) |
183
+ | `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter`, `z` |
109
184
  | `@waveso/docs/types` | Any | Every shared type. Type-only |
185
+ | `@waveso/docs/errors` | Any | `DocsErrorCode`, `DocsError`, `isDocsError`, `DOCS_ERROR_PREFIX` |
110
186
  | `@waveso/docs/styles.css` | — | The stylesheet |
111
187
 
112
- The Node-only subpaths carry `"browser": null`, so importing one from client code fails with a located *module not found* rather than quietly bundling `node:fs`.
188
+ The Node-only subpaths carry `"browser": null`, so importing one from client code fails with a located *module not found* rather than resolving.
189
+
190
+ 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.
191
+
192
+ `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.
193
+
194
+ ### Layout tokens
195
+
196
+ Five custom properties size the shell, all layered so an unlayered `:root` of your own still wins. The full contract is in [`docs/adr/001-shell-contract.md`](./docs/adr/001-shell-contract.md).
197
+
198
+ | Token | Default | Controls |
199
+ | --- | --- | --- |
200
+ | `--wave-docs-measure` | `46rem` | Prose column width. `none` opts out |
201
+ | `--wave-docs-header-height` | `3.5rem` | Header, and the offset sticky columns park below |
202
+ | `--wave-docs-sidebar-width` | `16rem` | Sidebar track |
203
+ | `--wave-docs-toc-width` | `15rem` | Table-of-contents track |
204
+ | `--wave-docs-shell-width` | `100rem` | Maximum shell width |
205
+
206
+ 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.
207
+
208
+ Set `--wave-docs-font-sans: inherit` to hand the whole package your own typeface.
209
+
210
+ 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.
113
211
 
114
212
  ## Components
115
213
 
116
- Every component takes data as props and imports nothing from `next/*` — the adapter injects `next/link` and `next/image`. That keeps the renderer host-agnostic and testable without a router.
214
+ Every component takes data as props and imports nothing from `next/*` — the adapter injects `next/link` and `next/image`. 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`.
117
215
 
118
216
  | Component | Subpath | Notes |
119
217
  | --- | --- | --- |
120
- | `DocContent` | `react/doc-content` | Renders a hast tree. Server Component |
218
+ | `DocContent` | `react/doc-content` | Renders a hast tree, inside `.wave-docs-prose`. Server Component |
121
219
  | `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
122
220
  | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver` |
123
- | `SearchDialog` | `react/search-dialog` | ⌘K, arrow keys, focus trap |
124
- | `Callout` | `react/callout` | Note · tip · important · warning · caution |
221
+ | `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
222
+ | `SearchDialog` | `react/search-dialog` | ⌘K, arrow keys, focus trap. Host-agnostic |
223
+ | `Callout` | `react/callout` | Note · tip · important · warning · caution. `CALLOUT_TYPES` is the list |
125
224
  | `YouTube` | `react/youtube` | Click-to-load facade |
126
- | `SkipLink` | `react/skip-link` | Targets `docs.Page`'s `<article>` |
127
- | `createMarkdownComponents` | `react/markdown-components` | The element → component map |
225
+ | `SkipLink` | `react/skip-link` | Targets `docs.Page`'s `<main>`; `DOCS_CONTENT_ID` is that id. `docs.Layout` renders one |
226
+ | `createMarkdownComponents` | `react/markdown-components` | The element → component map. `defaultMarkdownComponents` is the unwired one |
128
227
 
129
228
  ### Layout
130
229
 
131
- App Router layouts are Server Components and `usePathname` is client-only, so the one client boundary in a docs layout is a wrapper around the sidebar:
230
+ `export default docs.Layout` the one line from the [quick start](#quick-start) — is a Server Component that renders the whole shell: skip link, sticky header, sidebar column, mobile drawer, 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.
132
231
 
133
- ```tsx
134
- // components/docs-nav.tsx
135
- 'use client';
136
-
137
- import Link from 'next/link';
138
- import { usePathname } from 'next/navigation';
139
- import { DocsSidebar } from '@waveso/docs/react/sidebar';
140
- import type { DocNavNode } from '@waveso/docs/types';
232
+ 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.
141
233
 
142
- export function DocsNav({ nav }: { nav: DocNavNode[] }) {
143
- return <DocsSidebar nav={nav} pathname={usePathname()} Link={Link} />;
144
- }
145
- ```
234
+ To put your own chrome in the header, call it instead of re-exporting it:
146
235
 
147
236
  ```tsx
148
- // app/docs/layout.tsx
149
237
  import type { ReactNode } from 'react';
150
- import { SkipLink } from '@waveso/docs/react/skip-link';
151
- import { DocsNav } from '@/components/docs-nav';
152
- import { docs } from '@/lib/docs';
153
238
  import '@waveso/docs/styles.css';
239
+ import { docs } from '@/lib/docs';
154
240
 
155
- export default async function DocsLayout({ children }: { children: ReactNode }) {
156
- const nav = await docs.source.nav();
241
+ export default function DocsLayout({ children }: { children: ReactNode }) {
157
242
  return (
158
- <>
159
- <SkipLink />
160
- <DocsNav nav={nav} />
243
+ <docs.Layout
244
+ title="Wave"
245
+ actions={<a href="https://github.com/waveso/docs">GitHub</a>}
246
+ >
161
247
  {children}
162
- </>
248
+ </docs.Layout>
163
249
  );
164
250
  }
165
251
  ```
166
252
 
167
- A page that needs the table of contents renders itself from `docs.getPage(segments)` instead of re-exporting `docs.Page`:
253
+ | Prop | Type | Default | |
254
+ | --- | --- | --- | --- |
255
+ | `title` | `ReactNode` | — | Brand, at the header start |
256
+ | `actions` | `ReactNode` | — | Header end, after search |
257
+ | `search` | `boolean \| DocsSearchProps` | `true` | The search trigger. An object configures the dialog |
258
+ | `labels` | `DocsLabels` | English | The four strings the shell renders itself |
259
+
260
+ Five props, and two of them are small objects. 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 `DocNavNode`s you author in `meta.json`. The header bar was the one region nothing else could reach — hence `actions`. Two node props can become a slots map later; a slots map cannot become two props.
261
+
262
+ `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.
263
+
264
+ `labels` is the whole of what a site not in English has to say to the shell; everything else a reader sees is your markdown or your `title`.
265
+
266
+ The same `app/docs/layout.tsx` as the quick start, written out instead of re-exported, because passing a prop needs a function:
168
267
 
169
268
  ```tsx
269
+ import type { ReactNode } from 'react';
270
+ import '@waveso/docs/styles.css';
271
+ import { docs } from '@/lib/docs';
272
+
273
+ export default function Layout(props: { children: ReactNode }) {
274
+ return docs.Layout({
275
+ ...props,
276
+ labels: {
277
+ nav: 'Documentação',
278
+ openNav: 'Abrir navegação',
279
+ closeNav: 'Fechar navegação',
280
+ skipToContent: 'Ir para o conteúdo',
281
+ },
282
+ });
283
+ }
284
+ ```
285
+
286
+ Each key falls back on its own, so a partial map is not a half-translated shell.
287
+
288
+ #### The mobile drawer
289
+
290
+ Below 64rem the sidebar is a `<dialog>` opened by a server-rendered `<button command="show-modal">` — so it works on the first tap, before hydration, and with JavaScript disabled. Focus moves inside and Tab stays there, Escape closes it and returns focus to the trigger, a click on the backdrop dismisses it, and the page behind does not scroll. All of that is the browser's, not ours.
291
+
292
+ At 64rem and above the same element becomes the sticky column, via `display: contents`. One navigation in the DOM at every width: one landmark, one copy of the links in the payload, nothing to keep in step.
293
+
294
+ #### Composing it yourself
295
+
296
+ `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:
297
+
298
+ ```tsx
299
+ // The catch-all page, written out instead of re-exporting `docs.Page`.
170
300
  import { notFound } from 'next/navigation';
171
301
  import { DocContent } from '@waveso/docs/react/doc-content';
172
302
  import { DocsToc } from '@waveso/docs/react/toc';
@@ -179,15 +309,25 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
179
309
 
180
310
  return (
181
311
  <>
182
- <article id="docs-content" tabIndex={-1} className="wave-docs-prose">
312
+ <main className="wave-docs-layout__main" id="docs-content" tabIndex={-1}>
183
313
  <DocContent hast={doc.hast} />
184
- </article>
185
- <DocsToc entries={doc.toc} />
314
+ </main>
315
+ {doc.toc.length === 0 ? null : (
316
+ <aside className="wave-docs-layout__toc">
317
+ <DocsToc entries={doc.toc} />
318
+ </aside>
319
+ )}
186
320
  </>
187
321
  );
188
322
  }
189
323
  ```
190
324
 
325
+ `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.
326
+
327
+ **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 are frozen in [`docs/adr/001-shell-contract.md`](docs/adr/001-shell-contract.md), so they are safe to write by hand.
328
+
329
+ 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.
330
+
191
331
  ## Frontmatter
192
332
 
193
333
  ```yaml
@@ -211,8 +351,7 @@ Pass a `frontmatterSchema` and every `DocFile` and `RenderedDoc` carries your fi
211
351
 
212
352
  ```ts
213
353
  // content/docs-schema.ts — one module, imported by every route file
214
- import { docFrontmatterSchema } from '@waveso/docs/frontmatter';
215
- import { z } from 'zod';
354
+ import { docFrontmatterSchema, z } from '@waveso/docs/frontmatter';
216
355
 
217
356
  export const frontmatterSchema = docFrontmatterSchema.extend({
218
357
  audience: z.enum(['user', 'operator']).exactOptional(),
@@ -220,6 +359,9 @@ export const frontmatterSchema = docFrontmatterSchema.extend({
220
359
  ```
221
360
 
222
361
  ```ts
362
+ import { createDocsRoute } from '@waveso/docs/next';
363
+ import { frontmatterSchema } from '@/content/docs-schema';
364
+
223
365
  const docs = createDocsRoute({ contentDir: 'content/docs', frontmatterSchema });
224
366
 
225
367
  const doc = await docs.getPage(['api', 'auth']);
@@ -227,12 +369,13 @@ doc?.frontmatter.audience; // 'user' | 'operator' | undefined
227
369
  doc?.frontmatter.title; // string
228
370
  ```
229
371
 
230
- Any [Standard Schema](https://standardschema.dev) 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. `zod` stays a required peer because `docFrontmatterSchema` is a Zod schema and extending it is the shortest path to a valid one.
372
+ Any [Standard Schema](https://standardschema.dev) 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.
231
373
 
232
374
  Four things are worth knowing before you write one.
233
375
 
234
376
  **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:
235
377
 
378
+ <!-- typecheck: skip — the two lines are the point; imports would bury them -->
236
379
  ```ts
237
380
  // ⚠️ Compiles. Every extra field is `undefined` at runtime, typed as present.
238
381
  const docs = createDocsRoute<MyFrontmatter>({ contentDir: 'content/docs' });
@@ -240,7 +383,9 @@ const docs = createDocsRoute<MyFrontmatter>({ contentDir: 'content/docs' });
240
383
 
241
384
  **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.
242
385
 
243
- **The output must still satisfy `DocFrontmatter`.** `title` drives the `<h1>` fallback and `<title>`, `draft` the visibility filter, `aliases` the redirects, `order` and `label` the sidebar. A schema that drops them is a compile error where you pass it, not a mystery at render time.
386
+ **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.
387
+
388
+ 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.
244
389
 
245
390
  **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.
246
391
 
@@ -301,7 +446,129 @@ A ```` ```cfg ```` fence (or ```` ```conf ````) uses the `ini` grammar, because
301
446
  the fence an author types follows the filename — nobody writes ```` ```ini ````
302
447
  above a file called `server.cfg`.
303
448
 
304
- Anything outside that set falls back to plain text rather than throwing. Pass `langs` to change the set, or `highlighter` to supply your own.
449
+ 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.
450
+
451
+ ### Code blocks
452
+
453
+ Every highlighted fence is wrapped in a `<figure>` with a copy button. Add a title and it gets a bar:
454
+
455
+ ````md
456
+ ```ts title="app/page.tsx"
457
+ export default function Page() {
458
+ return <h1>Hello</h1>;
459
+ }
460
+ ```
461
+ ````
462
+
463
+ 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.
464
+
465
+ 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.
466
+
467
+ 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.
468
+
469
+ The `<figure>` carries `data-lang` (the folded language, so ```` ```JSON ```` gives `json`). No badge is rendered by default; one rule turns it on:
470
+
471
+ ```css
472
+ .wave-docs-code[data-lang]::before {
473
+ content: attr(data-lang);
474
+ }
475
+ ```
476
+
477
+ 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.
478
+
479
+ #### Fences you render yourself
480
+
481
+ `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:
482
+
483
+ ```ts
484
+ import { createDocsRoute } from '@waveso/docs/next';
485
+
486
+ // In `lib/docs.ts`, beside the rest of your configuration.
487
+ export const docs = createDocsRoute({
488
+ contentDir: 'content/docs',
489
+ excludeLangs: ['mermaid'],
490
+ });
491
+ ```
492
+
493
+ 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.
494
+
495
+ To render them, map `pre`:
496
+
497
+ ```tsx
498
+ import { isValidElement, type ReactNode } from 'react';
499
+
500
+ /** Yours: a `'use client'` component wrapping whichever renderer you like. */
501
+ declare function Mermaid(props: { children: string }): ReactNode;
502
+
503
+ function textOf(node: ReactNode): string {
504
+ if (typeof node === 'string') return node;
505
+ if (Array.isArray(node)) return node.map(textOf).join('');
506
+ if (isValidElement<{ children?: ReactNode }>(node)) {
507
+ return textOf(node.props.children);
508
+ }
509
+ return '';
510
+ }
511
+
512
+ export const components = {
513
+ pre: (props: { children?: ReactNode }) => {
514
+ const child = props.children;
515
+ const className = isValidElement<{ className?: string | string[] }>(child)
516
+ ? child.props.className
517
+ : undefined;
518
+
519
+ /*
520
+ * ⚠️ AN ARRAY, NOT A STRING. An excluded fence never reached Shiki, so its
521
+ * `<code>` still carries hast's `["language-mermaid"]` — Shiki's own
522
+ * output is a string. A `className === 'language-mermaid'` check compiles,
523
+ * reads correctly, and silently never matches, so every diagram renders as
524
+ * its own source.
525
+ */
526
+ const languages = Array.isArray(className) ? className : [className];
527
+
528
+ if (languages.includes('language-mermaid')) {
529
+ return <Mermaid>{textOf(props.children)}</Mermaid>;
530
+ }
531
+ return <pre {...props} />;
532
+ },
533
+ };
534
+ ```
535
+
536
+ Pass it as `components` to `createDocsRoute`, or to `DocContent` directly.
537
+
538
+ `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.
539
+
540
+ ### Images
541
+
542
+ **Absolute and external sources just work.** Put the file in `public/` and write `![](/diagram.png)`.
543
+
544
+ ```md
545
+ ![Architecture](/diagram.png) ✅ served from public/
546
+ ![Logo](https://example.com/logo.png) ✅ external
547
+ ![Architecture](./diagram.png) ⛔️ needs an imageResolver
548
+ ```
549
+
550
+ 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.
551
+
552
+ 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:
553
+
554
+ ```ts
555
+ import { readFile } from 'node:fs/promises';
556
+ import path from 'node:path';
557
+ import { imageSize } from 'image-size';
558
+ import { createDocsRoute } from '@waveso/docs/next';
559
+
560
+ createDocsRoute({
561
+ contentDir: 'content/docs',
562
+ imageResolver: async (src) => {
563
+ const { width, height } = imageSize(
564
+ await readFile(path.join('content/docs', src)),
565
+ );
566
+ return { src: `/docs-assets/${src}`, width, height };
567
+ },
568
+ });
569
+ ```
570
+
571
+ A source that climbs above the content root fails the build whether or not a resolver is configured.
305
572
 
306
573
  ## Theming
307
574
 
@@ -318,19 +585,41 @@ your own `:root`, after the import:
318
585
  ```
319
586
 
320
587
  That works because **everything this stylesheet declares lives in a `@layer`** —
321
- `theme` for the tokens, `base` for element resets, `components` for the classes
322
- and unlayered CSS outranks every layer regardless of specificity.
588
+ `theme` for the tokens, `base` for element resets, `components` for the classes
589
+ and unlayered CSS outranks every layer regardless of specificity.
323
590
 
324
591
  The distinction matters. The dark tokens are declared as
325
- `:root:not([data-theme='light'])`, which is specificity (0,2,0). Before the
326
- layers, an unlayered `:root` at (0,1,0) lost *no matter where it was loaded* —
327
- the cascade never reached source order, and overriding meant writing
328
- `:root:root:root`. Now source order and layering settle it, and a plain `:root`
329
- is enough.
592
+ `:root[data-theme='dark']`, which is specificity (0,2,0). Outside a layer, an
593
+ unlayered `:root` at (0,1,0) would lose *no matter where it was loaded* — the
594
+ cascade never reaches source order and overriding would mean writing
595
+ `:root:root:root`. Layered, source order settles it and a plain `:root` is
596
+ enough.
597
+
598
+ ### Dark mode is opt-in
599
+
600
+ | On `<html>` | Result |
601
+ | --- | --- |
602
+ | nothing | Light |
603
+ | `class="dark"` | Dark |
604
+ | `data-theme="dark"` | Dark |
605
+ | `data-theme="system"` | Follows `prefers-color-scheme` |
606
+
607
+ `.dark` is honoured because [next-themes](https://github.com/pacocoursey/next-themes)
608
+ defaults to `attribute="class"` and never sets `data-theme`.
330
609
 
331
- Dark mode follows the OS by default and `data-theme` overrides it in both
332
- directions: `[data-theme='light']` opts out of a dark system,
333
- `[data-theme='dark']` opts into dark on a light one.
610
+ **This is deliberate, and it is a change.** The tokens used to switch on
611
+ `prefers-color-scheme` alone. But the stylesheet styles the docs subtree, not
612
+ the page so on a light-only site with a `/docs` section, a visitor whose OS
613
+ was in dark mode got the near-white foreground ramp on the host's white
614
+ background: **1.23:1**, i.e. invisible. A stylesheet cannot assume it owns the
615
+ page it is dropped into, so it now switches only when the host says to.
616
+
617
+ If your site really does follow the OS and has no theme toggle, say so once:
618
+
619
+ <!-- typecheck: skip — one tag, shown as markup rather than as a module -->
620
+ ```tsx
621
+ <html lang="en" data-theme="system">
622
+ ```
334
623
 
335
624
  To restyle rather than retheme, override the classes — `.wave-docs-prose`,
336
625
  `.wave-docs-skip-link`, and the rest — from your own unlayered CSS.
@@ -345,35 +634,113 @@ To restyle rather than retheme, override the classes — `.wave-docs-prose`,
345
634
 
346
635
  Build-time index, client-side dialog, MiniSearch. Records are section-scoped — one per `h2`–`h6` — so a hit deep-links to the right heading instead of dropping the reader at the top of a 2,000-word page.
347
636
 
348
- Nothing builds the index for you. `docs.renderAll()` exists for exactly this, and shares the scan, the highlighter and the render cache with your routes:
637
+ Nothing to set up: `docs.Layout` renders the trigger, and the [route file in the quick start](#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.
638
+
639
+ 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.
640
+
641
+ `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.
642
+
643
+ MiniSearch is `import()`ed and the index fetched on hover, focus or first open — never on page load.
644
+
645
+ > [!WARNING]
646
+ > **`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.
647
+
648
+ 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.
649
+
650
+ ### Caching
651
+
652
+ 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.
653
+
654
+ 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()`.
655
+
656
+ ### What gets indexed
657
+
658
+ **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.
659
+
660
+ 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.
661
+
662
+ ### CJK and other scripts
663
+
664
+ 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 安装.
665
+
666
+ ### Tuning
667
+
668
+ 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:
349
669
 
350
670
  ```ts
351
- // scripts/build-search-index.ts run before `next build`
352
- import { extractSearchRecords, writeSearchIndex } from '@waveso/docs/search-index';
353
- import { docs } from '../lib/docs';
671
+ import { createDocsRoute } from '@waveso/docs/next';
354
672
 
355
- const rendered = await docs.renderAll();
356
- const records = rendered.flatMap((doc) => extractSearchRecords(doc));
357
- await writeSearchIndex(records, 'public/search-index.json');
673
+ export const docs = createDocsRoute({
674
+ contentDir: 'content/docs',
675
+ miniSearchOptions: { searchOptions: { fuzzy: 0.1, prefix: true } },
676
+ });
358
677
  ```
359
678
 
360
679
  ```tsx
361
- 'use client';
362
-
363
- import Link from 'next/link';
364
- import { useRouter } from 'next/navigation';
365
- import { SearchDialog } from '@waveso/docs/react/search-dialog';
680
+ import { DocsSearch } from '@waveso/docs/react/next-search';
681
+ import { docs } from '@/lib/docs';
366
682
 
367
683
  export function Search() {
368
- const router = useRouter();
369
- return <SearchDialog indexUrl="/search-index.json" navigate={router.push} Link={Link} />;
684
+ return (
685
+ <DocsSearch
686
+ indexUrl={docs.searchIndexUrl}
687
+ miniSearchOptions={{ searchOptions: { fuzzy: 0.1, prefix: true } }}
688
+ />
689
+ );
370
690
  }
371
691
  ```
372
692
 
373
- MiniSearch is `import()`ed and the index fetched on hover, focus or first open — never on page load.
693
+ `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.
694
+
695
+ ### Building the index yourself
696
+
697
+ 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:
698
+
699
+ ```ts
700
+ import { buildSearchIndex, extractSearchRecords } from '@waveso/docs/search-index';
701
+ import { docs } from '@/lib/docs';
702
+
703
+ const rendered = await docs.renderAll();
704
+ const json = buildSearchIndex(rendered.flatMap((doc) => extractSearchRecords(doc)));
705
+ ```
706
+
707
+ `docs.searchIndex` is exactly this, served — asserted byte-for-byte by a test, so the escape hatch cannot drift from the route.
708
+
709
+ ## Plugins
710
+
711
+ Two slots, at the two positions that are actually useful:
712
+
713
+ ```ts
714
+ import type { Plugin } from 'unified';
715
+ import { createDocsRoute } from '@waveso/docs/next';
716
+
717
+ // Whatever you install — `remark-math` and `rehype-katex` here.
718
+ declare const remarkMath: Plugin;
719
+ declare const rehypeKatex: Plugin;
720
+
721
+ export const mathDocs = createDocsRoute({
722
+ contentDir: 'content/docs',
723
+ remarkPlugins: [remarkMath],
724
+ rehypePlugins: [rehypeKatex],
725
+ });
726
+ ```
727
+
728
+ `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.
729
+
730
+ `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.
731
+
732
+ 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.
733
+
734
+ 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.
735
+
736
+ > [!NOTE]
737
+ > The pipeline is built and frozen once and shared by every file, so a plugin
738
+ > holding state accumulates it across the whole build rather than per document.
739
+ > Keep them pure, or key what they hold on the vfile.
374
740
 
375
741
  ## Configuration
376
742
 
743
+ <!-- typecheck: skip — a reference listing of the type, not a module -->
377
744
  ```ts
378
745
  interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
379
746
  contentDir: string; // relative paths resolve against process.cwd()
@@ -388,42 +755,125 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
388
755
 
389
756
  | Option | Default | Purpose |
390
757
  | --- | --- | --- |
391
- | `langs` | 16 grammars | Typed `readonly DocsLang[]`, so a typo is a compile error |
758
+ | `langs` | 18 grammars | Typed `readonly DocsLang[]`, so a typo is a compile error |
392
759
  | `themes` | `github-light` / `github-dark` | Shiki theme pair |
393
760
  | `highlighter` | built-in | Supply your own for grammars outside the set |
394
761
  | `titleHeading` | `true` | Build an `<h1>` from `frontmatter.title` when the markdown has none |
395
762
  | `components` | built-in map | Override any element → component mapping |
396
- | `contentId` | `'docs-content'` | The id `SkipLink` targets; `false` if your layout owns it |
397
- | `rescanPerRequest` | dev only | Re-scan the content directory per request |
398
763
  | `siteUrl` | — | Makes canonical URLs absolute |
399
764
  | `linkResolver` · `imageResolver` | — | Override link rewriting and image dimensions. An `imageResolver` receives a folded, contained src — except an absolute `/logo.png` or a schemed `https://…`, which arrive unfolded, so branch on them |
400
765
 
401
766
  `titleHeading` defaults on because a document with no `h1` has a broken heading outline and fails every accessibility audit. Turn it off if your layout renders the title itself.
402
767
 
768
+ The `<main>` always carries `id="docs-content"`, which is what `SkipLink` targets by default — there is no option to change it, because there was no matching option on `SkipLink` to follow it with, so changing it silently pointed the skip link at nothing. Outside `NODE_ENV=production` the content directory is always re-scanned per request; `docs.source.invalidate()` is the escape hatch if you need to force one.
769
+
403
770
  ### Redirects and sitemap
404
771
 
405
772
  Separate calls, usable from `next.config.ts` and `app/sitemap.ts` — neither loads the Next runtime:
406
773
 
407
774
  ```ts
408
- import { createDocsRedirects, createDocsSitemap } from '@waveso/docs/next';
409
-
410
775
  // next.config.ts
776
+ import { createDocsRedirects } from '@waveso/docs/next';
777
+
411
778
  export default { redirects: () => createDocsRedirects({ contentDir: 'content/docs' }) };
779
+ ```
412
780
 
781
+ ```ts
413
782
  // app/sitemap.ts
783
+ import { createDocsSitemap } from '@waveso/docs/next';
784
+
414
785
  export default () =>
415
786
  createDocsSitemap({ contentDir: 'content/docs', siteUrl: 'https://example.com' });
416
787
  ```
417
788
 
789
+ `siteUrl` must be a bare origin. A path component (`https://example.com/product-docs`) is rejected, because `new URL(href, siteUrl)` discards it — every canonical and every sitemap entry would point somewhere that 404s. Put the path in `basePath`, which does take multiple segments.
790
+
791
+ **An alias is a redirect, not a page.** It is never prerendered, so linking one from your markdown fails the build and names the page to link instead. Aliases are also validated when the page is read:
792
+
793
+ | Alias | |
794
+ | --- | --- |
795
+ | `quickstart`, `guides/old-name` | ✅ |
796
+ | `v1:beta`, `c++`, `docs/(old)` | ⛔️ path-to-regexp metacharacters |
797
+ | `../escape`, `./here` | ⛔️ relative segments |
798
+ | `''` | ⛔️ empty |
799
+
800
+ The rejected spellings are not pedantry. Next compiles a redirect `source` as a path pattern, so `aliases: ['v1:beta']` installed a **wildcard** — it built green and then permanently 308'd `/docs/v1-guide`, a real prerendered page, away to somewhere else.
801
+
418
802
  ### Development
419
803
 
420
- Markdown files are not in Next's module graph, so nothing recompiles a route module when one changes. `createDocsRoute` re-scans the content directory on every request outside `NODE_ENV=production`: edits appear on reload, new files are found without a restart. A rescan of a few hundred small files is single-digit milliseconds — it is the render that costs.
804
+ Markdown files are not in Next's module graph, so nothing recompiles a route module when one changes. `createDocsRoute` re-scans the content directory on every request outside `NODE_ENV=production`: edits appear on reload, new files are found without a restart, and the sidebar from `docs.source.nav()` agrees with the page body on the *same* request.
805
+
806
+ The rescan is shared. Next runs `generateMetadata` and your page concurrently, and a layout calling `nav()` is a third reader; invalidation is wrapped in `React.cache`, so the first of them re-reads the disk and the rest see that scan. Without it each invalidated the others' work in flight — measured at 22 `readdir` + 824 `readFile` per request on a 401-file tree, against 11 + 412 for one scan.
807
+
808
+ ## Runtimes
809
+
810
+ What each entry point *requires*, measured by bundling it with no runtime assumed and asserted exactly — not approximately — by `src/entry-runtime.test.ts`:
811
+
812
+ | Entry | Node builtins |
813
+ | --- | --- |
814
+ | `@waveso/docs/types` | none |
815
+ | `@waveso/docs/frontmatter` | none |
816
+ | `@waveso/docs/highlighter` | none |
817
+ | `@waveso/docs/render` | none |
818
+ | `@waveso/docs/search-index` | none |
819
+ | `@waveso/docs/source` | `node:fs/promises`, `node:path` |
820
+ | `@waveso/docs/next` | `node:crypto`, `node:fs/promises`, `node:path` |
821
+ | `@waveso/docs/react/*` | none |
822
+
823
+ The markdown pipeline needs no filesystem and no `.wasm` — Shiki is loaded through its JavaScript regex engine deliberately, not its WASM one. So parsing and highlighting run wherever JavaScript does; only reading a directory of `.md` files needs Node, which is what `source` is for.
824
+
825
+ That is a statement about requirements and not a blessing. A bundle that resolves is not a runtime, and this package is tested on Node. If you run it elsewhere, note that `render` bundles to roughly 2.8 MB with all eighteen grammars inlined — narrow `langs` for anything with a size limit, since grammars are dynamic imports.
826
+
827
+ ## Errors
828
+
829
+ Every failure this package raises carries a `code`, so a host can branch on the kind of thing that went wrong rather than on message text:
830
+
831
+ ```ts
832
+ import { isDocsError } from '@waveso/docs/errors';
833
+ import { docs } from '@/lib/docs';
834
+
835
+ try {
836
+ await docs.renderAll();
837
+ } catch (error) {
838
+ if (isDocsError(error) && error.code === 'draft-link') {
839
+ console.warn(error.message);
840
+ } else {
841
+ throw error;
842
+ }
843
+ }
844
+ ```
845
+
846
+ `DocsErrorCode` is exported as a union, so a `switch` over it is exhaustive and a typo is a compile error. No error class is exported, deliberately: `instanceof` against a copy of a module resolved twice — two versions in a monorepo, a bundler that duplicates it — silently answers `false`, and a string code with a structural guard has no such failure mode.
847
+
848
+ ### Troubleshooting
849
+
850
+ | Code | What happened | What to do |
851
+ | --- | --- | --- |
852
+ | `broken-link` | A markdown link resolves to a route no published page owns. | Fix the link, or add an `aliases` entry to the page that moved. |
853
+ | `draft-link` | A link points at a page that exists but is `draft: true`. | Publish the page, or drop the link until it ships. |
854
+ | `alias-link` | A link points at an alias, which is a redirect and not a page. | Link the page the alias redirects to — the error names it. |
855
+ | `invalid-alias` | An `aliases` entry is empty, escapes the content root, or is not URL-safe. | Write it as a root-relative path, e.g. `/docs/old-name`. |
856
+ | `alias-collision` | Two pages claim one alias, or an alias shadows a real route. | Remove one of them; a redirect cannot have two destinations. |
857
+ | `route-collision` | Two files resolve to the same route. | Usually `about.md` beside `about/index.md`. Keep one. |
858
+ | `invalid-frontmatter` | A page has no frontmatter block, or the schema rejected it. | Every page needs at least `title`. The message names the file and the field. |
859
+ | `invalid-meta` | A `meta.json` is malformed, or names a page that is not there. | Check the filename spelling — entries are filenames without the extension. |
860
+ | `invalid-config` | An option passed to this package cannot be used as given. | The message names the option. `siteUrl` must be an absolute origin with no path. |
861
+ | `missing-content-dir` | `contentDir` does not point at a readable directory. | It resolves against `process.cwd()`, which is your project root under `next build`. |
862
+ | `broken-symlink` | A markdown page is reachable only through a broken symbolic link. | Repoint or delete the link; skipping it would silently drop a route. |
863
+ | `invalid-image` | A relative image needs an `imageResolver`, or one returned an unusable shape. | Pass `imageResolver`, or use an absolute `/path` the browser can resolve. |
864
+ | `unknown-theme` | A theme name outside the supported set. | Pass a `highlighter` of your own if you need a theme this package does not load. |
865
+ | `unknown-language` | A fence language outside the loaded set. | Add it to `langs`, or accept the plain-text fallback. |
866
+ | `missing-peer` | `next` is absent, or not the shape this adapter expects. | Install `next`, or build pages from `@waveso/docs/react/*` with your own loader. |
867
+ | `search-index-unavailable` | The dialog could not fetch or parse the index. | Check `indexUrl` — pass `docs.searchIndexUrl`, and prefix it yourself under a Next `basePath`. |
868
+ | `search-index-dynamic` | The search-index route ran at request time instead of prerendering. | Add `export const dynamic = 'force-static'` to the route file. It must be a literal. |
869
+ | `invalid-code-meta` | A fence's `title=` cannot be read. | Quote it: ```` ```ts title="app/page.tsx" ````. |
870
+ | `internal` | A plugin ran without context this package always supplies. | This one is a bug here. Please report it with the stack trace. |
421
871
 
422
872
  ## Requirements
423
873
 
424
874
  | | |
425
875
  | --- | --- |
426
- | Node.js | ≥ 20.19.0 |
876
+ | Node.js | ≥ 22.12.0 |
427
877
  | React | 19 |
428
878
  | Next.js | 16 (optional peer — only `@waveso/docs/next` needs it) |
429
879
  | Module format | **ESM only** |
@@ -435,10 +885,64 @@ If you extend the frontmatter schema, use `.exactOptional()` rather than `.optio
435
885
 
436
886
  > [!NOTE]
437
887
  > Under `exactOptionalPropertyTypes: true`, passing `next/link` straight into
438
- > `DocsSidebar` or `SearchDialog` does not type-check `next/link` types
439
- > `prefetch` as `boolean | null | undefined` where `DocsLinkProps` says
440
- > `boolean | undefined`. `docs.Page` is unaffected, because the adapter wraps
441
- > `next/link` internally. Without that flag, `Link={Link}` compiles as shown.
888
+ > `DocsSidebar` does not type-check. Next's `LinkProps` re-declares `onClick?`,
889
+ > `onMouseEnter?` and `onTouchStart?` *without* `| undefined`, and React's
890
+ > anchor props include it, so the two declaration files disagree — about props
891
+ > `next/link` accepts perfectly well at runtime. It is true of every
892
+ > `next/link` call site in a project with that flag on, not just this one.
893
+ > Cast at the call site (`Link={Link as DocsLinkComponent}`) until the
894
+ > Next-wired navigation component ships.
895
+ >
896
+ > `docs.Page` and `DocsSearch` are both unaffected — each wraps `next/link`
897
+ > inside the package, which is where that cast belongs. Without the flag,
898
+ > `Link={Link}` compiles exactly as shown above.
899
+
900
+ ## Stability
901
+
902
+ **This is `0.x`, and `0.x` means breaking changes land in minors.** They will be
903
+ listed, with the migration, in the changelog. What follows is what counts as
904
+ breaking — which is the part most packages leave unsaid until someone is angry.
905
+
906
+ ### What is public API
907
+
908
+ | | Covered |
909
+ | --- | --- |
910
+ | Every subpath in `exports`, and every runtime name it exports | ✅ enforced by `manifest.test.ts` |
911
+ | Exported types, including `DocsErrorCode`'s members | ✅ enforced by `error-taxonomy.test.ts` |
912
+ | **CSS class names** — `wave-docs-*`, and the shell's element tree | ✅ frozen in [ADR 001](docs/adr/001-shell-contract.md) |
913
+ | **The hast this emits** — element names, and the attributes on them | ✅ the same policy as the types |
914
+ | Layout tokens — the five custom properties above | ✅ |
915
+ | Anything reachable only through `dist/` internals, or a private module | ❌ |
916
+
917
+ The two in bold are the ones usually omitted, and omitting them is how a
918
+ package ships a "patch" that silently reflows everyone's site. If you can write
919
+ a selector against it or read it out of `RenderedDoc.hast`, this package owes
920
+ you a changelog entry before it moves.
921
+
922
+ ### Four clauses
923
+
924
+ **Dropping a major of `next`, `react` or `react-dom` is breaking; adding one is
925
+ not.** Widening `peerDependencies` to accept the next major is a minor and
926
+ always safe to take. Narrowing it — dropping React 19 once React 20 has settled
927
+ — is breaking, gets its own release, and will not be bundled with features.
928
+
929
+ **The Node floor follows LTS, and moving it is breaking.** It rises when a
930
+ version leaves maintenance, not when a shiny builtin appears. Today `22.12.0`.
931
+
932
+ **A third-party type in this package's signature makes that library's major
933
+ ours.** `unified`'s `PluggableList` is in `remarkPlugins`, `MiniSearch`'s
934
+ `Options` is in `miniSearchOptions`, and hast's `Root` is in `RenderedDoc`. When
935
+ one of those releases a breaking major, so does this — a package that quietly
936
+ re-exports someone else's break is worse than one that names it.
937
+
938
+ **Zod is a dependency, not a peer, and that is deliberate.** Your Zod is yours.
939
+ When you extend the frontmatter schema, take `z` from
940
+ `@waveso/docs/frontmatter`.
941
+
942
+ ### What is not covered
943
+
944
+ The rendered *appearance* — colours, spacing, the type scale — is design, and it
945
+ will change without a major. The class names it hangs on will not.
442
946
 
443
947
  ## Design notes
444
948
 
@@ -471,7 +975,7 @@ It also hardcodes `passNode: true` with no opt-out, so any component you map tha
471
975
 
472
976
  An HTML string is a dead end: you can only render it with `dangerouslySetInnerHTML`, which forfeits component mapping, makes every element unstyleable except through descendant selectors, and puts the burden of trusting the content on you.
473
977
 
474
- A hast tree is data. It survives `JSON.stringify`, crosses the RSC boundary, caches to disk, and renders through `hast-util-to-jsx-runtime` with your components substituted for whichever elements you care about. The cost is a slightly larger payload; positions are stripped before it ships, which removes about 44% of the JSON on a typical page.
978
+ A hast tree is data. It survives `JSON.stringify`, crosses the RSC boundary, caches to disk, and renders through `hast-util-to-jsx-runtime` with your components substituted for whichever elements you care about. The cost is a slightly larger payload; positions are stripped before it ships, which removes roughly a third of the JSON. Measured at 33% on a mixed page — it rises on short pages, where the offsets are a larger share of a smaller tree. Two figures in this repository disagreed about it (38% in a comment, 44% here) until somebody measured.
475
979
 
476
980
  </details>
477
981