@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
@@ -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(/\/+$/, "");
@@ -107,11 +113,31 @@ function decodeSegment(segment, href) {
107
113
  * Decode BEFORE folding, never after: `%2E%2E%2F` is `../` in disguise, and
108
114
  * `foldSegments` is the only thing that refuses a chain climbing out of the
109
115
  * content root.
116
+ *
117
+ * Exported for `route-path.ts`, which needs the decode without the split:
118
+ * an alias may carry a literal `#` or `?` — `c# guide` is a page name — and
119
+ * {@link splitHref} would cut the string there and throw the rest away.
110
120
  */
111
121
  function decodePath(path, href) {
112
122
  return path.split("/").map((segment) => decodeSegment(segment, href)).join("/");
113
123
  }
114
124
  /**
125
+ * Split an href into its path, query and fragment; decode only the path.
126
+ *
127
+ * Throws a {@link URIError} if the path is not valid percent-encoding, with the
128
+ * whole href in the message — see {@link decodeSegment}.
129
+ */
130
+ function splitHref(href) {
131
+ const parts = HREF_PARTS.exec(href);
132
+ const rawPath = parts?.[1] ?? "";
133
+ return {
134
+ path: decodePath(rawPath, href),
135
+ rawPath,
136
+ query: parts?.[2] ?? "",
137
+ hash: parts?.[3] ?? ""
138
+ };
139
+ }
140
+ /**
115
141
  * The built-in {@link LinkResolver}: markdown file path in, route out.
116
142
  *
117
143
  * Exported for reuse by hosts that want to wrap rather than replace it. Throws
@@ -119,12 +145,9 @@ function decodePath(path, href) {
119
145
  * reported as `undefined`.
120
146
  */
121
147
  function resolveMarkdownLink(href, fromDir, basePath) {
122
- const parts = HREF_PARTS.exec(href);
123
- const path = parts?.[1] ?? "";
124
- const query = parts?.[2] ?? "";
125
- const hash = parts?.[3] ?? "";
148
+ const { path, query, hash } = splitHref(href);
126
149
  if (path === "") return;
127
- const segments = foldSegments(fromDir, decodePath(path, href));
150
+ const segments = foldSegments(fromDir, path);
128
151
  if (segments === void 0) return;
129
152
  const last = segments.at(-1);
130
153
  if (last !== void 0) {
@@ -149,12 +172,9 @@ function resolveMarkdownLink(href, fromDir, basePath) {
149
172
  * browser resolves happily and `knownRoutes` has never heard of.
150
173
  */
151
174
  function normalizeInternalRoute(href, basePath) {
152
- const parts = HREF_PARTS.exec(href);
153
- const path = parts?.[1] ?? "";
154
- const query = parts?.[2] ?? "";
155
- const hash = parts?.[3] ?? "";
175
+ const { rawPath, query, hash } = splitHref(href);
156
176
  const base = basePath.replace(/\/+$/, "");
157
- const segments = foldSegments([], decodePath(path.slice(base.length), href));
177
+ const segments = foldSegments([], decodePath(rawPath.slice(base.length), href));
158
178
  if (segments === void 0) return;
159
179
  return `${toRoute(basePath, segments)}${query}${hash}`;
160
180
  }
@@ -183,11 +203,8 @@ function isAssetLink(href) {
183
203
  * the exception.
184
204
  */
185
205
  function resolveAssetLink(href, fromDir, basePath) {
186
- const parts = HREF_PARTS.exec(href);
187
- const path = parts?.[1] ?? "";
188
- const query = parts?.[2] ?? "";
189
- const hash = parts?.[3] ?? "";
190
- const segments = foldSegments(fromDir, decodePath(path, href));
206
+ const { path, query, hash } = splitHref(href);
207
+ const segments = foldSegments(fromDir, path);
191
208
  if (segments === void 0) return;
192
209
  return `${toRoute(basePath, segments)}${query}${hash}`;
193
210
  }
@@ -213,23 +230,33 @@ const remarkDocLinks = (options) => {
213
230
  const raw = node.url;
214
231
  if (node.type === "definition" && imageIdentifiers.has(node.identifier)) continue;
215
232
  const line = node.position?.start.line;
216
- const record = (href, asset) => {
233
+ const record = (href, flags = {}) => {
217
234
  const ref = {
218
235
  raw,
219
236
  href
220
237
  };
221
238
  if (line !== void 0) ref.line = line;
222
- 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;
223
242
  refs.push(ref);
224
243
  if (href !== void 0) node.url = href;
225
244
  };
226
245
  try {
246
+ if (raw.startsWith("#")) {
247
+ record(raw, { anchorOnly: true });
248
+ continue;
249
+ }
227
250
  if (!isRelativeLink(raw)) {
228
- 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 });
229
256
  continue;
230
257
  }
231
258
  if (resolve === void 0 && isAssetLink(raw)) {
232
- record(resolveAssetLink(raw, context.dirSegments, basePath), true);
259
+ record(resolveAssetLink(raw, context.dirSegments, basePath), { asset: true });
233
260
  continue;
234
261
  }
235
262
  if (resolve) {
@@ -246,4 +273,4 @@ const remarkDocLinks = (options) => {
246
273
  };
247
274
  };
248
275
  //#endregion
249
- export { foldSegments, remarkDocLinks, resolveMarkdownLink };
276
+ export { decodePath, foldSegments, isRootMount, remarkDocLinks, resolveMarkdownLink, splitHref };
@@ -1,13 +1,28 @@
1
1
  import { Plugin } from "unified";
2
2
  import { Root } from "mdast";
3
3
  //#region src/plugins/remark-youtube.d.ts
4
+ /** What a YouTube URL says, beyond which video it is. */
5
+ interface YouTubeRef {
6
+ id: string;
7
+ /** Seconds to start at, from `t` or `start`. */
8
+ start?: number | undefined;
9
+ /** Playlist the video was linked inside, from `list`. */
10
+ list?: string | undefined;
11
+ }
4
12
  /**
5
- * Extract a video id from a YouTube watch/short/embed URL.
13
+ * Extract a video reference from a YouTube watch/short/embed URL.
6
14
  *
7
15
  * Returns `undefined` for anything else, including YouTube URLs that are not a
8
16
  * single video (channels, playlists) — those stay ordinary links.
17
+ *
18
+ * ⚠️ THE TIMESTAMP AND THE PLAYLIST ARE PART OF THE LINK, AND WERE DROPPED. Only
19
+ * the id survived, so `https://youtu.be/x?t=754` — a link to one specific moment
20
+ * in a two-hour talk, which is most of why anyone deep-links a video — opened at
21
+ * zero. And because the facade passes `autoplay=1`, it did not merely start in
22
+ * the wrong place: it started *playing* in the wrong place, so the reader had to
23
+ * work out that the author had meant somewhere else.
9
24
  */
10
- declare function parseYouTubeId(href: string): string | undefined;
25
+ declare function parseYouTubeRef(href: string): YouTubeRef | undefined;
11
26
  /**
12
27
  * remark plugin. Replaces the whole PARAGRAPH, not the link inside it — which
13
28
  * is the point: leaving the paragraph is what nested a block element in
@@ -19,4 +34,4 @@ declare function parseYouTubeId(href: string): string | undefined;
19
34
  */
20
35
  declare const remarkYouTube: Plugin<[], Root>;
21
36
  //#endregion
22
- export { parseYouTubeId, remarkYouTube };
37
+ export { YouTubeRef, parseYouTubeRef, remarkYouTube };
@@ -4,13 +4,40 @@ import { SKIP, visit } from "unist-util-visit";
4
4
  const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
5
5
  /** `https://` or `http://`, for comparing a label against its own href. */
6
6
  const HTTP_SCHEME = /^https?:\/\//i;
7
+ /** A playlist id, conservatively: what YouTube uses and nothing else. */
8
+ const PLAYLIST_ID = /^[A-Za-z0-9_-]{2,64}$/;
7
9
  /**
8
- * Extract a video id from a YouTube watch/short/embed URL.
10
+ * `1h2m3s`, `90s`, `90` every spelling YouTube's own `t` parameter takes.
11
+ */
12
+ const TIMESTAMP = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/;
13
+ /**
14
+ * Seconds from a `t`/`start` value, or `undefined` if it is not one.
15
+ *
16
+ * YouTube accepts `t=90`, `t=90s` and `t=1m30s`, and an author linking to a
17
+ * moment in a talk pastes whichever the share dialog gave them.
18
+ */
19
+ function parseTimestamp(value) {
20
+ if (value === null || value === "") return void 0;
21
+ const match = TIMESTAMP.exec(value);
22
+ if (match === null) return void 0;
23
+ const [, hours, minutes, seconds] = match;
24
+ const total = Number(hours ?? 0) * 3600 + Number(minutes ?? 0) * 60 + Number(seconds ?? 0);
25
+ return Number.isFinite(total) && total > 0 ? total : void 0;
26
+ }
27
+ /**
28
+ * Extract a video reference from a YouTube watch/short/embed URL.
9
29
  *
10
30
  * Returns `undefined` for anything else, including YouTube URLs that are not a
11
31
  * single video (channels, playlists) — those stay ordinary links.
32
+ *
33
+ * ⚠️ THE TIMESTAMP AND THE PLAYLIST ARE PART OF THE LINK, AND WERE DROPPED. Only
34
+ * the id survived, so `https://youtu.be/x?t=754` — a link to one specific moment
35
+ * in a two-hour talk, which is most of why anyone deep-links a video — opened at
36
+ * zero. And because the facade passes `autoplay=1`, it did not merely start in
37
+ * the wrong place: it started *playing* in the wrong place, so the reader had to
38
+ * work out that the author had meant somewhere else.
12
39
  */
13
- function parseYouTubeId(href) {
40
+ function parseYouTubeRef(href) {
14
41
  let url;
15
42
  try {
16
43
  url = new URL(href, "https://example.invalid");
@@ -19,17 +46,34 @@ function parseYouTubeId(href) {
19
46
  }
20
47
  const host = url.hostname.replace(/^(www|m)\./, "");
21
48
  const segments = url.pathname.split("/").filter(Boolean);
49
+ const extras = () => {
50
+ const start = parseTimestamp(url.searchParams.get("t")) ?? parseTimestamp(url.searchParams.get("start")) ?? parseTimestamp(url.hash.replace(/^#t=/, "") || null);
51
+ const list = url.searchParams.get("list");
52
+ return {
53
+ ...start === void 0 ? {} : { start },
54
+ ...list !== null && PLAYLIST_ID.test(list) ? { list } : {}
55
+ };
56
+ };
22
57
  if (host === "youtu.be") {
23
58
  const [id] = segments;
24
- return id !== void 0 && VIDEO_ID.test(id) ? id : void 0;
59
+ return id !== void 0 && VIDEO_ID.test(id) ? {
60
+ id,
61
+ ...extras()
62
+ } : void 0;
25
63
  }
26
64
  if (host !== "youtube.com" && host !== "youtube-nocookie.com") return;
27
65
  if (url.pathname === "/watch") {
28
66
  const id = url.searchParams.get("v");
29
- return id !== null && VIDEO_ID.test(id) ? id : void 0;
67
+ return id !== null && VIDEO_ID.test(id) ? {
68
+ id,
69
+ ...extras()
70
+ } : void 0;
30
71
  }
31
72
  const [prefix, id] = segments;
32
- if ((prefix === "embed" || prefix === "shorts") && id !== void 0) return VIDEO_ID.test(id) ? id : void 0;
73
+ if ((prefix === "embed" || prefix === "shorts") && id !== void 0) return VIDEO_ID.test(id) ? {
74
+ id,
75
+ ...extras()
76
+ } : void 0;
33
77
  }
34
78
  /** Whitespace-only text is what separates two links on consecutive lines. */
35
79
  function isIgnorable(node) {
@@ -66,14 +110,18 @@ const remarkYouTube = () => {
66
110
  const meaningful = node.children.filter((child) => !isIgnorable(child));
67
111
  const only = meaningful[0];
68
112
  if (meaningful.length !== 1 || only === void 0 || !isBareUrl(only)) return;
69
- const id = only.type === "link" ? parseYouTubeId(only.url) : void 0;
70
- if (id === void 0) return;
113
+ const ref = only.type === "link" ? parseYouTubeRef(only.url) : void 0;
114
+ if (ref === void 0) return;
71
115
  parent.children[index] = {
72
116
  type: "paragraph",
73
117
  children: [],
74
118
  data: {
75
119
  hName: "youtube",
76
- hProperties: { id }
120
+ hProperties: {
121
+ id: ref.id,
122
+ ...ref.start === void 0 ? {} : { start: ref.start },
123
+ ...ref.list === void 0 ? {} : { list: ref.list }
124
+ }
77
125
  }
78
126
  };
79
127
  return [SKIP, index + 1];
@@ -81,4 +129,4 @@ const remarkYouTube = () => {
81
129
  };
82
130
  };
83
131
  //#endregion
84
- export { parseYouTubeId, remarkYouTube };
132
+ export { parseYouTubeRef, remarkYouTube };
@@ -16,6 +16,18 @@ interface CalloutProps {
16
16
  type?: string | undefined;
17
17
  /** Overrides the default label ("Note", "Warning", …). */
18
18
  title?: string | undefined;
19
+ /**
20
+ * Default headings per type, for a site that is not in English.
21
+ *
22
+ * `title` still wins — it is what a single callout in the markdown asked for,
23
+ * and this is what every callout on the site is called otherwise.
24
+ *
25
+ * Here rather than resolved by the caller so that {@link normalizeCalloutType}
26
+ * stays the one place that decides what an unrecognised type falls back to. A
27
+ * caller picking the heading itself would need that rule too, and a second
28
+ * copy of it is a second thing to keep in step.
29
+ */
30
+ labels?: Partial<Record<CalloutType, string>> | undefined;
19
31
  className?: string | undefined;
20
32
  children?: ReactNode;
21
33
  }
@@ -32,6 +44,6 @@ interface CalloutProps {
32
44
  * All styling lives in `@waveso/docs/styles.css` under `.wave-docs-callout`,
33
45
  * so consumers can restyle it without forking the component.
34
46
  */
35
- declare function Callout({ type, title, className, children }: CalloutProps): ReactNode;
47
+ declare function Callout({ type, title, labels, className, children }: CalloutProps): ReactNode;
36
48
  //#endregion
37
49
  export { CALLOUT_TYPES, Callout, CalloutProps, CalloutType };
@@ -69,9 +69,9 @@ function normalizeCalloutType(value) {
69
69
  * All styling lives in `@waveso/docs/styles.css` under `.wave-docs-callout`,
70
70
  * so consumers can restyle it without forking the component.
71
71
  */
72
- function Callout({ type, title, className, children }) {
72
+ function Callout({ type, title, labels, className, children }) {
73
73
  const kind = normalizeCalloutType(type);
74
- const label = title?.trim() || CALLOUT_LABELS[kind];
74
+ const label = title?.trim() || labels?.[kind] || CALLOUT_LABELS[kind];
75
75
  return /* @__PURE__ */ jsxs("aside", {
76
76
  role: "note",
77
77
  "aria-label": label,
@@ -1,5 +1,15 @@
1
1
  import { ReactNode } from "react";
2
2
  //#region src/react/code-runtime.d.ts
3
+ /** The two announcements, for a site that is not in English. */
4
+ interface CodeRuntimeLabels {
5
+ /** Announced after a successful copy. Default `'Copied to the clipboard.'` */
6
+ copied?: string | undefined;
7
+ /**
8
+ * Announced after a failed one. Default
9
+ * `'Copy failed. Select the code and press Control or Command + C.'`
10
+ */
11
+ copyFailed?: string | undefined;
12
+ }
3
13
  /**
4
14
  * Mount the copy runtime. Renders nothing.
5
15
  *
@@ -9,6 +19,6 @@ import { ReactNode } from "react";
9
19
  * re-renders and there is no state to get out of step with a page that was
10
20
  * server-rendered.
11
21
  */
12
- declare function DocsCodeRuntime(): ReactNode;
22
+ declare function DocsCodeRuntime({ copied, copyFailed }?: CodeRuntimeLabels): ReactNode;
13
23
  //#endregion
14
- export { DocsCodeRuntime };
24
+ export { CodeRuntimeLabels, DocsCodeRuntime };
@@ -40,6 +40,24 @@ const SKIP_LINE_CLASSES = [];
40
40
  const TRIMMED_LINE_CLASSES = [];
41
41
  let refCount = 0;
42
42
  let detach;
43
+ const DEFAULT_COPIED = "Copied to the clipboard.";
44
+ const DEFAULT_COPY_FAILED = "Copy failed. Select the code and press Control or Command + C.";
45
+ /**
46
+ * Module scope, beside `refCount`, because the listener is a singleton too.
47
+ *
48
+ * The runtime installs once per page however many `DocContent`s mount it, so
49
+ * the messages belong to the installation rather than to a component — and two
50
+ * mounts with different labels would be a page with two languages in it, which
51
+ * is not a case worth code. First one in wins, and `refCount` says which.
52
+ *
53
+ * Spelled out rather than `Required<CodeRuntimeLabels>`: the props are declared
54
+ * `string | undefined` for `exactOptionalPropertyTypes`, and `Required` strips
55
+ * the `?` while leaving the `undefined` in the value type.
56
+ */
57
+ let messages = {
58
+ copied: DEFAULT_COPIED,
59
+ copyFailed: DEFAULT_COPY_FAILED
60
+ };
43
61
  /**
44
62
  * Mount the copy runtime. Renders nothing.
45
63
  *
@@ -49,10 +67,16 @@ let detach;
49
67
  * re-renders and there is no state to get out of step with a page that was
50
68
  * server-rendered.
51
69
  */
52
- function DocsCodeRuntime() {
70
+ function DocsCodeRuntime({ copied, copyFailed } = {}) {
53
71
  useEffect(() => {
54
72
  refCount += 1;
55
- if (refCount === 1) detach = install();
73
+ if (refCount === 1) {
74
+ messages = {
75
+ copied: copied ?? DEFAULT_COPIED,
76
+ copyFailed: copyFailed ?? DEFAULT_COPY_FAILED
77
+ };
78
+ detach = install();
79
+ }
56
80
  return () => {
57
81
  refCount -= 1;
58
82
  if (refCount === 0) {
@@ -60,7 +84,7 @@ function DocsCodeRuntime() {
60
84
  detach = void 0;
61
85
  }
62
86
  };
63
- }, []);
87
+ }, [copied, copyFailed]);
64
88
  return null;
65
89
  }
66
90
  function install() {
@@ -110,7 +134,7 @@ function readCode(pre) {
110
134
  async function copy(text, button, status) {
111
135
  const copied = await writeClipboard(text) ? "true" : "false";
112
136
  button.dataset.copied = copied;
113
- status.textContent = copied === "true" ? "Copied to the clipboard." : "Copy failed. Select the code and press Control or Command + C.";
137
+ status.textContent = copied === "true" ? messages.copied : messages.copyFailed;
114
138
  const existing = timers.get(button);
115
139
  if (existing !== void 0) window.clearTimeout(existing);
116
140
  timers.set(button, window.setTimeout(() => {
@@ -1,4 +1,5 @@
1
1
  import { MarkdownComponents } from "./markdown-components.js";
2
+ import { CodeRuntimeLabels } from "./code-runtime.js";
2
3
  import { ReactNode } from "react";
3
4
  import { Root } from "hast";
4
5
  //#region src/react/doc-content.d.ts
@@ -20,6 +21,16 @@ interface DocContentProps {
20
21
  * choice rather than as a mistake.
21
22
  */
22
23
  className?: string | undefined;
24
+ /**
25
+ * The two things the copy runtime announces, for a site that is not in
26
+ * English.
27
+ *
28
+ * Here rather than on `docs.Layout` because this is the component that mounts
29
+ * the runtime, and it is the one no consumer can avoid — the hand-rolled route
30
+ * in the README renders it directly. Forwarded only when set, so a site that
31
+ * overrides nothing sends no extra props across the boundary.
32
+ */
33
+ labels?: CodeRuntimeLabels | undefined;
23
34
  }
24
35
  /**
25
36
  * Render a hast tree as React elements, inside the prose wrapper.
@@ -61,6 +72,6 @@ interface DocContentProps {
61
72
  * you, because `node` is a legal prop on the component and an unknown attribute
62
73
  * on the element.
63
74
  */
64
- declare function DocContent({ hast, components, className }: DocContentProps): ReactNode;
75
+ declare function DocContent({ hast, components, className, labels }: DocContentProps): ReactNode;
65
76
  //#endregion
66
77
  export { DocContent, DocContentProps };
@@ -44,10 +44,10 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
44
44
  * you, because `node` is a legal prop on the component and an unknown attribute
45
45
  * on the element.
46
46
  */
47
- function DocContent({ hast, components, className }) {
47
+ function DocContent({ hast, components, className, labels }) {
48
48
  return /* @__PURE__ */ jsxs("div", {
49
49
  className: className === void 0 || className === "" ? "wave-docs-prose" : `wave-docs-prose ${className}`,
50
- children: [hasCodeFrame(hast) ? /* @__PURE__ */ jsx(DocsCodeRuntime, {}) : null, toJsxRuntime(hast, {
50
+ children: [hasCodeFrame(hast) ? /* @__PURE__ */ jsx(DocsCodeRuntime, { ...labels }) : null, toJsxRuntime(hast, {
51
51
  Fragment,
52
52
  jsx,
53
53
  jsxs,
@@ -1,6 +1,7 @@
1
1
  import { DocNavNode } from "../types.js";
2
- import { DocsSearchProps } from "./next-search.js";
2
+ import { SerializableSearchOptions } from "../search-options.js";
3
3
  import { DocsLabels } from "./shell-labels.js";
4
+ import { DocsSearchProps } from "./next-search.js";
4
5
  import { ReactNode } from "react";
5
6
  //#region src/react/layout.d.ts
6
7
  /**
@@ -9,8 +10,19 @@ import { ReactNode } from "react";
9
10
  * `indexUrl` is derived from `basePath` and is not negotiable here: the whole
10
11
  * reason `docs.Layout` exists is that nobody should have to know the index's
11
12
  * address, and a hand-passed one is wrong under every non-root `basePath`.
13
+ *
14
+ * `miniSearchOptions` is narrower than the one `DocsSearch` itself takes, and
15
+ * has to be. `docs.Layout` is a Server Component and `DocsSearch` is a Client
16
+ * Component, so everything here is serialised on its way across — a `tokenize`
17
+ * or a `processTerm` passed at this seam fails `next build` with *"Functions
18
+ * cannot be passed directly to Client Components"*. {@link
19
+ * SerializableSearchOptions} documents the escape hatch: a `'use client'`
20
+ * wrapper of your own, where the function is a module import on both sides
21
+ * rather than a prop between them.
12
22
  */
13
- type DocsLayoutSearchProps = Omit<DocsSearchProps, 'indexUrl'>;
23
+ type DocsLayoutSearchProps = Omit<DocsSearchProps, 'indexUrl' | 'miniSearchOptions'> & {
24
+ miniSearchOptions?: SerializableSearchOptions | undefined;
25
+ };
14
26
  interface DocsLayoutShellProps {
15
27
  children: ReactNode;
16
28
  nav: DocNavNode[];
@@ -20,14 +32,19 @@ interface DocsLayoutShellProps {
20
32
  /**
21
33
  * `false` to omit the trigger; an object to configure it.
22
34
  *
23
- * ⚠️ AN OBJECT IS WHAT MAKES `miniSearchOptions` REACHABLE. MiniSearch reads
24
- * `tokenize` and `processTerm` both when indexing and when querying, so the
25
- * object `createDocsRoute` built the index with has to be the object the
26
- * dialog queries it with — and while this was a bare boolean there was no
27
- * channel for it at all. Configuring the route and rendering `docs.Layout`
28
- * produced an index whose terms no query could spell: zero results, no error,
29
- * nothing in the console, and the option's own docstring warning about
30
- * exactly that.
35
+ * ⚠️ AN OBJECT IS WHAT MAKES `miniSearchOptions` REACHABLE, AND ONLY THE
36
+ * SERIALISABLE PART OF IT. MiniSearch reads `tokenize` and `processTerm` both
37
+ * when indexing and when querying, so the object `createDocsRoute` built the
38
+ * index with has to be the object the dialog queries it with — and while this
39
+ * was a bare boolean there was no channel for it at all. Configuring the
40
+ * route and rendering `docs.Layout` produced an index whose terms no query
41
+ * could spell: zero results, no error, nothing in the console.
42
+ *
43
+ * Widening it to a boolean-or-object fixed that for data overrides and broke
44
+ * the function ones, which is the harder half: this prop is serialised on its
45
+ * way from a Server Component to a Client one, so a function in it is a build
46
+ * failure rather than a silent miss. `createDocsRoute` refuses to forward one
47
+ * and says so; {@link DocsLayoutSearchProps} carries the remedy.
31
48
  */
32
49
  search?: boolean | DocsLayoutSearchProps | undefined;
33
50
  /**
@@ -38,8 +38,8 @@ function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search
38
38
  }),
39
39
  search === false ? null : /* @__PURE__ */ jsx(DocsSearch, {
40
40
  indexUrl: searchIndexUrl,
41
- className: "wave-docs-layout__search",
42
- ...search === true ? {} : search
41
+ ...search === true ? {} : search,
42
+ className: ["wave-docs-layout__search", search === true ? void 0 : search?.className].filter(Boolean).join(" ")
43
43
  }),
44
44
  actions === void 0 ? null : /* @__PURE__ */ jsx("div", {
45
45
  className: "wave-docs-layout__actions",
@@ -55,7 +55,10 @@ function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search
55
55
  children: /* @__PURE__ */ jsx(DocsNextNav, {
56
56
  nav,
57
57
  label: text.nav,
58
- closeLabel: text.closeNav
58
+ closeLabel: text.closeNav,
59
+ ...labels?.expandGroup === void 0 ? {} : { expandGroup: labels.expandGroup },
60
+ ...labels?.collapseGroup === void 0 ? {} : { collapseGroup: labels.collapseGroup },
61
+ ...labels?.externalLink === void 0 ? {} : { externalLink: labels.externalLink }
59
62
  })
60
63
  }), children]
61
64
  })
@@ -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,4 +1,5 @@
1
1
  import { CalloutProps } from "./callout.js";
2
+ import { DocsLabels } from "./shell-labels.js";
2
3
  import { YouTubeProps } from "./youtube.js";
3
4
  import { ComponentProps, ComponentType, JSX, ReactNode } from "react";
4
5
  //#region src/react/markdown-components.d.ts
@@ -53,6 +54,20 @@ interface DocsImageProps {
53
54
  className?: string | undefined;
54
55
  sizes?: string | undefined;
55
56
  loading?: 'eager' | 'lazy' | undefined;
57
+ /**
58
+ * Passed through to the image. Defaults to `'async'` on the plain `<img>`.
59
+ *
60
+ * ⚠️ DECLARED BECAUSE THE COMMENT BELOW PROMISED IT AND THE CODE DROPPED IT.
61
+ * `createImage` spreads the tree's own attributes into whichever component it
62
+ * was given, with a comment saying `decoding` and `fetchPriority` survive into
63
+ * the optimising branch — and `wrapNextImage` destructures a fixed list, so
64
+ * they did not. A closed props interface is the right shape for this seam, so
65
+ * the two the comment named are members of it now rather than a promise it
66
+ * could not keep.
67
+ */
68
+ decoding?: 'async' | 'auto' | 'sync' | undefined;
69
+ /** Passed through to the image. `'high'` on a hero image is the usual reason. */
70
+ fetchPriority?: 'high' | 'low' | 'auto' | undefined;
56
71
  }
57
72
  /** A `next/image`-compatible component. */
58
73
  type DocsImageComponent = ComponentType<DocsImageProps>;
@@ -61,7 +76,20 @@ interface MarkdownComponentsOptions {
61
76
  Link?: DocsLinkComponent | undefined;
62
77
  /** Optimising image component, e.g. `next/image`. Falls back to `<img>`. */
63
78
  Image?: DocsImageComponent | undefined;
79
+ /**
80
+ * Overrides for the strings this map renders itself.
81
+ *
82
+ * Five callout headings, the external-link suffix, a wide table's region
83
+ * name and the YouTube facade's three — every one of them hardcoded English
84
+ * until this existed, on a shell whose `labels` prop claimed to be the whole
85
+ * of a site's translatable chrome.
86
+ *
87
+ * All server-rendered, so overriding them costs no client bytes.
88
+ */
89
+ labels?: MarkdownLabels | undefined;
64
90
  }
91
+ /** The subset of `DocsLabels` this map is responsible for. */
92
+ type MarkdownLabels = Pick<DocsLabels, 'externalLink' | 'table' | 'calloutNote' | 'calloutTip' | 'calloutImportant' | 'calloutWarning' | 'calloutCaution' | 'youtubeTitle' | 'youtubePlay' | 'youtubeHide'>;
65
93
  /**
66
94
  * Build the default component map, optionally injecting host-specific link and
67
95
  * image components.
@@ -81,4 +109,4 @@ declare function createMarkdownComponents(options?: MarkdownComponentsOptions):
81
109
  /** The map used when a caller supplies none. Plain `<a>` and `<img>`. */
82
110
  declare const defaultMarkdownComponents: MarkdownComponents;
83
111
  //#endregion
84
- export { DocsImageComponent, DocsImageProps, DocsLinkComponent, DocsLinkProps, MarkdownComponents, MarkdownComponentsOptions, createMarkdownComponents, defaultMarkdownComponents };
112
+ export { DocsImageComponent, DocsImageProps, DocsLinkComponent, DocsLinkProps, MarkdownComponents, MarkdownComponentsOptions, MarkdownLabels, createMarkdownComponents, defaultMarkdownComponents };