roast-my-design-system 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +119 -0
- package/bin/roast.mjs +93 -0
- package/package.json +35 -0
- package/skills/roast-my-design-system/scripts/benchmark/benchmark.json +1356 -0
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +870 -0
- package/skills/roast-my-design-system/scripts/harvest/components.mjs +189 -0
- package/skills/roast-my-design-system/scripts/harvest/context.mjs +42 -0
- package/skills/roast-my-design-system/scripts/harvest/duplicates.mjs +153 -0
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +75 -0
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +312 -0
- package/skills/roast-my-design-system/scripts/harvest/walk.mjs +147 -0
- package/skills/roast-my-design-system/scripts/lib/typefaces.mjs +19 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implicit-token harvest — every colour, spacing, radius, font-size the repo
|
|
3
|
+
* ACTUALLY uses, with frequency counts and where. This is the heart of the
|
|
4
|
+
* diagnosis ("27 greys, 19 spacing values"): we count real occurrences across
|
|
5
|
+
* stylesheets, inline styles, and Tailwind utility classes.
|
|
6
|
+
*
|
|
7
|
+
* New in 2.0 — 1.0 only ever read a clean globals.css; this reads the mess.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
// ---------- counters ----------
|
|
13
|
+
class Tally {
|
|
14
|
+
constructor() { this.map = new Map(); }
|
|
15
|
+
add(value, file) {
|
|
16
|
+
let e = this.map.get(value);
|
|
17
|
+
if (!e) { e = { value, count: 0, files: new Map() }; this.map.set(value, e); }
|
|
18
|
+
e.count++;
|
|
19
|
+
e.files.set(file, (e.files.get(file) ?? 0) + 1);
|
|
20
|
+
}
|
|
21
|
+
toJSON(limitFiles = 5) {
|
|
22
|
+
return [...this.map.values()]
|
|
23
|
+
.sort((a, b) => b.count - a.count)
|
|
24
|
+
.map((e) => ({
|
|
25
|
+
value: e.value,
|
|
26
|
+
count: e.count,
|
|
27
|
+
files: [...e.files.entries()].sort((a, b) => b[1] - a[1]).slice(0, limitFiles)
|
|
28
|
+
.map(([file, count]) => ({ file, count })),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------- colour parsing ----------
|
|
34
|
+
const HEX_RE = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
|
|
35
|
+
const FUNC_COLOR_RE = /\b(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\(\s*[^)]{1,80}\)/g;
|
|
36
|
+
|
|
37
|
+
/** normalize: lowercase; expand #abc → #aabbcc so duplicates merge. */
|
|
38
|
+
export function normalizeHex(hex) {
|
|
39
|
+
let h = hex.toLowerCase();
|
|
40
|
+
if (h.length === 4 || h.length === 5) {
|
|
41
|
+
h = '#' + [...h.slice(1)].map((c) => c + c).join('');
|
|
42
|
+
}
|
|
43
|
+
return h;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Is this hex a grey (R≈G≈B within a small tolerance)? Diagnosis loves this. */
|
|
47
|
+
export function isGrey(hex) {
|
|
48
|
+
const h = normalizeHex(hex);
|
|
49
|
+
if (h.length < 7) return false;
|
|
50
|
+
const r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16);
|
|
51
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
52
|
+
return max - min <= 10;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------- spacing / radius / font-size from CSS declarations ----------
|
|
56
|
+
const SPACING_PROPS = /(?:^|[^-\w])(margin(?:-(?:top|right|bottom|left))?|padding(?:-(?:top|right|bottom|left))?|gap|row-gap|column-gap|top|right|bottom|left|inset)\s*:\s*([^;{}]+)[;}]/g;
|
|
57
|
+
const RADIUS_PROPS = /border(?:-(?:top|bottom)-(?:left|right))?-radius\s*:\s*([^;{}]+)[;}]/g;
|
|
58
|
+
const FONTSIZE_PROPS = /font-size\s*:\s*([^;{}]+)[;}]/g;
|
|
59
|
+
const FONTFAMILY_PROPS = /font-family\s*:\s*([^;{}]+)[;}]/g;
|
|
60
|
+
const SHADOW_PROPS = /box-shadow\s*:\s*([^;{}]+)[;}]/g;
|
|
61
|
+
const LENGTH_RE = /-?\d*\.?\d+(?:px|rem|em|%|vh|vw|ch)\b/g;
|
|
62
|
+
|
|
63
|
+
// ---------- Tailwind utility classes ----------
|
|
64
|
+
// Colour-bearing utilities: bg-red-500, text-zinc-400, border-gray-200, arbitrary bg-[#1a1a1a]
|
|
65
|
+
const TW_COLOR_RE = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|divide|outline|decoration|accent|caret|shadow)-(?:\[(#[0-9a-fA-F]{3,8}|rgba?\([^\]]*\)|hsla?\([^\]]*\)|oklch\([^\]]*\))\]|((?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|white|black)(?:-\d{2,3})?))(?=[\s"'`/}:]|$)/g;
|
|
66
|
+
// Spacing utilities: p-4 px-2 mt-6 gap-3 space-x-2 -mx-4, arbitrary p-[13px]
|
|
67
|
+
const TW_SPACING_RE = /\b-?(?:[mp][trblxy]?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left)-(\[(?:[^\]]+)\]|\d+(?:\.\d+)?|px)(?=[\s"'`}:]|$)/g;
|
|
68
|
+
const TW_RADIUS_RE = /\brounded(?:-(?:t|b|l|r|tl|tr|bl|br))?(?:-(none|sm|md|lg|xl|2xl|3xl|full|\[[^\]]+\]))?(?=[\s"'`}:]|$)/g;
|
|
69
|
+
const TW_TEXTSIZE_RE = /\btext-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl|\[[^\]]+\])(?=[\s"'`}:]|$)/g;
|
|
70
|
+
|
|
71
|
+
/** Extract string contents of className=/class= attributes + template classes. */
|
|
72
|
+
function classStrings(src) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const m of src.matchAll(/class(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g)) {
|
|
75
|
+
out.push(m[1] ?? m[2] ?? m[3] ?? '');
|
|
76
|
+
}
|
|
77
|
+
// cva()/cn()/clsx() string args also carry classes
|
|
78
|
+
for (const m of src.matchAll(/\b(?:cva|cn|clsx|classnames|twMerge)\s*\(([\s\S]{0,2000}?)\)/g)) {
|
|
79
|
+
for (const s of m[1].matchAll(/["'`]([^"'`]+)["'`]/g)) out.push(s[1]);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// An inline style that only pipes currentcolor/inherit through (the standard
|
|
85
|
+
// SVG icon-inheritance pattern, often 60x in one glyph file) is not "styling
|
|
86
|
+
// bypassing the system" — counting it hands a sharp dev the whole rebuttal.
|
|
87
|
+
const TRIVIAL_INLINE_RE = /^\{?\s*(?:(?:\.\.\.[\w.]+|(?:color|fill|stroke)\s*:\s*['"]?(?:currentcolor|inherit)['"]?)\s*,?\s*)*\}?$/i;
|
|
88
|
+
|
|
89
|
+
// A style block whose values are runtime expressions (translate(\`\${x}px\`),
|
|
90
|
+
// width: sidebarWidth, ternaries) CANNOT be static CSS — that's dynamic
|
|
91
|
+
// positioning/theming, not a style crime. Only all-literal blocks count.
|
|
92
|
+
function isStaticInline(block) {
|
|
93
|
+
const inner = block.trim().replace(/^\{/, '').replace(/\}$/, '');
|
|
94
|
+
if (inner.includes('${')) return false;
|
|
95
|
+
// every value must start like a literal: quote, number, negative, hex
|
|
96
|
+
for (const m of inner.matchAll(/[\w-]+\s*:\s*([^,{}]+)/g)) {
|
|
97
|
+
const v = m[1].trim();
|
|
98
|
+
if (!/^["'0-9#-]/.test(v)) return false;
|
|
99
|
+
}
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Render-to-image surfaces (satori OG cards, react-pdf invoices) have inline
|
|
104
|
+
// styles as their ONLY styling mechanism — same honesty rule as email.
|
|
105
|
+
const RENDER_TO_IMAGE_RE = /ImageResponse|from ['"]satori['"]|from ['"]@react-pdf|next\/og/;
|
|
106
|
+
|
|
107
|
+
/** Extract inline style objects: style={{ ... }} */
|
|
108
|
+
function inlineStyleBlocks(src) {
|
|
109
|
+
const out = [];
|
|
110
|
+
let from = 0, idx;
|
|
111
|
+
while ((idx = src.indexOf('style={{', from)) !== -1) {
|
|
112
|
+
const close = src.indexOf('}}', idx);
|
|
113
|
+
if (close === -1) break;
|
|
114
|
+
out.push(src.slice(idx + 7, close + 1));
|
|
115
|
+
from = close + 2;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Harvest tokens across style files + code files.
|
|
122
|
+
* Returns { colors, greys, spacing, radii, fontSizes, fontFamilies, shadows,
|
|
123
|
+
* tailwind: {...same buckets from tw classes...}, inlineStyleCount }
|
|
124
|
+
*/
|
|
125
|
+
export function harvestTokens(root, styleFiles, codeFiles) {
|
|
126
|
+
const colors = new Tally(), spacing = new Tally(), radii = new Tally(),
|
|
127
|
+
fontSizes = new Tally(), fontFamilies = new Tally(), shadows = new Tally();
|
|
128
|
+
const twColors = new Tally(), twSpacing = new Tally(), twRadii = new Tally(), twTextSizes = new Tally();
|
|
129
|
+
const twArbitrary = new Tally();
|
|
130
|
+
let inlineStyleCount = 0;
|
|
131
|
+
const inlineStyleFiles = new Map();
|
|
132
|
+
|
|
133
|
+
// Colours that appear in a CSS custom-property DEFINITION (--grey-100: #f5f5f5)
|
|
134
|
+
// are deliberate tokens; everything else is a hardcoded stray. The diagnosis
|
|
135
|
+
// hinges on this split.
|
|
136
|
+
const tokenDefined = new Set();
|
|
137
|
+
const tokenDefsPerFile = new Map();
|
|
138
|
+
|
|
139
|
+
const scanCssText = (text, file) => {
|
|
140
|
+
for (const m of text.matchAll(/--[\w-]+\s*:\s*([^;{}]+)[;}]/g)) {
|
|
141
|
+
tokenDefsPerFile.set(file, (tokenDefsPerFile.get(file) ?? 0) + 1);
|
|
142
|
+
for (const c of m[1].matchAll(HEX_RE)) tokenDefined.add(normalizeHex(c[0]));
|
|
143
|
+
for (const c of m[1].matchAll(FUNC_COLOR_RE)) tokenDefined.add(c[0].replace(/\s+/g, ' ').toLowerCase());
|
|
144
|
+
}
|
|
145
|
+
for (const m of text.matchAll(HEX_RE)) colors.add(normalizeHex(m[0]), file);
|
|
146
|
+
for (const m of text.matchAll(FUNC_COLOR_RE)) colors.add(m[0].replace(/\s+/g, ' ').toLowerCase(), file);
|
|
147
|
+
for (const m of text.matchAll(SPACING_PROPS)) {
|
|
148
|
+
for (const len of (m[2].match(LENGTH_RE) ?? [])) spacing.add(len, file);
|
|
149
|
+
}
|
|
150
|
+
for (const m of text.matchAll(RADIUS_PROPS)) radii.add(m[1].trim(), file);
|
|
151
|
+
for (const m of text.matchAll(FONTSIZE_PROPS)) fontSizes.add(m[1].trim(), file);
|
|
152
|
+
for (const m of text.matchAll(FONTFAMILY_PROPS)) {
|
|
153
|
+
const v = m[1].trim().replace(/\s+/g, ' ');
|
|
154
|
+
// var(--x) and inherit are disciplined token usage, not declarations
|
|
155
|
+
if (/^(var\(--[\w-]+\)|inherit)$/i.test(v)) continue;
|
|
156
|
+
fontFamilies.add(v, file);
|
|
157
|
+
}
|
|
158
|
+
for (const m of text.matchAll(SHADOW_PROPS)) shadows.add(m[1].trim().replace(/\s+/g, ' '), file);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
for (const f of styleFiles) {
|
|
162
|
+
if (/email|(^|[/.])print([/.]|$)/i.test(f)) continue;
|
|
163
|
+
let text; try { text = readFileSync(join(root, f), 'utf8'); } catch { continue; }
|
|
164
|
+
scanCssText(text, f);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Email templates (and print styles) MUST inline their styling — that's
|
|
168
|
+
// correct practice, not mess. Counting them would hand a sharp dev an easy
|
|
169
|
+
// reason to discredit the whole report.
|
|
170
|
+
const EXEMPT_RE = /email|(^|[/.])print([/.]|$)/i;
|
|
171
|
+
// SVG artwork components (logos, badges, illustrated icons) carry hex that
|
|
172
|
+
// is drawing, not styling — same honesty rule as email templates.
|
|
173
|
+
const ARTWORK_RE = /(^|\/)[\w.-]*(icon|logo|badge|illustration|shield|artwork|graphic|background)[\w.-]*\.(tsx|jsx)$/i;
|
|
174
|
+
// A component that is mostly SVG markup (theme-preview mocks, decorative
|
|
175
|
+
// scenes) is drawing, not styling — regardless of its filename.
|
|
176
|
+
const svgHeavy = (src) => (src.match(/<(?:svg|path|rect|circle|ellipse|polygon|mask|defs)\b/g) ?? []).length >= 15;
|
|
177
|
+
// Canvas/scene renderers draw pixels; their colour literals are not UI styling.
|
|
178
|
+
const RENDERER_PATH_RE = /(^|\/)(renderers?|scene|canvas)\/|renderElement|DebugCanvas/i;
|
|
179
|
+
// Colour-picker palettes and design-field option lists are user-facing data.
|
|
180
|
+
const PALETTE_FILE_RE = /(color-picker|colour-picker|palette|design-fields|swatch)/i;
|
|
181
|
+
|
|
182
|
+
for (const f of codeFiles) {
|
|
183
|
+
if (!/\.(tsx|jsx|ts|js)$/.test(f)) continue;
|
|
184
|
+
if (EXEMPT_RE.test(f)) continue;
|
|
185
|
+
let src; try { src = readFileSync(join(root, f), 'utf8'); } catch { continue; }
|
|
186
|
+
|
|
187
|
+
// A Tailwind config's colours ARE the token layer (the tailwind-native
|
|
188
|
+
// equivalent of --var definitions). Its fontFamily block is the typeface
|
|
189
|
+
// declaration for the whole app.
|
|
190
|
+
if (/(^|\/)tailwind\.config\.[mc]?[jt]s$/.test(f)) {
|
|
191
|
+
for (const m of src.matchAll(HEX_RE)) { tokenDefined.add(normalizeHex(m[0])); colors.add(normalizeHex(m[0]), f); }
|
|
192
|
+
const famBlock = src.match(/fontFamily\s*:\s*\{([\s\S]*?)\n\s*\}/);
|
|
193
|
+
if (famBlock) {
|
|
194
|
+
// one declaration per stack: the first non-var name; fallbacks in the
|
|
195
|
+
// same array are not additional "ways" of declaring a font
|
|
196
|
+
for (const arr of famBlock[1].matchAll(/\[([^\]]+)\]/g)) {
|
|
197
|
+
const first = [...arr[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1])
|
|
198
|
+
.find((v) => !v.startsWith('var(') && !/^--/.test(v));
|
|
199
|
+
if (first) fontFamilies.add(first, f);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// next/font and geist load typefaces without a font-family declaration —
|
|
205
|
+
// reporting "0 typefaces" for a repo shipping .woff2 loaders is wrong.
|
|
206
|
+
for (const m of src.matchAll(/import\s*\{\s*([^}]+)\}\s*from\s*["']next\/font\/google["']/g)) {
|
|
207
|
+
for (const name of m[1].split(',')) { const t = name.trim().split(/\s+as\s+/)[0].trim(); if (t) fontFamilies.add(t.replace(/_/g, ' '), f); }
|
|
208
|
+
}
|
|
209
|
+
for (const m of src.matchAll(/localFont\s*\(\s*\{[\s\S]{0,600}?variable\s*:\s*["']--font-([\w-]+)["']/g)) {
|
|
210
|
+
fontFamilies.add(m[1].replace(/-/g, ' '), f);
|
|
211
|
+
}
|
|
212
|
+
if (/from\s*["']geist\/font/.test(src)) fontFamilies.add('Geist', f);
|
|
213
|
+
if (PALETTE_FILE_RE.test(f)) {
|
|
214
|
+
for (const m of src.matchAll(HEX_RE)) tokenDefined.add(normalizeHex(m[0]));
|
|
215
|
+
}
|
|
216
|
+
if (ARTWORK_RE.test(f) || RENDERER_PATH_RE.test(f) || svgHeavy(src)) continue;
|
|
217
|
+
const renderToImage = RENDER_TO_IMAGE_RE.test(src) || /(^|\/)api\/og\//.test(f);
|
|
218
|
+
|
|
219
|
+
// Tailwind classes
|
|
220
|
+
for (const cls of classStrings(src)) {
|
|
221
|
+
for (const m of cls.matchAll(TW_COLOR_RE)) twColors.add(m[1] ? normalizeHex(m[1].startsWith('#') ? m[1] : m[1]) : m[2], f);
|
|
222
|
+
for (const m of cls.matchAll(TW_SPACING_RE)) twSpacing.add(m[1], f);
|
|
223
|
+
for (const m of cls.matchAll(TW_RADIUS_RE)) twRadii.add(m[1] ?? 'default', f);
|
|
224
|
+
for (const m of cls.matchAll(TW_TEXTSIZE_RE)) twTextSizes.add(m[1], f);
|
|
225
|
+
// Arbitrary length values (p-[13px], text-[10px], w-[257px]) — the
|
|
226
|
+
// Tailwind-era way a scale erodes, one bracket at a time.
|
|
227
|
+
for (const m of cls.matchAll(/[a-z][\w-]*-\[(-?\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|pt))\]/g)) twArbitrary.add(`[${m[1]}]`, f);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Inline styles: dynamic blocks and render-to-image surfaces are
|
|
231
|
+
// legitimate STYLING (not counted as blocks), but any hardcoded colour or
|
|
232
|
+
// length inside them is still a hardcoded value — harvest those from every
|
|
233
|
+
// non-trivial block; count only the all-literal blocks.
|
|
234
|
+
const allBlocks = inlineStyleBlocks(src).filter((b) => !TRIVIAL_INLINE_RE.test(b.trim()));
|
|
235
|
+
const staticBlocks = renderToImage ? [] : allBlocks.filter(isStaticInline);
|
|
236
|
+
if (staticBlocks.length) {
|
|
237
|
+
inlineStyleCount += staticBlocks.length;
|
|
238
|
+
inlineStyleFiles.set(f, (inlineStyleFiles.get(f) ?? 0) + staticBlocks.length);
|
|
239
|
+
}
|
|
240
|
+
for (const b of allBlocks) {
|
|
241
|
+
for (const m of b.matchAll(HEX_RE)) colors.add(normalizeHex(m[0]), f);
|
|
242
|
+
for (const m of b.matchAll(FUNC_COLOR_RE)) colors.add(m[0].replace(/\s+/g, ' ').toLowerCase(), f);
|
|
243
|
+
for (const m of b.matchAll(LENGTH_RE)) spacing.add(m[0], f);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Raw hex colours in code outside class strings (styled-components, consts)
|
|
247
|
+
// — cheap approximation: any hex in a .ts/.js file counts as a colour in code.
|
|
248
|
+
if (/\.(ts|js)$/.test(f)) {
|
|
249
|
+
for (const m of src.matchAll(HEX_RE)) {
|
|
250
|
+
// avoid counting hashes that aren't colors (e.g. IDs): require 3/4/6/8 hex digits already enforced by regex
|
|
251
|
+
colors.add(normalizeHex(m[0]), f);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// A code file defining 15+ distinct colours is a deliberate palette
|
|
257
|
+
// (drawing swatches, avatar generators, chart scales) — the MUI lesson:
|
|
258
|
+
// definitions are architecture, not strays.
|
|
259
|
+
const perFileDistinct = new Map();
|
|
260
|
+
for (const e of colors.map.values()) {
|
|
261
|
+
for (const [file] of e.files) perFileDistinct.set(file, (perFileDistinct.get(file) ?? 0) + 1);
|
|
262
|
+
}
|
|
263
|
+
for (const e of colors.map.values()) {
|
|
264
|
+
if ([...e.files.keys()].some((f) => /\.[jt]sx?$/.test(f) && perFileDistinct.get(f) >= 15)) tokenDefined.add(e.value);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const colorList = colors.toJSON().map((c) => ({ ...c, isToken: tokenDefined.has(c.value) }));
|
|
268
|
+
|
|
269
|
+
// Worst offenders: files carrying the most stray styling (hardcoded colours
|
|
270
|
+
// that never appear in a --var definition, plus inline style blocks).
|
|
271
|
+
const offenders = new Map();
|
|
272
|
+
const bump = (file, key, by) => {
|
|
273
|
+
const e = offenders.get(file) ?? { file, strayColors: 0, inlineBlocks: 0 };
|
|
274
|
+
e[key] += by;
|
|
275
|
+
offenders.set(file, e);
|
|
276
|
+
};
|
|
277
|
+
for (const e of colors.map.values()) {
|
|
278
|
+
if (tokenDefined.has(e.value)) continue;
|
|
279
|
+
for (const [file, count] of e.files) bump(file, 'strayColors', count);
|
|
280
|
+
}
|
|
281
|
+
for (const [file, count] of inlineStyleFiles) bump(file, 'inlineBlocks', count);
|
|
282
|
+
const offenderList = [...offenders.values()]
|
|
283
|
+
.map((o) => ({ ...o, total: o.strayColors + o.inlineBlocks }))
|
|
284
|
+
.sort((a, b) => b.total - a.total)
|
|
285
|
+
.slice(0, 10);
|
|
286
|
+
|
|
287
|
+
const tokenFile = [...tokenDefsPerFile.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
tokenFile,
|
|
291
|
+
colors: colorList,
|
|
292
|
+
offenders: offenderList,
|
|
293
|
+
greyCount: colorList.filter((c) => c.value.startsWith('#') && isGrey(c.value)).length,
|
|
294
|
+
spacing: spacing.toJSON(),
|
|
295
|
+
radii: radii.toJSON(),
|
|
296
|
+
fontSizes: fontSizes.toJSON(),
|
|
297
|
+
fontFamilies: fontFamilies.toJSON(),
|
|
298
|
+
shadows: shadows.toJSON(),
|
|
299
|
+
tailwind: {
|
|
300
|
+
colors: twColors.toJSON(),
|
|
301
|
+
spacing: twSpacing.toJSON(),
|
|
302
|
+
radii: twRadii.toJSON(),
|
|
303
|
+
textSizes: twTextSizes.toJSON(),
|
|
304
|
+
arbitrary: twArbitrary.toJSON(),
|
|
305
|
+
},
|
|
306
|
+
inlineStyles: {
|
|
307
|
+
count: inlineStyleCount,
|
|
308
|
+
files: [...inlineStyleFiles.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
|
|
309
|
+
.map(([file, count]) => ({ file, count })),
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File walker + repo profile. Adapted from 1.0's repo-inspect (proffer-spine),
|
|
3
|
+
* widened: the harvester must see EVERYTHING that styles the app — code,
|
|
4
|
+
* stylesheets of any flavor, and config — not just a happy-path shadcn layout.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
7
|
+
import { join, relative, extname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function readJSON(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } }
|
|
10
|
+
export function read(p) { try { return readFileSync(p, 'utf8'); } catch { return null; } }
|
|
11
|
+
|
|
12
|
+
const SKIP_DIRS = new Set([
|
|
13
|
+
'node_modules', '.next', '.git', 'dist', 'build', 'out', 'coverage',
|
|
14
|
+
'.turbo', '.vercel', '.cache', 'storybook-static', 'public',
|
|
15
|
+
// Documentation sites (Docusaurus and friends) carry their own theme and
|
|
16
|
+
// demo fixtures — that styling is not the product's design language.
|
|
17
|
+
'docs', 'dev-docs', 'website', 'documentation',
|
|
18
|
+
// Example integrations and demo apps are not the product's design language.
|
|
19
|
+
'examples', 'example', 'demos', 'demo', 'playground', 'fixtures',
|
|
20
|
+
// Test/fixture surfaces are not the product's design language — counting a
|
|
21
|
+
// story's `export const Default` 36× would poison the diagnosis numbers.
|
|
22
|
+
'__tests__', '__mocks__', '__fixtures__', '__snapshots__',
|
|
23
|
+
'cypress', 'e2e', 'playwright', 'test', 'tests', '.storybook',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// Same idea at file granularity: Button.test.tsx / Button.stories.tsx / *.cy.ts
|
|
27
|
+
const TEST_FILE_RE = /\.(test|spec|stories|story|cy)\.[cm]?[jt]sx?$/;
|
|
28
|
+
|
|
29
|
+
const CODE_EXTS = new Set(['.tsx', '.jsx', '.ts', '.js', '.mjs', '.cjs']);
|
|
30
|
+
const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
31
|
+
|
|
32
|
+
/** Walk the repo, returning relative paths bucketed by role. Depth-capped. */
|
|
33
|
+
export function walkRepo(root, maxDepth = 8) {
|
|
34
|
+
const files = { code: [], styles: [], other: [] };
|
|
35
|
+
const recurse = (dir, depth) => {
|
|
36
|
+
if (depth > maxDepth) return;
|
|
37
|
+
let entries = [];
|
|
38
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
if (e.name.startsWith('.') && e.name !== '.cursorrules') continue;
|
|
41
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
42
|
+
const p = join(dir, e.name);
|
|
43
|
+
if (e.isDirectory()) { recurse(p, depth + 1); continue; }
|
|
44
|
+
if (TEST_FILE_RE.test(e.name)) continue;
|
|
45
|
+
const rel = relative(root, p).replaceAll('\\', '/');
|
|
46
|
+
const ext = extname(e.name);
|
|
47
|
+
if (STYLE_EXTS.has(ext)) files.styles.push(rel);
|
|
48
|
+
else if (CODE_EXTS.has(ext)) files.code.push(rel);
|
|
49
|
+
else files.other.push(rel);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
recurse(root, 0);
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Component libraries we can name on sight (from 1.0's repo-inspect).
|
|
57
|
+
const KNOWN_LIBRARIES = [
|
|
58
|
+
{ pkg: '@mui/material', name: 'Material UI' },
|
|
59
|
+
{ pkg: '@chakra-ui/react', name: 'Chakra UI' },
|
|
60
|
+
{ pkg: 'antd', name: 'Ant Design' },
|
|
61
|
+
{ pkg: '@mantine/core', name: 'Mantine' },
|
|
62
|
+
{ pkg: '@heroui/react', name: 'HeroUI' },
|
|
63
|
+
{ pkg: '@nextui-org/react', name: 'NextUI' },
|
|
64
|
+
{ pkg: '@radix-ui/themes', name: 'Radix Themes' },
|
|
65
|
+
{ pkg: '@fluentui/react', name: 'Fluent UI' },
|
|
66
|
+
{ pkg: 'react-bootstrap', name: 'React Bootstrap' },
|
|
67
|
+
{ pkg: 'grommet', name: 'Grommet' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const STYLING_DEPS = [
|
|
71
|
+
{ pkg: 'tailwindcss', label: 'Tailwind CSS' },
|
|
72
|
+
{ pkg: 'styled-components', label: 'styled-components' },
|
|
73
|
+
{ pkg: '@emotion/react', label: 'Emotion' },
|
|
74
|
+
{ pkg: '@emotion/styled', label: 'Emotion (styled)' },
|
|
75
|
+
{ pkg: 'sass', label: 'Sass' },
|
|
76
|
+
{ pkg: 'less', label: 'Less' },
|
|
77
|
+
{ pkg: '@vanilla-extract/css', label: 'vanilla-extract' },
|
|
78
|
+
{ pkg: '@stitches/react', label: 'Stitches' },
|
|
79
|
+
{ pkg: 'class-variance-authority', label: 'CVA' },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Framework + design-system classification (1.0 logic, widened for monorepos). */
|
|
83
|
+
export function profileRepo(root, files) {
|
|
84
|
+
const pkg = readJSON(join(root, 'package.json')) || {};
|
|
85
|
+
// Monorepos keep real deps in workspace sub-packages — merge every
|
|
86
|
+
// package.json the walk saw (root values win on conflict).
|
|
87
|
+
const pkgFiles = files.other.filter((f) => f.endsWith('package.json'));
|
|
88
|
+
let deps = {};
|
|
89
|
+
for (const f of pkgFiles) {
|
|
90
|
+
const sub = readJSON(join(root, f));
|
|
91
|
+
if (sub) deps = { ...deps, ...sub.dependencies, ...sub.devDependencies };
|
|
92
|
+
}
|
|
93
|
+
deps = { ...deps, ...pkg.dependencies, ...pkg.devDependencies };
|
|
94
|
+
const monorepo = pkgFiles.some((f) => f !== 'package.json');
|
|
95
|
+
|
|
96
|
+
const framework =
|
|
97
|
+
deps.next ? 'next'
|
|
98
|
+
: deps['@remix-run/react'] || deps['@react-router/dev'] ? 'remix'
|
|
99
|
+
: deps.vite && deps.react ? 'vite-react'
|
|
100
|
+
: deps.react ? 'react'
|
|
101
|
+
: 'unknown';
|
|
102
|
+
|
|
103
|
+
const componentsJson = readJSON(join(root, 'components.json'));
|
|
104
|
+
const uiDir = ['src/components/ui', 'components/ui', 'app/components/ui'].find((d) => existsSync(join(root, d)));
|
|
105
|
+
const isShadcn = Boolean(componentsJson) || Boolean(uiDir);
|
|
106
|
+
|
|
107
|
+
const knownLib = KNOWN_LIBRARIES.find((d) => deps[d.pkg]);
|
|
108
|
+
const homegrown = files.code.filter((f) => /(^|\/)components\//.test(f) && !/\/components\/ui\//.test(f) && /\.(tsx|jsx)$/.test(f));
|
|
109
|
+
|
|
110
|
+
let designSystem;
|
|
111
|
+
if (isShadcn) designSystem = { kind: 'shadcn', name: 'shadcn/ui', confidence: 'high' };
|
|
112
|
+
else if (knownLib) designSystem = { kind: 'library', name: knownLib.name, pkg: knownLib.pkg, confidence: 'high' };
|
|
113
|
+
else if (homegrown.length >= 3) designSystem = { kind: 'custom', name: 'custom (unrecognized)', confidence: 'low' };
|
|
114
|
+
else designSystem = { kind: 'none', confidence: 'high' };
|
|
115
|
+
|
|
116
|
+
// Import alias from tsconfig paths or components.json
|
|
117
|
+
let alias = null;
|
|
118
|
+
const tsconfig = readJSON(join(root, 'tsconfig.json'));
|
|
119
|
+
const paths = tsconfig?.compilerOptions?.paths;
|
|
120
|
+
if (paths) {
|
|
121
|
+
const key = Object.keys(paths).find((k) => k.endsWith('/*'));
|
|
122
|
+
if (key) alias = key.replace('/*', '');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const styling = STYLING_DEPS.filter((s) => deps[s.pkg]).map((s) => s.label);
|
|
126
|
+
|
|
127
|
+
// The git remote is a far better identity than package.json's name field
|
|
128
|
+
// ("chatbot" vs "vercel/ai-chatbot"). Parsed from .git/config, no git exec.
|
|
129
|
+
let remoteSlug = null;
|
|
130
|
+
try {
|
|
131
|
+
const gitConfig = readFileSync(join(root, '.git/config'), 'utf8');
|
|
132
|
+
const m = gitConfig.match(/url\s*=\s*(?:https?:\/\/|git@)([^\/:]+)[\/:]([^\s\/]+\/[^\s\/]+?)(?:\.git)?\s*$/m);
|
|
133
|
+
if (m && /github|gitlab|bitbucket|codeberg/.test(m[1])) remoteSlug = m[2];
|
|
134
|
+
} catch { /* not a clone, or no remote: fall back to pkg name */ }
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
name: remoteSlug || pkg.name || null,
|
|
138
|
+
pkgName: pkg.name || null,
|
|
139
|
+
monorepo,
|
|
140
|
+
framework,
|
|
141
|
+
typescript: Boolean(deps.typescript || existsSync(join(root, 'tsconfig.json'))),
|
|
142
|
+
designSystem,
|
|
143
|
+
stylingDeps: styling,
|
|
144
|
+
importAlias: componentsJson?.aliases?.components?.split('/')[0] || alias || null,
|
|
145
|
+
uiDir: uiDir || null,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize font-family declarations to real typeface names. "16 font
|
|
3
|
+
* families" overstates when var(--font-cal), "Cal Sans" and inherit all
|
|
4
|
+
* reference the same face — the honest number is distinct typefaces.
|
|
5
|
+
*/
|
|
6
|
+
export const GENERIC_FONTS = new Set(['sans-serif', 'serif', 'monospace', 'system-ui', 'ui-sans-serif',
|
|
7
|
+
'ui-serif', 'ui-monospace', 'inherit', 'initial', 'unset', 'cursive', 'fantasy', '-apple-system']);
|
|
8
|
+
|
|
9
|
+
export function typefaceOf(decl) {
|
|
10
|
+
for (let part of decl.split(',')) {
|
|
11
|
+
part = part.trim().replace(/^["']|["']$/g, '');
|
|
12
|
+
if (!part || part.startsWith('var(') || GENERIC_FONTS.has(part.toLowerCase())) continue;
|
|
13
|
+
return part;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const distinctTypefaces = (fontFamilies) =>
|
|
19
|
+
[...new Set(fontFamilies.map((f) => typefaceOf(f.value)).filter(Boolean))];
|