@waveso/docs 0.3.0 → 0.5.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 +201 -0
- package/README.md +160 -39
- package/dist/errors.d.ts +2 -0
- package/dist/highlighter.js +2 -1
- package/dist/meta.js +6 -9
- package/dist/next.d.ts +54 -14
- package/dist/next.js +115 -18
- package/dist/plugins/rehype-code-frame.d.ts +13 -1
- package/dist/plugins/rehype-code-frame.js +2 -1
- package/dist/plugins/remark-doc-links.d.ts +51 -1
- package/dist/plugins/remark-doc-links.js +27 -16
- package/dist/plugins/remark-youtube.d.ts +18 -3
- package/dist/plugins/remark-youtube.js +57 -9
- package/dist/react/callout.d.ts +13 -1
- package/dist/react/callout.js +2 -2
- package/dist/react/code-runtime.d.ts +12 -2
- package/dist/react/code-runtime.js +28 -4
- package/dist/react/doc-content.d.ts +12 -1
- package/dist/react/doc-content.js +2 -2
- package/dist/react/layout.d.ts +27 -10
- package/dist/react/layout.js +6 -3
- package/dist/react/markdown-components.d.ts +29 -1
- package/dist/react/markdown-components.js +69 -67
- package/dist/react/nav.d.ts +5 -1
- package/dist/react/nav.js +5 -2
- package/dist/react/next-nav.d.ts +5 -1
- package/dist/react/next-nav.js +5 -2
- package/dist/react/search-dialog.d.ts +92 -5
- package/dist/react/search-dialog.js +182 -43
- package/dist/react/shell-labels.d.ts +135 -21
- package/dist/react/shell-labels.js +47 -6
- package/dist/react/sidebar.d.ts +18 -1
- package/dist/react/sidebar.js +59 -23
- package/dist/react/youtube.d.ts +22 -1
- package/dist/react/youtube.js +22 -4
- package/dist/render.d.ts +11 -0
- package/dist/render.js +38 -11
- package/dist/route-path.js +7 -2
- package/dist/safe-href.d.ts +47 -0
- package/dist/safe-href.js +73 -0
- package/dist/search-index.js +1 -1
- package/dist/search-options.d.ts +64 -2
- package/dist/search-options.js +25 -1
- package/dist/semaphore.d.ts +46 -0
- package/dist/semaphore.js +60 -0
- package/dist/source.js +80 -10
- package/dist/styles.css +62 -13
- package/dist/types.d.ts +30 -0
- package/package.json +1 -1
|
@@ -1,11 +1,42 @@
|
|
|
1
1
|
//#region src/react/shell-labels.ts
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Every key of {@link DocsLabels}, for the test that proves each one is wired.
|
|
4
4
|
*
|
|
5
|
-
* A
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
5
|
+
* A list rather than a type-level trick because the assertion has to run: the
|
|
6
|
+
* failure being guarded against is a key that is declared, documented and never
|
|
7
|
+
* read, which type-checks perfectly.
|
|
8
|
+
*/
|
|
9
|
+
const DOCS_LABEL_KEYS = [
|
|
10
|
+
"nav",
|
|
11
|
+
"openNav",
|
|
12
|
+
"closeNav",
|
|
13
|
+
"skipToContent",
|
|
14
|
+
"expandGroup",
|
|
15
|
+
"collapseGroup",
|
|
16
|
+
"toc",
|
|
17
|
+
"backToTop",
|
|
18
|
+
"externalLink",
|
|
19
|
+
"table",
|
|
20
|
+
"calloutNote",
|
|
21
|
+
"calloutTip",
|
|
22
|
+
"calloutImportant",
|
|
23
|
+
"calloutWarning",
|
|
24
|
+
"calloutCaution",
|
|
25
|
+
"youtubeTitle",
|
|
26
|
+
"youtubePlay",
|
|
27
|
+
"youtubeHide",
|
|
28
|
+
"copyCode",
|
|
29
|
+
"copyCodeFrom",
|
|
30
|
+
"copied",
|
|
31
|
+
"copyFailed"
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Defaults for the shell's four, in one place.
|
|
35
|
+
*
|
|
36
|
+
* A `Required<…>` rather than four `=` defaults spread across three components:
|
|
37
|
+
* the previous arrangement is how `DocsSidebar` came to default its landmark to
|
|
38
|
+
* `'Docs'` while `DocsNav` defaulted the same landmark to `'Documentation'` —
|
|
39
|
+
* two names for one region, depending on the viewport.
|
|
9
40
|
*/
|
|
10
41
|
const DEFAULT_DOCS_LABELS = {
|
|
11
42
|
nav: "Documentation",
|
|
@@ -23,5 +54,15 @@ function resolveLabels(labels) {
|
|
|
23
54
|
skipToContent: labels.skipToContent ?? DEFAULT_DOCS_LABELS.skipToContent
|
|
24
55
|
};
|
|
25
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* `template` with `{title}` replaced.
|
|
59
|
+
*
|
|
60
|
+
* A placeholder, because these strings cross a Server → Client boundary where a
|
|
61
|
+
* function cannot go — and because a translator needs to move the name within
|
|
62
|
+
* the sentence, which string concatenation does not allow.
|
|
63
|
+
*/
|
|
64
|
+
function fillTitle(template, title) {
|
|
65
|
+
return template.replace("{title}", title);
|
|
66
|
+
}
|
|
26
67
|
//#endregion
|
|
27
|
-
export { DEFAULT_DOCS_LABELS, resolveLabels };
|
|
68
|
+
export { DEFAULT_DOCS_LABELS, DOCS_LABEL_KEYS, fillTitle, resolveLabels };
|
package/dist/react/sidebar.d.ts
CHANGED
|
@@ -15,6 +15,23 @@ interface DocsSidebarProps {
|
|
|
15
15
|
Link?: DocsLinkComponent | undefined;
|
|
16
16
|
/** Accessible name for the landmark. Distinguish multiple navs on a page. */
|
|
17
17
|
label?: string | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* A collapsed group's toggle. Default `'Expand {title}'`.
|
|
20
|
+
*
|
|
21
|
+
* `{title}` is replaced with the group's own name. A placeholder rather than
|
|
22
|
+
* a function, because `docs.Layout` sets this from a Server Component and a
|
|
23
|
+
* function cannot cross that boundary — and because a translator has to be
|
|
24
|
+
* able to move the name within the sentence.
|
|
25
|
+
*/
|
|
26
|
+
expandGroup?: string | undefined;
|
|
27
|
+
/** The same toggle when open. Default `'Collapse {title}'`. */
|
|
28
|
+
collapseGroup?: string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Screen-reader suffix on an external link. Default `'(opens in a new tab)'`.
|
|
31
|
+
*
|
|
32
|
+
* The separating space is markup, so this is the sentence and nothing else.
|
|
33
|
+
*/
|
|
34
|
+
externalLink?: string | undefined;
|
|
18
35
|
className?: string | undefined;
|
|
19
36
|
}
|
|
20
37
|
/**
|
|
@@ -59,6 +76,6 @@ interface DocsSidebarProps {
|
|
|
59
76
|
* nothing prefetches locally whatever this says. Do not "fix" it back because
|
|
60
77
|
* the network tab looks the same.
|
|
61
78
|
*/
|
|
62
|
-
declare function DocsSidebar({ nav, pathname, Link, label, className }: DocsSidebarProps): ReactNode;
|
|
79
|
+
declare function DocsSidebar({ nav, pathname, Link, label, expandGroup, collapseGroup, externalLink, className }: DocsSidebarProps): ReactNode;
|
|
63
80
|
//#endregion
|
|
64
81
|
export { DocsSidebar, DocsSidebarProps };
|
package/dist/react/sidebar.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { nearestScrollTop } from "./nearest-scroll-top.js";
|
|
3
|
-
import { useId, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
4
4
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
5
5
|
//#region src/react/sidebar.tsx
|
|
6
|
+
const DEFAULT_SIDEBAR_LABELS = {
|
|
7
|
+
expandGroup: "Expand {title}",
|
|
8
|
+
collapseGroup: "Collapse {title}",
|
|
9
|
+
externalLink: "(opens in a new tab)"
|
|
10
|
+
};
|
|
6
11
|
/** Trailing slashes are a routing detail, not a difference in identity. */
|
|
7
12
|
function normalizeHref(href) {
|
|
8
13
|
return href.length > 1 ? href.replace(/\/+$/, "") : href;
|
|
@@ -61,7 +66,12 @@ function containsActive(node, pathname) {
|
|
|
61
66
|
* nothing prefetches locally whatever this says. Do not "fix" it back because
|
|
62
67
|
* the network tab looks the same.
|
|
63
68
|
*/
|
|
64
|
-
function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
69
|
+
function DocsSidebar({ nav, pathname, Link, label = "Docs", expandGroup, collapseGroup, externalLink, className }) {
|
|
70
|
+
const text = {
|
|
71
|
+
expandGroup: expandGroup ?? DEFAULT_SIDEBAR_LABELS.expandGroup,
|
|
72
|
+
collapseGroup: collapseGroup ?? DEFAULT_SIDEBAR_LABELS.collapseGroup,
|
|
73
|
+
externalLink: externalLink ?? DEFAULT_SIDEBAR_LABELS.externalLink
|
|
74
|
+
};
|
|
65
75
|
const baseId = useId();
|
|
66
76
|
const [toggled, setToggled] = useState({});
|
|
67
77
|
const lastPathname = useRef(pathname);
|
|
@@ -77,19 +87,19 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
|
77
87
|
};
|
|
78
88
|
const navRef = useRef(null);
|
|
79
89
|
useLayoutEffect(() => {
|
|
80
|
-
|
|
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;
|
|
90
|
+
revealActive(navRef.current);
|
|
92
91
|
}, [pathname]);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const dialog = navRef.current?.closest("dialog");
|
|
94
|
+
if (!(dialog instanceof HTMLDialogElement)) return;
|
|
95
|
+
const onToggle = () => {
|
|
96
|
+
if (dialog.open) revealActive(navRef.current);
|
|
97
|
+
};
|
|
98
|
+
dialog.addEventListener("toggle", onToggle);
|
|
99
|
+
return () => {
|
|
100
|
+
dialog.removeEventListener("toggle", onToggle);
|
|
101
|
+
};
|
|
102
|
+
}, []);
|
|
93
103
|
return /* @__PURE__ */ jsx("nav", {
|
|
94
104
|
ref: navRef,
|
|
95
105
|
"aria-label": label,
|
|
@@ -101,7 +111,8 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
|
101
111
|
pathname,
|
|
102
112
|
Link,
|
|
103
113
|
toggled,
|
|
104
|
-
onToggle: handleToggle
|
|
114
|
+
onToggle: handleToggle,
|
|
115
|
+
text
|
|
105
116
|
})
|
|
106
117
|
});
|
|
107
118
|
}
|
|
@@ -115,6 +126,28 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", className }) {
|
|
|
115
126
|
* one navigation where they know precisely what they asked for.
|
|
116
127
|
* `sidebar.test.tsx` spies on it and asserts it is never called.
|
|
117
128
|
*/
|
|
129
|
+
/**
|
|
130
|
+
* Scroll the item marked `aria-current="page"` into view, if it is not already.
|
|
131
|
+
*
|
|
132
|
+
* A no-op wherever there is nothing to measure — no nav, no active item, no
|
|
133
|
+
* scrollport — which is every server render, every jsdom render, and every
|
|
134
|
+
* layout where the column is shorter than its content. Nothing to do, and
|
|
135
|
+
* nothing to do wrongly.
|
|
136
|
+
*/
|
|
137
|
+
function revealActive(nav) {
|
|
138
|
+
const active = nav?.querySelector("[aria-current=\"page\"]");
|
|
139
|
+
if (!(active instanceof HTMLElement)) return;
|
|
140
|
+
const port = scrollableAncestor(active);
|
|
141
|
+
if (port === null) return;
|
|
142
|
+
const next = nearestScrollTop({
|
|
143
|
+
itemTop: active.getBoundingClientRect().top - port.getBoundingClientRect().top + port.scrollTop,
|
|
144
|
+
itemHeight: active.offsetHeight,
|
|
145
|
+
viewHeight: port.clientHeight,
|
|
146
|
+
scrollTop: port.scrollTop,
|
|
147
|
+
scrollHeight: port.scrollHeight
|
|
148
|
+
});
|
|
149
|
+
if (next !== void 0) port.scrollTop = next;
|
|
150
|
+
}
|
|
118
151
|
function scrollableAncestor(element) {
|
|
119
152
|
let current = element.parentElement;
|
|
120
153
|
while (current !== null) {
|
|
@@ -124,7 +157,7 @@ function scrollableAncestor(element) {
|
|
|
124
157
|
}
|
|
125
158
|
return null;
|
|
126
159
|
}
|
|
127
|
-
function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, id }) {
|
|
160
|
+
function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, text, id }) {
|
|
128
161
|
const holdsActive = nodes.some((node) => (node.type === "page" || node.type === "link" && !node.external) && isActiveHref(pathname, node.href));
|
|
129
162
|
return /* @__PURE__ */ jsx("ul", {
|
|
130
163
|
id,
|
|
@@ -148,6 +181,7 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
|
|
|
148
181
|
isActive: !node.external && isActiveHref(pathname, node.href),
|
|
149
182
|
isNearby: holdsActive,
|
|
150
183
|
Link,
|
|
184
|
+
externalLink: text.externalLink,
|
|
151
185
|
children: node.title
|
|
152
186
|
})
|
|
153
187
|
}, key);
|
|
@@ -169,14 +203,15 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, i
|
|
|
169
203
|
pathname,
|
|
170
204
|
Link,
|
|
171
205
|
toggled,
|
|
172
|
-
onToggle
|
|
206
|
+
onToggle,
|
|
207
|
+
text
|
|
173
208
|
}, key);
|
|
174
209
|
default: return null;
|
|
175
210
|
}
|
|
176
211
|
})
|
|
177
212
|
});
|
|
178
213
|
}
|
|
179
|
-
function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
|
|
214
|
+
function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle, text }) {
|
|
180
215
|
const listId = `${itemKey}-list`;
|
|
181
216
|
const hasActive = containsActive(node, pathname);
|
|
182
217
|
const isOpen = toggled[itemKey] ?? hasActive;
|
|
@@ -208,7 +243,7 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
|
|
|
208
243
|
className: "wave-docs-sidebar__group-toggle",
|
|
209
244
|
"aria-expanded": isOpen,
|
|
210
245
|
"aria-controls": isOpen ? listId : void 0,
|
|
211
|
-
"aria-label":
|
|
246
|
+
"aria-label": (isOpen ? text.collapseGroup : text.expandGroup).replace("{title}", node.title),
|
|
212
247
|
onClick: () => onToggle(itemKey, !isOpen),
|
|
213
248
|
children: /* @__PURE__ */ jsx(Chevron, { isOpen })
|
|
214
249
|
})] })
|
|
@@ -220,11 +255,12 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle }) {
|
|
|
220
255
|
pathname,
|
|
221
256
|
Link,
|
|
222
257
|
toggled,
|
|
223
|
-
onToggle
|
|
258
|
+
onToggle,
|
|
259
|
+
text
|
|
224
260
|
}) : null]
|
|
225
261
|
});
|
|
226
262
|
}
|
|
227
|
-
function NavLink({ href, isExternal, isActive, isNearby = false, Link, children }) {
|
|
263
|
+
function NavLink({ href, isExternal, isActive, isNearby = false, Link, externalLink = DEFAULT_SIDEBAR_LABELS.externalLink, children }) {
|
|
228
264
|
const className = "wave-docs-sidebar__link";
|
|
229
265
|
if (isExternal) return /* @__PURE__ */ jsxs("a", {
|
|
230
266
|
className,
|
|
@@ -247,9 +283,9 @@ function NavLink({ href, isExternal, isActive, isNearby = false, Link, children
|
|
|
247
283
|
strokeLinejoin: "round",
|
|
248
284
|
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
285
|
}),
|
|
250
|
-
/* @__PURE__ */
|
|
286
|
+
/* @__PURE__ */ jsxs("span", {
|
|
251
287
|
className: "wave-docs-sr-only",
|
|
252
|
-
children: "
|
|
288
|
+
children: [" ", externalLink]
|
|
253
289
|
})
|
|
254
290
|
]
|
|
255
291
|
});
|
package/dist/react/youtube.d.ts
CHANGED
|
@@ -3,11 +3,32 @@ import { ReactNode } from "react";
|
|
|
3
3
|
interface YouTubeProps {
|
|
4
4
|
/** The 11-character video id, e.g. `dQw4w9WgXcQ`. */
|
|
5
5
|
id?: string | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Seconds to start at, from the link's `t` or `start`.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ IT USED TO BE DROPPED, AND THE FACADE AUTOPLAYS. `https://youtu.be/x?t=754`
|
|
10
|
+
* is a link to one moment in a two-hour talk — most of why anyone deep-links a
|
|
11
|
+
* video at all — and it opened at zero and started playing there, leaving the
|
|
12
|
+
* reader to work out that the author had meant somewhere else.
|
|
13
|
+
*/
|
|
14
|
+
start?: number | undefined;
|
|
15
|
+
/** Playlist the video was linked inside, from the link's `list`. */
|
|
16
|
+
list?: string | undefined;
|
|
6
17
|
/**
|
|
7
18
|
* Accessible name for the player. Markdown carries no video title, so the
|
|
8
19
|
* fallback is generic — pass a real one where you have it.
|
|
9
20
|
*/
|
|
10
21
|
title?: string | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The closed facade's control. Default `'Play video: {title}'`.
|
|
24
|
+
*
|
|
25
|
+
* `{title}` is replaced with {@link YouTubeProps.title}. A placeholder rather
|
|
26
|
+
* than concatenation because a translator has to be able to move the name
|
|
27
|
+
* within the sentence — several languages put it first.
|
|
28
|
+
*/
|
|
29
|
+
playLabel?: string | undefined;
|
|
30
|
+
/** The open facade's control. Default `'Hide video: {title}'`. */
|
|
31
|
+
hideLabel?: string | undefined;
|
|
11
32
|
className?: string | undefined;
|
|
12
33
|
}
|
|
13
34
|
/**
|
|
@@ -48,6 +69,6 @@ interface YouTubeProps {
|
|
|
48
69
|
* `hqdefault.jpg` rather than `maxresdefault.jpg` deliberately: maxres does not
|
|
49
70
|
* exist for uploads below 1280×720 and 404s to a broken image with no fallback.
|
|
50
71
|
*/
|
|
51
|
-
declare function YouTube({ id, title, className }: YouTubeProps): ReactNode;
|
|
72
|
+
declare function YouTube({ id, start, list, title, playLabel, hideLabel, className }: YouTubeProps): ReactNode;
|
|
52
73
|
//#endregion
|
|
53
74
|
export { YouTube, YouTubeProps };
|
package/dist/react/youtube.js
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
//#region src/react/youtube.tsx
|
|
3
|
+
/**
|
|
4
|
+
* The embed URL, with whatever the author's link carried.
|
|
5
|
+
*
|
|
6
|
+
* `URLSearchParams` rather than string concatenation: `list` comes out of a
|
|
7
|
+
* document and goes into a URL, and building this by hand is how a crafted
|
|
8
|
+
* "playlist id" adds parameters of its own. `start` is already a number.
|
|
9
|
+
*/
|
|
10
|
+
function embedUrl(id, start, list) {
|
|
11
|
+
const params = new URLSearchParams({
|
|
12
|
+
rel: "0",
|
|
13
|
+
autoplay: "1"
|
|
14
|
+
});
|
|
15
|
+
if (start !== void 0 && Number.isFinite(start) && start > 0) params.set("start", String(Math.floor(start)));
|
|
16
|
+
if (list !== void 0 && list !== "") params.set("list", list);
|
|
17
|
+
return `https://www.youtube-nocookie.com/embed/${id}?${params.toString()}`;
|
|
18
|
+
}
|
|
3
19
|
const DEFAULT_TITLE = "YouTube video player";
|
|
20
|
+
const DEFAULT_PLAY_LABEL = "Play video: {title}";
|
|
21
|
+
const DEFAULT_HIDE_LABEL = "Hide video: {title}";
|
|
4
22
|
/**
|
|
5
23
|
* Click-to-load YouTube embed, with **no client JavaScript at all**.
|
|
6
24
|
*
|
|
@@ -39,7 +57,7 @@ const DEFAULT_TITLE = "YouTube video player";
|
|
|
39
57
|
* `hqdefault.jpg` rather than `maxresdefault.jpg` deliberately: maxres does not
|
|
40
58
|
* exist for uploads below 1280×720 and 404s to a broken image with no fallback.
|
|
41
59
|
*/
|
|
42
|
-
function YouTube({ id, title, className }) {
|
|
60
|
+
function YouTube({ id, start, list, title, playLabel, hideLabel, className }) {
|
|
43
61
|
if (!id) return null;
|
|
44
62
|
const safeId = encodeURIComponent(id);
|
|
45
63
|
const label = title?.trim() || DEFAULT_TITLE;
|
|
@@ -78,17 +96,17 @@ function YouTube({ id, title, className }) {
|
|
|
78
96
|
}),
|
|
79
97
|
/* @__PURE__ */ jsx("span", {
|
|
80
98
|
className: "wave-docs-sr-only wave-docs-youtube__label-play",
|
|
81
|
-
children:
|
|
99
|
+
children: (playLabel ?? DEFAULT_PLAY_LABEL).replace("{title}", label)
|
|
82
100
|
}),
|
|
83
101
|
/* @__PURE__ */ jsx("span", {
|
|
84
102
|
className: "wave-docs-youtube__label-hide",
|
|
85
|
-
children:
|
|
103
|
+
children: (hideLabel ?? DEFAULT_HIDE_LABEL).replace("{title}", label)
|
|
86
104
|
})
|
|
87
105
|
]
|
|
88
106
|
}), /* @__PURE__ */ jsx("iframe", {
|
|
89
107
|
className: "wave-docs-youtube__frame",
|
|
90
108
|
loading: "lazy",
|
|
91
|
-
src:
|
|
109
|
+
src: embedUrl(safeId, start, list),
|
|
92
110
|
title: label,
|
|
93
111
|
allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
|
|
94
112
|
allowFullScreen: true
|
package/dist/render.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DocFile, DocFrontmatter, ImageResolver, LinkResolver, RenderedDoc, ResolvedDocsConfig } from "./types.js";
|
|
2
2
|
import { DocsHighlighter, DocsLang, DocsThemes } from "./highlighter.js";
|
|
3
|
+
import { RehypeCodeFrameOptions } from "./plugins/rehype-code-frame.js";
|
|
3
4
|
import { resolveMarkdownLink } from "./plugins/remark-doc-links.js";
|
|
4
5
|
import { PluggableList } from "unified";
|
|
5
6
|
//#region src/render.d.ts
|
|
@@ -115,6 +116,16 @@ interface DocsRendererOptions {
|
|
|
115
116
|
* than a monochrome block of DSL.
|
|
116
117
|
*/
|
|
117
118
|
excludeLangs?: readonly string[] | undefined;
|
|
119
|
+
/**
|
|
120
|
+
* Accessible names for the copy button, for a site that is not in English.
|
|
121
|
+
*
|
|
122
|
+
* ⚠️ `rehypeCodeFrame` HAS TAKEN A `copyLabel` SINCE IT WAS WRITTEN AND
|
|
123
|
+
* NOTHING EVER PASSED ONE. The plugin is private, so the option was
|
|
124
|
+
* unreachable from every entry point — the README said the label was
|
|
125
|
+
* configurable and it was not. Baked into the HTML at build time, so
|
|
126
|
+
* overriding it costs no client bytes.
|
|
127
|
+
*/
|
|
128
|
+
codeLabels?: RehypeCodeFrameOptions | undefined;
|
|
118
129
|
}
|
|
119
130
|
/**
|
|
120
131
|
* Renders {@link DocFile}s. Build one per process and reuse it.
|
package/dist/render.js
CHANGED
|
@@ -5,7 +5,7 @@ import { rehypeCodeFrame } from "./plugins/rehype-code-frame.js";
|
|
|
5
5
|
import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
|
|
6
6
|
import { rehypeFallbackHeadingIds } from "./plugins/rehype-fallback-heading-ids.js";
|
|
7
7
|
import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
|
|
8
|
-
import { foldSegments, remarkDocLinks, resolveMarkdownLink } from "./plugins/remark-doc-links.js";
|
|
8
|
+
import { foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref } from "./plugins/remark-doc-links.js";
|
|
9
9
|
import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
|
|
10
10
|
import { remarkYouTube } from "./plugins/remark-youtube.js";
|
|
11
11
|
import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
|
|
@@ -96,9 +96,29 @@ function assertResolvedImage(value, src, relativePath) {
|
|
|
96
96
|
* author means by `` and what the link path has always done.
|
|
97
97
|
*/
|
|
98
98
|
function foldImageSrc(src, dirSegments) {
|
|
99
|
-
if (isPublicImageSrc(src)) return
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
if (isPublicImageSrc(src)) return {
|
|
100
|
+
path: src,
|
|
101
|
+
suffix: ""
|
|
102
|
+
};
|
|
103
|
+
const { path, query, hash } = splitHref(src);
|
|
104
|
+
if (path === "") return;
|
|
105
|
+
const segments = foldSegments(dirSegments, path);
|
|
106
|
+
return segments === void 0 ? void 0 : {
|
|
107
|
+
path: segments.join("/"),
|
|
108
|
+
suffix: `${query}${hash}`
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Put the authored `?query#hash` back, unless the resolver wrote its own.
|
|
113
|
+
*
|
|
114
|
+
* A resolver returning `/img/diagram.a1b2c3.png?w=800` has said something more
|
|
115
|
+
* specific than the author's `?v=2` — and concatenating the two would produce
|
|
116
|
+
* two `?` in one URL, which is not a URL. Same rule the rest of this package
|
|
117
|
+
* uses when a host and a default disagree: the more specific one wins.
|
|
118
|
+
*/
|
|
119
|
+
function withSuffix(src, suffix) {
|
|
120
|
+
if (suffix === "" || /[?#]/.test(src)) return src;
|
|
121
|
+
return `${src}${suffix}`;
|
|
102
122
|
}
|
|
103
123
|
/** Route without its `?query` / `#anchor`, for existence checks. */
|
|
104
124
|
function toRouteKey(href) {
|
|
@@ -238,7 +258,7 @@ async function buildProcessor(options, themes, highlighterPromise) {
|
|
|
238
258
|
ariaHidden: "true",
|
|
239
259
|
tabIndex: -1
|
|
240
260
|
}
|
|
241
|
-
}).use(options.rehypePlugins ?? []).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeCodeFrame).use(rehypeShikiFromHighlighter, highlighter, {
|
|
261
|
+
}).use(options.rehypePlugins ?? []).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeCodeFrame, options.codeLabels ?? {}).use(rehypeShikiFromHighlighter, highlighter, {
|
|
242
262
|
themes,
|
|
243
263
|
defaultColor: false,
|
|
244
264
|
fallbackLanguage: "text",
|
|
@@ -294,7 +314,13 @@ function createDocsRenderer(options) {
|
|
|
294
314
|
await Promise.all(images.map(async (node) => {
|
|
295
315
|
const src = node.properties.src;
|
|
296
316
|
if (typeof src !== "string" || src === "") return;
|
|
297
|
-
|
|
317
|
+
let folded;
|
|
318
|
+
try {
|
|
319
|
+
folded = foldImageSrc(src, context.dirSegments);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (!(error instanceof URIError)) throw error;
|
|
322
|
+
throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} is not valid percent-encoding. Write %25 for a literal percent sign, or name the file as it is on disk.`, { cause: error });
|
|
323
|
+
}
|
|
298
324
|
if (folded === void 0) throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} climbs above the content root.`);
|
|
299
325
|
if (resolve === void 0) {
|
|
300
326
|
if (isPublicImageSrc(src)) return;
|
|
@@ -302,16 +328,16 @@ function createDocsRenderer(options) {
|
|
|
302
328
|
}
|
|
303
329
|
let resolved;
|
|
304
330
|
try {
|
|
305
|
-
resolved = await resolve(folded, context);
|
|
331
|
+
resolved = await resolve(folded.path, context);
|
|
306
332
|
} catch (error) {
|
|
307
333
|
throw docsError("invalid-image", `@waveso/docs: the imageResolver threw on image "${src}" in ${file.relativePath}.`, { cause: error });
|
|
308
334
|
}
|
|
309
335
|
if (resolved === void 0) {
|
|
310
|
-
|
|
311
|
-
|
|
336
|
+
if (isPublicImageSrc(src)) return;
|
|
337
|
+
throw docsError("invalid-image", `@waveso/docs: the imageResolver returned nothing for image "${src}" in ${file.relativePath}, which is relative to the markdown file — so nothing can serve it: the browser would resolve it against the page route, and the same markdown would request a different file from every page. Return a src for it, or move the image under \`public/\` and write an absolute one such as "/diagram.png".`);
|
|
312
338
|
}
|
|
313
339
|
assertResolvedImage(resolved, src, file.relativePath);
|
|
314
|
-
node.properties.src = resolved.src;
|
|
340
|
+
node.properties.src = withSuffix(resolved.src, folded.suffix);
|
|
315
341
|
if (resolved.width !== void 0) node.properties.width = resolved.width;
|
|
316
342
|
if (resolved.height !== void 0) node.properties.height = resolved.height;
|
|
317
343
|
}));
|
|
@@ -336,8 +362,9 @@ function createDocsRenderer(options) {
|
|
|
336
362
|
}
|
|
337
363
|
return { async render(file) {
|
|
338
364
|
const processor = await processorPromise;
|
|
365
|
+
const lead = "\n".repeat(file.frontmatterLines ?? 0);
|
|
339
366
|
const vfile = new VFile({
|
|
340
|
-
value: file.content,
|
|
367
|
+
value: lead + file.content,
|
|
341
368
|
path: file.filePath
|
|
342
369
|
});
|
|
343
370
|
vfile.data.docLinkContext = {
|
package/dist/route-path.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { docsError } from "./docs-error.js";
|
|
2
|
-
import { foldSegments } from "./plugins/remark-doc-links.js";
|
|
2
|
+
import { decodePath, foldSegments } from "./plugins/remark-doc-links.js";
|
|
3
3
|
//#region src/route-path.ts
|
|
4
4
|
/**
|
|
5
5
|
* Turning route segments into a URL path.
|
|
@@ -39,7 +39,12 @@ const ALIAS_PATTERN_CHARS = /[:()+*?{}]/;
|
|
|
39
39
|
* every rejection below names the markdown file at the moment it is read.
|
|
40
40
|
*/
|
|
41
41
|
function toAliasRoute(alias, basePath, sourceLabel) {
|
|
42
|
-
|
|
42
|
+
let trimmed;
|
|
43
|
+
try {
|
|
44
|
+
trimmed = decodePath(alias.trim(), alias);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} is not valid percent-encoding. Write %25 for a literal percent sign, or write the former URL as its readable form.`, { cause: error });
|
|
47
|
+
}
|
|
43
48
|
if (trimmed.split("/").some((part) => part === "." || part === "..")) throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} has a '.' or '..' segment. An alias is a former URL relative to the docs base path, not a path on disk: write \`aliases: [legacy/old-name]\`.`);
|
|
44
49
|
const pattern = ALIAS_PATTERN_CHARS.exec(trimmed);
|
|
45
50
|
if (pattern !== null) throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} contains '${pattern[0]}', which Next compiles as redirect pattern syntax rather than as part of the URL — the redirect then swallows every page whose route the pattern happens to match, or fails the build. Remove the character; an alias is a literal former URL.`);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//#region src/safe-href.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The one allowlist of schemes this package will put in an `href`.
|
|
4
|
+
*
|
|
5
|
+
* Private — deliberately not an entry point.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ IT WAS THE MARKDOWN PATH'S ALONE, AND `meta.json` WENT ROUND IT. A hand
|
|
8
|
+
* written nav entry — `{ "title": "Status", "href": "javascript:…" }` — reached
|
|
9
|
+
* `<a href>` through `DocsSidebar` with nothing checking it, while the markdown
|
|
10
|
+
* beside it was filtered by a comment calling the check load-bearing. Both paths
|
|
11
|
+
* end at the same anchor, so both need the same rule, and a rule with two copies
|
|
12
|
+
* is a rule with one that is out of date.
|
|
13
|
+
*
|
|
14
|
+
* Node-safe and browser-safe: two regular expressions and two functions, no
|
|
15
|
+
* imports at all.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Strip the characters a browser ignores inside a URL.
|
|
19
|
+
*
|
|
20
|
+
* `java\nscript:` and `java	script:` are `javascript:` to a parser and not
|
|
21
|
+
* to a naive regular expression, so the test has to run on the string the
|
|
22
|
+
* browser will see rather than on the one that was written.
|
|
23
|
+
*/
|
|
24
|
+
declare function normaliseUrl(href: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Would this href navigate somewhere we are willing to send a reader?
|
|
27
|
+
*
|
|
28
|
+
* Nothing upstream filters it on the markdown side: `remarkDocLinks` skips every
|
|
29
|
+
* href with a scheme, so `assertLinks` never sees one either, and `remarkRehype`
|
|
30
|
+
* runs with `allowDangerousHtml` off but passes a link's own url through
|
|
31
|
+
* untouched. Verified against React 19: it neutralises `javascript:` in every
|
|
32
|
+
* obfuscated form, silently — but it lets `vbscript:` and
|
|
33
|
+
* `data:text/html;base64,…` reach the DOM verbatim. So the allowlist is ours.
|
|
34
|
+
*/
|
|
35
|
+
declare function isSafeHref(href: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Does following this href leave the site in a new tab?
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ NOT "HAS A SCHEME". `meta.json` used that test, so a `mailto:` sidebar
|
|
40
|
+
* entry was given `target="_blank"` and announced as "(opens in a new tab)" — a
|
|
41
|
+
* tab that never opens, described to precisely the reader who cannot see that it
|
|
42
|
+
* did not. Only http(s) and protocol-relative navigate; `mailto:` and `tel:`
|
|
43
|
+
* hand off to the OS and leave the page where it is.
|
|
44
|
+
*/
|
|
45
|
+
declare function opensInNewTab(href: string): boolean;
|
|
46
|
+
//#endregion
|
|
47
|
+
export { isSafeHref, normaliseUrl, opensInNewTab };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region src/safe-href.ts
|
|
2
|
+
/**
|
|
3
|
+
* The one allowlist of schemes this package will put in an `href`.
|
|
4
|
+
*
|
|
5
|
+
* Private — deliberately not an entry point.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ IT WAS THE MARKDOWN PATH'S ALONE, AND `meta.json` WENT ROUND IT. A hand
|
|
8
|
+
* written nav entry — `{ "title": "Status", "href": "javascript:…" }` — reached
|
|
9
|
+
* `<a href>` through `DocsSidebar` with nothing checking it, while the markdown
|
|
10
|
+
* beside it was filtered by a comment calling the check load-bearing. Both paths
|
|
11
|
+
* end at the same anchor, so both need the same rule, and a rule with two copies
|
|
12
|
+
* is a rule with one that is out of date.
|
|
13
|
+
*
|
|
14
|
+
* Node-safe and browser-safe: two regular expressions and two functions, no
|
|
15
|
+
* imports at all.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Any URL with a scheme, or protocol-relative.
|
|
19
|
+
*/
|
|
20
|
+
const ABSOLUTE_URL = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
21
|
+
/**
|
|
22
|
+
* The schemes a link may carry.
|
|
23
|
+
*
|
|
24
|
+
* GitHub's own allowlist, which is the bar to match: documentation links to
|
|
25
|
+
* `sms:`, `ftp:` and `irc:` are ordinary, and an allowlist of three silently
|
|
26
|
+
* deleted them. The point of the check is to stop `javascript:`, `data:` and
|
|
27
|
+
* `vbscript:` reaching an `href`, not to have an opinion about protocols.
|
|
28
|
+
*
|
|
29
|
+
* A scheme not listed here — `vscode:`, `obsidian:`, `slack:` — is refused
|
|
30
|
+
* rather than rendered. That is deliberate: an allowlist that grows on request
|
|
31
|
+
* is safe, one that guesses is not.
|
|
32
|
+
*/
|
|
33
|
+
const SAFE_SCHEME = /^(https?|mailto|tel|sms|ftp|ftps|irc|ircs|xmpp|news|nntp|feed|git|matrix):/i;
|
|
34
|
+
/**
|
|
35
|
+
* Strip the characters a browser ignores inside a URL.
|
|
36
|
+
*
|
|
37
|
+
* `java\nscript:` and `java	script:` are `javascript:` to a parser and not
|
|
38
|
+
* to a naive regular expression, so the test has to run on the string the
|
|
39
|
+
* browser will see rather than on the one that was written.
|
|
40
|
+
*/
|
|
41
|
+
function normaliseUrl(href) {
|
|
42
|
+
return [...href].filter((char) => (char.codePointAt(0) ?? 0) > 32).join("");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Would this href navigate somewhere we are willing to send a reader?
|
|
46
|
+
*
|
|
47
|
+
* Nothing upstream filters it on the markdown side: `remarkDocLinks` skips every
|
|
48
|
+
* href with a scheme, so `assertLinks` never sees one either, and `remarkRehype`
|
|
49
|
+
* runs with `allowDangerousHtml` off but passes a link's own url through
|
|
50
|
+
* untouched. Verified against React 19: it neutralises `javascript:` in every
|
|
51
|
+
* obfuscated form, silently — but it lets `vbscript:` and
|
|
52
|
+
* `data:text/html;base64,…` reach the DOM verbatim. So the allowlist is ours.
|
|
53
|
+
*/
|
|
54
|
+
function isSafeHref(href) {
|
|
55
|
+
const normalised = normaliseUrl(href);
|
|
56
|
+
if (!ABSOLUTE_URL.test(normalised)) return true;
|
|
57
|
+
return normalised.startsWith("//") || SAFE_SCHEME.test(normalised);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Does following this href leave the site in a new tab?
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ NOT "HAS A SCHEME". `meta.json` used that test, so a `mailto:` sidebar
|
|
63
|
+
* entry was given `target="_blank"` and announced as "(opens in a new tab)" — a
|
|
64
|
+
* tab that never opens, described to precisely the reader who cannot see that it
|
|
65
|
+
* did not. Only http(s) and protocol-relative navigate; `mailto:` and `tel:`
|
|
66
|
+
* hand off to the OS and leave the page where it is.
|
|
67
|
+
*/
|
|
68
|
+
function opensInNewTab(href) {
|
|
69
|
+
const normalised = normaliseUrl(href);
|
|
70
|
+
return /^https?:\/\//i.test(normalised) || normalised.startsWith("//");
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
export { isSafeHref, normaliseUrl, opensInNewTab };
|
package/dist/search-index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { isFootnotes, isTransparentContainer } from "./section-boundary.js";
|
|
2
1
|
import { mergeSearchOptions } from "./search-options.js";
|
|
2
|
+
import { isFootnotes, isTransparentContainer } from "./section-boundary.js";
|
|
3
3
|
import MiniSearch from "minisearch";
|
|
4
4
|
//#region src/search-index.ts
|
|
5
5
|
/** `<h1>`…`<h6>` to their numeric depth. */
|