@waveso/docs 0.2.0 → 0.4.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 (59) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +490 -75
  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 +2 -57
  10. package/dist/docs-error.js +3 -15
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/next.d.ts +153 -28
  14. package/dist/next.js +65 -33
  15. package/dist/plugins/rehype-capture-toc.js +26 -5
  16. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  17. package/dist/plugins/rehype-code-frame.js +88 -0
  18. package/dist/plugins/rehype-code-language.js +7 -1
  19. package/dist/react/code-runtime.d.ts +14 -0
  20. package/dist/react/code-runtime.js +161 -0
  21. package/dist/react/doc-content.d.ts +39 -2
  22. package/dist/react/doc-content.js +42 -10
  23. package/dist/react/layout.d.ts +44 -0
  24. package/dist/react/layout.js +65 -0
  25. package/dist/react/nav.d.ts +28 -0
  26. package/dist/react/nav.js +70 -0
  27. package/dist/react/nearest-scroll-top.d.ts +45 -0
  28. package/dist/react/nearest-scroll-top.js +44 -0
  29. package/dist/react/next-link.d.ts +34 -0
  30. package/dist/react/next-link.js +30 -0
  31. package/dist/react/next-nav.d.ts +11 -0
  32. package/dist/react/next-nav.js +32 -0
  33. package/dist/react/next-search.d.ts +22 -0
  34. package/dist/react/next-search.js +52 -0
  35. package/dist/react/search-dialog.d.ts +53 -10
  36. package/dist/react/search-dialog.js +147 -47
  37. package/dist/react/shell-labels.d.ts +43 -0
  38. package/dist/react/shell-labels.js +27 -0
  39. package/dist/react/sidebar.d.ts +38 -3
  40. package/dist/react/sidebar.js +104 -12
  41. package/dist/react/skip-link.d.ts +1 -9
  42. package/dist/react/skip-link.js +6 -5
  43. package/dist/react/toc.d.ts +12 -4
  44. package/dist/react/toc.js +18 -7
  45. package/dist/react/youtube.d.ts +31 -5
  46. package/dist/react/youtube.js +76 -54
  47. package/dist/render.d.ts +35 -1
  48. package/dist/render.js +35 -14
  49. package/dist/route-path.d.ts +46 -0
  50. package/dist/route-path.js +51 -0
  51. package/dist/search-index.d.ts +6 -23
  52. package/dist/search-index.js +6 -51
  53. package/dist/sitemap-limit.d.ts +34 -0
  54. package/dist/sitemap-limit.js +37 -0
  55. package/dist/source.d.ts +1 -23
  56. package/dist/source.js +40 -43
  57. package/dist/styles.css +1001 -106
  58. package/dist/types.d.ts +11 -2
  59. package/package.json +58 -23
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
@@ -55,9 +84,30 @@ That is not a style preference. `.extend()` produces a schema only as trustworth
55
84
 
56
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.
57
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× |
101
+
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.
103
+
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.
107
+
58
108
  ## Quick start
59
109
 
60
- **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.
61
111
 
62
112
  Create the route once, in a module every route file imports:
63
113
 
@@ -86,6 +136,22 @@ export default docs.IndexPage;
86
136
  export const generateMetadata = docs.generateMetadata;
87
137
  ```
88
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
+
89
155
  ```
90
156
  content/docs/
91
157
  index.md
@@ -95,7 +161,9 @@ content/docs/
95
161
  authentication.md
96
162
  ```
97
163
 
98
- 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.
99
167
 
100
168
  > [!IMPORTANT]
101
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.
@@ -108,73 +176,127 @@ There is no root export. Every entry point is a subpath, so an import always nam
108
176
  | --- | --- | --- |
109
177
  | `@waveso/docs/next` | Node | `createDocsRoute`, `createDocsSitemap`, `createDocsRedirects` |
110
178
  | `@waveso/docs/source` | Node | `createDocsSource`, `resolveDocsConfig` |
111
- | `@waveso/docs/render` | Node | `createDocsRenderer` |
112
- | `@waveso/docs/highlighter` | Node | `createDocsHighlighter`, `DEFAULT_DOCS_LANGS` |
113
- | `@waveso/docs/search-index` | Node | `extractSearchRecords`, `buildSearchIndex`, `writeSearchIndex` |
114
- | `@waveso/docs/react/*` | Browser + RSC | See [Components](#components) |
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) |
115
183
  | `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter`, `z` |
116
- | `@waveso/docs/search-options` | Any | `SEARCH_INDEX_OPTIONS` |
117
184
  | `@waveso/docs/types` | Any | Every shared type. Type-only |
185
+ | `@waveso/docs/errors` | Any | `DocsErrorCode`, `DocsError`, `isDocsError`, `DOCS_ERROR_PREFIX` |
118
186
  | `@waveso/docs/styles.css` | — | The stylesheet |
119
187
 
120
- 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.
121
211
 
122
212
  ## Components
123
213
 
124
- 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`.
125
215
 
126
216
  | Component | Subpath | Notes |
127
217
  | --- | --- | --- |
128
- | `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 |
129
219
  | `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
130
220
  | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver` |
131
- | `SearchDialog` | `react/search-dialog` | ⌘K, arrow keys, focus trap |
132
- | `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 |
133
224
  | `YouTube` | `react/youtube` | Click-to-load facade |
134
- | `SkipLink` | `react/skip-link` | Targets `docs.Page`'s `<article>` |
135
- | `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 |
136
227
 
137
228
  ### Layout
138
229
 
139
- 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.
140
231
 
141
- ```tsx
142
- // components/docs-nav.tsx
143
- 'use client';
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.
144
233
 
145
- import Link from 'next/link';
146
- import { usePathname } from 'next/navigation';
147
- import { DocsSidebar } from '@waveso/docs/react/sidebar';
148
- import type { DocNavNode } from '@waveso/docs/types';
149
-
150
- export function DocsNav({ nav }: { nav: DocNavNode[] }) {
151
- return <DocsSidebar nav={nav} pathname={usePathname()} Link={Link} />;
152
- }
153
- ```
234
+ To put your own chrome in the header, call it instead of re-exporting it:
154
235
 
155
236
  ```tsx
156
- // app/docs/layout.tsx
157
237
  import type { ReactNode } from 'react';
158
- import { SkipLink } from '@waveso/docs/react/skip-link';
159
- import { DocsNav } from '@/components/docs-nav';
160
- import { docs } from '@/lib/docs';
161
238
  import '@waveso/docs/styles.css';
239
+ import { docs } from '@/lib/docs';
162
240
 
163
- export default async function DocsLayout({ children }: { children: ReactNode }) {
164
- const nav = await docs.source.nav();
241
+ export default function DocsLayout({ children }: { children: ReactNode }) {
165
242
  return (
166
- <>
167
- <SkipLink />
168
- <DocsNav nav={nav} />
243
+ <docs.Layout
244
+ title="Wave"
245
+ actions={<a href="https://github.com/waveso/docs">GitHub</a>}
246
+ >
169
247
  {children}
170
- </>
248
+ </docs.Layout>
171
249
  );
172
250
  }
173
251
  ```
174
252
 
175
- 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:
176
267
 
177
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`.
178
300
  import { notFound } from 'next/navigation';
179
301
  import { DocContent } from '@waveso/docs/react/doc-content';
180
302
  import { DocsToc } from '@waveso/docs/react/toc';
@@ -187,15 +309,25 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
187
309
 
188
310
  return (
189
311
  <>
190
- <article id="docs-content" tabIndex={-1} className="wave-docs-prose">
312
+ <main className="wave-docs-layout__main" id="docs-content" tabIndex={-1}>
191
313
  <DocContent hast={doc.hast} />
192
- </article>
193
- <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
+ )}
194
320
  </>
195
321
  );
196
322
  }
197
323
  ```
198
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
+
199
331
  ## Frontmatter
200
332
 
201
333
  ```yaml
@@ -227,6 +359,9 @@ export const frontmatterSchema = docFrontmatterSchema.extend({
227
359
  ```
228
360
 
229
361
  ```ts
362
+ import { createDocsRoute } from '@waveso/docs/next';
363
+ import { frontmatterSchema } from '@/content/docs-schema';
364
+
230
365
  const docs = createDocsRoute({ contentDir: 'content/docs', frontmatterSchema });
231
366
 
232
367
  const doc = await docs.getPage(['api', 'auth']);
@@ -240,6 +375,7 @@ Four things are worth knowing before you write one.
240
375
 
241
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:
242
377
 
378
+ <!-- typecheck: skip — the two lines are the point; imports would bury them -->
243
379
  ```ts
244
380
  // ⚠️ Compiles. Every extra field is `undefined` at runtime, typed as present.
245
381
  const docs = createDocsRoute<MyFrontmatter>({ contentDir: 'content/docs' });
@@ -312,6 +448,95 @@ above a file called `server.cfg`.
312
448
 
313
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.
314
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
+
315
540
  ### Images
316
541
 
317
542
  **Absolute and external sources just work.** Put the file in `public/` and write `![](/diagram.png)`.
@@ -327,10 +552,17 @@ A **relative** source is a different thing. Nothing in `public/` corresponds to
327
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:
328
553
 
329
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
+
330
560
  createDocsRoute({
331
561
  contentDir: 'content/docs',
332
562
  imageResolver: async (src) => {
333
- const { width, height } = await imageSize(path.join('content/docs', src));
563
+ const { width, height } = imageSize(
564
+ await readFile(path.join('content/docs', src)),
565
+ );
334
566
  return { src: `/docs-assets/${src}`, width, height };
335
567
  },
336
568
  });
@@ -384,6 +616,7 @@ page it is dropped into, so it now switches only when the host says to.
384
616
 
385
617
  If your site really does follow the OS and has no theme toggle, say so once:
386
618
 
619
+ <!-- typecheck: skip — one tag, shown as markup rather than as a module -->
387
620
  ```tsx
388
621
  <html lang="en" data-theme="system">
389
622
  ```
@@ -401,53 +634,113 @@ To restyle rather than retheme, override the classes — `.wave-docs-prose`,
401
634
 
402
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.
403
636
 
404
- 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.
405
638
 
406
- ```ts
407
- // scripts/build-search-index.ts — run before `next build`
408
- import { extractSearchRecords, writeSearchIndex } from '@waveso/docs/search-index';
409
- import { docs } from '../lib/docs';
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.
410
640
 
411
- const rendered = await docs.renderAll();
412
- const records = rendered.flatMap((doc) => extractSearchRecords(doc));
413
- await writeSearchIndex(records, 'public/search-index.json');
414
- ```
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.
415
642
 
416
- ```tsx
417
- 'use client';
643
+ MiniSearch is `import()`ed and the index fetched on hover, focus or first open — never on page load.
418
644
 
419
- import Link from 'next/link';
420
- import { useRouter } from 'next/navigation';
421
- import { SearchDialog } from '@waveso/docs/react/search-dialog';
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.
422
647
 
423
- export function Search() {
424
- const router = useRouter();
425
- return <SearchDialog indexUrl="/search-index.json" navigate={router.push} Link={Link} />;
426
- }
427
- ```
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.
428
649
 
429
- MiniSearch is `import()`ed and the index fetched on hover, focus or first open — never on page load.
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()`.
430
655
 
431
656
  ### What gets indexed
432
657
 
433
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.
434
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
+
435
662
  ### CJK and other scripts
436
663
 
437
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 安装.
438
665
 
439
- 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:
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:
440
669
 
441
670
  ```ts
442
- buildSearchIndex(records, { fuzzy: 0.1, prefix: true });
671
+ import { createDocsRoute } from '@waveso/docs/next';
672
+
673
+ export const docs = createDocsRoute({
674
+ contentDir: 'content/docs',
675
+ miniSearchOptions: { searchOptions: { fuzzy: 0.1, prefix: true } },
676
+ });
443
677
  ```
444
678
 
445
679
  ```tsx
446
- <SearchDialog indexUrl="/search-index.json" searchOptions={{ fuzzy: 0.1, prefix: true }} />
680
+ import { DocsSearch } from '@waveso/docs/react/next-search';
681
+ import { docs } from '@/lib/docs';
682
+
683
+ export function Search() {
684
+ return (
685
+ <DocsSearch
686
+ indexUrl={docs.searchIndexUrl}
687
+ miniSearchOptions={{ searchOptions: { fuzzy: 0.1, prefix: true } }}
688
+ />
689
+ );
690
+ }
691
+ ```
692
+
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
+ });
447
726
  ```
448
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.
740
+
449
741
  ## Configuration
450
742
 
743
+ <!-- typecheck: skip — a reference listing of the type, not a module -->
451
744
  ```ts
452
745
  interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
453
746
  contentDir: string; // relative paths resolve against process.cwd()
@@ -467,24 +760,28 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
467
760
  | `highlighter` | built-in | Supply your own for grammars outside the set |
468
761
  | `titleHeading` | `true` | Build an `<h1>` from `frontmatter.title` when the markdown has none |
469
762
  | `components` | built-in map | Override any element → component mapping |
470
- | `contentId` | `'docs-content'` | The id `SkipLink` targets; `false` if your layout owns it |
471
- | `rescanPerRequest` | dev only | Re-scan the content directory per request |
472
763
  | `siteUrl` | — | Makes canonical URLs absolute |
473
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 |
474
765
 
475
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.
476
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
+
477
770
  ### Redirects and sitemap
478
771
 
479
772
  Separate calls, usable from `next.config.ts` and `app/sitemap.ts` — neither loads the Next runtime:
480
773
 
481
774
  ```ts
482
- import { createDocsRedirects, createDocsSitemap } from '@waveso/docs/next';
483
-
484
775
  // next.config.ts
776
+ import { createDocsRedirects } from '@waveso/docs/next';
777
+
485
778
  export default { redirects: () => createDocsRedirects({ contentDir: 'content/docs' }) };
779
+ ```
486
780
 
781
+ ```ts
487
782
  // app/sitemap.ts
783
+ import { createDocsSitemap } from '@waveso/docs/next';
784
+
488
785
  export default () =>
489
786
  createDocsSitemap({ contentDir: 'content/docs', siteUrl: 'https://example.com' });
490
787
  ```
@@ -508,11 +805,75 @@ Markdown files are not in Next's module graph, so nothing recompiles a route mod
508
805
 
509
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.
510
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. |
871
+
511
872
  ## Requirements
512
873
 
513
874
  | | |
514
875
  | --- | --- |
515
- | Node.js | ≥ 20.19.0 |
876
+ | Node.js | ≥ 22.12.0 |
516
877
  | React | 19 |
517
878
  | Next.js | 16 (optional peer — only `@waveso/docs/next` needs it) |
518
879
  | Module format | **ESM only** |
@@ -524,10 +885,64 @@ If you extend the frontmatter schema, use `.exactOptional()` rather than `.optio
524
885
 
525
886
  > [!NOTE]
526
887
  > Under `exactOptionalPropertyTypes: true`, passing `next/link` straight into
527
- > `DocsSidebar` or `SearchDialog` does not type-check `next/link` types
528
- > `prefetch` as `boolean | null | undefined` where `DocsLinkProps` says
529
- > `boolean | undefined`. `docs.Page` is unaffected, because the adapter wraps
530
- > `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.
531
946
 
532
947
  ## Design notes
533
948
 
@@ -560,7 +975,7 @@ It also hardcodes `passNode: true` with no opt-out, so any component you map tha
560
975
 
561
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.
562
977
 
563
- 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.
564
979
 
565
980
  </details>
566
981