@dryui/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/dist/ai-surface.d.ts +10 -0
- package/dist/architecture.d.ts +103 -0
- package/dist/architecture.js +6262 -0
- package/dist/architecture.json +24419 -0
- package/dist/check-contract.d.ts +1 -0
- package/dist/composition-data.d.ts +27 -0
- package/dist/composition-data.js +5502 -0
- package/dist/contract.d.ts +41 -0
- package/dist/contract.v1.json +22804 -0
- package/dist/contract.v1.schema.json +523 -0
- package/dist/generate-architecture.d.ts +1 -0
- package/dist/generate-contract.d.ts +1 -0
- package/dist/generate-llms-txt.d.ts +6 -0
- package/dist/generate-spec.d.ts +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +31474 -0
- package/dist/project-planner.d.ts +73 -0
- package/dist/project-planner.js +374 -0
- package/dist/reviewer.d.ts +28 -0
- package/dist/reviewer.js +744 -0
- package/dist/spec-formatters.d.ts +19 -0
- package/dist/spec-formatters.js +256 -0
- package/dist/spec-types.d.ts +83 -0
- package/dist/spec-types.js +0 -0
- package/dist/spec.json +22976 -0
- package/dist/theme-checker.d.ts +22 -0
- package/dist/theme-checker.js +823 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +72 -0
- package/dist/workspace-audit.d.ts +54 -0
- package/dist/workspace-audit.js +2099 -0
- package/package.json +94 -0
package/dist/reviewer.js
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
function __accessProp(key) {
|
|
7
|
+
return this[key];
|
|
8
|
+
}
|
|
9
|
+
var __toESMCache_node;
|
|
10
|
+
var __toESMCache_esm;
|
|
11
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
12
|
+
var canCache = mod != null && typeof mod === "object";
|
|
13
|
+
if (canCache) {
|
|
14
|
+
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
15
|
+
var cached = cache.get(mod);
|
|
16
|
+
if (cached)
|
|
17
|
+
return cached;
|
|
18
|
+
}
|
|
19
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
20
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
21
|
+
for (let key of __getOwnPropNames(mod))
|
|
22
|
+
if (!__hasOwnProp.call(to, key))
|
|
23
|
+
__defProp(to, key, {
|
|
24
|
+
get: __accessProp.bind(mod, key),
|
|
25
|
+
enumerable: true
|
|
26
|
+
});
|
|
27
|
+
if (canCache)
|
|
28
|
+
cache.set(mod, to);
|
|
29
|
+
return to;
|
|
30
|
+
};
|
|
31
|
+
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
32
|
+
var __returnValue = (v) => v;
|
|
33
|
+
function __exportSetter(name, newValue) {
|
|
34
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
35
|
+
}
|
|
36
|
+
var __export = (target, all) => {
|
|
37
|
+
for (var name in all)
|
|
38
|
+
__defProp(target, name, {
|
|
39
|
+
get: all[name],
|
|
40
|
+
enumerable: true,
|
|
41
|
+
configurable: true,
|
|
42
|
+
set: __exportSetter.bind(all, name)
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/utils.ts
|
|
47
|
+
function buildLineOffsets(text) {
|
|
48
|
+
const offsets = [0];
|
|
49
|
+
for (let i = 0;i < text.length; i++) {
|
|
50
|
+
if (text[i] === `
|
|
51
|
+
`)
|
|
52
|
+
offsets.push(i + 1);
|
|
53
|
+
}
|
|
54
|
+
return offsets;
|
|
55
|
+
}
|
|
56
|
+
function lineAtOffset(lineOffsets, offset) {
|
|
57
|
+
let lo = 0;
|
|
58
|
+
let hi = lineOffsets.length - 1;
|
|
59
|
+
while (lo < hi) {
|
|
60
|
+
const mid = lo + hi + 1 >> 1;
|
|
61
|
+
const midVal = lineOffsets[mid];
|
|
62
|
+
if (midVal !== undefined && midVal <= offset)
|
|
63
|
+
lo = mid;
|
|
64
|
+
else
|
|
65
|
+
hi = mid - 1;
|
|
66
|
+
}
|
|
67
|
+
return lo + 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/reviewer.ts
|
|
71
|
+
function extractImports(code) {
|
|
72
|
+
const imports = new Set;
|
|
73
|
+
const scriptMatch = code.match(/<script[^>]*>([\s\S]*?)<\/script>/);
|
|
74
|
+
if (!scriptMatch)
|
|
75
|
+
return imports;
|
|
76
|
+
const scriptContent = scriptMatch[1] ?? "";
|
|
77
|
+
const importRegex = /import\s*\{([^}]+)\}\s*from\s*['"]@dryui\/(ui|primitives)['"]/g;
|
|
78
|
+
let match;
|
|
79
|
+
while ((match = importRegex.exec(scriptContent)) !== null) {
|
|
80
|
+
const raw = match[1] ?? "";
|
|
81
|
+
const names = raw.split(",");
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
const trimmed = name.trim();
|
|
84
|
+
if (trimmed)
|
|
85
|
+
imports.add(trimmed);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return imports;
|
|
89
|
+
}
|
|
90
|
+
function extractTags(code) {
|
|
91
|
+
const template = code.replace(/<script[^>]*>[\s\S]*?<\/script>/g, (m) => `
|
|
92
|
+
`.repeat(countNewlines(m))).replace(/<style[^>]*>[\s\S]*?<\/style>/g, (m) => `
|
|
93
|
+
`.repeat(countNewlines(m)));
|
|
94
|
+
const lineOffsets = buildLineOffsets(code);
|
|
95
|
+
const tagRegex = /<([A-Z][a-zA-Z0-9]*(?:\.[A-Z][a-zA-Z0-9]*)*)\s*([^>]*?)(\/)?>/g;
|
|
96
|
+
const tags = [];
|
|
97
|
+
let match;
|
|
98
|
+
while ((match = tagRegex.exec(template)) !== null) {
|
|
99
|
+
const name = match[1] ?? "";
|
|
100
|
+
const attrsStr = match[2] ?? "";
|
|
101
|
+
const selfClosing = match[3] === "/";
|
|
102
|
+
const line = lineAtOffset(lineOffsets, match.index);
|
|
103
|
+
const props = extractPropsFromAttrs(attrsStr);
|
|
104
|
+
const hasSpread = /\{\.\.\./.test(attrsStr);
|
|
105
|
+
tags.push({ name, line, props, hasSpread, selfClosing });
|
|
106
|
+
}
|
|
107
|
+
return tags;
|
|
108
|
+
}
|
|
109
|
+
function extractStyles(code) {
|
|
110
|
+
const styleMatch = code.match(/<style[^>]*>([\s\S]*?)<\/style>/);
|
|
111
|
+
return styleMatch ? styleMatch[1] ?? null : null;
|
|
112
|
+
}
|
|
113
|
+
function countNewlines(str) {
|
|
114
|
+
let count = 0;
|
|
115
|
+
for (let i = 0;i < str.length; i++) {
|
|
116
|
+
if (str[i] === `
|
|
117
|
+
`)
|
|
118
|
+
count++;
|
|
119
|
+
}
|
|
120
|
+
return count;
|
|
121
|
+
}
|
|
122
|
+
function stripBraceExpressions(str) {
|
|
123
|
+
let result = "";
|
|
124
|
+
let depth = 0;
|
|
125
|
+
for (let i = 0;i < str.length; i++) {
|
|
126
|
+
if (str[i] === "{") {
|
|
127
|
+
if (depth === 0)
|
|
128
|
+
result += "{}";
|
|
129
|
+
depth++;
|
|
130
|
+
} else if (str[i] === "}") {
|
|
131
|
+
depth--;
|
|
132
|
+
} else if (depth === 0) {
|
|
133
|
+
result += str[i];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
function extractPropsFromAttrs(attrsStr) {
|
|
139
|
+
const props = [];
|
|
140
|
+
if (!attrsStr.trim())
|
|
141
|
+
return props;
|
|
142
|
+
const stripped = stripBraceExpressions(attrsStr.replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''"));
|
|
143
|
+
const bindRegex = /\bbind:([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
144
|
+
let m;
|
|
145
|
+
while ((m = bindRegex.exec(stripped)) !== null) {
|
|
146
|
+
const bound = m[1];
|
|
147
|
+
if (bound)
|
|
148
|
+
props.push("bind:" + bound);
|
|
149
|
+
}
|
|
150
|
+
const namedRegex = /\b([a-zA-Z_][a-zA-Z0-9_-]*)\s*=/g;
|
|
151
|
+
while ((m = namedRegex.exec(stripped)) !== null) {
|
|
152
|
+
const propName = m[1];
|
|
153
|
+
if (propName && !props.includes(propName)) {
|
|
154
|
+
props.push(propName);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const boolRegex = /(?<!\.)(?<![:{])\b([a-zA-Z_][a-zA-Z0-9_-]*)\b(?!\s*=)/g;
|
|
158
|
+
while ((m = boolRegex.exec(stripped)) !== null) {
|
|
159
|
+
const propName = m[1];
|
|
160
|
+
if (!propName)
|
|
161
|
+
continue;
|
|
162
|
+
if (props.includes(propName) || props.includes("bind:" + propName) || propName === "bind") {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
props.push(propName);
|
|
166
|
+
}
|
|
167
|
+
return props;
|
|
168
|
+
}
|
|
169
|
+
var NATIVE_HTML_ATTRS = new Set([
|
|
170
|
+
"id",
|
|
171
|
+
"class",
|
|
172
|
+
"style",
|
|
173
|
+
"title",
|
|
174
|
+
"lang",
|
|
175
|
+
"dir",
|
|
176
|
+
"tabindex",
|
|
177
|
+
"hidden",
|
|
178
|
+
"role",
|
|
179
|
+
"slot",
|
|
180
|
+
"is",
|
|
181
|
+
"part",
|
|
182
|
+
"translate",
|
|
183
|
+
"draggable",
|
|
184
|
+
"contenteditable",
|
|
185
|
+
"spellcheck",
|
|
186
|
+
"autocapitalize",
|
|
187
|
+
"inputmode",
|
|
188
|
+
"enterkeyhint",
|
|
189
|
+
"children",
|
|
190
|
+
"name",
|
|
191
|
+
"value",
|
|
192
|
+
"type",
|
|
193
|
+
"placeholder",
|
|
194
|
+
"required",
|
|
195
|
+
"readonly",
|
|
196
|
+
"disabled",
|
|
197
|
+
"checked",
|
|
198
|
+
"selected",
|
|
199
|
+
"multiple",
|
|
200
|
+
"autofocus",
|
|
201
|
+
"autocomplete",
|
|
202
|
+
"pattern",
|
|
203
|
+
"min",
|
|
204
|
+
"max",
|
|
205
|
+
"step",
|
|
206
|
+
"minlength",
|
|
207
|
+
"maxlength",
|
|
208
|
+
"form",
|
|
209
|
+
"formaction",
|
|
210
|
+
"formmethod",
|
|
211
|
+
"formtarget",
|
|
212
|
+
"formnovalidate",
|
|
213
|
+
"accept",
|
|
214
|
+
"capture",
|
|
215
|
+
"list",
|
|
216
|
+
"size",
|
|
217
|
+
"href",
|
|
218
|
+
"target",
|
|
219
|
+
"rel",
|
|
220
|
+
"download",
|
|
221
|
+
"src",
|
|
222
|
+
"alt",
|
|
223
|
+
"width",
|
|
224
|
+
"height",
|
|
225
|
+
"loading",
|
|
226
|
+
"decoding",
|
|
227
|
+
"crossorigin",
|
|
228
|
+
"referrerpolicy",
|
|
229
|
+
"for",
|
|
230
|
+
"htmlFor"
|
|
231
|
+
]);
|
|
232
|
+
function isPropAllowed(propName) {
|
|
233
|
+
if (NATIVE_HTML_ATTRS.has(propName))
|
|
234
|
+
return true;
|
|
235
|
+
if (propName.startsWith("aria-"))
|
|
236
|
+
return true;
|
|
237
|
+
if (propName.startsWith("data-"))
|
|
238
|
+
return true;
|
|
239
|
+
if (propName.startsWith("on"))
|
|
240
|
+
return true;
|
|
241
|
+
if (propName.startsWith("bind:"))
|
|
242
|
+
return true;
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
function checkBareCompound(tags, spec) {
|
|
246
|
+
const issues = [];
|
|
247
|
+
for (const tag of tags) {
|
|
248
|
+
if (tag.name.includes("."))
|
|
249
|
+
continue;
|
|
250
|
+
const def = spec.components[tag.name];
|
|
251
|
+
if (!def?.compound)
|
|
252
|
+
continue;
|
|
253
|
+
const partNames = Object.keys(def.parts ?? {});
|
|
254
|
+
const hasRootPart = partNames.includes("Root");
|
|
255
|
+
const firstPart = partNames.find((part) => part !== "Root");
|
|
256
|
+
if (hasRootPart) {
|
|
257
|
+
issues.push({
|
|
258
|
+
severity: "error",
|
|
259
|
+
code: "bare-compound",
|
|
260
|
+
line: tag.line,
|
|
261
|
+
message: `<${tag.name}> is a compound component — use <${tag.name}.Root>`,
|
|
262
|
+
fix: `<${tag.name}.Root>`
|
|
263
|
+
});
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
issues.push({
|
|
267
|
+
severity: "error",
|
|
268
|
+
code: "bare-compound",
|
|
269
|
+
line: tag.line,
|
|
270
|
+
message: `<${tag.name}> is a namespaced component — use a part like <${tag.name}.${firstPart ?? "Text"}>`,
|
|
271
|
+
fix: `<${tag.name}.${firstPart ?? "Text"}>`
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return issues;
|
|
275
|
+
}
|
|
276
|
+
function checkUnknownComponent(tags, imports, spec) {
|
|
277
|
+
const issues = [];
|
|
278
|
+
for (const tag of tags) {
|
|
279
|
+
const root = tag.name.split(".")[0] ?? tag.name;
|
|
280
|
+
if (imports.has(root) && !spec.components[root]) {
|
|
281
|
+
issues.push({
|
|
282
|
+
severity: "error",
|
|
283
|
+
code: "unknown-component",
|
|
284
|
+
line: tag.line,
|
|
285
|
+
message: `<${tag.name}> is not a known DryUI component`,
|
|
286
|
+
fix: null
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return issues;
|
|
291
|
+
}
|
|
292
|
+
function checkInvalidPartName(tags, spec) {
|
|
293
|
+
const issues = [];
|
|
294
|
+
for (const tag of tags) {
|
|
295
|
+
if (!tag.name.includes("."))
|
|
296
|
+
continue;
|
|
297
|
+
const parts = tag.name.split(".");
|
|
298
|
+
const root = parts[0] ?? "";
|
|
299
|
+
const part = parts[1] ?? "";
|
|
300
|
+
if (!root || !part)
|
|
301
|
+
continue;
|
|
302
|
+
const def = spec.components[root];
|
|
303
|
+
if (!def?.compound || !def.parts)
|
|
304
|
+
continue;
|
|
305
|
+
if (!def.parts[part]) {
|
|
306
|
+
const validParts = Object.keys(def.parts);
|
|
307
|
+
issues.push({
|
|
308
|
+
severity: "error",
|
|
309
|
+
code: "invalid-part",
|
|
310
|
+
line: tag.line,
|
|
311
|
+
message: `<${root}.${part}> — "${part}" is not a valid part of ${root}. Valid parts: ${validParts.join(", ")}`,
|
|
312
|
+
fix: null
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return issues;
|
|
317
|
+
}
|
|
318
|
+
function checkInvalidProp(tags, spec) {
|
|
319
|
+
const issues = [];
|
|
320
|
+
for (const tag of tags) {
|
|
321
|
+
const segments = tag.name.split(".");
|
|
322
|
+
const root = segments[0] ?? "";
|
|
323
|
+
const part = segments[1];
|
|
324
|
+
if (!root)
|
|
325
|
+
continue;
|
|
326
|
+
const def = spec.components[root];
|
|
327
|
+
if (!def)
|
|
328
|
+
continue;
|
|
329
|
+
let specProps;
|
|
330
|
+
if (part) {
|
|
331
|
+
specProps = def.parts?.[part]?.props;
|
|
332
|
+
} else if (!def.compound) {
|
|
333
|
+
specProps = def.props;
|
|
334
|
+
} else {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (!specProps)
|
|
338
|
+
continue;
|
|
339
|
+
for (const prop of tag.props) {
|
|
340
|
+
const checkName = prop.startsWith("bind:") ? prop.slice(5) : prop;
|
|
341
|
+
if (isPropAllowed(prop))
|
|
342
|
+
continue;
|
|
343
|
+
if (!specProps[checkName]) {
|
|
344
|
+
issues.push({
|
|
345
|
+
severity: "error",
|
|
346
|
+
code: "invalid-prop",
|
|
347
|
+
line: tag.line,
|
|
348
|
+
message: `<${tag.name}> does not accept prop "${checkName}"`,
|
|
349
|
+
fix: null
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return issues;
|
|
355
|
+
}
|
|
356
|
+
function checkMissingRequiredProp(tags, spec) {
|
|
357
|
+
const issues = [];
|
|
358
|
+
for (const tag of tags) {
|
|
359
|
+
if (tag.hasSpread)
|
|
360
|
+
continue;
|
|
361
|
+
const segments = tag.name.split(".");
|
|
362
|
+
const root = segments[0] ?? "";
|
|
363
|
+
const part = segments[1];
|
|
364
|
+
if (!root)
|
|
365
|
+
continue;
|
|
366
|
+
const def = spec.components[root];
|
|
367
|
+
if (!def)
|
|
368
|
+
continue;
|
|
369
|
+
let specProps;
|
|
370
|
+
if (part) {
|
|
371
|
+
specProps = def.parts?.[part]?.props;
|
|
372
|
+
} else if (!def.compound) {
|
|
373
|
+
specProps = def.props;
|
|
374
|
+
} else {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (!specProps)
|
|
378
|
+
continue;
|
|
379
|
+
const tagPropNames = new Set(tag.props.map((p) => p.startsWith("bind:") ? p.slice(5) : p));
|
|
380
|
+
for (const [propName, propDef] of Object.entries(specProps)) {
|
|
381
|
+
if (propDef.required && !tagPropNames.has(propName)) {
|
|
382
|
+
if (propName === "children" && !tag.selfClosing)
|
|
383
|
+
continue;
|
|
384
|
+
issues.push({
|
|
385
|
+
severity: "error",
|
|
386
|
+
code: "missing-required-prop",
|
|
387
|
+
line: tag.line,
|
|
388
|
+
message: `<${tag.name}> is missing required prop "${propName}"`,
|
|
389
|
+
fix: `${propName}={...}`
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return issues;
|
|
395
|
+
}
|
|
396
|
+
function checkOrphanedPart(tags, spec) {
|
|
397
|
+
const issues = [];
|
|
398
|
+
const allNames = new Set(tags.map((t) => t.name));
|
|
399
|
+
for (const tag of tags) {
|
|
400
|
+
if (!tag.name.includes("."))
|
|
401
|
+
continue;
|
|
402
|
+
const root = tag.name.split(".")[0] ?? "";
|
|
403
|
+
if (!root)
|
|
404
|
+
continue;
|
|
405
|
+
const def = spec.components[root];
|
|
406
|
+
if (!def?.compound || !def.parts?.Root)
|
|
407
|
+
continue;
|
|
408
|
+
if (!allNames.has(`${root}.Root`)) {
|
|
409
|
+
issues.push({
|
|
410
|
+
severity: "warning",
|
|
411
|
+
code: "orphaned-part",
|
|
412
|
+
line: tag.line,
|
|
413
|
+
message: `<${tag.name}> used without <${root}.Root> in the template`,
|
|
414
|
+
fix: `Wrap in <${root}.Root>`
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return issues;
|
|
419
|
+
}
|
|
420
|
+
function checkMissingLabel(tags, code) {
|
|
421
|
+
const issues = [];
|
|
422
|
+
const template = code.replace(/<script[^>]*>[\s\S]*?<\/script>/g, (m) => `
|
|
423
|
+
`.repeat(countNewlines(m))).replace(/<style[^>]*>[\s\S]*?<\/style>/g, (m) => `
|
|
424
|
+
`.repeat(countNewlines(m)));
|
|
425
|
+
const lineOffsets = buildLineOffsets(code);
|
|
426
|
+
for (const tag of tags) {
|
|
427
|
+
if (tag.name !== "Input" && tag.name !== "Select.Root" && tag.name !== "Combobox.Input")
|
|
428
|
+
continue;
|
|
429
|
+
const hasAriaLabel = tag.props.some((p) => p === "aria-label");
|
|
430
|
+
if (hasAriaLabel)
|
|
431
|
+
continue;
|
|
432
|
+
const tagOffset = findTagOffset(template, tag.name, tag.line, lineOffsets);
|
|
433
|
+
const wrappedByField = tagOffset !== -1 && template.lastIndexOf("<Field.Root", tagOffset) !== -1 && template.indexOf("</Field.Root>", tagOffset) !== -1;
|
|
434
|
+
if (!wrappedByField) {
|
|
435
|
+
issues.push({
|
|
436
|
+
severity: "warning",
|
|
437
|
+
code: "missing-label",
|
|
438
|
+
line: tag.line,
|
|
439
|
+
message: `<${tag.name}> may be missing an accessible label — add aria-label or wrap in <Field.Root> with <Label>`,
|
|
440
|
+
fix: 'aria-label="..."'
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return issues;
|
|
445
|
+
}
|
|
446
|
+
function findTagOffset(template, tagName, targetLine, lineOffsets) {
|
|
447
|
+
const regex = new RegExp(`<${tagName.replace(".", "\\.")}[\\s/>]`, "g");
|
|
448
|
+
let match;
|
|
449
|
+
while ((match = regex.exec(template)) !== null) {
|
|
450
|
+
const line = lineAtOffset(lineOffsets, match.index);
|
|
451
|
+
if (line === targetLine)
|
|
452
|
+
return match.index;
|
|
453
|
+
}
|
|
454
|
+
return -1;
|
|
455
|
+
}
|
|
456
|
+
function checkMissingThumbnail(imports, spec) {
|
|
457
|
+
if (!spec.thumbnails)
|
|
458
|
+
return [];
|
|
459
|
+
const issues = [];
|
|
460
|
+
const thumbnailSet = new Set(spec.thumbnails);
|
|
461
|
+
for (const name of imports) {
|
|
462
|
+
if (!spec.components[name])
|
|
463
|
+
continue;
|
|
464
|
+
if (!thumbnailSet.has(name)) {
|
|
465
|
+
issues.push({
|
|
466
|
+
severity: "warning",
|
|
467
|
+
code: "missing-thumbnail",
|
|
468
|
+
line: 1,
|
|
469
|
+
message: `Component '${name}' has no SVG thumbnail — run 'bun run thumbnail:create ${name}'`,
|
|
470
|
+
fix: null
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return issues;
|
|
475
|
+
}
|
|
476
|
+
function checkImageWithoutAlt(tags) {
|
|
477
|
+
const issues = [];
|
|
478
|
+
for (const tag of tags) {
|
|
479
|
+
if (tag.name !== "Avatar")
|
|
480
|
+
continue;
|
|
481
|
+
const hasAlt = tag.props.includes("alt");
|
|
482
|
+
const hasFallback = tag.props.includes("fallback");
|
|
483
|
+
if (!hasAlt && !hasFallback) {
|
|
484
|
+
issues.push({
|
|
485
|
+
severity: "warning",
|
|
486
|
+
code: "missing-alt",
|
|
487
|
+
line: tag.line,
|
|
488
|
+
message: '<Avatar> is missing "alt" and "fallback" props for accessibility',
|
|
489
|
+
fix: 'alt="..."'
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return issues;
|
|
494
|
+
}
|
|
495
|
+
function checkCustomGridLayout(styles, code) {
|
|
496
|
+
const issues = [];
|
|
497
|
+
if (/display:\s*grid/.test(styles)) {
|
|
498
|
+
const startLine = getStyleBlockStartLine(code);
|
|
499
|
+
const localLine = findLineInBlock(styles, /display:\s*grid/);
|
|
500
|
+
issues.push({
|
|
501
|
+
severity: "warning",
|
|
502
|
+
code: "use-grid-component",
|
|
503
|
+
line: styleLineToFileLine(localLine, startLine),
|
|
504
|
+
message: "Use DryUI's <Grid> component instead of custom CSS grid. Grid provides responsive columns, gap tokens, and breakpoint handling.",
|
|
505
|
+
fix: '<Grid --dry-grid-columns="repeat(2, 1fr)" --dry-grid-gap="var(--dry-space-4)">'
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return issues;
|
|
509
|
+
}
|
|
510
|
+
function checkCustomFlexLayout(styles, code) {
|
|
511
|
+
const issues = [];
|
|
512
|
+
if (/display:\s*flex/.test(styles)) {
|
|
513
|
+
const startLine = getStyleBlockStartLine(code);
|
|
514
|
+
const localLine = findLineInBlock(styles, /display:\s*flex/);
|
|
515
|
+
issues.push({
|
|
516
|
+
severity: "warning",
|
|
517
|
+
code: "use-flex-component",
|
|
518
|
+
line: styleLineToFileLine(localLine, startLine),
|
|
519
|
+
message: "Use DryUI's <Flex> or <Stack> component instead of custom CSS flexbox. These provide gap, alignment, and responsive behavior with theme tokens.",
|
|
520
|
+
fix: "<Flex> or <Stack>"
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
return issues;
|
|
524
|
+
}
|
|
525
|
+
function checkCustomFieldMarkup(code) {
|
|
526
|
+
const issues = [];
|
|
527
|
+
const template = code.replace(/<script[^>]*>[\s\S]*?<\/script>/g, (m) => `
|
|
528
|
+
`.repeat(countNewlines(m))).replace(/<style[^>]*>[\s\S]*?<\/style>/g, (m) => `
|
|
529
|
+
`.repeat(countNewlines(m)));
|
|
530
|
+
const lineOffsets = buildLineOffsets(code);
|
|
531
|
+
const fieldClassRegex = /class=["']field["']/g;
|
|
532
|
+
let match;
|
|
533
|
+
while ((match = fieldClassRegex.exec(template)) !== null) {
|
|
534
|
+
const line = lineAtOffset(lineOffsets, match.index);
|
|
535
|
+
issues.push({
|
|
536
|
+
severity: "warning",
|
|
537
|
+
code: "use-field-component",
|
|
538
|
+
line,
|
|
539
|
+
message: "Use <Field.Root> + <Label> instead of custom field markup. Field provides accessible labeling, error states, and consistent spacing.",
|
|
540
|
+
fix: "<Field.Root> + <Label>"
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
const manualFieldRegex = /<span[^>]*>[\s\S]*?<\/span>\s*<(?:input|select|textarea)[\s/>]/g;
|
|
544
|
+
while ((match = manualFieldRegex.exec(template)) !== null) {
|
|
545
|
+
const line = lineAtOffset(lineOffsets, match.index);
|
|
546
|
+
issues.push({
|
|
547
|
+
severity: "warning",
|
|
548
|
+
code: "use-field-component",
|
|
549
|
+
line,
|
|
550
|
+
message: "Use <Field.Root> + <Label> instead of custom field markup. Field provides accessible labeling, error states, and consistent spacing.",
|
|
551
|
+
fix: "<Field.Root> + <Label>"
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
return issues;
|
|
555
|
+
}
|
|
556
|
+
function checkRawStyledButton(code) {
|
|
557
|
+
const issues = [];
|
|
558
|
+
const template = code.replace(/<script[^>]*>[\s\S]*?<\/script>/g, (m) => `
|
|
559
|
+
`.repeat(countNewlines(m))).replace(/<style[^>]*>[\s\S]*?<\/style>/g, (m) => `
|
|
560
|
+
`.repeat(countNewlines(m)));
|
|
561
|
+
const lineOffsets = buildLineOffsets(code);
|
|
562
|
+
const rawBtnRegex = /<button\s[^>]*class=/g;
|
|
563
|
+
let match;
|
|
564
|
+
while ((match = rawBtnRegex.exec(template)) !== null) {
|
|
565
|
+
const line = lineAtOffset(lineOffsets, match.index);
|
|
566
|
+
issues.push({
|
|
567
|
+
severity: "warning",
|
|
568
|
+
code: "use-button-component",
|
|
569
|
+
line,
|
|
570
|
+
message: "Use DryUI's <Button> component instead of raw <button> with custom classes. Button provides variants, sizes, loading states, and theme-consistent styling.",
|
|
571
|
+
fix: "<Button>"
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
return issues;
|
|
575
|
+
}
|
|
576
|
+
function checkCustomMaxWidthCentering(styles, code) {
|
|
577
|
+
const issues = [];
|
|
578
|
+
if (/max-width/.test(styles) && /margin[^;]*auto/.test(styles)) {
|
|
579
|
+
const startLine = getStyleBlockStartLine(code);
|
|
580
|
+
const localLine = findLineInBlock(styles, /max-width/);
|
|
581
|
+
issues.push({
|
|
582
|
+
severity: "warning",
|
|
583
|
+
code: "use-container-component",
|
|
584
|
+
line: styleLineToFileLine(localLine, startLine),
|
|
585
|
+
message: "Use DryUI's <Container> component instead of custom max-width + margin centering.",
|
|
586
|
+
fix: "<Container>"
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
return issues;
|
|
590
|
+
}
|
|
591
|
+
function getStyleBlockStartLine(code) {
|
|
592
|
+
const idx = code.search(/<style[^>]*>/);
|
|
593
|
+
if (idx === -1)
|
|
594
|
+
return 1;
|
|
595
|
+
const lineOffsets = buildLineOffsets(code);
|
|
596
|
+
const tagEndIdx = code.indexOf(">", idx) + 1;
|
|
597
|
+
return lineAtOffset(lineOffsets, tagEndIdx);
|
|
598
|
+
}
|
|
599
|
+
function styleLineToFileLine(lineInStyleBlock, styleStartLine) {
|
|
600
|
+
return styleStartLine + lineInStyleBlock - 1;
|
|
601
|
+
}
|
|
602
|
+
function findLineInBlock(block, regex) {
|
|
603
|
+
const lines = block.split(`
|
|
604
|
+
`);
|
|
605
|
+
for (let i = 0;i < lines.length; i++) {
|
|
606
|
+
const line = lines[i];
|
|
607
|
+
if (line !== undefined && regex.test(line))
|
|
608
|
+
return i + 1;
|
|
609
|
+
}
|
|
610
|
+
return 1;
|
|
611
|
+
}
|
|
612
|
+
function checkManualFlex(styles, code) {
|
|
613
|
+
const issues = [];
|
|
614
|
+
if (/display:\s*flex/.test(styles)) {
|
|
615
|
+
const startLine = getStyleBlockStartLine(code);
|
|
616
|
+
const localLine = findLineInBlock(styles, /display:\s*flex/);
|
|
617
|
+
issues.push({
|
|
618
|
+
severity: "suggestion",
|
|
619
|
+
code: "prefer-layout",
|
|
620
|
+
line: styleLineToFileLine(localLine, startLine),
|
|
621
|
+
message: "Manual `display: flex` detected — consider using <Flex> or <Stack> instead",
|
|
622
|
+
fix: "<Flex> or <Stack>"
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
return issues;
|
|
626
|
+
}
|
|
627
|
+
function checkManualGrid(styles, code) {
|
|
628
|
+
const issues = [];
|
|
629
|
+
if (/display:\s*grid/.test(styles) && /grid-template-columns/.test(styles)) {
|
|
630
|
+
const startLine = getStyleBlockStartLine(code);
|
|
631
|
+
const localLine = findLineInBlock(styles, /display:\s*grid/);
|
|
632
|
+
issues.push({
|
|
633
|
+
severity: "suggestion",
|
|
634
|
+
code: "prefer-layout",
|
|
635
|
+
line: styleLineToFileLine(localLine, startLine),
|
|
636
|
+
message: 'Manual CSS grid detected — consider using <Grid --dry-grid-columns="repeat(2, 1fr)"> instead',
|
|
637
|
+
fix: '<Grid --dry-grid-columns="repeat(2, 1fr)" --dry-grid-gap="var(--dry-space-4)">'
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return issues;
|
|
641
|
+
}
|
|
642
|
+
function checkHardcodedColors(styles, code) {
|
|
643
|
+
const issues = [];
|
|
644
|
+
const colorRegex = /(?:^|;)\s*(?:color|background(?:-color)?)\s*:\s*(?!.*var\s*\()/m;
|
|
645
|
+
if (colorRegex.test(styles)) {
|
|
646
|
+
const startLine = getStyleBlockStartLine(code);
|
|
647
|
+
const localLine = findLineInBlock(styles, /(?:color|background(?:-color)?)\s*:\s*(?!.*var\s*\()/);
|
|
648
|
+
issues.push({
|
|
649
|
+
severity: "suggestion",
|
|
650
|
+
code: "hardcoded-color",
|
|
651
|
+
line: styleLineToFileLine(localLine, startLine),
|
|
652
|
+
message: "Hardcoded color value — consider using `--dry-*` CSS custom properties for theming",
|
|
653
|
+
fix: "var(--dry-*)"
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
return issues;
|
|
657
|
+
}
|
|
658
|
+
function checkManualCentering(styles, code) {
|
|
659
|
+
const issues = [];
|
|
660
|
+
if (/max-width/.test(styles) && /margin:\s*[^;]*auto/.test(styles)) {
|
|
661
|
+
const startLine = getStyleBlockStartLine(code);
|
|
662
|
+
const localLine = findLineInBlock(styles, /max-width/);
|
|
663
|
+
issues.push({
|
|
664
|
+
severity: "suggestion",
|
|
665
|
+
code: "prefer-container",
|
|
666
|
+
line: styleLineToFileLine(localLine, startLine),
|
|
667
|
+
message: "Manual centering with max-width + margin auto — consider using <Container> instead",
|
|
668
|
+
fix: "<Container>"
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
return issues;
|
|
672
|
+
}
|
|
673
|
+
function checkCustomThemeOverrides(styles, code) {
|
|
674
|
+
if (!styles || !/--dry-/.test(styles))
|
|
675
|
+
return [];
|
|
676
|
+
return [
|
|
677
|
+
{
|
|
678
|
+
severity: "suggestion",
|
|
679
|
+
code: "theme-in-style",
|
|
680
|
+
line: getStyleBlockStartLine(code),
|
|
681
|
+
message: "Custom --dry-* variable overrides detected in <style> — run the `diagnose` tool on your theme CSS for a full health check",
|
|
682
|
+
fix: null
|
|
683
|
+
}
|
|
684
|
+
];
|
|
685
|
+
}
|
|
686
|
+
function checkRawHr(code) {
|
|
687
|
+
const issues = [];
|
|
688
|
+
const template = code.replace(/<script[^>]*>[\s\S]*?<\/script>/g, (m) => `
|
|
689
|
+
`.repeat(countNewlines(m))).replace(/<style[^>]*>[\s\S]*?<\/style>/g, (m) => `
|
|
690
|
+
`.repeat(countNewlines(m)));
|
|
691
|
+
const hrRegex = /<hr[\s/>]/g;
|
|
692
|
+
const lineOffsets = buildLineOffsets(code);
|
|
693
|
+
let match;
|
|
694
|
+
while ((match = hrRegex.exec(template)) !== null) {
|
|
695
|
+
const line = lineAtOffset(lineOffsets, match.index);
|
|
696
|
+
issues.push({
|
|
697
|
+
severity: "suggestion",
|
|
698
|
+
code: "prefer-separator",
|
|
699
|
+
line,
|
|
700
|
+
message: "Raw <hr> element — consider using <Separator /> for consistent styling",
|
|
701
|
+
fix: "<Separator />"
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
return issues;
|
|
705
|
+
}
|
|
706
|
+
function reviewComponent(code, spec, filename) {
|
|
707
|
+
const imports = extractImports(code);
|
|
708
|
+
const tags = extractTags(code);
|
|
709
|
+
const styles = extractStyles(code);
|
|
710
|
+
const issues = [];
|
|
711
|
+
issues.push(...checkBareCompound(tags, spec));
|
|
712
|
+
issues.push(...checkUnknownComponent(tags, imports, spec));
|
|
713
|
+
issues.push(...checkInvalidPartName(tags, spec));
|
|
714
|
+
issues.push(...checkInvalidProp(tags, spec));
|
|
715
|
+
issues.push(...checkMissingRequiredProp(tags, spec));
|
|
716
|
+
issues.push(...checkOrphanedPart(tags, spec));
|
|
717
|
+
issues.push(...checkMissingLabel(tags, code));
|
|
718
|
+
issues.push(...checkImageWithoutAlt(tags));
|
|
719
|
+
issues.push(...checkMissingThumbnail(imports, spec));
|
|
720
|
+
issues.push(...checkCustomFieldMarkup(code));
|
|
721
|
+
issues.push(...checkRawStyledButton(code));
|
|
722
|
+
if (styles) {
|
|
723
|
+
issues.push(...checkCustomGridLayout(styles, code));
|
|
724
|
+
issues.push(...checkCustomFlexLayout(styles, code));
|
|
725
|
+
issues.push(...checkCustomMaxWidthCentering(styles, code));
|
|
726
|
+
}
|
|
727
|
+
if (styles) {
|
|
728
|
+
issues.push(...checkManualFlex(styles, code));
|
|
729
|
+
issues.push(...checkManualGrid(styles, code));
|
|
730
|
+
issues.push(...checkHardcodedColors(styles, code));
|
|
731
|
+
issues.push(...checkManualCentering(styles, code));
|
|
732
|
+
issues.push(...checkCustomThemeOverrides(styles, code));
|
|
733
|
+
}
|
|
734
|
+
issues.push(...checkRawHr(code));
|
|
735
|
+
issues.sort((a, b) => a.line - b.line);
|
|
736
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
737
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
738
|
+
const suggestions = issues.filter((i) => i.severity === "suggestion").length;
|
|
739
|
+
const summary = issues.length === 0 ? "No issues found" : `${errors} error${errors !== 1 ? "s" : ""}, ${warnings} warning${warnings !== 1 ? "s" : ""}, ${suggestions} suggestion${suggestions !== 1 ? "s" : ""}`;
|
|
740
|
+
return { issues, summary, ...filename ? { filename } : {} };
|
|
741
|
+
}
|
|
742
|
+
export {
|
|
743
|
+
reviewComponent
|
|
744
|
+
};
|