@waveso/docs 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +270 -0
  2. package/README.md +221 -79
  3. package/dist/anchors.d.ts +44 -0
  4. package/dist/anchors.js +76 -0
  5. package/dist/errors.d.ts +4 -0
  6. package/dist/highlighter.js +2 -1
  7. package/dist/link-suggestion.d.ts +31 -0
  8. package/dist/link-suggestion.js +94 -0
  9. package/dist/meta.js +6 -9
  10. package/dist/next.d.ts +54 -14
  11. package/dist/next.js +135 -20
  12. package/dist/plugins/rehype-code-frame.d.ts +13 -1
  13. package/dist/plugins/rehype-code-frame.js +2 -1
  14. package/dist/plugins/rehype-fallback-heading-ids.js +1 -1
  15. package/dist/plugins/remark-doc-links.d.ts +83 -1
  16. package/dist/plugins/remark-doc-links.js +50 -23
  17. package/dist/plugins/remark-youtube.d.ts +18 -3
  18. package/dist/plugins/remark-youtube.js +57 -9
  19. package/dist/react/callout.d.ts +13 -1
  20. package/dist/react/callout.js +2 -2
  21. package/dist/react/code-runtime.d.ts +12 -2
  22. package/dist/react/code-runtime.js +28 -4
  23. package/dist/react/doc-content.d.ts +12 -1
  24. package/dist/react/doc-content.js +2 -2
  25. package/dist/react/layout.d.ts +27 -10
  26. package/dist/react/layout.js +6 -3
  27. package/dist/react/link-adapter.d.ts +34 -0
  28. package/dist/react/link-adapter.js +30 -0
  29. package/dist/react/markdown-components.d.ts +29 -1
  30. package/dist/react/markdown-components.js +69 -67
  31. package/dist/react/nav.d.ts +5 -1
  32. package/dist/react/nav.js +5 -2
  33. package/dist/react/next-link.d.ts +6 -28
  34. package/dist/react/next-link.js +45 -24
  35. package/dist/react/next-nav.d.ts +5 -1
  36. package/dist/react/next-nav.js +6 -3
  37. package/dist/react/next-search.js +1 -1
  38. package/dist/react/search-dialog.d.ts +59 -3
  39. package/dist/react/search-dialog.js +53 -9
  40. package/dist/react/shell-labels.d.ts +135 -21
  41. package/dist/react/shell-labels.js +47 -6
  42. package/dist/react/sidebar.d.ts +18 -1
  43. package/dist/react/sidebar.js +59 -23
  44. package/dist/react/youtube.d.ts +22 -1
  45. package/dist/react/youtube.js +22 -4
  46. package/dist/render.d.ts +12 -1
  47. package/dist/render.js +107 -21
  48. package/dist/route-path.js +7 -2
  49. package/dist/safe-href.d.ts +47 -0
  50. package/dist/safe-href.js +73 -0
  51. package/dist/search-index.js +1 -1
  52. package/dist/search-options.d.ts +64 -2
  53. package/dist/search-options.js +25 -1
  54. package/dist/semaphore.d.ts +46 -0
  55. package/dist/semaphore.js +60 -0
  56. package/dist/source.js +86 -12
  57. package/dist/types.d.ts +102 -6
  58. package/package.json +6 -3
@@ -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/meta.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { docsError } from "./docs-error.js";
2
+ import { isSafeHref, opensInNewTab } from "./safe-href.js";
2
3
  import { z } from "zod";
3
4
  import { readFile } from "node:fs/promises";
4
5
  import path from "node:path";
@@ -16,11 +17,6 @@ const SEPARATOR_PATTERN = /^---(.+)---$/;
16
17
  /** The rest wildcard: everything not named explicitly, in place. */
17
18
  const REST = "...";
18
19
  /**
19
- * `<scheme>:` or protocol-relative `//host` — i.e. a URL that leaves the site.
20
- * Anything else (`/changelog`, `../pricing`) is internal.
21
- */
22
- const ABSOLUTE_HREF_PATTERN = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
23
- /**
24
20
  * Zod mirror of {@link DocsMeta}.
25
21
  *
26
22
  * Strict on purpose: `meta.json` is hand-written and unvalidated keys are
@@ -31,7 +27,7 @@ const docsMetaSchema = z.strictObject({
31
27
  title: z.string().exactOptional(),
32
28
  pages: z.array(z.union([z.string(), z.strictObject({
33
29
  title: z.string(),
34
- href: z.string()
30
+ href: z.string().refine(isSafeHref, { message: "that is not a scheme this package will put in a link. Use http(s), mailto, tel, sms, ftp, irc, xmpp, news, feed, git or matrix — or a path, which needs no scheme at all." })
35
31
  })])).exactOptional()
36
32
  });
37
33
  /**
@@ -58,7 +54,7 @@ async function readDocsMeta(dirPath) {
58
54
  }
59
55
  let parsed;
60
56
  try {
61
- parsed = JSON.parse(raw);
57
+ parsed = JSON.parse(raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw);
62
58
  } catch (err) {
63
59
  const reason = err instanceof Error ? err.message : String(err);
64
60
  throw docsError("invalid-meta", `Could not parse ${filePath} as JSON: ${reason}`, { cause: err });
@@ -90,13 +86,14 @@ function orderNavEntries(entries, meta, metaPath, depth) {
90
86
  const used = /* @__PURE__ */ new Set();
91
87
  const nodes = [];
92
88
  let restAt = -1;
93
- for (const page of pages) {
89
+ for (const raw of pages) {
90
+ const page = typeof raw === "string" ? raw.normalize("NFC") : raw;
94
91
  if (typeof page !== "string") {
95
92
  nodes.push({
96
93
  type: "link",
97
94
  title: page.title,
98
95
  href: page.href,
99
- external: ABSOLUTE_HREF_PATTERN.test(page.href)
96
+ external: opensInNewTab(page.href)
100
97
  });
101
98
  continue;
102
99
  }
package/dist/next.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { DocFile, DocFrontmatter, DocsConfig, ImageResolver, LinkResolver, RenderedDoc, SearchRecord } from "./types.js";
2
2
  import { DocsHighlighter, DocsLang, DocsTheme, DocsThemes } from "./highlighter.js";
3
- import { MarkdownComponents } from "./react/markdown-components.js";
3
+ import { SerializableSearchOptions } from "./search-options.js";
4
4
  import { DocsLabels } from "./react/shell-labels.js";
5
+ import { MarkdownComponents } from "./react/markdown-components.js";
5
6
  import { DocsLayoutSearchProps } from "./react/layout.js";
6
7
  import { DocsSource } from "./source.js";
7
8
  import { ReactNode } from "react";
@@ -61,13 +62,36 @@ interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter>
61
62
  /**
62
63
  * MiniSearch overrides for the index {@link DocsRoute.searchIndex} builds.
63
64
  *
64
- * ⚠️ THE IDENTICAL OBJECT MUST REACH THE DIALOG pass it to `DocsSearch`'s
65
- * (or `SearchDialog`'s) `miniSearchOptions`. MiniSearch reads `tokenize` and
66
- * `processTerm` both when indexing and when querying, so applying one here
67
- * and not there produces an index whose terms no query can spell: zero
65
+ * ⚠️ THE IDENTICAL OBJECT MUST REACH THE DIALOG. MiniSearch reads `tokenize`
66
+ * and `processTerm` both when indexing and when querying, so applying one
67
+ * here and not there produces an index whose terms no query can spell: zero
68
68
  * results, no error, nothing in the console.
69
+ *
70
+ * {@link DocsRoute.Layout} forwards this for you, which covers every
71
+ * serialisable override — `storeFields`, `boost`, `searchOptions.fuzzy`. It
72
+ * cannot forward a **function**: the dialog is a Client Component and props
73
+ * crossing that boundary are serialised, so `tokenize`, `processTerm` and
74
+ * their kind make `next build` fail. Rather than drop them silently — which
75
+ * is the zero-results failure above, with the warning turned off — `Layout`
76
+ * throws `invalid-config` and names the remedy: pass `search={false}`, render
77
+ * `DocsSearch` from a `'use client'` module of your own, and import the same
78
+ * function there. See {@link SerializableSearchOptions}.
69
79
  */
70
80
  miniSearchOptions?: Partial<Options<SearchRecord>> | undefined;
81
+ /**
82
+ * Every string this package renders that is not your content.
83
+ *
84
+ * ⚠️ THE ROUTE IS WHERE THEY BELONG, BECAUSE THEY DO NOT ALL LIVE IN ONE
85
+ * RUNTIME. Four are rendered by the shell, two by the table of contents, nine
86
+ * by the markdown component map, two by a client-side copy runtime — and two
87
+ * are baked into the HTML by a rehype plugin at build time. `docs.Layout` can
88
+ * reach the first four and no more, which is exactly why its own `labels` prop
89
+ * documented itself as the whole set and covered less than a fifth of it.
90
+ *
91
+ * `docs.Layout` forwards these for you, and its `labels` prop still overrides
92
+ * them per-layout. Set them once here.
93
+ */
94
+ labels?: DocsLabels | undefined;
71
95
  }
72
96
  /**
73
97
  * Props for {@link DocsRoute.Layout}.
@@ -97,23 +121,39 @@ interface DocsLayoutProps {
97
121
  * The search trigger. Defaults to on, and the URL is always derived.
98
122
  *
99
123
  * `false` omits it. An object configures the dialog — `placeholder`,
100
- * `hotkey`, `miniSearchOptions` and the rest of `DocsSearch`'s surface,
101
- * minus `indexUrl`.
124
+ * `pageSize`, `minQueryLength`, the state messages and the rest of
125
+ * `DocsSearch`'s surface, minus `indexUrl`.
126
+ *
127
+ * (It used to say `hotkey`. There is no such prop and there never was: the
128
+ * shortcut is ⌘K / Ctrl-K and is not configurable. A docstring naming an
129
+ * option that does not exist is worse than one naming none.)
102
130
  *
103
131
  * You do not need to pass `miniSearchOptions` here to match what
104
132
  * `createDocsRoute` was given: the route's own value is forwarded, so the
105
133
  * object that built the index is the object that queries it. Pass one only
106
134
  * to override that.
135
+ *
136
+ * Serialisable overrides only, in both directions. This is a Server
137
+ * Component handing props to a Client one, so a `tokenize` or a
138
+ * `processTerm` here does not compile and one on the route throws rather
139
+ * than being quietly dropped — {@link SerializableSearchOptions} has the
140
+ * `'use client'` recipe for those.
107
141
  */
108
142
  search?: boolean | DocsLayoutSearchProps | undefined;
109
143
  /**
110
- * The four strings the shell renders itself: the navigation landmark's name,
111
- * the drawer's open and close buttons, and the skip link.
144
+ * Overrides for the strings the shell renders, over the route's.
145
+ *
146
+ * ⚠️ THIS DOCSTRING USED TO CLAIM TO BE "THE WHOLE OF WHAT A NON-ENGLISH SITE
147
+ * HAS TO SAY" AND REACHED FOUR STRINGS OF TWENTY-TWO. It could not have
148
+ * reached the rest: the table of contents is rendered by `docs.Page`, the
149
+ * callout headings by a component map built when the route is created, and the
150
+ * copy button is baked into the HTML by a rehype plugin at build time. None of
151
+ * those is downstream of a layout prop.
112
152
  *
113
- * Everything else a reader sees is your markdown or your `title`. This is the
114
- * whole of what a non-English site has to sayand it is the fifth prop,
115
- * added deliberately: a documentation shell nobody can translate is not a
116
- * shell for the whole ecosystem.
153
+ * So the set lives on {@link DocsRouteOptions.labels}, which is upstream of
154
+ * all four runtimes, and this overrides it key by key for a site with two
155
+ * shells, or a section in another language. Whole-object replacement would
156
+ * mean naming one string cost you the other twenty-one.
117
157
  */
118
158
  labels?: DocsLabels | undefined;
119
159
  }
@@ -400,4 +440,4 @@ interface DocsRedirect {
400
440
  */
401
441
  declare function createDocsRedirects(config: DocsConfig): Promise<DocsRedirect[]>;
402
442
  //#endregion
403
- export { type DocsLang, DocsLayoutProps, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, createDocsRedirects, createDocsRoute, createDocsSitemap };
443
+ export { type DocsLang, DocsLayoutProps, type DocsLayoutSearchProps, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, type SerializableSearchOptions, createDocsRedirects, createDocsRoute, createDocsSitemap };
package/dist/next.js CHANGED
@@ -1,10 +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";
6
+ import { findFunctionValuedOptions } from "./search-options.js";
4
7
  import { createMarkdownComponents } from "./react/markdown-components.js";
5
8
  import { DocContent } from "./react/doc-content.js";
6
9
  import { DocsToc } from "./react/toc.js";
7
- import { wrapNextLink } from "./react/next-link.js";
10
+ import { wrapNextLink } from "./react/link-adapter.js";
8
11
  import { createDocsRenderer } from "./render.js";
9
12
  import { toAliasRoute } from "./route-path.js";
10
13
  import { createDocsSource, resolveDocsConfig } from "./source.js";
@@ -119,9 +122,14 @@ async function loadNotFound() {
119
122
  * as `number | \`${number}\``, and spreading a `ComponentProps<'img'>`-shaped
120
123
  * object into it fails with TS2322 because the DOM types allow a bare `string`.
121
124
  * The build-time {@link ImageResolver} has already produced real numbers here.
125
+ *
126
+ * ⚠️ SO THE LIST IS THE CONTRACT, AND IT HAS TO MATCH `DocsImageProps`. A prop
127
+ * added there and forgotten here is dropped silently — which is what happened to
128
+ * `decoding` and `fetchPriority`, under a comment in `createImage` promising
129
+ * they survived.
122
130
  */
123
131
  function wrapNextImage(NextImage) {
124
- return function DocsNextImage({ src, alt, width, height, title, className, sizes, loading }) {
132
+ return function DocsNextImage({ src, alt, width, height, title, className, sizes, loading, decoding, fetchPriority }) {
125
133
  return createElement(NextImage, {
126
134
  src,
127
135
  alt,
@@ -130,35 +138,92 @@ function wrapNextImage(NextImage) {
130
138
  ...title === void 0 ? {} : { title },
131
139
  ...className === void 0 ? {} : { className },
132
140
  ...sizes === void 0 ? {} : { sizes },
133
- ...loading === void 0 ? {} : { loading }
141
+ ...loading === void 0 ? {} : { loading },
142
+ ...decoding === void 0 ? {} : { decoding },
143
+ ...fetchPriority === void 0 ? {} : { fetchPriority }
134
144
  });
135
145
  };
136
146
  }
137
147
  /**
138
- * The component map is built once per process.
148
+ * A memo for the component map, one per route.
139
149
  *
140
150
  * `createMarkdownComponents` returns fresh component identities on every call,
141
151
  * and a new identity for `a` remounts every link in the document on every
142
152
  * render — so this memo is correctness, not micro-optimisation.
153
+ *
154
+ * ⚠️ PER ROUTE, NOT PER PROCESS, SINCE THE MAP CLOSES OVER THE ROUTE'S LABELS.
155
+ * A single process-wide memo would hand the second route the first route's
156
+ * language. Nothing is lost: `import()` caches the `next/link` and `next/image`
157
+ * modules itself, so all a second route pays for is two wrapper identities —
158
+ * and a route's identities only ever have to be stable against themselves,
159
+ * because the pages that use them come from that same route.
143
160
  */
144
- let nextComponents = null;
145
- function loadNextComponents() {
146
- if (nextComponents === null) nextComponents = buildNextComponents().catch((error) => {
147
- nextComponents = null;
148
- throw error;
149
- });
150
- return nextComponents;
161
+ function createComponentsMemo(labels) {
162
+ let memo = null;
163
+ return () => {
164
+ if (memo === null) memo = buildNextComponents(labels).catch((error) => {
165
+ memo = null;
166
+ throw error;
167
+ });
168
+ return memo;
169
+ };
151
170
  }
152
- async function buildNextComponents() {
171
+ async function buildNextComponents(labels) {
153
172
  const [linkMod, imageMod] = await Promise.all([importNext(() => import("next/link"), "next/link"), importNext(() => import("next/image"), "next/image")]);
154
173
  const NextLink = readDefaultExport(linkMod, "next/link");
155
174
  const NextImage = readDefaultExport(imageMod, "next/image");
156
175
  return createMarkdownComponents({
157
176
  Link: wrapNextLink(NextLink),
158
- Image: wrapNextImage(NextImage)
177
+ Image: wrapNextImage(NextImage),
178
+ ...labels === void 0 ? {} : { labels }
159
179
  });
160
180
  }
161
181
  /**
182
+ * The named subset of `labels`, or `undefined` when none of it is set.
183
+ *
184
+ * `undefined` rather than `{}` is the point: every forwarding site here spreads
185
+ * with `...(x === undefined ? {} : { x })`, so an empty object would still add a
186
+ * prop — and for the two groups that cross a client boundary that is a prop in
187
+ * every page's payload, forever, saying nothing.
188
+ *
189
+ * `map` goes target-key → `DocsLabels` key, so the rename is visible at the call
190
+ * site rather than hidden in a component.
191
+ */
192
+ function pickLabels(labels, map) {
193
+ if (labels === void 0) return void 0;
194
+ const picked = {};
195
+ let found = false;
196
+ for (const [target, source] of Object.entries(map)) {
197
+ const value = labels[source];
198
+ if (value !== void 0) {
199
+ picked[target] = value;
200
+ found = true;
201
+ }
202
+ }
203
+ return found ? picked : void 0;
204
+ }
205
+ /**
206
+ * `candidate`, once it is known to hold no functions.
207
+ *
208
+ * The cast at the end is the whole point of the function, and
209
+ * {@link findFunctionValuedOptions} is what earns it: a structural walk over
210
+ * the values, so it answers for options MiniSearch has not shipped yet as well
211
+ * as the five it has. `SerializableSearchOptions` narrows the same thing at
212
+ * compile time, which is friendlier and strictly weaker — a JavaScript caller
213
+ * has no types at all, and an `Omit` list goes stale the minor MiniSearch adds
214
+ * a callback.
215
+ *
216
+ * Throwing beats dropping. Silently forwarding the serialisable half would
217
+ * rebuild the original defect this channel exists to close — an index built
218
+ * with a `processTerm` the query does not share returns nothing, reports
219
+ * nothing, and looks like an empty corpus.
220
+ */
221
+ function serializableSearchOptions(candidate) {
222
+ const functions = findFunctionValuedOptions(candidate);
223
+ if (functions.length > 0) throw docsError("invalid-config", `the search dialog cannot be given MiniSearch functions from a server component: ${functions.map((name) => `\`miniSearchOptions.${name}\``).join(", ")}. \`docs.Layout\` renders the dialog as a client component, so its props are serialised on the way across and React rejects a function with "Functions cannot be passed directly to Client Components" while prerendering. Keep the function on \`createDocsRoute\` so the index is still built with it, pass \`search={false}\` to \`docs.Layout\`, and render the dialog yourself from a \`'use client'\` module that imports the same function — \`<DocsSearch indexUrl={docs.searchIndexUrl} miniSearchOptions={{ processTerm }} />\` — putting that component in \`actions\`. Serialisable overrides (\`storeFields\`, \`boost\`, \`searchOptions.fuzzy\`) need none of this and are forwarded as before.`);
224
+ return candidate;
225
+ }
226
+ /**
162
227
  * Create the route handlers for a documentation tree.
163
228
  *
164
229
  * Call it once at module scope in each of the two route files. The filesystem
@@ -170,6 +235,28 @@ function createDocsRoute(options) {
170
235
  const source = createDocsSource(options);
171
236
  const siteUrl = normalizeSiteUrl(options.siteUrl);
172
237
  const rescanPerRequest = process.env.NODE_ENV !== "production";
238
+ const routeLabels = options.labels;
239
+ const codeLabels = pickLabels(routeLabels, {
240
+ copyLabel: "copyCode",
241
+ copyFromLabel: "copyCodeFrom"
242
+ });
243
+ const contentLabels = pickLabels(routeLabels, {
244
+ externalLink: "externalLink",
245
+ table: "table",
246
+ calloutNote: "calloutNote",
247
+ calloutTip: "calloutTip",
248
+ calloutImportant: "calloutImportant",
249
+ calloutWarning: "calloutWarning",
250
+ calloutCaution: "calloutCaution",
251
+ youtubeTitle: "youtubeTitle",
252
+ youtubePlay: "youtubePlay",
253
+ youtubeHide: "youtubeHide"
254
+ });
255
+ const copyLabels = pickLabels(routeLabels, {
256
+ copied: "copied",
257
+ copyFailed: "copyFailed"
258
+ });
259
+ const loadComponents = createComponentsMemo(contentLabels);
173
260
  let renderer = null;
174
261
  const knownRoutes = /* @__PURE__ */ new Set();
175
262
  const draftRoutes = /* @__PURE__ */ new Set();
@@ -232,6 +319,7 @@ function createDocsRoute(options) {
232
319
  ...options.langs === void 0 ? {} : { langs: options.langs },
233
320
  ...options.themes === void 0 ? {} : { themes: options.themes },
234
321
  ...options.excludeLangs === void 0 ? {} : { excludeLangs: options.excludeLangs },
322
+ ...codeLabels === void 0 ? {} : { codeLabels },
235
323
  ...options.titleHeading === void 0 ? {} : { titleHeading: options.titleHeading },
236
324
  ...options.remarkPlugins === void 0 ? {} : { remarkPlugins: options.remarkPlugins },
237
325
  ...options.rehypePlugins === void 0 ? {} : { rehypePlugins: options.rehypePlugins },
@@ -288,7 +376,23 @@ function createDocsRoute(options) {
288
376
  const files = await source.all();
289
377
  await loadRoutes();
290
378
  const renderer = loadRenderer();
291
- 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);
292
396
  };
293
397
  const searchIndexUrl = `${config.basePath}/search-index.json`;
294
398
  /**
@@ -321,7 +425,7 @@ function createDocsRoute(options) {
321
425
  async function renderRoute(segments) {
322
426
  const doc = await getPage(segments);
323
427
  if (doc === void 0) return (await loadNotFound())();
324
- const components = await loadNextComponents();
428
+ const components = await loadComponents();
325
429
  return createElement(Fragment, null, createElement("main", {
326
430
  className: "wave-docs-layout__main",
327
431
  id: DOCS_CONTENT_ID,
@@ -331,8 +435,13 @@ function createDocsRoute(options) {
331
435
  components: {
332
436
  ...components,
333
437
  ...options.components
334
- }
335
- })), doc.toc.length === 0 ? null : createElement("aside", { className: "wave-docs-layout__toc" }, createElement(DocsToc, { entries: doc.toc })));
438
+ },
439
+ ...copyLabels === void 0 ? {} : { labels: copyLabels }
440
+ })), doc.toc.length === 0 ? null : createElement("aside", { className: "wave-docs-layout__toc" }, createElement(DocsToc, {
441
+ entries: doc.toc,
442
+ ...routeLabels?.toc === void 0 ? {} : { label: routeLabels.toc },
443
+ ...routeLabels?.backToTop === void 0 ? {} : { topLabel: routeLabels.backToTop }
444
+ })));
336
445
  }
337
446
  return {
338
447
  source: requestScopedSource,
@@ -350,9 +459,15 @@ function createDocsRoute(options) {
350
459
  },
351
460
  async Layout({ children, title, actions, search, labels }) {
352
461
  const { DocsLayoutShell } = await import("./react/layout.js");
462
+ const host = search === true || search === void 0 || search === false ? void 0 : search;
463
+ const requestedOptions = host?.miniSearchOptions ?? options.miniSearchOptions;
353
464
  const searchProps = search === false ? false : {
354
- ...options.miniSearchOptions === void 0 ? {} : { miniSearchOptions: options.miniSearchOptions },
355
- ...search === true || search === void 0 ? {} : search
465
+ ...host,
466
+ ...requestedOptions === void 0 ? {} : { miniSearchOptions: serializableSearchOptions(requestedOptions) }
467
+ };
468
+ const shellLabels = options.labels === void 0 && labels === void 0 ? void 0 : {
469
+ ...options.labels,
470
+ ...labels
356
471
  };
357
472
  return createElement(DocsLayoutShell, {
358
473
  children,
@@ -361,7 +476,7 @@ function createDocsRoute(options) {
361
476
  search: searchProps,
362
477
  ...title === void 0 ? {} : { title },
363
478
  ...actions === void 0 ? {} : { actions },
364
- ...labels === void 0 ? {} : { labels }
479
+ ...shellLabels === void 0 ? {} : { labels: shellLabels }
365
480
  });
366
481
  },
367
482
  async generateStaticParams() {
@@ -2,8 +2,20 @@ import { Plugin } from "unified";
2
2
  import { Root } from "hast";
3
3
  //#region src/plugins/rehype-code-frame.d.ts
4
4
  interface RehypeCodeFrameOptions {
5
- /** Accessible name when a fence has no title. */
5
+ /**
6
+ * Accessible name of the copy button on a fence with no title.
7
+ * Default `'Copy code'`.
8
+ */
6
9
  copyLabel?: string | undefined;
10
+ /**
11
+ * The same button on a titled fence. Default `'Copy code from {title}'`.
12
+ *
13
+ * `{title}` is replaced with the fence's own `title="…"`. Two controls both
14
+ * called "Copy code" are indistinguishable in a screen reader's element list,
15
+ * which is why the titled form exists at all — and a translator needs to be
16
+ * able to move the title within the sentence, which concatenation forbade.
17
+ */
18
+ copyFromLabel?: string | undefined;
7
19
  }
8
20
  declare const rehypeCodeFrame: Plugin<[RehypeCodeFrameOptions?], Root>;
9
21
  //#endregion
@@ -6,6 +6,7 @@ import { CONTINUE, SKIP, visit } from "unist-util-visit";
6
6
  const LANGUAGE_CLASS = /^language-(.+)$/;
7
7
  const rehypeCodeFrame = (options = {}) => {
8
8
  const copyLabel = options.copyLabel ?? "Copy code";
9
+ const copyFromLabel = options.copyFromLabel ?? "Copy code from {title}";
9
10
  return (tree, file) => {
10
11
  const path = file.data.docLinkContext?.relativePath ?? file.path ?? "a document";
11
12
  visit(tree, "element", (node, index, parent) => {
@@ -27,7 +28,7 @@ const rehypeCodeFrame = (options = {}) => {
27
28
  value: title
28
29
  }]
29
30
  });
30
- children.push(copyButton(title === void 0 ? copyLabel : `${copyLabel} from ${title}`), node);
31
+ children.push(copyButton(title === void 0 ? copyLabel : copyFromLabel.replace("{title}", title)), node);
31
32
  parent.children[index] = {
32
33
  type: "element",
33
34
  tagName: "figure",
@@ -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
  *
@@ -56,6 +88,56 @@ interface RemarkDocLinksOptions {
56
88
  * are contained by one implementation rather than two.
57
89
  */
58
90
  declare function foldSegments(from: readonly string[], path: string): string[] | undefined;
91
+ /**
92
+ * Decode BEFORE folding, never after: `%2E%2E%2F` is `../` in disguise, and
93
+ * `foldSegments` is the only thing that refuses a chain climbing out of the
94
+ * content root.
95
+ *
96
+ * Exported for `route-path.ts`, which needs the decode without the split:
97
+ * an alias may carry a literal `#` or `?` — `c# guide` is a page name — and
98
+ * {@link splitHref} would cut the string there and throw the rest away.
99
+ */
100
+ declare function decodePath(path: string, href: string): string;
101
+ /**
102
+ * An href, split at `?` and `#`, with the path decoded and the rest left alone.
103
+ *
104
+ * ⚠️ ONE IMPLEMENTATION, BECAUSE THE THIRD COPY WAS WRONG. Four call sites need
105
+ * this exact sequence — split, decode the path, fold, re-attach — and three of
106
+ * them had it inline while `foldImageSrc` in `render.ts` had none of it. An
107
+ * author dragging a file into GitHub's editor gets `![a](./getting%20started.png)`
108
+ * written for them, and that reached the resolver undecoded: `readFile` on a
109
+ * filename with a literal `%20` in it, `ENOENT`, and a build failed on an image
110
+ * that is plainly on disk and that GitHub renders. `./diagram.png?v=2` and
111
+ * `./sprite.svg#icon` baked the query and the fragment into the filename the
112
+ * same way.
113
+ *
114
+ * A query and a fragment are never decoded: `?q=a%26b` carries a literal
115
+ * ampersand that decoding would turn into a separator, and neither is part of
116
+ * any filename.
117
+ */
118
+ interface HrefParts {
119
+ /** Path, percent-decoded and NFC-normalised — what {@link foldSegments} needs. */
120
+ path: string;
121
+ /**
122
+ * The same span exactly as authored.
123
+ *
124
+ * One caller needs it: {@link normalizeInternalRoute} strips `basePath` off
125
+ * the front by length, and doing that to a decoded path would cut at the
126
+ * wrong offset for any href that percent-encoded part of the prefix.
127
+ */
128
+ rawPath: string;
129
+ /** `?query` as authored, or `''`. */
130
+ query: string;
131
+ /** `#hash` as authored, or `''`. */
132
+ hash: string;
133
+ }
134
+ /**
135
+ * Split an href into its path, query and fragment; decode only the path.
136
+ *
137
+ * Throws a {@link URIError} if the path is not valid percent-encoding, with the
138
+ * whole href in the message — see {@link decodeSegment}.
139
+ */
140
+ declare function splitHref(href: string): HrefParts;
59
141
  /**
60
142
  * The built-in {@link LinkResolver}: markdown file path in, route out.
61
143
  *
@@ -71,4 +153,4 @@ declare function resolveMarkdownLink(href: string, fromDir: readonly string[], b
71
153
  */
72
154
  declare const remarkDocLinks: Plugin<[RemarkDocLinksOptions], Root>;
73
155
  //#endregion
74
- export { type DocLinkContext, DocLinkRef, RemarkDocLinksOptions, foldSegments, remarkDocLinks, resolveMarkdownLink };
156
+ export { type DocLinkContext, DocLinkRef, HrefParts, RemarkDocLinksOptions, decodePath, foldSegments, isRootMount, remarkDocLinks, resolveMarkdownLink, splitHref };