@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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import rehypeSlug from "rehype-slug";
|
|
2
|
+
import { unified } from "unified";
|
|
3
|
+
import { visit } from "unist-util-visit";
|
|
4
|
+
//#region src/plugins/rehype-fallback-heading-ids.ts
|
|
5
|
+
const HEADING = /^h[1-6]$/;
|
|
6
|
+
/** `-1`, `-2` … — what `github-slugger` returns for a repeated empty slug. */
|
|
7
|
+
const EMPTY_SLUG_COLLISION = /^-\d+$/;
|
|
8
|
+
/**
|
|
9
|
+
* `rehype-slug` itself, run over a throwaway tree, is how we learn what it will
|
|
10
|
+
* assign. Reimplementing `github-slugger`'s removal table would put a second,
|
|
11
|
+
* drifting opinion about slugs in the pipeline — the exact failure
|
|
12
|
+
* `rehypeCaptureToc` reads ids off the tree to avoid.
|
|
13
|
+
*/
|
|
14
|
+
const slugProbe = unified().use(rehypeSlug).freeze();
|
|
15
|
+
function readId(node) {
|
|
16
|
+
const id = node.properties.id;
|
|
17
|
+
return typeof id === "string" ? id : "";
|
|
18
|
+
}
|
|
19
|
+
const rehypeFallbackHeadingIds = () => {
|
|
20
|
+
return (tree) => {
|
|
21
|
+
const headings = [];
|
|
22
|
+
visit(tree, "element", (node) => {
|
|
23
|
+
if (HEADING.test(node.tagName)) headings.push(node);
|
|
24
|
+
});
|
|
25
|
+
if (headings.length === 0) return;
|
|
26
|
+
const probe = {
|
|
27
|
+
type: "root",
|
|
28
|
+
children: headings.map((heading) => ({
|
|
29
|
+
...heading,
|
|
30
|
+
properties: { ...heading.properties }
|
|
31
|
+
}))
|
|
32
|
+
};
|
|
33
|
+
slugProbe.runSync(probe);
|
|
34
|
+
const willBe = probe.children.map((child) => child.type === "element" ? readId(child) : "");
|
|
35
|
+
const taken = new Set(willBe);
|
|
36
|
+
let counter = 0;
|
|
37
|
+
headings.forEach((heading, index) => {
|
|
38
|
+
const id = willBe[index] ?? "";
|
|
39
|
+
if (id !== "" && !EMPTY_SLUG_COLLISION.test(id)) return;
|
|
40
|
+
let fallback;
|
|
41
|
+
do {
|
|
42
|
+
counter += 1;
|
|
43
|
+
fallback = `section-${counter}`;
|
|
44
|
+
} while (taken.has(fallback));
|
|
45
|
+
heading.properties.id = fallback;
|
|
46
|
+
taken.add(fallback);
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { rehypeFallbackHeadingIds };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Plugin } from "unified";
|
|
2
|
+
import { Root } from "hast";
|
|
3
|
+
//#region src/plugins/rehype-flatten-roots.d.ts
|
|
4
|
+
/** rehype plugin. Must run after every plugin that can splice in a subtree. */
|
|
5
|
+
declare const rehypeFlattenRoots: Plugin<[], Root>;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { rehypeFlattenRoots };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
//#region src/plugins/rehype-flatten-roots.ts
|
|
2
|
+
/**
|
|
3
|
+
* Takes the open {@link AnyNode} on purpose: `RootContent` has no `root`
|
|
4
|
+
* member, so `child.type === 'root'` on a typed child is a compile error
|
|
5
|
+
* ("no overlap"). The declared type is the thing being repaired here.
|
|
6
|
+
*/
|
|
7
|
+
function isNestedRoot(node) {
|
|
8
|
+
return node.type === "root";
|
|
9
|
+
}
|
|
10
|
+
/** `RootContent` minus `doctype`, which is the only member an element rejects. */
|
|
11
|
+
function isElementContent(node) {
|
|
12
|
+
return node.type !== "doctype";
|
|
13
|
+
}
|
|
14
|
+
function flatten(parent) {
|
|
15
|
+
const children = [];
|
|
16
|
+
let nested = false;
|
|
17
|
+
for (const child of parent.children) {
|
|
18
|
+
const node = child;
|
|
19
|
+
if (isNestedRoot(node)) {
|
|
20
|
+
nested = true;
|
|
21
|
+
children.push(...node.children);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
children.push(child);
|
|
25
|
+
}
|
|
26
|
+
if (nested) {
|
|
27
|
+
if (parent.type === "root") parent.children = children;
|
|
28
|
+
else parent.children = children.filter(isElementContent);
|
|
29
|
+
}
|
|
30
|
+
for (const child of parent.children) if (child.type === "element") flatten(child);
|
|
31
|
+
}
|
|
32
|
+
/** rehype plugin. Must run after every plugin that can splice in a subtree. */
|
|
33
|
+
const rehypeFlattenRoots = () => {
|
|
34
|
+
return (tree) => {
|
|
35
|
+
flatten(tree);
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
//#endregion
|
|
39
|
+
export { rehypeFlattenRoots };
|
|
@@ -17,6 +17,15 @@ interface DocLinkRef {
|
|
|
17
17
|
href: string | undefined;
|
|
18
18
|
/** 1-based line in the source markdown, when the parser recorded one. */
|
|
19
19
|
line?: number;
|
|
20
|
+
/**
|
|
21
|
+
* The target is a file served beside the docs (`./schema.json`), not a page.
|
|
22
|
+
*
|
|
23
|
+
* It is still folded and still recorded, because a `../` chain that climbs
|
|
24
|
+
* out of the content root is an authoring error whatever it points at — but
|
|
25
|
+
* it must not be checked against the set of published ROUTES, which it will
|
|
26
|
+
* never be a member of.
|
|
27
|
+
*/
|
|
28
|
+
asset?: true;
|
|
20
29
|
}
|
|
21
30
|
declare module 'vfile' {
|
|
22
31
|
interface DataMap {
|
|
@@ -50,7 +59,9 @@ declare function foldSegments(from: readonly string[], path: string): string[] |
|
|
|
50
59
|
/**
|
|
51
60
|
* The built-in {@link LinkResolver}: markdown file path in, route out.
|
|
52
61
|
*
|
|
53
|
-
* Exported for reuse by hosts that want to wrap rather than replace it.
|
|
62
|
+
* Exported for reuse by hosts that want to wrap rather than replace it. Throws
|
|
63
|
+
* a {@link URIError} on a malformed percent-escape; every other failure is
|
|
64
|
+
* reported as `undefined`.
|
|
54
65
|
*/
|
|
55
66
|
declare function resolveMarkdownLink(href: string, fromDir: readonly string[], basePath: string): string | undefined;
|
|
56
67
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { docsError } from "../docs-error.js";
|
|
1
2
|
import { visit } from "unist-util-visit";
|
|
2
3
|
//#region src/plugins/remark-doc-links.ts
|
|
3
4
|
/** `scheme:` — matches `https:`, `mailto:`, `tel:`, `data:`. */
|
|
@@ -20,6 +21,24 @@ function isRelativeLink(href) {
|
|
|
20
21
|
return href !== "" && !href.startsWith("#") && !href.startsWith("?") && !href.startsWith("/") && !HAS_SCHEME.test(href);
|
|
21
22
|
}
|
|
22
23
|
/**
|
|
24
|
+
* Is this already-absolute href one of OUR routes?
|
|
25
|
+
*
|
|
26
|
+
* `/docs/api/auht` needs no rewriting and used to need no thought either — so
|
|
27
|
+
* it was never recorded, never asserted, and shipped a 404 with a green build.
|
|
28
|
+
* A typo in a hand-written absolute link is exactly as likely as one in a
|
|
29
|
+
* relative link; only the rewriting differs.
|
|
30
|
+
*
|
|
31
|
+
* Requires a non-empty base path. Docs mounted at the site root cannot be told
|
|
32
|
+
* apart from the rest of the site, and asserting `/login` against the set of
|
|
33
|
+
* documentation routes would fail builds over links that are perfectly good.
|
|
34
|
+
*/
|
|
35
|
+
function isInternalAbsoluteLink(href, basePath) {
|
|
36
|
+
const base = basePath.replace(/\/+$/, "");
|
|
37
|
+
if (base === "" || href.startsWith("//")) return false;
|
|
38
|
+
const path = HREF_PARTS.exec(href)?.[1] ?? "";
|
|
39
|
+
return path === base || path.startsWith(`${base}/`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
23
42
|
* Fold `.` and `..` against a starting directory.
|
|
24
43
|
*
|
|
25
44
|
* Hand-rolled rather than `path.resolve` because these are URL paths, not
|
|
@@ -46,17 +65,58 @@ function foldSegments(from, path) {
|
|
|
46
65
|
}
|
|
47
66
|
return out;
|
|
48
67
|
}
|
|
49
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Join route segments onto the base path, e.g. `('/docs', ['api'])`.
|
|
70
|
+
*
|
|
71
|
+
* ⚠️ ENCODES EACH SEGMENT, which is the half of the percent-encoding contract
|
|
72
|
+
* that lives here: `source.ts` builds every published `href` as
|
|
73
|
+
* `basePath + '/' + segments.map(encodeURIComponent).join('/')` and keeps
|
|
74
|
+
* `segments` raw, so a route computed from a link has to be spelled the same
|
|
75
|
+
* way or `assertLinks` compares an encoded route against a decoded one and
|
|
76
|
+
* fails the build on a page that exists.
|
|
77
|
+
*/
|
|
50
78
|
function toRoute(basePath, segments) {
|
|
51
79
|
const base = basePath.replace(/\/+$/, "");
|
|
52
|
-
const path = segments.join("/");
|
|
80
|
+
const path = segments.map((segment) => encodeURIComponent(segment)).join("/");
|
|
53
81
|
if (path === "") return base === "" ? "/" : base;
|
|
54
82
|
return `${base}/${path}`;
|
|
55
83
|
}
|
|
56
84
|
/**
|
|
85
|
+
* Percent-decode one authored path segment, and normalise it.
|
|
86
|
+
*
|
|
87
|
+
* GitHub's own UI writes `[gs](./getting%20started.md)` when you drag a file
|
|
88
|
+
* with a space in its name into an issue, and that is the form that ends up in
|
|
89
|
+
* a repository's markdown. Without decoding, the segment stays `getting%20started`,
|
|
90
|
+
* matches no file, and hard-fails the build on a link GitHub renders correctly.
|
|
91
|
+
*
|
|
92
|
+
* NFC because macOS hands back decomposed filenames and `source.ts` normalises
|
|
93
|
+
* at the `readdir` boundary; two spellings of `é` are one page.
|
|
94
|
+
*
|
|
95
|
+
* Throws rather than returning the input on failure: `decodeURIComponent`
|
|
96
|
+
* rejects `100%-faster`, and a silent pass-through would turn a malformed link
|
|
97
|
+
* into a mystery 404 instead of a message the author can act on.
|
|
98
|
+
*/
|
|
99
|
+
function decodeSegment(segment, href) {
|
|
100
|
+
try {
|
|
101
|
+
return decodeURIComponent(segment).normalize("NFC");
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new URIError(`@waveso/docs: link '${href}' is not valid percent-encoding — '${segment}' cannot be decoded. Write '%25' for a literal percent sign, or link to the file by its real name.`, { cause: error });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Decode BEFORE folding, never after: `%2E%2E%2F` is `../` in disguise, and
|
|
108
|
+
* `foldSegments` is the only thing that refuses a chain climbing out of the
|
|
109
|
+
* content root.
|
|
110
|
+
*/
|
|
111
|
+
function decodePath(path, href) {
|
|
112
|
+
return path.split("/").map((segment) => decodeSegment(segment, href)).join("/");
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
57
115
|
* The built-in {@link LinkResolver}: markdown file path in, route out.
|
|
58
116
|
*
|
|
59
|
-
* Exported for reuse by hosts that want to wrap rather than replace it.
|
|
117
|
+
* Exported for reuse by hosts that want to wrap rather than replace it. Throws
|
|
118
|
+
* a {@link URIError} on a malformed percent-escape; every other failure is
|
|
119
|
+
* reported as `undefined`.
|
|
60
120
|
*/
|
|
61
121
|
function resolveMarkdownLink(href, fromDir, basePath) {
|
|
62
122
|
const parts = HREF_PARTS.exec(href);
|
|
@@ -64,7 +124,7 @@ function resolveMarkdownLink(href, fromDir, basePath) {
|
|
|
64
124
|
const query = parts?.[2] ?? "";
|
|
65
125
|
const hash = parts?.[3] ?? "";
|
|
66
126
|
if (path === "") return;
|
|
67
|
-
const segments = foldSegments(fromDir, path);
|
|
127
|
+
const segments = foldSegments(fromDir, decodePath(path, href));
|
|
68
128
|
if (segments === void 0) return;
|
|
69
129
|
const last = segments.at(-1);
|
|
70
130
|
if (last !== void 0) {
|
|
@@ -75,6 +135,30 @@ function resolveMarkdownLink(href, fromDir, basePath) {
|
|
|
75
135
|
return `${toRoute(basePath, segments)}${query}${hash}`;
|
|
76
136
|
}
|
|
77
137
|
/**
|
|
138
|
+
* Respell an already-absolute internal link the way route keys are spelled.
|
|
139
|
+
*
|
|
140
|
+
* An absolute link is not rewritten — it is already a route — but it still has
|
|
141
|
+
* to be *compared* against one, and `source.ts` spells every published href
|
|
142
|
+
* with `encodeURIComponent` per segment. Recording the author's raw text made
|
|
143
|
+
* that comparison spelling-sensitive: `/docs/café` and `/docs/caf%C3%A9` are the
|
|
144
|
+
* same page, and only the second matched, so the human-readable form every
|
|
145
|
+
* editor produces failed the build with "no such page exists" for a page that
|
|
146
|
+
* plainly exists. Decoding and re-encoding puts both on the canonical spelling.
|
|
147
|
+
*
|
|
148
|
+
* `.`/`..` are folded for the same reason: `/docs/api/../guide` is a route a
|
|
149
|
+
* browser resolves happily and `knownRoutes` has never heard of.
|
|
150
|
+
*/
|
|
151
|
+
function normalizeInternalRoute(href, basePath) {
|
|
152
|
+
const parts = HREF_PARTS.exec(href);
|
|
153
|
+
const path = parts?.[1] ?? "";
|
|
154
|
+
const query = parts?.[2] ?? "";
|
|
155
|
+
const hash = parts?.[3] ?? "";
|
|
156
|
+
const base = basePath.replace(/\/+$/, "");
|
|
157
|
+
const segments = foldSegments([], decodePath(path.slice(base.length), href));
|
|
158
|
+
if (segments === void 0) return;
|
|
159
|
+
return `${toRoute(basePath, segments)}${query}${hash}`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
78
162
|
* Is this relative href pointing at an asset rather than a page?
|
|
79
163
|
*
|
|
80
164
|
* A link to `./diagram.svg` or `./schema.sql` is a download, not a route, and
|
|
@@ -88,6 +172,26 @@ function isAssetLink(href) {
|
|
|
88
172
|
return FILE_EXTENSION.test(last) && !MARKDOWN_EXTENSION.test(last);
|
|
89
173
|
}
|
|
90
174
|
/**
|
|
175
|
+
* An asset href, folded against the page's directory.
|
|
176
|
+
*
|
|
177
|
+
* ⚠️ ASSETS USED TO BE RETURNED UNTOUCHED, which made `./schema.json` mean two
|
|
178
|
+
* different files: the browser resolves a relative href against the ROUTE, so
|
|
179
|
+
* `guide/index.md` requested `/docs/guide/schema.json` and `guide/setup.md`
|
|
180
|
+
* requested `/docs/guide/setup/schema.json` — from byte-identical markdown that
|
|
181
|
+
* previews correctly in both places. Folding to a route-absolute path is what
|
|
182
|
+
* every link in this file already does, and there is no reason a download is
|
|
183
|
+
* the exception.
|
|
184
|
+
*/
|
|
185
|
+
function resolveAssetLink(href, fromDir, basePath) {
|
|
186
|
+
const parts = HREF_PARTS.exec(href);
|
|
187
|
+
const path = parts?.[1] ?? "";
|
|
188
|
+
const query = parts?.[2] ?? "";
|
|
189
|
+
const hash = parts?.[3] ?? "";
|
|
190
|
+
const segments = foldSegments(fromDir, decodePath(path, href));
|
|
191
|
+
if (segments === void 0) return;
|
|
192
|
+
return `${toRoute(basePath, segments)}${query}${hash}`;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
91
195
|
* remark plugin. Requires `file.data.docLinkContext` to be set; without it the
|
|
92
196
|
* containing document is unknown and every relative link would resolve against
|
|
93
197
|
* the content root, which is worse than leaving them alone.
|
|
@@ -96,26 +200,49 @@ const remarkDocLinks = (options) => {
|
|
|
96
200
|
const { basePath, resolve } = options;
|
|
97
201
|
return (tree, file) => {
|
|
98
202
|
const context = file.data.docLinkContext;
|
|
99
|
-
if (context === void 0) throw
|
|
203
|
+
if (context === void 0) throw docsError("internal", `@waveso/docs: remarkDocLinks ran without file.data.docLinkContext${file.path === void 0 ? "" : ` (file: ${file.path})`}. Set it before running the processor.`);
|
|
100
204
|
const refs = file.data.docLinks ?? [];
|
|
101
205
|
file.data.docLinks = refs;
|
|
102
|
-
|
|
103
|
-
|
|
206
|
+
const imageIdentifiers = /* @__PURE__ */ new Set();
|
|
207
|
+
const targets = [];
|
|
208
|
+
visit(tree, (node) => {
|
|
209
|
+
if (node.type === "imageReference") imageIdentifiers.add(node.identifier);
|
|
210
|
+
else if (node.type === "link" || node.type === "definition") targets.push(node);
|
|
211
|
+
});
|
|
212
|
+
for (const node of targets) {
|
|
104
213
|
const raw = node.url;
|
|
105
|
-
if (
|
|
106
|
-
if (resolve === void 0 && isAssetLink(raw)) return;
|
|
107
|
-
const href = resolve ? resolve(raw, context) : resolveMarkdownLink(raw, context.dirSegments, basePath);
|
|
214
|
+
if (node.type === "definition" && imageIdentifiers.has(node.identifier)) continue;
|
|
108
215
|
const line = node.position?.start.line;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
216
|
+
const record = (href, asset) => {
|
|
217
|
+
const ref = {
|
|
218
|
+
raw,
|
|
219
|
+
href
|
|
220
|
+
};
|
|
221
|
+
if (line !== void 0) ref.line = line;
|
|
222
|
+
if (asset !== void 0) ref.asset = asset;
|
|
223
|
+
refs.push(ref);
|
|
224
|
+
if (href !== void 0) node.url = href;
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
if (!isRelativeLink(raw)) {
|
|
228
|
+
if (isInternalAbsoluteLink(raw, basePath) && !isAssetLink(raw)) record(normalizeInternalRoute(raw, basePath));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (resolve === void 0 && isAssetLink(raw)) {
|
|
232
|
+
record(resolveAssetLink(raw, context.dirSegments, basePath), true);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (resolve) {
|
|
236
|
+
record(resolve(raw, context));
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
record(resolveMarkdownLink(raw, context.dirSegments, basePath));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (!(error instanceof URIError)) throw error;
|
|
242
|
+
const at = line === void 0 ? "" : `:${line}`;
|
|
243
|
+
throw docsError("broken-link", `@waveso/docs: ${context.relativePath}${at} links to '${raw}', whose percent-encoding is malformed. Write %25 for a literal percent sign, or link to the file by its real name.`, { cause: error });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
119
246
|
};
|
|
120
247
|
};
|
|
121
248
|
//#endregion
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
//#region src/react/code-runtime.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Mount the copy runtime. Renders nothing.
|
|
5
|
+
*
|
|
6
|
+
* Every hook of state lives in the DOM rather than in React: the button's
|
|
7
|
+
* copied state is a `data-copied` attribute the stylesheet reads, and the
|
|
8
|
+
* announcement is a live region. React owns none of these nodes, so nothing
|
|
9
|
+
* re-renders and there is no state to get out of step with a page that was
|
|
10
|
+
* server-rendered.
|
|
11
|
+
*/
|
|
12
|
+
declare function DocsCodeRuntime(): ReactNode;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { DocsCodeRuntime };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_READY_ATTRIBUTE } from "../code-frame.js";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
//#region src/react/code-runtime.tsx
|
|
5
|
+
/**
|
|
6
|
+
* The nine hundred bytes that make every copy button on the page work.
|
|
7
|
+
*
|
|
8
|
+
* Private. `DocContent` mounts it, and only when the tree it was handed
|
|
9
|
+
* actually contains a code frame — so a page with no fences ships none of
|
|
10
|
+
* this rather than a component that mounts and finds nothing to do.
|
|
11
|
+
*
|
|
12
|
+
* ## One listener, not one component per block
|
|
13
|
+
*
|
|
14
|
+
* The buttons are plain server-rendered HTML with no React identity at all.
|
|
15
|
+
* This attaches a single delegated `click` listener to `document` and one live
|
|
16
|
+
* region to `<body>`, both behind a module-level ref count: the first instance
|
|
17
|
+
* installs them, later instances increment and return, and the last one out
|
|
18
|
+
* removes them. Two `DocContent`s on one page therefore copy once and announce
|
|
19
|
+
* once — a bug nothing else would catch, because the second announcement is
|
|
20
|
+
* only audible to a screen-reader user.
|
|
21
|
+
*
|
|
22
|
+
* The alternative every comparable package ships — mapping `pre` to a
|
|
23
|
+
* `'use client'` component — puts one client reference and one hydration root
|
|
24
|
+
* in the flight stream per fence, and drags the highlighted subtree across the
|
|
25
|
+
* client boundary as children.
|
|
26
|
+
*/
|
|
27
|
+
/** How long the button shows its copied state. */
|
|
28
|
+
const COPIED_MS = 2e3;
|
|
29
|
+
/**
|
|
30
|
+
* Line classes whose text is not part of what the reader wanted.
|
|
31
|
+
*
|
|
32
|
+
* Empty today, and deliberately an array rather than an inline condition:
|
|
33
|
+
* `@shikijs/transformers` lands at 0.3, and `'remove'` — the class it puts on
|
|
34
|
+
* a deleted diff line — goes here. Copying deleted lines into somebody's
|
|
35
|
+
* editor is the kind of failure that is discovered at run time, in their
|
|
36
|
+
* project, days later.
|
|
37
|
+
*/
|
|
38
|
+
const SKIP_LINE_CLASSES = [];
|
|
39
|
+
/** Marker classes whose lines carry trailing whitespace worth trimming. */
|
|
40
|
+
const TRIMMED_LINE_CLASSES = [];
|
|
41
|
+
let refCount = 0;
|
|
42
|
+
let detach;
|
|
43
|
+
/**
|
|
44
|
+
* Mount the copy runtime. Renders nothing.
|
|
45
|
+
*
|
|
46
|
+
* Every hook of state lives in the DOM rather than in React: the button's
|
|
47
|
+
* copied state is a `data-copied` attribute the stylesheet reads, and the
|
|
48
|
+
* announcement is a live region. React owns none of these nodes, so nothing
|
|
49
|
+
* re-renders and there is no state to get out of step with a page that was
|
|
50
|
+
* server-rendered.
|
|
51
|
+
*/
|
|
52
|
+
function DocsCodeRuntime() {
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
refCount += 1;
|
|
55
|
+
if (refCount === 1) detach = install();
|
|
56
|
+
return () => {
|
|
57
|
+
refCount -= 1;
|
|
58
|
+
if (refCount === 0) {
|
|
59
|
+
detach?.();
|
|
60
|
+
detach = void 0;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}, []);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function install() {
|
|
67
|
+
const status = document.createElement("div");
|
|
68
|
+
status.setAttribute("role", "status");
|
|
69
|
+
status.setAttribute("aria-live", "polite");
|
|
70
|
+
status.className = "wave-docs-code__status";
|
|
71
|
+
document.body.append(status);
|
|
72
|
+
const onClick = (event) => {
|
|
73
|
+
const target = event.target;
|
|
74
|
+
if (!(target instanceof Element)) return;
|
|
75
|
+
const button = target.closest(`[${CODE_COPY_ATTRIBUTE}]`);
|
|
76
|
+
if (!(button instanceof HTMLElement)) return;
|
|
77
|
+
const pre = button.closest(`[${CODE_FRAME_ATTRIBUTE}]`)?.querySelector("pre");
|
|
78
|
+
if (pre === null || pre === void 0) return;
|
|
79
|
+
copy(readCode(pre), button, status);
|
|
80
|
+
};
|
|
81
|
+
document.addEventListener("click", onClick);
|
|
82
|
+
document.documentElement.setAttribute(CODE_READY_ATTRIBUTE, "");
|
|
83
|
+
return () => {
|
|
84
|
+
document.removeEventListener("click", onClick);
|
|
85
|
+
document.documentElement.removeAttribute(CODE_READY_ATTRIBUTE);
|
|
86
|
+
status.remove();
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The text of a code block, as the author wrote it.
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ NOT `pre.textContent`. Shiki emits one `<span class="line">` per line with
|
|
93
|
+
* a literal `"\n"` text node between them, so `textContent` happens to be
|
|
94
|
+
* right *today* — and stops being right the moment a transformer adds a line
|
|
95
|
+
* that should not be copied, or a gutter of line numbers that should not be
|
|
96
|
+
* either. Walking the lines is the same amount of code and survives both.
|
|
97
|
+
*/
|
|
98
|
+
function readCode(pre) {
|
|
99
|
+
const lines = pre.querySelectorAll(".line");
|
|
100
|
+
if (lines.length === 0) return pre.textContent ?? "";
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
const classes = line.className.split(/\s+/);
|
|
104
|
+
if (classes.some((name) => SKIP_LINE_CLASSES.includes(name))) continue;
|
|
105
|
+
const text = line.textContent ?? "";
|
|
106
|
+
out.push(classes.some((name) => TRIMMED_LINE_CLASSES.includes(name)) ? text.replace(/\s+$/, "") : text);
|
|
107
|
+
}
|
|
108
|
+
return out.join("\n");
|
|
109
|
+
}
|
|
110
|
+
async function copy(text, button, status) {
|
|
111
|
+
const copied = await writeClipboard(text) ? "true" : "false";
|
|
112
|
+
button.dataset.copied = copied;
|
|
113
|
+
status.textContent = copied === "true" ? "Copied to the clipboard." : "Copy failed. Select the code and press Control or Command + C.";
|
|
114
|
+
const existing = timers.get(button);
|
|
115
|
+
if (existing !== void 0) window.clearTimeout(existing);
|
|
116
|
+
timers.set(button, window.setTimeout(() => {
|
|
117
|
+
timers.delete(button);
|
|
118
|
+
button.removeAttribute("data-copied");
|
|
119
|
+
}, COPIED_MS));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The pending "clear the indicator" timer per button.
|
|
123
|
+
*
|
|
124
|
+
* A `WeakMap`, so a button removed by a client-side navigation takes its entry
|
|
125
|
+
* with it — this module is a page-lifetime singleton and a `Map` here would
|
|
126
|
+
* hold every code block the reader ever copied from.
|
|
127
|
+
*/
|
|
128
|
+
const timers = /* @__PURE__ */ new WeakMap();
|
|
129
|
+
async function writeClipboard(text) {
|
|
130
|
+
if (window.isSecureContext && navigator.clipboard !== void 0) try {
|
|
131
|
+
await navigator.clipboard.writeText(text);
|
|
132
|
+
return true;
|
|
133
|
+
} catch {}
|
|
134
|
+
return legacyCopy(text);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* `execCommand('copy')`, which is deprecated and still the only thing that
|
|
138
|
+
* works over plain HTTP.
|
|
139
|
+
*
|
|
140
|
+
* When it finally goes, this returns `false` and the reader gets the
|
|
141
|
+
* instruction — which is why that message is written as an instruction rather
|
|
142
|
+
* than as an apology.
|
|
143
|
+
*/
|
|
144
|
+
function legacyCopy(text) {
|
|
145
|
+
const area = document.createElement("textarea");
|
|
146
|
+
area.value = text;
|
|
147
|
+
area.setAttribute("readonly", "");
|
|
148
|
+
area.setAttribute("aria-hidden", "true");
|
|
149
|
+
area.style.cssText = "position:fixed;top:-9999px;opacity:0;";
|
|
150
|
+
document.body.append(area);
|
|
151
|
+
try {
|
|
152
|
+
area.select();
|
|
153
|
+
return document.execCommand("copy");
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
} finally {
|
|
157
|
+
area.remove();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
//#endregion
|
|
161
|
+
export { DocsCodeRuntime };
|
|
@@ -10,20 +10,57 @@ interface DocContentProps {
|
|
|
10
10
|
hast: Root;
|
|
11
11
|
/** Overrides, merged over {@link defaultMarkdownComponents}. */
|
|
12
12
|
components?: MarkdownComponents | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Appended to `wave-docs-prose`, never substituted for it.
|
|
15
|
+
*
|
|
16
|
+
* Substitution is the failure this whole wrapper exists to prevent, so it is
|
|
17
|
+
* not offered: almost every rule in the stylesheet is scoped under
|
|
18
|
+
* `.wave-docs-prose`, and dropping it leaves a page whose code blocks still
|
|
19
|
+
* carry correct syntax colours and nothing else — which reads as a design
|
|
20
|
+
* choice rather than as a mistake.
|
|
21
|
+
*/
|
|
22
|
+
className?: string | undefined;
|
|
13
23
|
}
|
|
14
24
|
/**
|
|
15
|
-
* Render a hast tree as React elements.
|
|
25
|
+
* Render a hast tree as React elements, inside the prose wrapper.
|
|
16
26
|
*
|
|
17
27
|
* Not a client component, and it must stay that way: the markdown parser and
|
|
18
28
|
* Shiki ran in Node at build time, and this component only walks the resulting
|
|
19
29
|
* tree. Nothing here pulls unified, remark or a highlighter into the browser.
|
|
20
30
|
*
|
|
31
|
+
* ## Why the wrapper is here and not on your `<article>`
|
|
32
|
+
*
|
|
33
|
+
* `.wave-docs-prose` is the scope for nearly every rule in `styles.css` —
|
|
34
|
+
* including `.wave-docs-prose .shiki`, which is deliberately scoped so the
|
|
35
|
+
* package never styles a code block it did not render. `createDocsRoute.Page`
|
|
36
|
+
* always put the class on for you, but the documented hand-rolled path made
|
|
37
|
+
* the consumer type it, and forgetting it silently unstyled every code block
|
|
38
|
+
* on the site while leaving the syntax colours intact. One component owning
|
|
39
|
+
* the class removes the way to get that wrong.
|
|
40
|
+
*
|
|
41
|
+
* The rules that care about tree shape are `.wave-docs-prose > * + *` and
|
|
42
|
+
* `.wave-docs-prose > :is(h2…h6)`, and the tree's own children are this
|
|
43
|
+
* element's direct children, so nothing moves.
|
|
44
|
+
*
|
|
45
|
+
* ## The copy runtime is mounted here
|
|
46
|
+
*
|
|
47
|
+
* Because this is the component no consumer can avoid: `createDocsRoute.Page`
|
|
48
|
+
* renders it, and the documented hand-rolled route renders it directly. Wiring
|
|
49
|
+
* the listener from `docs.Layout` instead would ship dead buttons to everyone
|
|
50
|
+
* composing their own shell, and "what about someone not using the layout?"
|
|
51
|
+
* would be a caveat rather than a non-question.
|
|
52
|
+
*
|
|
53
|
+
* It renders only when the tree actually contains a code frame. The server has
|
|
54
|
+
* the tree in hand, the check is one pass, and the result is that a page
|
|
55
|
+
* without fences ships zero extra bytes rather than a runtime with nothing to
|
|
56
|
+
* do.
|
|
57
|
+
*
|
|
21
58
|
* `passNode` is left off (the default). `react-markdown` hardcodes it *on* with
|
|
22
59
|
* no opt-out, so any mapped component that spreads its props renders
|
|
23
60
|
* `node="[object Object]"` into production HTML — with no type error to warn
|
|
24
61
|
* you, because `node` is a legal prop on the component and an unknown attribute
|
|
25
62
|
* on the element.
|
|
26
63
|
*/
|
|
27
|
-
declare function DocContent({ hast, components }: DocContentProps): ReactNode;
|
|
64
|
+
declare function DocContent({ hast, components, className }: DocContentProps): ReactNode;
|
|
28
65
|
//#endregion
|
|
29
66
|
export { DocContent, DocContentProps };
|
|
@@ -1,29 +1,61 @@
|
|
|
1
|
+
import { hasCodeFrame } from "../code-frame.js";
|
|
2
|
+
import { DocsCodeRuntime } from "./code-runtime.js";
|
|
1
3
|
import { defaultMarkdownComponents } from "./markdown-components.js";
|
|
2
4
|
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
|
|
3
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
6
|
//#region src/react/doc-content.tsx
|
|
5
7
|
/**
|
|
6
|
-
* Render a hast tree as React elements.
|
|
8
|
+
* Render a hast tree as React elements, inside the prose wrapper.
|
|
7
9
|
*
|
|
8
10
|
* Not a client component, and it must stay that way: the markdown parser and
|
|
9
11
|
* Shiki ran in Node at build time, and this component only walks the resulting
|
|
10
12
|
* tree. Nothing here pulls unified, remark or a highlighter into the browser.
|
|
11
13
|
*
|
|
14
|
+
* ## Why the wrapper is here and not on your `<article>`
|
|
15
|
+
*
|
|
16
|
+
* `.wave-docs-prose` is the scope for nearly every rule in `styles.css` —
|
|
17
|
+
* including `.wave-docs-prose .shiki`, which is deliberately scoped so the
|
|
18
|
+
* package never styles a code block it did not render. `createDocsRoute.Page`
|
|
19
|
+
* always put the class on for you, but the documented hand-rolled path made
|
|
20
|
+
* the consumer type it, and forgetting it silently unstyled every code block
|
|
21
|
+
* on the site while leaving the syntax colours intact. One component owning
|
|
22
|
+
* the class removes the way to get that wrong.
|
|
23
|
+
*
|
|
24
|
+
* The rules that care about tree shape are `.wave-docs-prose > * + *` and
|
|
25
|
+
* `.wave-docs-prose > :is(h2…h6)`, and the tree's own children are this
|
|
26
|
+
* element's direct children, so nothing moves.
|
|
27
|
+
*
|
|
28
|
+
* ## The copy runtime is mounted here
|
|
29
|
+
*
|
|
30
|
+
* Because this is the component no consumer can avoid: `createDocsRoute.Page`
|
|
31
|
+
* renders it, and the documented hand-rolled route renders it directly. Wiring
|
|
32
|
+
* the listener from `docs.Layout` instead would ship dead buttons to everyone
|
|
33
|
+
* composing their own shell, and "what about someone not using the layout?"
|
|
34
|
+
* would be a caveat rather than a non-question.
|
|
35
|
+
*
|
|
36
|
+
* It renders only when the tree actually contains a code frame. The server has
|
|
37
|
+
* the tree in hand, the check is one pass, and the result is that a page
|
|
38
|
+
* without fences ships zero extra bytes rather than a runtime with nothing to
|
|
39
|
+
* do.
|
|
40
|
+
*
|
|
12
41
|
* `passNode` is left off (the default). `react-markdown` hardcodes it *on* with
|
|
13
42
|
* no opt-out, so any mapped component that spreads its props renders
|
|
14
43
|
* `node="[object Object]"` into production HTML — with no type error to warn
|
|
15
44
|
* you, because `node` is a legal prop on the component and an unknown attribute
|
|
16
45
|
* on the element.
|
|
17
46
|
*/
|
|
18
|
-
function DocContent({ hast, components }) {
|
|
19
|
-
return
|
|
20
|
-
|
|
21
|
-
jsx,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
47
|
+
function DocContent({ hast, components, className }) {
|
|
48
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
49
|
+
className: className === void 0 || className === "" ? "wave-docs-prose" : `wave-docs-prose ${className}`,
|
|
50
|
+
children: [hasCodeFrame(hast) ? /* @__PURE__ */ jsx(DocsCodeRuntime, {}) : null, toJsxRuntime(hast, {
|
|
51
|
+
Fragment,
|
|
52
|
+
jsx,
|
|
53
|
+
jsxs,
|
|
54
|
+
components: {
|
|
55
|
+
...defaultMarkdownComponents,
|
|
56
|
+
...components
|
|
57
|
+
}
|
|
58
|
+
})]
|
|
27
59
|
});
|
|
28
60
|
}
|
|
29
61
|
//#endregion
|