@monoharada/wcf-mcp 0.1.1 → 0.2.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 +401 -15
- package/bin.mjs +19 -2
- package/core.mjs +1897 -157
- package/data/custom-elements.json +18 -0
- package/data/design-tokens.json +1708 -2
- package/data/guidelines-index.json +589 -3
- package/data/llms-full.txt +5291 -0
- package/examples/plugins/custom-validation-plugin.mjs +70 -0
- package/package.json +4 -2
- package/server.mjs +183 -5
- package/validator.mjs +633 -5
- package/wcf-mcp.config.example.json +24 -0
package/validator.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Only includes the two functions used by the MCP server:
|
|
4
4
|
* - collectCemCustomElements
|
|
5
5
|
* - validateTextAgainstCem
|
|
6
|
+
* - detectTokenMisuseInInlineStyles
|
|
7
|
+
* - detectAccessibilityMisuseInMarkup
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
const GLOBAL_ATTR_ALLOW_PREFIXES = Object.freeze(['aria-', 'data-']);
|
|
@@ -25,6 +27,15 @@ const GLOBAL_ATTR_ALLOW_SET = Object.freeze(
|
|
|
25
27
|
);
|
|
26
28
|
|
|
27
29
|
const FORBIDDEN_ATTR_SET = Object.freeze(new Set(['placeholder']));
|
|
30
|
+
const TOKEN_MISUSE_STYLE_PROPS = Object.freeze(new Set([
|
|
31
|
+
'color',
|
|
32
|
+
'background-color',
|
|
33
|
+
'padding',
|
|
34
|
+
'padding-top',
|
|
35
|
+
'padding-right',
|
|
36
|
+
'padding-bottom',
|
|
37
|
+
'padding-left',
|
|
38
|
+
]));
|
|
28
39
|
|
|
29
40
|
function isForbiddenAttr(attrName) {
|
|
30
41
|
return FORBIDDEN_ATTR_SET.has(attrName.toLowerCase());
|
|
@@ -80,6 +91,130 @@ export function collectCemCustomElements(manifest) {
|
|
|
80
91
|
return byTag;
|
|
81
92
|
}
|
|
82
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Parse a CEM type.text union like "'solid' | 'outlined' | 'text'" into a Set of valid values.
|
|
96
|
+
* Only handles string literal unions. Returns undefined for non-enum types.
|
|
97
|
+
* @param {string} typeText
|
|
98
|
+
* @returns {Set<string> | undefined}
|
|
99
|
+
*/
|
|
100
|
+
function parseEnumTypeText(typeText) {
|
|
101
|
+
if (typeof typeText !== 'string' || !typeText) return undefined;
|
|
102
|
+
// Must contain at least one single-quoted value
|
|
103
|
+
const literals = typeText.match(/'([^']*)'/g);
|
|
104
|
+
if (!literals || literals.length === 0) return undefined;
|
|
105
|
+
// All parts separated by | must be quoted literals (allow whitespace)
|
|
106
|
+
const parts = typeText.split('|').map((s) => s.trim());
|
|
107
|
+
for (const part of parts) {
|
|
108
|
+
if (!/^'[^']*'$/.test(part)) return undefined;
|
|
109
|
+
}
|
|
110
|
+
const values = new Set();
|
|
111
|
+
for (const lit of literals) {
|
|
112
|
+
values.add(lit.slice(1, -1));
|
|
113
|
+
}
|
|
114
|
+
return values.size > 0 ? values : undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build a map of enum attributes from the CEM manifest.
|
|
119
|
+
* Returns Map<tagName, Map<attrName, Set<validValues>>>
|
|
120
|
+
* @param {object} manifest
|
|
121
|
+
* @returns {Map<string, Map<string, Set<string>>>}
|
|
122
|
+
*/
|
|
123
|
+
export function buildEnumAttributeMap(manifest) {
|
|
124
|
+
const result = new Map();
|
|
125
|
+
|
|
126
|
+
const modules = Array.isArray(manifest?.modules) ? manifest.modules : [];
|
|
127
|
+
for (const mod of modules) {
|
|
128
|
+
const declarations = Array.isArray(mod?.declarations) ? mod.declarations : [];
|
|
129
|
+
for (const decl of declarations) {
|
|
130
|
+
const tagName = decl?.tagName;
|
|
131
|
+
const isCustomElement = decl?.customElement === true || decl?.kind === 'custom-element';
|
|
132
|
+
if (!isCustomElement || typeof tagName !== 'string' || !tagName) continue;
|
|
133
|
+
const tag = tagName.toLowerCase();
|
|
134
|
+
|
|
135
|
+
const attrEnums = new Map();
|
|
136
|
+
const declAttrs = Array.isArray(decl?.attributes) ? decl.attributes : [];
|
|
137
|
+
for (const a of declAttrs) {
|
|
138
|
+
if (typeof a?.name !== 'string' || !a.name) continue;
|
|
139
|
+
const typeText = a?.type?.text;
|
|
140
|
+
const enumValues = parseEnumTypeText(typeText);
|
|
141
|
+
if (enumValues) {
|
|
142
|
+
attrEnums.set(a.name.toLowerCase(), enumValues);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (attrEnums.size > 0) {
|
|
147
|
+
result.set(tag, attrEnums);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Detect enum value misuse in HTML markup.
|
|
157
|
+
* @param {{
|
|
158
|
+
* filePath?: string;
|
|
159
|
+
* text: string;
|
|
160
|
+
* enumMap: Map<string, Map<string, Set<string>>>;
|
|
161
|
+
* severity?: string;
|
|
162
|
+
* }} params
|
|
163
|
+
*/
|
|
164
|
+
export function detectEnumValueMisuse({
|
|
165
|
+
filePath = '<input>',
|
|
166
|
+
text,
|
|
167
|
+
enumMap,
|
|
168
|
+
severity = 'error',
|
|
169
|
+
}) {
|
|
170
|
+
const diagnostics = [];
|
|
171
|
+
if (!(enumMap instanceof Map) || enumMap.size === 0) return diagnostics;
|
|
172
|
+
|
|
173
|
+
const lineStarts = computeLineIndex(text);
|
|
174
|
+
const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
|
|
175
|
+
let m;
|
|
176
|
+
|
|
177
|
+
while ((m = tagRe.exec(text))) {
|
|
178
|
+
const tag = String(m[1] ?? '').toLowerCase();
|
|
179
|
+
if (!tag.includes('-')) continue;
|
|
180
|
+
|
|
181
|
+
const attrEnums = enumMap.get(tag);
|
|
182
|
+
if (!attrEnums) continue;
|
|
183
|
+
|
|
184
|
+
const attrChunk = String(m[2] ?? '');
|
|
185
|
+
const rawAttrsStart = m.index + 1 + tag.length;
|
|
186
|
+
const attrs = parseAttributes(attrChunk);
|
|
187
|
+
|
|
188
|
+
for (const { name, offset, value } of attrs) {
|
|
189
|
+
const attrName = name.toLowerCase();
|
|
190
|
+
const validValues = attrEnums.get(attrName);
|
|
191
|
+
if (!validValues) continue;
|
|
192
|
+
|
|
193
|
+
// Skip empty values (boolean-style attributes)
|
|
194
|
+
if (value === undefined || value === '') continue;
|
|
195
|
+
|
|
196
|
+
if (!validValues.has(value)) {
|
|
197
|
+
const startIndex = rawAttrsStart + offset;
|
|
198
|
+
const endIndex = startIndex + name.length;
|
|
199
|
+
const range = makeRange(lineStarts, startIndex, endIndex);
|
|
200
|
+
const validList = [...validValues].map((v) => `'${v}'`).join(' | ');
|
|
201
|
+
diagnostics.push({
|
|
202
|
+
file: filePath,
|
|
203
|
+
range,
|
|
204
|
+
severity,
|
|
205
|
+
code: 'invalidEnumValue',
|
|
206
|
+
message: `Invalid value "${value}" for attribute "${attrName}" on <${tag}>. Valid values: ${validList}`,
|
|
207
|
+
tagName: tag,
|
|
208
|
+
attrName,
|
|
209
|
+
hint: `Use one of: ${validList}`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return diagnostics;
|
|
216
|
+
}
|
|
217
|
+
|
|
83
218
|
function shouldSkipAttr(attrName) {
|
|
84
219
|
const name = attrName.toLowerCase();
|
|
85
220
|
if (GLOBAL_ATTR_ALLOW_SET.has(name)) return true;
|
|
@@ -96,7 +231,29 @@ function makeRange(lineStarts, startIndex, endIndex) {
|
|
|
96
231
|
return { start, end };
|
|
97
232
|
}
|
|
98
233
|
|
|
234
|
+
function normalizeStyleValue(value) {
|
|
235
|
+
return String(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseInlineStyleAttribute(attrChunk) {
|
|
239
|
+
const styleMatch = /\bstyle\s*=\s*("([^"]*)"|'([^']*)')/i.exec(attrChunk);
|
|
240
|
+
if (!styleMatch) return undefined;
|
|
241
|
+
|
|
242
|
+
const quoted = styleMatch[1] ?? '';
|
|
243
|
+
const styleValue = styleMatch[2] ?? styleMatch[3] ?? '';
|
|
244
|
+
if (!styleValue) return undefined;
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
styleValue,
|
|
248
|
+
styleValueOffsetInAttr: styleMatch.index + quoted.indexOf(styleValue),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
99
252
|
function parseAttributeNames(rawAttrs) {
|
|
253
|
+
return parseAttributes(rawAttrs).map(({ name, offset }) => ({ name, offset }));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parseAttributes(rawAttrs) {
|
|
100
257
|
/** @type {{ name: string, offset: number }[]} */
|
|
101
258
|
const out = [];
|
|
102
259
|
|
|
@@ -121,9 +278,7 @@ function parseAttributeNames(rawAttrs) {
|
|
|
121
278
|
}
|
|
122
279
|
|
|
123
280
|
const name = rawAttrs.slice(nameStart, i);
|
|
124
|
-
|
|
125
|
-
out.push({ name, offset: nameStart });
|
|
126
|
-
}
|
|
281
|
+
let value = '';
|
|
127
282
|
|
|
128
283
|
while (i < len && isSpace(rawAttrs.charCodeAt(i))) i += 1;
|
|
129
284
|
|
|
@@ -135,16 +290,24 @@ function parseAttributeNames(rawAttrs) {
|
|
|
135
290
|
const quote = rawAttrs[i];
|
|
136
291
|
if (quote === '"' || quote === "'") {
|
|
137
292
|
i += 1;
|
|
293
|
+
const valueStart = i;
|
|
138
294
|
while (i < len && rawAttrs[i] !== quote) i += 1;
|
|
295
|
+
value = rawAttrs.slice(valueStart, i);
|
|
139
296
|
if (i < len) i += 1;
|
|
140
297
|
} else {
|
|
298
|
+
const valueStart = i;
|
|
141
299
|
while (i < len) {
|
|
142
300
|
const cc = rawAttrs[i];
|
|
143
301
|
if (cc === '>' || cc === '/' || isSpace(rawAttrs.charCodeAt(i))) break;
|
|
144
302
|
i += 1;
|
|
145
303
|
}
|
|
304
|
+
value = rawAttrs.slice(valueStart, i);
|
|
146
305
|
}
|
|
147
306
|
}
|
|
307
|
+
|
|
308
|
+
if (name && !name.startsWith('${') && !name.includes('{') && !name.includes('}')) {
|
|
309
|
+
out.push({ name, offset: nameStart, value });
|
|
310
|
+
}
|
|
148
311
|
}
|
|
149
312
|
|
|
150
313
|
return out;
|
|
@@ -176,13 +339,13 @@ export function validateTextAgainstCem({
|
|
|
176
339
|
|
|
177
340
|
const tagOffset = m.index + 1;
|
|
178
341
|
const attrChunk = String(m[2] ?? '');
|
|
342
|
+
const rawAttrsStart = m.index + 1 + tag.length;
|
|
179
343
|
|
|
180
344
|
const attrNames = parseAttributeNames(attrChunk);
|
|
181
345
|
for (const { name, offset } of attrNames) {
|
|
182
346
|
const attrName = name.toLowerCase();
|
|
183
347
|
if (!isForbiddenAttr(attrName)) continue;
|
|
184
348
|
|
|
185
|
-
const rawAttrsStart = m.index + 1 + tag.length;
|
|
186
349
|
const startIndex = rawAttrsStart + offset;
|
|
187
350
|
const endIndex = startIndex + attrName.length;
|
|
188
351
|
const range = makeRange(lineStarts, startIndex, endIndex);
|
|
@@ -220,7 +383,6 @@ export function validateTextAgainstCem({
|
|
|
220
383
|
if (shouldSkipAttr(attrName)) continue;
|
|
221
384
|
if (meta.attributes.has(attrName)) continue;
|
|
222
385
|
|
|
223
|
-
const rawAttrsStart = m.index + 1 + tag.length;
|
|
224
386
|
const startIndex = rawAttrsStart + offset;
|
|
225
387
|
const endIndex = startIndex + attrName.length;
|
|
226
388
|
const range = makeRange(lineStarts, startIndex, endIndex);
|
|
@@ -238,3 +400,469 @@ export function validateTextAgainstCem({
|
|
|
238
400
|
|
|
239
401
|
return diagnostics;
|
|
240
402
|
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* @param {{
|
|
406
|
+
* filePath?: string;
|
|
407
|
+
* text: string;
|
|
408
|
+
* valueToToken?: Map<string, string>;
|
|
409
|
+
* severity?: string;
|
|
410
|
+
* }} params
|
|
411
|
+
*/
|
|
412
|
+
export function detectTokenMisuseInInlineStyles({
|
|
413
|
+
filePath = '<input>',
|
|
414
|
+
text,
|
|
415
|
+
valueToToken = new Map(),
|
|
416
|
+
severity = 'warning',
|
|
417
|
+
}) {
|
|
418
|
+
const diagnostics = [];
|
|
419
|
+
if (!(valueToToken instanceof Map) || valueToToken.size === 0) return diagnostics;
|
|
420
|
+
|
|
421
|
+
const lineStarts = computeLineIndex(text);
|
|
422
|
+
const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
|
|
423
|
+
let m;
|
|
424
|
+
|
|
425
|
+
while ((m = tagRe.exec(text))) {
|
|
426
|
+
const tag = String(m[1] ?? '').toLowerCase();
|
|
427
|
+
const attrChunk = String(m[2] ?? '');
|
|
428
|
+
const inlineStyle = parseInlineStyleAttribute(attrChunk);
|
|
429
|
+
if (!inlineStyle) continue;
|
|
430
|
+
|
|
431
|
+
const { styleValue, styleValueOffsetInAttr } = inlineStyle;
|
|
432
|
+
const rawAttrsStart = m.index + 1 + tag.length;
|
|
433
|
+
|
|
434
|
+
const declarationRe = /([a-z-]+)\s*:\s*([^;]+)/gi;
|
|
435
|
+
let d;
|
|
436
|
+
while ((d = declarationRe.exec(styleValue))) {
|
|
437
|
+
const prop = String(d[1] ?? '').trim().toLowerCase();
|
|
438
|
+
if (!TOKEN_MISUSE_STYLE_PROPS.has(prop)) continue;
|
|
439
|
+
|
|
440
|
+
const valueRaw = String(d[2] ?? '').trim();
|
|
441
|
+
if (!valueRaw || /^var\(/i.test(valueRaw)) continue;
|
|
442
|
+
|
|
443
|
+
const normalizedValue = normalizeStyleValue(valueRaw);
|
|
444
|
+
const cssVariable = valueToToken.get(normalizedValue);
|
|
445
|
+
if (!cssVariable) continue;
|
|
446
|
+
|
|
447
|
+
const valueOffsetInDecl = d[0].indexOf(d[2]);
|
|
448
|
+
const valueOffsetInStyle = d.index + Math.max(0, valueOffsetInDecl);
|
|
449
|
+
const startIndex = rawAttrsStart + styleValueOffsetInAttr + valueOffsetInStyle;
|
|
450
|
+
const endIndex = startIndex + d[2].length;
|
|
451
|
+
const range = makeRange(lineStarts, startIndex, endIndex);
|
|
452
|
+
|
|
453
|
+
diagnostics.push({
|
|
454
|
+
file: filePath,
|
|
455
|
+
range,
|
|
456
|
+
severity,
|
|
457
|
+
code: 'tokenMisuse',
|
|
458
|
+
message: `Use var(${cssVariable}) instead of ${valueRaw} for ${prop}`,
|
|
459
|
+
tagName: tag,
|
|
460
|
+
attrName: 'style',
|
|
461
|
+
hint: `Replace ${prop}: ${valueRaw} with ${prop}: var(${cssVariable})`,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return diagnostics;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* @param {{
|
|
471
|
+
* filePath?: string;
|
|
472
|
+
* text: string;
|
|
473
|
+
* severity?: string;
|
|
474
|
+
* }} params
|
|
475
|
+
*/
|
|
476
|
+
export function detectAccessibilityMisuseInMarkup({
|
|
477
|
+
filePath = '<input>',
|
|
478
|
+
text,
|
|
479
|
+
severity = 'warning',
|
|
480
|
+
}) {
|
|
481
|
+
const diagnostics = [];
|
|
482
|
+
const lineStarts = computeLineIndex(text);
|
|
483
|
+
const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
|
|
484
|
+
let m;
|
|
485
|
+
|
|
486
|
+
while ((m = tagRe.exec(text))) {
|
|
487
|
+
const tag = String(m[1] ?? '').toLowerCase();
|
|
488
|
+
const attrChunk = String(m[2] ?? '');
|
|
489
|
+
const rawAttrsStart = m.index + 1 + tag.length;
|
|
490
|
+
|
|
491
|
+
const attrNames = parseAttributeNames(attrChunk);
|
|
492
|
+
for (const { name, offset } of attrNames) {
|
|
493
|
+
const attrName = String(name ?? '').toLowerCase();
|
|
494
|
+
if (attrName !== 'aria-live') continue;
|
|
495
|
+
|
|
496
|
+
const startIndex = rawAttrsStart + offset;
|
|
497
|
+
const endIndex = startIndex + attrName.length;
|
|
498
|
+
const range = makeRange(lineStarts, startIndex, endIndex);
|
|
499
|
+
diagnostics.push({
|
|
500
|
+
file: filePath,
|
|
501
|
+
range,
|
|
502
|
+
severity,
|
|
503
|
+
code: 'ariaLiveNotRecommended',
|
|
504
|
+
message: 'Avoid aria-live in component markup; use static text with aria-describedby instead.',
|
|
505
|
+
tagName: tag,
|
|
506
|
+
attrName: 'aria-live',
|
|
507
|
+
hint: 'Remove aria-live and connect error/support text via aria-describedby.',
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const roleAttr = parseAttributes(attrChunk).find(({ name }) => String(name ?? '').toLowerCase() === 'role');
|
|
512
|
+
const roleValue = String(roleAttr?.value ?? '').trim().toLowerCase();
|
|
513
|
+
if (roleAttr && roleValue === 'alert') {
|
|
514
|
+
const attrName = 'role';
|
|
515
|
+
const roleOffsetInChunk = roleAttr.offset;
|
|
516
|
+
const startIndex = rawAttrsStart + roleOffsetInChunk;
|
|
517
|
+
const endIndex = startIndex + attrName.length;
|
|
518
|
+
const range = makeRange(lineStarts, startIndex, endIndex);
|
|
519
|
+
diagnostics.push({
|
|
520
|
+
file: filePath,
|
|
521
|
+
range,
|
|
522
|
+
severity,
|
|
523
|
+
code: 'roleAlertNotRecommended',
|
|
524
|
+
message: 'Avoid role=\"alert\" in component markup; prefer static error text and aria-describedby.',
|
|
525
|
+
tagName: tag,
|
|
526
|
+
attrName,
|
|
527
|
+
hint: 'Replace role=\"alert\" with non-live text associated to the control.',
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return diagnostics;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Build a map of slot names per component from the CEM manifest.
|
|
537
|
+
* Returns Map<tagName, Set<validSlotNames>>
|
|
538
|
+
* @param {object} manifest
|
|
539
|
+
* @returns {Map<string, Set<string>>}
|
|
540
|
+
*/
|
|
541
|
+
export function buildSlotNameMap(manifest) {
|
|
542
|
+
const result = new Map();
|
|
543
|
+
|
|
544
|
+
const modules = Array.isArray(manifest?.modules) ? manifest.modules : [];
|
|
545
|
+
for (const mod of modules) {
|
|
546
|
+
const declarations = Array.isArray(mod?.declarations) ? mod.declarations : [];
|
|
547
|
+
for (const decl of declarations) {
|
|
548
|
+
const tagName = decl?.tagName;
|
|
549
|
+
const isCustomElement = decl?.customElement === true || decl?.kind === 'custom-element';
|
|
550
|
+
if (!isCustomElement || typeof tagName !== 'string' || !tagName) continue;
|
|
551
|
+
const tag = tagName.toLowerCase();
|
|
552
|
+
|
|
553
|
+
const slotNames = new Set();
|
|
554
|
+
const declSlots = Array.isArray(decl?.slots) ? decl.slots : [];
|
|
555
|
+
for (const s of declSlots) {
|
|
556
|
+
if (typeof s?.name !== 'string') continue;
|
|
557
|
+
slotNames.add(s.name);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (slotNames.size > 0) {
|
|
561
|
+
result.set(tag, slotNames);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return result;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Detect invalid slot names in HTML markup.
|
|
571
|
+
* Checks if `slot="name"` values match any known slot across the design system.
|
|
572
|
+
* @param {{
|
|
573
|
+
* filePath?: string;
|
|
574
|
+
* text: string;
|
|
575
|
+
* slotMap: Map<string, Set<string>>;
|
|
576
|
+
* severity?: string;
|
|
577
|
+
* }} params
|
|
578
|
+
*/
|
|
579
|
+
export function detectInvalidSlotName({
|
|
580
|
+
filePath = '<input>',
|
|
581
|
+
text,
|
|
582
|
+
slotMap,
|
|
583
|
+
severity = 'error',
|
|
584
|
+
}) {
|
|
585
|
+
const diagnostics = [];
|
|
586
|
+
if (!(slotMap instanceof Map) || slotMap.size === 0) return diagnostics;
|
|
587
|
+
|
|
588
|
+
// Build global slot vocabulary (all valid slot names across all components)
|
|
589
|
+
const globalSlotNames = new Set();
|
|
590
|
+
for (const slotNames of slotMap.values()) {
|
|
591
|
+
for (const name of slotNames) globalSlotNames.add(name);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const lineStarts = computeLineIndex(text);
|
|
595
|
+
const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
|
|
596
|
+
let m;
|
|
597
|
+
|
|
598
|
+
while ((m = tagRe.exec(text))) {
|
|
599
|
+
const tag = String(m[1] ?? '').toLowerCase();
|
|
600
|
+
const attrChunk = String(m[2] ?? '');
|
|
601
|
+
const rawAttrsStart = m.index + 1 + tag.length;
|
|
602
|
+
const attrs = parseAttributes(attrChunk);
|
|
603
|
+
|
|
604
|
+
for (const { name, offset, value } of attrs) {
|
|
605
|
+
const attrName = name.toLowerCase();
|
|
606
|
+
if (attrName !== 'slot') continue;
|
|
607
|
+
if (value === undefined || value === '') continue;
|
|
608
|
+
|
|
609
|
+
// 'default' is always valid (unnamed slot)
|
|
610
|
+
if (value === 'default') continue;
|
|
611
|
+
|
|
612
|
+
if (!globalSlotNames.has(value)) {
|
|
613
|
+
const startIndex = rawAttrsStart + offset;
|
|
614
|
+
const endIndex = startIndex + name.length;
|
|
615
|
+
const range = makeRange(lineStarts, startIndex, endIndex);
|
|
616
|
+
diagnostics.push({
|
|
617
|
+
file: filePath,
|
|
618
|
+
range,
|
|
619
|
+
severity,
|
|
620
|
+
code: 'invalidSlotName',
|
|
621
|
+
message: `Unknown slot name "${value}". No component in the design system defines this slot.`,
|
|
622
|
+
tagName: tag,
|
|
623
|
+
attrName: 'slot',
|
|
624
|
+
hint: `Check the parent component's API for available slot names.`,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return diagnostics;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Parent-child constraints: child → expected parent.
|
|
635
|
+
* If a child tag appears without its parent wrapping it, emit a warning.
|
|
636
|
+
*/
|
|
637
|
+
const PARENT_CHILD_CONSTRAINTS = new Map([
|
|
638
|
+
['dads-accordion-item-details', 'dads-accordion-details'],
|
|
639
|
+
['dads-breadcrumb-item', 'dads-breadcrumb'],
|
|
640
|
+
['dads-list-item', 'dads-list'],
|
|
641
|
+
['dads-step-navigation-item', 'dads-step-navigation'],
|
|
642
|
+
['dads-global-menu-item', 'dads-global-menu'],
|
|
643
|
+
['dads-menu-list-item', 'dads-menu-list'],
|
|
644
|
+
]);
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Detect orphaned child components (child appears without expected parent).
|
|
648
|
+
* Uses regex/text scan (DIG-13) — lower confidence, severity: warning.
|
|
649
|
+
* @param {{
|
|
650
|
+
* filePath?: string;
|
|
651
|
+
* text: string;
|
|
652
|
+
* prefix?: string;
|
|
653
|
+
* severity?: string;
|
|
654
|
+
* }} params
|
|
655
|
+
*/
|
|
656
|
+
export function detectOrphanedChildComponents({
|
|
657
|
+
filePath = '<input>',
|
|
658
|
+
text,
|
|
659
|
+
prefix = 'dads',
|
|
660
|
+
severity = 'warning',
|
|
661
|
+
}) {
|
|
662
|
+
const diagnostics = [];
|
|
663
|
+
const lineStarts = computeLineIndex(text);
|
|
664
|
+
const p = prefix.toLowerCase();
|
|
665
|
+
const canonicalPrefix = 'dads';
|
|
666
|
+
|
|
667
|
+
// Build prefix-aware constraint map
|
|
668
|
+
const constraints = new Map();
|
|
669
|
+
for (const [child, parent] of PARENT_CHILD_CONSTRAINTS.entries()) {
|
|
670
|
+
const mappedChild = p !== canonicalPrefix ? child.replace(canonicalPrefix, p) : child;
|
|
671
|
+
const mappedParent = p !== canonicalPrefix ? parent.replace(canonicalPrefix, p) : parent;
|
|
672
|
+
constraints.set(mappedChild, mappedParent);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const textLower = text.toLowerCase();
|
|
676
|
+
|
|
677
|
+
const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
|
|
678
|
+
let m;
|
|
679
|
+
|
|
680
|
+
while ((m = tagRe.exec(text))) {
|
|
681
|
+
const tag = String(m[1] ?? '').toLowerCase();
|
|
682
|
+
const expectedParent = constraints.get(tag);
|
|
683
|
+
if (!expectedParent) continue;
|
|
684
|
+
|
|
685
|
+
// Check if the expected parent tag appears before this child in the text
|
|
686
|
+
const precedingText = textLower.slice(0, m.index);
|
|
687
|
+
const parentOpenPattern = `<${expectedParent}`;
|
|
688
|
+
const parentClosePattern = `</${expectedParent}`;
|
|
689
|
+
|
|
690
|
+
const lastParentOpen = precedingText.lastIndexOf(parentOpenPattern);
|
|
691
|
+
const lastParentClose = precedingText.lastIndexOf(parentClosePattern);
|
|
692
|
+
|
|
693
|
+
if (lastParentOpen === -1 || lastParentClose > lastParentOpen) {
|
|
694
|
+
const tagOffset = m.index + 1;
|
|
695
|
+
const range = makeRange(lineStarts, tagOffset, tagOffset + tag.length);
|
|
696
|
+
diagnostics.push({
|
|
697
|
+
file: filePath,
|
|
698
|
+
range,
|
|
699
|
+
severity,
|
|
700
|
+
code: 'orphanedChildComponent',
|
|
701
|
+
message: `<${tag}> should be a child of <${expectedParent}>.`,
|
|
702
|
+
tagName: tag,
|
|
703
|
+
hint: `Wrap <${tag}> inside <${expectedParent}>...</${expectedParent}>.`,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return diagnostics;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Interactive elements that should have content (text or slotted content).
|
|
713
|
+
*/
|
|
714
|
+
const INTERACTIVE_ELEMENTS = new Set([
|
|
715
|
+
'dads-button',
|
|
716
|
+
]);
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Detect empty interactive elements (e.g., button with no text content).
|
|
720
|
+
* Severity: warning (DIG-04).
|
|
721
|
+
* @param {{
|
|
722
|
+
* filePath?: string;
|
|
723
|
+
* text: string;
|
|
724
|
+
* prefix?: string;
|
|
725
|
+
* severity?: string;
|
|
726
|
+
* }} params
|
|
727
|
+
*/
|
|
728
|
+
export function detectEmptyInteractiveElement({
|
|
729
|
+
filePath = '<input>',
|
|
730
|
+
text,
|
|
731
|
+
prefix = 'dads',
|
|
732
|
+
severity = 'warning',
|
|
733
|
+
}) {
|
|
734
|
+
const diagnostics = [];
|
|
735
|
+
const lineStarts = computeLineIndex(text);
|
|
736
|
+
const p = prefix.toLowerCase();
|
|
737
|
+
const canonicalPrefix = 'dads';
|
|
738
|
+
|
|
739
|
+
const elements = new Set();
|
|
740
|
+
for (const el of INTERACTIVE_ELEMENTS) {
|
|
741
|
+
elements.add(p !== canonicalPrefix ? el.replace(canonicalPrefix, p) : el);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Match self-closing tags: <dads-button ... />
|
|
745
|
+
const selfClosingRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)\/>/gi;
|
|
746
|
+
let m;
|
|
747
|
+
|
|
748
|
+
while ((m = selfClosingRe.exec(text))) {
|
|
749
|
+
const tag = String(m[1] ?? '').toLowerCase();
|
|
750
|
+
if (!elements.has(tag)) continue;
|
|
751
|
+
|
|
752
|
+
// Check if aria-label is present
|
|
753
|
+
const attrChunk = String(m[2] ?? '');
|
|
754
|
+
const attrs = parseAttributes(attrChunk);
|
|
755
|
+
const hasAriaLabel = attrs.some(({ name }) => name.toLowerCase() === 'aria-label');
|
|
756
|
+
if (hasAriaLabel) continue;
|
|
757
|
+
|
|
758
|
+
const tagOffset = m.index + 1;
|
|
759
|
+
const range = makeRange(lineStarts, tagOffset, tagOffset + tag.length);
|
|
760
|
+
diagnostics.push({
|
|
761
|
+
file: filePath,
|
|
762
|
+
range,
|
|
763
|
+
severity,
|
|
764
|
+
code: 'emptyInteractiveElement',
|
|
765
|
+
message: `<${tag}> appears empty. Add text content or aria-label for accessibility.`,
|
|
766
|
+
tagName: tag,
|
|
767
|
+
hint: `Add visible text or aria-label="..." to <${tag}>.`,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Match open+close with no content: <dads-button ...></dads-button>
|
|
772
|
+
for (const tag of elements) {
|
|
773
|
+
const emptyRe = new RegExp(`<(${tag})\\b([^<>]*?)>\\s*</${tag}>`, 'gi');
|
|
774
|
+
let em;
|
|
775
|
+
while ((em = emptyRe.exec(text))) {
|
|
776
|
+
const matchedTag = String(em[1] ?? '').toLowerCase();
|
|
777
|
+
const attrChunk = String(em[2] ?? '');
|
|
778
|
+
const attrs = parseAttributes(attrChunk);
|
|
779
|
+
const hasAriaLabel = attrs.some(({ name }) => name.toLowerCase() === 'aria-label');
|
|
780
|
+
if (hasAriaLabel) continue;
|
|
781
|
+
|
|
782
|
+
const tagOffset = em.index + 1;
|
|
783
|
+
const range = makeRange(lineStarts, tagOffset, tagOffset + matchedTag.length);
|
|
784
|
+
diagnostics.push({
|
|
785
|
+
file: filePath,
|
|
786
|
+
range,
|
|
787
|
+
severity,
|
|
788
|
+
code: 'emptyInteractiveElement',
|
|
789
|
+
message: `<${matchedTag}> appears empty. Add text content or aria-label for accessibility.`,
|
|
790
|
+
tagName: matchedTag,
|
|
791
|
+
hint: `Add visible text or aria-label="..." to <${matchedTag}>.`,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return diagnostics;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Hardcoded map of required attributes per form component (DIG-08).
|
|
801
|
+
* Only `label` for form inputs.
|
|
802
|
+
*/
|
|
803
|
+
const REQUIRED_ATTRIBUTES = new Map([
|
|
804
|
+
['dads-input-text', ['label']],
|
|
805
|
+
['dads-textarea', ['label']],
|
|
806
|
+
['dads-select', ['label']],
|
|
807
|
+
['dads-checkbox', ['label']],
|
|
808
|
+
['dads-radio', ['label']],
|
|
809
|
+
]);
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Detect missing required attributes on form elements.
|
|
813
|
+
* @param {{
|
|
814
|
+
* filePath?: string;
|
|
815
|
+
* text: string;
|
|
816
|
+
* prefix?: string;
|
|
817
|
+
* severity?: string;
|
|
818
|
+
* }} params
|
|
819
|
+
*/
|
|
820
|
+
export function detectMissingRequiredAttributes({
|
|
821
|
+
filePath = '<input>',
|
|
822
|
+
text,
|
|
823
|
+
prefix = 'dads',
|
|
824
|
+
severity = 'error',
|
|
825
|
+
}) {
|
|
826
|
+
const diagnostics = [];
|
|
827
|
+
const lineStarts = computeLineIndex(text);
|
|
828
|
+
const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
|
|
829
|
+
let m;
|
|
830
|
+
|
|
831
|
+
// Build prefix-aware required map
|
|
832
|
+
const requiredMap = new Map();
|
|
833
|
+
for (const [tag, attrs] of REQUIRED_ATTRIBUTES.entries()) {
|
|
834
|
+
const p = prefix.toLowerCase();
|
|
835
|
+
const canonicalPrefix = 'dads';
|
|
836
|
+
const mappedTag = p !== canonicalPrefix ? tag.replace(canonicalPrefix, p) : tag;
|
|
837
|
+
requiredMap.set(mappedTag, attrs);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
while ((m = tagRe.exec(text))) {
|
|
841
|
+
const tag = String(m[1] ?? '').toLowerCase();
|
|
842
|
+
const requiredAttrs = requiredMap.get(tag);
|
|
843
|
+
if (!requiredAttrs) continue;
|
|
844
|
+
|
|
845
|
+
const attrChunk = String(m[2] ?? '');
|
|
846
|
+
const attrs = parseAttributes(attrChunk);
|
|
847
|
+
const presentAttrs = new Set(attrs.map(({ name }) => name.toLowerCase()));
|
|
848
|
+
|
|
849
|
+
for (const required of requiredAttrs) {
|
|
850
|
+
if (!presentAttrs.has(required)) {
|
|
851
|
+
const tagOffset = m.index + 1;
|
|
852
|
+
const range = makeRange(lineStarts, tagOffset, tagOffset + tag.length);
|
|
853
|
+
diagnostics.push({
|
|
854
|
+
file: filePath,
|
|
855
|
+
range,
|
|
856
|
+
severity,
|
|
857
|
+
code: 'missingRequiredAttribute',
|
|
858
|
+
message: `<${tag}> requires attribute "${required}" for accessibility.`,
|
|
859
|
+
tagName: tag,
|
|
860
|
+
attrName: required,
|
|
861
|
+
hint: `Add ${required}="..." to <${tag}>.`,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return diagnostics;
|
|
868
|
+
}
|