OmniLex terminology lookup pane and hover summaries for Claude Code

Yêu cầu Claude Code 2.1.287 trở lên. Kiểm tra bằng claude --version.
Trong phiên Claude Code:
/plugin install omnilex --marketplace DangAW2002/omnilex
Hoặc qua CLI:
claude plugin marketplace add DangAW2002/omnilex
claude plugin install omnilex
Trong thư mục gốc của repo:
pnpm build
claude plugin validate ./packages/claude-mod
claude plugin test ./packages/claude-mod
claude --plugin-dir ./packages/claude-mod
Để tự nạp mod ở các phiên sau mà không cần --plugin-dir, đặt biến môi trường Windows CLAUDE_CODE_PLUGIN_DIRS thành đường dẫn tuyệt đối đến thư mục packages/claude-mod, rồi mở lại terminal hoặc Claude Desktop.
Trong phiên Claude Code:
/omnilex lang en để xem tóm tắt, pane tra cứu và giải thích bằng tiếng Anh; gõ /omnilex lang vi để trở lại tiếng Việt. Gõ /omnilex lang để xem ngôn ngữ đang chọn. Mod nhớ lựa chọn cho các phiên sau. Mod vẫn nhận diện thuật ngữ tiếng Việt và tiếng Anh trong câu trả lời./omnilex mode <tên> (lưu qua các phiên):hover (mặc định): dòng văn xuôi hoặc mục list có thuật ngữ được mod tự dàn dòng để hover trực tiếp trên thuật ngữ; code, bảng, trích dẫn và các dòng khác vẫn do Markdown gốc vẽ.links: toàn bộ tin nhắn do Markdown gốc vẽ, lần nhắc đầu của mỗi thuật ngữ thành link; nhấn link mở pane (terminal fullscreen), không có thẻ hover.badges: giữ cách vẽ gốc và đặt nhãn tra cứu bên dưới tin nhắn./omnilex browse để duyệt khái niệm theo lĩnh vực: chọn lĩnh vực và nhánh, lật trang bằng ‹ Trước / Sau ›, nhấn một thuật ngữ để xem giải thích, ← Quay lại danh sách để trở về. /omnilex browse it/ai-ml mở thẳng một nhánh. Hai tab Tìm kiếm / Duyệt lĩnh vực ở đầu pane để chuyển qua lại./omnilex để nhập mô tả bất kỳ. Chọn kết quả, rồi nhấn Chèn thuật ngữ vào prompt để đưa nhãn chuẩn vào ô nhập..omnilex/ của thư mục nơi bạn khởi động Claude Code (đi ngược lên thư mục cha) và nạp đúng các gói project chọn. Không có project hay gói nào đã cài thì mod dùng gói starter đóng kèm (khái niệm cơ bản, ≤ 1 MB). Overlay .omnilex/overrides.yaml của project (exclude, prefer, autolink, alias) cũng có hiệu lực trong mod./omnilex packs it.ai-ml,finance chỉ nạp các gói đó trong phiên này; /omnilex packs all trở lại lựa chọn của project. /omnilex profile lite|standard|semantic đổi profile trong phiên (lite không nạp tìm kiếm và bài giải thích). /omnilex status cho biết project, profile, các gói, tier đang nạp và cảnh báo.Để thử hover trực tiếp, yêu cầu Claude trả lời đúng một dòng thuần văn bản có chữ Lãi kép, không dùng Markdown. Mod thay cách vẽ của riêng dòng đó; nội dung hội thoại lưu trữ không bị sửa. Đây là bản thử: cách xuống dòng, định dạng và vị trí thẻ hover trên máy của bạn vẫn cần kiểm tra trực quan. Mod vẽ giao diện trong terminal và tab Code của Claude Desktop, không vẽ trong khung chat VS Code.
Khi sửa dữ liệu thuật ngữ, chạy lại pnpm build rồi /reload-plugins trong Claude Code hoặc khởi động phiên mới. pnpm ol build chỉ sinh dữ liệu từ bản core đã biên dịch; dùng pnpm build sau khi sửa mã của builder.
hooks/register.js 1144 lines1import { createMatcher, createReverseMatcher } from './runtime/matcher.js'
2import { createLookupIndex } from './runtime/lookup.js'
3import { openProject } from './runtime/project.js'
4import { PackStore, PrefetchIO } from './runtime/pack-reader.js'
5import { mergeTerms } from './runtime/pack-format.js'
6import { searchConfigHash } from './runtime/search-config.js'
7
8const PANE_ID = 'omnilex-lookup'
9const MAX_BADGES = 4
10const PROFILES = ['lite', 'standard', 'semantic']
11
12let terms = []
13let concepts = []
14let conceptsById = new Map()
15let explanations = new Map()
16let query = ''
17let selectedId = null
18let trieMatcher = null
19let reverseMatcher = null
20// Concepts the reverse matcher is still to be built from (see ensureReverse).
21let reverseSource = null
22const scanCache = new Map()
23let repoRoot = ''
24let localized = {}
25let lookupIndex = null
26// Packs resolved for the session's project (Plan 3.3, D11); null means the
27// data bundled with the plugin is in use.
28let packs = null
29let sessionCwd = ''
30let sessionPacks = null
31let sessionProfile = null
32let notices = []
33let paneResults = { key: '', results: [] }
34let selectFirstResult = false
35const explanationCache = new Map()
36// Browse view: the domain tree (data/taxonomy.json) and the path picked in it.
37let taxonomy = []
38let view = 'search'
39let browseDomain = ''
40let browseBranch = ''
41let browsePage = 0
42const BROWSE_PAGE_SIZE = 15
43
44const MODES = ['hover', 'links', 'badges']
45const LOCALES = ['vi', 'en']
46const MAX_INLINE_TERMS = 30
47const MAX_SCAN_CACHE_ENTRIES = 256
48let mode = 'hover'
49let locale = 'vi'
50const UI_TEXT = {
51 vi: {
52 searchTitle: 'Tra ngược: mô tả bằng lời thường',
53 description: 'Mô tả',
54 placeholder: 'Ví dụ: tiền lãi lại sinh ra lãi',
55 insert: 'Chèn thuật ngữ vào prompt',
56 choose: 'Chọn một thuật ngữ để xem giải thích.',
57 hint: 'Hover thuật ngữ trong câu trả lời để xem tóm tắt.',
58 searchTab: 'Tìm kiếm',
59 browseTab: 'Duyệt lĩnh vực',
60 domain: 'Lĩnh vực',
61 branch: 'Nhánh',
62 all: 'Tất cả',
63 back: '← Quay lại danh sách',
64 previous: '‹ Trước',
65 next: 'Sau ›',
66 page: (page, pages, count) => `Trang ${page}/${pages} · ${count} khái niệm`,
67 empty: 'Không có khái niệm nào trong nhánh này.',
68 },
69 en: {
70 searchTitle: 'Reverse lookup: describe the idea in plain words',
71 description: 'Description',
72 placeholder: 'Example: interest on interest',
73 insert: 'Insert term into prompt',
74 choose: 'Select a term to read its explanation.',
75 hint: 'Hover a term in the reply to see its summary.',
76 searchTab: 'Search',
77 browseTab: 'Browse domains',
78 domain: 'Domain',
79 branch: 'Branch',
80 all: 'All',
81 back: '← Back to list',
82 previous: '‹ Previous',
83 next: 'Next ›',
84 page: (page, pages, count) => `Page ${page}/${pages} · ${count} concepts`,
85 empty: 'No concepts in this branch.',
86 },
87}
88
89function cleanTranscript(text) {
90 return text
91 .replace(/```[\s\S]*?```/g, ' ')
92 .replace(/`[^`\n]+`/g, ' ')
93 .replace(/https?:\/\/\S+/g, ' ')
94 .replace(/\]\([^)]*\)/g, ' ')
95}
96
97function escapeRegex(value) {
98 return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
99}
100
101function buildMatchers() {
102 const localizedTerms = terms.map((term) => ({
103 ...term,
104 concepts: term.concepts.map((c) => conceptsById.get(c.id) || c),
105 }))
106 trieMatcher = createMatcher(localizedTerms)
107 reverseMatcher = null
108 reverseSource = concepts
109 // With packs the pane searches the pack indexes, loaded on first use.
110 lookupIndex = packs ? null : createLookupIndex(concepts)
111 scanCache.clear()
112}
113
114function selectLocale(value) {
115 const data = localized[value]
116 if (!data) return false
117 locale = value
118 concepts = data.concepts
119 conceptsById = new Map(concepts.map((item) => [item.id, item]))
120 explanations = data.explanations || new Map()
121 buildMatchers()
122 return true
123}
124
125function matchedConcepts(text, requestId) {
126 if (!trieMatcher) return []
127 if (requestId) {
128 const key = String(requestId)
129 const signature = transcriptSignature(text)
130 const cached = scanCache.get(key)
131 if (cached?.signature === signature) {
132 scanCache.delete(key)
133 scanCache.set(key, cached)
134 return cached.concepts
135 }
136 const res = trieMatcher.matchedConcepts(text, MAX_BADGES)
137 scanCache.delete(key)
138 scanCache.set(key, { signature, concepts: res })
139 while (scanCache.size > MAX_SCAN_CACHE_ENTRIES) {
140 scanCache.delete(scanCache.keys().next().value)
141 }
142 return res
143 }
144 return trieMatcher.matchedConcepts(text, MAX_BADGES)
145}
146
147function transcriptSignature(text) {
148 let first = 0x811c9dc5
149 let second = 0x9e3779b9
150 for (let i = 0; i < text.length; i++) {
151 const code = text.charCodeAt(i)
152 first = Math.imul(first ^ code, 0x01000193)
153 second = Math.imul(second ^ (code + i), 0x85ebca6b)
154 }
155 return `${text.length}:${first >>> 0}:${second >>> 0}`
156}
157
158// Hits of every term in `text`, longest key first, none inside `blocked` ranges.
159function termHits(text, blocked = []) {
160 if (!trieMatcher) return []
161 return trieMatcher.termHits(text, blocked)
162}
163
164function charWidth(cp) {
165 if ((cp >= 0x300 && cp <= 0x36f) || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0
166 if ((cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3)
167 || (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60)
168 || (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd)) return 2
169 return 1
170}
171
172function textWidth(text) {
173 let width = 0
174 for (const char of text) width += charWidth(char.codePointAt(0))
175 return width
176}
177
178// One unit per source line, a fence (closed or still streaming) kept whole.
179function parseUnits(text) {
180 const lines = text.split('\n')
181 const units = []
182 for (let i = 0; i < lines.length; i++) {
183 const line = lines[i]
184 const fence = line.match(/^\s*(```|~~~)/)
185 if (fence) {
186 const start = i
187 for (i++; i < lines.length && !lines[i].trimStart().startsWith(fence[1]); i++);
188 units.push({ kind: 'raw', raw: lines.slice(start, i + 1) })
189 continue
190 }
191 if (!line.trim()) { units.push({ kind: 'blank' }); continue }
192 const heading = line.match(/^#{1,6}\s+(.*)$/)
193 if (heading) { units.push({ kind: 'heading', text: heading[1], raw: [line] }); continue }
194 const item = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/)
195 if (item && !/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
196 units.push({ kind: 'item', indent: item[1].length, marker: item[2], text: item[3], raw: [line] })
197 continue
198 }
199 if (/^\s*(\||>)/.test(line) || /^\s*([-*_])(\s*\1){2,}\s*$/.test(line) || /^ {4,}\S/.test(line)) {
200 units.push({ kind: 'raw', raw: [line] })
201 continue
202 }
203 units.push({ kind: 'para', text: line, raw: [line] })
204 }
205 return units
206}
207
208const INLINE = /(\*\*|__)(?=\S)(.+?)(?<=\S)\1|`([^`\n]+)`|\[([^\]\n]+)\]\(([^)\s]+)\)|(?<![\p{L}\p{N}*_\\])([*_])(?=[^\s*_])([^*_\n]+?)(?<=[^\s*_])\6(?![\p{L}\p{N}])/gu
209
210function inlineSegments(source) {
211 const segments = []
212 let last = 0
213 for (const match of source.matchAll(INLINE)) {
214 if (match.index > last) segments.push({ text: source.slice(last, match.index) })
215 if (match[1]) segments.push({ text: match[2], bold: true })
216 else if (match[3] !== undefined) segments.push({ text: match[3], code: true })
217 else if (match[4]) segments.push({ text: match[4], link: match[5] })
218 else segments.push({ text: match[7], italic: true })
219 last = match.index + match[0].length
220 }
221 if (last < source.length) segments.push({ text: source.slice(last) })
222 return segments
223}
224
225// Splits styled segments at term hits; a hit across two segments is dropped.
226function termRuns(segments) {
227 let offset = 0
228 const placed = segments.map((segment) => {
229 const item = { ...segment, start: offset, end: offset + segment.text.length }
230 offset = item.end
231 return item
232 })
233 const plain = placed.map((segment) => segment.text).join('')
234 const blocked = placed.filter((segment) => segment.code || segment.link)
235 const hits = termHits(plain, blocked).filter((hit) =>
236 placed.some((segment) => hit.start >= segment.start && hit.end <= segment.end))
237 const runs = []
238 for (const segment of placed) {
239 const style = { bold: segment.bold, italic: segment.italic, code: segment.code, link: segment.link }
240 let cursor = segment.start
241 for (const hit of hits.filter((item) => item.start >= segment.start && item.end <= segment.end)) {
242 if (hit.start > cursor) runs.push({ text: plain.slice(cursor, hit.start), style })
243 runs.push({ text: plain.slice(hit.start, hit.end), style, concept: hit.concept })
244 cursor = hit.end
245 }
246 if (cursor < segment.end) runs.push({ text: plain.slice(cursor, segment.end), style })
247 }
248 return { runs, hitCount: hits.length }
249}
250
251// Greedy word wrap; a term stays whole so its hover box never splits.
252function wrapRuns(runs, width) {
253 const atoms = []
254 for (const run of runs) {
255 if (run.concept || run.style?.link) { atoms.push({ ...run, width: textWidth(run.text) }); continue }
256 for (const part of run.text.split(/(\s+)/)) {
257 if (part) atoms.push({ text: part, style: run.style, space: /^\s/.test(part), width: textWidth(part) })
258 }
259 }
260 const lines = [[]]
261 let used = 0
262 const breakLine = () => {
263 const line = lines.at(-1)
264 while (line.length && line.at(-1).space) line.pop()
265 lines.push([])
266 used = 0
267 }
268 for (const atom of atoms) {
269 if (atom.space) {
270 if (used === 0) continue
271 if (used + atom.width > width) { breakLine(); continue }
272 } else if (used > 0 && used + atom.width > width) breakLine()
273 if (!atom.concept && atom.width > width) {
274 let chunk = ''
275 for (const char of atom.text) {
276 if (used + textWidth(chunk + char) > width) {
277 lines.at(-1).push({ ...atom, text: chunk, col: used })
278 breakLine()
279 chunk = ''
280 }
281 chunk += char
282 }
283 atom.text = chunk
284 atom.width = textWidth(chunk)
285 }
286 lines.at(-1).push({ ...atom, col: used })
287 used += atom.width
288 }
289 return lines.filter((line, index) => line.length || index === 0)
290}
291
292function wrappedRowCount(text, width) {
293 return wrapRuns([{ text, style: {} }], width).length
294}
295
296function textStyle(style) {
297 return {
298 ...(style.bold ? { bold: true } : {}),
299 ...(style.italic ? { italic: true } : {}),
300 ...(style.code ? { color: 'permission' } : {}),
301 }
302}
303
304// A term reads as a link at rest and lights up while its box is hovered.
305const TERM_STYLE = { color: 'suggestion', underline: true, hover: { bold: true, inverse: true } }
306
307function hoverCard(elements, concept, col, width) {
308 const { Box, Text } = elements
309 const hint = concept.summary
310 const cardWidth = Math.max(12, Math.min(60, width, textWidth(hint) + 4))
311 const rows = wrappedRowCount(hint, cardWidth - 4) + 2
312 return Box({
313 position: 'absolute', top: -rows, left: Math.min(0, width - col - cardWidth),
314 width: cardWidth, display: 'none', hover: { display: 'flex' },
315 borderStyle: 'round', paddingX: 1,
316 children: [Text({ children: [hint] })],
317 })
318}
319
320function unitRows(elements, unit, width, keyBase, open) {
321 const { Box, Text, Button, Markdown } = elements
322 const prefix = unit.kind === 'item'
323 ? `${' '.repeat(unit.indent)}${/\d/.test(unit.marker) ? unit.marker : '-'} `
324 : ''
325 const contentWidth = Math.max(10, width - textWidth(prefix))
326 const segments = inlineSegments(unit.text)
327 .map((segment) => unit.kind === 'heading' ? { ...segment, bold: true } : segment)
328 const { runs } = termRuns(segments)
329 return wrapRuns(runs, contentWidth).map((line, lineIndex) => {
330 const pieces = []
331 for (const atom of line) {
332 const previous = pieces.at(-1)
333 if (!atom.concept && previous && !previous.concept
334 && JSON.stringify(previous.style) === JSON.stringify(atom.style)) previous.text += atom.text
335 else pieces.push({ ...atom })
336 }
337 const children = pieces.map((piece, index) => {
338 if (!piece.concept) {
339 if (piece.style?.link) {
340 const label = piece.text.replace(/([\\[\]])/g, '\\$1')
341 const href = piece.style.link.replace(/[()\\]/g, '\\$&')
342 return Markdown({ text: `[${label}](${href})` })
343 }
344 return Text({ ...textStyle(piece.style), children: [piece.text] })
345 }
346 const key = `${keyBase}-${lineIndex}-${index}`
347 return Box({
348 key, position: 'relative',
349 children: [
350 Button({
351 key: `inline-${key}`, plain: true,
352 onPress: () => open(piece.concept.id),
353 children: [Text({ ...textStyle(piece.style), ...TERM_STYLE, children: [piece.text] })],
354 }),
355 hoverCard(elements, piece.concept, piece.col, contentWidth),
356 ],
357 })
358 })
359 return Box({
360 flexDirection: 'row',
361 children: [Text({ children: [lineIndex === 0 ? prefix : ' '.repeat(textWidth(prefix))] }), ...children],
362 })
363 })
364}
365
366// Prose lines holding a term are laid out here; every other line stays one
367// native Markdown block, so code, tables and plain paragraphs draw as before.
368function hoverTree(elements, e, open) {
369 const { Box, Text, Markdown } = elements
370 const width = Math.max(20, (e.viewport?.columns || 80) - 2)
371 const items = []
372 let gap = false
373 let inlineTerms = 0
374 for (const unit of parseUnits(e.props.text)) {
375 if (unit.kind === 'blank') { gap = items.length > 0; continue }
376 let own = false
377 if (unit.kind !== 'raw' && inlineTerms < MAX_INLINE_TERMS) {
378 const { hitCount } = termRuns(inlineSegments(unit.text))
379 if (hitCount) { own = true; inlineTerms += hitCount }
380 }
381 const last = items.at(-1)
382 if (own) items.push({ unit, gap })
383 else if (last && last.lines) last.lines.push(...(gap ? [''] : []), ...unit.raw)
384 else items.push({ lines: [...unit.raw], gap })
385 gap = false
386 }
387 if (!items.some((item) => item.unit)) return null
388 const blocks = items.map((item, index) => Box({
389 flexDirection: 'column', marginTop: item.gap ? 1 : 0,
390 children: item.unit
391 ? unitRows(elements, item.unit, width, `tb-${e.requestId}-${index}`, open)
392 : [Markdown({ text: item.lines.join('\n') })],
393 }))
394 return Box({
395 flexDirection: 'row',
396 children: [
397 Text({ children: [e.props.isFirstOfReply ? '● ' : ' '] }),
398 Box({ flexDirection: 'column', width, children: blocks }),
399 ],
400 })
401}
402
403function conceptHref(id) {
404 return `file:///${repoRoot.replace(/^\/+/, '')}/concepts/${id}/${locale}.md`
405}
406
407function conceptFromHref(href) {
408 return href.match(/\/concepts\/([^/]+)\/(?:vi|en)\.md$/)?.[1]
409}
410
411// Turns the first mention of each concept into a link, outside code and links.
412function linkify(text) {
413 const seen = new Set()
414 const hrefs = []
415 let fence = null
416 const lines = text.split('\n').map((line) => {
417 const opener = line.match(/^\s*(```|~~~)/)
418 if (fence) { if (line.trimStart().startsWith(fence)) fence = null; return line }
419 if (opener) { fence = opener[1]; return line }
420 const blocked = [...line.matchAll(/`[^`\n]+`|\[[^\]\n]*\]\([^)\n]*\)|https?:\/\/\S+/g)]
421 .map((match) => ({ start: match.index, end: match.index + match[0].length }))
422 let result = line
423 const hits = termHits(line, blocked).filter((hit) => {
424 if (seen.has(hit.concept.id)) return false
425 seen.add(hit.concept.id)
426 return true
427 })
428 for (const hit of hits.reverse()) {
429 const href = conceptHref(hit.concept.id)
430 hrefs.push(href)
431 result = `${result.slice(0, hit.start)}[${result.slice(hit.start, hit.end)}](${href})${result.slice(hit.end)}`
432 }
433 return result
434 })
435 return { text: lines.join('\n'), hrefs }
436}
437
438function canRenderInline(text, columns) {
439 const available = Math.min(90, (columns || 80) - 12)
440 return available > 0 && text.length <= available && !/[\r\n\t`*_#\[\]<>|]/.test(text)
441 && !/https?:\/\//i.test(text)
442}
443
444function forwardSpans(text) {
445 if (!trieMatcher) return []
446 return trieMatcher.forwardSpans(text, MAX_BADGES)
447}
448
449function reverseSpans(text, hints) {
450 if (!ensureReverse()) return []
451 return reverseMatcher.reverseSpans(text, hints, MAX_BADGES)
452}
453
454function inlineTree(elements, text, spans, requestId, prefix, hintFor, openConcept) {
455 const { Box, Text, Button } = elements
456 const row = [Text({ children: [prefix] })]
457 let cursor = 0
458 for (const [index, span] of spans.entries()) {
459 if (span.start > cursor) row.push(Text({ children: [text.slice(cursor, span.start)] }))
460 const scope = `tb-${String(requestId).slice(-36)}-${index}`
461 row.push(Box({
462 key: scope, position: 'relative', hover: { scope },
463 children: [
464 Button({
465 key: `inline-${requestId}-${index}`,
466 label: text.slice(span.start, span.end), plain: true,
467 onPress: () => openConcept(span.concept.id),
468 }),
469 Box({
470 position: 'absolute', top: -3, left: 0,
471 display: 'none', hover: { scope, display: 'flex' },
472 borderStyle: 'round', paddingX: 1,
473 children: [Text({ children: [hintFor(span.concept)] })],
474 }),
475 ],
476 }))
477 cursor = span.end
478 }
479 if (cursor < text.length) row.push(Text({ children: [text.slice(cursor)] }))
480 return Box({ flexDirection: 'row', children: row })
481}
482
483// Pane search: the pack indexes (standard), exact lookup (lite), or the
484// bundled trigram index. Results are cached for the current query.
485async function paneSearch(input) {
486 const text = input.trim()
487 const key = `${locale}|${text}`
488 if (paneResults.key === key) return paneResults.results
489 let results = []
490 if (!text) results = []
491 else if (!packs) results = lookupIndex?.search(text, 5) || []
492 else {
493 try {
494 const lite = packs.ctx.plan.profile === 'lite'
495 const hits = await packs.io.run(() => lite
496 ? packs.store.lookup(text, { locale })
497 : packs.store.find(text, { locale, limit: 5 }))
498 results = hits.map((hit) => conceptsById.get(hit.id) || hit)
499 } catch {
500 results = []
501 }
502 }
503 paneResults = { key, results }
504 return results
505}
506
507// The reverse matcher is the slowest index to build, and only a reply needs
508// it: it is built right after session.start, or by the first reply before that.
509function ensureReverse() {
510 if (!reverseMatcher && reverseSource) {
511 reverseMatcher = createReverseMatcher(reverseSource)
512 reverseSource = null
513 }
514 return reverseMatcher
515}
516
517function warmReverse($) {
518 try {
519 $.clock.after(0, () => ensureReverse())
520 } catch {
521 // Without a clock the first reply builds it.
522 }
523}
524
525function statusText() {
526 if (!packs) {
527 return [
528 'OmniLex: dữ liệu đóng kèm plugin (data/*.json) / bundled plugin data',
529 ...notices.map((notice) => ` ⚠ ${notice}`),
530 ].join('\n')
531 }
532 const { ctx } = packs
533 const total = ctx.plan.packs.reduce((sum, item) => sum + item.pack.concepts, 0)
534 const lines = [
535 `OmniLex: project ${ctx.projectDir || '(none)'} · profile ${ctx.plan.profile} · ${ctx.plan.packs.length} gói / packs · ${total} khái niệm / concepts · ~${ctx.plan.estimateMB.toFixed(1)} MB${ctx.plan.limitMB ? ` / ${ctx.plan.limitMB} MB` : ''}`,
536 ...ctx.plan.packs.map(({ pack, tiers }) =>
537 ` ${pack.id}@${pack.version} (${pack.sourceId === 'store' ? 'store' : 'dev'}) terms=${tiers.terms} cards=${tiers.cards} search=${tiers.search} explanations=${tiers.explanations}`),
538 ...ctx.plan.dropped.map((pack) => ` ${pack.id}: bỏ do giới hạn / dropped by limits`),
539 ...notices.map((notice) => ` ⚠ ${notice}`),
540 ]
541 return lines.join('\n')
542}
543
544function reverseHints(text) {
545 if (!ensureReverse()) return []
546 return reverseMatcher.reverseHints(text, MAX_BADGES)
547}
548
549// Explanations load when a concept is opened, never before (lazy tier).
550async function explanationFor($, id) {
551 if (!packs) {
552 await ensureExplanations($, locale)
553 return explanations.get(id)
554 }
555 const key = `${locale}|${id}`
556 if (!explanationCache.has(key)) {
557 try {
558 const record = await packs.io.run(() => packs.store.explanation(id, locale))
559 explanationCache.set(key, record?.entry?.content || '')
560 } catch {
561 explanationCache.set(key, '')
562 }
563 }
564 return explanationCache.get(key)
565}
566
567async function ensureExplanations($, loc) {
568 if (localized[loc] && localized[loc].explanations) {
569 explanations = localized[loc].explanations
570 return
571 }
572 try {
573 const raw = await $.fs.read(`${$.plugin.root}/data/concepts-${loc}.json`)
574 const parsed = JSON.parse(raw)
575 const map = new Map(parsed.map((item) => [item.id, item.content]))
576 if (localized[loc]) localized[loc].explanations = map
577 if (locale === loc) explanations = map
578 } catch {
579 if (localized[loc]) localized[loc].explanations = new Map()
580 if (locale === loc) explanations = new Map()
581 }
582}
583
584async function homeDir($) {
585 const read = async (get) => { try { return await get() } catch { return undefined } }
586 const explicit = await read(() => $.env.get('OMNILEX_HOME'))
587 if (explicit) return explicit.replace(/\\/g, '/')
588 const home = (await read(() => $.env.get('HOME'))) || (await read(() => $.env.get('USERPROFILE'))) || ''
589 const base = home.replace(/\\/g, '/').replace(/\/+$/, '')
590 try {
591 if (await $.fs.exists(`${base}/.omnilex`)) return `${base}/.omnilex`
592 } catch {}
593 return `${base}/.omnilex`
594}
595
596function projectFs($) {
597 return {
598 async read(path) {
599 try {
600 const text = await $.fs.read(path)
601 return typeof text === 'string' ? text : null
602 } catch {
603 return null
604 }
605 },
606 async exists(path) {
607 try { return (await $.fs.exists(path)) === true } catch { return false }
608 },
609 async list(path) {
610 try {
611 const entries = await $.fs.list(path)
612 return Array.isArray(entries) ? entries.filter((entry) => entry.kind === 'dir').map((entry) => entry.name) : []
613 } catch {
614 return []
615 }
616 },
617 async mtime(path) {
618 try { return (await $.fs.stat(path)).mtimeMs } catch { return null }
619 },
620 }
621}
622
623async function loadPackLocale(loc) {
624 if (localized[loc]) return true
625 if (!packs) return false
626 const seen = new Set()
627 // A concept in several packs (the starter repeats others): first pack wins.
628 const cards = (await packs.io.run(() => packs.ids.flatMap((id) => packs.store.cards(id, loc))))
629 .filter((card) => !seen.has(card.id) && seen.add(card.id))
630 localized[loc] = { concepts: cards, explanations: null }
631 return true
632}
633
634// Resolves the project's packs (config layers from the session's cwd, the
635// store, dev builds) and loads terms of both locales plus the active cards.
636async function loadPacks($) {
637 const fsAdapter = projectFs($)
638 const sessionConfig = {}
639 if (sessionPacks) Object.assign(sessionConfig, { packsReset: true, packs: sessionPacks })
640 if (sessionProfile) sessionConfig.profile = sessionProfile
641 const home = await homeDir($)
642 const open = (session) => openProject(fsAdapter, {
643 cwd: sessionCwd,
644 home,
645 surface: 'mod',
646 locale,
647 session,
648 configHash: searchConfigHash(),
649 })
650 let ctx = await open(Object.keys(sessionConfig).length ? { source: '/omnilex', config: sessionConfig } : undefined)
651 notices = ctx.notices
652 if (ctx.plan.packs.length === 0) {
653 // No project or store packs: the starter pack the plugin ships (3.9).
654 const bundled = `${String($.plugin.root).replace(/\\/g, '/').replace(/\/+$/, '')}/data/starter`
655 ctx = await open({ source: 'plugin', config: { ...sessionConfig, packsReset: true, packs: [`dev:${bundled}`] } })
656 notices = [...notices, ...ctx.notices.filter((notice) => !notices.includes(notice))]
657 }
658 if (ctx.plan.packs.length === 0) return false
659 const io = new PrefetchIO((path) => fsAdapter.read(path))
660 const store = new PackStore(io, (message) => { if (!notices.includes(message)) notices.push(message) }, {
661 manifest: ctx.manifest,
662 disabled: ctx.disabled,
663 overlay: ctx.overlay,
664 })
665 const ids = ctx.plan.packs.map((item) => item.pack.id)
666 const bases = ctx.plan.packs.map((item) => item.pack.path.replace(/\/pack\.json$/, ''))
667 await io.prefetch([
668 ...ctx.plan.packs.map((item) => item.pack.path),
669 ...bases.flatMap((base) => [...LOCALES.map((loc) => `${base}/${loc}/terms.json`), `${base}/${locale}/cards.json`]),
670 ])
671 packs = { ctx, store, io, ids }
672 localized = {}
673 explanationCache.clear()
674 paneResults = { key: '', results: [] }
675 const termList = await io.run(() => LOCALES.flatMap((loc) => ids.flatMap((id) => store.terms(id, loc))))
676 await loadPackLocale(locale)
677 const byId = new Map(localized[locale].concepts.map((card) => [card.id, card]))
678 terms = mergeTerms(termList, (id) => byId.get(id))
679 .sort((a, b) => b.key.length - a.key.length)
680 selectLocale(locale)
681 for (const notice of notices) $.ui.log(notice)
682 return true
683}
684
685async function loadData($) {
686 packs = null
687 try {
688 if (await loadPacks($)) return warmReverse($)
689 } catch (error) {
690 packs = null
691 $.ui.log(`OmniLex: không đọc được gói, dùng dữ liệu đóng kèm / cannot read packs, using bundled data (${error?.message || error})`)
692 }
693 await loadBundled($)
694 warmReverse($)
695}
696
697async function loadBundled($) {
698 try {
699 const [viTerms, enTerms, viConcepts, enConcepts] = await Promise.all([
700 $.fs.read(`${$.plugin.root}/data/terms-vi.json`),
701 $.fs.read(`${$.plugin.root}/data/terms-en.json`),
702 $.fs.read(`${$.plugin.root}/data/search-vi.json`),
703 $.fs.read(`${$.plugin.root}/data/search-en.json`),
704 ])
705 localized = {
706 vi: {
707 concepts: JSON.parse(viConcepts),
708 explanations: null,
709 },
710 en: {
711 concepts: JSON.parse(enConcepts),
712 explanations: null,
713 },
714 }
715 const grouped = new Map()
716 for (const term of [...JSON.parse(viTerms), ...JSON.parse(enTerms)]) {
717 const key = term.key.normalize('NFC').toLowerCase()
718 if (!grouped.has(key)) grouped.set(key, { ...term, concepts: [...term.concepts] })
719 else {
720 const current = grouped.get(key)
721 current.autolink &&= term.autolink
722 if (term.match === 'exact-case') current.match = 'exact-case'
723 for (const concept of term.concepts) {
724 if (!current.concepts.some((item) => item.id === concept.id)) current.concepts.push(concept)
725 }
726 }
727 }
728 terms = [...grouped.values()].sort((a, b) => b.key.length - a.key.length)
729 selectLocale(locale)
730 } catch {
731 terms = []
732 concepts = []
733 lookupIndex = null
734 explanations = new Map()
735 localized = {}
736 $.ui.log('OmniLex: run pnpm ol build to generate mod lookup data')
737 }
738}
739
740async function loadTaxonomy($) {
741 try {
742 const parsed = JSON.parse(await $.fs.read(`${$.plugin.root}/data/taxonomy.json`))
743 taxonomy = Array.isArray(parsed) ? parsed.filter((node) => node?.id && node.labels) : []
744 } catch {
745 taxonomy = []
746 }
747}
748
749function inDomain(concept, path) {
750 return !path || (concept.domains || []).some((domain) => domain === path || domain.startsWith(`${path}/`))
751}
752
753function domainLabel(node) {
754 return node.labels?.[locale] || node.labels?.vi || node.id
755}
756
757// Taxonomy nodes holding at least one loaded concept; domains the loaded
758// concepts use but the tree lacks are listed under their bare id.
759function domainOptions(parentPath) {
760 const prefix = parentPath ? `${parentPath}/` : ''
761 const parent = parentPath
762 ? parentPath.split('/').reduce((nodes, id) => nodes?.find((node) => node.id === id)?.children, taxonomy)
763 : taxonomy
764 const nodes = [...(parent || [])]
765 for (const concept of concepts) {
766 for (const domain of concept.domains || []) {
767 if (!domain.startsWith(prefix)) continue
768 const id = domain.slice(prefix.length).split('/')[0]
769 if (id && !nodes.some((node) => node.id === id)) nodes.push({ id, labels: {} })
770 }
771 }
772 return nodes
773 .map((node) => {
774 const path = `${prefix}${node.id}`
775 return { path, label: domainLabel(node), count: concepts.filter((concept) => inDomain(concept, path)).length }
776 })
777 .filter((option) => option.count > 0)
778}
779
780function conceptTitle(concept) {
781 return `${concept.label}${concept.qualifier ? ` ${concept.qualifier}` : ''}`
782}
783
784function browseList() {
785 const path = browseBranch || browseDomain
786 return concepts
787 .filter((concept) => inDomain(concept, path))
788 .sort((a, b) => a.label.localeCompare(b.label, locale))
789}
790
791async function handleCommand($, e) {
792 const [verb, value] = e.args.trim().split(/\s+/)
793 if (verb === 'status') return { text: statusText() }
794 if (verb === 'packs') {
795 if (!value) return { text: statusText() }
796 sessionPacks = value === 'all' ? null : value.split(',').map((item) => item.trim()).filter(Boolean)
797 await loadData($)
798 selectedId = null
799 query = ''
800 $.ui.invalidate('ui.render')
801 return { text: statusText() }
802 }
803 if (verb === 'profile') {
804 if (!PROFILES.includes(value)) return { text: `OmniLex: profile hiện tại là ${packs?.ctx.plan.profile || 'standard'} (lite, standard, semantic)` }
805 sessionProfile = value
806 await loadData($)
807 $.ui.invalidate('ui.render')
808 return { text: statusText() }
809 }
810 if (verb === 'lang' || verb === 'language') {
811 if (!LOCALES.includes(value)) return { text: `OmniLex: ngôn ngữ hiện tại là ${locale} (vi, en)` }
812 try { await loadPackLocale(value) } catch {}
813 if (!selectLocale(value)) return { text: 'OmniLex: thiếu dữ liệu ngôn ngữ; chạy pnpm build rồi nạp lại mod.' }
814 selectedId = null
815 query = ''
816 try { await $.store.set('locale', locale) } catch {}
817 $.ui.invalidate('ui.render')
818 return { text: locale === 'vi' ? 'OmniLex: đã chuyển sang tiếng Việt' : 'OmniLex: switched to English' }
819 }
820 if (verb === 'mode') {
821 if (!MODES.includes(value)) return { text: `OmniLex: chế độ hiện tại là ${mode} (hover, links, badges)` }
822 mode = value
823 try { await $.store.set('mode', mode) } catch {}
824 $.ui.invalidate('ui.render')
825 return { text: `OmniLex: chuyển sang chế độ ${mode}` }
826 }
827 selectedId = null
828 query = ''
829 view = verb === 'browse' ? 'browse' : 'search'
830 if (view === 'browse') {
831 // `/omnilex browse it/ai-ml` opens at that domain and branch.
832 const [top = '', branch = ''] = (value || '').split('/')
833 browseDomain = domainOptions('').some((option) => option.path === top) ? top : ''
834 browseBranch = browseDomain && branch && domainOptions(browseDomain).some((option) => option.path === `${top}/${branch}`)
835 ? `${top}/${branch}` : ''
836 browsePage = 0
837 }
838 await $.ui.open({ id: PANE_ID, title: 'OmniLex', focus: true, closeOnEscape: true })
839 $.ui.invalidate('ui.render')
840 return {}
841}
842
843export function register(on) {
844 on('session.start', async ($, e, next) => {
845 mode = 'hover'
846 locale = 'vi'
847 // D11: the project is the directory the session was started in.
848 sessionCwd = String(e.cwd || '').replace(/\\/g, '/')
849 sessionPacks = null
850 sessionProfile = null
851 view = 'search'
852 browseDomain = ''
853 browseBranch = ''
854 browsePage = 0
855 try {
856 const saved = await $.store.get('locale')
857 if (LOCALES.includes(saved)) locale = saved
858 } catch {}
859 await loadData($)
860 await loadTaxonomy($)
861 repoRoot = String($.plugin.root).replace(/\\/g, '/').replace(/\/+$/, '').replace(/\/packages\/claude-mod$/, '')
862 try {
863 const saved = await $.store.get('mode')
864 if (MODES.includes(saved)) mode = saved
865 } catch {}
866 if (!selectLocale(locale)) selectLocale('vi')
867 await $.command.register({
868 name: 'omnilex',
869 description: 'Tra thuật ngữ; /omnilex browse [lĩnh vực]; lang vi|en; mode hover|links|badges; packs <id,...>|all; profile lite|standard|semantic; status',
870 })
871 return next(e)
872 })
873
874 on('command.run', { command: 'omnilex' }, handleCommand)
875
876 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
877 if (e.props.isSummary) return next(e)
878 const matches = matchedConcepts(e.props.text, e.requestId)
879 if (matches.length === 0) return next(e)
880 const openConcept = async (id) => {
881 selectedId = id
882 query = ''
883 await $.ui.open({ id: PANE_ID, title: 'OmniLex', focus: true, closeOnEscape: true })
884 $.ui.invalidate('ui.render')
885 }
886 if (mode === 'hover') {
887 const tree = hoverTree($.ui.resolve(e), e, openConcept)
888 if (tree) return tree
889 }
890 if (mode === 'links') {
891 const { Box, Text, Markdown } = $.ui.resolve(e)
892 const { text, hrefs } = linkify(e.props.text)
893 if (hrefs.length) {
894 return Box({
895 flexDirection: 'row',
896 children: [
897 Text({ children: [e.props.isFirstOfReply ? '● ' : ' '] }),
898 Box({
899 flexDirection: 'column', width: Math.max(20, (e.viewport?.columns || 80) - 2),
900 children: [Markdown({
901 key: `links-${e.requestId}`, text, pressableLinks: hrefs,
902 onLinkPress: (link) => {
903 const id = conceptFromHref(link.href)
904 if (id) openConcept(id)
905 },
906 })],
907 }),
908 ],
909 })
910 }
911 }
912 if (mode === 'badges' && canRenderInline(e.props.text, e.viewport?.columns)) {
913 const spans = forwardSpans(e.props.text)
914 if (spans.length) {
915 const elements = $.ui.resolve(e)
916 return inlineTree(
917 elements, e.props.text, spans, e.requestId,
918 e.props.isFirstOfReply ? '● ' : ' ',
919 (concept) => concept.summary,
920 async (id) => {
921 selectedId = id
922 query = ''
923 await $.ui.open({ id: PANE_ID, title: 'OmniLex', focus: true, closeOnEscape: true })
924 $.ui.invalidate('ui.render')
925 },
926 )
927 }
928 }
929 const original = await next(e)
930 const { Box, Text, Button } = $.ui.resolve(e)
931 const badges = matches.map((concept, index) =>
932 Box({
933 key: `term-${e.requestId}-${index}`,
934 position: 'relative',
935 children: [
936 Button({
937 key: `open-${e.requestId}-${index}`,
938 label: concept.label,
939 plain: true,
940 onPress: async () => {
941 selectedId = concept.id
942 query = ''
943 await $.ui.open({ id: PANE_ID, title: 'OmniLex', focus: true, closeOnEscape: true })
944 $.ui.invalidate('ui.render')
945 },
946 }),
947 Box({
948 position: 'absolute', top: -3, left: 0, display: 'none',
949 hover: { display: 'flex' }, borderStyle: 'round', paddingX: 1,
950 children: [Text({ children: [concept.summary] })],
951 }),
952 ],
953 })
954 )
955 return Box({
956 flexDirection: 'column',
957 children: [original, Box({ flexDirection: 'row', columnGap: 2, children: badges })],
958 })
959 })
960
961 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
962 if (e.props.origin.kind !== 'composer') return next(e)
963 const hints = reverseHints(e.props.text)
964 if (hints.length === 0) return next(e)
965 if (e.props.isExpanded && canRenderInline(e.props.text, e.viewport?.columns)) {
966 const spans = reverseSpans(e.props.text, hints)
967 if (spans.length) {
968 const elements = $.ui.resolve(e)
969 return inlineTree(
970 elements, e.props.text, spans, e.requestId,
971 '> ',
972 (concept) => `${concept.label}: ${concept.summary}`,
973 async (id) => {
974 selectedId = id
975 query = ''
976 await $.ui.open({ id: PANE_ID, title: 'OmniLex', focus: true, closeOnEscape: true })
977 $.ui.invalidate('ui.render')
978 },
979 )
980 }
981 }
982 const original = await next(e)
983 const { Box, Text, Button } = $.ui.resolve(e)
984 return Box({
985 flexDirection: 'column',
986 children: [
987 original,
988 Box({
989 flexDirection: 'row', columnGap: 2,
990 children: hints.map((concept, index) => Box({
991 key: `reverse-${e.requestId}-${index}`, position: 'relative',
992 children: [
993 Button({
994 key: `reverse-open-${e.requestId}-${index}`,
995 label: `→ ${concept.label}`, plain: true,
996 onPress: async () => {
997 selectedId = concept.id
998 query = ''
999 await $.ui.open({ id: PANE_ID, title: 'OmniLex', focus: true, closeOnEscape: true })
1000 $.ui.invalidate('ui.render')
1001 },
1002 }),
1003 Box({
1004 position: 'absolute', top: -3, left: 0, display: 'none',
1005 hover: { display: 'flex' }, borderStyle: 'round', paddingX: 1,
1006 children: [Text({ children: [concept.summary] })],
1007 }),
1008 ],
1009 })),
1010 }),
1011 ],
1012 })
1013 })
1014
1015 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
1016 if (e.requestId !== PANE_ID) return next(e)
1017 const { Box, Text, Button, Input, Select, Markdown } = $.ui.resolve(e)
1018 const strings = UI_TEXT[locale]
1019 const redraw = () => $.ui.invalidate('ui.render')
1020 const results = view === 'search' ? await paneSearch(query) : []
1021 if (view === 'search' && selectFirstResult) {
1022 selectedId = results[0]?.id || null
1023 selectFirstResult = false
1024 }
1025 const selected = conceptsById.get(selectedId)
1026 const explanation = selected ? await explanationFor($, selected.id) : ''
1027 const conceptRow = (concept, index) =>
1028 Box({
1029 key: `result-${index}`,
1030 position: 'relative',
1031 children: [
1032 Button({
1033 key: `select-${index}`,
1034 label: conceptTitle(concept),
1035 plain: true,
1036 onPress: () => { selectedId = concept.id; redraw() },
1037 }),
1038 Box({
1039 position: 'absolute', top: -3, left: 0, display: 'none',
1040 hover: { display: 'flex' }, borderStyle: 'round', paddingX: 1,
1041 children: [Text({ children: [concept.summary] })],
1042 }),
1043 ],
1044 })
1045 const detail = selected
1046 ? Box({ flexDirection: 'column', children: [
1047 Text({ children: [' '] }),
1048 Text({ bold: true, children: [conceptTitle(selected)] }),
1049 Text({ children: [selected.summary] }),
1050 Text({ dimColor: true, children: [`${(selected.domains || []).join(', ')} · ${selected.status || 'draft'}`] }),
1051 Button({
1052 key: 'insert-term', label: strings.insert,
1053 onPress: async () => { await $.prompt.fill({ text: selected.label, mode: 'insert' }) },
1054 }),
1055 ...(explanation
1056 ? [Markdown({ text: explanation.slice(0, 10000) })]
1057 : []),
1058 ] })
1059 : null
1060 const tabs = Box({
1061 flexDirection: 'row', columnGap: 2,
1062 children: [['search', strings.searchTab], ['browse', strings.browseTab]].map(([id, label]) => Button({
1063 key: `tab-${id}`, plain: true,
1064 onPress: () => { view = id; selectedId = null; redraw() },
1065 children: [Text({ bold: view === id, underline: view === id, dimColor: view !== id, children: [label] })],
1066 })),
1067 })
1068
1069 if (view === 'browse') {
1070 if (selected) {
1071 return Box({
1072 flexDirection: 'column',
1073 children: [
1074 tabs,
1075 Button({ key: 'browse-back', label: strings.back, plain: true, onPress: () => { selectedId = null; redraw() } }),
1076 detail,
1077 ],
1078 })
1079 }
1080 const domains = domainOptions('')
1081 if (!domains.some((option) => option.path === browseDomain)) browseDomain = ''
1082 const branches = browseDomain ? domainOptions(browseDomain) : []
1083 if (!branches.some((option) => option.path === browseBranch)) browseBranch = ''
1084 const list = browseList()
1085 const pages = Math.max(1, Math.ceil(list.length / BROWSE_PAGE_SIZE))
1086 browsePage = Math.min(browsePage, pages - 1)
1087 const shown = list.slice(browsePage * BROWSE_PAGE_SIZE, (browsePage + 1) * BROWSE_PAGE_SIZE)
1088 const picker = (key, label, value, options, total, onSelect) => Select({
1089 key, label, value,
1090 options: [
1091 { value: '', label: `${strings.all} (${total})` },
1092 ...options.map((option) => ({ value: option.path, label: `${option.label} (${option.count})` })),
1093 ],
1094 onSelect: (picked) => { onSelect(picked); browsePage = 0; selectedId = null; redraw() },
1095 })
1096 return Box({
1097 flexDirection: 'column',
1098 children: [
1099 tabs,
1100 picker('browse-domain', strings.domain, browseDomain, domains, concepts.length,
1101 (picked) => { browseDomain = picked; browseBranch = '' }),
1102 ...(branches.length > 1
1103 ? [picker('browse-branch', strings.branch, browseBranch, branches,
1104 domains.find((option) => option.path === browseDomain).count,
1105 (picked) => { browseBranch = picked })]
1106 : []),
1107 Text({ children: [' '] }),
1108 ...(shown.length ? shown.map(conceptRow) : [Text({ dimColor: true, children: [strings.empty] })]),
1109 Box({
1110 flexDirection: 'row', columnGap: 2,
1111 children: [
1112 ...(browsePage > 0
1113 ? [Button({ key: 'browse-prev', label: strings.previous, plain: true, onPress: () => { browsePage--; redraw() } })]
1114 : []),
1115 Text({ dimColor: true, children: [strings.page(browsePage + 1, pages, list.length)] }),
1116 ...(browsePage < pages - 1
1117 ? [Button({ key: 'browse-next', label: strings.next, plain: true, onPress: () => { browsePage++; redraw() } })]
1118 : []),
1119 ],
1120 }),
1121 ],
1122 })
1123 }
1124
1125 return Box({
1126 flexDirection: 'column',
1127 children: [
1128 tabs,
1129 Text({ bold: true, children: [strings.searchTitle] }),
1130 Input({
1131 key: 'omnilex-query', label: strings.description, value: query,
1132 placeholder: strings.placeholder,
1133 onInput: (value) => { query = value; selectedId = null; redraw() },
1134 onSubmit: (value) => { query = value; selectedId = null; selectFirstResult = true; redraw() },
1135 }),
1136 ...results.map(conceptRow),
1137 selected
1138 ? detail
1139 : Text({ dimColor: true, children: [query ? strings.choose : strings.hint] }),
1140 ],
1141 })
1142 })
1143}
1144hooks/runtime/matcher.js 395 lines1export function isWordChar(ch) {
2 if (!ch)
3 return false;
4 return /^[\p{L}\p{N}]$/u.test(ch);
5}
6function getCharBefore(text, index) {
7 if (index <= 0)
8 return '';
9 const prevCode = text.charCodeAt(index - 1);
10 if (prevCode >= 0xdc00 && prevCode <= 0xdfff && index >= 2) {
11 return text.slice(index - 2, index);
12 }
13 return text[index - 1];
14}
15function getCharAfter(text, index) {
16 if (index >= text.length)
17 return '';
18 const code = text.charCodeAt(index);
19 if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) {
20 return text.slice(index, index + 2);
21 }
22 return text[index];
23}
24export function cleanTranscript(text) {
25 return text
26 .replace(/```[\s\S]*?```/g, ' ')
27 .replace(/`[^`\n]+`/g, ' ')
28 .replace(/https?:\/\/\S+/g, ' ')
29 .replace(/\]\([^)]*\)/g, ' ');
30}
31export function normalizeSearchTerm(value) {
32 return value
33 .normalize('NFD')
34 .replace(/\p{M}/gu, '')
35 .replace(/đ/gi, 'd')
36 .toLowerCase();
37}
38export class TrieMatcher {
39 nodes;
40 terms;
41 constructor(terms) {
42 this.terms = terms;
43 this.nodes = [{ transitions: new Map(), fail: 0, outputs: [] }];
44 this.buildTrie();
45 }
46 buildTrie() {
47 for (const term of this.terms) {
48 if (term.autolinkConfig === 'never')
49 continue;
50 // Ambiguous homonyms are kept for context voting, but non-homonyms with autolink: false are skipped
51 if (!term.autolink && term.concepts.length <= 1)
52 continue;
53 const normKey = term.key.normalize('NFC').toLowerCase();
54 if (!normKey)
55 continue;
56 let curr = 0;
57 for (const ch of normKey) {
58 if (!this.nodes[curr].transitions.has(ch)) {
59 const nextId = this.nodes.length;
60 this.nodes[curr].transitions.set(ch, nextId);
61 this.nodes.push({ transitions: new Map(), fail: 0, outputs: [] });
62 }
63 curr = this.nodes[curr].transitions.get(ch);
64 }
65 this.nodes[curr].outputs.push({
66 term,
67 key: term.key.normalize('NFC'),
68 len: normKey.length,
69 exactCase: term.match === 'exact-case'
70 });
71 }
72 // BFS construction of Aho-Corasick failure transitions and output propagation
73 const queue = [];
74 for (const [, next] of this.nodes[0].transitions) {
75 queue.push(next);
76 }
77 let queueHead = 0;
78 while (queueHead < queue.length) {
79 const curr = queue[queueHead++];
80 for (const [ch, next] of this.nodes[curr].transitions) {
81 let f = this.nodes[curr].fail;
82 while (f !== 0 && !this.nodes[f].transitions.has(ch)) {
83 f = this.nodes[f].fail;
84 }
85 const failTarget = this.nodes[f].transitions.get(ch) || 0;
86 this.nodes[next].fail = failTarget;
87 if (this.nodes[failTarget].outputs.length > 0) {
88 this.nodes[next].outputs.push(...this.nodes[failTarget].outputs);
89 }
90 queue.push(next);
91 }
92 }
93 }
94 match(rawText, options) {
95 if (!rawText || !rawText.trim())
96 return [];
97 let text = rawText;
98 let mapToRaw = null;
99 // Handle NFD canonical equivalence and offset mapping if text is not NFC
100 if (rawText !== rawText.normalize('NFC')) {
101 text = rawText.normalize('NFC');
102 const rawSegs = [...new Intl.Segmenter().segment(rawText)];
103 const nfcSegs = [...new Intl.Segmenter().segment(text)];
104 if (rawSegs.length === nfcSegs.length) {
105 mapToRaw = (nfcStart, nfcEnd) => {
106 let startSegIdx = 0;
107 let endSegIdx = nfcSegs.length - 1;
108 for (let i = 0; i < nfcSegs.length; i++) {
109 if (nfcSegs[i].index <= nfcStart)
110 startSegIdx = i;
111 if (nfcSegs[i].index + nfcSegs[i].segment.length >= nfcEnd) {
112 endSegIdx = i;
113 break;
114 }
115 }
116 const start = rawSegs[startSegIdx].index;
117 const end = rawSegs[endSegIdx].index + rawSegs[endSegIdx].segment.length;
118 return { start, end };
119 };
120 }
121 }
122 const rawCandidates = [];
123 let curr = 0;
124 for (let i = 0; i < text.length; i++) {
125 const ch = text[i].toLowerCase();
126 while (curr !== 0 && !this.nodes[curr].transitions.has(ch)) {
127 curr = this.nodes[curr].fail;
128 }
129 curr = this.nodes[curr].transitions.get(ch) || 0;
130 if (this.nodes[curr].outputs.length > 0) {
131 for (const out of this.nodes[curr].outputs) {
132 const matchStart = i + 1 - out.len;
133 const matchEnd = i + 1;
134 // Unicode word boundary check
135 const before = getCharBefore(text, matchStart);
136 if (isWordChar(before))
137 continue;
138 const after = getCharAfter(text, matchEnd);
139 if (isWordChar(after))
140 continue;
141 // exact-case check
142 if (out.exactCase && text.slice(matchStart, matchEnd) !== out.key) {
143 continue;
144 }
145 let start = matchStart;
146 let end = matchEnd;
147 if (mapToRaw) {
148 const mapped = mapToRaw(start, end);
149 start = mapped.start;
150 end = mapped.end;
151 }
152 rawCandidates.push({
153 start,
154 end,
155 term: out.term
156 });
157 }
158 }
159 }
160 // Filter blocked ranges
161 const blocked = options?.blocked;
162 let candidates = rawCandidates;
163 if (blocked && blocked.length > 0) {
164 candidates = candidates.filter((cand) => !blocked.some((b) => cand.start < b.end && cand.end > b.start));
165 }
166 // Count context votes from unambiguous concepts
167 const domainVotes = {};
168 for (const cand of candidates) {
169 if (cand.term.concepts.length === 1) {
170 const concept = cand.term.concepts[0];
171 for (const d of concept.domains || []) {
172 const root = d.split('/')[0];
173 domainVotes[root] = (domainVotes[root] || 0) + 1;
174 domainVotes[d] = (domainVotes[d] || 0) + 1;
175 }
176 }
177 }
178 // Resolve candidates
179 const resolvable = [];
180 for (const cand of candidates) {
181 if (cand.term.concepts.length === 1) {
182 resolvable.push({
183 start: cand.start,
184 end: cand.end,
185 term: cand.term,
186 concept: cand.term.concepts[0]
187 });
188 }
189 else if (cand.term.concepts.length > 1) {
190 // Homonym context voting
191 let bestConcept = null;
192 let bestScore = -1;
193 let secondBestScore = -1;
194 for (const concept of cand.term.concepts) {
195 let score = 0;
196 for (const d of concept.domains || []) {
197 const root = d.split('/')[0];
198 score += (domainVotes[root] || 0) + (domainVotes[d] || 0);
199 }
200 if (score > bestScore) {
201 secondBestScore = bestScore;
202 bestScore = score;
203 bestConcept = concept;
204 }
205 else if (score === bestScore) {
206 secondBestScore = score;
207 }
208 }
209 // Only autolink if there is a distinct winning domain
210 if (bestConcept && bestScore > 0 && bestScore > secondBestScore) {
211 resolvable.push({
212 start: cand.start,
213 end: cand.end,
214 term: cand.term,
215 concept: bestConcept
216 });
217 }
218 }
219 }
220 // Sort: longest match first (a.start - b.start || b.end - a.end)
221 resolvable.sort((a, b) => a.start - b.start || b.end - a.end);
222 // Pick non-overlapping spans
223 const chosen = [];
224 let lastChosenEnd = -1;
225 for (const cand of resolvable) {
226 // Candidates are already ordered by start, then longest first. Only the
227 // most recently chosen span can overlap the next candidate.
228 if (cand.start >= lastChosenEnd) {
229 chosen.push(cand);
230 lastChosenEnd = cand.end;
231 }
232 }
233 return chosen;
234 }
235 termHits(text, blocked = []) {
236 return this.match(text, { blocked });
237 }
238 forwardSpans(text, maxBadges = 4) {
239 const hits = this.match(text);
240 return hits.slice(0, maxBadges);
241 }
242 matchedConcepts(text, maxBadges = 4) {
243 const visible = cleanTranscript(text);
244 const hits = this.match(visible);
245 const unique = [];
246 const seen = new Set();
247 for (const hit of hits) {
248 if (!seen.has(hit.concept.id)) {
249 seen.add(hit.concept.id);
250 unique.push(hit.concept);
251 if (unique.length >= maxBadges)
252 break;
253 }
254 }
255 return unique;
256 }
257}
258export class ReverseMatcher {
259 nodes;
260 minLength;
261 constructor(concepts, minLength = 12) {
262 this.minLength = minLength;
263 this.nodes = [{ transitions: new Map(), fail: 0, outputs: [] }];
264 this.buildTrie(concepts);
265 }
266 buildTrie(concepts) {
267 for (const concept of concepts) {
268 const descriptions = concept.plain_descriptions || [];
269 for (const desc of descriptions) {
270 const norm = normalizeSearchTerm(desc);
271 if (norm.length < this.minLength)
272 continue;
273 let curr = 0;
274 for (const ch of norm) {
275 if (!this.nodes[curr].transitions.has(ch)) {
276 const nextId = this.nodes.length;
277 this.nodes[curr].transitions.set(ch, nextId);
278 this.nodes.push({ transitions: new Map(), fail: 0, outputs: [] });
279 }
280 curr = this.nodes[curr].transitions.get(ch);
281 }
282 this.nodes[curr].outputs.push({
283 concept,
284 phrase: desc,
285 len: norm.length
286 });
287 }
288 }
289 // BFS failure links
290 const queue = [];
291 for (const [, next] of this.nodes[0].transitions) {
292 queue.push(next);
293 }
294 let queueHead = 0;
295 while (queueHead < queue.length) {
296 const curr = queue[queueHead++];
297 for (const [ch, next] of this.nodes[curr].transitions) {
298 let f = this.nodes[curr].fail;
299 while (f !== 0 && !this.nodes[f].transitions.has(ch)) {
300 f = this.nodes[f].fail;
301 }
302 const failTarget = this.nodes[f].transitions.get(ch) || 0;
303 this.nodes[next].fail = failTarget;
304 if (this.nodes[failTarget].outputs.length > 0) {
305 this.nodes[next].outputs.push(...this.nodes[failTarget].outputs);
306 }
307 queue.push(next);
308 }
309 }
310 }
311 reverseHints(text, maxBadges = 4) {
312 const norm = normalizeSearchTerm(text);
313 if (!norm)
314 return [];
315 const found = [];
316 const seen = new Set();
317 let curr = 0;
318 for (let i = 0; i < norm.length; i++) {
319 const ch = norm[i];
320 while (curr !== 0 && !this.nodes[curr].transitions.has(ch)) {
321 curr = this.nodes[curr].fail;
322 }
323 curr = this.nodes[curr].transitions.get(ch) || 0;
324 if (this.nodes[curr].outputs.length > 0) {
325 for (const out of this.nodes[curr].outputs) {
326 if (!seen.has(out.concept.id)) {
327 seen.add(out.concept.id);
328 found.push(out.concept);
329 if (found.length >= maxBadges)
330 return found;
331 }
332 }
333 }
334 }
335 return found;
336 }
337 reverseSpans(text, hints, maxBadges = 4) {
338 const normalized = normalizeSearchTextWithMap(text);
339 const candidates = [];
340 for (const concept of hints) {
341 for (const description of concept.plain_descriptions || []) {
342 const phrase = normalizeSearchTerm(description);
343 if (!phrase)
344 continue;
345 let idx = normalized.text.indexOf(phrase);
346 while (idx !== -1) {
347 const start = normalized.starts[idx];
348 const end = normalized.ends[idx + phrase.length - 1];
349 if (start === undefined || end === undefined)
350 break;
351 candidates.push({
352 start,
353 end,
354 concept
355 });
356 idx = normalized.text.indexOf(phrase, idx + phrase.length);
357 }
358 }
359 }
360 candidates.sort((a, b) => a.start - b.start || b.end - a.end);
361 const chosen = [];
362 let lastChosenEnd = -1;
363 for (const cand of candidates) {
364 if (cand.start >= lastChosenEnd) {
365 chosen.push(cand);
366 lastChosenEnd = cand.end;
367 if (chosen.length >= maxBadges)
368 break;
369 }
370 }
371 return chosen;
372 }
373}
374function normalizeSearchTextWithMap(value) {
375 let text = '';
376 const starts = [];
377 const ends = [];
378 const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
379 for (const { segment, index } of segmenter.segment(value)) {
380 const normalized = normalizeSearchTerm(segment);
381 text += normalized;
382 for (let i = 0; i < normalized.length; i++) {
383 starts.push(index);
384 ends.push(index + segment.length);
385 }
386 }
387 return { text, starts, ends };
388}
389export function createMatcher(terms) {
390 return new TrieMatcher(terms);
391}
392export function createReverseMatcher(concepts, minLength = 12) {
393 return new ReverseMatcher(concepts, minLength);
394}
395hooks/runtime/lookup.js 99 lines1import { normalizeSearchTerm } from './matcher.js';
2/**
3 * Runtime friendly search index for the mod's inline lookup pane.
4 * Candidate concepts are found through shared trigrams and terms so each
5 * input update scores only relevant concepts instead of scanning the corpus.
6 */
7export class ConceptLookupIndex {
8 entries;
9 trigrams = new Map();
10 tokens = new Map();
11 constructor(concepts) {
12 this.entries = concepts.map((concept, index) => {
13 const fields = [
14 { value: normalizeSearchTerm(concept.label || ''), weight: 4 },
15 ...(concept.plain_descriptions || []).map((value) => ({ value: normalizeSearchTerm(value), weight: 4 })),
16 ...(concept.aliases || []).map((value) => ({ value: normalizeSearchTerm(value), weight: 3 })),
17 ...(concept.loanwords || []).map((value) => ({ value: normalizeSearchTerm(value), weight: 3 })),
18 { value: normalizeSearchTerm(concept.summary || ''), weight: 1 },
19 ];
20 for (const field of fields) {
21 const chars = [...field.value];
22 const grams = new Set();
23 for (let i = 0; i + 2 < chars.length; i++)
24 grams.add(chars.slice(i, i + 3).join(''));
25 for (const gram of grams)
26 this.addPosting(this.trigrams, gram, index);
27 const words = new Set(field.value.split(/[^\p{L}\p{N}]+/u).filter(Boolean));
28 for (const word of words)
29 this.addPosting(this.tokens, word, index);
30 }
31 return { concept, fields };
32 });
33 }
34 addPosting(index, key, conceptIndex) {
35 let posting = index.get(key);
36 if (!posting)
37 index.set(key, (posting = new Set()));
38 posting.add(conceptIndex);
39 }
40 matchingTrigrams(value) {
41 const chars = [...value];
42 if (chars.length < 3)
43 return new Set();
44 const grams = new Set();
45 for (let i = 0; i + 2 < chars.length; i++)
46 grams.add(chars.slice(i, i + 3).join(''));
47 const postings = [...grams].map((gram) => this.trigrams.get(gram));
48 if (postings.some((posting) => !posting))
49 return new Set();
50 postings.sort((a, b) => a.size - b.size);
51 const [smallest, ...rest] = postings;
52 const matches = new Set();
53 for (const index of smallest) {
54 if (rest.every((posting) => posting.has(index)))
55 matches.add(index);
56 }
57 return matches;
58 }
59 search(input, limit = 5) {
60 const needle = normalizeSearchTerm(input.trim());
61 if (!needle || limit <= 0)
62 return [];
63 const candidates = this.matchingTrigrams(needle);
64 const tokens = [...new Set(needle.split(/[^\p{L}\p{N}]+/u).filter(Boolean))];
65 for (const token of tokens) {
66 const matches = [...token].length >= 3
67 ? this.matchingTrigrams(token)
68 : this.tokens.get(token) || new Set();
69 for (const index of matches)
70 candidates.add(index);
71 }
72 const scored = [];
73 for (const index of candidates) {
74 const entry = this.entries[index];
75 let score = 0;
76 for (const field of entry.fields) {
77 if (field.value === needle)
78 score = Math.max(score, field.weight * 20);
79 else if (field.value.includes(needle))
80 score = Math.max(score, field.weight * 10);
81 else {
82 const hits = tokens.filter((token) => field.value.includes(token)).length;
83 if (hits)
84 score = Math.max(score, field.weight * hits / tokens.length);
85 }
86 }
87 if (score >= 0.5)
88 scored.push({ concept: entry.concept, score });
89 }
90 return scored
91 .sort((a, b) => b.score - a.score || a.concept.label.localeCompare(b.concept.label))
92 .slice(0, limit)
93 .map((item) => item.concept);
94 }
95}
96export function createLookupIndex(concepts) {
97 return new ConceptLookupIndex(concepts);
98}
99hooks/runtime/project.js 375 lines1// Finds the configuration layers of a project, builds the pack catalog and
2// resolves what to load. One implementation for CLI, MCP and the mod (D-2):
3// the file system is an async adapter, paths are absolute and '/'-separated.
4import { load as parseYaml } from './js-yaml.js';
5import { EMPTY_OVERLAY, parseOverlay } from './overlay.js';
6import { isSourceOp, mergeConfigLayers, normalizeConfig, } from './config.js';
7import { PACK_FORMAT_VERSION, } from './pack-format.js';
8import { planLoad, resolvePacks, } from './resolver.js';
9export const STARTER_PACK = 'starter';
10export const D3_PROMPT_THRESHOLD = 2000;
11export function toPosix(value) {
12 return value.replace(/\\/g, '/');
13}
14function isRoot(path) {
15 return path === '/' || /^[A-Za-z]:\/?$/.test(path);
16}
17export function parentDir(path) {
18 const p = toPosix(path);
19 if (isRoot(p))
20 return p;
21 const trimmed = p.replace(/\/+$/, '');
22 const i = trimmed.lastIndexOf('/');
23 if (i < 0)
24 return trimmed;
25 if (i === 0)
26 return '/';
27 const head = trimmed.slice(0, i);
28 return /^[A-Za-z]:$/.test(head) ? `${head}/` : head;
29}
30export function joinPath(base, ...parts) {
31 let result = toPosix(base);
32 for (const part of parts) {
33 result = `${result.replace(/\/+$/, '')}/${toPosix(part).replace(/^\/+/, '')}`;
34 }
35 return result;
36}
37export function resolvePath(base, relative) {
38 const rel = toPosix(relative);
39 const absolute = rel.startsWith('/') || /^[A-Za-z]:\//.test(rel);
40 const start = absolute ? rel : joinPath(base, rel);
41 const drive = /^[A-Za-z]:/.exec(start)?.[0] || '';
42 const segments = [];
43 for (const segment of start.slice(drive.length).split('/')) {
44 if (!segment || segment === '.')
45 continue;
46 if (segment === '..')
47 segments.pop();
48 else
49 segments.push(segment);
50 }
51 return `${drive}/${segments.join('/')}`;
52}
53export function parseLock(text) {
54 if (!text)
55 return null;
56 try {
57 const lock = JSON.parse(text);
58 return lock && lock.lockfileVersion === 1 && typeof lock.packs === 'object' ? lock : null;
59 }
60 catch {
61 return null;
62 }
63}
64// Where a project's local glossary is built (gitignored cache).
65export function localPackId(glossaryDir) {
66 const name = toPosix(glossaryDir).replace(/\/+$/, '').split('/').pop() || 'glossary';
67 return `local.${name.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'glossary'}`;
68}
69export function localPackCacheDir(projectDir, glossaryDir) {
70 return joinPath(projectDir, '.omnilex', '.cache', 'local', localPackId(glossaryDir).slice('local.'.length));
71}
72export function storeDirName(id, version, sha256) {
73 return `${id}@${version}-${sha256.slice(0, 8)}`;
74}
75export function parseStoreDirName(name) {
76 const match = /^(.+)@([^@]+)-([0-9a-f]{8})$/.exec(name);
77 return match ? { id: match[1], version: match[2], sha8: match[3] } : null;
78}
79async function readConfigFile(fs, file, warnings) {
80 const text = await fs.read(file);
81 if (text === null)
82 return null;
83 try {
84 const { config, warnings: found } = normalizeConfig(parseYaml(text), file);
85 warnings.push(...found);
86 return config;
87 }
88 catch (error) {
89 warnings.push(`${file}: YAML không đọc được / unreadable YAML (${error?.message || error})`);
90 return null;
91 }
92}
93export async function discoverLayers(fs, options) {
94 const home = toPosix(options.home).replace(/\/+$/, '');
95 const warnings = [];
96 const files = [];
97 const layers = [];
98 const userFile = joinPath(home, 'config.yaml');
99 files.push(userFile);
100 const user = await readConfigFile(fs, userFile, warnings);
101 if (user)
102 layers.push({ kind: 'user', source: userFile, dir: parentDir(home), config: user });
103 // Walk up from cwd; the home folder is never a project.
104 const projectLayers = [];
105 let projectDir;
106 let dir = toPosix(options.cwd);
107 for (;;) {
108 const marker = joinPath(dir, '.omnilex');
109 if (marker.replace(/\/+$/, '') !== home && await fs.exists(marker)) {
110 projectDir ??= dir;
111 const file = joinPath(marker, 'config.yaml');
112 files.push(file);
113 const config = await readConfigFile(fs, file, warnings);
114 if (config) {
115 projectLayers.unshift({ kind: 'project', source: file, dir, config });
116 if (config.root)
117 break;
118 }
119 }
120 const up = parentDir(dir);
121 if (up === dir)
122 break;
123 dir = up;
124 }
125 layers.push(...projectLayers);
126 if (projectDir) {
127 const file = joinPath(projectDir, '.omnilex', 'config.local.yaml');
128 files.push(file);
129 const local = await readConfigFile(fs, file, warnings);
130 if (local)
131 layers.push({ kind: 'local', source: file, dir: projectDir, config: local });
132 }
133 if (options.session)
134 layers.push({ kind: 'session', source: options.session.source, dir: toPosix(options.cwd), config: options.session.config });
135 return { layers, projectDir, files, warnings };
136}
137// dev:/local: paths become absolute so sources can be compared.
138export function normalizeOps(ops) {
139 return ops.map((op) => (isSourceOp(op) ? { ...op, path: resolvePath(op.dir || '/', op.path) } : op));
140}
141export async function buildCatalog(fs, options) {
142 const sources = [];
143 const packs = [];
144 const notices = [];
145 const watch = [];
146 // dev: builds are read in place; local: glossaries from their built cache.
147 const devDirs = [];
148 for (const op of options.ops) {
149 if (op.op === 'dev' && !devDirs.some((item) => item.dir === op.path))
150 devDirs.push({ dir: op.path, local: false });
151 if (op.op === 'local' && !devDirs.some((item) => item.spec === op.path)) {
152 devDirs.push({ dir: localPackCacheDir(options.projectDir || options.cwd || parentDir(op.path), op.path), spec: op.path, local: true });
153 }
154 }
155 for (const [index, { dir, spec, local }] of devDirs.entries()) {
156 const manifestPath = joinPath(dir, 'manifest.json');
157 watch.push(manifestPath);
158 const text = await fs.read(manifestPath);
159 let parsed;
160 try {
161 parsed = text ? JSON.parse(text) : undefined;
162 }
163 catch {
164 parsed = undefined;
165 }
166 if (!parsed || typeof parsed.formatVersion !== 'number' || !Array.isArray(parsed.packs)) {
167 notices.push(local
168 ? `OmniLex: glossary ${spec} chưa được dựng; chạy ol pack status ở project / glossary ${spec} is not built yet; run ol pack status in the project`
169 : `OmniLex: ${dir} chưa có manifest.json, hãy chạy pnpm ol build / ${dir} has no manifest.json, run pnpm ol build`);
170 continue;
171 }
172 if (parsed.formatVersion > PACK_FORMAT_VERSION) {
173 notices.push(`OmniLex: ${dir} dùng formatVersion ${parsed.formatVersion}, cần cập nhật OmniLex / ${dir} uses formatVersion ${parsed.formatVersion}, please update OmniLex`);
174 continue;
175 }
176 let stale = false;
177 if (fs.mtime && !local) {
178 const [built, concepts] = await Promise.all([fs.mtime(manifestPath), fs.mtime(joinPath(parentDir(dir), 'concepts'))]);
179 stale = built !== null && concepts !== null && concepts > built;
180 }
181 const sourceId = local ? `local${index}` : `dev${index}`;
182 const search = parsed.search
183 ? {
184 packs: parsed.search.packs,
185 files: Object.fromEntries(Object.entries(parsed.search.files).map(([locale, file]) => [locale, { ...file, path: joinPath(dir, file.path) }])),
186 }
187 : undefined;
188 sources.push({
189 id: sourceId,
190 kind: 'dev',
191 dir,
192 spec,
193 contentIds: parsed.packs.filter((pack) => pack.kind === 'content' && pack.role !== 'starter').map((pack) => pack.id),
194 search,
195 stale,
196 searchConfigHash: parsed.searchConfigHash,
197 });
198 const entries = await Promise.all(parsed.packs.map(async (entry) => {
199 const path = joinPath(dir, entry.path);
200 const raw = await fs.read(path);
201 let json;
202 try {
203 json = raw ? JSON.parse(raw) : undefined;
204 }
205 catch {
206 json = undefined;
207 }
208 return { entry, path, json };
209 }));
210 for (const { entry, path, json } of entries) {
211 if (!json)
212 continue;
213 packs.push({
214 id: entry.id,
215 version: entry.version,
216 kind: entry.kind,
217 role: entry.role,
218 requires: json.requires || {},
219 concepts: entry.concepts,
220 tiers: json.tiers,
221 sha256: entry.sha256,
222 sourceId,
223 path,
224 });
225 }
226 }
227 const storeDir = joinPath(options.home, 'store');
228 watch.push(storeDir);
229 if (await fs.exists(storeDir)) {
230 sources.push({ id: 'store', kind: 'store', dir: storeDir });
231 const names = (await fs.list(storeDir)).sort();
232 const loaded = await Promise.all(names.map(async (name) => {
233 const parsedName = parseStoreDirName(name);
234 if (!parsedName)
235 return null;
236 const path = joinPath(storeDir, name, 'pack.json');
237 const raw = await fs.read(path);
238 try {
239 return raw ? { name, path, json: JSON.parse(raw), sha8: parsedName.sha8 } : null;
240 }
241 catch {
242 return null;
243 }
244 }));
245 for (const item of loaded) {
246 if (!item || item.json.formatVersion > PACK_FORMAT_VERSION)
247 continue;
248 packs.push({
249 id: item.json.id,
250 version: item.json.version,
251 kind: item.json.kind,
252 role: item.json.role,
253 requires: item.json.requires || {},
254 concepts: item.json.concepts,
255 tiers: item.json.tiers,
256 sha256: options.lock?.packs[item.json.id]?.store === item.name ? options.lock.packs[item.json.id].sha256 : item.sha8,
257 sourceId: 'store',
258 path: item.path,
259 });
260 }
261 }
262 const pins = options.lock
263 ? Object.fromEntries(Object.entries(options.lock.packs).map(([id, entry]) => [id, entry.version]))
264 : undefined;
265 return { catalog: { sources, packs, pins }, notices, watch };
266}
267// A manifest for PackStore over the resolved packs, wherever they live. The
268// combined index of a dev build is kept only when exactly its packs are loaded.
269export function composeManifest(packs, catalog, configHash) {
270 const ids = packs.map((pack) => pack.id).sort();
271 let search;
272 let searchConfigHash = configHash;
273 for (const source of catalog.sources) {
274 if (source.kind !== 'dev' || !source.search)
275 continue;
276 const fromSource = packs.every((pack) => pack.sourceId === source.id);
277 const all = [...(source.contentIds || [])].sort();
278 if (fromSource && all.length === ids.length && all.every((id, i) => id === ids[i])) {
279 search = source.search;
280 searchConfigHash = source.searchConfigHash || configHash;
281 }
282 }
283 return {
284 formatVersion: PACK_FORMAT_VERSION,
285 searchConfigHash,
286 locales: [],
287 packs: packs.map((pack) => ({
288 id: pack.id,
289 kind: pack.kind,
290 ...(pack.role ? { role: pack.role } : {}),
291 version: pack.version,
292 path: pack.path,
293 sha256: pack.sha256,
294 concepts: pack.concepts,
295 })),
296 search,
297 };
298}
299export async function openProject(fs, options) {
300 const home = toPosix(options.home).replace(/\/+$/, '');
301 const discovered = await discoverLayers(fs, { cwd: options.cwd, home, session: options.session });
302 const effective = mergeConfigLayers(discovered.layers);
303 const notices = [...discovered.warnings];
304 const ops = normalizeOps(effective.packOps);
305 const lockPath = discovered.projectDir
306 ? joinPath(discovered.projectDir, '.omnilex', 'lock.json')
307 : joinPath(home, 'lock.json');
308 const lock = parseLock(await fs.read(lockPath));
309 const { catalog, notices: catalogNotices, watch } = await buildCatalog(fs, { ops, home, lock, projectDir: discovered.projectDir, cwd: toPosix(options.cwd) });
310 notices.push(...catalogNotices);
311 const resolved = resolvePacks(ops, catalog, { packsConfigured: effective.packsConfigured });
312 notices.push(...resolved.errors);
313 if (resolved.missing.length > 0) {
314 notices.push(`OmniLex: thiếu gói ${resolved.missing.join(', ')}; chạy: ol pack install / missing packs ${resolved.missing.join(', ')}; run: ol pack install`);
315 }
316 // D3: with nothing configured, the mod asks before loading a large set.
317 let packs = resolved.packs;
318 if (options.surface === 'mod' && !effective.packsConfigured) {
319 const total = packs.reduce((sum, pack) => sum + pack.concepts, 0);
320 if (total >= D3_PROMPT_THRESHOLD) {
321 notices.push(`OmniLex: đã cài ${total} khái niệm; chọn gói bằng /omnilex packs <id,...>. Tạm thời chỉ nạp ${STARTER_PACK} / ${total} concepts installed; choose packs with /omnilex packs <id,...>. Loading only ${STARTER_PACK} for now`);
322 packs = catalog.packs.filter((pack) => pack.kind === 'content' && pack.role === 'starter').slice(0, 1);
323 }
324 }
325 const locale = options.locale || effective.locale;
326 const plan = planLoad(packs, effective, { surface: options.surface, locale, notice: (message) => notices.push(message) });
327 const loaded = plan.packs.map((item) => item.pack);
328 const disabled = {};
329 for (const item of plan.packs) {
330 const off = Object.entries(item.tiers)
331 .filter(([tier, mode]) => tier !== 'vectors' && mode === 'off')
332 .map(([tier]) => tier);
333 if (off.length)
334 disabled[item.pack.id] = off;
335 }
336 const stale = loaded.some((pack) => catalog.sources.find((source) => source.id === pack.sourceId)?.stale);
337 // Overlay: the `overrides` path (relative to the project), else the default file.
338 const base = discovered.projectDir || toPosix(options.cwd);
339 const overlayPath = effective.overrides
340 ? resolvePath(base, effective.overrides)
341 : discovered.projectDir ? joinPath(discovered.projectDir, '.omnilex', 'overrides.yaml') : undefined;
342 let overlay = EMPTY_OVERLAY;
343 if (overlayPath) {
344 const text = await fs.read(overlayPath);
345 if (text !== null) {
346 try {
347 const parsed = parseOverlay(parseYaml(text), overlayPath);
348 overlay = parsed.overlay;
349 notices.push(...parsed.warnings);
350 }
351 catch (error) {
352 notices.push(`${overlayPath}: YAML không đọc được / unreadable YAML (${error?.message || error})`);
353 }
354 }
355 }
356 return {
357 cwd: toPosix(options.cwd),
358 home,
359 projectDir: discovered.projectDir,
360 layers: discovered.layers,
361 effective,
362 catalog,
363 resolved,
364 plan,
365 manifest: composeManifest(loaded, catalog, options.configHash),
366 disabled,
367 overlay,
368 overlayPath,
369 stale,
370 lockPath,
371 notices,
372 watch: [...discovered.files, lockPath, ...(overlayPath ? [overlayPath] : []), ...watch],
373 };
374}
375hooks/runtime/pack-reader.js 370 lines1import { normalizeSearchTerm } from './matcher.js';
2import { isEmptyOverlay, overlayCards, overlayLookup, overlayTerms } from './overlay.js';
3import { PACK_FORMAT_VERSION, PACK_TIER_FILES, cardToSearchDoc, expandPackSelection, } from './pack-format.js';
4import { SEARCH_RESULT_LIMIT, createSearchIndex, domainMatches, loadSearchIndex, searchConfigHash, searchIndexToJSON, searchIndexes, } from './search-config.js';
5function parseJson(text) {
6 if (typeof text !== 'string')
7 return undefined;
8 try {
9 return JSON.parse(text);
10 }
11 catch {
12 return undefined;
13 }
14}
15function fingerprint(value) {
16 let first = 0x811c9dc5;
17 let second = 0x9e3779b9;
18 for (let i = 0; i < value.length; i++) {
19 const code = value.charCodeAt(i);
20 first = Math.imul(first ^ code, 0x01000193);
21 second = Math.imul(second ^ (code + i), 0x85ebca6b);
22 }
23 return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
24}
25// Reads packs (formatVersion 1). Each notice is reported once per store;
26// missing or newer data degrades to "not available" instead of throwing.
27export class PackStore {
28 io;
29 onNotice;
30 manifest = null;
31 formatVersion = 0;
32 notices = new Set();
33 packs = new Map();
34 cardCache = new Map();
35 termCache = new Map();
36 indexCache = new Map();
37 lookupCache = new Map();
38 configHash = searchConfigHash();
39 cache;
40 disabled;
41 overlay;
42 constructor(io, onNotice = () => { }, options = {}) {
43 this.io = io;
44 this.onNotice = onNotice;
45 this.cache = options.cache;
46 this.disabled = options.disabled || {};
47 this.overlay = options.overlay && !isEmptyOverlay(options.overlay) ? options.overlay : undefined;
48 let manifest = options.manifest;
49 if (!manifest) {
50 const raw = io.readText('manifest.json');
51 // No manifest at all: the legacy flat layout (formatVersion 0), not an error.
52 if (raw === null || raw === undefined)
53 return;
54 manifest = parseJson(raw);
55 }
56 if (!manifest || typeof manifest.formatVersion !== 'number' || !Array.isArray(manifest.packs)) {
57 this.notice('OmniLex: dist/manifest.json không hợp lệ, dùng dữ liệu cũ / invalid dist/manifest.json, using legacy data');
58 return;
59 }
60 this.formatVersion = manifest.formatVersion;
61 if (manifest.formatVersion > PACK_FORMAT_VERSION) {
62 this.notice(`OmniLex: dữ liệu dùng formatVersion ${manifest.formatVersion}, cần cập nhật OmniLex / data uses formatVersion ${manifest.formatVersion}, please update OmniLex`);
63 return;
64 }
65 this.manifest = manifest;
66 }
67 get available() {
68 return this.manifest !== null && this.formatVersion === PACK_FORMAT_VERSION;
69 }
70 notice(message) {
71 if (this.notices.has(message))
72 return;
73 this.notices.add(message);
74 this.onNotice(message);
75 }
76 base(packId) {
77 const entry = this.manifest?.packs.find((item) => item.id === packId);
78 return entry ? entry.path.replace(/\/?pack\.json$/, '') : null;
79 }
80 tierPath(packId, locale, tier) {
81 return `${this.base(packId)}/${locale}/${PACK_TIER_FILES[tier]}`;
82 }
83 tierOff(packId, tier) {
84 return this.disabled[packId]?.includes(tier) ?? false;
85 }
86 pack(id) {
87 if (this.packs.has(id))
88 return this.packs.get(id);
89 const entry = this.manifest?.packs.find((item) => item.id === id);
90 const pack = entry ? parseJson(this.io.readText(entry.path)) : undefined;
91 let result = pack || null;
92 if (pack && pack.formatVersion > PACK_FORMAT_VERSION) {
93 this.notice(`OmniLex: gói ${id} cần OmniLex mới hơn, bỏ qua / pack ${id} needs a newer OmniLex, skipped`);
94 result = null;
95 }
96 else if (!pack && entry) {
97 this.notice(`OmniLex: không đọc được gói ${id} / cannot read pack ${id}`);
98 }
99 this.packs.set(id, result);
100 return result;
101 }
102 contentPacks(selection) {
103 if (!this.manifest)
104 return [];
105 return expandPackSelection(this.manifest, selection, (id) => Object.keys(this.pack(id)?.requires || {}), (id) => this.notice(`OmniLex: không có gói ${id} / pack ${id} not found`)).filter((id) => this.pack(id) !== null);
106 }
107 cards(packId, locale) {
108 const key = `${packId}\u0000${locale}`;
109 let cards = this.cardCache.get(key);
110 if (!cards) {
111 const pack = this.pack(packId);
112 cards = pack ? parseJson(this.io.readText(this.tierPath(packId, locale, 'cards'))) || [] : [];
113 if (this.overlay)
114 cards = overlayCards(this.overlay, cards, locale);
115 this.cardCache.set(key, cards);
116 }
117 return cards;
118 }
119 terms(packId, locale) {
120 const key = `${packId}\u0000${locale}`;
121 let terms = this.termCache.get(key);
122 if (!terms) {
123 const pack = this.pack(packId);
124 terms = pack ? parseJson(this.io.readText(this.tierPath(packId, locale, 'terms'))) || [] : [];
125 if (this.overlay)
126 terms = overlayTerms(this.overlay, terms, locale);
127 this.termCache.set(key, terms);
128 }
129 return terms;
130 }
131 index(packId, locale) {
132 const key = `${packId}\u0000${locale}`;
133 const cached = this.indexCache.get(key);
134 if (cached)
135 return cached;
136 const pack = this.pack(packId);
137 if (!pack)
138 return null;
139 let index = null;
140 if (this.tierOff(packId, 'search')) {
141 index = null;
142 }
143 else if (pack.tiers?.search?.configHash === this.configHash) {
144 const raw = this.io.readText(this.tierPath(packId, locale, 'search'));
145 if (typeof raw === 'string') {
146 try {
147 index = loadSearchIndex(raw);
148 }
149 catch {
150 index = null;
151 }
152 }
153 }
154 else {
155 this.notice(`OmniLex: chỉ mục của gói ${packId} dựng bằng cấu hình tìm kiếm khác, đang dựng lại / search index of pack ${packId} was built with a different search config, rebuilding`);
156 }
157 if (!index)
158 index = createSearchIndex(this.cards(packId, locale).map(cardToSearchDoc));
159 this.indexCache.set(key, index);
160 return index;
161 }
162 // Index over every content pack of one build; null for any other selection.
163 combinedIndex(packIds, locale) {
164 const search = this.manifest?.search;
165 if (!search || search.packs.length !== packIds.length || !search.packs.every((id, i) => id === packIds[i]))
166 return null;
167 const key = `*\u0000${locale}`;
168 const cached = this.indexCache.get(key);
169 if (cached)
170 return cached;
171 let index = null;
172 const file = search.files[locale];
173 if (this.manifest.searchConfigHash !== this.configHash) {
174 this.notice('OmniLex: chỉ mục tìm kiếm dựng bằng cấu hình khác, đang dựng lại / search index was built with a different search config, rebuilding');
175 }
176 else if (file) {
177 const raw = this.io.readText(file.path);
178 if (typeof raw === 'string') {
179 try {
180 index = loadSearchIndex(raw);
181 }
182 catch {
183 index = null;
184 }
185 }
186 }
187 if (!index) {
188 const docs = packIds.flatMap((id) => this.cards(id, locale)).map(cardToSearchDoc);
189 index = createSearchIndex(docs);
190 }
191 this.indexCache.set(key, index);
192 return index;
193 }
194 // Several packs but not a whole build: one index over exactly those cards, so
195 // BM25 statistics (and ranking) match a single corpus. Cached when possible.
196 subsetIndex(packIds, locale) {
197 const shas = packIds.map((id) => `${id}:${this.manifest.packs.find((item) => item.id === id)?.sha256 || ''}`);
198 const key = `subset-${locale}-${fingerprint(`${this.configHash}|${shas.join('|')}`)}`;
199 const cached = this.indexCache.get(key);
200 if (cached)
201 return cached;
202 let index = null;
203 const raw = this.cache?.read(key);
204 if (typeof raw === 'string') {
205 try {
206 index = loadSearchIndex(raw);
207 }
208 catch {
209 index = null;
210 }
211 }
212 if (!index) {
213 const seen = new Set();
214 const cards = packIds.flatMap((id) => this.cards(id, locale)).filter((card) => !seen.has(card.id) && seen.add(card.id));
215 index = createSearchIndex(cards.map(cardToSearchDoc));
216 try {
217 this.cache?.write(key, searchIndexToJSON(index));
218 }
219 catch {
220 // A cache that cannot be written only costs a rebuild next time.
221 }
222 }
223 this.indexCache.set(key, index);
224 return index;
225 }
226 find(query, options) {
227 if (!this.available)
228 return [];
229 const packIds = [...this.contentPacks(options.packs)].sort();
230 if (packIds.length === 0)
231 return [];
232 const index = this.combinedIndex(packIds, options.locale)
233 || (packIds.length === 1 ? this.index(packIds[0], options.locale) : this.subsetIndex(packIds, options.locale));
234 if (!index)
235 return [];
236 const limit = options.limit ?? SEARCH_RESULT_LIMIT;
237 if (!this.overlay?.exclude.length)
238 return searchIndexes([index], query, { domain: options.domain, limit });
239 // Prebuilt indexes still hold excluded concepts: ask for more, then drop them.
240 const excluded = new Set(this.overlay.exclude);
241 return searchIndexes([index], query, { domain: options.domain, limit: limit + excluded.size })
242 .filter((hit) => !excluded.has(hit.id))
243 .slice(0, limit);
244 }
245 lookupMaps(options) {
246 const packIds = this.contentPacks(options.packs);
247 const key = `${options.locale}\u0000${packIds.join(',')}`;
248 let maps = this.lookupCache.get(key);
249 if (!maps) {
250 const byKey = new Map();
251 const byId = new Map();
252 const packOf = new Map();
253 const add = (value, card) => {
254 const normalized = normalizeSearchTerm(value);
255 const list = byKey.get(normalized);
256 if (!list)
257 byKey.set(normalized, [card]);
258 else if (!list.includes(card))
259 list.push(card);
260 };
261 for (const packId of packIds) {
262 for (const card of this.cards(packId, options.locale)) {
263 // A concept in several packs (the starter repeats others): first pack wins.
264 if (byId.has(card.id))
265 continue;
266 byId.set(card.id, card);
267 packOf.set(card.id, packId);
268 add(card.label, card);
269 for (const alias of card.aliases)
270 add(alias, card);
271 for (const loanword of card.loanwords)
272 add(loanword, card);
273 }
274 }
275 maps = { byKey, byId, packOf };
276 this.lookupCache.set(key, maps);
277 }
278 return maps;
279 }
280 // Tier 0: exact id, label, alias or loanword (accent-insensitive).
281 lookup(idOrLabel, options) {
282 if (!this.available)
283 return [];
284 const { byKey, byId } = this.lookupMaps(options);
285 if (idOrLabel.startsWith('id:')) {
286 const card = byId.get(idOrLabel.slice(3));
287 return card ? [card] : [];
288 }
289 const matches = byKey.get(normalizeSearchTerm(idOrLabel.trim())) || [];
290 if (matches.length > 0)
291 return this.overlay ? overlayLookup(this.overlay, idOrLabel, matches) : [...matches];
292 const direct = byId.get(idOrLabel);
293 return direct ? [direct] : [];
294 }
295 card(id, options) {
296 return this.available ? this.lookupMaps(options).byId.get(id) : undefined;
297 }
298 browse(domainPrefix, options) {
299 if (!this.available)
300 return [];
301 const seen = new Set();
302 return this.contentPacks(options.packs)
303 .flatMap((packId) => this.cards(packId, options.locale))
304 .filter((card) => !seen.has(card.id) && seen.add(card.id) && card.domains.some((domain) => domainMatches(domain, domainPrefix)));
305 }
306 explanation(id, locale) {
307 if (!this.available)
308 return null;
309 const packId = this.lookupMaps({ locale }).packOf.get(id);
310 if (!packId || this.tierOff(packId, 'explanations'))
311 return null;
312 // A built dist also has one small file per concept next to its packs.
313 const root = /^(?:(.*)\/)?packs\/[^/]+$/.exec(this.base(packId) || '');
314 if (root) {
315 const single = parseJson(this.io.readText(`${root[1] ? `${root[1]}/` : ''}${locale}/concepts/${id}.json`));
316 if (single && single.id === id)
317 return single;
318 }
319 const raw = this.io.readText(this.tierPath(packId, locale, 'explanations'));
320 for (const line of (raw || '').split('\n')) {
321 const record = parseJson(line);
322 if (record?.id === id)
323 return record;
324 }
325 return null;
326 }
327}
328// Lets an async file system (the mod's $.fs) drive the synchronous PackStore:
329// a read that is not fetched yet throws, `run` fetches it and retries.
330export class PendingRead extends Error {
331 path;
332 constructor(path) {
333 super(`pending read: ${path}`);
334 this.path = path;
335 }
336}
337export class PrefetchIO {
338 fetch;
339 files = new Map();
340 constructor(fetch) {
341 this.fetch = fetch;
342 }
343 readText(path) {
344 if (!this.files.has(path))
345 throw new PendingRead(path);
346 return this.files.get(path);
347 }
348 has(path) {
349 return this.files.has(path);
350 }
351 async prefetch(paths) {
352 const wanted = [...new Set(paths)].filter((path) => !this.files.has(path));
353 const texts = await Promise.all(wanted.map((path) => this.fetch(path).catch(() => null)));
354 wanted.forEach((path, i) => this.files.set(path, texts[i] ?? null));
355 }
356 async run(fn, maxRounds = 256) {
357 for (let round = 0; round < maxRounds; round++) {
358 try {
359 return fn();
360 }
361 catch (error) {
362 if (!(error instanceof PendingRead))
363 throw error;
364 await this.prefetch([error.path]);
365 }
366 }
367 throw new Error('PrefetchIO: too many rounds');
368 }
369}
370hooks/runtime/pack-format.js 123 lines1// Plain data and pure helpers shared by the builder and every reader. No Node
2// APIs, so the mod runtime can load this file as is.
3export const PACK_FORMAT_VERSION = 1;
4export const PACK_TIERS = ['terms', 'cards', 'search', 'explanations'];
5export const PACK_TIER_FILES = {
6 terms: 'terms.json',
7 cards: 'cards.json',
8 search: 'search-index.json',
9 explanations: 'explanations.jsonl',
10};
11export const PACK_SPLIT_WARNING = 2000;
12// D6: the starter pack ships terms, cards and search only, at most 1 MB.
13export const STARTER_PACK_ID = 'starter';
14export const STARTER_TIERS = ['terms', 'cards', 'search'];
15export const STARTER_MAX_CONCEPTS = 300;
16export const STARTER_MAX_BYTES = 1024 * 1024;
17// "it/ai-ml/llm-agents" -> "it.ai-ml"; a root packed whole -> "iot";
18// a concept tagged at the root of a split domain -> "it.core".
19export function packIdForDomain(domain, packingOf) {
20 const [root, branch] = domain.split('/');
21 if (packingOf(root) === 'root')
22 return root;
23 return `${root}.${branch || 'core'}`;
24}
25export function termConceptFromCard(card) {
26 return {
27 id: card.id,
28 label: card.label,
29 qualifier: card.qualifier,
30 summary: card.summary,
31 domains: card.domains,
32 };
33}
34export function cardToSearchDoc(card) {
35 return {
36 id: card.id,
37 label: card.label,
38 qualifier: card.qualifier,
39 aliases: card.aliases.join(' '),
40 loanwords: card.loanwords.join(' '),
41 plain_descriptions: card.plain_descriptions.join(' '),
42 summary: card.summary,
43 domains: card.domains,
44 level: card.level,
45 status: card.status,
46 };
47}
48// Keys shared by several concepts, possibly from different packs, are merged
49// here at load time; a key that points to more than one concept never
50// autolinks.
51export function mergeTerms(terms, conceptFor) {
52 const grouped = new Map();
53 for (const term of terms) {
54 const concept = conceptFor(term.conceptId);
55 if (!concept)
56 continue;
57 const key = term.key.trim().toLowerCase();
58 const existing = grouped.get(key);
59 if (existing) {
60 if (!existing.concepts.some((item) => item.id === concept.id))
61 existing.concepts.push(concept);
62 if (term.match === 'exact-case')
63 existing.match = 'exact-case';
64 if (term.autolinkConfig === 'always')
65 existing.autolinkConfig = 'always';
66 if (term.autolinkConfig === 'never')
67 existing.autolinkConfig = 'never';
68 existing.autolink = existing.autolink && term.autolink;
69 }
70 else {
71 grouped.set(key, {
72 key: term.key,
73 match: term.match,
74 concepts: [concept],
75 autolink: term.autolink,
76 autolinkConfig: term.autolinkConfig,
77 });
78 }
79 }
80 return Array.from(grouped.values()).map((term) => {
81 const explicitAlways = term.autolinkConfig === 'always';
82 const explicitNever = term.autolinkConfig === 'never';
83 const shouldAutolink = explicitNever
84 ? false
85 : explicitAlways
86 ? term.concepts.length === 1
87 : term.autolink && term.concepts.length === 1 && !/^(margin|token)$/i.test(term.key);
88 const { autolinkConfig, ...rest } = term;
89 return { ...rest, autolink: shouldAutolink };
90 });
91}
92// Expands meta-packs into the content packs they require. Unknown ids are
93// reported through `onMissing` and skipped.
94export function expandPackSelection(manifest, wanted, requiresOf, onMissing = () => { }) {
95 const byId = new Map(manifest.packs.map((pack) => [pack.id, pack]));
96 if (!wanted || wanted.length === 0) {
97 // The starter repeats concepts of the other packs: it counts only when alone.
98 const content = manifest.packs.filter((pack) => pack.kind === 'content');
99 const full = content.filter((pack) => pack.role !== 'starter');
100 return (full.length ? full : content).map((pack) => pack.id);
101 }
102 const result = [];
103 const seen = new Set();
104 const visit = (id) => {
105 if (seen.has(id))
106 return;
107 seen.add(id);
108 const pack = byId.get(id);
109 if (!pack) {
110 onMissing(id);
111 return;
112 }
113 if (pack.kind === 'content')
114 result.push(id);
115 else
116 for (const child of requiresOf(id))
117 visit(child);
118 };
119 for (const id of wanted)
120 visit(id);
121 return result;
122}
123hooks/runtime/search-config.js 92 lines1import MiniSearch from './minisearch.js';
2import { normalizeSearchTerm } from './matcher.js';
3// Shared by build, CLI, MCP, site and mod. Bump the version when ranking
4// behaviour changes in a way the option fingerprint below cannot see.
5export const SEARCH_CONFIG_VERSION = 1;
6export const SEARCH_RESULT_LIMIT = 5;
7export const SEARCH_SCORE_RATIO = 0.35;
8export const SEARCH_INDEX_OPTIONS = {
9 fields: ['id', 'label', 'aliases', 'loanwords', 'plain_descriptions', 'summary'],
10 storeFields: ['id', 'label', 'qualifier', 'summary', 'domains', 'plain_descriptions', 'status'],
11 processTerm: normalizeSearchTerm,
12 searchOptions: {
13 boost: { id: 3, plain_descriptions: 3, label: 3, aliases: 2, summary: 1 },
14 combineWith: 'AND',
15 fuzzy: 0.15,
16 prefix: true,
17 },
18};
19// Probes the normalizer by behaviour so the hash is the same whichever
20// compiler emitted the function.
21const NORMALIZE_PROBES = ['Lãi Kép', 'ĐẦU TƯ', 'Tiền gốc', 'API Gateway', 'Ångström fi'];
22function fingerprint(value) {
23 let first = 0x811c9dc5;
24 let second = 0x9e3779b9;
25 for (let i = 0; i < value.length; i++) {
26 const code = value.charCodeAt(i);
27 first = Math.imul(first ^ code, 0x01000193);
28 second = Math.imul(second ^ (code + i), 0x85ebca6b);
29 }
30 return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0).toString(16).padStart(8, '0')}`;
31}
32export function searchConfigHash() {
33 const { processTerm, ...options } = SEARCH_INDEX_OPTIONS;
34 return fingerprint(JSON.stringify({
35 version: SEARCH_CONFIG_VERSION,
36 options,
37 normalize: NORMALIZE_PROBES.map((probe) => processTerm(probe)),
38 }));
39}
40export function createSearchIndex(docs) {
41 const miniSearch = new MiniSearch(SEARCH_INDEX_OPTIONS);
42 miniSearch.addAll(docs);
43 return miniSearch;
44}
45export function searchIndexToJSON(index) {
46 return JSON.stringify(index.toJSON());
47}
48export function loadSearchIndex(json) {
49 if (typeof json === 'string') {
50 return MiniSearch.loadJSON(json, SEARCH_INDEX_OPTIONS);
51 }
52 return MiniSearch.loadJS(json, SEARCH_INDEX_OPTIONS);
53}
54// Taxonomy-path prefix on segment boundaries: "it" matches "it/web" but not "iot".
55export function domainMatches(domain, prefix) {
56 const trimmed = prefix.replace(/\/+$/, '');
57 return domain === trimmed || domain.startsWith(`${trimmed}/`);
58}
59function rank(hits, domain, limit) {
60 hits.sort((a, b) => b.score - a.score);
61 const bestScore = hits[0]?.score || 0;
62 let filtered = hits.filter((hit) => hit.score >= bestScore * SEARCH_SCORE_RATIO);
63 if (domain) {
64 filtered = filtered.filter((hit) => Array.isArray(hit.domains) && hit.domains.some((d) => domainMatches(d, domain)));
65 }
66 return filtered.slice(0, limit).map((hit) => ({
67 id: hit.id,
68 label: hit.label,
69 qualifier: hit.qualifier,
70 summary: hit.summary,
71 domains: hit.domains,
72 status: hit.status,
73 score: hit.score,
74 }));
75}
76// Exact and prefix matches first; fuzzy matching (typo tolerance) only runs
77// when they find nothing, because fuzzy expansion dominates query time on a
78// large vocabulary (50-60 ms for a long description at 10k concepts).
79export function searchIndexes(indexes, query, options = {}) {
80 const limit = options.limit ?? SEARCH_RESULT_LIMIT;
81 const run = (fuzzy) => {
82 const hits = [];
83 for (const index of indexes) {
84 for (const hit of index.search(query, fuzzy ? undefined : { fuzzy: false }))
85 hits.push(hit);
86 }
87 return rank(hits, options.domain, limit);
88 };
89 const exact = run(false);
90 return exact.length > 0 ? exact : run(true);
91}
92hooks/runtime/js-yaml.js 3083 lines1function getDefaultExportFromCjs(x) {
2 return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
3}
4var jsYaml = {};
5var loader = {};
6var common = {};
7var hasRequiredCommon;
8function requireCommon() {
9 if (hasRequiredCommon) return common;
10 hasRequiredCommon = 1;
11 function isNothing(subject) {
12 return typeof subject === "undefined" || subject === null;
13 }
14 function isObject(subject) {
15 return typeof subject === "object" && subject !== null;
16 }
17 function toArray(sequence) {
18 if (Array.isArray(sequence)) return sequence;
19 else if (isNothing(sequence)) return [];
20 return [sequence];
21 }
22 function extend(target, source) {
23 if (source) {
24 const sourceKeys = Object.keys(source);
25 for (let index = 0, length = sourceKeys.length; index < length; index += 1) {
26 const key = sourceKeys[index];
27 target[key] = source[key];
28 }
29 }
30 return target;
31 }
32 function repeat(string, count) {
33 let result = "";
34 for (let cycle = 0; cycle < count; cycle += 1) {
35 result += string;
36 }
37 return result;
38 }
39 function isNegativeZero(number) {
40 return number === 0 && Number.NEGATIVE_INFINITY === 1 / number;
41 }
42 common.isNothing = isNothing;
43 common.isObject = isObject;
44 common.toArray = toArray;
45 common.repeat = repeat;
46 common.isNegativeZero = isNegativeZero;
47 common.extend = extend;
48 return common;
49}
50var exception;
51var hasRequiredException;
52function requireException() {
53 if (hasRequiredException) return exception;
54 hasRequiredException = 1;
55 function formatError(exception2, compact) {
56 let where = "";
57 const message = exception2.reason || "(unknown reason)";
58 if (!exception2.mark) return message;
59 if (exception2.mark.name) {
60 where += 'in "' + exception2.mark.name + '" ';
61 }
62 where += "(" + (exception2.mark.line + 1) + ":" + (exception2.mark.column + 1) + ")";
63 if (!compact && exception2.mark.snippet) {
64 where += "\n\n" + exception2.mark.snippet;
65 }
66 return message + " " + where;
67 }
68 function YAMLException2(reason, mark) {
69 Error.call(this);
70 this.name = "YAMLException";
71 this.reason = reason;
72 this.mark = mark;
73 this.message = formatError(this, false);
74 if (Error.captureStackTrace) {
75 Error.captureStackTrace(this, this.constructor);
76 } else {
77 this.stack = new Error().stack || "";
78 }
79 }
80 YAMLException2.prototype = Object.create(Error.prototype);
81 YAMLException2.prototype.constructor = YAMLException2;
82 YAMLException2.prototype.toString = function toString(compact) {
83 return this.name + ": " + formatError(this, compact);
84 };
85 exception = YAMLException2;
86 return exception;
87}
88var snippet;
89var hasRequiredSnippet;
90function requireSnippet() {
91 if (hasRequiredSnippet) return snippet;
92 hasRequiredSnippet = 1;
93 const common2 = requireCommon();
94 function getLine(buffer, lineStart, lineEnd, position, maxLineLength) {
95 let head = "";
96 let tail = "";
97 const maxHalfLength = Math.floor(maxLineLength / 2) - 1;
98 if (position - lineStart > maxHalfLength) {
99 head = " ... ";
100 lineStart = position - maxHalfLength + head.length;
101 }
102 if (lineEnd - position > maxHalfLength) {
103 tail = " ...";
104 lineEnd = position + maxHalfLength - tail.length;
105 }
106 return {
107 str: head + buffer.slice(lineStart, lineEnd).replace(/\t/g, "→") + tail,
108 pos: position - lineStart + head.length
109 // relative position
110 };
111 }
112 function padStart(string, max) {
113 return common2.repeat(" ", max - string.length) + string;
114 }
115 function makeSnippet(mark, options) {
116 options = Object.create(options || null);
117 if (!mark.buffer) return null;
118 if (!options.maxLength) options.maxLength = 79;
119 if (typeof options.indent !== "number") options.indent = 1;
120 if (typeof options.linesBefore !== "number") options.linesBefore = 3;
121 if (typeof options.linesAfter !== "number") options.linesAfter = 2;
122 const re = /\r?\n|\r|\0/g;
123 const lineStarts = [0];
124 const lineEnds = [];
125 let match;
126 let foundLineNo = -1;
127 while (match = re.exec(mark.buffer)) {
128 lineEnds.push(match.index);
129 lineStarts.push(match.index + match[0].length);
130 if (mark.position <= match.index && foundLineNo < 0) {
131 foundLineNo = lineStarts.length - 2;
132 }
133 }
134 if (foundLineNo < 0) foundLineNo = lineStarts.length - 1;
135 let result = "";
136 const lineNoLength = Math.min(mark.line + options.linesAfter, lineEnds.length).toString().length;
137 const maxLineLength = options.maxLength - (options.indent + lineNoLength + 3);
138 for (let i = 1; i <= options.linesBefore; i++) {
139 if (foundLineNo - i < 0) break;
140 const line2 = getLine(
141 mark.buffer,
142 lineStarts[foundLineNo - i],
143 lineEnds[foundLineNo - i],
144 mark.position - (lineStarts[foundLineNo] - lineStarts[foundLineNo - i]),
145 maxLineLength
146 );
147 result = common2.repeat(" ", options.indent) + padStart((mark.line - i + 1).toString(), lineNoLength) + " | " + line2.str + "\n" + result;
148 }
149 const line = getLine(mark.buffer, lineStarts[foundLineNo], lineEnds[foundLineNo], mark.position, maxLineLength);
150 result += common2.repeat(" ", options.indent) + padStart((mark.line + 1).toString(), lineNoLength) + " | " + line.str + "\n";
151 result += common2.repeat("-", options.indent + lineNoLength + 3 + line.pos) + "^\n";
152 for (let i = 1; i <= options.linesAfter; i++) {
153 if (foundLineNo + i >= lineEnds.length) break;
154 const line2 = getLine(
155 mark.buffer,
156 lineStarts[foundLineNo + i],
157 lineEnds[foundLineNo + i],
158 mark.position - (lineStarts[foundLineNo] - lineStarts[foundLineNo + i]),
159 maxLineLength
160 );
161 result += common2.repeat(" ", options.indent) + padStart((mark.line + i + 1).toString(), lineNoLength) + " | " + line2.str + "\n";
162 }
163 return result.replace(/\n$/, "");
164 }
165 snippet = makeSnippet;
166 return snippet;
167}
168var type;
169var hasRequiredType;
170function requireType() {
171 if (hasRequiredType) return type;
172 hasRequiredType = 1;
173 const YAMLException2 = requireException();
174 const TYPE_CONSTRUCTOR_OPTIONS = [
175 "kind",
176 "multi",
177 "resolve",
178 "construct",
179 "instanceOf",
180 "predicate",
181 "represent",
182 "representName",
183 "defaultStyle",
184 "styleAliases"
185 ];
186 const YAML_NODE_KINDS = [
187 "scalar",
188 "sequence",
189 "mapping"
190 ];
191 function compileStyleAliases(map2) {
192 const result = {};
193 if (map2 !== null) {
194 Object.keys(map2).forEach(function(style) {
195 map2[style].forEach(function(alias) {
196 result[String(alias)] = style;
197 });
198 });
199 }
200 return result;
201 }
202 function Type2(tag, options) {
203 options = options || {};
204 Object.keys(options).forEach(function(name) {
205 if (TYPE_CONSTRUCTOR_OPTIONS.indexOf(name) === -1) {
206 throw new YAMLException2('Unknown option "' + name + '" is met in definition of "' + tag + '" YAML type.');
207 }
208 });
209 this.options = options;
210 this.tag = tag;
211 this.kind = options["kind"] || null;
212 this.resolve = options["resolve"] || function() {
213 return true;
214 };
215 this.construct = options["construct"] || function(data) {
216 return data;
217 };
218 this.instanceOf = options["instanceOf"] || null;
219 this.predicate = options["predicate"] || null;
220 this.represent = options["represent"] || null;
221 this.representName = options["representName"] || null;
222 this.defaultStyle = options["defaultStyle"] || null;
223 this.multi = options["multi"] || false;
224 this.styleAliases = compileStyleAliases(options["styleAliases"] || null);
225 if (YAML_NODE_KINDS.indexOf(this.kind) === -1) {
226 throw new YAMLException2('Unknown kind "' + this.kind + '" is specified for "' + tag + '" YAML type.');
227 }
228 }
229 type = Type2;
230 return type;
231}
232var schema;
233var hasRequiredSchema;
234function requireSchema() {
235 if (hasRequiredSchema) return schema;
236 hasRequiredSchema = 1;
237 const YAMLException2 = requireException();
238 const Type2 = requireType();
239 function compileList(schema2, name) {
240 const result = [];
241 schema2[name].forEach(function(currentType) {
242 let newIndex = result.length;
243 result.forEach(function(previousType, previousIndex) {
244 if (previousType.tag === currentType.tag && previousType.kind === currentType.kind && previousType.multi === currentType.multi) {
245 newIndex = previousIndex;
246 }
247 });
248 result[newIndex] = currentType;
249 });
250 return result;
251 }
252 function compileMap() {
253 const result = {
254 scalar: {},
255 sequence: {},
256 mapping: {},
257 fallback: {},
258 multi: {
259 scalar: [],
260 sequence: [],
261 mapping: [],
262 fallback: []
263 }
264 };
265 function collectType(type2) {
266 if (type2.multi) {
267 result.multi[type2.kind].push(type2);
268 result.multi["fallback"].push(type2);
269 } else {
270 result[type2.kind][type2.tag] = result["fallback"][type2.tag] = type2;
271 }
272 }
273 for (let index = 0, length = arguments.length; index < length; index += 1) {
274 arguments[index].forEach(collectType);
275 }
276 return result;
277 }
278 function Schema2(definition) {
279 return this.extend(definition);
280 }
281 Schema2.prototype.extend = function extend(definition) {
282 let implicit = [];
283 let explicit = [];
284 if (definition instanceof Type2) {
285 explicit.push(definition);
286 } else if (Array.isArray(definition)) {
287 explicit = explicit.concat(definition);
288 } else if (definition && (Array.isArray(definition.implicit) || Array.isArray(definition.explicit))) {
289 if (definition.implicit) implicit = implicit.concat(definition.implicit);
290 if (definition.explicit) explicit = explicit.concat(definition.explicit);
291 } else {
292 throw new YAMLException2("Schema.extend argument should be a Type, [ Type ], or a schema definition ({ implicit: [...], explicit: [...] })");
293 }
294 implicit.forEach(function(type2) {
295 if (!(type2 instanceof Type2)) {
296 throw new YAMLException2("Specified list of YAML types (or a single Type object) contains a non-Type object.");
297 }
298 if (type2.loadKind && type2.loadKind !== "scalar") {
299 throw new YAMLException2("There is a non-scalar type in the implicit list of a schema. Implicit resolving of such types is not supported.");
300 }
301 if (type2.multi) {
302 throw new YAMLException2("There is a multi type in the implicit list of a schema. Multi tags can only be listed as explicit.");
303 }
304 });
305 explicit.forEach(function(type2) {
306 if (!(type2 instanceof Type2)) {
307 throw new YAMLException2("Specified list of YAML types (or a single Type object) contains a non-Type object.");
308 }
309 });
310 const result = Object.create(Schema2.prototype);
311 result.implicit = (this.implicit || []).concat(implicit);
312 result.explicit = (this.explicit || []).concat(explicit);
313 result.compiledImplicit = compileList(result, "implicit");
314 result.compiledExplicit = compileList(result, "explicit");
315 result.compiledTypeMap = compileMap(result.compiledImplicit, result.compiledExplicit);
316 return result;
317 };
318 schema = Schema2;
319 return schema;
320}
321var str;
322var hasRequiredStr;
323function requireStr() {
324 if (hasRequiredStr) return str;
325 hasRequiredStr = 1;
326 const Type2 = requireType();
327 str = new Type2("tag:yaml.org,2002:str", {
328 kind: "scalar",
329 construct: function(data) {
330 return data !== null ? data : "";
331 }
332 });
333 return str;
334}
335var seq;
336var hasRequiredSeq;
337function requireSeq() {
338 if (hasRequiredSeq) return seq;
339 hasRequiredSeq = 1;
340 const Type2 = requireType();
341 seq = new Type2("tag:yaml.org,2002:seq", {
342 kind: "sequence",
343 construct: function(data) {
344 return data !== null ? data : [];
345 }
346 });
347 return seq;
348}
349var map;
350var hasRequiredMap;
351function requireMap() {
352 if (hasRequiredMap) return map;
353 hasRequiredMap = 1;
354 const Type2 = requireType();
355 map = new Type2("tag:yaml.org,2002:map", {
356 kind: "mapping",
357 construct: function(data) {
358 return data !== null ? data : {};
359 }
360 });
361 return map;
362}
363var failsafe;
364var hasRequiredFailsafe;
365function requireFailsafe() {
366 if (hasRequiredFailsafe) return failsafe;
367 hasRequiredFailsafe = 1;
368 const Schema2 = requireSchema();
369 failsafe = new Schema2({
370 explicit: [
371 requireStr(),
372 requireSeq(),
373 requireMap()
374 ]
375 });
376 return failsafe;
377}
378var _null;
379var hasRequired_null;
380function require_null() {
381 if (hasRequired_null) return _null;
382 hasRequired_null = 1;
383 const Type2 = requireType();
384 function resolveYamlNull(data) {
385 if (data === null) return true;
386 const max = data.length;
387 return max === 1 && data === "~" || max === 4 && (data === "null" || data === "Null" || data === "NULL");
388 }
389 function constructYamlNull() {
390 return null;
391 }
392 function isNull(object) {
393 return object === null;
394 }
395 _null = new Type2("tag:yaml.org,2002:null", {
396 kind: "scalar",
397 resolve: resolveYamlNull,
398 construct: constructYamlNull,
399 predicate: isNull,
400 represent: {
401 canonical: function() {
402 return "~";
403 },
404 lowercase: function() {
405 return "null";
406 },
407 uppercase: function() {
408 return "NULL";
409 },
410 camelcase: function() {
411 return "Null";
412 },
413 empty: function() {
414 return "";
415 }
416 },
417 defaultStyle: "lowercase"
418 });
419 return _null;
420}
421var bool;
422var hasRequiredBool;
423function requireBool() {
424 if (hasRequiredBool) return bool;
425 hasRequiredBool = 1;
426 const Type2 = requireType();
427 function resolveYamlBoolean(data) {
428 if (data === null) return false;
429 const max = data.length;
430 return max === 4 && (data === "true" || data === "True" || data === "TRUE") || max === 5 && (data === "false" || data === "False" || data === "FALSE");
431 }
432 function constructYamlBoolean(data) {
433 return data === "true" || data === "True" || data === "TRUE";
434 }
435 function isBoolean(object) {
436 return Object.prototype.toString.call(object) === "[object Boolean]";
437 }
438 bool = new Type2("tag:yaml.org,2002:bool", {
439 kind: "scalar",
440 resolve: resolveYamlBoolean,
441 construct: constructYamlBoolean,
442 predicate: isBoolean,
443 represent: {
444 lowercase: function(object) {
445 return object ? "true" : "false";
446 },
447 uppercase: function(object) {
448 return object ? "TRUE" : "FALSE";
449 },
450 camelcase: function(object) {
451 return object ? "True" : "False";
452 }
453 },
454 defaultStyle: "lowercase"
455 });
456 return bool;
457}
458var int;
459var hasRequiredInt;
460function requireInt() {
461 if (hasRequiredInt) return int;
462 hasRequiredInt = 1;
463 const common2 = requireCommon();
464 const Type2 = requireType();
465 function isHexCode(c) {
466 return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102;
467 }
468 function isOctCode(c) {
469 return c >= 48 && c <= 55;
470 }
471 function isDecCode(c) {
472 return c >= 48 && c <= 57;
473 }
474 function resolveYamlInteger(data) {
475 if (data === null) return false;
476 const max = data.length;
477 let index = 0;
478 let hasDigits = false;
479 if (!max) return false;
480 let ch = data[index];
481 if (ch === "-" || ch === "+") {
482 ch = data[++index];
483 }
484 if (ch === "0") {
485 if (index + 1 === max) return true;
486 ch = data[++index];
487 if (ch === "b") {
488 index++;
489 for (; index < max; index++) {
490 ch = data[index];
491 if (ch !== "0" && ch !== "1") return false;
492 hasDigits = true;
493 }
494 return hasDigits && isFinite(parseYamlInteger(data));
495 }
496 if (ch === "x") {
497 index++;
498 for (; index < max; index++) {
499 if (!isHexCode(data.charCodeAt(index))) return false;
500 hasDigits = true;
501 }
502 return hasDigits && isFinite(parseYamlInteger(data));
503 }
504 if (ch === "o") {
505 index++;
506 for (; index < max; index++) {
507 if (!isOctCode(data.charCodeAt(index))) return false;
508 hasDigits = true;
509 }
510 return hasDigits && isFinite(parseYamlInteger(data));
511 }
512 }
513 for (; index < max; index++) {
514 if (!isDecCode(data.charCodeAt(index))) {
515 return false;
516 }
517 hasDigits = true;
518 }
519 if (!hasDigits) return false;
520 return isFinite(parseYamlInteger(data));
521 }
522 function parseYamlInteger(data) {
523 let value = data;
524 let sign = 1;
525 let ch = value[0];
526 if (ch === "-" || ch === "+") {
527 if (ch === "-") sign = -1;
528 value = value.slice(1);
529 ch = value[0];
530 }
531 if (value === "0") return 0;
532 if (ch === "0") {
533 if (value[1] === "b") return sign * parseInt(value.slice(2), 2);
534 if (value[1] === "x") return sign * parseInt(value.slice(2), 16);
535 if (value[1] === "o") return sign * parseInt(value.slice(2), 8);
536 }
537 return sign * parseInt(value, 10);
538 }
539 function constructYamlInteger(data) {
540 return parseYamlInteger(data);
541 }
542 function isInteger(object) {
543 return Object.prototype.toString.call(object) === "[object Number]" && (object % 1 === 0 && !common2.isNegativeZero(object));
544 }
545 int = new Type2("tag:yaml.org,2002:int", {
546 kind: "scalar",
547 resolve: resolveYamlInteger,
548 construct: constructYamlInteger,
549 predicate: isInteger,
550 represent: {
551 binary: function(obj) {
552 return obj >= 0 ? "0b" + obj.toString(2) : "-0b" + obj.toString(2).slice(1);
553 },
554 octal: function(obj) {
555 return obj >= 0 ? "0o" + obj.toString(8) : "-0o" + obj.toString(8).slice(1);
556 },
557 decimal: function(obj) {
558 return obj.toString(10);
559 },
560 hexadecimal: function(obj) {
561 return obj >= 0 ? "0x" + obj.toString(16).toUpperCase() : "-0x" + obj.toString(16).toUpperCase().slice(1);
562 }
563 },
564 defaultStyle: "decimal",
565 styleAliases: {
566 binary: [2, "bin"],
567 octal: [8, "oct"],
568 decimal: [10, "dec"],
569 hexadecimal: [16, "hex"]
570 }
571 });
572 return int;
573}
574var float;
575var hasRequiredFloat;
576function requireFloat() {
577 if (hasRequiredFloat) return float;
578 hasRequiredFloat = 1;
579 const common2 = requireCommon();
580 const Type2 = requireType();
581 const YAML_FLOAT_PATTERN = new RegExp(
582 // 2.5e4, 2.5 and integers
583 "^(?:[-+]?(?:[0-9]+)(?:\\.[0-9]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$"
584 );
585 const YAML_FLOAT_SPECIAL_PATTERN = new RegExp(
586 "^(?:[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$"
587 );
588 function resolveYamlFloat(data) {
589 if (data === null) return false;
590 if (!YAML_FLOAT_PATTERN.test(data)) {
591 return false;
592 }
593 if (isFinite(parseFloat(data, 10))) {
594 return true;
595 }
596 return YAML_FLOAT_SPECIAL_PATTERN.test(data);
597 }
598 function constructYamlFloat(data) {
599 let value = data.toLowerCase();
600 const sign = value[0] === "-" ? -1 : 1;
601 if ("+-".indexOf(value[0]) >= 0) {
602 value = value.slice(1);
603 }
604 if (value === ".inf") {
605 return sign === 1 ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
606 } else if (value === ".nan") {
607 return NaN;
608 }
609 return sign * parseFloat(value, 10);
610 }
611 const SCIENTIFIC_WITHOUT_DOT = /^[-+]?[0-9]+e/;
612 function representYamlFloat(object, style) {
613 if (isNaN(object)) {
614 switch (style) {
615 case "lowercase":
616 return ".nan";
617 case "uppercase":
618 return ".NAN";
619 case "camelcase":
620 return ".NaN";
621 }
622 } else if (Number.POSITIVE_INFINITY === object) {
623 switch (style) {
624 case "lowercase":
625 return ".inf";
626 case "uppercase":
627 return ".INF";
628 case "camelcase":
629 return ".Inf";
630 }
631 } else if (Number.NEGATIVE_INFINITY === object) {
632 switch (style) {
633 case "lowercase":
634 return "-.inf";
635 case "uppercase":
636 return "-.INF";
637 case "camelcase":
638 return "-.Inf";
639 }
640 } else if (common2.isNegativeZero(object)) {
641 return "-0.0";
642 }
643 const res = object.toString(10);
644 return SCIENTIFIC_WITHOUT_DOT.test(res) ? res.replace("e", ".e") : res;
645 }
646 function isFloat(object) {
647 return Object.prototype.toString.call(object) === "[object Number]" && (object % 1 !== 0 || common2.isNegativeZero(object));
648 }
649 float = new Type2("tag:yaml.org,2002:float", {
650 kind: "scalar",
651 resolve: resolveYamlFloat,
652 construct: constructYamlFloat,
653 predicate: isFloat,
654 represent: representYamlFloat,
655 defaultStyle: "lowercase"
656 });
657 return float;
658}
659var json;
660var hasRequiredJson;
661function requireJson() {
662 if (hasRequiredJson) return json;
663 hasRequiredJson = 1;
664 json = requireFailsafe().extend({
665 implicit: [
666 require_null(),
667 requireBool(),
668 requireInt(),
669 requireFloat()
670 ]
671 });
672 return json;
673}
674var core;
675var hasRequiredCore;
676function requireCore() {
677 if (hasRequiredCore) return core;
678 hasRequiredCore = 1;
679 core = requireJson();
680 return core;
681}
682var timestamp;
683var hasRequiredTimestamp;
684function requireTimestamp() {
685 if (hasRequiredTimestamp) return timestamp;
686 hasRequiredTimestamp = 1;
687 const Type2 = requireType();
688 const YAML_DATE_REGEXP = new RegExp(
689 "^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"
690 );
691 const YAML_TIMESTAMP_REGEXP = new RegExp(
692 "^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$"
693 );
694 function resolveYamlTimestamp(data) {
695 if (data === null) return false;
696 if (YAML_DATE_REGEXP.exec(data) !== null) return true;
697 if (YAML_TIMESTAMP_REGEXP.exec(data) !== null) return true;
698 return false;
699 }
700 function constructYamlTimestamp(data) {
701 let fraction = 0;
702 let delta = null;
703 let match = YAML_DATE_REGEXP.exec(data);
704 if (match === null) match = YAML_TIMESTAMP_REGEXP.exec(data);
705 if (match === null) throw new Error("Date resolve error");
706 const year = +match[1];
707 const month = +match[2] - 1;
708 const day = +match[3];
709 if (!match[4]) {
710 return new Date(Date.UTC(year, month, day));
711 }
712 const hour = +match[4];
713 const minute = +match[5];
714 const second = +match[6];
715 if (match[7]) {
716 fraction = match[7].slice(0, 3);
717 while (fraction.length < 3) {
718 fraction += "0";
719 }
720 fraction = +fraction;
721 }
722 if (match[9]) {
723 const tzHour = +match[10];
724 const tzMinute = +(match[11] || 0);
725 delta = (tzHour * 60 + tzMinute) * 6e4;
726 if (match[9] === "-") delta = -delta;
727 }
728 const date = new Date(Date.UTC(year, month, day, hour, minute, second, fraction));
729 if (delta) date.setTime(date.getTime() - delta);
730 return date;
731 }
732 function representYamlTimestamp(object) {
733 return object.toISOString();
734 }
735 timestamp = new Type2("tag:yaml.org,2002:timestamp", {
736 kind: "scalar",
737 resolve: resolveYamlTimestamp,
738 construct: constructYamlTimestamp,
739 instanceOf: Date,
740 represent: representYamlTimestamp
741 });
742 return timestamp;
743}
744var merge;
745var hasRequiredMerge;
746function requireMerge() {
747 if (hasRequiredMerge) return merge;
748 hasRequiredMerge = 1;
749 const Type2 = requireType();
750 function resolveYamlMerge(data) {
751 return data === "<<" || data === null;
752 }
753 merge = new Type2("tag:yaml.org,2002:merge", {
754 kind: "scalar",
755 resolve: resolveYamlMerge
756 });
757 return merge;
758}
759var binary;
760var hasRequiredBinary;
761function requireBinary() {
762 if (hasRequiredBinary) return binary;
763 hasRequiredBinary = 1;
764 const Type2 = requireType();
765 const BASE64_MAP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r";
766 function resolveYamlBinary(data) {
767 if (data === null) return false;
768 let bitlen = 0;
769 const max = data.length;
770 const map2 = BASE64_MAP;
771 for (let idx = 0; idx < max; idx++) {
772 const code = map2.indexOf(data.charAt(idx));
773 if (code > 64) continue;
774 if (code < 0) return false;
775 bitlen += 6;
776 }
777 return bitlen % 8 === 0;
778 }
779 function constructYamlBinary(data) {
780 const input = data.replace(/[\r\n=]/g, "");
781 const max = input.length;
782 const map2 = BASE64_MAP;
783 let bits = 0;
784 const result = [];
785 for (let idx = 0; idx < max; idx++) {
786 if (idx % 4 === 0 && idx) {
787 result.push(bits >> 16 & 255);
788 result.push(bits >> 8 & 255);
789 result.push(bits & 255);
790 }
791 bits = bits << 6 | map2.indexOf(input.charAt(idx));
792 }
793 const tailbits = max % 4 * 6;
794 if (tailbits === 0) {
795 result.push(bits >> 16 & 255);
796 result.push(bits >> 8 & 255);
797 result.push(bits & 255);
798 } else if (tailbits === 18) {
799 result.push(bits >> 10 & 255);
800 result.push(bits >> 2 & 255);
801 } else if (tailbits === 12) {
802 result.push(bits >> 4 & 255);
803 }
804 return new Uint8Array(result);
805 }
806 function representYamlBinary(object) {
807 let result = "";
808 let bits = 0;
809 const max = object.length;
810 const map2 = BASE64_MAP;
811 for (let idx = 0; idx < max; idx++) {
812 if (idx % 3 === 0 && idx) {
813 result += map2[bits >> 18 & 63];
814 result += map2[bits >> 12 & 63];
815 result += map2[bits >> 6 & 63];
816 result += map2[bits & 63];
817 }
818 bits = (bits << 8) + object[idx];
819 }
820 const tail = max % 3;
821 if (tail === 0) {
822 result += map2[bits >> 18 & 63];
823 result += map2[bits >> 12 & 63];
824 result += map2[bits >> 6 & 63];
825 result += map2[bits & 63];
826 } else if (tail === 2) {
827 result += map2[bits >> 10 & 63];
828 result += map2[bits >> 4 & 63];
829 result += map2[bits << 2 & 63];
830 result += map2[64];
831 } else if (tail === 1) {
832 result += map2[bits >> 2 & 63];
833 result += map2[bits << 4 & 63];
834 result += map2[64];
835 result += map2[64];
836 }
837 return result;
838 }
839 function isBinary(obj) {
840 return Object.prototype.toString.call(obj) === "[object Uint8Array]";
841 }
842 binary = new Type2("tag:yaml.org,2002:binary", {
843 kind: "scalar",
844 resolve: resolveYamlBinary,
845 construct: constructYamlBinary,
846 predicate: isBinary,
847 represent: representYamlBinary
848 });
849 return binary;
850}
851var omap;
852var hasRequiredOmap;
853function requireOmap() {
854 if (hasRequiredOmap) return omap;
855 hasRequiredOmap = 1;
856 const Type2 = requireType();
857 const _hasOwnProperty = Object.prototype.hasOwnProperty;
858 const _toString = Object.prototype.toString;
859 function resolveYamlOmap(data) {
860 if (data === null) return true;
861 const objectKeys = {};
862 const object = data;
863 for (let index = 0, length = object.length; index < length; index += 1) {
864 const pair = object[index];
865 let pairHasKey = false;
866 if (_toString.call(pair) !== "[object Object]") return false;
867 let pairKey;
868 for (pairKey in pair) {
869 if (_hasOwnProperty.call(pair, pairKey)) {
870 if (!pairHasKey) pairHasKey = true;
871 else return false;
872 }
873 }
874 if (!pairHasKey) return false;
875 if (_hasOwnProperty.call(objectKeys, pairKey)) return false;
876 Object.defineProperty(objectKeys, pairKey, { value: true });
877 }
878 return true;
879 }
880 function constructYamlOmap(data) {
881 return data !== null ? data : [];
882 }
883 omap = new Type2("tag:yaml.org,2002:omap", {
884 kind: "sequence",
885 resolve: resolveYamlOmap,
886 construct: constructYamlOmap
887 });
888 return omap;
889}
890var pairs;
891var hasRequiredPairs;
892function requirePairs() {
893 if (hasRequiredPairs) return pairs;
894 hasRequiredPairs = 1;
895 const Type2 = requireType();
896 const _toString = Object.prototype.toString;
897 function resolveYamlPairs(data) {
898 if (data === null) return true;
899 const object = data;
900 const result = new Array(object.length);
901 for (let index = 0, length = object.length; index < length; index += 1) {
902 const pair = object[index];
903 if (_toString.call(pair) !== "[object Object]") return false;
904 const keys = Object.keys(pair);
905 if (keys.length !== 1) return false;
906 result[index] = [keys[0], pair[keys[0]]];
907 }
908 return true;
909 }
910 function constructYamlPairs(data) {
911 if (data === null) return [];
912 const object = data;
913 const result = new Array(object.length);
914 for (let index = 0, length = object.length; index < length; index += 1) {
915 const pair = object[index];
916 const keys = Object.keys(pair);
917 result[index] = [keys[0], pair[keys[0]]];
918 }
919 return result;
920 }
921 pairs = new Type2("tag:yaml.org,2002:pairs", {
922 kind: "sequence",
923 resolve: resolveYamlPairs,
924 construct: constructYamlPairs
925 });
926 return pairs;
927}
928var set;
929var hasRequiredSet;
930function requireSet() {
931 if (hasRequiredSet) return set;
932 hasRequiredSet = 1;
933 const Type2 = requireType();
934 const _hasOwnProperty = Object.prototype.hasOwnProperty;
935 function resolveYamlSet(data) {
936 if (data === null) return true;
937 const object = data;
938 for (const key in object) {
939 if (_hasOwnProperty.call(object, key)) {
940 if (object[key] !== null) return false;
941 }
942 }
943 return true;
944 }
945 function constructYamlSet(data) {
946 return data !== null ? data : {};
947 }
948 set = new Type2("tag:yaml.org,2002:set", {
949 kind: "mapping",
950 resolve: resolveYamlSet,
951 construct: constructYamlSet
952 });
953 return set;
954}
955var _default;
956var hasRequired_default;
957function require_default() {
958 if (hasRequired_default) return _default;
959 hasRequired_default = 1;
960 _default = requireCore().extend({
961 implicit: [
962 requireTimestamp(),
963 requireMerge()
964 ],
965 explicit: [
966 requireBinary(),
967 requireOmap(),
968 requirePairs(),
969 requireSet()
970 ]
971 });
972 return _default;
973}
974var hasRequiredLoader;
975function requireLoader() {
976 if (hasRequiredLoader) return loader;
977 hasRequiredLoader = 1;
978 const common2 = requireCommon();
979 const YAMLException2 = requireException();
980 const makeSnippet = requireSnippet();
981 const DEFAULT_SCHEMA2 = require_default();
982 const _hasOwnProperty = Object.prototype.hasOwnProperty;
983 const CONTEXT_FLOW_IN = 1;
984 const CONTEXT_FLOW_OUT = 2;
985 const CONTEXT_BLOCK_IN = 3;
986 const CONTEXT_BLOCK_OUT = 4;
987 const CHOMPING_CLIP = 1;
988 const CHOMPING_STRIP = 2;
989 const CHOMPING_KEEP = 3;
990 const PATTERN_NON_PRINTABLE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/;
991 const PATTERN_NON_ASCII_LINE_BREAKS = /[\x85\u2028\u2029]/;
992 const PATTERN_FLOW_INDICATORS = /[,\[\]{}]/;
993 const PATTERN_TAG_HANDLE = /^(?:!|!!|![0-9A-Za-z-]+!)$/;
994 const PATTERN_TAG_URI = /^(?:!|[^,\[\]{}])(?:%[0-9a-f]{2}|[0-9a-z\-#;/?:@&=+$,_.!~*'()\[\]])*$/i;
995 function _class(obj) {
996 return Object.prototype.toString.call(obj);
997 }
998 function isEol(c) {
999 return c === 10 || c === 13;
1000 }
1001 function isWhiteSpace(c) {
1002 return c === 9 || c === 32;
1003 }
1004 function isWsOrEol(c) {
1005 return c === 9 || c === 32 || c === 10 || c === 13;
1006 }
1007 function isFlowIndicator(c) {
1008 return c === 44 || c === 91 || c === 93 || c === 123 || c === 125;
1009 }
1010 function fromHexCode(c) {
1011 if (c >= 48 && c <= 57) {
1012 return c - 48;
1013 }
1014 const lc = c | 32;
1015 if (lc >= 97 && lc <= 102) {
1016 return lc - 97 + 10;
1017 }
1018 return -1;
1019 }
1020 function escapedHexLen(c) {
1021 if (c === 120) {
1022 return 2;
1023 }
1024 if (c === 117) {
1025 return 4;
1026 }
1027 if (c === 85) {
1028 return 8;
1029 }
1030 return 0;
1031 }
1032 function fromDecimalCode(c) {
1033 if (c >= 48 && c <= 57) {
1034 return c - 48;
1035 }
1036 return -1;
1037 }
1038 function simpleEscapeSequence(c) {
1039 switch (c) {
1040 case 48:
1041 return "\0";
1042 case 97:
1043 return "\x07";
1044 case 98:
1045 return "\b";
1046 case 116:
1047 return " ";
1048 case 9:
1049 return " ";
1050 case 110:
1051 return "\n";
1052 case 118:
1053 return "\v";
1054 case 102:
1055 return "\f";
1056 case 114:
1057 return "\r";
1058 case 101:
1059 return "\x1B";
1060 case 32:
1061 return " ";
1062 case 34:
1063 return '"';
1064 case 47:
1065 return "/";
1066 case 92:
1067 return "\\";
1068 case 78:
1069 return "
";
1070 case 95:
1071 return " ";
1072 case 76:
1073 return "\u2028";
1074 case 80:
1075 return "\u2029";
1076 default:
1077 return "";
1078 }
1079 }
1080 function charFromCodepoint(c) {
1081 if (c <= 65535) {
1082 return String.fromCharCode(c);
1083 }
1084 return String.fromCharCode(
1085 (c - 65536 >> 10) + 55296,
1086 (c - 65536 & 1023) + 56320
1087 );
1088 }
1089 function setProperty(object, key, value) {
1090 if (key === "__proto__") {
1091 Object.defineProperty(object, key, {
1092 configurable: true,
1093 enumerable: true,
1094 writable: true,
1095 value
1096 });
1097 } else {
1098 object[key] = value;
1099 }
1100 }
1101 const simpleEscapeCheck = new Array(256);
1102 const simpleEscapeMap = new Array(256);
1103 for (let i = 0; i < 256; i++) {
1104 simpleEscapeCheck[i] = simpleEscapeSequence(i) ? 1 : 0;
1105 simpleEscapeMap[i] = simpleEscapeSequence(i);
1106 }
1107 function State(input, options) {
1108 this.input = input;
1109 this.filename = options["filename"] || null;
1110 this.schema = options["schema"] || DEFAULT_SCHEMA2;
1111 this.onWarning = options["onWarning"] || null;
1112 this.legacy = options["legacy"] || false;
1113 this.json = options["json"] || false;
1114 this.listener = options["listener"] || null;
1115 this.maxDepth = typeof options["maxDepth"] === "number" ? options["maxDepth"] : 100;
1116 this.maxTotalMergeKeys = typeof options["maxTotalMergeKeys"] === "number" ? options["maxTotalMergeKeys"] : 1e4;
1117 this.implicitTypes = this.schema.compiledImplicit;
1118 this.typeMap = this.schema.compiledTypeMap;
1119 this.length = input.length;
1120 this.position = 0;
1121 this.line = 0;
1122 this.lineStart = 0;
1123 this.lineIndent = 0;
1124 this.depth = 0;
1125 this.totalMergeKeys = 0;
1126 this.firstTabInLine = -1;
1127 this.documents = [];
1128 this.anchorMapTransactions = [];
1129 }
1130 function generateError(state, message) {
1131 const mark = {
1132 name: state.filename,
1133 buffer: state.input.slice(0, -1),
1134 // omit trailing \0
1135 position: state.position,
1136 line: state.line,
1137 column: state.position - state.lineStart
1138 };
1139 mark.snippet = makeSnippet(mark);
1140 return new YAMLException2(message, mark);
1141 }
1142 function throwError(state, message) {
1143 throw generateError(state, message);
1144 }
1145 function throwWarning(state, message) {
1146 if (state.onWarning) {
1147 state.onWarning.call(null, generateError(state, message));
1148 }
1149 }
1150 function storeAnchor(state, name, value) {
1151 const transactions = state.anchorMapTransactions;
1152 if (transactions.length !== 0) {
1153 const transaction = transactions[transactions.length - 1];
1154 if (!_hasOwnProperty.call(transaction, name)) {
1155 transaction[name] = {
1156 existed: _hasOwnProperty.call(state.anchorMap, name),
1157 value: state.anchorMap[name]
1158 };
1159 }
1160 }
1161 state.anchorMap[name] = value;
1162 }
1163 function beginAnchorTransaction(state) {
1164 state.anchorMapTransactions.push(/* @__PURE__ */ Object.create(null));
1165 }
1166 function commitAnchorTransaction(state) {
1167 const transaction = state.anchorMapTransactions.pop();
1168 const transactions = state.anchorMapTransactions;
1169 if (transactions.length === 0) return;
1170 const parent = transactions[transactions.length - 1];
1171 const names = Object.keys(transaction);
1172 for (let index = 0, length = names.length; index < length; index += 1) {
1173 const name = names[index];
1174 if (!_hasOwnProperty.call(parent, name)) {
1175 parent[name] = transaction[name];
1176 }
1177 }
1178 }
1179 function rollbackAnchorTransaction(state) {
1180 const transaction = state.anchorMapTransactions.pop();
1181 const names = Object.keys(transaction);
1182 for (let index = names.length - 1; index >= 0; index -= 1) {
1183 const entry = transaction[names[index]];
1184 if (entry.existed) {
1185 state.anchorMap[names[index]] = entry.value;
1186 } else {
1187 delete state.anchorMap[names[index]];
1188 }
1189 }
1190 }
1191 function snapshotState(state) {
1192 return {
1193 position: state.position,
1194 line: state.line,
1195 lineStart: state.lineStart,
1196 lineIndent: state.lineIndent,
1197 firstTabInLine: state.firstTabInLine,
1198 tag: state.tag,
1199 anchor: state.anchor,
1200 kind: state.kind,hooks/runtime/overlay.js 127 lines1// Project overlay (.omnilex/overrides.yaml, Plan 3.7): changes how concepts
2// are recognised and shown in one project, never their explanations. Pure, so
3// CLI, MCP and the mod apply it the same way.
4import { normalizeSearchTerm } from './matcher.js';
5export const EMPTY_OVERLAY = { concepts: {}, exclude: [], prefer: {} };
6export function parseOverlay(raw, source) {
7 const warnings = [];
8 const overlay = { concepts: {}, exclude: [], prefer: {} };
9 if (raw === null || raw === undefined)
10 return { overlay, warnings };
11 if (typeof raw !== 'object' || Array.isArray(raw)) {
12 return { overlay, warnings: [`${source}: overlay phải là một object / overlay must be a mapping`] };
13 }
14 const value = raw;
15 for (const key of Object.keys(value)) {
16 if (!['concepts', 'exclude', 'prefer'].includes(key))
17 warnings.push(`${source}: khóa lạ "${key}" / unknown key "${key}"`);
18 }
19 for (const [id, entry] of Object.entries(value.concepts || {})) {
20 const item = {};
21 const e = entry;
22 if (e?.autolink !== undefined) {
23 ['always', 'never', 'auto'].includes(e.autolink) ? (item.autolink = e.autolink) : warnings.push(`${source}: concepts.${id}.autolink không hợp lệ / invalid`);
24 }
25 if (e?.aliases !== undefined) {
26 const aliases = {};
27 for (const [locale, list] of Object.entries(e.aliases || {})) {
28 if (Array.isArray(list) && list.every((alias) => typeof alias === 'string'))
29 aliases[locale] = list;
30 else
31 warnings.push(`${source}: concepts.${id}.aliases.${locale} phải là danh sách / must be a list`);
32 }
33 item.aliases = aliases;
34 }
35 overlay.concepts[id] = item;
36 }
37 if (value.exclude !== undefined) {
38 Array.isArray(value.exclude) ? (overlay.exclude = value.exclude.map(String)) : warnings.push(`${source}: exclude phải là danh sách / must be a list`);
39 }
40 for (const [key, id] of Object.entries(value.prefer || {}))
41 overlay.prefer[key.trim().toLowerCase()] = String(id);
42 return { overlay, warnings };
43}
44function aliasEdits(overlay, id, locale) {
45 const list = overlay.concepts[id]?.aliases?.[locale] || [];
46 return {
47 add: list.filter((alias) => !alias.startsWith('-')).map((alias) => alias.replace(/^\+/, '').trim()).filter(Boolean),
48 remove: new Set(list.filter((alias) => alias.startsWith('-')).map((alias) => alias.slice(1).trim().toLowerCase())),
49 };
50}
51export function overlayCards(overlay, cards, locale) {
52 const excluded = new Set(overlay.exclude);
53 return cards.filter((card) => !excluded.has(card.id)).map((card) => {
54 const { add, remove } = aliasEdits(overlay, card.id, locale);
55 if (add.length === 0 && remove.size === 0)
56 return card;
57 const aliases = card.aliases.filter((alias) => !remove.has(alias.toLowerCase()));
58 for (const alias of add)
59 if (!aliases.some((item) => item.toLowerCase() === alias.toLowerCase()))
60 aliases.push(alias);
61 return { ...card, aliases };
62 });
63}
64// Same rules the builder uses for a key's match mode and default autolink.
65function termFor(key, conceptId, autolinkConfig) {
66 const autolink = autolinkConfig === 'always' ? true : autolinkConfig === 'never' ? false : key.length > 3;
67 return {
68 key,
69 match: key.length <= 4 && key === key.toUpperCase() ? 'exact-case' : 'any-case',
70 autolink,
71 ...(autolinkConfig ? { autolinkConfig } : {}),
72 conceptId,
73 };
74}
75export function overlayTerms(overlay, terms, locale) {
76 const excluded = new Set(overlay.exclude);
77 const result = [];
78 const seen = new Set();
79 for (const term of terms) {
80 if (excluded.has(term.conceptId))
81 continue;
82 const key = term.key.trim().toLowerCase();
83 const preferred = overlay.prefer[key];
84 if (preferred && preferred !== term.conceptId)
85 continue;
86 if (aliasEdits(overlay, term.conceptId, locale).remove.has(key))
87 continue;
88 const mode = overlay.concepts[term.conceptId]?.autolink;
89 result.push(mode ? { ...term, autolink: mode === 'always' ? true : mode === 'never' ? false : term.autolink, autolinkConfig: mode } : term);
90 seen.add(`${term.conceptId}\u0000${key}`);
91 }
92 for (const id of Object.keys(overlay.concepts)) {
93 if (excluded.has(id))
94 continue;
95 for (const alias of aliasEdits(overlay, id, locale).add) {
96 const key = alias.toLowerCase();
97 const preferred = overlay.prefer[key];
98 if (seen.has(`${id}\u0000${key}`) || (preferred && preferred !== id))
99 continue;
100 // Only concepts the packs actually contain get the new key.
101 if (!terms.some((term) => term.conceptId === id))
102 continue;
103 result.push(termFor(alias, id, overlay.concepts[id]?.autolink));
104 seen.add(`${id}\u0000${key}`);
105 }
106 }
107 return result;
108}
109// Tier-0 lookup within a project: `prefer` settles a homonym.
110export function overlayLookup(overlay, query, matches) {
111 const preferred = overlay.prefer[query.trim().toLowerCase()] ?? overlay.prefer[normalizeSearchTerm(query.trim())];
112 if (!preferred)
113 return matches;
114 const chosen = matches.filter((card) => card.id === preferred);
115 return chosen.length ? chosen : matches;
116}
117export function isEmptyOverlay(overlay) {
118 return Object.keys(overlay.concepts).length === 0 && overlay.exclude.length === 0 && Object.keys(overlay.prefer).length === 0;
119}
120// For derived packs (tb pack export --effective): cards and terms per concept.
121export function overlayConcepts(overlay, locale, concepts) {
122 const cards = new Map(overlayCards(overlay, concepts.map((concept) => concept.card), locale).map((card) => [card.id, card]));
123 return concepts
124 .filter((concept) => cards.has(concept.card.id))
125 .map((concept) => ({ ...concept, card: cards.get(concept.card.id), terms: overlayTerms(overlay, concept.terms, locale) }));
126}
127hooks/runtime/config.js 180 lines1// Layered configuration (Plan 3.3). Pure: callers read the files and hand the
2// parsed layers over, lowest first.
3export function isSourceOp(op) {
4 return op.op === 'dev' || op.op === 'local';
5}
6export const DEFAULT_CONFIG = {
7 locale: 'vi',
8 fallbackLocales: ['en'],
9 profile: 'standard',
10 tiers: { vectors: 'auto' },
11 autoInstall: 'prompt',
12};
13const KNOWN_KEYS = new Set([
14 'root', 'locale', 'fallbackLocales', 'profile', 'packs', 'packsReset',
15 'tiers', 'limits', 'autoInstall', 'registries', 'overrides',
16]);
17const PROFILES = ['lite', 'standard', 'semantic'];
18const TIER_MODES = ['eager', 'lazy', 'remote', 'off'];
19// Keeps the keys we understand, with the right types; everything else is a warning.
20export function normalizeConfig(raw, source) {
21 const warnings = [];
22 const config = {};
23 if (raw === null || raw === undefined)
24 return { config, warnings };
25 if (typeof raw !== 'object' || Array.isArray(raw)) {
26 return { config, warnings: [`${source}: cấu hình phải là một object / config must be a mapping`] };
27 }
28 const value = raw;
29 const bad = (key) => warnings.push(`${source}: giá trị "${key}" không hợp lệ, bỏ qua / invalid "${key}", ignored`);
30 for (const key of Object.keys(value)) {
31 if (!KNOWN_KEYS.has(key))
32 warnings.push(`${source}: khóa lạ "${key}" / unknown key "${key}"`);
33 }
34 if (value.root !== undefined)
35 typeof value.root === 'boolean' ? (config.root = value.root) : bad('root');
36 if (value.locale !== undefined)
37 typeof value.locale === 'string' ? (config.locale = value.locale) : bad('locale');
38 if (value.fallbackLocales !== undefined) {
39 Array.isArray(value.fallbackLocales) && value.fallbackLocales.every((item) => typeof item === 'string')
40 ? (config.fallbackLocales = value.fallbackLocales)
41 : bad('fallbackLocales');
42 }
43 if (value.profile !== undefined)
44 PROFILES.includes(value.profile) ? (config.profile = value.profile) : bad('profile');
45 if (value.packs !== undefined) {
46 Array.isArray(value.packs) && value.packs.every((item) => typeof item === 'string')
47 ? (config.packs = value.packs.map((item) => item.trim()).filter(Boolean))
48 : bad('packs');
49 }
50 if (value.packsReset !== undefined)
51 typeof value.packsReset === 'boolean' ? (config.packsReset = value.packsReset) : bad('packsReset');
52 if (value.tiers !== undefined) {
53 const tiers = {};
54 if (value.tiers?.explanations !== undefined) {
55 TIER_MODES.includes(value.tiers.explanations) ? (tiers.explanations = value.tiers.explanations) : bad('tiers.explanations');
56 }
57 if (value.tiers?.vectors !== undefined) {
58 ['auto', 'on', 'off'].includes(value.tiers.vectors) ? (tiers.vectors = value.tiers.vectors) : bad('tiers.vectors');
59 }
60 config.tiers = tiers;
61 }
62 if (value.limits !== undefined) {
63 const limits = {};
64 for (const key of ['memoryMB', 'diskMB']) {
65 const limit = value.limits?.[key];
66 if (limit === undefined)
67 continue;
68 typeof limit === 'number' && limit > 0 ? (limits[key] = limit) : bad(`limits.${key}`);
69 }
70 config.limits = limits;
71 }
72 if (value.autoInstall !== undefined) {
73 ['never', 'prompt', 'always'].includes(value.autoInstall) ? (config.autoInstall = value.autoInstall) : bad('autoInstall');
74 }
75 if (value.registries !== undefined) {
76 Array.isArray(value.registries) ? (config.registries = value.registries.map(String)) : bad('registries');
77 }
78 if (value.overrides !== undefined)
79 typeof value.overrides === 'string' ? (config.overrides = value.overrides) : bad('overrides');
80 return { config, warnings };
81}
82// "+it.ai-ml@^0.1", "-finance", "tech", "dev:./dist", "local:./glossary"
83export function parsePackItem(item, layer) {
84 const source = layer.source;
85 for (const kind of ['dev', 'local']) {
86 if (item.startsWith(`${kind}:`))
87 return { op: kind, path: item.slice(kind.length + 1), dir: layer.dir, source };
88 }
89 const op = item.startsWith('-') ? 'remove' : 'add';
90 const body = item.replace(/^[+-]/, '');
91 const at = body.indexOf('@', 1);
92 return at > 0
93 ? { op, id: body.slice(0, at), range: body.slice(at + 1), source }
94 : { op, id: body, source };
95}
96export function mergeConfigLayers(layers) {
97 const all = [{ kind: 'default', source: 'default', config: DEFAULT_CONFIG }, ...layers];
98 const effective = {
99 locale: 'vi',
100 fallbackLocales: [],
101 profile: 'standard',
102 tiers: { vectors: 'auto' },
103 limits: {},
104 autoInstall: 'prompt',
105 registries: [],
106 packOps: [],
107 packsConfigured: false,
108 origins: {},
109 warnings: [],
110 };
111 const set = (key, value, source, assign) => {
112 if (value === undefined)
113 return;
114 assign();
115 effective.origins[key] = source;
116 };
117 for (const layer of all) {
118 const c = layer.config;
119 set('locale', c.locale, layer.source, () => (effective.locale = c.locale));
120 set('fallbackLocales', c.fallbackLocales, layer.source, () => (effective.fallbackLocales = [...c.fallbackLocales]));
121 set('profile', c.profile, layer.source, () => (effective.profile = c.profile));
122 set('autoInstall', c.autoInstall, layer.source, () => (effective.autoInstall = c.autoInstall));
123 set('registries', c.registries, layer.source, () => (effective.registries = [...c.registries]));
124 set('overrides', c.overrides, layer.source, () => (effective.overrides = c.overrides));
125 set('tiers.explanations', c.tiers?.explanations, layer.source, () => (effective.tiers.explanations = c.tiers.explanations));
126 set('tiers.vectors', c.tiers?.vectors, layer.source, () => (effective.tiers.vectors = c.tiers.vectors));
127 for (const key of ['memoryMB', 'diskMB']) {
128 const limit = c.limits?.[key];
129 const current = effective.limits[key];
130 // A layer can only tighten a limit set below it.
131 if (limit !== undefined && (current === undefined || limit < current)) {
132 effective.limits[key] = limit;
133 effective.origins[`limits.${key}`] = layer.source;
134 }
135 }
136 if (c.packsReset) {
137 effective.packOps.push({ op: 'reset', source: layer.source });
138 effective.packsConfigured = true;
139 }
140 for (const item of c.packs || []) {
141 effective.packOps.push(parsePackItem(item, layer));
142 effective.packsConfigured = true;
143 }
144 }
145 return effective;
146}
147function show(value) {
148 return Array.isArray(value) ? `[${value.join(', ')}]` : String(value);
149}
150// Like `git config --show-origin`: one line per effective value.
151export function explainConfig(effective) {
152 const lines = [];
153 const add = (key, value) => {
154 if (value === undefined)
155 return;
156 lines.push(`${key} = ${show(value)} # ${effective.origins[key] || 'default'}`);
157 };
158 add('locale', effective.locale);
159 add('fallbackLocales', effective.fallbackLocales);
160 add('profile', effective.profile);
161 add('tiers.explanations', effective.tiers.explanations);
162 add('tiers.vectors', effective.tiers.vectors);
163 add('limits.memoryMB', effective.limits.memoryMB);
164 add('limits.diskMB', effective.limits.diskMB);
165 add('autoInstall', effective.autoInstall);
166 add('registries', effective.registries.length ? effective.registries : undefined);
167 add('overrides', effective.overrides);
168 for (const op of effective.packOps) {
169 let text;
170 if (op.op === 'reset')
171 text = 'packsReset';
172 else if (isSourceOp(op))
173 text = `${op.op}:${op.path}`;
174 else
175 text = `${op.op === 'remove' ? '-' : '+'}${op.id}${op.range ? `@${op.range}` : ''}`;
176 lines.push(`packs ${text} # ${op.source}`);
177 }
178 return lines;
179}
180hooks/runtime/resolver.js 155 lines1import { maxSatisfying } from './semver.js';
2function candidates(catalog, id, range) {
3 const all = catalog.packs.filter((pack) => pack.id === id);
4 if (all.length === 0)
5 return null;
6 // A dev build is what the developer is editing; it wins over the store.
7 for (const source of catalog.sources) {
8 if (source.kind !== 'dev')
9 continue;
10 const dev = all.find((pack) => pack.sourceId === source.id);
11 if (dev)
12 return dev;
13 }
14 const pinned = catalog.pins?.[id];
15 const versions = all.map((pack) => pack.version);
16 const chosen = pinned && versions.includes(pinned) && (!range || maxSatisfying([pinned], range))
17 ? pinned
18 : maxSatisfying(versions, range || '*');
19 return chosen ? all.find((pack) => pack.version === chosen) || null : null;
20}
21export function resolvePacks(ops, catalog, options = { packsConfigured: true }) {
22 const selected = new Map();
23 const metas = new Map();
24 const missing = new Set();
25 const errors = [];
26 const expand = (id, range, stack, sink, report) => {
27 if (stack.includes(id)) {
28 const message = `Vòng phụ thuộc giữa các gói / dependency cycle: ${[...stack, id].join(' → ')}`;
29 if (report && !errors.includes(message))
30 errors.push(message);
31 return;
32 }
33 const pack = candidates(catalog, id, range);
34 if (!pack) {
35 if (report)
36 missing.add(range ? `${id}@${range}` : id);
37 return;
38 }
39 if (pack.kind === 'content') {
40 sink(pack);
41 return;
42 }
43 if (report)
44 metas.set(pack.id, pack);
45 for (const [child, childRange] of Object.entries(pack.requires)) {
46 expand(child, childRange, [...stack, id], sink, report);
47 }
48 };
49 const add = (pack) => {
50 if (!selected.has(pack.id))
51 selected.set(pack.id, pack);
52 };
53 if (!options.packsConfigured) {
54 // D3: nothing configured means every installed or built content pack; the
55 // starter repeats concepts of the others, so it counts only when alone.
56 const content = catalog.packs.filter((pack) => pack.kind === 'content');
57 const full = content.filter((pack) => pack.role !== 'starter');
58 for (const pack of full.length ? full : content)
59 expand(pack.id, undefined, [], add, false);
60 }
61 for (const op of ops) {
62 if (op.op === 'reset') {
63 selected.clear();
64 }
65 else if (op.op === 'dev' || op.op === 'local') {
66 const source = catalog.sources.find((item) => item.kind === 'dev' && (op.op === 'local' ? item.spec === op.path : item.dir === op.path));
67 // A build holding only a starter (the one the mod ships) selects it.
68 const ids = source?.contentIds?.length
69 ? source.contentIds
70 : catalog.packs.filter((item) => item.sourceId === source?.id && item.kind === 'content').map((item) => item.id);
71 for (const id of ids) {
72 const pack = catalog.packs.find((item) => item.id === id && item.sourceId === source.id);
73 if (pack)
74 add(pack);
75 }
76 }
77 else if (op.op === 'add') {
78 expand(op.id, op.range, [], add, true);
79 }
80 else if (op.op === 'remove') {
81 expand(op.id, op.range, [], (pack) => selected.delete(pack.id), false);
82 }
83 }
84 return { packs: Array.from(selected.values()), metas: Array.from(metas.values()), missing: Array.from(missing).sort(), errors };
85}
86// JSON bytes -> rough in-memory size (Q4 of the WP6 note).
87export const MEMORY_FACTORS = { terms: 3, cards: 3, search: 2, explanations: 1.5 };
88function tierBytes(pack, tier, locales) {
89 const files = pack.tiers?.[tier]?.files || {};
90 return locales.reduce((sum, locale) => sum + (files[locale]?.bytes || 0), 0);
91}
92export function estimateMemoryMB(plans, locale, surface) {
93 let bytes = 0;
94 for (const { pack, tiers } of plans) {
95 for (const tier of Object.keys(MEMORY_FACTORS)) {
96 if (tiers[tier] !== 'eager')
97 continue;
98 // The mod matches terms of both primary locales at once.
99 const locales = tier === 'terms' && surface === 'mod' ? Object.keys(pack.tiers?.terms?.files || {}) : [locale];
100 bytes += tierBytes(pack, tier, locales) * MEMORY_FACTORS[tier];
101 }
102 }
103 return bytes / (1024 * 1024);
104}
105export function planLoad(packs, effective, options) {
106 const notice = options.notice || (() => { });
107 let profile = effective.profile;
108 if (profile === 'semantic') {
109 notice('OmniLex: chưa có vectors cho profile semantic, dùng standard / no vectors for the semantic profile yet, using standard');
110 profile = 'standard';
111 }
112 if (effective.tiers.vectors === 'on') {
113 notice('OmniLex: tier vectors chưa được hỗ trợ, bỏ qua / the vectors tier is not supported yet, ignored');
114 }
115 const search = profile === 'lite' ? 'off' : options.surface === 'mod' ? 'lazy' : 'eager';
116 const explanations = effective.tiers.explanations || (profile === 'lite' ? 'off' : 'lazy');
117 const plans = packs.map((pack) => ({
118 pack,
119 tiers: { terms: 'eager', cards: 'eager', search, explanations, vectors: 'off' },
120 }));
121 const limitMB = effective.limits.memoryMB;
122 const steps = [];
123 const dropped = [];
124 const estimate = () => estimateMemoryMB(plans, options.locale, options.surface);
125 const over = () => limitMB !== undefined && estimate() > limitMB;
126 const step = (message) => {
127 steps.push(message);
128 notice(message);
129 };
130 if (over()) {
131 // 1. vectors are never loaded yet, so there is nothing to turn off.
132 // 2. explanations eager -> lazy (lazy and remote cost nothing at load).
133 if (plans.some((plan) => plan.tiers.explanations === 'eager')) {
134 for (const plan of plans)
135 if (plan.tiers.explanations === 'eager')
136 plan.tiers.explanations = 'lazy';
137 step(`OmniLex: vượt ${limitMB} MB, chuyển bài giải thích sang lazy / over ${limitMB} MB, explanations now lazy`);
138 }
139 }
140 if (over() && plans.some((plan) => plan.tiers.search === 'eager')) {
141 // 3. search index only on the first search.
142 for (const plan of plans)
143 if (plan.tiers.search === 'eager')
144 plan.tiers.search = 'lazy';
145 step(`OmniLex: vượt ${limitMB} MB, chỉ nạp chỉ mục tìm kiếm khi tìm lần đầu / over ${limitMB} MB, search index loads on first search`);
146 }
147 // 4. drop the lowest-priority packs, last in the list first.
148 while (over() && plans.length > 0) {
149 const removed = plans.pop();
150 dropped.push(removed.pack);
151 step(`OmniLex: vượt ${limitMB} MB, bỏ gói ${removed.pack.id} / over ${limitMB} MB, dropped pack ${removed.pack.id}`);
152 }
153 return { profile, packs: plans, dropped, estimateMB: estimate(), limitMB, steps };
154}
155hooks/runtime/minisearch.js 2006 lines1/** @ignore */
2const ENTRIES = 'ENTRIES';
3/** @ignore */
4const KEYS = 'KEYS';
5/** @ignore */
6const VALUES = 'VALUES';
7/** @ignore */
8const LEAF = '';
9/**
10 * @private
11 */
12class TreeIterator {
13 constructor(set, type) {
14 const node = set._tree;
15 const keys = Array.from(node.keys());
16 this.set = set;
17 this._type = type;
18 this._path = keys.length > 0 ? [{ node, keys }] : [];
19 }
20 next() {
21 const value = this.dive();
22 this.backtrack();
23 return value;
24 }
25 dive() {
26 if (this._path.length === 0) {
27 return { done: true, value: undefined };
28 }
29 const { node, keys } = last$1(this._path);
30 if (last$1(keys) === LEAF) {
31 return { done: false, value: this.result() };
32 }
33 const child = node.get(last$1(keys));
34 this._path.push({ node: child, keys: Array.from(child.keys()) });
35 return this.dive();
36 }
37 backtrack() {
38 if (this._path.length === 0) {
39 return;
40 }
41 const keys = last$1(this._path).keys;
42 keys.pop();
43 if (keys.length > 0) {
44 return;
45 }
46 this._path.pop();
47 this.backtrack();
48 }
49 key() {
50 return this.set._prefix + this._path
51 .map(({ keys }) => last$1(keys))
52 .filter(key => key !== LEAF)
53 .join('');
54 }
55 value() {
56 return last$1(this._path).node.get(LEAF);
57 }
58 result() {
59 switch (this._type) {
60 case VALUES: return this.value();
61 case KEYS: return this.key();
62 default: return [this.key(), this.value()];
63 }
64 }
65 [Symbol.iterator]() {
66 return this;
67 }
68}
69const last$1 = (array) => {
70 return array[array.length - 1];
71};
72
73/* eslint-disable no-labels */
74/**
75 * @ignore
76 */
77const fuzzySearch = (node, query, maxDistance) => {
78 const results = new Map();
79 if (query === undefined)
80 return results;
81 // Number of columns in the Levenshtein matrix.
82 const n = query.length + 1;
83 // Matching terms can never be longer than N + maxDistance.
84 const m = n + maxDistance;
85 // Fill first matrix row and column with numbers: 0 1 2 3 ...
86 const matrix = new Uint8Array(m * n).fill(maxDistance + 1);
87 for (let j = 0; j < n; ++j)
88 matrix[j] = j;
89 for (let i = 1; i < m; ++i)
90 matrix[i * n] = i;
91 recurse(node, query, maxDistance, results, matrix, 1, n, '');
92 return results;
93};
94// Modified version of http://stevehanov.ca/blog/?id=114
95// This builds a Levenshtein matrix for a given query and continuously updates
96// it for nodes in the radix tree that fall within the given maximum edit
97// distance. Keeping the same matrix around is beneficial especially for larger
98// edit distances.
99//
100// k a t e <-- query
101// 0 1 2 3 4
102// c 1 1 2 3 4
103// a 2 2 1 2 3
104// t 3 3 2 1 [2] <-- edit distance
105// ^
106// ^ term in radix tree, rows are added and removed as needed
107const recurse = (node, query, maxDistance, results, matrix, m, n, prefix) => {
108 const offset = m * n;
109 key: for (const key of node.keys()) {
110 if (key === LEAF) {
111 // We've reached a leaf node. Check if the edit distance acceptable and
112 // store the result if it is.
113 const distance = matrix[offset - 1];
114 if (distance <= maxDistance) {
115 results.set(prefix, [node.get(key), distance]);
116 }
117 }
118 else {
119 // Iterate over all characters in the key. Update the Levenshtein matrix
120 // and check if the minimum distance in the last row is still within the
121 // maximum edit distance. If it is, we can recurse over all child nodes.
122 let i = m;
123 for (let pos = 0; pos < key.length; ++pos, ++i) {
124 const char = key[pos];
125 const thisRowOffset = n * i;
126 const prevRowOffset = thisRowOffset - n;
127 // Set the first column based on the previous row, and initialize the
128 // minimum distance in the current row.
129 let minDistance = matrix[thisRowOffset];
130 const jmin = Math.max(0, i - maxDistance - 1);
131 const jmax = Math.min(n - 1, i + maxDistance);
132 // Iterate over remaining columns (characters in the query).
133 for (let j = jmin; j < jmax; ++j) {
134 const different = char !== query[j];
135 // It might make sense to only read the matrix positions used for
136 // deletion/insertion if the characters are different. But we want to
137 // avoid conditional reads for performance reasons.
138 const rpl = matrix[prevRowOffset + j] + +different;
139 const del = matrix[prevRowOffset + j + 1] + 1;
140 const ins = matrix[thisRowOffset + j] + 1;
141 const dist = matrix[thisRowOffset + j + 1] = Math.min(rpl, del, ins);
142 if (dist < minDistance)
143 minDistance = dist;
144 }
145 // Because distance will never decrease, we can stop. There will be no
146 // matching child nodes.
147 if (minDistance > maxDistance) {
148 continue key;
149 }
150 }
151 recurse(node.get(key), query, maxDistance, results, matrix, i, n, prefix + key);
152 }
153 }
154};
155
156/* eslint-disable no-labels */
157/**
158 * A class implementing the same interface as a standard JavaScript
159 * [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map)
160 * with string keys, but adding support for efficiently searching entries with
161 * prefix or fuzzy search. This class is used internally by {@link MiniSearch}
162 * as the inverted index data structure. The implementation is a radix tree
163 * (compressed prefix tree).
164 *
165 * Since this class can be of general utility beyond _MiniSearch_, it is
166 * exported by the `minisearch` package and can be imported (or required) as
167 * `minisearch/SearchableMap`.
168 *
169 * @typeParam T The type of the values stored in the map.
170 */
171class SearchableMap {
172 /**
173 * The constructor is normally called without arguments, creating an empty
174 * map. In order to create a {@link SearchableMap} from an iterable or from an
175 * object, check {@link SearchableMap.from} and {@link
176 * SearchableMap.fromObject}.
177 *
178 * The constructor arguments are for internal use, when creating derived
179 * mutable views of a map at a prefix.
180 */
181 constructor(tree = new Map(), prefix = '') {
182 this._size = undefined;
183 this._tree = tree;
184 this._prefix = prefix;
185 }
186 /**
187 * Creates and returns a mutable view of this {@link SearchableMap},
188 * containing only entries that share the given prefix.
189 *
190 * ### Usage:
191 *
192 * ```javascript
193 * let map = new SearchableMap()
194 * map.set("unicorn", 1)
195 * map.set("universe", 2)
196 * map.set("university", 3)
197 * map.set("unique", 4)
198 * map.set("hello", 5)
199 *
200 * let uni = map.atPrefix("uni")
201 * uni.get("unique") // => 4
202 * uni.get("unicorn") // => 1
203 * uni.get("hello") // => undefined
204 *
205 * let univer = map.atPrefix("univer")
206 * univer.get("unique") // => undefined
207 * univer.get("universe") // => 2
208 * univer.get("university") // => 3
209 * ```
210 *
211 * @param prefix The prefix
212 * @return A {@link SearchableMap} representing a mutable view of the original
213 * Map at the given prefix
214 */
215 atPrefix(prefix) {
216 if (!prefix.startsWith(this._prefix)) {
217 throw new Error('Mismatched prefix');
218 }
219 const [node, path] = trackDown(this._tree, prefix.slice(this._prefix.length));
220 if (node === undefined) {
221 const [parentNode, key] = last(path);
222 for (const k of parentNode.keys()) {
223 if (k !== LEAF && k.startsWith(key)) {
224 const node = new Map();
225 node.set(k.slice(key.length), parentNode.get(k));
226 return new SearchableMap(node, prefix);
227 }
228 }
229 }
230 return new SearchableMap(node, prefix);
231 }
232 /**
233 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/clear
234 */
235 clear() {
236 this._size = undefined;
237 this._tree.clear();
238 }
239 /**
240 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/delete
241 * @param key Key to delete
242 */
243 delete(key) {
244 this._size = undefined;
245 return remove(this._tree, key);
246 }
247 /**
248 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries
249 * @return An iterator iterating through `[key, value]` entries.
250 */
251 entries() {
252 return new TreeIterator(this, ENTRIES);
253 }
254 /**
255 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/forEach
256 * @param fn Iteration function
257 */
258 forEach(fn) {
259 for (const [key, value] of this) {
260 fn(key, value, this);
261 }
262 }
263 /**
264 * Returns a Map of all the entries that have a key within the given edit
265 * distance from the search key. The keys of the returned Map are the matching
266 * keys, while the values are two-element arrays where the first element is
267 * the value associated to the key, and the second is the edit distance of the
268 * key to the search key.
269 *
270 * ### Usage:
271 *
272 * ```javascript
273 * let map = new SearchableMap()
274 * map.set('hello', 'world')
275 * map.set('hell', 'yeah')
276 * map.set('ciao', 'mondo')
277 *
278 * // Get all entries that match the key 'hallo' with a maximum edit distance of 2
279 * map.fuzzyGet('hallo', 2)
280 * // => Map(2) { 'hello' => ['world', 1], 'hell' => ['yeah', 2] }
281 *
282 * // In the example, the "hello" key has value "world" and edit distance of 1
283 * // (change "e" to "a"), the key "hell" has value "yeah" and edit distance of 2
284 * // (change "e" to "a", delete "o")
285 * ```
286 *
287 * @param key The search key
288 * @param maxEditDistance The maximum edit distance (Levenshtein)
289 * @return A Map of the matching keys to their value and edit distance
290 */
291 fuzzyGet(key, maxEditDistance) {
292 return fuzzySearch(this._tree, key, maxEditDistance);
293 }
294 /**
295 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/get
296 * @param key Key to get
297 * @return Value associated to the key, or `undefined` if the key is not
298 * found.
299 */
300 get(key) {
301 const node = lookup(this._tree, key);
302 return node !== undefined ? node.get(LEAF) : undefined;
303 }
304 /**
305 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/has
306 * @param key Key
307 * @return True if the key is in the map, false otherwise
308 */
309 has(key) {
310 const node = lookup(this._tree, key);
311 return node !== undefined && node.has(LEAF);
312 }
313 /**
314 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys
315 * @return An `Iterable` iterating through keys
316 */
317 keys() {
318 return new TreeIterator(this, KEYS);
319 }
320 /**
321 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/set
322 * @param key Key to set
323 * @param value Value to associate to the key
324 * @return The {@link SearchableMap} itself, to allow chaining
325 */
326 set(key, value) {
327 if (typeof key !== 'string') {
328 throw new Error('key must be a string');
329 }
330 this._size = undefined;
331 const node = createPath(this._tree, key);
332 node.set(LEAF, value);
333 return this;
334 }
335 /**
336 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/size
337 */
338 get size() {
339 if (this._size) {
340 return this._size;
341 }
342 /** @ignore */
343 this._size = 0;
344 const iter = this.entries();
345 while (!iter.next().done)
346 this._size += 1;
347 return this._size;
348 }
349 /**
350 * Updates the value at the given key using the provided function. The function
351 * is called with the current value at the key, and its return value is used as
352 * the new value to be set.
353 *
354 * ### Example:
355 *
356 * ```javascript
357 * // Increment the current value by one
358 * searchableMap.update('somekey', (currentValue) => currentValue == null ? 0 : currentValue + 1)
359 * ```
360 *
361 * If the value at the given key is or will be an object, it might not require
362 * re-assignment. In that case it is better to use `fetch()`, because it is
363 * faster.
364 *
365 * @param key The key to update
366 * @param fn The function used to compute the new value from the current one
367 * @return The {@link SearchableMap} itself, to allow chaining
368 */
369 update(key, fn) {
370 if (typeof key !== 'string') {
371 throw new Error('key must be a string');
372 }
373 this._size = undefined;
374 const node = createPath(this._tree, key);
375 node.set(LEAF, fn(node.get(LEAF)));
376 return this;
377 }
378 /**
379 * Fetches the value of the given key. If the value does not exist, calls the
380 * given function to create a new value, which is inserted at the given key
381 * and subsequently returned.
382 *
383 * ### Example:
384 *
385 * ```javascript
386 * const map = searchableMap.fetch('somekey', () => new Map())
387 * map.set('foo', 'bar')
388 * ```
389 *
390 * @param key The key to update
391 * @param initial A function that creates a new value if the key does not exist
392 * @return The existing or new value at the given key
393 */
394 fetch(key, initial) {
395 if (typeof key !== 'string') {
396 throw new Error('key must be a string');
397 }
398 this._size = undefined;
399 const node = createPath(this._tree, key);
400 let value = node.get(LEAF);
401 if (value === undefined) {
402 node.set(LEAF, value = initial());
403 }
404 return value;
405 }
406 /**
407 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/values
408 * @return An `Iterable` iterating through values.
409 */
410 values() {
411 return new TreeIterator(this, VALUES);
412 }
413 /**
414 * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/@@iterator
415 */
416 [Symbol.iterator]() {
417 return this.entries();
418 }
419 /**
420 * Creates a {@link SearchableMap} from an `Iterable` of entries
421 *
422 * @param entries Entries to be inserted in the {@link SearchableMap}
423 * @return A new {@link SearchableMap} with the given entries
424 */
425 static from(entries) {
426 const tree = new SearchableMap();
427 for (const [key, value] of entries) {
428 tree.set(key, value);
429 }
430 return tree;
431 }
432 /**
433 * Creates a {@link SearchableMap} from the iterable properties of a JavaScript object
434 *
435 * @param object Object of entries for the {@link SearchableMap}
436 * @return A new {@link SearchableMap} with the given entries
437 */
438 static fromObject(object) {
439 return SearchableMap.from(Object.entries(object));
440 }
441}
442const trackDown = (tree, key, path = []) => {
443 if (key.length === 0 || tree == null) {
444 return [tree, path];
445 }
446 for (const k of tree.keys()) {
447 if (k !== LEAF && key.startsWith(k)) {
448 path.push([tree, k]); // performance: update in place
449 return trackDown(tree.get(k), key.slice(k.length), path);
450 }
451 }
452 path.push([tree, key]); // performance: update in place
453 return trackDown(undefined, '', path);
454};
455const lookup = (tree, key) => {
456 if (key.length === 0 || tree == null) {
457 return tree;
458 }
459 for (const k of tree.keys()) {
460 if (k !== LEAF && key.startsWith(k)) {
461 return lookup(tree.get(k), key.slice(k.length));
462 }
463 }
464};
465// Create a path in the radix tree for the given key, and returns the deepest
466// node. This function is in the hot path for indexing. It avoids unnecessary
467// string operations and recursion for performance.
468const createPath = (node, key) => {
469 const keyLength = key.length;
470 outer: for (let pos = 0; node && pos < keyLength;) {
471 for (const k of node.keys()) {
472 // Check whether this key is a candidate: the first characters must match.
473 if (k !== LEAF && key[pos] === k[0]) {
474 const len = Math.min(keyLength - pos, k.length);
475 // Advance offset to the point where key and k no longer match.
476 let offset = 1;
477 while (offset < len && key[pos + offset] === k[offset])
478 ++offset;
479 const child = node.get(k);
480 if (offset === k.length) {
481 // The existing key is shorter than the key we need to create.
482 node = child;
483 }
484 else {
485 // Partial match: we need to insert an intermediate node to contain
486 // both the existing subtree and the new node.
487 const intermediate = new Map();
488 intermediate.set(k.slice(offset), child);
489 node.set(key.slice(pos, pos + offset), intermediate);
490 node.delete(k);
491 node = intermediate;
492 }
493 pos += offset;
494 continue outer;
495 }
496 }
497 // Create a final child node to contain the final suffix of the key.
498 const child = new Map();
499 node.set(key.slice(pos), child);
500 return child;
501 }
502 return node;
503};
504const remove = (tree, key) => {
505 const [node, path] = trackDown(tree, key);
506 if (node === undefined) {
507 return;
508 }
509 node.delete(LEAF);
510 if (node.size === 0) {
511 cleanup(path);
512 }
513 else if (node.size === 1) {
514 const [key, value] = node.entries().next().value;
515 merge(path, key, value);
516 }
517};
518const cleanup = (path) => {
519 if (path.length === 0) {
520 return;
521 }
522 const [node, key] = last(path);
523 node.delete(key);
524 if (node.size === 0) {
525 cleanup(path.slice(0, -1));
526 }
527 else if (node.size === 1) {
528 const [key, value] = node.entries().next().value;
529 if (key !== LEAF) {
530 merge(path.slice(0, -1), key, value);
531 }
532 }
533};
534const merge = (path, key, value) => {
535 if (path.length === 0) {
536 return;
537 }
538 const [node, nodeKey] = last(path);
539 node.set(nodeKey + key, value);
540 node.delete(nodeKey);
541};
542const last = (array) => {
543 return array[array.length - 1];
544};
545
546const OR = 'or';
547const AND = 'and';
548const AND_NOT = 'and_not';
549/**
550 * {@link MiniSearch} is the main entrypoint class, implementing a full-text
551 * search engine in memory.
552 *
553 * @typeParam T The type of the documents being indexed.
554 *
555 * ### Basic example:
556 *
557 * ```javascript
558 * const documents = [
559 * {
560 * id: 1,
561 * title: 'Moby Dick',
562 * text: 'Call me Ishmael. Some years ago...',
563 * category: 'fiction'
564 * },
565 * {
566 * id: 2,
567 * title: 'Zen and the Art of Motorcycle Maintenance',
568 * text: 'I can see by my watch...',
569 * category: 'fiction'
570 * },
571 * {
572 * id: 3,
573 * title: 'Neuromancer',
574 * text: 'The sky above the port was...',
575 * category: 'fiction'
576 * },
577 * {
578 * id: 4,
579 * title: 'Zen and the Art of Archery',
580 * text: 'At first sight it must seem...',
581 * category: 'non-fiction'
582 * },
583 * // ...and more
584 * ]
585 *
586 * // Create a search engine that indexes the 'title' and 'text' fields for
587 * // full-text search. Search results will include 'title' and 'category' (plus the
588 * // id field, that is always stored and returned)
589 * const miniSearch = new MiniSearch({
590 * fields: ['title', 'text'],
591 * storeFields: ['title', 'category']
592 * })
593 *
594 * // Add documents to the index
595 * miniSearch.addAll(documents)
596 *
597 * // Search for documents:
598 * let results = miniSearch.search('zen art motorcycle')
599 * // => [
600 * // { id: 2, title: 'Zen and the Art of Motorcycle Maintenance', category: 'fiction', score: 2.77258 },
601 * // { id: 4, title: 'Zen and the Art of Archery', category: 'non-fiction', score: 1.38629 }
602 * // ]
603 * ```
604 */
605class MiniSearch {
606 /**
607 * @param options Configuration options
608 *
609 * ### Examples:
610 *
611 * ```javascript
612 * // Create a search engine that indexes the 'title' and 'text' fields of your
613 * // documents:
614 * const miniSearch = new MiniSearch({ fields: ['title', 'text'] })
615 * ```
616 *
617 * ### ID Field:
618 *
619 * ```javascript
620 * // Your documents are assumed to include a unique 'id' field, but if you want
621 * // to use a different field for document identification, you can set the
622 * // 'idField' option:
623 * const miniSearch = new MiniSearch({ idField: 'key', fields: ['title', 'text'] })
624 * ```
625 *
626 * ### Options and defaults:
627 *
628 * ```javascript
629 * // The full set of options (here with their default value) is:
630 * const miniSearch = new MiniSearch({
631 * // idField: field that uniquely identifies a document
632 * idField: 'id',
633 *
634 * // extractField: function used to get the value of a field in a document.
635 * // By default, it assumes the document is a flat object with field names as
636 * // property keys and field values as string property values, but custom logic
637 * // can be implemented by setting this option to a custom extractor function.
638 * extractField: (document, fieldName) => document[fieldName],
639 *
640 * // tokenize: function used to split fields into individual terms. By
641 * // default, it is also used to tokenize search queries, unless a specific
642 * // `tokenize` search option is supplied. When tokenizing an indexed field,
643 * // the field name is passed as the second argument.
644 * tokenize: (string, _fieldName) => string.split(SPACE_OR_PUNCTUATION),
645 *
646 * // processTerm: function used to process each tokenized term before
647 * // indexing. It can be used for stemming and normalization. Return a falsy
648 * // value in order to discard a term. By default, it is also used to process
649 * // search queries, unless a specific `processTerm` option is supplied as a
650 * // search option. When processing a term from a indexed field, the field
651 * // name is passed as the second argument.
652 * processTerm: (term, _fieldName) => term.toLowerCase(),
653 *
654 * // searchOptions: default search options, see the `search` method for
655 * // details
656 * searchOptions: undefined,
657 *
658 * // fields: document fields to be indexed. Mandatory, but not set by default
659 * fields: undefined
660 *
661 * // storeFields: document fields to be stored and returned as part of the
662 * // search results.
663 * storeFields: []
664 * })
665 * ```
666 */
667 constructor(options) {
668 if ((options === null || options === void 0 ? void 0 : options.fields) == null) {
669 throw new Error('MiniSearch: option "fields" must be provided');
670 }
671 const autoVacuum = (options.autoVacuum == null || options.autoVacuum === true) ? defaultAutoVacuumOptions : options.autoVacuum;
672 this._options = {
673 ...defaultOptions,
674 ...options,
675 autoVacuum,
676 searchOptions: { ...defaultSearchOptions, ...(options.searchOptions || {}) },
677 autoSuggestOptions: { ...defaultAutoSuggestOptions, ...(options.autoSuggestOptions || {}) }
678 };
679 this._index = new SearchableMap();
680 this._documentCount = 0;
681 this._documentIds = new Map();
682 this._idToShortId = new Map();
683 // Fields are defined during initialization, don't change, are few in
684 // number, rarely need iterating over, and have string keys. Therefore in
685 // this case an object is a better candidate than a Map to store the mapping
686 // from field key to ID.
687 this._fieldIds = {};
688 this._fieldLength = new Map();
689 this._avgFieldLength = [];
690 this._nextId = 0;
691 this._storedFields = new Map();
692 this._dirtCount = 0;
693 this._currentVacuum = null;
694 this._enqueuedVacuum = null;
695 this._enqueuedVacuumConditions = defaultVacuumConditions;
696 this.addFields(this._options.fields);
697 }
698 /**
699 * Adds a document to the index
700 *
701 * @param document The document to be indexed
702 */
703 add(document) {
704 const { extractField, stringifyField, tokenize, processTerm, fields, idField } = this._options;
705 const id = extractField(document, idField);
706 if (id == null) {
707 throw new Error(`MiniSearch: document does not have ID field "${idField}"`);
708 }
709 if (this._idToShortId.has(id)) {
710 throw new Error(`MiniSearch: duplicate ID ${id}`);
711 }
712 const shortDocumentId = this.addDocumentId(id);
713 this.saveStoredFields(shortDocumentId, document);
714 for (const field of fields) {
715 const fieldValue = extractField(document, field);
716 if (fieldValue == null)
717 continue;
718 const tokens = tokenize(stringifyField(fieldValue, field), field);
719 const fieldId = this._fieldIds[field];
720 const uniqueTerms = new Set(tokens).size;
721 this.addFieldLength(shortDocumentId, fieldId, this._documentCount - 1, uniqueTerms);
722 for (const term of tokens) {
723 const processedTerm = processTerm(term, field);
724 if (Array.isArray(processedTerm)) {
725 for (const t of processedTerm) {
726 this.addTerm(fieldId, shortDocumentId, t);
727 }
728 }
729 else if (processedTerm) {
730 this.addTerm(fieldId, shortDocumentId, processedTerm);
731 }
732 }
733 }
734 }
735 /**
736 * Adds all the given documents to the index
737 *
738 * @param documents An array of documents to be indexed
739 */
740 addAll(documents) {
741 for (const document of documents)
742 this.add(document);
743 }
744 /**
745 * Adds all the given documents to the index asynchronously.
746 *
747 * Returns a promise that resolves (to `undefined`) when the indexing is done.
748 * This method is useful when index many documents, to avoid blocking the main
749 * thread. The indexing is performed asynchronously and in chunks.
750 *
751 * @param documents An array of documents to be indexed
752 * @param options Configuration options
753 * @return A promise resolving to `undefined` when the indexing is done
754 */
755 addAllAsync(documents, options = {}) {
756 const { chunkSize = 10 } = options;
757 const acc = { chunk: [], promise: Promise.resolve() };
758 const { chunk, promise } = documents.reduce(({ chunk, promise }, document, i) => {
759 chunk.push(document);
760 if ((i + 1) % chunkSize === 0) {
761 return {
762 chunk: [],
763 promise: promise
764 .then(() => new Promise(resolve => setTimeout(resolve, 0)))
765 .then(() => this.addAll(chunk))
766 };
767 }
768 else {
769 return { chunk, promise };
770 }
771 }, acc);
772 return promise.then(() => this.addAll(chunk));
773 }
774 /**
775 * Removes the given document from the index.
776 *
777 * The document to remove must NOT have changed between indexing and removal,
778 * otherwise the index will be corrupted.
779 *
780 * This method requires passing the full document to be removed (not just the
781 * ID), and immediately removes the document from the inverted index, allowing
782 * memory to be released. A convenient alternative is {@link
783 * MiniSearch#discard}, which needs only the document ID, and has the same
784 * visible effect, but delays cleaning up the index until the next vacuuming.
785 *
786 * @param document The document to be removed
787 */
788 remove(document) {
789 const { tokenize, processTerm, extractField, stringifyField, fields, idField } = this._options;
790 const id = extractField(document, idField);
791 if (id == null) {
792 throw new Error(`MiniSearch: document does not have ID field "${idField}"`);
793 }
794 const shortId = this._idToShortId.get(id);
795 if (shortId == null) {
796 throw new Error(`MiniSearch: cannot remove document with ID ${id}: it is not in the index`);
797 }
798 for (const field of fields) {
799 const fieldValue = extractField(document, field);
800 if (fieldValue == null)
801 continue;
802 const tokens = tokenize(stringifyField(fieldValue, field), field);
803 const fieldId = this._fieldIds[field];
804 const uniqueTerms = new Set(tokens).size;
805 this.removeFieldLength(shortId, fieldId, this._documentCount, uniqueTerms);
806 for (const term of tokens) {
807 const processedTerm = processTerm(term, field);
808 if (Array.isArray(processedTerm)) {
809 for (const t of processedTerm) {
810 this.removeTerm(fieldId, shortId, t);
811 }
812 }
813 else if (processedTerm) {
814 this.removeTerm(fieldId, shortId, processedTerm);
815 }
816 }
817 }
818 this._storedFields.delete(shortId);
819 this._documentIds.delete(shortId);
820 this._idToShortId.delete(id);
821 this._fieldLength.delete(shortId);
822 this._documentCount -= 1;
823 }
824 /**
825 * Removes all the given documents from the index. If called with no arguments,
826 * it removes _all_ documents from the index.
827 *
828 * @param documents The documents to be removed. If this argument is omitted,
829 * all documents are removed. Note that, for removing all documents, it is
830 * more efficient to call this method with no arguments than to pass all
831 * documents.
832 */
833 removeAll(documents) {
834 if (documents) {
835 for (const document of documents)
836 this.remove(document);
837 }
838 else if (arguments.length > 0) {
839 throw new Error('Expected documents to be present. Omit the argument to remove all documents.');
840 }
841 else {
842 this._index = new SearchableMap();
843 this._documentCount = 0;
844 this._documentIds = new Map();
845 this._idToShortId = new Map();
846 this._fieldLength = new Map();
847 this._avgFieldLength = [];
848 this._storedFields = new Map();
849 this._nextId = 0;
850 }
851 }
852 /**
853 * Discards the document with the given ID, so it won't appear in search results
854 *
855 * It has the same visible effect of {@link MiniSearch.remove} (both cause the
856 * document to stop appearing in searches), but a different effect on the
857 * internal data structures:
858 *
859 * - {@link MiniSearch#remove} requires passing the full document to be
860 * removed as argument, and removes it from the inverted index immediately.
861 *
862 * - {@link MiniSearch#discard} instead only needs the document ID, and
863 * works by marking the current version of the document as discarded, so it
864 * is immediately ignored by searches. This is faster and more convenient
865 * than {@link MiniSearch#remove}, but the index is not immediately
866 * modified. To take care of that, vacuuming is performed after a certain
867 * number of documents are discarded, cleaning up the index and allowing
868 * memory to be released.
869 *
870 * After discarding a document, it is possible to re-add a new version, and
871 * only the new version will appear in searches. In other words, discarding
872 * and re-adding a document works exactly like removing and re-adding it. The
873 * {@link MiniSearch.replace} method can also be used to replace a document
874 * with a new version.
875 *
876 * #### Details about vacuuming
877 *
878 * Repetite calls to this method would leave obsolete document references in
879 * the index, invisible to searches. Two mechanisms take care of cleaning up:
880 * clean up during search, and vacuuming.
881 *
882 * - Upon search, whenever a discarded ID is found (and ignored for the
883 * results), references to the discarded document are removed from the
884 * inverted index entries for the search terms. This ensures that subsequent
885 * searches for the same terms do not need to skip these obsolete references
886 * again.
887 *
888 * - In addition, vacuuming is performed automatically by default (see the
889 * `autoVacuum` field in {@link Options}) after a certain number of
890 * documents are discarded. Vacuuming traverses all terms in the index,
891 * cleaning up all references to discarded documents. Vacuuming can also be
892 * triggered manually by calling {@link MiniSearch#vacuum}.
893 *
894 * @param id The ID of the document to be discarded
895 */
896 discard(id) {
897 const shortId = this._idToShortId.get(id);
898 if (shortId == null) {
899 throw new Error(`MiniSearch: cannot discard document with ID ${id}: it is not in the index`);
900 }
901 this._idToShortId.delete(id);
902 this._documentIds.delete(shortId);
903 this._storedFields.delete(shortId);
904 (this._fieldLength.get(shortId) || []).forEach((fieldLength, fieldId) => {
905 this.removeFieldLength(shortId, fieldId, this._documentCount, fieldLength);
906 });
907 this._fieldLength.delete(shortId);
908 this._documentCount -= 1;
909 this._dirtCount += 1;
910 this.maybeAutoVacuum();
911 }
912 maybeAutoVacuum() {
913 if (this._options.autoVacuum === false) {
914 return;
915 }
916 const { minDirtFactor, minDirtCount, batchSize, batchWait } = this._options.autoVacuum;
917 this.conditionalVacuum({ batchSize, batchWait }, { minDirtCount, minDirtFactor });
918 }
919 /**
920 * Discards the documents with the given IDs, so they won't appear in search
921 * results
922 *
923 * It is equivalent to calling {@link MiniSearch#discard} for all the given
924 * IDs, but with the optimization of triggering at most one automatic
925 * vacuuming at the end.
926 *
927 * Note: to remove all documents from the index, it is faster and more
928 * convenient to call {@link MiniSearch.removeAll} with no argument, instead
929 * of passing all IDs to this method.
930 */
931 discardAll(ids) {
932 const autoVacuum = this._options.autoVacuum;
933 try {
934 this._options.autoVacuum = false;
935 for (const id of ids) {
936 this.discard(id);
937 }
938 }
939 finally {
940 this._options.autoVacuum = autoVacuum;
941 }
942 this.maybeAutoVacuum();
943 }
944 /**
945 * It replaces an existing document with the given updated version
946 *
947 * It works by discarding the current version and adding the updated one, so
948 * it is functionally equivalent to calling {@link MiniSearch#discard}
949 * followed by {@link MiniSearch#add}. The ID of the updated document should
950 * be the same as the original one.
951 *
952 * Since it uses {@link MiniSearch#discard} internally, this method relies on
953 * vacuuming to clean up obsolete document references from the index, allowing
954 * memory to be released (see {@link MiniSearch#discard}).
955 *
956 * @param updatedDocument The updated document to replace the old version
957 * with
958 */
959 replace(updatedDocument) {
960 const { idField, extractField } = this._options;
961 const id = extractField(updatedDocument, idField);
962 this.discard(id);
963 this.add(updatedDocument);
964 }
965 /**
966 * Triggers a manual vacuuming, cleaning up references to discarded documents
967 * from the inverted index
968 *
969 * Vacuuming is only useful for applications that use the {@link
970 * MiniSearch#discard} or {@link MiniSearch#replace} methods.
971 *
972 * By default, vacuuming is performed automatically when needed (controlled by
973 * the `autoVacuum` field in {@link Options}), so there is usually no need to
974 * call this method, unless one wants to make sure to perform vacuuming at a
975 * specific moment.
976 *
977 * Vacuuming traverses all terms in the inverted index in batches, and cleans
978 * up references to discarded documents from the posting list, allowing memory
979 * to be released.
980 *
981 * The method takes an optional object as argument with the following keys:
982 *
983 * - `batchSize`: the size of each batch (1000 by default)
984 *
985 * - `batchWait`: the number of milliseconds to wait between batches (10 by
986 * default)
987 *
988 * On large indexes, vacuuming could have a non-negligible cost: batching
989 * avoids blocking the thread for long, diluting this cost so that it is not
990 * negatively affecting the application. Nonetheless, this method should only
991 * be called when necessary, and relying on automatic vacuuming is usually
992 * better.
993 *
994 * It returns a promise that resolves (to undefined) when the clean up is
995 * completed. If vacuuming is already ongoing at the time this method is
996 * called, a new one is enqueued immediately after the ongoing one, and a
997 * corresponding promise is returned. However, no more than one vacuuming is
998 * enqueued on top of the ongoing one, even if this method is called more
999 * times (enqueuing multiple ones would be useless).
1000 *
1001 * @param options Configuration options for the batch size and delay. See
1002 * {@link VacuumOptions}.
1003 */
1004 vacuum(options = {}) {
1005 return this.conditionalVacuum(options);
1006 }
1007 conditionalVacuum(options, conditions) {
1008 // If a vacuum is already ongoing, schedule another as soon as it finishes,
1009 // unless there's already one enqueued. If one was already enqueued, do not
1010 // enqueue another on top, but make sure that the conditions are the
1011 // broadest.
1012 if (this._currentVacuum) {
1013 this._enqueuedVacuumConditions = this._enqueuedVacuumConditions && conditions;
1014 if (this._enqueuedVacuum != null) {
1015 return this._enqueuedVacuum;
1016 }
1017 this._enqueuedVacuum = this._currentVacuum.then(() => {
1018 const conditions = this._enqueuedVacuumConditions;
1019 this._enqueuedVacuumConditions = defaultVacuumConditions;
1020 return this.performVacuuming(options, conditions);
1021 });
1022 return this._enqueuedVacuum;
1023 }
1024 if (this.vacuumConditionsMet(conditions) === false) {
1025 return Promise.resolve();
1026 }
1027 this._currentVacuum = this.performVacuuming(options);
1028 return this._currentVacuum;
1029 }
1030 async performVacuuming(options, conditions) {
1031 const initialDirtCount = this._dirtCount;
1032 if (this.vacuumConditionsMet(conditions)) {
1033 const batchSize = options.batchSize || defaultVacuumOptions.batchSize;
1034 const batchWait = options.batchWait || defaultVacuumOptions.batchWait;
1035 let i = 1;
1036 for (const [term, fieldsData] of this._index) {
1037 for (const [fieldId, fieldIndex] of fieldsData) {
1038 for (const [shortId] of fieldIndex) {
1039 if (this._documentIds.has(shortId)) {
1040 continue;
1041 }
1042 if (fieldIndex.size <= 1) {
1043 fieldsData.delete(fieldId);
1044 }
1045 else {
1046 fieldIndex.delete(shortId);
1047 }
1048 }
1049 }
1050 if (this._index.get(term).size === 0) {
1051 this._index.delete(term);
1052 }
1053 if (i % batchSize === 0) {
1054 await new Promise((resolve) => setTimeout(resolve, batchWait));
1055 }
1056 i += 1;
1057 }
1058 this._dirtCount -= initialDirtCount;
1059 }
1060 // Make the next lines always async, so they execute after this function returns
1061 await null;
1062 this._currentVacuum = this._enqueuedVacuum;
1063 this._enqueuedVacuum = null;
1064 }
1065 vacuumConditionsMet(conditions) {
1066 if (conditions == null) {
1067 return true;
1068 }
1069 let { minDirtCount, minDirtFactor } = conditions;
1070 minDirtCount = minDirtCount || defaultAutoVacuumOptions.minDirtCount;
1071 minDirtFactor = minDirtFactor || defaultAutoVacuumOptions.minDirtFactor;
1072 return this.dirtCount >= minDirtCount && this.dirtFactor >= minDirtFactor;
1073 }
1074 /**
1075 * Is `true` if a vacuuming operation is ongoing, `false` otherwise
1076 */
1077 get isVacuuming() {
1078 return this._currentVacuum != null;
1079 }
1080 /**
1081 * The number of documents discarded since the most recent vacuuming
1082 */
1083 get dirtCount() {
1084 return this._dirtCount;
1085 }
1086 /**
1087 * A number between 0 and 1 giving an indication about the proportion of
1088 * documents that are discarded, and can therefore be cleaned up by vacuuming.
1089 * A value close to 0 means that the index is relatively clean, while a higher
1090 * value means that the index is relatively dirty, and vacuuming could release
1091 * memory.
1092 */
1093 get dirtFactor() {
1094 return this._dirtCount / (1 + this._documentCount + this._dirtCount);
1095 }
1096 /**
1097 * Returns `true` if a document with the given ID is present in the index and
1098 * available for search, `false` otherwise
1099 *
1100 * @param id The document ID
1101 */
1102 has(id) {
1103 return this._idToShortId.has(id);
1104 }
1105 /**
1106 * Returns the stored fields (as configured in the `storeFields` constructor
1107 * option) for the given document ID. Returns `undefined` if the document is
1108 * not present in the index.
1109 *
1110 * @param id The document ID
1111 */
1112 getStoredFields(id) {
1113 const shortId = this._idToShortId.get(id);
1114 if (shortId == null) {
1115 return undefined;
1116 }
1117 return this._storedFields.get(shortId);
1118 }
1119 /**
1120 * Search for documents matching the given search query.
1121 *
1122 * The result is a list of scored document IDs matching the query, sorted by
1123 * descending score, and each including data about which terms were matched and
1124 * in which fields.
1125 *
1126 * ### Basic usage:
1127 *
1128 * ```javascript
1129 * // Search for "zen art motorcycle" with default options: terms have to match
1130 * // exactly, and individual terms are joined with OR
1131 * miniSearch.search('zen art motorcycle')
1132 * // => [ { id: 2, score: 2.77258, match: { ... } }, { id: 4, score: 1.38629, match: { ... } } ]
1133 * ```
1134 *
1135 * ### Restrict search to specific fields:
1136 *
1137 * ```javascript
1138 * // Search only in the 'title' field
1139 * miniSearch.search('zen', { fields: ['title'] })
1140 * ```
1141 *
1142 * ### Field boosting:
1143 *
1144 * ```javascript
1145 * // Boost a field
1146 * miniSearch.search('zen', { boost: { title: 2 } })
1147 * ```
1148 *
1149 * ### Prefix search:
1150 *
1151 * ```javascript
1152 * // Search for "moto" with prefix search (it will match documents
1153 * // containing terms that start with "moto" or "neuro")
1154 * miniSearch.search('moto neuro', { prefix: true })
1155 * ```
1156 *
1157 * ### Fuzzy search:
1158 *
1159 * ```javascript
1160 * // Search for "ismael" with fuzzy search (it will match documents containing
1161 * // terms similar to "ismael", with a maximum edit distance of 0.2 term.length
1162 * // (rounded to nearest integer)
1163 * miniSearch.search('ismael', { fuzzy: 0.2 })
1164 * ```
1165 *
1166 * ### Combining strategies:
1167 *
1168 * ```javascript
1169 * // Mix of exact match, prefix search, and fuzzy search
1170 * miniSearch.search('ismael mob', {
1171 * prefix: true,
1172 * fuzzy: 0.2
1173 * })
1174 * ```
1175 *
1176 * ### Advanced prefix and fuzzy search:
1177 *
1178 * ```javascript
1179 * // Perform fuzzy and prefix search depending on the search term. Here
1180 * // performing prefix and fuzzy search only on terms longer than 3 characters
1181 * miniSearch.search('ismael mob', {
1182 * prefix: term => term.length > 3
1183 * fuzzy: term => term.length > 3 ? 0.2 : null
1184 * })
1185 * ```
1186 *
1187 * ### Combine with AND:
1188 *
1189 * ```javascript
1190 * // Combine search terms with AND (to match only documents that contain both
1191 * // "motorcycle" and "art")
1192 * miniSearch.search('motorcycle art', { combineWith: 'AND' })
1193 * ```
1194 *
1195 * ### Combine with AND_NOT:
1196 *
1197 * There is also an AND_NOT combinator, that finds documents that match the
1198 * first term, but do not match any of the other terms. This combinator is
1199 * rarely useful with simple queries, and is meant to be used with advanced
1200 * query combinations (see later for more details).