@waveso/docs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +518 -0
  3. package/dist/frontmatter.d.ts +55 -0
  4. package/dist/frontmatter.js +80 -0
  5. package/dist/highlighter.d.ts +99 -0
  6. package/dist/highlighter.js +183 -0
  7. package/dist/meta.d.ts +75 -0
  8. package/dist/meta.js +183 -0
  9. package/dist/next.d.ts +256 -0
  10. package/dist/next.js +365 -0
  11. package/dist/plugins/rehype-capture-toc.d.ts +18 -0
  12. package/dist/plugins/rehype-capture-toc.js +69 -0
  13. package/dist/plugins/remark-doc-links.d.ts +63 -0
  14. package/dist/plugins/remark-doc-links.js +122 -0
  15. package/dist/plugins/remark-unwrap-images.d.ts +11 -0
  16. package/dist/plugins/remark-unwrap-images.js +25 -0
  17. package/dist/plugins/remark-youtube.d.ts +22 -0
  18. package/dist/plugins/remark-youtube.js +84 -0
  19. package/dist/react/callout.d.ts +37 -0
  20. package/dist/react/callout.js +113 -0
  21. package/dist/react/doc-content.d.ts +29 -0
  22. package/dist/react/doc-content.js +30 -0
  23. package/dist/react/markdown-components.d.ts +84 -0
  24. package/dist/react/markdown-components.js +122 -0
  25. package/dist/react/search-dialog.d.ts +41 -0
  26. package/dist/react/search-dialog.js +404 -0
  27. package/dist/react/sidebar.d.ts +29 -0
  28. package/dist/react/sidebar.js +196 -0
  29. package/dist/react/skip-link.d.ts +37 -0
  30. package/dist/react/skip-link.js +37 -0
  31. package/dist/react/toc.d.ts +35 -0
  32. package/dist/react/toc.js +87 -0
  33. package/dist/react/youtube.d.ts +27 -0
  34. package/dist/react/youtube.js +75 -0
  35. package/dist/render.d.ts +72 -0
  36. package/dist/render.js +279 -0
  37. package/dist/search-index.d.ts +51 -0
  38. package/dist/search-index.js +274 -0
  39. package/dist/search-options.d.ts +18 -0
  40. package/dist/search-options.js +40 -0
  41. package/dist/source.d.ts +67 -0
  42. package/dist/source.js +332 -0
  43. package/dist/styles.css +1033 -0
  44. package/dist/types.d.ts +334 -0
  45. package/dist/types.js +0 -0
  46. package/package.json +166 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wave
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,518 @@
1
+ <div align="center">
2
+
3
+ # @waveso/docs
4
+
5
+ <p><strong>Markdown documentation for Next.js.<br />Parsed in Node at build time, so the parser never reaches the browser.</strong></p>
6
+
7
+ [![npm](https://img.shields.io/npm/v/@waveso/docs)](https://www.npmjs.com/package/@waveso/docs)
8
+ [![license](https://img.shields.io/npm/l/@waveso/docs)](./LICENSE)
9
+ ![React 19](https://img.shields.io/badge/React-19-149ECA?logo=react&logoColor=white)
10
+ ![Next.js 16](https://img.shields.io/badge/Next.js-16-000000?logo=nextdotjs&logoColor=white)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## Why Wave Docs
17
+
18
+ Point it at a folder of `.md` files and you get a documentation site: routing, navigation, a table of contents, syntax highlighting, search and redirects.
19
+
20
+ The design decision everything else follows from: **markdown becomes [hast](https://github.com/syntax-tree/hast) in Node, at build time.** A hast tree is plain serialisable JSON, so Next renders it inside a Server Component and the browser receives a tree of nodes and a component map — never `unified`, never `remark-parse`, never Shiki.
21
+
22
+ ```
23
+ content/*.md ──▶ source ──▶ render ──▶ { hast, toc, frontmatter }
24
+ (Node) (Node) │
25
+ RSC payload
26
+
27
+ <DocContent hast={…} />
28
+ ```
29
+
30
+ Three things follow from that shape, and they are the reasons to choose this over the alternatives.
31
+
32
+ **Nothing is stringified to HTML.** The pipeline stops at hast, so the output stays *data*. You map `h2`, `a`, `img`, `pre` and `callout` onto your own components, and nothing is ever handed to `dangerouslySetInnerHTML`.
33
+
34
+ **Table-of-contents anchors cannot drift.** Heading ids are read off the same pass that annotated the document, rather than recomputed by a second parse. Two sections called "Install" get `#install` and `#install-1`, and the TOC links match — by construction, not by coincidence.
35
+
36
+ **Broken internal links fail the build.** `[auth](./api/auth.md)` is the right way to link between markdown files: it resolves on GitHub and in every editor preview, and it 404s once published. Those links are rewritten to routes and their targets checked, so the failure lands in CI instead of in production.
37
+
38
+ ## Installation
39
+
40
+ ```sh
41
+ pnpm add @waveso/docs
42
+ ```
43
+
44
+ `react`, `react-dom` and `zod` are required peers; `next` and `tailwindcss` are optional — install only what you use.
45
+
46
+ The `zod` floor is `4.4.3`, not `^4.0.0`: the built-in frontmatter schema calls `.exactOptional()` at module scope, so an earlier 4.x throws on import of `@waveso/docs/frontmatter` with nothing in the message naming zod.
47
+
48
+ There is no `image-size` peer. An `imageResolver` you write is welcome to read dimensions with it — but it is your dependency, in your own `package.json`. It was declared here as an optional peer, which installs nothing and therefore does not make `await import('image-size')` resolve for you; the declaration only looked like it helped.
49
+
50
+ ## Quick start
51
+
52
+ **Two route files are required.** `[...slug]` does not match `/docs` itself, so the index needs its own `page.tsx`. An optional catch-all (`[[...slug]]`) does match, but leaves `/docs/index` live and serving byte-identical HTML with no canonical between them.
53
+
54
+ Create the route once, in a module every route file imports:
55
+
56
+ ```ts
57
+ // lib/docs.ts
58
+ import { createDocsRoute } from '@waveso/docs/next';
59
+
60
+ export const docs = createDocsRoute({ contentDir: 'content/docs' });
61
+ ```
62
+
63
+ ```tsx
64
+ // app/docs/[...slug]/page.tsx
65
+ import { docs } from '@/lib/docs';
66
+
67
+ export default docs.Page;
68
+ export const generateStaticParams = docs.generateStaticParams;
69
+ export const generateMetadata = docs.generateMetadata;
70
+ export const dynamicParams = false;
71
+ ```
72
+
73
+ ```tsx
74
+ // app/docs/page.tsx
75
+ import { docs } from '@/lib/docs';
76
+
77
+ export default docs.IndexPage;
78
+ export const generateMetadata = docs.generateMetadata;
79
+ ```
80
+
81
+ ```
82
+ content/docs/
83
+ index.md
84
+ getting-started.md
85
+ api/
86
+ meta.json
87
+ authentication.md
88
+ ```
89
+
90
+ That is a working documentation site.
91
+
92
+ > [!IMPORTANT]
93
+ > `dynamicParams` must be written out as `false`. Route segment config is parsed statically before the module runs, so `export const dynamicParams = docs.dynamicParams` fails `next build`. Without it, Next renders unlisted URLs on demand — `/docs/typo` reaches the filesystem, `readFile` throws, and Next answers **500** where it should answer 404.
94
+
95
+ ## Entry points
96
+
97
+ There is no root export. Every entry point is a subpath, so an import always names the file it came from.
98
+
99
+ | Subpath | Environment | Contents |
100
+ | --- | --- | --- |
101
+ | `@waveso/docs/next` | Node | `createDocsRoute`, `createDocsSitemap`, `createDocsRedirects` |
102
+ | `@waveso/docs/source` | Node | `createDocsSource`, `resolveDocsConfig` |
103
+ | `@waveso/docs/render` | Node | `createDocsRenderer` |
104
+ | `@waveso/docs/highlighter` | Node | `createDocsHighlighter`, `DEFAULT_DOCS_LANGS` |
105
+ | `@waveso/docs/search-index` | Node | `extractSearchRecords`, `buildSearchIndex`, `writeSearchIndex` |
106
+ | `@waveso/docs/react/*` | Browser + RSC | See [Components](#components) |
107
+ | `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter` |
108
+ | `@waveso/docs/search-options` | Any | `SEARCH_INDEX_OPTIONS` |
109
+ | `@waveso/docs/types` | Any | Every shared type. Type-only |
110
+ | `@waveso/docs/styles.css` | — | The stylesheet |
111
+
112
+ The Node-only subpaths carry `"browser": null`, so importing one from client code fails with a located *module not found* rather than quietly bundling `node:fs`.
113
+
114
+ ## Components
115
+
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.
117
+
118
+ | Component | Subpath | Notes |
119
+ | --- | --- | --- |
120
+ | `DocContent` | `react/doc-content` | Renders a hast tree. Server Component |
121
+ | `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
122
+ | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver` |
123
+ | `SearchDialog` | `react/search-dialog` | ⌘K, arrow keys, focus trap |
124
+ | `Callout` | `react/callout` | Note · tip · important · warning · caution |
125
+ | `YouTube` | `react/youtube` | Click-to-load facade |
126
+ | `SkipLink` | `react/skip-link` | Targets `docs.Page`'s `<article>` |
127
+ | `createMarkdownComponents` | `react/markdown-components` | The element → component map |
128
+
129
+ ### Layout
130
+
131
+ App Router layouts are Server Components and `usePathname` is client-only, so the one client boundary in a docs layout is a wrapper around the sidebar:
132
+
133
+ ```tsx
134
+ // components/docs-nav.tsx
135
+ 'use client';
136
+
137
+ import Link from 'next/link';
138
+ import { usePathname } from 'next/navigation';
139
+ import { DocsSidebar } from '@waveso/docs/react/sidebar';
140
+ import type { DocNavNode } from '@waveso/docs/types';
141
+
142
+ export function DocsNav({ nav }: { nav: DocNavNode[] }) {
143
+ return <DocsSidebar nav={nav} pathname={usePathname()} Link={Link} />;
144
+ }
145
+ ```
146
+
147
+ ```tsx
148
+ // app/docs/layout.tsx
149
+ 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
+ import '@waveso/docs/styles.css';
154
+
155
+ export default async function DocsLayout({ children }: { children: ReactNode }) {
156
+ const nav = await docs.source.nav();
157
+ return (
158
+ <>
159
+ <SkipLink />
160
+ <DocsNav nav={nav} />
161
+ {children}
162
+ </>
163
+ );
164
+ }
165
+ ```
166
+
167
+ A page that needs the table of contents renders itself from `docs.getPage(segments)` instead of re-exporting `docs.Page`:
168
+
169
+ ```tsx
170
+ import { notFound } from 'next/navigation';
171
+ import { DocContent } from '@waveso/docs/react/doc-content';
172
+ import { DocsToc } from '@waveso/docs/react/toc';
173
+ import { docs } from '@/lib/docs';
174
+
175
+ export default async function Page({ params }: { params: Promise<{ slug?: string[] }> }) {
176
+ const { slug } = await params;
177
+ const doc = await docs.getPage(slug ?? []);
178
+ if (!doc) notFound();
179
+
180
+ return (
181
+ <>
182
+ <article id="docs-content" tabIndex={-1} className="wave-docs-prose">
183
+ <DocContent hast={doc.hast} />
184
+ </article>
185
+ <DocsToc entries={doc.toc} />
186
+ </>
187
+ );
188
+ }
189
+ ```
190
+
191
+ ## Frontmatter
192
+
193
+ ```yaml
194
+ ---
195
+ title: Authentication # required
196
+ description: Bearer tokens. # <meta name="description"> and search
197
+ label: Auth # sidebar label, when the title is too long
198
+ draft: true # excluded from nav, search and static params
199
+ order: 10 # sort weight where there is no meta.json
200
+ aliases: [old-auth, legacy/auth] # former URLs → permanent redirects
201
+ ---
202
+ ```
203
+
204
+ `title` is required on every `.md` file in the tree. A file without one fails the build rather than shipping an untitled page.
205
+
206
+ `draft` is deliberately **not** tied to `NODE_ENV`. Preview deployments are production builds, so branching on it would hide drafts in exactly the place reviewers look — drive `includeDrafts` from your own environment check instead.
207
+
208
+ ### Your own fields
209
+
210
+ Pass a `frontmatterSchema` and every `DocFile` and `RenderedDoc` carries your fields, inferred, with no type argument anywhere:
211
+
212
+ ```ts
213
+ // content/docs-schema.ts — one module, imported by every route file
214
+ import { docFrontmatterSchema } from '@waveso/docs/frontmatter';
215
+ import { z } from 'zod';
216
+
217
+ export const frontmatterSchema = docFrontmatterSchema.extend({
218
+ audience: z.enum(['user', 'operator']).exactOptional(),
219
+ });
220
+ ```
221
+
222
+ ```ts
223
+ const docs = createDocsRoute({ contentDir: 'content/docs', frontmatterSchema });
224
+
225
+ const doc = await docs.getPage(['api', 'auth']);
226
+ doc?.frontmatter.audience; // 'user' | 'operator' | undefined
227
+ doc?.frontmatter.title; // string
228
+ ```
229
+
230
+ Any [Standard Schema](https://standardschema.dev) validator works — Zod, Valibot, ArkType. The field is typed `StandardSchemaV1<unknown, TFrontmatter>` rather than as a Zod type, so the package does not dictate your validator. `zod` stays a required peer because `docFrontmatterSchema` is a Zod schema and extending it is the shortest path to a valid one.
231
+
232
+ Four things are worth knowing before you write one.
233
+
234
+ **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
+
236
+ ```ts
237
+ // ⚠️ Compiles. Every extra field is `undefined` at runtime, typed as present.
238
+ const docs = createDocsRoute<MyFrontmatter>({ contentDir: 'content/docs' });
239
+ ```
240
+
241
+ **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
+
243
+ **The output must still satisfy `DocFrontmatter`.** `title` drives the `<h1>` fallback and `<title>`, `draft` the visibility filter, `aliases` the redirects, `order` and `label` the sidebar. A schema that drops them is a compile error where you pass it, not a mystery at render time.
244
+
245
+ **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
+
247
+ A page the schema rejects fails the build, naming the file, every bad path, and whose schema rejected it:
248
+
249
+ ```
250
+ Invalid frontmatter in api/auth.md:
251
+ - audience: Invalid option: expected one of "user"|"operator"
252
+ Fix the YAML block at the top of that file, or the `frontmatterSchema` in your docs config.
253
+ ```
254
+
255
+ ## Navigation
256
+
257
+ One optional `meta.json` per directory controls order and labelling. Chosen over numeric filename prefixes because a filename cannot express separators, external links or a directory title.
258
+
259
+ ```json
260
+ {
261
+ "title": "API Reference",
262
+ "pages": [
263
+ "index",
264
+ "authentication",
265
+ "---Advanced---",
266
+ "...webhooks",
267
+ "...",
268
+ { "title": "Status page", "href": "https://status.example.com" }
269
+ ]
270
+ }
271
+ ```
272
+
273
+ | Entry | Meaning |
274
+ | --- | --- |
275
+ | `"authentication"` | A file or subdirectory in this directory, in this position |
276
+ | `"---Advanced---"` | A non-interactive separator with the enclosed label |
277
+ | `"..."` | Everything not named explicitly. At most one per file |
278
+ | `"...webhooks"` | Expand the `webhooks` subdirectory inline, with no group wrapper |
279
+ | `{ "title", "href" }` | An arbitrary link. `external` is inferred from the href |
280
+
281
+ Omit `pages` entirely and the directory sorts by frontmatter `order`, then title — exactly what a lone `"..."` does. Naming an entry that resolves to nothing fails the build, with the `meta.json` path, the offending entry and the list of available names.
282
+
283
+ A group heading takes its `meta.json` `title`, else its `index.md` `label`, else its `index.md` `title`, else the directory name humanised.
284
+
285
+ ## Markdown support
286
+
287
+ GFM (tables, strikethrough, task lists, autolinks), GitHub alert syntax (`> [!NOTE]` → `<callout type="note">`), heading ids and permalinks, dual-theme Shiki highlighting, and lone images unwrapped out of their paragraph.
288
+
289
+ Raw HTML in the source is **dropped**, not passed through. `rehype-raw` is not in the chain — on its own it happily reparses `<script>` back into the tree.
290
+
291
+ A bare YouTube URL on its own line becomes a click-to-load facade: one ~15 KB thumbnail instead of ~717 KB of embed and player JavaScript on page load. A labelled link keeps its label and stays a link.
292
+
293
+ Eighteen grammars load by default — what technical documentation actually contains:
294
+
295
+ ```
296
+ typescript tsx javascript jsx json shellscript css html
297
+ markdown yaml diff sql python go rust prisma ini toml
298
+ ```
299
+
300
+ A ```` ```cfg ```` fence (or ```` ```conf ````) uses the `ini` grammar, because
301
+ the fence an author types follows the filename — nobody writes ```` ```ini ````
302
+ above a file called `server.cfg`.
303
+
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.
305
+
306
+ ## Theming
307
+
308
+ Every colour is a `--wave-docs-*` custom property. Redefine the ones you want in
309
+ your own `:root`, after the import:
310
+
311
+ ```css
312
+ @import '@waveso/docs/styles.css';
313
+
314
+ :root {
315
+ --wave-docs-accent: oklch(0.55 0.2 265);
316
+ --wave-docs-bg-subtle: oklch(0.98 0.004 265);
317
+ }
318
+ ```
319
+
320
+ 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.
323
+
324
+ The distinction matters. The dark tokens are declared as
325
+ `:root:not([data-theme='light'])`, which is specificity (0,2,0). Before the
326
+ layers, an unlayered `:root` at (0,1,0) lost *no matter where it was loaded* —
327
+ the cascade never reached source order, and overriding meant writing
328
+ `:root:root:root`. Now source order and layering settle it, and a plain `:root`
329
+ is enough.
330
+
331
+ Dark mode follows the OS by default and `data-theme` overrides it in both
332
+ directions: `[data-theme='light']` opts out of a dark system,
333
+ `[data-theme='dark']` opts into dark on a light one.
334
+
335
+ To restyle rather than retheme, override the classes — `.wave-docs-prose`,
336
+ `.wave-docs-skip-link`, and the rest — from your own unlayered CSS.
337
+
338
+ > [!NOTE]
339
+ > If you retheme, re-check contrast. `src/styles.test.ts` asserts every
340
+ > foreground/background pair the shipped tokens compose clears WCAG 1.4.3
341
+ > (4.5:1); none of the text here is "large" in the WCAG sense, so 3:1 is never
342
+ > enough.
343
+
344
+ ## Search
345
+
346
+ 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
+
348
+ Nothing builds the index for you. `docs.renderAll()` exists for exactly this, and shares the scan, the highlighter and the render cache with your routes:
349
+
350
+ ```ts
351
+ // scripts/build-search-index.ts — run before `next build`
352
+ import { extractSearchRecords, writeSearchIndex } from '@waveso/docs/search-index';
353
+ import { docs } from '../lib/docs';
354
+
355
+ const rendered = await docs.renderAll();
356
+ const records = rendered.flatMap((doc) => extractSearchRecords(doc));
357
+ await writeSearchIndex(records, 'public/search-index.json');
358
+ ```
359
+
360
+ ```tsx
361
+ 'use client';
362
+
363
+ import Link from 'next/link';
364
+ import { useRouter } from 'next/navigation';
365
+ import { SearchDialog } from '@waveso/docs/react/search-dialog';
366
+
367
+ export function Search() {
368
+ const router = useRouter();
369
+ return <SearchDialog indexUrl="/search-index.json" navigate={router.push} Link={Link} />;
370
+ }
371
+ ```
372
+
373
+ MiniSearch is `import()`ed and the index fetched on hover, focus or first open — never on page load.
374
+
375
+ ## Configuration
376
+
377
+ ```ts
378
+ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
379
+ contentDir: string; // relative paths resolve against process.cwd()
380
+ basePath?: string; // default '/docs'; '/' normalises to ''
381
+ includeDrafts?: boolean; // default false
382
+ assertLinks?: boolean; // default true
383
+ frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter>;
384
+ }
385
+ ```
386
+
387
+ `createDocsRoute` additionally accepts:
388
+
389
+ | Option | Default | Purpose |
390
+ | --- | --- | --- |
391
+ | `langs` | 16 grammars | Typed `readonly DocsLang[]`, so a typo is a compile error |
392
+ | `themes` | `github-light` / `github-dark` | Shiki theme pair |
393
+ | `highlighter` | built-in | Supply your own for grammars outside the set |
394
+ | `titleHeading` | `true` | Build an `<h1>` from `frontmatter.title` when the markdown has none |
395
+ | `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
+ | `siteUrl` | — | Makes canonical URLs absolute |
399
+ | `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
+
401
+ `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
+
403
+ ### Redirects and sitemap
404
+
405
+ Separate calls, usable from `next.config.ts` and `app/sitemap.ts` — neither loads the Next runtime:
406
+
407
+ ```ts
408
+ import { createDocsRedirects, createDocsSitemap } from '@waveso/docs/next';
409
+
410
+ // next.config.ts
411
+ export default { redirects: () => createDocsRedirects({ contentDir: 'content/docs' }) };
412
+
413
+ // app/sitemap.ts
414
+ export default () =>
415
+ createDocsSitemap({ contentDir: 'content/docs', siteUrl: 'https://example.com' });
416
+ ```
417
+
418
+ ### Development
419
+
420
+ Markdown files are not in Next's module graph, so nothing recompiles a route module when one changes. `createDocsRoute` re-scans the content directory on every request outside `NODE_ENV=production`: edits appear on reload, new files are found without a restart. A rescan of a few hundred small files is single-digit milliseconds — it is the render that costs.
421
+
422
+ ## Requirements
423
+
424
+ | | |
425
+ | --- | --- |
426
+ | Node.js | ≥ 20.19.0 |
427
+ | React | 19 |
428
+ | Next.js | 16 (optional peer — only `@waveso/docs/next` needs it) |
429
+ | Module format | **ESM only** |
430
+ | TypeScript | 5.9+ |
431
+
432
+ ESM-only is forced rather than chosen: `unified` and the entire `remark-*` / `rehype-*` lineage are `"type": "module"` with no CJS build, so a dual output would resolve to nothing. `attw` reports `cjs-resolves-to-esm` for this package; that is the intended shape.
433
+
434
+ If you extend the frontmatter schema, use `.exactOptional()` rather than `.optional()` for optional fields: the latter infers `{ description?: string | undefined }`, which is not assignable to `DocFrontmatter`.
435
+
436
+ > [!NOTE]
437
+ > Under `exactOptionalPropertyTypes: true`, passing `next/link` straight into
438
+ > `DocsSidebar` or `SearchDialog` does not type-check — `next/link` types
439
+ > `prefetch` as `boolean | null | undefined` where `DocsLinkProps` says
440
+ > `boolean | undefined`. `docs.Page` is unaffected, because the adapter wraps
441
+ > `next/link` internally. Without that flag, `Link={Link}` compiles as shown.
442
+
443
+ ## Design notes
444
+
445
+ <details>
446
+ <summary><strong>Why not MDX?</strong></summary>
447
+
448
+ <br />
449
+
450
+ MDX is not markdown; it is a JavaScript module that looks like markdown. That buys arbitrary components in prose, and costs the thing this package exists to protect: the output is code, so it must be compiled and bundled per page, it cannot be cached as JSON, it cannot cross the RSC boundary as data, and a non-engineer can no longer safely edit a page. An author can also break the build with a stray `<`.
451
+
452
+ Here the extension points are the component map and the `callout` element. If you need more component freedom in prose than that allows, you need MDX — use it, and accept the bundle.
453
+
454
+ </details>
455
+
456
+ <details>
457
+ <summary><strong>Why not react-markdown?</strong></summary>
458
+
459
+ <br />
460
+
461
+ `react-markdown` parses **in the browser**. `unified` + `remark-parse` + `remark-gfm` is roughly 60 KB gzipped shipped to every reader, plus Shiki if you want highlighting, to do work that could have happened once at build time.
462
+
463
+ It also hardcodes `passNode: true` with no opt-out, so any component you map that spreads its props renders `node="[object Object]"` into production HTML — with no type error, because `node` is a legal prop on the component and an unknown attribute on the element. `DocContent` leaves `passNode` off.
464
+
465
+ </details>
466
+
467
+ <details>
468
+ <summary><strong>Why stop at hast instead of an HTML string?</strong></summary>
469
+
470
+ <br />
471
+
472
+ 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
+
474
+ A hast tree is data. It survives `JSON.stringify`, crosses the RSC boundary, caches to disk, and renders through `hast-util-to-jsx-runtime` with your components substituted for whichever elements you care about. The cost is a slightly larger payload; positions are stripped before it ships, which removes about 44% of the JSON on a typical page.
475
+
476
+ </details>
477
+
478
+ ## Development
479
+
480
+ ```sh
481
+ pnpm install
482
+ pnpm test # vitest
483
+ pnpm run typecheck # tsc --noEmit
484
+ pnpm run build # tsdown
485
+ pnpm run lint # biome
486
+ pnpm run check:package # publint + are-the-types-wrong
487
+ ```
488
+
489
+ ### Project structure
490
+
491
+ ```
492
+ .changeset/ # Changesets config
493
+ .github/workflows/ # CI + publish
494
+ src/
495
+ types.ts # The shared contract. Type-only
496
+ source.ts # Filesystem → DocFile[] + nav tree
497
+ render.ts # The unified pipeline → hast + TOC
498
+ highlighter.ts # Fine-grained Shiki
499
+ frontmatter.ts # Base schema + validation
500
+ search-index.ts # Section-scoped records → MiniSearch
501
+ next.ts # The App Router adapter
502
+ meta.ts # meta.json ordering
503
+ plugins/ # remark/rehype plugins
504
+ react/ # Components. Import nothing from next/*
505
+ styles.css # Theme tokens + prose styles
506
+ ```
507
+
508
+ ## Releasing
509
+
510
+ This project uses [Changesets](https://github.com/changesets/changesets) with GitHub Actions.
511
+
512
+ 1. Run `pnpm changeset` to describe your changes (patch, minor, or major)
513
+ 2. Commit the generated changeset file with your PR
514
+ 3. When merged to `main`, CI versions and publishes to npm
515
+
516
+ ## License
517
+
518
+ [MIT](./LICENSE)
@@ -0,0 +1,55 @@
1
+ import { DocFrontmatter } from "./types.js";
2
+ import { z } from "zod";
3
+ import { StandardSchemaV1 } from "@standard-schema/spec";
4
+ //#region src/frontmatter.d.ts
5
+ /**
6
+ * The frontmatter fields the package itself understands, as a Zod schema.
7
+ *
8
+ * Optional fields use `.exactOptional()` rather than `.optional()` so the
9
+ * inferred type is `{ description?: string }` and not
10
+ * `{ description?: string | undefined }` — the latter is not assignable to
11
+ * {@link DocFrontmatter} under `exactOptionalPropertyTypes`, and an explicit
12
+ * `undefined` cannot come out of YAML anyway.
13
+ *
14
+ * `description` is deliberately not length-capped. The 155–160 character
15
+ * advice for `<meta name="description">` is a pixel-width heuristic about
16
+ * where Google truncates a snippet, not a limit — enforcing it here would
17
+ * fail a build over prose that renders fine.
18
+ *
19
+ * Extend it for project-specific fields, then pass the result as
20
+ * `frontmatterSchema`:
21
+ *
22
+ * ```ts
23
+ * const schema = docFrontmatterSchema.extend({
24
+ * audience: z.enum(['user', 'operator']).exactOptional(),
25
+ * });
26
+ * ```
27
+ */
28
+ declare const docFrontmatterSchema: z.ZodObject<{
29
+ title: z.ZodString;
30
+ description: z.ZodExactOptional<z.ZodString>;
31
+ label: z.ZodExactOptional<z.ZodString>;
32
+ draft: z.ZodExactOptional<z.ZodBoolean>;
33
+ aliases: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
34
+ order: z.ZodExactOptional<z.ZodNumber>;
35
+ }, z.core.$strip>;
36
+ /**
37
+ * Validate one file's frontmatter, or throw an error that names the file.
38
+ *
39
+ * `raw` is whatever the YAML parser produced — `unknown` by construction, so
40
+ * every field is checked rather than trusted.
41
+ *
42
+ * Async because `~standard.validate` is allowed to return a promise and some
43
+ * validators do (any schema with an async refinement). The source layer is
44
+ * already async, so awaiting here costs nothing and refusing promises would
45
+ * have made a documented half of the spec silently unsupported.
46
+ *
47
+ * @param raw - Parsed YAML frontmatter block.
48
+ * @param filePath - Path reported in the error. Pass the path the author
49
+ * would recognise (relative to the content root), not an absolute one.
50
+ * @param schema - Optional replacement schema, normally
51
+ * `docFrontmatterSchema.extend(...)`. Any Standard Schema validator works.
52
+ */
53
+ declare function parseFrontmatter<TFrontmatter extends DocFrontmatter = DocFrontmatter>(raw: unknown, filePath: string, schema?: StandardSchemaV1<unknown, TFrontmatter>): Promise<TFrontmatter>;
54
+ //#endregion
55
+ export { docFrontmatterSchema, parseFrontmatter };
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+ //#region src/frontmatter.ts
3
+ /**
4
+ * The frontmatter fields the package itself understands, as a Zod schema.
5
+ *
6
+ * Optional fields use `.exactOptional()` rather than `.optional()` so the
7
+ * inferred type is `{ description?: string }` and not
8
+ * `{ description?: string | undefined }` — the latter is not assignable to
9
+ * {@link DocFrontmatter} under `exactOptionalPropertyTypes`, and an explicit
10
+ * `undefined` cannot come out of YAML anyway.
11
+ *
12
+ * `description` is deliberately not length-capped. The 155–160 character
13
+ * advice for `<meta name="description">` is a pixel-width heuristic about
14
+ * where Google truncates a snippet, not a limit — enforcing it here would
15
+ * fail a build over prose that renders fine.
16
+ *
17
+ * Extend it for project-specific fields, then pass the result as
18
+ * `frontmatterSchema`:
19
+ *
20
+ * ```ts
21
+ * const schema = docFrontmatterSchema.extend({
22
+ * audience: z.enum(['user', 'operator']).exactOptional(),
23
+ * });
24
+ * ```
25
+ */
26
+ const docFrontmatterSchema = z.object({
27
+ title: z.string().min(1),
28
+ description: z.string().exactOptional(),
29
+ label: z.string().exactOptional(),
30
+ draft: z.boolean().exactOptional(),
31
+ aliases: z.array(z.string()).exactOptional(),
32
+ order: z.number().exactOptional()
33
+ });
34
+ /**
35
+ * Validate one file's frontmatter, or throw an error that names the file.
36
+ *
37
+ * `raw` is whatever the YAML parser produced — `unknown` by construction, so
38
+ * every field is checked rather than trusted.
39
+ *
40
+ * Async because `~standard.validate` is allowed to return a promise and some
41
+ * validators do (any schema with an async refinement). The source layer is
42
+ * already async, so awaiting here costs nothing and refusing promises would
43
+ * have made a documented half of the spec silently unsupported.
44
+ *
45
+ * @param raw - Parsed YAML frontmatter block.
46
+ * @param filePath - Path reported in the error. Pass the path the author
47
+ * would recognise (relative to the content root), not an absolute one.
48
+ * @param schema - Optional replacement schema, normally
49
+ * `docFrontmatterSchema.extend(...)`. Any Standard Schema validator works.
50
+ */
51
+ async function parseFrontmatter(raw, filePath, schema) {
52
+ const active = schema ?? docFrontmatterSchema;
53
+ let result;
54
+ try {
55
+ result = await active["~standard"].validate(raw ?? {});
56
+ } catch (error) {
57
+ throw new Error(`Invalid frontmatter in ${filePath}: the schema threw while validating it. This is a bug in the schema, not in the YAML — check any \`refine\`/\`transform\`/\`check\` it declares.`, { cause: error });
58
+ }
59
+ if (result.issues === void 0) return result.value;
60
+ if (result.issues.length === 0) throw new Error(`Invalid frontmatter in ${filePath}: the schema rejected it but reported no issues, so there is nothing to act on. This is a bug in the schema.`);
61
+ const details = result.issues.map((issue) => ` - ${formatIssuePath(issue.path)}: ${issue.message}`).join("\n");
62
+ throw new Error(`Invalid frontmatter in ${filePath}:\n${details}\n${schema === void 0 ? "Fix the YAML block at the top of that file." : "Fix the YAML block at the top of that file, or the `frontmatterSchema` in your docs config."}`);
63
+ }
64
+ /**
65
+ * Render an issue path as `aliases[0]` / `title`, never as `""`.
66
+ *
67
+ * Standard Schema spells a path segment as either the key itself or an object
68
+ * wrapping it, and a validator may use both forms in one issue list — so both
69
+ * are handled here rather than at the call site.
70
+ */
71
+ function formatIssuePath(path) {
72
+ if (path === void 0 || path.length === 0) return "(document)";
73
+ return path.reduce((acc, segment) => {
74
+ const key = typeof segment === "object" && segment !== null ? segment.key : segment;
75
+ if (typeof key === "number") return `${acc}[${key}]`;
76
+ return acc === "" ? String(key) : `${acc}.${String(key)}`;
77
+ }, "");
78
+ }
79
+ //#endregion
80
+ export { docFrontmatterSchema, parseFrontmatter };