@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 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
- ⚠️ ABSOLUTE `raw.githubusercontent.com` URLS, PINNED TO A TAG.
18
-
19
- npm rewrites relative markdown image paths onto its own CDN, but it does NOT
20
- rewrite `<source srcset>` inside a `<picture>` — so a relative path here shows
21
- a broken image on npmjs.com. And pinning to `main` rather than a tag means an
22
- old version's README displays a future product: someone reading 0.3.0 in 2027
23
- would see whatever the shell looks like then.
24
-
25
- `pnpm shoot` regenerates these from the real site build. 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 `next/image`; 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.
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
- assertLinks?: boolean; // default true
794
+ onBrokenLinks?: DocsLinkSeverity; // default 'throw'
795
+ onBrokenAnchors?: DocsLinkSeverity; // default 'throw'
796
+ externalRoutes?: readonly string[]; // routes your app owns, not the docs
821
797
  frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter>;
822
798
  }
799
+
800
+ type DocsLinkSeverity = 'throw' | 'warn' | 'ignore';
823
801
  ```
824
802
 
825
- `createDocsRoute` additionally accepts:
803
+ ### Broken links
826
804
 
827
- | Option | Default | Purpose |
828
- | --- | --- | --- |
829
- | `langs` | 18 grammars | Typed `readonly DocsLang[]`, so a typo is a compile error |
830
- | `themes` | `github-light` / `github-dark` | Shiki theme pair |
831
- | `highlighter` | built-in | Supply your own for grammars outside the set |
832
- | `titleHeading` | `true` | Build an `<h1>` from `frontmatter.title` when the markdown has none |
833
- | `components` | built-in map | Override any element → component mapping |
834
- | `siteUrl` | | Makes canonical URLs absolute |
835
- | `linkResolver` · `imageResolver` | — | Override link rewriting and image dimensions. An `imageResolver` receives a folded, contained src — except an absolute `/logo.png` or a schemed `https://…`, which arrive unfolded, so branch on them |
805
+ **`onBrokenLinks` defaults to `'throw'`, and should stay there.** A link that 404s was valid in your editor and on GitHub, so it is the kind of mistake nobody finds by reading — and a warning in a build log is a warning nobody reads. `'warn'` exists for a migration running knowingly against an incomplete corpus.
806
+
807
+ The error names the file, the line and the closest published route when the link looks like a typo of one:
808
+
809
+ ```
810
+ @waveso/docs: guide.md:12 links to './instalation.md', which resolves to
811
+ '/docs/instalation' no such page exists. Did you mean '/docs/installation'?
812
+ Fix the link, or add an `aliases` entry to the page it used to point at.
813
+ ```
814
+
815
+ A suggestion is offered only for a genuine near-miss. `/docs/instructions` is five edits from `/docs/installation` — a different word, not a typo — and gets none, because a wrong suggestion sends you to rename a link that was correct.
816
+
817
+ ### Broken anchors
818
+
819
+ **`onBrokenAnchors` defaults to `'throw'`.** A route used to be verified and its fragment thrown away, so `[setup](./install.md#setup)` built green with no `#setup` anywhere on the page. It is the more common of the two failures: headings get renamed constantly, and nothing renames the links into them.
820
+
821
+ ```
822
+ @waveso/docs: guide.md:12 links to '#instalation', and this page has no
823
+ '#instalation'. Did you mean 'installation'? Heading ids come from the heading
824
+ text, so renaming a heading renames its anchor.
825
+ ```
826
+
827
+ Checked against every `id` in the rendered page rather than against the table of contents — which captures `h2`–`h3` only, so a link to an `h4` is fine, and so is a link to an id one of your `rehypePlugins` added. Lower it to `'warn'` if a plugin of yours adds ids this package cannot see at render time.
828
+
829
+ Same-page anchors are checked as each page renders, so those errors carry the file and the line. Cross-page anchors need the target page's ids, which exist only once everything has been rendered — `docs.renderAll()` does that pass, and it runs in every build that serves search, because the index route is `force-static`.
836
830
 
837
- `titleHeading` defaults on because a document with no `h1` has a broken heading outline and fails every accessibility audit. Turn it off if your layout renders the title itself.
831
+ ### Routes your application owns
838
832
 
839
- The `<main>` always carries `id="docs-content"`, which is what `SkipLink` targets by default there is no option to change it, because there was no matching option on `SkipLink` to follow it with, so changing it silently pointed the skip link at nothing. Outside `NODE_ENV=production` the content directory is always re-scanned per request; `docs.source.invalidate()` is the escape hatch if you need to force one.
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
- > Under `exactOptionalPropertyTypes: true`, passing `next/link` straight into
1009
- > `DocsSidebar` does not type-check. Next's `LinkProps` re-declares `onClick?`,
1010
- > `onMouseEnter?` and `onTouchStart?` *without* `| undefined`, and React's
1018
+ > Passing `next/link` **straight** into `DocsSidebar` does not type-check under
1019
+ > `exactOptionalPropertyTypes: true`. Next's `LinkProps` re-declares `onClick?`,
1020
+ > `onMouseEnter?` and `onTouchStart?` *without* `| undefined` while React's
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
- > `next/link` call site in a project with that flag on, not just this one.
1014
- > Cast at the call site (`Link={Link as DocsLinkComponent}`) until the
1015
- > Next-wired navigation component ships.
1022
+ > `next/link` accepts perfectly well at runtime. It is true of every `next/link`
1023
+ > call site in a project with that flag on, not just this one.
1024
+ >
1025
+ > Import `DocsLink` instead of casting:
1026
+ >
1027
+ > ```tsx
1028
+ > 'use client';
1029
+ > import { DocsLink } from '@waveso/docs/react/next-link';
1030
+ > import { DocsSidebar } from '@waveso/docs/react/sidebar';
1031
+ >
1032
+ > <DocsSidebar nav={nav} pathname={pathname} Link={DocsLink} />
1033
+ > ```
1034
+ >
1035
+ > `docs.Layout` and `DocsSearch` have always used the same adapter internally,
1036
+ > so this only ever came up when composing a shell by hand.
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 };
@@ -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/next-link.js";
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
- return mapPooled(files, RENDER_CONCURRENCY, (file) => renderer.render(file));
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
- * Requires a non-empty base path. Docs mounted at the site root cannot be told
32
- * apart from the rest of the site, and asserting `/login` against the set of
33
- * documentation routes would fail builds over links that are perfectly good.
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, asset) => {
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)) record(normalizeInternalRoute(raw, basePath));
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
- * The part of `next/link` this package uses.
4
+ * Module scope, and never inside a render.
6
5
  *
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`.
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
- 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;
10
+ declare const DocsLink: DocsLinkComponent;
33
11
  //#endregion
34
- export { NextLinkComponent, wrapNextLink };
12
+ export { DocsLink };
@@ -1,30 +1,51 @@
1
- import { createElement } from "react";
2
- //#region src/react/next-link.ts
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
- * Adapt `next/link` to {@link DocsLinkProps}.
6
+ * `next/link`, adapted to this package's {@link DocsLinkProps} and ready to pass.
5
7
  *
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.
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
- * `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.
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
- * 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.
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
- 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
- }
49
+ const DocsLink = wrapNextLink(NextLink);
29
50
  //#endregion
30
- export { wrapNextLink };
51
+ export { DocsLink };
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { wrapNextLink } from "./next-link.js";
2
+ import { wrapNextLink } from "./link-adapter.js";
3
3
  import { DocsNav } from "./nav.js";
4
4
  import { jsx } from "react/jsx-runtime";
5
5
  import NextLink from "next/link";
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { wrapNextLink } from "./next-link.js";
2
+ import { wrapNextLink } from "./link-adapter.js";
3
3
  import { SearchDialog } from "./search-dialog.js";
4
4
  import { useCallback } from "react";
5
5
  import { jsx } from "react/jsx-runtime";
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' | 'assertLinks'>;
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
- * Fail the build on a link that would have 404'd.
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
- * Deliberately a throw and not a warning: the link was valid in the editor
349
- * and on GitHub, so a warning in a build log is a warning nobody reads.
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.href === void 0) throw docsError("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.`);
354
- if (knownRoutes === void 0 || ref.asset) continue;
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)) throw docsError("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\`.`);
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) throw docsError("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.`);
360
- throw docsError("broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — no such page exists. Fix the link, or add an \`aliases\` entry to the page it used to point at.`);
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
- if (config.assertLinks) assertLinks(file, vfile.data.docLinks ?? []);
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
- assertLinks: config.assertLinks ?? true,
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.assertLinks,
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 — with `assertLinks` on — fails
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
- * Fail the build when an internal link resolves to a page that does not
325
- * exist. Defaults to `true`; there is no good reason to turn it off.
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
- assertLinks?: boolean | undefined;
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
- assertLinks: boolean;
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.5.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
  }