@waveso/docs 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +490 -75
- package/dist/code-frame.d.ts +29 -0
- package/dist/code-frame.js +41 -0
- package/dist/code-meta.d.ts +48 -0
- package/dist/code-meta.js +72 -0
- package/dist/docs-content-id.d.ts +19 -0
- package/dist/docs-content-id.js +19 -0
- package/dist/docs-error.d.ts +2 -57
- package/dist/docs-error.js +3 -15
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +45 -0
- package/dist/next.d.ts +153 -28
- package/dist/next.js +65 -33
- package/dist/plugins/rehype-capture-toc.js +26 -5
- package/dist/plugins/rehype-code-frame.d.ts +10 -0
- package/dist/plugins/rehype-code-frame.js +88 -0
- package/dist/plugins/rehype-code-language.js +7 -1
- package/dist/react/code-runtime.d.ts +14 -0
- package/dist/react/code-runtime.js +161 -0
- package/dist/react/doc-content.d.ts +39 -2
- package/dist/react/doc-content.js +42 -10
- package/dist/react/layout.d.ts +44 -0
- package/dist/react/layout.js +65 -0
- package/dist/react/nav.d.ts +28 -0
- package/dist/react/nav.js +70 -0
- package/dist/react/nearest-scroll-top.d.ts +45 -0
- package/dist/react/nearest-scroll-top.js +44 -0
- package/dist/react/next-link.d.ts +34 -0
- package/dist/react/next-link.js +30 -0
- package/dist/react/next-nav.d.ts +11 -0
- package/dist/react/next-nav.js +32 -0
- package/dist/react/next-search.d.ts +22 -0
- package/dist/react/next-search.js +52 -0
- package/dist/react/search-dialog.d.ts +20 -8
- package/dist/react/search-dialog.js +15 -10
- package/dist/react/shell-labels.d.ts +43 -0
- package/dist/react/shell-labels.js +27 -0
- package/dist/react/sidebar.d.ts +38 -3
- package/dist/react/sidebar.js +104 -12
- package/dist/react/skip-link.d.ts +1 -9
- package/dist/react/skip-link.js +6 -5
- package/dist/react/toc.d.ts +12 -4
- package/dist/react/toc.js +18 -7
- package/dist/react/youtube.d.ts +31 -5
- package/dist/react/youtube.js +76 -54
- package/dist/render.d.ts +35 -1
- package/dist/render.js +35 -14
- package/dist/route-path.d.ts +46 -0
- package/dist/route-path.js +51 -0
- package/dist/search-index.d.ts +6 -23
- package/dist/search-index.js +6 -51
- package/dist/sitemap-limit.d.ts +34 -0
- package/dist/sitemap-limit.js +37 -0
- package/dist/source.d.ts +1 -23
- package/dist/source.js +40 -43
- package/dist/styles.css +939 -93
- package/dist/types.d.ts +11 -2
- package/package.json +58 -23
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 };
|
package/dist/render.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { docsError } from "./docs-error.js";
|
|
2
2
|
import { DEFAULT_DOCS_THEMES, createDocsHighlighter } from "./highlighter.js";
|
|
3
3
|
import { rehypeCaptureToc } from "./plugins/rehype-capture-toc.js";
|
|
4
|
+
import { rehypeCodeFrame } from "./plugins/rehype-code-frame.js";
|
|
4
5
|
import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
|
|
5
6
|
import { rehypeFallbackHeadingIds } from "./plugins/rehype-fallback-heading-ids.js";
|
|
6
7
|
import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
|
|
7
|
-
import { foldSegments, remarkDocLinks } from "./plugins/remark-doc-links.js";
|
|
8
|
+
import { foldSegments, remarkDocLinks, resolveMarkdownLink } from "./plugins/remark-doc-links.js";
|
|
8
9
|
import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
|
|
9
10
|
import { remarkYouTube } from "./plugins/remark-youtube.js";
|
|
10
11
|
import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
|
|
@@ -145,7 +146,7 @@ function titleHeadingNode(title) {
|
|
|
145
146
|
*
|
|
146
147
|
* The tree is the payload: it crosses the RSC boundary, so every byte is
|
|
147
148
|
* shipped to every reader.
|
|
148
|
-
* Positions are
|
|
149
|
+
* Positions are roughly a third of that JSON — line and column offsets
|
|
149
150
|
* into a markdown file the browser does not have and cannot fetch. Nothing
|
|
150
151
|
* downstream reads them: link errors are reported from positions captured
|
|
151
152
|
* during the mdast phase, and the TOC works off ids.
|
|
@@ -188,28 +189,48 @@ function stripPositions(tree) {
|
|
|
188
189
|
* 8. `rehypeFallbackHeadingIds` — before slugging, so an emoji-only heading
|
|
189
190
|
* never seeds the collision counter with `''`.
|
|
190
191
|
* 9. `rehypeSlug` — assigns heading ids.
|
|
191
|
-
* 10. `
|
|
192
|
-
*
|
|
193
|
-
*
|
|
192
|
+
* 10. `rehypeAutolinkHeadings` — appends the permalink.
|
|
193
|
+
* 11. `rehypePlugins` — the consumer's, after slugging and autolinking so
|
|
194
|
+
* heading ids exist, and before the code steps so
|
|
195
|
+
* a `<pre>` is still the author's text.
|
|
194
196
|
* 12. `rehypeNormalizeCodeLanguage` — immediately before Shiki, which is the
|
|
195
197
|
* last moment `class="language-JSON"` exists.
|
|
196
|
-
* 13. `
|
|
198
|
+
* 13. `rehypeCodeFrame` — the one step wide window: after 12, which folds
|
|
199
|
+
* the language and disguises excluded fences, and
|
|
200
|
+
* before Shiki, which destroys `code.data.meta`
|
|
201
|
+
* and with it the fence's `title="…"`.
|
|
202
|
+
* 14. `rehypeShikiFromHighlighter` — near-last: it replaces `<pre><code>`
|
|
197
203
|
* wholesale, and anything walking code blocks
|
|
198
204
|
* afterwards would be walking Shiki's token spans.
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* `root
|
|
205
|
+
* 15. `rehypeRestoreExcludedCode` — the other side of step 12's disguise.
|
|
206
|
+
* 16. `rehypeFlattenRoots` — because Shiki is what splices a `root` into
|
|
207
|
+
* `root.children` and the published
|
|
202
208
|
* `RenderedDoc.hast` type says that cannot happen.
|
|
209
|
+
* Step 13 is the first thing to put a `root`
|
|
210
|
+
* inside an *element* rather than at the top, so
|
|
211
|
+
* this recursing into element children is now
|
|
212
|
+
* load-bearing rather than defensive.
|
|
213
|
+
* 17. `rehypeCaptureToc` — DEAD LAST, and that is the design rather than an
|
|
214
|
+
* ordering detail. The TOC is then read off the
|
|
215
|
+
* identical tree `extractSearchRecords` walks, so
|
|
216
|
+
* a consumer plugin cannot put the two out of step
|
|
217
|
+
* — and no validation pass or error has to exist
|
|
218
|
+
* to notice when it does. Measured both drifts
|
|
219
|
+
* before the move: a plugin deleting a heading id
|
|
220
|
+
* left `toc` pointing at an id no longer in the
|
|
221
|
+
* DOM while search silently dropped the section;
|
|
222
|
+
* one adding an `<h2>` produced a search record
|
|
223
|
+
* with no TOC entry. Both silent.
|
|
203
224
|
*/
|
|
204
225
|
async function buildProcessor(options, themes, highlighterPromise) {
|
|
205
226
|
const highlighter = await highlighterPromise;
|
|
206
|
-
return unified().use(remarkParse).use(remarkGfm).use(remarkDocLinks, {
|
|
227
|
+
return unified().use(remarkParse).use(remarkGfm).use(options.remarkPlugins ?? []).use(remarkDocLinks, {
|
|
207
228
|
basePath: options.config.basePath,
|
|
208
229
|
...options.linkResolver === void 0 ? {} : { resolve: options.linkResolver }
|
|
209
230
|
}).use(remarkUnwrapImages).use(remarkYouTube).use(remarkRehype, {
|
|
210
231
|
allowDangerousHtml: false,
|
|
211
232
|
footnoteLabelProperties: { className: ["wave-docs-sr-only"] }
|
|
212
|
-
}).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeFallbackHeadingIds).use(rehypeSlug).use(
|
|
233
|
+
}).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeFallbackHeadingIds).use(rehypeSlug).use(rehypeAutolinkHeadings, {
|
|
213
234
|
behavior: "append",
|
|
214
235
|
content: HEADING_ANCHOR_CONTENT,
|
|
215
236
|
properties: {
|
|
@@ -217,13 +238,13 @@ async function buildProcessor(options, themes, highlighterPromise) {
|
|
|
217
238
|
ariaHidden: "true",
|
|
218
239
|
tabIndex: -1
|
|
219
240
|
}
|
|
220
|
-
}).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeShikiFromHighlighter, highlighter, {
|
|
241
|
+
}).use(options.rehypePlugins ?? []).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeCodeFrame).use(rehypeShikiFromHighlighter, highlighter, {
|
|
221
242
|
themes,
|
|
222
243
|
defaultColor: false,
|
|
223
244
|
fallbackLanguage: "text",
|
|
224
245
|
defaultLanguage: "text",
|
|
225
246
|
addLanguageClass: true
|
|
226
|
-
}).use(rehypeRestoreExcludedCode).use(rehypeFlattenRoots).freeze();
|
|
247
|
+
}).use(rehypeRestoreExcludedCode).use(rehypeFlattenRoots).use(rehypeCaptureToc).freeze();
|
|
227
248
|
}
|
|
228
249
|
/**
|
|
229
250
|
* Create a renderer.
|
|
@@ -338,4 +359,4 @@ function createDocsRenderer(options) {
|
|
|
338
359
|
} };
|
|
339
360
|
}
|
|
340
361
|
//#endregion
|
|
341
|
-
export { createDocsRenderer };
|
|
362
|
+
export { createDocsRenderer, resolveMarkdownLink };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/route-path.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Turning route segments into a URL path.
|
|
4
|
+
*
|
|
5
|
+
* Private — deliberately not an entry point in `package.json`. `toAliasRoute`
|
|
6
|
+
* was exported from `./source` and therefore public, which froze both its
|
|
7
|
+
* signature and the wording of three error messages under semver, for a
|
|
8
|
+
* function no README mentions and only this package calls. It lives here with
|
|
9
|
+
* `encodeSegments` because the two have to agree: an alias and a link that
|
|
10
|
+
* spell the same page differently produce a redirect no request can match.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Percent-encode the segments, and only here.
|
|
14
|
+
*
|
|
15
|
+
* `segments` and `slug` stay raw on purpose: Next decodes route params before
|
|
16
|
+
* they reach `find()`, so an encoded slug would match nothing. Unencoded, a
|
|
17
|
+
* `#`, `?` or `%` in a filename stops being part of the path — the sitemap
|
|
18
|
+
* emitted `https://example.com/docs/c#%20guide`, and `alternates.canonical`
|
|
19
|
+
* and `og:url` are built by the same call — while a space produced a URL that
|
|
20
|
+
* only works until something re-encodes it.
|
|
21
|
+
*/
|
|
22
|
+
declare function encodeSegments(segments: readonly string[]): string;
|
|
23
|
+
/**
|
|
24
|
+
* A former URL from `aliases` frontmatter, as a route.
|
|
25
|
+
*
|
|
26
|
+
* `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
|
|
27
|
+
* Leading and trailing slashes are tolerated because authors write them, but
|
|
28
|
+
* the value is always relative to the base path — an alias of `'/docs/old'` on
|
|
29
|
+
* a `/docs` site would produce `/docs/docs/old`.
|
|
30
|
+
*
|
|
31
|
+
* Shared by both adapters so they agree on which routes exist: an alias is a
|
|
32
|
+
* redirect the host installs, so a link to one resolves, and a link that
|
|
33
|
+
* builds under Next must build under Vite. The source scan calls it too, so
|
|
34
|
+
* every rejection below names the markdown file at the moment it is read.
|
|
35
|
+
*/
|
|
36
|
+
declare function toAliasRoute(alias: string, basePath: string,
|
|
37
|
+
/**
|
|
38
|
+
* The source path, for the error. A STRING rather than the whole `DocFile`
|
|
39
|
+
* it used to take: this function dereferenced exactly one property of it, and
|
|
40
|
+
* demanding the object meant a cache reader or a manifest-driven redirect
|
|
41
|
+
* table had to fabricate a `DocFile` to agree with the package about which
|
|
42
|
+
* routes exist. That is the reason it is exported at all.
|
|
43
|
+
*/
|
|
44
|
+
sourceLabel: string): string;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { encodeSegments, toAliasRoute };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { docsError } from "./docs-error.js";
|
|
2
|
+
import { foldSegments } from "./plugins/remark-doc-links.js";
|
|
3
|
+
//#region src/route-path.ts
|
|
4
|
+
/**
|
|
5
|
+
* Turning route segments into a URL path.
|
|
6
|
+
*
|
|
7
|
+
* Private — deliberately not an entry point in `package.json`. `toAliasRoute`
|
|
8
|
+
* was exported from `./source` and therefore public, which froze both its
|
|
9
|
+
* signature and the wording of three error messages under semver, for a
|
|
10
|
+
* function no README mentions and only this package calls. It lives here with
|
|
11
|
+
* `encodeSegments` because the two have to agree: an alias and a link that
|
|
12
|
+
* spell the same page differently produce a redirect no request can match.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Percent-encode the segments, and only here.
|
|
16
|
+
*
|
|
17
|
+
* `segments` and `slug` stay raw on purpose: Next decodes route params before
|
|
18
|
+
* they reach `find()`, so an encoded slug would match nothing. Unencoded, a
|
|
19
|
+
* `#`, `?` or `%` in a filename stops being part of the path — the sitemap
|
|
20
|
+
* emitted `https://example.com/docs/c#%20guide`, and `alternates.canonical`
|
|
21
|
+
* and `og:url` are built by the same call — while a space produced a URL that
|
|
22
|
+
* only works until something re-encodes it.
|
|
23
|
+
*/
|
|
24
|
+
function encodeSegments(segments) {
|
|
25
|
+
return segments.map(encodeURIComponent).join("/");
|
|
26
|
+
}
|
|
27
|
+
const ALIAS_PATTERN_CHARS = /[:()+*?{}]/;
|
|
28
|
+
/**
|
|
29
|
+
* A former URL from `aliases` frontmatter, as a route.
|
|
30
|
+
*
|
|
31
|
+
* `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
|
|
32
|
+
* Leading and trailing slashes are tolerated because authors write them, but
|
|
33
|
+
* the value is always relative to the base path — an alias of `'/docs/old'` on
|
|
34
|
+
* a `/docs` site would produce `/docs/docs/old`.
|
|
35
|
+
*
|
|
36
|
+
* Shared by both adapters so they agree on which routes exist: an alias is a
|
|
37
|
+
* redirect the host installs, so a link to one resolves, and a link that
|
|
38
|
+
* builds under Next must build under Vite. The source scan calls it too, so
|
|
39
|
+
* every rejection below names the markdown file at the moment it is read.
|
|
40
|
+
*/
|
|
41
|
+
function toAliasRoute(alias, basePath, sourceLabel) {
|
|
42
|
+
const trimmed = alias.trim();
|
|
43
|
+
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
|
+
const pattern = ALIAS_PATTERN_CHARS.exec(trimmed);
|
|
45
|
+
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.`);
|
|
46
|
+
const segments = foldSegments([], trimmed);
|
|
47
|
+
if (segments === void 0 || segments.length === 0) throw docsError("invalid-alias", `@waveso/docs: ${sourceLabel} has an empty entry in its \`aliases\` frontmatter. Each alias is a former URL for this page, relative to the docs base path — e.g. \`aliases: [quickstart]\`.`);
|
|
48
|
+
return `${basePath}/${encodeSegments(segments)}`;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { encodeSegments, toAliasRoute };
|
package/dist/search-index.d.ts
CHANGED
|
@@ -35,10 +35,11 @@ declare function extractSearchRecords(doc: RenderedDoc): SearchRecord[];
|
|
|
35
35
|
/**
|
|
36
36
|
* Build a serialised MiniSearch index from extracted records.
|
|
37
37
|
*
|
|
38
|
-
* The return value is JSON, ready for `MiniSearch.loadJSON` on the client
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* The return value is JSON, ready for `MiniSearch.loadJSON` on the client.
|
|
39
|
+
* `docs.searchIndex` serves exactly this; reach for `buildSearchIndex`
|
|
40
|
+
* directly only when you need an artifact that route cannot produce. The
|
|
41
|
+
* output is byte-stable for a given record list, which is what lets the
|
|
42
|
+
* route ship a strong `ETag`.
|
|
42
43
|
*
|
|
43
44
|
* ⚠️ `options` MUST ALSO REACH THE DIALOG — pass the identical object to
|
|
44
45
|
* `SearchDialog`'s `searchOptions`. Both sides feed it through
|
|
@@ -47,23 +48,5 @@ declare function extractSearchRecords(doc: RenderedDoc): SearchRecord[];
|
|
|
47
48
|
* spell: zero results, no error, nothing in the console.
|
|
48
49
|
*/
|
|
49
50
|
declare function buildSearchIndex(records: SearchRecord[], options?: Partial<Options<SearchRecord>>): string;
|
|
50
|
-
/**
|
|
51
|
-
* Write the serialised index to `outFile`, creating parent directories.
|
|
52
|
-
*
|
|
53
|
-
* Returns the byte size written, so a build step can log it or assert a
|
|
54
|
-
* budget — a docs index that quietly crosses a megabyte is a regression
|
|
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.
|
|
66
|
-
*/
|
|
67
|
-
declare function writeSearchIndex(records: SearchRecord[], outFile: string, options?: Partial<Options<SearchRecord>>): Promise<number>;
|
|
68
51
|
//#endregion
|
|
69
|
-
export { buildSearchIndex, extractSearchRecords
|
|
52
|
+
export { buildSearchIndex, extractSearchRecords };
|
package/dist/search-index.js
CHANGED
|
@@ -1,22 +1,7 @@
|
|
|
1
1
|
import { isFootnotes, isTransparentContainer } from "./section-boundary.js";
|
|
2
2
|
import { mergeSearchOptions } from "./search-options.js";
|
|
3
|
-
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
-
import path from "node:path";
|
|
5
3
|
import MiniSearch from "minisearch";
|
|
6
4
|
//#region src/search-index.ts
|
|
7
|
-
/**
|
|
8
|
-
* Build-time search index construction.
|
|
9
|
-
*
|
|
10
|
-
* Node-only, and deliberately so: the markdown parser, the hast walk and
|
|
11
|
-
* MiniSearch's index builder all run once per build, and the browser receives
|
|
12
|
-
* nothing but the serialised result. `src/react/search-dialog.tsx` is the
|
|
13
|
-
* matching client half.
|
|
14
|
-
*
|
|
15
|
-
* MiniSearch over Fuse.js is a measured choice, not a taste one: on a 282-page
|
|
16
|
-
* corpus Fuse ran 96.6 ms median / 298 ms max per query against MiniSearch's
|
|
17
|
-
* 1.35 ms / 3.84 ms. Fuse is a fuzzy short-string matcher routinely
|
|
18
|
-
* misapplied to full text.
|
|
19
|
-
*/
|
|
20
5
|
/** `<h1>`…`<h6>` to their numeric depth. */
|
|
21
6
|
const HEADING_DEPTHS = /* @__PURE__ */ new Map([
|
|
22
7
|
["h1", 1],
|
|
@@ -217,10 +202,11 @@ function collapseWhitespace(text) {
|
|
|
217
202
|
/**
|
|
218
203
|
* Build a serialised MiniSearch index from extracted records.
|
|
219
204
|
*
|
|
220
|
-
* The return value is JSON, ready for `MiniSearch.loadJSON` on the client
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
205
|
+
* The return value is JSON, ready for `MiniSearch.loadJSON` on the client.
|
|
206
|
+
* `docs.searchIndex` serves exactly this; reach for `buildSearchIndex`
|
|
207
|
+
* directly only when you need an artifact that route cannot produce. The
|
|
208
|
+
* output is byte-stable for a given record list, which is what lets the
|
|
209
|
+
* route ship a strong `ETag`.
|
|
224
210
|
*
|
|
225
211
|
* ⚠️ `options` MUST ALSO REACH THE DIALOG — pass the identical object to
|
|
226
212
|
* `SearchDialog`'s `searchOptions`. Both sides feed it through
|
|
@@ -233,36 +219,5 @@ function buildSearchIndex(records, options = {}) {
|
|
|
233
219
|
index.addAll(records);
|
|
234
220
|
return JSON.stringify(index);
|
|
235
221
|
}
|
|
236
|
-
/**
|
|
237
|
-
* Write the serialised index to `outFile`, creating parent directories.
|
|
238
|
-
*
|
|
239
|
-
* Returns the byte size written, so a build step can log it or assert a
|
|
240
|
-
* budget — a docs index that quietly crosses a megabyte is a regression
|
|
241
|
-
* nobody notices until the dialog takes a second to open.
|
|
242
|
-
*
|
|
243
|
-
* ⚠️ WRITTEN BESIDE THE TARGET AND RENAMED OVER IT, NEVER INTO IT. The target
|
|
244
|
-
* is normally `public/search-index.json`, a live static asset: writing in
|
|
245
|
-
* place truncates it to zero and grows it back in 1 MiB chunks, and a fetch
|
|
246
|
-
* landing in that window gets a 200 with a half-written body. `response.ok`
|
|
247
|
-
* passes, the parse throws, and the dialog is stuck in its error state —
|
|
248
|
-
* *"Try reloading the page"* — for every visitor, reloading forever, until
|
|
249
|
-
* someone redeploys content that did not change. `rename` is atomic within a
|
|
250
|
-
* filesystem, so a reader sees either the whole old file or the whole new one;
|
|
251
|
-
* it also makes two concurrent builds safe.
|
|
252
|
-
*/
|
|
253
|
-
async function writeSearchIndex(records, outFile, options = {}) {
|
|
254
|
-
const json = buildSearchIndex(records, options);
|
|
255
|
-
const absolute = path.resolve(outFile);
|
|
256
|
-
const temporary = `${absolute}.tmp-${process.pid}`;
|
|
257
|
-
await mkdir(path.dirname(absolute), { recursive: true });
|
|
258
|
-
try {
|
|
259
|
-
await writeFile(temporary, json, "utf8");
|
|
260
|
-
await rename(temporary, absolute);
|
|
261
|
-
} catch (error) {
|
|
262
|
-
await rm(temporary, { force: true });
|
|
263
|
-
throw error;
|
|
264
|
-
}
|
|
265
|
-
return Buffer.byteLength(json, "utf8");
|
|
266
|
-
}
|
|
267
222
|
//#endregion
|
|
268
|
-
export { buildSearchIndex, extractSearchRecords
|
|
223
|
+
export { buildSearchIndex, extractSearchRecords };
|