@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,5 +1,6 @@
1
1
  "use client";
2
- import { SEARCH_INDEX_OPTIONS } from "../search-options.js";
2
+ import { docsError } from "../docs-error.js";
3
+ import { mergeSearchOptions } from "../search-options.js";
3
4
  import { useCallback, useEffect, useId, useRef, useState } from "react";
4
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
6
  import { createPortal } from "react-dom";
@@ -25,7 +26,7 @@ const FOCUSABLE_SELECTOR = [
25
26
  * portalled to `document.body`, so a navbar's stacking context cannot trap
26
27
  * it behind the page.
27
28
  */
28
- function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", maxResults = 8, debounceMs = 120 }) {
29
+ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", maxResults = 8, debounceMs = 120, className, searchOptions }) {
29
30
  const [isOpen, setIsOpen] = useState(false);
30
31
  const [query, setQuery] = useState("");
31
32
  const [hits, setHits] = useState([]);
@@ -37,29 +38,34 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
37
38
  const inputRef = useRef(null);
38
39
  const listRef = useRef(null);
39
40
  const returnFocusRef = useRef(null);
40
- const indexRef = useRef(null);
41
+ const indexCacheRef = useRef(/* @__PURE__ */ new Map());
42
+ const searchOptionsRef = useRef(searchOptions);
41
43
  /** Whether the dialog has ever been open. See the focus effect below. */
42
44
  const hasOpenedRef = useRef(false);
43
45
  const baseId = useId();
44
46
  const listId = `${baseId}-results`;
45
47
  const optionId = (index) => `${baseId}-option-${index}`;
46
- /** Load the index at most once; a failure clears the cache so a retry can. */
48
+ useEffect(() => {
49
+ searchOptionsRef.current = searchOptions;
50
+ }, [searchOptions]);
51
+ /** Load each URL at most once; a failure evicts that key so a retry can. */
47
52
  const ensureIndex = useCallback(() => {
48
- let pending = indexRef.current;
49
- if (pending === null) {
50
- pending = loadIndex(indexUrl).catch((error) => {
51
- indexRef.current = null;
53
+ const cache = indexCacheRef.current;
54
+ let pending = cache.get(indexUrl);
55
+ if (pending === void 0) {
56
+ pending = loadIndex(indexUrl, searchOptionsRef.current).catch((error) => {
57
+ cache.delete(indexUrl);
52
58
  throw error;
53
59
  });
54
- indexRef.current = pending;
60
+ cache.set(indexUrl, pending);
55
61
  }
56
62
  return pending;
57
63
  }, [indexUrl]);
58
64
  const warmIndex = useCallback(() => {
59
- if (indexRef.current !== null) return;
65
+ if (indexCacheRef.current.has(indexUrl)) return;
60
66
  setStatus("loading");
61
67
  ensureIndex().then(() => setStatus("ready"), () => setStatus("error"));
62
- }, [ensureIndex]);
68
+ }, [ensureIndex, indexUrl]);
63
69
  const openDialog = useCallback(() => {
64
70
  returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
65
71
  setIsOpen(true);
@@ -83,6 +89,20 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
83
89
  openDialog,
84
90
  closeDialog
85
91
  ]);
92
+ useEffect(() => {
93
+ if (!isOpen) return;
94
+ function handleKeyDown(event) {
95
+ if (event.key === "Escape") {
96
+ event.preventDefault();
97
+ event.stopPropagation();
98
+ closeDialog();
99
+ return;
100
+ }
101
+ if (event.key === "Tab") trapFocus(dialogRef.current, event);
102
+ }
103
+ document.addEventListener("keydown", handleKeyDown, true);
104
+ return () => document.removeEventListener("keydown", handleKeyDown, true);
105
+ }, [isOpen, closeDialog]);
86
106
  useEffect(() => {
87
107
  const isApple = /mac|iphone|ipad|ipod/i.test(navigator.userAgent);
88
108
  setShortcutHint(isApple ? "⌘K" : "Ctrl K");
@@ -153,17 +173,8 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
153
173
  closeDialog();
154
174
  navigate(hit.href);
155
175
  }, [closeDialog, navigate]);
156
- function handleDialogKeyDown(event) {
157
- if (event.key === "Escape") {
158
- event.preventDefault();
159
- event.stopPropagation();
160
- closeDialog();
161
- return;
162
- }
163
- if (event.key === "Tab") {
164
- trapFocus(dialogRef.current, event);
165
- return;
166
- }
176
+ function handleInputKeyDown(event) {
177
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
167
178
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
168
179
  if (hits.length === 0) return;
169
180
  event.preventDefault();
@@ -182,7 +193,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
182
193
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("button", {
183
194
  type: "button",
184
195
  ref: triggerRef,
185
- className: "wave-docs-search-trigger",
196
+ className: ["wave-docs-search-trigger", className].filter(Boolean).join(" "),
186
197
  "aria-label": triggerLabel,
187
198
  "aria-keyshortcuts": "Meta+K Control+K",
188
199
  onClick: openDialog,
@@ -206,7 +217,6 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
206
217
  role: "dialog",
207
218
  "aria-modal": "true",
208
219
  "aria-label": dialogLabel,
209
- onKeyDown: handleDialogKeyDown,
210
220
  children: [
211
221
  /* @__PURE__ */ jsxs("div", {
212
222
  className: "wave-docs-search-input-row",
@@ -223,6 +233,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
223
233
  placeholder,
224
234
  value: query,
225
235
  onChange: (event) => setQuery(event.target.value),
236
+ onKeyDown: handleInputKeyDown,
226
237
  autoComplete: "off",
227
238
  autoCorrect: "off",
228
239
  spellCheck: false
@@ -335,10 +346,10 @@ function SearchStatus({ status, query, hitCount }) {
335
346
  * `loadJSONAsync` yields between chunks so deserialising a large index does
336
347
  * not freeze the frame the dialog just opened in.
337
348
  */
338
- async function loadIndex(url) {
349
+ async function loadIndex(url, overrides) {
339
350
  const [{ default: MiniSearchClass }, response] = await Promise.all([import("minisearch"), fetch(url)]);
340
- if (!response.ok) throw new Error(`Failed to load the search index from ${url} (HTTP ${response.status}).`);
341
- return MiniSearchClass.loadJSONAsync(await response.text(), SEARCH_INDEX_OPTIONS);
351
+ if (!response.ok) throw docsError("search-index-unavailable", `Failed to load the search index from ${url} (HTTP ${response.status}).`);
352
+ return MiniSearchClass.loadJSONAsync(await response.text(), mergeSearchOptions(overrides));
342
353
  }
343
354
  /**
344
355
  * Narrow one MiniSearch result. Its stored fields are untyped by design, and
@@ -392,10 +403,16 @@ function trapFocus(root, event) {
392
403
  event.preventDefault();
393
404
  return;
394
405
  }
395
- if (event.shiftKey && document.activeElement === first) {
406
+ const active = document.activeElement;
407
+ if (!(active instanceof HTMLElement) || !root.contains(active)) {
408
+ event.preventDefault();
409
+ (event.shiftKey ? last : first).focus();
410
+ return;
411
+ }
412
+ if (event.shiftKey && active === first) {
396
413
  event.preventDefault();
397
414
  last.focus();
398
- } else if (!event.shiftKey && document.activeElement === last) {
415
+ } else if (!event.shiftKey && active === last) {
399
416
  event.preventDefault();
400
417
  first.focus();
401
418
  }
package/dist/react/toc.js CHANGED
@@ -3,6 +3,13 @@ import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/react/toc.tsx
5
5
  const DEFAULT_ROOT_MARGIN = "-80px 0px -60% 0px";
6
+ /**
7
+ * How many frames to keep looking for headings that are not in the document
8
+ * yet. ~1s at 60Hz, which covers a `<Suspense>` boundary resolving or a
9
+ * tabs/accordion wrapper revealing its panel. Bounded because the loop must
10
+ * also terminate on a page whose headings genuinely never arrive.
11
+ */
12
+ const MAX_ATTACH_FRAMES = 60;
6
13
  function flattenTocIds(entries) {
7
14
  const ids = [];
8
15
  const walk = (list) => {
@@ -46,11 +53,27 @@ function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MA
46
53
  rootMargin,
47
54
  threshold: 0
48
55
  });
49
- for (const id of ids) {
50
- const element = document.getElementById(id);
51
- if (element !== null) observer.observe(element);
52
- }
53
- return () => observer.disconnect();
56
+ let frame;
57
+ let framesLeft = MAX_ATTACH_FRAMES;
58
+ const attach = () => {
59
+ let attached = 0;
60
+ for (const id of ids) {
61
+ const element = document.getElementById(id);
62
+ if (element !== null) {
63
+ observer.observe(element);
64
+ attached += 1;
65
+ }
66
+ }
67
+ if (attached === 0 && framesLeft > 0 && typeof requestAnimationFrame === "function") {
68
+ framesLeft -= 1;
69
+ frame = requestAnimationFrame(attach);
70
+ }
71
+ };
72
+ attach();
73
+ return () => {
74
+ if (frame !== void 0) cancelAnimationFrame(frame);
75
+ observer.disconnect();
76
+ };
54
77
  }, [ids, rootMargin]);
55
78
  if (entries.length === 0) return null;
56
79
  return /* @__PURE__ */ jsx("nav", {
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { useState } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/react/youtube.tsx
5
5
  const DEFAULT_TITLE = "YouTube video player";
@@ -17,6 +17,10 @@ const DEFAULT_TITLE = "YouTube video player";
17
17
  */
18
18
  function YouTube({ id, title, className }) {
19
19
  const [isPlaying, setIsPlaying] = useState(false);
20
+ const frameRef = useRef(null);
21
+ useEffect(() => {
22
+ if (isPlaying) frameRef.current?.focus();
23
+ }, [isPlaying]);
20
24
  if (!id) return null;
21
25
  const safeId = encodeURIComponent(id);
22
26
  const label = title?.trim() || DEFAULT_TITLE;
@@ -30,9 +34,7 @@ function YouTube({ id, title, className }) {
30
34
  loading: "lazy",
31
35
  allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
32
36
  allowFullScreen: true,
33
- ref: (node) => {
34
- node?.focus();
35
- }
37
+ ref: frameRef
36
38
  })
37
39
  });
38
40
  return /* @__PURE__ */ jsx("div", {
package/dist/render.d.ts CHANGED
@@ -16,11 +16,11 @@ interface DocsRendererOptions {
16
16
  * Reuse an existing highlighter — the escape hatch for grammars and themes
17
17
  * outside the curated set. Defaults to {@link createDocsHighlighter}.
18
18
  */
19
- highlighter?: DocsHighlighter | Promise<DocsHighlighter>;
19
+ highlighter?: DocsHighlighter | Promise<DocsHighlighter> | undefined;
20
20
  /** Grammars to load, when building the default highlighter. */
21
- langs?: readonly DocsLang[];
21
+ langs?: readonly DocsLang[] | undefined;
22
22
  /** Theme pair. Defaults to {@link DEFAULT_DOCS_THEMES}. */
23
- themes?: DocsThemes;
23
+ themes?: DocsThemes | undefined;
24
24
  /**
25
25
  * Prepend an `<h1>` built from `frontmatter.title` when the markdown body
26
26
  * has none. Defaults to `true`.
@@ -30,15 +30,20 @@ interface DocsRendererOptions {
30
30
  * starting at `h2`, and markdown that repeats the frontmatter title as `# `
31
31
  * is a duplication authors forget to keep in step.
32
32
  */
33
- titleHeading?: boolean;
33
+ titleHeading?: boolean | undefined;
34
34
  /** Replaces the built-in markdown-link resolution. */
35
- linkResolver?: LinkResolver;
35
+ linkResolver?: LinkResolver | undefined;
36
36
  /**
37
37
  * Resolves image `src` to a public URL and intrinsic dimensions, so
38
- * `next/image` can render without `fill`. Images are left untouched when
39
- * omitted, or when the resolver returns `undefined`.
38
+ * `next/image` can render without `fill`.
39
+ *
40
+ * Required as soon as any page writes a relative `![](./diagram.png)`: there
41
+ * is no correct output for one without it, so it throws rather than shipping
42
+ * a src the browser resolves against the route. Absolute (`/logo.png`) and
43
+ * external sources need no resolver, and a resolver returning `undefined`
44
+ * keeps the folded — not the authored — src.
40
45
  */
41
- imageResolver?: ImageResolver;
46
+ imageResolver?: ImageResolver | undefined;
42
47
  /**
43
48
  * Every route the site publishes, used by `assertLinks`. Read at render
44
49
  * time, so a host may pass a set it populates during the source walk.
@@ -46,7 +51,36 @@ interface DocsRendererOptions {
46
51
  * Without it only unresolvable links can be caught; with it, links to pages
47
52
  * that simply do not exist are caught too.
48
53
  */
49
- knownRoutes?: ReadonlySet<string>;
54
+ knownRoutes?: ReadonlySet<string> | undefined;
55
+ /**
56
+ * Routes of pages excluded from {@link DocsRendererOptions.knownRoutes}
57
+ * because they are `draft: true`.
58
+ *
59
+ * Purely diagnostic, and it earns its place: a link to a draft is a link to a
60
+ * file plainly sitting on disk, and the generic "no such page exists — add an
61
+ * `aliases` entry" is advice that cannot be followed. Failing the build is
62
+ * still right; naming the reason is what makes it fixable.
63
+ */
64
+ draftRoutes?: ReadonlySet<string> | undefined;
65
+ /**
66
+ * Alias route → the canonical `href` it redirects to.
67
+ *
68
+ * Deliberately not folded into {@link DocsRendererOptions.knownRoutes}. An
69
+ * alias is only a live URL once `createDocsRedirects` is wired into
70
+ * `next.config.ts`, which the quick start does not do — so treating one as
71
+ * publishable produced a green build and a hard 404 for every reader who
72
+ * clicked, which is the exact failure `assertLinks` exists to prevent.
73
+ * Knowing the target lets the error name the page to link instead.
74
+ */
75
+ aliasRoutes?: ReadonlyMap<string, string> | undefined;
76
+ /**
77
+ * Fence languages Shiki must not touch, e.g. `['mermaid']`.
78
+ *
79
+ * The `<pre><code class="language-mermaid">` reaches your `pre`/`code`
80
+ * component untouched, which is what lets a consumer render a diagram rather
81
+ * than a monochrome block of DSL.
82
+ */
83
+ excludeLangs?: readonly string[] | undefined;
50
84
  }
51
85
  /**
52
86
  * Renders {@link DocFile}s. Build one per process and reuse it.
package/dist/render.js CHANGED
@@ -1,5 +1,9 @@
1
+ import { docsError } from "./docs-error.js";
1
2
  import { DEFAULT_DOCS_THEMES, createDocsHighlighter } from "./highlighter.js";
2
3
  import { rehypeCaptureToc } from "./plugins/rehype-capture-toc.js";
4
+ import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
5
+ import { rehypeFallbackHeadingIds } from "./plugins/rehype-fallback-heading-ids.js";
6
+ import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
3
7
  import { foldSegments, remarkDocLinks } from "./plugins/remark-doc-links.js";
4
8
  import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
5
9
  import { remarkYouTube } from "./plugins/remark-youtube.js";
@@ -11,7 +15,7 @@ import remarkGfm from "remark-gfm";
11
15
  import remarkParse from "remark-parse";
12
16
  import remarkRehype from "remark-rehype";
13
17
  import { unified } from "unified";
14
- import { CONTINUE, EXIT, visit } from "unist-util-visit";
18
+ import { visit } from "unist-util-visit";
15
19
  import { VFile } from "vfile";
16
20
  //#region src/render.ts
17
21
  /**
@@ -54,6 +58,34 @@ function toDirSegments(relativePath) {
54
58
  /** `scheme:` — `https:`, `data:`, anything that is not ours to resolve. */
55
59
  const IMAGE_HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
56
60
  /**
61
+ * Is this src already a URL a browser can fetch from any page?
62
+ *
63
+ * `/logo.png`, `//cdn/…` and `https://…` are; everything else is relative to
64
+ * the markdown file and means nothing once the file has become a route.
65
+ */
66
+ function isPublicImageSrc(src) {
67
+ return src.startsWith("/") || IMAGE_HAS_SCHEME.test(src);
68
+ }
69
+ /**
70
+ * Check what the resolver actually returned.
71
+ *
72
+ * `ImageResolver` is a type, and a type stops at the JavaScript boundary: a
73
+ * host reading dimensions from a manifest hands back `{ src, width: '1200' }`
74
+ * or a bare string, and the only symptom is `width="undefined"` in the HTML —
75
+ * on one page, at build time, with nothing naming the image or the document.
76
+ */
77
+ function assertResolvedImage(value, src, relativePath) {
78
+ const blame = `for image "${src}" in ${relativePath}`;
79
+ if (typeof value !== "object" || value === null) throw docsError("invalid-image", `@waveso/docs: the imageResolver returned ${typeof value} ${blame}. Return \`{ src, width?, height? }\`, or \`undefined\` to leave the src alone.`);
80
+ const resolved = value;
81
+ if (typeof resolved.src !== "string" || resolved.src === "") throw docsError("invalid-image", `@waveso/docs: the imageResolver returned no \`src\` ${blame}. Return \`{ src, width?, height? }\`, or \`undefined\` to leave the src alone.`);
82
+ for (const key of ["width", "height"]) {
83
+ const dimension = resolved[key];
84
+ if (dimension === void 0) continue;
85
+ if (typeof dimension !== "number" || !Number.isFinite(dimension)) throw docsError("invalid-image", `@waveso/docs: the imageResolver returned a non-numeric \`${key}\` ${blame}. \`next/image\` needs intrinsic pixel dimensions; parse the value before returning it.`);
86
+ }
87
+ }
88
+ /**
57
89
  * An image `src` folded against the page's directory, or `undefined` if it
58
90
  * climbs out of the content root.
59
91
  *
@@ -63,7 +95,7 @@ const IMAGE_HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
63
95
  * author means by `![](./diagram.png)` and what the link path has always done.
64
96
  */
65
97
  function foldImageSrc(src, dirSegments) {
66
- if (src.startsWith("/") || IMAGE_HAS_SCHEME.test(src)) return src;
98
+ if (isPublicImageSrc(src)) return src;
67
99
  const segments = foldSegments(dirSegments, src);
68
100
  return segments === void 0 ? void 0 : segments.join("/");
69
101
  }
@@ -76,15 +108,17 @@ function describeLink(file, ref) {
76
108
  const at = ref.line === void 0 ? "" : `:${ref.line}`;
77
109
  return `${file.relativePath}${at}`;
78
110
  }
79
- /** Does the document already open on a page title? */
111
+ /**
112
+ * Does the document already open on a page title?
113
+ *
114
+ * ⚠️ TOP-LEVEL CHILDREN ONLY, DELIBERATELY. A whole-tree walk counted an `h1`
115
+ * anywhere — including `> [!NOTE]\n> # Callout title` — and suppressed the
116
+ * frontmatter heading, so the page title appeared nowhere in the body and the
117
+ * document's only `h1` was buried inside a callout. That is precisely the
118
+ * `page-has-heading-one` failure this option's docstring says it prevents.
119
+ */
80
120
  function hasHeadingOne(tree) {
81
- let found = false;
82
- visit(tree, "element", (node) => {
83
- if (node.tagName !== "h1") return CONTINUE;
84
- found = true;
85
- return EXIT;
86
- });
87
- return found;
121
+ return tree.children.some((child) => child.type === "element" && child.tagName === "h1");
88
122
  }
89
123
  /**
90
124
  * The `<h1>` a page gets when its markdown does not declare one.
@@ -151,27 +185,31 @@ function stripPositions(tree) {
151
185
  * `remark-gfm` does not implement alerts at all.
152
186
  * Runs before slugging so a heading inside a
153
187
  * callout is slugged in its final position.
154
- * 8. `rehypeSlug` assigns heading ids.
155
- * 9. `rehypeCaptureToc` — reads those ids. Before autolinking, so heading
188
+ * 8. `rehypeFallbackHeadingIds` before slugging, so an emoji-only heading
189
+ * never seeds the collision counter with `''`.
190
+ * 9. `rehypeSlug` — assigns heading ids.
191
+ * 10. `rehypeCaptureToc` — reads those ids. Before autolinking, so heading
156
192
  * text is captured without the appended `#`.
157
- * 10. `rehypeAutolinkHeadings` — appends the permalink.
158
- * 11. `rehypeShikiFromHighlighter` — last: it replaces `<pre><code>` wholesale,
159
- * and anything walking code blocks afterwards
160
- * would be walking Shiki's token spans instead.
193
+ * 11. `rehypeAutolinkHeadings` — appends the permalink.
194
+ * 12. `rehypeNormalizeCodeLanguage` — immediately before Shiki, which is the
195
+ * last moment `class="language-JSON"` exists.
196
+ * 13. `rehypeShikiFromHighlighter` near-last: it replaces `<pre><code>`
197
+ * wholesale, and anything walking code blocks
198
+ * afterwards would be walking Shiki's token spans.
199
+ * 14. `rehypeRestoreExcludedCode` — the other side of step 12's disguise.
200
+ * 15. `rehypeFlattenRoots` — last of all, because Shiki is what splices a
201
+ * `root` into `root.children` and the published
202
+ * `RenderedDoc.hast` type says that cannot happen.
161
203
  */
162
- async function buildProcessor(options) {
163
- const themes = options.themes ?? DEFAULT_DOCS_THEMES;
164
- const highlighter = await (options.highlighter ?? createDocsHighlighter({
165
- themes,
166
- ...options.langs === void 0 ? {} : { langs: options.langs }
167
- }));
204
+ async function buildProcessor(options, themes, highlighterPromise) {
205
+ const highlighter = await highlighterPromise;
168
206
  return unified().use(remarkParse).use(remarkGfm).use(remarkDocLinks, {
169
207
  basePath: options.config.basePath,
170
208
  ...options.linkResolver === void 0 ? {} : { resolve: options.linkResolver }
171
209
  }).use(remarkUnwrapImages).use(remarkYouTube).use(remarkRehype, {
172
210
  allowDangerousHtml: false,
173
211
  footnoteLabelProperties: { className: ["wave-docs-sr-only"] }
174
- }).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeSlug).use(rehypeCaptureToc).use(rehypeAutolinkHeadings, {
212
+ }).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeFallbackHeadingIds).use(rehypeSlug).use(rehypeCaptureToc).use(rehypeAutolinkHeadings, {
175
213
  behavior: "append",
176
214
  content: HEADING_ANCHOR_CONTENT,
177
215
  properties: {
@@ -179,11 +217,13 @@ async function buildProcessor(options) {
179
217
  ariaHidden: "true",
180
218
  tabIndex: -1
181
219
  }
182
- }).use(rehypeShikiFromHighlighter, highlighter, {
220
+ }).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeShikiFromHighlighter, highlighter, {
183
221
  themes,
222
+ defaultColor: false,
184
223
  fallbackLanguage: "text",
185
- defaultLanguage: "text"
186
- }).freeze();
224
+ defaultLanguage: "text",
225
+ addLanguageClass: true
226
+ }).use(rehypeRestoreExcludedCode).use(rehypeFlattenRoots).freeze();
187
227
  }
188
228
  /**
189
229
  * Create a renderer.
@@ -193,33 +233,38 @@ async function buildProcessor(options) {
193
233
  * a docs build that takes a second and one that takes a minute.
194
234
  */
195
235
  function createDocsRenderer(options) {
196
- const processorPromise = buildProcessor(options);
197
- const { config, imageResolver, knownRoutes } = options;
236
+ const themes = options.themes ?? DEFAULT_DOCS_THEMES;
237
+ const processorPromise = buildProcessor(options, themes, options.highlighter ?? createDocsHighlighter({
238
+ themes,
239
+ ...options.langs === void 0 ? {} : { langs: options.langs }
240
+ }));
241
+ processorPromise.catch(() => void 0);
242
+ const { config, imageResolver, knownRoutes, draftRoutes, aliasRoutes } = options;
198
243
  const titleHeading = options.titleHeading ?? true;
199
244
  /**
200
- * Hand every `<img>` to the resolver, FOLDED AND CONTAINED.
201
- *
202
- * ⚠️ IMAGES USED TO SKIP FOLDING ENTIRELY. `remarkDocLinks` visits `link` and
203
- * `definition` and never `image`, so an image `src` reached the resolver
204
- * exactly as authored — `../../../../.env` included — while every LINK on the
205
- * same page went through `foldSegments`, which refuses a chain that climbs
206
- * out of the content root. Two paths into the same kind of consumer code,
207
- * one of them guarded.
245
+ * Fold every `<img src>`, then hand it to the resolver if there is one.
208
246
  *
209
- * That is a containment hole rather than a formatting bug: the resolver's
210
- * documented job is to turn a src into a public URL, and a reasonable
211
- * implementation joins it onto a directory. So the fold happens HERE, before
212
- * the call, and an escape throws with the file named — the same treatment
213
- * `assertLinks` gives a link that climbs out.
247
+ * ⚠️ THIS RUNS FOR EVERY DOCUMENT, RESOLVER OR NOT, AND THAT IS THE POINT.
248
+ * It used to be gated on `imageResolver`, which is the option nobody sets
249
+ * first so under the quickstart config `![d](./diagram.png)` shipped
250
+ * byte-for-byte as authored and the BROWSER resolved it, against the route:
251
+ * `/docs/guide` asked for `/docs/diagram.png` and `/docs/guide/setup` asked
252
+ * for `/docs/guide/diagram.png`, from identical markdown. `assertLinks` could
253
+ * not see it either — `remarkDocLinks` visits `link` and `definition`, never
254
+ * `image` — so the build stayed green and the containment throw below was
255
+ * dead code in the only configuration most sites run.
214
256
  *
215
- * Absolute and external srcs are passed through untouched: `/logo.png` is
216
- * already a public URL and `https://…` belongs to someone else.
257
+ * A relative src has no correct output without a resolver, so it throws.
258
+ * Absolute (`/logo.png`) and schemed srcs are already public URLs and are
259
+ * passed through untouched — but still offered to the resolver, so a host can
260
+ * rewrite them onto a CDN.
217
261
  */
218
262
  async function resolveImages(tree, file, resolve) {
219
263
  const images = [];
220
264
  visit(tree, "element", (node) => {
221
265
  if (node.tagName === "img") images.push(node);
222
266
  });
267
+ if (images.length === 0) return;
223
268
  const context = {
224
269
  segments: file.segments,
225
270
  dirSegments: toDirSegments(file.relativePath),
@@ -229,9 +274,22 @@ function createDocsRenderer(options) {
229
274
  const src = node.properties.src;
230
275
  if (typeof src !== "string" || src === "") return;
231
276
  const folded = foldImageSrc(src, context.dirSegments);
232
- if (folded === void 0) throw new Error(`@waveso/docs: image "${src}" in ${file.relativePath} climbs above the content root.`);
233
- const resolved = await resolve(folded, context);
234
- if (resolved === void 0) return;
277
+ if (folded === void 0) throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} climbs above the content root.`);
278
+ if (resolve === void 0) {
279
+ if (isPublicImageSrc(src)) return;
280
+ throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} is relative to the markdown file, and nothing can serve it: the browser would resolve it against the page route, so the same markdown would request a different file from every page. Pass an \`imageResolver\`, or move the image under \`public/\` and write an absolute src such as "/diagram.png".`);
281
+ }
282
+ let resolved;
283
+ try {
284
+ resolved = await resolve(folded, context);
285
+ } catch (error) {
286
+ throw docsError("invalid-image", `@waveso/docs: the imageResolver threw on image "${src}" in ${file.relativePath}.`, { cause: error });
287
+ }
288
+ if (resolved === void 0) {
289
+ node.properties.src = folded;
290
+ return;
291
+ }
292
+ assertResolvedImage(resolved, src, file.relativePath);
235
293
  node.properties.src = resolved.src;
236
294
  if (resolved.width !== void 0) node.properties.width = resolved.width;
237
295
  if (resolved.height !== void 0) node.properties.height = resolved.height;
@@ -245,10 +303,14 @@ function createDocsRenderer(options) {
245
303
  */
246
304
  function assertLinks(file, refs) {
247
305
  for (const ref of refs) {
248
- if (ref.href === void 0) throw new Error(`@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which does not resolve to a documentation page. Use a path relative to this file, or an absolute URL for external links.`);
249
- if (knownRoutes === void 0) continue;
306
+ if (ref.href === void 0) throw docsError("broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which does not resolve to a documentation page. Use a path relative to this file, or an absolute URL for external links.`);
307
+ if (knownRoutes === void 0 || ref.asset) continue;
250
308
  const route = toRouteKey(ref.href);
251
- if (!knownRoutes.has(route)) throw new Error(`@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — no such page exists. Fix the link, or add an \`aliases\` entry to the page it used to point at.`);
309
+ if (knownRoutes.has(route)) continue;
310
+ if (draftRoutes?.has(route)) throw docsError("draft-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — a page marked \`draft: true\`, so it is not published and the link would 404. Publish the page, remove the link, or build with \`includeDrafts\`.`);
311
+ const aliasTarget = aliasRoutes?.get(route);
312
+ if (aliasTarget !== void 0) throw docsError("alias-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — an alias that redirects to '${aliasTarget}'. An alias is not a page: it 404s unless \`createDocsRedirects\` is wired into \`next.config.ts\`, and it is never prerendered. Link to '${aliasTarget}' directly.`);
313
+ throw docsError("broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — no such page exists. Fix the link, or add an \`aliases\` entry to the page it used to point at.`);
252
314
  }
253
315
  }
254
316
  return { async render(file) {
@@ -264,7 +326,7 @@ function createDocsRenderer(options) {
264
326
  };
265
327
  const hast = await processor.run(processor.parse(vfile), vfile);
266
328
  if (titleHeading && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
267
- if (imageResolver !== void 0) await resolveImages(hast, file, imageResolver);
329
+ await resolveImages(hast, file, imageResolver);
268
330
  if (config.assertLinks) assertLinks(file, vfile.data.docLinks ?? []);
269
331
  return {
270
332
  frontmatter: file.frontmatter,
@@ -1,14 +1,6 @@
1
1
  import { RenderedDoc, SearchRecord } from "./types.js";
2
+ import { Options } from "minisearch";
2
3
  //#region src/search-index.d.ts
3
- /** Options for {@link extractSearchRecords}. */
4
- interface ExtractSearchRecordsOptions {
5
- /**
6
- * Maximum length of {@link SearchRecord.text}, in characters. Defaults to
7
- * 300 — long enough to carry a section's vocabulary into the index, short
8
- * enough that a 300-page corpus stays under a megabyte.
9
- */
10
- excerptLength?: number;
11
- }
12
4
  /**
13
5
  * Split a rendered document into section-scoped {@link SearchRecord}s.
14
6
  *
@@ -26,26 +18,52 @@ interface ExtractSearchRecordsOptions {
26
18
  * does with the same heading — the two must not disagree about which sections
27
19
  * exist.
28
20
  *
21
+ * `text` is the section's PROSE IN FULL. It used to be cut to 300 characters
22
+ * before indexing, which on a normal corpus (200 pages × 6 sections, ~1,686
23
+ * characters of prose each) dropped 82% of the words from the index — and
24
+ * `combineWith: 'AND'` compounds it, since every term of a query then has to
25
+ * land inside the surviving prefix of the same section. The cap bought nothing
26
+ * back: `storeFields` does not carry `text`, so not one character of the kept
27
+ * prefix was ever rendered. Truncate for display, in the layer that displays.
28
+ *
29
29
  * Not generic over the frontmatter type, deliberately: `frontmatter.title` is
30
30
  * the only field read, and a `RenderedDoc` carrying a project's own fields is
31
31
  * assignable to this signature already. A type parameter here would appear in
32
32
  * every call site and constrain nothing.
33
33
  */
34
- declare function extractSearchRecords(doc: RenderedDoc, options?: ExtractSearchRecordsOptions): SearchRecord[];
34
+ declare function extractSearchRecords(doc: RenderedDoc): SearchRecord[];
35
35
  /**
36
36
  * Build a serialised MiniSearch index from extracted records.
37
37
  *
38
38
  * The return value is JSON, ready for `MiniSearch.loadJSON` on the client or
39
- * for {@link writeSearchIndex} to put on disk.
39
+ * for {@link writeSearchIndex} to put on disk. The output is byte-stable for a
40
+ * given record list, so an index committed to the repository does not dirty
41
+ * the diff on every build.
42
+ *
43
+ * ⚠️ `options` MUST ALSO REACH THE DIALOG — pass the identical object to
44
+ * `SearchDialog`'s `searchOptions`. Both sides feed it through
45
+ * `mergeSearchOptions`, and a `tokenize` or `processTerm` applied to the
46
+ * documents but not to the query produces an index whose terms no query can
47
+ * spell: zero results, no error, nothing in the console.
40
48
  */
41
- declare function buildSearchIndex(records: SearchRecord[]): string;
49
+ declare function buildSearchIndex(records: SearchRecord[], options?: Partial<Options<SearchRecord>>): string;
42
50
  /**
43
51
  * Write the serialised index to `outFile`, creating parent directories.
44
52
  *
45
53
  * Returns the byte size written, so a build step can log it or assert a
46
54
  * budget — a docs index that quietly crosses a megabyte is a regression
47
55
  * nobody notices until the dialog takes a second to open.
56
+ *
57
+ * ⚠️ WRITTEN BESIDE THE TARGET AND RENAMED OVER IT, NEVER INTO IT. The target
58
+ * is normally `public/search-index.json`, a live static asset: writing in
59
+ * place truncates it to zero and grows it back in 1 MiB chunks, and a fetch
60
+ * landing in that window gets a 200 with a half-written body. `response.ok`
61
+ * passes, the parse throws, and the dialog is stuck in its error state —
62
+ * *"Try reloading the page"* — for every visitor, reloading forever, until
63
+ * someone redeploys content that did not change. `rename` is atomic within a
64
+ * filesystem, so a reader sees either the whole old file or the whole new one;
65
+ * it also makes two concurrent builds safe.
48
66
  */
49
- declare function writeSearchIndex(records: SearchRecord[], outFile: string): Promise<number>;
67
+ declare function writeSearchIndex(records: SearchRecord[], outFile: string, options?: Partial<Options<SearchRecord>>): Promise<number>;
50
68
  //#endregion
51
- export { ExtractSearchRecordsOptions, buildSearchIndex, extractSearchRecords, writeSearchIndex };
69
+ export { buildSearchIndex, extractSearchRecords, writeSearchIndex };