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