@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.
- package/LICENSE +21 -0
- package/README.md +518 -0
- package/dist/frontmatter.d.ts +55 -0
- package/dist/frontmatter.js +80 -0
- package/dist/highlighter.d.ts +99 -0
- package/dist/highlighter.js +183 -0
- package/dist/meta.d.ts +75 -0
- package/dist/meta.js +183 -0
- package/dist/next.d.ts +256 -0
- package/dist/next.js +365 -0
- package/dist/plugins/rehype-capture-toc.d.ts +18 -0
- package/dist/plugins/rehype-capture-toc.js +69 -0
- package/dist/plugins/remark-doc-links.d.ts +63 -0
- package/dist/plugins/remark-doc-links.js +122 -0
- package/dist/plugins/remark-unwrap-images.d.ts +11 -0
- package/dist/plugins/remark-unwrap-images.js +25 -0
- package/dist/plugins/remark-youtube.d.ts +22 -0
- package/dist/plugins/remark-youtube.js +84 -0
- package/dist/react/callout.d.ts +37 -0
- package/dist/react/callout.js +113 -0
- package/dist/react/doc-content.d.ts +29 -0
- package/dist/react/doc-content.js +30 -0
- package/dist/react/markdown-components.d.ts +84 -0
- package/dist/react/markdown-components.js +122 -0
- package/dist/react/search-dialog.d.ts +41 -0
- package/dist/react/search-dialog.js +404 -0
- package/dist/react/sidebar.d.ts +29 -0
- package/dist/react/sidebar.js +196 -0
- package/dist/react/skip-link.d.ts +37 -0
- package/dist/react/skip-link.js +37 -0
- package/dist/react/toc.d.ts +35 -0
- package/dist/react/toc.js +87 -0
- package/dist/react/youtube.d.ts +27 -0
- package/dist/react/youtube.js +75 -0
- package/dist/render.d.ts +72 -0
- package/dist/render.js +279 -0
- package/dist/search-index.d.ts +51 -0
- package/dist/search-index.js +274 -0
- package/dist/search-options.d.ts +18 -0
- package/dist/search-options.js +40 -0
- package/dist/source.d.ts +67 -0
- package/dist/source.js +332 -0
- package/dist/styles.css +1033 -0
- package/dist/types.d.ts +334 -0
- package/dist/types.js +0 -0
- 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 `` 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 };
|