@waveso/docs 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +592 -88
  3. package/dist/code-frame.d.ts +29 -0
  4. package/dist/code-frame.js +41 -0
  5. package/dist/code-meta.d.ts +48 -0
  6. package/dist/code-meta.js +72 -0
  7. package/dist/docs-content-id.d.ts +19 -0
  8. package/dist/docs-content-id.js +19 -0
  9. package/dist/docs-error.d.ts +19 -0
  10. package/dist/docs-error.js +28 -0
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/frontmatter.d.ts +39 -7
  14. package/dist/frontmatter.js +51 -24
  15. package/dist/highlighter.d.ts +2 -2
  16. package/dist/highlighter.js +3 -2
  17. package/dist/map-pooled.d.ts +26 -0
  18. package/dist/map-pooled.js +45 -0
  19. package/dist/meta.d.ts +7 -3
  20. package/dist/meta.js +61 -15
  21. package/dist/next.d.ts +182 -35
  22. package/dist/next.js +177 -49
  23. package/dist/plugins/rehype-capture-toc.js +52 -20
  24. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  25. package/dist/plugins/rehype-code-frame.js +88 -0
  26. package/dist/plugins/rehype-code-language.d.ts +24 -0
  27. package/dist/plugins/rehype-code-language.js +54 -0
  28. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  29. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  30. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  31. package/dist/plugins/rehype-flatten-roots.js +39 -0
  32. package/dist/plugins/remark-doc-links.d.ts +12 -1
  33. package/dist/plugins/remark-doc-links.js +147 -20
  34. package/dist/react/code-runtime.d.ts +14 -0
  35. package/dist/react/code-runtime.js +161 -0
  36. package/dist/react/doc-content.d.ts +39 -2
  37. package/dist/react/doc-content.js +42 -10
  38. package/dist/react/layout.d.ts +44 -0
  39. package/dist/react/layout.js +65 -0
  40. package/dist/react/markdown-components.js +71 -6
  41. package/dist/react/nav.d.ts +28 -0
  42. package/dist/react/nav.js +70 -0
  43. package/dist/react/nearest-scroll-top.d.ts +45 -0
  44. package/dist/react/nearest-scroll-top.js +44 -0
  45. package/dist/react/next-link.d.ts +34 -0
  46. package/dist/react/next-link.js +30 -0
  47. package/dist/react/next-nav.d.ts +11 -0
  48. package/dist/react/next-nav.js +32 -0
  49. package/dist/react/next-search.d.ts +22 -0
  50. package/dist/react/next-search.js +52 -0
  51. package/dist/react/search-dialog.d.ts +35 -7
  52. package/dist/react/search-dialog.js +55 -33
  53. package/dist/react/shell-labels.d.ts +43 -0
  54. package/dist/react/shell-labels.js +27 -0
  55. package/dist/react/sidebar.d.ts +38 -3
  56. package/dist/react/sidebar.js +104 -12
  57. package/dist/react/skip-link.d.ts +1 -9
  58. package/dist/react/skip-link.js +6 -5
  59. package/dist/react/toc.d.ts +12 -4
  60. package/dist/react/toc.js +46 -12
  61. package/dist/react/youtube.d.ts +31 -5
  62. package/dist/react/youtube.js +76 -52
  63. package/dist/render.d.ts +78 -10
  64. package/dist/render.js +137 -54
  65. package/dist/route-path.d.ts +46 -0
  66. package/dist/route-path.js +51 -0
  67. package/dist/search-index.d.ts +22 -21
  68. package/dist/search-index.js +27 -78
  69. package/dist/search-options.d.ts +32 -1
  70. package/dist/search-options.js +66 -3
  71. package/dist/section-boundary.d.ts +17 -0
  72. package/dist/section-boundary.js +43 -0
  73. package/dist/sitemap-limit.d.ts +34 -0
  74. package/dist/sitemap-limit.js +37 -0
  75. package/dist/source.d.ts +12 -22
  76. package/dist/source.js +165 -72
  77. package/dist/styles.css +1117 -125
  78. package/dist/types.d.ts +52 -29
  79. package/package.json +70 -34
@@ -11,13 +11,39 @@ interface YouTubeProps {
11
11
  className?: string | undefined;
12
12
  }
13
13
  /**
14
- * Click-to-load YouTube embed.
14
+ * Click-to-load YouTube embed, with **no client JavaScript at all**.
15
15
  *
16
16
  * An eager `<iframe>` costs ~137 KB of embed document plus ~580 KB gzipped of
17
- * player JavaScript, on every page view, whether or not anyone presses play.
18
- * A facade costs one ~15 KB JPEG and loads the rest on demand. On a docs page
19
- * with three videos that is the difference between a good Lighthouse score and
20
- * a bad one.
17
+ * player JavaScript on every page view, whether or not anyone presses play. A
18
+ * facade costs one ~15 KB JPEG and loads the rest on demand.
19
+ *
20
+ * ## Why `<details>` and not `useState`
21
+ *
22
+ * This was a `'use client'` component, and that made it the one thing in
23
+ * `defaultMarkdownComponents` that crossed the client boundary — so every page
24
+ * carried a reference to it whether or not it embedded a video. Measured on
25
+ * the smoke build over a corpus containing no YouTube URL anywhere: its code
26
+ * was in a client chunk **referenced from the prerendered HTML and the flight
27
+ * payload of every page**, and afterwards it is in no chunk at all.
28
+ *
29
+ * Be precise about the size, because the tempting number is the wrong one:
30
+ * that chunk was 41 KB raw / 12.70 KB brotli, but it was a *shared* chunk and
31
+ * most of it was not this component. Total client JavaScript went 610.8 KB to
32
+ * 609.2 KB raw. The win here is a client boundary removed from the path every
33
+ * consumer renders — one fewer hydration root, and a default map that is now
34
+ * provably server-only — not a large byte saving.
35
+ *
36
+ * `<details>` does the same job in markup. Measured in Chromium: an
37
+ * `<iframe loading="lazy">` inside a **closed** `<details>` issues no request
38
+ * at all, and issues one the moment it opens — so the facade still defers the
39
+ * player, without a state hook, a hydration root or a client reference. Native
40
+ * also brings the keyboard handling and the disclosure semantics the button
41
+ * version had to spell out.
42
+ *
43
+ * The summary stays in the DOM once open, visually hidden rather than removed:
44
+ * removing the element under the reader's focus is what the old version needed
45
+ * a `useEffect` to paper over, and a hidden-but-focusable control keeps focus
46
+ * where the reader put it *and* leaves them a way to collapse it again.
21
47
  *
22
48
  * `hqdefault.jpg` rather than `maxresdefault.jpg` deliberately: maxres does not
23
49
  * exist for uploads below 1280×720 and 404s to a broken image with no fallback.
@@ -1,74 +1,98 @@
1
- "use client";
2
- import { useState } from "react";
3
1
  import { jsx, jsxs } from "react/jsx-runtime";
4
2
  //#region src/react/youtube.tsx
5
3
  const DEFAULT_TITLE = "YouTube video player";
6
4
  /**
7
- * Click-to-load YouTube embed.
5
+ * Click-to-load YouTube embed, with **no client JavaScript at all**.
8
6
  *
9
7
  * An eager `<iframe>` costs ~137 KB of embed document plus ~580 KB gzipped of
10
- * player JavaScript, on every page view, whether or not anyone presses play.
11
- * A facade costs one ~15 KB JPEG and loads the rest on demand. On a docs page
12
- * with three videos that is the difference between a good Lighthouse score and
13
- * a bad one.
8
+ * player JavaScript on every page view, whether or not anyone presses play. A
9
+ * facade costs one ~15 KB JPEG and loads the rest on demand.
10
+ *
11
+ * ## Why `<details>` and not `useState`
12
+ *
13
+ * This was a `'use client'` component, and that made it the one thing in
14
+ * `defaultMarkdownComponents` that crossed the client boundary — so every page
15
+ * carried a reference to it whether or not it embedded a video. Measured on
16
+ * the smoke build over a corpus containing no YouTube URL anywhere: its code
17
+ * was in a client chunk **referenced from the prerendered HTML and the flight
18
+ * payload of every page**, and afterwards it is in no chunk at all.
19
+ *
20
+ * Be precise about the size, because the tempting number is the wrong one:
21
+ * that chunk was 41 KB raw / 12.70 KB brotli, but it was a *shared* chunk and
22
+ * most of it was not this component. Total client JavaScript went 610.8 KB to
23
+ * 609.2 KB raw. The win here is a client boundary removed from the path every
24
+ * consumer renders — one fewer hydration root, and a default map that is now
25
+ * provably server-only — not a large byte saving.
26
+ *
27
+ * `<details>` does the same job in markup. Measured in Chromium: an
28
+ * `<iframe loading="lazy">` inside a **closed** `<details>` issues no request
29
+ * at all, and issues one the moment it opens — so the facade still defers the
30
+ * player, without a state hook, a hydration root or a client reference. Native
31
+ * also brings the keyboard handling and the disclosure semantics the button
32
+ * version had to spell out.
33
+ *
34
+ * The summary stays in the DOM once open, visually hidden rather than removed:
35
+ * removing the element under the reader's focus is what the old version needed
36
+ * a `useEffect` to paper over, and a hidden-but-focusable control keeps focus
37
+ * where the reader put it *and* leaves them a way to collapse it again.
14
38
  *
15
39
  * `hqdefault.jpg` rather than `maxresdefault.jpg` deliberately: maxres does not
16
40
  * exist for uploads below 1280×720 and 404s to a broken image with no fallback.
17
41
  */
18
42
  function YouTube({ id, title, className }) {
19
- const [isPlaying, setIsPlaying] = useState(false);
20
43
  if (!id) return null;
21
44
  const safeId = encodeURIComponent(id);
22
45
  const label = title?.trim() || DEFAULT_TITLE;
23
46
  const rootClassName = ["wave-docs-youtube", className].filter(Boolean).join(" ");
24
- if (isPlaying) return /* @__PURE__ */ jsx("div", {
47
+ return /* @__PURE__ */ jsxs("details", {
25
48
  className: rootClassName,
26
- children: /* @__PURE__ */ jsx("iframe", {
27
- className: "wave-docs-youtube__frame",
28
- src: `https://www.youtube-nocookie.com/embed/${safeId}?autoplay=1&rel=0`,
29
- title: label,
30
- loading: "lazy",
31
- allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
32
- allowFullScreen: true,
33
- ref: (node) => {
34
- node?.focus();
35
- }
36
- })
37
- });
38
- return /* @__PURE__ */ jsx("div", {
39
- className: rootClassName,
40
- children: /* @__PURE__ */ jsxs("button", {
41
- type: "button",
49
+ children: [/* @__PURE__ */ jsxs("summary", {
42
50
  className: "wave-docs-youtube__facade",
43
- onClick: () => setIsPlaying(true),
44
- "aria-label": `Play video: ${label}`,
45
- children: [/* @__PURE__ */ jsx("img", {
46
- className: "wave-docs-youtube__thumbnail",
47
- src: `https://i.ytimg.com/vi/${safeId}/hqdefault.jpg`,
48
- alt: "",
49
- width: 480,
50
- height: 360,
51
- loading: "lazy",
52
- decoding: "async"
53
- }), /* @__PURE__ */ jsx("span", {
54
- className: "wave-docs-youtube__play",
55
- "aria-hidden": "true",
56
- children: /* @__PURE__ */ jsxs("svg", {
57
- viewBox: "0 0 68 48",
58
- width: "68",
59
- height: "48",
51
+ children: [
52
+ /* @__PURE__ */ jsx("img", {
53
+ className: "wave-docs-youtube__thumbnail",
54
+ src: `https://i.ytimg.com/vi/${safeId}/hqdefault.jpg`,
55
+ alt: "",
56
+ width: 480,
57
+ height: 360,
58
+ loading: "lazy",
59
+ decoding: "async"
60
+ }),
61
+ /* @__PURE__ */ jsx("span", {
62
+ className: "wave-docs-youtube__play",
60
63
  "aria-hidden": "true",
61
- focusable: "false",
62
- children: [/* @__PURE__ */ jsx("path", {
63
- className: "wave-docs-youtube__play-bg",
64
- d: "M66.52 7.74a8 8 0 0 0-5.65-5.66C56.1.99 34 .99 34 .99s-22.1 0-26.87 1.09a8 8 0 0 0-5.65 5.66C.39 12.51.39 24 .39 24s0 11.49 1.09 16.26a8 8 0 0 0 5.65 5.66C11.9 47 34 47 34 47s22.1 0 26.87-1.08a8 8 0 0 0 5.65-5.66C67.61 35.49 67.61 24 67.61 24s0-11.49-1.09-16.26"
65
- }), /* @__PURE__ */ jsx("path", {
66
- className: "wave-docs-youtube__play-arrow",
67
- d: "M27 34V14l17 10z"
68
- })]
64
+ children: /* @__PURE__ */ jsxs("svg", {
65
+ viewBox: "0 0 68 48",
66
+ width: "68",
67
+ height: "48",
68
+ "aria-hidden": "true",
69
+ focusable: "false",
70
+ children: [/* @__PURE__ */ jsx("path", {
71
+ className: "wave-docs-youtube__play-bg",
72
+ d: "M66.52 7.74a8 8 0 0 0-5.65-5.66C56.1.99 34 .99 34 .99s-22.1 0-26.87 1.09a8 8 0 0 0-5.65 5.66C.39 12.51.39 24 .39 24s0 11.49 1.09 16.26a8 8 0 0 0 5.65 5.66C11.9 47 34 47 34 47s22.1 0 26.87-1.08a8 8 0 0 0 5.65-5.66C67.61 35.49 67.61 24 67.61 24s0-11.49-1.09-16.26"
73
+ }), /* @__PURE__ */ jsx("path", {
74
+ className: "wave-docs-youtube__play-arrow",
75
+ d: "M27 34V14l17 10z"
76
+ })]
77
+ })
78
+ }),
79
+ /* @__PURE__ */ jsx("span", {
80
+ className: "wave-docs-sr-only wave-docs-youtube__label-play",
81
+ children: `Play video: ${label}`
82
+ }),
83
+ /* @__PURE__ */ jsx("span", {
84
+ className: "wave-docs-youtube__label-hide",
85
+ children: `Hide video: ${label}`
69
86
  })
70
- })]
71
- })
87
+ ]
88
+ }), /* @__PURE__ */ jsx("iframe", {
89
+ className: "wave-docs-youtube__frame",
90
+ loading: "lazy",
91
+ src: `https://www.youtube-nocookie.com/embed/${safeId}?rel=0&autoplay=1`,
92
+ title: label,
93
+ allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
94
+ allowFullScreen: true
95
+ })]
72
96
  });
73
97
  }
74
98
  //#endregion
package/dist/render.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { DocFile, DocFrontmatter, ImageResolver, LinkResolver, RenderedDoc, ResolvedDocsConfig } from "./types.js";
2
2
  import { DocsHighlighter, DocsLang, DocsThemes } from "./highlighter.js";
3
+ import { resolveMarkdownLink } from "./plugins/remark-doc-links.js";
4
+ import { PluggableList } from "unified";
3
5
  //#region src/render.d.ts
4
6
  /**
5
7
  * The parts of {@link ResolvedDocsConfig} rendering actually depends on.
@@ -16,11 +18,11 @@ interface DocsRendererOptions {
16
18
  * Reuse an existing highlighter — the escape hatch for grammars and themes
17
19
  * outside the curated set. Defaults to {@link createDocsHighlighter}.
18
20
  */
19
- highlighter?: DocsHighlighter | Promise<DocsHighlighter>;
21
+ highlighter?: DocsHighlighter | Promise<DocsHighlighter> | undefined;
20
22
  /** Grammars to load, when building the default highlighter. */
21
- langs?: readonly DocsLang[];
23
+ langs?: readonly DocsLang[] | undefined;
22
24
  /** Theme pair. Defaults to {@link DEFAULT_DOCS_THEMES}. */
23
- themes?: DocsThemes;
25
+ themes?: DocsThemes | undefined;
24
26
  /**
25
27
  * Prepend an `<h1>` built from `frontmatter.title` when the markdown body
26
28
  * has none. Defaults to `true`.
@@ -30,15 +32,52 @@ interface DocsRendererOptions {
30
32
  * starting at `h2`, and markdown that repeats the frontmatter title as `# `
31
33
  * is a duplication authors forget to keep in step.
32
34
  */
33
- titleHeading?: boolean;
35
+ titleHeading?: boolean | undefined;
36
+ /**
37
+ * Extra remark plugins, attached **after `remarkGfm` and before
38
+ * `remarkDocLinks`**.
39
+ *
40
+ * Which is to say: while links are still mdast `url` strings, so anything
41
+ * you emit is folded, contained and asserted exactly like authored markdown.
42
+ * A plugin emitting `[x](../other/page.md)` gets the same resolution an
43
+ * author would; one emitting `![i](./x.png)` throws `invalid-image` without
44
+ * a resolver, for the same reason.
45
+ *
46
+ * ⚠️ ATTACHED ONCE, TO A PROCESSOR SHARED BY EVERY FILE. The pipeline is
47
+ * built and frozen a single time, so a plugin holding state accumulates it
48
+ * across the whole build rather than per document. Keep them pure, or key
49
+ * whatever they hold on the vfile.
50
+ */
51
+ remarkPlugins?: PluggableList | undefined;
52
+ /**
53
+ * Extra rehype plugins, attached **after `rehypeAutolinkHeadings` and before
54
+ * the code frame and Shiki**.
55
+ *
56
+ * The position is the useful one and it is not negotiable: after slugging
57
+ * and autolinking, so heading ids exist; before Shiki, so a `<pre>` is still
58
+ * `<pre><code class="language-ts">` with the author's text inside rather
59
+ * than several hundred token spans. Fences excluded by `excludeLangs` are
60
+ * not yet disguised at this point either, so a plugin sees every code block
61
+ * the same way.
62
+ *
63
+ * Code-block internals are Shiki's `transformers`, not this. There is no
64
+ * after-Shiki slot, because the honest documentation for one would be a list
65
+ * of things you must not do.
66
+ */
67
+ rehypePlugins?: PluggableList | undefined;
34
68
  /** Replaces the built-in markdown-link resolution. */
35
- linkResolver?: LinkResolver;
69
+ linkResolver?: LinkResolver | undefined;
36
70
  /**
37
71
  * 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`.
72
+ * `next/image` can render without `fill`.
73
+ *
74
+ * Required as soon as any page writes a relative `![](./diagram.png)`: there
75
+ * is no correct output for one without it, so it throws rather than shipping
76
+ * a src the browser resolves against the route. Absolute (`/logo.png`) and
77
+ * external sources need no resolver, and a resolver returning `undefined`
78
+ * keeps the folded — not the authored — src.
40
79
  */
41
- imageResolver?: ImageResolver;
80
+ imageResolver?: ImageResolver | undefined;
42
81
  /**
43
82
  * Every route the site publishes, used by `assertLinks`. Read at render
44
83
  * time, so a host may pass a set it populates during the source walk.
@@ -46,7 +85,36 @@ interface DocsRendererOptions {
46
85
  * Without it only unresolvable links can be caught; with it, links to pages
47
86
  * that simply do not exist are caught too.
48
87
  */
49
- knownRoutes?: ReadonlySet<string>;
88
+ knownRoutes?: ReadonlySet<string> | undefined;
89
+ /**
90
+ * Routes of pages excluded from {@link DocsRendererOptions.knownRoutes}
91
+ * because they are `draft: true`.
92
+ *
93
+ * Purely diagnostic, and it earns its place: a link to a draft is a link to a
94
+ * file plainly sitting on disk, and the generic "no such page exists — add an
95
+ * `aliases` entry" is advice that cannot be followed. Failing the build is
96
+ * still right; naming the reason is what makes it fixable.
97
+ */
98
+ draftRoutes?: ReadonlySet<string> | undefined;
99
+ /**
100
+ * Alias route → the canonical `href` it redirects to.
101
+ *
102
+ * Deliberately not folded into {@link DocsRendererOptions.knownRoutes}. An
103
+ * alias is only a live URL once `createDocsRedirects` is wired into
104
+ * `next.config.ts`, which the quick start does not do — so treating one as
105
+ * publishable produced a green build and a hard 404 for every reader who
106
+ * clicked, which is the exact failure `assertLinks` exists to prevent.
107
+ * Knowing the target lets the error name the page to link instead.
108
+ */
109
+ aliasRoutes?: ReadonlyMap<string, string> | undefined;
110
+ /**
111
+ * Fence languages Shiki must not touch, e.g. `['mermaid']`.
112
+ *
113
+ * The `<pre><code class="language-mermaid">` reaches your `pre`/`code`
114
+ * component untouched, which is what lets a consumer render a diagram rather
115
+ * than a monochrome block of DSL.
116
+ */
117
+ excludeLangs?: readonly string[] | undefined;
50
118
  }
51
119
  /**
52
120
  * Renders {@link DocFile}s. Build one per process and reuse it.
@@ -69,4 +137,4 @@ interface DocsRenderer {
69
137
  */
70
138
  declare function createDocsRenderer(options: DocsRendererOptions): DocsRenderer;
71
139
  //#endregion
72
- export { DocsRenderer, DocsRendererConfig, DocsRendererOptions, createDocsRenderer };
140
+ export { DocsRenderer, DocsRendererConfig, DocsRendererOptions, createDocsRenderer, resolveMarkdownLink };
package/dist/render.js CHANGED
@@ -1,6 +1,11 @@
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";
3
- import { foldSegments, remarkDocLinks } from "./plugins/remark-doc-links.js";
4
+ import { rehypeCodeFrame } from "./plugins/rehype-code-frame.js";
5
+ import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
6
+ import { rehypeFallbackHeadingIds } from "./plugins/rehype-fallback-heading-ids.js";
7
+ import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
8
+ import { foldSegments, remarkDocLinks, resolveMarkdownLink } from "./plugins/remark-doc-links.js";
4
9
  import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
5
10
  import { remarkYouTube } from "./plugins/remark-youtube.js";
6
11
  import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
@@ -11,7 +16,7 @@ import remarkGfm from "remark-gfm";
11
16
  import remarkParse from "remark-parse";
12
17
  import remarkRehype from "remark-rehype";
13
18
  import { unified } from "unified";
14
- import { CONTINUE, EXIT, visit } from "unist-util-visit";
19
+ import { visit } from "unist-util-visit";
15
20
  import { VFile } from "vfile";
16
21
  //#region src/render.ts
17
22
  /**
@@ -54,6 +59,34 @@ function toDirSegments(relativePath) {
54
59
  /** `scheme:` — `https:`, `data:`, anything that is not ours to resolve. */
55
60
  const IMAGE_HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
56
61
  /**
62
+ * Is this src already a URL a browser can fetch from any page?
63
+ *
64
+ * `/logo.png`, `//cdn/…` and `https://…` are; everything else is relative to
65
+ * the markdown file and means nothing once the file has become a route.
66
+ */
67
+ function isPublicImageSrc(src) {
68
+ return src.startsWith("/") || IMAGE_HAS_SCHEME.test(src);
69
+ }
70
+ /**
71
+ * Check what the resolver actually returned.
72
+ *
73
+ * `ImageResolver` is a type, and a type stops at the JavaScript boundary: a
74
+ * host reading dimensions from a manifest hands back `{ src, width: '1200' }`
75
+ * or a bare string, and the only symptom is `width="undefined"` in the HTML —
76
+ * on one page, at build time, with nothing naming the image or the document.
77
+ */
78
+ function assertResolvedImage(value, src, relativePath) {
79
+ const blame = `for image "${src}" in ${relativePath}`;
80
+ 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.`);
81
+ const resolved = value;
82
+ 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.`);
83
+ for (const key of ["width", "height"]) {
84
+ const dimension = resolved[key];
85
+ if (dimension === void 0) continue;
86
+ 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.`);
87
+ }
88
+ }
89
+ /**
57
90
  * An image `src` folded against the page's directory, or `undefined` if it
58
91
  * climbs out of the content root.
59
92
  *
@@ -63,7 +96,7 @@ const IMAGE_HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
63
96
  * author means by `![](./diagram.png)` and what the link path has always done.
64
97
  */
65
98
  function foldImageSrc(src, dirSegments) {
66
- if (src.startsWith("/") || IMAGE_HAS_SCHEME.test(src)) return src;
99
+ if (isPublicImageSrc(src)) return src;
67
100
  const segments = foldSegments(dirSegments, src);
68
101
  return segments === void 0 ? void 0 : segments.join("/");
69
102
  }
@@ -76,15 +109,17 @@ function describeLink(file, ref) {
76
109
  const at = ref.line === void 0 ? "" : `:${ref.line}`;
77
110
  return `${file.relativePath}${at}`;
78
111
  }
79
- /** Does the document already open on a page title? */
112
+ /**
113
+ * Does the document already open on a page title?
114
+ *
115
+ * ⚠️ TOP-LEVEL CHILDREN ONLY, DELIBERATELY. A whole-tree walk counted an `h1`
116
+ * anywhere — including `> [!NOTE]\n> # Callout title` — and suppressed the
117
+ * frontmatter heading, so the page title appeared nowhere in the body and the
118
+ * document's only `h1` was buried inside a callout. That is precisely the
119
+ * `page-has-heading-one` failure this option's docstring says it prevents.
120
+ */
80
121
  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;
122
+ return tree.children.some((child) => child.type === "element" && child.tagName === "h1");
88
123
  }
89
124
  /**
90
125
  * The `<h1>` a page gets when its markdown does not declare one.
@@ -111,7 +146,7 @@ function titleHeadingNode(title) {
111
146
  *
112
147
  * The tree is the payload: it crosses the RSC boundary, so every byte is
113
148
  * shipped to every reader.
114
- * Positions are 38% of that JSON on a typical page — line and column offsets
149
+ * Positions are roughly a third of that JSON — line and column offsets
115
150
  * into a markdown file the browser does not have and cannot fetch. Nothing
116
151
  * downstream reads them: link errors are reported from positions captured
117
152
  * during the mdast phase, and the TOC works off ids.
@@ -151,27 +186,51 @@ function stripPositions(tree) {
151
186
  * `remark-gfm` does not implement alerts at all.
152
187
  * Runs before slugging so a heading inside a
153
188
  * callout is slugged in its final position.
154
- * 8. `rehypeSlug` assigns heading ids.
155
- * 9. `rehypeCaptureToc` — reads those ids. Before autolinking, so heading
156
- * text is captured without the appended `#`.
189
+ * 8. `rehypeFallbackHeadingIds` before slugging, so an emoji-only heading
190
+ * never seeds the collision counter with `''`.
191
+ * 9. `rehypeSlug` — assigns heading ids.
157
192
  * 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. `rehypePlugins` the consumer's, after slugging and autolinking so
194
+ * heading ids exist, and before the code steps so
195
+ * a `<pre>` is still the author's text.
196
+ * 12. `rehypeNormalizeCodeLanguage` — immediately before Shiki, which is the
197
+ * last moment `class="language-JSON"` exists.
198
+ * 13. `rehypeCodeFrame` — the one step wide window: after 12, which folds
199
+ * the language and disguises excluded fences, and
200
+ * before Shiki, which destroys `code.data.meta`
201
+ * and with it the fence's `title="…"`.
202
+ * 14. `rehypeShikiFromHighlighter` — near-last: it replaces `<pre><code>`
203
+ * wholesale, and anything walking code blocks
204
+ * afterwards would be walking Shiki's token spans.
205
+ * 15. `rehypeRestoreExcludedCode` — the other side of step 12's disguise.
206
+ * 16. `rehypeFlattenRoots` — because Shiki is what splices a `root` into
207
+ * `root.children` and the published
208
+ * `RenderedDoc.hast` type says that cannot happen.
209
+ * Step 13 is the first thing to put a `root`
210
+ * inside an *element* rather than at the top, so
211
+ * this recursing into element children is now
212
+ * load-bearing rather than defensive.
213
+ * 17. `rehypeCaptureToc` — DEAD LAST, and that is the design rather than an
214
+ * ordering detail. The TOC is then read off the
215
+ * identical tree `extractSearchRecords` walks, so
216
+ * a consumer plugin cannot put the two out of step
217
+ * — and no validation pass or error has to exist
218
+ * to notice when it does. Measured both drifts
219
+ * before the move: a plugin deleting a heading id
220
+ * left `toc` pointing at an id no longer in the
221
+ * DOM while search silently dropped the section;
222
+ * one adding an `<h2>` produced a search record
223
+ * with no TOC entry. Both silent.
161
224
  */
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
- }));
168
- return unified().use(remarkParse).use(remarkGfm).use(remarkDocLinks, {
225
+ async function buildProcessor(options, themes, highlighterPromise) {
226
+ const highlighter = await highlighterPromise;
227
+ return unified().use(remarkParse).use(remarkGfm).use(options.remarkPlugins ?? []).use(remarkDocLinks, {
169
228
  basePath: options.config.basePath,
170
229
  ...options.linkResolver === void 0 ? {} : { resolve: options.linkResolver }
171
230
  }).use(remarkUnwrapImages).use(remarkYouTube).use(remarkRehype, {
172
231
  allowDangerousHtml: false,
173
232
  footnoteLabelProperties: { className: ["wave-docs-sr-only"] }
174
- }).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeSlug).use(rehypeCaptureToc).use(rehypeAutolinkHeadings, {
233
+ }).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeFallbackHeadingIds).use(rehypeSlug).use(rehypeAutolinkHeadings, {
175
234
  behavior: "append",
176
235
  content: HEADING_ANCHOR_CONTENT,
177
236
  properties: {
@@ -179,11 +238,13 @@ async function buildProcessor(options) {
179
238
  ariaHidden: "true",
180
239
  tabIndex: -1
181
240
  }
182
- }).use(rehypeShikiFromHighlighter, highlighter, {
241
+ }).use(options.rehypePlugins ?? []).use(rehypeNormalizeCodeLanguage, { ...options.excludeLangs === void 0 ? {} : { exclude: options.excludeLangs } }).use(rehypeCodeFrame).use(rehypeShikiFromHighlighter, highlighter, {
183
242
  themes,
243
+ defaultColor: false,
184
244
  fallbackLanguage: "text",
185
- defaultLanguage: "text"
186
- }).freeze();
245
+ defaultLanguage: "text",
246
+ addLanguageClass: true
247
+ }).use(rehypeRestoreExcludedCode).use(rehypeFlattenRoots).use(rehypeCaptureToc).freeze();
187
248
  }
188
249
  /**
189
250
  * Create a renderer.
@@ -193,33 +254,38 @@ async function buildProcessor(options) {
193
254
  * a docs build that takes a second and one that takes a minute.
194
255
  */
195
256
  function createDocsRenderer(options) {
196
- const processorPromise = buildProcessor(options);
197
- const { config, imageResolver, knownRoutes } = options;
257
+ const themes = options.themes ?? DEFAULT_DOCS_THEMES;
258
+ const processorPromise = buildProcessor(options, themes, options.highlighter ?? createDocsHighlighter({
259
+ themes,
260
+ ...options.langs === void 0 ? {} : { langs: options.langs }
261
+ }));
262
+ processorPromise.catch(() => void 0);
263
+ const { config, imageResolver, knownRoutes, draftRoutes, aliasRoutes } = options;
198
264
  const titleHeading = options.titleHeading ?? true;
199
265
  /**
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.
266
+ * Fold every `<img src>`, then hand it to the resolver if there is one.
208
267
  *
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.
268
+ * ⚠️ THIS RUNS FOR EVERY DOCUMENT, RESOLVER OR NOT, AND THAT IS THE POINT.
269
+ * It used to be gated on `imageResolver`, which is the option nobody sets
270
+ * first so under the quickstart config `![d](./diagram.png)` shipped
271
+ * byte-for-byte as authored and the BROWSER resolved it, against the route:
272
+ * `/docs/guide` asked for `/docs/diagram.png` and `/docs/guide/setup` asked
273
+ * for `/docs/guide/diagram.png`, from identical markdown. `assertLinks` could
274
+ * not see it either — `remarkDocLinks` visits `link` and `definition`, never
275
+ * `image` — so the build stayed green and the containment throw below was
276
+ * dead code in the only configuration most sites run.
214
277
  *
215
- * Absolute and external srcs are passed through untouched: `/logo.png` is
216
- * already a public URL and `https://…` belongs to someone else.
278
+ * A relative src has no correct output without a resolver, so it throws.
279
+ * Absolute (`/logo.png`) and schemed srcs are already public URLs and are
280
+ * passed through untouched — but still offered to the resolver, so a host can
281
+ * rewrite them onto a CDN.
217
282
  */
218
283
  async function resolveImages(tree, file, resolve) {
219
284
  const images = [];
220
285
  visit(tree, "element", (node) => {
221
286
  if (node.tagName === "img") images.push(node);
222
287
  });
288
+ if (images.length === 0) return;
223
289
  const context = {
224
290
  segments: file.segments,
225
291
  dirSegments: toDirSegments(file.relativePath),
@@ -229,9 +295,22 @@ function createDocsRenderer(options) {
229
295
  const src = node.properties.src;
230
296
  if (typeof src !== "string" || src === "") return;
231
297
  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;
298
+ if (folded === void 0) throw docsError("invalid-image", `@waveso/docs: image "${src}" in ${file.relativePath} climbs above the content root.`);
299
+ if (resolve === void 0) {
300
+ if (isPublicImageSrc(src)) return;
301
+ 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".`);
302
+ }
303
+ let resolved;
304
+ try {
305
+ resolved = await resolve(folded, context);
306
+ } catch (error) {
307
+ throw docsError("invalid-image", `@waveso/docs: the imageResolver threw on image "${src}" in ${file.relativePath}.`, { cause: error });
308
+ }
309
+ if (resolved === void 0) {
310
+ node.properties.src = folded;
311
+ return;
312
+ }
313
+ assertResolvedImage(resolved, src, file.relativePath);
235
314
  node.properties.src = resolved.src;
236
315
  if (resolved.width !== void 0) node.properties.width = resolved.width;
237
316
  if (resolved.height !== void 0) node.properties.height = resolved.height;
@@ -245,10 +324,14 @@ function createDocsRenderer(options) {
245
324
  */
246
325
  function assertLinks(file, refs) {
247
326
  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;
327
+ 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.`);
328
+ if (knownRoutes === void 0 || ref.asset) continue;
250
329
  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.`);
330
+ if (knownRoutes.has(route)) continue;
331
+ 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\`.`);
332
+ const aliasTarget = aliasRoutes?.get(route);
333
+ 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.`);
334
+ 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
335
  }
253
336
  }
254
337
  return { async render(file) {
@@ -264,7 +347,7 @@ function createDocsRenderer(options) {
264
347
  };
265
348
  const hast = await processor.run(processor.parse(vfile), vfile);
266
349
  if (titleHeading && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
267
- if (imageResolver !== void 0) await resolveImages(hast, file, imageResolver);
350
+ await resolveImages(hast, file, imageResolver);
268
351
  if (config.assertLinks) assertLinks(file, vfile.data.docLinks ?? []);
269
352
  return {
270
353
  frontmatter: file.frontmatter,
@@ -276,4 +359,4 @@ function createDocsRenderer(options) {
276
359
  } };
277
360
  }
278
361
  //#endregion
279
- export { createDocsRenderer };
362
+ export { createDocsRenderer, resolveMarkdownLink };