designlang 5.0.0 → 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 +215 -91
- package/package.json +9 -4
- package/src/config.js +36 -0
- package/src/crawler.js +181 -95
- package/src/extractors/animations.js +37 -5
- package/src/extractors/borders.js +40 -5
- package/src/extractors/components.js +77 -1
- package/src/extractors/gradients.js +25 -5
- package/src/extractors/shadows.js +60 -17
- package/src/extractors/spacing.js +31 -2
- package/src/extractors/variables.js +20 -1
- package/src/formatters/figma.js +66 -47
- 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 +46 -20
- 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/src/crawler.js
CHANGED
|
@@ -4,107 +4,121 @@ import { join } from 'path';
|
|
|
4
4
|
|
|
5
5
|
const MAX_ELEMENTS = 5000;
|
|
6
6
|
|
|
7
|
+
async function gotoWithRetry(page, url, opts, retries = 3) {
|
|
8
|
+
for (let i = 0; i < retries; i++) {
|
|
9
|
+
try {
|
|
10
|
+
await page.goto(url, opts);
|
|
11
|
+
return;
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if (i === retries - 1) throw err;
|
|
14
|
+
await page.waitForTimeout(2000 * (i + 1));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
export async function crawlPage(url, options = {}) {
|
|
8
|
-
const { width = 1280, height = 800, wait = 0, dark = false, depth = 0, screenshots = false, outDir = '', executablePath, browserArgs, cookies, headers } = options;
|
|
20
|
+
const { width = 1280, height = 800, wait = 0, dark = false, depth = 0, screenshots = false, outDir = '', executablePath, browserArgs, cookies, headers, ignore } = options;
|
|
9
21
|
|
|
10
22
|
const browser = await chromium.launch({
|
|
11
23
|
headless: true,
|
|
12
24
|
...(executablePath && { executablePath }),
|
|
13
25
|
...(browserArgs && { args: browserArgs }),
|
|
14
26
|
});
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
// Set cookies if provided
|
|
22
|
-
if (cookies && cookies.length > 0) {
|
|
23
|
-
await context.addCookies(cookies.map(c => {
|
|
24
|
-
if (typeof c === 'string') {
|
|
25
|
-
const [name, ...rest] = c.split('=');
|
|
26
|
-
return { name, value: rest.join('='), url };
|
|
27
|
-
}
|
|
28
|
-
return c;
|
|
29
|
-
}));
|
|
30
|
-
}
|
|
31
|
-
const page = await context.newPage();
|
|
27
|
+
try {
|
|
28
|
+
const context = await browser.newContext({
|
|
29
|
+
viewport: { width, height },
|
|
30
|
+
colorScheme: 'light',
|
|
31
|
+
...(headers && { extraHTTPHeaders: headers }),
|
|
32
|
+
});
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
// Set cookies if provided
|
|
35
|
+
if (cookies && cookies.length > 0) {
|
|
36
|
+
await context.addCookies(cookies.map(c => {
|
|
37
|
+
if (typeof c === 'string') {
|
|
38
|
+
const [name, ...rest] = c.split('=');
|
|
39
|
+
return { name, value: rest.join('='), url };
|
|
40
|
+
}
|
|
41
|
+
return c;
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
const page = await context.newPage();
|
|
38
45
|
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
await gotoWithRetry(page, url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
47
|
+
// Wait for network to settle — but don't hang on sites with persistent connections
|
|
48
|
+
await page.waitForLoadState('networkidle').catch(() => {});
|
|
49
|
+
if (wait > 0) await page.waitForTimeout(wait);
|
|
50
|
+
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
41
51
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (screenshots && outDir) {
|
|
45
|
-
componentScreenshots = await captureComponentScreenshots(page, outDir);
|
|
46
|
-
}
|
|
52
|
+
const title = await page.title();
|
|
53
|
+
const lightData = await extractPageData(page, ignore);
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
for (const link of internalLinks) {
|
|
53
|
-
try {
|
|
54
|
-
await page.goto(link, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
55
|
-
await page.waitForLoadState('networkidle').catch(() => {});
|
|
56
|
-
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
57
|
-
const pageData = await extractPageData(page);
|
|
58
|
-
additionalPages.push({ url: link, data: pageData });
|
|
59
|
-
} catch { /* skip failed pages */ }
|
|
55
|
+
// Component screenshots
|
|
56
|
+
let componentScreenshots = {};
|
|
57
|
+
if (screenshots && outDir) {
|
|
58
|
+
componentScreenshots = await captureComponentScreenshots(page, outDir);
|
|
60
59
|
}
|
|
61
|
-
}
|
|
62
60
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
} else {
|
|
78
|
-
await context.close();
|
|
79
|
-
}
|
|
61
|
+
// Multi-page crawl: discover internal links and extract from them
|
|
62
|
+
let additionalPages = [];
|
|
63
|
+
if (depth > 0) {
|
|
64
|
+
const internalLinks = await discoverInternalLinks(page, url, depth);
|
|
65
|
+
for (const link of internalLinks) {
|
|
66
|
+
try {
|
|
67
|
+
await gotoWithRetry(page, link, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
68
|
+
await page.waitForLoadState('networkidle').catch(() => {});
|
|
69
|
+
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
70
|
+
const pageData = await extractPageData(page);
|
|
71
|
+
additionalPages.push({ url: link, data: pageData });
|
|
72
|
+
} catch { /* skip failed pages */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
80
75
|
|
|
81
|
-
|
|
76
|
+
// Dark mode extraction
|
|
77
|
+
let darkData = null;
|
|
78
|
+
if (dark) {
|
|
79
|
+
await context.close();
|
|
80
|
+
const darkContext = await browser.newContext({
|
|
81
|
+
viewport: { width, height },
|
|
82
|
+
colorScheme: 'dark',
|
|
83
|
+
});
|
|
84
|
+
const darkPage = await darkContext.newPage();
|
|
85
|
+
await gotoWithRetry(darkPage, url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
86
|
+
await darkPage.waitForLoadState('networkidle').catch(() => {});
|
|
87
|
+
await darkPage.evaluate(() => document.fonts.ready).catch(() => {});
|
|
88
|
+
darkData = await extractPageData(darkPage);
|
|
89
|
+
await darkContext.close();
|
|
90
|
+
} else {
|
|
91
|
+
await context.close();
|
|
92
|
+
}
|
|
82
93
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
// Merge additional page data into light data
|
|
95
|
+
if (additionalPages.length > 0) {
|
|
96
|
+
lightData.computedStyles = mergeStyles(lightData.computedStyles, additionalPages);
|
|
97
|
+
for (const ap of additionalPages) {
|
|
98
|
+
Object.assign(lightData.cssVariables, ap.data.cssVariables);
|
|
99
|
+
lightData.mediaQueries.push(...ap.data.mediaQueries);
|
|
100
|
+
lightData.keyframes.push(...ap.data.keyframes);
|
|
101
|
+
}
|
|
102
|
+
// Deduplicate media queries and keyframes
|
|
103
|
+
lightData.mediaQueries = [...new Set(lightData.mediaQueries)];
|
|
104
|
+
const seenKf = new Set();
|
|
105
|
+
lightData.keyframes = lightData.keyframes.filter(kf => {
|
|
106
|
+
if (seenKf.has(kf.name)) return false;
|
|
107
|
+
seenKf.add(kf.name);
|
|
108
|
+
return true;
|
|
109
|
+
});
|
|
90
110
|
}
|
|
91
|
-
// Deduplicate media queries and keyframes
|
|
92
|
-
lightData.mediaQueries = [...new Set(lightData.mediaQueries)];
|
|
93
|
-
const seenKf = new Set();
|
|
94
|
-
lightData.keyframes = lightData.keyframes.filter(kf => {
|
|
95
|
-
if (seenKf.has(kf.name)) return false;
|
|
96
|
-
seenKf.add(kf.name);
|
|
97
|
-
return true;
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
111
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
return {
|
|
113
|
+
url, title,
|
|
114
|
+
light: lightData,
|
|
115
|
+
dark: darkData,
|
|
116
|
+
pagesAnalyzed: 1 + additionalPages.length,
|
|
117
|
+
componentScreenshots,
|
|
118
|
+
};
|
|
119
|
+
} finally {
|
|
120
|
+
await browser.close();
|
|
121
|
+
}
|
|
108
122
|
}
|
|
109
123
|
|
|
110
124
|
function mergeStyles(primary, additionalPages) {
|
|
@@ -174,19 +188,39 @@ export async function captureComponentScreenshots(page, outDir) {
|
|
|
174
188
|
return result;
|
|
175
189
|
}
|
|
176
190
|
|
|
177
|
-
async function extractPageData(page) {
|
|
178
|
-
|
|
191
|
+
async function extractPageData(page, ignoreSelectors) {
|
|
192
|
+
const data = await page.evaluate(({ maxElements, ignoreSelectors }) => {
|
|
193
|
+
// Remove ignored elements before extraction
|
|
194
|
+
if (ignoreSelectors && ignoreSelectors.length > 0) {
|
|
195
|
+
for (const sel of ignoreSelectors) {
|
|
196
|
+
try {
|
|
197
|
+
for (const el of document.querySelectorAll(sel)) {
|
|
198
|
+
el.remove();
|
|
199
|
+
}
|
|
200
|
+
} catch { /* invalid selector */ }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
179
204
|
const results = {
|
|
180
205
|
computedStyles: [],
|
|
181
206
|
cssVariables: {},
|
|
182
207
|
mediaQueries: [],
|
|
183
208
|
keyframes: [],
|
|
209
|
+
crossOriginSheets: [],
|
|
184
210
|
};
|
|
185
211
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
212
|
+
// Collect elements including shadow DOM contents
|
|
213
|
+
function collectElements(root, collected) {
|
|
214
|
+
for (const el of root.querySelectorAll('*')) {
|
|
215
|
+
if (collected.length >= maxElements) break;
|
|
216
|
+
collected.push(el);
|
|
217
|
+
if (el.shadowRoot) {
|
|
218
|
+
collectElements(el.shadowRoot, collected);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return collected;
|
|
222
|
+
}
|
|
223
|
+
const elements = collectElements(document, []);
|
|
190
224
|
|
|
191
225
|
for (const el of elements) {
|
|
192
226
|
const cs = getComputedStyle(el);
|
|
@@ -217,7 +251,10 @@ async function extractPageData(page) {
|
|
|
217
251
|
marginLeft: cs.marginLeft,
|
|
218
252
|
gap: cs.gap,
|
|
219
253
|
borderRadius: cs.borderRadius,
|
|
254
|
+
borderWidth: cs.borderWidth,
|
|
255
|
+
borderStyle: cs.borderStyle,
|
|
220
256
|
boxShadow: cs.boxShadow,
|
|
257
|
+
textShadow: cs.textShadow,
|
|
221
258
|
zIndex: cs.zIndex,
|
|
222
259
|
transition: cs.transition,
|
|
223
260
|
animation: cs.animation,
|
|
@@ -248,7 +285,7 @@ async function extractPageData(page) {
|
|
|
248
285
|
}
|
|
249
286
|
}
|
|
250
287
|
}
|
|
251
|
-
} catch {
|
|
288
|
+
} catch { if (sheet.href) results.crossOriginSheets.push(sheet.href); }
|
|
252
289
|
}
|
|
253
290
|
} catch { /* no access */ }
|
|
254
291
|
|
|
@@ -268,7 +305,7 @@ async function extractPageData(page) {
|
|
|
268
305
|
results.mediaQueries.push(rule.conditionText || rule.media.mediaText);
|
|
269
306
|
}
|
|
270
307
|
}
|
|
271
|
-
} catch { /* cross-origin */ }
|
|
308
|
+
} catch { /* cross-origin — already tracked */ }
|
|
272
309
|
}
|
|
273
310
|
} catch { /* no access */ }
|
|
274
311
|
|
|
@@ -285,7 +322,7 @@ async function extractPageData(page) {
|
|
|
285
322
|
results.keyframes.push({ name: rule.name, steps });
|
|
286
323
|
}
|
|
287
324
|
}
|
|
288
|
-
} catch { /* cross-origin */ }
|
|
325
|
+
} catch { /* cross-origin — already tracked */ }
|
|
289
326
|
}
|
|
290
327
|
} catch { /* no access */ }
|
|
291
328
|
|
|
@@ -321,7 +358,7 @@ async function extractPageData(page) {
|
|
|
321
358
|
});
|
|
322
359
|
}
|
|
323
360
|
}
|
|
324
|
-
} catch { /* cross-origin */ }
|
|
361
|
+
} catch { /* cross-origin — already tracked */ }
|
|
325
362
|
}
|
|
326
363
|
} catch {}
|
|
327
364
|
for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com"]')) {
|
|
@@ -353,5 +390,54 @@ async function extractPageData(page) {
|
|
|
353
390
|
}
|
|
354
391
|
|
|
355
392
|
return results;
|
|
356
|
-
}, MAX_ELEMENTS);
|
|
393
|
+
}, { maxElements: MAX_ELEMENTS, ignoreSelectors: ignoreSelectors || [] });
|
|
394
|
+
|
|
395
|
+
// Fetch and parse cross-origin stylesheets
|
|
396
|
+
if (data.crossOriginSheets && data.crossOriginSheets.length > 0) {
|
|
397
|
+
const seen = new Set();
|
|
398
|
+
for (const href of data.crossOriginSheets) {
|
|
399
|
+
if (seen.has(href)) continue;
|
|
400
|
+
seen.add(href);
|
|
401
|
+
try {
|
|
402
|
+
const cssText = await page.evaluate(async (url) => {
|
|
403
|
+
const res = await fetch(url, { mode: 'cors' });
|
|
404
|
+
return res.text();
|
|
405
|
+
}, href);
|
|
406
|
+
parseCrossOriginCSS(cssText, data);
|
|
407
|
+
} catch { /* fetch failed too */ }
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
delete data.crossOriginSheets;
|
|
411
|
+
|
|
412
|
+
return data;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function parseCrossOriginCSS(cssText, data) {
|
|
416
|
+
// Media queries
|
|
417
|
+
for (const m of cssText.matchAll(/@media\s*([^{]+)\{/g)) {
|
|
418
|
+
data.mediaQueries.push(m[1].trim());
|
|
419
|
+
}
|
|
420
|
+
// Keyframes
|
|
421
|
+
for (const m of cssText.matchAll(/@keyframes\s+([\w-]+)\s*\{([\s\S]*?)\n\}/g)) {
|
|
422
|
+
const steps = [];
|
|
423
|
+
for (const s of m[2].matchAll(/([\d%,\s]+|from|to)\s*\{([^}]*)\}/g)) {
|
|
424
|
+
steps.push({ offset: s[1].trim(), style: s[2].trim() });
|
|
425
|
+
}
|
|
426
|
+
if (steps.length > 0) data.keyframes.push({ name: m[1], steps });
|
|
427
|
+
}
|
|
428
|
+
// :root variables
|
|
429
|
+
for (const rootBlock of cssText.matchAll(/:root\s*\{([^}]+)\}/g)) {
|
|
430
|
+
for (const v of rootBlock[1].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
|
|
431
|
+
if (!data.cssVariables[v[1]]) data.cssVariables[v[1]] = v[2].trim();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// @font-face
|
|
435
|
+
for (const m of cssText.matchAll(/@font-face\s*\{([^}]+)\}/g)) {
|
|
436
|
+
const block = m[1];
|
|
437
|
+
const family = block.match(/font-family\s*:\s*['"]?([^'";]+)/)?.[1]?.trim();
|
|
438
|
+
const style = block.match(/font-style\s*:\s*([^;]+)/)?.[1]?.trim() || 'normal';
|
|
439
|
+
const weight = block.match(/font-weight\s*:\s*([^;]+)/)?.[1]?.trim() || '400';
|
|
440
|
+
const src = block.match(/src\s*:\s*([^;]+)/)?.[1]?.trim() || '';
|
|
441
|
+
if (family) data.fontData.fontFaces.push({ family, style, weight, src });
|
|
442
|
+
}
|
|
357
443
|
}
|
|
@@ -9,11 +9,11 @@ export function extractAnimations(computedStyles, keyframes) {
|
|
|
9
9
|
if (el.transition && el.transition !== 'all 0s ease 0s' && el.transition !== 'none') {
|
|
10
10
|
transitionSet.add(el.transition);
|
|
11
11
|
|
|
12
|
-
// Extract
|
|
13
|
-
const dMatch = el.transition.match(/([\d
|
|
12
|
+
// Extract duration — only match standalone duration values, not inside functions
|
|
13
|
+
const dMatch = el.transition.match(/(?<![(\d])(\d+\.?\d*m?s)(?![)\w])/g);
|
|
14
14
|
if (dMatch) dMatch.forEach(d => durationSet.add(d));
|
|
15
15
|
|
|
16
|
-
const eMatch = el.transition.match(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\([
|
|
16
|
+
const eMatch = el.transition.match(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*\))/g);
|
|
17
17
|
if (eMatch) eMatch.forEach(e => easingSet.add(e));
|
|
18
18
|
|
|
19
19
|
// Extract which properties are animated
|
|
@@ -26,10 +26,18 @@ export function extractAnimations(computedStyles, keyframes) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// Capture animation usage
|
|
29
|
+
// Capture animation usage with full shorthand parsing
|
|
30
30
|
if (el.animation && el.animation !== 'none 0s ease 0s 1 normal none running' && el.animation !== 'none') {
|
|
31
31
|
const nameMatch = el.animation.match(/^([\w-]+)/);
|
|
32
32
|
if (nameMatch && nameMatch[1] !== 'none') animationNames.add(nameMatch[1]);
|
|
33
|
+
|
|
34
|
+
// Parse delay, iteration-count, fill-mode from shorthand
|
|
35
|
+
const delayMatch = el.animation.match(/(?<!\d)(\d+\.?\d*m?s)\s+(\d+\.?\d*m?s)/);
|
|
36
|
+
if (delayMatch) durationSet.add(delayMatch[1]); // duration is first, delay is second
|
|
37
|
+
|
|
38
|
+
const iterMatch = el.animation.match(/\b(infinite|\d+)\b(?=\s+(normal|reverse|alternate)|\s+none|\s+running|\s+paused|$)/);
|
|
39
|
+
const fillMatch = el.animation.match(/\b(none|forwards|backwards|both)\b/);
|
|
40
|
+
// These are collected but don't need separate sets — they enrich keyframe data below
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
|
|
@@ -53,9 +61,33 @@ export function extractAnimations(computedStyles, keyframes) {
|
|
|
53
61
|
.sort((a, b) => b[1] - a[1])
|
|
54
62
|
.map(([prop, count]) => ({ property: prop, count }));
|
|
55
63
|
|
|
64
|
+
// Classify timing patterns for each easing
|
|
65
|
+
const classifiedEasings = [...easingSet].map(e => {
|
|
66
|
+
let pattern = 'custom';
|
|
67
|
+
if (e === 'linear') pattern = 'linear';
|
|
68
|
+
else if (e === 'ease-in') pattern = 'ease-in';
|
|
69
|
+
else if (e === 'ease-out') pattern = 'ease-out';
|
|
70
|
+
else if (e === 'ease-in-out' || e === 'ease') pattern = 'ease-in-out';
|
|
71
|
+
else {
|
|
72
|
+
const cbMatch = e.match(/cubic-bezier\(\s*([\d.]+)\s*,\s*([-\d.]+)\s*,\s*([\d.]+)\s*,\s*([-\d.]+)\s*\)/);
|
|
73
|
+
if (cbMatch) {
|
|
74
|
+
const [, , y1, , y2] = cbMatch.map(Number);
|
|
75
|
+
if (y1 > 1 || y1 < 0 || y2 > 1 || y2 < 0) pattern = 'spring-like';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { value: e, pattern };
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Detect bounce keyframes (return to start value)
|
|
82
|
+
for (const kf of enhancedKeyframes) {
|
|
83
|
+
const firstStep = kf.steps.find(s => s.offset === '0%' || s.offset === 'from');
|
|
84
|
+
const lastStep = kf.steps.find(s => s.offset === '100%' || s.offset === 'to');
|
|
85
|
+
kf.isBounce = !!(firstStep && lastStep && firstStep.style === lastStep.style && kf.steps.length > 2);
|
|
86
|
+
}
|
|
87
|
+
|
|
56
88
|
return {
|
|
57
89
|
transitions: [...transitionSet],
|
|
58
|
-
easings:
|
|
90
|
+
easings: classifiedEasings,
|
|
59
91
|
durations: [...durationSet],
|
|
60
92
|
keyframes: enhancedKeyframes,
|
|
61
93
|
transitionProperties: sortedProps,
|
|
@@ -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,6 +149,64 @@ 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
|
+
|
|
134
210
|
// Generate CSS snippets for each component
|
|
135
211
|
for (const [type, data] of Object.entries(components)) {
|
|
136
212
|
if (data.baseStyle) {
|
|
@@ -6,7 +6,9 @@ export function extractGradients(styles) {
|
|
|
6
6
|
const bg = el.backgroundImage;
|
|
7
7
|
if (!bg || !bg.includes('gradient')) continue;
|
|
8
8
|
const rawGradients = splitGradients(bg);
|
|
9
|
-
for (
|
|
9
|
+
for (let raw of rawGradients) {
|
|
10
|
+
// Normalize vendor prefixes
|
|
11
|
+
raw = raw.replace(/-(webkit|moz)-/g, '');
|
|
10
12
|
if (seen.has(raw)) continue;
|
|
11
13
|
seen.add(raw);
|
|
12
14
|
gradients.push(parseGradient(raw));
|
|
@@ -58,15 +60,33 @@ function parseGradient(raw) {
|
|
|
58
60
|
let direction = null;
|
|
59
61
|
let stopArgs = args;
|
|
60
62
|
const first = args[0] || '';
|
|
61
|
-
if (/^(to |from |\d+deg|at )/.test(first) || /^(circle|ellipse)/.test(first)) {
|
|
63
|
+
if (/^(to |from |\d+(\.\d+)?(deg|grad|rad|turn)|at )/.test(first) || /^(circle|ellipse)/.test(first)) {
|
|
62
64
|
direction = first;
|
|
63
65
|
stopArgs = args.slice(1);
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
const stops = stopArgs.map(s => {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
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
|
+
}
|
|
70
90
|
return { color, position };
|
|
71
91
|
});
|
|
72
92
|
|