@waveso/docs 0.1.0 → 0.2.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 (41) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +111 -22
  3. package/dist/docs-error.d.ts +74 -0
  4. package/dist/docs-error.js +40 -0
  5. package/dist/frontmatter.d.ts +39 -7
  6. package/dist/frontmatter.js +51 -24
  7. package/dist/highlighter.d.ts +2 -2
  8. package/dist/highlighter.js +3 -2
  9. package/dist/map-pooled.d.ts +26 -0
  10. package/dist/map-pooled.js +45 -0
  11. package/dist/meta.d.ts +7 -3
  12. package/dist/meta.js +61 -15
  13. package/dist/next.d.ts +41 -19
  14. package/dist/next.js +117 -21
  15. package/dist/plugins/rehype-capture-toc.js +26 -15
  16. package/dist/plugins/rehype-code-language.d.ts +24 -0
  17. package/dist/plugins/rehype-code-language.js +48 -0
  18. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  19. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  20. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  21. package/dist/plugins/rehype-flatten-roots.js +39 -0
  22. package/dist/plugins/remark-doc-links.d.ts +12 -1
  23. package/dist/plugins/remark-doc-links.js +147 -20
  24. package/dist/react/markdown-components.js +71 -6
  25. package/dist/react/search-dialog.d.ts +23 -7
  26. package/dist/react/search-dialog.js +46 -29
  27. package/dist/react/toc.js +28 -5
  28. package/dist/react/youtube.js +6 -4
  29. package/dist/render.d.ts +43 -9
  30. package/dist/render.js +112 -50
  31. package/dist/search-index.d.ts +32 -14
  32. package/dist/search-index.js +45 -51
  33. package/dist/search-options.d.ts +32 -1
  34. package/dist/search-options.js +66 -3
  35. package/dist/section-boundary.d.ts +17 -0
  36. package/dist/section-boundary.js +43 -0
  37. package/dist/source.d.ts +13 -1
  38. package/dist/source.js +152 -56
  39. package/dist/styles.css +236 -90
  40. package/dist/types.d.ts +41 -27
  41. package/package.json +13 -12
package/CHANGELOG.md ADDED
@@ -0,0 +1,84 @@
1
+ # @waveso/docs
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7fefb08: Fix 38 defects found in a pre-publish review. Several are behaviour changes; the ones you can notice are listed first.
8
+
9
+ **Dark mode is now opt-in.** The tokens used to switch on `prefers-color-scheme` alone, but the stylesheet styles the docs subtree rather than the page — so a light-only site with a `/docs` section served near-white text on the host's white background (1.23:1) to every visitor whose OS was in dark mode. Dark now requires `data-theme="dark"` or `class="dark"` on `<html>`; `data-theme="system"` opts back into following the OS. `color-scheme` is declared, so native scrollbars and form controls match.
10
+
11
+ **The `tailwindcss` peer dependency is gone.** Nothing in the package used Tailwind, and declaring it blocked `npm install` outright for any project on Tailwind 3 — npm range-checks an optional peer that happens to be installed. The `@source "./"` directive is gone with it; it was injecting 14 unrequested utilities into every Tailwind consumer's CSS.
12
+
13
+ **`zod` is a dependency now, not a peer**, for the same reason and a larger one: it is imported at module scope, and as a peer its `^4.4.3` range refused to install beside roughly 69% of the Zod in the ecosystem — 47% still on 3.x, plus every 4.x below 4.4.3. Your project's Zod is now irrelevant. `z` is re-exported from `@waveso/docs/frontmatter`, so extending `docFrontmatterSchema` needs no install and cannot pick up a second copy.
14
+
15
+ **Search indexes the full text of a section.** It previously truncated to 300 characters _before_ indexing, dropping ~80% of a normal corpus, and stored none of it for display either. Tokenisation now uses `Intl.Segmenter`, so CJK text is searchable at all — it previously returned zero hits. Both `buildSearchIndex` and `SearchDialog` accept MiniSearch overrides.
16
+
17
+ **Aliases are validated.** `aliases: ['v1:beta']` used to compile to a Next redirect _wildcard_: the build passed, then `/docs/v1-guide` — a real prerendered page — was permanently 308'd away. Metacharacters, relative segments and empty entries are now rejected when the page is read, naming the file. Linking to an alias also fails the build now, naming the page to link instead: an alias is never prerendered, so it was a green build and a hard 404.
18
+
19
+ **Relative images fail the build instead of shipping a broken `src`.** Nothing ever rewrote them, so the browser resolved them against the route and identical markdown requested a different file from every page. Absolute and external sources are unaffected.
20
+
21
+ **A custom `frontmatterSchema` can no longer drop the package's own fields.** All six — `title`, `description`, `label`, `draft`, `aliases`, `order` — are parsed from the raw YAML and laid back over your schema's output, so a custom schema can only ever _add_. A bare `z.object({ title, … })` type-checks but used to strip `draft` and `aliases`, publishing every draft, submitting them to Google, and returning no redirects. The price is that a `.default()`, `.transform()` or `.coerce` aimed at one of the six is not honoured: the YAML wins.
22
+
23
+ **Links to unusual URL schemes are dropped rather than rendered.** `javascript:`, `data:` and `vbscript:` never reach an `href` — including the obfuscated spellings a browser still navigates. The allowlist is GitHub's (`http`, `https`, `mailto`, `tel`, `sms`, `ftp`, `ftps`, `irc`, `ircs`, `xmpp`, `news`, `nntp`, `feed`, `git`, `matrix`); anything else keeps its text, loses its destination, and warns outside production.
24
+
25
+ **Absolute internal links are respelled before they are checked.** `/docs/café` and `/docs/caf%C3%A9` are the same page, and only the encoded form used to match — so the human-readable spelling every editor produces failed the build with "no such page exists" for a page that plainly exists.
26
+
27
+ Also fixed: `siteUrl` with a path silently truncated out of every canonical and the whole sitemap; ` ```JSON `/` ```Bash ` shipping unhighlighted; the search dialog's focus trap breaking on a click, its Close button navigating on Enter, and IME composition being consumed as "open result"; the search index cached forever against the first `indexUrl`; every focus indicator vanishing under Windows High Contrast; long tokens forcing horizontal scroll at 320px; a `draft: true` index page publishing its title as a public sidebar heading; symlinked and `.MD` files vanishing silently; percent-encoded and NFD filenames failing to resolve; `writeSearchIndex` truncating the served file in place; the TOC scrollspy dying permanently when headings mount late; YouTube re-stealing focus on every re-render; markdown images ignoring an author's `loading`; Shiki splicing an invalid `root` node into the tree; `renderAll` running unbounded; and `docs.source.nav()` being one request stale in dev while every page view scanned the filesystem twice over.
28
+
29
+ Every failure now throws with a `code` (`'broken-link'`, `'invalid-alias'`, `'invalid-frontmatter'`, …) and a message naming this package, so a host can branch on the kind of failure instead of matching message text. Where an underlying parser failed — js-yaml on frontmatter, `JSON.parse` on `meta.json` — its own error is attached as `cause`.
30
+
31
+ ### API changes
32
+
33
+ - `extractSearchRecords(doc)` takes no options; `ExtractSearchRecordsOptions` and `excerptLength` are gone, since full section text is now indexed.
34
+ - `SearchRecord.id` is a slug (`page#anchor`), not an `href` — an href embedded `basePath`, so moving a site from `/docs` to `/reference` changed the identity of every record.
35
+ - `buildSearchIndex(records, options?)` and `<SearchDialog searchOptions={…}>` accept MiniSearch overrides. They must agree.
36
+ - `DocsSource.drafts()` is new, and `DocsRouteOptions` gains `excludeLangs`, which existed on the renderer and was reachable from nothing.
37
+ - Optional properties across the public option types are now spelled `?: T | undefined`, so a consumer with `exactOptionalPropertyTypes` can pass a possibly-undefined value — `siteUrl: process.env.SITE_URL` was previously a compile error.
38
+ - The CSS custom property `--wave-docs-header-height` is now `--wave-docs-scroll-padding`, which is what it actually controls.
39
+ - `engines.node` is `>=22.12.0`. Node 20 reached end of life in April 2026.
40
+
41
+ ## 0.1.0
42
+
43
+ ### Minor Changes
44
+
45
+ - adddaea: Initial release.
46
+
47
+ Markdown documentation for Next.js, from one content directory and one pipeline.
48
+ Markdown becomes hast in Node at build time, so the browser receives a tree of
49
+ nodes and a component map — never `unified`, `remark-parse` or Shiki.
50
+
51
+ - `createDocsRoute` wires a content directory to an App Router catch-all, with
52
+ `dynamicParams: false`, a real index route, awaited `params` and a canonical
53
+ URL on every page.
54
+ - Frontmatter is extensible through `frontmatterSchema`, typed as a
55
+ [Standard Schema](https://standardschema.dev) so Zod, Valibot and ArkType all
56
+ work and your fields are inferred with no type argument.
57
+ - Internal `.md` links are rewritten to routes and their targets checked, so a
58
+ link that works on GitHub cannot 404 once published.
59
+ - Table-of-contents ids come from the same `rehype-slug` pass that annotates the
60
+ document, so anchors match by construction rather than by a second parse.
61
+ - GitHub alert syntax, a click-to-load YouTube facade, section-scoped MiniSearch
62
+ records, a sidebar, a scrollspy TOC, a search dialog and a themeable
63
+ stylesheet.
64
+
65
+ - ed73890: Retheming now works from a plain `:root`, and config files highlight.
66
+
67
+ The stylesheet's own guidance — "redefine the tokens in your own `:root`" — could
68
+ not work against it. The dark tokens are `:root:not([data-theme='light'])`, which
69
+ is specificity (0,2,0), so an unlayered `:root` at (0,1,0) lost regardless of load
70
+ order; the cascade never reached source order. Overriding meant `:root:root:root`.
71
+
72
+ Every block now lives in a layer — `theme` for tokens, `base` for resets,
73
+ `components` for classes, declared in that order — and unlayered CSS outranks
74
+ every layer whatever its specificity. Inside the layer the dark blocks still beat
75
+ the light one, so OS following and `data-theme` are unchanged. The README gains a
76
+ Theming section, which it did not have.
77
+
78
+ Added the `ini` and `toml` grammars, and registered `cfg` and `conf` as aliases of
79
+ `ini`. Shiki resolves a fence against a grammar's own aliases rather than against
80
+ this package's loader keys, and `ini` ships only `properties` — so a `` cfg block
81
+ threw `Language 'cfg' not found` and `fallbackLanguage` rendered it as plain text.
82
+ The fence an author writes follows the filename: nobody types ``ini above a file
83
+ called `server.cfg`, and on a FiveM docs site that block is the most-read code on
84
+ the page.
package/README.md CHANGED
@@ -41,11 +41,19 @@ Three things follow from that shape, and they are the reasons to choose this ove
41
41
  pnpm add @waveso/docs
42
42
  ```
43
43
 
44
- `react`, `react-dom` and `zod` are required peers; `next` and `tailwindcss` are optional install only what you use.
44
+ That is the whole installation. `react` and `react-dom` are required peers; `next` is optional, needed only by `@waveso/docs/next`.
45
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.
46
+ **Zod is not a peer.** It ships as a dependency of this package, so your project's Zod — version 3, version 4, or none at all is irrelevant and nothing conflicts. When you extend the built-in schema, take `z` from here rather than from your own install:
47
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.
48
+ ```ts
49
+ import { docFrontmatterSchema, z } from '@waveso/docs/frontmatter';
50
+ ```
51
+
52
+ That is not a style preference. `.extend()` produces a schema only as trustworthy as the instance that built it, and re-exporting ours means the extension is built from the same module object by construction rather than by luck. Your own Zod stays yours, for everything else in your app.
53
+
54
+ **There is no `tailwindcss` peer and no Tailwind involved.** The stylesheet is plain CSS with `wave-docs-*` class names. It was declared as an optional peer once, which blocked `npm install` outright for any project on Tailwind 3 — npm still range-checks an optional peer that happens to be installed.
55
+
56
+ There is no `image-size` peer either. An `imageResolver` you write is welcome to read dimensions with it — but it is your dependency, in your own `package.json`. Declaring it here installed nothing and did not make `await import('image-size')` resolve for you; it only looked like it helped.
49
57
 
50
58
  ## Quick start
51
59
 
@@ -90,7 +98,7 @@ content/docs/
90
98
  That is a working documentation site.
91
99
 
92
100
  > [!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.
101
+ > `dynamicParams` must be written out as `false`. Route segment config is parsed statically before the module runs, so `export const dynamicParams = docs.dynamicParams` fails `next build`. Without it, Next invokes the route on a server at request time for every unlisted URL, to produce a 404 that was already knowable at build time — and `output: 'export'` refuses to build at all.
94
102
 
95
103
  ## Entry points
96
104
 
@@ -104,7 +112,7 @@ There is no root export. Every entry point is a subpath, so an import always nam
104
112
  | `@waveso/docs/highlighter` | Node | `createDocsHighlighter`, `DEFAULT_DOCS_LANGS` |
105
113
  | `@waveso/docs/search-index` | Node | `extractSearchRecords`, `buildSearchIndex`, `writeSearchIndex` |
106
114
  | `@waveso/docs/react/*` | Browser + RSC | See [Components](#components) |
107
- | `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter` |
115
+ | `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter`, `z` |
108
116
  | `@waveso/docs/search-options` | Any | `SEARCH_INDEX_OPTIONS` |
109
117
  | `@waveso/docs/types` | Any | Every shared type. Type-only |
110
118
  | `@waveso/docs/styles.css` | — | The stylesheet |
@@ -211,8 +219,7 @@ Pass a `frontmatterSchema` and every `DocFile` and `RenderedDoc` carries your fi
211
219
 
212
220
  ```ts
213
221
  // content/docs-schema.ts — one module, imported by every route file
214
- import { docFrontmatterSchema } from '@waveso/docs/frontmatter';
215
- import { z } from 'zod';
222
+ import { docFrontmatterSchema, z } from '@waveso/docs/frontmatter';
216
223
 
217
224
  export const frontmatterSchema = docFrontmatterSchema.extend({
218
225
  audience: z.enum(['user', 'operator']).exactOptional(),
@@ -227,7 +234,7 @@ doc?.frontmatter.audience; // 'user' | 'operator' | undefined
227
234
  doc?.frontmatter.title; // string
228
235
  ```
229
236
 
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.
237
+ Any [Standard Schema](https://standardschema.dev) validator works — Zod, Valibot, ArkType. The field is typed `StandardSchemaV1<unknown, TFrontmatter>` rather than as a Zod type, so the package does not dictate your validator; a schema you hand over is never re-wrapped by the Zod in here. The `z` above is re-exported from this package precisely so that extending `docFrontmatterSchema` needs no install and no matching version.
231
238
 
232
239
  Four things are worth knowing before you write one.
233
240
 
@@ -240,7 +247,9 @@ const docs = createDocsRoute<MyFrontmatter>({ contentDir: 'content/docs' });
240
247
 
241
248
  **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
249
 
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.
250
+ **The package's own fields survive a schema that forgets them.** `title` drives the `<h1>` fallback and `<title>`, `draft` the visibility filter, `aliases` the redirects, `order` and `label` the sidebar. These are parsed from the raw YAML and merged over your schema's output, so a custom schema can only ever *add* fields — it cannot drop or corrupt the ones the package reads itself.
251
+
252
+ That is a runtime guarantee, not a compile-time one, and the difference matters: `TFrontmatter extends DocFrontmatter` constrains only `title`, because the rest are optional. A `z.object({ title, audience })` type-checks perfectly and used to strip `draft` and `aliases` on the way through — publishing every draft, submitting them to Google, and silently returning no redirects at all. Prefer `docFrontmatterSchema.extend(…)` anyway: you then get the built-in fields in *your* inferred type, rather than merely at runtime.
244
253
 
245
254
  **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
255
 
@@ -301,7 +310,33 @@ A ```` ```cfg ```` fence (or ```` ```conf ````) uses the `ini` grammar, because
301
310
  the fence an author types follows the filename — nobody writes ```` ```ini ````
302
311
  above a file called `server.cfg`.
303
312
 
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.
313
+ Anything outside that set falls back to plain text rather than throwing. Pass `langs` to change the set, or `highlighter` to supply your own. Fence languages are matched case-insensitively, so ```` ```JSON ```` and ```` ```Bash ```` highlight like their lowercase spellings rather than silently shipping monochrome.
314
+
315
+ ### Images
316
+
317
+ **Absolute and external sources just work.** Put the file in `public/` and write `![](/diagram.png)`.
318
+
319
+ ```md
320
+ ![Architecture](/diagram.png) ✅ served from public/
321
+ ![Logo](https://example.com/logo.png) ✅ external
322
+ ![Architecture](./diagram.png) ⛔️ needs an imageResolver
323
+ ```
324
+
325
+ A **relative** source is a different thing. Nothing in `public/` corresponds to it, and the browser would resolve it against the *route* — so `/docs/guide` and `/docs/guide/setup` request two different files from byte-identical markdown. Rather than ship that, a relative source with no `imageResolver` fails the build, naming the file and offering both fixes.
326
+
327
+ An `imageResolver` receives the source already folded against the markdown file's directory (`./diagram.png` in `guides/deploying.md` arrives as `guides/diagram.png`) and returns a public URL plus intrinsic dimensions — which `next/image` requires and markdown does not carry:
328
+
329
+ ```ts
330
+ createDocsRoute({
331
+ contentDir: 'content/docs',
332
+ imageResolver: async (src) => {
333
+ const { width, height } = await imageSize(path.join('content/docs', src));
334
+ return { src: `/docs-assets/${src}`, width, height };
335
+ },
336
+ });
337
+ ```
338
+
339
+ A source that climbs above the content root fails the build whether or not a resolver is configured.
305
340
 
306
341
  ## Theming
307
342
 
@@ -318,19 +353,40 @@ your own `:root`, after the import:
318
353
  ```
319
354
 
320
355
  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.
356
+ `theme` for the tokens, `base` for element resets, `components` for the classes
357
+ and unlayered CSS outranks every layer regardless of specificity.
323
358
 
324
359
  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.
360
+ `:root[data-theme='dark']`, which is specificity (0,2,0). Outside a layer, an
361
+ unlayered `:root` at (0,1,0) would lose *no matter where it was loaded* — the
362
+ cascade never reaches source order and overriding would mean writing
363
+ `:root:root:root`. Layered, source order settles it and a plain `:root` is
364
+ enough.
365
+
366
+ ### Dark mode is opt-in
367
+
368
+ | On `<html>` | Result |
369
+ | --- | --- |
370
+ | nothing | Light |
371
+ | `class="dark"` | Dark |
372
+ | `data-theme="dark"` | Dark |
373
+ | `data-theme="system"` | Follows `prefers-color-scheme` |
374
+
375
+ `.dark` is honoured because [next-themes](https://github.com/pacocoursey/next-themes)
376
+ defaults to `attribute="class"` and never sets `data-theme`.
330
377
 
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.
378
+ **This is deliberate, and it is a change.** The tokens used to switch on
379
+ `prefers-color-scheme` alone. But the stylesheet styles the docs subtree, not
380
+ the page so on a light-only site with a `/docs` section, a visitor whose OS
381
+ was in dark mode got the near-white foreground ramp on the host's white
382
+ background: **1.23:1**, i.e. invisible. A stylesheet cannot assume it owns the
383
+ page it is dropped into, so it now switches only when the host says to.
384
+
385
+ If your site really does follow the OS and has no theme toggle, say so once:
386
+
387
+ ```tsx
388
+ <html lang="en" data-theme="system">
389
+ ```
334
390
 
335
391
  To restyle rather than retheme, override the classes — `.wave-docs-prose`,
336
392
  `.wave-docs-skip-link`, and the rest — from your own unlayered CSS.
@@ -372,6 +428,24 @@ export function Search() {
372
428
 
373
429
  MiniSearch is `import()`ed and the index fetched on hover, focus or first open — never on page load.
374
430
 
431
+ ### What gets indexed
432
+
433
+ **The whole section**, not a preview of it. `extractSearchRecords` once truncated `text` to 300 characters *before* indexing, which dropped roughly 80% of a normal corpus — and because the default `combineWith: 'AND'` requires every term to land in the same record, a two-word query against a page that plainly contained both words returned nothing. Indexing and display are now separate concerns: the full text is searchable, and `storeFields` carries only what the dialog renders.
434
+
435
+ ### CJK and other scripts
436
+
437
+ Tokenisation uses `Intl.Segmenter` where available, so Chinese, Japanese and Thai — which do not delimit words with spaces — index and query as words rather than as whole clauses. Without it, `search('安装')` matched nothing on a page that was entirely about 安装.
438
+
439
+ Both halves of the seam take the same overrides, and they must agree — an index built with one `tokenize` and queried with another matches nothing at all:
440
+
441
+ ```ts
442
+ buildSearchIndex(records, { fuzzy: 0.1, prefix: true });
443
+ ```
444
+
445
+ ```tsx
446
+ <SearchDialog indexUrl="/search-index.json" searchOptions={{ fuzzy: 0.1, prefix: true }} />
447
+ ```
448
+
375
449
  ## Configuration
376
450
 
377
451
  ```ts
@@ -388,7 +462,7 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
388
462
 
389
463
  | Option | Default | Purpose |
390
464
  | --- | --- | --- |
391
- | `langs` | 16 grammars | Typed `readonly DocsLang[]`, so a typo is a compile error |
465
+ | `langs` | 18 grammars | Typed `readonly DocsLang[]`, so a typo is a compile error |
392
466
  | `themes` | `github-light` / `github-dark` | Shiki theme pair |
393
467
  | `highlighter` | built-in | Supply your own for grammars outside the set |
394
468
  | `titleHeading` | `true` | Build an `<h1>` from `frontmatter.title` when the markdown has none |
@@ -415,9 +489,24 @@ export default () =>
415
489
  createDocsSitemap({ contentDir: 'content/docs', siteUrl: 'https://example.com' });
416
490
  ```
417
491
 
492
+ `siteUrl` must be a bare origin. A path component (`https://example.com/product-docs`) is rejected, because `new URL(href, siteUrl)` discards it — every canonical and every sitemap entry would point somewhere that 404s. Put the path in `basePath`, which does take multiple segments.
493
+
494
+ **An alias is a redirect, not a page.** It is never prerendered, so linking one from your markdown fails the build and names the page to link instead. Aliases are also validated when the page is read:
495
+
496
+ | Alias | |
497
+ | --- | --- |
498
+ | `quickstart`, `guides/old-name` | ✅ |
499
+ | `v1:beta`, `c++`, `docs/(old)` | ⛔️ path-to-regexp metacharacters |
500
+ | `../escape`, `./here` | ⛔️ relative segments |
501
+ | `''` | ⛔️ empty |
502
+
503
+ The rejected spellings are not pedantry. Next compiles a redirect `source` as a path pattern, so `aliases: ['v1:beta']` installed a **wildcard** — it built green and then permanently 308'd `/docs/v1-guide`, a real prerendered page, away to somewhere else.
504
+
418
505
  ### Development
419
506
 
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.
507
+ Markdown files are not in Next's module graph, so nothing recompiles a route module when one changes. `createDocsRoute` re-scans the content directory on every request outside `NODE_ENV=production`: edits appear on reload, new files are found without a restart, and the sidebar from `docs.source.nav()` agrees with the page body on the *same* request.
508
+
509
+ The rescan is shared. Next runs `generateMetadata` and your page concurrently, and a layout calling `nav()` is a third reader; invalidation is wrapped in `React.cache`, so the first of them re-reads the disk and the rest see that scan. Without it each invalidated the others' work in flight — measured at 22 `readdir` + 824 `readFile` per request on a 401-file tree, against 11 + 412 for one scan.
421
510
 
422
511
  ## Requirements
423
512
 
@@ -0,0 +1,74 @@
1
+ //#region src/docs-error.d.ts
2
+ /**
3
+ * One error shape for the whole package.
4
+ *
5
+ * Private — deliberately not an entry point in `package.json`. Consumers get
6
+ * the `code` off the error they already catch; they do not import a class and
7
+ * they cannot `instanceof` against a copy of this module resolved twice.
8
+ */
9
+ /**
10
+ * What went wrong, as something a consumer can branch on.
11
+ *
12
+ * Before this existed, every failure was a bare `Error` and roughly half the
13
+ * messages omitted the package prefix, so a host wanting to downgrade (say)
14
+ * broken-link failures in dev had nothing to test but the message text — and
15
+ * `message.startsWith('@waveso/docs:')` was not even a reliable filter.
16
+ */
17
+ type DocsErrorCode =
18
+ /** A link resolves to a route no published page owns. */
19
+ 'broken-link' |
20
+ /** A link resolves to a page that exists but is `draft: true`. */
21
+ 'draft-link' |
22
+ /** A link resolves to an alias, which is a redirect and not a page. */
23
+ 'alias-link' |
24
+ /** An `aliases` entry is empty, escapes the root, or is not URL-safe. */
25
+ 'invalid-alias' |
26
+ /** Two pages claim one alias, or an alias shadows a real route. */
27
+ 'alias-collision' |
28
+ /** Two files resolve to the same route. */
29
+ 'route-collision' |
30
+ /** A page's frontmatter is missing, malformed, or rejected by the schema. */
31
+ 'invalid-frontmatter' |
32
+ /** A `meta.json` is malformed, or names something that is not there. */
33
+ 'invalid-meta' |
34
+ /** An option passed to this package cannot be used as given. */
35
+ 'invalid-config' |
36
+ /** `contentDir` does not point at a readable directory. */
37
+ 'missing-content-dir' |
38
+ /** A markdown page is reachable only through a broken symbolic link. */
39
+ 'broken-symlink' |
40
+ /** An `imageResolver` returned an unusable shape, threw, or was needed. */
41
+ 'invalid-image' |
42
+ /** A theme name outside the supported set. */
43
+ 'unknown-theme' |
44
+ /** A fence language outside the loaded set. */
45
+ 'unknown-language' |
46
+ /** An optional peer (`next`) is absent or not the expected shape. */
47
+ 'missing-peer' |
48
+ /** The search index could not be fetched or parsed. */
49
+ 'search-index-unavailable' |
50
+ /** A plugin ran without the context this package always supplies. */
51
+ 'internal';
52
+ /** An {@link Error} carrying a {@link DocsErrorCode}. */
53
+ interface DocsError extends Error {
54
+ readonly code: DocsErrorCode;
55
+ }
56
+ /**
57
+ * Build an error that names this package and says what class of thing failed.
58
+ *
59
+ * The message is passed through untouched apart from the prefix, which is added
60
+ * only when absent — the wording at each throw site is the part a human reads,
61
+ * and several of them took real effort to get right.
62
+ *
63
+ * `code` is attached non-enumerably so it does not appear in `JSON.stringify`
64
+ * or a spread, which keeps error objects looking exactly as they did while
65
+ * still being branchable.
66
+ *
67
+ * The stack is left alone. Flattening it would hide the `dist/` frames a
68
+ * consumer does not care about, and also the ones a maintainer needs.
69
+ */
70
+ declare function docsError(code: DocsErrorCode, message: string, options?: ErrorOptions): DocsError;
71
+ /** Narrow an unknown caught value to one of this package's errors. */
72
+ declare function isDocsError(value: unknown): value is DocsError;
73
+ //#endregion
74
+ export { DocsError, DocsErrorCode, docsError, isDocsError };
@@ -0,0 +1,40 @@
1
+ //#region src/docs-error.ts
2
+ /**
3
+ * One error shape for the whole package.
4
+ *
5
+ * Private — deliberately not an entry point in `package.json`. Consumers get
6
+ * the `code` off the error they already catch; they do not import a class and
7
+ * they cannot `instanceof` against a copy of this module resolved twice.
8
+ */
9
+ /** Prefix every message carries, so a stack trace names the culprit package. */
10
+ const PREFIX = "@waveso/docs: ";
11
+ /**
12
+ * Build an error that names this package and says what class of thing failed.
13
+ *
14
+ * The message is passed through untouched apart from the prefix, which is added
15
+ * only when absent — the wording at each throw site is the part a human reads,
16
+ * and several of them took real effort to get right.
17
+ *
18
+ * `code` is attached non-enumerably so it does not appear in `JSON.stringify`
19
+ * or a spread, which keeps error objects looking exactly as they did while
20
+ * still being branchable.
21
+ *
22
+ * The stack is left alone. Flattening it would hide the `dist/` frames a
23
+ * consumer does not care about, and also the ones a maintainer needs.
24
+ */
25
+ function docsError(code, message, options) {
26
+ const error = new Error(message.startsWith(PREFIX) ? message : `${PREFIX}${message}`, options);
27
+ Object.defineProperty(error, "code", {
28
+ value: code,
29
+ enumerable: false,
30
+ writable: false,
31
+ configurable: true
32
+ });
33
+ return error;
34
+ }
35
+ /** Narrow an unknown caught value to one of this package's errors. */
36
+ function isDocsError(value) {
37
+ return value instanceof Error && typeof value.code === "string" && value.message.startsWith(PREFIX);
38
+ }
39
+ //#endregion
40
+ export { docsError, isDocsError };
@@ -6,10 +6,10 @@ import { StandardSchemaV1 } from "@standard-schema/spec";
6
6
  * The frontmatter fields the package itself understands, as a Zod schema.
7
7
  *
8
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.
9
+ * inferred type is `{ description?: string }`: absent stays absent, which is
10
+ * the only thing YAML can expressthere is no way to author an explicit
11
+ * `undefined` in a frontmatter block. A consumer's schema may still use plain
12
+ * `.optional()`; {@link DocFrontmatter} accepts both.
13
13
  *
14
14
  * `description` is deliberately not length-capped. The 155–160 character
15
15
  * advice for `<meta name="description">` is a pixel-width heuristic about
@@ -39,6 +39,17 @@ declare const docFrontmatterSchema: z.ZodObject<{
39
39
  * `raw` is whatever the YAML parser produced — `unknown` by construction, so
40
40
  * every field is checked rather than trusted.
41
41
  *
42
+ * The six fields this package reads are always parsed from the raw block by
43
+ * {@link docFrontmatterSchema} and laid back over the result, so a custom
44
+ * `schema` can only ever *add* fields. It cannot drop `draft`, `aliases`,
45
+ * `order`, `label`, `description` or `title` — which a bare
46
+ * `z.object({ title, audience })` silently does — and it cannot redefine them
47
+ * into a shape the sidebar, the redirects and the sitemap do not expect. The
48
+ * price is that a `.default()`, `.transform()` or `.coerce` aimed at one of the
49
+ * six is not honoured: the YAML wins. The return type intersects
50
+ * {@link DocFrontmatter} for the same reason — the built-ins are there whether
51
+ * or not the schema declared them.
52
+ *
42
53
  * Async because `~standard.validate` is allowed to return a promise and some
43
54
  * validators do (any schema with an async refinement). The source layer is
44
55
  * already async, so awaiting here costs nothing and refusing promises would
@@ -47,9 +58,30 @@ declare const docFrontmatterSchema: z.ZodObject<{
47
58
  * @param raw - Parsed YAML frontmatter block.
48
59
  * @param filePath - Path reported in the error. Pass the path the author
49
60
  * would recognise (relative to the content root), not an absolute one.
50
- * @param schema - Optional replacement schema, normally
61
+ */
62
+ declare function parseFrontmatter(raw: unknown, filePath: string): Promise<DocFrontmatter>;
63
+ /**
64
+ * Validate one file's frontmatter against `schema`, or throw an error that
65
+ * names the file. See the two-argument overload for the contract.
66
+ *
67
+ * `TFrontmatter` comes from the schema and must never be named at the call
68
+ * site. That is what the two overloads buy: `parseFrontmatter<Mine>(raw, file)`
69
+ * with no schema is a compile error, where before it type-checked and then lied
70
+ * — nothing validated `Mine`, so its fields were `undefined` at runtime while
71
+ * typed as present.
72
+ *
73
+ * `undefined` is accepted here rather than the schema being optional so that a
74
+ * `DocsConfig<T>`'s own optional schema passes straight through. An earlier
75
+ * attempt made it required and broke exactly that, at `createDocsRoute` and at
76
+ * any consumer holding a typed config.
77
+ *
78
+ * @param raw - Parsed YAML frontmatter block.
79
+ * @param filePath - Path reported in the error. Pass the path the author
80
+ * would recognise (relative to the content root), not an absolute one.
81
+ * @param schema - Replacement schema, normally
51
82
  * `docFrontmatterSchema.extend(...)`. Any Standard Schema validator works.
83
+ * `undefined` selects {@link docFrontmatterSchema}.
52
84
  */
53
- declare function parseFrontmatter<TFrontmatter extends DocFrontmatter = DocFrontmatter>(raw: unknown, filePath: string, schema?: StandardSchemaV1<unknown, TFrontmatter>): Promise<TFrontmatter>;
85
+ declare function parseFrontmatter<TFrontmatter extends DocFrontmatter>(raw: unknown, filePath: string, schema: StandardSchemaV1<unknown, TFrontmatter> | undefined): Promise<TFrontmatter & DocFrontmatter>;
54
86
  //#endregion
55
- export { docFrontmatterSchema, parseFrontmatter };
87
+ export { docFrontmatterSchema, parseFrontmatter, z };
@@ -1,13 +1,14 @@
1
+ import { docsError } from "./docs-error.js";
1
2
  import { z } from "zod";
2
3
  //#region src/frontmatter.ts
3
4
  /**
4
5
  * The frontmatter fields the package itself understands, as a Zod schema.
5
6
  *
6
7
  * 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.
8
+ * inferred type is `{ description?: string }`: absent stays absent, which is
9
+ * the only thing YAML can expressthere is no way to author an explicit
10
+ * `undefined` in a frontmatter block. A consumer's schema may still use plain
11
+ * `.optional()`; {@link DocFrontmatter} accepts both.
11
12
  *
12
13
  * `description` is deliberately not length-capped. The 155–160 character
13
14
  * advice for `<meta name="description">` is a pixel-width heuristic about
@@ -32,34 +33,60 @@ const docFrontmatterSchema = z.object({
32
33
  order: z.number().exactOptional()
33
34
  });
34
35
  /**
35
- * Validate one file's frontmatter, or throw an error that names the file.
36
+ * The package's own fields, every one of them optional, for the overlay pass
37
+ * in {@link parseFrontmatter}.
36
38
  *
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.
39
+ * `title` is optional here too, even though the package requires it: a custom
40
+ * schema may legitimately produce a title the YAML does not carry (a
41
+ * `.default()`), and demanding one of the raw block would fail that file. The
42
+ * check after the merge reads the merged title instead, so both routes to a
43
+ * title are accepted and no route to a missing one is.
50
44
  */
45
+ const builtInFields = docFrontmatterSchema.partial();
51
46
  async function parseFrontmatter(raw, filePath, schema) {
52
47
  const active = schema ?? docFrontmatterSchema;
53
48
  let result;
54
49
  try {
55
50
  result = await active["~standard"].validate(raw ?? {});
56
51
  } 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 });
52
+ throw docsError("invalid-frontmatter", `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
53
  }
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."}`);
54
+ if (result.issues === void 0) return active === docFrontmatterSchema ? result.value : applyBuiltIns(result.value, raw, filePath);
55
+ if (result.issues.length === 0) throw docsError("invalid-frontmatter", `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.`);
56
+ const source = 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.";
57
+ throw docsError("invalid-frontmatter", `Invalid frontmatter in ${filePath}:\n${formatIssues(result.issues)}\n${source}`);
58
+ }
59
+ /**
60
+ * Re-read the package's own fields from the raw block and lay them back over a
61
+ * custom schema's output.
62
+ *
63
+ * Without this, the guarantee that a `frontmatterSchema` keeps the built-ins
64
+ * rests on `TFrontmatter extends DocFrontmatter` — which constrains `title` and
65
+ * nothing else, because the other five fields are optional. So
66
+ * `z.object({ title, audience })`, written from scratch instead of as
67
+ * `docFrontmatterSchema.extend(...)`, type-checks and then strips `draft`,
68
+ * `aliases`, `order` and `label` on the way through. The package reads the
69
+ * absence as "not a draft, no redirects, no order": an unpublished page ships,
70
+ * is linked from the sidebar and is submitted to Google in the sitemap, and
71
+ * every renamed page 404s. `tsc` reports nothing, at either end.
72
+ *
73
+ * The overlay runs after the custom schema rather than instead of it, so a
74
+ * stricter rule on a built-in (`title: z.string().max(60)`) still fails the
75
+ * build — it just cannot change the value the package then reads.
76
+ */
77
+ async function applyBuiltIns(value, raw, filePath) {
78
+ const result = await builtInFields["~standard"].validate(raw ?? {});
79
+ if (result.issues !== void 0) throw docsError("invalid-frontmatter", `Invalid frontmatter in ${filePath}:\n${formatIssues(result.issues)}\n@waveso/docs reads these fields itself, so they are validated even when your \`frontmatterSchema\` does not declare them. Fix the YAML block at the top of that file.`);
80
+ const merged = {
81
+ ...value,
82
+ ...result.value
83
+ };
84
+ if (typeof merged.title !== "string" || merged.title.length === 0) throw docsError("invalid-frontmatter", `Invalid frontmatter in ${filePath}: no \`title\`. Every page needs one — it drives the \`<h1>\` fallback, \`<title>\`, the sidebar and search. Add \`title:\` to the YAML block at the top of that file, or give your \`frontmatterSchema\` a title it can supply.`);
85
+ return merged;
86
+ }
87
+ /** Render a validator's issues as the bullet list under the file name. */
88
+ function formatIssues(issues) {
89
+ return issues.map((issue) => ` - ${formatIssuePath(issue.path)}: ${issue.message}`).join("\n");
63
90
  }
64
91
  /**
65
92
  * Render an issue path as `aliases[0]` / `title`, never as `""`.
@@ -77,4 +104,4 @@ function formatIssuePath(path) {
77
104
  }, "");
78
105
  }
79
106
  //#endregion
80
- export { docFrontmatterSchema, parseFrontmatter };
107
+ export { docFrontmatterSchema, parseFrontmatter, z };
@@ -81,9 +81,9 @@ interface DocsHighlighterOptions {
81
81
  * than a build-time throw. The runtime check below stays for JavaScript
82
82
  * callers and for values that arrive from JSON config.
83
83
  */
84
- langs?: readonly DocsLang[];
84
+ langs?: readonly DocsLang[] | undefined;
85
85
  /** Theme pair. Defaults to {@link DEFAULT_DOCS_THEMES}. */
86
- themes?: DocsThemes;
86
+ themes?: DocsThemes | undefined;
87
87
  }
88
88
  /**
89
89
  * Create (or reuse) the process-wide highlighter for a given option set.
@@ -1,3 +1,4 @@
1
+ import { docsError } from "./docs-error.js";
1
2
  import { createHighlighterCore } from "shiki/core";
2
3
  import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
3
4
  //#region src/highlighter.ts
@@ -155,11 +156,11 @@ function isDocsTheme(name) {
155
156
  function createDocsHighlighter(options = {}) {
156
157
  const themes = options.themes ?? DEFAULT_DOCS_THEMES;
157
158
  const requested = options.langs ?? DEFAULT_DOCS_LANGS;
158
- for (const theme of [themes.light, themes.dark]) if (!isDocsTheme(theme)) throw new Error(`@waveso/docs: unknown Shiki theme '${theme}'. Supported themes: ${Object.keys(THEME_LOADERS).sort().join(", ")}. To use another theme, pass your own highlighter to createDocsRenderer().`);
159
+ for (const theme of [themes.light, themes.dark]) if (!isDocsTheme(theme)) throw docsError("unknown-theme", `@waveso/docs: unknown Shiki theme '${theme}'. Supported themes: ${Object.keys(THEME_LOADERS).sort().join(", ")}. To use another theme, pass your own highlighter to createDocsRenderer().`);
159
160
  const langs = [...new Set(requested)].sort();
160
161
  const loaders = /* @__PURE__ */ new Set();
161
162
  for (const lang of langs) {
162
- if (!isDocsLang(lang)) throw new Error(`@waveso/docs: unknown code language '${lang}'. Supported languages: ${Object.keys(LANG_LOADERS).sort().join(", ")}. To use another grammar, pass your own highlighter to createDocsRenderer().`);
163
+ if (!isDocsLang(lang)) throw docsError("unknown-language", `@waveso/docs: unknown code language '${lang}'. Supported languages: ${Object.keys(LANG_LOADERS).sort().join(", ")}. To use another grammar, pass your own highlighter to createDocsRenderer().`);
163
164
  loaders.add(LANG_LOADERS[lang]);
164
165
  }
165
166
  const key = JSON.stringify({