@monoharada/wcf-mcp 0.1.2 → 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 +348 -8
- package/bin.mjs +19 -2
- package/core.mjs +1125 -80
- 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 +459 -0
- package/wcf-mcp.config.example.json +24 -0
package/validator.mjs
CHANGED
|
@@ -91,6 +91,130 @@ export function collectCemCustomElements(manifest) {
|
|
|
91
91
|
return byTag;
|
|
92
92
|
}
|
|
93
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
|
+
|
|
94
218
|
function shouldSkipAttr(attrName) {
|
|
95
219
|
const name = attrName.toLowerCase();
|
|
96
220
|
if (GLOBAL_ATTR_ALLOW_SET.has(name)) return true;
|
|
@@ -407,3 +531,338 @@ export function detectAccessibilityMisuseInMarkup({
|
|
|
407
531
|
|
|
408
532
|
return diagnostics;
|
|
409
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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"dataSources": {
|
|
3
|
+
"guidelines-index.json": "./packages/mcp-server/data/guidelines-index.json"
|
|
4
|
+
},
|
|
5
|
+
"plugins": [
|
|
6
|
+
{
|
|
7
|
+
"module": "./packages/mcp-server/examples/plugins/custom-validation-plugin.mjs"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"name": "static-tools-plugin",
|
|
11
|
+
"version": "0.1.0",
|
|
12
|
+
"staticTools": [
|
|
13
|
+
{
|
|
14
|
+
"name": "plugin_healthcheck",
|
|
15
|
+
"description": "Return plugin health information.",
|
|
16
|
+
"payload": {
|
|
17
|
+
"ok": true,
|
|
18
|
+
"source": "static-tools-plugin"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|