@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
@@ -1,22 +1,45 @@
1
1
  //#region src/react/shell-labels.d.ts
2
2
  /**
3
- * Every user-visible string the shell renders that is not the reader's content.
3
+ * Every user-visible string this package renders that is not the reader's
4
+ * content.
4
5
  *
5
- * ⚠️ THERE ARE ONLY FIVE, AND THAT IS THE POINT. `docs.Layout` renders the
6
- * whole chrome of a documentation site, and until this existed all five were
7
- * hardcoded English with no way to reach them: `DocsNav` declared `label` and
8
- * `closeLabel` props, documented them, defaulted them — and the layout that is
9
- * the only thing rendering `DocsNav` never passed either, while `DocsLayoutProps`
10
- * had no way to say them. Dead options that read as configuration.
6
+ * ⚠️ THERE USED TO BE FOUR, AND THE DOCSTRING CLAIMED THEY WERE ALL OF THEM.
7
+ * They were the four `docs.Layout` renders directly, and the claim "the whole
8
+ * of what a non-English site has to say" was false by seventeen strings. A
9
+ * German site built exactly the documented way shipped
10
+ * `<nav aria-label="On this page">`, a visible `Back to top`, `aria-label="Tip"`
11
+ * on every callout, `Copy code` on every fence and `(opens in a new tab)` after
12
+ * every external link. Verified in this repository's own `site/out`, which is
13
+ * how it was found.
11
14
  *
12
- * Private module, public type: `next.ts` re-exports {@link DocsLabels} as part
13
- * of `DocsLayoutProps`, and this file exists so the Node adapter can name the
14
- * type without importing a `'use client'` module for it.
15
+ * ## Where each one is rendered, because it decides the cost
16
+ *
17
+ * Most are emitted by Server Components or baked into the HTML by a rehype
18
+ * plugin at build time, so overriding them costs nothing at all. Six cross into
19
+ * a Client Component — the two sidebar disclosure verbs, the two table-of-contents
20
+ * strings and the two copy-status messages — and those are forwarded ONLY when
21
+ * set, so a site that overrides nothing carries exactly the payload it did
22
+ * before.
23
+ *
24
+ * ## The defaults do not all live here
25
+ *
26
+ * {@link DEFAULT_DOCS_LABELS} covers the four shell strings and no more, on
27
+ * purpose. The rest default inside the component that renders them, because
28
+ * this module would otherwise have to be imported by `sidebar.tsx` and
29
+ * `code-runtime.tsx` — and an object literal of twenty-one strings does not
30
+ * tree-shake, so every reader would download the German site's English
31
+ * fallbacks. `next.test.ts` asserts that every key here reaches the output, so
32
+ * a key that is declared and never wired fails rather than reading as
33
+ * configuration.
15
34
  *
16
35
  * Not here: the search dialog's strings, which are reachable through
17
- * `search={{ … }}`; and the sidebar's own `label`, which is public API on
18
- * `DocsSidebar` for anyone composing a shell by hand. This is the set that had
19
- * no route at all.
36
+ * `search={{ … }}` on `docs.Layout` its trigger, its placeholder, its
37
+ * accessible name and its five state messages, plus the plural forms of its
38
+ * live region. They travel with the dialog's own props rather than with these
39
+ * because that channel already existed and already carries `pageSize` and
40
+ * `minQueryLength`; a second route to the same component would be two places to
41
+ * look. `DocsSidebar`'s own `label` is likewise public API, for anyone composing
42
+ * a shell by hand.
20
43
  */
21
44
  interface DocsLabels {
22
45
  /** The navigation landmark's accessible name. Default `'Documentation'`. */
@@ -27,17 +50,108 @@ interface DocsLabels {
27
50
  closeNav?: string | undefined;
28
51
  /** The skip link's visible text. Default `'Skip to content'`. */
29
52
  skipToContent?: string | undefined;
53
+ /**
54
+ * Accessible name of a collapsed group's toggle. Default `'Expand {title}'`.
55
+ *
56
+ * `{title}` is replaced with the group's own name. A placeholder rather than a
57
+ * function because this crosses from a Server Component to a Client one, where
58
+ * a function cannot go.
59
+ */
60
+ expandGroup?: string | undefined;
61
+ /** The same toggle when open. Default `'Collapse {title}'`. */
62
+ collapseGroup?: string | undefined;
63
+ /** The TOC landmark's accessible name. Default `'On this page'`. */
64
+ toc?: string | undefined;
65
+ /** The link at the end of the TOC. Default `'Back to top'`. */
66
+ backToTop?: string | undefined;
67
+ /**
68
+ * Screen-reader suffix on a link that opens a new tab.
69
+ * Default `'(opens in a new tab)'`.
70
+ */
71
+ externalLink?: string | undefined;
72
+ /**
73
+ * Accessible name of a wide table's scroll region. Default `'Table'`.
74
+ *
75
+ * The region exists so a keyboard user can scroll a table that overflows; an
76
+ * unnamed one is announced as "region", which says nothing about what it
77
+ * holds.
78
+ */
79
+ table?: string | undefined;
80
+ /** Heading on a `> [!NOTE]` callout. Default `'Note'`. */
81
+ calloutNote?: string | undefined;
82
+ /** Heading on a `> [!TIP]` callout. Default `'Tip'`. */
83
+ calloutTip?: string | undefined;
84
+ /** Heading on a `> [!IMPORTANT]` callout. Default `'Important'`. */
85
+ calloutImportant?: string | undefined;
86
+ /** Heading on a `> [!WARNING]` callout. Default `'Warning'`. */
87
+ calloutWarning?: string | undefined;
88
+ /** Heading on a `> [!CAUTION]` callout. Default `'Caution'`. */
89
+ calloutCaution?: string | undefined;
90
+ /**
91
+ * Accessible name of a YouTube embed. Default `'YouTube video player'`.
92
+ *
93
+ * Markdown carries no video title, so this is the name every embed on the site
94
+ * gets unless a `<YouTube title>` overrides it.
95
+ */
96
+ youtubeTitle?: string | undefined;
97
+ /**
98
+ * The closed facade's control. Default `'Play video: {title}'`.
99
+ *
100
+ * `{title}` is the embed's accessible name.
101
+ */
102
+ youtubePlay?: string | undefined;
103
+ /** The open facade's control. Default `'Hide video: {title}'`. */
104
+ youtubeHide?: string | undefined;
105
+ /** The copy button on a fence with no title. Default `'Copy code'`. */
106
+ copyCode?: string | undefined;
107
+ /**
108
+ * The copy button on a titled fence. Default `'Copy code from {title}'`.
109
+ *
110
+ * `{title}` is the fence's own `title="…"`. Two controls both called "Copy
111
+ * code" are indistinguishable in a screen reader's element list, which is why
112
+ * the titled form exists at all.
113
+ */
114
+ copyCodeFrom?: string | undefined;
115
+ /** Announced after a successful copy. Default `'Copied to the clipboard.'` */
116
+ copied?: string | undefined;
117
+ /**
118
+ * Announced after a failed one. Default
119
+ * `'Copy failed. Select the code and press Control or Command + C.'`
120
+ *
121
+ * Says what to do instead, not merely that it failed: the common way to land
122
+ * here is `next dev` on a phone over plain HTTP, where there is no secure
123
+ * context and retrying cannot help.
124
+ */
125
+ copyFailed?: string | undefined;
30
126
  }
31
127
  /**
32
- * The defaults, in one place.
128
+ * Every key of {@link DocsLabels}, for the test that proves each one is wired.
33
129
  *
34
- * A `Required<DocsLabels>` rather than four `=` defaults spread across three
35
- * components: the previous arrangement is how `DocsSidebar` came to default its
36
- * landmark to `'Docs'` while `DocsNav` defaulted the same landmark to
37
- * `'Documentation'` — two names for one region, depending on the viewport.
130
+ * A list rather than a type-level trick because the assertion has to run: the
131
+ * failure being guarded against is a key that is declared, documented and never
132
+ * read, which type-checks perfectly.
38
133
  */
39
- declare const DEFAULT_DOCS_LABELS: Required<DocsLabels>;
134
+ declare const DOCS_LABEL_KEYS: readonly ["nav", "openNav", "closeNav", "skipToContent", "expandGroup", "collapseGroup", "toc", "backToTop", "externalLink", "table", "calloutNote", "calloutTip", "calloutImportant", "calloutWarning", "calloutCaution", "youtubeTitle", "youtubePlay", "youtubeHide", "copyCode", "copyCodeFrom", "copied", "copyFailed"];
135
+ /** The four the shell renders itself, resolved centrally. */
136
+ type ShellLabelKey = 'nav' | 'openNav' | 'closeNav' | 'skipToContent';
137
+ /**
138
+ * Defaults for the shell's four, in one place.
139
+ *
140
+ * A `Required<…>` rather than four `=` defaults spread across three components:
141
+ * the previous arrangement is how `DocsSidebar` came to default its landmark to
142
+ * `'Docs'` while `DocsNav` defaulted the same landmark to `'Documentation'` —
143
+ * two names for one region, depending on the viewport.
144
+ */
145
+ declare const DEFAULT_DOCS_LABELS: Required<Pick<DocsLabels, ShellLabelKey>>;
40
146
  /** The given labels over the defaults, with `undefined` treated as unset. */
41
- declare function resolveLabels(labels: DocsLabels | undefined): Required<DocsLabels>;
147
+ declare function resolveLabels(labels: DocsLabels | undefined): Required<Pick<DocsLabels, ShellLabelKey>>;
148
+ /**
149
+ * `template` with `{title}` replaced.
150
+ *
151
+ * A placeholder, because these strings cross a Server → Client boundary where a
152
+ * function cannot go — and because a translator needs to move the name within
153
+ * the sentence, which string concatenation does not allow.
154
+ */
155
+ declare function fillTitle(template: string, title: string): string;
42
156
  //#endregion
43
- export { DEFAULT_DOCS_LABELS, DocsLabels, resolveLabels };
157
+ export { DEFAULT_DOCS_LABELS, DOCS_LABEL_KEYS, DocsLabels, ShellLabelKey, fillTitle, resolveLabels };
@@ -1,11 +1,42 @@
1
1
  //#region src/react/shell-labels.ts
2
2
  /**
3
- * The defaults, in one place.
3
+ * Every key of {@link DocsLabels}, for the test that proves each one is wired.
4
4
  *
5
- * A `Required<DocsLabels>` rather than four `=` defaults spread across three
6
- * components: the previous arrangement is how `DocsSidebar` came to default its
7
- * landmark to `'Docs'` while `DocsNav` defaulted the same landmark to
8
- * `'Documentation'` — two names for one region, depending on the viewport.
5
+ * A list rather than a type-level trick because the assertion has to run: the
6
+ * failure being guarded against is a key that is declared, documented and never
7
+ * read, which type-checks perfectly.
8
+ */
9
+ const DOCS_LABEL_KEYS = [
10
+ "nav",
11
+ "openNav",
12
+ "closeNav",
13
+ "skipToContent",
14
+ "expandGroup",
15
+ "collapseGroup",
16
+ "toc",
17
+ "backToTop",
18
+ "externalLink",
19
+ "table",
20
+ "calloutNote",
21
+ "calloutTip",
22
+ "calloutImportant",
23
+ "calloutWarning",
24
+ "calloutCaution",
25
+ "youtubeTitle",
26
+ "youtubePlay",
27
+ "youtubeHide",
28
+ "copyCode",
29
+ "copyCodeFrom",
30
+ "copied",
31
+ "copyFailed"
32
+ ];
33
+ /**
34
+ * Defaults for the shell's four, in one place.
35
+ *
36
+ * A `Required<…>` rather than four `=` defaults spread across three components:
37
+ * the previous arrangement is how `DocsSidebar` came to default its landmark to
38
+ * `'Docs'` while `DocsNav` defaulted the same landmark to `'Documentation'` —
39
+ * two names for one region, depending on the viewport.
9
40
  */
10
41
  const DEFAULT_DOCS_LABELS = {
11
42
  nav: "Documentation",
@@ -23,5 +54,15 @@ function resolveLabels(labels) {
23
54
  skipToContent: labels.skipToContent ?? DEFAULT_DOCS_LABELS.skipToContent
24
55
  };
25
56
  }
57
+ /**
58
+ * `template` with `{title}` replaced.
59
+ *
60
+ * A placeholder, because these strings cross a Server → Client boundary where a
61
+ * function cannot go — and because a translator needs to move the name within
62
+ * the sentence, which string concatenation does not allow.
63
+ */
64
+ function fillTitle(template, title) {
65
+ return template.replace("{title}", title);
66
+ }
26
67
  //#endregion
27
- export { DEFAULT_DOCS_LABELS, resolveLabels };
68
+ export { DEFAULT_DOCS_LABELS, DOCS_LABEL_KEYS, fillTitle, resolveLabels };
@@ -15,6 +15,23 @@ interface DocsSidebarProps {
15
15
  Link?: DocsLinkComponent | undefined;
16
16
  /** Accessible name for the landmark. Distinguish multiple navs on a page. */
17
17
  label?: string | undefined;
18
+ /**
19
+ * A collapsed group's toggle. Default `'Expand {title}'`.
20
+ *
21
+ * `{title}` is replaced with the group's own name. A placeholder rather than
22
+ * a function, because `docs.Layout` sets this from a Server Component and a
23
+ * function cannot cross that boundary — and because a translator has to be
24
+ * able to move the name within the sentence.
25
+ */
26
+ expandGroup?: string | undefined;
27
+ /** The same toggle when open. Default `'Collapse {title}'`. */
28
+ collapseGroup?: string | undefined;
29
+ /**
30
+ * Screen-reader suffix on an external link. Default `'(opens in a new tab)'`.
31
+ *
32
+ * The separating space is markup, so this is the sentence and nothing else.
33
+ */
34
+ externalLink?: string | undefined;
18
35
  className?: string | undefined;
19
36
  }
20
37
  /**
@@ -59,6 +76,6 @@ interface DocsSidebarProps {
59
76
  * nothing prefetches locally whatever this says. Do not "fix" it back because
60
77
  * the network tab looks the same.
61
78
  */
62
- declare function DocsSidebar({ nav, pathname, Link, label, className }: DocsSidebarProps): ReactNode;
79
+ declare function DocsSidebar({ nav, pathname, Link, label, expandGroup, collapseGroup, externalLink, className }: DocsSidebarProps): ReactNode;
63
80
  //#endregion
64
81
  export { DocsSidebar, DocsSidebarProps };
@@ -1,8 +1,13 @@
1
1
  "use client";
2
2
  import { nearestScrollTop } from "./nearest-scroll-top.js";
3
- import { useId, useLayoutEffect, useRef, useState } from "react";
3
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
4
4
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/react/sidebar.tsx
6
+ const DEFAULT_SIDEBAR_LABELS = {
7
+ expandGroup: "Expand {title}",
8
+ collapseGroup: "Collapse {title}",
9
+ externalLink: "(opens in a new tab)"
10
+ };
6
11
  /** Trailing slashes are a routing detail, not a difference in identity. */
7
12
  function normalizeHref(href) {
8
13
  return href.length > 1 ? href.replace(/\/+$/, "") : href;
@@ -61,7 +66,12 @@ function containsActive(node, pathname) {
61
66
  * nothing prefetches locally whatever this says. Do not "fix" it back because
62
67
  * the network tab looks the same.
63
68
  */
64
- function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
69
+ function DocsSidebar({ nav, pathname, Link, label = "Docs", expandGroup, collapseGroup, externalLink, className }) {
70
+ const text = {
71
+ expandGroup: expandGroup ?? DEFAULT_SIDEBAR_LABELS.expandGroup,
72
+ collapseGroup: collapseGroup ?? DEFAULT_SIDEBAR_LABELS.collapseGroup,
73
+ externalLink: externalLink ?? DEFAULT_SIDEBAR_LABELS.externalLink
74
+ };
65
75
  const baseId = useId();
66
76
  const [toggled, setToggled] = useState({});
67
77
  const lastPathname = useRef(pathname);
@@ -77,19 +87,19 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
77
87
  };
78
88
  const navRef = useRef(null);
79
89
  useLayoutEffect(() => {
80
- const active = navRef.current?.querySelector("[aria-current=\"page\"]");
81
- if (!(active instanceof HTMLElement)) return;
82
- const port = scrollableAncestor(active);
83
- if (port === null) return;
84
- const next = nearestScrollTop({
85
- itemTop: active.getBoundingClientRect().top - port.getBoundingClientRect().top + port.scrollTop,
86
- itemHeight: active.offsetHeight,
87
- viewHeight: port.clientHeight,
88
- scrollTop: port.scrollTop,
89
- scrollHeight: port.scrollHeight
90
- });
91
- if (next !== void 0) port.scrollTop = next;
90
+ revealActive(navRef.current);
92
91
  }, [pathname]);
92
+ useEffect(() => {
93
+ const dialog = navRef.current?.closest("dialog");
94
+ if (!(dialog instanceof HTMLDialogElement)) return;
95
+ const onToggle = () => {
96
+ if (dialog.open) revealActive(navRef.current);
97
+ };
98
+ dialog.addEventListener("toggle", onToggle);
99
+ return () => {
100
+ dialog.removeEventListener("toggle", onToggle);
101
+ };
102
+ }, []);
93
103
  return /* @__PURE__ */ jsx("nav", {
94
104
  ref: navRef,
95
105
  "aria-label": label,
@@ -101,7 +111,8 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
101
111
  pathname,
102
112
  Link,
103
113
  toggled,
104
- onToggle: handleToggle
114
+ onToggle: handleToggle,
115
+ text
105
116
  })
106
117
  });
107
118
  }
@@ -115,6 +126,28 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
115
126
  * one navigation where they know precisely what they asked for.
116
127
  * `sidebar.test.tsx` spies on it and asserts it is never called.
117
128
  */
129
+ /**
130
+ * Scroll the item marked `aria-current="page"` into view, if it is not already.
131
+ *
132
+ * A no-op wherever there is nothing to measure — no nav, no active item, no
133
+ * scrollport — which is every server render, every jsdom render, and every
134
+ * layout where the column is shorter than its content. Nothing to do, and
135
+ * nothing to do wrongly.
136
+ */
137
+ function revealActive(nav) {
138
+ const active = nav?.querySelector("[aria-current=\"page\"]");
139
+ if (!(active instanceof HTMLElement)) return;
140
+ const port = scrollableAncestor(active);
141
+ if (port === null) return;
142
+ const next = nearestScrollTop({
143
+ itemTop: active.getBoundingClientRect().top - port.getBoundingClientRect().top + port.scrollTop,
144
+ itemHeight: active.offsetHeight,
145
+ viewHeight: port.clientHeight,
146
+ scrollTop: port.scrollTop,
147
+ scrollHeight: port.scrollHeight
148
+ });
149
+ if (next !== void 0) port.scrollTop = next;
150
+ }
118
151
  function scrollableAncestor(element) {
119
152
  let current = element.parentElement;
120
153
  while (current !== null) {
@@ -124,7 +157,7 @@ function scrollableAncestor(element) {
124
157
  }
125
158
  return null;
126
159
  }
127
- function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, id }) {
160
+ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, text, id }) {
128
161
  const holdsActive = nodes.some((node) => (node.type === "page" || node.type === "link" && !node.external) && isActiveHref(pathname, node.href));
129
162
  return /* @__PURE__ */ jsx("ul", {
130
163
  id,
@@ -148,6 +181,7 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
148
181
  isActive: !node.external && isActiveHref(pathname, node.href),
149
182
  isNearby: holdsActive,
150
183
  Link,
184
+ externalLink: text.externalLink,
151
185
  children: node.title
152
186
  })
153
187
  }, key);
@@ -169,14 +203,15 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
169
203
  pathname,
170
204
  Link,
171
205
  toggled,
172
- onToggle
206
+ onToggle,
207
+ text
173
208
  }, key);
174
209
  default: return null;
175
210
  }
176
211
  })
177
212
  });
178
213
  }
179
- function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
214
+ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle, text }) {
180
215
  const listId = `${itemKey}-list`;
181
216
  const hasActive = containsActive(node, pathname);
182
217
  const isOpen = toggled[itemKey] ?? hasActive;
@@ -208,7 +243,7 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
208
243
  className: "wave-docs-sidebar__group-toggle",
209
244
  "aria-expanded": isOpen,
210
245
  "aria-controls": isOpen ? listId : void 0,
211
- "aria-label": `${isOpen ? "Collapse" : "Expand"} ${node.title}`,
246
+ "aria-label": (isOpen ? text.collapseGroup : text.expandGroup).replace("{title}", node.title),
212
247
  onClick: () => onToggle(itemKey, !isOpen),
213
248
  children: /* @__PURE__ */ jsx(Chevron, { isOpen })
214
249
  })] })
@@ -220,11 +255,12 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
220
255
  pathname,
221
256
  Link,
222
257
  toggled,
223
- onToggle
258
+ onToggle,
259
+ text
224
260
  }) : null]
225
261
  });
226
262
  }
227
- function NavLink({ href, isExternal, isActive, isNearby = false, Link, children }) {
263
+ function NavLink({ href, isExternal, isActive, isNearby = false, Link, externalLink = DEFAULT_SIDEBAR_LABELS.externalLink, children }) {
228
264
  const className = "wave-docs-sidebar__link";
229
265
  if (isExternal) return /* @__PURE__ */ jsxs("a", {
230
266
  className,
@@ -247,9 +283,9 @@ function NavLink({ href, isExternal, isActive, isNearby = false, Link, children
247
283
  strokeLinejoin: "round",
248
284
  children: /* @__PURE__ */ jsx("path", { d: "M14 4h6v6M20 4l-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" })
249
285
  }),
250
- /* @__PURE__ */ jsx("span", {
286
+ /* @__PURE__ */ jsxs("span", {
251
287
  className: "wave-docs-sr-only",
252
- children: " (opens in a new tab)"
288
+ children: [" ", externalLink]
253
289
  })
254
290
  ]
255
291
  });
@@ -3,11 +3,32 @@ import { ReactNode } from "react";
3
3
  interface YouTubeProps {
4
4
  /** The 11-character video id, e.g. `dQw4w9WgXcQ`. */
5
5
  id?: string | undefined;
6
+ /**
7
+ * Seconds to start at, from the link's `t` or `start`.
8
+ *
9
+ * ⚠️ IT USED TO BE DROPPED, AND THE FACADE AUTOPLAYS. `https://youtu.be/x?t=754`
10
+ * is a link to one moment in a two-hour talk — most of why anyone deep-links a
11
+ * video at all — and it opened at zero and started playing there, leaving the
12
+ * reader to work out that the author had meant somewhere else.
13
+ */
14
+ start?: number | undefined;
15
+ /** Playlist the video was linked inside, from the link's `list`. */
16
+ list?: string | undefined;
6
17
  /**
7
18
  * Accessible name for the player. Markdown carries no video title, so the
8
19
  * fallback is generic — pass a real one where you have it.
9
20
  */
10
21
  title?: string | undefined;
22
+ /**
23
+ * The closed facade's control. Default `'Play video: {title}'`.
24
+ *
25
+ * `{title}` is replaced with {@link YouTubeProps.title}. A placeholder rather
26
+ * than concatenation because a translator has to be able to move the name
27
+ * within the sentence — several languages put it first.
28
+ */
29
+ playLabel?: string | undefined;
30
+ /** The open facade's control. Default `'Hide video: {title}'`. */
31
+ hideLabel?: string | undefined;
11
32
  className?: string | undefined;
12
33
  }
13
34
  /**
@@ -48,6 +69,6 @@ interface YouTubeProps {
48
69
  * `hqdefault.jpg` rather than `maxresdefault.jpg` deliberately: maxres does not
49
70
  * exist for uploads below 1280×720 and 404s to a broken image with no fallback.
50
71
  */
51
- declare function YouTube({ id, title, className }: YouTubeProps): ReactNode;
72
+ declare function YouTube({ id, start, list, title, playLabel, hideLabel, className }: YouTubeProps): ReactNode;
52
73
  //#endregion
53
74
  export { YouTube, YouTubeProps };
@@ -1,6 +1,24 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  //#region src/react/youtube.tsx
3
+ /**
4
+ * The embed URL, with whatever the author's link carried.
5
+ *
6
+ * `URLSearchParams` rather than string concatenation: `list` comes out of a
7
+ * document and goes into a URL, and building this by hand is how a crafted
8
+ * "playlist id" adds parameters of its own. `start` is already a number.
9
+ */
10
+ function embedUrl(id, start, list) {
11
+ const params = new URLSearchParams({
12
+ rel: "0",
13
+ autoplay: "1"
14
+ });
15
+ if (start !== void 0 && Number.isFinite(start) && start > 0) params.set("start", String(Math.floor(start)));
16
+ if (list !== void 0 && list !== "") params.set("list", list);
17
+ return `https://www.youtube-nocookie.com/embed/${id}?${params.toString()}`;
18
+ }
3
19
  const DEFAULT_TITLE = "YouTube video player";
20
+ const DEFAULT_PLAY_LABEL = "Play video: {title}";
21
+ const DEFAULT_HIDE_LABEL = "Hide video: {title}";
4
22
  /**
5
23
  * Click-to-load YouTube embed, with **no client JavaScript at all**.
6
24
  *
@@ -39,7 +57,7 @@ const DEFAULT_TITLE = "YouTube video player";
39
57
  * `hqdefault.jpg` rather than `maxresdefault.jpg` deliberately: maxres does not
40
58
  * exist for uploads below 1280×720 and 404s to a broken image with no fallback.
41
59
  */
42
- function YouTube({ id, title, className }) {
60
+ function YouTube({ id, start, list, title, playLabel, hideLabel, className }) {
43
61
  if (!id) return null;
44
62
  const safeId = encodeURIComponent(id);
45
63
  const label = title?.trim() || DEFAULT_TITLE;
@@ -78,17 +96,17 @@ function YouTube({ id, title, className }) {
78
96
  }),
79
97
  /* @__PURE__ */ jsx("span", {
80
98
  className: "wave-docs-sr-only wave-docs-youtube__label-play",
81
- children: `Play video: ${label}`
99
+ children: (playLabel ?? DEFAULT_PLAY_LABEL).replace("{title}", label)
82
100
  }),
83
101
  /* @__PURE__ */ jsx("span", {
84
102
  className: "wave-docs-youtube__label-hide",
85
- children: `Hide video: ${label}`
103
+ children: (hideLabel ?? DEFAULT_HIDE_LABEL).replace("{title}", label)
86
104
  })
87
105
  ]
88
106
  }), /* @__PURE__ */ jsx("iframe", {
89
107
  className: "wave-docs-youtube__frame",
90
108
  loading: "lazy",
91
- src: `https://www.youtube-nocookie.com/embed/${safeId}?rel=0&autoplay=1`,
109
+ src: embedUrl(safeId, start, list),
92
110
  title: label,
93
111
  allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
94
112
  allowFullScreen: true
package/dist/render.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { DocFile, DocFrontmatter, ImageResolver, LinkResolver, RenderedDoc, ResolvedDocsConfig } from "./types.js";
2
2
  import { DocsHighlighter, DocsLang, DocsThemes } from "./highlighter.js";
3
+ import { RehypeCodeFrameOptions } from "./plugins/rehype-code-frame.js";
3
4
  import { resolveMarkdownLink } from "./plugins/remark-doc-links.js";
4
5
  import { PluggableList } from "unified";
5
6
  //#region src/render.d.ts
@@ -11,7 +12,7 @@ import { PluggableList } from "unified";
11
12
  * same object to the source walk and to the renderer. Nothing here re-applies
12
13
  * defaults, so the two cannot drift.
13
14
  */
14
- type DocsRendererConfig = Pick<ResolvedDocsConfig, 'basePath' | 'assertLinks'>;
15
+ type DocsRendererConfig = Pick<ResolvedDocsConfig, 'basePath' | 'onBrokenLinks' | 'onBrokenAnchors' | 'externalRoutes'>;
15
16
  interface DocsRendererOptions {
16
17
  config: DocsRendererConfig;
17
18
  /**
@@ -115,6 +116,16 @@ interface DocsRendererOptions {
115
116
  * than a monochrome block of DSL.
116
117
  */
117
118
  excludeLangs?: readonly string[] | undefined;
119
+ /**
120
+ * Accessible names for the copy button, for a site that is not in English.
121
+ *
122
+ * ⚠️ `rehypeCodeFrame` HAS TAKEN A `copyLabel` SINCE IT WAS WRITTEN AND
123
+ * NOTHING EVER PASSED ONE. The plugin is private, so the option was
124
+ * unreachable from every entry point — the README said the label was
125
+ * configurable and it was not. Baked into the HTML at build time, so
126
+ * overriding it costs no client bytes.
127
+ */
128
+ codeLabels?: RehypeCodeFrameOptions | undefined;
118
129
  }
119
130
  /**
120
131
  * Renders {@link DocFile}s. Build one per process and reuse it.