@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.
- package/CHANGELOG.md +138 -0
- package/README.md +490 -75
- package/dist/code-frame.d.ts +29 -0
- package/dist/code-frame.js +41 -0
- package/dist/code-meta.d.ts +48 -0
- package/dist/code-meta.js +72 -0
- package/dist/docs-content-id.d.ts +19 -0
- package/dist/docs-content-id.js +19 -0
- package/dist/docs-error.d.ts +2 -57
- package/dist/docs-error.js +3 -15
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +45 -0
- package/dist/next.d.ts +153 -28
- package/dist/next.js +65 -33
- package/dist/plugins/rehype-capture-toc.js +26 -5
- package/dist/plugins/rehype-code-frame.d.ts +10 -0
- package/dist/plugins/rehype-code-frame.js +88 -0
- package/dist/plugins/rehype-code-language.js +7 -1
- package/dist/react/code-runtime.d.ts +14 -0
- package/dist/react/code-runtime.js +161 -0
- package/dist/react/doc-content.d.ts +39 -2
- package/dist/react/doc-content.js +42 -10
- package/dist/react/layout.d.ts +44 -0
- package/dist/react/layout.js +65 -0
- package/dist/react/nav.d.ts +28 -0
- package/dist/react/nav.js +70 -0
- package/dist/react/nearest-scroll-top.d.ts +45 -0
- package/dist/react/nearest-scroll-top.js +44 -0
- package/dist/react/next-link.d.ts +34 -0
- package/dist/react/next-link.js +30 -0
- package/dist/react/next-nav.d.ts +11 -0
- package/dist/react/next-nav.js +32 -0
- package/dist/react/next-search.d.ts +22 -0
- package/dist/react/next-search.js +52 -0
- package/dist/react/search-dialog.d.ts +53 -10
- package/dist/react/search-dialog.js +147 -47
- package/dist/react/shell-labels.d.ts +43 -0
- package/dist/react/shell-labels.js +27 -0
- package/dist/react/sidebar.d.ts +38 -3
- package/dist/react/sidebar.js +104 -12
- package/dist/react/skip-link.d.ts +1 -9
- package/dist/react/skip-link.js +6 -5
- package/dist/react/toc.d.ts +12 -4
- package/dist/react/toc.js +18 -7
- package/dist/react/youtube.d.ts +31 -5
- package/dist/react/youtube.js +76 -54
- package/dist/render.d.ts +35 -1
- package/dist/render.js +35 -14
- package/dist/route-path.d.ts +46 -0
- package/dist/route-path.js +51 -0
- package/dist/search-index.d.ts +6 -23
- package/dist/search-index.js +6 -51
- package/dist/sitemap-limit.d.ts +34 -0
- package/dist/sitemap-limit.js +37 -0
- package/dist/source.d.ts +1 -23
- package/dist/source.js +40 -43
- package/dist/styles.css +1001 -106
- package/dist/types.d.ts +11 -2
- 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
|
-
**
|
|
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
|
|
114
|
-
| `@waveso/docs/react
|
|
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
|
|
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
|
|
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
|
-
| `
|
|
132
|
-
| `
|
|
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 `<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
const nav = await docs.source.nav();
|
|
241
|
+
export default function DocsLayout({ children }: { children: ReactNode }) {
|
|
165
242
|
return (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
312
|
+
<main className="wave-docs-layout__main" id="docs-content" tabIndex={-1}>
|
|
191
313
|
<DocContent hast={doc.hast} />
|
|
192
|
-
</
|
|
193
|
-
|
|
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 ``.
|
|
@@ -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 } =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `` 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 | ≥
|
|
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`
|
|
528
|
-
> `
|
|
529
|
-
>
|
|
530
|
-
> `next/link`
|
|
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
|
|
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
|
|