@mindfiredigital/ignix-lite-engine 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.turbo/turbo-build.log +22 -0
  2. package/CHANGELOG.md +7 -0
  3. package/LICENSE +21 -0
  4. package/README.md +283 -0
  5. package/dist/index.d.ts +171 -0
  6. package/dist/index.js +2540 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/manifests/accordion.json +61 -0
  9. package/dist/manifests/alert.json +69 -0
  10. package/dist/manifests/avatar.json +75 -0
  11. package/dist/manifests/badge.json +74 -0
  12. package/dist/manifests/breadcrumb.json +87 -0
  13. package/dist/manifests/button.json +85 -0
  14. package/dist/manifests/card.json +91 -0
  15. package/dist/manifests/checkbox.json +122 -0
  16. package/dist/manifests/codeblock.json +63 -0
  17. package/dist/manifests/combobox.json +33 -0
  18. package/dist/manifests/dialog.json +64 -0
  19. package/dist/manifests/divider.json +47 -0
  20. package/dist/manifests/dropdown.json +105 -0
  21. package/dist/manifests/form.json +81 -0
  22. package/dist/manifests/grid.json +143 -0
  23. package/dist/manifests/input.json +99 -0
  24. package/dist/manifests/meter.json +103 -0
  25. package/dist/manifests/navigation.json +70 -0
  26. package/dist/manifests/progress.json +88 -0
  27. package/dist/manifests/radio.json +121 -0
  28. package/dist/manifests/select.json +109 -0
  29. package/dist/manifests/skeleton.json +101 -0
  30. package/dist/manifests/tab.json +88 -0
  31. package/dist/manifests/table.json +92 -0
  32. package/dist/manifests/textarea.json +117 -0
  33. package/dist/manifests/toast.json +157 -0
  34. package/dist/manifests/tooltip.json +115 -0
  35. package/dist/vector-index.json +14015 -0
  36. package/package.json +33 -0
  37. package/src/global.d.ts +3 -0
  38. package/src/index.ts +14 -0
  39. package/src/manifests/accordion.json +61 -0
  40. package/src/manifests/alert.json +69 -0
  41. package/src/manifests/avatar.json +75 -0
  42. package/src/manifests/badge.json +74 -0
  43. package/src/manifests/breadcrumb.json +87 -0
  44. package/src/manifests/button.json +85 -0
  45. package/src/manifests/card.json +91 -0
  46. package/src/manifests/checkbox.json +122 -0
  47. package/src/manifests/codeblock.json +63 -0
  48. package/src/manifests/combobox.json +33 -0
  49. package/src/manifests/dialog.json +64 -0
  50. package/src/manifests/divider.json +47 -0
  51. package/src/manifests/dropdown.json +105 -0
  52. package/src/manifests/form.json +81 -0
  53. package/src/manifests/grid.json +143 -0
  54. package/src/manifests/index.ts +49 -0
  55. package/src/manifests/input.json +99 -0
  56. package/src/manifests/meter.json +103 -0
  57. package/src/manifests/navigation.json +70 -0
  58. package/src/manifests/progress.json +88 -0
  59. package/src/manifests/radio.json +121 -0
  60. package/src/manifests/select.json +109 -0
  61. package/src/manifests/skeleton.json +101 -0
  62. package/src/manifests/tab.json +88 -0
  63. package/src/manifests/table.json +92 -0
  64. package/src/manifests/textarea.json +117 -0
  65. package/src/manifests/toast.json +157 -0
  66. package/src/manifests/tooltip.json +115 -0
  67. package/src/tools/build-index.ts +43 -0
  68. package/src/tools/check-a11y.ts +96 -0
  69. package/src/tools/embedder.ts +18 -0
  70. package/src/tools/generate-theme.ts +42 -0
  71. package/src/tools/get-emmet.ts +64 -0
  72. package/src/tools/get-manifests.ts +55 -0
  73. package/src/tools/handoff.ts +302 -0
  74. package/src/tools/intent-engine.ts +215 -0
  75. package/src/tools/list-components.ts +20 -0
  76. package/src/tools/preview.ts +186 -0
  77. package/src/tools/search-index.ts +82 -0
  78. package/src/tools/theme-palette.ts +65 -0
  79. package/src/tools/theme-tokens.ts +176 -0
  80. package/src/tools/token-counter.ts +59 -0
  81. package/src/tools/validator.ts +353 -0
  82. package/src/types.ts +63 -0
  83. package/src/utils/a11y-rules.ts +873 -0
  84. package/src/utils/a11y-types.ts +15 -0
  85. package/src/utils/cosine.ts +15 -0
  86. package/src/utils/emmet-helpers.ts +283 -0
  87. package/src/utils/intent-helpers.ts +66 -0
  88. package/src/utils/intent-parser.ts +175 -0
  89. package/src/utils/tokenizer.ts +7 -0
  90. package/tsconfig.json +17 -0
  91. package/tsup.config.ts +10 -0
@@ -0,0 +1,15 @@
1
+ export type IssueType = 'error' | 'warning'
2
+
3
+ export interface A11yIssue {
4
+ type: IssueType
5
+ rule: string
6
+ element: string
7
+ message: string
8
+ fix: string
9
+ confidence?: number
10
+ }
11
+
12
+ export interface RuleResult {
13
+ ruleName: string
14
+ issues: A11yIssue[]
15
+ }
@@ -0,0 +1,15 @@
1
+ export function cosineSimilarity(a: number[], b: number[]): number {
2
+ let dot = 0
3
+ let magA = 0
4
+ let magB = 0
5
+
6
+ for (let i = 0; i < a.length; i++) {
7
+ dot += a[i] * b[i]
8
+ magA += a[i] * a[i]
9
+ magB += b[i] * b[i]
10
+ }
11
+
12
+ if (magA === 0 || magB === 0) return 0
13
+
14
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB))
15
+ }
@@ -0,0 +1,283 @@
1
+ import emmet from 'emmet'
2
+
3
+ // Expand Emmet shorthand to full HTML using the emmet npm package
4
+ export function expandEmmet(emmetStr: string): string {
5
+ try {
6
+ return emmet(emmetStr)
7
+ } catch {
8
+ return `<!-- expand: ${emmetStr} -->`
9
+ }
10
+ }
11
+
12
+ // Maps HTML element names / web-component tag names → ignix-lite component names
13
+ export const ELEMENT_TO_COMPONENT: Record<string, string> = {
14
+ // Native elements
15
+ button: 'button',
16
+ input: 'input',
17
+ textarea: 'textarea',
18
+ select: 'select',
19
+ form: 'form',
20
+ dialog: 'dialog',
21
+ details: 'accordion',
22
+ summary: 'accordion',
23
+ progress: 'progress',
24
+ meter: 'meter',
25
+ aside: 'alert',
26
+ mark: 'badge',
27
+ img: 'avatar',
28
+ article: 'card',
29
+ nav: 'navigation',
30
+ table: 'table',
31
+ pre: 'codeblock',
32
+ code: 'codeblock',
33
+ hr: 'divider',
34
+ label: 'input', // label wraps input/checkbox/radio
35
+ // Web components
36
+ 'ix-tabs': 'tab',
37
+ 'ix-dropdown': 'dropdown',
38
+ 'ix-combobox': 'combobox',
39
+ 'ix-tooltip': 'tooltip',
40
+ 'ix-toast': 'toast'
41
+ }
42
+
43
+ // Skeleton heuristic: span with aria-busy or data-shape
44
+ export const SKELETON_PATTERN = /span\[[^\]]*(?:aria-busy|data-shape)[^\]]*\]/i
45
+
46
+ // Given an emmet string, return the unique set of ignix-lite component names hat the pattern uses.
47
+
48
+ export function extractComponents(emmetStr: string): string[] {
49
+ const found = new Set<string>()
50
+
51
+ // Special case: skeleton uses span[aria-busy] or span[data-shape]
52
+ if (SKELETON_PATTERN.test(emmetStr)) {
53
+ found.add('skeleton')
54
+ }
55
+
56
+ // Special case: nav[aria-label=Breadcrumb] → breadcrumb
57
+ if (/nav\[[^\]]*breadcrumb/i.test(emmetStr)) {
58
+ found.add('breadcrumb')
59
+ } else if (/\bnav\b/.test(emmetStr)) {
60
+ found.add('navigation')
61
+ }
62
+
63
+ // Special case: label > input[type=checkbox] → checkbox
64
+ if (/input\[[^\]]*type=checkbox/i.test(emmetStr)) {
65
+ found.add('checkbox')
66
+ }
67
+
68
+ // Special case: label > input[type=radio] → radio
69
+ if (/input\[[^\]]*type=radio/i.test(emmetStr)) {
70
+ found.add('radio')
71
+ }
72
+
73
+ // Special case: section[data-grid] → grid (only when data-grid attr is present)
74
+ if (/section\[[^\]]*data-grid/i.test(emmetStr)) {
75
+ found.add('grid')
76
+ }
77
+
78
+ // General: extract all tag names from the emmet string
79
+ const tagPattern = /(?:^|[>+(])([a-z][a-z0-9-]*)/gi
80
+ let m: RegExpExecArray | null
81
+ while ((m = tagPattern.exec(emmetStr)) !== null) {
82
+ const tag = m[1].toLowerCase()
83
+ const component = ELEMENT_TO_COMPONENT[tag]
84
+ if (component) found.add(component)
85
+ }
86
+
87
+ return Array.from(found)
88
+ }
89
+
90
+ // Customizes standard Emmet shorthand by dynamically replacing text strings and visual intent attributes based on the user prompt description.
91
+
92
+ function injectAttribute(emmetStr: string, attr: string, value: string): string {
93
+ if (emmetStr.includes(attr)) return emmetStr
94
+ const match = emmetStr.match(/^([a-z][a-z0-9-]*)(?:\[([^\]]*)\])?/i)
95
+ if (!match) return emmetStr
96
+ const tag = match[1]
97
+ const attrs = match[2] ? `${attr}=${value} ${match[2]}` : `${attr}=${value}`
98
+ return emmetStr.replace(/^([a-z][a-z0-9-]*)(?:\[[^\]]*\])?/i, `${tag}[${attrs}]`)
99
+ }
100
+
101
+ export function interpolateEmmet(
102
+ emmetStr: string,
103
+ description: string
104
+ ): string {
105
+ let result = emmetStr
106
+ const descLower = description.toLowerCase()
107
+
108
+ let intent = 'primary'
109
+ if (/\b(red|danger|delete|remove|destroy)\b/.test(descLower)) {
110
+ intent = 'danger'
111
+ } else if (/\b(green|success|work|done)\b/.test(descLower)) {
112
+ intent = 'success'
113
+ } else if (/\b(yellow|orange|warning|risky)\b/.test(descLower)) {
114
+ intent = 'warning'
115
+ } else if (/\b(ghost|secondary)\b/.test(descLower)) {
116
+ intent = 'ghost'
117
+ } else if (/\b(neutral|gray|grey)\b/.test(descLower)) {
118
+ intent = 'neutral'
119
+ }
120
+ result = result.replace(/data-intent=\?/g, `data-intent=${intent}`)
121
+
122
+ // Only override data-intent on semantic elements, not wrappers like label
123
+ const INTENT_ELEMENTS = /^(button|aside|mark|dialog|ix-tooltip|ix-toast)/
124
+ if (intent !== 'primary') {
125
+ result = result.replace(
126
+ /data-intent=(?:primary|neutral|ghost|success|warning|danger)/g,
127
+ (match, offset) => {
128
+ // Walk backwards to find the tag name to avoid injecting on label
129
+ const before = result.slice(0, offset)
130
+ const tagMatch = before.match(/([a-z][a-z0-9-]*)\[[^\]]*$/i)
131
+ if (tagMatch && !INTENT_ELEMENTS.test(tagMatch[1])) {
132
+ return match // leave unchanged
133
+ }
134
+ return `data-intent=${intent}`
135
+ }
136
+ )
137
+ }
138
+
139
+ let size = 'md'
140
+ if (/\b(large|lg|big)\b/.test(descLower)) {
141
+ size = 'lg'
142
+ } else if (/\b(small|sm|tiny)\b/.test(descLower)) {
143
+ size = 'sm'
144
+ }
145
+ result = result.replace(/data-size=\?/g, `data-size=${size}`)
146
+
147
+ let variant = 'solid'
148
+ if (/\b(outline|ghost|link)\b/.test(descLower)) {
149
+ variant = 'outline'
150
+ }
151
+ result = result.replace(/data-variant=\?/g, `data-variant=${variant}`)
152
+
153
+ // Recognize shape keywords: rectangular → rect, circle/round → circle
154
+ if (/\b(rect|rectangular|rectangle)\b/.test(descLower)) {
155
+ result = result.replace(/data-shape=(?:text|circle|\?)/g, 'data-shape=rect')
156
+ } else if (/\b(circle|round|circular)\b/.test(descLower)) {
157
+ result = result.replace(/data-shape=(?:text|rect|\?)/g, 'data-shape=circle')
158
+ }
159
+
160
+ if (/\b(loading|spinner|busy)\b/.test(descLower)) {
161
+ result = injectAttribute(result, 'aria-busy', 'true')
162
+ }
163
+ if (/\b(disabled|inactive)\b/.test(descLower)) {
164
+ result = injectAttribute(result, 'disabled', 'true')
165
+ }
166
+
167
+ const matches = [...description.matchAll(/['"]([^'"]+)['"]/g)]
168
+ const quotedStrings = matches
169
+ .map((m) => m[1].trim())
170
+ .filter((s) => s.length > 0)
171
+
172
+ if (quotedStrings.length > 0) {
173
+ let quoteIndex = 0
174
+
175
+ result = result.replace(/content(=\?|=""|=''|(?=[\]\s]|$))/g, () => {
176
+ if (quoteIndex < quotedStrings.length) {
177
+ return `content="${quotedStrings[quoteIndex++]}"`
178
+ }
179
+ return 'content=""'
180
+ })
181
+
182
+ result = result.replace(/title(=\?|=""|=''|(?=[\]\s]|$))/g, () => {
183
+ if (quoteIndex < quotedStrings.length) {
184
+ return `title="${quotedStrings[quoteIndex++]}"`
185
+ }
186
+ return 'title=""'
187
+ })
188
+
189
+ result = result.replace(/label(=\?|=""|=''|(?=[\]\s]|$))/g, () => {
190
+ if (quoteIndex < quotedStrings.length) {
191
+ return `label="${quotedStrings[quoteIndex++]}"`
192
+ }
193
+ return 'label=""'
194
+ })
195
+
196
+ const hasButtonMention = /\b(button|says|labeled|action|click)\b/i.test(
197
+ description
198
+ )
199
+
200
+ // Don't replace button labels when the only quoted string was already consumed as content=
201
+ const remainingQuotes = quotedStrings.slice(quoteIndex)
202
+ // Also skip if the emmet already has a populated content= attribute (e.g. from tooltip template)
203
+ // — in that case the quoted string belongs to content, not the button label
204
+ const hasPopulatedContent = /content="[^"]+"/i.test(result)
205
+ const shouldReplaceButton =
206
+ !hasPopulatedContent &&
207
+ (remainingQuotes.length >= 2 ||
208
+ (remainingQuotes.length === 1 && hasButtonMention))
209
+
210
+ if (shouldReplaceButton) {
211
+ const buttonRegex = /(button[^}]*)\{([^}]+)\}/g
212
+ const buttonMatches = [...result.matchAll(buttonRegex)]
213
+
214
+ if (buttonMatches.length > 0) {
215
+ const buttonLabel = remainingQuotes[remainingQuotes.length - 1]
216
+ result = result.replace(buttonRegex, (match, p1) => {
217
+ return `${p1}{${buttonLabel}}`
218
+ })
219
+ }
220
+ }
221
+
222
+
223
+
224
+ const textPlaceholderRegex = /\{(label|message|title|text)\}/gi
225
+ result = result.replace(textPlaceholderRegex, (match) => {
226
+ if (quoteIndex < quotedStrings.length) {
227
+ return `{${quotedStrings[quoteIndex++]}}`
228
+ }
229
+ return match
230
+ })
231
+
232
+ const labelRegex = /label\{([^}]+)\}/g
233
+ result = result.replace(labelRegex, (match) => {
234
+ if (quoteIndex < quotedStrings.length) {
235
+ return `label{${quotedStrings[quoteIndex++]}}`
236
+ }
237
+ return match
238
+ })
239
+
240
+ if (/label\{username\}/i.test(result)) {
241
+ result = result.replace(/type=email/g, 'type=text')
242
+ }
243
+ } else {
244
+ const labelMap: Record<string, string> = {
245
+ delete: 'Delete',
246
+ remove: 'Remove',
247
+ destroy: 'Destroy',
248
+ danger: 'Delete',
249
+ save: 'Save',
250
+ submit: 'Submit',
251
+ cancel: 'Cancel',
252
+ confirm: 'Confirm',
253
+ search: 'Search',
254
+ download: 'Download',
255
+ upload: 'Upload',
256
+ edit: 'Edit',
257
+ add: 'Add',
258
+ create: 'Create',
259
+ update: 'Update'
260
+ }
261
+ let extractedLabel = ''
262
+ for (const [key, val] of Object.entries(labelMap)) {
263
+ if (descLower.includes(key)) {
264
+ extractedLabel = val
265
+ break
266
+ }
267
+ }
268
+ if (!extractedLabel) {
269
+ const components = extractComponents(emmetStr)
270
+ if (components.length > 0) {
271
+ extractedLabel = components[0].charAt(0).toUpperCase() + components[0].slice(1)
272
+ } else {
273
+ extractedLabel = 'Button'
274
+ }
275
+ }
276
+ result = result.replace(/\{(label|Label|message|Message|title|Title|text|Text)\}/g, `{${extractedLabel}}`)
277
+ }
278
+
279
+ result = result.replace(/alt=\?/g, 'alt="User"')
280
+ result = result.replace(/src=\?/g, 'src="?"')
281
+
282
+ return result
283
+ }
@@ -0,0 +1,66 @@
1
+ export const STOP_WORDS = new Set([
2
+ 'with',
3
+ 'for',
4
+ 'and',
5
+ 'the',
6
+ 'you',
7
+ 'can',
8
+ 'from',
9
+ 'this',
10
+ 'that',
11
+ 'your',
12
+ 'want',
13
+ 'need',
14
+ 'show',
15
+ 'give',
16
+ 'nice',
17
+ 'page',
18
+ 'here',
19
+ 'please',
20
+ 'make',
21
+ 'create',
22
+ 'build',
23
+ 'about',
24
+ 'using',
25
+ 'what',
26
+ 'should',
27
+ 'how'
28
+ ])
29
+
30
+ export function tokenise(text: string): string[] {
31
+ return text
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9\s/]/g, ' ')
34
+ .split(/[\s/]+/)
35
+ .filter((w) => w.length > 1 && !STOP_WORDS.has(w))
36
+ }
37
+
38
+ export function editDistance(s1: string, s2: string): number {
39
+ const costs: number[] = []
40
+ for (let i = 0; i <= s1.length; i++) {
41
+ let lastValue = i
42
+ for (let j = 0; j <= s2.length; j++) {
43
+ if (i === 0) {
44
+ costs[j] = j
45
+ } else {
46
+ if (j > 0) {
47
+ let newValue = costs[j - 1]
48
+ if (s1.charAt(i - 1) !== s2.charAt(j - 1)) {
49
+ newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1
50
+ }
51
+ costs[j - 1] = lastValue
52
+ lastValue = newValue
53
+ }
54
+ }
55
+ }
56
+ if (i > 0) costs[s2.length] = lastValue
57
+ }
58
+ return costs[s2.length]
59
+ }
60
+
61
+ export function isSimilar(w1: string, w2: string): boolean {
62
+ if (w1.length < 3 || w2.length < 3) return w1 === w2
63
+ // Typo tolerance: 1 edit for 3-5 chars, 2 edits for 6+ chars
64
+ const maxDistance = w1.length >= 6 && w2.length >= 6 ? 2 : 1
65
+ return editDistance(w1, w2) <= maxDistance
66
+ }
@@ -0,0 +1,175 @@
1
+ import { readFileSync, existsSync } from 'fs'
2
+ import path from 'path'
3
+ import { fileURLToPath } from 'url'
4
+ import { tokenise, isSimilar } from './intent-helpers.js'
5
+
6
+ export type IntentEntry = {
7
+ name: string
8
+ phrases: string[]
9
+ emmet: string
10
+ category: string
11
+ }
12
+
13
+
14
+ function parseIntents(raw: string): IntentEntry[] {
15
+ const entries: IntentEntry[] = []
16
+
17
+ // Find where the INTENTS block starts
18
+ const intentStart = raw.indexOf('INTENTS:')
19
+ if (intentStart === -1) return entries
20
+
21
+ const block = raw.slice(intentStart)
22
+ const lines = block.split(/\r?\n/)
23
+
24
+ let currentCategory = 'GENERAL'
25
+ let pendingPhrase: string | null = null
26
+
27
+ for (const raw_line of lines) {
28
+ const line = raw_line.trim()
29
+
30
+ // Category header: --- DESTROY / DANGER ---
31
+ if (line.startsWith('---') && line.endsWith('---')) {
32
+ currentCategory = line
33
+ .replace(/^-+\s*/, '')
34
+ .replace(/\s*-+$/, '')
35
+ .trim()
36
+ pendingPhrase = null
37
+ continue
38
+ }
39
+
40
+ // Skip meta comment line [caveman prompt → emmet...]
41
+ if (line.startsWith('[') && line.endsWith(']')) continue
42
+
43
+ // Skip NEVER DO section (starts with "never use")
44
+ if (line.startsWith('never ')) continue
45
+
46
+ // Skip blank lines and "→" alias lines (these are in NEVER DO)
47
+ if (line === '' || line.startsWith('→')) continue
48
+
49
+ // Emmet line: starts with "- "
50
+ if (line.startsWith('- ') && pendingPhrase !== null) {
51
+ const emmet = line.slice(2).trim()
52
+ entries.push({
53
+ name: pendingPhrase,
54
+ phrases: tokenise(pendingPhrase),
55
+ emmet,
56
+ category: currentCategory
57
+ })
58
+ pendingPhrase = null
59
+ continue
60
+ }
61
+
62
+ // Anything else is a caveman phrase (intent description)
63
+ if (!line.startsWith('-')) {
64
+ pendingPhrase = line
65
+ }
66
+ }
67
+
68
+ return entries
69
+ }
70
+
71
+ // ---- Resolve the api-full.txt path relative to this package root ----
72
+ function loadIntents(): IntentEntry[] {
73
+ try {
74
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
75
+
76
+ // Try relative paths from current file structure (packages/mcp/src/utils/ or dist/utils/)
77
+ const path1 = path.resolve(currentDir, '../../../api-full.txt')
78
+ const path2 = path.resolve(currentDir, '../../api-full.txt')
79
+ const path3 = path.resolve(currentDir, '../../../../api-full.txt')
80
+
81
+ let txtPath = path1
82
+ if (existsSync(path1)) {
83
+ txtPath = path1
84
+ } else if (existsSync(path2)) {
85
+ txtPath = path2
86
+ } else if (existsSync(path3)) {
87
+ txtPath = path3
88
+ } else {
89
+ txtPath = path.resolve(process.cwd(), '../../api-full.txt')
90
+ }
91
+
92
+ const raw = readFileSync(txtPath, 'utf8')
93
+ return parseIntents(raw)
94
+ } catch {
95
+ return []
96
+ }
97
+ }
98
+
99
+ // Singleton - parsed once at server startup, stays in-memory (DC-13)
100
+ let _cache: IntentEntry[] | null = null
101
+
102
+ export function getIntentEntries(): IntentEntry[] {
103
+ if (!_cache) {
104
+ _cache = loadIntents()
105
+ }
106
+ return _cache
107
+ }
108
+
109
+ export type ScoreResult = {
110
+ score: number
111
+ density: number
112
+ }
113
+
114
+ // Score a single IntentEntry against the query, tolerating minor typos. eturns { score, density } for tie-breaking.
115
+
116
+ export function scoreEntry(
117
+ entry: IntentEntry,
118
+ queryWords: string[]
119
+ ): ScoreResult {
120
+ let score = 0
121
+ const entryWords = new Set(entry.phrases)
122
+ let matchedCount = 0
123
+
124
+ for (const qw of queryWords) {
125
+ let matched = false
126
+ // 1. Exact match (+10)
127
+ if (entryWords.has(qw)) {
128
+ score += 10
129
+ matched = true
130
+ } else {
131
+ // 2. Fuzzy spelling similarity match (+8)
132
+ for (const ew of entryWords) {
133
+ if (isSimilar(qw, ew)) {
134
+ score += 8
135
+ matched = true
136
+ break
137
+ }
138
+ }
139
+ // 3. Substring match fallback (+4)
140
+ if (!matched) {
141
+ for (const ew of entryWords) {
142
+ if (ew.includes(qw) || qw.includes(ew)) {
143
+ score += 4
144
+ matched = true
145
+ break
146
+ }
147
+ }
148
+ }
149
+ }
150
+ if (matched) {
151
+ matchedCount++
152
+ }
153
+ }
154
+
155
+ // Bonus: category keyword overlap (supports fuzzy spelling matches)
156
+ const catWords = tokenise(entry.category)
157
+ for (const qw of queryWords) {
158
+ if (catWords.includes(qw)) {
159
+ score += 3
160
+ } else {
161
+ for (const cw of catWords) {
162
+ if (isSimilar(qw, cw)) {
163
+ score += 2
164
+ break
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ // Density = portion of the entry's phrases that were matched by the query
171
+ const density =
172
+ entry.phrases.length > 0 ? matchedCount / entry.phrases.length : 0
173
+
174
+ return { score, density }
175
+ }
@@ -0,0 +1,7 @@
1
+ import { encoding_for_model } from 'tiktoken'
2
+
3
+ const encoder = encoding_for_model('gpt-4')
4
+
5
+ export function getTokenCount(input: string): number {
6
+ return encoder.encode(input).length
7
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "target": "ES2022",
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "resolveJsonModule": true,
8
+ "esModuleInterop": true,
9
+ "strict": true,
10
+ "outDir": "./dist",
11
+ "composite": false
12
+ },
13
+ "include": [
14
+ "src/**/*",
15
+ "src/**/*.json"
16
+ ]
17
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'tsup'
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm'],
6
+ dts: true,
7
+ clean: true,
8
+ sourcemap: true,
9
+ minify: false
10
+ })