@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/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
@@ -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