@waveso/docs 0.1.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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +518 -0
  3. package/dist/frontmatter.d.ts +55 -0
  4. package/dist/frontmatter.js +80 -0
  5. package/dist/highlighter.d.ts +99 -0
  6. package/dist/highlighter.js +183 -0
  7. package/dist/meta.d.ts +75 -0
  8. package/dist/meta.js +183 -0
  9. package/dist/next.d.ts +256 -0
  10. package/dist/next.js +365 -0
  11. package/dist/plugins/rehype-capture-toc.d.ts +18 -0
  12. package/dist/plugins/rehype-capture-toc.js +69 -0
  13. package/dist/plugins/remark-doc-links.d.ts +63 -0
  14. package/dist/plugins/remark-doc-links.js +122 -0
  15. package/dist/plugins/remark-unwrap-images.d.ts +11 -0
  16. package/dist/plugins/remark-unwrap-images.js +25 -0
  17. package/dist/plugins/remark-youtube.d.ts +22 -0
  18. package/dist/plugins/remark-youtube.js +84 -0
  19. package/dist/react/callout.d.ts +37 -0
  20. package/dist/react/callout.js +113 -0
  21. package/dist/react/doc-content.d.ts +29 -0
  22. package/dist/react/doc-content.js +30 -0
  23. package/dist/react/markdown-components.d.ts +84 -0
  24. package/dist/react/markdown-components.js +122 -0
  25. package/dist/react/search-dialog.d.ts +41 -0
  26. package/dist/react/search-dialog.js +404 -0
  27. package/dist/react/sidebar.d.ts +29 -0
  28. package/dist/react/sidebar.js +196 -0
  29. package/dist/react/skip-link.d.ts +37 -0
  30. package/dist/react/skip-link.js +37 -0
  31. package/dist/react/toc.d.ts +35 -0
  32. package/dist/react/toc.js +87 -0
  33. package/dist/react/youtube.d.ts +27 -0
  34. package/dist/react/youtube.js +75 -0
  35. package/dist/render.d.ts +72 -0
  36. package/dist/render.js +279 -0
  37. package/dist/search-index.d.ts +51 -0
  38. package/dist/search-index.js +274 -0
  39. package/dist/search-options.d.ts +18 -0
  40. package/dist/search-options.js +40 -0
  41. package/dist/source.d.ts +67 -0
  42. package/dist/source.js +332 -0
  43. package/dist/styles.css +1033 -0
  44. package/dist/types.d.ts +334 -0
  45. package/dist/types.js +0 -0
  46. package/package.json +166 -0
package/dist/render.js ADDED
@@ -0,0 +1,279 @@
1
+ import { DEFAULT_DOCS_THEMES, createDocsHighlighter } from "./highlighter.js";
2
+ import { rehypeCaptureToc } from "./plugins/rehype-capture-toc.js";
3
+ import { foldSegments, remarkDocLinks } from "./plugins/remark-doc-links.js";
4
+ import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
5
+ import { remarkYouTube } from "./plugins/remark-youtube.js";
6
+ import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
7
+ import { rehypeGithubAlerts } from "rehype-github-alerts";
8
+ import rehypeAutolinkHeadings from "rehype-autolink-headings";
9
+ import rehypeSlug from "rehype-slug";
10
+ import remarkGfm from "remark-gfm";
11
+ import remarkParse from "remark-parse";
12
+ import remarkRehype from "remark-rehype";
13
+ import { unified } from "unified";
14
+ import { CONTINUE, EXIT, visit } from "unist-util-visit";
15
+ import { VFile } from "vfile";
16
+ //#region src/render.ts
17
+ /**
18
+ * Markdown to hast, in Node, at build time.
19
+ *
20
+ * The output is a plain hast tree — serialisable JSON. It crosses the RSC
21
+ * boundary, survives a `JSON.stringify` into any build-time artifact, and
22
+ * caches to disk, all without the markdown parser or Shiki following it into
23
+ * the browser. That is the product claim of this package, and stopping at hast
24
+ * (rather than stringifying to HTML, or shipping MDX) is what buys it.
25
+ */
26
+ /**
27
+ * Emit a bare `<callout type="note">` instead of GitHub's markup.
28
+ *
29
+ * The default build ships octicon SVGs and `markdown-alert` classes, which
30
+ * hardcodes an icon set and a stylesheet into the content tree. A single
31
+ * element with a `type` is enough for the React layer to map onto a real
32
+ * component that matches the rest of the site.
33
+ */
34
+ const buildCallout = (alert, children) => ({
35
+ type: "element",
36
+ tagName: "callout",
37
+ properties: { type: alert.keyword.toLowerCase() },
38
+ children
39
+ });
40
+ /** The visible text of a heading permalink. Styling lives in the stylesheet. */
41
+ const HEADING_ANCHOR_CONTENT = {
42
+ type: "text",
43
+ value: "#"
44
+ };
45
+ /**
46
+ * Directory of a content-relative path, as segments.
47
+ *
48
+ * Split on both separators: the contract spells `relativePath` with forward
49
+ * slashes, but a host that builds it from `path.relative` on Windows will not.
50
+ */
51
+ function toDirSegments(relativePath) {
52
+ return relativePath.split(/[/\\]+/).slice(0, -1).filter(Boolean);
53
+ }
54
+ /** `scheme:` — `https:`, `data:`, anything that is not ours to resolve. */
55
+ const IMAGE_HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
56
+ /**
57
+ * An image `src` folded against the page's directory, or `undefined` if it
58
+ * climbs out of the content root.
59
+ *
60
+ * Absolute (`/logo.png`), protocol-relative and schemed sources are returned
61
+ * UNCHANGED: they are already public URLs, and folding one would corrupt it.
62
+ * Everything else is relative to the file that wrote it, which is what an
63
+ * author means by `![](./diagram.png)` and what the link path has always done.
64
+ */
65
+ function foldImageSrc(src, dirSegments) {
66
+ if (src.startsWith("/") || IMAGE_HAS_SCHEME.test(src)) return src;
67
+ const segments = foldSegments(dirSegments, src);
68
+ return segments === void 0 ? void 0 : segments.join("/");
69
+ }
70
+ /** Route without its `?query` / `#anchor`, for existence checks. */
71
+ function toRouteKey(href) {
72
+ const cut = href.search(/[?#]/);
73
+ return cut === -1 ? href : href.slice(0, cut);
74
+ }
75
+ function describeLink(file, ref) {
76
+ const at = ref.line === void 0 ? "" : `:${ref.line}`;
77
+ return `${file.relativePath}${at}`;
78
+ }
79
+ /** Does the document already open on a page title? */
80
+ 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;
88
+ }
89
+ /**
90
+ * The `<h1>` a page gets when its markdown does not declare one.
91
+ *
92
+ * `DocFrontmatter.title` is documented as the `<h1>` fallback, and without it
93
+ * a page whose body starts at `## ` ships with no `h1` at all: a broken
94
+ * heading outline for anyone navigating by headings, and `page-has-heading-one`
95
+ * for every accessibility audit. No id and no permalink — the page title is
96
+ * addressable as the page.
97
+ */
98
+ function titleHeadingNode(title) {
99
+ return {
100
+ type: "element",
101
+ tagName: "h1",
102
+ properties: {},
103
+ children: [{
104
+ type: "text",
105
+ value: title
106
+ }]
107
+ };
108
+ }
109
+ /**
110
+ * Drop the `position` unist attaches to every node.
111
+ *
112
+ * The tree is the payload: it crosses the RSC boundary, so every byte is
113
+ * shipped to every reader.
114
+ * Positions are 38% of that JSON on a typical page — line and column offsets
115
+ * into a markdown file the browser does not have and cannot fetch. Nothing
116
+ * downstream reads them: link errors are reported from positions captured
117
+ * during the mdast phase, and the TOC works off ids.
118
+ */
119
+ function stripPositions(tree) {
120
+ visit(tree, (node) => {
121
+ delete node.position;
122
+ });
123
+ return tree;
124
+ }
125
+ /**
126
+ * Build the processor once and reuse it for every file.
127
+ *
128
+ * The plugin order below is load-bearing and each step depends on the one
129
+ * before it:
130
+ *
131
+ * 1. `remarkParse` — markdown to mdast.
132
+ * 2. `remarkGfm` — tables, strikethrough, task lists, autolinks.
133
+ * 3. `remarkDocLinks` — rewrite links while paths are still mdast `url`
134
+ * strings; after `remarkRehype` they are hast
135
+ * properties and the source position is gone.
136
+ * 4. `remarkUnwrapImages` — must precede `remarkRehype` for the same reason:
137
+ * the paragraph wrapper is created by the mdast to
138
+ * hast conversion's phrasing rules.
139
+ * 5. `remarkYouTube` — after `remarkDocLinks`, which leaves an external
140
+ * URL alone, and before `remarkRehype` on
141
+ * `remarkUnwrapImages`'s reasoning: this replaces
142
+ * the whole PARAGRAPH, because a block element left
143
+ * inside the `<p>` markdown wraps a link in is
144
+ * invalid HTML and hydrates as a mismatch.
145
+ * 6. `remarkRehype` — mdast to hast. `allowDangerousHtml` stays off:
146
+ * raw HTML in the source is dropped rather than
147
+ * passed through, because this package does not
148
+ * run `rehype-raw`, and `rehype-raw` on its own
149
+ * happily reparses `<script>` into the tree.
150
+ * 7. `rehypeGithubAlerts` — `> [!NOTE]` is still a blockquote at this point;
151
+ * `remark-gfm` does not implement alerts at all.
152
+ * Runs before slugging so a heading inside a
153
+ * 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 `#`.
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.
161
+ */
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, {
169
+ basePath: options.config.basePath,
170
+ ...options.linkResolver === void 0 ? {} : { resolve: options.linkResolver }
171
+ }).use(remarkUnwrapImages).use(remarkYouTube).use(remarkRehype, {
172
+ allowDangerousHtml: false,
173
+ footnoteLabelProperties: { className: ["wave-docs-sr-only"] }
174
+ }).use(rehypeGithubAlerts, { build: buildCallout }).use(rehypeSlug).use(rehypeCaptureToc).use(rehypeAutolinkHeadings, {
175
+ behavior: "append",
176
+ content: HEADING_ANCHOR_CONTENT,
177
+ properties: {
178
+ className: ["heading-anchor"],
179
+ ariaHidden: "true",
180
+ tabIndex: -1
181
+ }
182
+ }).use(rehypeShikiFromHighlighter, highlighter, {
183
+ themes,
184
+ fallbackLanguage: "text",
185
+ defaultLanguage: "text"
186
+ }).freeze();
187
+ }
188
+ /**
189
+ * Create a renderer.
190
+ *
191
+ * The processor and the highlighter are built once, eagerly, and shared by
192
+ * every call to `render`. Constructing them per file is the difference between
193
+ * a docs build that takes a second and one that takes a minute.
194
+ */
195
+ function createDocsRenderer(options) {
196
+ const processorPromise = buildProcessor(options);
197
+ const { config, imageResolver, knownRoutes } = options;
198
+ const titleHeading = options.titleHeading ?? true;
199
+ /**
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.
208
+ *
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.
214
+ *
215
+ * Absolute and external srcs are passed through untouched: `/logo.png` is
216
+ * already a public URL and `https://…` belongs to someone else.
217
+ */
218
+ async function resolveImages(tree, file, resolve) {
219
+ const images = [];
220
+ visit(tree, "element", (node) => {
221
+ if (node.tagName === "img") images.push(node);
222
+ });
223
+ const context = {
224
+ segments: file.segments,
225
+ dirSegments: toDirSegments(file.relativePath),
226
+ relativePath: file.relativePath
227
+ };
228
+ await Promise.all(images.map(async (node) => {
229
+ const src = node.properties.src;
230
+ if (typeof src !== "string" || src === "") return;
231
+ 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;
235
+ node.properties.src = resolved.src;
236
+ if (resolved.width !== void 0) node.properties.width = resolved.width;
237
+ if (resolved.height !== void 0) node.properties.height = resolved.height;
238
+ }));
239
+ }
240
+ /**
241
+ * Fail the build on a link that would have 404'd.
242
+ *
243
+ * Deliberately a throw and not a warning: the link was valid in the editor
244
+ * and on GitHub, so a warning in a build log is a warning nobody reads.
245
+ */
246
+ function assertLinks(file, refs) {
247
+ 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;
250
+ 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.`);
252
+ }
253
+ }
254
+ return { async render(file) {
255
+ const processor = await processorPromise;
256
+ const vfile = new VFile({
257
+ value: file.content,
258
+ path: file.filePath
259
+ });
260
+ vfile.data.docLinkContext = {
261
+ segments: file.segments,
262
+ dirSegments: toDirSegments(file.relativePath),
263
+ relativePath: file.relativePath
264
+ };
265
+ const hast = await processor.run(processor.parse(vfile), vfile);
266
+ if (titleHeading && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
267
+ if (imageResolver !== void 0) await resolveImages(hast, file, imageResolver);
268
+ if (config.assertLinks) assertLinks(file, vfile.data.docLinks ?? []);
269
+ return {
270
+ frontmatter: file.frontmatter,
271
+ hast: stripPositions(hast),
272
+ toc: vfile.data.toc ?? [],
273
+ segments: file.segments,
274
+ href: file.href
275
+ };
276
+ } };
277
+ }
278
+ //#endregion
279
+ export { createDocsRenderer };
@@ -0,0 +1,51 @@
1
+ import { RenderedDoc, SearchRecord } from "./types.js";
2
+ //#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
+ /**
13
+ * Split a rendered document into section-scoped {@link SearchRecord}s.
14
+ *
15
+ * One record per `h2`–`h6`, plus a lead record covering everything before the
16
+ * first heading. The lead record is always emitted: it is what makes a page
17
+ * findable by its title even when every section under it is empty.
18
+ *
19
+ * Anchors are read from the ids `rehype-slug` already put on the tree. They
20
+ * are never recomputed — a second slugger seeded independently drifts exactly
21
+ * where it hurts, on duplicate headings that carry `-1` collision suffixes.
22
+ *
23
+ * A heading with no usable id opens no section: `github-slugger` returns the
24
+ * empty string for `## 🚀`, and there is nothing to deep-link to. Its text is
25
+ * folded into the enclosing section instead, which is what `rehypeCaptureToc`
26
+ * does with the same heading — the two must not disagree about which sections
27
+ * exist.
28
+ *
29
+ * Not generic over the frontmatter type, deliberately: `frontmatter.title` is
30
+ * the only field read, and a `RenderedDoc` carrying a project's own fields is
31
+ * assignable to this signature already. A type parameter here would appear in
32
+ * every call site and constrain nothing.
33
+ */
34
+ declare function extractSearchRecords(doc: RenderedDoc, options?: ExtractSearchRecordsOptions): SearchRecord[];
35
+ /**
36
+ * Build a serialised MiniSearch index from extracted records.
37
+ *
38
+ * The return value is JSON, ready for `MiniSearch.loadJSON` on the client or
39
+ * for {@link writeSearchIndex} to put on disk.
40
+ */
41
+ declare function buildSearchIndex(records: SearchRecord[]): string;
42
+ /**
43
+ * Write the serialised index to `outFile`, creating parent directories.
44
+ *
45
+ * Returns the byte size written, so a build step can log it or assert a
46
+ * budget — a docs index that quietly crosses a megabyte is a regression
47
+ * nobody notices until the dialog takes a second to open.
48
+ */
49
+ declare function writeSearchIndex(records: SearchRecord[], outFile: string): Promise<number>;
50
+ //#endregion
51
+ export { ExtractSearchRecordsOptions, buildSearchIndex, extractSearchRecords, writeSearchIndex };
@@ -0,0 +1,274 @@
1
+ import { SEARCH_INDEX_OPTIONS } from "./search-options.js";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import MiniSearch from "minisearch";
5
+ //#region src/search-index.ts
6
+ /**
7
+ * Build-time search index construction.
8
+ *
9
+ * Node-only, and deliberately so: the markdown parser, the hast walk and
10
+ * MiniSearch's index builder all run once per build, and the browser receives
11
+ * nothing but the serialised result. `src/react/search-dialog.tsx` is the
12
+ * matching client half.
13
+ *
14
+ * MiniSearch over Fuse.js is a measured choice, not a taste one: on a 282-page
15
+ * corpus Fuse ran 96.6 ms median / 298 ms max per query against MiniSearch's
16
+ * 1.35 ms / 3.84 ms. Fuse is a fuzzy short-string matcher routinely
17
+ * misapplied to full text.
18
+ */
19
+ /** Default excerpt length, in characters, of {@link SearchRecord.text}. */
20
+ const DEFAULT_EXCERPT_LENGTH = 300;
21
+ /** `<h1>`…`<h6>` to their numeric depth. */
22
+ const HEADING_DEPTHS = /* @__PURE__ */ new Map([
23
+ ["h1", 1],
24
+ ["h2", 2],
25
+ ["h3", 3],
26
+ ["h4", 4],
27
+ ["h5", 5],
28
+ ["h6", 6]
29
+ ]);
30
+ /**
31
+ * Elements whose text never belongs in the index.
32
+ *
33
+ * `pre` is the important one: after Shiki, a code block is hundreds of
34
+ * `<span class="line">` token wrappers whose text indexes as a bag of
35
+ * punctuation and keyword fragments. It inflates the index and poisons
36
+ * relevance. Inline `code` is kept — `useMemo` is exactly the sort of thing
37
+ * people search for.
38
+ */
39
+ const SKIPPED_TAGS = /* @__PURE__ */ new Set([
40
+ "pre",
41
+ "script",
42
+ "style",
43
+ "svg",
44
+ "template"
45
+ ]);
46
+ /**
47
+ * Containers that merely wrap content rather than nesting it semantically.
48
+ *
49
+ * Walking through them keeps heading detection working when a rehype plugin
50
+ * (or a consumer's own) wraps the document body, without descending into
51
+ * blockquotes or list items where a heading is not a section boundary.
52
+ */
53
+ const TRANSPARENT_TAGS = /* @__PURE__ */ new Set([
54
+ "div",
55
+ "section",
56
+ "article",
57
+ "main"
58
+ ]);
59
+ /**
60
+ * The GFM footnote block `mdast-util-to-hast` appends, and everything in it.
61
+ *
62
+ * It is a `section` — so {@link TRANSPARENT_TAGS} would otherwise walk straight
63
+ * into it — carrying a generated `<h2 id="footnote-label">Footnotes</h2>`. That
64
+ * heading is machinery, not a section of the page: indexing it puts a
65
+ * `Footnotes` hit in the dialog for every footnoted page, and the footnote text
66
+ * itself is already indexed where it was written. `rehypeCaptureToc` skips the
67
+ * same subtree, so the TOC and the index agree.
68
+ */
69
+ function isFootnotes(node) {
70
+ return node.properties.dataFootnotes !== void 0;
71
+ }
72
+ /** Tags after which extracted text needs a separator. */
73
+ const BLOCK_TAGS = /* @__PURE__ */ new Set([
74
+ "address",
75
+ "blockquote",
76
+ "br",
77
+ "dd",
78
+ "details",
79
+ "div",
80
+ "dl",
81
+ "dt",
82
+ "figcaption",
83
+ "figure",
84
+ "h1",
85
+ "h2",
86
+ "h3",
87
+ "h4",
88
+ "h5",
89
+ "h6",
90
+ "hr",
91
+ "li",
92
+ "ol",
93
+ "p",
94
+ "section",
95
+ "summary",
96
+ "table",
97
+ "tbody",
98
+ "td",
99
+ "th",
100
+ "thead",
101
+ "tr",
102
+ "ul"
103
+ ]);
104
+ /**
105
+ * Split a rendered document into section-scoped {@link SearchRecord}s.
106
+ *
107
+ * One record per `h2`–`h6`, plus a lead record covering everything before the
108
+ * first heading. The lead record is always emitted: it is what makes a page
109
+ * findable by its title even when every section under it is empty.
110
+ *
111
+ * Anchors are read from the ids `rehype-slug` already put on the tree. They
112
+ * are never recomputed — a second slugger seeded independently drifts exactly
113
+ * where it hurts, on duplicate headings that carry `-1` collision suffixes.
114
+ *
115
+ * A heading with no usable id opens no section: `github-slugger` returns the
116
+ * empty string for `## 🚀`, and there is nothing to deep-link to. Its text is
117
+ * folded into the enclosing section instead, which is what `rehypeCaptureToc`
118
+ * does with the same heading — the two must not disagree about which sections
119
+ * exist.
120
+ *
121
+ * Not generic over the frontmatter type, deliberately: `frontmatter.title` is
122
+ * the only field read, and a `RenderedDoc` carrying a project's own fields is
123
+ * assignable to this signature already. A type parameter here would appear in
124
+ * every call site and constrain nothing.
125
+ */
126
+ function extractSearchRecords(doc, options = {}) {
127
+ const excerptLength = options.excerptLength ?? DEFAULT_EXCERPT_LENGTH;
128
+ const title = doc.frontmatter.title;
129
+ const records = [];
130
+ const slug = doc.segments.join("/");
131
+ /** Open headings, outermost first, that enclose the current section. */
132
+ const ancestors = [];
133
+ let section = {
134
+ heading: title,
135
+ anchor: void 0,
136
+ ancestors: [],
137
+ parts: []
138
+ };
139
+ const flush = () => {
140
+ const href = section.anchor === void 0 ? doc.href : `${doc.href}#${section.anchor}`;
141
+ const id = section.anchor === void 0 ? slug : `${slug}#${section.anchor}`;
142
+ records.push({
143
+ id,
144
+ title,
145
+ heading: section.heading,
146
+ ancestors: section.ancestors,
147
+ href,
148
+ text: truncateAtWordBoundary(collapseWhitespace(section.parts.join(" ")), excerptLength)
149
+ });
150
+ };
151
+ for (const node of iterateBlocks(doc.hast.children)) {
152
+ if (node.type === "element") {
153
+ const depth = HEADING_DEPTHS.get(node.tagName);
154
+ if (depth === 1) continue;
155
+ if (depth !== void 0) {
156
+ const anchor = readHeadingId(node);
157
+ const heading = extractHeadingText(node);
158
+ if (anchor === void 0) {
159
+ if (heading !== "") section.parts.push(heading);
160
+ continue;
161
+ }
162
+ flush();
163
+ while (ancestors.length > 0) {
164
+ const open = ancestors[ancestors.length - 1];
165
+ if (open === void 0 || open.depth < depth) break;
166
+ ancestors.pop();
167
+ }
168
+ section = {
169
+ heading,
170
+ anchor,
171
+ ancestors: ancestors.map((ancestor) => ancestor.text),
172
+ parts: []
173
+ };
174
+ ancestors.push({
175
+ depth,
176
+ text: heading
177
+ });
178
+ continue;
179
+ }
180
+ }
181
+ const text = collapseWhitespace(extractText(node));
182
+ if (text !== "") section.parts.push(text);
183
+ }
184
+ flush();
185
+ return records;
186
+ }
187
+ /**
188
+ * Yield block-level nodes in document order, stepping through wrapper
189
+ * elements so a heading inside one still reads as a section boundary.
190
+ */
191
+ function* iterateBlocks(nodes) {
192
+ for (const node of nodes) {
193
+ if (node.type === "element" && isFootnotes(node)) continue;
194
+ if (node.type === "element" && TRANSPARENT_TAGS.has(node.tagName)) yield* iterateBlocks(node.children);
195
+ else yield node;
196
+ }
197
+ }
198
+ /** Plain text of a node, minus code blocks and presentational cruft. */
199
+ function extractText(node) {
200
+ const parts = [];
201
+ collectText(node, parts);
202
+ return parts.join("");
203
+ }
204
+ function collectText(node, out) {
205
+ if (node.type === "text") {
206
+ out.push(node.value);
207
+ return;
208
+ }
209
+ if (node.type !== "element") return;
210
+ if (SKIPPED_TAGS.has(node.tagName) || isPresentational(node)) return;
211
+ for (const child of node.children) collectText(child, out);
212
+ if (BLOCK_TAGS.has(node.tagName)) out.push(" ");
213
+ }
214
+ /**
215
+ * `aria-hidden` / `hidden` elements are decoration — the anchor icon
216
+ * `rehype-autolink-headings` appends, the alert icons `rehype-github-alerts`
217
+ * injects — and none of it is text a reader would search for.
218
+ */
219
+ function isPresentational(node) {
220
+ const properties = node.properties;
221
+ if (properties === void 0) return false;
222
+ const ariaHidden = properties.ariaHidden;
223
+ const hidden = properties.hidden;
224
+ return ariaHidden === true || ariaHidden === "true" || hidden === true;
225
+ }
226
+ function extractHeadingText(node) {
227
+ return collapseWhitespace(extractText(node)).replace(/\s+#+$/, "");
228
+ }
229
+ /** The heading's anchor, or `undefined` when nothing can link to it. */
230
+ function readHeadingId(node) {
231
+ const id = node.properties?.id;
232
+ return typeof id === "string" && id !== "" ? id : void 0;
233
+ }
234
+ function collapseWhitespace(text) {
235
+ return text.replace(/\s+/g, " ").trim();
236
+ }
237
+ /**
238
+ * Cut `text` to at most `limit` characters, on a word boundary where one is
239
+ * near enough to the cut to be worth honouring.
240
+ */
241
+ function truncateAtWordBoundary(text, limit) {
242
+ if (limit <= 0) return "";
243
+ if (text.length <= limit) return text;
244
+ const slice = text.slice(0, limit);
245
+ const lastSpace = slice.lastIndexOf(" ");
246
+ return `${(lastSpace > limit / 2 ? slice.slice(0, lastSpace) : slice).trimEnd()}…`;
247
+ }
248
+ /**
249
+ * Build a serialised MiniSearch index from extracted records.
250
+ *
251
+ * The return value is JSON, ready for `MiniSearch.loadJSON` on the client or
252
+ * for {@link writeSearchIndex} to put on disk.
253
+ */
254
+ function buildSearchIndex(records) {
255
+ const index = new MiniSearch(SEARCH_INDEX_OPTIONS);
256
+ index.addAll(records);
257
+ return JSON.stringify(index);
258
+ }
259
+ /**
260
+ * Write the serialised index to `outFile`, creating parent directories.
261
+ *
262
+ * Returns the byte size written, so a build step can log it or assert a
263
+ * budget — a docs index that quietly crosses a megabyte is a regression
264
+ * nobody notices until the dialog takes a second to open.
265
+ */
266
+ async function writeSearchIndex(records, outFile) {
267
+ const json = buildSearchIndex(records);
268
+ const absolute = path.resolve(outFile);
269
+ await mkdir(path.dirname(absolute), { recursive: true });
270
+ await writeFile(absolute, json, "utf8");
271
+ return Buffer.byteLength(json, "utf8");
272
+ }
273
+ //#endregion
274
+ export { buildSearchIndex, extractSearchRecords, writeSearchIndex };
@@ -0,0 +1,18 @@
1
+ import { SearchRecord } from "./types.js";
2
+ import { Options } from "minisearch";
3
+ //#region src/search-options.d.ts
4
+ /**
5
+ * Fields, stored fields and query defaults for the docs index.
6
+ *
7
+ * `storeFields` MUST be applied at build time. It decides what
8
+ * `MiniSearch.toJSON()` carries; passing it to `loadJSON` on the client cannot
9
+ * recover fields the serialised index never stored, and the failure is silent
10
+ * — results arrive with an id and a score and nothing to render.
11
+ *
12
+ * `combineWith: 'AND'` is not MiniSearch's default and is not optional here.
13
+ * The default OR returned 68–131 hits on real queries where AND returned a
14
+ * usable handful.
15
+ */
16
+ declare const SEARCH_INDEX_OPTIONS: Options<SearchRecord>;
17
+ //#endregion
18
+ export { SEARCH_INDEX_OPTIONS };
@@ -0,0 +1,40 @@
1
+ //#region src/search-options.ts
2
+ /**
3
+ * Fields, stored fields and query defaults for the docs index.
4
+ *
5
+ * `storeFields` MUST be applied at build time. It decides what
6
+ * `MiniSearch.toJSON()` carries; passing it to `loadJSON` on the client cannot
7
+ * recover fields the serialised index never stored, and the failure is silent
8
+ * — results arrive with an id and a score and nothing to render.
9
+ *
10
+ * `combineWith: 'AND'` is not MiniSearch's default and is not optional here.
11
+ * The default OR returned 68–131 hits on real queries where AND returned a
12
+ * usable handful.
13
+ */
14
+ const SEARCH_INDEX_OPTIONS = {
15
+ fields: [
16
+ "title",
17
+ "heading",
18
+ "text",
19
+ "ancestors"
20
+ ],
21
+ storeFields: [
22
+ "title",
23
+ "heading",
24
+ "ancestors",
25
+ "href"
26
+ ],
27
+ searchOptions: {
28
+ prefix: true,
29
+ fuzzy: .2,
30
+ combineWith: "AND",
31
+ boost: {
32
+ title: 4,
33
+ heading: 3,
34
+ text: 2,
35
+ ancestors: 1
36
+ }
37
+ }
38
+ };
39
+ //#endregion
40
+ export { SEARCH_INDEX_OPTIONS };