@waveso/docs 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/README.md +111 -22
- package/dist/docs-error.d.ts +74 -0
- package/dist/docs-error.js +40 -0
- package/dist/frontmatter.d.ts +39 -7
- package/dist/frontmatter.js +51 -24
- package/dist/highlighter.d.ts +2 -2
- package/dist/highlighter.js +3 -2
- package/dist/map-pooled.d.ts +26 -0
- package/dist/map-pooled.js +45 -0
- package/dist/meta.d.ts +7 -3
- package/dist/meta.js +61 -15
- package/dist/next.d.ts +41 -19
- package/dist/next.js +117 -21
- package/dist/plugins/rehype-capture-toc.js +26 -15
- package/dist/plugins/rehype-code-language.d.ts +24 -0
- package/dist/plugins/rehype-code-language.js +48 -0
- package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
- package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
- package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
- package/dist/plugins/rehype-flatten-roots.js +39 -0
- package/dist/plugins/remark-doc-links.d.ts +12 -1
- package/dist/plugins/remark-doc-links.js +147 -20
- package/dist/react/markdown-components.js +71 -6
- package/dist/react/search-dialog.d.ts +23 -7
- package/dist/react/search-dialog.js +46 -29
- package/dist/react/toc.js +28 -5
- package/dist/react/youtube.js +6 -4
- package/dist/render.d.ts +43 -9
- package/dist/render.js +112 -50
- package/dist/search-index.d.ts +32 -14
- package/dist/search-index.js +45 -51
- package/dist/search-options.d.ts +32 -1
- package/dist/search-options.js +66 -3
- package/dist/section-boundary.d.ts +17 -0
- package/dist/section-boundary.js +43 -0
- package/dist/source.d.ts +13 -1
- package/dist/source.js +152 -56
- package/dist/styles.css +236 -90
- package/dist/types.d.ts +41 -27
- package/package.json +13 -12
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isFootnotes, isTransparentContainer } from "../section-boundary.js";
|
|
2
2
|
import { toString } from "hast-util-to-string";
|
|
3
3
|
//#region src/plugins/rehype-capture-toc.ts
|
|
4
4
|
/** `h2`–`h6`. `h1` is the page title and never appears in a TOC. */
|
|
@@ -16,17 +16,6 @@ function isPermalink(child) {
|
|
|
16
16
|
const ariaHidden = child.properties.ariaHidden;
|
|
17
17
|
return ariaHidden === true || ariaHidden === "true" || Array.isArray(className) && className.includes("heading-anchor");
|
|
18
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
19
|
function headingText(node) {
|
|
31
20
|
return node.children.filter((child) => !isPermalink(child)).map((child) => toString(child)).join("").trim();
|
|
32
21
|
}
|
|
@@ -40,8 +29,7 @@ const rehypeCaptureToc = () => {
|
|
|
40
29
|
const toc = [];
|
|
41
30
|
/** Open ancestors, outermost first. */
|
|
42
31
|
const stack = [];
|
|
43
|
-
|
|
44
|
-
if (isFootnotes(node)) return SKIP;
|
|
32
|
+
const capture = (node) => {
|
|
45
33
|
const level = HEADING.exec(node.tagName)?.[1];
|
|
46
34
|
if (level === void 0) return;
|
|
47
35
|
const id = node.properties.id;
|
|
@@ -61,7 +49,30 @@ const rehypeCaptureToc = () => {
|
|
|
61
49
|
if (parent === void 0) toc.push(entry);
|
|
62
50
|
else parent.children.push(entry);
|
|
63
51
|
stack.push(entry);
|
|
64
|
-
}
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Block-level nodes in document order, stepping into wrappers a section may
|
|
55
|
+
* legitimately sit inside — and no further.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ THE BOUND IS THE POINT, AND IT IS SHARED WITH THE SEARCH INDEX. A
|
|
58
|
+
* whole-tree walk gave a TOC entry to any heading with an id however deeply
|
|
59
|
+
* wrapped, while `extractSearchRecords` opens a section only inside an
|
|
60
|
+
* {@link isTransparentContainer} — so a `## ` written inside a list item or
|
|
61
|
+
* a table cell got a TOC entry with no searchable section behind it, and its
|
|
62
|
+
* prose was folded into the section above under the wrong breadcrumb. One
|
|
63
|
+
* exported predicate is what stops the two drifting apart again.
|
|
64
|
+
*/
|
|
65
|
+
const walk = (nodes) => {
|
|
66
|
+
for (const node of nodes) {
|
|
67
|
+
if (node.type !== "element" || isFootnotes(node)) continue;
|
|
68
|
+
if (isTransparentContainer(node)) {
|
|
69
|
+
walk(node.children);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
capture(node);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
walk(tree.children);
|
|
65
76
|
file.data.toc = toc;
|
|
66
77
|
};
|
|
67
78
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Plugin } from "unified";
|
|
2
|
+
import { Root } from "hast";
|
|
3
|
+
//#region src/plugins/rehype-code-language.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The tag an excluded `<pre>` wears while Shiki walks the tree.
|
|
6
|
+
*
|
|
7
|
+
* Exported so a test can prove it never reaches the output: a leaked sentinel
|
|
8
|
+
* is an unstyled block with an invented tag name, which React renders happily
|
|
9
|
+
* and nobody notices.
|
|
10
|
+
*/
|
|
11
|
+
declare const EXCLUDED_PRE_TAG = "wave-docs-excluded-pre";
|
|
12
|
+
interface RehypeCodeLanguageOptions {
|
|
13
|
+
/** Lower-cased fence languages Shiki must leave alone, e.g. `['mermaid']`. */
|
|
14
|
+
exclude?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
declare const rehypeNormalizeCodeLanguage: Plugin<[RehypeCodeLanguageOptions?], Root>;
|
|
17
|
+
/**
|
|
18
|
+
* Undo the disguise. Must run after `rehypeShikiFromHighlighter`, and it is a
|
|
19
|
+
* separate plugin rather than a flag on the first one because unified gives a
|
|
20
|
+
* plugin one position in the pipeline and this needs the other side of Shiki.
|
|
21
|
+
*/
|
|
22
|
+
declare const rehypeRestoreExcludedCode: Plugin<[], Root>;
|
|
23
|
+
//#endregion
|
|
24
|
+
export { EXCLUDED_PRE_TAG, RehypeCodeLanguageOptions, rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CONTINUE, SKIP, visit } from "unist-util-visit";
|
|
2
|
+
//#region src/plugins/rehype-code-language.ts
|
|
3
|
+
/** `language-ts` on a `<code>` — how a fence's language survives to hast. */
|
|
4
|
+
const LANGUAGE_CLASS = /^language-(.+)$/;
|
|
5
|
+
/**
|
|
6
|
+
* The tag an excluded `<pre>` wears while Shiki walks the tree.
|
|
7
|
+
*
|
|
8
|
+
* Exported so a test can prove it never reaches the output: a leaked sentinel
|
|
9
|
+
* is an unstyled block with an invented tag name, which React renders happily
|
|
10
|
+
* and nobody notices.
|
|
11
|
+
*/
|
|
12
|
+
const EXCLUDED_PRE_TAG = "wave-docs-excluded-pre";
|
|
13
|
+
const rehypeNormalizeCodeLanguage = (options = {}) => {
|
|
14
|
+
const excluded = new Set((options.exclude ?? []).map((lang) => lang.toLowerCase()));
|
|
15
|
+
return (tree) => {
|
|
16
|
+
visit(tree, "element", (node) => {
|
|
17
|
+
if (node.tagName !== "pre") return CONTINUE;
|
|
18
|
+
const code = node.children[0];
|
|
19
|
+
if (code === void 0 || code.type !== "element") return CONTINUE;
|
|
20
|
+
const classNames = code.properties.className;
|
|
21
|
+
if (!Array.isArray(classNames)) return SKIP;
|
|
22
|
+
let language;
|
|
23
|
+
code.properties.className = classNames.map((name) => {
|
|
24
|
+
if (typeof name !== "string") return name;
|
|
25
|
+
const found = LANGUAGE_CLASS.exec(name)?.[1];
|
|
26
|
+
if (found === void 0) return name;
|
|
27
|
+
language = found.toLowerCase();
|
|
28
|
+
return `language-${language}`;
|
|
29
|
+
});
|
|
30
|
+
if (language !== void 0 && excluded.has(language)) node.tagName = EXCLUDED_PRE_TAG;
|
|
31
|
+
return SKIP;
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Undo the disguise. Must run after `rehypeShikiFromHighlighter`, and it is a
|
|
37
|
+
* separate plugin rather than a flag on the first one because unified gives a
|
|
38
|
+
* plugin one position in the pipeline and this needs the other side of Shiki.
|
|
39
|
+
*/
|
|
40
|
+
const rehypeRestoreExcludedCode = () => {
|
|
41
|
+
return (tree) => {
|
|
42
|
+
visit(tree, "element", (node) => {
|
|
43
|
+
if (node.tagName === "wave-docs-excluded-pre") node.tagName = "pre";
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
//#endregion
|
|
48
|
+
export { EXCLUDED_PRE_TAG, rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode };
|
|
@@ -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
|
|
@@ -6,6 +6,62 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
const HTTP_SCHEME = /^https?:\/\//i;
|
|
7
7
|
/** Any URL with a scheme, or protocol-relative. */
|
|
8
8
|
const ABSOLUTE_URL = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
9
|
+
/**
|
|
10
|
+
* The schemes a markdown link may carry.
|
|
11
|
+
*
|
|
12
|
+
* GitHub's own allowlist, which is the bar to match: documentation links to
|
|
13
|
+
* `sms:`, `ftp:` and `irc:` are ordinary, and an allowlist of three silently
|
|
14
|
+
* deleted them. The point of the check is to stop `javascript:`, `data:` and
|
|
15
|
+
* `vbscript:` reaching an `href`, not to have an opinion about protocols.
|
|
16
|
+
*
|
|
17
|
+
* A scheme not listed here — `vscode:`, `obsidian:`, `slack:` — is dropped
|
|
18
|
+
* rather than rendered. That is deliberate: an allowlist that grows on request
|
|
19
|
+
* is safe, one that guesses is not. {@link warnDroppedHref} makes it visible.
|
|
20
|
+
*/
|
|
21
|
+
const SAFE_SCHEME = /^(https?|mailto|tel|sms|ftp|ftps|irc|ircs|xmpp|news|nntp|feed|git|matrix):/i;
|
|
22
|
+
/** Hrefs already reported, so a re-render does not repeat the warning. */
|
|
23
|
+
const warnedHrefs = /* @__PURE__ */ new Set();
|
|
24
|
+
/**
|
|
25
|
+
* Say something when a link is dropped.
|
|
26
|
+
*
|
|
27
|
+
* A destination that vanishes with the text left behind is the quietest
|
|
28
|
+
* possible failure — the page looks fine and the link is simply gone. Every
|
|
29
|
+
* other rejection in this package names a file and a fix; this one cannot see
|
|
30
|
+
* the file, so it names the href and stays out of production noise.
|
|
31
|
+
*/
|
|
32
|
+
function warnDroppedHref(href) {
|
|
33
|
+
if (process.env.NODE_ENV === "production" || warnedHrefs.has(href)) return;
|
|
34
|
+
warnedHrefs.add(href);
|
|
35
|
+
console.warn(`@waveso/docs: dropped a link to '${href}' — its URL scheme is not in the allowlist, so the text was kept and the destination removed. Use http, https, mailto, tel, sms, ftp, irc, xmpp or matrix, or render the link yourself with a custom \`a\` component.`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A copy of `href` as a browser will parse it.
|
|
39
|
+
*
|
|
40
|
+
* ASCII control characters and spaces are stripped before parsing, so
|
|
41
|
+
* ` javascript:` and `java<TAB>script:` both navigate where the raw string
|
|
42
|
+
* matches no scheme at all — which is how a scheme check gets walked around.
|
|
43
|
+
*/
|
|
44
|
+
function normaliseUrl(href) {
|
|
45
|
+
return [...href].filter((char) => (char.codePointAt(0) ?? 0) > 32).join("");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Would this href navigate somewhere we are willing to send a reader?
|
|
49
|
+
*
|
|
50
|
+
* Nothing upstream filters it: `remarkDocLinks` skips every href with a scheme
|
|
51
|
+
* (`isRelativeLink` is false for it), so `assertLinks` never sees one either,
|
|
52
|
+
* and `remarkRehype` runs with `allowDangerousHtml` off but passes a link's own
|
|
53
|
+
* url through untouched. Verified against React 19: it neutralises
|
|
54
|
+
* `javascript:` in every obfuscated form, silently — but it lets `vbscript:`
|
|
55
|
+
* and `data:text/html;base64,…` reach the DOM verbatim. So the allowlist is
|
|
56
|
+
* ours to keep.
|
|
57
|
+
*
|
|
58
|
+
* Tested against {@link normaliseUrl}, not the raw string.
|
|
59
|
+
*/
|
|
60
|
+
function isSafeHref(href) {
|
|
61
|
+
const normalised = normaliseUrl(href);
|
|
62
|
+
if (!ABSOLUTE_URL.test(normalised)) return true;
|
|
63
|
+
return normalised.startsWith("//") || SAFE_SCHEME.test(normalised);
|
|
64
|
+
}
|
|
9
65
|
function joinClassNames(...values) {
|
|
10
66
|
const joined = values.filter(Boolean).join(" ");
|
|
11
67
|
return joined === "" ? void 0 : joined;
|
|
@@ -23,6 +79,10 @@ function createAnchor(Link) {
|
|
|
23
79
|
...rest,
|
|
24
80
|
children
|
|
25
81
|
});
|
|
82
|
+
if (!isSafeHref(href)) {
|
|
83
|
+
warnDroppedHref(href);
|
|
84
|
+
return /* @__PURE__ */ jsx("span", { children });
|
|
85
|
+
}
|
|
26
86
|
if (HTTP_SCHEME.test(href) || href.startsWith("//")) return /* @__PURE__ */ jsxs("a", {
|
|
27
87
|
...rest,
|
|
28
88
|
href,
|
|
@@ -46,28 +106,33 @@ function createAnchor(Link) {
|
|
|
46
106
|
};
|
|
47
107
|
}
|
|
48
108
|
function createImage(Image) {
|
|
49
|
-
return function MarkdownImage({ src, alt, width, height, title, className, ...rest }) {
|
|
109
|
+
return function MarkdownImage({ src, alt, width, height, title, className, sizes, loading, ...rest }) {
|
|
50
110
|
const resolvedWidth = toDimension(width);
|
|
51
111
|
const resolvedHeight = toDimension(height);
|
|
112
|
+
const resolvedLoading = loading ?? "lazy";
|
|
113
|
+
const resolvedClassName = joinClassNames("wave-docs-image", className);
|
|
52
114
|
if (Image !== void 0 && typeof src === "string" && resolvedWidth !== void 0 && resolvedHeight !== void 0) return /* @__PURE__ */ jsx(Image, {
|
|
115
|
+
...rest,
|
|
53
116
|
src,
|
|
54
117
|
alt: alt ?? "",
|
|
55
118
|
width: resolvedWidth,
|
|
56
119
|
height: resolvedHeight,
|
|
57
120
|
title,
|
|
58
|
-
className:
|
|
59
|
-
|
|
121
|
+
className: resolvedClassName,
|
|
122
|
+
sizes,
|
|
123
|
+
loading: resolvedLoading
|
|
60
124
|
});
|
|
61
125
|
return /* @__PURE__ */ jsx("img", {
|
|
126
|
+
decoding: "async",
|
|
62
127
|
...rest,
|
|
63
128
|
src,
|
|
64
129
|
alt: alt ?? "",
|
|
65
130
|
width,
|
|
66
131
|
height,
|
|
67
132
|
title,
|
|
68
|
-
className:
|
|
69
|
-
|
|
70
|
-
|
|
133
|
+
className: resolvedClassName,
|
|
134
|
+
sizes,
|
|
135
|
+
loading: resolvedLoading
|
|
71
136
|
});
|
|
72
137
|
};
|
|
73
138
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { SearchRecord } from "../types.js";
|
|
1
2
|
import { DocsLinkComponent } from "./markdown-components.js";
|
|
2
3
|
import { ReactNode } from "react";
|
|
4
|
+
import { Options } from "minisearch";
|
|
3
5
|
//#region src/react/search-dialog.d.ts
|
|
4
6
|
interface SearchDialogProps {
|
|
5
7
|
/**
|
|
@@ -17,17 +19,31 @@ interface SearchDialogProps {
|
|
|
17
19
|
* Optional link component for results, e.g. `next/link`, so hovering a hit
|
|
18
20
|
* prefetches the page. Results fall back to a plain anchor.
|
|
19
21
|
*/
|
|
20
|
-
Link?: DocsLinkComponent;
|
|
22
|
+
Link?: DocsLinkComponent | undefined;
|
|
21
23
|
/** Trigger button label. Defaults to `'Search'`. */
|
|
22
|
-
triggerLabel?: string;
|
|
24
|
+
triggerLabel?: string | undefined;
|
|
23
25
|
/** Input placeholder. Defaults to `'Search documentation'`. */
|
|
24
|
-
placeholder?: string;
|
|
26
|
+
placeholder?: string | undefined;
|
|
25
27
|
/** Accessible name for the dialog. Defaults to `'Search documentation'`. */
|
|
26
|
-
dialogLabel?: string;
|
|
28
|
+
dialogLabel?: string | undefined;
|
|
27
29
|
/** Maximum results rendered. Defaults to 8. */
|
|
28
|
-
maxResults?: number;
|
|
30
|
+
maxResults?: number | undefined;
|
|
29
31
|
/** Input debounce in milliseconds. Defaults to 120. */
|
|
30
|
-
debounceMs?: number;
|
|
32
|
+
debounceMs?: number | undefined;
|
|
33
|
+
/** Extra class names for the trigger button, e.g. a navbar's own layout. */
|
|
34
|
+
className?: string | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Overrides applied through `mergeSearchOptions` when the index is
|
|
37
|
+
* deserialised — the escape hatch for tokenisation, `processTerm` and the
|
|
38
|
+
* query defaults (`fuzzy`, `prefix`, `combineWith`, `boost`) without waiting
|
|
39
|
+
* on a release of this package.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ HAND THE IDENTICAL OVERRIDES TO `buildSearchIndex`. `tokenize` and
|
|
42
|
+
* `processTerm` decide how terms were written into the index; a client that
|
|
43
|
+
* splits differently from the build looks up terms that were never written
|
|
44
|
+
* and finds nothing, silently.
|
|
45
|
+
*/
|
|
46
|
+
searchOptions?: Partial<Options<SearchRecord>> | undefined;
|
|
31
47
|
}
|
|
32
48
|
/**
|
|
33
49
|
* Search trigger plus its dialog.
|
|
@@ -36,6 +52,6 @@ interface SearchDialogProps {
|
|
|
36
52
|
* portalled to `document.body`, so a navbar's stacking context cannot trap
|
|
37
53
|
* it behind the page.
|
|
38
54
|
*/
|
|
39
|
-
declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs }: SearchDialogProps): ReactNode;
|
|
55
|
+
declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs, className, searchOptions }: SearchDialogProps): ReactNode;
|
|
40
56
|
//#endregion
|
|
41
57
|
export { SearchDialog, SearchDialogProps };
|