@monoharada/wcf-mcp 0.1.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 ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Lightweight validator extracted from scripts/wc/validator-core.mjs.
3
+ * Only includes the two functions used by the MCP server:
4
+ * - collectCemCustomElements
5
+ * - validateTextAgainstCem
6
+ */
7
+
8
+ const GLOBAL_ATTR_ALLOW_PREFIXES = Object.freeze(['aria-', 'data-']);
9
+ const GLOBAL_ATTR_ALLOW_SET = Object.freeze(
10
+ new Set([
11
+ 'class',
12
+ 'id',
13
+ 'style',
14
+ 'title',
15
+ 'slot',
16
+ 'part',
17
+ 'exportparts',
18
+ 'tabindex',
19
+ 'role',
20
+ 'lang',
21
+ 'dir',
22
+ 'hidden',
23
+ 'inert',
24
+ ]),
25
+ );
26
+
27
+ const FORBIDDEN_ATTR_SET = Object.freeze(new Set(['placeholder']));
28
+
29
+ function isForbiddenAttr(attrName) {
30
+ return FORBIDDEN_ATTR_SET.has(attrName.toLowerCase());
31
+ }
32
+
33
+ function computeLineIndex(text) {
34
+ const out = [0];
35
+ for (let i = 0; i < text.length; i += 1) {
36
+ if (text.charCodeAt(i) === 10) out.push(i + 1);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function indexToLineCol(lineStarts, index) {
42
+ let lo = 0;
43
+ let hi = lineStarts.length - 1;
44
+ while (lo <= hi) {
45
+ const mid = (lo + hi) >> 1;
46
+ const start = lineStarts[mid];
47
+ if (start === index) return { line: mid + 1, col: 1 };
48
+ if (start < index) lo = mid + 1;
49
+ else hi = mid - 1;
50
+ }
51
+ const line = Math.max(0, lo - 1);
52
+ const col = index - lineStarts[line];
53
+ return { line: line + 1, col: col + 1 };
54
+ }
55
+
56
+ export function collectCemCustomElements(manifest) {
57
+ /** @type {Map<string, { attributes: Set<string> }>} */
58
+ const byTag = new Map();
59
+
60
+ const modules = Array.isArray(manifest?.modules) ? manifest.modules : [];
61
+ for (const mod of modules) {
62
+ const declarations = Array.isArray(mod?.declarations) ? mod.declarations : [];
63
+ for (const decl of declarations) {
64
+ const tagName = decl?.tagName;
65
+ const isCustomElement = decl?.customElement === true || decl?.kind === 'custom-element';
66
+ if (!isCustomElement || typeof tagName !== 'string' || !tagName) continue;
67
+ const tag = tagName.toLowerCase();
68
+
69
+ const attrs = new Set();
70
+ const declAttrs = Array.isArray(decl?.attributes) ? decl.attributes : [];
71
+ for (const a of declAttrs) {
72
+ if (typeof a?.name !== 'string' || !a.name) continue;
73
+ attrs.add(a.name.toLowerCase());
74
+ }
75
+
76
+ byTag.set(tag, { attributes: attrs });
77
+ }
78
+ }
79
+
80
+ return byTag;
81
+ }
82
+
83
+ function shouldSkipAttr(attrName) {
84
+ const name = attrName.toLowerCase();
85
+ if (GLOBAL_ATTR_ALLOW_SET.has(name)) return true;
86
+ for (const prefix of GLOBAL_ATTR_ALLOW_PREFIXES) {
87
+ if (name.startsWith(prefix)) return true;
88
+ }
89
+ if (name.startsWith('on')) return true;
90
+ return false;
91
+ }
92
+
93
+ function makeRange(lineStarts, startIndex, endIndex) {
94
+ const start = indexToLineCol(lineStarts, startIndex);
95
+ const end = indexToLineCol(lineStarts, endIndex);
96
+ return { start, end };
97
+ }
98
+
99
+ function parseAttributeNames(rawAttrs) {
100
+ /** @type {{ name: string, offset: number }[]} */
101
+ const out = [];
102
+
103
+ const len = rawAttrs.length;
104
+ let i = 0;
105
+
106
+ const isSpace = (code) =>
107
+ code === 9 || code === 10 || code === 12 || code === 13 || code === 32;
108
+
109
+ while (i < len) {
110
+ while (i < len && isSpace(rawAttrs.charCodeAt(i))) i += 1;
111
+ if (i >= len) break;
112
+
113
+ const c = rawAttrs[i];
114
+ if (c === '/' || c === '>') break;
115
+
116
+ const nameStart = i;
117
+ while (i < len) {
118
+ const ch = rawAttrs[i];
119
+ if (ch === '=' || ch === '>' || ch === '/' || isSpace(rawAttrs.charCodeAt(i))) break;
120
+ i += 1;
121
+ }
122
+
123
+ const name = rawAttrs.slice(nameStart, i);
124
+ if (name && !name.startsWith('${') && !name.includes('{') && !name.includes('}')) {
125
+ out.push({ name, offset: nameStart });
126
+ }
127
+
128
+ while (i < len && isSpace(rawAttrs.charCodeAt(i))) i += 1;
129
+
130
+ if (i < len && rawAttrs[i] === '=') {
131
+ i += 1;
132
+ while (i < len && isSpace(rawAttrs.charCodeAt(i))) i += 1;
133
+ if (i >= len) break;
134
+
135
+ const quote = rawAttrs[i];
136
+ if (quote === '"' || quote === "'") {
137
+ i += 1;
138
+ while (i < len && rawAttrs[i] !== quote) i += 1;
139
+ if (i < len) i += 1;
140
+ } else {
141
+ while (i < len) {
142
+ const cc = rawAttrs[i];
143
+ if (cc === '>' || cc === '/' || isSpace(rawAttrs.charCodeAt(i))) break;
144
+ i += 1;
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * @param {{
155
+ * filePath?: string;
156
+ * text: string;
157
+ * cem: Map<string, { attributes: Set<string> }>;
158
+ * severity?: { unknownElement?: string; unknownAttribute?: string };
159
+ * ignoreTags?: Set<string>;
160
+ * }} params
161
+ */
162
+ export function validateTextAgainstCem({
163
+ filePath = '<input>',
164
+ text,
165
+ cem,
166
+ severity = {},
167
+ ignoreTags = new Set(),
168
+ }) {
169
+ const diagnostics = [];
170
+ const lineStarts = computeLineIndex(text);
171
+
172
+ const tagRe = /<([a-z][a-z0-9-]*)\b([^<>]*?)>/gi;
173
+ let m;
174
+ while ((m = tagRe.exec(text))) {
175
+ const tag = String(m[1] ?? '').toLowerCase();
176
+
177
+ const tagOffset = m.index + 1;
178
+ const attrChunk = String(m[2] ?? '');
179
+
180
+ const attrNames = parseAttributeNames(attrChunk);
181
+ for (const { name, offset } of attrNames) {
182
+ const attrName = name.toLowerCase();
183
+ if (!isForbiddenAttr(attrName)) continue;
184
+
185
+ const rawAttrsStart = m.index + 1 + tag.length;
186
+ const startIndex = rawAttrsStart + offset;
187
+ const endIndex = startIndex + attrName.length;
188
+ const range = makeRange(lineStarts, startIndex, endIndex);
189
+ diagnostics.push({
190
+ file: filePath,
191
+ range,
192
+ severity: 'error',
193
+ code: 'forbiddenAttribute',
194
+ message: `Forbidden attribute: ${attrName} (use explicit labels/support text instead)`,
195
+ tagName: tag,
196
+ attrName,
197
+ });
198
+ }
199
+
200
+ if (!tag.includes('-')) continue;
201
+ if (ignoreTags.has(tag)) continue;
202
+
203
+ const meta = cem.get(tag);
204
+ if (!meta) {
205
+ const range = makeRange(lineStarts, tagOffset, tagOffset + tag.length);
206
+ diagnostics.push({
207
+ file: filePath,
208
+ range,
209
+ severity: severity.unknownElement ?? 'error',
210
+ code: 'unknownElement',
211
+ message: `Unknown element: ${tag}`,
212
+ tagName: tag,
213
+ });
214
+ continue;
215
+ }
216
+
217
+ for (const { name, offset } of attrNames) {
218
+ const attrName = name.toLowerCase();
219
+ if (isForbiddenAttr(attrName)) continue;
220
+ if (shouldSkipAttr(attrName)) continue;
221
+ if (meta.attributes.has(attrName)) continue;
222
+
223
+ const rawAttrsStart = m.index + 1 + tag.length;
224
+ const startIndex = rawAttrsStart + offset;
225
+ const endIndex = startIndex + attrName.length;
226
+ const range = makeRange(lineStarts, startIndex, endIndex);
227
+ diagnostics.push({
228
+ file: filePath,
229
+ range,
230
+ severity: severity.unknownAttribute ?? 'warning',
231
+ code: 'unknownAttribute',
232
+ message: `Unknown attribute on <${tag}>: ${attrName}`,
233
+ tagName: tag,
234
+ attrName,
235
+ });
236
+ }
237
+ }
238
+
239
+ return diagnostics;
240
+ }