@waveso/docs 0.1.0 → 0.3.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/CHANGELOG.md +194 -0
- package/README.md +592 -88
- package/dist/code-frame.d.ts +29 -0
- package/dist/code-frame.js +41 -0
- package/dist/code-meta.d.ts +48 -0
- package/dist/code-meta.js +72 -0
- package/dist/docs-content-id.d.ts +19 -0
- package/dist/docs-content-id.js +19 -0
- package/dist/docs-error.d.ts +19 -0
- package/dist/docs-error.js +28 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +45 -0
- package/dist/frontmatter.d.ts +39 -7
- package/dist/frontmatter.js +51 -24
- package/dist/highlighter.d.ts +2 -2
- package/dist/highlighter.js +3 -2
- package/dist/map-pooled.d.ts +26 -0
- package/dist/map-pooled.js +45 -0
- package/dist/meta.d.ts +7 -3
- package/dist/meta.js +61 -15
- package/dist/next.d.ts +182 -35
- package/dist/next.js +177 -49
- package/dist/plugins/rehype-capture-toc.js +52 -20
- package/dist/plugins/rehype-code-frame.d.ts +10 -0
- package/dist/plugins/rehype-code-frame.js +88 -0
- package/dist/plugins/rehype-code-language.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +54 -0
- package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
- package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
- package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
- package/dist/plugins/rehype-flatten-roots.js +39 -0
- package/dist/plugins/remark-doc-links.d.ts +12 -1
- package/dist/plugins/remark-doc-links.js +147 -20
- package/dist/react/code-runtime.d.ts +14 -0
- package/dist/react/code-runtime.js +161 -0
- package/dist/react/doc-content.d.ts +39 -2
- package/dist/react/doc-content.js +42 -10
- package/dist/react/layout.d.ts +44 -0
- package/dist/react/layout.js +65 -0
- package/dist/react/markdown-components.js +71 -6
- package/dist/react/nav.d.ts +28 -0
- package/dist/react/nav.js +70 -0
- package/dist/react/nearest-scroll-top.d.ts +45 -0
- package/dist/react/nearest-scroll-top.js +44 -0
- package/dist/react/next-link.d.ts +34 -0
- package/dist/react/next-link.js +30 -0
- package/dist/react/next-nav.d.ts +11 -0
- package/dist/react/next-nav.js +32 -0
- package/dist/react/next-search.d.ts +22 -0
- package/dist/react/next-search.js +52 -0
- package/dist/react/search-dialog.d.ts +35 -7
- package/dist/react/search-dialog.js +55 -33
- package/dist/react/shell-labels.d.ts +43 -0
- package/dist/react/shell-labels.js +27 -0
- package/dist/react/sidebar.d.ts +38 -3
- package/dist/react/sidebar.js +104 -12
- package/dist/react/skip-link.d.ts +1 -9
- package/dist/react/skip-link.js +6 -5
- package/dist/react/toc.d.ts +12 -4
- package/dist/react/toc.js +46 -12
- package/dist/react/youtube.d.ts +31 -5
- package/dist/react/youtube.js +76 -52
- package/dist/render.d.ts +78 -10
- package/dist/render.js +137 -54
- package/dist/route-path.d.ts +46 -0
- package/dist/route-path.js +51 -0
- package/dist/search-index.d.ts +22 -21
- package/dist/search-index.js +27 -78
- package/dist/search-options.d.ts +32 -1
- package/dist/search-options.js +66 -3
- package/dist/section-boundary.d.ts +17 -0
- package/dist/section-boundary.js +43 -0
- package/dist/sitemap-limit.d.ts +34 -0
- package/dist/sitemap-limit.js +37 -0
- package/dist/source.d.ts +12 -22
- package/dist/source.js +165 -72
- package/dist/styles.css +1117 -125
- package/dist/types.d.ts +52 -29
- package/package.json +70 -34
|
@@ -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 };
|
|
@@ -6,6 +6,62 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
const HTTP_SCHEME = /^https?:\/\//i;
|
|
7
7
|
/** Any URL with a scheme, or protocol-relative. */
|
|
8
8
|
const ABSOLUTE_URL = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
9
|
+
/**
|
|
10
|
+
* The schemes a markdown link may carry.
|
|
11
|
+
*
|
|
12
|
+
* GitHub's own allowlist, which is the bar to match: documentation links to
|
|
13
|
+
* `sms:`, `ftp:` and `irc:` are ordinary, and an allowlist of three silently
|
|
14
|
+
* deleted them. The point of the check is to stop `javascript:`, `data:` and
|
|
15
|
+
* `vbscript:` reaching an `href`, not to have an opinion about protocols.
|
|
16
|
+
*
|
|
17
|
+
* A scheme not listed here — `vscode:`, `obsidian:`, `slack:` — is dropped
|
|
18
|
+
* rather than rendered. That is deliberate: an allowlist that grows on request
|
|
19
|
+
* is safe, one that guesses is not. {@link warnDroppedHref} makes it visible.
|
|
20
|
+
*/
|
|
21
|
+
const SAFE_SCHEME = /^(https?|mailto|tel|sms|ftp|ftps|irc|ircs|xmpp|news|nntp|feed|git|matrix):/i;
|
|
22
|
+
/** Hrefs already reported, so a re-render does not repeat the warning. */
|
|
23
|
+
const warnedHrefs = /* @__PURE__ */ new Set();
|
|
24
|
+
/**
|
|
25
|
+
* Say something when a link is dropped.
|
|
26
|
+
*
|
|
27
|
+
* A destination that vanishes with the text left behind is the quietest
|
|
28
|
+
* possible failure — the page looks fine and the link is simply gone. Every
|
|
29
|
+
* other rejection in this package names a file and a fix; this one cannot see
|
|
30
|
+
* the file, so it names the href and stays out of production noise.
|
|
31
|
+
*/
|
|
32
|
+
function warnDroppedHref(href) {
|
|
33
|
+
if (process.env.NODE_ENV === "production" || warnedHrefs.has(href)) return;
|
|
34
|
+
warnedHrefs.add(href);
|
|
35
|
+
console.warn(`@waveso/docs: dropped a link to '${href}' — its URL scheme is not in the allowlist, so the text was kept and the destination removed. Use http, https, mailto, tel, sms, ftp, irc, xmpp or matrix, or render the link yourself with a custom \`a\` component.`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A copy of `href` as a browser will parse it.
|
|
39
|
+
*
|
|
40
|
+
* ASCII control characters and spaces are stripped before parsing, so
|
|
41
|
+
* ` javascript:` and `java<TAB>script:` both navigate where the raw string
|
|
42
|
+
* matches no scheme at all — which is how a scheme check gets walked around.
|
|
43
|
+
*/
|
|
44
|
+
function normaliseUrl(href) {
|
|
45
|
+
return [...href].filter((char) => (char.codePointAt(0) ?? 0) > 32).join("");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Would this href navigate somewhere we are willing to send a reader?
|
|
49
|
+
*
|
|
50
|
+
* Nothing upstream filters it: `remarkDocLinks` skips every href with a scheme
|
|
51
|
+
* (`isRelativeLink` is false for it), so `assertLinks` never sees one either,
|
|
52
|
+
* and `remarkRehype` runs with `allowDangerousHtml` off but passes a link's own
|
|
53
|
+
* url through untouched. Verified against React 19: it neutralises
|
|
54
|
+
* `javascript:` in every obfuscated form, silently — but it lets `vbscript:`
|
|
55
|
+
* and `data:text/html;base64,…` reach the DOM verbatim. So the allowlist is
|
|
56
|
+
* ours to keep.
|
|
57
|
+
*
|
|
58
|
+
* Tested against {@link normaliseUrl}, not the raw string.
|
|
59
|
+
*/
|
|
60
|
+
function isSafeHref(href) {
|
|
61
|
+
const normalised = normaliseUrl(href);
|
|
62
|
+
if (!ABSOLUTE_URL.test(normalised)) return true;
|
|
63
|
+
return normalised.startsWith("//") || SAFE_SCHEME.test(normalised);
|
|
64
|
+
}
|
|
9
65
|
function joinClassNames(...values) {
|
|
10
66
|
const joined = values.filter(Boolean).join(" ");
|
|
11
67
|
return joined === "" ? void 0 : joined;
|
|
@@ -23,6 +79,10 @@ function createAnchor(Link) {
|
|
|
23
79
|
...rest,
|
|
24
80
|
children
|
|
25
81
|
});
|
|
82
|
+
if (!isSafeHref(href)) {
|
|
83
|
+
warnDroppedHref(href);
|
|
84
|
+
return /* @__PURE__ */ jsx("span", { children });
|
|
85
|
+
}
|
|
26
86
|
if (HTTP_SCHEME.test(href) || href.startsWith("//")) return /* @__PURE__ */ jsxs("a", {
|
|
27
87
|
...rest,
|
|
28
88
|
href,
|
|
@@ -46,28 +106,33 @@ function createAnchor(Link) {
|
|
|
46
106
|
};
|
|
47
107
|
}
|
|
48
108
|
function createImage(Image) {
|
|
49
|
-
return function MarkdownImage({ src, alt, width, height, title, className, ...rest }) {
|
|
109
|
+
return function MarkdownImage({ src, alt, width, height, title, className, sizes, loading, ...rest }) {
|
|
50
110
|
const resolvedWidth = toDimension(width);
|
|
51
111
|
const resolvedHeight = toDimension(height);
|
|
112
|
+
const resolvedLoading = loading ?? "lazy";
|
|
113
|
+
const resolvedClassName = joinClassNames("wave-docs-image", className);
|
|
52
114
|
if (Image !== void 0 && typeof src === "string" && resolvedWidth !== void 0 && resolvedHeight !== void 0) return /* @__PURE__ */ jsx(Image, {
|
|
115
|
+
...rest,
|
|
53
116
|
src,
|
|
54
117
|
alt: alt ?? "",
|
|
55
118
|
width: resolvedWidth,
|
|
56
119
|
height: resolvedHeight,
|
|
57
120
|
title,
|
|
58
|
-
className:
|
|
59
|
-
|
|
121
|
+
className: resolvedClassName,
|
|
122
|
+
sizes,
|
|
123
|
+
loading: resolvedLoading
|
|
60
124
|
});
|
|
61
125
|
return /* @__PURE__ */ jsx("img", {
|
|
126
|
+
decoding: "async",
|
|
62
127
|
...rest,
|
|
63
128
|
src,
|
|
64
129
|
alt: alt ?? "",
|
|
65
130
|
width,
|
|
66
131
|
height,
|
|
67
132
|
title,
|
|
68
|
-
className:
|
|
69
|
-
|
|
70
|
-
|
|
133
|
+
className: resolvedClassName,
|
|
134
|
+
sizes,
|
|
135
|
+
loading: resolvedLoading
|
|
71
136
|
});
|
|
72
137
|
};
|
|
73
138
|
}
|
|
@@ -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 };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { wrapNextLink } from "./next-link.js";
|
|
3
|
+
import { DocsNav } from "./nav.js";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
import NextLink from "next/link";
|
|
6
|
+
import { usePathname } from "next/navigation";
|
|
7
|
+
//#region src/react/next-nav.tsx
|
|
8
|
+
/**
|
|
9
|
+
* {@link DocsNav}, wired to Next's router.
|
|
10
|
+
*
|
|
11
|
+
* Private, and the **only** module in `src/react/` that imports
|
|
12
|
+
* `next/navigation` — named so that is obvious from the file list. Everything
|
|
13
|
+
* else in here takes `pathname` and `Link` as props, which is what lets the
|
|
14
|
+
* components be tested without mounting a router and reused outside Next.
|
|
15
|
+
*
|
|
16
|
+
* The split is not ceremony: `usePathname` is why the consumer used to have to
|
|
17
|
+
* hand-write a `'use client'` wrapper of their own, and deleting that file from
|
|
18
|
+
* the README is most of what `docs.Layout` is for.
|
|
19
|
+
*/
|
|
20
|
+
/** Module scope: a fresh identity here remounts every nav link on every render. */
|
|
21
|
+
const Link = wrapNextLink(NextLink);
|
|
22
|
+
function DocsNextNav({ nav, label, closeLabel }) {
|
|
23
|
+
return /* @__PURE__ */ jsx(DocsNav, {
|
|
24
|
+
nav,
|
|
25
|
+
pathname: usePathname(),
|
|
26
|
+
Link,
|
|
27
|
+
...label === void 0 ? {} : { label },
|
|
28
|
+
...closeLabel === void 0 ? {} : { closeLabel }
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { DocsNextNav };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SearchDialogProps } from "./search-dialog.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
//#region src/react/next-search.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Everything `SearchDialog` takes except the two things Next answers for you.
|
|
6
|
+
*
|
|
7
|
+
* `indexUrl` stays required, and `docs.searchIndexUrl` is the only value
|
|
8
|
+
* anyone should pass: defaulting it to `/search-index.json` would be wrong
|
|
9
|
+
* under every non-root `basePath`, and wrong as a 404 the reader hits and the
|
|
10
|
+
* author never sees.
|
|
11
|
+
*/
|
|
12
|
+
type DocsSearchProps = Omit<SearchDialogProps, 'navigate' | 'Link'>;
|
|
13
|
+
/**
|
|
14
|
+
* Search trigger and dialog for a Next application.
|
|
15
|
+
*
|
|
16
|
+
* `next/link` is passed through rather than left to the plain-anchor fallback,
|
|
17
|
+
* so hovering a result prefetches the page it points at — which is most of why
|
|
18
|
+
* a hit feels instant when you press Enter.
|
|
19
|
+
*/
|
|
20
|
+
declare function DocsSearch(props: DocsSearchProps): ReactNode;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { DocsSearch, DocsSearchProps };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { wrapNextLink } from "./next-link.js";
|
|
3
|
+
import { SearchDialog } from "./search-dialog.js";
|
|
4
|
+
import { useCallback } from "react";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
import NextLink from "next/link";
|
|
7
|
+
import { useRouter } from "next/navigation";
|
|
8
|
+
//#region src/react/next-search.tsx
|
|
9
|
+
/**
|
|
10
|
+
* {@link SearchDialog}, wired to Next's router and `next/link`.
|
|
11
|
+
*
|
|
12
|
+
* `SearchDialog` takes `navigate` and `Link` as props so it stays
|
|
13
|
+
* host-agnostic, and that seam is why its own tests are worth having — they
|
|
14
|
+
* assert behaviour against a stub router rather than mounting Next. But this
|
|
15
|
+
* package is a Next adapter, so in practice every consumer wrote the same
|
|
16
|
+
* fifteen-line `'use client'` wrapper around `useRouter().push` and
|
|
17
|
+
* `next/link`, and the ones who skipped `Link` lost hover prefetching on
|
|
18
|
+
* every result without anything telling them.
|
|
19
|
+
*
|
|
20
|
+
* So it ships. The same bargain `createDocsRoute` already strikes on the
|
|
21
|
+
* server, where `next/link` and `next/image` are wired by default and
|
|
22
|
+
* overridable through `components`.
|
|
23
|
+
*
|
|
24
|
+
* ```tsx
|
|
25
|
+
* // app/docs/layout.tsx — a Server Component; this file carries the boundary
|
|
26
|
+
* import { DocsSearch } from '@waveso/docs/react/next-search';
|
|
27
|
+
* import { docs } from '@/lib/docs';
|
|
28
|
+
*
|
|
29
|
+
* <DocsSearch indexUrl={docs.searchIndexUrl} />
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
const Link = wrapNextLink(NextLink);
|
|
33
|
+
/**
|
|
34
|
+
* Search trigger and dialog for a Next application.
|
|
35
|
+
*
|
|
36
|
+
* `next/link` is passed through rather than left to the plain-anchor fallback,
|
|
37
|
+
* so hovering a result prefetches the page it points at — which is most of why
|
|
38
|
+
* a hit feels instant when you press Enter.
|
|
39
|
+
*/
|
|
40
|
+
function DocsSearch(props) {
|
|
41
|
+
const router = useRouter();
|
|
42
|
+
const navigate = useCallback((href) => {
|
|
43
|
+
router.push(href);
|
|
44
|
+
}, [router]);
|
|
45
|
+
return /* @__PURE__ */ jsx(SearchDialog, {
|
|
46
|
+
...props,
|
|
47
|
+
navigate,
|
|
48
|
+
Link
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { DocsSearch };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { SearchRecord } from "../types.js";
|
|
1
2
|
import { DocsLinkComponent } from "./markdown-components.js";
|
|
2
3
|
import { ReactNode } from "react";
|
|
4
|
+
import { Options } from "minisearch";
|
|
3
5
|
//#region src/react/search-dialog.d.ts
|
|
4
6
|
interface SearchDialogProps {
|
|
5
7
|
/**
|
|
@@ -17,17 +19,43 @@ interface SearchDialogProps {
|
|
|
17
19
|
* Optional link component for results, e.g. `next/link`, so hovering a hit
|
|
18
20
|
* prefetches the page. Results fall back to a plain anchor.
|
|
19
21
|
*/
|
|
20
|
-
Link?: DocsLinkComponent;
|
|
22
|
+
Link?: DocsLinkComponent | undefined;
|
|
21
23
|
/** Trigger button label. Defaults to `'Search'`. */
|
|
22
|
-
triggerLabel?: string;
|
|
24
|
+
triggerLabel?: string | undefined;
|
|
23
25
|
/** Input placeholder. Defaults to `'Search documentation'`. */
|
|
24
|
-
placeholder?: string;
|
|
26
|
+
placeholder?: string | undefined;
|
|
25
27
|
/** Accessible name for the dialog. Defaults to `'Search documentation'`. */
|
|
26
|
-
dialogLabel?: string;
|
|
28
|
+
dialogLabel?: string | undefined;
|
|
27
29
|
/** Maximum results rendered. Defaults to 8. */
|
|
28
|
-
maxResults?: number;
|
|
30
|
+
maxResults?: number | undefined;
|
|
29
31
|
/** Input debounce in milliseconds. Defaults to 120. */
|
|
30
|
-
debounceMs?: number;
|
|
32
|
+
debounceMs?: number | undefined;
|
|
33
|
+
/** Extra class names for the trigger button, e.g. a navbar's own layout. */
|
|
34
|
+
className?: string | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Overrides applied through `mergeSearchOptions` when the index is
|
|
37
|
+
* deserialised — the escape hatch for tokenisation, `processTerm` and the
|
|
38
|
+
* query defaults, without waiting on a release of this package.
|
|
39
|
+
*
|
|
40
|
+
* MiniSearch's own name for the query defaults is `searchOptions`, so they
|
|
41
|
+
* nest one level down:
|
|
42
|
+
*
|
|
43
|
+
* ```tsx
|
|
44
|
+
* <DocsSearch miniSearchOptions={{ searchOptions: { fuzzy: 0.1 } }} />
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* That stutter is why this prop is not called `searchOptions` too. It was,
|
|
48
|
+
* and `searchOptions={{ fuzzy: 0.1 }}` reads so naturally that both README
|
|
49
|
+
* examples were written that way — neither compiled, and the flat form is
|
|
50
|
+
* not a runtime error either. It is a `fuzzy` MiniSearch never reads.
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ HAND THE IDENTICAL OVERRIDES TO THE BUILD — `createDocsRoute`'s
|
|
53
|
+
* `miniSearchOptions`, or `buildSearchIndex`'s second argument. `tokenize`
|
|
54
|
+
* and `processTerm` decide how terms were written into the index; a client
|
|
55
|
+
* that splits differently from the build looks up terms that were never
|
|
56
|
+
* written and finds nothing, silently.
|
|
57
|
+
*/
|
|
58
|
+
miniSearchOptions?: Partial<Options<SearchRecord>> | undefined;
|
|
31
59
|
}
|
|
32
60
|
/**
|
|
33
61
|
* Search trigger plus its dialog.
|
|
@@ -36,6 +64,6 @@ interface SearchDialogProps {
|
|
|
36
64
|
* portalled to `document.body`, so a navbar's stacking context cannot trap
|
|
37
65
|
* it behind the page.
|
|
38
66
|
*/
|
|
39
|
-
declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs }: SearchDialogProps): ReactNode;
|
|
67
|
+
declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs, className, miniSearchOptions }: SearchDialogProps): ReactNode;
|
|
40
68
|
//#endregion
|
|
41
69
|
export { SearchDialog, SearchDialogProps };
|