@waveso/docs 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -0
- package/README.md +592 -88
- 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 +19 -0
- package/dist/docs-error.js +28 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +45 -0
- package/dist/frontmatter.d.ts +39 -7
- package/dist/frontmatter.js +51 -24
- package/dist/highlighter.d.ts +2 -2
- package/dist/highlighter.js +3 -2
- package/dist/map-pooled.d.ts +26 -0
- package/dist/map-pooled.js +45 -0
- package/dist/meta.d.ts +7 -3
- package/dist/meta.js +61 -15
- package/dist/next.d.ts +182 -35
- package/dist/next.js +177 -49
- package/dist/plugins/rehype-capture-toc.js +52 -20
- 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.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +54 -0
- package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
- package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
- package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
- package/dist/plugins/rehype-flatten-roots.js +39 -0
- package/dist/plugins/remark-doc-links.d.ts +12 -1
- package/dist/plugins/remark-doc-links.js +147 -20
- 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/markdown-components.js +71 -6
- 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 +35 -7
- package/dist/react/search-dialog.js +55 -33
- 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 +46 -12
- package/dist/react/youtube.d.ts +31 -5
- package/dist/react/youtube.js +76 -52
- package/dist/render.d.ts +78 -10
- package/dist/render.js +137 -54
- package/dist/route-path.d.ts +46 -0
- package/dist/route-path.js +51 -0
- package/dist/search-index.d.ts +22 -21
- package/dist/search-index.js +27 -78
- package/dist/search-options.d.ts +32 -1
- package/dist/search-options.js +66 -3
- package/dist/section-boundary.d.ts +17 -0
- package/dist/section-boundary.js +43 -0
- package/dist/sitemap-limit.d.ts +34 -0
- package/dist/sitemap-limit.js +37 -0
- package/dist/source.d.ts +12 -22
- package/dist/source.js +165 -72
- package/dist/styles.css +1117 -125
- package/dist/types.d.ts +52 -29
- package/package.json +70 -34
package/README.md
CHANGED
|
@@ -11,6 +11,35 @@
|
|
|
11
11
|
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
|
+
<br />
|
|
15
|
+
|
|
16
|
+
<!--
|
|
17
|
+
⚠️ ABSOLUTE `raw.githubusercontent.com` URLS, PINNED TO A TAG.
|
|
18
|
+
|
|
19
|
+
npm rewrites relative markdown image paths onto its own CDN, but it does NOT
|
|
20
|
+
rewrite `<source srcset>` inside a `<picture>` — so a relative path here shows
|
|
21
|
+
a broken image on npmjs.com. And pinning to `main` rather than a tag means an
|
|
22
|
+
old version's README displays a future product: someone reading 0.3.0 in 2027
|
|
23
|
+
would see whatever the shell looks like then.
|
|
24
|
+
|
|
25
|
+
`pnpm shoot` regenerates these from the real site build; CI runs
|
|
26
|
+
`pnpm shoot --check` on any pull request touching the stylesheet, the React
|
|
27
|
+
layer or the site.
|
|
28
|
+
-->
|
|
29
|
+
<picture>
|
|
30
|
+
<source
|
|
31
|
+
media="(prefers-color-scheme: dark)"
|
|
32
|
+
srcset="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.3.0/docs/media/hero-dark.png"
|
|
33
|
+
/>
|
|
34
|
+
<img
|
|
35
|
+
src="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.3.0/docs/media/hero-light.png"
|
|
36
|
+
alt="A documentation page rendered by @waveso/docs: a navigation sidebar, prose with syntax-highlighted code frames, and a table of contents."
|
|
37
|
+
width="100%"
|
|
38
|
+
/>
|
|
39
|
+
</picture>
|
|
40
|
+
|
|
41
|
+
<p align="center"><em>The default page, with no CSS of your own. <a href="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.3.0/docs/media/search.png">Search dialog →</a></em></p>
|
|
42
|
+
|
|
14
43
|
---
|
|
15
44
|
|
|
16
45
|
## Why Wave Docs
|
|
@@ -41,15 +70,44 @@ Three things follow from that shape, and they are the reasons to choose this ove
|
|
|
41
70
|
pnpm add @waveso/docs
|
|
42
71
|
```
|
|
43
72
|
|
|
44
|
-
|
|
73
|
+
That is the whole installation. `react` and `react-dom` are required peers; `next` is optional, needed only by `@waveso/docs/next`.
|
|
74
|
+
|
|
75
|
+
**Zod is not a peer.** It ships as a dependency of this package, so your project's Zod — version 3, version 4, or none at all — is irrelevant and nothing conflicts. When you extend the built-in schema, take `z` from here rather than from your own install:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { docFrontmatterSchema, z } from '@waveso/docs/frontmatter';
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That is not a style preference. `.extend()` produces a schema only as trustworthy as the instance that built it, and re-exporting ours means the extension is built from the same module object by construction rather than by luck. Your own Zod stays yours, for everything else in your app.
|
|
82
|
+
|
|
83
|
+
**There is no `tailwindcss` peer and no Tailwind involved.** The stylesheet is plain CSS with `wave-docs-*` class names. It was declared as an optional peer once, which blocked `npm install` outright for any project on Tailwind 3 — npm still range-checks an optional peer that happens to be installed.
|
|
84
|
+
|
|
85
|
+
There is no `image-size` peer either. An `imageResolver` you write is welcome to read dimensions with it — but it is your dependency, in your own `package.json`. Declaring it here installed nothing and did not make `await import('image-size')` resolve for you; it only looked like it helped.
|
|
86
|
+
|
|
87
|
+
## What it costs
|
|
88
|
+
|
|
89
|
+
Every figure below is a **ceiling**, and `pnpm size` fails the build if the measurement passes it — in CI and again in `prepublishOnly`. So these are numbers this package is held to, not numbers somebody remembered to update.
|
|
90
|
+
|
|
91
|
+
| | At most |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| Everything the quick start ships, gzipped | 13.0 KB |
|
|
94
|
+
| Search dialog and router wiring | 9.0 KB |
|
|
95
|
+
| Navigation: sidebar and mobile drawer | 2.2 KB |
|
|
96
|
+
| Table of contents | 0.9 KB |
|
|
97
|
+
| Copy-button runtime | 0.9 KB |
|
|
98
|
+
| hast over the wire vs HTML, prose page | 1.20× |
|
|
99
|
+
| hast over the wire vs HTML, code and tables | 1.12× |
|
|
100
|
+
| Highlighting vs no highlighting | 2.00× |
|
|
45
101
|
|
|
46
|
-
The
|
|
102
|
+
The first row is the honest total: a reader who lands on a page of your documentation downloads under 13 KB gzipped of JavaScript from this package, and that is the whole of it. No markdown parser and no syntax highlighter reach the browser at all — those run in Node at build time. Drop the search dialog and it is under 4 KB.
|
|
47
103
|
|
|
48
|
-
|
|
104
|
+
The one real cost is the middle pair: shipping a tree instead of a string is about 20% more brotli on a prose page, and about 12% on a page with code and tables, where Shiki's token spans dominate both representations equally. That is the price of never handing markup to `dangerouslySetInnerHTML`, and it is the first number a skeptical reviewer should ask for.
|
|
105
|
+
|
|
106
|
+
`size-budget.json` holds a second, looser ceiling per entry with a note explaining what to do when it is hit — and a build fails if the table above ever promises worse than that file enforces.
|
|
49
107
|
|
|
50
108
|
## Quick start
|
|
51
109
|
|
|
52
|
-
**
|
|
110
|
+
**Three route files, and each one earns its place.** `[...slug]` does not match `/docs` itself, so the index needs its own `page.tsx` — an optional catch-all (`[[...slug]]`) does match, but leaves `/docs/index` live and serving byte-identical HTML with no canonical between them. The third serves the search index, which the layout's search trigger reads.
|
|
53
111
|
|
|
54
112
|
Create the route once, in a module every route file imports:
|
|
55
113
|
|
|
@@ -78,6 +136,22 @@ export default docs.IndexPage;
|
|
|
78
136
|
export const generateMetadata = docs.generateMetadata;
|
|
79
137
|
```
|
|
80
138
|
|
|
139
|
+
```tsx
|
|
140
|
+
// app/docs/layout.tsx
|
|
141
|
+
import '@waveso/docs/styles.css';
|
|
142
|
+
import { docs } from '@/lib/docs';
|
|
143
|
+
|
|
144
|
+
export default docs.Layout;
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// app/docs/search-index.json/route.ts
|
|
149
|
+
import { docs } from '@/lib/docs';
|
|
150
|
+
|
|
151
|
+
export const GET = docs.searchIndex;
|
|
152
|
+
export const dynamic = 'force-static';
|
|
153
|
+
```
|
|
154
|
+
|
|
81
155
|
```
|
|
82
156
|
content/docs/
|
|
83
157
|
index.md
|
|
@@ -87,10 +161,12 @@ content/docs/
|
|
|
87
161
|
authentication.md
|
|
88
162
|
```
|
|
89
163
|
|
|
90
|
-
That is a working documentation site.
|
|
164
|
+
That is a working documentation site: routing, a navigation sidebar, a table of contents, syntax highlighting, search, a mobile drawer and a skip link.
|
|
165
|
+
|
|
166
|
+
The search route is in the quick start rather than in a section further down because `docs.Layout` renders the search trigger by default — leave the route out and a reader gets a control that opens onto "Search is unavailable". If you genuinely do not want search, `export default function Layout(props) { return docs.Layout({ ...props, search: false }) }` drops both the trigger and this file. See [Search](#search) for tuning.
|
|
91
167
|
|
|
92
168
|
> [!IMPORTANT]
|
|
93
|
-
> `dynamicParams` must be written out as `false`. Route segment config is parsed statically before the module runs, so `export const dynamicParams = docs.dynamicParams` fails `next build`. Without it, Next
|
|
169
|
+
> `dynamicParams` must be written out as `false`. Route segment config is parsed statically before the module runs, so `export const dynamicParams = docs.dynamicParams` fails `next build`. Without it, Next invokes the route on a server at request time for every unlisted URL, to produce a 404 that was already knowable at build time — and `output: 'export'` refuses to build at all.
|
|
94
170
|
|
|
95
171
|
## Entry points
|
|
96
172
|
|
|
@@ -100,73 +176,127 @@ There is no root export. Every entry point is a subpath, so an import always nam
|
|
|
100
176
|
| --- | --- | --- |
|
|
101
177
|
| `@waveso/docs/next` | Node | `createDocsRoute`, `createDocsSitemap`, `createDocsRedirects` |
|
|
102
178
|
| `@waveso/docs/source` | Node | `createDocsSource`, `resolveDocsConfig` |
|
|
103
|
-
| `@waveso/docs/render` | Node | `createDocsRenderer` |
|
|
104
|
-
| `@waveso/docs/highlighter` | Node | `createDocsHighlighter`, `DEFAULT_DOCS_LANGS` |
|
|
105
|
-
| `@waveso/docs/search-index` | Node | `extractSearchRecords`, `buildSearchIndex
|
|
106
|
-
| `@waveso/docs/react
|
|
107
|
-
| `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter` |
|
|
108
|
-
| `@waveso/docs/search-options` | Any | `SEARCH_INDEX_OPTIONS` |
|
|
179
|
+
| `@waveso/docs/render` | Node | `createDocsRenderer`, `resolveMarkdownLink` |
|
|
180
|
+
| `@waveso/docs/highlighter` | Node | `createDocsHighlighter`, `DEFAULT_DOCS_LANGS`, `DEFAULT_DOCS_THEMES` |
|
|
181
|
+
| `@waveso/docs/search-index` | Node | `extractSearchRecords`, `buildSearchIndex` |
|
|
182
|
+
| `@waveso/docs/react/<name>` | Browser + RSC | Nine components, one per subpath — see [Components](#components) |
|
|
183
|
+
| `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter`, `z` |
|
|
109
184
|
| `@waveso/docs/types` | Any | Every shared type. Type-only |
|
|
185
|
+
| `@waveso/docs/errors` | Any | `DocsErrorCode`, `DocsError`, `isDocsError`, `DOCS_ERROR_PREFIX` |
|
|
110
186
|
| `@waveso/docs/styles.css` | — | The stylesheet |
|
|
111
187
|
|
|
112
|
-
The Node-only subpaths carry `"browser": null`, so importing one from client code fails with a located *module not found* rather than
|
|
188
|
+
The Node-only subpaths carry `"browser": null`, so importing one from client code fails with a located *module not found* rather than resolving.
|
|
189
|
+
|
|
190
|
+
That is about **weight, not about `node:fs`** — and the distinction matters, because three of the five would bundle perfectly happily. `render`, `highlighter` and `search-index` require no Node builtins at all; the markdown pipeline runs wherever JavaScript does, and Shiki is loaded through its JavaScript regex engine rather than WASM on purpose. What a bundler would do with them is succeed, and ship `unified`, `remark-parse` and every Shiki grammar to a reader — the exact outcome this package exists to prevent, arriving with no error to notice. Only `source` and `next` genuinely need the filesystem.
|
|
191
|
+
|
|
192
|
+
`entry-runtime.test.ts` asserts each set exactly, so a new builtin three modules deep fails the build instead of silently ruling out a non-Node runtime.
|
|
193
|
+
|
|
194
|
+
### Layout tokens
|
|
195
|
+
|
|
196
|
+
Five custom properties size the shell, all layered so an unlayered `:root` of your own still wins. The full contract is in [`docs/adr/001-shell-contract.md`](./docs/adr/001-shell-contract.md).
|
|
197
|
+
|
|
198
|
+
| Token | Default | Controls |
|
|
199
|
+
| --- | --- | --- |
|
|
200
|
+
| `--wave-docs-measure` | `46rem` | Prose column width. `none` opts out |
|
|
201
|
+
| `--wave-docs-header-height` | `3.5rem` | Header, and the offset sticky columns park below |
|
|
202
|
+
| `--wave-docs-sidebar-width` | `16rem` | Sidebar track |
|
|
203
|
+
| `--wave-docs-toc-width` | `15rem` | Table-of-contents track |
|
|
204
|
+
| `--wave-docs-shell-width` | `100rem` | Maximum shell width |
|
|
205
|
+
|
|
206
|
+
The shell has three breakpoints, in `rem` so they scale with the reader's base font size: the sidebar appears at **64rem**, the table of contents at **80rem**, and the whole grid stops growing at **100rem**. 64rem is arithmetic rather than taste — a 16rem sidebar plus a 46rem measure plus two 1.5rem gutters is 65rem, so anything narrower introduces the sidebar exactly where it starts eating the measure it frames.
|
|
207
|
+
|
|
208
|
+
Set `--wave-docs-font-sans: inherit` to hand the whole package your own typeface.
|
|
209
|
+
|
|
210
|
+
Every subpath is enumerated in `exports` — there is no wildcard. A name that is not documented here is not importable, and that is a guarantee rather than an intention: `manifest.test.ts` enumerates the runtime exports of every built subpath and fails the build on one this README does not mention.
|
|
113
211
|
|
|
114
212
|
## Components
|
|
115
213
|
|
|
116
|
-
Every component takes data as props and imports nothing from `next/*` — the adapter injects `next/link` and `next/image`. That keeps the renderer host-agnostic and testable without a router.
|
|
214
|
+
Every component takes data as props and imports nothing from `next/*` — the adapter injects `next/link` and `next/image`. That keeps the renderer host-agnostic and testable without a router. `DocsSearch` is the one exception, and it exists precisely so that the exception is ours rather than yours: it is the fifteen-line wrapper you would otherwise write around `SearchDialog`.
|
|
117
215
|
|
|
118
216
|
| Component | Subpath | Notes |
|
|
119
217
|
| --- | --- | --- |
|
|
120
|
-
| `DocContent` | `react/doc-content` | Renders a hast tree
|
|
218
|
+
| `DocContent` | `react/doc-content` | Renders a hast tree, inside `.wave-docs-prose`. Server Component |
|
|
121
219
|
| `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
|
|
122
220
|
| `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver` |
|
|
123
|
-
| `
|
|
124
|
-
| `
|
|
221
|
+
| `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
|
|
222
|
+
| `SearchDialog` | `react/search-dialog` | ⌘K, arrow keys, focus trap. Host-agnostic |
|
|
223
|
+
| `Callout` | `react/callout` | Note · tip · important · warning · caution. `CALLOUT_TYPES` is the list |
|
|
125
224
|
| `YouTube` | `react/youtube` | Click-to-load facade |
|
|
126
|
-
| `SkipLink` | `react/skip-link` | Targets `docs.Page`'s `<
|
|
127
|
-
| `createMarkdownComponents` | `react/markdown-components` | The element → component map |
|
|
225
|
+
| `SkipLink` | `react/skip-link` | Targets `docs.Page`'s `<main>`; `DOCS_CONTENT_ID` is that id. `docs.Layout` renders one |
|
|
226
|
+
| `createMarkdownComponents` | `react/markdown-components` | The element → component map. `defaultMarkdownComponents` is the unwired one |
|
|
128
227
|
|
|
129
228
|
### Layout
|
|
130
229
|
|
|
131
|
-
|
|
230
|
+
`export default docs.Layout` — the one line from the [quick start](#quick-start) — is a Server Component that renders the whole shell: skip link, sticky header, sidebar column, mobile drawer, and the grid that arranges them. It reads the navigation tree and the search index URL itself, so there is nothing to fetch and nothing to pass.
|
|
132
231
|
|
|
133
|
-
|
|
134
|
-
// components/docs-nav.tsx
|
|
135
|
-
'use client';
|
|
136
|
-
|
|
137
|
-
import Link from 'next/link';
|
|
138
|
-
import { usePathname } from 'next/navigation';
|
|
139
|
-
import { DocsSidebar } from '@waveso/docs/react/sidebar';
|
|
140
|
-
import type { DocNavNode } from '@waveso/docs/types';
|
|
232
|
+
Your layout stays a Server Component. The two pieces that need a client — the navigation's `usePathname`, the search dialog — carry their own `'use client'` boundaries inside the package.
|
|
141
233
|
|
|
142
|
-
|
|
143
|
-
return <DocsSidebar nav={nav} pathname={usePathname()} Link={Link} />;
|
|
144
|
-
}
|
|
145
|
-
```
|
|
234
|
+
To put your own chrome in the header, call it instead of re-exporting it:
|
|
146
235
|
|
|
147
236
|
```tsx
|
|
148
|
-
// app/docs/layout.tsx
|
|
149
237
|
import type { ReactNode } from 'react';
|
|
150
|
-
import { SkipLink } from '@waveso/docs/react/skip-link';
|
|
151
|
-
import { DocsNav } from '@/components/docs-nav';
|
|
152
|
-
import { docs } from '@/lib/docs';
|
|
153
238
|
import '@waveso/docs/styles.css';
|
|
239
|
+
import { docs } from '@/lib/docs';
|
|
154
240
|
|
|
155
|
-
export default
|
|
156
|
-
const nav = await docs.source.nav();
|
|
241
|
+
export default function DocsLayout({ children }: { children: ReactNode }) {
|
|
157
242
|
return (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
<
|
|
243
|
+
<docs.Layout
|
|
244
|
+
title="Wave"
|
|
245
|
+
actions={<a href="https://github.com/waveso/docs">GitHub</a>}
|
|
246
|
+
>
|
|
161
247
|
{children}
|
|
162
|
-
|
|
248
|
+
</docs.Layout>
|
|
163
249
|
);
|
|
164
250
|
}
|
|
165
251
|
```
|
|
166
252
|
|
|
167
|
-
|
|
253
|
+
| Prop | Type | Default | |
|
|
254
|
+
| --- | --- | --- | --- |
|
|
255
|
+
| `title` | `ReactNode` | — | Brand, at the header start |
|
|
256
|
+
| `actions` | `ReactNode` | — | Header end, after search |
|
|
257
|
+
| `search` | `boolean \| DocsSearchProps` | `true` | The search trigger. An object configures the dialog |
|
|
258
|
+
| `labels` | `DocsLabels` | English | The four strings the shell renders itself |
|
|
259
|
+
|
|
260
|
+
Five props, and two of them are small objects. That is deliberate, and it is the difference between this and an eleven-slot layout: everything else a docs shell gets asked for is already reachable. An announcement banner goes *above* `<docs.Layout>` in your own layout, because this does not own `<body>`. A content footer goes inside `children`. Sidebar links, social icons and separators are `DocNavNode`s you author in `meta.json`. The header bar was the one region nothing else could reach — hence `actions`. Two node props can become a slots map later; a slots map cannot become two props.
|
|
261
|
+
|
|
262
|
+
`search` takes anything `DocsSearch` takes except `indexUrl`, which stays derived from your `basePath`. You do not need to repeat `miniSearchOptions` here to match `createDocsRoute` — the route's own value is forwarded, so the object that built the index is the object that queries it.
|
|
263
|
+
|
|
264
|
+
`labels` is the whole of what a site not in English has to say to the shell; everything else a reader sees is your markdown or your `title`.
|
|
265
|
+
|
|
266
|
+
The same `app/docs/layout.tsx` as the quick start, written out instead of re-exported, because passing a prop needs a function:
|
|
168
267
|
|
|
169
268
|
```tsx
|
|
269
|
+
import type { ReactNode } from 'react';
|
|
270
|
+
import '@waveso/docs/styles.css';
|
|
271
|
+
import { docs } from '@/lib/docs';
|
|
272
|
+
|
|
273
|
+
export default function Layout(props: { children: ReactNode }) {
|
|
274
|
+
return docs.Layout({
|
|
275
|
+
...props,
|
|
276
|
+
labels: {
|
|
277
|
+
nav: 'Documentação',
|
|
278
|
+
openNav: 'Abrir navegação',
|
|
279
|
+
closeNav: 'Fechar navegação',
|
|
280
|
+
skipToContent: 'Ir para o conteúdo',
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Each key falls back on its own, so a partial map is not a half-translated shell.
|
|
287
|
+
|
|
288
|
+
#### The mobile drawer
|
|
289
|
+
|
|
290
|
+
Below 64rem the sidebar is a `<dialog>` opened by a server-rendered `<button command="show-modal">` — so it works on the first tap, before hydration, and with JavaScript disabled. Focus moves inside and Tab stays there, Escape closes it and returns focus to the trigger, a click on the backdrop dismisses it, and the page behind does not scroll. All of that is the browser's, not ours.
|
|
291
|
+
|
|
292
|
+
At 64rem and above the same element becomes the sticky column, via `display: contents`. One navigation in the DOM at every width: one landmark, one copy of the links in the payload, nothing to keep in step.
|
|
293
|
+
|
|
294
|
+
#### Composing it yourself
|
|
295
|
+
|
|
296
|
+
`docs.Layout` is one opinion, not a tax. The components underneath are exported individually and take data as props, so a shell of your own is `DocsSidebar` + `DocsToc` + `SkipLink` + `DocsSearch` with your own CSS — and `docs.getPage(segments)` gives you the parts a custom page needs:
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
// The catch-all page, written out instead of re-exporting `docs.Page`.
|
|
170
300
|
import { notFound } from 'next/navigation';
|
|
171
301
|
import { DocContent } from '@waveso/docs/react/doc-content';
|
|
172
302
|
import { DocsToc } from '@waveso/docs/react/toc';
|
|
@@ -179,15 +309,25 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
|
|
|
179
309
|
|
|
180
310
|
return (
|
|
181
311
|
<>
|
|
182
|
-
<
|
|
312
|
+
<main className="wave-docs-layout__main" id="docs-content" tabIndex={-1}>
|
|
183
313
|
<DocContent hast={doc.hast} />
|
|
184
|
-
</
|
|
185
|
-
|
|
314
|
+
</main>
|
|
315
|
+
{doc.toc.length === 0 ? null : (
|
|
316
|
+
<aside className="wave-docs-layout__toc">
|
|
317
|
+
<DocsToc entries={doc.toc} />
|
|
318
|
+
</aside>
|
|
319
|
+
)}
|
|
186
320
|
</>
|
|
187
321
|
);
|
|
188
322
|
}
|
|
189
323
|
```
|
|
190
324
|
|
|
325
|
+
`docs.Page` returns exactly this shape: the `<main>` and the table of contents as **two siblings**, not one wrapped element. They land as direct children of the grid, which is what puts them in separate columns — so if you compose your own page inside `docs.Layout`, return a fragment rather than a wrapper.
|
|
326
|
+
|
|
327
|
+
**The two class names are load-bearing**, and they are the part of this that is easy to leave off. `wave-docs-layout__main` carries `min-width: 0`, without which a wide table pushes the whole document into horizontal scroll (measured: 1048px of document inside a 1024px viewport). `wave-docs-layout__toc` is what the grid reserves its third track with, via `:has()` — unclassed, the table of contents auto-places into the next row underneath the sidebar above 80rem, and renders inline on a phone instead of being hidden. Both are frozen in [`docs/adr/001-shell-contract.md`](docs/adr/001-shell-contract.md), so they are safe to write by hand.
|
|
328
|
+
|
|
329
|
+
The `null` is load-bearing too: `:has()` matches an empty `<aside>` exactly as well as a full one, so a page with no headings would give up 15rem to nothing.
|
|
330
|
+
|
|
191
331
|
## Frontmatter
|
|
192
332
|
|
|
193
333
|
```yaml
|
|
@@ -211,8 +351,7 @@ Pass a `frontmatterSchema` and every `DocFile` and `RenderedDoc` carries your fi
|
|
|
211
351
|
|
|
212
352
|
```ts
|
|
213
353
|
// content/docs-schema.ts — one module, imported by every route file
|
|
214
|
-
import { docFrontmatterSchema } from '@waveso/docs/frontmatter';
|
|
215
|
-
import { z } from 'zod';
|
|
354
|
+
import { docFrontmatterSchema, z } from '@waveso/docs/frontmatter';
|
|
216
355
|
|
|
217
356
|
export const frontmatterSchema = docFrontmatterSchema.extend({
|
|
218
357
|
audience: z.enum(['user', 'operator']).exactOptional(),
|
|
@@ -220,6 +359,9 @@ export const frontmatterSchema = docFrontmatterSchema.extend({
|
|
|
220
359
|
```
|
|
221
360
|
|
|
222
361
|
```ts
|
|
362
|
+
import { createDocsRoute } from '@waveso/docs/next';
|
|
363
|
+
import { frontmatterSchema } from '@/content/docs-schema';
|
|
364
|
+
|
|
223
365
|
const docs = createDocsRoute({ contentDir: 'content/docs', frontmatterSchema });
|
|
224
366
|
|
|
225
367
|
const doc = await docs.getPage(['api', 'auth']);
|
|
@@ -227,12 +369,13 @@ doc?.frontmatter.audience; // 'user' | 'operator' | undefined
|
|
|
227
369
|
doc?.frontmatter.title; // string
|
|
228
370
|
```
|
|
229
371
|
|
|
230
|
-
Any [Standard Schema](https://standardschema.dev) validator works — Zod, Valibot, ArkType. The field is typed `StandardSchemaV1<unknown, TFrontmatter>` rather than as a Zod type, so the package does not dictate your validator
|
|
372
|
+
Any [Standard Schema](https://standardschema.dev) validator works — Zod, Valibot, ArkType. The field is typed `StandardSchemaV1<unknown, TFrontmatter>` rather than as a Zod type, so the package does not dictate your validator; a schema you hand over is never re-wrapped by the Zod in here. The `z` above is re-exported from this package precisely so that extending `docFrontmatterSchema` needs no install and no matching version.
|
|
231
373
|
|
|
232
374
|
Four things are worth knowing before you write one.
|
|
233
375
|
|
|
234
376
|
**Let the type be inferred — never name it.** Naming it explicitly *and* omitting the schema type-checks and then lies, because nothing validates the type you named:
|
|
235
377
|
|
|
378
|
+
<!-- typecheck: skip — the two lines are the point; imports would bury them -->
|
|
236
379
|
```ts
|
|
237
380
|
// ⚠️ Compiles. Every extra field is `undefined` at runtime, typed as present.
|
|
238
381
|
const docs = createDocsRoute<MyFrontmatter>({ contentDir: 'content/docs' });
|
|
@@ -240,7 +383,9 @@ const docs = createDocsRoute<MyFrontmatter>({ contentDir: 'content/docs' });
|
|
|
240
383
|
|
|
241
384
|
**Unknown keys are stripped**, by Zod and by every other validator worth using. Declare every field you intend to read — under the base schema, a page with `audience: operator` parses fine and silently loses the value. `docFrontmatterSchema.extend(…)` keeps the built-ins; a `z.object({ … })` written from scratch does not.
|
|
242
385
|
|
|
243
|
-
**The
|
|
386
|
+
**The package's own fields survive a schema that forgets them.** `title` drives the `<h1>` fallback and `<title>`, `draft` the visibility filter, `aliases` the redirects, `order` and `label` the sidebar. These are parsed from the raw YAML and merged over your schema's output, so a custom schema can only ever *add* fields — it cannot drop or corrupt the ones the package reads itself.
|
|
387
|
+
|
|
388
|
+
That is a runtime guarantee, not a compile-time one, and the difference matters: `TFrontmatter extends DocFrontmatter` constrains only `title`, because the rest are optional. A `z.object({ title, audience })` type-checks perfectly and used to strip `draft` and `aliases` on the way through — publishing every draft, submitting them to Google, and silently returning no redirects at all. Prefer `docFrontmatterSchema.extend(…)` anyway: you then get the built-in fields in *your* inferred type, rather than merely at runtime.
|
|
244
389
|
|
|
245
390
|
**Export the schema from one module.** The filesystem scan is memoised per resolved config, and two schema objects count as the same schema only when they are the same object. Build one inline in each route file and each file pays for its own scan.
|
|
246
391
|
|
|
@@ -301,7 +446,129 @@ A ```` ```cfg ```` fence (or ```` ```conf ````) uses the `ini` grammar, because
|
|
|
301
446
|
the fence an author types follows the filename — nobody writes ```` ```ini ````
|
|
302
447
|
above a file called `server.cfg`.
|
|
303
448
|
|
|
304
|
-
Anything outside that set falls back to plain text rather than throwing. Pass `langs` to change the set, or `highlighter` to supply your own.
|
|
449
|
+
Anything outside that set falls back to plain text rather than throwing. Pass `langs` to change the set, or `highlighter` to supply your own. Fence languages are matched case-insensitively, so ```` ```JSON ```` and ```` ```Bash ```` highlight like their lowercase spellings rather than silently shipping monochrome.
|
|
450
|
+
|
|
451
|
+
### Code blocks
|
|
452
|
+
|
|
453
|
+
Every highlighted fence is wrapped in a `<figure>` with a copy button. Add a title and it gets a bar:
|
|
454
|
+
|
|
455
|
+
````md
|
|
456
|
+
```ts title="app/page.tsx"
|
|
457
|
+
export default function Page() {
|
|
458
|
+
return <h1>Hello</h1>;
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
````
|
|
462
|
+
|
|
463
|
+
The title lands in three places at once — the caption, the button's accessible name (`Copy code from app/page.tsx`, rather than eight controls all called "Copy code"), and the search index.
|
|
464
|
+
|
|
465
|
+
Anything else in the meta string is left alone, so `{1,3-5}` and `showLineNumbers` pass through to Shiki untouched. A `title=` that is not double-quoted fails the build naming the document, because the alternative is a caption that silently truncates at the first space.
|
|
466
|
+
|
|
467
|
+
The copy button is one delegated listener for the whole page, mounted by `DocContent` — not a client component per code block. A page with no fences ships none of it. And it is `visibility: hidden` until that listener attaches, so a reader with JavaScript disabled sees no button and finds no dead tab stop where a control should be.
|
|
468
|
+
|
|
469
|
+
The `<figure>` carries `data-lang` (the folded language, so ```` ```JSON ```` gives `json`). No badge is rendered by default; one rule turns it on:
|
|
470
|
+
|
|
471
|
+
```css
|
|
472
|
+
.wave-docs-code[data-lang]::before {
|
|
473
|
+
content: attr(data-lang);
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Keeping it in CSS is deliberate — a real element would enter the search index and `textContent`, so every code block would pollute search results with its language name and the copy button would copy it.
|
|
478
|
+
|
|
479
|
+
#### Fences you render yourself
|
|
480
|
+
|
|
481
|
+
`excludeLangs` tells Shiki to leave a language alone, so the `<pre>` reaches your own component untouched — for diagrams, or anything that is not really code:
|
|
482
|
+
|
|
483
|
+
```ts
|
|
484
|
+
import { createDocsRoute } from '@waveso/docs/next';
|
|
485
|
+
|
|
486
|
+
// In `lib/docs.ts`, beside the rest of your configuration.
|
|
487
|
+
export const docs = createDocsRoute({
|
|
488
|
+
contentDir: 'content/docs',
|
|
489
|
+
excludeLangs: ['mermaid'],
|
|
490
|
+
});
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Those fences are deliberately **not** framed: a copy button on a rendered diagram copies its source, which is not what the reader clicked. They still get the same background, border and horizontal scroll as a highlighted block, so `excludeLangs` on its own produces a page that looks deliberate rather than unstyled.
|
|
494
|
+
|
|
495
|
+
To render them, map `pre`:
|
|
496
|
+
|
|
497
|
+
```tsx
|
|
498
|
+
import { isValidElement, type ReactNode } from 'react';
|
|
499
|
+
|
|
500
|
+
/** Yours: a `'use client'` component wrapping whichever renderer you like. */
|
|
501
|
+
declare function Mermaid(props: { children: string }): ReactNode;
|
|
502
|
+
|
|
503
|
+
function textOf(node: ReactNode): string {
|
|
504
|
+
if (typeof node === 'string') return node;
|
|
505
|
+
if (Array.isArray(node)) return node.map(textOf).join('');
|
|
506
|
+
if (isValidElement<{ children?: ReactNode }>(node)) {
|
|
507
|
+
return textOf(node.props.children);
|
|
508
|
+
}
|
|
509
|
+
return '';
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export const components = {
|
|
513
|
+
pre: (props: { children?: ReactNode }) => {
|
|
514
|
+
const child = props.children;
|
|
515
|
+
const className = isValidElement<{ className?: string | string[] }>(child)
|
|
516
|
+
? child.props.className
|
|
517
|
+
: undefined;
|
|
518
|
+
|
|
519
|
+
/*
|
|
520
|
+
* ⚠️ AN ARRAY, NOT A STRING. An excluded fence never reached Shiki, so its
|
|
521
|
+
* `<code>` still carries hast's `["language-mermaid"]` — Shiki's own
|
|
522
|
+
* output is a string. A `className === 'language-mermaid'` check compiles,
|
|
523
|
+
* reads correctly, and silently never matches, so every diagram renders as
|
|
524
|
+
* its own source.
|
|
525
|
+
*/
|
|
526
|
+
const languages = Array.isArray(className) ? className : [className];
|
|
527
|
+
|
|
528
|
+
if (languages.includes('language-mermaid')) {
|
|
529
|
+
return <Mermaid>{textOf(props.children)}</Mermaid>;
|
|
530
|
+
}
|
|
531
|
+
return <pre {...props} />;
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
Pass it as `components` to `createDocsRoute`, or to `DocContent` directly.
|
|
537
|
+
|
|
538
|
+
`Mermaid` is yours — a `'use client'` component wrapping whichever renderer you like. This package deliberately does not ship one: several hundred kilobytes of client JavaScript with its own CVE history, behind an option most sites never set, in a package with three peer dependencies against Fumadocs' eighteen.
|
|
539
|
+
|
|
540
|
+
### Images
|
|
541
|
+
|
|
542
|
+
**Absolute and external sources just work.** Put the file in `public/` and write ``.
|
|
543
|
+
|
|
544
|
+
```md
|
|
545
|
+
 ✅ served from public/
|
|
546
|
+
 ✅ external
|
|
547
|
+
 ⛔️ needs an imageResolver
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
A **relative** source is a different thing. Nothing in `public/` corresponds to it, and the browser would resolve it against the *route* — so `/docs/guide` and `/docs/guide/setup` request two different files from byte-identical markdown. Rather than ship that, a relative source with no `imageResolver` fails the build, naming the file and offering both fixes.
|
|
551
|
+
|
|
552
|
+
An `imageResolver` receives the source already folded against the markdown file's directory (`./diagram.png` in `guides/deploying.md` arrives as `guides/diagram.png`) and returns a public URL plus intrinsic dimensions — which `next/image` requires and markdown does not carry:
|
|
553
|
+
|
|
554
|
+
```ts
|
|
555
|
+
import { readFile } from 'node:fs/promises';
|
|
556
|
+
import path from 'node:path';
|
|
557
|
+
import { imageSize } from 'image-size';
|
|
558
|
+
import { createDocsRoute } from '@waveso/docs/next';
|
|
559
|
+
|
|
560
|
+
createDocsRoute({
|
|
561
|
+
contentDir: 'content/docs',
|
|
562
|
+
imageResolver: async (src) => {
|
|
563
|
+
const { width, height } = imageSize(
|
|
564
|
+
await readFile(path.join('content/docs', src)),
|
|
565
|
+
);
|
|
566
|
+
return { src: `/docs-assets/${src}`, width, height };
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
A source that climbs above the content root fails the build whether or not a resolver is configured.
|
|
305
572
|
|
|
306
573
|
## Theming
|
|
307
574
|
|
|
@@ -318,19 +585,41 @@ your own `:root`, after the import:
|
|
|
318
585
|
```
|
|
319
586
|
|
|
320
587
|
That works because **everything this stylesheet declares lives in a `@layer`** —
|
|
321
|
-
`theme` for the tokens, `base` for element resets, `components` for the classes
|
|
322
|
-
and unlayered CSS outranks every layer regardless of specificity.
|
|
588
|
+
`theme` for the tokens, `base` for element resets, `components` for the classes
|
|
589
|
+
— and unlayered CSS outranks every layer regardless of specificity.
|
|
323
590
|
|
|
324
591
|
The distinction matters. The dark tokens are declared as
|
|
325
|
-
`:root
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
`:root:root:root`.
|
|
329
|
-
|
|
592
|
+
`:root[data-theme='dark']`, which is specificity (0,2,0). Outside a layer, an
|
|
593
|
+
unlayered `:root` at (0,1,0) would lose *no matter where it was loaded* — the
|
|
594
|
+
cascade never reaches source order — and overriding would mean writing
|
|
595
|
+
`:root:root:root`. Layered, source order settles it and a plain `:root` is
|
|
596
|
+
enough.
|
|
597
|
+
|
|
598
|
+
### Dark mode is opt-in
|
|
599
|
+
|
|
600
|
+
| On `<html>` | Result |
|
|
601
|
+
| --- | --- |
|
|
602
|
+
| nothing | Light |
|
|
603
|
+
| `class="dark"` | Dark |
|
|
604
|
+
| `data-theme="dark"` | Dark |
|
|
605
|
+
| `data-theme="system"` | Follows `prefers-color-scheme` |
|
|
606
|
+
|
|
607
|
+
`.dark` is honoured because [next-themes](https://github.com/pacocoursey/next-themes)
|
|
608
|
+
defaults to `attribute="class"` and never sets `data-theme`.
|
|
330
609
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
610
|
+
**This is deliberate, and it is a change.** The tokens used to switch on
|
|
611
|
+
`prefers-color-scheme` alone. But the stylesheet styles the docs subtree, not
|
|
612
|
+
the page — so on a light-only site with a `/docs` section, a visitor whose OS
|
|
613
|
+
was in dark mode got the near-white foreground ramp on the host's white
|
|
614
|
+
background: **1.23:1**, i.e. invisible. A stylesheet cannot assume it owns the
|
|
615
|
+
page it is dropped into, so it now switches only when the host says to.
|
|
616
|
+
|
|
617
|
+
If your site really does follow the OS and has no theme toggle, say so once:
|
|
618
|
+
|
|
619
|
+
<!-- typecheck: skip — one tag, shown as markup rather than as a module -->
|
|
620
|
+
```tsx
|
|
621
|
+
<html lang="en" data-theme="system">
|
|
622
|
+
```
|
|
334
623
|
|
|
335
624
|
To restyle rather than retheme, override the classes — `.wave-docs-prose`,
|
|
336
625
|
`.wave-docs-skip-link`, and the rest — from your own unlayered CSS.
|
|
@@ -345,35 +634,113 @@ To restyle rather than retheme, override the classes — `.wave-docs-prose`,
|
|
|
345
634
|
|
|
346
635
|
Build-time index, client-side dialog, MiniSearch. Records are section-scoped — one per `h2`–`h6` — so a hit deep-links to the right heading instead of dropping the reader at the top of a 2,000-word page.
|
|
347
636
|
|
|
348
|
-
Nothing
|
|
637
|
+
Nothing to set up: `docs.Layout` renders the trigger, and the [route file in the quick start](#quick-start) serves the index. The index is a route rather than a build script, so it is rebuilt by the same `next build` that builds your pages, and in `next dev` it re-reads the disk per request — a page you add is searchable on the next keystroke, with no restart and no script to remember.
|
|
638
|
+
|
|
639
|
+
Outside `docs.Layout`, `<DocsSearch indexUrl={docs.searchIndexUrl} />` puts the trigger wherever it belongs. `DocsSearch` carries its own `'use client'` boundary, so the layout around it stays a Server Component.
|
|
640
|
+
|
|
641
|
+
`docs.searchIndexUrl` is derived from your `basePath`, so it is right whether the docs are mounted at `/`, at `/docs` or under a nested prefix. Pass it rather than a literal.
|
|
642
|
+
|
|
643
|
+
MiniSearch is `import()`ed and the index fetched on hover, focus or first open — never on page load.
|
|
644
|
+
|
|
645
|
+
> [!WARNING]
|
|
646
|
+
> **`export const dynamic = 'force-static'` is not optional, and it has to be a literal** — route segment config is parsed out of the module before any of it runs, exactly like `dynamicParams`. Without it Next marks the route `ƒ` (Dynamic) and re-renders your whole corpus on every request, from markdown that output tracing did not put in the deployment bundle. On a serverless host that does not degrade, it throws — at the reader, inside the dialog. The build prints no warning, so the handler detects it and throws with `code: 'search-index-dynamic'`, naming the file to fix.
|
|
647
|
+
|
|
648
|
+
Under `output: 'export'` the same route is written out as a plain `docs/search-index.json`. Both modes are asserted by a real `next build` in this repository's CI.
|
|
649
|
+
|
|
650
|
+
### Caching
|
|
651
|
+
|
|
652
|
+
The response carries `cache-control: public, max-age=0, must-revalidate` and a strong `ETag`, replacing Next's default of a year of `s-maxage` with no validator — which, on a URL that never changes, is a CDN serving a stale index until someone purges it by hand. `next start` does not honour `If-None-Match` itself (it answers 200 with the full body); a CDN or reverse proxy in front of it does.
|
|
653
|
+
|
|
654
|
+
If your site sets Next's own `basePath` config, prefix `indexUrl` yourself: Next applies it to `<Link>` and to navigation, but never to a client `fetch()`.
|
|
655
|
+
|
|
656
|
+
### What gets indexed
|
|
657
|
+
|
|
658
|
+
**The whole section**, not a preview of it. `extractSearchRecords` once truncated `text` to 300 characters *before* indexing, which dropped roughly 80% of a normal corpus — and because the default `combineWith: 'AND'` requires every term to land in the same record, a two-word query against a page that plainly contained both words returned nothing. Indexing and display are now separate concerns: the full text is searchable, and `storeFields` carries only what the dialog renders.
|
|
659
|
+
|
|
660
|
+
Drafts are excluded, and code blocks are skipped — after Shiki a fence is hundreds of token spans that index as a bag of punctuation. Inline `code` is kept, because `useMemo` is exactly the sort of thing people search for.
|
|
661
|
+
|
|
662
|
+
### CJK and other scripts
|
|
663
|
+
|
|
664
|
+
Tokenisation uses `Intl.Segmenter` where available, so Chinese, Japanese and Thai — which do not delimit words with spaces — index and query as words rather than as whole clauses. Without it, `search('安装')` matched nothing on a page that was entirely about 安装.
|
|
665
|
+
|
|
666
|
+
### Tuning
|
|
667
|
+
|
|
668
|
+
Both halves of the seam take the same overrides and **they must agree** — an index built with one `tokenize` and queried with another matches nothing at all, silently. So the option has one name on both sides:
|
|
349
669
|
|
|
350
670
|
```ts
|
|
351
|
-
|
|
352
|
-
import { extractSearchRecords, writeSearchIndex } from '@waveso/docs/search-index';
|
|
353
|
-
import { docs } from '../lib/docs';
|
|
671
|
+
import { createDocsRoute } from '@waveso/docs/next';
|
|
354
672
|
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
673
|
+
export const docs = createDocsRoute({
|
|
674
|
+
contentDir: 'content/docs',
|
|
675
|
+
miniSearchOptions: { searchOptions: { fuzzy: 0.1, prefix: true } },
|
|
676
|
+
});
|
|
358
677
|
```
|
|
359
678
|
|
|
360
679
|
```tsx
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
import Link from 'next/link';
|
|
364
|
-
import { useRouter } from 'next/navigation';
|
|
365
|
-
import { SearchDialog } from '@waveso/docs/react/search-dialog';
|
|
680
|
+
import { DocsSearch } from '@waveso/docs/react/next-search';
|
|
681
|
+
import { docs } from '@/lib/docs';
|
|
366
682
|
|
|
367
683
|
export function Search() {
|
|
368
|
-
|
|
369
|
-
|
|
684
|
+
return (
|
|
685
|
+
<DocsSearch
|
|
686
|
+
indexUrl={docs.searchIndexUrl}
|
|
687
|
+
miniSearchOptions={{ searchOptions: { fuzzy: 0.1, prefix: true } }}
|
|
688
|
+
/>
|
|
689
|
+
);
|
|
370
690
|
}
|
|
371
691
|
```
|
|
372
692
|
|
|
373
|
-
MiniSearch
|
|
693
|
+
`fuzzy`, `prefix`, `combineWith` and `boost` are MiniSearch *query* defaults, so they nest under `searchOptions`; `fields`, `storeFields`, `tokenize` and `processTerm` sit at the top level. The nesting is easy to get wrong and wrong is silent — a stray `fuzzy` at the top level is simply never read — so both examples above are type-checked in CI.
|
|
694
|
+
|
|
695
|
+
### Building the index yourself
|
|
696
|
+
|
|
697
|
+
Only if the route cannot express what you need — a second index per locale, say, or an artifact consumed by something other than the dialog:
|
|
698
|
+
|
|
699
|
+
```ts
|
|
700
|
+
import { buildSearchIndex, extractSearchRecords } from '@waveso/docs/search-index';
|
|
701
|
+
import { docs } from '@/lib/docs';
|
|
702
|
+
|
|
703
|
+
const rendered = await docs.renderAll();
|
|
704
|
+
const json = buildSearchIndex(rendered.flatMap((doc) => extractSearchRecords(doc)));
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
`docs.searchIndex` is exactly this, served — asserted byte-for-byte by a test, so the escape hatch cannot drift from the route.
|
|
708
|
+
|
|
709
|
+
## Plugins
|
|
710
|
+
|
|
711
|
+
Two slots, at the two positions that are actually useful:
|
|
712
|
+
|
|
713
|
+
```ts
|
|
714
|
+
import type { Plugin } from 'unified';
|
|
715
|
+
import { createDocsRoute } from '@waveso/docs/next';
|
|
716
|
+
|
|
717
|
+
// Whatever you install — `remark-math` and `rehype-katex` here.
|
|
718
|
+
declare const remarkMath: Plugin;
|
|
719
|
+
declare const rehypeKatex: Plugin;
|
|
720
|
+
|
|
721
|
+
export const mathDocs = createDocsRoute({
|
|
722
|
+
contentDir: 'content/docs',
|
|
723
|
+
remarkPlugins: [remarkMath],
|
|
724
|
+
rehypePlugins: [rehypeKatex],
|
|
725
|
+
});
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
`remarkPlugins` attach after GFM and **before link resolution**, so anything they emit is folded, contained and asserted exactly like authored markdown — a plugin writing `[x](../other/page.md)` gets the same resolution an author would, and one writing `` throws without an `imageResolver` for the same reason.
|
|
729
|
+
|
|
730
|
+
`rehypePlugins` attach after heading ids and permalinks exist and **before Shiki**, so a code fence is still `<pre><code class="language-ts">` with the author's text in it rather than several hundred token spans. Fences named by `excludeLangs` are not disguised yet either, so a plugin sees every code block the same way.
|
|
731
|
+
|
|
732
|
+
There is no after-Shiki slot. Code-block internals belong to Shiki's own `transformers`, and the honest documentation for an after-Shiki hook would be a list of things you must not do.
|
|
733
|
+
|
|
734
|
+
The table of contents is captured **last**, after your plugins and after everything else, so it describes the same document the search index does. A plugin that adds or removes a heading changes both together; there is no validation pass because there is nothing to validate.
|
|
735
|
+
|
|
736
|
+
> [!NOTE]
|
|
737
|
+
> The pipeline is built and frozen once and shared by every file, so a plugin
|
|
738
|
+
> holding state accumulates it across the whole build rather than per document.
|
|
739
|
+
> Keep them pure, or key what they hold on the vfile.
|
|
374
740
|
|
|
375
741
|
## Configuration
|
|
376
742
|
|
|
743
|
+
<!-- typecheck: skip — a reference listing of the type, not a module -->
|
|
377
744
|
```ts
|
|
378
745
|
interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
|
|
379
746
|
contentDir: string; // relative paths resolve against process.cwd()
|
|
@@ -388,42 +755,125 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
|
|
|
388
755
|
|
|
389
756
|
| Option | Default | Purpose |
|
|
390
757
|
| --- | --- | --- |
|
|
391
|
-
| `langs` |
|
|
758
|
+
| `langs` | 18 grammars | Typed `readonly DocsLang[]`, so a typo is a compile error |
|
|
392
759
|
| `themes` | `github-light` / `github-dark` | Shiki theme pair |
|
|
393
760
|
| `highlighter` | built-in | Supply your own for grammars outside the set |
|
|
394
761
|
| `titleHeading` | `true` | Build an `<h1>` from `frontmatter.title` when the markdown has none |
|
|
395
762
|
| `components` | built-in map | Override any element → component mapping |
|
|
396
|
-
| `contentId` | `'docs-content'` | The id `SkipLink` targets; `false` if your layout owns it |
|
|
397
|
-
| `rescanPerRequest` | dev only | Re-scan the content directory per request |
|
|
398
763
|
| `siteUrl` | — | Makes canonical URLs absolute |
|
|
399
764
|
| `linkResolver` · `imageResolver` | — | Override link rewriting and image dimensions. An `imageResolver` receives a folded, contained src — except an absolute `/logo.png` or a schemed `https://…`, which arrive unfolded, so branch on them |
|
|
400
765
|
|
|
401
766
|
`titleHeading` defaults on because a document with no `h1` has a broken heading outline and fails every accessibility audit. Turn it off if your layout renders the title itself.
|
|
402
767
|
|
|
768
|
+
The `<main>` always carries `id="docs-content"`, which is what `SkipLink` targets by default — there is no option to change it, because there was no matching option on `SkipLink` to follow it with, so changing it silently pointed the skip link at nothing. Outside `NODE_ENV=production` the content directory is always re-scanned per request; `docs.source.invalidate()` is the escape hatch if you need to force one.
|
|
769
|
+
|
|
403
770
|
### Redirects and sitemap
|
|
404
771
|
|
|
405
772
|
Separate calls, usable from `next.config.ts` and `app/sitemap.ts` — neither loads the Next runtime:
|
|
406
773
|
|
|
407
774
|
```ts
|
|
408
|
-
import { createDocsRedirects, createDocsSitemap } from '@waveso/docs/next';
|
|
409
|
-
|
|
410
775
|
// next.config.ts
|
|
776
|
+
import { createDocsRedirects } from '@waveso/docs/next';
|
|
777
|
+
|
|
411
778
|
export default { redirects: () => createDocsRedirects({ contentDir: 'content/docs' }) };
|
|
779
|
+
```
|
|
412
780
|
|
|
781
|
+
```ts
|
|
413
782
|
// app/sitemap.ts
|
|
783
|
+
import { createDocsSitemap } from '@waveso/docs/next';
|
|
784
|
+
|
|
414
785
|
export default () =>
|
|
415
786
|
createDocsSitemap({ contentDir: 'content/docs', siteUrl: 'https://example.com' });
|
|
416
787
|
```
|
|
417
788
|
|
|
789
|
+
`siteUrl` must be a bare origin. A path component (`https://example.com/product-docs`) is rejected, because `new URL(href, siteUrl)` discards it — every canonical and every sitemap entry would point somewhere that 404s. Put the path in `basePath`, which does take multiple segments.
|
|
790
|
+
|
|
791
|
+
**An alias is a redirect, not a page.** It is never prerendered, so linking one from your markdown fails the build and names the page to link instead. Aliases are also validated when the page is read:
|
|
792
|
+
|
|
793
|
+
| Alias | |
|
|
794
|
+
| --- | --- |
|
|
795
|
+
| `quickstart`, `guides/old-name` | ✅ |
|
|
796
|
+
| `v1:beta`, `c++`, `docs/(old)` | ⛔️ path-to-regexp metacharacters |
|
|
797
|
+
| `../escape`, `./here` | ⛔️ relative segments |
|
|
798
|
+
| `''` | ⛔️ empty |
|
|
799
|
+
|
|
800
|
+
The rejected spellings are not pedantry. Next compiles a redirect `source` as a path pattern, so `aliases: ['v1:beta']` installed a **wildcard** — it built green and then permanently 308'd `/docs/v1-guide`, a real prerendered page, away to somewhere else.
|
|
801
|
+
|
|
418
802
|
### Development
|
|
419
803
|
|
|
420
|
-
Markdown files are not in Next's module graph, so nothing recompiles a route module when one changes. `createDocsRoute` re-scans the content directory on every request outside `NODE_ENV=production`: edits appear on reload, new files are found without a restart
|
|
804
|
+
Markdown files are not in Next's module graph, so nothing recompiles a route module when one changes. `createDocsRoute` re-scans the content directory on every request outside `NODE_ENV=production`: edits appear on reload, new files are found without a restart, and the sidebar from `docs.source.nav()` agrees with the page body on the *same* request.
|
|
805
|
+
|
|
806
|
+
The rescan is shared. Next runs `generateMetadata` and your page concurrently, and a layout calling `nav()` is a third reader; invalidation is wrapped in `React.cache`, so the first of them re-reads the disk and the rest see that scan. Without it each invalidated the others' work in flight — measured at 22 `readdir` + 824 `readFile` per request on a 401-file tree, against 11 + 412 for one scan.
|
|
807
|
+
|
|
808
|
+
## Runtimes
|
|
809
|
+
|
|
810
|
+
What each entry point *requires*, measured by bundling it with no runtime assumed and asserted exactly — not approximately — by `src/entry-runtime.test.ts`:
|
|
811
|
+
|
|
812
|
+
| Entry | Node builtins |
|
|
813
|
+
| --- | --- |
|
|
814
|
+
| `@waveso/docs/types` | none |
|
|
815
|
+
| `@waveso/docs/frontmatter` | none |
|
|
816
|
+
| `@waveso/docs/highlighter` | none |
|
|
817
|
+
| `@waveso/docs/render` | none |
|
|
818
|
+
| `@waveso/docs/search-index` | none |
|
|
819
|
+
| `@waveso/docs/source` | `node:fs/promises`, `node:path` |
|
|
820
|
+
| `@waveso/docs/next` | `node:crypto`, `node:fs/promises`, `node:path` |
|
|
821
|
+
| `@waveso/docs/react/*` | none |
|
|
822
|
+
|
|
823
|
+
The markdown pipeline needs no filesystem and no `.wasm` — Shiki is loaded through its JavaScript regex engine deliberately, not its WASM one. So parsing and highlighting run wherever JavaScript does; only reading a directory of `.md` files needs Node, which is what `source` is for.
|
|
824
|
+
|
|
825
|
+
That is a statement about requirements and not a blessing. A bundle that resolves is not a runtime, and this package is tested on Node. If you run it elsewhere, note that `render` bundles to roughly 2.8 MB with all eighteen grammars inlined — narrow `langs` for anything with a size limit, since grammars are dynamic imports.
|
|
826
|
+
|
|
827
|
+
## Errors
|
|
828
|
+
|
|
829
|
+
Every failure this package raises carries a `code`, so a host can branch on the kind of thing that went wrong rather than on message text:
|
|
830
|
+
|
|
831
|
+
```ts
|
|
832
|
+
import { isDocsError } from '@waveso/docs/errors';
|
|
833
|
+
import { docs } from '@/lib/docs';
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
await docs.renderAll();
|
|
837
|
+
} catch (error) {
|
|
838
|
+
if (isDocsError(error) && error.code === 'draft-link') {
|
|
839
|
+
console.warn(error.message);
|
|
840
|
+
} else {
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
`DocsErrorCode` is exported as a union, so a `switch` over it is exhaustive and a typo is a compile error. No error class is exported, deliberately: `instanceof` against a copy of a module resolved twice — two versions in a monorepo, a bundler that duplicates it — silently answers `false`, and a string code with a structural guard has no such failure mode.
|
|
847
|
+
|
|
848
|
+
### Troubleshooting
|
|
849
|
+
|
|
850
|
+
| Code | What happened | What to do |
|
|
851
|
+
| --- | --- | --- |
|
|
852
|
+
| `broken-link` | A markdown link resolves to a route no published page owns. | Fix the link, or add an `aliases` entry to the page that moved. |
|
|
853
|
+
| `draft-link` | A link points at a page that exists but is `draft: true`. | Publish the page, or drop the link until it ships. |
|
|
854
|
+
| `alias-link` | A link points at an alias, which is a redirect and not a page. | Link the page the alias redirects to — the error names it. |
|
|
855
|
+
| `invalid-alias` | An `aliases` entry is empty, escapes the content root, or is not URL-safe. | Write it as a root-relative path, e.g. `/docs/old-name`. |
|
|
856
|
+
| `alias-collision` | Two pages claim one alias, or an alias shadows a real route. | Remove one of them; a redirect cannot have two destinations. |
|
|
857
|
+
| `route-collision` | Two files resolve to the same route. | Usually `about.md` beside `about/index.md`. Keep one. |
|
|
858
|
+
| `invalid-frontmatter` | A page has no frontmatter block, or the schema rejected it. | Every page needs at least `title`. The message names the file and the field. |
|
|
859
|
+
| `invalid-meta` | A `meta.json` is malformed, or names a page that is not there. | Check the filename spelling — entries are filenames without the extension. |
|
|
860
|
+
| `invalid-config` | An option passed to this package cannot be used as given. | The message names the option. `siteUrl` must be an absolute origin with no path. |
|
|
861
|
+
| `missing-content-dir` | `contentDir` does not point at a readable directory. | It resolves against `process.cwd()`, which is your project root under `next build`. |
|
|
862
|
+
| `broken-symlink` | A markdown page is reachable only through a broken symbolic link. | Repoint or delete the link; skipping it would silently drop a route. |
|
|
863
|
+
| `invalid-image` | A relative image needs an `imageResolver`, or one returned an unusable shape. | Pass `imageResolver`, or use an absolute `/path` the browser can resolve. |
|
|
864
|
+
| `unknown-theme` | A theme name outside the supported set. | Pass a `highlighter` of your own if you need a theme this package does not load. |
|
|
865
|
+
| `unknown-language` | A fence language outside the loaded set. | Add it to `langs`, or accept the plain-text fallback. |
|
|
866
|
+
| `missing-peer` | `next` is absent, or not the shape this adapter expects. | Install `next`, or build pages from `@waveso/docs/react/*` with your own loader. |
|
|
867
|
+
| `search-index-unavailable` | The dialog could not fetch or parse the index. | Check `indexUrl` — pass `docs.searchIndexUrl`, and prefix it yourself under a Next `basePath`. |
|
|
868
|
+
| `search-index-dynamic` | The search-index route ran at request time instead of prerendering. | Add `export const dynamic = 'force-static'` to the route file. It must be a literal. |
|
|
869
|
+
| `invalid-code-meta` | A fence's `title=` cannot be read. | Quote it: ```` ```ts title="app/page.tsx" ````. |
|
|
870
|
+
| `internal` | A plugin ran without context this package always supplies. | This one is a bug here. Please report it with the stack trace. |
|
|
421
871
|
|
|
422
872
|
## Requirements
|
|
423
873
|
|
|
424
874
|
| | |
|
|
425
875
|
| --- | --- |
|
|
426
|
-
| Node.js | ≥
|
|
876
|
+
| Node.js | ≥ 22.12.0 |
|
|
427
877
|
| React | 19 |
|
|
428
878
|
| Next.js | 16 (optional peer — only `@waveso/docs/next` needs it) |
|
|
429
879
|
| Module format | **ESM only** |
|
|
@@ -435,10 +885,64 @@ If you extend the frontmatter schema, use `.exactOptional()` rather than `.optio
|
|
|
435
885
|
|
|
436
886
|
> [!NOTE]
|
|
437
887
|
> Under `exactOptionalPropertyTypes: true`, passing `next/link` straight into
|
|
438
|
-
> `DocsSidebar`
|
|
439
|
-
> `
|
|
440
|
-
>
|
|
441
|
-
> `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.
|
|
442
946
|
|
|
443
947
|
## Design notes
|
|
444
948
|
|
|
@@ -471,7 +975,7 @@ It also hardcodes `passNode: true` with no opt-out, so any component you map tha
|
|
|
471
975
|
|
|
472
976
|
An HTML string is a dead end: you can only render it with `dangerouslySetInnerHTML`, which forfeits component mapping, makes every element unstyleable except through descendant selectors, and puts the burden of trusting the content on you.
|
|
473
977
|
|
|
474
|
-
A hast tree is data. It survives `JSON.stringify`, crosses the RSC boundary, caches to disk, and renders through `hast-util-to-jsx-runtime` with your components substituted for whichever elements you care about. The cost is a slightly larger payload; positions are stripped before it ships, which removes
|
|
978
|
+
A hast tree is data. It survives `JSON.stringify`, crosses the RSC boundary, caches to disk, and renders through `hast-util-to-jsx-runtime` with your components substituted for whichever elements you care about. The cost is a slightly larger payload; positions are stripped before it ships, which removes roughly a third of the JSON. Measured at 33% on a mixed page — it rises on short pages, where the offsets are a larger share of a smaller tree. Two figures in this repository disagreed about it (38% in a comment, 44% here) until somebody measured.
|
|
475
979
|
|
|
476
980
|
</details>
|
|
477
981
|
|