@waveso/docs 0.1.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/LICENSE +21 -0
- package/README.md +518 -0
- package/dist/frontmatter.d.ts +55 -0
- package/dist/frontmatter.js +80 -0
- package/dist/highlighter.d.ts +99 -0
- package/dist/highlighter.js +183 -0
- package/dist/meta.d.ts +75 -0
- package/dist/meta.js +183 -0
- package/dist/next.d.ts +256 -0
- package/dist/next.js +365 -0
- package/dist/plugins/rehype-capture-toc.d.ts +18 -0
- package/dist/plugins/rehype-capture-toc.js +69 -0
- package/dist/plugins/remark-doc-links.d.ts +63 -0
- package/dist/plugins/remark-doc-links.js +122 -0
- package/dist/plugins/remark-unwrap-images.d.ts +11 -0
- package/dist/plugins/remark-unwrap-images.js +25 -0
- package/dist/plugins/remark-youtube.d.ts +22 -0
- package/dist/plugins/remark-youtube.js +84 -0
- package/dist/react/callout.d.ts +37 -0
- package/dist/react/callout.js +113 -0
- package/dist/react/doc-content.d.ts +29 -0
- package/dist/react/doc-content.js +30 -0
- package/dist/react/markdown-components.d.ts +84 -0
- package/dist/react/markdown-components.js +122 -0
- package/dist/react/search-dialog.d.ts +41 -0
- package/dist/react/search-dialog.js +404 -0
- package/dist/react/sidebar.d.ts +29 -0
- package/dist/react/sidebar.js +196 -0
- package/dist/react/skip-link.d.ts +37 -0
- package/dist/react/skip-link.js +37 -0
- package/dist/react/toc.d.ts +35 -0
- package/dist/react/toc.js +87 -0
- package/dist/react/youtube.d.ts +27 -0
- package/dist/react/youtube.js +75 -0
- package/dist/render.d.ts +72 -0
- package/dist/render.js +279 -0
- package/dist/search-index.d.ts +51 -0
- package/dist/search-index.js +274 -0
- package/dist/search-options.d.ts +18 -0
- package/dist/search-options.js +40 -0
- package/dist/source.d.ts +67 -0
- package/dist/source.js +332 -0
- package/dist/styles.css +1033 -0
- package/dist/types.d.ts +334 -0
- package/dist/types.js +0 -0
- package/package.json +166 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { SKIP, visit } from "unist-util-visit";
|
|
2
|
+
import { toString } from "hast-util-to-string";
|
|
3
|
+
//#region src/plugins/rehype-capture-toc.ts
|
|
4
|
+
/** `h2`–`h6`. `h1` is the page title and never appears in a TOC. */
|
|
5
|
+
const HEADING = /^h([2-6])$/;
|
|
6
|
+
/**
|
|
7
|
+
* Drop the permalink anchor `rehype-autolink-headings` appends.
|
|
8
|
+
*
|
|
9
|
+
* This plugin is ordered before that one, so in practice there is nothing to
|
|
10
|
+
* drop — but the check costs nothing and the alternative, if the order ever
|
|
11
|
+
* changes, is every TOC entry silently gaining a trailing `#`.
|
|
12
|
+
*/
|
|
13
|
+
function isPermalink(child) {
|
|
14
|
+
if (child.type !== "element" || child.tagName !== "a") return false;
|
|
15
|
+
const className = child.properties.className;
|
|
16
|
+
const ariaHidden = child.properties.ariaHidden;
|
|
17
|
+
return ariaHidden === true || ariaHidden === "true" || Array.isArray(className) && className.includes("heading-anchor");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The GFM footnote block `mdast-util-to-hast` appends.
|
|
21
|
+
*
|
|
22
|
+
* It carries a generated `<h2 id="footnote-label">Footnotes</h2>` that is
|
|
23
|
+
* machinery rather than a section of the page — and is visually hidden, so a
|
|
24
|
+
* TOC entry for it points the reader at nothing they can see. `search-index.ts`
|
|
25
|
+
* skips the same subtree; the two must agree about which sections exist.
|
|
26
|
+
*/
|
|
27
|
+
function isFootnotes(node) {
|
|
28
|
+
return node.properties.dataFootnotes !== void 0;
|
|
29
|
+
}
|
|
30
|
+
function headingText(node) {
|
|
31
|
+
return node.children.filter((child) => !isPermalink(child)).map((child) => toString(child)).join("").trim();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* rehype plugin. Must run after `rehype-slug`; ordering is enforced by the
|
|
35
|
+
* renderer rather than checked here, because a heading legitimately may have
|
|
36
|
+
* been given an id by hand.
|
|
37
|
+
*/
|
|
38
|
+
const rehypeCaptureToc = () => {
|
|
39
|
+
return (tree, file) => {
|
|
40
|
+
const toc = [];
|
|
41
|
+
/** Open ancestors, outermost first. */
|
|
42
|
+
const stack = [];
|
|
43
|
+
visit(tree, "element", (node) => {
|
|
44
|
+
if (isFootnotes(node)) return SKIP;
|
|
45
|
+
const level = HEADING.exec(node.tagName)?.[1];
|
|
46
|
+
if (level === void 0) return;
|
|
47
|
+
const id = node.properties.id;
|
|
48
|
+
if (typeof id !== "string" || id === "") return;
|
|
49
|
+
const entry = {
|
|
50
|
+
id,
|
|
51
|
+
text: headingText(node),
|
|
52
|
+
depth: Number(level),
|
|
53
|
+
children: []
|
|
54
|
+
};
|
|
55
|
+
while (stack.length > 0) {
|
|
56
|
+
const top = stack[stack.length - 1];
|
|
57
|
+
if (top !== void 0 && top.depth < entry.depth) break;
|
|
58
|
+
stack.pop();
|
|
59
|
+
}
|
|
60
|
+
const parent = stack[stack.length - 1];
|
|
61
|
+
if (parent === void 0) toc.push(entry);
|
|
62
|
+
else parent.children.push(entry);
|
|
63
|
+
stack.push(entry);
|
|
64
|
+
});
|
|
65
|
+
file.data.toc = toc;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
//#endregion
|
|
69
|
+
export { rehypeCaptureToc };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { DocLinkContext, LinkResolver } from "../types.js";
|
|
2
|
+
import { Plugin } from "unified";
|
|
3
|
+
import { Root } from "mdast";
|
|
4
|
+
//#region src/plugins/remark-doc-links.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Where a link was found and what it resolved to.
|
|
7
|
+
*
|
|
8
|
+
* `href` is `undefined` when resolution failed — an unknown resolver result,
|
|
9
|
+
* or a `../` chain that climbs out of the content root. That is a distinct
|
|
10
|
+
* condition from "resolved but the page does not exist", and the caller
|
|
11
|
+
* reports them differently.
|
|
12
|
+
*/
|
|
13
|
+
interface DocLinkRef {
|
|
14
|
+
/** The href exactly as authored, e.g. `'./api/auth.md'`. */
|
|
15
|
+
raw: string;
|
|
16
|
+
/** Resolved route including `?query` and `#anchor`, or `undefined`. */
|
|
17
|
+
href: string | undefined;
|
|
18
|
+
/** 1-based line in the source markdown, when the parser recorded one. */
|
|
19
|
+
line?: number;
|
|
20
|
+
}
|
|
21
|
+
declare module 'vfile' {
|
|
22
|
+
interface DataMap {
|
|
23
|
+
/** Set by the caller before running the processor. */
|
|
24
|
+
docLinkContext: DocLinkContext;
|
|
25
|
+
/** Appended to by {@link remarkDocLinks}. */
|
|
26
|
+
docLinks: DocLinkRef[];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
interface RemarkDocLinksOptions {
|
|
30
|
+
/** URL prefix the docs are mounted at, e.g. `'/docs'`. */
|
|
31
|
+
basePath: string;
|
|
32
|
+
/** Overrides the built-in resolution entirely, for every relative link. */
|
|
33
|
+
resolve?: LinkResolver;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Fold `.` and `..` against a starting directory.
|
|
37
|
+
*
|
|
38
|
+
* Hand-rolled rather than `path.resolve` because these are URL paths, not
|
|
39
|
+
* filesystem paths: they must use `/` on Windows and must not pick up the
|
|
40
|
+
* process working directory. Returns `undefined` when the chain climbs above
|
|
41
|
+
* the content root, which is always an authoring error worth surfacing.
|
|
42
|
+
*
|
|
43
|
+
* EXPORTED as part of the wrap-don't-replace surface. Getting `../` right is
|
|
44
|
+
* the fiddly half of writing a {@link LinkResolver}, and a host that reuses
|
|
45
|
+
* this cannot disagree with the built-in resolver about where a link points.
|
|
46
|
+
* It is also what `render.ts` folds image sources with, so links and images
|
|
47
|
+
* are contained by one implementation rather than two.
|
|
48
|
+
*/
|
|
49
|
+
declare function foldSegments(from: readonly string[], path: string): string[] | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* The built-in {@link LinkResolver}: markdown file path in, route out.
|
|
52
|
+
*
|
|
53
|
+
* Exported for reuse by hosts that want to wrap rather than replace it.
|
|
54
|
+
*/
|
|
55
|
+
declare function resolveMarkdownLink(href: string, fromDir: readonly string[], basePath: string): string | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* remark plugin. Requires `file.data.docLinkContext` to be set; without it the
|
|
58
|
+
* containing document is unknown and every relative link would resolve against
|
|
59
|
+
* the content root, which is worse than leaving them alone.
|
|
60
|
+
*/
|
|
61
|
+
declare const remarkDocLinks: Plugin<[RemarkDocLinksOptions], Root>;
|
|
62
|
+
//#endregion
|
|
63
|
+
export { type DocLinkContext, DocLinkRef, RemarkDocLinksOptions, foldSegments, remarkDocLinks, resolveMarkdownLink };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { visit } from "unist-util-visit";
|
|
2
|
+
//#region src/plugins/remark-doc-links.ts
|
|
3
|
+
/** `scheme:` — matches `https:`, `mailto:`, `tel:`, `data:`. */
|
|
4
|
+
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
5
|
+
/** Splits `path?query#hash`; every group is optional. */
|
|
6
|
+
const HREF_PARTS = /^([^?#]*)(\?[^#]*)?(#.*)?$/;
|
|
7
|
+
const MARKDOWN_EXTENSION = /\.mdx?$/i;
|
|
8
|
+
/** A dot-extension on the final segment, e.g. `.png` in `img/logo.png`. */
|
|
9
|
+
const FILE_EXTENSION = /\.[^./]+$/;
|
|
10
|
+
/**
|
|
11
|
+
* Does this href point at another page in the same docs tree?
|
|
12
|
+
*
|
|
13
|
+
* Absolute paths are already routes, in-page anchors are already correct, and
|
|
14
|
+
* anything with a scheme belongs to someone else. A path-less href — `?tab=json`
|
|
15
|
+
* — addresses the current page and is left alone for the same reason `#anchor`
|
|
16
|
+
* is: there is nothing to resolve, and resolving it to `undefined` would fail
|
|
17
|
+
* the build with advice the author cannot act on.
|
|
18
|
+
*/
|
|
19
|
+
function isRelativeLink(href) {
|
|
20
|
+
return href !== "" && !href.startsWith("#") && !href.startsWith("?") && !href.startsWith("/") && !HAS_SCHEME.test(href);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Fold `.` and `..` against a starting directory.
|
|
24
|
+
*
|
|
25
|
+
* Hand-rolled rather than `path.resolve` because these are URL paths, not
|
|
26
|
+
* filesystem paths: they must use `/` on Windows and must not pick up the
|
|
27
|
+
* process working directory. Returns `undefined` when the chain climbs above
|
|
28
|
+
* the content root, which is always an authoring error worth surfacing.
|
|
29
|
+
*
|
|
30
|
+
* EXPORTED as part of the wrap-don't-replace surface. Getting `../` right is
|
|
31
|
+
* the fiddly half of writing a {@link LinkResolver}, and a host that reuses
|
|
32
|
+
* this cannot disagree with the built-in resolver about where a link points.
|
|
33
|
+
* It is also what `render.ts` folds image sources with, so links and images
|
|
34
|
+
* are contained by one implementation rather than two.
|
|
35
|
+
*/
|
|
36
|
+
function foldSegments(from, path) {
|
|
37
|
+
const out = [...from];
|
|
38
|
+
for (const part of path.split("/")) {
|
|
39
|
+
if (part === "" || part === ".") continue;
|
|
40
|
+
if (part === "..") {
|
|
41
|
+
if (out.length === 0) return;
|
|
42
|
+
out.pop();
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
out.push(part);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** Join route segments onto the base path, e.g. `('/docs', ['api'])`. */
|
|
50
|
+
function toRoute(basePath, segments) {
|
|
51
|
+
const base = basePath.replace(/\/+$/, "");
|
|
52
|
+
const path = segments.join("/");
|
|
53
|
+
if (path === "") return base === "" ? "/" : base;
|
|
54
|
+
return `${base}/${path}`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The built-in {@link LinkResolver}: markdown file path in, route out.
|
|
58
|
+
*
|
|
59
|
+
* Exported for reuse by hosts that want to wrap rather than replace it.
|
|
60
|
+
*/
|
|
61
|
+
function resolveMarkdownLink(href, fromDir, basePath) {
|
|
62
|
+
const parts = HREF_PARTS.exec(href);
|
|
63
|
+
const path = parts?.[1] ?? "";
|
|
64
|
+
const query = parts?.[2] ?? "";
|
|
65
|
+
const hash = parts?.[3] ?? "";
|
|
66
|
+
if (path === "") return;
|
|
67
|
+
const segments = foldSegments(fromDir, path);
|
|
68
|
+
if (segments === void 0) return;
|
|
69
|
+
const last = segments.at(-1);
|
|
70
|
+
if (last !== void 0) {
|
|
71
|
+
const stripped = last.replace(MARKDOWN_EXTENSION, "");
|
|
72
|
+
if (stripped === "index") segments.pop();
|
|
73
|
+
else segments[segments.length - 1] = stripped;
|
|
74
|
+
}
|
|
75
|
+
return `${toRoute(basePath, segments)}${query}${hash}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Is this relative href pointing at an asset rather than a page?
|
|
79
|
+
*
|
|
80
|
+
* A link to `./diagram.svg` or `./schema.sql` is a download, not a route, and
|
|
81
|
+
* rewriting it would break it. Markdown extensions are pages; any other
|
|
82
|
+
* extension on the final segment is an asset; no extension is a page.
|
|
83
|
+
* The false positive is a page literally named `v2.0`, which is rare enough
|
|
84
|
+
* to accept and is fixed by writing `v2.0.md`.
|
|
85
|
+
*/
|
|
86
|
+
function isAssetLink(href) {
|
|
87
|
+
const last = (HREF_PARTS.exec(href)?.[1] ?? "").split("/").at(-1) ?? "";
|
|
88
|
+
return FILE_EXTENSION.test(last) && !MARKDOWN_EXTENSION.test(last);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* remark plugin. Requires `file.data.docLinkContext` to be set; without it the
|
|
92
|
+
* containing document is unknown and every relative link would resolve against
|
|
93
|
+
* the content root, which is worse than leaving them alone.
|
|
94
|
+
*/
|
|
95
|
+
const remarkDocLinks = (options) => {
|
|
96
|
+
const { basePath, resolve } = options;
|
|
97
|
+
return (tree, file) => {
|
|
98
|
+
const context = file.data.docLinkContext;
|
|
99
|
+
if (context === void 0) throw new Error(`@waveso/docs: remarkDocLinks ran without file.data.docLinkContext${file.path === void 0 ? "" : ` (file: ${file.path})`}. Set it before running the processor.`);
|
|
100
|
+
const refs = file.data.docLinks ?? [];
|
|
101
|
+
file.data.docLinks = refs;
|
|
102
|
+
visit(tree, ["link", "definition"], (node) => {
|
|
103
|
+
if (node.type !== "link" && node.type !== "definition") return;
|
|
104
|
+
const raw = node.url;
|
|
105
|
+
if (!isRelativeLink(raw)) return;
|
|
106
|
+
if (resolve === void 0 && isAssetLink(raw)) return;
|
|
107
|
+
const href = resolve ? resolve(raw, context) : resolveMarkdownLink(raw, context.dirSegments, basePath);
|
|
108
|
+
const line = node.position?.start.line;
|
|
109
|
+
refs.push(line === void 0 ? {
|
|
110
|
+
raw,
|
|
111
|
+
href
|
|
112
|
+
} : {
|
|
113
|
+
raw,
|
|
114
|
+
href,
|
|
115
|
+
line
|
|
116
|
+
});
|
|
117
|
+
if (href !== void 0) node.url = href;
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
//#endregion
|
|
122
|
+
export { foldSegments, remarkDocLinks, resolveMarkdownLink };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Plugin } from "unified";
|
|
2
|
+
import { Root } from "mdast";
|
|
3
|
+
//#region src/plugins/remark-unwrap-images.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* remark plugin. Only unwraps paragraphs whose sole meaningful child is an
|
|
6
|
+
* image — a paragraph with a caption beside the image is prose, and prose
|
|
7
|
+
* belongs in a paragraph.
|
|
8
|
+
*/
|
|
9
|
+
declare const remarkUnwrapImages: Plugin<[], Root>;
|
|
10
|
+
//#endregion
|
|
11
|
+
export { remarkUnwrapImages };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { SKIP, visit } from "unist-util-visit";
|
|
2
|
+
//#region src/plugins/remark-unwrap-images.ts
|
|
3
|
+
/** Whitespace-only text is what separates `\n`. */
|
|
4
|
+
function isIgnorable(node) {
|
|
5
|
+
return node.type === "text" && node.value.trim() === "";
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* remark plugin. Only unwraps paragraphs whose sole meaningful child is an
|
|
9
|
+
* image — a paragraph with a caption beside the image is prose, and prose
|
|
10
|
+
* belongs in a paragraph.
|
|
11
|
+
*/
|
|
12
|
+
const remarkUnwrapImages = () => {
|
|
13
|
+
return (tree) => {
|
|
14
|
+
visit(tree, "paragraph", (node, index, parent) => {
|
|
15
|
+
if (parent === void 0 || index === void 0) return;
|
|
16
|
+
const meaningful = node.children.filter((child) => !isIgnorable(child));
|
|
17
|
+
const only = meaningful[0];
|
|
18
|
+
if (meaningful.length !== 1 || only === void 0 || only.type !== "image" && only.type !== "imageReference") return;
|
|
19
|
+
parent.children[index] = only;
|
|
20
|
+
return [SKIP, index + 1];
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { remarkUnwrapImages };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Plugin } from "unified";
|
|
2
|
+
import { Root } from "mdast";
|
|
3
|
+
//#region src/plugins/remark-youtube.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Extract a video id from a YouTube watch/short/embed URL.
|
|
6
|
+
*
|
|
7
|
+
* Returns `undefined` for anything else, including YouTube URLs that are not a
|
|
8
|
+
* single video (channels, playlists) — those stay ordinary links.
|
|
9
|
+
*/
|
|
10
|
+
declare function parseYouTubeId(href: string): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* remark plugin. Replaces the whole PARAGRAPH, not the link inside it — which
|
|
13
|
+
* is the point: leaving the paragraph is what nested a block element in
|
|
14
|
+
* phrasing content.
|
|
15
|
+
*
|
|
16
|
+
* `data.hName` / `data.hProperties` is how `mdast-util-to-hast` is told to
|
|
17
|
+
* serialise a node as something other than its default, so the output is a
|
|
18
|
+
* plain hast element and the React layer maps it like any other.
|
|
19
|
+
*/
|
|
20
|
+
declare const remarkYouTube: Plugin<[], Root>;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { parseYouTubeId, remarkYouTube };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { SKIP, visit } from "unist-util-visit";
|
|
2
|
+
//#region src/plugins/remark-youtube.ts
|
|
3
|
+
/** YouTube ids are exactly eleven URL-safe characters. */
|
|
4
|
+
const VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
5
|
+
/** `https://` or `http://`, for comparing a label against its own href. */
|
|
6
|
+
const HTTP_SCHEME = /^https?:\/\//i;
|
|
7
|
+
/**
|
|
8
|
+
* Extract a video id from a YouTube watch/short/embed URL.
|
|
9
|
+
*
|
|
10
|
+
* Returns `undefined` for anything else, including YouTube URLs that are not a
|
|
11
|
+
* single video (channels, playlists) — those stay ordinary links.
|
|
12
|
+
*/
|
|
13
|
+
function parseYouTubeId(href) {
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(href, "https://example.invalid");
|
|
17
|
+
} catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const host = url.hostname.replace(/^(www|m)\./, "");
|
|
21
|
+
const segments = url.pathname.split("/").filter(Boolean);
|
|
22
|
+
if (host === "youtu.be") {
|
|
23
|
+
const [id] = segments;
|
|
24
|
+
return id !== void 0 && VIDEO_ID.test(id) ? id : void 0;
|
|
25
|
+
}
|
|
26
|
+
if (host !== "youtube.com" && host !== "youtube-nocookie.com") return;
|
|
27
|
+
if (url.pathname === "/watch") {
|
|
28
|
+
const id = url.searchParams.get("v");
|
|
29
|
+
return id !== null && VIDEO_ID.test(id) ? id : void 0;
|
|
30
|
+
}
|
|
31
|
+
const [prefix, id] = segments;
|
|
32
|
+
if ((prefix === "embed" || prefix === "shorts") && id !== void 0) return VIDEO_ID.test(id) ? id : void 0;
|
|
33
|
+
}
|
|
34
|
+
/** Whitespace-only text is what separates two links on consecutive lines. */
|
|
35
|
+
function isIgnorable(node) {
|
|
36
|
+
return node.type === "text" && node.value.trim() === "";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Is this link a BARE URL rather than a labelled one?
|
|
40
|
+
*
|
|
41
|
+
* `[the intro](https://youtu.be/x)` keeps its label and stays a link; only a
|
|
42
|
+
* link whose visible text IS its own href becomes a video. Compared with the
|
|
43
|
+
* scheme and any trailing slash removed, because a markdown autolink often
|
|
44
|
+
* drops the scheme from the label.
|
|
45
|
+
*/
|
|
46
|
+
function isBareUrl(node) {
|
|
47
|
+
if (node.type !== "link" || node.children.length !== 1) return false;
|
|
48
|
+
const [only] = node.children;
|
|
49
|
+
if (only === void 0 || only.type !== "text") return false;
|
|
50
|
+
const strip = (value) => value.replace(HTTP_SCHEME, "").replace(/\/$/, "");
|
|
51
|
+
return strip(only.value) === strip(node.url);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* remark plugin. Replaces the whole PARAGRAPH, not the link inside it — which
|
|
55
|
+
* is the point: leaving the paragraph is what nested a block element in
|
|
56
|
+
* phrasing content.
|
|
57
|
+
*
|
|
58
|
+
* `data.hName` / `data.hProperties` is how `mdast-util-to-hast` is told to
|
|
59
|
+
* serialise a node as something other than its default, so the output is a
|
|
60
|
+
* plain hast element and the React layer maps it like any other.
|
|
61
|
+
*/
|
|
62
|
+
const remarkYouTube = () => {
|
|
63
|
+
return (tree) => {
|
|
64
|
+
visit(tree, "paragraph", (node, index, parent) => {
|
|
65
|
+
if (parent === void 0 || index === void 0) return;
|
|
66
|
+
const meaningful = node.children.filter((child) => !isIgnorable(child));
|
|
67
|
+
const only = meaningful[0];
|
|
68
|
+
if (meaningful.length !== 1 || only === void 0 || !isBareUrl(only)) return;
|
|
69
|
+
const id = only.type === "link" ? parseYouTubeId(only.url) : void 0;
|
|
70
|
+
if (id === void 0) return;
|
|
71
|
+
parent.children[index] = {
|
|
72
|
+
type: "paragraph",
|
|
73
|
+
children: [],
|
|
74
|
+
data: {
|
|
75
|
+
hName: "youtube",
|
|
76
|
+
hProperties: { id }
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
return [SKIP, index + 1];
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { parseYouTubeId, remarkYouTube };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
//#region src/react/callout.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The five alert kinds GitHub understands, which is also what
|
|
5
|
+
* `rehype-github-alerts` produces from `> [!NOTE]` and friends.
|
|
6
|
+
*/
|
|
7
|
+
declare const CALLOUT_TYPES: readonly ["note", "tip", "important", "warning", "caution"];
|
|
8
|
+
/** One of {@link CALLOUT_TYPES}. */
|
|
9
|
+
type CalloutType = (typeof CALLOUT_TYPES)[number];
|
|
10
|
+
interface CalloutProps {
|
|
11
|
+
/**
|
|
12
|
+
* Kind of callout. Typed as a plain string because it arrives as an
|
|
13
|
+
* unvalidated hast attribute; anything unrecognised falls back to `note`
|
|
14
|
+
* rather than rendering an unstyled box.
|
|
15
|
+
*/
|
|
16
|
+
type?: string | undefined;
|
|
17
|
+
/** Overrides the default label ("Note", "Warning", …). */
|
|
18
|
+
title?: string | undefined;
|
|
19
|
+
className?: string | undefined;
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A note/tip/important/warning/caution block.
|
|
24
|
+
*
|
|
25
|
+
* Rendered as an `<aside role="note">`: `aside` is the honest element, and the
|
|
26
|
+
* explicit role keeps a callout in the middle of an article from showing up in
|
|
27
|
+
* every screen reader's landmark list. The type is carried on `aria-label`
|
|
28
|
+
* rather than left to the coloured border, which conveys nothing to a screen
|
|
29
|
+
* reader and nothing to the 8% of men who cannot separate the red one from the
|
|
30
|
+
* green one.
|
|
31
|
+
*
|
|
32
|
+
* All styling lives in `@waveso/docs/styles.css` under `.wave-docs-callout`,
|
|
33
|
+
* so consumers can restyle it without forking the component.
|
|
34
|
+
*/
|
|
35
|
+
declare function Callout({ type, title, className, children }: CalloutProps): ReactNode;
|
|
36
|
+
//#endregion
|
|
37
|
+
export { CALLOUT_TYPES, Callout, CalloutProps, CalloutType };
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
//#region src/react/callout.tsx
|
|
3
|
+
/**
|
|
4
|
+
* The five alert kinds GitHub understands, which is also what
|
|
5
|
+
* `rehype-github-alerts` produces from `> [!NOTE]` and friends.
|
|
6
|
+
*/
|
|
7
|
+
const CALLOUT_TYPES = [
|
|
8
|
+
"note",
|
|
9
|
+
"tip",
|
|
10
|
+
"important",
|
|
11
|
+
"warning",
|
|
12
|
+
"caution"
|
|
13
|
+
];
|
|
14
|
+
const CALLOUT_LABELS = {
|
|
15
|
+
note: "Note",
|
|
16
|
+
tip: "Tip",
|
|
17
|
+
important: "Important",
|
|
18
|
+
warning: "Warning",
|
|
19
|
+
caution: "Caution"
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Lucide-derived glyph paths, drawn at 24×24 with a 2px stroke.
|
|
23
|
+
*
|
|
24
|
+
* Inlined rather than imported so the package takes no icon dependency and
|
|
25
|
+
* ships no icon bytes for callouts a given page does not use.
|
|
26
|
+
*/
|
|
27
|
+
const CALLOUT_ICON_PATHS = {
|
|
28
|
+
note: ["M12 16v-4", "M12 8h.01"],
|
|
29
|
+
tip: [
|
|
30
|
+
"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5a6 6 0 0 0-12 0c0 1.3.5 2.6 1.5 3.5.8.8 1.3 1.5 1.5 2.5",
|
|
31
|
+
"M9 18h6",
|
|
32
|
+
"M10 22h4"
|
|
33
|
+
],
|
|
34
|
+
important: [
|
|
35
|
+
"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z",
|
|
36
|
+
"M12 7v4",
|
|
37
|
+
"M12 15h.01"
|
|
38
|
+
],
|
|
39
|
+
warning: [
|
|
40
|
+
"m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3",
|
|
41
|
+
"M12 9v4",
|
|
42
|
+
"M12 17h.01"
|
|
43
|
+
],
|
|
44
|
+
caution: [
|
|
45
|
+
"M15.31 2a2 2 0 0 1 1.42.59l4.68 4.68A2 2 0 0 1 22 8.69v6.62a2 2 0 0 1-.59 1.42l-4.68 4.68a2 2 0 0 1-1.42.59H8.69a2 2 0 0 1-1.42-.59l-4.68-4.68A2 2 0 0 1 2 15.31V8.69a2 2 0 0 1 .59-1.42l4.68-4.68A2 2 0 0 1 8.69 2z",
|
|
46
|
+
"M12 8v4",
|
|
47
|
+
"M12 16h.01"
|
|
48
|
+
]
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* `note` is the only glyph whose enclosing shape is not already one of its
|
|
52
|
+
* paths, so it gets a circle drawn before the strokes.
|
|
53
|
+
*/
|
|
54
|
+
const CALLOUT_ICON_CIRCLE = { note: true };
|
|
55
|
+
function normalizeCalloutType(value) {
|
|
56
|
+
const candidate = value?.toLowerCase();
|
|
57
|
+
return CALLOUT_TYPES.find((type) => type === candidate) ?? "note";
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A note/tip/important/warning/caution block.
|
|
61
|
+
*
|
|
62
|
+
* Rendered as an `<aside role="note">`: `aside` is the honest element, and the
|
|
63
|
+
* explicit role keeps a callout in the middle of an article from showing up in
|
|
64
|
+
* every screen reader's landmark list. The type is carried on `aria-label`
|
|
65
|
+
* rather than left to the coloured border, which conveys nothing to a screen
|
|
66
|
+
* reader and nothing to the 8% of men who cannot separate the red one from the
|
|
67
|
+
* green one.
|
|
68
|
+
*
|
|
69
|
+
* All styling lives in `@waveso/docs/styles.css` under `.wave-docs-callout`,
|
|
70
|
+
* so consumers can restyle it without forking the component.
|
|
71
|
+
*/
|
|
72
|
+
function Callout({ type, title, className, children }) {
|
|
73
|
+
const kind = normalizeCalloutType(type);
|
|
74
|
+
const label = title?.trim() || CALLOUT_LABELS[kind];
|
|
75
|
+
return /* @__PURE__ */ jsxs("aside", {
|
|
76
|
+
role: "note",
|
|
77
|
+
"aria-label": label,
|
|
78
|
+
className: [
|
|
79
|
+
"wave-docs-callout",
|
|
80
|
+
`wave-docs-callout--${kind}`,
|
|
81
|
+
className
|
|
82
|
+
].filter(Boolean).join(" "),
|
|
83
|
+
children: [/* @__PURE__ */ jsxs("p", {
|
|
84
|
+
className: "wave-docs-callout__label",
|
|
85
|
+
children: [/* @__PURE__ */ jsx(CalloutIcon, { type: kind }), label]
|
|
86
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
87
|
+
className: "wave-docs-callout__body",
|
|
88
|
+
children
|
|
89
|
+
})]
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function CalloutIcon({ type }) {
|
|
93
|
+
return /* @__PURE__ */ jsxs("svg", {
|
|
94
|
+
className: "wave-docs-callout__icon",
|
|
95
|
+
viewBox: "0 0 24 24",
|
|
96
|
+
width: "16",
|
|
97
|
+
height: "16",
|
|
98
|
+
fill: "none",
|
|
99
|
+
stroke: "currentColor",
|
|
100
|
+
strokeWidth: "2",
|
|
101
|
+
strokeLinecap: "round",
|
|
102
|
+
strokeLinejoin: "round",
|
|
103
|
+
"aria-hidden": "true",
|
|
104
|
+
focusable: "false",
|
|
105
|
+
children: [CALLOUT_ICON_CIRCLE[type] ? /* @__PURE__ */ jsx("circle", {
|
|
106
|
+
cx: "12",
|
|
107
|
+
cy: "12",
|
|
108
|
+
r: "10"
|
|
109
|
+
}) : null, CALLOUT_ICON_PATHS[type].map((d) => /* @__PURE__ */ jsx("path", { d }, d))]
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
//#endregion
|
|
113
|
+
export { CALLOUT_TYPES, Callout };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { MarkdownComponents } from "./markdown-components.js";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
import { Root } from "hast";
|
|
4
|
+
//#region src/react/doc-content.d.ts
|
|
5
|
+
interface DocContentProps {
|
|
6
|
+
/**
|
|
7
|
+
* The tree from `@waveso/docs/render`. Plain serialisable JSON, so it
|
|
8
|
+
* crosses the RSC boundary and survives any build-time artifact intact.
|
|
9
|
+
*/
|
|
10
|
+
hast: Root;
|
|
11
|
+
/** Overrides, merged over {@link defaultMarkdownComponents}. */
|
|
12
|
+
components?: MarkdownComponents | undefined;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Render a hast tree as React elements.
|
|
16
|
+
*
|
|
17
|
+
* Not a client component, and it must stay that way: the markdown parser and
|
|
18
|
+
* Shiki ran in Node at build time, and this component only walks the resulting
|
|
19
|
+
* tree. Nothing here pulls unified, remark or a highlighter into the browser.
|
|
20
|
+
*
|
|
21
|
+
* `passNode` is left off (the default). `react-markdown` hardcodes it *on* with
|
|
22
|
+
* no opt-out, so any mapped component that spreads its props renders
|
|
23
|
+
* `node="[object Object]"` into production HTML — with no type error to warn
|
|
24
|
+
* you, because `node` is a legal prop on the component and an unknown attribute
|
|
25
|
+
* on the element.
|
|
26
|
+
*/
|
|
27
|
+
declare function DocContent({ hast, components }: DocContentProps): ReactNode;
|
|
28
|
+
//#endregion
|
|
29
|
+
export { DocContent, DocContentProps };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defaultMarkdownComponents } from "./markdown-components.js";
|
|
2
|
+
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
|
|
3
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/react/doc-content.tsx
|
|
5
|
+
/**
|
|
6
|
+
* Render a hast tree as React elements.
|
|
7
|
+
*
|
|
8
|
+
* Not a client component, and it must stay that way: the markdown parser and
|
|
9
|
+
* Shiki ran in Node at build time, and this component only walks the resulting
|
|
10
|
+
* tree. Nothing here pulls unified, remark or a highlighter into the browser.
|
|
11
|
+
*
|
|
12
|
+
* `passNode` is left off (the default). `react-markdown` hardcodes it *on* with
|
|
13
|
+
* no opt-out, so any mapped component that spreads its props renders
|
|
14
|
+
* `node="[object Object]"` into production HTML — with no type error to warn
|
|
15
|
+
* you, because `node` is a legal prop on the component and an unknown attribute
|
|
16
|
+
* on the element.
|
|
17
|
+
*/
|
|
18
|
+
function DocContent({ hast, components }) {
|
|
19
|
+
return toJsxRuntime(hast, {
|
|
20
|
+
Fragment,
|
|
21
|
+
jsx,
|
|
22
|
+
jsxs,
|
|
23
|
+
components: {
|
|
24
|
+
...defaultMarkdownComponents,
|
|
25
|
+
...components
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { DocContent };
|