@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
package/signature.js
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* signature.js
|
|
5
|
+
*
|
|
6
|
+
* Component-signature machinery shared by the rule and the catalog scanner.
|
|
7
|
+
*
|
|
8
|
+
* A Signature is the normalized, order-insensitive description of one JSX
|
|
9
|
+
* element:
|
|
10
|
+
*
|
|
11
|
+
* {
|
|
12
|
+
* tag: "div",
|
|
13
|
+
* styles: { "display": ["flex"], "gap": ["1rem"], ... }, // CSS prop -> candidate values
|
|
14
|
+
* actions: ["click", ...], // event handlers (onClick -> click)
|
|
15
|
+
* a11y: ["role:dialog", "aria-modal:true", ...],
|
|
16
|
+
* data: ["data-testid:...", ...],
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* All style sources — tailwind className, inline style={...}, and
|
|
20
|
+
* styled-components CSS — are converted into the same CSS-property space,
|
|
21
|
+
* so "flex gap-4" (tailwind), { display: "flex", gap: "1rem" } (inline), and
|
|
22
|
+
* "display: flex; gap: 1rem;" (styled) all describe the same element.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Tailwind utility class -> [cssProperty, cssValue] (value null = property presence). */
|
|
26
|
+
const TW = {
|
|
27
|
+
flex: ["display", "flex"],
|
|
28
|
+
"flex-1": ["flex", "1"],
|
|
29
|
+
"flex-auto": ["flex", "auto"],
|
|
30
|
+
"flex-none": ["flex", "none"],
|
|
31
|
+
block: ["display", "block"],
|
|
32
|
+
"inline-block": ["display", "inline-block"],
|
|
33
|
+
hidden: ["display", "none"],
|
|
34
|
+
grid: ["display", "grid"],
|
|
35
|
+
"inline-grid": ["display", "inline-grid"],
|
|
36
|
+
"flex-col": ["flex-direction", "column"],
|
|
37
|
+
"flex-row": ["flex-direction", "row"],
|
|
38
|
+
"flex-wrap": ["flex-wrap", "wrap"],
|
|
39
|
+
"flex-nowrap": ["flex-wrap", "nowrap"],
|
|
40
|
+
"flex-col-reverse": ["flex-direction", "column-reverse"],
|
|
41
|
+
"flex-row-reverse": ["flex-direction", "row-reverse"],
|
|
42
|
+
"justify-center": ["justify-content", "center"],
|
|
43
|
+
"justify-between": ["justify-content", "space-between"],
|
|
44
|
+
"justify-around": ["justify-content", "space-around"],
|
|
45
|
+
"justify-start": ["justify-content", "flex-start"],
|
|
46
|
+
"justify-end": ["justify-content", "flex-end"],
|
|
47
|
+
"items-center": ["align-items", "center"],
|
|
48
|
+
"items-start": ["align-items", "flex-start"],
|
|
49
|
+
"items-end": ["align-items", "flex-end"],
|
|
50
|
+
"items-baseline": ["align-items", "baseline"],
|
|
51
|
+
"items-stretch": ["align-items", "stretch"],
|
|
52
|
+
"self-center": ["align-self", "center"],
|
|
53
|
+
"self-start": ["align-self", "flex-start"],
|
|
54
|
+
"self-end": ["align-self", "flex-end"],
|
|
55
|
+
"self-stretch": ["align-self", "stretch"],
|
|
56
|
+
"content-center": ["align-content", "center"],
|
|
57
|
+
"content-between": ["align-content", "space-between"],
|
|
58
|
+
"content-start": ["align-content", "flex-start"],
|
|
59
|
+
"content-end": ["align-content", "flex-end"],
|
|
60
|
+
gap: ["gap", null],
|
|
61
|
+
"gap-0": ["gap", "0"],
|
|
62
|
+
"gap-1": ["gap", "0.25rem"],
|
|
63
|
+
"gap-2": ["gap", "0.5rem"],
|
|
64
|
+
"gap-3": ["gap", "0.75rem"],
|
|
65
|
+
"gap-4": ["gap", "1rem"],
|
|
66
|
+
"gap-5": ["gap", "1.25rem"],
|
|
67
|
+
"gap-6": ["gap", "1.5rem"],
|
|
68
|
+
"gap-8": ["gap", "2rem"],
|
|
69
|
+
"gap-10": ["gap", "2.5rem"],
|
|
70
|
+
"gap-12": ["gap", "3rem"],
|
|
71
|
+
"gap-16": ["gap", "4rem"],
|
|
72
|
+
"gap-20": ["gap", "5rem"],
|
|
73
|
+
"gap-24": ["gap", "6rem"],
|
|
74
|
+
"gap-x-4": ["column-gap", "1rem"],
|
|
75
|
+
"gap-y-4": ["row-gap", "1rem"],
|
|
76
|
+
"gap-x-8": ["column-gap", "2rem"],
|
|
77
|
+
"gap-y-8": ["row-gap", "2rem"],
|
|
78
|
+
rounded: ["border-radius", "0.25rem"],
|
|
79
|
+
"rounded-sm": ["border-radius", "0.125rem"],
|
|
80
|
+
"rounded-md": ["border-radius", "0.375rem"],
|
|
81
|
+
"rounded-lg": ["border-radius", "0.5rem"],
|
|
82
|
+
"rounded-xl": ["border-radius", "0.75rem"],
|
|
83
|
+
"rounded-2xl": ["border-radius", "1rem"],
|
|
84
|
+
"rounded-3xl": ["border-radius", "1.5rem"],
|
|
85
|
+
"rounded-full": ["border-radius", "9999px"],
|
|
86
|
+
"rounded-none": ["border-radius", "0"],
|
|
87
|
+
"rounded-t-full": ["border-radius", "9999px"],
|
|
88
|
+
"rounded-l-full": ["border-radius", "9999px"],
|
|
89
|
+
"rounded-r-full": ["border-radius", "9999px"],
|
|
90
|
+
"rounded-tl": ["border-top-left-radius", "0.25rem"],
|
|
91
|
+
"rounded-tr": ["border-top-right-radius", "0.25rem"],
|
|
92
|
+
"rounded-bl": ["border-bottom-left-radius", "0.25rem"],
|
|
93
|
+
"rounded-br": ["border-bottom-right-radius", "0.25rem"],
|
|
94
|
+
shadow: ["box-shadow", "1"],
|
|
95
|
+
"shadow-sm": ["box-shadow", "1"],
|
|
96
|
+
"shadow-md": ["box-shadow", "1"],
|
|
97
|
+
"shadow-lg": ["box-shadow", "1"],
|
|
98
|
+
"shadow-xl": ["box-shadow", "1"],
|
|
99
|
+
"shadow-2xl": ["box-shadow", "1"],
|
|
100
|
+
"shadow-inner": ["box-shadow", "1"],
|
|
101
|
+
"shadow-none": ["box-shadow", "none"],
|
|
102
|
+
border: ["border", "1"],
|
|
103
|
+
"border-0": ["border-width", "0"],
|
|
104
|
+
"border-2": ["border-width", "2px"],
|
|
105
|
+
"border-4": ["border-width", "4px"],
|
|
106
|
+
"border-t": ["border-top", "1"],
|
|
107
|
+
"border-b": ["border-bottom", "1"],
|
|
108
|
+
"border-l": ["border-left", "1"],
|
|
109
|
+
"border-r": ["border-right", "1"],
|
|
110
|
+
"border-dashed": ["border-style", "dashed"],
|
|
111
|
+
"border-dotted": ["border-style", "dotted"],
|
|
112
|
+
"border-solid": ["border-style", "solid"],
|
|
113
|
+
"border-none": ["border", "none"],
|
|
114
|
+
"border-collapse": ["border-collapse", "collapse"],
|
|
115
|
+
"border-separate": ["border-separate", "separate"],
|
|
116
|
+
"overflow-hidden": ["overflow", "hidden"],
|
|
117
|
+
"overflow-auto": ["overflow", "auto"],
|
|
118
|
+
"overflow-scroll": ["overflow", "scroll"],
|
|
119
|
+
"overflow-visible": ["overflow", "visible"],
|
|
120
|
+
"overflow-x-auto": ["overflow-x", "auto"],
|
|
121
|
+
"overflow-y-auto": ["overflow-y", "auto"],
|
|
122
|
+
truncate: ["text-overflow", "ellipsis"],
|
|
123
|
+
"line-clamp-2": ["display", "-webkit-box"],
|
|
124
|
+
"w-full": ["width", "100%"],
|
|
125
|
+
"w-auto": ["width", "auto"],
|
|
126
|
+
"w-screen": ["width", "100vw"],
|
|
127
|
+
"min-w-0": ["min-width", "0"],
|
|
128
|
+
"min-w-full": ["min-width", "100%"],
|
|
129
|
+
"max-w-full": ["max-width", "100%"],
|
|
130
|
+
"max-w-screen": ["max-width", "100vw"],
|
|
131
|
+
"max-w-prose": ["max-width", "65ch"],
|
|
132
|
+
"max-w-xs": ["max-width", "20rem"],
|
|
133
|
+
"max-w-sm": ["max-width", "24rem"],
|
|
134
|
+
"max-w-md": ["max-width", "28rem"],
|
|
135
|
+
"max-w-lg": ["max-width", "32rem"],
|
|
136
|
+
"max-w-xl": ["max-width", "36rem"],
|
|
137
|
+
"max-w-2xl": ["max-width", "42rem"],
|
|
138
|
+
"max-w-3xl": ["max-width", "48rem"],
|
|
139
|
+
"max-w-4xl": ["max-width", "56rem"],
|
|
140
|
+
"max-w-5xl": ["max-width", "64rem"],
|
|
141
|
+
"max-w-6xl": ["max-width", "72rem"],
|
|
142
|
+
"max-w-7xl": ["max-width", "80rem"],
|
|
143
|
+
"h-full": ["height", "100%"],
|
|
144
|
+
"h-auto": ["height", "auto"],
|
|
145
|
+
"h-screen": ["height", "100vh"],
|
|
146
|
+
"min-h-0": ["min-height", "0"],
|
|
147
|
+
"min-h-full": ["min-height", "100%"],
|
|
148
|
+
"min-h-screen": ["min-height", "100vh"],
|
|
149
|
+
"p-0": ["padding", "0"],
|
|
150
|
+
"p-1": ["padding", "0.25rem"],
|
|
151
|
+
"p-2": ["padding", "0.5rem"],
|
|
152
|
+
"p-3": ["padding", "0.75rem"],
|
|
153
|
+
"p-4": ["padding", "1rem"],
|
|
154
|
+
"p-5": ["padding", "1.25rem"],
|
|
155
|
+
"p-6": ["padding", "1.5rem"],
|
|
156
|
+
"p-8": ["padding", "2rem"],
|
|
157
|
+
"p-10": ["padding", "2.5rem"],
|
|
158
|
+
"p-12": ["padding", "3rem"],
|
|
159
|
+
"px-0": ["padding-left", "0"],
|
|
160
|
+
"px-1": ["padding-left", "0.25rem"],
|
|
161
|
+
"px-2": ["padding-left", "0.5rem"],
|
|
162
|
+
"px-3": ["padding-left", "0.75rem"],
|
|
163
|
+
"px-4": ["padding-left", "1rem"],
|
|
164
|
+
"px-5": ["padding-left", "1.25rem"],
|
|
165
|
+
"px-6": ["padding-left", "1.5rem"],
|
|
166
|
+
"px-8": ["padding-left", "2rem"],
|
|
167
|
+
"py-0": ["padding-top", "0"],
|
|
168
|
+
"py-1": ["padding-top", "0.25rem"],
|
|
169
|
+
"py-2": ["padding-top", "0.5rem"],
|
|
170
|
+
"py-3": ["padding-top", "0.75rem"],
|
|
171
|
+
"py-4": ["padding-top", "1rem"],
|
|
172
|
+
"py-5": ["padding-top", "1.25rem"],
|
|
173
|
+
"py-6": ["padding-top", "1.5rem"],
|
|
174
|
+
"py-8": ["padding-top", "2rem"],
|
|
175
|
+
"pt-4": ["padding-top", "1rem"],
|
|
176
|
+
"pb-4": ["padding-bottom", "1rem"],
|
|
177
|
+
"pl-4": ["padding-left", "1rem"],
|
|
178
|
+
"pr-4": ["padding-right", "1rem"],
|
|
179
|
+
"pt-8": ["padding-top", "2rem"],
|
|
180
|
+
"pb-8": ["padding-bottom", "2rem"],
|
|
181
|
+
"pl-8": ["padding-left", "2rem"],
|
|
182
|
+
"pr-8": ["padding-right", "2rem"],
|
|
183
|
+
"m-0": ["margin", "0"],
|
|
184
|
+
"m-1": ["margin", "0.25rem"],
|
|
185
|
+
"m-2": ["margin", "0.5rem"],
|
|
186
|
+
"m-3": ["margin", "0.75rem"],
|
|
187
|
+
"m-4": ["margin", "1rem"],
|
|
188
|
+
"m-6": ["margin", "1.5rem"],
|
|
189
|
+
"m-8": ["margin", "2rem"],
|
|
190
|
+
"mx-auto": ["margin-left", "auto"],
|
|
191
|
+
"my-4": ["margin-top", "1rem"],
|
|
192
|
+
"mt-0": ["margin-top", "0"],
|
|
193
|
+
"mt-1": ["margin-top", "0.25rem"],
|
|
194
|
+
"mt-2": ["margin-top", "0.5rem"],
|
|
195
|
+
"mt-3": ["margin-top", "0.75rem"],
|
|
196
|
+
"mt-4": ["margin-top", "1rem"],
|
|
197
|
+
"mt-6": ["margin-top", "1.5rem"],
|
|
198
|
+
"mt-8": ["margin-top", "2rem"],
|
|
199
|
+
"mb-0": ["margin-bottom", "0"],
|
|
200
|
+
"mb-1": ["margin-bottom", "0.25rem"],
|
|
201
|
+
"mb-2": ["margin-bottom", "0.5rem"],
|
|
202
|
+
"mb-3": ["margin-bottom", "0.75rem"],
|
|
203
|
+
"mb-4": ["margin-bottom", "1rem"],
|
|
204
|
+
"mb-6": ["margin-bottom", "1.5rem"],
|
|
205
|
+
"mb-8": ["margin-bottom", "2rem"],
|
|
206
|
+
"ml-0": ["margin-left", "0"],
|
|
207
|
+
"ml-4": ["margin-left", "1rem"],
|
|
208
|
+
"mr-4": ["margin-right", "1rem"],
|
|
209
|
+
"ml-auto": ["margin-left", "auto"],
|
|
210
|
+
"mr-auto": ["margin-right", "auto"],
|
|
211
|
+
"mt-auto": ["margin-top", "auto"],
|
|
212
|
+
"mb-auto": ["margin-bottom", "auto"],
|
|
213
|
+
static: ["position", "static"],
|
|
214
|
+
fixed: ["position", "fixed"],
|
|
215
|
+
absolute: ["position", "absolute"],
|
|
216
|
+
relative: ["position", "relative"],
|
|
217
|
+
sticky: ["position", "sticky"],
|
|
218
|
+
"inset-0": ["inset", "0"],
|
|
219
|
+
"top-0": ["top", "0"],
|
|
220
|
+
"top-4": ["top", "1rem"],
|
|
221
|
+
"top-1/2": ["top", "50%"],
|
|
222
|
+
"bottom-0": ["bottom", "0"],
|
|
223
|
+
"left-0": ["left", "0"],
|
|
224
|
+
"right-0": ["right", "0"],
|
|
225
|
+
"z-0": ["z-index", "0"],
|
|
226
|
+
"z-10": ["z-index", "10"],
|
|
227
|
+
"z-20": ["z-index", "20"],
|
|
228
|
+
"z-30": ["z-index", "30"],
|
|
229
|
+
"z-40": ["z-index", "40"],
|
|
230
|
+
"z-50": ["z-index", "50"],
|
|
231
|
+
"z-auto": ["z-index", "auto"],
|
|
232
|
+
"text-center": ["text-align", "center"],
|
|
233
|
+
"text-left": ["text-align", "left"],
|
|
234
|
+
"text-right": ["text-align", "right"],
|
|
235
|
+
"text-justify": ["text-align", "justify"],
|
|
236
|
+
"text-2xl": ["font-size", "1.5rem"],
|
|
237
|
+
"text-xl": ["font-size", "1.25rem"],
|
|
238
|
+
"text-lg": ["font-size", "1.125rem"],
|
|
239
|
+
"text-base": ["font-size", "1rem"],
|
|
240
|
+
"text-sm": ["font-size", "0.875rem"],
|
|
241
|
+
"text-xs": ["font-size", "0.75rem"],
|
|
242
|
+
"text-2xs": ["font-size", "0.625rem"],
|
|
243
|
+
"font-bold": ["font-weight", "700"],
|
|
244
|
+
"font-semibold": ["font-weight", "600"],
|
|
245
|
+
"font-medium": ["font-weight", "500"],
|
|
246
|
+
"font-normal": ["font-weight", "400"],
|
|
247
|
+
"font-light": ["font-weight", "300"],
|
|
248
|
+
"font-thin": ["font-weight", "100"],
|
|
249
|
+
"font-extrabold": ["font-weight", "800"],
|
|
250
|
+
"font-black": ["font-weight", "900"],
|
|
251
|
+
"font-extralight": ["font-weight", "200"],
|
|
252
|
+
italic: ["font-style", "italic"],
|
|
253
|
+
"not-italic": ["font-style", "normal"],
|
|
254
|
+
underline: ["text-decoration", "underline"],
|
|
255
|
+
"line-through": ["text-decoration", "line-through"],
|
|
256
|
+
"no-underline": ["text-decoration", "none"],
|
|
257
|
+
uppercase: ["text-transform", "uppercase"],
|
|
258
|
+
lowercase: ["text-transform", "lowercase"],
|
|
259
|
+
capitalize: ["text-transform", "capitalize"],
|
|
260
|
+
"normal-case": ["text-transform", "none"],
|
|
261
|
+
"tracking-tight": ["letter-spacing", "-0.025em"],
|
|
262
|
+
"tracking-wide": ["letter-spacing", "0.025em"],
|
|
263
|
+
"tracking-widest": ["letter-spacing", "0.1em"],
|
|
264
|
+
"leading-none": ["line-height", "1"],
|
|
265
|
+
"leading-tight": ["line-height", "1.25"],
|
|
266
|
+
"leading-snug": ["line-height", "1.375"],
|
|
267
|
+
"leading-normal": ["line-height", "1.5"],
|
|
268
|
+
"leading-relaxed": ["line-height", "1.625"],
|
|
269
|
+
"leading-loose": ["line-height", "2"],
|
|
270
|
+
"whitespace-nowrap": ["white-space", "nowrap"],
|
|
271
|
+
"whitespace-normal": ["white-space", "normal"],
|
|
272
|
+
"whitespace-pre": ["white-space", "pre"],
|
|
273
|
+
"whitespace-pre-line": ["white-space", "pre-line"],
|
|
274
|
+
"whitespace-pre-wrap": ["white-space", "pre-wrap"],
|
|
275
|
+
"break-words": ["word-break", "break-word"],
|
|
276
|
+
"break-all": ["word-break", "break-all"],
|
|
277
|
+
"list-none": ["list-style", "none"],
|
|
278
|
+
"list-disc": ["list-style", "disc"],
|
|
279
|
+
"list-decimal": ["list-style", "decimal"],
|
|
280
|
+
"appearance-none": ["appearance", "none"],
|
|
281
|
+
"cursor-pointer": ["cursor", "pointer"],
|
|
282
|
+
"cursor-default": ["cursor", "default"],
|
|
283
|
+
"cursor-wait": ["cursor", "wait"],
|
|
284
|
+
"cursor-not-allowed": ["cursor", "not-allowed"],
|
|
285
|
+
"cursor-text": ["cursor", "text"],
|
|
286
|
+
"pointer-events-none": ["pointer-events", "none"],
|
|
287
|
+
"pointer-events-auto": ["pointer-events", "auto"],
|
|
288
|
+
"select-none": ["user-select", "none"],
|
|
289
|
+
"select-text": ["user-select", "text"],
|
|
290
|
+
"resize-none": ["resize", "none"],
|
|
291
|
+
"resize-y": ["resize", "vertical"],
|
|
292
|
+
"resize-x": ["resize", "horizontal"],
|
|
293
|
+
resize: ["resize", "both"],
|
|
294
|
+
"object-cover": ["object-fit", "cover"],
|
|
295
|
+
"object-contain": ["object-fit", "contain"],
|
|
296
|
+
"object-fill": ["object-fit", "fill"],
|
|
297
|
+
"object-none": ["object-fit", "none"],
|
|
298
|
+
"object-scale-down": ["object-fit", "scale-down"],
|
|
299
|
+
transition: ["transition", "1"],
|
|
300
|
+
"transition-all": ["transition-property", "all"],
|
|
301
|
+
"transition-colors": ["transition-property", "color"],
|
|
302
|
+
"transition-opacity": ["transition-property", "opacity"],
|
|
303
|
+
"transition-transform": ["transition-property", "transform"],
|
|
304
|
+
"transition-none": ["transition", "none"],
|
|
305
|
+
"duration-150": ["transition-duration", "150ms"],
|
|
306
|
+
"duration-200": ["transition-duration", "200ms"],
|
|
307
|
+
"duration-300": ["transition-duration", "300ms"],
|
|
308
|
+
"duration-500": ["transition-duration", "500ms"],
|
|
309
|
+
"duration-700": ["transition-duration", "700ms"],
|
|
310
|
+
"ease-in": ["transition-timing-function", "cubic-bezier(0.4, 0, 1, 1)"],
|
|
311
|
+
"ease-out": ["transition-timing-function", "cubic-bezier(0, 0, 0.2, 1)"],
|
|
312
|
+
"ease-in-out": ["transition-timing-function", "cubic-bezier(0.4, 0, 0.2, 1)"],
|
|
313
|
+
"ease-linear": ["transition-timing-function", "linear"],
|
|
314
|
+
"animate-spin": ["animation", "spin"],
|
|
315
|
+
"animate-ping": ["animation", "ping"],
|
|
316
|
+
"animate-pulse": ["animation", "pulse"],
|
|
317
|
+
"animate-bounce": ["animation", "bounce"],
|
|
318
|
+
"animate-none": ["animation", "none"],
|
|
319
|
+
transform: ["transform", "1"],
|
|
320
|
+
"scale-50": ["transform", "scale(0.5)"],
|
|
321
|
+
"scale-75": ["transform", "scale(0.75)"],
|
|
322
|
+
"scale-90": ["transform", "scale(0.9)"],
|
|
323
|
+
"scale-95": ["transform", "scale(0.95)"],
|
|
324
|
+
"scale-100": ["transform", "scale(1)"],
|
|
325
|
+
"scale-105": ["transform", "scale(1.05)"],
|
|
326
|
+
"scale-110": ["transform", "scale(1.1)"],
|
|
327
|
+
"scale-125": ["transform", "scale(1.25)"],
|
|
328
|
+
"scale-150": ["transform", "scale(1.5)"],
|
|
329
|
+
"rotate-0": ["transform", "rotate(0deg)"],
|
|
330
|
+
"rotate-45": ["transform", "rotate(45deg)"],
|
|
331
|
+
"rotate-90": ["transform", "rotate(90deg)"],
|
|
332
|
+
"rotate-180": ["transform", "rotate(180deg)"],
|
|
333
|
+
"backface-hidden": ["backface-visibility", "hidden"],
|
|
334
|
+
"backface-visible": ["backface-visibility", "visible"],
|
|
335
|
+
"will-change-transform": ["will-change", "transform"],
|
|
336
|
+
"will-change-scroll": ["will-change", "scroll"],
|
|
337
|
+
"will-change-auto": ["will-change", "auto"],
|
|
338
|
+
"sr-only": ["position", "absolute"],
|
|
339
|
+
"not-sr-only": ["position", "static"],
|
|
340
|
+
"bg-cover": ["background-size", "cover"],
|
|
341
|
+
"bg-center": ["background-position", "center"],
|
|
342
|
+
"bg-no-repeat": ["background-repeat", "no-repeat"],
|
|
343
|
+
"bg-repeat": ["background-repeat", "repeat"],
|
|
344
|
+
"bg-fixed": ["background-attachment", "fixed"],
|
|
345
|
+
"bg-local": ["background-attachment", "local"],
|
|
346
|
+
"bg-scroll": ["background-attachment", "scroll"],
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/** Responsive/state prefixes to strip (sm:, md:, lg:, xl:, hover:, focus:, ...). */
|
|
350
|
+
const TW_PREFIX_RE = /^(sm|md|lg|xl|2xl|hover|focus|active|disabled|group-hover|peer-hover|motion-safe|motion-reduce|dark|light|first|last|odd|even|checked|indeterminate|selected|placeholder|file|before|after|in|out|open|read-only|read-write|required|valid|invalid|target|visited|link|any|not|has|is|empty|only|aria|focus-within|focus-visible|has-focus|group-focus|peer-focus|group-active|peer-active|group-disabled|peer-disabled|group-checked|peer-checked|group-selected|peer-selected|group-focus-within|peer-focus-within|group-focus-visible|peer-focus-visible|group-open|peer-open|group-read-only|peer-read-only|group-read-write|peer-read-write|group-required|peer-required|group-valid|peer-valid|group-invalid|peer-invalid|group-target|peer-target|group-visited|peer-visited|group-link|peer-link):/;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Arbitrary-value utilities: the bracketed value is taken VERBATIM (Tailwind
|
|
354
|
+
* does the same — `z-[1000]` is `z-index: 1000`), the prefix maps to a CSS
|
|
355
|
+
* property. `text-[13px]` is a font-size only when the value is a length
|
|
356
|
+
* (`text-[red]` is a color and is skipped); `bg-[...]` is a background-color
|
|
357
|
+
* only for color-like values (`bg-[url(...)]` is skipped).
|
|
358
|
+
*/
|
|
359
|
+
const TW_ARBITRARY_PREFIX = {
|
|
360
|
+
z: "z-index",
|
|
361
|
+
top: "top",
|
|
362
|
+
left: "left",
|
|
363
|
+
right: "right",
|
|
364
|
+
bottom: "bottom",
|
|
365
|
+
inset: "inset",
|
|
366
|
+
w: "width",
|
|
367
|
+
h: "height",
|
|
368
|
+
"min-w": "min-width",
|
|
369
|
+
"max-w": "max-width",
|
|
370
|
+
"min-h": "min-height",
|
|
371
|
+
"max-h": "max-height",
|
|
372
|
+
p: "padding",
|
|
373
|
+
px: "padding-inline",
|
|
374
|
+
py: "padding-block",
|
|
375
|
+
pt: "padding-top",
|
|
376
|
+
pr: "padding-right",
|
|
377
|
+
pb: "padding-bottom",
|
|
378
|
+
pl: "padding-left",
|
|
379
|
+
m: "margin",
|
|
380
|
+
mx: "margin-inline",
|
|
381
|
+
my: "margin-block",
|
|
382
|
+
mt: "margin-top",
|
|
383
|
+
mr: "margin-right",
|
|
384
|
+
mb: "margin-bottom",
|
|
385
|
+
ml: "margin-left",
|
|
386
|
+
rounded: "border-radius",
|
|
387
|
+
border: "border-width",
|
|
388
|
+
text: "font-size", // only for length values — see twArbitrary
|
|
389
|
+
bg: "background-color", // only for color values — see twArbitrary
|
|
390
|
+
};
|
|
391
|
+
const TW_LENGTH_RE = /^[0-9.]+(px|rem|em|vh|vw|vmin|vmax|ch|ex|%|pt|pc|in|cm|mm)$/;
|
|
392
|
+
const TW_COLOR_RE = /^(#|rgb|hsl|hwb|lab|lch|oklab|oklch|color\(|[a-z]+$)/;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Parse an arbitrary-value utility (`z-[1000]`, `w-[420px]`, `inset-[0px]`,
|
|
396
|
+
* `p-[12px]`, `text-[13px]`, `rounded-[8px]`, `bg-[#000]`, ...).
|
|
397
|
+
* Returns [prop, value] or null when the prefix (or value kind) is unknown.
|
|
398
|
+
*/
|
|
399
|
+
function twArbitrary(token) {
|
|
400
|
+
const m = token.match(/^([a-z]+(?:-[a-z0-9]+)*)-\[(.+)\]$/);
|
|
401
|
+
if (!m) return null;
|
|
402
|
+
const prop = TW_ARBITRARY_PREFIX[m[1]];
|
|
403
|
+
if (!prop) return null;
|
|
404
|
+
const value = m[2];
|
|
405
|
+
if (m[1] === "text" && !TW_LENGTH_RE.test(value)) return null; // text-[red] = color, not size
|
|
406
|
+
if (m[1] === "bg" && !TW_COLOR_RE.test(value)) return null; // bg-[url(...)] = image
|
|
407
|
+
return [prop, value];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Convert a tailwind class string into a styles map
|
|
412
|
+
* (cssProperty -> array of candidate values). Unknown utilities are
|
|
413
|
+
* skipped — only recognized utilities contribute to the signature.
|
|
414
|
+
*/
|
|
415
|
+
function twToStyles(cls) {
|
|
416
|
+
const styles = {};
|
|
417
|
+
if (typeof cls !== "string") return styles;
|
|
418
|
+
for (const rawToken of cls.split(/\s+/)) {
|
|
419
|
+
if (!rawToken) continue;
|
|
420
|
+
let token = rawToken;
|
|
421
|
+
// Strip responsive/state prefixes (repeat for stacked ones like lg:hover:).
|
|
422
|
+
let guard = 0;
|
|
423
|
+
while (TW_PREFIX_RE.test(token) && guard < 4) {
|
|
424
|
+
token = token.replace(TW_PREFIX_RE, "");
|
|
425
|
+
guard += 1;
|
|
426
|
+
}
|
|
427
|
+
// Negative values: -ml-4 -> margin-left: -1rem.
|
|
428
|
+
let negative = false;
|
|
429
|
+
if (token.startsWith("-")) {
|
|
430
|
+
negative = true;
|
|
431
|
+
token = token.slice(1);
|
|
432
|
+
}
|
|
433
|
+
// Arbitrary values first (`z-[1000]`, `w-[420px]`, ...), then the fixed map.
|
|
434
|
+
const arb = twArbitrary(token);
|
|
435
|
+
const hit = arb || TW[token];
|
|
436
|
+
if (!hit) continue;
|
|
437
|
+
const [prop, value] = hit;
|
|
438
|
+
const v = value == null ? (negative ? "0" : "1") : negative ? `-${value}` : value;
|
|
439
|
+
if (!Object.prototype.hasOwnProperty.call(styles, prop)) styles[prop] = [];
|
|
440
|
+
if (!styles[prop].includes(v)) styles[prop].push(v);
|
|
441
|
+
}
|
|
442
|
+
return styles;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** camelCase (JS style object) -> kebab-case (CSS). */
|
|
446
|
+
function camelToKebab(name) {
|
|
447
|
+
return String(name).replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Parse a CSS declaration block (styled-components template body or a plain
|
|
452
|
+
* "a: b; c: d" string) into a styles map. Interpolations ({...}) are
|
|
453
|
+
* skipped — only static declarations contribute.
|
|
454
|
+
*/
|
|
455
|
+
function cssTextToStyles(text) {
|
|
456
|
+
const styles = {};
|
|
457
|
+
if (typeof text !== "string") return styles;
|
|
458
|
+
// Remove comments.
|
|
459
|
+
const clean = text.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
460
|
+
for (const decl of clean.split(";")) {
|
|
461
|
+
const idx = decl.indexOf(":");
|
|
462
|
+
if (idx < 0) continue;
|
|
463
|
+
const prop = decl.slice(0, idx).trim();
|
|
464
|
+
const value = decl.slice(idx + 1).trim();
|
|
465
|
+
if (!prop || !value) continue;
|
|
466
|
+
if (prop.startsWith("@") || prop.startsWith("$") || value.includes("{")) continue;
|
|
467
|
+
if (!/^[a-zA-Z-]+$/.test(prop)) continue;
|
|
468
|
+
const p = prop.toLowerCase();
|
|
469
|
+
if (!Object.prototype.hasOwnProperty.call(styles, p)) styles[p] = [];
|
|
470
|
+
if (!styles[p].includes(value)) styles[p].push(value);
|
|
471
|
+
}
|
|
472
|
+
return styles;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Merge styles maps. A value is "compatible" when one is a presence marker
|
|
477
|
+
* ("1") — in that case the other value wins.
|
|
478
|
+
*/
|
|
479
|
+
function mergeStyles(a, b) {
|
|
480
|
+
const out = {};
|
|
481
|
+
for (const [prop, values] of Object.entries(a)) {
|
|
482
|
+
out[prop] = [...values];
|
|
483
|
+
}
|
|
484
|
+
for (const [prop, values] of Object.entries(b)) {
|
|
485
|
+
if (!Object.prototype.hasOwnProperty.call(out, prop)) {
|
|
486
|
+
out[prop] = [...values];
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
for (const v of values) {
|
|
490
|
+
if (v === "1" && out[prop].length > 0) continue; // presence marker loses
|
|
491
|
+
if (v === "1" && out[prop].every((x) => x === "1")) continue;
|
|
492
|
+
if (!out[prop].includes(v)) out[prop].push(v);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Jaccard similarity of two styles maps (property-level). */
|
|
499
|
+
function stylesSimilarity(a, b) {
|
|
500
|
+
const A = new Set(Object.keys(a));
|
|
501
|
+
const B = new Set(Object.keys(b));
|
|
502
|
+
if (A.size === 0 && B.size === 0) return 0;
|
|
503
|
+
let inter = 0;
|
|
504
|
+
for (const p of A) if (B.has(p)) inter += 1;
|
|
505
|
+
const union = A.size + B.size - inter;
|
|
506
|
+
return union === 0 ? 0 : inter / union;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Decision matrix for a candidate element signature vs a catalog entry.
|
|
511
|
+
*
|
|
512
|
+
* Returns null (no match), or { level: "error" | "warning", reason }.
|
|
513
|
+
*
|
|
514
|
+
* error: a11y/actions overlap (role:dialog, input[type=range], onClick on a role)
|
|
515
|
+
* OR styles >= 0.9 (any tag)
|
|
516
|
+
* OR same tag AND styles >= 0.7
|
|
517
|
+
* warning: different tag AND styles 0.5..0.9
|
|
518
|
+
* OR same tag AND styles 0.4..0.7
|
|
519
|
+
*/
|
|
520
|
+
/**
|
|
521
|
+
* Minimum number of SHARED style keys required for a styles-only match to
|
|
522
|
+
* count. A generic 2-key layout (e.g. `display:flex; justify-content:center`)
|
|
523
|
+
* appears on hundreds of unrelated divs; without this floor it becomes a
|
|
524
|
+
* union-find "hub" that absorbs the whole graph and hides real duplicates.
|
|
525
|
+
* A distinctive 5-key signature still matches. a11y/action matches are never
|
|
526
|
+
* gated by this floor (they are already specific signals).
|
|
527
|
+
*/
|
|
528
|
+
const MIN_SHARED_STYLE_KEYS = 3;
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Is an a11y token a real duplicate signal? Boolean-presence tokens (value
|
|
532
|
+
* "true", e.g. `aria-label:true`, `aria-expanded:true`) are NOT: they mark
|
|
533
|
+
* "this element HAS an aria-label / is expanded", which is ubiquitous and
|
|
534
|
+
* would match any labeled app element against any labeled catalog component.
|
|
535
|
+
* Meaningful tokens carry a concrete value (role:dialog, type:range, ...).
|
|
536
|
+
*
|
|
537
|
+
* @param {string} token a11y token, e.g. "role:dialog" or "aria-label:true"
|
|
538
|
+
* @returns {boolean} true when the token is a real duplicate signal
|
|
539
|
+
*/
|
|
540
|
+
function meaningfulA11yToken(token) {
|
|
541
|
+
return !!token && !token.endsWith(":true");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Tokens that are meaningful (they carry a value) but still too common to
|
|
546
|
+
* identify a component on their own: every button declares type:button, every
|
|
547
|
+
* text input type:text, every fieldset role:group. In the ESLint rule these
|
|
548
|
+
* are fine because the candidate is ONE specific element; in report clustering
|
|
549
|
+
* they are transitive glue — a single type:button catalog entry chains every
|
|
550
|
+
* button in the repo into one giant cluster.
|
|
551
|
+
*/
|
|
552
|
+
const UBQUITOUS_A11Y_TOKENS = new Set([
|
|
553
|
+
"type:button",
|
|
554
|
+
"type:text",
|
|
555
|
+
"type:submit",
|
|
556
|
+
"type:reset",
|
|
557
|
+
"type:email",
|
|
558
|
+
"type:password",
|
|
559
|
+
"type:search",
|
|
560
|
+
"type:tel",
|
|
561
|
+
"type:url",
|
|
562
|
+
"type:number",
|
|
563
|
+
"type:date",
|
|
564
|
+
"type:time",
|
|
565
|
+
"role:group",
|
|
566
|
+
"role:presentation",
|
|
567
|
+
"role:none",
|
|
568
|
+
"role:list",
|
|
569
|
+
"role:listitem",
|
|
570
|
+
"role:status",
|
|
571
|
+
"role:alert",
|
|
572
|
+
"role:img",
|
|
573
|
+
]);
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* An a11y token specific enough to identify a component by itself
|
|
577
|
+
* (role:dialog, aria-label:Rows per page, ...).
|
|
578
|
+
*/
|
|
579
|
+
function specificA11yToken(token) {
|
|
580
|
+
return meaningfulA11yToken(token) && !UBQUITOUS_A11Y_TOKENS.has(token);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function decide(candidate, entry) {
|
|
584
|
+
const entryA11y = new Set(entry.a11y);
|
|
585
|
+
const a11yOverlap = candidate.a11y.filter((a) => meaningfulA11yToken(a) && entryA11y.has(a));
|
|
586
|
+
const actionOverlap = candidate.actions.filter((a) => entry.actions.includes(a));
|
|
587
|
+
const sameTag = candidate.tag === entry.tag;
|
|
588
|
+
const sim = stylesSimilarity(candidate.styles, entry.styles);
|
|
589
|
+
// Shared style-key footprint.
|
|
590
|
+
const A = Object.keys(candidate.styles);
|
|
591
|
+
const B = new Set(Object.keys(entry.styles));
|
|
592
|
+
const shared = A.filter((p) => B.has(p)).length;
|
|
593
|
+
|
|
594
|
+
// a11y overlap is a specific, standalone duplicate signal (role:dialog,
|
|
595
|
+
// type:range, ...). Boolean-presence tokens are filtered by meaningfulA11yToken.
|
|
596
|
+
if (a11yOverlap.length > 0) return { level: "error", reason: a11yOverlap[0] };
|
|
597
|
+
|
|
598
|
+
// Action overlap (click/change/...) is NOT a standalone duplicate signal:
|
|
599
|
+
// every button has onClick, every input has onChange, so a bare action match
|
|
600
|
+
// would make every button "duplicate" every other button. It only proves a
|
|
601
|
+
// duplicate when at least one side carries a MEANINGFUL a11y marker
|
|
602
|
+
// (type:button, role:dialog, ...) — that marker identifies the element as a
|
|
603
|
+
// re-implementation of a specific canonical component rather than just
|
|
604
|
+
// "another clickable thing". A plain <Button onClick> with no such marker
|
|
605
|
+
// against a catalog entry that has none either stays silent.
|
|
606
|
+
// Action overlap (click/change/...) is a WEAK standalone signal: every
|
|
607
|
+
// button has onClick, every input has onChange, so a bare action match would
|
|
608
|
+
// make every clickable element "duplicate" every other clickable element.
|
|
609
|
+
// It is promoted to a duplicate ONLY when the ENTRY is a full BUTTON
|
|
610
|
+
// definition: it carries the `type:button` marker AND a real style footprint
|
|
611
|
+
// (>= MIN_SHARED_STYLE_KEYS). That combination is unambiguous — "the
|
|
612
|
+
// canonical button" — so a bare element matching it on action is a
|
|
613
|
+
// re-implementation of THAT component, not just "another clickable thing".
|
|
614
|
+
//
|
|
615
|
+
// A bare <PageNumber onClick> / <ClearButton onClick> against an entry that
|
|
616
|
+
// is only a labeled overlay (role:dialog), a tab (role:tab + 2 generic
|
|
617
|
+
// styles), or a range (type:range) is NOT convicted here: it is "another
|
|
618
|
+
// clickable thing", not a re-implementation of that component. The candidate
|
|
619
|
+
// is still caught by the a11y-overlap branch (above) or the styles branch
|
|
620
|
+
// (below) whenever it genuinely shares those signals. The candidate's OWN
|
|
621
|
+
// meaningful a11y is deliberately NOT used here — it does not prove the
|
|
622
|
+
// element matches THIS entry and is the source of the ubiquitous-action FPs.
|
|
623
|
+
const entryIsButton = (entry.a11y || []).includes("type:button");
|
|
624
|
+
const entryStyleCount = Object.keys(entry.styles || {}).length;
|
|
625
|
+
if (actionOverlap.length > 0 && entryIsButton && entryStyleCount >= MIN_SHARED_STYLE_KEYS) {
|
|
626
|
+
return { level: "error", reason: actionOverlap[0] };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Styles-only match (no a11y/action signal): require a real shared footprint.
|
|
630
|
+
if (shared < MIN_SHARED_STYLE_KEYS) return null;
|
|
631
|
+
if (sim >= 0.9) return { level: "error", reason: `styles ${sim.toFixed(2)}` };
|
|
632
|
+
if (sameTag && sim >= 0.7) return { level: "error", reason: `styles ${sim.toFixed(2)}` };
|
|
633
|
+
if (!sameTag && sim >= 0.5) return { level: "warning", reason: `styles ${sim.toFixed(2)}` };
|
|
634
|
+
if (sameTag && sim >= 0.4) return { level: "warning", reason: `styles ${sim.toFixed(2)}` };
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Match a candidate signature against the catalog. Returns the best
|
|
640
|
+
* { name, path, level, reason, sim } or null.
|
|
641
|
+
*/
|
|
642
|
+
function matchSignature(candidate, catalog) {
|
|
643
|
+
const entries = (catalog && catalog.components) || [];
|
|
644
|
+
let best = null;
|
|
645
|
+
for (const entry of entries) {
|
|
646
|
+
const d = decide(candidate, entry);
|
|
647
|
+
if (!d) continue;
|
|
648
|
+
const score = d.level === "error" ? 2 : 1;
|
|
649
|
+
const sim = stylesSimilarity(candidate.styles, entry.styles);
|
|
650
|
+
// `score` MUST be stored on `best`: the comparison below reads
|
|
651
|
+
// `best.score`, and without it `best.score` is `undefined`, so
|
|
652
|
+
// `score > best.score` is always false and the first match wins
|
|
653
|
+
// regardless of level — silently defeating the "error beats warning"
|
|
654
|
+
// selection (a warning-tier match found earlier would mask a later
|
|
655
|
+
// error-tier duplicate).
|
|
656
|
+
if (!best || score > best.score || (score === best.score && sim > best.sim)) {
|
|
657
|
+
best = { name: entry.name, path: entry.path, level: d.level, reason: d.reason, sim, score };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return best;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
module.exports = { TW, twToStyles, camelToKebab, cssTextToStyles, mergeStyles, stylesSimilarity, decide, matchSignature, meaningfulA11yToken, specificA11yToken, MIN_SHARED_STYLE_KEYS };
|