@monoharada/wcf-mcp 0.9.1 → 0.10.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/validator.mjs CHANGED
@@ -41,6 +41,42 @@ function isForbiddenAttr(attrName) {
41
41
  return FORBIDDEN_ATTR_SET.has(attrName.toLowerCase());
42
42
  }
43
43
 
44
+ function maskTextPreserveNewlines(fragment) {
45
+ return String(fragment).replace(/[^\n\r]/g, ' ');
46
+ }
47
+
48
+ function sanitizeMarkupForValidation(text) {
49
+ let out = String(text ?? '');
50
+ const patterns = [
51
+ /^---[\s\S]*?\n---/m, // Astro/frontmatter blocks
52
+ /<!--[\s\S]*?-->/g,
53
+ /<script\b[\s\S]*?<\/script>/gi,
54
+ /<style\b[\s\S]*?<\/style>/gi,
55
+ /{%\s*comment\s*%}[\s\S]*?{%\s*endcomment\s*%}/gi,
56
+ /{{!--[\s\S]*?--}}/g,
57
+ /{#[\s\S]*?#}/g,
58
+ /<%[\s\S]*?%>/g,
59
+ /<\?[\s\S]*?\?>/g,
60
+ ];
61
+ for (const pattern of patterns) {
62
+ out = out.replace(pattern, (match) => maskTextPreserveNewlines(match));
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function isTemplateValue(value) {
68
+ const raw = String(value ?? '').trim();
69
+ if (!raw) return false;
70
+ return (
71
+ raw.includes('{{') ||
72
+ raw.includes('{%') ||
73
+ raw.includes('<%') ||
74
+ raw.includes('<?') ||
75
+ raw.includes('${') ||
76
+ (/^\{[\s\S]*\}$/.test(raw))
77
+ );
78
+ }
79
+
44
80
  function computeLineIndex(text) {
45
81
  const out = [0];
46
82
  for (let i = 0; i < text.length; i += 1) {
@@ -170,11 +206,12 @@ export function detectEnumValueMisuse({
170
206
  const diagnostics = [];
171
207
  if (!(enumMap instanceof Map) || enumMap.size === 0) return diagnostics;
172
208
 
173
- const lineStarts = computeLineIndex(text);
209
+ const sourceText = sanitizeMarkupForValidation(text);
210
+ const lineStarts = computeLineIndex(sourceText);
174
211
  const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
175
212
  let m;
176
213
 
177
- while ((m = tagRe.exec(text))) {
214
+ while ((m = tagRe.exec(sourceText))) {
178
215
  const tag = String(m[1] ?? '').toLowerCase();
179
216
  if (!tag.includes('-')) continue;
180
217
 
@@ -191,7 +228,7 @@ export function detectEnumValueMisuse({
191
228
  if (!validValues) continue;
192
229
 
193
230
  // Skip empty values (boolean-style attributes)
194
- if (value === undefined || value === '') continue;
231
+ if (value === undefined || value.trim() === '' || isTemplateValue(value)) continue;
195
232
 
196
233
  if (!validValues.has(value)) {
197
234
  const startIndex = rawAttrsStart + offset;
@@ -253,6 +290,43 @@ function parseAttributeNames(rawAttrs) {
253
290
  return parseAttributes(rawAttrs).map(({ name, offset }) => ({ name, offset }));
254
291
  }
255
292
 
293
+ function readBalancedBraces(text, startIndex) {
294
+ let index = startIndex;
295
+ let depth = 0;
296
+ let quote = null;
297
+
298
+ while (index < text.length) {
299
+ const ch = text[index];
300
+ if (quote) {
301
+ if (ch === '\\') {
302
+ index += 2;
303
+ continue;
304
+ }
305
+ if (ch === quote) quote = null;
306
+ index += 1;
307
+ continue;
308
+ }
309
+
310
+ if (ch === '"' || ch === "'" || ch === '`') {
311
+ quote = ch;
312
+ index += 1;
313
+ continue;
314
+ }
315
+
316
+ if (ch === '{') depth += 1;
317
+ if (ch === '}') {
318
+ depth -= 1;
319
+ index += 1;
320
+ if (depth === 0) break;
321
+ continue;
322
+ }
323
+
324
+ index += 1;
325
+ }
326
+
327
+ return index;
328
+ }
329
+
256
330
  function parseAttributes(rawAttrs) {
257
331
  /** @type {{ name: string, offset: number }[]} */
258
332
  const out = [];
@@ -269,11 +343,15 @@ function parseAttributes(rawAttrs) {
269
343
 
270
344
  const c = rawAttrs[i];
271
345
  if (c === '/' || c === '>') break;
346
+ if (c === '{') {
347
+ i = readBalancedBraces(rawAttrs, i);
348
+ continue;
349
+ }
272
350
 
273
351
  const nameStart = i;
274
352
  while (i < len) {
275
353
  const ch = rawAttrs[i];
276
- if (ch === '=' || ch === '>' || ch === '/' || isSpace(rawAttrs.charCodeAt(i))) break;
354
+ if (ch === '=' || ch === '>' || ch === '/' || ch === '{' || isSpace(rawAttrs.charCodeAt(i))) break;
277
355
  i += 1;
278
356
  }
279
357
 
@@ -294,6 +372,10 @@ function parseAttributes(rawAttrs) {
294
372
  while (i < len && rawAttrs[i] !== quote) i += 1;
295
373
  value = rawAttrs.slice(valueStart, i);
296
374
  if (i < len) i += 1;
375
+ } else if (quote === '{') {
376
+ const valueStart = i;
377
+ i = readBalancedBraces(rawAttrs, i);
378
+ value = rawAttrs.slice(valueStart, i);
297
379
  } else {
298
380
  const valueStart = i;
299
381
  while (i < len) {
@@ -330,11 +412,12 @@ export function validateTextAgainstCem({
330
412
  ignoreTags = new Set(),
331
413
  }) {
332
414
  const diagnostics = [];
333
- const lineStarts = computeLineIndex(text);
415
+ const sourceText = sanitizeMarkupForValidation(text);
416
+ const lineStarts = computeLineIndex(sourceText);
334
417
 
335
418
  const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
336
419
  let m;
337
- while ((m = tagRe.exec(text))) {
420
+ while ((m = tagRe.exec(sourceText))) {
338
421
  const tag = String(m[1] ?? '').toLowerCase();
339
422
 
340
423
  const tagOffset = m.index + 1;
@@ -418,11 +501,12 @@ export function detectTokenMisuseInInlineStyles({
418
501
  const diagnostics = [];
419
502
  if (!(valueToToken instanceof Map) || valueToToken.size === 0) return diagnostics;
420
503
 
421
- const lineStarts = computeLineIndex(text);
504
+ const sourceText = sanitizeMarkupForValidation(text);
505
+ const lineStarts = computeLineIndex(sourceText);
422
506
  const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
423
507
  let m;
424
508
 
425
- while ((m = tagRe.exec(text))) {
509
+ while ((m = tagRe.exec(sourceText))) {
426
510
  const tag = String(m[1] ?? '').toLowerCase();
427
511
  const attrChunk = String(m[2] ?? '');
428
512
  const inlineStyle = parseInlineStyleAttribute(attrChunk);
@@ -438,7 +522,7 @@ export function detectTokenMisuseInInlineStyles({
438
522
  if (!TOKEN_MISUSE_STYLE_PROPS.has(prop)) continue;
439
523
 
440
524
  const valueRaw = String(d[2] ?? '').trim();
441
- if (!valueRaw || /^var\(/i.test(valueRaw)) continue;
525
+ if (!valueRaw || /^var\(/i.test(valueRaw) || isTemplateValue(valueRaw)) continue;
442
526
 
443
527
  const normalizedValue = normalizeStyleValue(valueRaw);
444
528
  const cssVariable = valueToToken.get(normalizedValue);
@@ -481,11 +565,12 @@ export function detectAccessibilityMisuseInMarkup({
481
565
  cemTagNames,
482
566
  }) {
483
567
  const diagnostics = [];
484
- const lineStarts = computeLineIndex(text);
568
+ const sourceText = sanitizeMarkupForValidation(text);
569
+ const lineStarts = computeLineIndex(sourceText);
485
570
  const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
486
571
  let m;
487
572
 
488
- while ((m = tagRe.exec(text))) {
573
+ while ((m = tagRe.exec(sourceText))) {
489
574
  const tag = String(m[1] ?? '').toLowerCase();
490
575
  const attrChunk = String(m[2] ?? '');
491
576
  const rawAttrsStart = m.index + 1 + tag.length;
@@ -542,7 +627,7 @@ export function detectAccessibilityMisuseInMarkup({
542
627
  ];
543
628
  for (const { name, offset, value } of parsedAttrs) {
544
629
  const attrLower = String(name ?? '').toLowerCase();
545
- if (typeof value !== 'string' || value.trim() !== '') continue;
630
+ if (typeof value !== 'string' || value.trim() !== '' || isTemplateValue(value)) continue;
546
631
  const check = EMPTY_LABEL_CHECKS.find((c) => c.attr === attrLower);
547
632
  if (!check) continue;
548
633
  const startIndex = rawAttrsStart + offset;
@@ -618,34 +703,66 @@ export function detectInvalidSlotName({
618
703
  const diagnostics = [];
619
704
  if (!(slotMap instanceof Map) || slotMap.size === 0) return diagnostics;
620
705
 
621
- // Build global slot vocabulary (all valid slot names across all components)
706
+ const sourceText = sanitizeMarkupForValidation(text);
622
707
  const globalSlotNames = new Set();
623
708
  for (const slotNames of slotMap.values()) {
624
709
  for (const name of slotNames) globalSlotNames.add(name);
625
710
  }
626
711
 
627
- const lineStarts = computeLineIndex(text);
628
- const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
712
+ const lineStarts = computeLineIndex(sourceText);
713
+ const stack = [];
714
+ const tagRe = /<\/?([a-z][a-z0-9-]*)\b([^<>]*?)\/?>/gi;
629
715
  let m;
630
716
 
631
- while ((m = tagRe.exec(text))) {
717
+ while ((m = tagRe.exec(sourceText))) {
718
+ const fullMatch = String(m[0] ?? '');
632
719
  const tag = String(m[1] ?? '').toLowerCase();
633
720
  const attrChunk = String(m[2] ?? '');
721
+ const isClosing = fullMatch.startsWith('</');
722
+ const isSelfClosing = fullMatch.endsWith('/>');
634
723
  const rawAttrsStart = m.index + 1 + tag.length;
724
+
725
+ if (isClosing) {
726
+ for (let index = stack.length - 1; index >= 0; index -= 1) {
727
+ if (stack[index] === tag) {
728
+ stack.splice(index, 1);
729
+ break;
730
+ }
731
+ }
732
+ continue;
733
+ }
734
+
635
735
  const attrs = parseAttributes(attrChunk);
736
+ const nearestComponentParent = [...stack].reverse().find((item) => slotMap.has(item));
636
737
 
637
738
  for (const { name, offset, value } of attrs) {
638
739
  const attrName = name.toLowerCase();
639
740
  if (attrName !== 'slot') continue;
640
- if (value === undefined || value === '') continue;
641
-
642
- // 'default' is always valid (unnamed slot)
741
+ if (value === undefined || value.trim() === '' || isTemplateValue(value)) continue;
643
742
  if (value === 'default') continue;
644
743
 
744
+ const startIndex = rawAttrsStart + offset;
745
+ const endIndex = startIndex + name.length;
746
+ const range = makeRange(lineStarts, startIndex, endIndex);
747
+
748
+ if (nearestComponentParent) {
749
+ const allowedSlots = slotMap.get(nearestComponentParent) ?? new Set();
750
+ if (!allowedSlots.has(value)) {
751
+ diagnostics.push({
752
+ file: filePath,
753
+ range,
754
+ severity,
755
+ code: 'invalidSlotName',
756
+ message: `Unknown slot name "${value}" for parent <${nearestComponentParent}>.`,
757
+ tagName: tag,
758
+ attrName: 'slot',
759
+ hint: `Check <${nearestComponentParent}> for available slot names.`,
760
+ });
761
+ }
762
+ continue;
763
+ }
764
+
645
765
  if (!globalSlotNames.has(value)) {
646
- const startIndex = rawAttrsStart + offset;
647
- const endIndex = startIndex + name.length;
648
- const range = makeRange(lineStarts, startIndex, endIndex);
649
766
  diagnostics.push({
650
767
  file: filePath,
651
768
  range,
@@ -658,6 +775,10 @@ export function detectInvalidSlotName({
658
775
  });
659
776
  }
660
777
  }
778
+
779
+ if (!isSelfClosing && !HTML_VOID_ELEMENTS.has(tag) && tag.includes('-')) {
780
+ stack.push(tag);
781
+ }
661
782
  }
662
783
 
663
784
  return diagnostics;
@@ -702,7 +823,8 @@ export function detectOrphanedChildComponents({
702
823
  severity = 'warning',
703
824
  }) {
704
825
  const diagnostics = [];
705
- const lineStarts = computeLineIndex(text);
826
+ const sourceText = sanitizeMarkupForValidation(text);
827
+ const lineStarts = computeLineIndex(sourceText);
706
828
  const p = prefix.toLowerCase();
707
829
  const canonicalPrefix = 'dads';
708
830
 
@@ -722,7 +844,7 @@ export function detectOrphanedChildComponents({
722
844
  const tagRe = /<\/?([a-z][a-z0-9-]*)\b[^<>]*?\/?>/gi;
723
845
  let m;
724
846
 
725
- while ((m = tagRe.exec(text))) {
847
+ while ((m = tagRe.exec(sourceText))) {
726
848
  const fullMatch = m[0];
727
849
  const tag = String(m[1] ?? '').toLowerCase();
728
850
  const isClosing = fullMatch.startsWith('</');
@@ -794,7 +916,8 @@ export function detectEmptyInteractiveElement({
794
916
  severity = 'warning',
795
917
  }) {
796
918
  const diagnostics = [];
797
- const lineStarts = computeLineIndex(text);
919
+ const sourceText = sanitizeMarkupForValidation(text);
920
+ const lineStarts = computeLineIndex(sourceText);
798
921
  const p = prefix.toLowerCase();
799
922
  const canonicalPrefix = 'dads';
800
923
 
@@ -807,7 +930,7 @@ export function detectEmptyInteractiveElement({
807
930
  const selfClosingRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)\/>/gi;
808
931
  let m;
809
932
 
810
- while ((m = selfClosingRe.exec(text))) {
933
+ while ((m = selfClosingRe.exec(sourceText))) {
811
934
  const tag = String(m[1] ?? '').toLowerCase();
812
935
  if (!elements.has(tag)) continue;
813
936
 
@@ -834,7 +957,7 @@ export function detectEmptyInteractiveElement({
834
957
  for (const tag of elements) {
835
958
  const emptyRe = new RegExp(`<(${tag})\\b([^<>]*?)>\\s*</${tag}>`, 'gi');
836
959
  let em;
837
- while ((em = emptyRe.exec(text))) {
960
+ while ((em = emptyRe.exec(sourceText))) {
838
961
  const matchedTag = String(em[1] ?? '').toLowerCase();
839
962
  const attrChunk = String(em[2] ?? '');
840
963
  const attrs = parseAttributes(attrChunk);
@@ -888,7 +1011,8 @@ export function detectMissingRequiredAttributes({
888
1011
  severity = 'error',
889
1012
  }) {
890
1013
  const diagnostics = [];
891
- const lineStarts = computeLineIndex(text);
1014
+ const sourceText = sanitizeMarkupForValidation(text);
1015
+ const lineStarts = computeLineIndex(sourceText);
892
1016
  const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
893
1017
  let m;
894
1018
 
@@ -901,7 +1025,7 @@ export function detectMissingRequiredAttributes({
901
1025
  requiredMap.set(mappedTag, attrs);
902
1026
  }
903
1027
 
904
- while ((m = tagRe.exec(text))) {
1028
+ while ((m = tagRe.exec(sourceText))) {
905
1029
  const tag = String(m[1] ?? '').toLowerCase();
906
1030
  const requiredAttrs = requiredMap.get(tag);
907
1031
  if (!requiredAttrs) continue;
@@ -931,6 +1055,59 @@ export function detectMissingRequiredAttributes({
931
1055
  return diagnostics;
932
1056
  }
933
1057
 
1058
+ /**
1059
+ * Detect duplicate id attributes within a single markup document.
1060
+ * @param {{
1061
+ * filePath?: string;
1062
+ * text: string;
1063
+ * severity?: string;
1064
+ * }} params
1065
+ */
1066
+ export function detectDuplicateIdsInMarkup({
1067
+ filePath = '<input>',
1068
+ text,
1069
+ severity = 'error',
1070
+ }) {
1071
+ const diagnostics = [];
1072
+ const sourceText = sanitizeMarkupForValidation(text);
1073
+ const lineStarts = computeLineIndex(sourceText);
1074
+ const seen = new Map();
1075
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
1076
+ let m;
1077
+
1078
+ while ((m = tagRe.exec(sourceText))) {
1079
+ const tag = String(m[1] ?? '').toLowerCase();
1080
+ const attrChunk = String(m[2] ?? '');
1081
+ const rawAttrsStart = m.index + 1 + tag.length;
1082
+ const attrs = parseAttributes(attrChunk);
1083
+ const idAttr = attrs.find(({ name }) => String(name ?? '').toLowerCase() === 'id');
1084
+ const idValue = String(idAttr?.value ?? '').trim();
1085
+ if (!idAttr || idValue === '' || isTemplateValue(idValue)) continue;
1086
+
1087
+ const startIndex = rawAttrsStart + idAttr.offset;
1088
+ const endIndex = startIndex + idAttr.name.length;
1089
+ const range = makeRange(lineStarts, startIndex, endIndex);
1090
+ const previous = seen.get(idValue);
1091
+ if (previous) {
1092
+ diagnostics.push({
1093
+ file: filePath,
1094
+ range,
1095
+ severity,
1096
+ code: 'duplicateId',
1097
+ message: `Duplicate id "${idValue}" found. IDs must be unique within a document.`,
1098
+ tagName: tag,
1099
+ attrName: 'id',
1100
+ hint: `Rename or remove one of the duplicated id="${idValue}" attributes.`,
1101
+ });
1102
+ continue;
1103
+ }
1104
+
1105
+ seen.set(idValue, { tag, range });
1106
+ }
1107
+
1108
+ return diagnostics;
1109
+ }
1110
+
934
1111
  /**
935
1112
  * Detect attributes written in non-lowercase on known custom elements.
936
1113
  * HTML attributes are case-insensitive, but WCF uses lowercase canonically.
@@ -950,11 +1127,12 @@ export function detectNonLowercaseAttributes({
950
1127
  const diagnostics = [];
951
1128
  if (!(cem instanceof Map) || cem.size === 0) return diagnostics;
952
1129
 
953
- const lineStarts = computeLineIndex(text);
1130
+ const sourceText = sanitizeMarkupForValidation(text);
1131
+ const lineStarts = computeLineIndex(sourceText);
954
1132
  const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
955
1133
  let m;
956
1134
 
957
- while ((m = tagRe.exec(text))) {
1135
+ while ((m = tagRe.exec(sourceText))) {
958
1136
  const tag = String(m[1] ?? '').toLowerCase();
959
1137
  if (!tag.includes('-')) continue;
960
1138