@waveso/docs 0.5.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 +97 -0
- package/README.md +74 -53
- package/dist/anchors.d.ts +44 -0
- package/dist/anchors.js +76 -0
- package/dist/errors.d.ts +2 -0
- package/dist/link-suggestion.d.ts +31 -0
- package/dist/link-suggestion.js +94 -0
- package/dist/next.js +20 -2
- package/dist/plugins/rehype-fallback-heading-ids.js +1 -1
- package/dist/plugins/remark-doc-links.d.ts +33 -1
- package/dist/plugins/remark-doc-links.js +24 -8
- package/dist/react/link-adapter.d.ts +34 -0
- package/dist/react/link-adapter.js +30 -0
- package/dist/react/next-link.d.ts +6 -28
- package/dist/react/next-link.js +45 -24
- package/dist/react/next-nav.js +1 -1
- package/dist/react/next-search.js +1 -1
- package/dist/render.d.ts +1 -1
- package/dist/render.js +69 -10
- package/dist/source.js +6 -2
- package/dist/types.d.ts +72 -6
- package/package.json +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,102 @@
|
|
|
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
|
+
|
|
3
100
|
## 0.5.0
|
|
4
101
|
|
|
5
102
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -13,38 +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. It is a LOCAL
|
|
26
|
-
command and CI deliberately does not run `pnpm shoot --check`: these PNGs are
|
|
27
|
-
compared byte for byte, and bytes do not survive a change of operating system
|
|
28
|
-
— the font stack resolves to SF Pro on the machine that shot them and to
|
|
29
|
-
DejaVu on a Linux runner, so every pixel of text differs and no tolerance
|
|
30
|
-
rescues glyphs that are different shapes. The regression that gate was meant
|
|
31
|
-
to catch — a stylesheet change reflowing the shell — is covered by the browser
|
|
32
|
-
tier, which asserts geometry rather than pixels in the same Chromium
|
|
33
|
-
everywhere. `scripts/shoot.ts` says the same at greater length.
|
|
34
|
-
-->
|
|
35
|
-
<picture>
|
|
36
|
-
<source
|
|
37
|
-
media="(prefers-color-scheme: dark)"
|
|
38
|
-
srcset="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/hero-dark.png"
|
|
39
|
-
/>
|
|
40
|
-
<img
|
|
41
|
-
src="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/hero-light.png"
|
|
42
|
-
alt="A documentation page rendered by @waveso/docs: a navigation sidebar, prose with syntax-highlighted code frames, and a table of contents."
|
|
43
|
-
width="100%"
|
|
44
|
-
/>
|
|
45
|
-
</picture>
|
|
46
|
-
|
|
47
|
-
<p align="center"><em>The default page, with no CSS of your own. <a href="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/search.png">Search dialog →</a></em></p>
|
|
16
|
+
<p align="center">
|
|
17
|
+
<strong><a href="https://docs.wave.so">docs.wave.so</a></strong> — the documentation for this package, built with this package.
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<p align="center"><em>Every page you see there is markdown in <code>site/content/</code>, rendered by <code>docs.Layout</code> with no layout CSS of its own. It is the acceptance harness and the showcase, and it is the same build CI runs on every commit — so it cannot drift from what this README claims.</em></p>
|
|
48
21
|
|
|
49
22
|
---
|
|
50
23
|
|
|
@@ -225,6 +198,7 @@ Every component takes data as props, and two modules in `src/react/` import from
|
|
|
225
198
|
| `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
|
|
226
199
|
| `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver`. `label`, `topLabel`, `rootMargin`, `className` |
|
|
227
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 |
|
|
228
202
|
| `SearchDialog` | `react/search-dialog` | ⌘K, arrow keys, focus trap. Host-agnostic |
|
|
229
203
|
| `Callout` | `react/callout` | Note · tip · important · warning · caution. `CALLOUT_TYPES` is the list |
|
|
230
204
|
| `YouTube` | `react/youtube` | Click-to-load facade. `title`, `playLabel`, `hideLabel` — `{title}` interpolates the first |
|
|
@@ -233,7 +207,7 @@ Every component takes data as props, and two modules in `src/react/` import from
|
|
|
233
207
|
|
|
234
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.
|
|
235
209
|
|
|
236
|
-
The two components the adapter injects take a little more than an `<a>` and an `<img>`. `DocsLinkProps` adds `prefetch` — passed straight to `next/link`, where `false` disables the hover and viewport paths both, so it is a stronger switch in the App Router than the name suggests. `DocsImageProps` adds `sizes`, `loading`, `decoding` and `fetchPriority`, forwarded to
|
|
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.
|
|
237
211
|
|
|
238
212
|
### Layout
|
|
239
213
|
|
|
@@ -817,26 +791,61 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
|
|
|
817
791
|
contentDir: string; // relative paths resolve against process.cwd()
|
|
818
792
|
basePath?: string; // default '/docs'; '/' normalises to ''
|
|
819
793
|
includeDrafts?: boolean; // default false
|
|
820
|
-
|
|
794
|
+
onBrokenLinks?: DocsLinkSeverity; // default 'throw'
|
|
795
|
+
onBrokenAnchors?: DocsLinkSeverity; // default 'throw'
|
|
796
|
+
externalRoutes?: readonly string[]; // routes your app owns, not the docs
|
|
821
797
|
frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter>;
|
|
822
798
|
}
|
|
799
|
+
|
|
800
|
+
type DocsLinkSeverity = 'throw' | 'warn' | 'ignore';
|
|
823
801
|
```
|
|
824
802
|
|
|
825
|
-
|
|
803
|
+
### Broken links
|
|
826
804
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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`.
|
|
836
830
|
|
|
837
|
-
|
|
831
|
+
### Routes your application owns
|
|
838
832
|
|
|
839
|
-
|
|
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.
|
|
840
849
|
|
|
841
850
|
### Translating the chrome
|
|
842
851
|
|
|
@@ -972,6 +981,7 @@ try {
|
|
|
972
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. |
|
|
973
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. |
|
|
974
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. |
|
|
975
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`. |
|
|
976
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. |
|
|
977
987
|
| `route-collision` | Two files resolve to the same route. | Usually `about.md` beside `about/index.md`. Keep one. |
|
|
@@ -1005,14 +1015,25 @@ ESM-only is forced rather than chosen: `unified` and the entire `remark-*` / `re
|
|
|
1005
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`.
|
|
1006
1016
|
|
|
1007
1017
|
> [!NOTE]
|
|
1008
|
-
>
|
|
1009
|
-
> `
|
|
1010
|
-
> `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
|
|
1011
1021
|
> anchor props include it, so the two declaration files disagree — about props
|
|
1012
|
-
> `next/link` accepts perfectly well at runtime. It is true of every
|
|
1013
|
-
>
|
|
1014
|
-
>
|
|
1015
|
-
>
|
|
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.
|
|
1016
1037
|
>
|
|
1017
1038
|
> `docs.Page` and `DocsSearch` are both unaffected — each wraps `next/link`
|
|
1018
1039
|
> inside the package, which is where that cast belongs. Without the flag,
|
|
@@ -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. */
|
|
@@ -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 };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
//#region src/link-suggestion.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 ceiling on how far apart two routes may be and still be called a typo.
|
|
22
|
+
*
|
|
23
|
+
* Three is roughly one slip per word in a two-word slug — `instalation`,
|
|
24
|
+
* `gettting-started`, `plugns`. It is a *ceiling*, not the whole rule: the
|
|
25
|
+
* budget is also scaled by the length of what was written, so short routes are
|
|
26
|
+
* held tighter and `/api` is not offered as a fix for `/ui`.
|
|
27
|
+
*
|
|
28
|
+
* Between them, `/docs/instructions` gets no suggestion for
|
|
29
|
+
* `/docs/installation` — five edits apart, similar enough to tempt a generous
|
|
30
|
+
* threshold, and a different word. `render.test.ts` pins that case, because
|
|
31
|
+
* without it this constant could be any number at all.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_DISTANCE = 3;
|
|
34
|
+
/**
|
|
35
|
+
* Levenshtein distance, bounded.
|
|
36
|
+
*
|
|
37
|
+
* Two rows rather than a full matrix: the corpus is every route on the site and
|
|
38
|
+
* this runs per broken link, so the allocation is the only part worth caring
|
|
39
|
+
* about. Returns early once every cell in a row exceeds `limit`, which is the
|
|
40
|
+
* common case — most candidates are nowhere near.
|
|
41
|
+
*/
|
|
42
|
+
function distance(a, b, limit) {
|
|
43
|
+
if (a === b) return 0;
|
|
44
|
+
if (Math.abs(a.length - b.length) > limit) return limit + 1;
|
|
45
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
46
|
+
let current = new Array(b.length + 1);
|
|
47
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
48
|
+
current[0] = i;
|
|
49
|
+
let best = i;
|
|
50
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
51
|
+
const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
|
|
52
|
+
const deletion = previous[j] + 1;
|
|
53
|
+
const insertion = current[j - 1] + 1;
|
|
54
|
+
const cell = Math.min(substitution, deletion, insertion);
|
|
55
|
+
current[j] = cell;
|
|
56
|
+
if (cell < best) best = cell;
|
|
57
|
+
}
|
|
58
|
+
if (best > limit) return limit + 1;
|
|
59
|
+
const swap = previous;
|
|
60
|
+
previous = current;
|
|
61
|
+
current = swap;
|
|
62
|
+
}
|
|
63
|
+
return previous[b.length];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The closest route to `target`, or `undefined` if nothing is close enough.
|
|
67
|
+
*
|
|
68
|
+
* Ties break on the shortest candidate and then alphabetically, so the message
|
|
69
|
+
* is the same on every machine — a suggestion that changes between runs reads
|
|
70
|
+
* as a flaky build.
|
|
71
|
+
*/
|
|
72
|
+
function suggestRoute(target, routes) {
|
|
73
|
+
const limit = Math.min(MAX_DISTANCE, Math.max(1, Math.floor(target.length / 3)));
|
|
74
|
+
let best;
|
|
75
|
+
let bestDistance = limit + 1;
|
|
76
|
+
for (const route of routes) {
|
|
77
|
+
if (route === target) continue;
|
|
78
|
+
const measured = distance(target, route, limit);
|
|
79
|
+
if (measured > limit) continue;
|
|
80
|
+
if (measured < bestDistance || measured === bestDistance && best !== void 0 && (route.length < best.length || route.length === best.length && route < best)) {
|
|
81
|
+
best = route;
|
|
82
|
+
bestDistance = measured;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return best;
|
|
86
|
+
}
|
|
87
|
+
/** ` Did you mean '/installation'?`, or `''` when nothing is close. */
|
|
88
|
+
function describeSuggestion(target, routes) {
|
|
89
|
+
if (routes === void 0) return "";
|
|
90
|
+
const suggestion = suggestRoute(target, routes);
|
|
91
|
+
return suggestion === void 0 ? "" : ` Did you mean '${suggestion}'?`;
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
export { describeSuggestion, suggestRoute };
|
package/dist/next.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { assertAnchors } from "./anchors.js";
|
|
1
2
|
import { docsError } from "./docs-error.js";
|
|
2
3
|
import { DOCS_CONTENT_ID } from "./docs-content-id.js";
|
|
4
|
+
import { describeSuggestion } from "./link-suggestion.js";
|
|
3
5
|
import { mapPooled } from "./map-pooled.js";
|
|
4
6
|
import { findFunctionValuedOptions } from "./search-options.js";
|
|
5
7
|
import { createMarkdownComponents } from "./react/markdown-components.js";
|
|
6
8
|
import { DocContent } from "./react/doc-content.js";
|
|
7
9
|
import { DocsToc } from "./react/toc.js";
|
|
8
|
-
import { wrapNextLink } from "./react/
|
|
10
|
+
import { wrapNextLink } from "./react/link-adapter.js";
|
|
9
11
|
import { createDocsRenderer } from "./render.js";
|
|
10
12
|
import { toAliasRoute } from "./route-path.js";
|
|
11
13
|
import { createDocsSource, resolveDocsConfig } from "./source.js";
|
|
@@ -374,7 +376,23 @@ function createDocsRoute(options) {
|
|
|
374
376
|
const files = await source.all();
|
|
375
377
|
await loadRoutes();
|
|
376
378
|
const renderer = loadRenderer();
|
|
377
|
-
|
|
379
|
+
const rendered = await mapPooled(files, RENDER_CONCURRENCY, (file) => renderer.render(file));
|
|
380
|
+
assertAnchors(rendered, (from, link, known) => {
|
|
381
|
+
reportAnchor(`@waveso/docs: ${from} links to '${link.href}', and '${link.route}' has no '#${link.fragment}'.${describeSuggestion(link.fragment, known)} Heading ids come from the heading text, so renaming a heading renames its anchor.`);
|
|
382
|
+
});
|
|
383
|
+
return rendered;
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* A cross-page anchor failure, at the configured severity.
|
|
387
|
+
*
|
|
388
|
+
* No line number, unlike the same-page check: positions are stripped from a
|
|
389
|
+
* returned tree, so the page and the link are what there is to name. Both
|
|
390
|
+
* halves share `onBrokenAnchors`, because to an author they are one mistake.
|
|
391
|
+
*/
|
|
392
|
+
const reportAnchor = (message) => {
|
|
393
|
+
if (config.onBrokenAnchors === "ignore") return;
|
|
394
|
+
if (config.onBrokenAnchors === "throw") throw docsError("broken-anchor", message);
|
|
395
|
+
console.warn(message);
|
|
378
396
|
};
|
|
379
397
|
const searchIndexUrl = `${config.basePath}/search-index.json`;
|
|
380
398
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { visit } from "unist-util-visit";
|
|
1
2
|
import rehypeSlug from "rehype-slug";
|
|
2
3
|
import { unified } from "unified";
|
|
3
|
-
import { visit } from "unist-util-visit";
|
|
4
4
|
//#region src/plugins/rehype-fallback-heading-ids.ts
|
|
5
5
|
const HEADING = /^h[1-6]$/;
|
|
6
6
|
/** `-1`, `-2` … — what `github-slugger` returns for a repeated empty slug. */
|
|
@@ -26,6 +26,36 @@ interface DocLinkRef {
|
|
|
26
26
|
* never be a member of.
|
|
27
27
|
*/
|
|
28
28
|
asset?: true;
|
|
29
|
+
/**
|
|
30
|
+
* An absolute link at a root mount, which cannot be proved to be ours.
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ RECORDED RATHER THAN SKIPPED, WHICH IS THE CHANGE. Under
|
|
33
|
+
* `basePath: '/docs'` an absolute link either starts with `/docs` — so it is
|
|
34
|
+
* a documentation route and is checked — or it does not, and belongs to the
|
|
35
|
+
* host's application. Under `basePath: '/'` that test cannot be made:
|
|
36
|
+
* `/setup` may be a page here and `/login` almost certainly is not, and
|
|
37
|
+
* nothing in the markdown says which.
|
|
38
|
+
*
|
|
39
|
+
* So it is collected and marked, and checked like any other link — because a
|
|
40
|
+
* root mount is what you choose when the origin serves documentation and
|
|
41
|
+
* nothing else, which makes an unknown absolute link a typo. An origin that
|
|
42
|
+
* serves something else names what is its own through
|
|
43
|
+
* `DocsConfig.externalRoutes`.
|
|
44
|
+
*/
|
|
45
|
+
unverifiable?: true;
|
|
46
|
+
/**
|
|
47
|
+
* A bare `#fragment` — a link into the page it is written on.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ RECORDED SO THE ANCHOR CAN BE CHECKED, AND FLAGGED SO THE ROUTE IS NOT.
|
|
50
|
+
* `isRelativeLink` excludes these and always did, correctly: there is no
|
|
51
|
+
* route to resolve. But that also meant they were never collected, so nothing
|
|
52
|
+
* downstream could see `#missing` at all — and a same-page anchor is the one
|
|
53
|
+
* a writer produces most, every "see below".
|
|
54
|
+
*
|
|
55
|
+
* `assertLinks` skips these; `assertOwnAnchors` is what reads them, and the
|
|
56
|
+
* recorded line is why the message can name one.
|
|
57
|
+
*/
|
|
58
|
+
anchorOnly?: true;
|
|
29
59
|
}
|
|
30
60
|
declare module 'vfile' {
|
|
31
61
|
interface DataMap {
|
|
@@ -41,6 +71,8 @@ interface RemarkDocLinksOptions {
|
|
|
41
71
|
/** Overrides the built-in resolution entirely, for every relative link. */
|
|
42
72
|
resolve?: LinkResolver;
|
|
43
73
|
}
|
|
74
|
+
/** No prefix at all — the docs own the whole origin. */
|
|
75
|
+
declare function isRootMount(basePath: string): boolean;
|
|
44
76
|
/**
|
|
45
77
|
* Fold `.` and `..` against a starting directory.
|
|
46
78
|
*
|
|
@@ -121,4 +153,4 @@ declare function resolveMarkdownLink(href: string, fromDir: readonly string[], b
|
|
|
121
153
|
*/
|
|
122
154
|
declare const remarkDocLinks: Plugin<[RemarkDocLinksOptions], Root>;
|
|
123
155
|
//#endregion
|
|
124
|
-
export { type DocLinkContext, DocLinkRef, HrefParts, RemarkDocLinksOptions, decodePath, foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref };
|
|
156
|
+
export { type DocLinkContext, DocLinkRef, HrefParts, RemarkDocLinksOptions, decodePath, foldSegments, isRootMount, remarkDocLinks, resolveMarkdownLink, splitHref };
|
|
@@ -20,6 +20,10 @@ const FILE_EXTENSION = /\.[^./]+$/;
|
|
|
20
20
|
function isRelativeLink(href) {
|
|
21
21
|
return href !== "" && !href.startsWith("#") && !href.startsWith("?") && !href.startsWith("/") && !HAS_SCHEME.test(href);
|
|
22
22
|
}
|
|
23
|
+
/** No prefix at all — the docs own the whole origin. */
|
|
24
|
+
function isRootMount(basePath) {
|
|
25
|
+
return basePath.replace(/\/+$/, "") === "";
|
|
26
|
+
}
|
|
23
27
|
/**
|
|
24
28
|
* Is this already-absolute href one of OUR routes?
|
|
25
29
|
*
|
|
@@ -28,9 +32,11 @@ function isRelativeLink(href) {
|
|
|
28
32
|
* A typo in a hand-written absolute link is exactly as likely as one in a
|
|
29
33
|
* relative link; only the rewriting differs.
|
|
30
34
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
35
|
+
* Answers `false` at a root mount, where there is no prefix to test against.
|
|
36
|
+
* That is not the end of the matter: the caller records those links anyway,
|
|
37
|
+
* marked, because at a root mount the *common* case is an origin that serves
|
|
38
|
+
* documentation and nothing else — so they are checked, and a site with other
|
|
39
|
+
* routes names them through `externalRoutes`.
|
|
34
40
|
*/
|
|
35
41
|
function isInternalAbsoluteLink(href, basePath) {
|
|
36
42
|
const base = basePath.replace(/\/+$/, "");
|
|
@@ -224,23 +230,33 @@ const remarkDocLinks = (options) => {
|
|
|
224
230
|
const raw = node.url;
|
|
225
231
|
if (node.type === "definition" && imageIdentifiers.has(node.identifier)) continue;
|
|
226
232
|
const line = node.position?.start.line;
|
|
227
|
-
const record = (href,
|
|
233
|
+
const record = (href, flags = {}) => {
|
|
228
234
|
const ref = {
|
|
229
235
|
raw,
|
|
230
236
|
href
|
|
231
237
|
};
|
|
232
238
|
if (line !== void 0) ref.line = line;
|
|
233
|
-
if (asset !== void 0) ref.asset = asset;
|
|
239
|
+
if (flags.asset !== void 0) ref.asset = flags.asset;
|
|
240
|
+
if (flags.unverifiable !== void 0) ref.unverifiable = flags.unverifiable;
|
|
241
|
+
if (flags.anchorOnly !== void 0) ref.anchorOnly = flags.anchorOnly;
|
|
234
242
|
refs.push(ref);
|
|
235
243
|
if (href !== void 0) node.url = href;
|
|
236
244
|
};
|
|
237
245
|
try {
|
|
246
|
+
if (raw.startsWith("#")) {
|
|
247
|
+
record(raw, { anchorOnly: true });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
238
250
|
if (!isRelativeLink(raw)) {
|
|
239
|
-
if (isInternalAbsoluteLink(raw, basePath) && !isAssetLink(raw))
|
|
251
|
+
if (isInternalAbsoluteLink(raw, basePath) && !isAssetLink(raw)) {
|
|
252
|
+
record(normalizeInternalRoute(raw, basePath));
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (isRootMount(basePath) && raw.startsWith("/") && !raw.startsWith("//") && !isAssetLink(raw)) record(normalizeInternalRoute(raw, basePath), { unverifiable: true });
|
|
240
256
|
continue;
|
|
241
257
|
}
|
|
242
258
|
if (resolve === void 0 && isAssetLink(raw)) {
|
|
243
|
-
record(resolveAssetLink(raw, context.dirSegments, basePath), true);
|
|
259
|
+
record(resolveAssetLink(raw, context.dirSegments, basePath), { asset: true });
|
|
244
260
|
continue;
|
|
245
261
|
}
|
|
246
262
|
if (resolve) {
|
|
@@ -257,4 +273,4 @@ const remarkDocLinks = (options) => {
|
|
|
257
273
|
};
|
|
258
274
|
};
|
|
259
275
|
//#endregion
|
|
260
|
-
export { decodePath, foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref };
|
|
276
|
+
export { decodePath, foldSegments, isRootMount, remarkDocLinks, resolveMarkdownLink, splitHref };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { DocsLinkComponent } from "./markdown-components.js";
|
|
2
|
+
import { ComponentProps, ComponentType } from "react";
|
|
3
|
+
//#region src/react/link-adapter.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The part of `next/link` this package uses.
|
|
6
|
+
*
|
|
7
|
+
* Declared structurally rather than imported: `next` is an optional peer, and
|
|
8
|
+
* a type-only import of it would still be a hard resolution requirement for
|
|
9
|
+
* anyone type-checking against our `.d.ts`.
|
|
10
|
+
*/
|
|
11
|
+
type NextLinkComponent = ComponentType<Omit<ComponentProps<'a'>, 'href' | 'ref'> & {
|
|
12
|
+
href: string;
|
|
13
|
+
prefetch?: boolean | null;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* Adapt `next/link` to {@link DocsLinkProps}.
|
|
17
|
+
*
|
|
18
|
+
* `next/link` widens `href` to `string | UrlObject` and `prefetch` to
|
|
19
|
+
* `boolean | 'auto' | null`; the React layer promises neither, because it must
|
|
20
|
+
* also run with a plain `<a>`. One wrapper keeps that mismatch in a single
|
|
21
|
+
* place instead of at every call site.
|
|
22
|
+
*
|
|
23
|
+
* `prefetch` is omitted rather than passed as `undefined`, which is not
|
|
24
|
+
* pedantry: under `exactOptionalPropertyTypes` — which this package compiles
|
|
25
|
+
* with, and which any consumer may turn on — `undefined` is not assignable to
|
|
26
|
+
* `boolean | 'auto' | null`, and `<SearchDialog Link={Link} />` written by
|
|
27
|
+
* hand fails to compile for a reason that reads as our bug.
|
|
28
|
+
*
|
|
29
|
+
* Call it once at module scope, never during a render: a fresh component
|
|
30
|
+
* identity for `a` remounts every link in the document on every render.
|
|
31
|
+
*/
|
|
32
|
+
declare function wrapNextLink(NextLink: NextLinkComponent): DocsLinkComponent;
|
|
33
|
+
//#endregion
|
|
34
|
+
export { NextLinkComponent, wrapNextLink };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
//#region src/react/link-adapter.ts
|
|
3
|
+
/**
|
|
4
|
+
* Adapt `next/link` to {@link DocsLinkProps}.
|
|
5
|
+
*
|
|
6
|
+
* `next/link` widens `href` to `string | UrlObject` and `prefetch` to
|
|
7
|
+
* `boolean | 'auto' | null`; the React layer promises neither, because it must
|
|
8
|
+
* also run with a plain `<a>`. One wrapper keeps that mismatch in a single
|
|
9
|
+
* place instead of at every call site.
|
|
10
|
+
*
|
|
11
|
+
* `prefetch` is omitted rather than passed as `undefined`, which is not
|
|
12
|
+
* pedantry: under `exactOptionalPropertyTypes` — which this package compiles
|
|
13
|
+
* with, and which any consumer may turn on — `undefined` is not assignable to
|
|
14
|
+
* `boolean | 'auto' | null`, and `<SearchDialog Link={Link} />` written by
|
|
15
|
+
* hand fails to compile for a reason that reads as our bug.
|
|
16
|
+
*
|
|
17
|
+
* Call it once at module scope, never during a render: a fresh component
|
|
18
|
+
* identity for `a` remounts every link in the document on every render.
|
|
19
|
+
*/
|
|
20
|
+
function wrapNextLink(NextLink) {
|
|
21
|
+
return function DocsNextLink({ href, prefetch, children, ...rest }) {
|
|
22
|
+
return createElement(NextLink, {
|
|
23
|
+
...rest,
|
|
24
|
+
href,
|
|
25
|
+
...prefetch === void 0 ? {} : { prefetch }
|
|
26
|
+
}, children);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { wrapNextLink };
|
|
@@ -1,34 +1,12 @@
|
|
|
1
1
|
import { DocsLinkComponent } from "./markdown-components.js";
|
|
2
|
-
import { ComponentProps, ComponentType } from "react";
|
|
3
2
|
//#region src/react/next-link.d.ts
|
|
4
3
|
/**
|
|
5
|
-
*
|
|
4
|
+
* Module scope, and never inside a render.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* A fresh component identity for a link remounts every link in the document on
|
|
7
|
+
* every render — which is why `next-search.tsx` and `next-nav.tsx` each build
|
|
8
|
+
* theirs at module scope too, and why this is a constant rather than a factory.
|
|
10
9
|
*/
|
|
11
|
-
|
|
12
|
-
href: string;
|
|
13
|
-
prefetch?: boolean | null;
|
|
14
|
-
}>;
|
|
15
|
-
/**
|
|
16
|
-
* Adapt `next/link` to {@link DocsLinkProps}.
|
|
17
|
-
*
|
|
18
|
-
* `next/link` widens `href` to `string | UrlObject` and `prefetch` to
|
|
19
|
-
* `boolean | 'auto' | null`; the React layer promises neither, because it must
|
|
20
|
-
* also run with a plain `<a>`. One wrapper keeps that mismatch in a single
|
|
21
|
-
* place instead of at every call site.
|
|
22
|
-
*
|
|
23
|
-
* `prefetch` is omitted rather than passed as `undefined`, which is not
|
|
24
|
-
* pedantry: under `exactOptionalPropertyTypes` — which this package compiles
|
|
25
|
-
* with, and which any consumer may turn on — `undefined` is not assignable to
|
|
26
|
-
* `boolean | 'auto' | null`, and `<SearchDialog Link={Link} />` written by
|
|
27
|
-
* hand fails to compile for a reason that reads as our bug.
|
|
28
|
-
*
|
|
29
|
-
* Call it once at module scope, never during a render: a fresh component
|
|
30
|
-
* identity for `a` remounts every link in the document on every render.
|
|
31
|
-
*/
|
|
32
|
-
declare function wrapNextLink(NextLink: NextLinkComponent): DocsLinkComponent;
|
|
10
|
+
declare const DocsLink: DocsLinkComponent;
|
|
33
11
|
//#endregion
|
|
34
|
-
export {
|
|
12
|
+
export { DocsLink };
|
package/dist/react/next-link.js
CHANGED
|
@@ -1,30 +1,51 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
import { wrapNextLink } from "./link-adapter.js";
|
|
3
|
+
import NextLink from "next/link";
|
|
4
|
+
//#region src/react/next-link.tsx
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
6
|
+
* `next/link`, adapted to this package's {@link DocsLinkProps} and ready to pass.
|
|
5
7
|
*
|
|
6
|
-
* `next/link`
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* ⚠️ IT EXISTS BECAUSE PASSING `next/link` DIRECTLY DOES NOT TYPE-CHECK. Under
|
|
9
|
+
* `exactOptionalPropertyTypes` — which this package compiles with, and which any
|
|
10
|
+
* consumer may turn on — Next's `LinkProps` re-declares `onClick?`,
|
|
11
|
+
* `onMouseEnter?` and `onTouchStart?` *without* `| undefined` while React's
|
|
12
|
+
* anchor props include it, so `<DocsSidebar Link={Link} />` fails to compile
|
|
13
|
+
* over three handlers `next/link` accepts perfectly well at run time. It is a
|
|
14
|
+
* disagreement between two dependencies' declaration files, true of every
|
|
15
|
+
* `next/link` call site in a project with that flag on, and nothing the shape of
|
|
16
|
+
* `DocsLinkProps` can fix without breaking the plain-`<a>` fallback that makes
|
|
17
|
+
* these components host-agnostic.
|
|
10
18
|
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* hand fails to compile for a reason that reads as our bug.
|
|
19
|
+
* `docs.Layout` and `DocsSearch` have always absorbed it internally, so it only
|
|
20
|
+
* ever bit someone composing a shell by hand — who was told to write
|
|
21
|
+
* `Link={Link as DocsLinkComponent}` and wait for this module. This is it; the
|
|
22
|
+
* cast is retired.
|
|
16
23
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
24
|
+
* ```tsx
|
|
25
|
+
* 'use client';
|
|
26
|
+
* import { DocsLink } from '@waveso/docs/react/next-link';
|
|
27
|
+
* import { DocsSidebar } from '@waveso/docs/react/sidebar';
|
|
28
|
+
*
|
|
29
|
+
* <DocsSidebar nav={nav} pathname={pathname} Link={DocsLink} />
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* ## Why `'use client'`
|
|
33
|
+
*
|
|
34
|
+
* Not for a hook — there is none. {@link DocsLink} is a *function*, and a
|
|
35
|
+
* function cannot be handed from a Server Component to a Client one: React
|
|
36
|
+
* serialises those props and refuses. Without the directive this module's export
|
|
37
|
+
* would be a server reference, and passing it to `DocsSidebar` — which is itself
|
|
38
|
+
* `'use client'` — would fail `next build` with "Functions cannot be passed
|
|
39
|
+
* directly to Client Components". The directive makes it a client reference,
|
|
40
|
+
* which crosses fine.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Module scope, and never inside a render.
|
|
44
|
+
*
|
|
45
|
+
* A fresh component identity for a link remounts every link in the document on
|
|
46
|
+
* every render — which is why `next-search.tsx` and `next-nav.tsx` each build
|
|
47
|
+
* theirs at module scope too, and why this is a constant rather than a factory.
|
|
19
48
|
*/
|
|
20
|
-
|
|
21
|
-
return function DocsNextLink({ href, prefetch, children, ...rest }) {
|
|
22
|
-
return createElement(NextLink, {
|
|
23
|
-
...rest,
|
|
24
|
-
href,
|
|
25
|
-
...prefetch === void 0 ? {} : { prefetch }
|
|
26
|
-
}, children);
|
|
27
|
-
};
|
|
28
|
-
}
|
|
49
|
+
const DocsLink = wrapNextLink(NextLink);
|
|
29
50
|
//#endregion
|
|
30
|
-
export {
|
|
51
|
+
export { DocsLink };
|
package/dist/react/next-nav.js
CHANGED
package/dist/render.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { PluggableList } from "unified";
|
|
|
12
12
|
* same object to the source walk and to the renderer. Nothing here re-applies
|
|
13
13
|
* defaults, so the two cannot drift.
|
|
14
14
|
*/
|
|
15
|
-
type DocsRendererConfig = Pick<ResolvedDocsConfig, 'basePath' | '
|
|
15
|
+
type DocsRendererConfig = Pick<ResolvedDocsConfig, 'basePath' | 'onBrokenLinks' | 'onBrokenAnchors' | 'externalRoutes'>;
|
|
16
16
|
interface DocsRendererOptions {
|
|
17
17
|
config: DocsRendererConfig;
|
|
18
18
|
/**
|
package/dist/render.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { collectAnchorIds, splitAnchor } from "./anchors.js";
|
|
1
2
|
import { docsError } from "./docs-error.js";
|
|
2
3
|
import { DEFAULT_DOCS_THEMES, createDocsHighlighter } from "./highlighter.js";
|
|
4
|
+
import { describeSuggestion } from "./link-suggestion.js";
|
|
3
5
|
import { rehypeCaptureToc } from "./plugins/rehype-capture-toc.js";
|
|
4
6
|
import { rehypeCodeFrame } from "./plugins/rehype-code-frame.js";
|
|
5
7
|
import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
|
|
@@ -8,6 +10,7 @@ import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
|
|
|
8
10
|
import { foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref } from "./plugins/remark-doc-links.js";
|
|
9
11
|
import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
|
|
10
12
|
import { remarkYouTube } from "./plugins/remark-youtube.js";
|
|
13
|
+
import { visit } from "unist-util-visit";
|
|
11
14
|
import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
|
|
12
15
|
import { rehypeGithubAlerts } from "rehype-github-alerts";
|
|
13
16
|
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
|
@@ -16,7 +19,6 @@ import remarkGfm from "remark-gfm";
|
|
|
16
19
|
import remarkParse from "remark-parse";
|
|
17
20
|
import remarkRehype from "remark-rehype";
|
|
18
21
|
import { unified } from "unified";
|
|
19
|
-
import { visit } from "unist-util-visit";
|
|
20
22
|
import { VFile } from "vfile";
|
|
21
23
|
//#region src/render.ts
|
|
22
24
|
/**
|
|
@@ -121,6 +123,18 @@ function withSuffix(src, suffix) {
|
|
|
121
123
|
return `${src}${suffix}`;
|
|
122
124
|
}
|
|
123
125
|
/** Route without its `?query` / `#anchor`, for existence checks. */
|
|
126
|
+
/**
|
|
127
|
+
* Does this route belong to the host application rather than the docs?
|
|
128
|
+
*
|
|
129
|
+
* Prefix match on a segment boundary, so `/api` covers `/api/keys` and does not
|
|
130
|
+
* accidentally cover `/apiary`.
|
|
131
|
+
*/
|
|
132
|
+
function isExternalRoute(route, external) {
|
|
133
|
+
return external.some((prefix) => {
|
|
134
|
+
const trimmed = prefix.replace(/\/+$/, "");
|
|
135
|
+
return route === trimmed || route.startsWith(`${trimmed}/`);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
124
138
|
function toRouteKey(href) {
|
|
125
139
|
const cut = href.search(/[?#]/);
|
|
126
140
|
return cut === -1 ? href : href.slice(0, cut);
|
|
@@ -343,21 +357,65 @@ function createDocsRenderer(options) {
|
|
|
343
357
|
}));
|
|
344
358
|
}
|
|
345
359
|
/**
|
|
346
|
-
*
|
|
360
|
+
* Act on a link problem at the severity the site chose.
|
|
361
|
+
*
|
|
362
|
+
* ⚠️ `'throw'` IS THE DEFAULT AND SHOULD STAY IT. The link was valid in the
|
|
363
|
+
* editor and on GitHub, so a warning in a build log is a warning nobody
|
|
364
|
+
* reads. The setting exists because a migration may knowingly run against an
|
|
365
|
+
* incomplete corpus for a while and the tool cannot know that — which is the
|
|
366
|
+
* same reason Docusaurus has one.
|
|
367
|
+
*/
|
|
368
|
+
function report(severity, code, message) {
|
|
369
|
+
if (severity === "ignore") return;
|
|
370
|
+
if (severity === "throw") throw docsError(code, message);
|
|
371
|
+
console.warn(docsError(code, message).message);
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Anchors that point into the page being rendered.
|
|
375
|
+
*
|
|
376
|
+
* ⚠️ HERE, NOT IN THE CORPUS PASS, BECAUSE THIS IS WHERE THE LINE NUMBER IS.
|
|
377
|
+
* The recorded refs carry the source line and the tree still has its
|
|
378
|
+
* positions; both are gone from a returned `RenderedDoc`. A same-page anchor
|
|
379
|
+
* is also the one a writer produces most — every "see below" — so it is worth
|
|
380
|
+
* the better message.
|
|
347
381
|
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
382
|
+
* Cross-page anchors need the other page's ids and are checked by
|
|
383
|
+
* `renderAll`, which is the first point at which they exist.
|
|
350
384
|
*/
|
|
385
|
+
function assertOwnAnchors(file, tree, refs) {
|
|
386
|
+
if (config.onBrokenAnchors === "ignore") return;
|
|
387
|
+
let ids;
|
|
388
|
+
for (const ref of refs) {
|
|
389
|
+
const anchor = splitAnchor(ref.href ?? ref.raw);
|
|
390
|
+
if (anchor === void 0) continue;
|
|
391
|
+
if (anchor.route !== "" && anchor.route !== file.href) continue;
|
|
392
|
+
ids ??= collectAnchorIds(tree);
|
|
393
|
+
if (ids.has(anchor.fragment)) continue;
|
|
394
|
+
report(config.onBrokenAnchors, "broken-anchor", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', and this page has no '#${anchor.fragment}'.${describeSuggestion(anchor.fragment, ids)} Heading ids come from the heading text, so renaming a heading renames its anchor.`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/** Check every link a page recorded, and act at the configured severity. */
|
|
351
398
|
function assertLinks(file, refs) {
|
|
352
399
|
for (const ref of refs) {
|
|
353
|
-
if (ref.
|
|
354
|
-
if (
|
|
400
|
+
if (ref.anchorOnly) continue;
|
|
401
|
+
if (ref.href === void 0) {
|
|
402
|
+
report(config.onBrokenLinks, "broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which does not resolve to a documentation page. Use a path relative to this file, or an absolute URL for external links.`);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (knownRoutes === void 0 || ref.asset || ref.anchorOnly) continue;
|
|
355
406
|
const route = toRouteKey(ref.href);
|
|
407
|
+
if (isExternalRoute(route, config.externalRoutes)) continue;
|
|
356
408
|
if (knownRoutes.has(route)) continue;
|
|
357
|
-
if (draftRoutes?.has(route))
|
|
409
|
+
if (draftRoutes?.has(route)) {
|
|
410
|
+
report(config.onBrokenLinks, "draft-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — a page marked \`draft: true\`, so it is not published and the link would 404. Publish the page, remove the link, or build with \`includeDrafts\`.`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
358
413
|
const aliasTarget = aliasRoutes?.get(route);
|
|
359
|
-
if (aliasTarget !== void 0)
|
|
360
|
-
|
|
414
|
+
if (aliasTarget !== void 0) {
|
|
415
|
+
report(config.onBrokenLinks, "alias-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — an alias that redirects to '${aliasTarget}'. An alias is not a page: it 404s unless \`createDocsRedirects\` is wired into \`next.config.ts\`, and it is never prerendered. Link to '${aliasTarget}' directly.`);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
report(config.onBrokenLinks, "broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — no such page exists.${describeSuggestion(route, knownRoutes)} Fix the link, or add an \`aliases\` entry to the page it used to point at.`);
|
|
361
419
|
}
|
|
362
420
|
}
|
|
363
421
|
return { async render(file) {
|
|
@@ -375,7 +433,8 @@ function createDocsRenderer(options) {
|
|
|
375
433
|
const hast = await processor.run(processor.parse(vfile), vfile);
|
|
376
434
|
if (titleHeading && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
|
|
377
435
|
await resolveImages(hast, file, imageResolver);
|
|
378
|
-
|
|
436
|
+
assertLinks(file, vfile.data.docLinks ?? []);
|
|
437
|
+
assertOwnAnchors(file, hast, vfile.data.docLinks ?? []);
|
|
379
438
|
return {
|
|
380
439
|
frontmatter: file.frontmatter,
|
|
381
440
|
hast: stripPositions(hast),
|
package/dist/source.js
CHANGED
|
@@ -71,7 +71,9 @@ function resolveDocsConfig(config) {
|
|
|
71
71
|
),
|
|
72
72
|
basePath: normalizeBasePath(config.basePath ?? "/docs"),
|
|
73
73
|
includeDrafts: config.includeDrafts ?? false,
|
|
74
|
-
|
|
74
|
+
onBrokenLinks: config.onBrokenLinks ?? "throw",
|
|
75
|
+
onBrokenAnchors: config.onBrokenAnchors ?? "throw",
|
|
76
|
+
externalRoutes: config.externalRoutes ?? [],
|
|
75
77
|
...config.frontmatterSchema === void 0 ? {} : { frontmatterSchema: config.frontmatterSchema }
|
|
76
78
|
};
|
|
77
79
|
}
|
|
@@ -114,7 +116,9 @@ function createDocsSource(config) {
|
|
|
114
116
|
resolved.contentDir,
|
|
115
117
|
resolved.basePath,
|
|
116
118
|
resolved.includeDrafts,
|
|
117
|
-
resolved.
|
|
119
|
+
resolved.onBrokenLinks,
|
|
120
|
+
resolved.onBrokenAnchors,
|
|
121
|
+
resolved.externalRoutes.join(","),
|
|
118
122
|
schemaKey(resolved.frontmatterSchema)
|
|
119
123
|
].join("\0");
|
|
120
124
|
const existing = sources.get(key);
|
package/dist/types.d.ts
CHANGED
|
@@ -244,11 +244,24 @@ interface DocLinkContext {
|
|
|
244
244
|
/** The source path, e.g. `'api/auth.md'`. For error messages. */
|
|
245
245
|
relativePath: string;
|
|
246
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* What a link problem should do to a build.
|
|
249
|
+
*
|
|
250
|
+
* The three Docusaurus settled on, and for the same reason: the tool cannot
|
|
251
|
+
* know how much a given site cares, and guessing produces either a build that
|
|
252
|
+
* fails on someone's legitimate URL or one that ships a dead link quietly.
|
|
253
|
+
*
|
|
254
|
+
* `'warn'` writes to `console.warn` and continues, which on a docs site is a
|
|
255
|
+
* line in a build log — useful during a migration, not a substitute for
|
|
256
|
+
* `'throw'`.
|
|
257
|
+
*/
|
|
258
|
+
type DocsLinkSeverity = 'throw' | 'warn' | 'ignore';
|
|
247
259
|
/**
|
|
248
260
|
* Resolve an internal markdown link target to a route.
|
|
249
261
|
*
|
|
250
262
|
* Called for every relative link found in the source. Returning `undefined`
|
|
251
|
-
* signals "not a documentation page", which —
|
|
263
|
+
* signals "not a documentation page", which — under the default
|
|
264
|
+
* `onBrokenLinks: 'throw'` — fails
|
|
252
265
|
* the build rather than shipping a 404 that was valid on GitHub.
|
|
253
266
|
*/
|
|
254
267
|
type LinkResolver = (
|
|
@@ -321,10 +334,61 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
|
|
|
321
334
|
*/
|
|
322
335
|
includeDrafts?: boolean | undefined;
|
|
323
336
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
337
|
+
* What to do about an internal link that resolves to no published page.
|
|
338
|
+
* Defaults to `'throw'`.
|
|
339
|
+
*
|
|
340
|
+
* A link that 404s was valid in the editor and on GitHub, so it is the kind
|
|
341
|
+
* of mistake nobody finds by reading. Throwing is the default for that
|
|
342
|
+
* reason, and there is rarely a good reason to lower it — `'warn'` exists
|
|
343
|
+
* for a migration where the corpus is knowingly incomplete for a while.
|
|
344
|
+
*
|
|
345
|
+
* The error names the file and the line, and offers the closest published
|
|
346
|
+
* route when the link looks like a typo of one.
|
|
347
|
+
*/
|
|
348
|
+
onBrokenLinks?: DocsLinkSeverity | undefined;
|
|
349
|
+
/**
|
|
350
|
+
* What to do about a `#fragment` that no heading on the target page owns.
|
|
351
|
+
* Defaults to `'throw'`.
|
|
352
|
+
*
|
|
353
|
+
* ⚠️ THE MORE COMMON OF THE TWO LINK FAILURES, AND IT WENT UNCHECKED. A route
|
|
354
|
+
* was verified and its fragment discarded, so `[setup](./install.md#setup)`
|
|
355
|
+
* built green with no `#setup` anywhere on the page. Headings get renamed
|
|
356
|
+
* constantly and nothing renames the links into them, which is exactly why it
|
|
357
|
+
* is worth checking and exactly why it breaks.
|
|
358
|
+
*
|
|
359
|
+
* Checked against every `id` in the rendered page rather than against the
|
|
360
|
+
* table of contents, which captures `h2`–`h3` only — so a link to an `h4` is
|
|
361
|
+
* fine, and so is one to an id a `rehypePlugins` entry put on something that
|
|
362
|
+
* is not a heading.
|
|
363
|
+
*
|
|
364
|
+
* Lower it to `'warn'` if a plugin of yours adds ids this package cannot see
|
|
365
|
+
* at render time.
|
|
366
|
+
*/
|
|
367
|
+
onBrokenAnchors?: DocsLinkSeverity | undefined;
|
|
368
|
+
/**
|
|
369
|
+
* Route prefixes that belong to your application, not to the documentation.
|
|
370
|
+
*
|
|
371
|
+
* ⚠️ ONLY MEANINGFUL AT A ROOT MOUNT, WHICH IS ALSO THE ONLY PLACE IT IS
|
|
372
|
+
* NEEDED. Under `basePath: '/docs'` an absolute link either carries the
|
|
373
|
+
* prefix — so it is documentation and is checked — or it does not, and this
|
|
374
|
+
* package leaves it alone. Under `basePath: '/'` there is no prefix to test
|
|
375
|
+
* against: `/setup` and `/login` look identical, and both are checked against
|
|
376
|
+
* the published pages.
|
|
377
|
+
*
|
|
378
|
+
* That is the right default, because a root mount is what you choose when the
|
|
379
|
+
* origin serves documentation and nothing else — `docs.example.com` — and
|
|
380
|
+
* there an unknown absolute link is always a typo. If the origin *does* serve
|
|
381
|
+
* something else, name what is yours:
|
|
382
|
+
*
|
|
383
|
+
* ```ts
|
|
384
|
+
* externalRoutes: ['/login', '/dashboard', '/api/']
|
|
385
|
+
* ```
|
|
386
|
+
*
|
|
387
|
+
* A link is skipped when it equals one of these or begins with one followed
|
|
388
|
+
* by `/`. It is a statement about your application, so nothing here can infer
|
|
389
|
+
* it and nothing tries.
|
|
326
390
|
*/
|
|
327
|
-
|
|
391
|
+
externalRoutes?: readonly string[] | undefined;
|
|
328
392
|
/**
|
|
329
393
|
* Validates every page's frontmatter. Defaults to `docFrontmatterSchema`
|
|
330
394
|
* from `@waveso/docs/frontmatter`.
|
|
@@ -375,7 +439,9 @@ interface ResolvedDocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatte
|
|
|
375
439
|
contentDir: string;
|
|
376
440
|
basePath: string;
|
|
377
441
|
includeDrafts: boolean;
|
|
378
|
-
|
|
442
|
+
onBrokenLinks: DocsLinkSeverity;
|
|
443
|
+
onBrokenAnchors: DocsLinkSeverity;
|
|
444
|
+
externalRoutes: readonly string[];
|
|
379
445
|
/**
|
|
380
446
|
* As supplied. `resolveDocsConfig` omits the key rather than setting it to
|
|
381
447
|
* `undefined` when the built-in `docFrontmatterSchema` applies, so the
|
|
@@ -384,4 +450,4 @@ interface ResolvedDocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatte
|
|
|
384
450
|
frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter> | undefined;
|
|
385
451
|
}
|
|
386
452
|
//#endregion
|
|
387
|
-
export { DocFile, DocFrontmatter, DocLinkContext, DocNavGroup, DocNavLink, DocNavNode, DocNavPage, DocNavSeparator, DocsConfig, DocsMeta, ImageResolver, LinkResolver, RenderedDoc, ResolvedDocsConfig, SearchRecord, TocEntry };
|
|
453
|
+
export { DocFile, DocFrontmatter, DocLinkContext, DocNavGroup, DocNavLink, DocNavNode, DocNavPage, DocNavSeparator, DocsConfig, DocsLinkSeverity, DocsMeta, ImageResolver, LinkResolver, RenderedDoc, ResolvedDocsConfig, SearchRecord, TocEntry };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waveso/docs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Zero parser bytes in the browser: markdown docs for Next.js, built to hast in Node and rendered as your components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -76,6 +76,10 @@
|
|
|
76
76
|
"types": "./dist/react/markdown-components.d.ts",
|
|
77
77
|
"default": "./dist/react/markdown-components.js"
|
|
78
78
|
},
|
|
79
|
+
"./react/next-link": {
|
|
80
|
+
"types": "./dist/react/next-link.d.ts",
|
|
81
|
+
"default": "./dist/react/next-link.js"
|
|
82
|
+
},
|
|
79
83
|
"./react/next-search": {
|
|
80
84
|
"types": "./dist/react/next-search.d.ts",
|
|
81
85
|
"default": "./dist/react/next-search.js"
|
|
@@ -196,7 +200,6 @@
|
|
|
196
200
|
"test:smoke": "node smoke/check.ts",
|
|
197
201
|
"size": "node scripts/size.ts",
|
|
198
202
|
"build:site": "next build site",
|
|
199
|
-
"dev:site": "next dev site"
|
|
200
|
-
"shoot": "node scripts/shoot.ts"
|
|
203
|
+
"dev:site": "next dev site"
|
|
201
204
|
}
|
|
202
205
|
}
|