@waveso/docs 0.1.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 +194 -0
- package/README.md +592 -88
- 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 +19 -0
- package/dist/docs-error.js +28 -0
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +45 -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 +182 -35
- package/dist/next.js +177 -49
- package/dist/plugins/rehype-capture-toc.js +52 -20
- 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.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +54 -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/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/markdown-components.js +71 -6
- 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 +35 -7
- package/dist/react/search-dialog.js +55 -33
- 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 +46 -12
- package/dist/react/youtube.d.ts +31 -5
- package/dist/react/youtube.js +76 -52
- package/dist/render.d.ts +78 -10
- package/dist/render.js +137 -54
- package/dist/route-path.d.ts +46 -0
- package/dist/route-path.js +51 -0
- package/dist/search-index.d.ts +22 -21
- package/dist/search-index.js +27 -78
- 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/sitemap-limit.d.ts +34 -0
- package/dist/sitemap-limit.js +37 -0
- package/dist/source.d.ts +12 -22
- package/dist/source.js +165 -72
- package/dist/styles.css +1117 -125
- package/dist/types.d.ts +52 -29
- package/package.json +70 -34
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,74 +1,98 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { 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
43
|
if (!id) return null;
|
|
21
44
|
const safeId = encodeURIComponent(id);
|
|
22
45
|
const label = title?.trim() || DEFAULT_TITLE;
|
|
23
46
|
const rootClassName = ["wave-docs-youtube", className].filter(Boolean).join(" ");
|
|
24
|
-
|
|
47
|
+
return /* @__PURE__ */ jsxs("details", {
|
|
25
48
|
className: rootClassName,
|
|
26
|
-
children: /* @__PURE__ */
|
|
27
|
-
className: "wave-docs-youtube__frame",
|
|
28
|
-
src: `https://www.youtube-nocookie.com/embed/${safeId}?autoplay=1&rel=0`,
|
|
29
|
-
title: label,
|
|
30
|
-
loading: "lazy",
|
|
31
|
-
allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
|
|
32
|
-
allowFullScreen: true,
|
|
33
|
-
ref: (node) => {
|
|
34
|
-
node?.focus();
|
|
35
|
-
}
|
|
36
|
-
})
|
|
37
|
-
});
|
|
38
|
-
return /* @__PURE__ */ jsx("div", {
|
|
39
|
-
className: rootClassName,
|
|
40
|
-
children: /* @__PURE__ */ jsxs("button", {
|
|
41
|
-
type: "button",
|
|
49
|
+
children: [/* @__PURE__ */ jsxs("summary", {
|
|
42
50
|
className: "wave-docs-youtube__facade",
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"aria-hidden": "true",
|
|
56
|
-
children: /* @__PURE__ */ jsxs("svg", {
|
|
57
|
-
viewBox: "0 0 68 48",
|
|
58
|
-
width: "68",
|
|
59
|
-
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",
|
|
60
63
|
"aria-hidden": "true",
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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}`
|
|
69
86
|
})
|
|
70
|
-
|
|
71
|
-
})
|
|
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
|
+
})]
|
|
72
96
|
});
|
|
73
97
|
}
|
|
74
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.
|
|
@@ -16,11 +18,11 @@ interface DocsRendererOptions {
|
|
|
16
18
|
* Reuse an existing highlighter — the escape hatch for grammars and themes
|
|
17
19
|
* outside the curated set. Defaults to {@link createDocsHighlighter}.
|
|
18
20
|
*/
|
|
19
|
-
highlighter?: DocsHighlighter | Promise<DocsHighlighter
|
|
21
|
+
highlighter?: DocsHighlighter | Promise<DocsHighlighter> | undefined;
|
|
20
22
|
/** Grammars to load, when building the default highlighter. */
|
|
21
|
-
langs?: readonly DocsLang[];
|
|
23
|
+
langs?: readonly DocsLang[] | undefined;
|
|
22
24
|
/** Theme pair. Defaults to {@link DEFAULT_DOCS_THEMES}. */
|
|
23
|
-
themes?: DocsThemes;
|
|
25
|
+
themes?: DocsThemes | undefined;
|
|
24
26
|
/**
|
|
25
27
|
* Prepend an `<h1>` built from `frontmatter.title` when the markdown body
|
|
26
28
|
* has none. Defaults to `true`.
|
|
@@ -30,15 +32,52 @@ interface DocsRendererOptions {
|
|
|
30
32
|
* starting at `h2`, and markdown that repeats the frontmatter title as `# `
|
|
31
33
|
* is a duplication authors forget to keep in step.
|
|
32
34
|
*/
|
|
33
|
-
titleHeading?: boolean;
|
|
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
|
-
linkResolver?: LinkResolver;
|
|
69
|
+
linkResolver?: LinkResolver | undefined;
|
|
36
70
|
/**
|
|
37
71
|
* Resolves image `src` to a public URL and intrinsic dimensions, so
|
|
38
|
-
* `next/image` can render without `fill`.
|
|
39
|
-
*
|
|
72
|
+
* `next/image` can render without `fill`.
|
|
73
|
+
*
|
|
74
|
+
* Required as soon as any page writes a relative ``: there
|
|
75
|
+
* is no correct output for one without it, so it throws rather than shipping
|
|
76
|
+
* a src the browser resolves against the route. Absolute (`/logo.png`) and
|
|
77
|
+
* external sources need no resolver, and a resolver returning `undefined`
|
|
78
|
+
* keeps the folded — not the authored — src.
|
|
40
79
|
*/
|
|
41
|
-
imageResolver?: ImageResolver;
|
|
80
|
+
imageResolver?: ImageResolver | undefined;
|
|
42
81
|
/**
|
|
43
82
|
* Every route the site publishes, used by `assertLinks`. Read at render
|
|
44
83
|
* time, so a host may pass a set it populates during the source walk.
|
|
@@ -46,7 +85,36 @@ interface DocsRendererOptions {
|
|
|
46
85
|
* Without it only unresolvable links can be caught; with it, links to pages
|
|
47
86
|
* that simply do not exist are caught too.
|
|
48
87
|
*/
|
|
49
|
-
knownRoutes?: ReadonlySet<string
|
|
88
|
+
knownRoutes?: ReadonlySet<string> | undefined;
|
|
89
|
+
/**
|
|
90
|
+
* Routes of pages excluded from {@link DocsRendererOptions.knownRoutes}
|
|
91
|
+
* because they are `draft: true`.
|
|
92
|
+
*
|
|
93
|
+
* Purely diagnostic, and it earns its place: a link to a draft is a link to a
|
|
94
|
+
* file plainly sitting on disk, and the generic "no such page exists — add an
|
|
95
|
+
* `aliases` entry" is advice that cannot be followed. Failing the build is
|
|
96
|
+
* still right; naming the reason is what makes it fixable.
|
|
97
|
+
*/
|
|
98
|
+
draftRoutes?: ReadonlySet<string> | undefined;
|
|
99
|
+
/**
|
|
100
|
+
* Alias route → the canonical `href` it redirects to.
|
|
101
|
+
*
|
|
102
|
+
* Deliberately not folded into {@link DocsRendererOptions.knownRoutes}. An
|
|
103
|
+
* alias is only a live URL once `createDocsRedirects` is wired into
|
|
104
|
+
* `next.config.ts`, which the quick start does not do — so treating one as
|
|
105
|
+
* publishable produced a green build and a hard 404 for every reader who
|
|
106
|
+
* clicked, which is the exact failure `assertLinks` exists to prevent.
|
|
107
|
+
* Knowing the target lets the error name the page to link instead.
|
|
108
|
+
*/
|
|
109
|
+
aliasRoutes?: ReadonlyMap<string, string> | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* Fence languages Shiki must not touch, e.g. `['mermaid']`.
|
|
112
|
+
*
|
|
113
|
+
* The `<pre><code class="language-mermaid">` reaches your `pre`/`code`
|
|
114
|
+
* component untouched, which is what lets a consumer render a diagram rather
|
|
115
|
+
* than a monochrome block of DSL.
|
|
116
|
+
*/
|
|
117
|
+
excludeLangs?: readonly string[] | undefined;
|
|
50
118
|
}
|
|
51
119
|
/**
|
|
52
120
|
* Renders {@link DocFile}s. Build one per process and reuse it.
|
|
@@ -69,4 +137,4 @@ interface DocsRenderer {
|
|
|
69
137
|
*/
|
|
70
138
|
declare function createDocsRenderer(options: DocsRendererOptions): DocsRenderer;
|
|
71
139
|
//#endregion
|
|
72
|
-
export { DocsRenderer, DocsRendererConfig, DocsRendererOptions, createDocsRenderer };
|
|
140
|
+
export { DocsRenderer, DocsRendererConfig, DocsRendererOptions, createDocsRenderer, resolveMarkdownLink };
|
package/dist/render.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
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";
|
|
3
|
-
import {
|
|
4
|
+
import { rehypeCodeFrame } from "./plugins/rehype-code-frame.js";
|
|
5
|
+
import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
|
|
6
|
+
import { rehypeFallbackHeadingIds } from "./plugins/rehype-fallback-heading-ids.js";
|
|
7
|
+
import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
|
|
8
|
+
import { foldSegments, remarkDocLinks, resolveMarkdownLink } from "./plugins/remark-doc-links.js";
|
|
4
9
|
import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
|
|
5
10
|
import { remarkYouTube } from "./plugins/remark-youtube.js";
|
|
6
11
|
import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
|
|
@@ -11,7 +16,7 @@ import remarkGfm from "remark-gfm";
|
|
|
11
16
|
import remarkParse from "remark-parse";
|
|
12
17
|
import remarkRehype from "remark-rehype";
|
|
13
18
|
import { unified } from "unified";
|
|
14
|
-
import {
|
|
19
|
+
import { visit } from "unist-util-visit";
|
|
15
20
|
import { VFile } from "vfile";
|
|
16
21
|
//#region src/render.ts
|
|
17
22
|
/**
|
|
@@ -54,6 +59,34 @@ function toDirSegments(relativePath) {
|
|
|
54
59
|
/** `scheme:` — `https:`, `data:`, anything that is not ours to resolve. */
|
|
55
60
|
const IMAGE_HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
56
61
|
/**
|
|
62
|
+
* Is this src already a URL a browser can fetch from any page?
|
|
63
|
+
*
|
|
64
|
+
* `/logo.png`, `//cdn/…` and `https://…` are; everything else is relative to
|
|
65
|
+
* the markdown file and means nothing once the file has become a route.
|
|
66
|
+
*/
|
|
67
|
+
function isPublicImageSrc(src) {
|
|
68
|
+
return src.startsWith("/") || IMAGE_HAS_SCHEME.test(src);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Check what the resolver actually returned.
|
|
72
|
+
*
|
|
73
|
+
* `ImageResolver` is a type, and a type stops at the JavaScript boundary: a
|
|
74
|
+
* host reading dimensions from a manifest hands back `{ src, width: '1200' }`
|
|
75
|
+
* or a bare string, and the only symptom is `width="undefined"` in the HTML —
|
|
76
|
+
* on one page, at build time, with nothing naming the image or the document.
|
|
77
|
+
*/
|
|
78
|
+
function assertResolvedImage(value, src, relativePath) {
|
|
79
|
+
const blame = `for image "${src}" in ${relativePath}`;
|
|
80
|
+
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.`);
|
|
81
|
+
const resolved = value;
|
|
82
|
+
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.`);
|
|
83
|
+
for (const key of ["width", "height"]) {
|
|
84
|
+
const dimension = resolved[key];
|
|
85
|
+
if (dimension === void 0) continue;
|
|
86
|
+
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.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
57
90
|
* An image `src` folded against the page's directory, or `undefined` if it
|
|
58
91
|
* climbs out of the content root.
|
|
59
92
|
*
|
|
@@ -63,7 +96,7 @@ const IMAGE_HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
|
63
96
|
* author means by `` and what the link path has always done.
|
|
64
97
|
*/
|
|
65
98
|
function foldImageSrc(src, dirSegments) {
|
|
66
|
-
if (
|
|
99
|
+
if (isPublicImageSrc(src)) return src;
|
|
67
100
|
const segments = foldSegments(dirSegments, src);
|
|
68
101
|
return segments === void 0 ? void 0 : segments.join("/");
|
|
69
102
|
}
|
|
@@ -76,15 +109,17 @@ function describeLink(file, ref) {
|
|
|
76
109
|
const at = ref.line === void 0 ? "" : `:${ref.line}`;
|
|
77
110
|
return `${file.relativePath}${at}`;
|
|
78
111
|
}
|
|
79
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Does the document already open on a page title?
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ TOP-LEVEL CHILDREN ONLY, DELIBERATELY. A whole-tree walk counted an `h1`
|
|
116
|
+
* anywhere — including `> [!NOTE]\n> # Callout title` — and suppressed the
|
|
117
|
+
* frontmatter heading, so the page title appeared nowhere in the body and the
|
|
118
|
+
* document's only `h1` was buried inside a callout. That is precisely the
|
|
119
|
+
* `page-has-heading-one` failure this option's docstring says it prevents.
|
|
120
|
+
*/
|
|
80
121
|
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;
|
|
122
|
+
return tree.children.some((child) => child.type === "element" && child.tagName === "h1");
|
|
88
123
|
}
|
|
89
124
|
/**
|
|
90
125
|
* The `<h1>` a page gets when its markdown does not declare one.
|
|
@@ -111,7 +146,7 @@ function titleHeadingNode(title) {
|
|
|
111
146
|
*
|
|
112
147
|
* The tree is the payload: it crosses the RSC boundary, so every byte is
|
|
113
148
|
* shipped to every reader.
|
|
114
|
-
* Positions are
|
|
149
|
+
* Positions are roughly a third of that JSON — line and column offsets
|
|
115
150
|
* into a markdown file the browser does not have and cannot fetch. Nothing
|
|
116
151
|
* downstream reads them: link errors are reported from positions captured
|
|
117
152
|
* during the mdast phase, and the TOC works off ids.
|
|
@@ -151,27 +186,51 @@ function stripPositions(tree) {
|
|
|
151
186
|
* `remark-gfm` does not implement alerts at all.
|
|
152
187
|
* Runs before slugging so a heading inside a
|
|
153
188
|
* callout is slugged in its final position.
|
|
154
|
-
* 8. `
|
|
155
|
-
*
|
|
156
|
-
*
|
|
189
|
+
* 8. `rehypeFallbackHeadingIds` — before slugging, so an emoji-only heading
|
|
190
|
+
* never seeds the collision counter with `''`.
|
|
191
|
+
* 9. `rehypeSlug` — assigns heading ids.
|
|
157
192
|
* 10. `rehypeAutolinkHeadings` — appends the permalink.
|
|
158
|
-
* 11. `
|
|
159
|
-
* and
|
|
160
|
-
*
|
|
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.
|
|
196
|
+
* 12. `rehypeNormalizeCodeLanguage` — immediately before Shiki, which is the
|
|
197
|
+
* last moment `class="language-JSON"` exists.
|
|
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>`
|
|
203
|
+
* wholesale, and anything walking code blocks
|
|
204
|
+
* afterwards would be walking Shiki's token spans.
|
|
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
|
|
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.
|
|
161
224
|
*/
|
|
162
|
-
async function buildProcessor(options) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
themes,
|
|
166
|
-
...options.langs === void 0 ? {} : { langs: options.langs }
|
|
167
|
-
}));
|
|
168
|
-
return unified().use(remarkParse).use(remarkGfm).use(remarkDocLinks, {
|
|
225
|
+
async function buildProcessor(options, themes, highlighterPromise) {
|
|
226
|
+
const highlighter = await highlighterPromise;
|
|
227
|
+
return unified().use(remarkParse).use(remarkGfm).use(options.remarkPlugins ?? []).use(remarkDocLinks, {
|
|
169
228
|
basePath: options.config.basePath,
|
|
170
229
|
...options.linkResolver === void 0 ? {} : { resolve: options.linkResolver }
|
|
171
230
|
}).use(remarkUnwrapImages).use(remarkYouTube).use(remarkRehype, {
|
|
172
231
|
allowDangerousHtml: false,
|
|
173
232
|
footnoteLabelProperties: { className: ["wave-docs-sr-only"] }
|
|
174
|
-
}).use(rehypeGithubAlerts, { build: buildCallout }).use(
|
|
233
|
+
}).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeFallbackHeadingIds).use(rehypeSlug).use(rehypeAutolinkHeadings, {
|
|
175
234
|
behavior: "append",
|
|
176
235
|
content: HEADING_ANCHOR_CONTENT,
|
|
177
236
|
properties: {
|
|
@@ -179,11 +238,13 @@ async function buildProcessor(options) {
|
|
|
179
238
|
ariaHidden: "true",
|
|
180
239
|
tabIndex: -1
|
|
181
240
|
}
|
|
182
|
-
}).use(rehypeShikiFromHighlighter, highlighter, {
|
|
241
|
+
}).use(options.rehypePlugins ?? []).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeCodeFrame).use(rehypeShikiFromHighlighter, highlighter, {
|
|
183
242
|
themes,
|
|
243
|
+
defaultColor: false,
|
|
184
244
|
fallbackLanguage: "text",
|
|
185
|
-
defaultLanguage: "text"
|
|
186
|
-
|
|
245
|
+
defaultLanguage: "text",
|
|
246
|
+
addLanguageClass: true
|
|
247
|
+
}).use(rehypeRestoreExcludedCode).use(rehypeFlattenRoots).use(rehypeCaptureToc).freeze();
|
|
187
248
|
}
|
|
188
249
|
/**
|
|
189
250
|
* Create a renderer.
|
|
@@ -193,33 +254,38 @@ async function buildProcessor(options) {
|
|
|
193
254
|
* a docs build that takes a second and one that takes a minute.
|
|
194
255
|
*/
|
|
195
256
|
function createDocsRenderer(options) {
|
|
196
|
-
const
|
|
197
|
-
const
|
|
257
|
+
const themes = options.themes ?? DEFAULT_DOCS_THEMES;
|
|
258
|
+
const processorPromise = buildProcessor(options, themes, options.highlighter ?? createDocsHighlighter({
|
|
259
|
+
themes,
|
|
260
|
+
...options.langs === void 0 ? {} : { langs: options.langs }
|
|
261
|
+
}));
|
|
262
|
+
processorPromise.catch(() => void 0);
|
|
263
|
+
const { config, imageResolver, knownRoutes, draftRoutes, aliasRoutes } = options;
|
|
198
264
|
const titleHeading = options.titleHeading ?? true;
|
|
199
265
|
/**
|
|
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.
|
|
266
|
+
* Fold every `<img src>`, then hand it to the resolver if there is one.
|
|
208
267
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
* `
|
|
268
|
+
* ⚠️ THIS RUNS FOR EVERY DOCUMENT, RESOLVER OR NOT, AND THAT IS THE POINT.
|
|
269
|
+
* It used to be gated on `imageResolver`, which is the option nobody sets
|
|
270
|
+
* first — so under the quickstart config `` shipped
|
|
271
|
+
* byte-for-byte as authored and the BROWSER resolved it, against the route:
|
|
272
|
+
* `/docs/guide` asked for `/docs/diagram.png` and `/docs/guide/setup` asked
|
|
273
|
+
* for `/docs/guide/diagram.png`, from identical markdown. `assertLinks` could
|
|
274
|
+
* not see it either — `remarkDocLinks` visits `link` and `definition`, never
|
|
275
|
+
* `image` — so the build stayed green and the containment throw below was
|
|
276
|
+
* dead code in the only configuration most sites run.
|
|
214
277
|
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
278
|
+
* A relative src has no correct output without a resolver, so it throws.
|
|
279
|
+
* Absolute (`/logo.png`) and schemed srcs are already public URLs and are
|
|
280
|
+
* passed through untouched — but still offered to the resolver, so a host can
|
|
281
|
+
* rewrite them onto a CDN.
|
|
217
282
|
*/
|
|
218
283
|
async function resolveImages(tree, file, resolve) {
|
|
219
284
|
const images = [];
|
|
220
285
|
visit(tree, "element", (node) => {
|
|
221
286
|
if (node.tagName === "img") images.push(node);
|
|
222
287
|
});
|
|
288
|
+
if (images.length === 0) return;
|
|
223
289
|
const context = {
|
|
224
290
|
segments: file.segments,
|
|
225
291
|
dirSegments: toDirSegments(file.relativePath),
|
|
@@ -229,9 +295,22 @@ function createDocsRenderer(options) {
|
|
|
229
295
|
const src = node.properties.src;
|
|
230
296
|
if (typeof src !== "string" || src === "") return;
|
|
231
297
|
const folded = foldImageSrc(src, context.dirSegments);
|
|
232
|
-
if (folded === void 0) throw
|
|
233
|
-
|
|
234
|
-
|
|
298
|
+
if (folded === void 0) throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} climbs above the content root.`);
|
|
299
|
+
if (resolve === void 0) {
|
|
300
|
+
if (isPublicImageSrc(src)) return;
|
|
301
|
+
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".`);
|
|
302
|
+
}
|
|
303
|
+
let resolved;
|
|
304
|
+
try {
|
|
305
|
+
resolved = await resolve(folded, context);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
throw docsError("invalid-image", `@waveso/docs: the imageResolver threw on image "${src}" in ${file.relativePath}.`, { cause: error });
|
|
308
|
+
}
|
|
309
|
+
if (resolved === void 0) {
|
|
310
|
+
node.properties.src = folded;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
assertResolvedImage(resolved, src, file.relativePath);
|
|
235
314
|
node.properties.src = resolved.src;
|
|
236
315
|
if (resolved.width !== void 0) node.properties.width = resolved.width;
|
|
237
316
|
if (resolved.height !== void 0) node.properties.height = resolved.height;
|
|
@@ -245,10 +324,14 @@ function createDocsRenderer(options) {
|
|
|
245
324
|
*/
|
|
246
325
|
function assertLinks(file, refs) {
|
|
247
326
|
for (const ref of refs) {
|
|
248
|
-
if (ref.href === void 0) throw
|
|
249
|
-
if (knownRoutes === void 0) continue;
|
|
327
|
+
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.`);
|
|
328
|
+
if (knownRoutes === void 0 || ref.asset) continue;
|
|
250
329
|
const route = toRouteKey(ref.href);
|
|
251
|
-
if (
|
|
330
|
+
if (knownRoutes.has(route)) continue;
|
|
331
|
+
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\`.`);
|
|
332
|
+
const aliasTarget = aliasRoutes?.get(route);
|
|
333
|
+
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.`);
|
|
334
|
+
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
335
|
}
|
|
253
336
|
}
|
|
254
337
|
return { async render(file) {
|
|
@@ -264,7 +347,7 @@ function createDocsRenderer(options) {
|
|
|
264
347
|
};
|
|
265
348
|
const hast = await processor.run(processor.parse(vfile), vfile);
|
|
266
349
|
if (titleHeading && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
|
|
267
|
-
|
|
350
|
+
await resolveImages(hast, file, imageResolver);
|
|
268
351
|
if (config.assertLinks) assertLinks(file, vfile.data.docLinks ?? []);
|
|
269
352
|
return {
|
|
270
353
|
frontmatter: file.frontmatter,
|
|
@@ -276,4 +359,4 @@ function createDocsRenderer(options) {
|
|
|
276
359
|
} };
|
|
277
360
|
}
|
|
278
361
|
//#endregion
|
|
279
|
-
export { createDocsRenderer };
|
|
362
|
+
export { createDocsRenderer, resolveMarkdownLink };
|