@waveso/docs 0.5.0 → 0.7.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/README.md CHANGED
@@ -13,38 +13,11 @@
13
13
 
14
14
  <br />
15
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. It is a LOCAL
26
- command and CI deliberately does not run `pnpm shoot --check`: these PNGs are
27
- compared byte for byte, and bytes do not survive a change of operating system
28
- — the font stack resolves to SF Pro on the machine that shot them and to
29
- DejaVu on a Linux runner, so every pixel of text differs and no tolerance
30
- rescues glyphs that are different shapes. The regression that gate was meant
31
- to catch — a stylesheet change reflowing the shell — is covered by the browser
32
- tier, which asserts geometry rather than pixels in the same Chromium
33
- everywhere. `scripts/shoot.ts` says the same at greater length.
34
- -->
35
- <picture>
36
- <source
37
- media="(prefers-color-scheme: dark)"
38
- srcset="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/hero-dark.png"
39
- />
40
- <img
41
- src="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/hero-light.png"
42
- alt="A documentation page rendered by @waveso/docs: a navigation sidebar, prose with syntax-highlighted code frames, and a table of contents."
43
- width="100%"
44
- />
45
- </picture>
46
-
47
- <p align="center"><em>The default page, with no CSS of your own. <a href="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/search.png">Search dialog →</a></em></p>
16
+ <p align="center">
17
+ <strong><a href="https://docs.wave.so">docs.wave.so</a></strong> — the documentation for this package, built with this package.
18
+ </p>
19
+
20
+ <p align="center"><em>Every page you see there is markdown in <code>site/content/</code>, rendered by <code>docs.Layout</code> with no layout CSS of its own. It is the acceptance harness and the showcase, and it is the same build CI runs on every commit — so it cannot drift from what this README claims.</em></p>
48
21
 
49
22
  ---
50
23
 
@@ -96,16 +69,16 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
96
69
 
97
70
  | | At most |
98
71
  | --- | --- |
99
- | Everything the quick start ships, gzipped | 13.5 KB |
72
+ | Everything the quick start ships, gzipped | 13.9 KB |
100
73
  | Search dialog and router wiring | 9.3 KB |
101
- | Navigation: sidebar and mobile drawer | 2.4 KB |
74
+ | Navigation: one sidebar, open and closed | 2.9 KB |
102
75
  | Table of contents | 0.9 KB |
103
76
  | Copy-button runtime | 1.1 KB |
104
77
  | hast over the wire vs HTML, prose page | 1.20× |
105
78
  | hast over the wire vs HTML, code and tables | 1.12× |
106
79
  | Highlighting vs no highlighting | 2.00× |
107
80
 
108
- The first row is the honest total: a reader who lands on a page of your documentation downloads under 13.5 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.
81
+ The first row is the honest total: a reader who lands on a page of your documentation downloads under 13.9 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.
109
82
 
110
83
  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.
111
84
 
@@ -167,7 +140,7 @@ content/docs/
167
140
  authentication.md
168
141
  ```
169
142
 
170
- That is a working documentation site: routing, a navigation sidebar, a table of contents, syntax highlighting, search, a mobile drawer and a skip link.
143
+ That is a working documentation site: routing, a navigation sidebar that opens and closes, a table of contents, syntax highlighting, search and a skip link.
171
144
 
172
145
  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.
173
146
 
@@ -185,7 +158,7 @@ There is no root export. Every entry point is a subpath, so an import always nam
185
158
  | `@waveso/docs/render` | Node | `createDocsRenderer`, `resolveMarkdownLink` |
186
159
  | `@waveso/docs/highlighter` | Node | `createDocsHighlighter`, `DEFAULT_DOCS_LANGS`, `DEFAULT_DOCS_THEMES` |
187
160
  | `@waveso/docs/search-index` | Node | `extractSearchRecords`, `buildSearchIndex` |
188
- | `@waveso/docs/react/<name>` | Browser + RSC | Nine components, one per subpath — see [Components](#components) |
161
+ | `@waveso/docs/react/<name>` | Browser + RSC | Ten subpaths, one component each — see [Components](#components) |
189
162
  | `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter`, `z` |
190
163
  | `@waveso/docs/types` | Any | Every shared type. Type-only |
191
164
  | `@waveso/docs/errors` | Any | `DocsErrorCode`, `DocsError`, `isDocsError`, `DOCS_ERROR_PREFIX` |
@@ -199,15 +172,21 @@ That is about **weight, not about `node:fs`** — and the distinction matters, b
199
172
 
200
173
  ### Layout tokens
201
174
 
202
- 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).
175
+ Five custom properties size the shell, all layered so an unlayered `:root` of your own still wins. All six are public API: a name changes only in a release that carries the migration.
203
176
 
204
177
  | Token | Default | Controls |
205
178
  | --- | --- | --- |
206
179
  | `--wave-docs-measure` | `46rem` | Prose column width. `none` opts out |
207
- | `--wave-docs-header-height` | `3.5rem` | Header, and the offset sticky columns park below |
208
- | `--wave-docs-sidebar-width` | `16rem` | Sidebar track |
180
+ | `--wave-docs-sidebar-width` | `16rem` | The navigation's width |
181
+ | `--wave-docs-trigger-width` | `1.25rem` | The trigger's button. The strip around it is this plus 4px a side |
209
182
  | `--wave-docs-toc-width` | `15rem` | Table-of-contents track |
210
- | `--wave-docs-shell-width` | `100rem` | Maximum shell width |
183
+ | `--wave-docs-chrome-offset` | `0rem` | Where our sticky chrome starts, below a bar of yours |
184
+
185
+ **`--wave-docs-shell-width` was removed in 0.7.0, and it is the sidebar's edge that replaced it.** It capped the whole shell and centred it, which put the sidebar's inline start 480px in from the screen on a 2560px display — and, worse, left a *closed* navigation parked in the centring margin instead of off the page. The reading column is the thing that should not stretch, so `--wave-docs-measure` caps it and it centres itself in its track; the sidebar keeps the page's inline start edge at every width, which is what makes "closed" mean off the screen by construction.
186
+
187
+ **`--wave-docs-header-height` is gone in 0.7.0, and renaming it to one thing would have lost the other.** It sized the header *and* it was the offset both sticky columns parked below. The header is gone, and so is every shape that sat above the content, so the sizing half has nothing left to size. The offset half is the one that matters and it is `--wave-docs-chrome-offset`: `--wave-docs-chrome-offset: 4rem` starts our sidebar and our table of contents 4rem down, and feeds the scroll padding that keeps an anchored heading clear of your bar.
188
+
189
+ The default is `0rem` rather than `0`, and the unit is load-bearing: it is read inside `calc(100dvh - …)` on both sticky columns, and `calc(100dvh - 0)` is invalid at computed-value time — a unitless zero kills the `max-height` instead of resolving to no change.
211
190
 
212
191
  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.
213
192
 
@@ -217,14 +196,16 @@ Every subpath is enumerated in `exports` — there is no wildcard. A name that i
217
196
 
218
197
  ## Components
219
198
 
220
- Every component takes data as props, and two modules in `src/react/` import from `next/*` — `next-nav` for `usePathname` and `next-search` for `useRouter`, each named so the exception is visible in the file list. Everything else has `next/link` and `next/image` injected. 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`.
199
+ Every component takes data as props, and every module that imports from `next/*` is named for it — `next-nav` for `usePathname`, `next-search` for `useRouter`, `next-link` for `next/link` itself — so the exception is visible in the file list rather than three imports deep. Everything else has `next/link` and `next/image` injected. 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`.
221
200
 
222
201
  | Component | Subpath | Notes |
223
202
  | --- | --- | --- |
224
203
  | `DocContent` | `react/doc-content` | Renders a hast tree, inside `.wave-docs-prose`. Server Component |
204
+ | `DocsHero` | `react/hero` | A landing page's header. `title`, `description`, `actions`, `Link`, `externalLabel`. Server Component |
225
205
  | `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
226
206
  | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver`. `label`, `topLabel`, `rootMargin`, `className` |
227
207
  | `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
208
+ | `DocsLink` | `react/next-link` | `next/link`, adapted — pass it as `Link` when composing by hand |
228
209
  | `SearchDialog` | `react/search-dialog` | ⌘K, arrow keys, focus trap. Host-agnostic |
229
210
  | `Callout` | `react/callout` | Note · tip · important · warning · caution. `CALLOUT_TYPES` is the list |
230
211
  | `YouTube` | `react/youtube` | Click-to-load facade. `title`, `playLabel`, `hideLabel` — `{title}` interpolates the first |
@@ -233,51 +214,105 @@ Every component takes data as props, and two modules in `src/react/` import from
233
214
 
234
215
  `DocsToc`'s `rootMargin` is the `IntersectionObserver` margin that decides how far above the viewport a heading counts as current; the default keeps the highlight on the section you are reading rather than the one about to arrive. `topLabel` is the back-to-top link at the end.
235
216
 
236
- The two components the adapter injects take a little more than an `<a>` and an `<img>`. `DocsLinkProps` adds `prefetch` — passed straight to `next/link`, where `false` disables the hover and viewport paths both, so it is a stronger switch in the App Router than the name suggests. `DocsImageProps` adds `sizes`, `loading`, `decoding` and `fetchPriority`, forwarded to `next/image`; markdown carries none of them, so they come from your `imageResolver` or from a `components` override. `decoding` defaults to `async`, and `loading` to `lazy` — except on an image the author marked `eager`, which is usually the page's largest element.
217
+ The two components the adapter injects take a little more than an `<a>` and an `<img>`. `DocsLinkProps` adds `prefetch` — passed straight to `next/link`, where `false` disables the hover and viewport paths both, so it is a stronger switch in the App Router than the name suggests. `DocsImageProps` carries `src`, `alt`, `width` and `height` — the four `next/image` refuses to render without — and adds `sizes`, `loading`, `decoding` and `fetchPriority`, forwarded to it; markdown carries none of them, so they come from your `imageResolver` or from a `components` override. `decoding` defaults to `async`, and `loading` to `lazy` — except on an image the author marked `eager`, which is usually the page's largest element.
237
218
 
238
219
  ### Layout
239
220
 
240
- `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.
221
+ `export default docs.Layout` — the one line from the [quick start](#quick-start) — is a Server Component that renders the whole shell: skip link, sidebar, search trigger, 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.
222
+
223
+ **It renders no header, and that is deliberate.** Two kinds of site use this package: documentation mounted inside an application that already has a header, a navbar and its own search, and documentation that is the whole site. A full-width sticky bar of ours serves the second and fights the first — two stacked bars competing for the viewport's top edge, and two search boxes on one page, one of which knows nothing about the documentation.
224
+
225
+ So the sidebar is the chrome. It is a real grid item at every width, and it is the same shell on a phone and on a desktop.
226
+
227
+ **Nothing this package renders is anchored to the viewport.** The sidebar is a grid item, the trigger is a flex child of it, and the scrim is `position: absolute` inside `.wave-docs-layout` — so every one of them resolves against a box this package owns and *your* layout placed. `position: fixed` is the thing to avoid, and the reason is specific: a fixed element is anchored to the viewport you share with it, your navbar is in the same viewport, and neither can detect the other. The search dialog is the one exception and always was — top-layer, present only while open, and nothing collides with something that is not there.
241
228
 
242
229
  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.
243
230
 
244
- To put your own chrome in the header, call it instead of re-exporting it:
231
+ Your own chrome goes *around* `docs.Layout`, in the layout file you already write — the same place `<html>` and `<body>` live. Call it instead of re-exporting it when you want to wrap it, configure it, or both:
245
232
 
246
233
  ```tsx
247
234
  import type { ReactNode } from 'react';
248
235
  import '@waveso/docs/styles.css';
249
236
  import { docs } from '@/lib/docs';
250
237
 
238
+ /** Yours: the header, theme toggle and repository link the rest of the site has. */
239
+ declare function SiteHeader(): ReactNode;
240
+
251
241
  export default function DocsLayout({ children }: { children: ReactNode }) {
252
242
  return (
253
- <docs.Layout
254
- title="Wave"
255
- actions={<a href="https://github.com/waveso/docs">GitHub</a>}
256
- >
257
- {children}
258
- </docs.Layout>
243
+ <>
244
+ <SiteHeader />
245
+ <docs.Layout search={{ placeholder: 'Search the docs' }}>
246
+ {children}
247
+ </docs.Layout>
248
+ </>
259
249
  );
260
250
  }
261
251
  ```
262
252
 
253
+ If that header of yours is sticky, say how tall it is once and the shell's sticky columns start below it:
254
+
255
+ ```css
256
+ :root {
257
+ --wave-docs-chrome-offset: 4rem;
258
+ }
259
+ ```
260
+
263
261
  | Prop | Type | Default | |
264
262
  | --- | --- | --- | --- |
265
- | `title` | `ReactNode` | — | Brand, at the header start |
266
- | `actions` | `ReactNode` | — | Header end, after search |
263
+ | `children` | `ReactNode` | — | What `docs.Page` returns — the `<main>` and the TOC, as two siblings |
267
264
  | `search` | `boolean \| DocsSearchProps` | `true` | The search trigger. An object configures the dialog |
268
265
  | `labels` | `DocsLabels` | the route's | Overrides `createDocsRoute`'s labels, key by key |
269
266
 
270
- 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.
267
+ Three props, and one of them is `children`. 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`. A theme toggle and a repository link go in the layout you write around this one.
268
+
269
+ **`title` and `actions` were removed in 0.7.0, with the header they lived in.** `title` was a brand slot, and a brand belongs to the index page's own title — content, authored and translatable, part of what the reader came for. The argument for `actions` was that the header bar was the one region nothing else could reach; there is no header bar, and the host wraps `docs.Layout` exactly as it already wraps `<html>` and `<body>`, so there is no region only this package can reach. The one place a host cannot reach through this prop list is *inside* the sidebar, and the answer to that is [composing the primitives yourself](#composing-it-yourself).
271
270
 
272
271
  `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.
273
272
 
274
273
  `labels` belongs on `createDocsRoute` — see [Translating the chrome](#translating-the-chrome) — and this prop overrides it key by key, for a site with two shells or a section in another language.
275
274
 
276
- #### The mobile drawer
275
+ #### One sidebar, open and closed
277
276
 
278
- 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.
277
+ There is no mobile version. The sidebar is a shell holding two things in a row:
279
278
 
280
- 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.
279
+ ```
280
+ .wave-docs-shell the query container
281
+ └─ .wave-docs-layout the grid
282
+ ├─ .wave-docs-layout__sidebar paints nothing, and moves
283
+ │ ├─ …__sidebar-nav the surface, and the one border
284
+ │ └─ …__sidebar-trigger the strip — paints nothing at rest
285
+ ├─ .wave-docs-layout__sidebar-scrim
286
+ ├─ .wave-docs-layout__main
287
+ └─ .wave-docs-layout__toc
288
+ ```
289
+
290
+ Pressing the trigger translates the shell by `calc(var(--wave-docs-trigger-width) - 100%)` — "minus all of me, plus the trigger back" — so the navigation goes entirely off the page and the trigger's outer edge lands exactly on the inline start edge. **The navigation's width appears nowhere in that expression**, so the two cannot drift apart. The trigger rides on the navigation's outer edge because it is the next flex item, not because a number says so.
291
+
292
+ 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
+ #### It adapts to its container, not to your screen
295
+
296
+ **There is not one width-based `@media` query in this package.** Every breakpoint is `@container`, and that is not a stylistic preference — it is the difference between a docs theme and a component you can mount inside something else.
297
+
298
+ `@media` asks how wide the *screen* is. If you put this in a 700px panel on a 1920px monitor, `@media` says "wide", the sidebar takes its 16rem column, and the reading column comes out around 60px. `@container` asks how wide the box you *gave* it is, which is the question with an answer.
299
+
300
+ Two shapes fall out of that:
301
+
302
+ - **Push**, in a container 64rem or wider: the navigation sits beside the article, and opening or closing it changes the article's width.
303
+ - **Cover**, below that: the navigation sits on top of the article behind a scrim, and the article's measure never changes when you toggle.
304
+
305
+ Same markup, same classes, same control, same translate. The only thing that differs is whether the article gets out of the way.
306
+
307
+ Cover mode is a real overlay, so it ships what an overlay owes a reader: `inert` on everything the navigation covers, Escape to close, click-the-scrim to dismiss, and focus moving into the navigation and back out again. Those five were the browser's while this was a `<dialog>`; they are hand-written now, and skipping them is how an overlay becomes a keyboard trap in the wrong direction.
308
+
309
+ `--wave-docs-sidebar-mode` is how the component knows which shape it is in: the stylesheet declares it, the component reads it back with `getComputedStyle`. `matchMedia` cannot answer a container query, and duplicating the breakpoint in JavaScript is how the two drift.
310
+
311
+ #### Three states, and no flash
312
+
313
+ The server renders no `data-state` at all. That absence means "nobody has chosen yet", and CSS resolves it per mode — closed where the navigation would cover the article, open where it would sit beside it. So the first paint is already right at both shapes, with no JavaScript and nothing to correct. Once a reader presses the trigger their choice is explicit and wins at every width.
314
+
315
+ **This replaced a `<dialog>` drawer in 0.7.0.** Below 64rem the navigation used to be a modal opened by a second control, with `display: contents` above it so the same DOM could serve as the desktop column. It bought focus trapping, Escape and a scroll lock from the browser, and it worked before hydration. It cost two controls for one piece of navigation, a drawer that painted over the tree it contained, and a scroll-into-view that could never run on a phone — a closed `<dialog>` has no layout, so the current page was always below the fold. A closed sidebar is *moved*, not hidden, so that last one is structurally impossible now.
281
316
 
282
317
  #### Composing it yourself
283
318
 
@@ -312,7 +347,7 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
312
347
 
313
348
  `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.
314
349
 
315
- **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.
350
+ **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 names are public API and change only in a release that carries the migration, so they are safe to write by hand.
316
351
 
317
352
  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.
318
353
 
@@ -326,6 +361,12 @@ label: Auth # sidebar label, when the title is too long
326
361
  draft: true # excluded from nav, search and static params
327
362
  order: 10 # sort weight where there is no meta.json
328
363
  aliases: [old-auth, legacy/auth] # former URLs → permanent redirects
364
+ actions: # calls to action — and the opt-in for a hero
365
+ - label: Quick start
366
+ href: /getting-started
367
+ - label: GitHub
368
+ href: https://github.com/waveso/docs
369
+ variant: secondary
329
370
  ---
330
371
  ```
331
372
 
@@ -333,6 +374,18 @@ aliases: [old-auth, legacy/auth] # former URLs → permanent redirects
333
374
 
334
375
  `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.
335
376
 
377
+ ### The hero
378
+
379
+ `actions` is the only thing that turns a page into a landing page. Declare it and `title` and `description` become a page header — a large heading, the description as a tagline under it, and these links beneath that. Leave it off and the page is exactly what it was: `description` stays a `<meta>` tag and the title is the first thing in the prose.
380
+
381
+ Each action takes a `label`, an `href` and an optional `variant` of `primary` or `secondary`. Omit the variant and the first action is the primary and the rest are secondary, which is the shape every landing page has. An `href` that leaves the site gets `target="_blank"`, `rel="noreferrer"` and a screen-reader suffix; `mailto:` and `tel:` do not, because they open no tab. Unsafe hrefs fail the build rather than reaching an `<a>`.
382
+
383
+ **That is the whole of the adaptation for the two shapes this package serves.** Documentation that is the entire site puts a hero on its index. Documentation mounted at `/docs` inside an application that already has a marketing page leaves `actions` off and gets an ordinary page. There is no mode, no `standalone` flag and nothing to configure — the opt-in lives in the file that wants it.
384
+
385
+ ⚠️ **A hero page must not also write its own `# Title`.** `render` normally prepends an `<h1>` from `frontmatter.title`; on a hero page the hero renders that heading instead, because the tagline and the actions have to sit beneath it. Writing one in the body as well ships two `h1`s — the same duplication `titleHeading` has always warned about.
386
+
387
+ The background is a rotated line grid under scrims painted in the page's own colour rather than behind a mask: a soft ellipse over the words, a bottom fade, and a vignette that closes at the corners. The vignette is an inset `box-shadow` rather than a gradient — a `radial-gradient` is only ever a circle or an ellipse, and this one needs a corner radius. `border-radius` is that knob, and `corner-shape: superellipse(3)` makes it a squircle where the browser supports it. Alpha compositing of a solid blends where mask layers multiply, so the falloff is smooth instead of compounding into a shoulder. It is drawn with `repeating-linear-gradient` rather than an inlined SVG — no data URI in the stylesheet. The lines are `--wave-docs-hero-grid` and `--wave-docs-hero-grid-strong`, which are Wave 200 and Wave 300 from `@waveso/ui` in the light theme and Wave 900 and Wave 800 in the dark one. They are tokens of their own rather than the border colours: the grid is decoration behind a mask that leaves it near-invisible where the words are, and a border is a boundary a reader has to be able to see.
388
+
336
389
  ### Your own fields
337
390
 
338
391
  Pass a `frontmatterSchema` and every `DocFile` and `RenderedDoc` carries your fields, inferred, with no type argument anywhere:
@@ -741,7 +794,7 @@ export function DocsSearchTrigger({ indexUrl }: { indexUrl: string }) {
741
794
  }
742
795
  ```
743
796
 
744
- Add the same function to your `createDocsRoute` call — `miniSearchOptions: { processTerm: stripDashes }` — so the index is built with it. Then turn the built-in trigger off and render yours in `actions`, in `app/docs/layout.tsx`:
797
+ Add the same function to your `createDocsRoute` call — `miniSearchOptions: { processTerm: stripDashes }` — so the index is built with it. Then turn the built-in trigger off and render yours in the layout you write around `docs.Layout`, in `app/docs/layout.tsx`:
745
798
 
746
799
  ```tsx
747
800
  import '@waveso/docs/styles.css';
@@ -751,17 +804,15 @@ import { docs } from '@/lib/docs';
751
804
 
752
805
  export default function DocsLayout({ children }: { children: ReactNode }) {
753
806
  return (
754
- <docs.Layout
755
- search={false}
756
- actions={<DocsSearchTrigger indexUrl={docs.searchIndexUrl} />}
757
- >
758
- {children}
759
- </docs.Layout>
807
+ <>
808
+ <DocsSearchTrigger indexUrl={docs.searchIndexUrl} />
809
+ <docs.Layout search={false}>{children}</docs.Layout>
810
+ </>
760
811
  );
761
812
  }
762
813
  ```
763
814
 
764
- `search={false}` omits the built-in trigger so yours is the only one, and it is also why the refusal is scoped to the forward: the route keeps the function for the index it builds on the server, and nothing crosses to the client but a string.
815
+ `search={false}` omits the built-in trigger so yours is the only one, and it is also why the refusal is scoped to the forward: the route keeps the function for the index it builds on the server, and nothing crosses to the client but a string. To put your trigger *inside* the sidebar rather than above it, [compose the shell yourself](#composing-it-yourself) — `docs.Layout` has no slot for it, deliberately.
765
816
 
766
817
  ### Building the index yourself
767
818
 
@@ -817,30 +868,65 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
817
868
  contentDir: string; // relative paths resolve against process.cwd()
818
869
  basePath?: string; // default '/docs'; '/' normalises to ''
819
870
  includeDrafts?: boolean; // default false
820
- assertLinks?: boolean; // default true
871
+ onBrokenLinks?: DocsLinkSeverity; // default 'throw'
872
+ onBrokenAnchors?: DocsLinkSeverity; // default 'throw'
873
+ externalRoutes?: readonly string[]; // routes your app owns, not the docs
821
874
  frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter>;
822
875
  }
876
+
877
+ type DocsLinkSeverity = 'throw' | 'warn' | 'ignore';
823
878
  ```
824
879
 
825
- `createDocsRoute` additionally accepts:
880
+ ### Broken links
826
881
 
827
- | Option | Default | Purpose |
828
- | --- | --- | --- |
829
- | `langs` | 18 grammars | Typed `readonly DocsLang[]`, so a typo is a compile error |
830
- | `themes` | `github-light` / `github-dark` | Shiki theme pair |
831
- | `highlighter` | built-in | Supply your own for grammars outside the set |
832
- | `titleHeading` | `true` | Build an `<h1>` from `frontmatter.title` when the markdown has none |
833
- | `components` | built-in map | Override any element → component mapping |
834
- | `siteUrl` | | Makes canonical URLs absolute |
835
- | `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 |
882
+ **`onBrokenLinks` defaults to `'throw'`, and should stay there.** A link that 404s was valid in your editor and on GitHub, so it is the kind of mistake nobody finds by reading — and a warning in a build log is a warning nobody reads. `'warn'` exists for a migration running knowingly against an incomplete corpus.
883
+
884
+ The error names the file, the line and the closest published route when the link looks like a typo of one:
885
+
886
+ ```
887
+ @waveso/docs: guide.md:12 links to './instalation.md', which resolves to
888
+ '/docs/instalation' no such page exists. Did you mean '/docs/installation'?
889
+ Fix the link, or add an `aliases` entry to the page it used to point at.
890
+ ```
891
+
892
+ A suggestion is offered only for a genuine near-miss. `/docs/instructions` is five edits from `/docs/installation` — a different word, not a typo — and gets none, because a wrong suggestion sends you to rename a link that was correct.
893
+
894
+ ### Broken anchors
895
+
896
+ **`onBrokenAnchors` defaults to `'throw'`.** A route used to be verified and its fragment thrown away, so `[setup](./install.md#setup)` built green with no `#setup` anywhere on the page. It is the more common of the two failures: headings get renamed constantly, and nothing renames the links into them.
897
+
898
+ ```
899
+ @waveso/docs: guide.md:12 links to '#instalation', and this page has no
900
+ '#instalation'. Did you mean 'installation'? Heading ids come from the heading
901
+ text, so renaming a heading renames its anchor.
902
+ ```
903
+
904
+ Checked against every `id` in the rendered page rather than against the table of contents — which captures `h2`–`h3` only, so a link to an `h4` is fine, and so is a link to an id one of your `rehypePlugins` added. Lower it to `'warn'` if a plugin of yours adds ids this package cannot see at render time.
905
+
906
+ Same-page anchors are checked as each page renders, so those errors carry the file and the line. Cross-page anchors need the target page's ids, which exist only once everything has been rendered — `docs.renderAll()` does that pass, and it runs in every build that serves search, because the index route is `force-static`.
836
907
 
837
- `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.
908
+ ### Routes your application owns
838
909
 
839
- 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.
910
+ **`externalRoutes` only matters at a root mount.** Under `basePath: '/docs'` an absolute link either carries the prefix so it is documentation and is checked or it does not, and this package leaves it alone. Under `basePath: '/'` there is no prefix: `/setup` and `/login` look identical, and both are checked against the published pages.
911
+
912
+ That is the right default, because a root mount is what you choose when the origin serves documentation and nothing else. If yours serves something else too, name what is yours:
913
+
914
+ ```ts
915
+ // lib/docs-root.ts
916
+ import { createDocsRoute } from '@waveso/docs/next';
917
+
918
+ export const docs = createDocsRoute({
919
+ contentDir: 'content/docs',
920
+ basePath: '/',
921
+ externalRoutes: ['/login', '/dashboard', '/api/'],
922
+ });
923
+ ```
924
+
925
+ A link is skipped when it equals one of these or begins with one followed by `/` — so `/api` covers `/api/keys` and not `/apiary`. It is a statement about your application, so nothing here infers it.
840
926
 
841
927
  ### Translating the chrome
842
928
 
843
- Twenty-two strings, and every one of them is yours to set. They go on `createDocsRoute` rather than on `docs.Layout`, because they are not all rendered in the same place: four are the shell's, two the table of contents', nine come from the markdown component map, two are baked into the HTML by a rehype plugin at build time, and two are announced by a client-side runtime after a copy. A layout prop is upstream of the first four and nothing else.
929
+ Twenty-two strings, and every one of them is yours to set. They go on `createDocsRoute` rather than on `docs.Layout`, because they are not all rendered in the same place: four are the shell's, three the navigation tree's, two the table of contents', nine come from the markdown component map, two are baked into the HTML by a rehype plugin at build time, and two are announced by a client-side runtime after a copy. A layout prop is upstream of the first four and nothing else.
844
930
 
845
931
  ```ts
846
932
  // lib/docs-pt.ts
@@ -972,6 +1058,7 @@ try {
972
1058
  | `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. |
973
1059
  | `draft-link` | A link points at a page that exists but is `draft: true`. | Publish the page, or drop the link until it ships. |
974
1060
  | `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. |
1061
+ | `broken-anchor` | A `#fragment` that no heading on the target page owns. | Fix the link, or restore the heading. Heading ids come from the heading text, so renaming one renames its anchor. |
975
1062
  | `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`. |
976
1063
  | `alias-collision` | Two pages claim one alias, or an alias shadows a real route. | Remove one of them; a redirect cannot have two destinations. |
977
1064
  | `route-collision` | Two files resolve to the same route. | Usually `about.md` beside `about/index.md`. Keep one. |
@@ -1005,14 +1092,25 @@ ESM-only is forced rather than chosen: `unified` and the entire `remark-*` / `re
1005
1092
  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`.
1006
1093
 
1007
1094
  > [!NOTE]
1008
- > Under `exactOptionalPropertyTypes: true`, passing `next/link` straight into
1009
- > `DocsSidebar` does not type-check. Next's `LinkProps` re-declares `onClick?`,
1010
- > `onMouseEnter?` and `onTouchStart?` *without* `| undefined`, and React's
1095
+ > Passing `next/link` **straight** into `DocsSidebar` does not type-check under
1096
+ > `exactOptionalPropertyTypes: true`. Next's `LinkProps` re-declares `onClick?`,
1097
+ > `onMouseEnter?` and `onTouchStart?` *without* `| undefined` while React's
1011
1098
  > anchor props include it, so the two declaration files disagree — about props
1012
- > `next/link` accepts perfectly well at runtime. It is true of every
1013
- > `next/link` call site in a project with that flag on, not just this one.
1014
- > Cast at the call site (`Link={Link as DocsLinkComponent}`) until the
1015
- > Next-wired navigation component ships.
1099
+ > `next/link` accepts perfectly well at runtime. It is true of every `next/link`
1100
+ > call site in a project with that flag on, not just this one.
1101
+ >
1102
+ > Import `DocsLink` instead of casting:
1103
+ >
1104
+ > ```tsx
1105
+ > 'use client';
1106
+ > import { DocsLink } from '@waveso/docs/react/next-link';
1107
+ > import { DocsSidebar } from '@waveso/docs/react/sidebar';
1108
+ >
1109
+ > <DocsSidebar nav={nav} pathname={pathname} Link={DocsLink} />
1110
+ > ```
1111
+ >
1112
+ > `docs.Layout` and `DocsSearch` have always used the same adapter internally,
1113
+ > so this only ever came up when composing a shell by hand.
1016
1114
  >
1017
1115
  > `docs.Page` and `DocsSearch` are both unaffected — each wraps `next/link`
1018
1116
  > inside the package, which is where that cast belongs. Without the flag,
@@ -1030,9 +1128,9 @@ breaking — which is the part most packages leave unsaid until someone is angry
1030
1128
  | --- | --- |
1031
1129
  | Every subpath in `exports`, and every runtime name it exports | ✅ enforced by `manifest.test.ts` |
1032
1130
  | Exported types, including `DocsErrorCode`'s members | ✅ enforced by `error-taxonomy.test.ts` |
1033
- | **CSS class names** — `wave-docs-*`, and the shell's element tree | ✅ frozen in [ADR 001](docs/adr/001-shell-contract.md) |
1131
+ | **CSS class names** — `wave-docs-*`, and the shell's element tree | ✅ a name changes only in a release that carries the migration |
1034
1132
  | **The hast this emits** — element names, and the attributes on them | ✅ the same policy as the types |
1035
- | Layout tokens — the five custom properties above | ✅ |
1133
+ | Layout tokens — the six custom properties above | ✅ |
1036
1134
  | Anything reachable only through `dist/` internals, or a private module | ❌ |
1037
1135
 
1038
1136
  The two in bold are the ones usually omitted, and omitting them is how a
@@ -1126,7 +1224,7 @@ src/
1126
1224
  next.ts # The App Router adapter
1127
1225
  meta.ts # meta.json ordering
1128
1226
  plugins/ # remark/rehype plugins
1129
- react/ # Components. Only next-nav and next-search touch next/*
1227
+ react/ # Components. Only the next-* modules touch next/*
1130
1228
  styles.css # Theme tokens + prose styles
1131
1229
  ```
1132
1230
 
@@ -0,0 +1,44 @@
1
+ import { RenderedDoc } from "./types.js";
2
+ import { Root } from "hast";
3
+ //#region src/anchors.d.ts
4
+ /**
5
+ * Every `id` in a tree, which is the set an anchor may target.
6
+ *
7
+ * Not the table of contents: that captures `h2`–`h3` only, so checking against
8
+ * it would reject a perfectly good link to an `h4` — and would miss an id a
9
+ * `rehypePlugins` entry put on something that is not a heading at all.
10
+ */
11
+ declare function collectAnchorIds(tree: Root): Set<string>;
12
+ /** An internal link carrying a fragment, as found in a rendered tree. */
13
+ interface AnchorLink {
14
+ /** The href exactly as it stands in the tree, e.g. `/docs/install#setup`. */
15
+ href: string;
16
+ /** Route without the fragment. */
17
+ route: string;
18
+ /** Fragment without the `#`, percent-decoded. */
19
+ fragment: string;
20
+ }
21
+ /**
22
+ * `#fragment` split off an internal href, or `undefined`.
23
+ *
24
+ * Skips anything with a scheme and anything protocol-relative: an anchor on
25
+ * somebody else's page is theirs to get wrong, and a fragment there is a
26
+ * routine way to link into a spec.
27
+ */
28
+ declare function splitAnchor(href: string): AnchorLink | undefined;
29
+ /** Every internal anchor link in a tree, in document order. */
30
+ declare function collectAnchorLinks(tree: Root): AnchorLink[];
31
+ /**
32
+ * Cross-page anchors, once every page has been rendered.
33
+ *
34
+ * `report` is passed in rather than imported so this module stays free of the
35
+ * error factory and the severity plumbing — it answers "which anchors are
36
+ * wrong", and the caller owns what that costs.
37
+ *
38
+ * A link to a route nothing rendered is a *broken link*, not a broken anchor,
39
+ * and `assertLinks` has already reported it at its own severity. Skipped here
40
+ * so one mistake is not two failures.
41
+ */
42
+ declare function assertAnchors(docs: ReadonlyArray<RenderedDoc<never>> | ReadonlyArray<RenderedDoc>, report: (from: string, link: AnchorLink, known: Set<string>) => void): void;
43
+ //#endregion
44
+ export { AnchorLink, assertAnchors, collectAnchorIds, collectAnchorLinks, splitAnchor };
@@ -0,0 +1,76 @@
1
+ import { visit } from "unist-util-visit";
2
+ //#region src/anchors.ts
3
+ /**
4
+ * Every `id` in a tree, which is the set an anchor may target.
5
+ *
6
+ * Not the table of contents: that captures `h2`–`h3` only, so checking against
7
+ * it would reject a perfectly good link to an `h4` — and would miss an id a
8
+ * `rehypePlugins` entry put on something that is not a heading at all.
9
+ */
10
+ function collectAnchorIds(tree) {
11
+ const ids = /* @__PURE__ */ new Set();
12
+ visit(tree, "element", (node) => {
13
+ const id = node.properties.id;
14
+ if (typeof id === "string" && id !== "") ids.add(id);
15
+ });
16
+ return ids;
17
+ }
18
+ /**
19
+ * `#fragment` split off an internal href, or `undefined`.
20
+ *
21
+ * Skips anything with a scheme and anything protocol-relative: an anchor on
22
+ * somebody else's page is theirs to get wrong, and a fragment there is a
23
+ * routine way to link into a spec.
24
+ */
25
+ function splitAnchor(href) {
26
+ if (href === "" || /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(href)) return void 0;
27
+ const hash = href.indexOf("#");
28
+ if (hash === -1 || hash === href.length - 1) return void 0;
29
+ const raw = href.slice(hash + 1);
30
+ let fragment;
31
+ try {
32
+ fragment = decodeURIComponent(raw);
33
+ } catch {
34
+ return;
35
+ }
36
+ return {
37
+ href,
38
+ route: href.slice(0, hash),
39
+ fragment
40
+ };
41
+ }
42
+ /** Every internal anchor link in a tree, in document order. */
43
+ function collectAnchorLinks(tree) {
44
+ const links = [];
45
+ visit(tree, "element", (node) => {
46
+ if (node.tagName !== "a") return;
47
+ const href = node.properties.href;
48
+ if (typeof href !== "string") return;
49
+ const anchor = splitAnchor(href);
50
+ if (anchor !== void 0) links.push(anchor);
51
+ });
52
+ return links;
53
+ }
54
+ /**
55
+ * Cross-page anchors, once every page has been rendered.
56
+ *
57
+ * `report` is passed in rather than imported so this module stays free of the
58
+ * error factory and the severity plumbing — it answers "which anchors are
59
+ * wrong", and the caller owns what that costs.
60
+ *
61
+ * A link to a route nothing rendered is a *broken link*, not a broken anchor,
62
+ * and `assertLinks` has already reported it at its own severity. Skipped here
63
+ * so one mistake is not two failures.
64
+ */
65
+ function assertAnchors(docs, report) {
66
+ const idsByRoute = /* @__PURE__ */ new Map();
67
+ for (const doc of docs) idsByRoute.set(doc.href, collectAnchorIds(doc.hast));
68
+ for (const doc of docs) for (const link of collectAnchorLinks(doc.hast)) {
69
+ if (link.route === "") continue;
70
+ const known = idsByRoute.get(link.route);
71
+ if (known === void 0 || known.has(link.fragment)) continue;
72
+ report(doc.href, link, known);
73
+ }
74
+ }
75
+ //#endregion
76
+ export { assertAnchors, collectAnchorIds, collectAnchorLinks, splitAnchor };
package/dist/errors.d.ts CHANGED
@@ -47,6 +47,8 @@ type DocsErrorCode =
47
47
  'draft-link' |
48
48
  /** A link resolves to an alias, which is a redirect and not a page. */
49
49
  'alias-link' |
50
+ /** A `#fragment` that no heading on the target page owns. */
51
+ 'broken-anchor' |
50
52
  /** An `aliases` entry is empty, escapes the root, or is not URL-safe. */
51
53
  'invalid-alias' |
52
54
  /** Two pages claim one alias, or an alias shadows a real route. */
@@ -32,6 +32,14 @@ declare const docFrontmatterSchema: z.ZodObject<{
32
32
  draft: z.ZodExactOptional<z.ZodBoolean>;
33
33
  aliases: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
34
34
  order: z.ZodExactOptional<z.ZodNumber>;
35
+ actions: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
36
+ label: z.ZodString;
37
+ href: z.ZodString;
38
+ variant: z.ZodExactOptional<z.ZodEnum<{
39
+ primary: "primary";
40
+ secondary: "secondary";
41
+ }>>;
42
+ }, z.core.$strip>>>;
35
43
  }, z.core.$strip>;
36
44
  /**
37
45
  * Validate one file's frontmatter, or throw an error that names the file.