@waveso/docs 0.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +518 -0
  3. package/dist/frontmatter.d.ts +55 -0
  4. package/dist/frontmatter.js +80 -0
  5. package/dist/highlighter.d.ts +99 -0
  6. package/dist/highlighter.js +183 -0
  7. package/dist/meta.d.ts +75 -0
  8. package/dist/meta.js +183 -0
  9. package/dist/next.d.ts +256 -0
  10. package/dist/next.js +365 -0
  11. package/dist/plugins/rehype-capture-toc.d.ts +18 -0
  12. package/dist/plugins/rehype-capture-toc.js +69 -0
  13. package/dist/plugins/remark-doc-links.d.ts +63 -0
  14. package/dist/plugins/remark-doc-links.js +122 -0
  15. package/dist/plugins/remark-unwrap-images.d.ts +11 -0
  16. package/dist/plugins/remark-unwrap-images.js +25 -0
  17. package/dist/plugins/remark-youtube.d.ts +22 -0
  18. package/dist/plugins/remark-youtube.js +84 -0
  19. package/dist/react/callout.d.ts +37 -0
  20. package/dist/react/callout.js +113 -0
  21. package/dist/react/doc-content.d.ts +29 -0
  22. package/dist/react/doc-content.js +30 -0
  23. package/dist/react/markdown-components.d.ts +84 -0
  24. package/dist/react/markdown-components.js +122 -0
  25. package/dist/react/search-dialog.d.ts +41 -0
  26. package/dist/react/search-dialog.js +404 -0
  27. package/dist/react/sidebar.d.ts +29 -0
  28. package/dist/react/sidebar.js +196 -0
  29. package/dist/react/skip-link.d.ts +37 -0
  30. package/dist/react/skip-link.js +37 -0
  31. package/dist/react/toc.d.ts +35 -0
  32. package/dist/react/toc.js +87 -0
  33. package/dist/react/youtube.d.ts +27 -0
  34. package/dist/react/youtube.js +75 -0
  35. package/dist/render.d.ts +72 -0
  36. package/dist/render.js +279 -0
  37. package/dist/search-index.d.ts +51 -0
  38. package/dist/search-index.js +274 -0
  39. package/dist/search-options.d.ts +18 -0
  40. package/dist/search-options.js +40 -0
  41. package/dist/source.d.ts +67 -0
  42. package/dist/source.js +332 -0
  43. package/dist/styles.css +1033 -0
  44. package/dist/types.d.ts +334 -0
  45. package/dist/types.js +0 -0
  46. package/package.json +166 -0
@@ -0,0 +1,84 @@
1
+ import { CalloutProps } from "./callout.js";
2
+ import { YouTubeProps } from "./youtube.js";
3
+ import { ComponentProps, ComponentType, JSX, ReactNode } from "react";
4
+ //#region src/react/markdown-components.d.ts
5
+ /**
6
+ * The component map handed to `hast-util-to-jsx-runtime`.
7
+ *
8
+ * Deliberately not that package's own `Components` type. `Components` is keyed
9
+ * by `keyof JSX.IntrinsicElements` resolved from the *global* `JSX` namespace,
10
+ * which React 19 no longer declares — so under this repo's config it collapses
11
+ * to a `string`-keyed map of `any`, and elsewhere it rejects the custom tag
12
+ * names our pipeline emits (`callout`, `youtube`). Keying off `React.JSX`
13
+ * explicitly gives real prop types in both worlds and room for our own tags.
14
+ *
15
+ * Note the absence of `node`: {@link DocContent} renders with `passNode: false`,
16
+ * so a component here can spread its props onto a DOM element without leaking
17
+ * `node="[object Object]"` into the HTML.
18
+ */
19
+ type MarkdownComponents = { [Tag in keyof JSX.IntrinsicElements]?: ComponentType<JSX.IntrinsicElements[Tag]>; } & {
20
+ /** `<callout type="warning">`, emitted from `> [!WARNING]`. */
21
+ callout?: ComponentType<CalloutProps>;
22
+ /** `<youtube id="...">`, emitted from a bare YouTube URL. */
23
+ youtube?: ComponentType<YouTubeProps>;
24
+ };
25
+ /**
26
+ * Props the injected link component must accept.
27
+ *
28
+ * Shaped to be satisfied by `next/link` without this package ever importing it
29
+ * — importing `next/*` here would couple the React layer to Next, and keeping
30
+ * it host-agnostic is half the point of the package. Do not "simplify" this
31
+ * by importing `next/link` directly.
32
+ */
33
+ interface DocsLinkProps extends Omit<ComponentProps<'a'>, 'href' | 'ref'> {
34
+ href: string;
35
+ /** Honoured by `next/link`; ignored by a plain `<a>`. */
36
+ prefetch?: boolean | undefined;
37
+ children?: ReactNode;
38
+ }
39
+ /** A `next/link`-compatible component. */
40
+ type DocsLinkComponent = ComponentType<DocsLinkProps>;
41
+ /**
42
+ * Props the injected image component must accept.
43
+ *
44
+ * `width`/`height` are required because `next/image` refuses to render without
45
+ * them (short of `fill`); they come from the build-time `ImageResolver`.
46
+ */
47
+ interface DocsImageProps {
48
+ src: string;
49
+ alt: string;
50
+ width: number;
51
+ height: number;
52
+ title?: string | undefined;
53
+ className?: string | undefined;
54
+ sizes?: string | undefined;
55
+ loading?: 'eager' | 'lazy' | undefined;
56
+ }
57
+ /** A `next/image`-compatible component. */
58
+ type DocsImageComponent = ComponentType<DocsImageProps>;
59
+ interface MarkdownComponentsOptions {
60
+ /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
61
+ Link?: DocsLinkComponent | undefined;
62
+ /** Optimising image component, e.g. `next/image`. Falls back to `<img>`. */
63
+ Image?: DocsImageComponent | undefined;
64
+ }
65
+ /**
66
+ * Build the default component map, optionally injecting host-specific link and
67
+ * image components.
68
+ *
69
+ * Call it once at module scope: every call returns fresh component identities,
70
+ * and remounting the whole document on each render is not what you want.
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * // Next.js
75
+ * const components = createMarkdownComponents({ Link, Image });
76
+ * // Any other host — plain <a> and <img>
77
+ * const components = createMarkdownComponents();
78
+ * ```
79
+ */
80
+ declare function createMarkdownComponents(options?: MarkdownComponentsOptions): MarkdownComponents;
81
+ /** The map used when a caller supplies none. Plain `<a>` and `<img>`. */
82
+ declare const defaultMarkdownComponents: MarkdownComponents;
83
+ //#endregion
84
+ export { DocsImageComponent, DocsImageProps, DocsLinkComponent, DocsLinkProps, MarkdownComponents, MarkdownComponentsOptions, createMarkdownComponents, defaultMarkdownComponents };
@@ -0,0 +1,122 @@
1
+ import { Callout } from "./callout.js";
2
+ import { YouTube } from "./youtube.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/react/markdown-components.tsx
5
+ /** Schemes we send to a new tab. `mailto:`/`tel:` are left to the OS. */
6
+ const HTTP_SCHEME = /^https?:\/\//i;
7
+ /** Any URL with a scheme, or protocol-relative. */
8
+ const ABSOLUTE_URL = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
9
+ function joinClassNames(...values) {
10
+ const joined = values.filter(Boolean).join(" ");
11
+ return joined === "" ? void 0 : joined;
12
+ }
13
+ function toDimension(value) {
14
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
15
+ if (typeof value === "string" && value.trim() !== "") {
16
+ const parsed = Number(value);
17
+ return Number.isFinite(parsed) ? parsed : void 0;
18
+ }
19
+ }
20
+ function createAnchor(Link) {
21
+ return function MarkdownAnchor({ href, children, ...rest }) {
22
+ if (href === void 0) return /* @__PURE__ */ jsx("a", {
23
+ ...rest,
24
+ children
25
+ });
26
+ if (HTTP_SCHEME.test(href) || href.startsWith("//")) return /* @__PURE__ */ jsxs("a", {
27
+ ...rest,
28
+ href,
29
+ target: "_blank",
30
+ rel: "noopener noreferrer",
31
+ children: [children, /* @__PURE__ */ jsx("span", {
32
+ className: "wave-docs-sr-only",
33
+ children: " (opens in a new tab)"
34
+ })]
35
+ });
36
+ if (href.startsWith("#") || ABSOLUTE_URL.test(href) || Link === void 0) return /* @__PURE__ */ jsx("a", {
37
+ ...rest,
38
+ href,
39
+ children
40
+ });
41
+ return /* @__PURE__ */ jsx(Link, {
42
+ ...rest,
43
+ href,
44
+ children
45
+ });
46
+ };
47
+ }
48
+ function createImage(Image) {
49
+ return function MarkdownImage({ src, alt, width, height, title, className, ...rest }) {
50
+ const resolvedWidth = toDimension(width);
51
+ const resolvedHeight = toDimension(height);
52
+ if (Image !== void 0 && typeof src === "string" && resolvedWidth !== void 0 && resolvedHeight !== void 0) return /* @__PURE__ */ jsx(Image, {
53
+ src,
54
+ alt: alt ?? "",
55
+ width: resolvedWidth,
56
+ height: resolvedHeight,
57
+ title,
58
+ className: joinClassNames("wave-docs-image", className),
59
+ loading: "lazy"
60
+ });
61
+ return /* @__PURE__ */ jsx("img", {
62
+ ...rest,
63
+ src,
64
+ alt: alt ?? "",
65
+ width,
66
+ height,
67
+ title,
68
+ className: joinClassNames("wave-docs-image", className),
69
+ loading: "lazy",
70
+ decoding: "async"
71
+ });
72
+ };
73
+ }
74
+ /**
75
+ * A table wrapped in its own scroll container.
76
+ *
77
+ * A wide table cannot be made scrollable by CSS alone without an extra
78
+ * element, and a scroll container that is not focusable cannot be scrolled by
79
+ * keyboard at all — hence the `tabIndex`, which is the documented exception to
80
+ * "no tabindex on non-interactive elements", not a violation of it. A labelled
81
+ * `<section>` is a `region` landmark, so the tab stop announces itself instead
82
+ * of being a mystery stop in the tab order.
83
+ */
84
+ function MarkdownTable({ className, ...rest }) {
85
+ return /* @__PURE__ */ jsx("section", {
86
+ className: "wave-docs-table-scroll",
87
+ "aria-label": "Table",
88
+ tabIndex: 0,
89
+ children: /* @__PURE__ */ jsx("table", {
90
+ ...rest,
91
+ className: joinClassNames("wave-docs-table", className)
92
+ })
93
+ });
94
+ }
95
+ /**
96
+ * Build the default component map, optionally injecting host-specific link and
97
+ * image components.
98
+ *
99
+ * Call it once at module scope: every call returns fresh component identities,
100
+ * and remounting the whole document on each render is not what you want.
101
+ *
102
+ * @example
103
+ * ```tsx
104
+ * // Next.js
105
+ * const components = createMarkdownComponents({ Link, Image });
106
+ * // Any other host — plain <a> and <img>
107
+ * const components = createMarkdownComponents();
108
+ * ```
109
+ */
110
+ function createMarkdownComponents(options = {}) {
111
+ return {
112
+ a: createAnchor(options.Link),
113
+ img: createImage(options.Image),
114
+ table: MarkdownTable,
115
+ callout: Callout,
116
+ youtube: YouTube
117
+ };
118
+ }
119
+ /** The map used when a caller supplies none. Plain `<a>` and `<img>`. */
120
+ const defaultMarkdownComponents = createMarkdownComponents();
121
+ //#endregion
122
+ export { createMarkdownComponents, defaultMarkdownComponents };
@@ -0,0 +1,41 @@
1
+ import { DocsLinkComponent } from "./markdown-components.js";
2
+ import { ReactNode } from "react";
3
+ //#region src/react/search-dialog.d.ts
4
+ interface SearchDialogProps {
5
+ /**
6
+ * URL of the serialised index, e.g. `/search-index.json`. Whatever
7
+ * `writeSearchIndex` wrote, served as a static asset.
8
+ */
9
+ indexUrl: string;
10
+ /**
11
+ * Router navigation for the selected result. Injected rather than imported
12
+ * so this component stays host-agnostic — `next/navigation`'s
13
+ * `useRouter().push`, a `react-router` navigate, or `location.assign`.
14
+ */
15
+ navigate: (href: string) => void;
16
+ /**
17
+ * Optional link component for results, e.g. `next/link`, so hovering a hit
18
+ * prefetches the page. Results fall back to a plain anchor.
19
+ */
20
+ Link?: DocsLinkComponent;
21
+ /** Trigger button label. Defaults to `'Search'`. */
22
+ triggerLabel?: string;
23
+ /** Input placeholder. Defaults to `'Search documentation'`. */
24
+ placeholder?: string;
25
+ /** Accessible name for the dialog. Defaults to `'Search documentation'`. */
26
+ dialogLabel?: string;
27
+ /** Maximum results rendered. Defaults to 8. */
28
+ maxResults?: number;
29
+ /** Input debounce in milliseconds. Defaults to 120. */
30
+ debounceMs?: number;
31
+ }
32
+ /**
33
+ * Search trigger plus its dialog.
34
+ *
35
+ * Render it once, wherever the trigger belongs — the dialog itself is
36
+ * portalled to `document.body`, so a navbar's stacking context cannot trap
37
+ * it behind the page.
38
+ */
39
+ declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs }: SearchDialogProps): ReactNode;
40
+ //#endregion
41
+ export { SearchDialog, SearchDialogProps };
@@ -0,0 +1,404 @@
1
+ "use client";
2
+ import { SEARCH_INDEX_OPTIONS } from "../search-options.js";
3
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ import { createPortal } from "react-dom";
6
+ //#region src/react/search-dialog.tsx
7
+ /**
8
+ * Tab stops inside the dialog. Every clause excludes `tabindex="-1"`: the
9
+ * result links are anchors *and* are deliberately out of the tab order, and a
10
+ * trap that counted them would let Tab escape the dialog at the last real
11
+ * control.
12
+ */
13
+ const FOCUSABLE_SELECTOR = [
14
+ "a[href]",
15
+ "button:not([disabled])",
16
+ "input:not([disabled])",
17
+ "select:not([disabled])",
18
+ "textarea:not([disabled])",
19
+ "[tabindex]"
20
+ ].map((selector) => `${selector}:not([tabindex="-1"])`).join(", ");
21
+ /**
22
+ * Search trigger plus its dialog.
23
+ *
24
+ * Render it once, wherever the trigger belongs — the dialog itself is
25
+ * portalled to `document.body`, so a navbar's stacking context cannot trap
26
+ * it behind the page.
27
+ */
28
+ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", maxResults = 8, debounceMs = 120 }) {
29
+ const [isOpen, setIsOpen] = useState(false);
30
+ const [query, setQuery] = useState("");
31
+ const [hits, setHits] = useState([]);
32
+ const [activeIndex, setActiveIndex] = useState(0);
33
+ const [status, setStatus] = useState("idle");
34
+ const [shortcutHint, setShortcutHint] = useState("");
35
+ const dialogRef = useRef(null);
36
+ const triggerRef = useRef(null);
37
+ const inputRef = useRef(null);
38
+ const listRef = useRef(null);
39
+ const returnFocusRef = useRef(null);
40
+ const indexRef = useRef(null);
41
+ /** Whether the dialog has ever been open. See the focus effect below. */
42
+ const hasOpenedRef = useRef(false);
43
+ const baseId = useId();
44
+ const listId = `${baseId}-results`;
45
+ const optionId = (index) => `${baseId}-option-${index}`;
46
+ /** Load the index at most once; a failure clears the cache so a retry can. */
47
+ const ensureIndex = useCallback(() => {
48
+ let pending = indexRef.current;
49
+ if (pending === null) {
50
+ pending = loadIndex(indexUrl).catch((error) => {
51
+ indexRef.current = null;
52
+ throw error;
53
+ });
54
+ indexRef.current = pending;
55
+ }
56
+ return pending;
57
+ }, [indexUrl]);
58
+ const warmIndex = useCallback(() => {
59
+ if (indexRef.current !== null) return;
60
+ setStatus("loading");
61
+ ensureIndex().then(() => setStatus("ready"), () => setStatus("error"));
62
+ }, [ensureIndex]);
63
+ const openDialog = useCallback(() => {
64
+ returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
65
+ setIsOpen(true);
66
+ warmIndex();
67
+ }, [warmIndex]);
68
+ const closeDialog = useCallback(() => {
69
+ setIsOpen(false);
70
+ }, []);
71
+ useEffect(() => {
72
+ function handleShortcut(event) {
73
+ if (!(event.metaKey || event.ctrlKey)) return;
74
+ if (event.key.toLowerCase() !== "k") return;
75
+ event.preventDefault();
76
+ if (isOpen) closeDialog();
77
+ else openDialog();
78
+ }
79
+ document.addEventListener("keydown", handleShortcut);
80
+ return () => document.removeEventListener("keydown", handleShortcut);
81
+ }, [
82
+ isOpen,
83
+ openDialog,
84
+ closeDialog
85
+ ]);
86
+ useEffect(() => {
87
+ const isApple = /mac|iphone|ipad|ipod/i.test(navigator.userAgent);
88
+ setShortcutHint(isApple ? "⌘K" : "Ctrl K");
89
+ }, []);
90
+ useEffect(() => {
91
+ if (isOpen) {
92
+ hasOpenedRef.current = true;
93
+ inputRef.current?.focus();
94
+ return;
95
+ }
96
+ if (!hasOpenedRef.current) return;
97
+ const previous = returnFocusRef.current;
98
+ returnFocusRef.current = null;
99
+ (previous !== null && previous !== document.body && previous.isConnected ? previous : triggerRef.current)?.focus();
100
+ }, [isOpen]);
101
+ useEffect(() => {
102
+ if (!isOpen) return;
103
+ const { body } = document;
104
+ const previousOverflow = body.style.overflow;
105
+ body.style.overflow = "hidden";
106
+ return () => {
107
+ body.style.overflow = previousOverflow;
108
+ };
109
+ }, [isOpen]);
110
+ useEffect(() => {
111
+ if (isOpen) return;
112
+ setQuery("");
113
+ setHits([]);
114
+ setActiveIndex(0);
115
+ }, [isOpen]);
116
+ useEffect(() => {
117
+ const trimmed = query.trim();
118
+ if (trimmed === "") {
119
+ setHits([]);
120
+ setActiveIndex(0);
121
+ return;
122
+ }
123
+ let isCancelled = false;
124
+ const timer = setTimeout(() => {
125
+ ensureIndex().then((index) => {
126
+ if (isCancelled) return;
127
+ setStatus("ready");
128
+ setHits(index.search(trimmed).slice(0, maxResults).map(toSearchHit).filter(isSearchHit));
129
+ setActiveIndex(0);
130
+ }, () => {
131
+ if (!isCancelled) setStatus("error");
132
+ });
133
+ }, debounceMs);
134
+ return () => {
135
+ isCancelled = true;
136
+ clearTimeout(timer);
137
+ };
138
+ }, [
139
+ query,
140
+ ensureIndex,
141
+ maxResults,
142
+ debounceMs
143
+ ]);
144
+ useEffect(() => {
145
+ if (hits.length === 0) return;
146
+ (listRef.current?.querySelector(`#${CSS.escape(`${baseId}-option-${activeIndex}`)}`))?.scrollIntoView({ block: "nearest" });
147
+ }, [
148
+ activeIndex,
149
+ hits,
150
+ baseId
151
+ ]);
152
+ const selectHit = useCallback((hit) => {
153
+ closeDialog();
154
+ navigate(hit.href);
155
+ }, [closeDialog, navigate]);
156
+ function handleDialogKeyDown(event) {
157
+ if (event.key === "Escape") {
158
+ event.preventDefault();
159
+ event.stopPropagation();
160
+ closeDialog();
161
+ return;
162
+ }
163
+ if (event.key === "Tab") {
164
+ trapFocus(dialogRef.current, event);
165
+ return;
166
+ }
167
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
168
+ if (hits.length === 0) return;
169
+ event.preventDefault();
170
+ const delta = event.key === "ArrowDown" ? 1 : -1;
171
+ setActiveIndex((index) => (index + delta + hits.length) % hits.length);
172
+ return;
173
+ }
174
+ if (event.key === "Enter") {
175
+ const hit = hits[activeIndex];
176
+ if (hit === void 0) return;
177
+ event.preventDefault();
178
+ selectHit(hit);
179
+ }
180
+ }
181
+ const activeHit = hits[activeIndex];
182
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
183
+ type: "button",
184
+ ref: triggerRef,
185
+ className: "wave-docs-search-trigger",
186
+ "aria-label": triggerLabel,
187
+ "aria-keyshortcuts": "Meta+K Control+K",
188
+ onClick: openDialog,
189
+ onPointerEnter: warmIndex,
190
+ onFocus: warmIndex,
191
+ children: [/* @__PURE__ */ jsx("span", {
192
+ className: "wave-docs-search-trigger-label",
193
+ children: triggerLabel
194
+ }), shortcutHint === "" ? null : /* @__PURE__ */ jsx("kbd", {
195
+ className: "wave-docs-search-trigger-kbd",
196
+ children: shortcutHint
197
+ })]
198
+ }), isOpen ? createPortal(/* @__PURE__ */ jsx("div", {
199
+ className: "wave-docs-search-backdrop",
200
+ onMouseDown: (event) => {
201
+ if (event.target === event.currentTarget) closeDialog();
202
+ },
203
+ children: /* @__PURE__ */ jsxs("div", {
204
+ ref: dialogRef,
205
+ className: "wave-docs-search-dialog",
206
+ role: "dialog",
207
+ "aria-modal": "true",
208
+ "aria-label": dialogLabel,
209
+ onKeyDown: handleDialogKeyDown,
210
+ children: [
211
+ /* @__PURE__ */ jsxs("div", {
212
+ className: "wave-docs-search-input-row",
213
+ children: [/* @__PURE__ */ jsx("input", {
214
+ ref: inputRef,
215
+ className: "wave-docs-search-input",
216
+ type: "text",
217
+ role: "combobox",
218
+ "aria-label": dialogLabel,
219
+ "aria-expanded": hits.length > 0,
220
+ "aria-controls": listId,
221
+ "aria-autocomplete": "list",
222
+ ...activeHit === void 0 ? {} : { "aria-activedescendant": optionId(activeIndex) },
223
+ placeholder,
224
+ value: query,
225
+ onChange: (event) => setQuery(event.target.value),
226
+ autoComplete: "off",
227
+ autoCorrect: "off",
228
+ spellCheck: false
229
+ }), /* @__PURE__ */ jsx("button", {
230
+ type: "button",
231
+ className: "wave-docs-search-close",
232
+ onClick: closeDialog,
233
+ children: "Close"
234
+ })]
235
+ }),
236
+ /* @__PURE__ */ jsx("div", {
237
+ ref: listRef,
238
+ id: listId,
239
+ className: "wave-docs-search-results",
240
+ role: "listbox",
241
+ "aria-label": dialogLabel,
242
+ children: hits.map((hit, index) => /* @__PURE__ */ jsx(SearchResultOption, {
243
+ hit,
244
+ id: optionId(index),
245
+ isActive: index === activeIndex,
246
+ onActivate: () => setActiveIndex(index),
247
+ onSelect: selectHit,
248
+ ...Link === void 0 ? {} : { Link }
249
+ }, hit.id))
250
+ }),
251
+ /* @__PURE__ */ jsx(SearchStatus, {
252
+ status,
253
+ query: query.trim(),
254
+ hitCount: hits.length
255
+ })
256
+ ]
257
+ })
258
+ }), document.body) : null] });
259
+ }
260
+ /** One result row: a real link, so middle-click and "open in new tab" work. */
261
+ function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
262
+ function handleClick(event) {
263
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
264
+ event.preventDefault();
265
+ onSelect(hit);
266
+ }
267
+ const trail = toBreadcrumbs(hit);
268
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
269
+ className: "wave-docs-search-result-heading",
270
+ children: hit.heading
271
+ }), trail.length === 0 ? null : /* @__PURE__ */ jsx("span", {
272
+ className: "wave-docs-search-result-breadcrumb",
273
+ children: trail.map((crumb, index) => /* @__PURE__ */ jsxs("span", {
274
+ className: "wave-docs-search-result-crumb",
275
+ children: [index === 0 ? null : /* @__PURE__ */ jsx("span", {
276
+ className: "wave-docs-search-result-crumb-separator",
277
+ "aria-hidden": "true",
278
+ children: "›"
279
+ }), crumb.text]
280
+ }, crumb.key))
281
+ })] });
282
+ return /* @__PURE__ */ jsx("div", {
283
+ id,
284
+ className: isActive ? "wave-docs-search-result wave-docs-search-result-active" : "wave-docs-search-result",
285
+ role: "option",
286
+ "aria-selected": isActive,
287
+ "aria-label": [hit.heading, ...trail.map((crumb) => crumb.text)].join(", "),
288
+ tabIndex: -1,
289
+ onPointerMove: onActivate,
290
+ children: Link === void 0 ? /* @__PURE__ */ jsx("a", {
291
+ className: "wave-docs-search-result-link",
292
+ href: hit.href,
293
+ tabIndex: -1,
294
+ onClick: handleClick,
295
+ children: body
296
+ }) : /* @__PURE__ */ jsx(Link, {
297
+ className: "wave-docs-search-result-link",
298
+ href: hit.href,
299
+ tabIndex: -1,
300
+ onClick: handleClick,
301
+ children: body
302
+ })
303
+ });
304
+ }
305
+ /** Loading, failure and empty states, plus a live region for hit counts. */
306
+ function SearchStatus({ status, query, hitCount }) {
307
+ let message = null;
308
+ let modifier = "";
309
+ if (status === "error") {
310
+ message = "Search is unavailable right now. Try reloading the page.";
311
+ modifier = " wave-docs-search-status-error";
312
+ } else if (query === "") {
313
+ message = "Start typing to search the documentation.";
314
+ modifier = " wave-docs-search-status-hint";
315
+ } else if (status !== "ready") {
316
+ message = "Loading the search index…";
317
+ modifier = " wave-docs-search-status-loading";
318
+ } else if (hitCount === 0) {
319
+ message = `No results for “${query}”.`;
320
+ modifier = " wave-docs-search-status-empty";
321
+ }
322
+ return /* @__PURE__ */ jsxs(Fragment, { children: [message === null ? null : /* @__PURE__ */ jsx("p", {
323
+ className: `wave-docs-search-status${modifier}`,
324
+ children: message
325
+ }), /* @__PURE__ */ jsx("p", {
326
+ className: "wave-docs-search-announcer",
327
+ role: "status",
328
+ "aria-live": "polite",
329
+ children: query === "" || status !== "ready" ? "" : `${hitCount} ${hitCount === 1 ? "result" : "results"}`
330
+ })] });
331
+ }
332
+ /**
333
+ * Fetch and deserialise the index.
334
+ *
335
+ * `loadJSONAsync` yields between chunks so deserialising a large index does
336
+ * not freeze the frame the dialog just opened in.
337
+ */
338
+ async function loadIndex(url) {
339
+ const [{ default: MiniSearchClass }, response] = await Promise.all([import("minisearch"), fetch(url)]);
340
+ if (!response.ok) throw new Error(`Failed to load the search index from ${url} (HTTP ${response.status}).`);
341
+ return MiniSearchClass.loadJSONAsync(await response.text(), SEARCH_INDEX_OPTIONS);
342
+ }
343
+ /**
344
+ * Narrow one MiniSearch result. Its stored fields are untyped by design, and
345
+ * an index built without `storeFields` yields rows with nothing to render —
346
+ * those are dropped rather than rendered blank.
347
+ */
348
+ function toSearchHit(result) {
349
+ if (typeof result !== "object" || result === null) return void 0;
350
+ const { id, title, heading, href, ancestors } = result;
351
+ if (typeof id !== "string") return void 0;
352
+ if (typeof title !== "string") return void 0;
353
+ if (typeof heading !== "string") return void 0;
354
+ if (typeof href !== "string") return void 0;
355
+ return {
356
+ id,
357
+ title,
358
+ heading,
359
+ href,
360
+ ancestors: Array.isArray(ancestors) ? ancestors.filter((entry) => typeof entry === "string") : []
361
+ };
362
+ }
363
+ function isSearchHit(hit) {
364
+ return hit !== void 0;
365
+ }
366
+ /**
367
+ * Page title first, then the ancestor headings: `ancestors` deliberately excludes
368
+ * the page title so the index does not carry it twice.
369
+ *
370
+ * A page's lead record carries `heading === title` and no ancestors (see
371
+ * `extractSearchRecords`), so its trail would be the one string already printed
372
+ * above it. "Installation" over "Installation" is not a path, it is a bug that
373
+ * reads as a rendering glitch — such a hit gets no trail at all.
374
+ */
375
+ function toBreadcrumbs(hit) {
376
+ if (hit.ancestors.length === 0 && hit.heading === hit.title) return [];
377
+ let trail = "";
378
+ return [hit.title, ...hit.ancestors].map((text) => {
379
+ trail = trail === "" ? text : `${trail}/${text}`;
380
+ return {
381
+ key: trail,
382
+ text
383
+ };
384
+ });
385
+ }
386
+ function trapFocus(root, event) {
387
+ if (root === null) return;
388
+ const focusable = root.querySelectorAll(FOCUSABLE_SELECTOR);
389
+ const first = focusable[0];
390
+ const last = focusable[focusable.length - 1];
391
+ if (first === void 0 || last === void 0) {
392
+ event.preventDefault();
393
+ return;
394
+ }
395
+ if (event.shiftKey && document.activeElement === first) {
396
+ event.preventDefault();
397
+ last.focus();
398
+ } else if (!event.shiftKey && document.activeElement === last) {
399
+ event.preventDefault();
400
+ first.focus();
401
+ }
402
+ }
403
+ //#endregion
404
+ export { SearchDialog };
@@ -0,0 +1,29 @@
1
+ import { DocNavNode } from "../types.js";
2
+ import { DocsLinkComponent } from "./markdown-components.js";
3
+ import { ReactNode } from "react";
4
+ //#region src/react/sidebar.d.ts
5
+ interface DocsSidebarProps {
6
+ /** The tree from `@waveso/docs/source`. */
7
+ nav: DocNavNode[];
8
+ /**
9
+ * Current route, used for `aria-current` and for deciding which groups open.
10
+ * Passed in rather than read from `next/navigation` so this component stays
11
+ * host-agnostic and testable without a router.
12
+ */
13
+ pathname: string;
14
+ /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
15
+ Link?: DocsLinkComponent | undefined;
16
+ /** Accessible name for the landmark. Distinguish multiple navs on a page. */
17
+ label?: string | undefined;
18
+ className?: string | undefined;
19
+ }
20
+ /**
21
+ * The docs navigation tree.
22
+ *
23
+ * Prefetch is off by default on the injected link. A full-tree sidebar on a
24
+ * few hundred pages otherwise asks Next to prefetch every route in it —
25
+ * ~1.8 KB brotli each, all of it wasted on the routes nobody clicks.
26
+ */
27
+ declare function DocsSidebar({ nav, pathname, Link, label, className }: DocsSidebarProps): ReactNode;
28
+ //#endregion
29
+ export { DocsSidebar, DocsSidebarProps };