@monoharada/wcf-mcp 0.1.0 → 0.1.2

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/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());
@@ -96,7 +107,29 @@ function makeRange(lineStarts, startIndex, endIndex) {
96
107
  return { start, end };
97
108
  }
98
109
 
110
+ function normalizeStyleValue(value) {
111
+ return String(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
112
+ }
113
+
114
+ function parseInlineStyleAttribute(attrChunk) {
115
+ const styleMatch = /\bstyle\s*=\s*("([^"]*)"|'([^']*)')/i.exec(attrChunk);
116
+ if (!styleMatch) return undefined;
117
+
118
+ const quoted = styleMatch[1] ?? '';
119
+ const styleValue = styleMatch[2] ?? styleMatch[3] ?? '';
120
+ if (!styleValue) return undefined;
121
+
122
+ return {
123
+ styleValue,
124
+ styleValueOffsetInAttr: styleMatch.index + quoted.indexOf(styleValue),
125
+ };
126
+ }
127
+
99
128
  function parseAttributeNames(rawAttrs) {
129
+ return parseAttributes(rawAttrs).map(({ name, offset }) => ({ name, offset }));
130
+ }
131
+
132
+ function parseAttributes(rawAttrs) {
100
133
  /** @type {{ name: string, offset: number }[]} */
101
134
  const out = [];
102
135
 
@@ -121,9 +154,7 @@ function parseAttributeNames(rawAttrs) {
121
154
  }
122
155
 
123
156
  const name = rawAttrs.slice(nameStart, i);
124
- if (name && !name.startsWith('${') && !name.includes('{') && !name.includes('}')) {
125
- out.push({ name, offset: nameStart });
126
- }
157
+ let value = '';
127
158
 
128
159
  while (i < len && isSpace(rawAttrs.charCodeAt(i))) i += 1;
129
160
 
@@ -135,16 +166,24 @@ function parseAttributeNames(rawAttrs) {
135
166
  const quote = rawAttrs[i];
136
167
  if (quote === '"' || quote === "'") {
137
168
  i += 1;
169
+ const valueStart = i;
138
170
  while (i < len && rawAttrs[i] !== quote) i += 1;
171
+ value = rawAttrs.slice(valueStart, i);
139
172
  if (i < len) i += 1;
140
173
  } else {
174
+ const valueStart = i;
141
175
  while (i < len) {
142
176
  const cc = rawAttrs[i];
143
177
  if (cc === '>' || cc === '/' || isSpace(rawAttrs.charCodeAt(i))) break;
144
178
  i += 1;
145
179
  }
180
+ value = rawAttrs.slice(valueStart, i);
146
181
  }
147
182
  }
183
+
184
+ if (name && !name.startsWith('${') && !name.includes('{') && !name.includes('}')) {
185
+ out.push({ name, offset: nameStart, value });
186
+ }
148
187
  }
149
188
 
150
189
  return out;
@@ -176,13 +215,13 @@ export function validateTextAgainstCem({
176
215
 
177
216
  const tagOffset = m.index + 1;
178
217
  const attrChunk = String(m[2] ?? '');
218
+ const rawAttrsStart = m.index + 1 + tag.length;
179
219
 
180
220
  const attrNames = parseAttributeNames(attrChunk);
181
221
  for (const { name, offset } of attrNames) {
182
222
  const attrName = name.toLowerCase();
183
223
  if (!isForbiddenAttr(attrName)) continue;
184
224
 
185
- const rawAttrsStart = m.index + 1 + tag.length;
186
225
  const startIndex = rawAttrsStart + offset;
187
226
  const endIndex = startIndex + attrName.length;
188
227
  const range = makeRange(lineStarts, startIndex, endIndex);
@@ -220,7 +259,6 @@ export function validateTextAgainstCem({
220
259
  if (shouldSkipAttr(attrName)) continue;
221
260
  if (meta.attributes.has(attrName)) continue;
222
261
 
223
- const rawAttrsStart = m.index + 1 + tag.length;
224
262
  const startIndex = rawAttrsStart + offset;
225
263
  const endIndex = startIndex + attrName.length;
226
264
  const range = makeRange(lineStarts, startIndex, endIndex);
@@ -238,3 +276,134 @@ export function validateTextAgainstCem({
238
276
 
239
277
  return diagnostics;
240
278
  }
279
+
280
+ /**
281
+ * @param {{
282
+ * filePath?: string;
283
+ * text: string;
284
+ * valueToToken?: Map<string, string>;
285
+ * severity?: string;
286
+ * }} params
287
+ */
288
+ export function detectTokenMisuseInInlineStyles({
289
+ filePath = '<input>',
290
+ text,
291
+ valueToToken = new Map(),
292
+ severity = 'warning',
293
+ }) {
294
+ const diagnostics = [];
295
+ if (!(valueToToken instanceof Map) || valueToToken.size === 0) return diagnostics;
296
+
297
+ const lineStarts = computeLineIndex(text);
298
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
299
+ let m;
300
+
301
+ while ((m = tagRe.exec(text))) {
302
+ const tag = String(m[1] ?? '').toLowerCase();
303
+ const attrChunk = String(m[2] ?? '');
304
+ const inlineStyle = parseInlineStyleAttribute(attrChunk);
305
+ if (!inlineStyle) continue;
306
+
307
+ const { styleValue, styleValueOffsetInAttr } = inlineStyle;
308
+ const rawAttrsStart = m.index + 1 + tag.length;
309
+
310
+ const declarationRe = /([a-z-]+)\s*:\s*([^;]+)/gi;
311
+ let d;
312
+ while ((d = declarationRe.exec(styleValue))) {
313
+ const prop = String(d[1] ?? '').trim().toLowerCase();
314
+ if (!TOKEN_MISUSE_STYLE_PROPS.has(prop)) continue;
315
+
316
+ const valueRaw = String(d[2] ?? '').trim();
317
+ if (!valueRaw || /^var\(/i.test(valueRaw)) continue;
318
+
319
+ const normalizedValue = normalizeStyleValue(valueRaw);
320
+ const cssVariable = valueToToken.get(normalizedValue);
321
+ if (!cssVariable) continue;
322
+
323
+ const valueOffsetInDecl = d[0].indexOf(d[2]);
324
+ const valueOffsetInStyle = d.index + Math.max(0, valueOffsetInDecl);
325
+ const startIndex = rawAttrsStart + styleValueOffsetInAttr + valueOffsetInStyle;
326
+ const endIndex = startIndex + d[2].length;
327
+ const range = makeRange(lineStarts, startIndex, endIndex);
328
+
329
+ diagnostics.push({
330
+ file: filePath,
331
+ range,
332
+ severity,
333
+ code: 'tokenMisuse',
334
+ message: `Use var(${cssVariable}) instead of ${valueRaw} for ${prop}`,
335
+ tagName: tag,
336
+ attrName: 'style',
337
+ hint: `Replace ${prop}: ${valueRaw} with ${prop}: var(${cssVariable})`,
338
+ });
339
+ }
340
+ }
341
+
342
+ return diagnostics;
343
+ }
344
+
345
+ /**
346
+ * @param {{
347
+ * filePath?: string;
348
+ * text: string;
349
+ * severity?: string;
350
+ * }} params
351
+ */
352
+ export function detectAccessibilityMisuseInMarkup({
353
+ filePath = '<input>',
354
+ text,
355
+ severity = 'warning',
356
+ }) {
357
+ const diagnostics = [];
358
+ const lineStarts = computeLineIndex(text);
359
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
360
+ let m;
361
+
362
+ while ((m = tagRe.exec(text))) {
363
+ const tag = String(m[1] ?? '').toLowerCase();
364
+ const attrChunk = String(m[2] ?? '');
365
+ const rawAttrsStart = m.index + 1 + tag.length;
366
+
367
+ const attrNames = parseAttributeNames(attrChunk);
368
+ for (const { name, offset } of attrNames) {
369
+ const attrName = String(name ?? '').toLowerCase();
370
+ if (attrName !== 'aria-live') continue;
371
+
372
+ const startIndex = rawAttrsStart + offset;
373
+ const endIndex = startIndex + attrName.length;
374
+ const range = makeRange(lineStarts, startIndex, endIndex);
375
+ diagnostics.push({
376
+ file: filePath,
377
+ range,
378
+ severity,
379
+ code: 'ariaLiveNotRecommended',
380
+ message: 'Avoid aria-live in component markup; use static text with aria-describedby instead.',
381
+ tagName: tag,
382
+ attrName: 'aria-live',
383
+ hint: 'Remove aria-live and connect error/support text via aria-describedby.',
384
+ });
385
+ }
386
+
387
+ const roleAttr = parseAttributes(attrChunk).find(({ name }) => String(name ?? '').toLowerCase() === 'role');
388
+ const roleValue = String(roleAttr?.value ?? '').trim().toLowerCase();
389
+ if (roleAttr && roleValue === 'alert') {
390
+ const attrName = 'role';
391
+ const roleOffsetInChunk = roleAttr.offset;
392
+ const startIndex = rawAttrsStart + roleOffsetInChunk;
393
+ const endIndex = startIndex + attrName.length;
394
+ const range = makeRange(lineStarts, startIndex, endIndex);
395
+ diagnostics.push({
396
+ file: filePath,
397
+ range,
398
+ severity,
399
+ code: 'roleAlertNotRecommended',
400
+ message: 'Avoid role=\"alert\" in component markup; prefer static error text and aria-describedby.',
401
+ tagName: tag,
402
+ attrName,
403
+ hint: 'Replace role=\"alert\" with non-live text associated to the control.',
404
+ });
405
+ }
406
+ }
407
+
408
+ return diagnostics;
409
+ }