@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +111 -22
  3. package/dist/docs-error.d.ts +74 -0
  4. package/dist/docs-error.js +40 -0
  5. package/dist/frontmatter.d.ts +39 -7
  6. package/dist/frontmatter.js +51 -24
  7. package/dist/highlighter.d.ts +2 -2
  8. package/dist/highlighter.js +3 -2
  9. package/dist/map-pooled.d.ts +26 -0
  10. package/dist/map-pooled.js +45 -0
  11. package/dist/meta.d.ts +7 -3
  12. package/dist/meta.js +61 -15
  13. package/dist/next.d.ts +41 -19
  14. package/dist/next.js +117 -21
  15. package/dist/plugins/rehype-capture-toc.js +26 -15
  16. package/dist/plugins/rehype-code-language.d.ts +24 -0
  17. package/dist/plugins/rehype-code-language.js +48 -0
  18. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  19. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  20. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  21. package/dist/plugins/rehype-flatten-roots.js +39 -0
  22. package/dist/plugins/remark-doc-links.d.ts +12 -1
  23. package/dist/plugins/remark-doc-links.js +147 -20
  24. package/dist/react/markdown-components.js +71 -6
  25. package/dist/react/search-dialog.d.ts +23 -7
  26. package/dist/react/search-dialog.js +46 -29
  27. package/dist/react/toc.js +28 -5
  28. package/dist/react/youtube.js +6 -4
  29. package/dist/render.d.ts +43 -9
  30. package/dist/render.js +112 -50
  31. package/dist/search-index.d.ts +32 -14
  32. package/dist/search-index.js +45 -51
  33. package/dist/search-options.d.ts +32 -1
  34. package/dist/search-options.js +66 -3
  35. package/dist/section-boundary.d.ts +17 -0
  36. package/dist/section-boundary.js +43 -0
  37. package/dist/source.d.ts +13 -1
  38. package/dist/source.js +152 -56
  39. package/dist/styles.css +236 -90
  40. package/dist/types.d.ts +41 -27
  41. package/package.json +13 -12
@@ -1,4 +1,4 @@
1
- import { SKIP, visit } from "unist-util-visit";
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
- visit(tree, "element", (node) => {
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,6 @@
1
+ import { Plugin } from "unified";
2
+ import { Root } from "hast";
3
+ //#region src/plugins/rehype-fallback-heading-ids.d.ts
4
+ declare const rehypeFallbackHeadingIds: Plugin<[], Root>;
5
+ //#endregion
6
+ export { rehypeFallbackHeadingIds };
@@ -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
- /** Join route segments onto the base path, e.g. `('/docs', ['api'])`. */
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 new Error(`@waveso/docs: remarkDocLinks ran without file.data.docLinkContext${file.path === void 0 ? "" : ` (file: ${file.path})`}. Set it before running the processor.`);
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
- visit(tree, ["link", "definition"], (node) => {
103
- if (node.type !== "link" && node.type !== "definition") return;
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 (!isRelativeLink(raw)) return;
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
- 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
- });
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: joinClassNames("wave-docs-image", className),
59
- loading: "lazy"
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: joinClassNames("wave-docs-image", className),
69
- loading: "lazy",
70
- decoding: "async"
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 };