@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
@@ -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
@@ -0,0 +1,14 @@
1
+ import { ReactNode } from "react";
2
+ //#region src/react/code-runtime.d.ts
3
+ /**
4
+ * Mount the copy runtime. Renders nothing.
5
+ *
6
+ * Every hook of state lives in the DOM rather than in React: the button's
7
+ * copied state is a `data-copied` attribute the stylesheet reads, and the
8
+ * announcement is a live region. React owns none of these nodes, so nothing
9
+ * re-renders and there is no state to get out of step with a page that was
10
+ * server-rendered.
11
+ */
12
+ declare function DocsCodeRuntime(): ReactNode;
13
+ //#endregion
14
+ export { DocsCodeRuntime };
@@ -0,0 +1,161 @@
1
+ "use client";
2
+ import { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_READY_ATTRIBUTE } from "../code-frame.js";
3
+ import { useEffect } from "react";
4
+ //#region src/react/code-runtime.tsx
5
+ /**
6
+ * The nine hundred bytes that make every copy button on the page work.
7
+ *
8
+ * Private. `DocContent` mounts it, and only when the tree it was handed
9
+ * actually contains a code frame — so a page with no fences ships none of
10
+ * this rather than a component that mounts and finds nothing to do.
11
+ *
12
+ * ## One listener, not one component per block
13
+ *
14
+ * The buttons are plain server-rendered HTML with no React identity at all.
15
+ * This attaches a single delegated `click` listener to `document` and one live
16
+ * region to `<body>`, both behind a module-level ref count: the first instance
17
+ * installs them, later instances increment and return, and the last one out
18
+ * removes them. Two `DocContent`s on one page therefore copy once and announce
19
+ * once — a bug nothing else would catch, because the second announcement is
20
+ * only audible to a screen-reader user.
21
+ *
22
+ * The alternative every comparable package ships — mapping `pre` to a
23
+ * `'use client'` component — puts one client reference and one hydration root
24
+ * in the flight stream per fence, and drags the highlighted subtree across the
25
+ * client boundary as children.
26
+ */
27
+ /** How long the button shows its copied state. */
28
+ const COPIED_MS = 2e3;
29
+ /**
30
+ * Line classes whose text is not part of what the reader wanted.
31
+ *
32
+ * Empty today, and deliberately an array rather than an inline condition:
33
+ * `@shikijs/transformers` lands at 0.3, and `'remove'` — the class it puts on
34
+ * a deleted diff line — goes here. Copying deleted lines into somebody's
35
+ * editor is the kind of failure that is discovered at run time, in their
36
+ * project, days later.
37
+ */
38
+ const SKIP_LINE_CLASSES = [];
39
+ /** Marker classes whose lines carry trailing whitespace worth trimming. */
40
+ const TRIMMED_LINE_CLASSES = [];
41
+ let refCount = 0;
42
+ let detach;
43
+ /**
44
+ * Mount the copy runtime. Renders nothing.
45
+ *
46
+ * Every hook of state lives in the DOM rather than in React: the button's
47
+ * copied state is a `data-copied` attribute the stylesheet reads, and the
48
+ * announcement is a live region. React owns none of these nodes, so nothing
49
+ * re-renders and there is no state to get out of step with a page that was
50
+ * server-rendered.
51
+ */
52
+ function DocsCodeRuntime() {
53
+ useEffect(() => {
54
+ refCount += 1;
55
+ if (refCount === 1) detach = install();
56
+ return () => {
57
+ refCount -= 1;
58
+ if (refCount === 0) {
59
+ detach?.();
60
+ detach = void 0;
61
+ }
62
+ };
63
+ }, []);
64
+ return null;
65
+ }
66
+ function install() {
67
+ const status = document.createElement("div");
68
+ status.setAttribute("role", "status");
69
+ status.setAttribute("aria-live", "polite");
70
+ status.className = "wave-docs-code__status";
71
+ document.body.append(status);
72
+ const onClick = (event) => {
73
+ const target = event.target;
74
+ if (!(target instanceof Element)) return;
75
+ const button = target.closest(`[${CODE_COPY_ATTRIBUTE}]`);
76
+ if (!(button instanceof HTMLElement)) return;
77
+ const pre = button.closest(`[${CODE_FRAME_ATTRIBUTE}]`)?.querySelector("pre");
78
+ if (pre === null || pre === void 0) return;
79
+ copy(readCode(pre), button, status);
80
+ };
81
+ document.addEventListener("click", onClick);
82
+ document.documentElement.setAttribute(CODE_READY_ATTRIBUTE, "");
83
+ return () => {
84
+ document.removeEventListener("click", onClick);
85
+ document.documentElement.removeAttribute(CODE_READY_ATTRIBUTE);
86
+ status.remove();
87
+ };
88
+ }
89
+ /**
90
+ * The text of a code block, as the author wrote it.
91
+ *
92
+ * ⚠️ NOT `pre.textContent`. Shiki emits one `<span class="line">` per line with
93
+ * a literal `"\n"` text node between them, so `textContent` happens to be
94
+ * right *today* — and stops being right the moment a transformer adds a line
95
+ * that should not be copied, or a gutter of line numbers that should not be
96
+ * either. Walking the lines is the same amount of code and survives both.
97
+ */
98
+ function readCode(pre) {
99
+ const lines = pre.querySelectorAll(".line");
100
+ if (lines.length === 0) return pre.textContent ?? "";
101
+ const out = [];
102
+ for (const line of lines) {
103
+ const classes = line.className.split(/\s+/);
104
+ if (classes.some((name) => SKIP_LINE_CLASSES.includes(name))) continue;
105
+ const text = line.textContent ?? "";
106
+ out.push(classes.some((name) => TRIMMED_LINE_CLASSES.includes(name)) ? text.replace(/\s+$/, "") : text);
107
+ }
108
+ return out.join("\n");
109
+ }
110
+ async function copy(text, button, status) {
111
+ const copied = await writeClipboard(text) ? "true" : "false";
112
+ button.dataset.copied = copied;
113
+ status.textContent = copied === "true" ? "Copied to the clipboard." : "Copy failed. Select the code and press Control or Command + C.";
114
+ const existing = timers.get(button);
115
+ if (existing !== void 0) window.clearTimeout(existing);
116
+ timers.set(button, window.setTimeout(() => {
117
+ timers.delete(button);
118
+ button.removeAttribute("data-copied");
119
+ }, COPIED_MS));
120
+ }
121
+ /**
122
+ * The pending "clear the indicator" timer per button.
123
+ *
124
+ * A `WeakMap`, so a button removed by a client-side navigation takes its entry
125
+ * with it — this module is a page-lifetime singleton and a `Map` here would
126
+ * hold every code block the reader ever copied from.
127
+ */
128
+ const timers = /* @__PURE__ */ new WeakMap();
129
+ async function writeClipboard(text) {
130
+ if (window.isSecureContext && navigator.clipboard !== void 0) try {
131
+ await navigator.clipboard.writeText(text);
132
+ return true;
133
+ } catch {}
134
+ return legacyCopy(text);
135
+ }
136
+ /**
137
+ * `execCommand('copy')`, which is deprecated and still the only thing that
138
+ * works over plain HTTP.
139
+ *
140
+ * When it finally goes, this returns `false` and the reader gets the
141
+ * instruction — which is why that message is written as an instruction rather
142
+ * than as an apology.
143
+ */
144
+ function legacyCopy(text) {
145
+ const area = document.createElement("textarea");
146
+ area.value = text;
147
+ area.setAttribute("readonly", "");
148
+ area.setAttribute("aria-hidden", "true");
149
+ area.style.cssText = "position:fixed;top:-9999px;opacity:0;";
150
+ document.body.append(area);
151
+ try {
152
+ area.select();
153
+ return document.execCommand("copy");
154
+ } catch {
155
+ return false;
156
+ } finally {
157
+ area.remove();
158
+ }
159
+ }
160
+ //#endregion
161
+ export { DocsCodeRuntime };
@@ -10,20 +10,57 @@ interface DocContentProps {
10
10
  hast: Root;
11
11
  /** Overrides, merged over {@link defaultMarkdownComponents}. */
12
12
  components?: MarkdownComponents | undefined;
13
+ /**
14
+ * Appended to `wave-docs-prose`, never substituted for it.
15
+ *
16
+ * Substitution is the failure this whole wrapper exists to prevent, so it is
17
+ * not offered: almost every rule in the stylesheet is scoped under
18
+ * `.wave-docs-prose`, and dropping it leaves a page whose code blocks still
19
+ * carry correct syntax colours and nothing else — which reads as a design
20
+ * choice rather than as a mistake.
21
+ */
22
+ className?: string | undefined;
13
23
  }
14
24
  /**
15
- * Render a hast tree as React elements.
25
+ * Render a hast tree as React elements, inside the prose wrapper.
16
26
  *
17
27
  * Not a client component, and it must stay that way: the markdown parser and
18
28
  * Shiki ran in Node at build time, and this component only walks the resulting
19
29
  * tree. Nothing here pulls unified, remark or a highlighter into the browser.
20
30
  *
31
+ * ## Why the wrapper is here and not on your `<article>`
32
+ *
33
+ * `.wave-docs-prose` is the scope for nearly every rule in `styles.css` —
34
+ * including `.wave-docs-prose .shiki`, which is deliberately scoped so the
35
+ * package never styles a code block it did not render. `createDocsRoute.Page`
36
+ * always put the class on for you, but the documented hand-rolled path made
37
+ * the consumer type it, and forgetting it silently unstyled every code block
38
+ * on the site while leaving the syntax colours intact. One component owning
39
+ * the class removes the way to get that wrong.
40
+ *
41
+ * The rules that care about tree shape are `.wave-docs-prose > * + *` and
42
+ * `.wave-docs-prose > :is(h2…h6)`, and the tree's own children are this
43
+ * element's direct children, so nothing moves.
44
+ *
45
+ * ## The copy runtime is mounted here
46
+ *
47
+ * Because this is the component no consumer can avoid: `createDocsRoute.Page`
48
+ * renders it, and the documented hand-rolled route renders it directly. Wiring
49
+ * the listener from `docs.Layout` instead would ship dead buttons to everyone
50
+ * composing their own shell, and "what about someone not using the layout?"
51
+ * would be a caveat rather than a non-question.
52
+ *
53
+ * It renders only when the tree actually contains a code frame. The server has
54
+ * the tree in hand, the check is one pass, and the result is that a page
55
+ * without fences ships zero extra bytes rather than a runtime with nothing to
56
+ * do.
57
+ *
21
58
  * `passNode` is left off (the default). `react-markdown` hardcodes it *on* with
22
59
  * no opt-out, so any mapped component that spreads its props renders
23
60
  * `node="[object Object]"` into production HTML — with no type error to warn
24
61
  * you, because `node` is a legal prop on the component and an unknown attribute
25
62
  * on the element.
26
63
  */
27
- declare function DocContent({ hast, components }: DocContentProps): ReactNode;
64
+ declare function DocContent({ hast, components, className }: DocContentProps): ReactNode;
28
65
  //#endregion
29
66
  export { DocContent, DocContentProps };
@@ -1,29 +1,61 @@
1
+ import { hasCodeFrame } from "../code-frame.js";
2
+ import { DocsCodeRuntime } from "./code-runtime.js";
1
3
  import { defaultMarkdownComponents } from "./markdown-components.js";
2
4
  import { toJsxRuntime } from "hast-util-to-jsx-runtime";
3
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
6
  //#region src/react/doc-content.tsx
5
7
  /**
6
- * Render a hast tree as React elements.
8
+ * Render a hast tree as React elements, inside the prose wrapper.
7
9
  *
8
10
  * Not a client component, and it must stay that way: the markdown parser and
9
11
  * Shiki ran in Node at build time, and this component only walks the resulting
10
12
  * tree. Nothing here pulls unified, remark or a highlighter into the browser.
11
13
  *
14
+ * ## Why the wrapper is here and not on your `<article>`
15
+ *
16
+ * `.wave-docs-prose` is the scope for nearly every rule in `styles.css` —
17
+ * including `.wave-docs-prose .shiki`, which is deliberately scoped so the
18
+ * package never styles a code block it did not render. `createDocsRoute.Page`
19
+ * always put the class on for you, but the documented hand-rolled path made
20
+ * the consumer type it, and forgetting it silently unstyled every code block
21
+ * on the site while leaving the syntax colours intact. One component owning
22
+ * the class removes the way to get that wrong.
23
+ *
24
+ * The rules that care about tree shape are `.wave-docs-prose > * + *` and
25
+ * `.wave-docs-prose > :is(h2…h6)`, and the tree's own children are this
26
+ * element's direct children, so nothing moves.
27
+ *
28
+ * ## The copy runtime is mounted here
29
+ *
30
+ * Because this is the component no consumer can avoid: `createDocsRoute.Page`
31
+ * renders it, and the documented hand-rolled route renders it directly. Wiring
32
+ * the listener from `docs.Layout` instead would ship dead buttons to everyone
33
+ * composing their own shell, and "what about someone not using the layout?"
34
+ * would be a caveat rather than a non-question.
35
+ *
36
+ * It renders only when the tree actually contains a code frame. The server has
37
+ * the tree in hand, the check is one pass, and the result is that a page
38
+ * without fences ships zero extra bytes rather than a runtime with nothing to
39
+ * do.
40
+ *
12
41
  * `passNode` is left off (the default). `react-markdown` hardcodes it *on* with
13
42
  * no opt-out, so any mapped component that spreads its props renders
14
43
  * `node="[object Object]"` into production HTML — with no type error to warn
15
44
  * you, because `node` is a legal prop on the component and an unknown attribute
16
45
  * on the element.
17
46
  */
18
- function DocContent({ hast, components }) {
19
- return toJsxRuntime(hast, {
20
- Fragment,
21
- jsx,
22
- jsxs,
23
- components: {
24
- ...defaultMarkdownComponents,
25
- ...components
26
- }
47
+ function DocContent({ hast, components, className }) {
48
+ return /* @__PURE__ */ jsxs("div", {
49
+ className: className === void 0 || className === "" ? "wave-docs-prose" : `wave-docs-prose ${className}`,
50
+ children: [hasCodeFrame(hast) ? /* @__PURE__ */ jsx(DocsCodeRuntime, {}) : null, toJsxRuntime(hast, {
51
+ Fragment,
52
+ jsx,
53
+ jsxs,
54
+ components: {
55
+ ...defaultMarkdownComponents,
56
+ ...components
57
+ }
58
+ })]
27
59
  });
28
60
  }
29
61
  //#endregion