@md-code/react-component-uniqueness 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/README.md +125 -0
- package/bin/react-component-uniqueness.js +10 -0
- package/config.js +290 -0
- package/debt.js +105 -0
- package/dom.js +158 -0
- package/errorTypes/duplicate-component.js +105 -0
- package/errorTypes/layout-primitive.js +212 -0
- package/errorTypes/raw-html.js +97 -0
- package/errorTypes/styled-in-app.js +93 -0
- package/index.js +283 -0
- package/loaders.js +123 -0
- package/package.json +59 -0
- package/plugin.js +22 -0
- package/report.js +339 -0
- package/resolve.js +374 -0
- package/scanner/attribute.js +79 -0
- package/scanner/clusters.js +96 -0
- package/scanner/component.js +346 -0
- package/scanner/extract.js +256 -0
- package/scanner/filters/drop-child-element-matches.js +16 -0
- package/scanner/filters/drop-usages.js +14 -0
- package/scanner/filters/index.js +8 -0
- package/scanner/filters/registry.js +30 -0
- package/scanner/generate.js +58 -0
- package/scanner/load-config.js +109 -0
- package/scanner/main.js +439 -0
- package/scanner/matching.js +269 -0
- package/scanner/output.js +42 -0
- package/scanner/walk.js +37 -0
- package/signature.js +663 -0
- package/snapshots/README.md +69 -0
- package/snapshots/build-playground.mjs +132 -0
- package/snapshots/run.mjs +43 -0
- package/snapshots/snapshot.mjs +204 -0
- package/thresholds.js +35 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const ts = require("typescript");
|
|
6
|
+
const { stringLiterals, resolveLocalIdentifier } = require("./extract");
|
|
7
|
+
|
|
8
|
+
const HTML_TAGS = new Set([
|
|
9
|
+
"html",
|
|
10
|
+
"head",
|
|
11
|
+
"body",
|
|
12
|
+
"div",
|
|
13
|
+
"span",
|
|
14
|
+
"a",
|
|
15
|
+
"p",
|
|
16
|
+
"main",
|
|
17
|
+
"section",
|
|
18
|
+
"header",
|
|
19
|
+
"footer",
|
|
20
|
+
"nav",
|
|
21
|
+
"aside",
|
|
22
|
+
"article",
|
|
23
|
+
"ul",
|
|
24
|
+
"ol",
|
|
25
|
+
"li",
|
|
26
|
+
"table",
|
|
27
|
+
"thead",
|
|
28
|
+
"tbody",
|
|
29
|
+
"tr",
|
|
30
|
+
"td",
|
|
31
|
+
"th",
|
|
32
|
+
"form",
|
|
33
|
+
"label",
|
|
34
|
+
"input",
|
|
35
|
+
"button",
|
|
36
|
+
"select",
|
|
37
|
+
"textarea",
|
|
38
|
+
"img",
|
|
39
|
+
"figure",
|
|
40
|
+
"figcaption",
|
|
41
|
+
"svg",
|
|
42
|
+
"path",
|
|
43
|
+
"circle",
|
|
44
|
+
"rect",
|
|
45
|
+
"g",
|
|
46
|
+
"pre",
|
|
47
|
+
"code",
|
|
48
|
+
"strong",
|
|
49
|
+
"em",
|
|
50
|
+
"small",
|
|
51
|
+
"h1",
|
|
52
|
+
"h2",
|
|
53
|
+
"h3",
|
|
54
|
+
"h4",
|
|
55
|
+
"h5",
|
|
56
|
+
"h6",
|
|
57
|
+
"hr",
|
|
58
|
+
"br",
|
|
59
|
+
"i",
|
|
60
|
+
"b",
|
|
61
|
+
"u",
|
|
62
|
+
"s",
|
|
63
|
+
"sub",
|
|
64
|
+
"sup",
|
|
65
|
+
"blockquote",
|
|
66
|
+
"details",
|
|
67
|
+
"summary",
|
|
68
|
+
"dialog",
|
|
69
|
+
"canvas",
|
|
70
|
+
"video",
|
|
71
|
+
"audio",
|
|
72
|
+
"source",
|
|
73
|
+
"track",
|
|
74
|
+
"map",
|
|
75
|
+
"area",
|
|
76
|
+
"dl",
|
|
77
|
+
"dt",
|
|
78
|
+
"dd",
|
|
79
|
+
"fieldset",
|
|
80
|
+
"legend",
|
|
81
|
+
"data",
|
|
82
|
+
"output",
|
|
83
|
+
"progress",
|
|
84
|
+
"meter",
|
|
85
|
+
"time",
|
|
86
|
+
"mark",
|
|
87
|
+
"abbr",
|
|
88
|
+
"ruby",
|
|
89
|
+
"rt",
|
|
90
|
+
"rp",
|
|
91
|
+
"wbr",
|
|
92
|
+
"bdi",
|
|
93
|
+
"bdo",
|
|
94
|
+
"template",
|
|
95
|
+
"slot",
|
|
96
|
+
"picture",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const isPascalCase = (name) => /^[A-Z][A-Za-z0-9]*$/.test(name);
|
|
100
|
+
|
|
101
|
+
const dottedName = (name) => {
|
|
102
|
+
if (ts.isIdentifier(name)) return name.text;
|
|
103
|
+
if (name && name.kind === ts.SyntaxKind.QualifiedName) return `${dottedName(name.left)}.${name.right.text}`;
|
|
104
|
+
if (name && name.kind === ts.SyntaxKind.PropertyAccessExpression) return `${dottedName(name.expression)}.${name.name.text}`;
|
|
105
|
+
return null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const tagOf = (name) => dottedName(name);
|
|
109
|
+
|
|
110
|
+
function hasJsx(node) {
|
|
111
|
+
let found = false;
|
|
112
|
+
const visit = (n) => {
|
|
113
|
+
if (found) return;
|
|
114
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
115
|
+
found = true;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
ts.forEachChild(n, visit);
|
|
119
|
+
};
|
|
120
|
+
visit(node);
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isExported(node, sf) {
|
|
125
|
+
const mods = ts.getModifiers(node) || [];
|
|
126
|
+
if (mods.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return true;
|
|
127
|
+
const parent = node.parent;
|
|
128
|
+
if (parent && (ts.isExportDeclaration(parent) || ts.isExportAssignment(parent))) return true;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function collectTokens(expr, sf, out) {
|
|
133
|
+
if (!expr) return;
|
|
134
|
+
if (ts.isJsxExpression(expr)) expr = expr.expression;
|
|
135
|
+
const strs = [];
|
|
136
|
+
stringLiterals(expr, strs);
|
|
137
|
+
const idents = new Set();
|
|
138
|
+
const visitIds = (n) => {
|
|
139
|
+
if (ts.isIdentifier(n) && n.text.length > 0) idents.add(n.text);
|
|
140
|
+
ts.forEachChild(n, visitIds);
|
|
141
|
+
};
|
|
142
|
+
visitIds(expr);
|
|
143
|
+
for (const id of idents) {
|
|
144
|
+
const r = resolveLocalIdentifier(id, sf, "str");
|
|
145
|
+
for (const s of r.str) strs.push(s);
|
|
146
|
+
}
|
|
147
|
+
for (const s of strs) {
|
|
148
|
+
for (const tok of s.split(/\s+/)) {
|
|
149
|
+
if (tok && !out.has(tok)) out.add(tok);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function openingOf(node) {
|
|
155
|
+
return ts.isJsxElement(node) ? node.openingElement : node;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function classNameTokens(opening, sf, out) {
|
|
159
|
+
for (const attr of opening.attributes.properties) {
|
|
160
|
+
if (ts.isJsxAttribute(attr) && attr.name) {
|
|
161
|
+
const aname = attr.name.getText(sf);
|
|
162
|
+
if (aname === "className" || aname === "class") collectTokens(attr.initializer, sf, out);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function gatherElements(node, sf, out) {
|
|
168
|
+
const visit = (n) => {
|
|
169
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
170
|
+
const opening = openingOf(n);
|
|
171
|
+
const tag = tagOf(opening.tagName);
|
|
172
|
+
const tokens = new Set();
|
|
173
|
+
classNameTokens(opening, sf, tokens);
|
|
174
|
+
out.push({ tag, tokens });
|
|
175
|
+
}
|
|
176
|
+
ts.forEachChild(n, visit);
|
|
177
|
+
};
|
|
178
|
+
visit(node);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function gatherSubtrees(node, sf) {
|
|
182
|
+
const out = [];
|
|
183
|
+
const visit = (n) => {
|
|
184
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
185
|
+
out.push(n);
|
|
186
|
+
}
|
|
187
|
+
ts.forEachChild(n, visit);
|
|
188
|
+
};
|
|
189
|
+
visit(node);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const MAX_SUBTREE_TAGS = 80;
|
|
194
|
+
|
|
195
|
+
function subtreeInfo(node, sf) {
|
|
196
|
+
const seq = [];
|
|
197
|
+
const tokens = new Set();
|
|
198
|
+
const visit = (n) => {
|
|
199
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
200
|
+
const opening = openingOf(n);
|
|
201
|
+
const tag = tagOf(opening.tagName);
|
|
202
|
+
if (tag) seq.push(tag);
|
|
203
|
+
classNameTokens(opening, sf, tokens);
|
|
204
|
+
}
|
|
205
|
+
ts.forEachChild(n, visit);
|
|
206
|
+
};
|
|
207
|
+
visit(node);
|
|
208
|
+
return { seq, tokens };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function subtreesOf(node, sf) {
|
|
212
|
+
const out = [];
|
|
213
|
+
for (const el of gatherSubtrees(node, sf, [])) {
|
|
214
|
+
const info = subtreeInfo(el, sf);
|
|
215
|
+
if (info.seq.length > 0 && info.seq.length <= MAX_SUBTREE_TAGS) {
|
|
216
|
+
out.push({ tag: info.seq[0], seq: info.seq, tokens: [...info.tokens].sort() });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function rootTagsOf(node, sf) {
|
|
223
|
+
const out = new Set();
|
|
224
|
+
const visit = (n, parent) => {
|
|
225
|
+
if (n !== node && (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n))) {
|
|
226
|
+
const isRoot = !parent || !(ts.isJsxElement(parent) || ts.isJsxSelfClosingElement(parent) || ts.isJsxFragment(parent));
|
|
227
|
+
if (isRoot) {
|
|
228
|
+
const tag = tagOf(openingOf(n).tagName);
|
|
229
|
+
if (tag) out.add(tag);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
ts.forEachChild(n, (c) => visit(c, n));
|
|
233
|
+
};
|
|
234
|
+
visit(node, null);
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function gatherA11y(node, sf, out) {
|
|
239
|
+
const visit = (n) => {
|
|
240
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
241
|
+
const opening = openingOf(n);
|
|
242
|
+
for (const attr of opening.attributes.properties) {
|
|
243
|
+
if (ts.isJsxAttribute(attr) && attr.name) {
|
|
244
|
+
const aname = attr.name.getText(sf);
|
|
245
|
+
if (aname === "role" || aname.startsWith("aria-")) {
|
|
246
|
+
const strs = [];
|
|
247
|
+
if (attr.initializer) stringLiterals(attr.initializer, strs);
|
|
248
|
+
const v = strs[0] ?? "true";
|
|
249
|
+
const key = `${aname}:${v}`;
|
|
250
|
+
if (!out.has(key)) out.add(key);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
ts.forEachChild(n, visit);
|
|
256
|
+
};
|
|
257
|
+
visit(node);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function canonicalImportNames(sf) {
|
|
261
|
+
const names = new Set();
|
|
262
|
+
const visit = (n) => {
|
|
263
|
+
if (ts.isImportDeclaration(n) && n.moduleSpecifier && ts.isStringLiteral(n.moduleSpecifier)) {
|
|
264
|
+
if (n.importClause) {
|
|
265
|
+
if (n.importClause.name) names.add(n.importClause.name.text);
|
|
266
|
+
const clause = n.importClause.namedBindings;
|
|
267
|
+
if (clause && ts.isNamedImports(clause)) {
|
|
268
|
+
for (const el of clause.elements) {
|
|
269
|
+
names.add(el.name.text);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
ts.forEachChild(n, visit);
|
|
275
|
+
};
|
|
276
|
+
visit(sf);
|
|
277
|
+
return names;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function canonicalTagsUsed(node, names) {
|
|
281
|
+
const found = new Set();
|
|
282
|
+
const visit = (n) => {
|
|
283
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n)) {
|
|
284
|
+
const opening = openingOf(n);
|
|
285
|
+
if (ts.isIdentifier(opening.tagName) && names.has(opening.tagName.text)) found.add(opening.tagName.text);
|
|
286
|
+
}
|
|
287
|
+
ts.forEachChild(n, visit);
|
|
288
|
+
};
|
|
289
|
+
visit(node);
|
|
290
|
+
return found;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function* iterateDeclarations(sf) {
|
|
294
|
+
const found = [];
|
|
295
|
+
const visit = (n) => {
|
|
296
|
+
if (ts.isFunctionDeclaration(n) && n.name && isPascalCase(n.name.text) && n.body) {
|
|
297
|
+
found.push({ name: n.name.text, body: n.body, node: n });
|
|
298
|
+
}
|
|
299
|
+
if (ts.isVariableStatement(n)) {
|
|
300
|
+
for (const d of n.declarationList.declarations) {
|
|
301
|
+
if (ts.isIdentifier(d.name) && isPascalCase(d.name.text) && d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))) {
|
|
302
|
+
found.push({ name: d.name.text, body: d.initializer.body, node: n });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
ts.forEachChild(n, visit);
|
|
307
|
+
};
|
|
308
|
+
visit(sf);
|
|
309
|
+
for (const d of found) yield d;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function componentsFromFile(fileAbs, canonicalNames) {
|
|
313
|
+
const source = fs.readFileSync(fileAbs, "utf8");
|
|
314
|
+
const kind = fileAbs.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
315
|
+
const sf = ts.createSourceFile(fileAbs, source, ts.ScriptTarget.ES2020, true, kind);
|
|
316
|
+
const canonNameSet = canonicalNames || new Set();
|
|
317
|
+
const out = [];
|
|
318
|
+
const decls = [...iterateDeclarations(sf)];
|
|
319
|
+
if (decls.length === 0 && hasJsx(sf)) {
|
|
320
|
+
const base = path.basename(fileAbs, path.extname(fileAbs));
|
|
321
|
+
const name = base.split(/[-_ ]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
322
|
+
decls.push({ name, body: sf, node: sf });
|
|
323
|
+
}
|
|
324
|
+
for (const d of decls) {
|
|
325
|
+
if (!hasJsx(d.body)) continue;
|
|
326
|
+
const elements = [];
|
|
327
|
+
gatherElements(d.body, sf, elements);
|
|
328
|
+
const elementProps = elements.map((e) => ({ tag: e.tag, tokens: [...e.tokens].sort() }));
|
|
329
|
+
const rootTags = [...rootTagsOf(d.body, sf)].sort();
|
|
330
|
+
const a11y = new Set();
|
|
331
|
+
gatherA11y(d.body, sf, a11y);
|
|
332
|
+
out.push({
|
|
333
|
+
name: d.name,
|
|
334
|
+
line: sf.getLineAndCharacterOfPosition(d.node.getStart(sf)).line + 1,
|
|
335
|
+
isExported: isExported(d.node, sf),
|
|
336
|
+
rootTags,
|
|
337
|
+
elementProps,
|
|
338
|
+
subtrees: subtreesOf(d.body, sf),
|
|
339
|
+
a11y: [...a11y].sort(),
|
|
340
|
+
usesCanonical: canonNameSet.size > 0 ? [...canonicalTagsUsed(d.body, canonNameSet)].sort() : [],
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = { componentsFromFile, gatherSubtrees, openingOf, tagOf, classNameTokens, HTML_TAGS, isPascalCase, isHtmlTag: (t) => HTML_TAGS.has(t) };
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scanner/extract.js
|
|
5
|
+
*
|
|
6
|
+
* Signature extraction from one source file (TypeScript AST). For every
|
|
7
|
+
* JSX element and every styled.* template, a normalized Signature is
|
|
8
|
+
* recorded:
|
|
9
|
+
*
|
|
10
|
+
* { tag, styles, actions, a11y, data }
|
|
11
|
+
*
|
|
12
|
+
* Only elements with at least one style / action / a11y marker are kept —
|
|
13
|
+
* a bare <div> is building material, not a component signature.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const ts = require("typescript");
|
|
18
|
+
const sig = require("../signature");
|
|
19
|
+
const { ACTION_ATTR_NAMES } = require("../config");
|
|
20
|
+
|
|
21
|
+
/** Collect string literals from a TS expression (shallow, capped). */
|
|
22
|
+
function stringLiterals(node, out, depth = 0) {
|
|
23
|
+
if (!node || depth > 4 || out.length >= 24) return;
|
|
24
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
25
|
+
out.push(node.text);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (ts.isTemplateExpression(node)) {
|
|
29
|
+
if (node.head.text) out.push(node.head.text);
|
|
30
|
+
for (const span of node.templateSpans) {
|
|
31
|
+
if (span.literal.text) out.push(span.literal.text);
|
|
32
|
+
stringLiterals(span.expression, out, depth + 1);
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
37
|
+
for (const p of node.properties) stringLiterals(p.initializer, out, depth + 1);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
41
|
+
for (const el of node.elements) stringLiterals(el, out, depth + 1);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (ts.isCallExpression(node)) {
|
|
45
|
+
for (const arg of node.arguments) stringLiterals(arg, out, depth + 1);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (ts.isConditionalExpression(node)) {
|
|
49
|
+
stringLiterals(node.whenTrue, out, depth + 1);
|
|
50
|
+
stringLiterals(node.whenFalse, out, depth + 1);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.Plus) {
|
|
54
|
+
stringLiterals(node.left, out, depth + 1);
|
|
55
|
+
stringLiterals(node.right, out, depth + 1);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve an identifier to a string-literal initializer or object literal
|
|
62
|
+
* declared somewhere in the same file (const cls = "..."; const st = {...}).
|
|
63
|
+
*/
|
|
64
|
+
function resolveLocalIdentifier(name, fileSf, kind) {
|
|
65
|
+
const found = { str: [], obj: null };
|
|
66
|
+
const walk = (n) => {
|
|
67
|
+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === name && n.initializer) {
|
|
68
|
+
if (kind === "str") {
|
|
69
|
+
const strs = [];
|
|
70
|
+
stringLiterals(n.initializer, strs);
|
|
71
|
+
found.str.push(...strs);
|
|
72
|
+
} else if (kind === "obj" && ts.isObjectLiteralExpression(n.initializer)) {
|
|
73
|
+
found.obj = n.initializer;
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
ts.forEachChild(n, walk);
|
|
78
|
+
};
|
|
79
|
+
walk(fileSf);
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract component signatures from one source file.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} fileAbs absolute path to a .ts/.tsx/.jsx file
|
|
87
|
+
* @returns {Array<{tag: string, styles: object, actions: string[], a11y: string[], data: string[]}>}
|
|
88
|
+
*/
|
|
89
|
+
function signaturesFromFile(fileAbs) {
|
|
90
|
+
const source = fs.readFileSync(fileAbs, "utf8");
|
|
91
|
+
const sf = ts.createSourceFile(fileAbs, source, ts.ScriptTarget.ES2020, true, fileAbs.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
92
|
+
const out = [];
|
|
93
|
+
|
|
94
|
+
/** Resolve a className-ish expression to a styles map. */
|
|
95
|
+
const classNameStyles = (expr) => {
|
|
96
|
+
const merged = {};
|
|
97
|
+
const addStyles = (s) => {
|
|
98
|
+
const tw = sig.twToStyles(s);
|
|
99
|
+
for (const [p, v] of Object.entries(tw)) {
|
|
100
|
+
if (!merged[p]) merged[p] = [];
|
|
101
|
+
for (const x of v) if (!merged[p].includes(x)) merged[p].push(x);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
if (!expr) return merged;
|
|
105
|
+
if (ts.isIdentifier(expr)) {
|
|
106
|
+
const r = resolveLocalIdentifier(expr.text, sf, "str");
|
|
107
|
+
for (const s of r.str) addStyles(s);
|
|
108
|
+
return merged;
|
|
109
|
+
}
|
|
110
|
+
const strs = [];
|
|
111
|
+
stringLiterals(expr, strs);
|
|
112
|
+
for (const s of strs) addStyles(s);
|
|
113
|
+
return merged;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Resolve a style={{...}} expression to { prop: [values] }. */
|
|
117
|
+
const styleObjectStyles = (expr) => {
|
|
118
|
+
// The attr initializer is a JSXExpressionContainer wrapping the real expression.
|
|
119
|
+
if (expr && expr.kind === ts.SyntaxKind.JsxExpression) expr = expr.expression;
|
|
120
|
+
const styles = {};
|
|
121
|
+
const add = (prop, value) => {
|
|
122
|
+
if (!Object.prototype.hasOwnProperty.call(styles, prop)) styles[prop] = [];
|
|
123
|
+
if (!styles[prop].includes(value)) styles[prop].push(value);
|
|
124
|
+
};
|
|
125
|
+
const visitObj = (n) => {
|
|
126
|
+
if (!n) return;
|
|
127
|
+
if (ts.isObjectLiteralExpression(n)) {
|
|
128
|
+
for (const p of n.properties) {
|
|
129
|
+
if (!ts.isPropertyAssignment(p) || !p.name) continue;
|
|
130
|
+
const key = ts.isIdentifier(p.name) ? p.name.text : ts.isStringLiteral(p.name) ? p.name.text : null;
|
|
131
|
+
if (!key) continue;
|
|
132
|
+
const prop = sig.camelToKebab(key);
|
|
133
|
+
if (ts.isStringLiteral(p.initializer)) {
|
|
134
|
+
add(prop, p.initializer.text);
|
|
135
|
+
} else if (ts.isNumericLiteral(p.initializer)) {
|
|
136
|
+
add(prop, p.initializer.text);
|
|
137
|
+
} else if (ts.isPrefixUnaryExpression(p.initializer) && ts.isNumericLiteral(p.initializer.operand)) {
|
|
138
|
+
add(prop, `${p.operator === ts.SyntaxKind.MinusToken ? "-" : ""}${p.initializer.operand.text}`);
|
|
139
|
+
} else {
|
|
140
|
+
const strs = [];
|
|
141
|
+
stringLiterals(p.initializer, strs);
|
|
142
|
+
for (const s of strs) add(prop, s);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (ts.isConditionalExpression(n)) {
|
|
148
|
+
visitObj(n.whenTrue);
|
|
149
|
+
visitObj(n.whenFalse);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (ts.isIdentifier(n)) {
|
|
153
|
+
const r = resolveLocalIdentifier(n.text, sf, "obj");
|
|
154
|
+
if (r.obj) visitObj(r.obj);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
visitObj(expr);
|
|
158
|
+
return styles;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const tagOf = (name) => (ts.isIdentifier(name) ? name.text : ts.isNamespacedName(name) ? `${name.name.text}:${name.namespace.text}` : null);
|
|
162
|
+
|
|
163
|
+
const visit = (node) => {
|
|
164
|
+
// JSX elements: <div className="..." role="dialog" onClick={...} data-testid="x" />
|
|
165
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
166
|
+
// JsxElement carries the tag in node.openingElement; a JsxSelfClosingElement
|
|
167
|
+
// IS the opening tag (it has tagName + attributes directly, no openingElement).
|
|
168
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
169
|
+
const tag = tagOf(opening.tagName);
|
|
170
|
+
if (!tag) return;
|
|
171
|
+
const sigObj = { tag, styles: {}, actions: [], a11y: [], data: [] };
|
|
172
|
+
for (const attr of opening.attributes.properties) {
|
|
173
|
+
if (!ts.isJsxAttribute(attr) || !attr.name) continue;
|
|
174
|
+
const aname = attr.name.getText(sf);
|
|
175
|
+
const value = attr.initializer;
|
|
176
|
+
if (aname === "className" || aname === "class") {
|
|
177
|
+
sigObj.styles = sig.mergeStyles(sigObj.styles, classNameStyles(value));
|
|
178
|
+
} else if (aname === "style") {
|
|
179
|
+
sigObj.styles = sig.mergeStyles(sigObj.styles, styleObjectStyles(value));
|
|
180
|
+
} else if (ACTION_ATTR_NAMES.has(aname)) {
|
|
181
|
+
const action = aname.slice(2).toLowerCase();
|
|
182
|
+
if (!sigObj.actions.includes(action)) sigObj.actions.push(action);
|
|
183
|
+
} else if (aname === "role" || aname.startsWith("aria-")) {
|
|
184
|
+
const strs = [];
|
|
185
|
+
if (value) stringLiterals(value, strs);
|
|
186
|
+
const v = strs[0] ?? "true";
|
|
187
|
+
const key = `${aname}:${v}`;
|
|
188
|
+
if (!sigObj.a11y.includes(key)) sigObj.a11y.push(key);
|
|
189
|
+
} else if (aname.startsWith("data-")) {
|
|
190
|
+
const strs = [];
|
|
191
|
+
if (value) stringLiterals(value, strs);
|
|
192
|
+
const v = strs[0] ?? "";
|
|
193
|
+
const key = `${aname}:${v}`;
|
|
194
|
+
if (!sigObj.data.includes(key)) sigObj.data.push(key);
|
|
195
|
+
} else if (aname === "type" && (tag === "input" || tag === "button")) {
|
|
196
|
+
const strs = [];
|
|
197
|
+
if (value) stringLiterals(value, strs);
|
|
198
|
+
const v = strs[0];
|
|
199
|
+
if (v) {
|
|
200
|
+
const key = `type:${v}`;
|
|
201
|
+
if (!sigObj.a11y.includes(key)) sigObj.a11y.push(key);
|
|
202
|
+
}
|
|
203
|
+
} else if (aname === "contentEditable") {
|
|
204
|
+
if (!sigObj.a11y.includes("contenteditable")) sigObj.a11y.push("contenteditable");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (Object.keys(sigObj.styles).length > 0 || sigObj.actions.length > 0 || sigObj.a11y.length > 0) {
|
|
208
|
+
out.push(sigObj);
|
|
209
|
+
}
|
|
210
|
+
if (ts.isJsxElement(node)) ts.forEachChild(node, visit);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// styled.div`css` / styled("div")`css`
|
|
215
|
+
if (ts.isVariableDeclaration(node) && node.initializer && ts.isTaggedTemplateExpression(node.initializer)) {
|
|
216
|
+
// styled.div`...` → tag is the PropertyAccessExpression itself (styled.div).
|
|
217
|
+
// styled("div")`...` → tag is a CallExpression. styled.div<Props>`...` → wrapped in a TypeAssertion.
|
|
218
|
+
const tagExpr = node.initializer.tag;
|
|
219
|
+
const tpl = node.initializer.template;
|
|
220
|
+
// No interpolation → NoSubstitutionTemplateLiteral (.text). With ${} → TemplateExpression (.head + .templateSpans).
|
|
221
|
+
const cssText = ts.isTemplateExpression(tpl)
|
|
222
|
+
? tpl.head.text + tpl.templateSpans.map((s) => s.literal.text).join("")
|
|
223
|
+
: ts.isNoSubstitutionTemplateLiteral(tpl)
|
|
224
|
+
? tpl.text
|
|
225
|
+
: "";
|
|
226
|
+
let tag = null;
|
|
227
|
+
// styled.div<Props>`...` wraps the member access in a TypeAssertion.
|
|
228
|
+
let real = tagExpr;
|
|
229
|
+
while (real && (ts.isTypeAssertionExpression(real) || ts.isAsExpression(real))) real = real.expression;
|
|
230
|
+
if (real && ts.isPropertyAccessExpression(real)) {
|
|
231
|
+
const base = real.expression;
|
|
232
|
+
if (ts.isIdentifier(base) && base.text === "styled") tag = real.name.text;
|
|
233
|
+
} else if (tagExpr && ts.isCallExpression(tagExpr)) {
|
|
234
|
+
const arg0 = tagExpr.arguments[0];
|
|
235
|
+
// styled("div")`...` — string literal.
|
|
236
|
+
if (arg0 && ts.isStringLiteral(arg0)) tag = arg0.text;
|
|
237
|
+
// styled(MuiButton)`...` — identifier: a wrapper built on a
|
|
238
|
+
// LIBRARY component. The tag is the component name (the rendered
|
|
239
|
+
// DOM tag is unknown statically — that is what the dynamic
|
|
240
|
+
// snapshot catalog is for), so the signature is matched by the
|
|
241
|
+
// CSS the wrapper adds on top of the library styles.
|
|
242
|
+
else if (arg0 && ts.isIdentifier(arg0)) tag = arg0.text;
|
|
243
|
+
}
|
|
244
|
+
if (tag) {
|
|
245
|
+
const sigObj = { tag, styles: sig.cssTextToStyles(cssText), actions: [], a11y: [], data: [] };
|
|
246
|
+
if (Object.keys(sigObj.styles).length > 0) out.push(sigObj);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
ts.forEachChild(node, visit);
|
|
251
|
+
};
|
|
252
|
+
visit(sf);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { signaturesFromFile, stringLiterals, resolveLocalIdentifier };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { registerFilter } = require("./registry");
|
|
4
|
+
|
|
5
|
+
registerFilter({
|
|
6
|
+
name: "drop-child-element-matches",
|
|
7
|
+
test(match) {
|
|
8
|
+
if (match.tier === "name" || match.tier === "name-fuzzy" || match.tier === "jsx-block") return null;
|
|
9
|
+
const appEls = (match.app.elementProps || []).length;
|
|
10
|
+
const canonEls = (match.canon.elementProps || []).length;
|
|
11
|
+
if (canonEls > 0 && appEls > 3 * canonEls) {
|
|
12
|
+
return `app has ${appEls} elements vs canonical's ${canonEls} — match is on a child element, not the component itself`;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { registerFilter } = require("./registry");
|
|
4
|
+
|
|
5
|
+
registerFilter({
|
|
6
|
+
name: "drop-usages",
|
|
7
|
+
test(match) {
|
|
8
|
+
const used = match.app.usesCanonical || [];
|
|
9
|
+
if (used.includes(match.canon.name)) {
|
|
10
|
+
return `renders canonical ${match.canon.name} (usage, not a duplicate)`;
|
|
11
|
+
}
|
|
12
|
+
return null;
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const FILTERS = [];
|
|
4
|
+
|
|
5
|
+
function registerFilter(filter) {
|
|
6
|
+
if (!filter || typeof filter.name !== "string") throw new Error("filter must have a string `name`");
|
|
7
|
+
if (typeof filter.test !== "function") throw new Error(`filter "${filter && filter.name}" must have a test(match) function`);
|
|
8
|
+
FILTERS.push(filter);
|
|
9
|
+
return filter;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function applyFilters(matches) {
|
|
13
|
+
const kept = [];
|
|
14
|
+
const dropped = [];
|
|
15
|
+
for (const m of matches) {
|
|
16
|
+
let drop = null;
|
|
17
|
+
for (const f of FILTERS) {
|
|
18
|
+
const reason = f.test(m);
|
|
19
|
+
if (reason) {
|
|
20
|
+
drop = { filter: f.name, reason };
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (drop) dropped.push({ match: m, ...drop });
|
|
25
|
+
else kept.push(m);
|
|
26
|
+
}
|
|
27
|
+
return { kept, dropped };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { FILTERS, registerFilter, applyFilters };
|