@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/README.md CHANGED
@@ -13,32 +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; CI runs
26
- `pnpm shoot --check` on any pull request touching the stylesheet, the React
27
- layer or the site.
28
- -->
29
- <picture>
30
- <source
31
- media="(prefers-color-scheme: dark)"
32
- srcset="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.3.0/docs/media/hero-dark.png"
33
- />
34
- <img
35
- src="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.3.0/docs/media/hero-light.png"
36
- alt="A documentation page rendered by @waveso/docs: a navigation sidebar, prose with syntax-highlighted code frames, and a table of contents."
37
- width="100%"
38
- />
39
- </picture>
40
-
41
- <p align="center"><em>The default page, with no CSS of your own. <a href="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.3.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>
42
21
 
43
22
  ---
44
23
 
@@ -90,16 +69,16 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
90
69
 
91
70
  | | At most |
92
71
  | --- | --- |
93
- | Everything the quick start ships, gzipped | 13.0 KB |
94
- | Search dialog and router wiring | 9.0 KB |
95
- | Navigation: sidebar and mobile drawer | 2.2 KB |
72
+ | Everything the quick start ships, gzipped | 13.5 KB |
73
+ | Search dialog and router wiring | 9.3 KB |
74
+ | Navigation: sidebar and mobile drawer | 2.4 KB |
96
75
  | Table of contents | 0.9 KB |
97
- | Copy-button runtime | 0.9 KB |
76
+ | Copy-button runtime | 1.1 KB |
98
77
  | hast over the wire vs HTML, prose page | 1.20× |
99
78
  | hast over the wire vs HTML, code and tables | 1.12× |
100
79
  | Highlighting vs no highlighting | 2.00× |
101
80
 
102
- The first row is the honest total: a reader who lands on a page of your documentation downloads under 13 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.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.
103
82
 
104
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.
105
84
 
@@ -211,20 +190,25 @@ Every subpath is enumerated in `exports` — there is no wildcard. A name that i
211
190
 
212
191
  ## Components
213
192
 
214
- Every component takes data as props and imports nothing from `next/*` — the adapter injects `next/link` and `next/image`. That keeps the renderer host-agnostic and testable without a router. `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`.
193
+ 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`.
215
194
 
216
195
  | Component | Subpath | Notes |
217
196
  | --- | --- | --- |
218
197
  | `DocContent` | `react/doc-content` | Renders a hast tree, inside `.wave-docs-prose`. Server Component |
219
198
  | `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
220
- | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver` |
199
+ | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver`. `label`, `topLabel`, `rootMargin`, `className` |
221
200
  | `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
201
+ | `DocsLink` | `react/next-link` | `next/link`, adapted — pass it as `Link` when composing by hand |
222
202
  | `SearchDialog` | `react/search-dialog` | ⌘K, arrow keys, focus trap. Host-agnostic |
223
203
  | `Callout` | `react/callout` | Note · tip · important · warning · caution. `CALLOUT_TYPES` is the list |
224
- | `YouTube` | `react/youtube` | Click-to-load facade |
204
+ | `YouTube` | `react/youtube` | Click-to-load facade. `title`, `playLabel`, `hideLabel` — `{title}` interpolates the first |
225
205
  | `SkipLink` | `react/skip-link` | Targets `docs.Page`'s `<main>`; `DOCS_CONTENT_ID` is that id. `docs.Layout` renders one |
226
206
  | `createMarkdownComponents` | `react/markdown-components` | The element → component map. `defaultMarkdownComponents` is the unwired one |
227
207
 
208
+ `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.
209
+
210
+ 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.
211
+
228
212
  ### Layout
229
213
 
230
214
  `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.
@@ -255,35 +239,13 @@ export default function DocsLayout({ children }: { children: ReactNode }) {
255
239
  | `title` | `ReactNode` | — | Brand, at the header start |
256
240
  | `actions` | `ReactNode` | — | Header end, after search |
257
241
  | `search` | `boolean \| DocsSearchProps` | `true` | The search trigger. An object configures the dialog |
258
- | `labels` | `DocsLabels` | English | The four strings the shell renders itself |
242
+ | `labels` | `DocsLabels` | the route's | Overrides `createDocsRoute`'s labels, key by key |
259
243
 
260
244
  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.
261
245
 
262
246
  `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.
263
247
 
264
- `labels` is the whole of what a site not in English has to say to the shell; everything else a reader sees is your markdown or your `title`.
265
-
266
- The same `app/docs/layout.tsx` as the quick start, written out instead of re-exported, because passing a prop needs a function:
267
-
268
- ```tsx
269
- import type { ReactNode } from 'react';
270
- import '@waveso/docs/styles.css';
271
- import { docs } from '@/lib/docs';
272
-
273
- export default function Layout(props: { children: ReactNode }) {
274
- return docs.Layout({
275
- ...props,
276
- labels: {
277
- nav: 'Documentação',
278
- openNav: 'Abrir navegação',
279
- closeNav: 'Fechar navegação',
280
- skipToContent: 'Ir para o conteúdo',
281
- },
282
- });
283
- }
284
- ```
285
-
286
- Each key falls back on its own, so a partial map is not a half-translated shell.
248
+ `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.
287
249
 
288
250
  #### The mobile drawer
289
251
 
@@ -653,6 +615,35 @@ The response carries `cache-control: public, max-age=0, must-revalidate` and a s
653
615
 
654
616
  If your site sets Next's own `basePath` config, prefix `indexUrl` yourself: Next applies it to `<Link>` and to navigation, but never to a client `fetch()`.
655
617
 
618
+ ### The dialog's props
619
+
620
+ `DocsSearch` takes everything `SearchDialog` does except `navigate` and `Link`, which the Next adapter wires. `docs.Layout`'s `search={{ … }}` takes all of it except `indexUrl`, which it derives.
621
+
622
+ | Prop | Type | Default | |
623
+ | --- | --- | --- | --- |
624
+ | `indexUrl` | `string` | — | Where the index is served. Pass `docs.searchIndexUrl` |
625
+ | `pageSize` | `number` | `20` | Results rendered at a time. **Not a cap** — another page loads as the reader nears the end |
626
+ | `minQueryLength` | `number` | `2` | Shortest query that runs |
627
+ | `debounceMs` | `number` | `120` | Input debounce |
628
+ | `className` | `string` | — | Extra classes for the trigger button |
629
+ | `triggerLabel` | `string` | `'Search'` | The trigger's text |
630
+ | `placeholder` | `string` | `'Search documentation'` | The input's placeholder |
631
+ | `dialogLabel` | `string` | `'Search documentation'` | The dialog's accessible name |
632
+ | `hintLabel` | `string` | `'Start typing to search the documentation.'` | Before anything is typed |
633
+ | `shortQueryLabel` | `string` | `'Keep typing — {min} characters or more.'` | Below `minQueryLength`. `{min}` is that number |
634
+ | `loadingLabel` | `string` | `'Loading the search index…'` | While the index is fetched |
635
+ | `errorLabel` | `string` | `'Search is unavailable right now. Try reloading the page.'` | When it cannot be |
636
+ | `emptyLabel` | `string` | `'No results for “{query}”.'` | No matches. `{query}` is what was typed |
637
+ | `resultCountLabels` | `Partial<Record<Intl.LDMLPluralRule, string>>` | `{ one: '{count} result', other: '{count} results' }` | The live region, by plural category |
638
+ | `locale` | `string` | `<html lang>`, then `'en'` | Language tag for those plural rules |
639
+ | `miniSearchOptions` | `Partial<Options<SearchRecord>>` | — | See [Tuning](#tuning) |
640
+
641
+ **`pageSize` replaced `maxResults` in 0.4.0**, and the meaning changed with the name: `maxResults` was a hard ceiling of 8 that made results unreachable on a six-page site, and the live region announced the slice as though it were the total. `pageSize` is a window — every match is reachable by scrolling, and the count announced is the real one.
642
+
643
+ `resultCountLabels` is keyed by plural category rather than being a singular and a plural, because most languages are not English: Polish takes four forms and Arabic six. `Intl.PluralRules` picks, and a category you do not list falls back to `other`.
644
+
645
+ There is no `hotkey` prop. The shortcut is ⌘K on Apple platforms and Ctrl-K elsewhere, and it is not configurable.
646
+
656
647
  ### What gets indexed
657
648
 
658
649
  **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.
@@ -692,6 +683,60 @@ export function Search() {
692
683
 
693
684
  `fuzzy`, `prefix`, `combineWith` and `boost` are MiniSearch *query* defaults, so they nest under `searchOptions`; `fields`, `storeFields`, `tokenize` and `processTerm` sit at the top level. The nesting is easy to get wrong and wrong is silent — a stray `fuzzy` at the top level is simply never read — so both examples above are type-checked in CI.
694
685
 
686
+ ### Functions need a client boundary
687
+
688
+ `tokenize` and `processTerm` are functions, and `docs.Layout` cannot hand a function to the dialog. The layout is a Server Component and the dialog is a Client Component, so props crossing between them are serialised — React refuses a function outright and `next build` fails while prerendering, with *"Functions cannot be passed directly to Client Components"*.
689
+
690
+ So `docs.Layout` forwards the serialisable half of `miniSearchOptions` — `fields`, `storeFields`, `boost`, and everything under `searchOptions` that is not a callback — and refuses the rest by name. It does not quietly drop them: an index built with a `processTerm` the query does not share matches nothing at all and says nothing, which is the exact failure the forwarding exists to prevent.
691
+
692
+ Function tuning means taking the boundary yourself, so the function is a module import on both sides rather than a prop between them:
693
+
694
+ ```ts
695
+ // lib/search-terms.ts — one function, imported by both halves
696
+ export function stripDashes(term: string): string {
697
+ return term.replace(/-/g, '');
698
+ }
699
+ ```
700
+
701
+ ```tsx
702
+ // components/docs-search.tsx
703
+ 'use client';
704
+
705
+ import { DocsSearch } from '@waveso/docs/react/next-search';
706
+ import { stripDashes } from '@/lib/search-terms';
707
+
708
+ export function DocsSearchTrigger({ indexUrl }: { indexUrl: string }) {
709
+ return (
710
+ <DocsSearch
711
+ indexUrl={indexUrl}
712
+ miniSearchOptions={{ processTerm: stripDashes }}
713
+ />
714
+ );
715
+ }
716
+ ```
717
+
718
+ 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`:
719
+
720
+ ```tsx
721
+ import '@waveso/docs/styles.css';
722
+ import type { ReactNode } from 'react';
723
+ import { DocsSearchTrigger } from '@/components/docs-search';
724
+ import { docs } from '@/lib/docs';
725
+
726
+ export default function DocsLayout({ children }: { children: ReactNode }) {
727
+ return (
728
+ <docs.Layout
729
+ search={false}
730
+ actions={<DocsSearchTrigger indexUrl={docs.searchIndexUrl} />}
731
+ >
732
+ {children}
733
+ </docs.Layout>
734
+ );
735
+ }
736
+ ```
737
+
738
+ `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.
739
+
695
740
  ### Building the index yourself
696
741
 
697
742
  Only if the route cannot express what you need — a second index per locale, say, or an artifact consumed by something other than the dialog:
@@ -746,26 +791,110 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
746
791
  contentDir: string; // relative paths resolve against process.cwd()
747
792
  basePath?: string; // default '/docs'; '/' normalises to ''
748
793
  includeDrafts?: boolean; // default false
749
- assertLinks?: boolean; // default true
794
+ onBrokenLinks?: DocsLinkSeverity; // default 'throw'
795
+ onBrokenAnchors?: DocsLinkSeverity; // default 'throw'
796
+ externalRoutes?: readonly string[]; // routes your app owns, not the docs
750
797
  frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter>;
751
798
  }
799
+
800
+ type DocsLinkSeverity = 'throw' | 'warn' | 'ignore';
752
801
  ```
753
802
 
754
- `createDocsRoute` additionally accepts:
803
+ ### Broken links
755
804
 
756
- | Option | Default | Purpose |
757
- | --- | --- | --- |
758
- | `langs` | 18 grammars | Typed `readonly DocsLang[]`, so a typo is a compile error |
759
- | `themes` | `github-light` / `github-dark` | Shiki theme pair |
760
- | `highlighter` | built-in | Supply your own for grammars outside the set |
761
- | `titleHeading` | `true` | Build an `<h1>` from `frontmatter.title` when the markdown has none |
762
- | `components` | built-in map | Override any element → component mapping |
763
- | `siteUrl` | | Makes canonical URLs absolute |
764
- | `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 |
805
+ **`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.
806
+
807
+ The error names the file, the line and the closest published route when the link looks like a typo of one:
808
+
809
+ ```
810
+ @waveso/docs: guide.md:12 links to './instalation.md', which resolves to
811
+ '/docs/instalation' no such page exists. Did you mean '/docs/installation'?
812
+ Fix the link, or add an `aliases` entry to the page it used to point at.
813
+ ```
814
+
815
+ 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.
816
+
817
+ ### Broken anchors
818
+
819
+ **`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.
820
+
821
+ ```
822
+ @waveso/docs: guide.md:12 links to '#instalation', and this page has no
823
+ '#instalation'. Did you mean 'installation'? Heading ids come from the heading
824
+ text, so renaming a heading renames its anchor.
825
+ ```
826
+
827
+ 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.
828
+
829
+ 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`.
830
+
831
+ ### Routes your application owns
832
+
833
+ **`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.
834
+
835
+ 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:
836
+
837
+ ```ts
838
+ // lib/docs-root.ts
839
+ import { createDocsRoute } from '@waveso/docs/next';
840
+
841
+ export const docs = createDocsRoute({
842
+ contentDir: 'content/docs',
843
+ basePath: '/',
844
+ externalRoutes: ['/login', '/dashboard', '/api/'],
845
+ });
846
+ ```
847
+
848
+ 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.
849
+
850
+ ### Translating the chrome
851
+
852
+ 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.
853
+
854
+ ```ts
855
+ // lib/docs-pt.ts
856
+ import { createDocsRoute } from '@waveso/docs/next';
765
857
 
766
- `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.
858
+ export const docs = createDocsRoute({
859
+ contentDir: 'content/docs',
860
+ labels: {
861
+ // The shell
862
+ nav: 'Documentação',
863
+ openNav: 'Abrir navegação',
864
+ closeNav: 'Fechar navegação',
865
+ skipToContent: 'Ir para o conteúdo',
866
+ // The navigation tree — `{title}` is the group's own name
867
+ expandGroup: 'Abrir {title}',
868
+ collapseGroup: 'Fechar {title}',
869
+ externalLink: '(abre num novo separador)',
870
+ // The table of contents
871
+ toc: 'Nesta página',
872
+ backToTop: 'Voltar ao topo',
873
+ // Your content
874
+ table: 'Tabela',
875
+ calloutNote: 'Nota',
876
+ calloutTip: 'Dica',
877
+ calloutImportant: 'Importante',
878
+ calloutWarning: 'Aviso',
879
+ calloutCaution: 'Atenção',
880
+ youtubeTitle: 'Vídeo do YouTube',
881
+ youtubePlay: 'Reproduzir: {title}',
882
+ youtubeHide: 'Esconder: {title}',
883
+ // Code frames
884
+ copyCode: 'Copiar código',
885
+ copyCodeFrom: 'Copiar código de {title}',
886
+ copied: 'Copiado para a área de transferência.',
887
+ copyFailed: 'Falhou. Selecione o código e prima Control ou Command + C.',
888
+ },
889
+ });
890
+ ```
891
+
892
+ Each key falls back on its own, so a partial map is not a half-translated site. `{title}` is a placeholder rather than a function, because three of these cross from a Server Component to a Client one — and because a translator has to be able to move the name within the sentence, which concatenation forbids.
767
893
 
768
- 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.
894
+ The search dialog's own strings are separate, and reachable through `search={{ }}` see [Search](#search).
895
+
896
+ > [!NOTE]
897
+ > These were hardcoded English until 0.5.0, under a `labels` prop on `docs.Layout` that documented itself as the whole of a site's translatable chrome and reached four strings of the twenty-two. A site built the documented way shipped `aria-label="On this page"`, a visible `Back to top`, `aria-label="Tip"` on every callout and `Copy code` on every fence, in English, whatever language it was written in.
769
898
 
770
899
  ### Redirects and sitemap
771
900
 
@@ -852,6 +981,7 @@ try {
852
981
  | `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. |
853
982
  | `draft-link` | A link points at a page that exists but is `draft: true`. | Publish the page, or drop the link until it ships. |
854
983
  | `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. |
984
+ | `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. |
855
985
  | `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`. |
856
986
  | `alias-collision` | Two pages claim one alias, or an alias shadows a real route. | Remove one of them; a redirect cannot have two destinations. |
857
987
  | `route-collision` | Two files resolve to the same route. | Usually `about.md` beside `about/index.md`. Keep one. |
@@ -860,6 +990,7 @@ try {
860
990
  | `invalid-config` | An option passed to this package cannot be used as given. | The message names the option. `siteUrl` must be an absolute origin with no path. |
861
991
  | `missing-content-dir` | `contentDir` does not point at a readable directory. | It resolves against `process.cwd()`, which is your project root under `next build`. |
862
992
  | `broken-symlink` | A markdown page is reachable only through a broken symbolic link. | Repoint or delete the link; skipping it would silently drop a route. |
993
+ | `descriptor-limit` | The process ran out of file descriptors while scanning the content directory. | Raise the limit — `ulimit -n`, or `LimitNOFILE` under systemd. The scan holds at most 64 open at once, so something else in the process has them. |
863
994
  | `invalid-image` | A relative image needs an `imageResolver`, or one returned an unusable shape. | Pass `imageResolver`, or use an absolute `/path` the browser can resolve. |
864
995
  | `unknown-theme` | A theme name outside the supported set. | Pass a `highlighter` of your own if you need a theme this package does not load. |
865
996
  | `unknown-language` | A fence language outside the loaded set. | Add it to `langs`, or accept the plain-text fallback. |
@@ -884,14 +1015,25 @@ ESM-only is forced rather than chosen: `unified` and the entire `remark-*` / `re
884
1015
  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`.
885
1016
 
886
1017
  > [!NOTE]
887
- > Under `exactOptionalPropertyTypes: true`, passing `next/link` straight into
888
- > `DocsSidebar` does not type-check. Next's `LinkProps` re-declares `onClick?`,
889
- > `onMouseEnter?` and `onTouchStart?` *without* `| undefined`, and React's
1018
+ > Passing `next/link` **straight** into `DocsSidebar` does not type-check under
1019
+ > `exactOptionalPropertyTypes: true`. Next's `LinkProps` re-declares `onClick?`,
1020
+ > `onMouseEnter?` and `onTouchStart?` *without* `| undefined` while React's
890
1021
  > anchor props include it, so the two declaration files disagree — about props
891
- > `next/link` accepts perfectly well at runtime. It is true of every
892
- > `next/link` call site in a project with that flag on, not just this one.
893
- > Cast at the call site (`Link={Link as DocsLinkComponent}`) until the
894
- > Next-wired navigation component ships.
1022
+ > `next/link` accepts perfectly well at runtime. It is true of every `next/link`
1023
+ > call site in a project with that flag on, not just this one.
1024
+ >
1025
+ > Import `DocsLink` instead of casting:
1026
+ >
1027
+ > ```tsx
1028
+ > 'use client';
1029
+ > import { DocsLink } from '@waveso/docs/react/next-link';
1030
+ > import { DocsSidebar } from '@waveso/docs/react/sidebar';
1031
+ >
1032
+ > <DocsSidebar nav={nav} pathname={pathname} Link={DocsLink} />
1033
+ > ```
1034
+ >
1035
+ > `docs.Layout` and `DocsSearch` have always used the same adapter internally,
1036
+ > so this only ever came up when composing a shell by hand.
895
1037
  >
896
1038
  > `docs.Page` and `DocsSearch` are both unaffected — each wraps `next/link`
897
1039
  > inside the package, which is where that cast belongs. Without the flag,
@@ -1005,7 +1147,7 @@ src/
1005
1147
  next.ts # The App Router adapter
1006
1148
  meta.ts # meta.json ordering
1007
1149
  plugins/ # remark/rehype plugins
1008
- react/ # Components. Import nothing from next/*
1150
+ react/ # Components. Only next-nav and next-search touch next/*
1009
1151
  styles.css # Theme tokens + prose styles
1010
1152
  ```
1011
1153
 
@@ -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. */
@@ -63,6 +65,8 @@ type DocsErrorCode =
63
65
  'missing-content-dir' |
64
66
  /** A markdown page is reachable only through a broken symbolic link. */
65
67
  'broken-symlink' |
68
+ /** The process ran out of file descriptors while scanning the content. */
69
+ 'descriptor-limit' |
66
70
  /** An `imageResolver` returned an unusable shape, threw, or was needed. */
67
71
  'invalid-image' |
68
72
  /** A theme name outside the supported set. */
@@ -165,7 +165,8 @@ function createDocsHighlighter(options = {}) {
165
165
  }
166
166
  const key = JSON.stringify({
167
167
  langs,
168
- themes
168
+ light: themes.light,
169
+ dark: themes.dark
169
170
  });
170
171
  const cached = highlighters.get(key);
171
172
  if (cached) return cached;
@@ -0,0 +1,31 @@
1
+ //#region src/link-suggestion.d.ts
2
+ /**
3
+ * "Did you mean …?" for a link that matched no page.
4
+ *
5
+ * Private — deliberately not an entry point.
6
+ *
7
+ * A broken link is almost always a typo, and a typo is a near-miss by
8
+ * construction: `/instalation` is one edit from `/installation`, while `/login`
9
+ * is six from anything in a docs tree. That gap is what makes a suggestion
10
+ * safe to offer and safe to withhold — the same reason `git`, `tsc`, `cargo`
11
+ * and Python 3.12 all do it, and the reason none of them offers one for a word
12
+ * that is nowhere near a real name.
13
+ *
14
+ * ⚠️ A SUGGESTION ONLY, NEVER A DECISION. Nothing here decides whether a link
15
+ * is an error; `render.ts` has already decided that by the time it asks. Using
16
+ * an edit distance to pick between failing and staying silent would be a
17
+ * heuristic holding a build hostage, which is not a thing to do to somebody
18
+ * whose page happens to be called `/setting`.
19
+ */
20
+ /**
21
+ * The closest route to `target`, or `undefined` if nothing is close enough.
22
+ *
23
+ * Ties break on the shortest candidate and then alphabetically, so the message
24
+ * is the same on every machine — a suggestion that changes between runs reads
25
+ * as a flaky build.
26
+ */
27
+ declare function suggestRoute(target: string, routes: Iterable<string>): string | undefined;
28
+ /** ` Did you mean '/installation'?`, or `''` when nothing is close. */
29
+ declare function describeSuggestion(target: string, routes: Iterable<string> | undefined): string;
30
+ //#endregion
31
+ export { describeSuggestion, suggestRoute };