@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.
- package/CHANGELOG.md +270 -0
- package/README.md +221 -79
- package/dist/anchors.d.ts +44 -0
- package/dist/anchors.js +76 -0
- package/dist/errors.d.ts +4 -0
- package/dist/highlighter.js +2 -1
- package/dist/link-suggestion.d.ts +31 -0
- package/dist/link-suggestion.js +94 -0
- package/dist/meta.js +6 -9
- package/dist/next.d.ts +54 -14
- package/dist/next.js +135 -20
- package/dist/plugins/rehype-code-frame.d.ts +13 -1
- package/dist/plugins/rehype-code-frame.js +2 -1
- package/dist/plugins/rehype-fallback-heading-ids.js +1 -1
- package/dist/plugins/remark-doc-links.d.ts +83 -1
- package/dist/plugins/remark-doc-links.js +50 -23
- package/dist/plugins/remark-youtube.d.ts +18 -3
- package/dist/plugins/remark-youtube.js +57 -9
- package/dist/react/callout.d.ts +13 -1
- package/dist/react/callout.js +2 -2
- package/dist/react/code-runtime.d.ts +12 -2
- package/dist/react/code-runtime.js +28 -4
- package/dist/react/doc-content.d.ts +12 -1
- package/dist/react/doc-content.js +2 -2
- package/dist/react/layout.d.ts +27 -10
- package/dist/react/layout.js +6 -3
- package/dist/react/link-adapter.d.ts +34 -0
- package/dist/react/link-adapter.js +30 -0
- package/dist/react/markdown-components.d.ts +29 -1
- package/dist/react/markdown-components.js +69 -67
- package/dist/react/nav.d.ts +5 -1
- package/dist/react/nav.js +5 -2
- package/dist/react/next-link.d.ts +6 -28
- package/dist/react/next-link.js +45 -24
- package/dist/react/next-nav.d.ts +5 -1
- package/dist/react/next-nav.js +6 -3
- package/dist/react/next-search.js +1 -1
- package/dist/react/search-dialog.d.ts +59 -3
- package/dist/react/search-dialog.js +53 -9
- package/dist/react/shell-labels.d.ts +135 -21
- package/dist/react/shell-labels.js +47 -6
- package/dist/react/sidebar.d.ts +18 -1
- package/dist/react/sidebar.js +59 -23
- package/dist/react/youtube.d.ts +22 -1
- package/dist/react/youtube.js +22 -4
- package/dist/render.d.ts +12 -1
- package/dist/render.js +107 -21
- package/dist/route-path.js +7 -2
- package/dist/safe-href.d.ts +47 -0
- package/dist/safe-href.js +73 -0
- package/dist/search-index.js +1 -1
- package/dist/search-options.d.ts +64 -2
- package/dist/search-options.js +25 -1
- package/dist/semaphore.d.ts +46 -0
- package/dist/semaphore.js +60 -0
- package/dist/source.js +86 -12
- package/dist/types.d.ts +102 -6
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -13,32 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
<br />
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
94
|
-
| Search dialog and router wiring | 9.
|
|
95
|
-
| Navigation: sidebar and mobile drawer | 2.
|
|
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 |
|
|
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
|
|
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` |
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
803
|
+
### Broken links
|
|
755
804
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
>
|
|
888
|
-
> `
|
|
889
|
-
> `onMouseEnter?` and `onTouchStart?` *without* `| undefined
|
|
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
|
-
>
|
|
893
|
-
>
|
|
894
|
-
>
|
|
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.
|
|
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 };
|
package/dist/anchors.js
ADDED
|
@@ -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. */
|
package/dist/highlighter.js
CHANGED
|
@@ -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 };
|