@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.
- package/LICENSE +21 -0
- package/README.md +518 -0
- package/dist/frontmatter.d.ts +55 -0
- package/dist/frontmatter.js +80 -0
- package/dist/highlighter.d.ts +99 -0
- package/dist/highlighter.js +183 -0
- package/dist/meta.d.ts +75 -0
- package/dist/meta.js +183 -0
- package/dist/next.d.ts +256 -0
- package/dist/next.js +365 -0
- package/dist/plugins/rehype-capture-toc.d.ts +18 -0
- package/dist/plugins/rehype-capture-toc.js +69 -0
- package/dist/plugins/remark-doc-links.d.ts +63 -0
- package/dist/plugins/remark-doc-links.js +122 -0
- package/dist/plugins/remark-unwrap-images.d.ts +11 -0
- package/dist/plugins/remark-unwrap-images.js +25 -0
- package/dist/plugins/remark-youtube.d.ts +22 -0
- package/dist/plugins/remark-youtube.js +84 -0
- package/dist/react/callout.d.ts +37 -0
- package/dist/react/callout.js +113 -0
- package/dist/react/doc-content.d.ts +29 -0
- package/dist/react/doc-content.js +30 -0
- package/dist/react/markdown-components.d.ts +84 -0
- package/dist/react/markdown-components.js +122 -0
- package/dist/react/search-dialog.d.ts +41 -0
- package/dist/react/search-dialog.js +404 -0
- package/dist/react/sidebar.d.ts +29 -0
- package/dist/react/sidebar.js +196 -0
- package/dist/react/skip-link.d.ts +37 -0
- package/dist/react/skip-link.js +37 -0
- package/dist/react/toc.d.ts +35 -0
- package/dist/react/toc.js +87 -0
- package/dist/react/youtube.d.ts +27 -0
- package/dist/react/youtube.js +75 -0
- package/dist/render.d.ts +72 -0
- package/dist/render.js +279 -0
- package/dist/search-index.d.ts +51 -0
- package/dist/search-index.js +274 -0
- package/dist/search-options.d.ts +18 -0
- package/dist/search-options.js +40 -0
- package/dist/source.d.ts +67 -0
- package/dist/source.js +332 -0
- package/dist/styles.css +1033 -0
- package/dist/types.d.ts +334 -0
- package/dist/types.js +0 -0
- 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 };
|