@mindfiredigital/ignix-lite-mcp 1.2.0 → 1.3.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/README.md +108 -0
- package/dist/server.js +187 -1954
- package/dist/server.js.map +1 -1
- package/dist/utils/check-api.js +2 -0
- package/dist/utils/check-api.js.map +1 -1
- package/package.json +14 -6
- package/.turbo/turbo-build.log +0 -25
- package/CHANGELOG.md +0 -7
- package/dist/manifests/accordion.json +0 -61
- package/dist/manifests/alert.json +0 -69
- package/dist/manifests/avatar.json +0 -75
- package/dist/manifests/badge.json +0 -74
- package/dist/manifests/breadcrumb.json +0 -87
- package/dist/manifests/button.json +0 -85
- package/dist/manifests/card.json +0 -91
- package/dist/manifests/checkbox.json +0 -122
- package/dist/manifests/codeblock.json +0 -63
- package/dist/manifests/combobox.json +0 -33
- package/dist/manifests/dialog.json +0 -64
- package/dist/manifests/divider.json +0 -47
- package/dist/manifests/dropdown.json +0 -105
- package/dist/manifests/form.json +0 -81
- package/dist/manifests/grid.json +0 -143
- package/dist/manifests/input.json +0 -99
- package/dist/manifests/meter.json +0 -103
- package/dist/manifests/navigation.json +0 -70
- package/dist/manifests/progress.json +0 -88
- package/dist/manifests/radio.json +0 -121
- package/dist/manifests/select.json +0 -109
- package/dist/manifests/skeleton.json +0 -101
- package/dist/manifests/tab.json +0 -88
- package/dist/manifests/table.json +0 -92
- package/dist/manifests/textarea.json +0 -117
- package/dist/manifests/toast.json +0 -157
- package/dist/manifests/tooltip.json +0 -115
- package/dist/vector-index.json +0 -14015
- package/src/context/api-context.ts +0 -14
- package/src/global.d.ts +0 -15
- package/src/manifests/accordion.json +0 -61
- package/src/manifests/alert.json +0 -69
- package/src/manifests/avatar.json +0 -75
- package/src/manifests/badge.json +0 -74
- package/src/manifests/breadcrumb.json +0 -87
- package/src/manifests/button.json +0 -85
- package/src/manifests/card.json +0 -91
- package/src/manifests/checkbox.json +0 -122
- package/src/manifests/codeblock.json +0 -63
- package/src/manifests/combobox.json +0 -33
- package/src/manifests/dialog.json +0 -64
- package/src/manifests/divider.json +0 -47
- package/src/manifests/dropdown.json +0 -105
- package/src/manifests/form.json +0 -81
- package/src/manifests/grid.json +0 -143
- package/src/manifests/index.ts +0 -45
- package/src/manifests/input.json +0 -99
- package/src/manifests/meter.json +0 -103
- package/src/manifests/navigation.json +0 -70
- package/src/manifests/progress.json +0 -88
- package/src/manifests/radio.json +0 -121
- package/src/manifests/select.json +0 -109
- package/src/manifests/skeleton.json +0 -101
- package/src/manifests/tab.json +0 -88
- package/src/manifests/table.json +0 -92
- package/src/manifests/textarea.json +0 -117
- package/src/manifests/toast.json +0 -157
- package/src/manifests/tooltip.json +0 -115
- package/src/server.ts +0 -201
- package/src/tools/build-index.ts +0 -55
- package/src/tools/check-a11y.ts +0 -106
- package/src/tools/embedder.ts +0 -18
- package/src/tools/generate-theme.ts +0 -42
- package/src/tools/get-emmet.ts +0 -64
- package/src/tools/get-manifests.ts +0 -55
- package/src/tools/intent-engine.ts +0 -197
- package/src/tools/list-components.ts +0 -20
- package/src/tools/search-index.ts +0 -66
- package/src/tools/theme-palette.ts +0 -65
- package/src/tools/theme-tokens.ts +0 -176
- package/src/tools/validator.ts +0 -367
- package/src/types.ts +0 -63
- package/src/utils/a11y-rules.ts +0 -873
- package/src/utils/a11y-types.ts +0 -15
- package/src/utils/check-api.ts +0 -13
- package/src/utils/cosine.ts +0 -15
- package/src/utils/emmet-helpers.ts +0 -171
- package/src/utils/intent-helpers.ts +0 -66
- package/src/utils/intent-parser.ts +0 -186
- package/src/utils/tokenizer.ts +0 -7
- package/tsconfig.json +0 -14
- package/tsup.config.ts +0 -13
package/dist/server.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
1
3
|
// src/server.ts
|
|
2
4
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -6,1954 +8,33 @@ import {
|
|
|
6
8
|
CallToolRequestSchema
|
|
7
9
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
8
10
|
|
|
9
|
-
// src/
|
|
10
|
-
import { readFileSync } from "fs";
|
|
11
|
+
// src/context/api-context.ts
|
|
12
|
+
import { readFileSync, existsSync } from "fs";
|
|
11
13
|
import path from "path";
|
|
12
14
|
import { fileURLToPath } from "url";
|
|
13
|
-
|
|
14
|
-
// src/utils/tokenizer.ts
|
|
15
|
-
import { encoding_for_model } from "tiktoken";
|
|
16
|
-
var encoder = encoding_for_model("gpt-4");
|
|
17
|
-
function getTokenCount(input) {
|
|
18
|
-
return encoder.encode(input).length;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// src/manifests/index.ts
|
|
22
15
|
var __filename = fileURLToPath(import.meta.url);
|
|
23
16
|
var __dirname = path.dirname(__filename);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
manifest.tokens = getTokenCount(manifest.emmet);
|
|
28
|
-
return manifest;
|
|
29
|
-
}
|
|
30
|
-
var manifests = {
|
|
31
|
-
accordion: loadManifest("accordion.json"),
|
|
32
|
-
alert: loadManifest("alert.json"),
|
|
33
|
-
avatar: loadManifest("avatar.json"),
|
|
34
|
-
badge: loadManifest("badge.json"),
|
|
35
|
-
breadcrumb: loadManifest("breadcrumb.json"),
|
|
36
|
-
button: loadManifest("button.json"),
|
|
37
|
-
card: loadManifest("card.json"),
|
|
38
|
-
checkbox: loadManifest("checkbox.json"),
|
|
39
|
-
codeblock: loadManifest("codeblock.json"),
|
|
40
|
-
combobox: loadManifest("combobox.json"),
|
|
41
|
-
dialog: loadManifest("dialog.json"),
|
|
42
|
-
divider: loadManifest("divider.json"),
|
|
43
|
-
dropdown: loadManifest("dropdown.json"),
|
|
44
|
-
form: loadManifest("form.json"),
|
|
45
|
-
grid: loadManifest("grid.json"),
|
|
46
|
-
input: loadManifest("input.json"),
|
|
47
|
-
meter: loadManifest("meter.json"),
|
|
48
|
-
navigation: loadManifest("navigation.json"),
|
|
49
|
-
progress: loadManifest("progress.json"),
|
|
50
|
-
radio: loadManifest("radio.json"),
|
|
51
|
-
select: loadManifest("select.json"),
|
|
52
|
-
skeleton: loadManifest("skeleton.json"),
|
|
53
|
-
tab: loadManifest("tab.json"),
|
|
54
|
-
table: loadManifest("table.json"),
|
|
55
|
-
textarea: loadManifest("textarea.json"),
|
|
56
|
-
toast: loadManifest("toast.json"),
|
|
57
|
-
tooltip: loadManifest("tooltip.json")
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
// src/tools/list-components.ts
|
|
61
|
-
function listComponents() {
|
|
62
|
-
const components = Object.keys(manifests).sort();
|
|
63
|
-
return {
|
|
64
|
-
content: [
|
|
65
|
-
{
|
|
66
|
-
type: "text",
|
|
67
|
-
text: JSON.stringify({
|
|
68
|
-
components,
|
|
69
|
-
count: components.length,
|
|
70
|
-
tokens_used: components.length
|
|
71
|
-
})
|
|
72
|
-
}
|
|
73
|
-
]
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// src/tools/get-manifests.ts
|
|
78
|
-
import { z } from "zod";
|
|
79
|
-
var schema = z.object({
|
|
80
|
-
name: z.string()
|
|
81
|
-
});
|
|
82
|
-
function getManifest(args) {
|
|
83
|
-
const parsed = schema.safeParse(args);
|
|
84
|
-
if (!parsed.success) {
|
|
85
|
-
return {
|
|
86
|
-
content: [
|
|
87
|
-
{
|
|
88
|
-
type: "text",
|
|
89
|
-
text: JSON.stringify({
|
|
90
|
-
error: "Invalid input",
|
|
91
|
-
suggestion: "Expected { name: string }",
|
|
92
|
-
tokens_used: 2
|
|
93
|
-
})
|
|
94
|
-
}
|
|
95
|
-
]
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
const { name } = parsed.data;
|
|
99
|
-
const manifest = manifests[name];
|
|
100
|
-
if (!manifest) {
|
|
101
|
-
return {
|
|
102
|
-
content: [
|
|
103
|
-
{
|
|
104
|
-
type: "text",
|
|
105
|
-
text: JSON.stringify({
|
|
106
|
-
error: `Unknown component: ${name}`,
|
|
107
|
-
suggestion: "Call list_components() first",
|
|
108
|
-
tokens_used: 2
|
|
109
|
-
})
|
|
110
|
-
}
|
|
111
|
-
]
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
return {
|
|
115
|
-
content: [
|
|
116
|
-
{
|
|
117
|
-
type: "text",
|
|
118
|
-
text: JSON.stringify(manifest)
|
|
119
|
-
}
|
|
120
|
-
]
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// src/tools/get-emmet.ts
|
|
125
|
-
import { z as z2 } from "zod";
|
|
126
|
-
var schema2 = z2.object({
|
|
127
|
-
name: z2.string()
|
|
128
|
-
});
|
|
129
|
-
function getEmmet(args) {
|
|
130
|
-
const parsed = schema2.safeParse(args);
|
|
131
|
-
if (!parsed.success) {
|
|
132
|
-
return {
|
|
133
|
-
content: [
|
|
134
|
-
{
|
|
135
|
-
type: "text",
|
|
136
|
-
text: JSON.stringify({
|
|
137
|
-
error: "Invalid input",
|
|
138
|
-
suggestion: "Expected { name: string }",
|
|
139
|
-
tokens_used: 2
|
|
140
|
-
})
|
|
141
|
-
}
|
|
142
|
-
]
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
const { name } = parsed.data;
|
|
146
|
-
const manifest = manifests[name];
|
|
147
|
-
if (!manifest) {
|
|
148
|
-
return {
|
|
149
|
-
content: [
|
|
150
|
-
{
|
|
151
|
-
type: "text",
|
|
152
|
-
text: JSON.stringify({
|
|
153
|
-
error: `Unknown component: ${name}`,
|
|
154
|
-
suggestion: "Call list_components() first",
|
|
155
|
-
tokens_used: 2
|
|
156
|
-
})
|
|
157
|
-
}
|
|
158
|
-
]
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
return {
|
|
162
|
-
content: [
|
|
163
|
-
{
|
|
164
|
-
type: "text",
|
|
165
|
-
text: JSON.stringify({
|
|
166
|
-
component: manifest.component,
|
|
167
|
-
emmet: manifest.emmet,
|
|
168
|
-
tokens: manifest.tokens,
|
|
169
|
-
tokens_used: 3
|
|
170
|
-
})
|
|
171
|
-
}
|
|
172
|
-
]
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// src/tools/validator.ts
|
|
177
|
-
import { parse } from "node-html-parser";
|
|
178
|
-
function validate(html) {
|
|
179
|
-
const root = parse(html);
|
|
180
|
-
const errors = [];
|
|
181
|
-
const elements = root.querySelectorAll("*");
|
|
182
|
-
const allowedWrappers = [
|
|
183
|
-
"label",
|
|
184
|
-
"span",
|
|
185
|
-
"p",
|
|
186
|
-
"img",
|
|
187
|
-
"small",
|
|
188
|
-
"h1",
|
|
189
|
-
"h2",
|
|
190
|
-
"h3",
|
|
191
|
-
"a",
|
|
192
|
-
"button",
|
|
193
|
-
"ul",
|
|
194
|
-
"li",
|
|
195
|
-
"thead",
|
|
196
|
-
"tbody",
|
|
197
|
-
"tr",
|
|
198
|
-
"td",
|
|
199
|
-
"th",
|
|
200
|
-
"summary"
|
|
201
|
-
];
|
|
202
|
-
const nativeAttributes = /* @__PURE__ */ new Set([
|
|
203
|
-
"id",
|
|
204
|
-
"role",
|
|
205
|
-
"slot",
|
|
206
|
-
"tabindex",
|
|
207
|
-
"aria-label",
|
|
208
|
-
"aria-live",
|
|
209
|
-
"aria-hidden",
|
|
210
|
-
"aria-expanded",
|
|
211
|
-
"aria-selected",
|
|
212
|
-
"aria-current",
|
|
213
|
-
"aria-sort",
|
|
214
|
-
"aria-invalid",
|
|
215
|
-
"aria-describedby",
|
|
216
|
-
"aria-labelledby",
|
|
217
|
-
"aria-haspopup",
|
|
218
|
-
"aria-busy",
|
|
219
|
-
"disabled",
|
|
220
|
-
"required",
|
|
221
|
-
"checked",
|
|
222
|
-
"multiple",
|
|
223
|
-
"readonly",
|
|
224
|
-
"type",
|
|
225
|
-
"value",
|
|
226
|
-
"placeholder",
|
|
227
|
-
"name",
|
|
228
|
-
"min",
|
|
229
|
-
"max",
|
|
230
|
-
"low",
|
|
231
|
-
"high",
|
|
232
|
-
"optimum",
|
|
233
|
-
"rows",
|
|
234
|
-
"open",
|
|
235
|
-
"hidden",
|
|
236
|
-
"href",
|
|
237
|
-
"content",
|
|
238
|
-
"is",
|
|
239
|
-
"src",
|
|
240
|
-
"alt",
|
|
241
|
-
"data-intent",
|
|
242
|
-
"data-position",
|
|
243
|
-
"data-variant",
|
|
244
|
-
"data-shape",
|
|
245
|
-
"data-sortable",
|
|
246
|
-
"data-open",
|
|
247
|
-
"data-lines",
|
|
248
|
-
"data-grid",
|
|
249
|
-
"data-gap",
|
|
250
|
-
"data-align",
|
|
251
|
-
"data-justify",
|
|
252
|
-
"data-col",
|
|
253
|
-
"data-row",
|
|
254
|
-
"data-dense"
|
|
255
|
-
]);
|
|
256
|
-
for (const el of elements) {
|
|
257
|
-
const tag = el.tagName.toLowerCase();
|
|
258
|
-
const attrs = el.attributes;
|
|
259
|
-
let manifestKey = tag.startsWith("ix-") ? tag.slice(3) : tag;
|
|
260
|
-
if (tag === "table" && attrs.is === "ix-table") {
|
|
261
|
-
manifestKey = "table";
|
|
262
|
-
}
|
|
263
|
-
if (tag === "nav") {
|
|
264
|
-
manifestKey = "navigation";
|
|
265
|
-
}
|
|
266
|
-
if (tag === "article" && el.querySelector("[slot]")) {
|
|
267
|
-
manifestKey = "card";
|
|
268
|
-
}
|
|
269
|
-
let manifest = manifests[manifestKey];
|
|
270
|
-
if (!manifest) {
|
|
271
|
-
if (tag === "mark") {
|
|
272
|
-
manifest = manifests.badge;
|
|
273
|
-
}
|
|
274
|
-
if (tag === "span" && attrs.role === "status") {
|
|
275
|
-
manifest = manifests.badge;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
if (!manifest && !allowedWrappers.includes(tag)) {
|
|
279
|
-
errors.push({
|
|
280
|
-
element: tag,
|
|
281
|
-
prop: "",
|
|
282
|
-
type: "WRONG_ELEMENT",
|
|
283
|
-
message: `<${tag}> is not a valid ignix-lite component`,
|
|
284
|
-
fix: `<button>Fix me</button>`,
|
|
285
|
-
confidence: 0.7
|
|
286
|
-
});
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
if (!manifest) continue;
|
|
290
|
-
const elementName = tag.startsWith("ix-") ? tag : manifest.element || tag;
|
|
291
|
-
if (manifest.required_wrapper) {
|
|
292
|
-
const parent = el.parentNode;
|
|
293
|
-
if (!parent || parent.tagName?.toLowerCase() !== manifest.required_wrapper) {
|
|
294
|
-
errors.push({
|
|
295
|
-
element: tag,
|
|
296
|
-
prop: "wrapper",
|
|
297
|
-
type: "MISSING_REQUIRED",
|
|
298
|
-
message: `<${tag}> must be inside <${manifest.required_wrapper}>`,
|
|
299
|
-
fix: `<${manifest.required_wrapper}><${elementName}></${elementName}></${manifest.required_wrapper}>`,
|
|
300
|
-
confidence: 0.95
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
if (tag === "span" && manifest.component === "badge" && attrs.role !== "status") {
|
|
305
|
-
errors.push({
|
|
306
|
-
element: tag,
|
|
307
|
-
prop: "role",
|
|
308
|
-
type: "INVALID_VALUE",
|
|
309
|
-
message: "span badge must have role=status",
|
|
310
|
-
fix: `<span role="status">${el.innerText}</span>`,
|
|
311
|
-
confidence: 0.9
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
if ("class" in attrs) {
|
|
315
|
-
errors.push({
|
|
316
|
-
element: tag,
|
|
317
|
-
prop: "class",
|
|
318
|
-
type: "FORBIDDEN_CLASS",
|
|
319
|
-
message: "class attribute not allowed",
|
|
320
|
-
fix: `<${elementName}>${el.innerText}</${elementName}>`,
|
|
321
|
-
confidence: 0.99
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
if (Object.keys(attrs).length > 4) {
|
|
325
|
-
errors.push({
|
|
326
|
-
element: tag,
|
|
327
|
-
prop: "multiple",
|
|
328
|
-
type: "PROP_EXPLOSION",
|
|
329
|
-
message: "Too many props",
|
|
330
|
-
fix: `<${elementName}></${elementName}>`,
|
|
331
|
-
confidence: 0.85
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
for (const attr of Object.keys(attrs)) {
|
|
335
|
-
if (attr.startsWith("on")) {
|
|
336
|
-
errors.push({
|
|
337
|
-
element: tag,
|
|
338
|
-
prop: attr,
|
|
339
|
-
type: "JS_ON_CSS_COMPONENT",
|
|
340
|
-
message: "JS handlers forbidden",
|
|
341
|
-
fix: `<${elementName}></${elementName}>`,
|
|
342
|
-
confidence: 0.95
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
for (const attr of Object.keys(attrs)) {
|
|
347
|
-
if (manifest.forbidden_props?.includes(attr)) {
|
|
348
|
-
errors.push({
|
|
349
|
-
element: tag,
|
|
350
|
-
prop: attr,
|
|
351
|
-
type: "UNKNOWN_ATTRIBUTE",
|
|
352
|
-
message: `'${attr}' forbidden`,
|
|
353
|
-
fix: `<${elementName}></${elementName}>`,
|
|
354
|
-
confidence: 0.98
|
|
355
|
-
});
|
|
356
|
-
continue;
|
|
357
|
-
}
|
|
358
|
-
if (!manifest.props?.[attr] && !nativeAttributes.has(attr)) {
|
|
359
|
-
errors.push({
|
|
360
|
-
element: tag,
|
|
361
|
-
prop: attr,
|
|
362
|
-
type: "UNKNOWN_ATTRIBUTE",
|
|
363
|
-
message: `'${attr}' invalid`,
|
|
364
|
-
fix: `<${elementName}></${elementName}>`,
|
|
365
|
-
confidence: 0.95
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
for (const attr of Object.keys(attrs)) {
|
|
370
|
-
const def = manifest.props?.[attr];
|
|
371
|
-
if (def?.values) {
|
|
372
|
-
const value = attrs[attr];
|
|
373
|
-
if (!def.values.includes(value)) {
|
|
374
|
-
errors.push({
|
|
375
|
-
element: tag,
|
|
376
|
-
prop: attr,
|
|
377
|
-
type: "INVALID_VALUE",
|
|
378
|
-
message: `'${value}' invalid`,
|
|
379
|
-
valid_values: def.values,
|
|
380
|
-
fix: `<${elementName} ${attr}="${def.values[0]}"></${elementName}>`,
|
|
381
|
-
confidence: 0.97
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
for (const req of manifest.required_props || []) {
|
|
387
|
-
if (!(req in attrs)) {
|
|
388
|
-
errors.push({
|
|
389
|
-
element: tag,
|
|
390
|
-
prop: req,
|
|
391
|
-
type: "MISSING_REQUIRED",
|
|
392
|
-
message: `Missing ${req}`,
|
|
393
|
-
fix: `<${elementName} ${req}=""></${elementName}>`,
|
|
394
|
-
confidence: 0.9
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
const slots = manifest.slots ?? {};
|
|
399
|
-
for (const [slotName, slotDef] of Object.entries(slots)) {
|
|
400
|
-
const child = el.querySelector(`[slot="${slotName}"]`);
|
|
401
|
-
if (slotDef.required && !child) {
|
|
402
|
-
errors.push({
|
|
403
|
-
element: tag,
|
|
404
|
-
prop: slotName,
|
|
405
|
-
type: "MISSING_SLOT",
|
|
406
|
-
message: `Missing slot ${slotName}`,
|
|
407
|
-
fix: `<${elementName}><span slot="${slotName}"></span></${elementName}>`,
|
|
408
|
-
confidence: 0.95
|
|
409
|
-
});
|
|
410
|
-
}
|
|
411
|
-
if (child && slotDef.element) {
|
|
412
|
-
const childTag = child.tagName.toLowerCase();
|
|
413
|
-
if (!slotDef.element.includes(childTag)) {
|
|
414
|
-
errors.push({
|
|
415
|
-
element: childTag,
|
|
416
|
-
prop: slotName,
|
|
417
|
-
type: "INVALID_VALUE",
|
|
418
|
-
message: `${childTag} invalid for slot ${slotName}`,
|
|
419
|
-
valid_values: slotDef.element,
|
|
420
|
-
fix: `<${slotDef.element[0]} slot="${slotName}"></${slotDef.element[0]}>`,
|
|
421
|
-
confidence: 0.95
|
|
422
|
-
});
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
const valid = errors.length === 0;
|
|
428
|
-
const score = valid ? 100 : Math.max(0, 100 - errors.length * 10);
|
|
429
|
-
return {
|
|
430
|
-
content: [
|
|
431
|
-
{
|
|
432
|
-
type: "text",
|
|
433
|
-
text: JSON.stringify({
|
|
434
|
-
valid,
|
|
435
|
-
score,
|
|
436
|
-
errors,
|
|
437
|
-
tokens_used: 50
|
|
438
|
-
})
|
|
439
|
-
}
|
|
440
|
-
]
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// src/tools/search-index.ts
|
|
445
|
-
import path2 from "path";
|
|
446
|
-
import { readFileSync as readFileSync2, existsSync } from "fs";
|
|
447
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
448
|
-
|
|
449
|
-
// src/tools/embedder.ts
|
|
450
|
-
var VOCAB_SIZE = 512;
|
|
451
|
-
function embedText(text) {
|
|
452
|
-
const vector = new Array(VOCAB_SIZE).fill(0);
|
|
453
|
-
const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
|
|
454
|
-
words.forEach((word) => {
|
|
455
|
-
let hash = 0;
|
|
456
|
-
for (let i = 0; i < word.length; i++) {
|
|
457
|
-
hash = (hash * 31 + word.charCodeAt(i)) % VOCAB_SIZE;
|
|
458
|
-
}
|
|
459
|
-
vector[hash] += 1;
|
|
460
|
-
});
|
|
461
|
-
return vector;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// src/utils/cosine.ts
|
|
465
|
-
function cosineSimilarity(a, b) {
|
|
466
|
-
let dot = 0;
|
|
467
|
-
let magA = 0;
|
|
468
|
-
let magB = 0;
|
|
469
|
-
for (let i = 0; i < a.length; i++) {
|
|
470
|
-
dot += a[i] * b[i];
|
|
471
|
-
magA += a[i] * a[i];
|
|
472
|
-
magB += b[i] * b[i];
|
|
473
|
-
}
|
|
474
|
-
if (magA === 0 || magB === 0) return 0;
|
|
475
|
-
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// src/tools/search-index.ts
|
|
479
|
-
var __filename2 = fileURLToPath2(import.meta.url);
|
|
480
|
-
var __dirname2 = path2.dirname(__filename2);
|
|
481
|
-
var _index = null;
|
|
482
|
-
function loadIndex() {
|
|
483
|
-
if (_index) return _index;
|
|
484
|
-
const indexPath = path2.resolve(__dirname2, "../../dist/vector-index.json");
|
|
485
|
-
if (!existsSync(indexPath)) {
|
|
486
|
-
console.warn(
|
|
487
|
-
`[search-index] dist/vector-index.json not found at: ${indexPath} - run pnpm build:index`
|
|
488
|
-
);
|
|
489
|
-
_index = [];
|
|
490
|
-
return _index;
|
|
491
|
-
}
|
|
492
|
-
try {
|
|
493
|
-
_index = JSON.parse(readFileSync2(indexPath, "utf8"));
|
|
494
|
-
} catch (err) {
|
|
495
|
-
console.error(`[search-index] Failed to load index from ${indexPath}:`, err);
|
|
496
|
-
_index = [];
|
|
497
|
-
}
|
|
498
|
-
return _index;
|
|
499
|
-
}
|
|
500
|
-
function searchIndex(description) {
|
|
501
|
-
const index = loadIndex();
|
|
502
|
-
const queryEmbedding = embedText(description);
|
|
503
|
-
const words = description.toLowerCase().split(/\s+/);
|
|
504
|
-
const ranked = index.map((item) => {
|
|
505
|
-
const similarity = cosineSimilarity(queryEmbedding, item.embedding);
|
|
506
|
-
let boost = 0;
|
|
507
|
-
const searchable = item.searchable.toLowerCase();
|
|
508
|
-
words.forEach((word) => {
|
|
509
|
-
if (item.name.toLowerCase() === word) {
|
|
510
|
-
boost += 2;
|
|
511
|
-
} else if (item.name.toLowerCase().includes(word)) {
|
|
512
|
-
boost += 1;
|
|
513
|
-
}
|
|
514
|
-
if (searchable.includes(word)) {
|
|
515
|
-
boost += 0.3;
|
|
516
|
-
}
|
|
517
|
-
});
|
|
518
|
-
return {
|
|
519
|
-
...item,
|
|
520
|
-
score: similarity + boost
|
|
521
|
-
};
|
|
522
|
-
}).sort((a, b) => b.score - a.score);
|
|
523
|
-
return ranked.slice(0, 3);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// src/utils/intent-parser.ts
|
|
527
|
-
import { readFileSync as readFileSync3, existsSync as existsSync2 } from "fs";
|
|
528
|
-
import path3 from "path";
|
|
529
|
-
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
530
|
-
|
|
531
|
-
// src/utils/intent-helpers.ts
|
|
532
|
-
var STOP_WORDS = /* @__PURE__ */ new Set([
|
|
533
|
-
"with",
|
|
534
|
-
"for",
|
|
535
|
-
"and",
|
|
536
|
-
"the",
|
|
537
|
-
"you",
|
|
538
|
-
"can",
|
|
539
|
-
"from",
|
|
540
|
-
"this",
|
|
541
|
-
"that",
|
|
542
|
-
"your",
|
|
543
|
-
"want",
|
|
544
|
-
"need",
|
|
545
|
-
"show",
|
|
546
|
-
"give",
|
|
547
|
-
"nice",
|
|
548
|
-
"page",
|
|
549
|
-
"here",
|
|
550
|
-
"please",
|
|
551
|
-
"make",
|
|
552
|
-
"create",
|
|
553
|
-
"build",
|
|
554
|
-
"about",
|
|
555
|
-
"using",
|
|
556
|
-
"what",
|
|
557
|
-
"should",
|
|
558
|
-
"how"
|
|
559
|
-
]);
|
|
560
|
-
function tokenise(text) {
|
|
561
|
-
return text.toLowerCase().replace(/[^a-z0-9\s/]/g, " ").split(/[\s/]+/).filter((w) => w.length > 1 && !STOP_WORDS.has(w));
|
|
562
|
-
}
|
|
563
|
-
function editDistance(s1, s2) {
|
|
564
|
-
const costs = [];
|
|
565
|
-
for (let i = 0; i <= s1.length; i++) {
|
|
566
|
-
let lastValue = i;
|
|
567
|
-
for (let j = 0; j <= s2.length; j++) {
|
|
568
|
-
if (i === 0) {
|
|
569
|
-
costs[j] = j;
|
|
570
|
-
} else {
|
|
571
|
-
if (j > 0) {
|
|
572
|
-
let newValue = costs[j - 1];
|
|
573
|
-
if (s1.charAt(i - 1) !== s2.charAt(j - 1)) {
|
|
574
|
-
newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1;
|
|
575
|
-
}
|
|
576
|
-
costs[j - 1] = lastValue;
|
|
577
|
-
lastValue = newValue;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
if (i > 0) costs[s2.length] = lastValue;
|
|
582
|
-
}
|
|
583
|
-
return costs[s2.length];
|
|
584
|
-
}
|
|
585
|
-
function isSimilar(w1, w2) {
|
|
586
|
-
if (w1.length < 3 || w2.length < 3) return w1 === w2;
|
|
587
|
-
const maxDistance = w1.length >= 6 && w2.length >= 6 ? 2 : 1;
|
|
588
|
-
return editDistance(w1, w2) <= maxDistance;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
// src/utils/intent-parser.ts
|
|
592
|
-
function parseIntents(raw) {
|
|
593
|
-
const entries = [];
|
|
594
|
-
const intentStart = raw.indexOf("INTENTS:");
|
|
595
|
-
if (intentStart === -1) return entries;
|
|
596
|
-
const block = raw.slice(intentStart);
|
|
597
|
-
const lines = block.split(/\r?\n/);
|
|
598
|
-
let currentCategory = "GENERAL";
|
|
599
|
-
let pendingPhrase = null;
|
|
600
|
-
for (const raw_line of lines) {
|
|
601
|
-
const line = raw_line.trim();
|
|
602
|
-
if (line.startsWith("---") && line.endsWith("---")) {
|
|
603
|
-
currentCategory = line.replace(/^-+\s*/, "").replace(/\s*-+$/, "").trim();
|
|
604
|
-
pendingPhrase = null;
|
|
605
|
-
continue;
|
|
606
|
-
}
|
|
607
|
-
if (line.startsWith("[") && line.endsWith("]")) continue;
|
|
608
|
-
if (line.startsWith("never ")) continue;
|
|
609
|
-
if (line === "" || line.startsWith("\u2192")) continue;
|
|
610
|
-
if (line.startsWith("- ") && pendingPhrase !== null) {
|
|
611
|
-
const emmet2 = line.slice(2).trim();
|
|
612
|
-
entries.push({
|
|
613
|
-
name: pendingPhrase,
|
|
614
|
-
phrases: tokenise(pendingPhrase),
|
|
615
|
-
emmet: emmet2,
|
|
616
|
-
category: currentCategory
|
|
617
|
-
});
|
|
618
|
-
pendingPhrase = null;
|
|
619
|
-
continue;
|
|
620
|
-
}
|
|
621
|
-
if (!line.startsWith("-")) {
|
|
622
|
-
pendingPhrase = line;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
return entries;
|
|
626
|
-
}
|
|
627
|
-
function loadIntents() {
|
|
628
|
-
try {
|
|
629
|
-
const currentDir = path3.dirname(fileURLToPath3(import.meta.url));
|
|
630
|
-
const path1 = path3.resolve(currentDir, "../../../api-full.txt");
|
|
631
|
-
const path22 = path3.resolve(currentDir, "../../api-full.txt");
|
|
632
|
-
const path32 = path3.resolve(currentDir, "../../../../api-full.txt");
|
|
633
|
-
let txtPath = path1;
|
|
634
|
-
if (existsSync2(path1)) {
|
|
635
|
-
txtPath = path1;
|
|
636
|
-
} else if (existsSync2(path22)) {
|
|
637
|
-
txtPath = path22;
|
|
638
|
-
} else if (existsSync2(path32)) {
|
|
639
|
-
txtPath = path32;
|
|
640
|
-
} else {
|
|
641
|
-
txtPath = path3.resolve(process.cwd(), "../../api-full.txt");
|
|
642
|
-
}
|
|
643
|
-
const raw = readFileSync3(txtPath, "utf8");
|
|
644
|
-
return parseIntents(raw);
|
|
645
|
-
} catch {
|
|
646
|
-
return [];
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
var _cache = null;
|
|
650
|
-
function getIntentEntries() {
|
|
651
|
-
if (!_cache) {
|
|
652
|
-
_cache = loadIntents();
|
|
653
|
-
}
|
|
654
|
-
return _cache;
|
|
655
|
-
}
|
|
656
|
-
function scoreEntry(entry, queryWords) {
|
|
657
|
-
let score = 0;
|
|
658
|
-
const entryWords = new Set(entry.phrases);
|
|
659
|
-
let matchedCount = 0;
|
|
660
|
-
for (const qw of queryWords) {
|
|
661
|
-
let matched = false;
|
|
662
|
-
if (entryWords.has(qw)) {
|
|
663
|
-
score += 10;
|
|
664
|
-
matched = true;
|
|
665
|
-
} else {
|
|
666
|
-
for (const ew of entryWords) {
|
|
667
|
-
if (isSimilar(qw, ew)) {
|
|
668
|
-
score += 8;
|
|
669
|
-
matched = true;
|
|
670
|
-
break;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
if (!matched) {
|
|
674
|
-
for (const ew of entryWords) {
|
|
675
|
-
if (ew.includes(qw) || qw.includes(ew)) {
|
|
676
|
-
score += 4;
|
|
677
|
-
matched = true;
|
|
678
|
-
break;
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
if (matched) {
|
|
684
|
-
matchedCount++;
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
const catWords = tokenise(entry.category);
|
|
688
|
-
for (const qw of queryWords) {
|
|
689
|
-
if (catWords.includes(qw)) {
|
|
690
|
-
score += 3;
|
|
691
|
-
} else {
|
|
692
|
-
for (const cw of catWords) {
|
|
693
|
-
if (isSimilar(qw, cw)) {
|
|
694
|
-
score += 2;
|
|
695
|
-
break;
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
const density = entry.phrases.length > 0 ? matchedCount / entry.phrases.length : 0;
|
|
701
|
-
return { score, density };
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// src/utils/emmet-helpers.ts
|
|
705
|
-
import emmet from "emmet";
|
|
706
|
-
function expandEmmet(emmetStr) {
|
|
707
|
-
try {
|
|
708
|
-
return emmet(emmetStr);
|
|
709
|
-
} catch {
|
|
710
|
-
return `<!-- expand: ${emmetStr} -->`;
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
var ELEMENT_TO_COMPONENT = {
|
|
714
|
-
// Native elements
|
|
715
|
-
button: "button",
|
|
716
|
-
input: "input",
|
|
717
|
-
textarea: "textarea",
|
|
718
|
-
select: "select",
|
|
719
|
-
form: "form",
|
|
720
|
-
dialog: "dialog",
|
|
721
|
-
details: "accordion",
|
|
722
|
-
summary: "accordion",
|
|
723
|
-
progress: "progress",
|
|
724
|
-
meter: "meter",
|
|
725
|
-
aside: "alert",
|
|
726
|
-
mark: "badge",
|
|
727
|
-
img: "avatar",
|
|
728
|
-
article: "card",
|
|
729
|
-
nav: "navigation",
|
|
730
|
-
table: "table",
|
|
731
|
-
pre: "codeblock",
|
|
732
|
-
code: "codeblock",
|
|
733
|
-
hr: "divider",
|
|
734
|
-
label: "input",
|
|
735
|
-
// label wraps input/checkbox/radio
|
|
736
|
-
// Web components
|
|
737
|
-
"ix-tabs": "tab",
|
|
738
|
-
"ix-dropdown": "dropdown",
|
|
739
|
-
"ix-combobox": "combobox",
|
|
740
|
-
"ix-tooltip": "tooltip",
|
|
741
|
-
"ix-toast": "toast"
|
|
742
|
-
};
|
|
743
|
-
var SKELETON_PATTERN = /span\[[^\]]*(?:aria-busy|data-shape)[^\]]*\]/i;
|
|
744
|
-
function extractComponents(emmetStr) {
|
|
745
|
-
const found = /* @__PURE__ */ new Set();
|
|
746
|
-
if (SKELETON_PATTERN.test(emmetStr)) {
|
|
747
|
-
found.add("skeleton");
|
|
748
|
-
}
|
|
749
|
-
if (/nav\[[^\]]*breadcrumb/i.test(emmetStr)) {
|
|
750
|
-
found.add("breadcrumb");
|
|
751
|
-
} else if (/\bnav\b/.test(emmetStr)) {
|
|
752
|
-
found.add("navigation");
|
|
753
|
-
}
|
|
754
|
-
if (/input\[[^\]]*type=checkbox/i.test(emmetStr)) {
|
|
755
|
-
found.add("checkbox");
|
|
756
|
-
}
|
|
757
|
-
if (/input\[[^\]]*type=radio/i.test(emmetStr)) {
|
|
758
|
-
found.add("radio");
|
|
759
|
-
}
|
|
760
|
-
if (/section\[[^\]]*data-grid/i.test(emmetStr)) {
|
|
761
|
-
found.add("grid");
|
|
762
|
-
}
|
|
763
|
-
const tagPattern = /(?:^|[>+(])([a-z][a-z0-9-]*)/gi;
|
|
764
|
-
let m;
|
|
765
|
-
while ((m = tagPattern.exec(emmetStr)) !== null) {
|
|
766
|
-
const tag = m[1].toLowerCase();
|
|
767
|
-
const component = ELEMENT_TO_COMPONENT[tag];
|
|
768
|
-
if (component) found.add(component);
|
|
769
|
-
}
|
|
770
|
-
return Array.from(found);
|
|
771
|
-
}
|
|
772
|
-
function interpolateEmmet(emmetStr, description) {
|
|
773
|
-
let result = emmetStr;
|
|
774
|
-
const descLower = description.toLowerCase();
|
|
775
|
-
if (/\b(red|danger|delete|remove|destroy)\b/.test(descLower)) {
|
|
776
|
-
result = result.replace(
|
|
777
|
-
/data-intent=(?:primary|neutral|ghost)/g,
|
|
778
|
-
"data-intent=danger"
|
|
779
|
-
);
|
|
780
|
-
} else if (/\b(green|success|work|done)\b/.test(descLower)) {
|
|
781
|
-
result = result.replace(
|
|
782
|
-
/data-intent=(?:primary|neutral|ghost)/g,
|
|
783
|
-
"data-intent=success"
|
|
784
|
-
);
|
|
785
|
-
} else if (/\b(yellow|orange|warning|risky)\b/.test(descLower)) {
|
|
786
|
-
result = result.replace(
|
|
787
|
-
/data-intent=(?:primary|neutral|ghost)/g,
|
|
788
|
-
"data-intent=warning"
|
|
789
|
-
);
|
|
790
|
-
}
|
|
791
|
-
const matches = [...description.matchAll(/['"]([^'"]+)['"]/g)];
|
|
792
|
-
const quotedStrings = matches.map((m) => m[1].trim()).filter((s) => s.length > 0);
|
|
793
|
-
if (quotedStrings.length > 0) {
|
|
794
|
-
const hasButtonMention = /\b(button|says|labeled|action|click)\b/i.test(
|
|
795
|
-
description
|
|
796
|
-
);
|
|
797
|
-
const shouldReplaceButton = quotedStrings.length >= 2 || quotedStrings.length === 1 && hasButtonMention;
|
|
798
|
-
if (shouldReplaceButton) {
|
|
799
|
-
const buttonRegex = /(button[^}]*)\{([^}]+)\}/g;
|
|
800
|
-
const buttonMatches = [...result.matchAll(buttonRegex)];
|
|
801
|
-
if (buttonMatches.length > 0) {
|
|
802
|
-
const buttonLabel = quotedStrings[quotedStrings.length - 1];
|
|
803
|
-
result = result.replace(buttonRegex, (match, p1) => {
|
|
804
|
-
return `${p1}{${buttonLabel}}`;
|
|
805
|
-
});
|
|
806
|
-
quotedStrings.pop();
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
const labelRegex = /label\{([^}]+)\}/g;
|
|
810
|
-
let quoteIndex = 0;
|
|
811
|
-
result = result.replace(labelRegex, (match) => {
|
|
812
|
-
if (quoteIndex < quotedStrings.length) {
|
|
813
|
-
return `label{${quotedStrings[quoteIndex++]}}`;
|
|
814
|
-
}
|
|
815
|
-
return match;
|
|
816
|
-
});
|
|
817
|
-
if (/label\{username\}/i.test(result)) {
|
|
818
|
-
result = result.replace(/type=email/g, "type=text");
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
return result;
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// src/tools/intent-engine.ts
|
|
825
|
-
var LAYER1_THRESHOLD = 8;
|
|
826
|
-
function searchIntentTable(description) {
|
|
827
|
-
const entries = getIntentEntries();
|
|
828
|
-
if (entries.length === 0) return null;
|
|
829
|
-
const queryWords = tokenise(description);
|
|
830
|
-
const candidates = [];
|
|
831
|
-
let bestSingle = null;
|
|
832
|
-
for (const entry of entries) {
|
|
833
|
-
const { score, density } = scoreEntry(entry, queryWords);
|
|
834
|
-
if (score >= LAYER1_THRESHOLD) {
|
|
835
|
-
const components = extractComponents(entry.emmet);
|
|
836
|
-
candidates.push({ entry, score, density, components });
|
|
837
|
-
const isBetter = !bestSingle || score > bestSingle.score || score === bestSingle.score && density > bestSingle.density;
|
|
838
|
-
if (isBetter) {
|
|
839
|
-
bestSingle = {
|
|
840
|
-
name: entry.name,
|
|
841
|
-
emmet: entry.emmet,
|
|
842
|
-
score,
|
|
843
|
-
density,
|
|
844
|
-
source: "intent-table"
|
|
845
|
-
};
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
if (bestSingle && bestSingle.score >= 18) {
|
|
850
|
-
return bestSingle;
|
|
851
|
-
}
|
|
852
|
-
const isStitchRequested = /\b(and|plus|\+)\b/i.test(description) || description.includes(",");
|
|
853
|
-
if (isStitchRequested && candidates.length >= 2) {
|
|
854
|
-
candidates.sort((a, b) => b.score - a.score || b.density - a.density);
|
|
855
|
-
const selected = [];
|
|
856
|
-
const usedComponents = /* @__PURE__ */ new Set();
|
|
857
|
-
for (const c of candidates) {
|
|
858
|
-
const hasOverlap = c.components.some((comp) => usedComponents.has(comp));
|
|
859
|
-
if (!hasOverlap) {
|
|
860
|
-
selected.push(c);
|
|
861
|
-
c.components.forEach((comp) => usedComponents.add(comp));
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
if (selected.length >= 2) {
|
|
865
|
-
selected.sort((a, b) => {
|
|
866
|
-
const indexA = description.toLowerCase().indexOf(tokenise(a.entry.name)[0]);
|
|
867
|
-
const indexB = description.toLowerCase().indexOf(tokenise(b.entry.name)[0]);
|
|
868
|
-
return indexA - indexB;
|
|
869
|
-
});
|
|
870
|
-
const combinedEmmet = selected.map((s) => s.entry.emmet).join("+");
|
|
871
|
-
const totalScore = selected.reduce((sum, s) => sum + s.score, 0);
|
|
872
|
-
return {
|
|
873
|
-
name: selected.map((s) => s.entry.name).join(" and "),
|
|
874
|
-
emmet: combinedEmmet,
|
|
875
|
-
score: totalScore,
|
|
876
|
-
source: "intent-table"
|
|
877
|
-
};
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
return bestSingle;
|
|
881
|
-
}
|
|
882
|
-
function searchVectorLayer(description) {
|
|
883
|
-
const results = searchIndex(description);
|
|
884
|
-
if (results.length === 0) return null;
|
|
885
|
-
const top = results[0];
|
|
886
|
-
return {
|
|
887
|
-
name: top.name,
|
|
888
|
-
emmet: top.emmet,
|
|
889
|
-
score: top.score,
|
|
890
|
-
source: "vector-index"
|
|
891
|
-
};
|
|
892
|
-
}
|
|
893
|
-
async function howToBuild(description) {
|
|
894
|
-
let cleanDesc = description.trim();
|
|
895
|
-
if (cleanDesc.startsWith("{") && cleanDesc.endsWith("}")) {
|
|
896
|
-
try {
|
|
897
|
-
const parsed = JSON.parse(cleanDesc);
|
|
898
|
-
if (parsed && typeof parsed.description === "string") {
|
|
899
|
-
cleanDesc = parsed.description;
|
|
900
|
-
} else if (parsed && typeof parsed.text === "string") {
|
|
901
|
-
cleanDesc = parsed.text;
|
|
902
|
-
}
|
|
903
|
-
} catch {
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
let match = searchIntentTable(cleanDesc);
|
|
907
|
-
if (!match) {
|
|
908
|
-
match = searchVectorLayer(cleanDesc);
|
|
909
|
-
}
|
|
910
|
-
if (!match) {
|
|
911
|
-
return {
|
|
912
|
-
content: [
|
|
913
|
-
{
|
|
914
|
-
type: "text",
|
|
915
|
-
text: JSON.stringify({
|
|
916
|
-
emmet: "",
|
|
917
|
-
html: "",
|
|
918
|
-
components_used: [],
|
|
919
|
-
confidence: 0,
|
|
920
|
-
tokens: 0,
|
|
921
|
-
tokens_used: 15,
|
|
922
|
-
source: "no-match",
|
|
923
|
-
suggestion: "Call list_components() to see all available components, or try a different description."
|
|
924
|
-
})
|
|
925
|
-
}
|
|
926
|
-
]
|
|
927
|
-
};
|
|
928
|
-
}
|
|
929
|
-
const emmet2 = interpolateEmmet(match.emmet, cleanDesc);
|
|
930
|
-
const components_used = extractComponents(emmet2);
|
|
931
|
-
const confidence = Math.min(1, match.score / 40);
|
|
932
|
-
return {
|
|
933
|
-
content: [
|
|
934
|
-
{
|
|
935
|
-
type: "text",
|
|
936
|
-
text: JSON.stringify({
|
|
937
|
-
emmet: emmet2,
|
|
938
|
-
html: expandEmmet(emmet2),
|
|
939
|
-
components_used,
|
|
940
|
-
confidence: Number(confidence.toFixed(2)),
|
|
941
|
-
tokens: getTokenCount(emmet2),
|
|
942
|
-
tokens_used: 15,
|
|
943
|
-
source: match.source
|
|
944
|
-
// tells caller which layer matched
|
|
945
|
-
})
|
|
946
|
-
}
|
|
947
|
-
]
|
|
948
|
-
};
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
// src/context/api-context.ts
|
|
952
|
-
import { readFileSync as readFileSync4, existsSync as existsSync3 } from "fs";
|
|
953
|
-
import path4 from "path";
|
|
954
|
-
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
955
|
-
var __filename3 = fileURLToPath4(import.meta.url);
|
|
956
|
-
var __dirname3 = path4.dirname(__filename3);
|
|
957
|
-
var apiPath = path4.resolve(__dirname3, "../../../api-full.txt");
|
|
958
|
-
if (!existsSync3(apiPath)) {
|
|
959
|
-
apiPath = path4.resolve(__dirname3, "../../../../api-full.txt");
|
|
960
|
-
}
|
|
961
|
-
var apiContext = readFileSync4(apiPath, "utf8");
|
|
962
|
-
|
|
963
|
-
// src/tools/generate-theme.ts
|
|
964
|
-
import { z as z3 } from "zod";
|
|
965
|
-
|
|
966
|
-
// src/tools/theme-palette.ts
|
|
967
|
-
var PALETTE = {
|
|
968
|
-
red: { primary: "#ef4444", contrast: "#ffffff" },
|
|
969
|
-
orange: { primary: "#f97316", contrast: "#ffffff" },
|
|
970
|
-
amber: { primary: "#f59e0b", contrast: "#111827" },
|
|
971
|
-
yellow: { primary: "#eab308", contrast: "#111827" },
|
|
972
|
-
lime: { primary: "#84cc16", contrast: "#111827" },
|
|
973
|
-
green: { primary: "#22c55e", contrast: "#ffffff" },
|
|
974
|
-
emerald: { primary: "#10b981", contrast: "#ffffff" },
|
|
975
|
-
teal: { primary: "#14b8a6", contrast: "#ffffff" },
|
|
976
|
-
cyan: { primary: "#06b6d4", contrast: "#111827" },
|
|
977
|
-
sky: { primary: "#0ea5e9", contrast: "#111827" },
|
|
978
|
-
blue: { primary: "#3b82f6", contrast: "#ffffff" },
|
|
979
|
-
indigo: { primary: "#6366f1", contrast: "#ffffff" },
|
|
980
|
-
violet: { primary: "#8b5cf6", contrast: "#ffffff" },
|
|
981
|
-
purple: { primary: "#a855f7", contrast: "#ffffff" },
|
|
982
|
-
fuchsia: { primary: "#d946ef", contrast: "#ffffff" },
|
|
983
|
-
pink: { primary: "#ec4899", contrast: "#ffffff" },
|
|
984
|
-
rose: { primary: "#f43f5e", contrast: "#ffffff" },
|
|
985
|
-
cyberpunk: { primary: "#ec4899", contrast: "#ffffff" },
|
|
986
|
-
neon: { primary: "#d946ef", contrast: "#ffffff" },
|
|
987
|
-
eco: { primary: "#10b981", contrast: "#ffffff" },
|
|
988
|
-
forest: { primary: "#0f766e", contrast: "#ffffff" },
|
|
989
|
-
coffee: { primary: "#78350f", contrast: "#ffffff" },
|
|
990
|
-
ocean: { primary: "#0284c7", contrast: "#ffffff" },
|
|
991
|
-
sunset: { primary: "#f97316", contrast: "#ffffff" },
|
|
992
|
-
midnight: { primary: "#3730a3", contrast: "#ffffff" },
|
|
993
|
-
lavender: { primary: "#8b5cf6", contrast: "#ffffff" },
|
|
994
|
-
coral: { primary: "#f43f5e", contrast: "#ffffff" },
|
|
995
|
-
slate: { primary: "#64748b", contrast: "#ffffff" },
|
|
996
|
-
gold: { primary: "#d97706", contrast: "#111827" },
|
|
997
|
-
silver: { primary: "#94a3b8", contrast: "#111827" }
|
|
998
|
-
};
|
|
999
|
-
function expandHex(hex) {
|
|
1000
|
-
const h = hex.replace("#", "");
|
|
1001
|
-
return h.length === 3 ? "#" + h[0] + h[0] + h[1] + h[1] + h[2] + h[2] : hex;
|
|
1002
|
-
}
|
|
1003
|
-
function contrastFor(hex6) {
|
|
1004
|
-
const h = hex6.replace("#", "");
|
|
1005
|
-
const r = parseInt(h.substring(0, 2), 16);
|
|
1006
|
-
const g = parseInt(h.substring(2, 4), 16);
|
|
1007
|
-
const b = parseInt(h.substring(4, 6), 16);
|
|
1008
|
-
return (r * 299 + g * 587 + b * 114) / 1e3 > 165 ? "#111827" : "#ffffff";
|
|
1009
|
-
}
|
|
1010
|
-
function resolveColor(query) {
|
|
1011
|
-
const hexMatch = query.match(/#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/);
|
|
1012
|
-
if (hexMatch) {
|
|
1013
|
-
const primary = expandHex(hexMatch[0]);
|
|
1014
|
-
return { primary, contrast: contrastFor(primary) };
|
|
1015
|
-
}
|
|
1016
|
-
const sorted = Object.keys(PALETTE).sort((a, b) => b.length - a.length);
|
|
1017
|
-
for (const name of sorted) {
|
|
1018
|
-
if (query.includes(name)) return PALETTE[name];
|
|
1019
|
-
}
|
|
1020
|
-
return { primary: "#6366f1", contrast: "#ffffff" };
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// src/tools/theme-tokens.ts
|
|
1024
|
-
function resolveTokens(query) {
|
|
1025
|
-
const { primary, contrast } = resolveColor(query);
|
|
1026
|
-
const isDark = query.includes("dark") || query.includes("night") || query.includes("midnight") || query.includes("cyberpunk") || query.includes("dim");
|
|
1027
|
-
const sharp = query.includes("sharp") || query.includes("flat") || query.includes("square");
|
|
1028
|
-
const round = query.includes("round") || query.includes("pill") || query.includes("soft");
|
|
1029
|
-
return {
|
|
1030
|
-
resolvedPrimary: primary,
|
|
1031
|
-
resolvedContrast: contrast,
|
|
1032
|
-
isDark,
|
|
1033
|
-
primary,
|
|
1034
|
-
primaryHover: isDark ? "color-mix(in srgb, var(--ix-primary) 75%, white)" : "color-mix(in srgb, var(--ix-primary) 85%, black)",
|
|
1035
|
-
primaryContrast: isDark ? "color-mix(in srgb, var(--ix-primary) 40%, white)" : contrast,
|
|
1036
|
-
primaryBg: isDark ? "color-mix(in srgb, var(--ix-primary) 20%, #0f172a)" : "color-mix(in srgb, var(--ix-primary) 15%, white)",
|
|
1037
|
-
danger: "#ef4444",
|
|
1038
|
-
dangerText: isDark ? "#f87171" : "#ef4444",
|
|
1039
|
-
dangerBg: isDark ? "#3f1d1d" : "#fee2e2",
|
|
1040
|
-
warning: isDark ? "#fbbf24" : "#f59e0b",
|
|
1041
|
-
warningBg: isDark ? "#3f2e05" : "#fef3c7",
|
|
1042
|
-
success: "#22c55e",
|
|
1043
|
-
successBg: isDark ? "#052e16" : "#dcfce7",
|
|
1044
|
-
infoBg: isDark ? "#1e293b" : "#f3f4f6",
|
|
1045
|
-
neutral: isDark ? "#9ca3af" : "#6b7280",
|
|
1046
|
-
divider: isDark ? "#475569" : "#9ca3af",
|
|
1047
|
-
surface: isDark ? "#0f172a" : "#f9fafb",
|
|
1048
|
-
surfaceRaised: isDark ? "#1e293b" : "#ffffff",
|
|
1049
|
-
text: isDark ? "#f9fafb" : "#111827",
|
|
1050
|
-
textMuted: isDark ? "#94a3b8" : "#6b7280",
|
|
1051
|
-
border: isDark ? "#334155" : "#e5e7eb",
|
|
1052
|
-
radius: sharp ? "0px" : round ? "0.75rem" : "0.375rem",
|
|
1053
|
-
radiusLg: sharp ? "0px" : round ? "1rem" : "0.5rem",
|
|
1054
|
-
badgeRadius: "999px",
|
|
1055
|
-
skeletonBg: isDark ? "#1e293b" : "#e5e7eb",
|
|
1056
|
-
skeletonShimmer: isDark ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,0.6)",
|
|
1057
|
-
gradientStart: primary,
|
|
1058
|
-
gradientEnd: "#fffbd5",
|
|
1059
|
-
gradientBgEnd: isDark ? "#aba67a" : "#d7d4bc"
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
|
-
function buildCss(t) {
|
|
1063
|
-
return `:root {
|
|
1064
|
-
--ix-primary: ${t.primary};
|
|
1065
|
-
--ix-primary-hover: ${t.primaryHover};
|
|
1066
|
-
--ix-primary-contrast: ${t.primaryContrast};
|
|
1067
|
-
|
|
1068
|
-
--ix-danger: ${t.danger};
|
|
1069
|
-
--ix-danger-text: ${t.dangerText};
|
|
1070
|
-
|
|
1071
|
-
--ix-warning: ${t.warning};
|
|
1072
|
-
--ix-success: ${t.success};
|
|
1073
|
-
--ix-neutral: ${t.neutral};
|
|
1074
|
-
--ix-ghost: transparent;
|
|
1075
|
-
|
|
1076
|
-
--ix-primary-bg: ${t.primaryBg};
|
|
1077
|
-
--ix-danger-bg: ${t.dangerBg};
|
|
1078
|
-
--ix-warning-bg: ${t.warningBg};
|
|
1079
|
-
--ix-success-bg: ${t.successBg};
|
|
1080
|
-
--ix-info-bg: ${t.infoBg};
|
|
1081
|
-
|
|
1082
|
-
--ix-on-danger: #ffffff;
|
|
1083
|
-
--ix-on-warning: #000000;
|
|
1084
|
-
--ix-on-success: #ffffff;
|
|
1085
|
-
--ix-on-primary: ${t.resolvedContrast};
|
|
1086
|
-
|
|
1087
|
-
--ix-divider: ${t.divider};
|
|
1088
|
-
|
|
1089
|
-
--ix-surface: ${t.surface};
|
|
1090
|
-
--ix-surface-raised: ${t.surfaceRaised};
|
|
1091
|
-
|
|
1092
|
-
--ix-text: ${t.text};
|
|
1093
|
-
--ix-text-muted: ${t.textMuted};
|
|
1094
|
-
|
|
1095
|
-
--ix-border: ${t.border};
|
|
1096
|
-
--ix-focus: 2px solid var(--ix-primary);
|
|
1097
|
-
|
|
1098
|
-
--ix-font: 'Segoe UI Variable', 'Segoe UI', 'Roboto', 'Noto Sans', system-ui, sans-serif;
|
|
1099
|
-
--ix-font-mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
|
1100
|
-
|
|
1101
|
-
--ix-size-xs: 0.75rem;
|
|
1102
|
-
--ix-size-sm: 0.875rem;
|
|
1103
|
-
--ix-size-md: 1rem;
|
|
1104
|
-
--ix-size-lg: 1.125rem;
|
|
1105
|
-
--ix-size-xl: 1.25rem;
|
|
1106
|
-
|
|
1107
|
-
--ix-line-height: 1.5;
|
|
1108
|
-
|
|
1109
|
-
--ix-space-sm: 0.5rem;
|
|
1110
|
-
--ix-space-md: 1rem;
|
|
1111
|
-
--ix-space-lg: 1.5rem;
|
|
1112
|
-
|
|
1113
|
-
--ix-radius: ${t.radius};
|
|
1114
|
-
--ix-radius-lg: ${t.radiusLg};
|
|
1115
|
-
--ix-badge-radius: ${t.badgeRadius};
|
|
1116
|
-
|
|
1117
|
-
--ix-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
1118
|
-
|
|
1119
|
-
--ix-gradient: linear-gradient(to right, ${t.gradientStart}, ${t.gradientEnd});
|
|
1120
|
-
--ix-gradient-bg: linear-gradient(to right, ${t.gradientStart}, ${t.gradientBgEnd});
|
|
1121
|
-
|
|
1122
|
-
--ix-skeleton-bg: ${t.skeletonBg};
|
|
1123
|
-
--ix-skeleton-shimmer: ${t.skeletonShimmer};
|
|
1124
|
-
}`;
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// src/tools/generate-theme.ts
|
|
1128
|
-
var schema3 = z3.object({ prompt: z3.string().min(1) });
|
|
1129
|
-
function generateTheme(args) {
|
|
1130
|
-
const parsed = schema3.safeParse(args);
|
|
1131
|
-
if (!parsed.success) {
|
|
1132
|
-
return {
|
|
1133
|
-
content: [
|
|
1134
|
-
{
|
|
1135
|
-
type: "text",
|
|
1136
|
-
text: JSON.stringify({
|
|
1137
|
-
error: "Invalid input",
|
|
1138
|
-
suggestion: "Expected { prompt: string }",
|
|
1139
|
-
tokens_used: 2
|
|
1140
|
-
})
|
|
1141
|
-
}
|
|
1142
|
-
]
|
|
1143
|
-
};
|
|
1144
|
-
}
|
|
1145
|
-
const { prompt } = parsed.data;
|
|
1146
|
-
const tokens = resolveTokens(prompt.toLowerCase().trim());
|
|
1147
|
-
return {
|
|
1148
|
-
content: [
|
|
1149
|
-
{
|
|
1150
|
-
type: "text",
|
|
1151
|
-
text: JSON.stringify({
|
|
1152
|
-
prompt,
|
|
1153
|
-
primary: tokens.resolvedPrimary,
|
|
1154
|
-
isDark: tokens.isDark,
|
|
1155
|
-
css: buildCss(tokens),
|
|
1156
|
-
tokens_used: 10
|
|
1157
|
-
})
|
|
1158
|
-
}
|
|
1159
|
-
]
|
|
1160
|
-
};
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
// src/tools/check-a11y.ts
|
|
1164
|
-
import { parse as parse2 } from "node-html-parser";
|
|
1165
|
-
|
|
1166
|
-
// src/utils/a11y-rules.ts
|
|
1167
|
-
function injectAttr(outerHTML, attr, value) {
|
|
1168
|
-
return outerHTML.replace(/^(<[a-zA-Z][a-zA-Z0-9-]*)/, `$1 ${attr}="${value}"`);
|
|
1169
|
-
}
|
|
1170
|
-
function clip(str, max = 200) {
|
|
1171
|
-
return str.length > max ? str.slice(0, max) + "..." : str;
|
|
1172
|
-
}
|
|
1173
|
-
function isDecorative(img) {
|
|
1174
|
-
const role = img.getAttribute("role");
|
|
1175
|
-
const ariaHidden = img.getAttribute("aria-hidden");
|
|
1176
|
-
return role === "presentation" || role === "none" || ariaHidden === "true";
|
|
1177
|
-
}
|
|
1178
|
-
function findBrokenAriaRefs(el, attr, root) {
|
|
1179
|
-
const val = el.getAttribute(attr);
|
|
1180
|
-
if (!val?.trim()) return [];
|
|
1181
|
-
return val.trim().split(/\s+/).filter((id) => !root.querySelector(`[id="${id}"]`));
|
|
1182
|
-
}
|
|
1183
|
-
function getVisibleText(el) {
|
|
1184
|
-
if (el.getAttribute("aria-hidden") === "true") return "";
|
|
1185
|
-
let result = "";
|
|
1186
|
-
for (const child of el.childNodes) {
|
|
1187
|
-
if (child.nodeType === 1) {
|
|
1188
|
-
result += getVisibleText(child);
|
|
1189
|
-
} else if (child.nodeType === 3) {
|
|
1190
|
-
result += child.text ?? "";
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
return result;
|
|
1194
|
-
}
|
|
1195
|
-
function getAccessibleName(el, root) {
|
|
1196
|
-
const text = getVisibleText(el).trim();
|
|
1197
|
-
if (text) return text;
|
|
1198
|
-
const ariaLabel = el.getAttribute("aria-label")?.trim();
|
|
1199
|
-
if (ariaLabel) return ariaLabel;
|
|
1200
|
-
const title = el.getAttribute("title")?.trim();
|
|
1201
|
-
if (title) return title;
|
|
1202
|
-
const childImg = el.querySelector("img");
|
|
1203
|
-
const childAlt = childImg?.getAttribute("alt")?.trim();
|
|
1204
|
-
if (childAlt) return childAlt;
|
|
1205
|
-
const labelledBy = el.getAttribute("aria-labelledby");
|
|
1206
|
-
if (labelledBy) {
|
|
1207
|
-
const name = labelledBy.trim().split(/\s+/).map((id) => root.querySelector(`[id="${id}"]`)?.text.trim() ?? "").join(" ").trim();
|
|
1208
|
-
if (name) return name;
|
|
1209
|
-
}
|
|
1210
|
-
return "";
|
|
1211
|
-
}
|
|
1212
|
-
function checkImages(root) {
|
|
1213
|
-
const ruleName = "WCAG 1.1.1 Non-text Content";
|
|
1214
|
-
const issues = [];
|
|
1215
|
-
root.querySelectorAll("img").forEach((img) => {
|
|
1216
|
-
const alt = img.getAttribute("alt");
|
|
1217
|
-
const ariaLabel = img.getAttribute("aria-label")?.trim();
|
|
1218
|
-
const labelledBy = img.getAttribute("aria-labelledby");
|
|
1219
|
-
const hasAriaName = !!(ariaLabel || labelledBy);
|
|
1220
|
-
if (alt === void 0) {
|
|
1221
|
-
if (hasAriaName) {
|
|
1222
|
-
issues.push({
|
|
1223
|
-
type: "warning",
|
|
1224
|
-
rule: ruleName,
|
|
1225
|
-
element: clip(img.outerHTML),
|
|
1226
|
-
message: "<img> uses aria-label/aria-labelledby but the alt attribute is recommended for broader screen reader compatibility",
|
|
1227
|
-
fix: injectAttr(
|
|
1228
|
-
img.outerHTML,
|
|
1229
|
-
"alt",
|
|
1230
|
-
ariaLabel ?? "[Describe the image]"
|
|
1231
|
-
)
|
|
1232
|
-
});
|
|
1233
|
-
} else {
|
|
1234
|
-
issues.push({
|
|
1235
|
-
type: "error",
|
|
1236
|
-
rule: ruleName,
|
|
1237
|
-
element: clip(img.outerHTML),
|
|
1238
|
-
message: "<img> is missing the alt attribute",
|
|
1239
|
-
fix: injectAttr(img.outerHTML, "alt", "[Describe the image]")
|
|
1240
|
-
});
|
|
1241
|
-
}
|
|
1242
|
-
return;
|
|
1243
|
-
}
|
|
1244
|
-
if (alt.trim() === "" && !isDecorative(img)) {
|
|
1245
|
-
issues.push({
|
|
1246
|
-
type: "warning",
|
|
1247
|
-
rule: ruleName,
|
|
1248
|
-
element: clip(img.outerHTML),
|
|
1249
|
-
message: '<img> has an empty alt but is not marked as decorative - add role="presentation" or provide a description',
|
|
1250
|
-
fix: injectAttr(img.outerHTML, "role", "presentation")
|
|
1251
|
-
});
|
|
1252
|
-
}
|
|
1253
|
-
});
|
|
1254
|
-
return { ruleName, issues };
|
|
1255
|
-
}
|
|
1256
|
-
function checkFormLabels(root) {
|
|
1257
|
-
const ruleName = "WCAG 1.3.1 Form Labels";
|
|
1258
|
-
const issues = [];
|
|
1259
|
-
const UNLABELED_SKIP = /* @__PURE__ */ new Set([
|
|
1260
|
-
"hidden",
|
|
1261
|
-
"submit",
|
|
1262
|
-
"reset",
|
|
1263
|
-
"button",
|
|
1264
|
-
"image"
|
|
1265
|
-
]);
|
|
1266
|
-
for (const tag of ["input", "select", "textarea"]) {
|
|
1267
|
-
root.querySelectorAll(tag).forEach((el) => {
|
|
1268
|
-
if (tag === "input") {
|
|
1269
|
-
const type = (el.getAttribute("type") ?? "text").toLowerCase();
|
|
1270
|
-
if (UNLABELED_SKIP.has(type)) return;
|
|
1271
|
-
}
|
|
1272
|
-
const id = el.getAttribute("id");
|
|
1273
|
-
const hasExplicit = id ? !!root.querySelector(`label[for="${id}"]`) : false;
|
|
1274
|
-
const hasImplicit = !!el.closest("label");
|
|
1275
|
-
const ariaLabel = el.getAttribute("aria-label")?.trim();
|
|
1276
|
-
const ariaLabelledBy = el.getAttribute("aria-labelledby");
|
|
1277
|
-
const hasTitle = !!el.getAttribute("title")?.trim();
|
|
1278
|
-
if (!hasExplicit && !hasImplicit && !ariaLabel && !ariaLabelledBy && !hasTitle) {
|
|
1279
|
-
issues.push({
|
|
1280
|
-
type: "error",
|
|
1281
|
-
rule: ruleName,
|
|
1282
|
-
element: clip(el.outerHTML),
|
|
1283
|
-
message: `<${tag}> is not associated with a <label> - use for/id, wrapping label, or aria-label`,
|
|
1284
|
-
fix: id ? `<label for="${id}">[Label text]</label>
|
|
1285
|
-
${el.outerHTML}` : `<label>[Label text] ${el.outerHTML}</label>`
|
|
1286
|
-
});
|
|
1287
|
-
return;
|
|
1288
|
-
}
|
|
1289
|
-
if (ariaLabelledBy) {
|
|
1290
|
-
const broken = findBrokenAriaRefs(el, "aria-labelledby", root);
|
|
1291
|
-
if (broken.length > 0) {
|
|
1292
|
-
issues.push({
|
|
1293
|
-
type: "error",
|
|
1294
|
-
rule: ruleName,
|
|
1295
|
-
element: clip(el.outerHTML),
|
|
1296
|
-
message: `<${tag}> aria-labelledby references non-existent element(s): ${broken.map((id2) => `#${id2}`).join(", ")}`,
|
|
1297
|
-
fix: `Ensure element(s) with id="${broken.join('", "')}" exist in the DOM, or use aria-label instead`
|
|
1298
|
-
});
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
});
|
|
1302
|
-
}
|
|
1303
|
-
root.querySelectorAll("fieldset").forEach((fieldset) => {
|
|
1304
|
-
const legend = fieldset.querySelector("legend");
|
|
1305
|
-
if (!legend || !legend.text.trim()) {
|
|
1306
|
-
issues.push({
|
|
1307
|
-
type: "error",
|
|
1308
|
-
rule: ruleName,
|
|
1309
|
-
element: "<fieldset>",
|
|
1310
|
-
message: "<fieldset> must have a non-empty <legend> to group related form controls",
|
|
1311
|
-
fix: `<fieldset>
|
|
1312
|
-
<legend>[Group label]</legend>
|
|
1313
|
-
...
|
|
1314
|
-
</fieldset>`
|
|
1315
|
-
});
|
|
1316
|
-
}
|
|
1317
|
-
});
|
|
1318
|
-
return { ruleName, issues };
|
|
1319
|
-
}
|
|
1320
|
-
function checkEmptyLabels(root) {
|
|
1321
|
-
const ruleName = "WCAG 2.4.6 Empty Labels";
|
|
1322
|
-
const issues = [];
|
|
1323
|
-
root.querySelectorAll("label").forEach((label) => {
|
|
1324
|
-
const hasText = !!label.text.trim();
|
|
1325
|
-
const hasAriaLabel = !!label.getAttribute("aria-label")?.trim();
|
|
1326
|
-
if (!hasText && !hasAriaLabel) {
|
|
1327
|
-
issues.push({
|
|
1328
|
-
type: "warning",
|
|
1329
|
-
rule: ruleName,
|
|
1330
|
-
element: clip(label.outerHTML),
|
|
1331
|
-
message: "<label> is empty and provides no accessible name for its control",
|
|
1332
|
-
fix: label.outerHTML.replace("</label>", "[Label text]</label>")
|
|
1333
|
-
});
|
|
1334
|
-
}
|
|
1335
|
-
const forAttr = label.getAttribute("for");
|
|
1336
|
-
if (forAttr && !root.querySelector(`[id="${forAttr}"]`)) {
|
|
1337
|
-
issues.push({
|
|
1338
|
-
type: "error",
|
|
1339
|
-
rule: ruleName,
|
|
1340
|
-
element: clip(label.outerHTML),
|
|
1341
|
-
message: `<label for="${forAttr}"> references id="${forAttr}" which does not exist in the DOM`,
|
|
1342
|
-
fix: `Add id="${forAttr}" to the target form element, or correct the for attribute`
|
|
1343
|
-
});
|
|
1344
|
-
}
|
|
1345
|
-
});
|
|
1346
|
-
return { ruleName, issues };
|
|
1347
|
-
}
|
|
1348
|
-
function checkButtons(root) {
|
|
1349
|
-
const ruleName = "WCAG 4.1.2 Button Names";
|
|
1350
|
-
const issues = [];
|
|
1351
|
-
root.querySelectorAll("button").forEach((button) => {
|
|
1352
|
-
if (!getAccessibleName(button, root)) {
|
|
1353
|
-
issues.push({
|
|
1354
|
-
type: "error",
|
|
1355
|
-
rule: ruleName,
|
|
1356
|
-
element: clip(button.outerHTML),
|
|
1357
|
-
message: "<button> has no accessible name - add text content, aria-label, or a child <img> with alt",
|
|
1358
|
-
fix: injectAttr(button.outerHTML, "aria-label", "[Action description]")
|
|
1359
|
-
});
|
|
1360
|
-
}
|
|
1361
|
-
});
|
|
1362
|
-
return { ruleName, issues };
|
|
1363
|
-
}
|
|
1364
|
-
function checkLinks(root) {
|
|
1365
|
-
const ruleName = "WCAG 2.4.4 Link Purpose";
|
|
1366
|
-
const issues = [];
|
|
1367
|
-
const VAGUE = /* @__PURE__ */ new Set([
|
|
1368
|
-
"click here",
|
|
1369
|
-
"here",
|
|
1370
|
-
"read more",
|
|
1371
|
-
"more",
|
|
1372
|
-
"link",
|
|
1373
|
-
"click",
|
|
1374
|
-
"learn more",
|
|
1375
|
-
"details",
|
|
1376
|
-
"info"
|
|
1377
|
-
]);
|
|
1378
|
-
root.querySelectorAll("a").forEach((link) => {
|
|
1379
|
-
const name = getAccessibleName(link, root);
|
|
1380
|
-
const href = link.getAttribute("href");
|
|
1381
|
-
const isButtonRole = link.getAttribute("role") === "button";
|
|
1382
|
-
if (!name) {
|
|
1383
|
-
issues.push({
|
|
1384
|
-
type: "error",
|
|
1385
|
-
rule: ruleName,
|
|
1386
|
-
element: clip(link.outerHTML),
|
|
1387
|
-
message: "<a> has no accessible name - add text, aria-label, or a child <img> with alt",
|
|
1388
|
-
fix: injectAttr(
|
|
1389
|
-
link.outerHTML,
|
|
1390
|
-
"aria-label",
|
|
1391
|
-
"[Describe the link destination]"
|
|
1392
|
-
)
|
|
1393
|
-
});
|
|
1394
|
-
return;
|
|
1395
|
-
}
|
|
1396
|
-
if (VAGUE.has(name.toLowerCase())) {
|
|
1397
|
-
issues.push({
|
|
1398
|
-
type: "warning",
|
|
1399
|
-
rule: ruleName,
|
|
1400
|
-
element: clip(link.outerHTML),
|
|
1401
|
-
message: `<a> has non-descriptive text "${name}" - use aria-label to clarify the destination`,
|
|
1402
|
-
fix: injectAttr(
|
|
1403
|
-
link.outerHTML,
|
|
1404
|
-
"aria-label",
|
|
1405
|
-
"[Describe where this link goes]"
|
|
1406
|
-
)
|
|
1407
|
-
});
|
|
1408
|
-
}
|
|
1409
|
-
if (!href && !isButtonRole) {
|
|
1410
|
-
issues.push({
|
|
1411
|
-
type: "warning",
|
|
1412
|
-
rule: ruleName,
|
|
1413
|
-
element: clip(link.outerHTML),
|
|
1414
|
-
message: "<a> has no href - use <button> for actions, or add a valid href",
|
|
1415
|
-
fix: link.outerHTML.replace(/^<a\b/, '<a href="#"')
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
1418
|
-
});
|
|
1419
|
-
return { ruleName, issues };
|
|
1420
|
-
}
|
|
1421
|
-
function checkAriaStates(root) {
|
|
1422
|
-
const ruleName = "WCAG 3.3.1 Error Identification";
|
|
1423
|
-
const issues = [];
|
|
1424
|
-
root.querySelectorAll("[aria-invalid]").forEach((el) => {
|
|
1425
|
-
const invalidVal = el.getAttribute("aria-invalid");
|
|
1426
|
-
const VALID_INVALID_VALUES = /* @__PURE__ */ new Set([
|
|
1427
|
-
"false",
|
|
1428
|
-
"true",
|
|
1429
|
-
"grammar",
|
|
1430
|
-
"spelling"
|
|
1431
|
-
]);
|
|
1432
|
-
if (!VALID_INVALID_VALUES.has(invalidVal ?? "")) {
|
|
1433
|
-
issues.push({
|
|
1434
|
-
type: "warning",
|
|
1435
|
-
rule: "WCAG 4.1.2 ARIA State Values",
|
|
1436
|
-
element: clip(el.outerHTML),
|
|
1437
|
-
message: `aria-invalid="${invalidVal}" is not valid - use "true", "false", "grammar", or "spelling"`,
|
|
1438
|
-
fix: el.outerHTML.replace(
|
|
1439
|
-
`aria-invalid="${invalidVal}"`,
|
|
1440
|
-
'aria-invalid="true"'
|
|
1441
|
-
)
|
|
1442
|
-
});
|
|
1443
|
-
return;
|
|
1444
|
-
}
|
|
1445
|
-
if (invalidVal === "true") {
|
|
1446
|
-
const describedBy = el.getAttribute("aria-describedby");
|
|
1447
|
-
if (!describedBy) {
|
|
1448
|
-
issues.push({
|
|
1449
|
-
type: "error",
|
|
1450
|
-
rule: ruleName,
|
|
1451
|
-
element: clip(el.outerHTML),
|
|
1452
|
-
message: 'Element with aria-invalid="true" must have aria-describedby pointing to the error message element',
|
|
1453
|
-
fix: injectAttr(el.outerHTML, "aria-describedby", "error-message-id")
|
|
1454
|
-
});
|
|
1455
|
-
} else {
|
|
1456
|
-
const broken = findBrokenAriaRefs(el, "aria-describedby", root);
|
|
1457
|
-
if (broken.length > 0) {
|
|
1458
|
-
issues.push({
|
|
1459
|
-
type: "error",
|
|
1460
|
-
rule: ruleName,
|
|
1461
|
-
element: clip(el.outerHTML),
|
|
1462
|
-
message: `aria-describedby references non-existent element(s): ${broken.map((id) => `#${id}`).join(", ")}`,
|
|
1463
|
-
fix: `Ensure element(s) with id="${broken.join('", "')}" exist in the DOM`
|
|
1464
|
-
});
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
});
|
|
1469
|
-
const BOOLEAN_ARIA_ATTRS = [
|
|
1470
|
-
"aria-busy",
|
|
1471
|
-
"aria-expanded",
|
|
1472
|
-
"aria-selected",
|
|
1473
|
-
"aria-pressed"
|
|
1474
|
-
];
|
|
1475
|
-
for (const attr of BOOLEAN_ARIA_ATTRS) {
|
|
1476
|
-
root.querySelectorAll(`[${attr}]`).forEach((el) => {
|
|
1477
|
-
const val = el.getAttribute(attr);
|
|
1478
|
-
if (val !== "true" && val !== "false") {
|
|
1479
|
-
issues.push({
|
|
1480
|
-
type: "warning",
|
|
1481
|
-
rule: "WCAG 4.1.2 ARIA State Values",
|
|
1482
|
-
element: clip(el.outerHTML),
|
|
1483
|
-
message: `${attr} must be "true" or "false", got "${val}"`,
|
|
1484
|
-
fix: el.outerHTML.replace(`${attr}="${val}"`, `${attr}="true"`)
|
|
1485
|
-
});
|
|
1486
|
-
}
|
|
1487
|
-
});
|
|
1488
|
-
}
|
|
1489
|
-
root.querySelectorAll("[aria-checked]").forEach((el) => {
|
|
1490
|
-
const val = el.getAttribute("aria-checked");
|
|
1491
|
-
if (val !== "true" && val !== "false" && val !== "mixed") {
|
|
1492
|
-
issues.push({
|
|
1493
|
-
type: "warning",
|
|
1494
|
-
rule: "WCAG 4.1.2 ARIA State Values",
|
|
1495
|
-
element: clip(el.outerHTML),
|
|
1496
|
-
message: `aria-checked must be "true", "false", or "mixed" (for tri-state), got "${val}"`,
|
|
1497
|
-
fix: el.outerHTML.replace(
|
|
1498
|
-
`aria-checked="${val}"`,
|
|
1499
|
-
'aria-checked="false"'
|
|
1500
|
-
)
|
|
1501
|
-
});
|
|
1502
|
-
}
|
|
1503
|
-
});
|
|
1504
|
-
const VALID_ARIA_LIVE = /* @__PURE__ */ new Set(["off", "polite", "assertive"]);
|
|
1505
|
-
root.querySelectorAll("[aria-live]").forEach((el) => {
|
|
1506
|
-
const val = el.getAttribute("aria-live") ?? "";
|
|
1507
|
-
if (!VALID_ARIA_LIVE.has(val)) {
|
|
1508
|
-
issues.push({
|
|
1509
|
-
type: "warning",
|
|
1510
|
-
rule: "WCAG 4.1.2 ARIA State Values",
|
|
1511
|
-
element: clip(el.outerHTML),
|
|
1512
|
-
message: `aria-live="${val}" is not valid - use "off", "polite", or "assertive"`,
|
|
1513
|
-
fix: el.outerHTML.replace(`aria-live="${val}"`, 'aria-live="polite"')
|
|
1514
|
-
});
|
|
1515
|
-
}
|
|
1516
|
-
});
|
|
1517
|
-
return { ruleName, issues };
|
|
1518
|
-
}
|
|
1519
|
-
function checkDuplicateIds(root) {
|
|
1520
|
-
const ruleName = "WCAG 4.1.1 Parsing";
|
|
1521
|
-
const issues = [];
|
|
1522
|
-
const seen = /* @__PURE__ */ new Map();
|
|
1523
|
-
root.querySelectorAll("[id]").forEach((el) => {
|
|
1524
|
-
const id = el.getAttribute("id");
|
|
1525
|
-
if (id !== void 0 && id.trim() === "") {
|
|
1526
|
-
issues.push({
|
|
1527
|
-
type: "error",
|
|
1528
|
-
rule: ruleName,
|
|
1529
|
-
element: clip(el.outerHTML),
|
|
1530
|
-
message: 'id="" is an empty ID - IDs must have a non-empty value',
|
|
1531
|
-
fix: el.outerHTML.replace('id=""', 'id="[unique-id]"')
|
|
1532
|
-
});
|
|
1533
|
-
return;
|
|
1534
|
-
}
|
|
1535
|
-
if (id) seen.set(id, (seen.get(id) ?? 0) + 1);
|
|
1536
|
-
});
|
|
1537
|
-
seen.forEach((count, id) => {
|
|
1538
|
-
if (count > 1) {
|
|
1539
|
-
issues.push({
|
|
1540
|
-
type: "error",
|
|
1541
|
-
rule: ruleName,
|
|
1542
|
-
element: `id="${id}"`,
|
|
1543
|
-
message: `id="${id}" is used ${count} times - IDs must be unique within a document`,
|
|
1544
|
-
fix: `Keep one element with id="${id}" and rename all duplicates to unique IDs`
|
|
1545
|
-
});
|
|
1546
|
-
}
|
|
1547
|
-
});
|
|
1548
|
-
return { ruleName, issues };
|
|
1549
|
-
}
|
|
1550
|
-
function checkTabIndex(root) {
|
|
1551
|
-
const ruleName = "WCAG 2.1.1 Keyboard";
|
|
1552
|
-
const issues = [];
|
|
1553
|
-
root.querySelectorAll("[tabindex]").forEach((el) => {
|
|
1554
|
-
const raw = el.getAttribute("tabindex") ?? "";
|
|
1555
|
-
const val = parseInt(raw, 10);
|
|
1556
|
-
if (isNaN(val)) {
|
|
1557
|
-
issues.push({
|
|
1558
|
-
type: "warning",
|
|
1559
|
-
rule: ruleName,
|
|
1560
|
-
element: clip(el.outerHTML),
|
|
1561
|
-
message: `tabindex="${raw}" is not a valid integer`,
|
|
1562
|
-
fix: el.outerHTML.replace(`tabindex="${raw}"`, 'tabindex="0"')
|
|
1563
|
-
});
|
|
1564
|
-
return;
|
|
1565
|
-
}
|
|
1566
|
-
if (val > 0) {
|
|
1567
|
-
issues.push({
|
|
1568
|
-
type: "warning",
|
|
1569
|
-
rule: ruleName,
|
|
1570
|
-
element: clip(el.outerHTML),
|
|
1571
|
-
message: `tabindex="${val}" disrupts natural tab order - use tabindex="0" or rely on DOM order`,
|
|
1572
|
-
fix: el.outerHTML.replace(`tabindex="${val}"`, 'tabindex="0"')
|
|
1573
|
-
});
|
|
1574
|
-
}
|
|
1575
|
-
});
|
|
1576
|
-
return { ruleName, issues };
|
|
1577
|
-
}
|
|
1578
|
-
function checkHeadings(root) {
|
|
1579
|
-
const ruleName = "WCAG 2.4.6 Heading Hierarchy";
|
|
1580
|
-
const issues = [];
|
|
1581
|
-
const headings = root.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
|
1582
|
-
let lastLevel = 0;
|
|
1583
|
-
let h1Count = 0;
|
|
1584
|
-
headings.forEach((h) => {
|
|
1585
|
-
const tag = h.tagName.toLowerCase();
|
|
1586
|
-
const level = parseInt(h.tagName.slice(1), 10);
|
|
1587
|
-
if (!h.text.trim()) {
|
|
1588
|
-
issues.push({
|
|
1589
|
-
type: "error",
|
|
1590
|
-
rule: ruleName,
|
|
1591
|
-
element: clip(h.outerHTML),
|
|
1592
|
-
message: `<${tag}> is empty - headings must have descriptive text`,
|
|
1593
|
-
fix: `<${tag}>[Heading text]</${tag}>`
|
|
1594
|
-
});
|
|
1595
|
-
}
|
|
1596
|
-
if (level === 1) h1Count++;
|
|
1597
|
-
if (lastLevel > 0 && level > lastLevel + 1) {
|
|
1598
|
-
issues.push({
|
|
1599
|
-
type: "warning",
|
|
1600
|
-
rule: ruleName,
|
|
1601
|
-
element: clip(h.outerHTML),
|
|
1602
|
-
message: `Heading skips from h${lastLevel} to h${level} - use h${lastLevel + 1} to maintain document outline`,
|
|
1603
|
-
fix: `<h${lastLevel + 1}>${h.text.trim()}</h${lastLevel + 1}>`
|
|
1604
|
-
});
|
|
1605
|
-
}
|
|
1606
|
-
lastLevel = level;
|
|
1607
|
-
});
|
|
1608
|
-
if (h1Count > 1) {
|
|
1609
|
-
issues.push({
|
|
1610
|
-
type: "warning",
|
|
1611
|
-
rule: ruleName,
|
|
1612
|
-
element: "h1",
|
|
1613
|
-
message: `${h1Count} <h1> elements found - a page should have exactly one <h1>`,
|
|
1614
|
-
fix: "Demote additional <h1> elements to <h2> or lower"
|
|
1615
|
-
});
|
|
1616
|
-
}
|
|
1617
|
-
return { ruleName, issues };
|
|
1618
|
-
}
|
|
1619
|
-
function checkTables(root) {
|
|
1620
|
-
const ruleName = "WCAG 1.3.1 Table Structure";
|
|
1621
|
-
const issues = [];
|
|
1622
|
-
root.querySelectorAll("table").forEach((table) => {
|
|
1623
|
-
const role = table.getAttribute("role");
|
|
1624
|
-
if (role === "presentation" || role === "none") return;
|
|
1625
|
-
const caption = table.querySelector("caption");
|
|
1626
|
-
const ariaLabel = table.getAttribute("aria-label");
|
|
1627
|
-
const ariaLabelledBy = table.getAttribute("aria-labelledby");
|
|
1628
|
-
if (!caption && !ariaLabel && !ariaLabelledBy) {
|
|
1629
|
-
issues.push({
|
|
1630
|
-
type: "warning",
|
|
1631
|
-
rule: ruleName,
|
|
1632
|
-
element: "<table>",
|
|
1633
|
-
message: "<table> has no caption, aria-label, or aria-labelledby - screen readers cannot identify its purpose",
|
|
1634
|
-
fix: injectAttr("<table>", "aria-label", "[Describe the table]")
|
|
1635
|
-
});
|
|
1636
|
-
}
|
|
1637
|
-
if (caption && !caption.text.trim()) {
|
|
1638
|
-
issues.push({
|
|
1639
|
-
type: "warning",
|
|
1640
|
-
rule: ruleName,
|
|
1641
|
-
element: clip(caption.outerHTML),
|
|
1642
|
-
message: "<caption> is empty - provide a meaningful description of the table",
|
|
1643
|
-
fix: caption.outerHTML.replace(
|
|
1644
|
-
"</caption>",
|
|
1645
|
-
"[Table description]</caption>"
|
|
1646
|
-
)
|
|
1647
|
-
});
|
|
1648
|
-
}
|
|
1649
|
-
table.querySelectorAll("th").forEach((th) => {
|
|
1650
|
-
if (!th.getAttribute("scope")) {
|
|
1651
|
-
issues.push({
|
|
1652
|
-
type: "warning",
|
|
1653
|
-
rule: ruleName,
|
|
1654
|
-
element: clip(th.outerHTML),
|
|
1655
|
-
message: '<th> is missing the scope attribute - use scope="col" for column headers or scope="row" for row headers',
|
|
1656
|
-
fix: injectAttr(th.outerHTML, "scope", "col")
|
|
1657
|
-
});
|
|
1658
|
-
}
|
|
1659
|
-
});
|
|
1660
|
-
});
|
|
1661
|
-
return { ruleName, issues };
|
|
1662
|
-
}
|
|
1663
|
-
function checkDialogs(root) {
|
|
1664
|
-
const ruleName = "WCAG 4.1.2 Dialog Accessibility";
|
|
1665
|
-
const issues = [];
|
|
1666
|
-
root.querySelectorAll("dialog").forEach((dialog) => {
|
|
1667
|
-
const id = dialog.getAttribute("id");
|
|
1668
|
-
const ariaLabel = dialog.getAttribute("aria-label");
|
|
1669
|
-
const ariaLabelledBy = dialog.getAttribute("aria-labelledby");
|
|
1670
|
-
if (!id) {
|
|
1671
|
-
issues.push({
|
|
1672
|
-
type: "warning",
|
|
1673
|
-
rule: ruleName,
|
|
1674
|
-
element: "<dialog>",
|
|
1675
|
-
message: '<dialog> has no id - required by the ignix-lite button[onclick="dialogId.showModal()"] pattern',
|
|
1676
|
-
fix: injectAttr("<dialog>", "id", "dialog-id")
|
|
1677
|
-
});
|
|
1678
|
-
}
|
|
1679
|
-
if (!ariaLabel && !ariaLabelledBy) {
|
|
1680
|
-
issues.push({
|
|
1681
|
-
type: "warning",
|
|
1682
|
-
rule: ruleName,
|
|
1683
|
-
element: "<dialog>",
|
|
1684
|
-
message: "<dialog> has no accessible name - add aria-labelledby pointing to a heading inside, or aria-label",
|
|
1685
|
-
fix: id ? `<dialog id="${id}" aria-labelledby="dialog-title">...</dialog>` : `<dialog aria-label="[Dialog purpose]">...</dialog>`
|
|
1686
|
-
});
|
|
1687
|
-
} else if (ariaLabelledBy) {
|
|
1688
|
-
const broken = findBrokenAriaRefs(dialog, "aria-labelledby", root);
|
|
1689
|
-
if (broken.length > 0) {
|
|
1690
|
-
issues.push({
|
|
1691
|
-
type: "error",
|
|
1692
|
-
rule: ruleName,
|
|
1693
|
-
element: "<dialog>",
|
|
1694
|
-
message: `dialog aria-labelledby references non-existent element(s): ${broken.map((id2) => `#${id2}`).join(", ")}`,
|
|
1695
|
-
fix: `Ensure element(s) with id="${broken.join('", "')}" exist inside the dialog`
|
|
1696
|
-
});
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
});
|
|
1700
|
-
return { ruleName, issues };
|
|
1701
|
-
}
|
|
1702
|
-
function checkRoles(root) {
|
|
1703
|
-
const ruleName = "WCAG 4.1.2 ARIA Role Requirements";
|
|
1704
|
-
const issues = [];
|
|
1705
|
-
const NATIVELY_INTERACTIVE = /* @__PURE__ */ new Set([
|
|
1706
|
-
"a",
|
|
1707
|
-
"button",
|
|
1708
|
-
"input",
|
|
1709
|
-
"select",
|
|
1710
|
-
"textarea",
|
|
1711
|
-
"details",
|
|
1712
|
-
"summary"
|
|
1713
|
-
]);
|
|
1714
|
-
root.querySelectorAll('[role="button"]').forEach((el) => {
|
|
1715
|
-
const tag = el.tagName.toLowerCase();
|
|
1716
|
-
const tabIndex = el.getAttribute("tabindex");
|
|
1717
|
-
if (!NATIVELY_INTERACTIVE.has(tag) && tabIndex !== "0") {
|
|
1718
|
-
issues.push({
|
|
1719
|
-
type: "error",
|
|
1720
|
-
rule: ruleName,
|
|
1721
|
-
element: clip(el.outerHTML),
|
|
1722
|
-
message: 'Element with role="button" must have tabindex="0" to be keyboard-accessible',
|
|
1723
|
-
fix: injectAttr(el.outerHTML, "tabindex", "0")
|
|
1724
|
-
});
|
|
1725
|
-
}
|
|
1726
|
-
});
|
|
1727
|
-
for (const role of [
|
|
1728
|
-
"checkbox",
|
|
1729
|
-
"radio",
|
|
1730
|
-
"menuitemcheckbox",
|
|
1731
|
-
"menuitemradio"
|
|
1732
|
-
]) {
|
|
1733
|
-
root.querySelectorAll(`[role="${role}"]`).forEach((el) => {
|
|
1734
|
-
if (!el.getAttribute("aria-checked")) {
|
|
1735
|
-
issues.push({
|
|
1736
|
-
type: "error",
|
|
1737
|
-
rule: ruleName,
|
|
1738
|
-
element: clip(el.outerHTML),
|
|
1739
|
-
message: `role="${role}" requires aria-checked attribute (values: "true", "false"${role === "checkbox" ? ', "mixed"' : ""})`,
|
|
1740
|
-
fix: injectAttr(el.outerHTML, "aria-checked", "false")
|
|
1741
|
-
});
|
|
1742
|
-
}
|
|
1743
|
-
});
|
|
1744
|
-
}
|
|
1745
|
-
root.querySelectorAll('[role="combobox"]').forEach((el) => {
|
|
1746
|
-
if (!el.getAttribute("aria-expanded")) {
|
|
1747
|
-
issues.push({
|
|
1748
|
-
type: "error",
|
|
1749
|
-
rule: ruleName,
|
|
1750
|
-
element: clip(el.outerHTML),
|
|
1751
|
-
message: 'role="combobox" requires aria-expanded attribute',
|
|
1752
|
-
fix: injectAttr(el.outerHTML, "aria-expanded", "false")
|
|
1753
|
-
});
|
|
1754
|
-
}
|
|
1755
|
-
});
|
|
1756
|
-
for (const role of ["tab", "option", "treeitem", "gridcell"]) {
|
|
1757
|
-
root.querySelectorAll(`[role="${role}"]`).forEach((el) => {
|
|
1758
|
-
if (!el.getAttribute("aria-selected")) {
|
|
1759
|
-
issues.push({
|
|
1760
|
-
type: "error",
|
|
1761
|
-
rule: ruleName,
|
|
1762
|
-
element: clip(el.outerHTML),
|
|
1763
|
-
message: `role="${role}" requires aria-selected attribute`,
|
|
1764
|
-
fix: injectAttr(el.outerHTML, "aria-selected", "false")
|
|
1765
|
-
});
|
|
1766
|
-
}
|
|
1767
|
-
});
|
|
1768
|
-
}
|
|
1769
|
-
root.querySelectorAll('[role="slider"]').forEach((el) => {
|
|
1770
|
-
const required = ["aria-valuenow", "aria-valuemin", "aria-valuemax"];
|
|
1771
|
-
const missing = required.filter((attr) => !el.getAttribute(attr));
|
|
1772
|
-
if (missing.length > 0) {
|
|
1773
|
-
issues.push({
|
|
1774
|
-
type: "error",
|
|
1775
|
-
rule: ruleName,
|
|
1776
|
-
element: clip(el.outerHTML),
|
|
1777
|
-
message: `role="slider" is missing required attributes: ${missing.join(", ")}`,
|
|
1778
|
-
fix: 'Add aria-valuenow="50" aria-valuemin="0" aria-valuemax="100" to the element'
|
|
1779
|
-
});
|
|
1780
|
-
}
|
|
1781
|
-
});
|
|
1782
|
-
root.querySelectorAll('[role="progressbar"]').forEach((el) => {
|
|
1783
|
-
if (!el.getAttribute("aria-valuenow") && !el.getAttribute("aria-valuetext")) {
|
|
1784
|
-
issues.push({
|
|
1785
|
-
type: "warning",
|
|
1786
|
-
rule: ruleName,
|
|
1787
|
-
element: clip(el.outerHTML),
|
|
1788
|
-
message: 'role="progressbar" should have aria-valuenow or aria-valuetext to communicate current progress',
|
|
1789
|
-
fix: injectAttr(el.outerHTML, "aria-valuenow", "0")
|
|
1790
|
-
});
|
|
1791
|
-
}
|
|
1792
|
-
});
|
|
1793
|
-
root.querySelectorAll('[role="listbox"]').forEach((el) => {
|
|
1794
|
-
if (!getAccessibleName(el, root)) {
|
|
1795
|
-
issues.push({
|
|
1796
|
-
type: "warning",
|
|
1797
|
-
rule: ruleName,
|
|
1798
|
-
element: clip(el.outerHTML),
|
|
1799
|
-
message: 'role="listbox" should have an accessible name via aria-label or aria-labelledby',
|
|
1800
|
-
fix: injectAttr(
|
|
1801
|
-
el.outerHTML,
|
|
1802
|
-
"aria-label",
|
|
1803
|
-
"[Describe the list options]"
|
|
1804
|
-
)
|
|
1805
|
-
});
|
|
1806
|
-
}
|
|
1807
|
-
});
|
|
1808
|
-
return { ruleName, issues };
|
|
1809
|
-
}
|
|
1810
|
-
function checkAutocomplete(root) {
|
|
1811
|
-
const ruleName = "WCAG 1.3.5 Input Purpose";
|
|
1812
|
-
const issues = [];
|
|
1813
|
-
const TYPE_AUTOCOMPLETE = {
|
|
1814
|
-
email: "email",
|
|
1815
|
-
tel: "tel",
|
|
1816
|
-
url: "url"
|
|
1817
|
-
};
|
|
1818
|
-
root.querySelectorAll("input").forEach((input) => {
|
|
1819
|
-
const type = (input.getAttribute("type") ?? "text").toLowerCase();
|
|
1820
|
-
const expected = TYPE_AUTOCOMPLETE[type];
|
|
1821
|
-
if (expected && !input.getAttribute("autocomplete")) {
|
|
1822
|
-
issues.push({
|
|
1823
|
-
type: "warning",
|
|
1824
|
-
rule: ruleName,
|
|
1825
|
-
element: clip(input.outerHTML),
|
|
1826
|
-
message: `<input type="${type}"> should have autocomplete="${expected}" to assist users with autofill`,
|
|
1827
|
-
fix: injectAttr(input.outerHTML, "autocomplete", expected)
|
|
1828
|
-
});
|
|
1829
|
-
}
|
|
1830
|
-
if (type === "password" && !input.getAttribute("autocomplete")) {
|
|
1831
|
-
issues.push({
|
|
1832
|
-
type: "warning",
|
|
1833
|
-
rule: ruleName,
|
|
1834
|
-
element: clip(input.outerHTML),
|
|
1835
|
-
message: '<input type="password"> should have autocomplete="current-password" or autocomplete="new-password"',
|
|
1836
|
-
fix: injectAttr(input.outerHTML, "autocomplete", "current-password")
|
|
1837
|
-
});
|
|
1838
|
-
}
|
|
1839
|
-
});
|
|
1840
|
-
return { ruleName, issues };
|
|
1841
|
-
}
|
|
1842
|
-
function checkFocusStyle(root) {
|
|
1843
|
-
const ruleName = "WCAG 2.4.7 Focus Visible";
|
|
1844
|
-
const issues = [];
|
|
1845
|
-
const KILLS_FOCUS = [
|
|
1846
|
-
/outline\s*:\s*none/i,
|
|
1847
|
-
/outline\s*:\s*0(?:px)?/i,
|
|
1848
|
-
/outline-width\s*:\s*0/i
|
|
1849
|
-
];
|
|
1850
|
-
root.querySelectorAll("[style]").forEach((el) => {
|
|
1851
|
-
const style = el.getAttribute("style") ?? "";
|
|
1852
|
-
if (KILLS_FOCUS.some((rx) => rx.test(style))) {
|
|
1853
|
-
issues.push({
|
|
1854
|
-
type: "warning",
|
|
1855
|
-
rule: ruleName,
|
|
1856
|
-
element: clip(el.outerHTML),
|
|
1857
|
-
message: "Inline style removes the focus outline - keyboard users cannot see the focus indicator",
|
|
1858
|
-
fix: el.outerHTML.replace(/outline\s*:\s*(none|0(?:px)?)\s*;?/gi, "").replace(/outline-width\s*:\s*0\s*;?/gi, "")
|
|
1859
|
-
});
|
|
1860
|
-
}
|
|
1861
|
-
});
|
|
1862
|
-
return { ruleName, issues };
|
|
1863
|
-
}
|
|
1864
|
-
function checkLang(root) {
|
|
1865
|
-
const ruleName = "WCAG 3.1.1 Language of Page";
|
|
1866
|
-
const issues = [];
|
|
1867
|
-
const htmlEl = root.querySelector("html");
|
|
1868
|
-
if (htmlEl && !htmlEl.getAttribute("lang")?.trim()) {
|
|
1869
|
-
issues.push({
|
|
1870
|
-
type: "error",
|
|
1871
|
-
rule: ruleName,
|
|
1872
|
-
element: "<html>",
|
|
1873
|
-
message: "<html> is missing the lang attribute - screen readers need this to select the correct voice/language",
|
|
1874
|
-
fix: '<html lang="en">'
|
|
1875
|
-
});
|
|
1876
|
-
}
|
|
1877
|
-
return { ruleName, issues };
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
// src/tools/check-a11y.ts
|
|
1881
|
-
var RULE_CONFIDENCES = {
|
|
1882
|
-
"WCAG 1.1.1 Non-text Content": { error: 0.99, warning: 0.8 },
|
|
1883
|
-
"WCAG 1.3.1 Form Labels": { error: 0.98, warning: 0.78 },
|
|
1884
|
-
"WCAG 2.4.6 Empty Labels": { error: 0.95, warning: 0.75 },
|
|
1885
|
-
"WCAG 4.1.2 Button Names": { error: 0.99, warning: 0.8 },
|
|
1886
|
-
"WCAG 2.4.4 Link Purpose": { error: 0.97, warning: 0.75 },
|
|
1887
|
-
"WCAG 3.3.1 Error Identification": { error: 0.98, warning: 0.75 },
|
|
1888
|
-
"WCAG 4.1.2 ARIA State Values": { error: 0.96, warning: 0.75 },
|
|
1889
|
-
"WCAG 4.1.1 Parsing": { error: 0.99, warning: 0.8 },
|
|
1890
|
-
"WCAG 2.1.1 Keyboard": { error: 0.95, warning: 0.75 },
|
|
1891
|
-
"WCAG 2.4.6 Heading Hierarchy": { error: 0.95, warning: 0.75 },
|
|
1892
|
-
"WCAG 1.3.1 Table Structure": { error: 0.97, warning: 0.75 },
|
|
1893
|
-
"WCAG 4.1.2 Dialog Accessibility": { error: 0.98, warning: 0.75 },
|
|
1894
|
-
"WCAG 4.1.2 ARIA Role Requirements": { error: 0.98, warning: 0.75 },
|
|
1895
|
-
"WCAG 1.3.5 Input Purpose": { error: 0.95, warning: 0.75 },
|
|
1896
|
-
"WCAG 2.4.7 Focus Visible": { error: 0.95, warning: 0.75 },
|
|
1897
|
-
"WCAG 3.1.1 Language of Page": { error: 0.99, warning: 0.8 }
|
|
1898
|
-
};
|
|
1899
|
-
function getConfidenceForRule(ruleName, type) {
|
|
1900
|
-
const conf = RULE_CONFIDENCES[ruleName];
|
|
1901
|
-
if (conf) {
|
|
1902
|
-
return type === "error" ? conf.error : conf.warning;
|
|
1903
|
-
}
|
|
1904
|
-
return type === "error" ? 0.98 : 0.75;
|
|
1905
|
-
}
|
|
1906
|
-
function computeScore(issues) {
|
|
1907
|
-
const errors = issues.filter((i) => i.type === "error").length;
|
|
1908
|
-
const warnings = issues.filter((i) => i.type === "warning").length;
|
|
1909
|
-
return Math.max(0, 100 - errors * 10 - warnings * 3);
|
|
1910
|
-
}
|
|
1911
|
-
function getPassingRules(results) {
|
|
1912
|
-
return results.filter((r) => r.issues.length === 0).map((r) => r.ruleName);
|
|
1913
|
-
}
|
|
1914
|
-
function checkA11y(html) {
|
|
1915
|
-
const root = parse2(html);
|
|
1916
|
-
const results = [
|
|
1917
|
-
checkImages(root),
|
|
1918
|
-
checkFormLabels(root),
|
|
1919
|
-
checkEmptyLabels(root),
|
|
1920
|
-
checkButtons(root),
|
|
1921
|
-
checkLinks(root),
|
|
1922
|
-
checkAriaStates(root),
|
|
1923
|
-
checkDuplicateIds(root),
|
|
1924
|
-
checkTabIndex(root),
|
|
1925
|
-
checkHeadings(root),
|
|
1926
|
-
checkTables(root),
|
|
1927
|
-
checkDialogs(root),
|
|
1928
|
-
checkRoles(root),
|
|
1929
|
-
checkAutocomplete(root),
|
|
1930
|
-
checkFocusStyle(root),
|
|
1931
|
-
checkLang(root)
|
|
1932
|
-
];
|
|
1933
|
-
const rawIssues = results.flatMap((r) => r.issues);
|
|
1934
|
-
const issues = rawIssues.map((i) => ({
|
|
1935
|
-
...i,
|
|
1936
|
-
confidence: getConfidenceForRule(i.rule, i.type)
|
|
1937
|
-
}));
|
|
1938
|
-
const passes = getPassingRules(results);
|
|
1939
|
-
const score = computeScore(issues);
|
|
1940
|
-
return {
|
|
1941
|
-
content: [
|
|
1942
|
-
{
|
|
1943
|
-
type: "text",
|
|
1944
|
-
text: JSON.stringify({
|
|
1945
|
-
score,
|
|
1946
|
-
passes,
|
|
1947
|
-
issues,
|
|
1948
|
-
wcag: "AA",
|
|
1949
|
-
tokens_used: Math.min(60, 20 + issues.length * 3)
|
|
1950
|
-
})
|
|
1951
|
-
}
|
|
1952
|
-
]
|
|
1953
|
-
};
|
|
17
|
+
var apiPath = path.resolve(__dirname, "../../../api-full.txt");
|
|
18
|
+
if (!existsSync(apiPath)) {
|
|
19
|
+
apiPath = path.resolve(__dirname, "../../../../api-full.txt");
|
|
1954
20
|
}
|
|
21
|
+
var apiContext = readFileSync(apiPath, "utf8");
|
|
1955
22
|
|
|
1956
23
|
// src/server.ts
|
|
24
|
+
import {
|
|
25
|
+
listComponents,
|
|
26
|
+
getManifest,
|
|
27
|
+
getEmmet,
|
|
28
|
+
validateHtml,
|
|
29
|
+
howToBuild,
|
|
30
|
+
generateTheme,
|
|
31
|
+
auditA11y,
|
|
32
|
+
preview,
|
|
33
|
+
getTokenSummary,
|
|
34
|
+
recordCall,
|
|
35
|
+
createHandoff,
|
|
36
|
+
applyHandoff
|
|
37
|
+
} from "@mindfiredigital/ignix-lite-engine";
|
|
1957
38
|
var server = new Server(
|
|
1958
39
|
{
|
|
1959
40
|
name: "ignix-lite",
|
|
@@ -2052,35 +133,176 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2052
133
|
},
|
|
2053
134
|
required: ["html"]
|
|
2054
135
|
}
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "preview",
|
|
139
|
+
description: "Render visual preview to PNG",
|
|
140
|
+
inputSchema: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: {
|
|
143
|
+
input: {
|
|
144
|
+
type: "string"
|
|
145
|
+
},
|
|
146
|
+
options: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: {
|
|
149
|
+
width: {
|
|
150
|
+
type: "number"
|
|
151
|
+
},
|
|
152
|
+
theme: {
|
|
153
|
+
type: "string"
|
|
154
|
+
},
|
|
155
|
+
scale: {
|
|
156
|
+
type: "number"
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
required: ["input"]
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "get_token_summary",
|
|
166
|
+
description: "Get session token summary and budget details",
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
context_window: {
|
|
171
|
+
type: "number",
|
|
172
|
+
description: "Optional model context window size"
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: "create_handoff",
|
|
179
|
+
description: "Create multi-agent handoff snapshot",
|
|
180
|
+
inputSchema: {
|
|
181
|
+
type: "object",
|
|
182
|
+
properties: {
|
|
183
|
+
rendered_html: {
|
|
184
|
+
type: "string"
|
|
185
|
+
},
|
|
186
|
+
metadata: {
|
|
187
|
+
type: "object"
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
required: ["rendered_html"]
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "apply_handoff",
|
|
195
|
+
description: "Apply changes to an existing handoff snapshot",
|
|
196
|
+
inputSchema: {
|
|
197
|
+
type: "object",
|
|
198
|
+
properties: {
|
|
199
|
+
handoff_id: {
|
|
200
|
+
type: "string"
|
|
201
|
+
},
|
|
202
|
+
changes: {
|
|
203
|
+
type: "array",
|
|
204
|
+
items: {
|
|
205
|
+
type: "object",
|
|
206
|
+
properties: {
|
|
207
|
+
selector: {
|
|
208
|
+
type: "string"
|
|
209
|
+
},
|
|
210
|
+
action: {
|
|
211
|
+
type: "string",
|
|
212
|
+
enum: ["update", "add", "remove"]
|
|
213
|
+
},
|
|
214
|
+
emmet: {
|
|
215
|
+
type: "string"
|
|
216
|
+
},
|
|
217
|
+
html: {
|
|
218
|
+
type: "string"
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
required: ["selector", "action"]
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
required: ["handoff_id", "changes"]
|
|
226
|
+
}
|
|
2055
227
|
}
|
|
2056
228
|
]
|
|
2057
229
|
}));
|
|
2058
230
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2059
231
|
const { name, arguments: args } = request.params;
|
|
232
|
+
console.error("TOOL CALLED:", name);
|
|
233
|
+
let response;
|
|
2060
234
|
switch (name) {
|
|
2061
235
|
case "list_components":
|
|
2062
|
-
|
|
236
|
+
response = listComponents();
|
|
237
|
+
break;
|
|
2063
238
|
case "get_manifest":
|
|
2064
|
-
|
|
239
|
+
response = getManifest(args);
|
|
240
|
+
break;
|
|
2065
241
|
case "get_emmet":
|
|
2066
|
-
|
|
242
|
+
response = getEmmet(args);
|
|
243
|
+
break;
|
|
2067
244
|
case "validate": {
|
|
2068
245
|
const validateArgs = args;
|
|
2069
|
-
|
|
246
|
+
const result = validateHtml(validateArgs.html ?? "");
|
|
247
|
+
response = {
|
|
248
|
+
content: [
|
|
249
|
+
{
|
|
250
|
+
type: "text",
|
|
251
|
+
text: JSON.stringify({
|
|
252
|
+
...result,
|
|
253
|
+
tokens_used: 50
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
]
|
|
257
|
+
};
|
|
258
|
+
break;
|
|
2070
259
|
}
|
|
2071
260
|
case "how_to_build": {
|
|
2072
261
|
const intentArgs = args;
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
case "generate_theme": {
|
|
2076
|
-
return generateTheme(args);
|
|
262
|
+
response = await howToBuild(intentArgs.description ?? "");
|
|
263
|
+
break;
|
|
2077
264
|
}
|
|
265
|
+
case "generate_theme":
|
|
266
|
+
response = generateTheme(args);
|
|
267
|
+
break;
|
|
2078
268
|
case "check_a11y": {
|
|
2079
269
|
const a11yArgs = args;
|
|
2080
|
-
|
|
270
|
+
const result = auditA11y(a11yArgs.html ?? "");
|
|
271
|
+
response = {
|
|
272
|
+
content: [
|
|
273
|
+
{
|
|
274
|
+
type: "text",
|
|
275
|
+
text: JSON.stringify({
|
|
276
|
+
...result,
|
|
277
|
+
tokens_used: Math.min(60, 20 + result.issues.length * 3)
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
]
|
|
281
|
+
};
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case "preview": {
|
|
285
|
+
const previewArgs = args;
|
|
286
|
+
response = await preview(previewArgs);
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
case "get_token_summary": {
|
|
290
|
+
const tokenArgs = args;
|
|
291
|
+
response = getTokenSummary(tokenArgs);
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
case "create_handoff": {
|
|
295
|
+
const handoffArgs = args;
|
|
296
|
+
response = createHandoff(handoffArgs);
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case "apply_handoff": {
|
|
300
|
+
const applyArgs = args;
|
|
301
|
+
response = applyHandoff(applyArgs);
|
|
302
|
+
break;
|
|
2081
303
|
}
|
|
2082
304
|
default:
|
|
2083
|
-
|
|
305
|
+
response = {
|
|
2084
306
|
content: [
|
|
2085
307
|
{
|
|
2086
308
|
type: "text",
|
|
@@ -2090,14 +312,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2090
312
|
}
|
|
2091
313
|
]
|
|
2092
314
|
};
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
if (response && response.content && response.content[0] && response.content[0].text) {
|
|
318
|
+
try {
|
|
319
|
+
const parsed = JSON.parse(response.content[0].text);
|
|
320
|
+
if (parsed && typeof parsed.tokens_used === "number") {
|
|
321
|
+
recordCall(name, parsed.tokens_used);
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
}
|
|
2093
325
|
}
|
|
326
|
+
return response;
|
|
2094
327
|
});
|
|
2095
328
|
async function start() {
|
|
2096
|
-
console.
|
|
2097
|
-
console.
|
|
329
|
+
console.error("API loaded");
|
|
330
|
+
console.error(apiContext.length);
|
|
2098
331
|
const transport = new StdioServerTransport();
|
|
2099
332
|
await server.connect(transport);
|
|
2100
|
-
console.
|
|
333
|
+
console.error("Ignix MCP started");
|
|
2101
334
|
}
|
|
2102
335
|
start();
|
|
2103
336
|
//# sourceMappingURL=server.js.map
|