@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.
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/dist/index.d.ts +171 -0
- package/dist/index.js +2540 -0
- package/dist/index.js.map +1 -0
- package/dist/manifests/accordion.json +61 -0
- package/dist/manifests/alert.json +69 -0
- package/dist/manifests/avatar.json +75 -0
- package/dist/manifests/badge.json +74 -0
- package/dist/manifests/breadcrumb.json +87 -0
- package/dist/manifests/button.json +85 -0
- package/dist/manifests/card.json +91 -0
- package/dist/manifests/checkbox.json +122 -0
- package/dist/manifests/codeblock.json +63 -0
- package/dist/manifests/combobox.json +33 -0
- package/dist/manifests/dialog.json +64 -0
- package/dist/manifests/divider.json +47 -0
- package/dist/manifests/dropdown.json +105 -0
- package/dist/manifests/form.json +81 -0
- package/dist/manifests/grid.json +143 -0
- package/dist/manifests/input.json +99 -0
- package/dist/manifests/meter.json +103 -0
- package/dist/manifests/navigation.json +70 -0
- package/dist/manifests/progress.json +88 -0
- package/dist/manifests/radio.json +121 -0
- package/dist/manifests/select.json +109 -0
- package/dist/manifests/skeleton.json +101 -0
- package/dist/manifests/tab.json +88 -0
- package/dist/manifests/table.json +92 -0
- package/dist/manifests/textarea.json +117 -0
- package/dist/manifests/toast.json +157 -0
- package/dist/manifests/tooltip.json +115 -0
- package/dist/vector-index.json +14015 -0
- package/package.json +33 -0
- package/src/global.d.ts +3 -0
- package/src/index.ts +14 -0
- package/src/manifests/accordion.json +61 -0
- package/src/manifests/alert.json +69 -0
- package/src/manifests/avatar.json +75 -0
- package/src/manifests/badge.json +74 -0
- package/src/manifests/breadcrumb.json +87 -0
- package/src/manifests/button.json +85 -0
- package/src/manifests/card.json +91 -0
- package/src/manifests/checkbox.json +122 -0
- package/src/manifests/codeblock.json +63 -0
- package/src/manifests/combobox.json +33 -0
- package/src/manifests/dialog.json +64 -0
- package/src/manifests/divider.json +47 -0
- package/src/manifests/dropdown.json +105 -0
- package/src/manifests/form.json +81 -0
- package/src/manifests/grid.json +143 -0
- package/src/manifests/index.ts +49 -0
- package/src/manifests/input.json +99 -0
- package/src/manifests/meter.json +103 -0
- package/src/manifests/navigation.json +70 -0
- package/src/manifests/progress.json +88 -0
- package/src/manifests/radio.json +121 -0
- package/src/manifests/select.json +109 -0
- package/src/manifests/skeleton.json +101 -0
- package/src/manifests/tab.json +88 -0
- package/src/manifests/table.json +92 -0
- package/src/manifests/textarea.json +117 -0
- package/src/manifests/toast.json +157 -0
- package/src/manifests/tooltip.json +115 -0
- package/src/tools/build-index.ts +43 -0
- package/src/tools/check-a11y.ts +96 -0
- package/src/tools/embedder.ts +18 -0
- package/src/tools/generate-theme.ts +42 -0
- package/src/tools/get-emmet.ts +64 -0
- package/src/tools/get-manifests.ts +55 -0
- package/src/tools/handoff.ts +302 -0
- package/src/tools/intent-engine.ts +215 -0
- package/src/tools/list-components.ts +20 -0
- package/src/tools/preview.ts +186 -0
- package/src/tools/search-index.ts +82 -0
- package/src/tools/theme-palette.ts +65 -0
- package/src/tools/theme-tokens.ts +176 -0
- package/src/tools/token-counter.ts +59 -0
- package/src/tools/validator.ts +353 -0
- package/src/types.ts +63 -0
- package/src/utils/a11y-rules.ts +873 -0
- package/src/utils/a11y-types.ts +15 -0
- package/src/utils/cosine.ts +15 -0
- package/src/utils/emmet-helpers.ts +283 -0
- package/src/utils/intent-helpers.ts +66 -0
- package/src/utils/intent-parser.ts +175 -0
- package/src/utils/tokenizer.ts +7 -0
- package/tsconfig.json +17 -0
- 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
|
+
}
|
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
|
+
}
|