@microsoft/rayfin-docs 1.27.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/assets/catalog.json +132 -0
- package/dist/_internal/build-index.d.ts +14 -0
- package/dist/_internal/build-index.js +13 -0
- package/dist/catalog-types.d.ts +52 -0
- package/dist/catalog-types.js +21 -0
- package/dist/catalog.d.ts +85 -0
- package/dist/catalog.js +227 -0
- package/dist/discovery.d.ts +110 -0
- package/dist/discovery.js +402 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.js +422 -0
- package/dist/loader.d.ts +21 -0
- package/dist/loader.js +296 -0
- package/dist/search.d.ts +72 -0
- package/dist/search.js +254 -0
- package/dist/site-cjs/discovery.js +409 -0
- package/dist/site-cjs/package.json +1 -0
- package/dist/site-cjs/types.js +2 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.js +2 -0
- package/package.json +62 -0
package/dist/loader.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from 'fs';
|
|
2
|
+
import { basename, extname, join, relative, sep } from 'path';
|
|
3
|
+
import { docKindToModule } from './discovery.js';
|
|
4
|
+
const DEFAULT_MODULES = ['guide', 'host', 'ts-sdk'];
|
|
5
|
+
function parseFrontMatter(markdown) {
|
|
6
|
+
const lines = markdown.split(/\r?\n/);
|
|
7
|
+
if (lines[0]?.trim() !== '---') {
|
|
8
|
+
return { attributes: {}, body: markdown };
|
|
9
|
+
}
|
|
10
|
+
let endIndex = -1;
|
|
11
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
12
|
+
if (lines[i]?.trim() === '---') {
|
|
13
|
+
endIndex = i;
|
|
14
|
+
break;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
if (endIndex === -1) {
|
|
18
|
+
return { attributes: {}, body: markdown };
|
|
19
|
+
}
|
|
20
|
+
const frontMatterLines = lines.slice(1, endIndex);
|
|
21
|
+
const attributes = {};
|
|
22
|
+
let currentArrayKey = null;
|
|
23
|
+
for (const line of frontMatterLines) {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (trimmed.startsWith('- ') && currentArrayKey) {
|
|
29
|
+
const value = trimmed.slice(2).trim();
|
|
30
|
+
const existing = attributes[currentArrayKey];
|
|
31
|
+
if (Array.isArray(existing)) {
|
|
32
|
+
existing.push(value);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
attributes[currentArrayKey] = [value];
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const separatorIndex = trimmed.indexOf(':');
|
|
40
|
+
if (separatorIndex === -1) {
|
|
41
|
+
currentArrayKey = null;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const key = trimmed.slice(0, separatorIndex).trim();
|
|
45
|
+
let value = trimmed.slice(separatorIndex + 1).trim();
|
|
46
|
+
if (!value) {
|
|
47
|
+
attributes[key] = [];
|
|
48
|
+
currentArrayKey = key;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
52
|
+
const inner = value.slice(1, -1).trim();
|
|
53
|
+
const parts = inner ? inner.split(',').map((part) => part.trim()) : [];
|
|
54
|
+
attributes[key] = parts.filter(Boolean);
|
|
55
|
+
currentArrayKey = null;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
59
|
+
value = value.slice(1, -1);
|
|
60
|
+
}
|
|
61
|
+
attributes[key] = value;
|
|
62
|
+
currentArrayKey = null;
|
|
63
|
+
}
|
|
64
|
+
const body = lines.slice(endIndex + 1).join('\n');
|
|
65
|
+
return { attributes, body };
|
|
66
|
+
}
|
|
67
|
+
function extractTitle(markdown) {
|
|
68
|
+
const match = markdown.match(/^#\s+(.+)$/m);
|
|
69
|
+
return match?.[1]?.trim();
|
|
70
|
+
}
|
|
71
|
+
function extractSymbolsFromHeading(heading) {
|
|
72
|
+
const symbols = new Set();
|
|
73
|
+
const cleaned = heading.replace(/`/g, '').trim();
|
|
74
|
+
const typedMatch = cleaned.match(/^(Class|Interface|Type|Function|Method|Enum|Variable|Decorator|Namespace)\s*:\s*(.+)$/i);
|
|
75
|
+
if (typedMatch?.[2]) {
|
|
76
|
+
symbols.add(typedMatch[2].trim());
|
|
77
|
+
}
|
|
78
|
+
const backtickMatches = heading.match(/`([^`]+)`/g) ?? [];
|
|
79
|
+
for (const match of backtickMatches) {
|
|
80
|
+
const value = match.replace(/`/g, '').trim();
|
|
81
|
+
if (value) {
|
|
82
|
+
symbols.add(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const simpleMatch = cleaned.match(/^(?:\w+\s*:\s*)?([A-Za-z0-9_.$<>-]+)$/);
|
|
86
|
+
if (simpleMatch?.[1]) {
|
|
87
|
+
symbols.add(simpleMatch[1]);
|
|
88
|
+
}
|
|
89
|
+
return [...symbols].filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
function extractSymbolsFromFilename(filePath) {
|
|
92
|
+
const name = basename(filePath, extname(filePath));
|
|
93
|
+
if (!name ||
|
|
94
|
+
name.toLowerCase() === 'index' ||
|
|
95
|
+
name.toLowerCase() === 'readme') {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
return [name];
|
|
99
|
+
}
|
|
100
|
+
function parseSections(markdown) {
|
|
101
|
+
const lines = markdown.split(/\r?\n/);
|
|
102
|
+
const sections = [];
|
|
103
|
+
let currentHeading = 'Overview';
|
|
104
|
+
let currentLevel = 1;
|
|
105
|
+
let buffer = [];
|
|
106
|
+
const flush = () => {
|
|
107
|
+
const content = buffer.join('\n').trim();
|
|
108
|
+
if (!content && currentHeading === 'Overview') {
|
|
109
|
+
buffer = [];
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
sections.push({
|
|
113
|
+
heading: currentHeading,
|
|
114
|
+
level: currentLevel,
|
|
115
|
+
content,
|
|
116
|
+
symbols: extractSymbolsFromHeading(currentHeading),
|
|
117
|
+
});
|
|
118
|
+
buffer = [];
|
|
119
|
+
};
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
const headingMatch = line.match(/^(#{2,3})\s+(.+)$/);
|
|
122
|
+
if (headingMatch) {
|
|
123
|
+
flush();
|
|
124
|
+
currentHeading = headingMatch[2].trim();
|
|
125
|
+
currentLevel = headingMatch[1].length;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
buffer.push(line);
|
|
129
|
+
}
|
|
130
|
+
flush();
|
|
131
|
+
return sections.filter((section) => section.content || section.heading !== 'Overview');
|
|
132
|
+
}
|
|
133
|
+
function collectMarkdownFiles(dir, visited = new Set(), realRoot) {
|
|
134
|
+
const realDir = realpathSync.native(dir);
|
|
135
|
+
if (realRoot && !realPathIsSelfOrDescendant(realDir, realRoot)) {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
if (visited.has(realDir)) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
visited.add(realDir);
|
|
142
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
143
|
+
const files = [];
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const fullPath = join(dir, entry.name);
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
// Skip `preview` directories entirely. Preview docs are intentionally
|
|
148
|
+
// excluded from both the published Docusaurus build and the MCP/docs
|
|
149
|
+
// index so that feature-flagged or in-progress content is not surfaced.
|
|
150
|
+
if (entry.name === 'preview') {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
let realEntryDir;
|
|
154
|
+
try {
|
|
155
|
+
realEntryDir = realpathSync.native(fullPath);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (realRoot && !realPathIsSelfOrDescendant(realEntryDir, realRoot)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
files.push(...collectMarkdownFiles(fullPath, visited, realRoot));
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (entry.isFile() && extname(entry.name).toLowerCase() === '.md') {
|
|
167
|
+
files.push(fullPath);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return files;
|
|
171
|
+
}
|
|
172
|
+
function realPathIsSelfOrDescendant(path, root) {
|
|
173
|
+
const normalizedPath = process.platform === 'win32' ? path.toLowerCase() : path;
|
|
174
|
+
const normalizedRoot = process.platform === 'win32' ? root.toLowerCase() : root;
|
|
175
|
+
return (normalizedPath === normalizedRoot ||
|
|
176
|
+
normalizedPath.startsWith(normalizedRoot + sep));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Build a single `DocEntry` from a markdown file. Shared by both the
|
|
180
|
+
* legacy {@link loadDocs} (bundled `assetsRoot/docs/<module>/...` layout)
|
|
181
|
+
* and the per-package {@link loadDocsFromPackage} (Phase 2+).
|
|
182
|
+
*
|
|
183
|
+
* `idPrefix` and `pathRoot` decouple the entry's identity (`<prefix>:<path>`)
|
|
184
|
+
* from the actual file location, so per-package layouts can produce stable
|
|
185
|
+
* IDs without leaking package roots into the entry.
|
|
186
|
+
*/
|
|
187
|
+
function buildEntry(filePath, module, pathRoot, source, idPrefix = module) {
|
|
188
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
189
|
+
const frontMatter = parseFrontMatter(raw);
|
|
190
|
+
const content = frontMatter.body.trim();
|
|
191
|
+
const title = (typeof frontMatter.attributes.title === 'string'
|
|
192
|
+
? frontMatter.attributes.title
|
|
193
|
+
: undefined) ??
|
|
194
|
+
extractTitle(content) ??
|
|
195
|
+
basename(filePath, extname(filePath));
|
|
196
|
+
const frontMatterSymbols = frontMatter.attributes.symbols;
|
|
197
|
+
const symbolsFromFrontMatter = Array.isArray(frontMatterSymbols)
|
|
198
|
+
? frontMatterSymbols
|
|
199
|
+
: typeof frontMatterSymbols === 'string'
|
|
200
|
+
? [frontMatterSymbols]
|
|
201
|
+
: [];
|
|
202
|
+
const sections = parseSections(content);
|
|
203
|
+
const symbols = new Set([
|
|
204
|
+
...symbolsFromFrontMatter,
|
|
205
|
+
...extractSymbolsFromFilename(filePath),
|
|
206
|
+
]);
|
|
207
|
+
for (const section of sections) {
|
|
208
|
+
for (const symbol of section.symbols) {
|
|
209
|
+
symbols.add(symbol);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const relativePath = relative(pathRoot, filePath).replace(/\\/g, '/');
|
|
213
|
+
const entry = {
|
|
214
|
+
id: `${idPrefix}:${relativePath}`,
|
|
215
|
+
module,
|
|
216
|
+
path: relativePath,
|
|
217
|
+
title,
|
|
218
|
+
content,
|
|
219
|
+
symbols: [...symbols].filter(Boolean),
|
|
220
|
+
sections,
|
|
221
|
+
};
|
|
222
|
+
if (source) {
|
|
223
|
+
entry.source = source;
|
|
224
|
+
}
|
|
225
|
+
return entry;
|
|
226
|
+
}
|
|
227
|
+
export function loadDocs(assetsRoot, modules = DEFAULT_MODULES) {
|
|
228
|
+
const entries = [];
|
|
229
|
+
for (const moduleName of modules) {
|
|
230
|
+
const moduleRoot = join(assetsRoot, 'docs', moduleName);
|
|
231
|
+
if (!existsSync(moduleRoot)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const files = collectMarkdownFiles(moduleRoot);
|
|
235
|
+
for (const filePath of files) {
|
|
236
|
+
entries.push(buildEntry(filePath, moduleName, assetsRoot));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return entries;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Walk the docs tree of a discovered package and produce {@link DocEntry}
|
|
243
|
+
* items tagged with the package's source metadata.
|
|
244
|
+
*
|
|
245
|
+
* Per design.md, discovered packages have a flat `<packageRoot>/<dir>/`
|
|
246
|
+
* layout (no `/docs/<module>/` nesting). The manifest's `kind` is
|
|
247
|
+
* mapped to a legacy `DocModule` so existing CLI/MCP filters
|
|
248
|
+
* (`--module ts-sdk`) keep working through the migration.
|
|
249
|
+
*
|
|
250
|
+
* **Symlink safety.** The manifest validator in `discovery.ts` rejects
|
|
251
|
+
* `..` segments in the manifest's `dir` string, but a malicious package
|
|
252
|
+
* could still place a symlink at `<packageRoot>/<dir>` or inside that
|
|
253
|
+
* tree pointing to e.g. `/etc/passwd`. We resolve the package root,
|
|
254
|
+
* docs root, walked directories, and every walked file's realpath, then
|
|
255
|
+
* skip anything that escapes the trusted package/docs boundaries.
|
|
256
|
+
*/
|
|
257
|
+
export function loadDocsFromPackage(pkg) {
|
|
258
|
+
const docsRoot = join(pkg.packageRoot, pkg.manifest.dir);
|
|
259
|
+
if (!existsSync(docsRoot)) {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
const realPackageRoot = realpathSync.native(pkg.packageRoot);
|
|
263
|
+
const realDocsRoot = realpathSync.native(docsRoot);
|
|
264
|
+
if (!realPathIsSelfOrDescendant(realDocsRoot, realPackageRoot)) {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
const module = docKindToModule(pkg.manifest.kind);
|
|
268
|
+
const source = {
|
|
269
|
+
module: pkg.manifest.module,
|
|
270
|
+
kind: pkg.manifest.kind,
|
|
271
|
+
packageName: pkg.packageName,
|
|
272
|
+
packageVersion: pkg.packageVersion,
|
|
273
|
+
};
|
|
274
|
+
const files = collectMarkdownFiles(docsRoot, new Set(), realDocsRoot);
|
|
275
|
+
const safe = [];
|
|
276
|
+
for (const filePath of files) {
|
|
277
|
+
let real;
|
|
278
|
+
try {
|
|
279
|
+
real = realpathSync.native(filePath);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Broken symlink or missing file by the time we resolve. Skip
|
|
283
|
+
// rather than treating as fatal — discovery is defensive.
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
// realDocsRoot ends without a trailing separator; require the
|
|
287
|
+
// resolved real path to be `realDocsRoot` itself or a descendant
|
|
288
|
+
// (`realDocsRoot/...`). Reject anything else as a symlink escape.
|
|
289
|
+
if (!realPathIsSelfOrDescendant(real, realDocsRoot)) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
safe.push(filePath);
|
|
293
|
+
}
|
|
294
|
+
return safe.map((filePath) => buildEntry(filePath, module, docsRoot, source, pkg.manifest.module));
|
|
295
|
+
}
|
|
296
|
+
//# sourceMappingURL=loader.js.map
|
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import MiniSearch, { type Options as MiniSearchOptions } from 'minisearch';
|
|
2
|
+
import type { DocEntry, DocModule, DocSearchResult, DocSearchScope, DocSectionRef } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Schema version for the on-disk prebuilt search index
|
|
5
|
+
* (`assets/docs.search.json`). Bumped on any breaking change to the
|
|
6
|
+
* serialized structure so a stale prebuilt forces a runtime fallback.
|
|
7
|
+
*
|
|
8
|
+
* Version 2: stores the MiniSearch index as a plain object (consumed by
|
|
9
|
+
* `MiniSearch.loadJS`) instead of a pre-stringified `miniJSON` field.
|
|
10
|
+
* Avoids escape-bloating the asset and a redundant second `JSON.parse`
|
|
11
|
+
* on the cold-start path that every CLI invocation pays.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DOCS_INDEX_SCHEMA_VERSION = 2;
|
|
14
|
+
/**
|
|
15
|
+
* Single source of truth for MiniSearch construction. Both `createDocsIndex`
|
|
16
|
+
* (build-time and runtime source-build path) and `MiniSearch.loadJS`
|
|
17
|
+
* (runtime prebuilt path) read from this object so a config change in source
|
|
18
|
+
* is automatically reflected by `DOCS_SEARCH_CONFIG_FINGERPRINT` below; a
|
|
19
|
+
* fingerprint mismatch triggers a runtime fallback.
|
|
20
|
+
*
|
|
21
|
+
* Frozen at module load so a runtime mutation can't desync from the
|
|
22
|
+
* fingerprint stored in the prebuilt envelope.
|
|
23
|
+
*/
|
|
24
|
+
export declare const DOCS_SEARCH_OPTIONS: MiniSearchOptions<DocEntry>;
|
|
25
|
+
/**
|
|
26
|
+
* Stable fingerprint of `DOCS_SEARCH_OPTIONS`. Used in the prebuilt index
|
|
27
|
+
* envelope so a runtime that reads a prebuilt JSON with a different config
|
|
28
|
+
* falls back to source-building rather than silently using stale options.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DOCS_SEARCH_CONFIG_FINGERPRINT: string;
|
|
31
|
+
export interface DocsIndex {
|
|
32
|
+
mini: MiniSearch<DocEntry>;
|
|
33
|
+
entriesById: Map<string, DocEntry>;
|
|
34
|
+
symbolToSections: Map<string, DocSectionRef[]>;
|
|
35
|
+
}
|
|
36
|
+
export declare function createDocsIndex(entries: DocEntry[]): DocsIndex;
|
|
37
|
+
/**
|
|
38
|
+
* Envelope written to `assets/docs.search.json` by the build-time index
|
|
39
|
+
* emitter and read by `loadDocsIndexFromPrebuilt` at runtime. Versioned and
|
|
40
|
+
* fingerprinted so a runtime that finds a stale or mismatched prebuilt
|
|
41
|
+
* silently falls back to source-building rather than serving incorrect data.
|
|
42
|
+
*
|
|
43
|
+
* `miniSerialized` holds the plain-object form returned by
|
|
44
|
+
* `MiniSearch#toJSON()`. Storing the live object (not a pre-stringified
|
|
45
|
+
* inner string) means the outer `JSON.stringify` of the envelope handles
|
|
46
|
+
* serialization exactly once, and the runtime load is a single
|
|
47
|
+
* `JSON.parse` followed by `MiniSearch.loadJS` — no double-parse and no
|
|
48
|
+
* escape-bloated string.
|
|
49
|
+
*
|
|
50
|
+
* `symbolToSections` is intentionally NOT persisted. We tried in PR #1143
|
|
51
|
+
* round 3 and the empirical eval showed the larger JSON parse cost ate
|
|
52
|
+
* the rebuild savings - net mean +3.4 ms across the default suite. The
|
|
53
|
+
* runtime walk over `entries` is the right tradeoff.
|
|
54
|
+
*/
|
|
55
|
+
export interface PrebuiltDocsIndex {
|
|
56
|
+
schemaVersion: number;
|
|
57
|
+
configFingerprint: string;
|
|
58
|
+
modules: DocModule[];
|
|
59
|
+
entries: DocEntry[];
|
|
60
|
+
miniSerialized: ReturnType<MiniSearch<DocEntry>['toJSON']>;
|
|
61
|
+
}
|
|
62
|
+
/** Build the on-disk envelope from a freshly constructed in-memory index. */
|
|
63
|
+
export declare function serializeDocsIndex(entries: DocEntry[], modules: DocModule[], index: DocsIndex): PrebuiltDocsIndex;
|
|
64
|
+
/**
|
|
65
|
+
* Rehydrate a `DocsIndex` from a prebuilt envelope. The caller is expected
|
|
66
|
+
* to have already validated that the envelope's `schemaVersion`,
|
|
67
|
+
* `configFingerprint`, and `modules` match what the runtime needs - this
|
|
68
|
+
* helper trusts those invariants.
|
|
69
|
+
*/
|
|
70
|
+
export declare function loadDocsIndexFromPrebuilt(prebuilt: PrebuiltDocsIndex): DocsIndex;
|
|
71
|
+
export declare function searchDocs(index: DocsIndex, query: string, moduleFilter?: DocModule, limit?: number, scope?: DocSearchScope): DocSearchResult[];
|
|
72
|
+
//# sourceMappingURL=search.d.ts.map
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import MiniSearch from 'minisearch';
|
|
2
|
+
/**
|
|
3
|
+
* Schema version for the on-disk prebuilt search index
|
|
4
|
+
* (`assets/docs.search.json`). Bumped on any breaking change to the
|
|
5
|
+
* serialized structure so a stale prebuilt forces a runtime fallback.
|
|
6
|
+
*
|
|
7
|
+
* Version 2: stores the MiniSearch index as a plain object (consumed by
|
|
8
|
+
* `MiniSearch.loadJS`) instead of a pre-stringified `miniJSON` field.
|
|
9
|
+
* Avoids escape-bloating the asset and a redundant second `JSON.parse`
|
|
10
|
+
* on the cold-start path that every CLI invocation pays.
|
|
11
|
+
*/
|
|
12
|
+
export const DOCS_INDEX_SCHEMA_VERSION = 2;
|
|
13
|
+
/**
|
|
14
|
+
* Single source of truth for MiniSearch construction. Both `createDocsIndex`
|
|
15
|
+
* (build-time and runtime source-build path) and `MiniSearch.loadJS`
|
|
16
|
+
* (runtime prebuilt path) read from this object so a config change in source
|
|
17
|
+
* is automatically reflected by `DOCS_SEARCH_CONFIG_FINGERPRINT` below; a
|
|
18
|
+
* fingerprint mismatch triggers a runtime fallback.
|
|
19
|
+
*
|
|
20
|
+
* Frozen at module load so a runtime mutation can't desync from the
|
|
21
|
+
* fingerprint stored in the prebuilt envelope.
|
|
22
|
+
*/
|
|
23
|
+
export const DOCS_SEARCH_OPTIONS = Object.freeze({
|
|
24
|
+
fields: ['title', 'content', 'symbols'],
|
|
25
|
+
// Keep storeFields minimal - everything the search/snippet path needs
|
|
26
|
+
// is fetched via `entriesById`, not the index. `module` is stored only
|
|
27
|
+
// because some callers filter on it directly from MiniSearch results.
|
|
28
|
+
storeFields: ['module'],
|
|
29
|
+
searchOptions: Object.freeze({
|
|
30
|
+
boost: Object.freeze({ title: 2, symbols: 1.5 }),
|
|
31
|
+
prefix: true,
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* Recursively-stable JSON.stringify that sorts keys at every nesting level.
|
|
36
|
+
* Plain `JSON.stringify(obj, Object.keys(obj).sort())` only applies the
|
|
37
|
+
* key whitelist at the top level — nested object keys silently disappear,
|
|
38
|
+
* which made `DOCS_SEARCH_CONFIG_FINGERPRINT` blind to changes inside
|
|
39
|
+
* `searchOptions.boost`, `searchOptions.prefix`, etc. Captured by the
|
|
40
|
+
* deep-review pass on PR #1143.
|
|
41
|
+
*/
|
|
42
|
+
function stableStringify(value) {
|
|
43
|
+
if (value === null || typeof value !== 'object') {
|
|
44
|
+
return JSON.stringify(value);
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
return `[${value.map((entry) => stableStringify(entry)).join(',')}]`;
|
|
48
|
+
}
|
|
49
|
+
const keys = Object.keys(value).sort();
|
|
50
|
+
const parts = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
|
|
51
|
+
return `{${parts.join(',')}}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Stable fingerprint of `DOCS_SEARCH_OPTIONS`. Used in the prebuilt index
|
|
55
|
+
* envelope so a runtime that reads a prebuilt JSON with a different config
|
|
56
|
+
* falls back to source-building rather than silently using stale options.
|
|
57
|
+
*/
|
|
58
|
+
export const DOCS_SEARCH_CONFIG_FINGERPRINT = stableStringify(DOCS_SEARCH_OPTIONS);
|
|
59
|
+
export function createDocsIndex(entries) {
|
|
60
|
+
const mini = new MiniSearch(DOCS_SEARCH_OPTIONS);
|
|
61
|
+
mini.addAll(entries);
|
|
62
|
+
const { entriesById, symbolToSections } = buildEntryAndSymbolMaps(entries);
|
|
63
|
+
return { mini, entriesById, symbolToSections };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build the `entriesById` and `symbolToSections` maps from raw entries.
|
|
67
|
+
* Extracted so the prebuilt-index load path can rebuild these maps without
|
|
68
|
+
* paying for minisearch index construction.
|
|
69
|
+
*/
|
|
70
|
+
function buildEntryAndSymbolMaps(entries) {
|
|
71
|
+
const entriesById = new Map();
|
|
72
|
+
const symbolToSections = new Map();
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
entriesById.set(entry.id, entry);
|
|
75
|
+
const sectionSymbols = new Set();
|
|
76
|
+
for (const section of entry.sections) {
|
|
77
|
+
if (!section.symbols.length) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
for (const symbol of section.symbols) {
|
|
81
|
+
sectionSymbols.add(symbol);
|
|
82
|
+
const key = symbol.toLowerCase();
|
|
83
|
+
const list = symbolToSections.get(key) ?? [];
|
|
84
|
+
list.push({
|
|
85
|
+
symbol,
|
|
86
|
+
entryId: entry.id,
|
|
87
|
+
module: entry.module,
|
|
88
|
+
path: entry.path,
|
|
89
|
+
title: entry.title,
|
|
90
|
+
heading: section.heading,
|
|
91
|
+
content: section.content,
|
|
92
|
+
...(entry.source ? { source: entry.source } : {}),
|
|
93
|
+
});
|
|
94
|
+
symbolToSections.set(key, list);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const symbol of entry.symbols) {
|
|
98
|
+
if (sectionSymbols.has(symbol)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const key = symbol.toLowerCase();
|
|
102
|
+
const list = symbolToSections.get(key) ?? [];
|
|
103
|
+
list.push({
|
|
104
|
+
symbol,
|
|
105
|
+
entryId: entry.id,
|
|
106
|
+
module: entry.module,
|
|
107
|
+
path: entry.path,
|
|
108
|
+
title: entry.title,
|
|
109
|
+
heading: entry.title,
|
|
110
|
+
content: entry.content,
|
|
111
|
+
...(entry.source ? { source: entry.source } : {}),
|
|
112
|
+
});
|
|
113
|
+
symbolToSections.set(key, list);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { entriesById, symbolToSections };
|
|
117
|
+
}
|
|
118
|
+
/** Build the on-disk envelope from a freshly constructed in-memory index. */
|
|
119
|
+
export function serializeDocsIndex(entries, modules, index) {
|
|
120
|
+
return {
|
|
121
|
+
schemaVersion: DOCS_INDEX_SCHEMA_VERSION,
|
|
122
|
+
configFingerprint: DOCS_SEARCH_CONFIG_FINGERPRINT,
|
|
123
|
+
modules: [...modules].sort(),
|
|
124
|
+
entries,
|
|
125
|
+
miniSerialized: index.mini.toJSON(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Rehydrate a `DocsIndex` from a prebuilt envelope. The caller is expected
|
|
130
|
+
* to have already validated that the envelope's `schemaVersion`,
|
|
131
|
+
* `configFingerprint`, and `modules` match what the runtime needs - this
|
|
132
|
+
* helper trusts those invariants.
|
|
133
|
+
*/
|
|
134
|
+
export function loadDocsIndexFromPrebuilt(prebuilt) {
|
|
135
|
+
const mini = MiniSearch.loadJS(prebuilt.miniSerialized, DOCS_SEARCH_OPTIONS);
|
|
136
|
+
const { entriesById, symbolToSections } = buildEntryAndSymbolMaps(prebuilt.entries);
|
|
137
|
+
return { mini, entriesById, symbolToSections };
|
|
138
|
+
}
|
|
139
|
+
function buildSnippet(content, query, maxLength = 200) {
|
|
140
|
+
const lower = content.toLowerCase();
|
|
141
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
142
|
+
let matchIndex = -1;
|
|
143
|
+
let matchLength = 0;
|
|
144
|
+
for (const term of terms) {
|
|
145
|
+
const index = lower.indexOf(term);
|
|
146
|
+
if (index !== -1 && (matchIndex === -1 || index < matchIndex)) {
|
|
147
|
+
matchIndex = index;
|
|
148
|
+
matchLength = term.length;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (matchIndex === -1) {
|
|
152
|
+
const snippet = content.slice(0, maxLength);
|
|
153
|
+
return { snippet, snippetStart: 0, snippetEnd: snippet.length };
|
|
154
|
+
}
|
|
155
|
+
const start = Math.max(0, matchIndex - Math.floor(maxLength / 2));
|
|
156
|
+
const end = Math.min(content.length, start + maxLength);
|
|
157
|
+
const snippet = content.slice(start, end);
|
|
158
|
+
return {
|
|
159
|
+
snippet,
|
|
160
|
+
snippetStart: matchIndex,
|
|
161
|
+
snippetEnd: matchIndex + matchLength,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function searchDocsByContent(index, query, moduleFilter) {
|
|
165
|
+
const results = index.mini.search(query);
|
|
166
|
+
const filtered = moduleFilter
|
|
167
|
+
? results.filter((result) => result.module === moduleFilter)
|
|
168
|
+
: results;
|
|
169
|
+
return filtered.map((result) => {
|
|
170
|
+
const entry = index.entriesById.get(result.id);
|
|
171
|
+
const content = entry?.content ?? '';
|
|
172
|
+
const { snippet, snippetStart, snippetEnd } = buildSnippet(content, query);
|
|
173
|
+
return {
|
|
174
|
+
id: result.id,
|
|
175
|
+
module: (entry?.module ?? result.module),
|
|
176
|
+
path: (entry?.path ?? result.path),
|
|
177
|
+
title: (entry?.title ?? result.title),
|
|
178
|
+
snippet,
|
|
179
|
+
snippetStart,
|
|
180
|
+
snippetEnd,
|
|
181
|
+
score: result.score ?? 0,
|
|
182
|
+
symbols: entry?.symbols ?? result.symbols ?? [],
|
|
183
|
+
kind: 'doc',
|
|
184
|
+
...(entry?.source ? { source: entry.source } : {}),
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
function scoreSymbolMatch(symbolKey, terms) {
|
|
189
|
+
let score = 0;
|
|
190
|
+
for (const term of terms) {
|
|
191
|
+
if (symbolKey === term) {
|
|
192
|
+
score += 3;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (symbolKey.startsWith(term)) {
|
|
196
|
+
score += 2;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (symbolKey.includes(term)) {
|
|
200
|
+
score += 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return score;
|
|
204
|
+
}
|
|
205
|
+
function searchDocsBySymbols(index, query, moduleFilter) {
|
|
206
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
207
|
+
if (!terms.length) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
const results = [];
|
|
211
|
+
for (const [symbolKey, sections] of index.symbolToSections.entries()) {
|
|
212
|
+
const score = scoreSymbolMatch(symbolKey, terms);
|
|
213
|
+
if (score <= 0) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
for (const section of sections) {
|
|
217
|
+
if (moduleFilter && section.module !== moduleFilter) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const { snippet, snippetStart, snippetEnd } = buildSnippet(section.content, query);
|
|
221
|
+
results.push({
|
|
222
|
+
id: section.entryId,
|
|
223
|
+
module: section.module,
|
|
224
|
+
path: section.path,
|
|
225
|
+
title: section.title,
|
|
226
|
+
heading: section.heading,
|
|
227
|
+
symbol: section.symbol,
|
|
228
|
+
snippet,
|
|
229
|
+
snippetStart,
|
|
230
|
+
snippetEnd,
|
|
231
|
+
score,
|
|
232
|
+
symbols: [section.symbol],
|
|
233
|
+
kind: 'symbol',
|
|
234
|
+
...(section.source ? { source: section.source } : {}),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return results;
|
|
239
|
+
}
|
|
240
|
+
export function searchDocs(index, query, moduleFilter, limit = 10, scope = 'docs') {
|
|
241
|
+
if (scope === 'docs') {
|
|
242
|
+
return searchDocsByContent(index, query, moduleFilter).slice(0, limit);
|
|
243
|
+
}
|
|
244
|
+
if (scope === 'symbols') {
|
|
245
|
+
const symbolResults = searchDocsBySymbols(index, query, moduleFilter).sort((a, b) => b.score - a.score);
|
|
246
|
+
return symbolResults.slice(0, limit);
|
|
247
|
+
}
|
|
248
|
+
const docResults = searchDocsByContent(index, query, moduleFilter);
|
|
249
|
+
const symbolResults = searchDocsBySymbols(index, query, moduleFilter);
|
|
250
|
+
return [...docResults, ...symbolResults]
|
|
251
|
+
.sort((a, b) => b.score - a.score)
|
|
252
|
+
.slice(0, limit);
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=search.js.map
|