designlang 4.0.1 → 6.0.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 +66 -5
- package/bin/design-extract.js +269 -70
- package/package.json +9 -4
- package/src/apply.js +65 -0
- package/src/config.js +36 -0
- package/src/crawler.js +247 -82
- package/src/darkdiff.js +65 -0
- package/src/extractors/animations.js +76 -8
- package/src/extractors/borders.js +40 -5
- package/src/extractors/components.js +100 -1
- package/src/extractors/fonts.js +82 -0
- package/src/extractors/gradients.js +100 -0
- package/src/extractors/icons.js +80 -0
- package/src/extractors/images.js +76 -0
- package/src/extractors/shadows.js +60 -17
- package/src/extractors/spacing.js +31 -2
- package/src/extractors/variables.js +20 -1
- package/src/extractors/zindex.js +65 -0
- package/src/formatters/figma.js +66 -47
- package/src/formatters/markdown.js +98 -0
- package/src/formatters/preview.js +65 -22
- package/src/formatters/svelte-theme.js +40 -0
- package/src/formatters/tailwind.js +57 -4
- package/src/formatters/theme.js +134 -0
- package/src/formatters/vue-theme.js +44 -0
- package/src/formatters/wordpress.js +84 -0
- package/src/history.js +8 -1
- package/src/index.js +54 -16
- package/src/utils.js +68 -0
- package/tests/cli.test.js +34 -0
- package/tests/extractors.test.js +661 -0
- package/tests/formatters.test.js +477 -0
- package/tests/utils.test.js +413 -0
- package/website/app/api/extract/route.js +85 -0
- package/website/app/components/Extractor.js +184 -0
- package/website/app/globals.css +291 -0
- package/website/app/page.js +13 -0
- package/website/next.config.mjs +10 -1
- package/website/package-lock.json +356 -0
- package/website/package.json +4 -1
|
@@ -1,14 +1,46 @@
|
|
|
1
1
|
import { parseCSSValue, clusterValues } from '../utils.js';
|
|
2
2
|
|
|
3
|
+
function parseBorderRadius(raw) {
|
|
4
|
+
if (!raw || raw === '0px') return [];
|
|
5
|
+
// Handle slash-separated (x/y) syntax — take the x part
|
|
6
|
+
const parts = raw.split('/')[0].trim().split(/\s+/);
|
|
7
|
+
const values = [];
|
|
8
|
+
for (const p of parts) {
|
|
9
|
+
const v = parseCSSValue(p);
|
|
10
|
+
if (v && v.value > 0) values.push(Math.round(v.value));
|
|
11
|
+
}
|
|
12
|
+
return values;
|
|
13
|
+
}
|
|
14
|
+
|
|
3
15
|
export function extractBorders(computedStyles) {
|
|
4
16
|
const radiiSet = new Map(); // value -> count
|
|
17
|
+
const widthSet = new Set();
|
|
18
|
+
const styleSet = new Set();
|
|
5
19
|
|
|
6
20
|
for (const el of computedStyles) {
|
|
7
21
|
if (el.borderRadius && el.borderRadius !== '0px') {
|
|
8
|
-
const
|
|
9
|
-
if (
|
|
10
|
-
|
|
11
|
-
|
|
22
|
+
const values = parseBorderRadius(el.borderRadius);
|
|
23
|
+
if (values.length > 0) {
|
|
24
|
+
// Use the max value from the shorthand as the representative
|
|
25
|
+
const representative = Math.max(...values);
|
|
26
|
+
radiiSet.set(representative, (radiiSet.get(representative) || 0) + 1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Collect border widths
|
|
31
|
+
if (el.borderWidth) {
|
|
32
|
+
const parts = el.borderWidth.split(/\s+/);
|
|
33
|
+
for (const p of parts) {
|
|
34
|
+
const v = parseCSSValue(p);
|
|
35
|
+
if (v && v.value > 0) widthSet.add(Math.round(v.value));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Collect border styles
|
|
40
|
+
if (el.borderStyle) {
|
|
41
|
+
const parts = el.borderStyle.split(/\s+/);
|
|
42
|
+
for (const p of parts) {
|
|
43
|
+
if (p && p !== 'none' && p !== 'initial') styleSet.add(p);
|
|
12
44
|
}
|
|
13
45
|
}
|
|
14
46
|
}
|
|
@@ -27,5 +59,8 @@ export function extractBorders(computedStyles) {
|
|
|
27
59
|
return { value: v, label, count: radiiSet.get(v) || 0 };
|
|
28
60
|
});
|
|
29
61
|
|
|
30
|
-
|
|
62
|
+
const widths = [...widthSet].sort((a, b) => a - b);
|
|
63
|
+
const styles = [...styleSet].sort();
|
|
64
|
+
|
|
65
|
+
return { radii, widths, styles };
|
|
31
66
|
}
|
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
export function extractComponents(computedStyles) {
|
|
2
2
|
const components = {};
|
|
3
3
|
|
|
4
|
-
// Buttons
|
|
4
|
+
// Buttons — with variant detection
|
|
5
5
|
const buttons = computedStyles.filter(el =>
|
|
6
6
|
el.tag === 'button' || el.role === 'button' ||
|
|
7
7
|
(el.tag === 'a' && /btn|button/i.test(el.classList))
|
|
8
8
|
);
|
|
9
9
|
if (buttons.length > 0) {
|
|
10
|
+
// Group by background color to detect variants
|
|
11
|
+
const bgGroups = new Map();
|
|
12
|
+
for (const btn of buttons) {
|
|
13
|
+
const bg = btn.backgroundColor || 'transparent';
|
|
14
|
+
if (!bgGroups.has(bg)) bgGroups.set(bg, []);
|
|
15
|
+
bgGroups.get(bg).push(btn);
|
|
16
|
+
}
|
|
17
|
+
const variants = [...bgGroups.entries()]
|
|
18
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
19
|
+
.map(([bg, group], i) => {
|
|
20
|
+
let variant = 'default';
|
|
21
|
+
if (bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') variant = 'ghost';
|
|
22
|
+
else if (i === 0) variant = 'primary';
|
|
23
|
+
else if (i === 1) variant = 'secondary';
|
|
24
|
+
else variant = `variant-${i + 1}`;
|
|
25
|
+
return { variant, backgroundColor: bg, count: group.length, style: mostCommonStyle(group, ['color', 'fontSize', 'fontWeight', 'paddingTop', 'paddingRight', 'borderRadius']) };
|
|
26
|
+
});
|
|
10
27
|
components.buttons = {
|
|
11
28
|
count: buttons.length,
|
|
12
29
|
baseStyle: mostCommonStyle(buttons, ['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop', 'paddingRight', 'borderRadius']),
|
|
30
|
+
variants,
|
|
13
31
|
};
|
|
14
32
|
}
|
|
15
33
|
|
|
@@ -131,9 +149,90 @@ export function extractComponents(computedStyles) {
|
|
|
131
149
|
};
|
|
132
150
|
}
|
|
133
151
|
|
|
152
|
+
// Tabs
|
|
153
|
+
const tabs = computedStyles.filter(el =>
|
|
154
|
+
el.role === 'tab' || /\btab\b/i.test(el.classList)
|
|
155
|
+
);
|
|
156
|
+
if (tabs.length > 0) {
|
|
157
|
+
components.tabs = {
|
|
158
|
+
count: tabs.length,
|
|
159
|
+
baseStyle: mostCommonStyle(tabs, ['backgroundColor', 'color', 'fontSize', 'fontWeight', 'paddingTop', 'paddingRight', 'borderColor', 'borderRadius']),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Accordions
|
|
164
|
+
const accordions = computedStyles.filter(el =>
|
|
165
|
+
/accordion/i.test(el.classList) ||
|
|
166
|
+
(el.tag === 'summary') ||
|
|
167
|
+
(el.tag === 'details')
|
|
168
|
+
);
|
|
169
|
+
if (accordions.length > 0) {
|
|
170
|
+
components.accordions = {
|
|
171
|
+
count: accordions.length,
|
|
172
|
+
baseStyle: mostCommonStyle(accordions, ['backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingRight', 'borderColor']),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Tooltips
|
|
177
|
+
const tooltips = computedStyles.filter(el =>
|
|
178
|
+
el.role === 'tooltip' || /tooltip/i.test(el.classList)
|
|
179
|
+
);
|
|
180
|
+
if (tooltips.length > 0) {
|
|
181
|
+
components.tooltips = {
|
|
182
|
+
count: tooltips.length,
|
|
183
|
+
baseStyle: mostCommonStyle(tooltips, ['backgroundColor', 'color', 'fontSize', 'borderRadius', 'paddingTop', 'paddingRight', 'boxShadow']),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Progress bars
|
|
188
|
+
const progressBars = computedStyles.filter(el =>
|
|
189
|
+
el.tag === 'progress' || el.role === 'progressbar' || /progress/i.test(el.classList)
|
|
190
|
+
);
|
|
191
|
+
if (progressBars.length > 0) {
|
|
192
|
+
components.progressBars = {
|
|
193
|
+
count: progressBars.length,
|
|
194
|
+
baseStyle: mostCommonStyle(progressBars, ['backgroundColor', 'color', 'borderRadius', 'fontSize']),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Switches / Toggles
|
|
199
|
+
const switches = computedStyles.filter(el =>
|
|
200
|
+
el.role === 'switch' ||
|
|
201
|
+
/switch|toggle/i.test(el.classList)
|
|
202
|
+
);
|
|
203
|
+
if (switches.length > 0) {
|
|
204
|
+
components.switches = {
|
|
205
|
+
count: switches.length,
|
|
206
|
+
baseStyle: mostCommonStyle(switches, ['backgroundColor', 'borderRadius', 'borderColor']),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Generate CSS snippets for each component
|
|
211
|
+
for (const [type, data] of Object.entries(components)) {
|
|
212
|
+
if (data.baseStyle) {
|
|
213
|
+
const style = type === 'tables' ? { ...data.baseStyle } : data.baseStyle;
|
|
214
|
+
delete style.cellStyle;
|
|
215
|
+
data.css = styleToCss(`.${type.replace(/s$/, '')}`, style);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
134
219
|
return components;
|
|
135
220
|
}
|
|
136
221
|
|
|
222
|
+
function styleToCss(selector, style) {
|
|
223
|
+
const propMap = {
|
|
224
|
+
backgroundColor: 'background-color', color: 'color', fontSize: 'font-size',
|
|
225
|
+
fontWeight: 'font-weight', paddingTop: 'padding-top', paddingRight: 'padding-right',
|
|
226
|
+
paddingBottom: 'padding-bottom', paddingLeft: 'padding-left',
|
|
227
|
+
borderRadius: 'border-radius', boxShadow: 'box-shadow', borderColor: 'border-color',
|
|
228
|
+
maxWidth: 'max-width', position: 'position',
|
|
229
|
+
};
|
|
230
|
+
const lines = Object.entries(style)
|
|
231
|
+
.filter(([, v]) => v)
|
|
232
|
+
.map(([k, v]) => ` ${propMap[k] || k}: ${v};`);
|
|
233
|
+
return `${selector} {\n${lines.join('\n')}\n}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
137
236
|
function mostCommonStyle(elements, properties) {
|
|
138
237
|
const style = {};
|
|
139
238
|
for (const prop of properties) {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export function extractFonts({ fontFaces = [], googleFontsLinks = [], documentFonts = [] }) {
|
|
2
|
+
const fontMap = new Map();
|
|
3
|
+
|
|
4
|
+
// Parse Google Fonts URLs into a lookup: family -> weights
|
|
5
|
+
const googleFamilies = new Map();
|
|
6
|
+
for (const url of googleFontsLinks) {
|
|
7
|
+
const params = new URL(url).searchParams;
|
|
8
|
+
for (const val of (params.getAll('family'))) {
|
|
9
|
+
const [name, spec] = val.split(':');
|
|
10
|
+
const family = name.replace(/\+/g, ' ');
|
|
11
|
+
const weights = spec?.match(/\d{3}/g) || ['400'];
|
|
12
|
+
googleFamilies.set(family, [...new Set([...(googleFamilies.get(family) || []), ...weights])]);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const getSource = (family, src) => {
|
|
17
|
+
if (googleFamilies.has(family)) return 'google-fonts';
|
|
18
|
+
if (src && /url\(/.test(src)) return /fonts\.(googleapis|gstatic|cdnfonts|bunny)/.test(src) ? 'cdn' : 'self-hosted';
|
|
19
|
+
return 'system';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const getOrCreate = (family) => {
|
|
23
|
+
if (!fontMap.has(family)) {
|
|
24
|
+
fontMap.set(family, { family, source: 'system', weights: new Set(), styles: new Set(), urls: [], fontFaceCSS: '' });
|
|
25
|
+
}
|
|
26
|
+
return fontMap.get(family);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Process @font-face rules
|
|
30
|
+
for (const ff of fontFaces) {
|
|
31
|
+
const family = ff.family?.replace(/["']/g, '');
|
|
32
|
+
if (!family) continue;
|
|
33
|
+
const entry = getOrCreate(family);
|
|
34
|
+
entry.source = getSource(family, ff.src);
|
|
35
|
+
if (ff.weight) entry.weights.add(String(ff.weight));
|
|
36
|
+
if (ff.style) entry.styles.add(ff.style);
|
|
37
|
+
if (ff.src) entry.urls.push(ff.src);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Process document.fonts entries
|
|
41
|
+
for (const df of documentFonts) {
|
|
42
|
+
const family = df.family?.replace(/["']/g, '');
|
|
43
|
+
if (!family) continue;
|
|
44
|
+
const entry = getOrCreate(family);
|
|
45
|
+
if (entry.source === 'system') entry.source = getSource(family, '');
|
|
46
|
+
if (df.weight) entry.weights.add(String(df.weight));
|
|
47
|
+
if (df.style) entry.styles.add(df.style);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Add Google Fonts families not yet seen
|
|
51
|
+
for (const [family, weights] of googleFamilies) {
|
|
52
|
+
const entry = getOrCreate(family);
|
|
53
|
+
entry.source = 'google-fonts';
|
|
54
|
+
for (const w of weights) entry.weights.add(w);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build output
|
|
58
|
+
const fonts = [];
|
|
59
|
+
const systemFonts = [];
|
|
60
|
+
|
|
61
|
+
for (const entry of fontMap.values()) {
|
|
62
|
+
const weights = [...entry.weights].sort();
|
|
63
|
+
const styles = [...entry.styles];
|
|
64
|
+
if (!weights.length) weights.push('400');
|
|
65
|
+
if (!styles.length) styles.push('normal');
|
|
66
|
+
|
|
67
|
+
const fontFaceCSS = entry.source === 'self-hosted'
|
|
68
|
+
? entry.urls.map((src, i) =>
|
|
69
|
+
`@font-face {\n font-family: '${entry.family}';\n font-weight: ${weights[i] || weights[0]};\n font-style: ${styles[i] || styles[0]};\n src: ${src};\n}`
|
|
70
|
+
).join('\n\n')
|
|
71
|
+
: '';
|
|
72
|
+
|
|
73
|
+
if (entry.source === 'system') { systemFonts.push(entry.family); continue; }
|
|
74
|
+
fonts.push({ family: entry.family, source: entry.source, weights, styles, urls: entry.urls, fontFaceCSS });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const googleFontsUrl = googleFontsLinks[0] || (googleFamilies.size
|
|
78
|
+
? `https://fonts.googleapis.com/css2?${[...googleFamilies].map(([f, w]) => `family=${f.replace(/ /g, '+')}:wght@${w.join(';')}`).join('&')}&display=swap`
|
|
79
|
+
: '');
|
|
80
|
+
|
|
81
|
+
return { fonts, googleFontsUrl, systemFonts };
|
|
82
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export function extractGradients(styles) {
|
|
2
|
+
const seen = new Set();
|
|
3
|
+
const gradients = [];
|
|
4
|
+
|
|
5
|
+
for (const el of styles) {
|
|
6
|
+
const bg = el.backgroundImage;
|
|
7
|
+
if (!bg || !bg.includes('gradient')) continue;
|
|
8
|
+
const rawGradients = splitGradients(bg);
|
|
9
|
+
for (let raw of rawGradients) {
|
|
10
|
+
// Normalize vendor prefixes
|
|
11
|
+
raw = raw.replace(/-(webkit|moz)-/g, '');
|
|
12
|
+
if (seen.has(raw)) continue;
|
|
13
|
+
seen.add(raw);
|
|
14
|
+
gradients.push(parseGradient(raw));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { gradients, count: gradients.length };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function splitGradients(value) {
|
|
22
|
+
// Split comma-separated gradient layers, respecting nested parens
|
|
23
|
+
const results = [];
|
|
24
|
+
let depth = 0, start = 0;
|
|
25
|
+
for (let i = 0; i < value.length; i++) {
|
|
26
|
+
if (value[i] === '(') depth++;
|
|
27
|
+
else if (value[i] === ')') depth--;
|
|
28
|
+
else if (value[i] === ',' && depth === 0) {
|
|
29
|
+
const chunk = value.slice(start, i).trim();
|
|
30
|
+
if (chunk.includes('gradient')) results.push(chunk);
|
|
31
|
+
start = i + 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const last = value.slice(start).trim();
|
|
35
|
+
if (last.includes('gradient')) results.push(last);
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseGradient(raw) {
|
|
40
|
+
const typeMatch = raw.match(/^(repeating-)?(linear|radial|conic)-gradient/);
|
|
41
|
+
const type = typeMatch ? (typeMatch[1] || '') + typeMatch[2] : 'linear';
|
|
42
|
+
|
|
43
|
+
// Extract content inside outermost parens
|
|
44
|
+
const inner = raw.slice(raw.indexOf('(') + 1, raw.lastIndexOf(')'));
|
|
45
|
+
|
|
46
|
+
// Split top-level arguments by comma (respecting nested parens)
|
|
47
|
+
const args = [];
|
|
48
|
+
let depth = 0, start = 0;
|
|
49
|
+
for (let i = 0; i < inner.length; i++) {
|
|
50
|
+
if (inner[i] === '(') depth++;
|
|
51
|
+
else if (inner[i] === ')') depth--;
|
|
52
|
+
else if (inner[i] === ',' && depth === 0) {
|
|
53
|
+
args.push(inner.slice(start, i).trim());
|
|
54
|
+
start = i + 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
args.push(inner.slice(start).trim());
|
|
58
|
+
|
|
59
|
+
// First arg is direction/angle if it doesn't look like a color
|
|
60
|
+
let direction = null;
|
|
61
|
+
let stopArgs = args;
|
|
62
|
+
const first = args[0] || '';
|
|
63
|
+
if (/^(to |from |\d+(\.\d+)?(deg|grad|rad|turn)|at )/.test(first) || /^(circle|ellipse)/.test(first)) {
|
|
64
|
+
direction = first;
|
|
65
|
+
stopArgs = args.slice(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const stops = stopArgs.map(s => {
|
|
69
|
+
// Match position only if it's outside parentheses (not inside rgb/hsl)
|
|
70
|
+
// Position is a percentage or length at the end, after the color value
|
|
71
|
+
const trimmed = s.trim();
|
|
72
|
+
// Check if trailing value is outside any parens
|
|
73
|
+
const lastParen = trimmed.lastIndexOf(')');
|
|
74
|
+
const trailing = lastParen >= 0 ? trimmed.slice(lastParen + 1).trim() : trimmed;
|
|
75
|
+
const posMatch = trailing.match(/([\d.]+(%|px|em|rem|vw|vh)?)$/);
|
|
76
|
+
let position = null;
|
|
77
|
+
let color = trimmed;
|
|
78
|
+
if (posMatch && posMatch[0] !== trimmed) {
|
|
79
|
+
// Position found after the color function closes
|
|
80
|
+
position = posMatch[0];
|
|
81
|
+
color = trimmed.slice(0, trimmed.length - trailing.length + trailing.indexOf(posMatch[0])).trim();
|
|
82
|
+
} else if (lastParen < 0) {
|
|
83
|
+
// No parens — simple color like "red 50%"
|
|
84
|
+
const simplePos = trimmed.match(/\s+([\d.]+(%|px|em|rem|vw|vh)?)$/);
|
|
85
|
+
if (simplePos) {
|
|
86
|
+
position = simplePos[1];
|
|
87
|
+
color = trimmed.slice(0, simplePos.index).trim();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { color, position };
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const colorCount = stops.length;
|
|
94
|
+
let classification = 'subtle';
|
|
95
|
+
if (colorCount > 4) classification = 'complex';
|
|
96
|
+
else if (colorCount > 2) classification = 'bold';
|
|
97
|
+
else if (colorCount === 2) classification = 'brand';
|
|
98
|
+
|
|
99
|
+
return { raw, type, direction, stops, classification };
|
|
100
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export function extractIcons(iconData) {
|
|
2
|
+
if (!iconData || !iconData.length) {
|
|
3
|
+
return { icons: [], sizeDistribution: { xs: 0, sm: 0, md: 0, lg: 0, xl: 0 }, dominantStyle: 'none', colorPalette: [], count: 0 };
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function classifySize(w, h) {
|
|
7
|
+
const s = Math.max(w || 0, h || 0);
|
|
8
|
+
if (s < 16) return 'xs';
|
|
9
|
+
if (s < 20) return 'sm';
|
|
10
|
+
if (s < 28) return 'md';
|
|
11
|
+
if (s < 40) return 'lg';
|
|
12
|
+
return 'xl';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function cleanSvg(svg) {
|
|
16
|
+
return svg.replace(/\s*(data-[a-z-]*|class|id)="[^"]*"/g, '').replace(/\s+/g, ' ').trim();
|
|
17
|
+
}
|
|
18
|
+
function simplify(svg) { return cleanSvg(svg); }
|
|
19
|
+
|
|
20
|
+
function detectStyle(svg) {
|
|
21
|
+
const hasStroke = /stroke="(?!none)[^"]+"|stroke-width="[^0"][^"]*"/.test(svg);
|
|
22
|
+
const hasFill = /fill="(?!none|transparent)[^"]+"|<(rect|circle|path)[^>]*(?!fill="none")/.test(svg);
|
|
23
|
+
const fillNone = /fill="none"/.test(svg);
|
|
24
|
+
if (hasStroke && (fillNone || !hasFill)) return 'outlined';
|
|
25
|
+
if (hasStroke && hasFill) return 'duo-tone';
|
|
26
|
+
return 'filled';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractColors(svg) {
|
|
30
|
+
const colors = new Set();
|
|
31
|
+
for (const m of svg.matchAll(/(?:fill|stroke)="([^"]+)"/g)) {
|
|
32
|
+
if (m[1] !== 'none' && m[1] !== 'transparent') colors.add(m[1]);
|
|
33
|
+
}
|
|
34
|
+
return [...colors];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Deduplicate
|
|
38
|
+
const seen = new Map();
|
|
39
|
+
for (const icon of iconData) {
|
|
40
|
+
const key = simplify(icon.svg);
|
|
41
|
+
if (!seen.has(key)) seen.set(key, icon);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sizeDistribution = { xs: 0, sm: 0, md: 0, lg: 0, xl: 0 };
|
|
45
|
+
const styleCounts = { outlined: 0, filled: 0, 'duo-tone': 0 };
|
|
46
|
+
const allColors = new Set();
|
|
47
|
+
|
|
48
|
+
const icons = [];
|
|
49
|
+
for (const icon of seen.values()) {
|
|
50
|
+
const cleaned = cleanSvg(icon.svg);
|
|
51
|
+
const sc = classifySize(icon.width, icon.height);
|
|
52
|
+
const style = detectStyle(icon.svg);
|
|
53
|
+
const colors = extractColors(icon.svg);
|
|
54
|
+
if (icon.fill && icon.fill !== 'none') colors.push(icon.fill);
|
|
55
|
+
if (icon.stroke && icon.stroke !== 'none') colors.push(icon.stroke);
|
|
56
|
+
const uniqueColors = [...new Set(colors)];
|
|
57
|
+
|
|
58
|
+
sizeDistribution[sc]++;
|
|
59
|
+
styleCounts[style]++;
|
|
60
|
+
uniqueColors.forEach(c => allColors.add(c));
|
|
61
|
+
|
|
62
|
+
icons.push({
|
|
63
|
+
svg: cleaned,
|
|
64
|
+
size: { width: icon.width, height: icon.height },
|
|
65
|
+
sizeClass: sc,
|
|
66
|
+
style,
|
|
67
|
+
colors: uniqueColors,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const dominantStyle = Object.entries(styleCounts).sort((a, b) => b[1] - a[1])[0][0];
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
icons,
|
|
75
|
+
sizeDistribution,
|
|
76
|
+
dominantStyle,
|
|
77
|
+
colorPalette: [...allColors],
|
|
78
|
+
count: icons.length,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export function extractImageStyles(imageData) {
|
|
2
|
+
const ratioCount = new Map();
|
|
3
|
+
const shapeCount = new Map();
|
|
4
|
+
const filterCount = new Map();
|
|
5
|
+
const fitCount = new Map();
|
|
6
|
+
const patternCount = new Map();
|
|
7
|
+
|
|
8
|
+
const knownRatios = [
|
|
9
|
+
[1, 1, '1:1'], [4, 3, '4:3'], [3, 4, '3:4'], [16, 9, '16:9'], [9, 16, '9:16'],
|
|
10
|
+
[3, 2, '3:2'], [2, 3, '2:3'], [21, 9, '21:9'],
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function closestRatio(w, h) {
|
|
14
|
+
if (!w || !h) return null;
|
|
15
|
+
const r = w / h;
|
|
16
|
+
let best = null, bestDiff = 0.15;
|
|
17
|
+
for (const [rw, rh, label] of knownRatios) {
|
|
18
|
+
const diff = Math.abs(r - rw / rh);
|
|
19
|
+
if (diff < bestDiff) { best = label; bestDiff = diff; }
|
|
20
|
+
}
|
|
21
|
+
return best || `${Math.round(r * 100) / 100}:1`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function classifyShape(borderRadius) {
|
|
25
|
+
const br = parseFloat(borderRadius) || 0;
|
|
26
|
+
if (br >= 50) return 'circular';
|
|
27
|
+
if (br >= 20) return 'pill';
|
|
28
|
+
if (br > 0) return 'rounded';
|
|
29
|
+
return 'square';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function classifyPattern(img, shape) {
|
|
33
|
+
const w = img.width || 0, h = img.height || 0;
|
|
34
|
+
const area = w * h;
|
|
35
|
+
if (shape === 'circular' && area <= 22500) return 'avatar';
|
|
36
|
+
if (w >= 600 && h >= 200 && img.objectFit === 'cover') return 'hero';
|
|
37
|
+
if (area <= 40000 && (shape === 'rounded' || shape === 'square')) return 'thumbnail';
|
|
38
|
+
if (w >= 400 && h >= 400 && shape === 'square') return 'gallery';
|
|
39
|
+
return 'general';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function incMap(map, key, extra) {
|
|
43
|
+
if (!map.has(key)) map.set(key, { count: 0, ...extra });
|
|
44
|
+
map.get(key).count++;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const img of imageData) {
|
|
48
|
+
const ratio = closestRatio(img.width, img.height);
|
|
49
|
+
if (ratio) incMap(ratioCount, ratio);
|
|
50
|
+
|
|
51
|
+
const shape = classifyShape(img.borderRadius);
|
|
52
|
+
incMap(shapeCount, shape, { borderRadius: img.borderRadius || '0' });
|
|
53
|
+
|
|
54
|
+
if (img.filter && img.filter !== 'none') {
|
|
55
|
+
for (const f of img.filter.match(/[a-z-]+\(/g) || [img.filter]) {
|
|
56
|
+
incMap(filterCount, f.replace('(', ''));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (img.objectFit && img.objectFit !== 'initial') incMap(fitCount, img.objectFit);
|
|
61
|
+
|
|
62
|
+
const pattern = classifyPattern(img, shape);
|
|
63
|
+
incMap(patternCount, pattern, { styles: { objectFit: img.objectFit, borderRadius: img.borderRadius, shape } });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const toArray = (map, keyName) =>
|
|
67
|
+
Array.from(map.entries()).map(([k, v]) => ({ [keyName]: k, ...v })).sort((a, b) => b.count - a.count);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
patterns: toArray(patternCount, 'name'),
|
|
71
|
+
aspectRatios: toArray(ratioCount, 'ratio'),
|
|
72
|
+
shapes: toArray(shapeCount, 'shape'),
|
|
73
|
+
filters: toArray(filterCount, 'filter'),
|
|
74
|
+
objectFitUsage: toArray(fitCount, 'value'),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -1,27 +1,70 @@
|
|
|
1
1
|
export function extractShadows(computedStyles) {
|
|
2
2
|
const shadowSet = new Set();
|
|
3
|
+
const textShadowSet = new Set();
|
|
3
4
|
|
|
4
5
|
for (const el of computedStyles) {
|
|
5
6
|
if (el.boxShadow && el.boxShadow !== 'none') {
|
|
6
7
|
shadowSet.add(el.boxShadow);
|
|
7
8
|
}
|
|
9
|
+
if (el.textShadow && el.textShadow !== 'none') {
|
|
10
|
+
textShadowSet.add(el.textShadow);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const values = [...shadowSet].map(raw => parseShadow(raw));
|
|
15
|
+
values.sort((a, b) => a.visualWeight - b.visualWeight);
|
|
16
|
+
|
|
17
|
+
const textShadows = [...textShadowSet].map(raw => parseShadow(raw));
|
|
18
|
+
textShadows.sort((a, b) => a.visualWeight - b.visualWeight);
|
|
19
|
+
|
|
20
|
+
return { values, textShadows };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseShadow(raw) {
|
|
24
|
+
const inset = raw.includes('inset');
|
|
25
|
+
const cleaned = raw.replace(/\binset\b/g, '').trim();
|
|
26
|
+
|
|
27
|
+
// Extract color (rgb/rgba/hsl/hsla or named/hex) — find the non-numeric portion
|
|
28
|
+
let color = '';
|
|
29
|
+
let numericPart = cleaned;
|
|
30
|
+
// Match color functions first (they may contain commas/numbers)
|
|
31
|
+
const colorFnMatch = cleaned.match(/(rgba?\([^)]+\)|hsla?\([^)]+\))/);
|
|
32
|
+
if (colorFnMatch) {
|
|
33
|
+
color = colorFnMatch[1];
|
|
34
|
+
numericPart = cleaned.replace(color, '').trim();
|
|
35
|
+
} else {
|
|
36
|
+
// Try hex or named color at start or end
|
|
37
|
+
const hexMatch = cleaned.match(/(#[0-9a-fA-F]{3,8})/);
|
|
38
|
+
if (hexMatch) {
|
|
39
|
+
color = hexMatch[1];
|
|
40
|
+
numericPart = cleaned.replace(color, '').trim();
|
|
41
|
+
} else {
|
|
42
|
+
// Named color — typically last or first token that isn't a length
|
|
43
|
+
const tokens = cleaned.split(/\s+/);
|
|
44
|
+
const colorToken = tokens.find(t => !/^-?[\d.]+px$/.test(t) && !/^[\d.]+$/.test(t));
|
|
45
|
+
if (colorToken) {
|
|
46
|
+
color = colorToken;
|
|
47
|
+
numericPart = cleaned.replace(colorToken, '').trim();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
8
50
|
}
|
|
9
51
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
52
|
+
// Parse numeric values (offset-x, offset-y, blur, spread)
|
|
53
|
+
const nums = numericPart.match(/-?[\d.]+px/g)?.map(n => parseFloat(n)) || [];
|
|
54
|
+
const offsetX = nums[0] || 0;
|
|
55
|
+
const offsetY = nums[1] || 0;
|
|
56
|
+
const blur = nums[2] || 0;
|
|
57
|
+
const spread = nums[3] || 0;
|
|
58
|
+
|
|
59
|
+
// Visual weight = distance + blur
|
|
60
|
+
const visualWeight = Math.sqrt(offsetX * offsetX + offsetY * offsetY) + blur;
|
|
61
|
+
|
|
62
|
+
let label = 'none';
|
|
63
|
+
if (visualWeight > 0 && visualWeight <= 3) label = 'xs';
|
|
64
|
+
else if (visualWeight <= 8) label = 'sm';
|
|
65
|
+
else if (visualWeight <= 16) label = 'md';
|
|
66
|
+
else if (visualWeight <= 32) label = 'lg';
|
|
67
|
+
else if (visualWeight > 32) label = 'xl';
|
|
68
|
+
|
|
69
|
+
return { raw, offsetX, offsetY, blur, spread, color, inset, visualWeight: Math.round(visualWeight * 100) / 100, label };
|
|
27
70
|
}
|
|
@@ -1,4 +1,33 @@
|
|
|
1
|
-
import { parseCSSValue,
|
|
1
|
+
import { parseCSSValue, detectScale } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
function naturalBreakCluster(values) {
|
|
4
|
+
if (values.length <= 1) return values;
|
|
5
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
6
|
+
if (sorted.length <= 2) return sorted;
|
|
7
|
+
|
|
8
|
+
// Compute gaps between consecutive values
|
|
9
|
+
const gaps = [];
|
|
10
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
11
|
+
gaps.push(sorted[i] - sorted[i - 1]);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Find median gap
|
|
15
|
+
const sortedGaps = [...gaps].sort((a, b) => a - b);
|
|
16
|
+
const medianGap = sortedGaps[Math.floor(sortedGaps.length / 2)];
|
|
17
|
+
|
|
18
|
+
// Split into clusters at gaps larger than the median
|
|
19
|
+
const clusters = [[sorted[0]]];
|
|
20
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
21
|
+
if (gaps[i - 1] > medianGap) {
|
|
22
|
+
clusters.push([sorted[i]]);
|
|
23
|
+
} else {
|
|
24
|
+
clusters[clusters.length - 1].push(sorted[i]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Use the first (smallest) value in each cluster as representative
|
|
29
|
+
return clusters.map(c => c[0]);
|
|
30
|
+
}
|
|
2
31
|
|
|
3
32
|
export function extractSpacing(computedStyles) {
|
|
4
33
|
const allValues = new Set();
|
|
@@ -13,7 +42,7 @@ export function extractSpacing(computedStyles) {
|
|
|
13
42
|
}
|
|
14
43
|
|
|
15
44
|
const sorted = [...allValues].sort((a, b) => a - b);
|
|
16
|
-
const clustered =
|
|
45
|
+
const clustered = naturalBreakCluster(sorted);
|
|
17
46
|
const { base, scale } = detectScale(clustered);
|
|
18
47
|
|
|
19
48
|
// Build named tokens
|
|
@@ -18,5 +18,24 @@ export function extractVariables(cssVariables) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Build dependency map: which variables reference other variables
|
|
22
|
+
const dependencies = {};
|
|
23
|
+
for (const [name, value] of Object.entries(cssVariables)) {
|
|
24
|
+
const refs = [...value.matchAll(/var\((--[\w-]+)/g)].map(m => m[1]);
|
|
25
|
+
if (refs.length > 0) {
|
|
26
|
+
dependencies[name] = refs;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Semantic grouping by name patterns
|
|
31
|
+
const semantic = { success: {}, warning: {}, error: {}, info: {} };
|
|
32
|
+
for (const [name, value] of Object.entries(cssVariables)) {
|
|
33
|
+
const lower = name.toLowerCase();
|
|
34
|
+
if (/success|green|valid|positive/.test(lower)) semantic.success[name] = value;
|
|
35
|
+
else if (/warning|warn|yellow|caution|amber/.test(lower)) semantic.warning[name] = value;
|
|
36
|
+
else if (/error|danger|destructive|red|invalid|negative/.test(lower)) semantic.error[name] = value;
|
|
37
|
+
else if (/info|informati|blue|notice/.test(lower)) semantic.info[name] = value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { ...categories, dependencies, semantic };
|
|
22
41
|
}
|