@waveso/docs 0.4.0 → 0.6.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 (58) hide show
  1. package/CHANGELOG.md +270 -0
  2. package/README.md +221 -79
  3. package/dist/anchors.d.ts +44 -0
  4. package/dist/anchors.js +76 -0
  5. package/dist/errors.d.ts +4 -0
  6. package/dist/highlighter.js +2 -1
  7. package/dist/link-suggestion.d.ts +31 -0
  8. package/dist/link-suggestion.js +94 -0
  9. package/dist/meta.js +6 -9
  10. package/dist/next.d.ts +54 -14
  11. package/dist/next.js +135 -20
  12. package/dist/plugins/rehype-code-frame.d.ts +13 -1
  13. package/dist/plugins/rehype-code-frame.js +2 -1
  14. package/dist/plugins/rehype-fallback-heading-ids.js +1 -1
  15. package/dist/plugins/remark-doc-links.d.ts +83 -1
  16. package/dist/plugins/remark-doc-links.js +50 -23
  17. package/dist/plugins/remark-youtube.d.ts +18 -3
  18. package/dist/plugins/remark-youtube.js +57 -9
  19. package/dist/react/callout.d.ts +13 -1
  20. package/dist/react/callout.js +2 -2
  21. package/dist/react/code-runtime.d.ts +12 -2
  22. package/dist/react/code-runtime.js +28 -4
  23. package/dist/react/doc-content.d.ts +12 -1
  24. package/dist/react/doc-content.js +2 -2
  25. package/dist/react/layout.d.ts +27 -10
  26. package/dist/react/layout.js +6 -3
  27. package/dist/react/link-adapter.d.ts +34 -0
  28. package/dist/react/link-adapter.js +30 -0
  29. package/dist/react/markdown-components.d.ts +29 -1
  30. package/dist/react/markdown-components.js +69 -67
  31. package/dist/react/nav.d.ts +5 -1
  32. package/dist/react/nav.js +5 -2
  33. package/dist/react/next-link.d.ts +6 -28
  34. package/dist/react/next-link.js +45 -24
  35. package/dist/react/next-nav.d.ts +5 -1
  36. package/dist/react/next-nav.js +6 -3
  37. package/dist/react/next-search.js +1 -1
  38. package/dist/react/search-dialog.d.ts +59 -3
  39. package/dist/react/search-dialog.js +53 -9
  40. package/dist/react/shell-labels.d.ts +135 -21
  41. package/dist/react/shell-labels.js +47 -6
  42. package/dist/react/sidebar.d.ts +18 -1
  43. package/dist/react/sidebar.js +59 -23
  44. package/dist/react/youtube.d.ts +22 -1
  45. package/dist/react/youtube.js +22 -4
  46. package/dist/render.d.ts +12 -1
  47. package/dist/render.js +107 -21
  48. package/dist/route-path.js +7 -2
  49. package/dist/safe-href.d.ts +47 -0
  50. package/dist/safe-href.js +73 -0
  51. package/dist/search-index.js +1 -1
  52. package/dist/search-options.d.ts +64 -2
  53. package/dist/search-options.js +25 -1
  54. package/dist/semaphore.d.ts +46 -0
  55. package/dist/semaphore.js +60 -0
  56. package/dist/source.js +86 -12
  57. package/dist/types.d.ts +102 -6
  58. package/package.json +6 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,275 @@
1
1
  # @waveso/docs
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 633f274: **Anchors are checked now.** A route was 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 link failures — headings get renamed constantly and nothing renames the links into them — and it went unchecked while the rarer one did not.
8
+
9
+ `onBrokenAnchors` defaults to `'throw'`, and the error names the heading you probably meant:
10
+
11
+ ```
12
+ @waveso/docs: guide.md:12 links to '#instalation', and this page has no
13
+ '#instalation'. Did you mean 'installation'?
14
+ ```
15
+
16
+ Checked against every `id` in the rendered page, not 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. Same-page anchors are checked as each page renders, so those errors carry a line number; cross-page anchors need the target's ids and are checked by `docs.renderAll()`, which runs in every build that serves search.
17
+
18
+ **`onUnverifiableLinks` is replaced by `externalRoutes`, and the default flipped.** It shipped in no release, so nothing to migrate.
19
+
20
+ The old option asked you to reason about _our_ inability to verify a link. The new one asks for a fact about _your_ application, which is the thing you actually know:
21
+
22
+ ```ts
23
+ createDocsRoute({
24
+ basePath: "/",
25
+ externalRoutes: ["/login", "/dashboard", "/api/"],
26
+ });
27
+ ```
28
+
29
+ And absolute links at a root mount are now checked by default rather than ignored. A root mount is what you choose when the origin serves documentation and nothing else — `docs.example.com` — so an unknown absolute link there is a typo, and silence was the wrong default. A site that serves something else names what is its own; `/api` covers `/api/keys` and not `/apiary`.
30
+
31
+ That also removes the `'warn'` level that made no sense: warning on every legitimate route in your application is not a diagnostic.
32
+
33
+ **New error code `broken-anchor`**, documented in the troubleshooting table and offered in the bug form.
34
+
35
+ - b6edd50: **New subpath `@waveso/docs/react/next-link`, exporting `DocsLink`** — `next/link` already adapted, so composing a shell by hand no longer needs a cast.
36
+
37
+ Passing `next/link` straight into `DocsSidebar` does not type-check under `exactOptionalPropertyTypes`: Next's `LinkProps` re-declares `onClick?`, `onMouseEnter?` and `onTouchStart?` _without_ `| undefined` while React's anchor props include it, so the two declaration files disagree over three props `next/link` accepts perfectly well at run time. It is a disagreement between dependencies, true of every `next/link` call site in a project with that flag on, and nothing the shape of `DocsLinkProps` can fix without breaking the plain-`<a>` fallback that keeps these components host-agnostic.
38
+
39
+ `docs.Layout` and `DocsSearch` have always absorbed it internally, so it only bit someone building their own shell — who was told in Troubleshooting to write `Link={Link as DocsLinkComponent}` and wait for a Next-wired component to ship. This is that component; the cast is retired and the note now shows the import.
40
+
41
+ ```tsx
42
+ "use client";
43
+ import { DocsLink } from "@waveso/docs/react/next-link";
44
+ import { DocsSidebar } from "@waveso/docs/react/sidebar";
45
+
46
+ <DocsSidebar nav={nav} pathname={pathname} Link={DocsLink} />;
47
+ ```
48
+
49
+ It carries `'use client'` — not for a hook, there is none, but because `DocsLink` is a function and a function cannot be handed from a Server Component to a Client one. Without the directive it would be a server reference and `next build` would refuse it, which is the same boundary this release fixed for MiniSearch options.
50
+
51
+ The private adapter factory it is built from is renamed `link-adapter.ts`, so the two are not one letter apart in the same directory. 180 bytes gzipped, with a 300-byte budget: it should stay the thinnest thing this package ships to a browser.
52
+
53
+ - 1fa4317: **Link checking has severity levels, and broken links now say what you probably meant.**
54
+
55
+ **BREAKING: `assertLinks: boolean` is replaced by `onBrokenLinks: 'throw' | 'warn' | 'ignore'`**, defaulting to `'throw'`. `assertLinks: false` becomes `onBrokenLinks: 'ignore'`; `assertLinks: true` was the default and can be dropped. The shape follows Docusaurus's `onBrokenLinks` for the same reason it exists there: the tool cannot know how much a given site cares, and guessing produces either a build that fails on somebody's legitimate URL or one that ships a dead link quietly.
56
+
57
+ **Broken-link errors now offer the closest published route** when the link looks like a typo of one:
58
+
59
+ ```
60
+ @waveso/docs: guide.md:12 links to './instalation.md', which resolves to
61
+ '/docs/instalation' — no such page exists. Did you mean '/docs/installation'?
62
+ ```
63
+
64
+ A typo is a near-miss by construction, which is what makes the suggestion safe to offer _and_ safe to withhold — the same trick `git`, `tsc`, `cargo` and Python 3.12 use. It decorates an error that was already being raised; it never decides whether to raise one. `/docs/instructions` is five edits from `/docs/installation` — a different word, not a typo — and gets no suggestion, because sending an author to rename a correct link is worse than saying nothing.
65
+
66
+ **New `onUnverifiableLinks`, defaulting to `'ignore'`, closes the root-mount gap.** To check `[x](/setup)` the package must first know it is a documentation link. Under `basePath: '/docs'` the prefix says so. Under `basePath: '/'` there is no prefix — `/setup` may be a page of yours, `/login` almost certainly is — so until now those links were dropped unrecorded and a typo in one shipped silently.
67
+
68
+ They are recorded and marked now, and the site decides:
69
+
70
+ ```ts
71
+ createDocsRoute({
72
+ contentDir: "content/docs",
73
+ basePath: "/",
74
+ onUnverifiableLinks: "throw", // this domain is documentation and nothing else
75
+ });
76
+ ```
77
+
78
+ The default stays `'ignore'` because a root mount inside a larger application genuinely cannot distinguish the two, and failing that build would be wrong. Relative links (`./other.md`) are resolved against the content tree, so they are verifiable at every mount and always governed by `onBrokenLinks`.
79
+
80
+ `docs.wave.so` runs with `onUnverifiableLinks: 'throw'`, which is the configuration this option was written for.
81
+
82
+ ### Patch Changes
83
+
84
+ - 102d6ae: **The README shows the live site instead of screenshots.** Three PNGs, a Playwright script to shoot them, a pinned tag and two tests to keep the pin honest — replaced by a link to [docs.wave.so](https://docs.wave.so), which is this package's documentation built with this package.
85
+
86
+ The screenshots were a photograph of the harness. The site _is_ the harness: the same `site/` that CI builds on every commit, whose acceptance test forbids it a single line of layout CSS of its own. A reader who wants to know what the shell looks like can now use it — open the search, resize to a phone, tab through the drawer — instead of looking at a picture of it taken on somebody's Mac.
87
+
88
+ It also removes a whole class of staleness. A pinned screenshot is wrong the moment the shell changes and right only if someone remembers to re-shoot and re-pin; the last one was pinned to `v0.3.0` while the images had been regenerated for 0.4.0, so npm showed a search dialog the release had already replaced. A URL cannot go stale.
89
+
90
+ `pnpm shoot` is gone. The regression it was meant to catch — a stylesheet change reflowing the shell — is the browser tier's, which asserts geometry rather than pixels and runs in the same Chromium everywhere.
91
+
92
+ - e2bbaf4: **docs.wave.so serves the documentation at its root**, so a page is `docs.wave.so/installation` rather than `docs.wave.so/docs/installation` — a host called `docs` should not say it twice.
93
+
94
+ Nothing in the package changed: `basePath` has always taken any prefix, and `'/'` is one of them. The default is still `/docs`, defined in one place, and every consumer gets it unless they say otherwise.
95
+
96
+ What did change is which configuration the harnesses cover. `smoke/` builds on the default `/docs` in both output modes on every CI run, so moving the site to the root mount loses nothing and covers the half that was thin: an empty base path is a distinct code path in `toHref`, `toRoute` and `isInternalAbsoluteLink`, and two unit assertions used to be all of it. The two harnesses now cover both mount points and both documented layout shapes — smoke keeps the README's one-line `export default docs.Layout`, the site composes `<docs.Layout>` inside a root layout.
97
+
98
+ **One behaviour differs at the root mount, and it is worth knowing.** With an empty base, an absolute link like `/installation` cannot be told apart from any other route in the application, so it is not checked against the published routes — under `/docs`, a typo in `/docs/instalation` fails the build; at the root it does not. Relative markdown links, which is what documentation should be written with, are unaffected.
99
+
100
+ ## 0.5.0
101
+
102
+ ### Minor Changes
103
+
104
+ - 0d61f5d: **The content scan no longer opens every markdown file at once.** It failed on exactly the large documentation sets this package exists for, and it got worse as a site grew.
105
+
106
+ `scanDir` read its own pages with a bare `Promise.all` _and_ recursed into its subdirectories with another, so the number of `readFile` calls in flight equalled the page count of the whole tree. Reproduced on a 1,201-page corpus at the 1,024-descriptor soft limit every Linux and CI image ships with: `next build` died with
107
+
108
+ ```
109
+ Error: EMFILE: too many open files, open '<contentDir>/s33/p0829.md'
110
+ ```
111
+
112
+ — no error code, no mention that this was the docs scan, and a filename out of a thousand that sends the author to inspect a page which is perfectly fine.
113
+
114
+ **Every filesystem call in the scan now goes through one process-wide semaphore**, bounded at 64. Process-wide rather than per-scan because descriptors are a process resource: two routes scanning two content directories would each stay under a per-scan bound and together exceed the only one that matters. The bound is on the leaf calls — `readFile`, `readdir`, `stat`, `realpath` — and deliberately not on the recursion, which would deadlock the moment every slot were held by a directory waiting for a slot to read its children.
115
+
116
+ **64 is chosen for the tightest descriptor limit, not for speed.** Measured over those 1,201 pages in 49 directories, nine runs, medians: **88 ms ungated, 106 ms at a bound of 16, 102 ms at 64, 101 ms at 128 and at 256.** Flat from 64 upwards — so the ~14 ms is the gate's own per-call overhead, not lost parallelism, and there is no speed to buy above 64. libuv's filesystem pool is four threads by default, so there was never 1,201-way parallelism there to lose. 14 ms sits against a build that highlights those same pages with Shiki, which is three orders of magnitude more.
117
+
118
+ **New error code `descriptor-limit`**, for when the limit is lower than the bound or something else in the process has the descriptors. It says which content directory was being scanned, that the scan holds at most 64 open, and that the fix is `ulimit -n` rather than a smaller corpus — the opposite of what a reader concludes from an error naming one of their own pages. Documented in the troubleshooting table and offered in the bug form.
119
+
120
+ The regression test asserts the invariant rather than the number: peak concurrent filesystem calls during a 300-page scan across 30 directories, which had 300 in flight before and has a fixed ceiling now however large the corpus.
121
+
122
+ - 6c244e5: **The mobile drawer now opens on the page you are reading.** Scroll-the-current-item-into-view had never run on a phone — not intermittently, never.
123
+
124
+ Below 64rem the sidebar lives inside `<dialog class="wave-docs-layout__drawer">`, which the UA stylesheet keeps at `display: none` until `showModal()`, wrapped in a `.wave-docs-layout__sidebar` that is `display: contents`. An element in a `display: none` subtree generates no boxes at all, so both report `scrollHeight === clientHeight === 0` and the walk for a scrollable ancestor went past the drawer, past the grid, and returned `null`.
125
+
126
+ The timing is what made it unreachable rather than merely unreliable: the effect was keyed on `pathname` alone, and `DocsNav` closes the drawer on every `pathname` change — so at the one moment it could fire, the drawer was always shut, and nothing re-ran when the reader opened it. Measured in Chromium at 390×800 on a 60-item nav: the active item's bottom edge sat at **1594px in an 800px drawer**, 794px below the fold, on the one navigation where the reader knows exactly what they asked for.
127
+
128
+ `DocsSidebar` now also positions itself when a `<dialog>` around it opens. It finds that dialog with `closest('dialog')` rather than taking a prop from `DocsNav`, because the condition is "I am inside something that can be hidden and revealed" rather than "I am inside the drawer" — so a consumer who puts `DocsSidebar` in a dialog of their own gets the same behaviour, and nothing in the sidebar has to know what the drawer is.
129
+
130
+ **Size budgets raised deliberately:** `sidebar` 1.8 → 1.9 KB, `nav` 2.2 → 2.25 KB, `next-nav` 2.25 → 2.34 KB. The cost is one `toggle` listener; the sidebar was sitting at exactly 100% of its old budget, which is a CI failure waiting for the next byte rather than a limit doing any work.
131
+
132
+ **Why no other tier could see it:** jsdom has no layout and no `showModal`, and the stylesheet read as text says nothing about what `display: contents` does to a scrollport. The new tests assert the premise first — that the item measures zero height while the drawer is closed — so they cannot quietly degrade into tests that pass by measuring nothing.
133
+
134
+ - f2ad4f4: **Every string this package renders is now yours to set.** `DocsLayoutProps.labels` documented itself as "the whole of what a non-English site has to say" and reached four strings of twenty-two.
135
+
136
+ Verified in this repository's own `site/out`, which is how it was found: a site built exactly the documented way shipped `<nav aria-label="On this page">`, a visible `Back to top`, `aria-label="Tip"` on every callout, `aria-label="Table"` on every wide table, `Copy code` on every fence, `(opens in a new tab)` after every external link, `Expand <group>` on every sidebar disclosure, and `Play video` on every embed — in English, whatever language the site was written in. The copy runtime announced `Copied to the clipboard.` to a screen reader, in English, on every site on earth.
137
+
138
+ **`createDocsRoute({ labels })` is where they live now**, because they are not rendered in one place and a layout prop could never have reached them:
139
+
140
+ | where | strings | cost to override |
141
+ | -------------------------------------------------------- | ------- | ----------------------------- |
142
+ | the shell | 4 | none, server-rendered |
143
+ | the navigation tree | 3 | crosses to a client component |
144
+ | the table of contents | 2 | crosses to a client component |
145
+ | your content — callouts, tables, external links, YouTube | 9 | none, server-rendered |
146
+ | code frames | 2 | none, baked in at build time |
147
+ | the copy runtime | 2 | crosses to a client component |
148
+
149
+ `docs.Layout`'s `labels` prop still exists and now overrides the route's **key by key**, for a site with two shells or a section in another language — a whole-object override would mean naming one string cost you the other twenty-one.
150
+
151
+ `{title}` is a placeholder in the five strings that interpolate a name, rather than a function: three of them cross a Server → Client boundary where a function cannot go, and a translator has to be able to move the name within the sentence, which concatenation forbids.
152
+
153
+ **`rehypeCodeFrame` has taken a `copyLabel` since it was written and nothing ever passed one.** The plugin is private, so the option was unreachable from every entry point while the README said the label was configurable. It is wired now, and joined by `copyCodeFrom` for a titled fence.
154
+
155
+ **Sizes moved, and the published figures moved with them.** `sidebar` 1.9 → 2.0 KB, `nav` 2.25 → 2.45, `next-nav` 2.34 → 2.55, `code-runtime` 0.98 → 1.15. The README's cost table now reads 13.5 KB for the quick start, 2.4 KB for navigation and 1.1 KB for the copy runtime. About 200 gzipped bytes for a chrome that can be translated at all.
156
+
157
+ **A key that is declared and never wired now fails the suite.** `LABEL_COVERAGE` in `next.test.ts` requires every member of `DocsLabels` to name where it is proven, and fourteen of them are asserted against real rendered markup with sentinel values — a string that cannot occur by accident cannot pass by accident.
158
+
159
+ - 952e182: **Image sources are now split and percent-decoded, exactly as links have always been.** `foldImageSrc` handed the authored `src` straight to `foldSegments`, so three ordinary spellings broke — and the first is the one GitHub's own editor writes for you.
160
+
161
+ - **`![a](./getting%20started.png)`** — drag a file whose name has a space into GitHub's editor and this is what it writes. It reached the `imageResolver` still encoded, so `readFile(path.join('content/docs', src))` — the implementation this README gives — threw `ENOENT` and the build died with `invalid-image` on a file that is plainly on disk and that GitHub renders correctly.
162
+ - **`![a](./diagram.png?v=2)` and `![a](./sprite.svg#icon)`** — the query and the fragment were baked into the filename, so the resolver looked for a file called `diagram.png?v=2`. They are split off before the call now and re-attached to whatever the resolver returns, so the cache-buster survives and `#icon` still selects the symbol inside the sprite. A resolver returning a query or fragment of its own keeps its own, because two `?` in one URL is not a URL.
163
+ - **`![a](./%2E%2E%2Fsecret.png)`** — folded without decoding, that is one segment with no slash in it, so the climb check never fired. `ImageResolver`'s contract promises the path is contained; any resolver that decodes — anything building a `URL` — was outside it. `foldSegments` can only refuse a `../` that is spelled as one, which is why decoding happens first.
164
+
165
+ **One implementation, in `splitHref`.** Four call sites need the same split-decode-fold-reattach sequence; three had it inline and the fourth had none of it. They now share one.
166
+
167
+ **`invalid-image` covers malformed encoding too.** Decoding means the image path can raise `URIError`, and an unwrapped one would reach the build with no code, no file and no line — past the very check that exists to make image failures locatable.
168
+
169
+ `ImageResolver`'s docstring now says what its argument is: a file path, decoded, with the query and fragment already removed.
170
+
171
+ - 791c1c0: **Link errors now name the line the link is on.** Every `broken-link`, `draft-link` and `alias-link` reported a line number offset by the length of the frontmatter block — which is to say, a wrong one, on every page that has frontmatter, which is every page.
172
+
173
+ `vfile-matter` with `strip: true` deletes the block from the body, so remark counts `node.position.start.line` from the first line of the _body_. The error prints `relativePath:line`, the exact `file:line` form a terminal and an editor turn into a jump. A page with `title`, `description`, `label` and `order` — four fields and two delimiters — is six lines out:
174
+
175
+ ```
176
+ - @waveso/docs: setup.md:4 links to './nowhere.md', which resolves to '/docs/nowhere'
177
+ + @waveso/docs: setup.md:10 links to './nowhere.md', which resolves to '/docs/nowhere'
178
+ ```
179
+
180
+ Line 4 is the middle of a block that is no longer there. Locatability is that error's entire job.
181
+
182
+ **The fix is padding rather than arithmetic.** `DocFile` carries a new `frontmatterLines`, set by the scan, and the renderer prepends that many newlines to what the parser sees. A blank line produces no markdown node, so it costs nothing in the output — and every position downstream is simply correct, including any a future plugin reports, which an offset applied at the four known throw sites would not be.
183
+
184
+ `content` is untouched. It is public, its exact value is pinned by tests, and a consumer measuring it should not have to know about this.
185
+
186
+ `frontmatterLines` is optional and treated as `0` when absent, so a host loading content itself — the documented reason `@waveso/docs/render` is an entry point — is unaffected.
187
+
188
+ **Why nothing caught it:** the render tests hand the renderer a body they wrote themselves, with no frontmatter and therefore no offset, and the source tests never render. The defect lived in the seam and was invisible from either side; the new tests scan a real file and render it.
189
+
190
+ - 5117aa4: **The search dialog's props are in the README, and the ones that were missing were the ones the changelog told you to migrate to.** A consumer on 0.3.0 with `<DocsSearch maxResults={20} />` upgraded to 0.4.0, watched TypeScript reject `maxResults`, opened the file the Stability section calls the definition of public API, searched 57 KB of it for the replacement — and found neither `pageSize` nor `minQueryLength`.
191
+
192
+ There is a props table under **Search → The dialog's props** now, and a check that stops the next rename shipping undocumented: `manifest.test.ts` reads every `…Props` interface out of the emitted `.d.ts` and requires each member to be named in the README. It found seven more the moment it was written — `DocsTocProps.rootMargin` and `.topLabel`, `DocsLinkProps.prefetch`, `DocsImageProps.sizes` and `.loading`, and two of my own — all now documented rather than allowlisted.
193
+
194
+ **Six of the dialog's strings turned out to be hardcoded English**, which is the same defect this release fixes everywhere else and was hiding behind a prop list that looked complete: `triggerLabel`, `placeholder` and `dialogLabel` were props from the start, so `search={{ … }}` read as _the_ channel for the dialog's words — while every state message was a literal. They are props now: `hintLabel`, `shortQueryLabel`, `loadingLabel`, `errorLabel`, `emptyLabel`, with `{min}` and `{query}` interpolated.
195
+
196
+ **And the live region announced "3 results" in English on every site on earth.** `resultCountLabels` is keyed by plural category rather than being a singular and a plural, because most languages are not English — Polish takes four forms, Arabic six. `Intl.PluralRules` picks, using `locale` or the document's own `<html lang>`, and a category you do not list falls back to `other`. An invalid `lang` is caught rather than thrown: that is the site's typo, not a reason to announce nothing.
197
+
198
+ **`hotkey` does not exist and never did.** `DocsLayoutProps.search` listed it among the props an object may carry. The shortcut is ⌘K / Ctrl-K and is not configurable; the docstring says so now. `SearchDialogProps.indexUrl` likewise documented itself in terms of `writeSearchIndex`, which was deleted in 0.3.0.
199
+
200
+ **Published figure raised:** search dialog and router wiring, 9.0 → 9.3 KB.
201
+
202
+ - 114eb3d: **`docs.Layout` no longer breaks `next build` when the route tunes MiniSearch with a function.** It has been doing so since 0.3.0, in exactly the case the option's own capitalised warning tells you to use it for.
203
+
204
+ `createDocsRoute({ miniSearchOptions })` and `export default docs.Layout` are the two things the quick start tells you to do, and the layout forwards those options to the dialog because it must — MiniSearch reads `tokenize` and `processTerm` when indexing _and_ when querying, so an index built with one and queried with another matches nothing at all and says nothing. But `docs.Layout` is a Server Component and the dialog is a Client Component, and React serialises what crosses between them. So a `processTerm` in that object took the whole build down:
205
+
206
+ ```
207
+ Error: Functions cannot be passed directly to Client Components
208
+ {processTerm: function processTerm}
209
+ ```
210
+
211
+ Serialisable overrides — `storeFields`, `boost`, anything under `searchOptions` — were unaffected, which is why this survived two releases: every documented example uses those.
212
+
213
+ **The fix is a refusal, not a silent drop.** Forwarding the serialisable half and discarding the rest would rebuild the original defect: an index built with a `processTerm` the query does not share is the zero-results-and-no-error failure the forwarding exists to prevent. `docs.Layout` now throws `invalid-config` instead, naming every offending option — `miniSearchOptions.processTerm`, `miniSearchOptions.searchOptions.filter` — and naming the remedy.
214
+
215
+ **The remedy is a client boundary of your own**, which is the only place the two halves can share a function by module reference rather than by prop. Keep it on `createDocsRoute` so the index is still built with it, pass `search={false}`, and render the dialog from a `'use client'` module that imports the same function — `README.md` has the three files under **Search → Functions need a client boundary**. `search={false}` deliberately does not throw: it is the supported path, so it must not be the one that fails.
216
+
217
+ **TypeScript now says so at the seam.** `DocsLayoutProps['search']` takes the new `SerializableSearchOptions` rather than MiniSearch's full `Options`, so `<docs.Layout search={{ miniSearchOptions: { tokenize } }}>` does not compile. `DocsSearch` and `SearchDialog` are unchanged and still take everything — they are already client components, which is the whole point. The type is the friendlier half of the guard, not the load-bearing one: a JavaScript caller has no types, and an `Omit` list goes stale the minor MiniSearch adds a callback, so the runtime check walks the values structurally and finds a function wherever it is.
218
+
219
+ **`DocsLayoutSearchProps` and `SerializableSearchOptions` are now exported from `@waveso/docs/next`.** Both already appeared in `DocsLayoutProps.search`; until now there was no way to spell either one.
220
+
221
+ **Why nothing caught it:** the test asserted on `element.props.search` and so never crossed the boundary it was testing. A props assertion is not an RSC test. The smoke build now checks that the forwarded options reach the flight payload — `pnpm test:smoke` reads them back out of the prerendered HTML — because only a real build can prove the allowed half arrives.
222
+
223
+ ### Patch Changes
224
+
225
+ - a5136d5: **An alias copied out of the address bar was encoded twice.** An alias _is_ a former URL, so `aliases: [getting%20started]` is the ordinary way to write one — and the route builder encoded the `%` again, producing a redirect source of `/docs/getting%2520started` that no request can ever match. The page moved, the alias was written, and the old URL still 404'd.
226
+
227
+ It decodes first now, which makes both spellings round-trip: the pasted form decodes and re-encodes to itself, and the readable `c# guide` has nothing to decode and still becomes `c%23%20guide`. The `.`/`..` and redirect-pattern checks moved to the decoded value too — `%2E%2E` is `..` in disguise and `%28` is `(`, so checking the raw text passed exactly the inputs those checks exist to catch.
228
+
229
+ **`basePath: '//docs'` pointed the whole site off itself.** A browser reads a leading `//` as scheme-relative, so `//docs/setup` navigates to a host called `docs` — and `basePath` builds every canonical, every `og:url` and every sitemap entry. Runs of slashes collapse now.
230
+
231
+ - 511ec6f: **Five documentation claims that were not true, and the tests that stop them rotting again.**
232
+
233
+ **`pnpm size` now really does gate `prepublishOnly`.** The paragraph introducing the cost table says every figure is enforced "in CI and again in `prepublishOnly`" — the sentence that makes the whole table worth trusting — and the script ran typecheck, lint, test, build, `check:readme` and `check:package`. Nothing verified the published numbers at the one moment they became published. The claim is now true rather than deleted, and a test compares the two.
234
+
235
+ **The README no longer claims CI runs `pnpm shoot --check`.** It never has: the gate was added and removed without running once, because byte-compared PNGs cannot survive a change of operating system — the font stack resolves to SF Pro on the machine that shot them and to DejaVu on a Linux runner, so every text pixel differs and no tolerance rescues glyphs that are different shapes. A test now requires the README and `ci.yml` to agree either way.
236
+
237
+ **The screenshots are pinned to a tag whose images they actually are.** The pin said `v0.3.0` while the committed PNGs had been regenerated for 0.4.0's search work, so the README on npm showed a dialog the release it documented had replaced. A test compares the committed bytes against `git show <tag>:docs/media/…` — exact, offline, and skipped only in a shallow clone.
238
+
239
+ **`src/react/` does not import nothing from `next/*`.** Two modules do, and are named after the fact: `next-nav` for `usePathname` and `next-search` for `useRouter`. The project-structure listing and the Components paragraph both said otherwise.
240
+
241
+ **`SECURITY.md` supports `0.5.x`**, not `0.3.x`.
242
+
243
+ And two comments in `next.ts` that misdescribed the code they sit on: `layout.tsx` was called a `'use client'` module — it is a Server Component, and its own docstring has always said so — and the lazy import beside it was justified by that non-fact rather than by the real reason, which is that `layout.tsx` statically imports two client modules that reach `next/navigation` and `next/link`.
244
+
245
+ - b3c0fa6: **`search={{ className }}` no longer deletes the class the header depends on.** It was applied before the spread, so a host passing a class — the ordinary reason to pass `search` an object — replaced `wave-docs-layout__search` instead of adding to it, and the trigger lost its place in the header grid. Adding a class should not remove one.
246
+
247
+ **A declining `imageResolver` no longer emits an unusable src.** Returning `undefined` for a relative image wrote the folded path — `guide/diagram.png` — which the browser resolves against the _route_: `/docs/guide` asks for `/docs/guide/diagram.png` and `/docs/guide/setup` asks for `/docs/guide/setup/guide/diagram.png`, from identical markdown, with a green build. That is the precise failure unconditional folding exists to prevent, surviving in the one branch that skipped the check for it. It is the same `invalid-image` as having no resolver at all, because it is the same situation. A _public_ src that a resolver declines still passes through untouched — "leave it as it is" is a complete answer for one of those.
248
+
249
+ **`decoding` and `fetchPriority` reach a custom `Image` component.** `createImage` spreads the tree's attributes into whichever component it was given, under a comment saying those two survive into the optimising branch — and `wrapNextImage` destructures a fixed list, so they did not. They are declared members of `DocsImageProps` now, which is the right shape for a component seam; a comment promising an open one was not.
250
+
251
+ **An equal theme pair written two ways no longer builds two highlighters.** The cache key was `JSON.stringify({ langs, themes })`, and `JSON.stringify` preserves insertion order — so `{ light, dark }` and `{ dark, light }` produced two keys and two whole Shiki instances, each loading every grammar. `langs` was already sorted for exactly this reason.
252
+
253
+ **`@waveso/docs/highlighter` has tests.** It is a public subpath and had none: the escape hatch a consumer reaches for precisely when the defaults do not fit, and the least likely to be exercised by anything else here. Seven now cover the cache identity, both refusals, every default grammar, and the `cfg / `conf aliases the module exists for — which Shiki resolves against a grammar's own alias list rather than against ours, so registering `ini` under a `cfg` key would not have worked and nothing would have said so.
254
+
255
+ - f97c345: **`meta.json` is now read the way markdown is.** Four ways it was not, and one of them is a security gap.
256
+
257
+ **A hand-written nav entry went round the link allowlist.** `{ "title": "Status", "href": "javascript:alert(1)" }` reached `<a href>` through `DocsSidebar` with nothing looking at its scheme — while a `javascript:` link in the markdown beside it was dropped by a check whose own comment calls it load-bearing. Both paths end at the same anchor, so both now use the same allowlist, and it lives in one module rather than two copies. `meta.json` is refused at parse time rather than dropped at render, because the file is authored and a nav entry that silently vanishes is the quietest possible failure.
258
+
259
+ **A `mailto:` entry was announced as opening a new tab.** `external` was "has a scheme", so `mailto:` and `tel:` were given `target="_blank"`, an external-link icon and an "(opens in a new tab)" suffix — describing a tab that never appears, to precisely the reader who cannot see that it did not. The markdown path has always drawn the line at http(s); the sidebar does now too. (The test asserting the old behaviour asserted the bug, and said so in its own name.)
260
+
261
+ **A UTF-8 BOM failed the build on a character nobody can see.** `readFile(…, 'utf8')` leaves it in and `JSON.parse` refuses it — `Unexpected token ''`, in the file the author is looking straight at. `readPage` learned this for markdown when `gray-matter` was swapped out; this is the same line for the same reason.
262
+
263
+ **A name in `meta.json` is matched in NFC**, because the filenames it is matched against are. `source.ts` normalises at the `readdir` boundary — macOS hands back decomposed forms — so a `meta.json` written on a Mac could list `café` and fail with `lists "café", which does not exist`, beside a list of available names containing a visually identical `café`.
264
+
265
+ - 0670710: **A YouTube link's timestamp and playlist are no longer dropped.** Only the video id survived the substitution, so `https://youtu.be/x?t=754` — a link to one moment in a two-hour talk, which is most of why anyone deep-links a video — opened at zero. And because the facade passes `autoplay=1`, it did not merely start in the wrong place: it started _playing_ there, leaving the reader to work out that the author had meant somewhere else.
266
+
267
+ Every spelling YouTube's own share dialog produces is understood — `t=754`, `t=90s`, `t=1m30s`, `t=1h2m3s`, the older `#t=` fragment, and `start=` on an embed URL — along with `list=`.
268
+
269
+ Both go into a URL, so both are checked: a playlist id has to look like one, a timestamp has to parse to a positive number of seconds, and the embed URL is built with `URLSearchParams` rather than by concatenation. Hand-built, a crafted `list=x%26autoplay%3D0` would have decoded into a real `&autoplay=0` and silently turned off the one interaction the facade exists to own.
270
+
271
+ `YouTube` takes `start` and `list` props to match. `parseYouTubeId` is now `parseYouTubeRef` and returns the whole reference; it is private, so nothing outside the package moves.
272
+
3
273
  ## 0.4.0
4
274
 
5
275
  ### Minor Changes