@waveso/docs 0.1.0 → 0.2.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 +84 -0
- package/README.md +111 -22
- package/dist/docs-error.d.ts +74 -0
- package/dist/docs-error.js +40 -0
- package/dist/frontmatter.d.ts +39 -7
- package/dist/frontmatter.js +51 -24
- package/dist/highlighter.d.ts +2 -2
- package/dist/highlighter.js +3 -2
- package/dist/map-pooled.d.ts +26 -0
- package/dist/map-pooled.js +45 -0
- package/dist/meta.d.ts +7 -3
- package/dist/meta.js +61 -15
- package/dist/next.d.ts +41 -19
- package/dist/next.js +117 -21
- package/dist/plugins/rehype-capture-toc.js +26 -15
- package/dist/plugins/rehype-code-language.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +48 -0
- package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
- package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
- package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
- package/dist/plugins/rehype-flatten-roots.js +39 -0
- package/dist/plugins/remark-doc-links.d.ts +12 -1
- package/dist/plugins/remark-doc-links.js +147 -20
- package/dist/react/markdown-components.js +71 -6
- package/dist/react/search-dialog.d.ts +23 -7
- package/dist/react/search-dialog.js +46 -29
- package/dist/react/toc.js +28 -5
- package/dist/react/youtube.js +6 -4
- package/dist/render.d.ts +43 -9
- package/dist/render.js +112 -50
- package/dist/search-index.d.ts +32 -14
- package/dist/search-index.js +45 -51
- package/dist/search-options.d.ts +32 -1
- package/dist/search-options.js +66 -3
- package/dist/section-boundary.d.ts +17 -0
- package/dist/section-boundary.js +43 -0
- package/dist/source.d.ts +13 -1
- package/dist/source.js +152 -56
- package/dist/styles.css +236 -90
- package/dist/types.d.ts +41 -27
- package/package.json +13 -12
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
2
|
+
import { docsError } from "../docs-error.js";
|
|
3
|
+
import { mergeSearchOptions } from "../search-options.js";
|
|
3
4
|
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
4
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
6
|
import { createPortal } from "react-dom";
|
|
@@ -25,7 +26,7 @@ const FOCUSABLE_SELECTOR = [
|
|
|
25
26
|
* portalled to `document.body`, so a navbar's stacking context cannot trap
|
|
26
27
|
* it behind the page.
|
|
27
28
|
*/
|
|
28
|
-
function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", maxResults = 8, debounceMs = 120 }) {
|
|
29
|
+
function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", maxResults = 8, debounceMs = 120, className, searchOptions }) {
|
|
29
30
|
const [isOpen, setIsOpen] = useState(false);
|
|
30
31
|
const [query, setQuery] = useState("");
|
|
31
32
|
const [hits, setHits] = useState([]);
|
|
@@ -37,29 +38,34 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
37
38
|
const inputRef = useRef(null);
|
|
38
39
|
const listRef = useRef(null);
|
|
39
40
|
const returnFocusRef = useRef(null);
|
|
40
|
-
const
|
|
41
|
+
const indexCacheRef = useRef(/* @__PURE__ */ new Map());
|
|
42
|
+
const searchOptionsRef = useRef(searchOptions);
|
|
41
43
|
/** Whether the dialog has ever been open. See the focus effect below. */
|
|
42
44
|
const hasOpenedRef = useRef(false);
|
|
43
45
|
const baseId = useId();
|
|
44
46
|
const listId = `${baseId}-results`;
|
|
45
47
|
const optionId = (index) => `${baseId}-option-${index}`;
|
|
46
|
-
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
searchOptionsRef.current = searchOptions;
|
|
50
|
+
}, [searchOptions]);
|
|
51
|
+
/** Load each URL at most once; a failure evicts that key so a retry can. */
|
|
47
52
|
const ensureIndex = useCallback(() => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
53
|
+
const cache = indexCacheRef.current;
|
|
54
|
+
let pending = cache.get(indexUrl);
|
|
55
|
+
if (pending === void 0) {
|
|
56
|
+
pending = loadIndex(indexUrl, searchOptionsRef.current).catch((error) => {
|
|
57
|
+
cache.delete(indexUrl);
|
|
52
58
|
throw error;
|
|
53
59
|
});
|
|
54
|
-
|
|
60
|
+
cache.set(indexUrl, pending);
|
|
55
61
|
}
|
|
56
62
|
return pending;
|
|
57
63
|
}, [indexUrl]);
|
|
58
64
|
const warmIndex = useCallback(() => {
|
|
59
|
-
if (
|
|
65
|
+
if (indexCacheRef.current.has(indexUrl)) return;
|
|
60
66
|
setStatus("loading");
|
|
61
67
|
ensureIndex().then(() => setStatus("ready"), () => setStatus("error"));
|
|
62
|
-
}, [ensureIndex]);
|
|
68
|
+
}, [ensureIndex, indexUrl]);
|
|
63
69
|
const openDialog = useCallback(() => {
|
|
64
70
|
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
65
71
|
setIsOpen(true);
|
|
@@ -83,6 +89,20 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
83
89
|
openDialog,
|
|
84
90
|
closeDialog
|
|
85
91
|
]);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!isOpen) return;
|
|
94
|
+
function handleKeyDown(event) {
|
|
95
|
+
if (event.key === "Escape") {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
event.stopPropagation();
|
|
98
|
+
closeDialog();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (event.key === "Tab") trapFocus(dialogRef.current, event);
|
|
102
|
+
}
|
|
103
|
+
document.addEventListener("keydown", handleKeyDown, true);
|
|
104
|
+
return () => document.removeEventListener("keydown", handleKeyDown, true);
|
|
105
|
+
}, [isOpen, closeDialog]);
|
|
86
106
|
useEffect(() => {
|
|
87
107
|
const isApple = /mac|iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
88
108
|
setShortcutHint(isApple ? "⌘K" : "Ctrl K");
|
|
@@ -153,17 +173,8 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
153
173
|
closeDialog();
|
|
154
174
|
navigate(hit.href);
|
|
155
175
|
}, [closeDialog, navigate]);
|
|
156
|
-
function
|
|
157
|
-
if (event.
|
|
158
|
-
event.preventDefault();
|
|
159
|
-
event.stopPropagation();
|
|
160
|
-
closeDialog();
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
if (event.key === "Tab") {
|
|
164
|
-
trapFocus(dialogRef.current, event);
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
176
|
+
function handleInputKeyDown(event) {
|
|
177
|
+
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
167
178
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
168
179
|
if (hits.length === 0) return;
|
|
169
180
|
event.preventDefault();
|
|
@@ -182,7 +193,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
182
193
|
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
|
|
183
194
|
type: "button",
|
|
184
195
|
ref: triggerRef,
|
|
185
|
-
className: "wave-docs-search-trigger",
|
|
196
|
+
className: ["wave-docs-search-trigger", className].filter(Boolean).join(" "),
|
|
186
197
|
"aria-label": triggerLabel,
|
|
187
198
|
"aria-keyshortcuts": "Meta+K Control+K",
|
|
188
199
|
onClick: openDialog,
|
|
@@ -206,7 +217,6 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
206
217
|
role: "dialog",
|
|
207
218
|
"aria-modal": "true",
|
|
208
219
|
"aria-label": dialogLabel,
|
|
209
|
-
onKeyDown: handleDialogKeyDown,
|
|
210
220
|
children: [
|
|
211
221
|
/* @__PURE__ */ jsxs("div", {
|
|
212
222
|
className: "wave-docs-search-input-row",
|
|
@@ -223,6 +233,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
|
|
|
223
233
|
placeholder,
|
|
224
234
|
value: query,
|
|
225
235
|
onChange: (event) => setQuery(event.target.value),
|
|
236
|
+
onKeyDown: handleInputKeyDown,
|
|
226
237
|
autoComplete: "off",
|
|
227
238
|
autoCorrect: "off",
|
|
228
239
|
spellCheck: false
|
|
@@ -335,10 +346,10 @@ function SearchStatus({ status, query, hitCount }) {
|
|
|
335
346
|
* `loadJSONAsync` yields between chunks so deserialising a large index does
|
|
336
347
|
* not freeze the frame the dialog just opened in.
|
|
337
348
|
*/
|
|
338
|
-
async function loadIndex(url) {
|
|
349
|
+
async function loadIndex(url, overrides) {
|
|
339
350
|
const [{ default: MiniSearchClass }, response] = await Promise.all([import("minisearch"), fetch(url)]);
|
|
340
|
-
if (!response.ok) throw
|
|
341
|
-
return MiniSearchClass.loadJSONAsync(await response.text(),
|
|
351
|
+
if (!response.ok) throw docsError("search-index-unavailable", `Failed to load the search index from ${url} (HTTP ${response.status}).`);
|
|
352
|
+
return MiniSearchClass.loadJSONAsync(await response.text(), mergeSearchOptions(overrides));
|
|
342
353
|
}
|
|
343
354
|
/**
|
|
344
355
|
* Narrow one MiniSearch result. Its stored fields are untyped by design, and
|
|
@@ -392,10 +403,16 @@ function trapFocus(root, event) {
|
|
|
392
403
|
event.preventDefault();
|
|
393
404
|
return;
|
|
394
405
|
}
|
|
395
|
-
|
|
406
|
+
const active = document.activeElement;
|
|
407
|
+
if (!(active instanceof HTMLElement) || !root.contains(active)) {
|
|
408
|
+
event.preventDefault();
|
|
409
|
+
(event.shiftKey ? last : first).focus();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (event.shiftKey && active === first) {
|
|
396
413
|
event.preventDefault();
|
|
397
414
|
last.focus();
|
|
398
|
-
} else if (!event.shiftKey &&
|
|
415
|
+
} else if (!event.shiftKey && active === last) {
|
|
399
416
|
event.preventDefault();
|
|
400
417
|
first.focus();
|
|
401
418
|
}
|
package/dist/react/toc.js
CHANGED
|
@@ -3,6 +3,13 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
3
3
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
4
|
//#region src/react/toc.tsx
|
|
5
5
|
const DEFAULT_ROOT_MARGIN = "-80px 0px -60% 0px";
|
|
6
|
+
/**
|
|
7
|
+
* How many frames to keep looking for headings that are not in the document
|
|
8
|
+
* yet. ~1s at 60Hz, which covers a `<Suspense>` boundary resolving or a
|
|
9
|
+
* tabs/accordion wrapper revealing its panel. Bounded because the loop must
|
|
10
|
+
* also terminate on a page whose headings genuinely never arrive.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_ATTACH_FRAMES = 60;
|
|
6
13
|
function flattenTocIds(entries) {
|
|
7
14
|
const ids = [];
|
|
8
15
|
const walk = (list) => {
|
|
@@ -46,11 +53,27 @@ function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MA
|
|
|
46
53
|
rootMargin,
|
|
47
54
|
threshold: 0
|
|
48
55
|
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
let frame;
|
|
57
|
+
let framesLeft = MAX_ATTACH_FRAMES;
|
|
58
|
+
const attach = () => {
|
|
59
|
+
let attached = 0;
|
|
60
|
+
for (const id of ids) {
|
|
61
|
+
const element = document.getElementById(id);
|
|
62
|
+
if (element !== null) {
|
|
63
|
+
observer.observe(element);
|
|
64
|
+
attached += 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (attached === 0 && framesLeft > 0 && typeof requestAnimationFrame === "function") {
|
|
68
|
+
framesLeft -= 1;
|
|
69
|
+
frame = requestAnimationFrame(attach);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
attach();
|
|
73
|
+
return () => {
|
|
74
|
+
if (frame !== void 0) cancelAnimationFrame(frame);
|
|
75
|
+
observer.disconnect();
|
|
76
|
+
};
|
|
54
77
|
}, [ids, rootMargin]);
|
|
55
78
|
if (entries.length === 0) return null;
|
|
56
79
|
return /* @__PURE__ */ jsx("nav", {
|
package/dist/react/youtube.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
4
4
|
//#region src/react/youtube.tsx
|
|
5
5
|
const DEFAULT_TITLE = "YouTube video player";
|
|
@@ -17,6 +17,10 @@ const DEFAULT_TITLE = "YouTube video player";
|
|
|
17
17
|
*/
|
|
18
18
|
function YouTube({ id, title, className }) {
|
|
19
19
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
20
|
+
const frameRef = useRef(null);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (isPlaying) frameRef.current?.focus();
|
|
23
|
+
}, [isPlaying]);
|
|
20
24
|
if (!id) return null;
|
|
21
25
|
const safeId = encodeURIComponent(id);
|
|
22
26
|
const label = title?.trim() || DEFAULT_TITLE;
|
|
@@ -30,9 +34,7 @@ function YouTube({ id, title, className }) {
|
|
|
30
34
|
loading: "lazy",
|
|
31
35
|
allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
|
|
32
36
|
allowFullScreen: true,
|
|
33
|
-
ref:
|
|
34
|
-
node?.focus();
|
|
35
|
-
}
|
|
37
|
+
ref: frameRef
|
|
36
38
|
})
|
|
37
39
|
});
|
|
38
40
|
return /* @__PURE__ */ jsx("div", {
|
package/dist/render.d.ts
CHANGED
|
@@ -16,11 +16,11 @@ interface DocsRendererOptions {
|
|
|
16
16
|
* Reuse an existing highlighter — the escape hatch for grammars and themes
|
|
17
17
|
* outside the curated set. Defaults to {@link createDocsHighlighter}.
|
|
18
18
|
*/
|
|
19
|
-
highlighter?: DocsHighlighter | Promise<DocsHighlighter
|
|
19
|
+
highlighter?: DocsHighlighter | Promise<DocsHighlighter> | undefined;
|
|
20
20
|
/** Grammars to load, when building the default highlighter. */
|
|
21
|
-
langs?: readonly DocsLang[];
|
|
21
|
+
langs?: readonly DocsLang[] | undefined;
|
|
22
22
|
/** Theme pair. Defaults to {@link DEFAULT_DOCS_THEMES}. */
|
|
23
|
-
themes?: DocsThemes;
|
|
23
|
+
themes?: DocsThemes | undefined;
|
|
24
24
|
/**
|
|
25
25
|
* Prepend an `<h1>` built from `frontmatter.title` when the markdown body
|
|
26
26
|
* has none. Defaults to `true`.
|
|
@@ -30,15 +30,20 @@ interface DocsRendererOptions {
|
|
|
30
30
|
* starting at `h2`, and markdown that repeats the frontmatter title as `# `
|
|
31
31
|
* is a duplication authors forget to keep in step.
|
|
32
32
|
*/
|
|
33
|
-
titleHeading?: boolean;
|
|
33
|
+
titleHeading?: boolean | undefined;
|
|
34
34
|
/** Replaces the built-in markdown-link resolution. */
|
|
35
|
-
linkResolver?: LinkResolver;
|
|
35
|
+
linkResolver?: LinkResolver | undefined;
|
|
36
36
|
/**
|
|
37
37
|
* Resolves image `src` to a public URL and intrinsic dimensions, so
|
|
38
|
-
* `next/image` can render without `fill`.
|
|
39
|
-
*
|
|
38
|
+
* `next/image` can render without `fill`.
|
|
39
|
+
*
|
|
40
|
+
* Required as soon as any page writes a relative ``: there
|
|
41
|
+
* is no correct output for one without it, so it throws rather than shipping
|
|
42
|
+
* a src the browser resolves against the route. Absolute (`/logo.png`) and
|
|
43
|
+
* external sources need no resolver, and a resolver returning `undefined`
|
|
44
|
+
* keeps the folded — not the authored — src.
|
|
40
45
|
*/
|
|
41
|
-
imageResolver?: ImageResolver;
|
|
46
|
+
imageResolver?: ImageResolver | undefined;
|
|
42
47
|
/**
|
|
43
48
|
* Every route the site publishes, used by `assertLinks`. Read at render
|
|
44
49
|
* time, so a host may pass a set it populates during the source walk.
|
|
@@ -46,7 +51,36 @@ interface DocsRendererOptions {
|
|
|
46
51
|
* Without it only unresolvable links can be caught; with it, links to pages
|
|
47
52
|
* that simply do not exist are caught too.
|
|
48
53
|
*/
|
|
49
|
-
knownRoutes?: ReadonlySet<string
|
|
54
|
+
knownRoutes?: ReadonlySet<string> | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Routes of pages excluded from {@link DocsRendererOptions.knownRoutes}
|
|
57
|
+
* because they are `draft: true`.
|
|
58
|
+
*
|
|
59
|
+
* Purely diagnostic, and it earns its place: a link to a draft is a link to a
|
|
60
|
+
* file plainly sitting on disk, and the generic "no such page exists — add an
|
|
61
|
+
* `aliases` entry" is advice that cannot be followed. Failing the build is
|
|
62
|
+
* still right; naming the reason is what makes it fixable.
|
|
63
|
+
*/
|
|
64
|
+
draftRoutes?: ReadonlySet<string> | undefined;
|
|
65
|
+
/**
|
|
66
|
+
* Alias route → the canonical `href` it redirects to.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately not folded into {@link DocsRendererOptions.knownRoutes}. An
|
|
69
|
+
* alias is only a live URL once `createDocsRedirects` is wired into
|
|
70
|
+
* `next.config.ts`, which the quick start does not do — so treating one as
|
|
71
|
+
* publishable produced a green build and a hard 404 for every reader who
|
|
72
|
+
* clicked, which is the exact failure `assertLinks` exists to prevent.
|
|
73
|
+
* Knowing the target lets the error name the page to link instead.
|
|
74
|
+
*/
|
|
75
|
+
aliasRoutes?: ReadonlyMap<string, string> | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Fence languages Shiki must not touch, e.g. `['mermaid']`.
|
|
78
|
+
*
|
|
79
|
+
* The `<pre><code class="language-mermaid">` reaches your `pre`/`code`
|
|
80
|
+
* component untouched, which is what lets a consumer render a diagram rather
|
|
81
|
+
* than a monochrome block of DSL.
|
|
82
|
+
*/
|
|
83
|
+
excludeLangs?: readonly string[] | undefined;
|
|
50
84
|
}
|
|
51
85
|
/**
|
|
52
86
|
* Renders {@link DocFile}s. Build one per process and reuse it.
|
package/dist/render.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { docsError } from "./docs-error.js";
|
|
1
2
|
import { DEFAULT_DOCS_THEMES, createDocsHighlighter } from "./highlighter.js";
|
|
2
3
|
import { rehypeCaptureToc } from "./plugins/rehype-capture-toc.js";
|
|
4
|
+
import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
|
|
5
|
+
import { rehypeFallbackHeadingIds } from "./plugins/rehype-fallback-heading-ids.js";
|
|
6
|
+
import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
|
|
3
7
|
import { foldSegments, remarkDocLinks } from "./plugins/remark-doc-links.js";
|
|
4
8
|
import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
|
|
5
9
|
import { remarkYouTube } from "./plugins/remark-youtube.js";
|
|
@@ -11,7 +15,7 @@ import remarkGfm from "remark-gfm";
|
|
|
11
15
|
import remarkParse from "remark-parse";
|
|
12
16
|
import remarkRehype from "remark-rehype";
|
|
13
17
|
import { unified } from "unified";
|
|
14
|
-
import {
|
|
18
|
+
import { visit } from "unist-util-visit";
|
|
15
19
|
import { VFile } from "vfile";
|
|
16
20
|
//#region src/render.ts
|
|
17
21
|
/**
|
|
@@ -54,6 +58,34 @@ function toDirSegments(relativePath) {
|
|
|
54
58
|
/** `scheme:` — `https:`, `data:`, anything that is not ours to resolve. */
|
|
55
59
|
const IMAGE_HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
56
60
|
/**
|
|
61
|
+
* Is this src already a URL a browser can fetch from any page?
|
|
62
|
+
*
|
|
63
|
+
* `/logo.png`, `//cdn/…` and `https://…` are; everything else is relative to
|
|
64
|
+
* the markdown file and means nothing once the file has become a route.
|
|
65
|
+
*/
|
|
66
|
+
function isPublicImageSrc(src) {
|
|
67
|
+
return src.startsWith("/") || IMAGE_HAS_SCHEME.test(src);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Check what the resolver actually returned.
|
|
71
|
+
*
|
|
72
|
+
* `ImageResolver` is a type, and a type stops at the JavaScript boundary: a
|
|
73
|
+
* host reading dimensions from a manifest hands back `{ src, width: '1200' }`
|
|
74
|
+
* or a bare string, and the only symptom is `width="undefined"` in the HTML —
|
|
75
|
+
* on one page, at build time, with nothing naming the image or the document.
|
|
76
|
+
*/
|
|
77
|
+
function assertResolvedImage(value, src, relativePath) {
|
|
78
|
+
const blame = `for image "${src}" in ${relativePath}`;
|
|
79
|
+
if (typeof value !== "object" || value === null) throw docsError("invalid-image", `@waveso/docs: the imageResolver returned ${typeof value} ${blame}. Return \`{ src, width?, height? }\`, or \`undefined\` to leave the src alone.`);
|
|
80
|
+
const resolved = value;
|
|
81
|
+
if (typeof resolved.src !== "string" || resolved.src === "") throw docsError("invalid-image", `@waveso/docs: the imageResolver returned no \`src\` ${blame}. Return \`{ src, width?, height? }\`, or \`undefined\` to leave the src alone.`);
|
|
82
|
+
for (const key of ["width", "height"]) {
|
|
83
|
+
const dimension = resolved[key];
|
|
84
|
+
if (dimension === void 0) continue;
|
|
85
|
+
if (typeof dimension !== "number" || !Number.isFinite(dimension)) throw docsError("invalid-image", `@waveso/docs: the imageResolver returned a non-numeric \`${key}\` ${blame}. \`next/image\` needs intrinsic pixel dimensions; parse the value before returning it.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
57
89
|
* An image `src` folded against the page's directory, or `undefined` if it
|
|
58
90
|
* climbs out of the content root.
|
|
59
91
|
*
|
|
@@ -63,7 +95,7 @@ const IMAGE_HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
|
63
95
|
* author means by `` and what the link path has always done.
|
|
64
96
|
*/
|
|
65
97
|
function foldImageSrc(src, dirSegments) {
|
|
66
|
-
if (
|
|
98
|
+
if (isPublicImageSrc(src)) return src;
|
|
67
99
|
const segments = foldSegments(dirSegments, src);
|
|
68
100
|
return segments === void 0 ? void 0 : segments.join("/");
|
|
69
101
|
}
|
|
@@ -76,15 +108,17 @@ function describeLink(file, ref) {
|
|
|
76
108
|
const at = ref.line === void 0 ? "" : `:${ref.line}`;
|
|
77
109
|
return `${file.relativePath}${at}`;
|
|
78
110
|
}
|
|
79
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Does the document already open on a page title?
|
|
113
|
+
*
|
|
114
|
+
* ⚠️ TOP-LEVEL CHILDREN ONLY, DELIBERATELY. A whole-tree walk counted an `h1`
|
|
115
|
+
* anywhere — including `> [!NOTE]\n> # Callout title` — and suppressed the
|
|
116
|
+
* frontmatter heading, so the page title appeared nowhere in the body and the
|
|
117
|
+
* document's only `h1` was buried inside a callout. That is precisely the
|
|
118
|
+
* `page-has-heading-one` failure this option's docstring says it prevents.
|
|
119
|
+
*/
|
|
80
120
|
function hasHeadingOne(tree) {
|
|
81
|
-
|
|
82
|
-
visit(tree, "element", (node) => {
|
|
83
|
-
if (node.tagName !== "h1") return CONTINUE;
|
|
84
|
-
found = true;
|
|
85
|
-
return EXIT;
|
|
86
|
-
});
|
|
87
|
-
return found;
|
|
121
|
+
return tree.children.some((child) => child.type === "element" && child.tagName === "h1");
|
|
88
122
|
}
|
|
89
123
|
/**
|
|
90
124
|
* The `<h1>` a page gets when its markdown does not declare one.
|
|
@@ -151,27 +185,31 @@ function stripPositions(tree) {
|
|
|
151
185
|
* `remark-gfm` does not implement alerts at all.
|
|
152
186
|
* Runs before slugging so a heading inside a
|
|
153
187
|
* callout is slugged in its final position.
|
|
154
|
-
* 8. `
|
|
155
|
-
*
|
|
188
|
+
* 8. `rehypeFallbackHeadingIds` — before slugging, so an emoji-only heading
|
|
189
|
+
* never seeds the collision counter with `''`.
|
|
190
|
+
* 9. `rehypeSlug` — assigns heading ids.
|
|
191
|
+
* 10. `rehypeCaptureToc` — reads those ids. Before autolinking, so heading
|
|
156
192
|
* text is captured without the appended `#`.
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
193
|
+
* 11. `rehypeAutolinkHeadings` — appends the permalink.
|
|
194
|
+
* 12. `rehypeNormalizeCodeLanguage` — immediately before Shiki, which is the
|
|
195
|
+
* last moment `class="language-JSON"` exists.
|
|
196
|
+
* 13. `rehypeShikiFromHighlighter` — near-last: it replaces `<pre><code>`
|
|
197
|
+
* wholesale, and anything walking code blocks
|
|
198
|
+
* afterwards would be walking Shiki's token spans.
|
|
199
|
+
* 14. `rehypeRestoreExcludedCode` — the other side of step 12's disguise.
|
|
200
|
+
* 15. `rehypeFlattenRoots` — last of all, because Shiki is what splices a
|
|
201
|
+
* `root` into `root.children` and the published
|
|
202
|
+
* `RenderedDoc.hast` type says that cannot happen.
|
|
161
203
|
*/
|
|
162
|
-
async function buildProcessor(options) {
|
|
163
|
-
const
|
|
164
|
-
const highlighter = await (options.highlighter ?? createDocsHighlighter({
|
|
165
|
-
themes,
|
|
166
|
-
...options.langs === void 0 ? {} : { langs: options.langs }
|
|
167
|
-
}));
|
|
204
|
+
async function buildProcessor(options, themes, highlighterPromise) {
|
|
205
|
+
const highlighter = await highlighterPromise;
|
|
168
206
|
return unified().use(remarkParse).use(remarkGfm).use(remarkDocLinks, {
|
|
169
207
|
basePath: options.config.basePath,
|
|
170
208
|
...options.linkResolver === void 0 ? {} : { resolve: options.linkResolver }
|
|
171
209
|
}).use(remarkUnwrapImages).use(remarkYouTube).use(remarkRehype, {
|
|
172
210
|
allowDangerousHtml: false,
|
|
173
211
|
footnoteLabelProperties: { className: ["wave-docs-sr-only"] }
|
|
174
|
-
}).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeSlug).use(rehypeCaptureToc).use(rehypeAutolinkHeadings, {
|
|
212
|
+
}).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeFallbackHeadingIds).use(rehypeSlug).use(rehypeCaptureToc).use(rehypeAutolinkHeadings, {
|
|
175
213
|
behavior: "append",
|
|
176
214
|
content: HEADING_ANCHOR_CONTENT,
|
|
177
215
|
properties: {
|
|
@@ -179,11 +217,13 @@ async function buildProcessor(options) {
|
|
|
179
217
|
ariaHidden: "true",
|
|
180
218
|
tabIndex: -1
|
|
181
219
|
}
|
|
182
|
-
}).use(rehypeShikiFromHighlighter, highlighter, {
|
|
220
|
+
}).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeShikiFromHighlighter, highlighter, {
|
|
183
221
|
themes,
|
|
222
|
+
defaultColor: false,
|
|
184
223
|
fallbackLanguage: "text",
|
|
185
|
-
defaultLanguage: "text"
|
|
186
|
-
|
|
224
|
+
defaultLanguage: "text",
|
|
225
|
+
addLanguageClass: true
|
|
226
|
+
}).use(rehypeRestoreExcludedCode).use(rehypeFlattenRoots).freeze();
|
|
187
227
|
}
|
|
188
228
|
/**
|
|
189
229
|
* Create a renderer.
|
|
@@ -193,33 +233,38 @@ async function buildProcessor(options) {
|
|
|
193
233
|
* a docs build that takes a second and one that takes a minute.
|
|
194
234
|
*/
|
|
195
235
|
function createDocsRenderer(options) {
|
|
196
|
-
const
|
|
197
|
-
const
|
|
236
|
+
const themes = options.themes ?? DEFAULT_DOCS_THEMES;
|
|
237
|
+
const processorPromise = buildProcessor(options, themes, options.highlighter ?? createDocsHighlighter({
|
|
238
|
+
themes,
|
|
239
|
+
...options.langs === void 0 ? {} : { langs: options.langs }
|
|
240
|
+
}));
|
|
241
|
+
processorPromise.catch(() => void 0);
|
|
242
|
+
const { config, imageResolver, knownRoutes, draftRoutes, aliasRoutes } = options;
|
|
198
243
|
const titleHeading = options.titleHeading ?? true;
|
|
199
244
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* ⚠️ IMAGES USED TO SKIP FOLDING ENTIRELY. `remarkDocLinks` visits `link` and
|
|
203
|
-
* `definition` and never `image`, so an image `src` reached the resolver
|
|
204
|
-
* exactly as authored — `../../../../.env` included — while every LINK on the
|
|
205
|
-
* same page went through `foldSegments`, which refuses a chain that climbs
|
|
206
|
-
* out of the content root. Two paths into the same kind of consumer code,
|
|
207
|
-
* one of them guarded.
|
|
245
|
+
* Fold every `<img src>`, then hand it to the resolver if there is one.
|
|
208
246
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
* `
|
|
247
|
+
* ⚠️ THIS RUNS FOR EVERY DOCUMENT, RESOLVER OR NOT, AND THAT IS THE POINT.
|
|
248
|
+
* It used to be gated on `imageResolver`, which is the option nobody sets
|
|
249
|
+
* first — so under the quickstart config `` shipped
|
|
250
|
+
* byte-for-byte as authored and the BROWSER resolved it, against the route:
|
|
251
|
+
* `/docs/guide` asked for `/docs/diagram.png` and `/docs/guide/setup` asked
|
|
252
|
+
* for `/docs/guide/diagram.png`, from identical markdown. `assertLinks` could
|
|
253
|
+
* not see it either — `remarkDocLinks` visits `link` and `definition`, never
|
|
254
|
+
* `image` — so the build stayed green and the containment throw below was
|
|
255
|
+
* dead code in the only configuration most sites run.
|
|
214
256
|
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
257
|
+
* A relative src has no correct output without a resolver, so it throws.
|
|
258
|
+
* Absolute (`/logo.png`) and schemed srcs are already public URLs and are
|
|
259
|
+
* passed through untouched — but still offered to the resolver, so a host can
|
|
260
|
+
* rewrite them onto a CDN.
|
|
217
261
|
*/
|
|
218
262
|
async function resolveImages(tree, file, resolve) {
|
|
219
263
|
const images = [];
|
|
220
264
|
visit(tree, "element", (node) => {
|
|
221
265
|
if (node.tagName === "img") images.push(node);
|
|
222
266
|
});
|
|
267
|
+
if (images.length === 0) return;
|
|
223
268
|
const context = {
|
|
224
269
|
segments: file.segments,
|
|
225
270
|
dirSegments: toDirSegments(file.relativePath),
|
|
@@ -229,9 +274,22 @@ function createDocsRenderer(options) {
|
|
|
229
274
|
const src = node.properties.src;
|
|
230
275
|
if (typeof src !== "string" || src === "") return;
|
|
231
276
|
const folded = foldImageSrc(src, context.dirSegments);
|
|
232
|
-
if (folded === void 0) throw
|
|
233
|
-
|
|
234
|
-
|
|
277
|
+
if (folded === void 0) throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} climbs above the content root.`);
|
|
278
|
+
if (resolve === void 0) {
|
|
279
|
+
if (isPublicImageSrc(src)) return;
|
|
280
|
+
throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} is relative to the markdown file, and nothing can serve it: the browser would resolve it against the page route, so the same markdown would request a different file from every page. Pass an \`imageResolver\`, or move the image under \`public/\` and write an absolute src such as "/diagram.png".`);
|
|
281
|
+
}
|
|
282
|
+
let resolved;
|
|
283
|
+
try {
|
|
284
|
+
resolved = await resolve(folded, context);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
throw docsError("invalid-image", `@waveso/docs: the imageResolver threw on image "${src}" in ${file.relativePath}.`, { cause: error });
|
|
287
|
+
}
|
|
288
|
+
if (resolved === void 0) {
|
|
289
|
+
node.properties.src = folded;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
assertResolvedImage(resolved, src, file.relativePath);
|
|
235
293
|
node.properties.src = resolved.src;
|
|
236
294
|
if (resolved.width !== void 0) node.properties.width = resolved.width;
|
|
237
295
|
if (resolved.height !== void 0) node.properties.height = resolved.height;
|
|
@@ -245,10 +303,14 @@ function createDocsRenderer(options) {
|
|
|
245
303
|
*/
|
|
246
304
|
function assertLinks(file, refs) {
|
|
247
305
|
for (const ref of refs) {
|
|
248
|
-
if (ref.href === void 0) throw
|
|
249
|
-
if (knownRoutes === void 0) continue;
|
|
306
|
+
if (ref.href === void 0) throw docsError("broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which does not resolve to a documentation page. Use a path relative to this file, or an absolute URL for external links.`);
|
|
307
|
+
if (knownRoutes === void 0 || ref.asset) continue;
|
|
250
308
|
const route = toRouteKey(ref.href);
|
|
251
|
-
if (
|
|
309
|
+
if (knownRoutes.has(route)) continue;
|
|
310
|
+
if (draftRoutes?.has(route)) throw docsError("draft-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — a page marked \`draft: true\`, so it is not published and the link would 404. Publish the page, remove the link, or build with \`includeDrafts\`.`);
|
|
311
|
+
const aliasTarget = aliasRoutes?.get(route);
|
|
312
|
+
if (aliasTarget !== void 0) throw docsError("alias-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — an alias that redirects to '${aliasTarget}'. An alias is not a page: it 404s unless \`createDocsRedirects\` is wired into \`next.config.ts\`, and it is never prerendered. Link to '${aliasTarget}' directly.`);
|
|
313
|
+
throw docsError("broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — no such page exists. Fix the link, or add an \`aliases\` entry to the page it used to point at.`);
|
|
252
314
|
}
|
|
253
315
|
}
|
|
254
316
|
return { async render(file) {
|
|
@@ -264,7 +326,7 @@ function createDocsRenderer(options) {
|
|
|
264
326
|
};
|
|
265
327
|
const hast = await processor.run(processor.parse(vfile), vfile);
|
|
266
328
|
if (titleHeading && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
|
|
267
|
-
|
|
329
|
+
await resolveImages(hast, file, imageResolver);
|
|
268
330
|
if (config.assertLinks) assertLinks(file, vfile.data.docLinks ?? []);
|
|
269
331
|
return {
|
|
270
332
|
frontmatter: file.frontmatter,
|
package/dist/search-index.d.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import { RenderedDoc, SearchRecord } from "./types.js";
|
|
2
|
+
import { Options } from "minisearch";
|
|
2
3
|
//#region src/search-index.d.ts
|
|
3
|
-
/** Options for {@link extractSearchRecords}. */
|
|
4
|
-
interface ExtractSearchRecordsOptions {
|
|
5
|
-
/**
|
|
6
|
-
* Maximum length of {@link SearchRecord.text}, in characters. Defaults to
|
|
7
|
-
* 300 — long enough to carry a section's vocabulary into the index, short
|
|
8
|
-
* enough that a 300-page corpus stays under a megabyte.
|
|
9
|
-
*/
|
|
10
|
-
excerptLength?: number;
|
|
11
|
-
}
|
|
12
4
|
/**
|
|
13
5
|
* Split a rendered document into section-scoped {@link SearchRecord}s.
|
|
14
6
|
*
|
|
@@ -26,26 +18,52 @@ interface ExtractSearchRecordsOptions {
|
|
|
26
18
|
* does with the same heading — the two must not disagree about which sections
|
|
27
19
|
* exist.
|
|
28
20
|
*
|
|
21
|
+
* `text` is the section's PROSE IN FULL. It used to be cut to 300 characters
|
|
22
|
+
* before indexing, which on a normal corpus (200 pages × 6 sections, ~1,686
|
|
23
|
+
* characters of prose each) dropped 82% of the words from the index — and
|
|
24
|
+
* `combineWith: 'AND'` compounds it, since every term of a query then has to
|
|
25
|
+
* land inside the surviving prefix of the same section. The cap bought nothing
|
|
26
|
+
* back: `storeFields` does not carry `text`, so not one character of the kept
|
|
27
|
+
* prefix was ever rendered. Truncate for display, in the layer that displays.
|
|
28
|
+
*
|
|
29
29
|
* Not generic over the frontmatter type, deliberately: `frontmatter.title` is
|
|
30
30
|
* the only field read, and a `RenderedDoc` carrying a project's own fields is
|
|
31
31
|
* assignable to this signature already. A type parameter here would appear in
|
|
32
32
|
* every call site and constrain nothing.
|
|
33
33
|
*/
|
|
34
|
-
declare function extractSearchRecords(doc: RenderedDoc
|
|
34
|
+
declare function extractSearchRecords(doc: RenderedDoc): SearchRecord[];
|
|
35
35
|
/**
|
|
36
36
|
* Build a serialised MiniSearch index from extracted records.
|
|
37
37
|
*
|
|
38
38
|
* The return value is JSON, ready for `MiniSearch.loadJSON` on the client or
|
|
39
|
-
* for {@link writeSearchIndex} to put on disk.
|
|
39
|
+
* for {@link writeSearchIndex} to put on disk. The output is byte-stable for a
|
|
40
|
+
* given record list, so an index committed to the repository does not dirty
|
|
41
|
+
* the diff on every build.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ `options` MUST ALSO REACH THE DIALOG — pass the identical object to
|
|
44
|
+
* `SearchDialog`'s `searchOptions`. Both sides feed it through
|
|
45
|
+
* `mergeSearchOptions`, and a `tokenize` or `processTerm` applied to the
|
|
46
|
+
* documents but not to the query produces an index whose terms no query can
|
|
47
|
+
* spell: zero results, no error, nothing in the console.
|
|
40
48
|
*/
|
|
41
|
-
declare function buildSearchIndex(records: SearchRecord[]): string;
|
|
49
|
+
declare function buildSearchIndex(records: SearchRecord[], options?: Partial<Options<SearchRecord>>): string;
|
|
42
50
|
/**
|
|
43
51
|
* Write the serialised index to `outFile`, creating parent directories.
|
|
44
52
|
*
|
|
45
53
|
* Returns the byte size written, so a build step can log it or assert a
|
|
46
54
|
* budget — a docs index that quietly crosses a megabyte is a regression
|
|
47
55
|
* nobody notices until the dialog takes a second to open.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ WRITTEN BESIDE THE TARGET AND RENAMED OVER IT, NEVER INTO IT. The target
|
|
58
|
+
* is normally `public/search-index.json`, a live static asset: writing in
|
|
59
|
+
* place truncates it to zero and grows it back in 1 MiB chunks, and a fetch
|
|
60
|
+
* landing in that window gets a 200 with a half-written body. `response.ok`
|
|
61
|
+
* passes, the parse throws, and the dialog is stuck in its error state —
|
|
62
|
+
* *"Try reloading the page"* — for every visitor, reloading forever, until
|
|
63
|
+
* someone redeploys content that did not change. `rename` is atomic within a
|
|
64
|
+
* filesystem, so a reader sees either the whole old file or the whole new one;
|
|
65
|
+
* it also makes two concurrent builds safe.
|
|
48
66
|
*/
|
|
49
|
-
declare function writeSearchIndex(records: SearchRecord[], outFile: string): Promise<number>;
|
|
67
|
+
declare function writeSearchIndex(records: SearchRecord[], outFile: string, options?: Partial<Options<SearchRecord>>): Promise<number>;
|
|
50
68
|
//#endregion
|
|
51
|
-
export {
|
|
69
|
+
export { buildSearchIndex, extractSearchRecords, writeSearchIndex };
|