@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.
- package/LICENSE +21 -0
- package/README.md +518 -0
- package/dist/frontmatter.d.ts +55 -0
- package/dist/frontmatter.js +80 -0
- package/dist/highlighter.d.ts +99 -0
- package/dist/highlighter.js +183 -0
- package/dist/meta.d.ts +75 -0
- package/dist/meta.js +183 -0
- package/dist/next.d.ts +256 -0
- package/dist/next.js +365 -0
- package/dist/plugins/rehype-capture-toc.d.ts +18 -0
- package/dist/plugins/rehype-capture-toc.js +69 -0
- package/dist/plugins/remark-doc-links.d.ts +63 -0
- package/dist/plugins/remark-doc-links.js +122 -0
- package/dist/plugins/remark-unwrap-images.d.ts +11 -0
- package/dist/plugins/remark-unwrap-images.js +25 -0
- package/dist/plugins/remark-youtube.d.ts +22 -0
- package/dist/plugins/remark-youtube.js +84 -0
- package/dist/react/callout.d.ts +37 -0
- package/dist/react/callout.js +113 -0
- package/dist/react/doc-content.d.ts +29 -0
- package/dist/react/doc-content.js +30 -0
- package/dist/react/markdown-components.d.ts +84 -0
- package/dist/react/markdown-components.js +122 -0
- package/dist/react/search-dialog.d.ts +41 -0
- package/dist/react/search-dialog.js +404 -0
- package/dist/react/sidebar.d.ts +29 -0
- package/dist/react/sidebar.js +196 -0
- package/dist/react/skip-link.d.ts +37 -0
- package/dist/react/skip-link.js +37 -0
- package/dist/react/toc.d.ts +35 -0
- package/dist/react/toc.js +87 -0
- package/dist/react/youtube.d.ts +27 -0
- package/dist/react/youtube.js +75 -0
- package/dist/render.d.ts +72 -0
- package/dist/render.js +279 -0
- package/dist/search-index.d.ts +51 -0
- package/dist/search-index.js +274 -0
- package/dist/search-options.d.ts +18 -0
- package/dist/search-options.js +40 -0
- package/dist/source.d.ts +67 -0
- package/dist/source.js +332 -0
- package/dist/styles.css +1033 -0
- package/dist/types.d.ts +334 -0
- package/dist/types.js +0 -0
- 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
|
+
[](https://www.npmjs.com/package/@waveso/docs)
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+

|
|
10
|
+

|
|
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 };
|