@waveso/docs 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -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 +53 -10
- package/dist/react/search-dialog.js +147 -47
- 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 +1001 -106
- package/dist/types.d.ts +11 -2
- package/package.json +58 -23
package/dist/react/sidebar.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { nearestScrollTop } from "./nearest-scroll-top.js";
|
|
3
|
+
import { useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
4
5
|
//#region src/react/sidebar.tsx
|
|
5
6
|
/** Trailing slashes are a routing detail, not a difference in identity. */
|
|
6
7
|
function normalizeHref(href) {
|
|
@@ -21,9 +22,44 @@ function containsActive(node, pathname) {
|
|
|
21
22
|
/**
|
|
22
23
|
* The docs navigation tree.
|
|
23
24
|
*
|
|
24
|
-
* Prefetch
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* ## Prefetch
|
|
26
|
+
*
|
|
27
|
+
* Nearby links prefetch; the rest do not. Nearby means the list that directly
|
|
28
|
+
* contains the current page, plus the heading link of the group the reader is
|
|
29
|
+
* inside — 5 to 15 warm links on a real sidebar rather than 400 or none.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ THE PREVIOUS NOTE HERE WAS WRONG, AND THE RETRACTION IS THE POINT. It
|
|
32
|
+
* said prefetch was off because a full-tree sidebar otherwise asks Next to
|
|
33
|
+
* prefetch every route in it, ~1.8 KB brotli each. That reasoning is correct
|
|
34
|
+
* for the Pages Router and wrong for the App Router:
|
|
35
|
+
* `next/dist/client/app-dir/link.js` computes
|
|
36
|
+
* `const prefetchEnabled = prefetchProp !== false`, and BOTH the hover path
|
|
37
|
+
* and the touch path bail on it, while the IntersectionObserver is only
|
|
38
|
+
* registered when it is true. So `prefetch={false}` did not trade viewport
|
|
39
|
+
* prefetching for hover prefetching — it turned off both, and made every
|
|
40
|
+
* navigation from the most-clicked control in a docs site a cold RSC
|
|
41
|
+
* round-trip.
|
|
42
|
+
*
|
|
43
|
+
* ## The keyboard model, and why there is no `role="tree"`
|
|
44
|
+
*
|
|
45
|
+
* This is the **APG Disclosure Navigation** pattern: a list of links, with a
|
|
46
|
+
* button per collapsible group. Every link is an ordinary tab stop, Enter
|
|
47
|
+
* follows it, and the browser does all of it. There is no `tabindex`
|
|
48
|
+
* anywhere in here and no roving focus, deliberately.
|
|
49
|
+
*
|
|
50
|
+
* `role="tree"` is the tempting alternative and it is refused. It removes
|
|
51
|
+
* every link from the tab order in favour of a single roving tabstop, so a
|
|
52
|
+
* reader who tabs into the navigation can no longer tab through it; and it
|
|
53
|
+
* makes a screen reader announce "tree item, level 3" for what is, in every
|
|
54
|
+
* way that matters to the person hearing it, a link to a page. A docs sidebar
|
|
55
|
+
* is not a file explorer. `sidebar.test.tsx` asserts the absence of both, so
|
|
56
|
+
* the decision survives someone reaching for the aria pattern that sounds
|
|
57
|
+
* closest to "collapsible tree".
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ AND IT IS UNOBSERVABLE IN `next dev`. The same file guards the hover path
|
|
60
|
+
* with `if (!prefetchEnabled || process.env.NODE_ENV === 'development')`, so
|
|
61
|
+
* nothing prefetches locally whatever this says. Do not "fix" it back because
|
|
62
|
+
* the network tab looks the same.
|
|
27
63
|
*/
|
|
28
64
|
function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
29
65
|
const baseId = useId();
|
|
@@ -39,7 +75,23 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
|
39
75
|
[key]: isOpen
|
|
40
76
|
}));
|
|
41
77
|
};
|
|
78
|
+
const navRef = useRef(null);
|
|
79
|
+
useLayoutEffect(() => {
|
|
80
|
+
const active = navRef.current?.querySelector("[aria-current=\"page\"]");
|
|
81
|
+
if (!(active instanceof HTMLElement)) return;
|
|
82
|
+
const port = scrollableAncestor(active);
|
|
83
|
+
if (port === null) return;
|
|
84
|
+
const next = nearestScrollTop({
|
|
85
|
+
itemTop: active.getBoundingClientRect().top - port.getBoundingClientRect().top + port.scrollTop,
|
|
86
|
+
itemHeight: active.offsetHeight,
|
|
87
|
+
viewHeight: port.clientHeight,
|
|
88
|
+
scrollTop: port.scrollTop,
|
|
89
|
+
scrollHeight: port.scrollHeight
|
|
90
|
+
});
|
|
91
|
+
if (next !== void 0) port.scrollTop = next;
|
|
92
|
+
}, [pathname]);
|
|
42
93
|
return /* @__PURE__ */ jsx("nav", {
|
|
94
|
+
ref: navRef,
|
|
43
95
|
"aria-label": label,
|
|
44
96
|
className: ["wave-docs-sidebar", className].filter(Boolean).join(" "),
|
|
45
97
|
children: /* @__PURE__ */ jsx(NavList, {
|
|
@@ -53,7 +105,27 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
|
53
105
|
})
|
|
54
106
|
});
|
|
55
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The nearest ancestor that actually scrolls, or `null`.
|
|
110
|
+
*
|
|
111
|
+
* ⚠️ THIS EXISTS SO `scrollIntoView` DOES NOT HAVE TO. `scrollIntoView({ block:
|
|
112
|
+
* 'nearest' })` reads as exactly the right call and scrolls **every**
|
|
113
|
+
* scrollable ancestor including the document — so on a docs page it brings the
|
|
114
|
+
* sidebar item into view and jumps the article the reader came to read, on the
|
|
115
|
+
* one navigation where they know precisely what they asked for.
|
|
116
|
+
* `sidebar.test.tsx` spies on it and asserts it is never called.
|
|
117
|
+
*/
|
|
118
|
+
function scrollableAncestor(element) {
|
|
119
|
+
let current = element.parentElement;
|
|
120
|
+
while (current !== null) {
|
|
121
|
+
const overflow = getComputedStyle(current).overflowY;
|
|
122
|
+
if ((overflow === "auto" || overflow === "scroll") && current.scrollHeight > current.clientHeight) return current;
|
|
123
|
+
current = current.parentElement;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
56
127
|
function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, id }) {
|
|
128
|
+
const holdsActive = nodes.some((node) => (node.type === "page" || node.type === "link" && !node.external) && isActiveHref(pathname, node.href));
|
|
57
129
|
return /* @__PURE__ */ jsx("ul", {
|
|
58
130
|
id,
|
|
59
131
|
className: "wave-docs-sidebar__list",
|
|
@@ -74,6 +146,7 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
|
|
|
74
146
|
href: node.href,
|
|
75
147
|
isExternal: node.external,
|
|
76
148
|
isActive: !node.external && isActiveHref(pathname, node.href),
|
|
149
|
+
isNearby: holdsActive,
|
|
77
150
|
Link,
|
|
78
151
|
children: node.title
|
|
79
152
|
})
|
|
@@ -84,6 +157,7 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
|
|
|
84
157
|
href: node.href,
|
|
85
158
|
isExternal: false,
|
|
86
159
|
isActive: isActiveHref(pathname, node.href),
|
|
160
|
+
isNearby: holdsActive,
|
|
87
161
|
Link,
|
|
88
162
|
children: node.title
|
|
89
163
|
})
|
|
@@ -122,10 +196,11 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
|
|
|
122
196
|
className: "wave-docs-sidebar__group-title",
|
|
123
197
|
children: node.title
|
|
124
198
|
}), /* @__PURE__ */ jsx(Chevron, { isOpen })]
|
|
125
|
-
}) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(NavLink, {
|
|
199
|
+
}) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(NavLink, {
|
|
126
200
|
href: node.href,
|
|
127
201
|
isExternal: false,
|
|
128
202
|
isActive: isGroupActive,
|
|
203
|
+
isNearby: hasActive,
|
|
129
204
|
Link,
|
|
130
205
|
children: node.title
|
|
131
206
|
}), /* @__PURE__ */ jsx("button", {
|
|
@@ -149,17 +224,34 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
|
|
|
149
224
|
}) : null]
|
|
150
225
|
});
|
|
151
226
|
}
|
|
152
|
-
function NavLink({ href, isExternal, isActive, Link, children }) {
|
|
227
|
+
function NavLink({ href, isExternal, isActive, isNearby = false, Link, children }) {
|
|
153
228
|
const className = "wave-docs-sidebar__link";
|
|
154
229
|
if (isExternal) return /* @__PURE__ */ jsxs("a", {
|
|
155
230
|
className,
|
|
156
231
|
href,
|
|
157
232
|
target: "_blank",
|
|
158
233
|
rel: "noopener noreferrer",
|
|
159
|
-
children: [
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
234
|
+
children: [
|
|
235
|
+
children,
|
|
236
|
+
/* @__PURE__ */ jsx("svg", {
|
|
237
|
+
className: "wave-docs-sidebar__external",
|
|
238
|
+
"aria-hidden": "true",
|
|
239
|
+
focusable: "false",
|
|
240
|
+
viewBox: "0 0 24 24",
|
|
241
|
+
width: "12",
|
|
242
|
+
height: "12",
|
|
243
|
+
fill: "none",
|
|
244
|
+
stroke: "currentColor",
|
|
245
|
+
strokeWidth: "2",
|
|
246
|
+
strokeLinecap: "round",
|
|
247
|
+
strokeLinejoin: "round",
|
|
248
|
+
children: /* @__PURE__ */ jsx("path", { d: "M14 4h6v6M20 4l-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" })
|
|
249
|
+
}),
|
|
250
|
+
/* @__PURE__ */ jsx("span", {
|
|
251
|
+
className: "wave-docs-sr-only",
|
|
252
|
+
children: " (opens in a new tab)"
|
|
253
|
+
})
|
|
254
|
+
]
|
|
163
255
|
});
|
|
164
256
|
if (Link === void 0) return /* @__PURE__ */ jsx("a", {
|
|
165
257
|
className,
|
|
@@ -170,7 +262,7 @@ function NavLink({ href, isExternal, isActive, Link, children }) {
|
|
|
170
262
|
return /* @__PURE__ */ jsx(Link, {
|
|
171
263
|
className,
|
|
172
264
|
href,
|
|
173
|
-
prefetch: false,
|
|
265
|
+
prefetch: isNearby ? void 0 : false,
|
|
174
266
|
"aria-current": isActive ? "page" : void 0,
|
|
175
267
|
children
|
|
176
268
|
});
|
|
@@ -1,14 +1,6 @@
|
|
|
1
|
+
import { DOCS_CONTENT_ID } from "../docs-content-id.js";
|
|
1
2
|
import { ReactNode } from "react";
|
|
2
3
|
//#region src/react/skip-link.d.ts
|
|
3
|
-
/**
|
|
4
|
-
* The `id` this link targets, and the one `createDocsRoute` puts on its
|
|
5
|
-
* `<article>`.
|
|
6
|
-
*
|
|
7
|
-
* One constant for both halves: the two files spelled the string independently,
|
|
8
|
-
* and a skip link pointing at an id nothing carries scrolls nowhere and focuses
|
|
9
|
-
* nothing — a failure with no symptom until a keyboard user hits it.
|
|
10
|
-
*/
|
|
11
|
-
declare const DOCS_CONTENT_ID = "docs-content";
|
|
12
4
|
interface SkipLinkProps {
|
|
13
5
|
/** Fragment id of the main content region. */
|
|
14
6
|
href?: string | undefined;
|
package/dist/react/skip-link.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import { DOCS_CONTENT_ID } from "../docs-content-id.js";
|
|
1
2
|
import { jsx } from "react/jsx-runtime";
|
|
2
3
|
//#region src/react/skip-link.tsx
|
|
3
4
|
/**
|
|
4
5
|
* The `id` this link targets, and the one `createDocsRoute` puts on its
|
|
5
|
-
* `<
|
|
6
|
+
* `<main>`. Defined in a private module so the two halves cannot spell it
|
|
7
|
+
* differently; re-exported here because this is the documented import path.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
* and
|
|
9
|
-
*
|
|
9
|
+
* (This comment used to call the file a `'use client'` module. It is not one
|
|
10
|
+
* and never was — `SkipLink` is an anchor with no state, so it is a Server
|
|
11
|
+
* Component like everything else here that does not need a browser.)
|
|
10
12
|
*/
|
|
11
|
-
const DOCS_CONTENT_ID = "docs-content";
|
|
12
13
|
/**
|
|
13
14
|
* Skip-to-content link: invisible until focused, first in the tab order.
|
|
14
15
|
*
|
package/dist/react/toc.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ interface DocsTocProps {
|
|
|
16
16
|
* other unit, `rem` included.
|
|
17
17
|
*/
|
|
18
18
|
rootMargin?: string | undefined;
|
|
19
|
+
/** Text for the back-to-top link. */
|
|
20
|
+
topLabel?: string | undefined;
|
|
19
21
|
className?: string | undefined;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
@@ -26,10 +28,16 @@ interface DocsTocProps {
|
|
|
26
28
|
* out of sync on duplicate headings.
|
|
27
29
|
*
|
|
28
30
|
* Scrolling itself is left to the browser: the links are real anchors, and
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
31
|
+
* nothing here calls `scrollTo`. Doing it in JavaScript means reimplementing
|
|
32
|
+
* the `prefers-reduced-motion` check, and getting that wrong makes people ill.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ AND THE STYLESHEET SETS NO `scroll-behavior: smooth` EITHER, deliberately
|
|
35
|
+
* — this comment used to say it did. Next 16 suppresses smooth scrolling
|
|
36
|
+
* across a route change only when `<html>` carries
|
|
37
|
+
* `data-scroll-behavior="smooth"`, an attribute only the host can set, so a
|
|
38
|
+
* package-level rule would smooth-scroll every navigation and no reader could
|
|
39
|
+
* turn it off. `styles.css` says the same at greater length.
|
|
32
40
|
*/
|
|
33
|
-
declare function DocsToc({ entries, label, rootMargin, className }: DocsTocProps): ReactNode;
|
|
41
|
+
declare function DocsToc({ entries, label, rootMargin, className, topLabel }: DocsTocProps): ReactNode;
|
|
34
42
|
//#endregion
|
|
35
43
|
export { DocsToc, DocsTocProps };
|
package/dist/react/toc.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { DOCS_CONTENT_ID } from "../docs-content-id.js";
|
|
2
3
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
4
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
5
|
//#region src/react/toc.tsx
|
|
@@ -29,11 +30,17 @@ function flattenTocIds(entries) {
|
|
|
29
30
|
* out of sync on duplicate headings.
|
|
30
31
|
*
|
|
31
32
|
* Scrolling itself is left to the browser: the links are real anchors, and
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* nothing here calls `scrollTo`. Doing it in JavaScript means reimplementing
|
|
34
|
+
* the `prefers-reduced-motion` check, and getting that wrong makes people ill.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ AND THE STYLESHEET SETS NO `scroll-behavior: smooth` EITHER, deliberately
|
|
37
|
+
* — this comment used to say it did. Next 16 suppresses smooth scrolling
|
|
38
|
+
* across a route change only when `<html>` carries
|
|
39
|
+
* `data-scroll-behavior="smooth"`, an attribute only the host can set, so a
|
|
40
|
+
* package-level rule would smooth-scroll every navigation and no reader could
|
|
41
|
+
* turn it off. `styles.css` says the same at greater length.
|
|
35
42
|
*/
|
|
36
|
-
function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MARGIN, className }) {
|
|
43
|
+
function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MARGIN, className, topLabel = "Back to top" }) {
|
|
37
44
|
const ids = useMemo(() => flattenTocIds(entries), [entries]);
|
|
38
45
|
const [activeId, setActiveId] = useState(void 0);
|
|
39
46
|
const lastIds = useRef(ids);
|
|
@@ -76,14 +83,18 @@ function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MA
|
|
|
76
83
|
};
|
|
77
84
|
}, [ids, rootMargin]);
|
|
78
85
|
if (entries.length === 0) return null;
|
|
79
|
-
return /* @__PURE__ */
|
|
86
|
+
return /* @__PURE__ */ jsxs("nav", {
|
|
80
87
|
"aria-label": label,
|
|
81
88
|
className: ["wave-docs-toc", className].filter(Boolean).join(" "),
|
|
82
|
-
children: /* @__PURE__ */ jsx(TocList, {
|
|
89
|
+
children: [/* @__PURE__ */ jsx(TocList, {
|
|
83
90
|
entries,
|
|
84
91
|
activeId,
|
|
85
92
|
onSelect: setActiveId
|
|
86
|
-
})
|
|
93
|
+
}), /* @__PURE__ */ jsx("a", {
|
|
94
|
+
className: "wave-docs-toc__top",
|
|
95
|
+
href: `#${DOCS_CONTENT_ID}`,
|
|
96
|
+
children: topLabel
|
|
97
|
+
})]
|
|
87
98
|
});
|
|
88
99
|
}
|
|
89
100
|
function TocList({ entries, activeId, onSelect }) {
|
package/dist/react/youtube.d.ts
CHANGED
|
@@ -11,13 +11,39 @@ interface YouTubeProps {
|
|
|
11
11
|
className?: string | undefined;
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
* Click-to-load YouTube embed
|
|
14
|
+
* Click-to-load YouTube embed, with **no client JavaScript at all**.
|
|
15
15
|
*
|
|
16
16
|
* An eager `<iframe>` costs ~137 KB of embed document plus ~580 KB gzipped of
|
|
17
|
-
* player JavaScript
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* player JavaScript on every page view, whether or not anyone presses play. A
|
|
18
|
+
* facade costs one ~15 KB JPEG and loads the rest on demand.
|
|
19
|
+
*
|
|
20
|
+
* ## Why `<details>` and not `useState`
|
|
21
|
+
*
|
|
22
|
+
* This was a `'use client'` component, and that made it the one thing in
|
|
23
|
+
* `defaultMarkdownComponents` that crossed the client boundary — so every page
|
|
24
|
+
* carried a reference to it whether or not it embedded a video. Measured on
|
|
25
|
+
* the smoke build over a corpus containing no YouTube URL anywhere: its code
|
|
26
|
+
* was in a client chunk **referenced from the prerendered HTML and the flight
|
|
27
|
+
* payload of every page**, and afterwards it is in no chunk at all.
|
|
28
|
+
*
|
|
29
|
+
* Be precise about the size, because the tempting number is the wrong one:
|
|
30
|
+
* that chunk was 41 KB raw / 12.70 KB brotli, but it was a *shared* chunk and
|
|
31
|
+
* most of it was not this component. Total client JavaScript went 610.8 KB to
|
|
32
|
+
* 609.2 KB raw. The win here is a client boundary removed from the path every
|
|
33
|
+
* consumer renders — one fewer hydration root, and a default map that is now
|
|
34
|
+
* provably server-only — not a large byte saving.
|
|
35
|
+
*
|
|
36
|
+
* `<details>` does the same job in markup. Measured in Chromium: an
|
|
37
|
+
* `<iframe loading="lazy">` inside a **closed** `<details>` issues no request
|
|
38
|
+
* at all, and issues one the moment it opens — so the facade still defers the
|
|
39
|
+
* player, without a state hook, a hydration root or a client reference. Native
|
|
40
|
+
* also brings the keyboard handling and the disclosure semantics the button
|
|
41
|
+
* version had to spell out.
|
|
42
|
+
*
|
|
43
|
+
* The summary stays in the DOM once open, visually hidden rather than removed:
|
|
44
|
+
* removing the element under the reader's focus is what the old version needed
|
|
45
|
+
* a `useEffect` to paper over, and a hidden-but-focusable control keeps focus
|
|
46
|
+
* where the reader put it *and* leaves them a way to collapse it again.
|
|
21
47
|
*
|
|
22
48
|
* `hqdefault.jpg` rather than `maxresdefault.jpg` deliberately: maxres does not
|
|
23
49
|
* exist for uploads below 1280×720 and 404s to a broken image with no fallback.
|
package/dist/react/youtube.js
CHANGED
|
@@ -1,76 +1,98 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { useEffect, useRef, useState } from "react";
|
|
3
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
2
|
//#region src/react/youtube.tsx
|
|
5
3
|
const DEFAULT_TITLE = "YouTube video player";
|
|
6
4
|
/**
|
|
7
|
-
* Click-to-load YouTube embed
|
|
5
|
+
* Click-to-load YouTube embed, with **no client JavaScript at all**.
|
|
8
6
|
*
|
|
9
7
|
* An eager `<iframe>` costs ~137 KB of embed document plus ~580 KB gzipped of
|
|
10
|
-
* player JavaScript
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* player JavaScript on every page view, whether or not anyone presses play. A
|
|
9
|
+
* facade costs one ~15 KB JPEG and loads the rest on demand.
|
|
10
|
+
*
|
|
11
|
+
* ## Why `<details>` and not `useState`
|
|
12
|
+
*
|
|
13
|
+
* This was a `'use client'` component, and that made it the one thing in
|
|
14
|
+
* `defaultMarkdownComponents` that crossed the client boundary — so every page
|
|
15
|
+
* carried a reference to it whether or not it embedded a video. Measured on
|
|
16
|
+
* the smoke build over a corpus containing no YouTube URL anywhere: its code
|
|
17
|
+
* was in a client chunk **referenced from the prerendered HTML and the flight
|
|
18
|
+
* payload of every page**, and afterwards it is in no chunk at all.
|
|
19
|
+
*
|
|
20
|
+
* Be precise about the size, because the tempting number is the wrong one:
|
|
21
|
+
* that chunk was 41 KB raw / 12.70 KB brotli, but it was a *shared* chunk and
|
|
22
|
+
* most of it was not this component. Total client JavaScript went 610.8 KB to
|
|
23
|
+
* 609.2 KB raw. The win here is a client boundary removed from the path every
|
|
24
|
+
* consumer renders — one fewer hydration root, and a default map that is now
|
|
25
|
+
* provably server-only — not a large byte saving.
|
|
26
|
+
*
|
|
27
|
+
* `<details>` does the same job in markup. Measured in Chromium: an
|
|
28
|
+
* `<iframe loading="lazy">` inside a **closed** `<details>` issues no request
|
|
29
|
+
* at all, and issues one the moment it opens — so the facade still defers the
|
|
30
|
+
* player, without a state hook, a hydration root or a client reference. Native
|
|
31
|
+
* also brings the keyboard handling and the disclosure semantics the button
|
|
32
|
+
* version had to spell out.
|
|
33
|
+
*
|
|
34
|
+
* The summary stays in the DOM once open, visually hidden rather than removed:
|
|
35
|
+
* removing the element under the reader's focus is what the old version needed
|
|
36
|
+
* a `useEffect` to paper over, and a hidden-but-focusable control keeps focus
|
|
37
|
+
* where the reader put it *and* leaves them a way to collapse it again.
|
|
14
38
|
*
|
|
15
39
|
* `hqdefault.jpg` rather than `maxresdefault.jpg` deliberately: maxres does not
|
|
16
40
|
* exist for uploads below 1280×720 and 404s to a broken image with no fallback.
|
|
17
41
|
*/
|
|
18
42
|
function YouTube({ id, title, className }) {
|
|
19
|
-
const [isPlaying, setIsPlaying] = useState(false);
|
|
20
|
-
const frameRef = useRef(null);
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
if (isPlaying) frameRef.current?.focus();
|
|
23
|
-
}, [isPlaying]);
|
|
24
43
|
if (!id) return null;
|
|
25
44
|
const safeId = encodeURIComponent(id);
|
|
26
45
|
const label = title?.trim() || DEFAULT_TITLE;
|
|
27
46
|
const rootClassName = ["wave-docs-youtube", className].filter(Boolean).join(" ");
|
|
28
|
-
|
|
47
|
+
return /* @__PURE__ */ jsxs("details", {
|
|
29
48
|
className: rootClassName,
|
|
30
|
-
children: /* @__PURE__ */
|
|
31
|
-
className: "wave-docs-youtube__frame",
|
|
32
|
-
src: `https://www.youtube-nocookie.com/embed/${safeId}?autoplay=1&rel=0`,
|
|
33
|
-
title: label,
|
|
34
|
-
loading: "lazy",
|
|
35
|
-
allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
|
|
36
|
-
allowFullScreen: true,
|
|
37
|
-
ref: frameRef
|
|
38
|
-
})
|
|
39
|
-
});
|
|
40
|
-
return /* @__PURE__ */ jsx("div", {
|
|
41
|
-
className: rootClassName,
|
|
42
|
-
children: /* @__PURE__ */ jsxs("button", {
|
|
43
|
-
type: "button",
|
|
49
|
+
children: [/* @__PURE__ */ jsxs("summary", {
|
|
44
50
|
className: "wave-docs-youtube__facade",
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"aria-hidden": "true",
|
|
58
|
-
children: /* @__PURE__ */ jsxs("svg", {
|
|
59
|
-
viewBox: "0 0 68 48",
|
|
60
|
-
width: "68",
|
|
61
|
-
height: "48",
|
|
51
|
+
children: [
|
|
52
|
+
/* @__PURE__ */ jsx("img", {
|
|
53
|
+
className: "wave-docs-youtube__thumbnail",
|
|
54
|
+
src: `https://i.ytimg.com/vi/${safeId}/hqdefault.jpg`,
|
|
55
|
+
alt: "",
|
|
56
|
+
width: 480,
|
|
57
|
+
height: 360,
|
|
58
|
+
loading: "lazy",
|
|
59
|
+
decoding: "async"
|
|
60
|
+
}),
|
|
61
|
+
/* @__PURE__ */ jsx("span", {
|
|
62
|
+
className: "wave-docs-youtube__play",
|
|
62
63
|
"aria-hidden": "true",
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
children: /* @__PURE__ */ jsxs("svg", {
|
|
65
|
+
viewBox: "0 0 68 48",
|
|
66
|
+
width: "68",
|
|
67
|
+
height: "48",
|
|
68
|
+
"aria-hidden": "true",
|
|
69
|
+
focusable: "false",
|
|
70
|
+
children: [/* @__PURE__ */ jsx("path", {
|
|
71
|
+
className: "wave-docs-youtube__play-bg",
|
|
72
|
+
d: "M66.52 7.74a8 8 0 0 0-5.65-5.66C56.1.99 34 .99 34 .99s-22.1 0-26.87 1.09a8 8 0 0 0-5.65 5.66C.39 12.51.39 24 .39 24s0 11.49 1.09 16.26a8 8 0 0 0 5.65 5.66C11.9 47 34 47 34 47s22.1 0 26.87-1.08a8 8 0 0 0 5.65-5.66C67.61 35.49 67.61 24 67.61 24s0-11.49-1.09-16.26"
|
|
73
|
+
}), /* @__PURE__ */ jsx("path", {
|
|
74
|
+
className: "wave-docs-youtube__play-arrow",
|
|
75
|
+
d: "M27 34V14l17 10z"
|
|
76
|
+
})]
|
|
77
|
+
})
|
|
78
|
+
}),
|
|
79
|
+
/* @__PURE__ */ jsx("span", {
|
|
80
|
+
className: "wave-docs-sr-only wave-docs-youtube__label-play",
|
|
81
|
+
children: `Play video: ${label}`
|
|
82
|
+
}),
|
|
83
|
+
/* @__PURE__ */ jsx("span", {
|
|
84
|
+
className: "wave-docs-youtube__label-hide",
|
|
85
|
+
children: `Hide video: ${label}`
|
|
71
86
|
})
|
|
72
|
-
|
|
73
|
-
})
|
|
87
|
+
]
|
|
88
|
+
}), /* @__PURE__ */ jsx("iframe", {
|
|
89
|
+
className: "wave-docs-youtube__frame",
|
|
90
|
+
loading: "lazy",
|
|
91
|
+
src: `https://www.youtube-nocookie.com/embed/${safeId}?rel=0&autoplay=1`,
|
|
92
|
+
title: label,
|
|
93
|
+
allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
|
|
94
|
+
allowFullScreen: true
|
|
95
|
+
})]
|
|
74
96
|
});
|
|
75
97
|
}
|
|
76
98
|
//#endregion
|
package/dist/render.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { DocFile, DocFrontmatter, ImageResolver, LinkResolver, RenderedDoc, ResolvedDocsConfig } from "./types.js";
|
|
2
2
|
import { DocsHighlighter, DocsLang, DocsThemes } from "./highlighter.js";
|
|
3
|
+
import { resolveMarkdownLink } from "./plugins/remark-doc-links.js";
|
|
4
|
+
import { PluggableList } from "unified";
|
|
3
5
|
//#region src/render.d.ts
|
|
4
6
|
/**
|
|
5
7
|
* The parts of {@link ResolvedDocsConfig} rendering actually depends on.
|
|
@@ -31,6 +33,38 @@ interface DocsRendererOptions {
|
|
|
31
33
|
* is a duplication authors forget to keep in step.
|
|
32
34
|
*/
|
|
33
35
|
titleHeading?: boolean | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Extra remark plugins, attached **after `remarkGfm` and before
|
|
38
|
+
* `remarkDocLinks`**.
|
|
39
|
+
*
|
|
40
|
+
* Which is to say: while links are still mdast `url` strings, so anything
|
|
41
|
+
* you emit is folded, contained and asserted exactly like authored markdown.
|
|
42
|
+
* A plugin emitting `[x](../other/page.md)` gets the same resolution an
|
|
43
|
+
* author would; one emitting `` throws `invalid-image` without
|
|
44
|
+
* a resolver, for the same reason.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ ATTACHED ONCE, TO A PROCESSOR SHARED BY EVERY FILE. The pipeline is
|
|
47
|
+
* built and frozen a single time, so a plugin holding state accumulates it
|
|
48
|
+
* across the whole build rather than per document. Keep them pure, or key
|
|
49
|
+
* whatever they hold on the vfile.
|
|
50
|
+
*/
|
|
51
|
+
remarkPlugins?: PluggableList | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Extra rehype plugins, attached **after `rehypeAutolinkHeadings` and before
|
|
54
|
+
* the code frame and Shiki**.
|
|
55
|
+
*
|
|
56
|
+
* The position is the useful one and it is not negotiable: after slugging
|
|
57
|
+
* and autolinking, so heading ids exist; before Shiki, so a `<pre>` is still
|
|
58
|
+
* `<pre><code class="language-ts">` with the author's text inside rather
|
|
59
|
+
* than several hundred token spans. Fences excluded by `excludeLangs` are
|
|
60
|
+
* not yet disguised at this point either, so a plugin sees every code block
|
|
61
|
+
* the same way.
|
|
62
|
+
*
|
|
63
|
+
* Code-block internals are Shiki's `transformers`, not this. There is no
|
|
64
|
+
* after-Shiki slot, because the honest documentation for one would be a list
|
|
65
|
+
* of things you must not do.
|
|
66
|
+
*/
|
|
67
|
+
rehypePlugins?: PluggableList | undefined;
|
|
34
68
|
/** Replaces the built-in markdown-link resolution. */
|
|
35
69
|
linkResolver?: LinkResolver | undefined;
|
|
36
70
|
/**
|
|
@@ -103,4 +137,4 @@ interface DocsRenderer {
|
|
|
103
137
|
*/
|
|
104
138
|
declare function createDocsRenderer(options: DocsRendererOptions): DocsRenderer;
|
|
105
139
|
//#endregion
|
|
106
|
-
export { DocsRenderer, DocsRendererConfig, DocsRendererOptions, createDocsRenderer };
|
|
140
|
+
export { DocsRenderer, DocsRendererConfig, DocsRendererOptions, createDocsRenderer, resolveMarkdownLink };
|