@waveso/docs 0.2.0 → 0.4.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 (59) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +490 -75
  3. package/dist/code-frame.d.ts +29 -0
  4. package/dist/code-frame.js +41 -0
  5. package/dist/code-meta.d.ts +48 -0
  6. package/dist/code-meta.js +72 -0
  7. package/dist/docs-content-id.d.ts +19 -0
  8. package/dist/docs-content-id.js +19 -0
  9. package/dist/docs-error.d.ts +2 -57
  10. package/dist/docs-error.js +3 -15
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/next.d.ts +153 -28
  14. package/dist/next.js +65 -33
  15. package/dist/plugins/rehype-capture-toc.js +26 -5
  16. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  17. package/dist/plugins/rehype-code-frame.js +88 -0
  18. package/dist/plugins/rehype-code-language.js +7 -1
  19. package/dist/react/code-runtime.d.ts +14 -0
  20. package/dist/react/code-runtime.js +161 -0
  21. package/dist/react/doc-content.d.ts +39 -2
  22. package/dist/react/doc-content.js +42 -10
  23. package/dist/react/layout.d.ts +44 -0
  24. package/dist/react/layout.js +65 -0
  25. package/dist/react/nav.d.ts +28 -0
  26. package/dist/react/nav.js +70 -0
  27. package/dist/react/nearest-scroll-top.d.ts +45 -0
  28. package/dist/react/nearest-scroll-top.js +44 -0
  29. package/dist/react/next-link.d.ts +34 -0
  30. package/dist/react/next-link.js +30 -0
  31. package/dist/react/next-nav.d.ts +11 -0
  32. package/dist/react/next-nav.js +32 -0
  33. package/dist/react/next-search.d.ts +22 -0
  34. package/dist/react/next-search.js +52 -0
  35. package/dist/react/search-dialog.d.ts +53 -10
  36. package/dist/react/search-dialog.js +147 -47
  37. package/dist/react/shell-labels.d.ts +43 -0
  38. package/dist/react/shell-labels.js +27 -0
  39. package/dist/react/sidebar.d.ts +38 -3
  40. package/dist/react/sidebar.js +104 -12
  41. package/dist/react/skip-link.d.ts +1 -9
  42. package/dist/react/skip-link.js +6 -5
  43. package/dist/react/toc.d.ts +12 -4
  44. package/dist/react/toc.js +18 -7
  45. package/dist/react/youtube.d.ts +31 -5
  46. package/dist/react/youtube.js +76 -54
  47. package/dist/render.d.ts +35 -1
  48. package/dist/render.js +35 -14
  49. package/dist/route-path.d.ts +46 -0
  50. package/dist/route-path.js +51 -0
  51. package/dist/search-index.d.ts +6 -23
  52. package/dist/search-index.js +6 -51
  53. package/dist/sitemap-limit.d.ts +34 -0
  54. package/dist/sitemap-limit.js +37 -0
  55. package/dist/source.d.ts +1 -23
  56. package/dist/source.js +40 -43
  57. package/dist/styles.css +1001 -106
  58. package/dist/types.d.ts +11 -2
  59. package/package.json +58 -23
@@ -0,0 +1,161 @@
1
+ "use client";
2
+ import { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_READY_ATTRIBUTE } from "../code-frame.js";
3
+ import { useEffect } from "react";
4
+ //#region src/react/code-runtime.tsx
5
+ /**
6
+ * The nine hundred bytes that make every copy button on the page work.
7
+ *
8
+ * Private. `DocContent` mounts it, and only when the tree it was handed
9
+ * actually contains a code frame — so a page with no fences ships none of
10
+ * this rather than a component that mounts and finds nothing to do.
11
+ *
12
+ * ## One listener, not one component per block
13
+ *
14
+ * The buttons are plain server-rendered HTML with no React identity at all.
15
+ * This attaches a single delegated `click` listener to `document` and one live
16
+ * region to `<body>`, both behind a module-level ref count: the first instance
17
+ * installs them, later instances increment and return, and the last one out
18
+ * removes them. Two `DocContent`s on one page therefore copy once and announce
19
+ * once — a bug nothing else would catch, because the second announcement is
20
+ * only audible to a screen-reader user.
21
+ *
22
+ * The alternative every comparable package ships — mapping `pre` to a
23
+ * `'use client'` component — puts one client reference and one hydration root
24
+ * in the flight stream per fence, and drags the highlighted subtree across the
25
+ * client boundary as children.
26
+ */
27
+ /** How long the button shows its copied state. */
28
+ const COPIED_MS = 2e3;
29
+ /**
30
+ * Line classes whose text is not part of what the reader wanted.
31
+ *
32
+ * Empty today, and deliberately an array rather than an inline condition:
33
+ * `@shikijs/transformers` lands at 0.3, and `'remove'` — the class it puts on
34
+ * a deleted diff line — goes here. Copying deleted lines into somebody's
35
+ * editor is the kind of failure that is discovered at run time, in their
36
+ * project, days later.
37
+ */
38
+ const SKIP_LINE_CLASSES = [];
39
+ /** Marker classes whose lines carry trailing whitespace worth trimming. */
40
+ const TRIMMED_LINE_CLASSES = [];
41
+ let refCount = 0;
42
+ let detach;
43
+ /**
44
+ * Mount the copy runtime. Renders nothing.
45
+ *
46
+ * Every hook of state lives in the DOM rather than in React: the button's
47
+ * copied state is a `data-copied` attribute the stylesheet reads, and the
48
+ * announcement is a live region. React owns none of these nodes, so nothing
49
+ * re-renders and there is no state to get out of step with a page that was
50
+ * server-rendered.
51
+ */
52
+ function DocsCodeRuntime() {
53
+ useEffect(() => {
54
+ refCount += 1;
55
+ if (refCount === 1) detach = install();
56
+ return () => {
57
+ refCount -= 1;
58
+ if (refCount === 0) {
59
+ detach?.();
60
+ detach = void 0;
61
+ }
62
+ };
63
+ }, []);
64
+ return null;
65
+ }
66
+ function install() {
67
+ const status = document.createElement("div");
68
+ status.setAttribute("role", "status");
69
+ status.setAttribute("aria-live", "polite");
70
+ status.className = "wave-docs-code__status";
71
+ document.body.append(status);
72
+ const onClick = (event) => {
73
+ const target = event.target;
74
+ if (!(target instanceof Element)) return;
75
+ const button = target.closest(`[${CODE_COPY_ATTRIBUTE}]`);
76
+ if (!(button instanceof HTMLElement)) return;
77
+ const pre = button.closest(`[${CODE_FRAME_ATTRIBUTE}]`)?.querySelector("pre");
78
+ if (pre === null || pre === void 0) return;
79
+ copy(readCode(pre), button, status);
80
+ };
81
+ document.addEventListener("click", onClick);
82
+ document.documentElement.setAttribute(CODE_READY_ATTRIBUTE, "");
83
+ return () => {
84
+ document.removeEventListener("click", onClick);
85
+ document.documentElement.removeAttribute(CODE_READY_ATTRIBUTE);
86
+ status.remove();
87
+ };
88
+ }
89
+ /**
90
+ * The text of a code block, as the author wrote it.
91
+ *
92
+ * ⚠️ NOT `pre.textContent`. Shiki emits one `<span class="line">` per line with
93
+ * a literal `"\n"` text node between them, so `textContent` happens to be
94
+ * right *today* — and stops being right the moment a transformer adds a line
95
+ * that should not be copied, or a gutter of line numbers that should not be
96
+ * either. Walking the lines is the same amount of code and survives both.
97
+ */
98
+ function readCode(pre) {
99
+ const lines = pre.querySelectorAll(".line");
100
+ if (lines.length === 0) return pre.textContent ?? "";
101
+ const out = [];
102
+ for (const line of lines) {
103
+ const classes = line.className.split(/\s+/);
104
+ if (classes.some((name) => SKIP_LINE_CLASSES.includes(name))) continue;
105
+ const text = line.textContent ?? "";
106
+ out.push(classes.some((name) => TRIMMED_LINE_CLASSES.includes(name)) ? text.replace(/\s+$/, "") : text);
107
+ }
108
+ return out.join("\n");
109
+ }
110
+ async function copy(text, button, status) {
111
+ const copied = await writeClipboard(text) ? "true" : "false";
112
+ button.dataset.copied = copied;
113
+ status.textContent = copied === "true" ? "Copied to the clipboard." : "Copy failed. Select the code and press Control or Command + C.";
114
+ const existing = timers.get(button);
115
+ if (existing !== void 0) window.clearTimeout(existing);
116
+ timers.set(button, window.setTimeout(() => {
117
+ timers.delete(button);
118
+ button.removeAttribute("data-copied");
119
+ }, COPIED_MS));
120
+ }
121
+ /**
122
+ * The pending "clear the indicator" timer per button.
123
+ *
124
+ * A `WeakMap`, so a button removed by a client-side navigation takes its entry
125
+ * with it — this module is a page-lifetime singleton and a `Map` here would
126
+ * hold every code block the reader ever copied from.
127
+ */
128
+ const timers = /* @__PURE__ */ new WeakMap();
129
+ async function writeClipboard(text) {
130
+ if (window.isSecureContext && navigator.clipboard !== void 0) try {
131
+ await navigator.clipboard.writeText(text);
132
+ return true;
133
+ } catch {}
134
+ return legacyCopy(text);
135
+ }
136
+ /**
137
+ * `execCommand('copy')`, which is deprecated and still the only thing that
138
+ * works over plain HTTP.
139
+ *
140
+ * When it finally goes, this returns `false` and the reader gets the
141
+ * instruction — which is why that message is written as an instruction rather
142
+ * than as an apology.
143
+ */
144
+ function legacyCopy(text) {
145
+ const area = document.createElement("textarea");
146
+ area.value = text;
147
+ area.setAttribute("readonly", "");
148
+ area.setAttribute("aria-hidden", "true");
149
+ area.style.cssText = "position:fixed;top:-9999px;opacity:0;";
150
+ document.body.append(area);
151
+ try {
152
+ area.select();
153
+ return document.execCommand("copy");
154
+ } catch {
155
+ return false;
156
+ } finally {
157
+ area.remove();
158
+ }
159
+ }
160
+ //#endregion
161
+ export { DocsCodeRuntime };
@@ -10,20 +10,57 @@ interface DocContentProps {
10
10
  hast: Root;
11
11
  /** Overrides, merged over {@link defaultMarkdownComponents}. */
12
12
  components?: MarkdownComponents | undefined;
13
+ /**
14
+ * Appended to `wave-docs-prose`, never substituted for it.
15
+ *
16
+ * Substitution is the failure this whole wrapper exists to prevent, so it is
17
+ * not offered: almost every rule in the stylesheet is scoped under
18
+ * `.wave-docs-prose`, and dropping it leaves a page whose code blocks still
19
+ * carry correct syntax colours and nothing else — which reads as a design
20
+ * choice rather than as a mistake.
21
+ */
22
+ className?: string | undefined;
13
23
  }
14
24
  /**
15
- * Render a hast tree as React elements.
25
+ * Render a hast tree as React elements, inside the prose wrapper.
16
26
  *
17
27
  * Not a client component, and it must stay that way: the markdown parser and
18
28
  * Shiki ran in Node at build time, and this component only walks the resulting
19
29
  * tree. Nothing here pulls unified, remark or a highlighter into the browser.
20
30
  *
31
+ * ## Why the wrapper is here and not on your `<article>`
32
+ *
33
+ * `.wave-docs-prose` is the scope for nearly every rule in `styles.css` —
34
+ * including `.wave-docs-prose .shiki`, which is deliberately scoped so the
35
+ * package never styles a code block it did not render. `createDocsRoute.Page`
36
+ * always put the class on for you, but the documented hand-rolled path made
37
+ * the consumer type it, and forgetting it silently unstyled every code block
38
+ * on the site while leaving the syntax colours intact. One component owning
39
+ * the class removes the way to get that wrong.
40
+ *
41
+ * The rules that care about tree shape are `.wave-docs-prose > * + *` and
42
+ * `.wave-docs-prose > :is(h2…h6)`, and the tree's own children are this
43
+ * element's direct children, so nothing moves.
44
+ *
45
+ * ## The copy runtime is mounted here
46
+ *
47
+ * Because this is the component no consumer can avoid: `createDocsRoute.Page`
48
+ * renders it, and the documented hand-rolled route renders it directly. Wiring
49
+ * the listener from `docs.Layout` instead would ship dead buttons to everyone
50
+ * composing their own shell, and "what about someone not using the layout?"
51
+ * would be a caveat rather than a non-question.
52
+ *
53
+ * It renders only when the tree actually contains a code frame. The server has
54
+ * the tree in hand, the check is one pass, and the result is that a page
55
+ * without fences ships zero extra bytes rather than a runtime with nothing to
56
+ * do.
57
+ *
21
58
  * `passNode` is left off (the default). `react-markdown` hardcodes it *on* with
22
59
  * no opt-out, so any mapped component that spreads its props renders
23
60
  * `node="[object Object]"` into production HTML — with no type error to warn
24
61
  * you, because `node` is a legal prop on the component and an unknown attribute
25
62
  * on the element.
26
63
  */
27
- declare function DocContent({ hast, components }: DocContentProps): ReactNode;
64
+ declare function DocContent({ hast, components, className }: DocContentProps): ReactNode;
28
65
  //#endregion
29
66
  export { DocContent, DocContentProps };
@@ -1,29 +1,61 @@
1
+ import { hasCodeFrame } from "../code-frame.js";
2
+ import { DocsCodeRuntime } from "./code-runtime.js";
1
3
  import { defaultMarkdownComponents } from "./markdown-components.js";
2
4
  import { toJsxRuntime } from "hast-util-to-jsx-runtime";
3
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
6
  //#region src/react/doc-content.tsx
5
7
  /**
6
- * Render a hast tree as React elements.
8
+ * Render a hast tree as React elements, inside the prose wrapper.
7
9
  *
8
10
  * Not a client component, and it must stay that way: the markdown parser and
9
11
  * Shiki ran in Node at build time, and this component only walks the resulting
10
12
  * tree. Nothing here pulls unified, remark or a highlighter into the browser.
11
13
  *
14
+ * ## Why the wrapper is here and not on your `<article>`
15
+ *
16
+ * `.wave-docs-prose` is the scope for nearly every rule in `styles.css` —
17
+ * including `.wave-docs-prose .shiki`, which is deliberately scoped so the
18
+ * package never styles a code block it did not render. `createDocsRoute.Page`
19
+ * always put the class on for you, but the documented hand-rolled path made
20
+ * the consumer type it, and forgetting it silently unstyled every code block
21
+ * on the site while leaving the syntax colours intact. One component owning
22
+ * the class removes the way to get that wrong.
23
+ *
24
+ * The rules that care about tree shape are `.wave-docs-prose > * + *` and
25
+ * `.wave-docs-prose > :is(h2…h6)`, and the tree's own children are this
26
+ * element's direct children, so nothing moves.
27
+ *
28
+ * ## The copy runtime is mounted here
29
+ *
30
+ * Because this is the component no consumer can avoid: `createDocsRoute.Page`
31
+ * renders it, and the documented hand-rolled route renders it directly. Wiring
32
+ * the listener from `docs.Layout` instead would ship dead buttons to everyone
33
+ * composing their own shell, and "what about someone not using the layout?"
34
+ * would be a caveat rather than a non-question.
35
+ *
36
+ * It renders only when the tree actually contains a code frame. The server has
37
+ * the tree in hand, the check is one pass, and the result is that a page
38
+ * without fences ships zero extra bytes rather than a runtime with nothing to
39
+ * do.
40
+ *
12
41
  * `passNode` is left off (the default). `react-markdown` hardcodes it *on* with
13
42
  * no opt-out, so any mapped component that spreads its props renders
14
43
  * `node="[object Object]"` into production HTML — with no type error to warn
15
44
  * you, because `node` is a legal prop on the component and an unknown attribute
16
45
  * on the element.
17
46
  */
18
- function DocContent({ hast, components }) {
19
- return toJsxRuntime(hast, {
20
- Fragment,
21
- jsx,
22
- jsxs,
23
- components: {
24
- ...defaultMarkdownComponents,
25
- ...components
26
- }
47
+ function DocContent({ hast, components, className }) {
48
+ return /* @__PURE__ */ jsxs("div", {
49
+ className: className === void 0 || className === "" ? "wave-docs-prose" : `wave-docs-prose ${className}`,
50
+ children: [hasCodeFrame(hast) ? /* @__PURE__ */ jsx(DocsCodeRuntime, {}) : null, toJsxRuntime(hast, {
51
+ Fragment,
52
+ jsx,
53
+ jsxs,
54
+ components: {
55
+ ...defaultMarkdownComponents,
56
+ ...components
57
+ }
58
+ })]
27
59
  });
28
60
  }
29
61
  //#endregion
@@ -0,0 +1,44 @@
1
+ import { DocNavNode } from "../types.js";
2
+ import { DocsSearchProps } from "./next-search.js";
3
+ import { DocsLabels } from "./shell-labels.js";
4
+ import { ReactNode } from "react";
5
+ //#region src/react/layout.d.ts
6
+ /**
7
+ * What a host may say about the search trigger, minus the URL.
8
+ *
9
+ * `indexUrl` is derived from `basePath` and is not negotiable here: the whole
10
+ * reason `docs.Layout` exists is that nobody should have to know the index's
11
+ * address, and a hand-passed one is wrong under every non-root `basePath`.
12
+ */
13
+ type DocsLayoutSearchProps = Omit<DocsSearchProps, 'indexUrl'>;
14
+ interface DocsLayoutShellProps {
15
+ children: ReactNode;
16
+ nav: DocNavNode[];
17
+ searchIndexUrl: string;
18
+ title?: ReactNode;
19
+ actions?: ReactNode;
20
+ /**
21
+ * `false` to omit the trigger; an object to configure it.
22
+ *
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.
31
+ */
32
+ search?: boolean | DocsLayoutSearchProps | undefined;
33
+ /**
34
+ * The four strings the chrome renders, for a site that is not in English.
35
+ *
36
+ * `DocsNav` declared `label` and `closeLabel`, documented them and defaulted
37
+ * them — and this component, the only thing that renders `DocsNav`, never
38
+ * passed either. Configuration that could not be configured.
39
+ */
40
+ labels?: DocsLabels | undefined;
41
+ }
42
+ declare function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search, labels }: DocsLayoutShellProps): ReactNode;
43
+ //#endregion
44
+ export { DocsLayoutSearchProps, DocsLayoutShell, DocsLayoutShellProps };
@@ -0,0 +1,65 @@
1
+ import { DocsSearch } from "./next-search.js";
2
+ import { resolveLabels } from "./shell-labels.js";
3
+ import { DOCS_NAV_ID } from "./nav.js";
4
+ import { DocsNextNav } from "./next-nav.js";
5
+ import { SkipLink } from "./skip-link.js";
6
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
+ //#region src/react/layout.tsx
8
+ function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search = true, labels }) {
9
+ const text = resolveLabels(labels);
10
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
11
+ /* @__PURE__ */ jsx(SkipLink, { children: text.skipToContent }),
12
+ /* @__PURE__ */ jsx("header", {
13
+ className: "wave-docs-layout__header",
14
+ children: /* @__PURE__ */ jsxs("div", {
15
+ className: "wave-docs-layout__header-inner",
16
+ children: [
17
+ /* @__PURE__ */ jsx("button", {
18
+ type: "button",
19
+ className: "wave-docs-layout__nav-trigger",
20
+ "aria-label": text.openNav,
21
+ command: "show-modal",
22
+ commandfor: DOCS_NAV_ID,
23
+ children: /* @__PURE__ */ jsx("svg", {
24
+ "aria-hidden": "true",
25
+ viewBox: "0 0 16 16",
26
+ width: "18",
27
+ height: "18",
28
+ fill: "none",
29
+ stroke: "currentColor",
30
+ strokeWidth: "1.5",
31
+ strokeLinecap: "round",
32
+ children: /* @__PURE__ */ jsx("path", { d: "M2.5 4h11M2.5 8h11M2.5 12h11" })
33
+ })
34
+ }),
35
+ title === void 0 ? null : /* @__PURE__ */ jsx("div", {
36
+ className: "wave-docs-layout__title",
37
+ children: title
38
+ }),
39
+ search === false ? null : /* @__PURE__ */ jsx(DocsSearch, {
40
+ indexUrl: searchIndexUrl,
41
+ className: "wave-docs-layout__search",
42
+ ...search === true ? {} : search
43
+ }),
44
+ actions === void 0 ? null : /* @__PURE__ */ jsx("div", {
45
+ className: "wave-docs-layout__actions",
46
+ children: actions
47
+ })
48
+ ]
49
+ })
50
+ }),
51
+ /* @__PURE__ */ jsxs("div", {
52
+ className: "wave-docs-layout",
53
+ children: [/* @__PURE__ */ jsx("div", {
54
+ className: "wave-docs-layout__sidebar",
55
+ children: /* @__PURE__ */ jsx(DocsNextNav, {
56
+ nav,
57
+ label: text.nav,
58
+ closeLabel: text.closeNav
59
+ })
60
+ }), children]
61
+ })
62
+ ] });
63
+ }
64
+ //#endregion
65
+ export { DocsLayoutShell };
@@ -0,0 +1,28 @@
1
+ import { DocNavNode } from "../types.js";
2
+ import { DocsLinkComponent } from "./markdown-components.js";
3
+ import { ReactNode } from "react";
4
+ //#region src/react/nav.d.ts
5
+ /**
6
+ * The drawer's `id`, and the header trigger's `commandfor`.
7
+ *
8
+ * A constant rather than a `useId`, for two reasons that both matter: the
9
+ * trigger is rendered on the server in a different subtree and cannot see a
10
+ * hook's value, and `command`/`commandfor` must agree before React hydrates or
11
+ * the button does nothing on the first tap.
12
+ */
13
+ declare const DOCS_NAV_ID = "wave-docs-nav";
14
+ interface DocsNavProps {
15
+ /** The tree from `docs.source.nav()`. */
16
+ nav: DocNavNode[];
17
+ /** Current route. Injected, so this stays testable without a router. */
18
+ pathname: string;
19
+ /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
20
+ Link?: DocsLinkComponent | undefined;
21
+ /** Accessible name for the nav landmark and the drawer. */
22
+ label?: string | undefined;
23
+ /** Accessible name for the close button. */
24
+ closeLabel?: string | undefined;
25
+ }
26
+ declare function DocsNav({ nav, pathname, Link, label, closeLabel }: DocsNavProps): ReactNode;
27
+ //#endregion
28
+ export { DOCS_NAV_ID, DocsNav, DocsNavProps };
@@ -0,0 +1,70 @@
1
+ "use client";
2
+ import { DocsSidebar } from "./sidebar.js";
3
+ import { useEffect, useRef } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ //#region src/react/nav.tsx
6
+ /**
7
+ * The drawer's `id`, and the header trigger's `commandfor`.
8
+ *
9
+ * A constant rather than a `useId`, for two reasons that both matter: the
10
+ * trigger is rendered on the server in a different subtree and cannot see a
11
+ * hook's value, and `command`/`commandfor` must agree before React hydrates or
12
+ * the button does nothing on the first tap.
13
+ */
14
+ const DOCS_NAV_ID = "wave-docs-nav";
15
+ function DocsNav({ nav, pathname, Link, label = "Documentation", closeLabel = "Close navigation" }) {
16
+ const ref = useRef(null);
17
+ useEffect(() => {
18
+ ref.current?.close?.();
19
+ }, [pathname]);
20
+ useEffect(() => {
21
+ if ("command" in HTMLButtonElement.prototype) return;
22
+ const onClick = (event) => {
23
+ const target = event.target;
24
+ if (!(target instanceof Element)) return;
25
+ const button = target.closest("button[commandfor]");
26
+ if (button === null) return;
27
+ if (button.getAttribute("commandfor") !== "wave-docs-nav") return;
28
+ const dialog = ref.current;
29
+ if (dialog === null) return;
30
+ if (button.getAttribute("command") === "close") dialog.close();
31
+ else dialog.showModal();
32
+ };
33
+ document.addEventListener("click", onClick);
34
+ return () => {
35
+ document.removeEventListener("click", onClick);
36
+ };
37
+ }, []);
38
+ return /* @__PURE__ */ jsxs("dialog", {
39
+ ref,
40
+ id: DOCS_NAV_ID,
41
+ className: "wave-docs-layout__drawer",
42
+ closedby: "any",
43
+ "aria-label": label,
44
+ children: [/* @__PURE__ */ jsx("button", {
45
+ type: "button",
46
+ className: "wave-docs-layout__drawer-close",
47
+ "aria-label": closeLabel,
48
+ command: "close",
49
+ commandfor: DOCS_NAV_ID,
50
+ children: /* @__PURE__ */ jsx("svg", {
51
+ "aria-hidden": "true",
52
+ viewBox: "0 0 16 16",
53
+ width: "16",
54
+ height: "16",
55
+ fill: "none",
56
+ stroke: "currentColor",
57
+ strokeWidth: "1.5",
58
+ strokeLinecap: "round",
59
+ children: /* @__PURE__ */ jsx("path", { d: "M4 4l8 8M12 4l-8 8" })
60
+ })
61
+ }), /* @__PURE__ */ jsx(DocsSidebar, {
62
+ nav,
63
+ pathname,
64
+ label,
65
+ Link
66
+ })]
67
+ });
68
+ }
69
+ //#endregion
70
+ export { DOCS_NAV_ID, DocsNav };
@@ -0,0 +1,45 @@
1
+ //#region src/react/nearest-scroll-top.d.ts
2
+ /**
3
+ * Where a scrollport should be scrolled to so an item inside it is visible,
4
+ * or `undefined` when it already is.
5
+ *
6
+ * Private, and pure, and separate from the component for one reason: this is
7
+ * the whole of the geometry, and geometry is the half that can be tested
8
+ * exhaustively. jsdom reports every rectangle as zero, so a test that drove
9
+ * the effect would assert nothing about the arithmetic.
10
+ *
11
+ * ## Why not `scrollIntoView`
12
+ *
13
+ * ⚠️ `element.scrollIntoView({ block: 'nearest' })` LOOKS LIKE THE ANSWER AND
14
+ * IS A TRAP. It scrolls **every** scrollable ancestor, the document included.
15
+ * On a docs page that means opening a deep link scrolls the sidebar *and*
16
+ * jumps the article the reader came to read — a page that silently moves under
17
+ * them, on the one navigation where they know exactly what they asked for.
18
+ *
19
+ * So the caller finds the nearest scrollable ancestor and assigns `scrollTop`
20
+ * itself. `sidebar.test.tsx` spies on `scrollIntoView` and asserts it is never
21
+ * called: a test for the API deliberately not used, which is the only thing
22
+ * that stops the page-jump being reintroduced by someone simplifying the code.
23
+ */
24
+ interface NearestScrollTopInput {
25
+ /** The item's offset from the top of the scrollport's content. */
26
+ itemTop: number;
27
+ itemHeight: number;
28
+ /** The scrollport's visible height. */
29
+ viewHeight: number;
30
+ /** Where the scrollport is scrolled to now. */
31
+ scrollTop: number;
32
+ /** Total scrollable content height, used to clamp. */
33
+ scrollHeight: number;
34
+ }
35
+ /**
36
+ * The new `scrollTop`, or `undefined` when nothing should move.
37
+ *
38
+ * `undefined` for the already-visible case is not an optimisation: it is the
39
+ * common case — most navigations are to a page already on screen — and
40
+ * assigning `scrollTop` to its current value still cancels a smooth scroll in
41
+ * progress and still fires a `scroll` event.
42
+ */
43
+ declare function nearestScrollTop({ itemTop, itemHeight, viewHeight, scrollTop, scrollHeight }: NearestScrollTopInput): number | undefined;
44
+ //#endregion
45
+ export { NearestScrollTopInput, nearestScrollTop };
@@ -0,0 +1,44 @@
1
+ //#region src/react/nearest-scroll-top.ts
2
+ /**
3
+ * Where a scrollport should be scrolled to so an item inside it is visible,
4
+ * or `undefined` when it already is.
5
+ *
6
+ * Private, and pure, and separate from the component for one reason: this is
7
+ * the whole of the geometry, and geometry is the half that can be tested
8
+ * exhaustively. jsdom reports every rectangle as zero, so a test that drove
9
+ * the effect would assert nothing about the arithmetic.
10
+ *
11
+ * ## Why not `scrollIntoView`
12
+ *
13
+ * ⚠️ `element.scrollIntoView({ block: 'nearest' })` LOOKS LIKE THE ANSWER AND
14
+ * IS A TRAP. It scrolls **every** scrollable ancestor, the document included.
15
+ * On a docs page that means opening a deep link scrolls the sidebar *and*
16
+ * jumps the article the reader came to read — a page that silently moves under
17
+ * them, on the one navigation where they know exactly what they asked for.
18
+ *
19
+ * So the caller finds the nearest scrollable ancestor and assigns `scrollTop`
20
+ * itself. `sidebar.test.tsx` spies on `scrollIntoView` and asserts it is never
21
+ * called: a test for the API deliberately not used, which is the only thing
22
+ * that stops the page-jump being reintroduced by someone simplifying the code.
23
+ */
24
+ /** Breathing room above or below the item, so it is not flush against the edge. */
25
+ const MARGIN = 16;
26
+ /**
27
+ * The new `scrollTop`, or `undefined` when nothing should move.
28
+ *
29
+ * `undefined` for the already-visible case is not an optimisation: it is the
30
+ * common case — most navigations are to a page already on screen — and
31
+ * assigning `scrollTop` to its current value still cancels a smooth scroll in
32
+ * progress and still fires a `scroll` event.
33
+ */
34
+ function nearestScrollTop({ itemTop, itemHeight, viewHeight, scrollTop, scrollHeight }) {
35
+ if (viewHeight <= 0 || scrollHeight <= viewHeight) return void 0;
36
+ const itemBottom = itemTop + itemHeight;
37
+ const viewBottom = scrollTop + viewHeight;
38
+ const target = itemTop < scrollTop ? itemTop - MARGIN : itemBottom > viewBottom ? itemBottom - viewHeight + MARGIN : void 0;
39
+ if (target === void 0) return void 0;
40
+ const clamped = Math.max(0, Math.min(target, scrollHeight - viewHeight));
41
+ return itemHeight > viewHeight ? Math.max(0, Math.min(itemTop - MARGIN, scrollHeight - viewHeight)) : clamped;
42
+ }
43
+ //#endregion
44
+ export { nearestScrollTop };
@@ -0,0 +1,34 @@
1
+ import { DocsLinkComponent } from "./markdown-components.js";
2
+ import { ComponentProps, ComponentType } from "react";
3
+ //#region src/react/next-link.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/next-link.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 };
@@ -0,0 +1,11 @@
1
+ import { DocNavNode } from "../types.js";
2
+ import { ReactNode } from "react";
3
+ //#region src/react/next-nav.d.ts
4
+ interface DocsNextNavProps {
5
+ nav: DocNavNode[];
6
+ label?: string | undefined;
7
+ closeLabel?: string | undefined;
8
+ }
9
+ declare function DocsNextNav({ nav, label, closeLabel }: DocsNextNavProps): ReactNode;
10
+ //#endregion
11
+ export { DocsNextNav, DocsNextNavProps };