SLOPSHOPPER

omnilex

OmniLex terminology lookup pane and hover summaries for Claude Code

newpanerowscommandtimer
v0.1.0MITupdated 2026-10-19DangAW2002/omnilex/packages/claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · omnilex
│ ┃ OmniLex ✕ › fix the failing auth test and add an audit log call │ ┃ tab-search tab-browse │ ┃ Tra ngược: mô tả bằng lời thường ● omnilex: OmniLex: run pnpm ol build to generate mod lookup data │ ┃ Mô tả: Ví dụ: tiền lãi lại sinh ra lãi ⏺ Read(src/auth.ts) │ ┃ Hover thuật ngữ trong câu trả lời để xem tóm ⎿ Read 6 lines │ ┃ tắt. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /omnilex │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · OmniLex
tab-search tab-browse Tra ngược: mô tả bằng lời thường Mô tả: Ví dụ: tiền lãi lại sinh ra lãi Hover thuật ngữ trong câu trả lời để xem tóm tắt.
README

OmniLex cho Claude Code

Yêu cầu Claude Code 2.1.287 trở lên. Kiểm tra bằng claude --version.

Cài đặt qua Marketplace (Khuyên dùng):

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

Chạy thử nghiệm từ mã nguồn:

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:

  • Gõ /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.
  • Với câu ngắn một dòng, không có Markdown hoặc URL, hover trực tiếp trên cụm thuật ngữ trong câu trả lời để xem thẻ tóm tắt nổi phía trên; nhấn cụm đó để mở pane giải thích đầy đủ.
  • Với câu hỏi ngắn chứa mô tả đời thường đã biết, hover trực tiếp trên cụm mô tả để xem thuật ngữ chuẩn. Câu hỏi cần đang ở dạng hiển thị đầy đủ.
  • Với câu dài hoặc có Markdown, cách hiển thị tùy chế độ, đổi bằng /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.
  • Gõ /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.
  • Gõ /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.
  • Mod đọc cấu hình .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.

Source 13 files
hooks/register.js 1144 lines
1import { 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}
1144
hooks/runtime/matcher.js 395 lines
1export 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}
395
hooks/runtime/lookup.js 99 lines
1import { 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}
99
hooks/runtime/project.js 375 lines
1// 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}
375
hooks/runtime/pack-reader.js 370 lines
1import { 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}
370
hooks/runtime/pack-format.js 123 lines
1// 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}
123
hooks/runtime/search-config.js 92 lines
1import 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}
92
hooks/runtime/js-yaml.js 3083 lines
1function 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 lines
1// 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}
127
hooks/runtime/config.js 180 lines
1// 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}
180
hooks/runtime/resolver.js 155 lines
1import { 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}
155
hooks/runtime/minisearch.js 2006 lines
1/** @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).