designlang 4.0.1 → 5.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/bin/design-extract.js +76 -1
- package/package.json +1 -1
- package/src/apply.js +65 -0
- package/src/crawler.js +81 -2
- package/src/darkdiff.js +65 -0
- package/src/extractors/animations.js +40 -4
- package/src/extractors/components.js +23 -0
- package/src/extractors/fonts.js +82 -0
- package/src/extractors/gradients.js +80 -0
- package/src/extractors/icons.js +80 -0
- package/src/extractors/images.js +76 -0
- package/src/extractors/zindex.js +65 -0
- package/src/formatters/markdown.js +98 -0
- package/src/index.js +13 -1
- 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
package/bin/design-extract.js
CHANGED
|
@@ -21,6 +21,8 @@ import { syncDesign } from '../src/sync.js';
|
|
|
21
21
|
import { compareBrands, formatBrandMatrix, formatBrandMatrixHtml } from '../src/multibrand.js';
|
|
22
22
|
import { generateClone } from '../src/clone.js';
|
|
23
23
|
import { watchSite } from '../src/watch.js';
|
|
24
|
+
import { diffDarkMode } from '../src/darkdiff.js';
|
|
25
|
+
import { applyDesign } from '../src/apply.js';
|
|
24
26
|
import { nameFromUrl } from '../src/utils.js';
|
|
25
27
|
|
|
26
28
|
const program = new Command();
|
|
@@ -28,7 +30,7 @@ const program = new Command();
|
|
|
28
30
|
program
|
|
29
31
|
.name('designlang')
|
|
30
32
|
.description('Extract the complete design language from any website')
|
|
31
|
-
.version('
|
|
33
|
+
.version('5.0.0');
|
|
32
34
|
|
|
33
35
|
// ── Main command: extract ──────────────────────────────────────
|
|
34
36
|
program
|
|
@@ -45,6 +47,8 @@ program
|
|
|
45
47
|
.option('--responsive', 'capture design at multiple breakpoints')
|
|
46
48
|
.option('--interactions', 'capture hover/focus/active states')
|
|
47
49
|
.option('--full', 'enable all extra captures (screenshots, responsive, interactions)')
|
|
50
|
+
.option('--cookie <cookies...>', 'cookies for authenticated pages (name=value)')
|
|
51
|
+
.option('--header <headers...>', 'custom headers (name:value)')
|
|
48
52
|
.option('--no-history', 'skip saving to history')
|
|
49
53
|
.option('--verbose', 'show detailed progress')
|
|
50
54
|
.action(async (url, opts) => {
|
|
@@ -61,6 +65,16 @@ program
|
|
|
61
65
|
|
|
62
66
|
try {
|
|
63
67
|
spinner.text = `Crawling${opts.depth > 0 ? ` (depth: ${opts.depth})` : ''}...`;
|
|
68
|
+
// Parse auth options
|
|
69
|
+
const cookies = opts.cookie || [];
|
|
70
|
+
const headers = {};
|
|
71
|
+
if (opts.header) {
|
|
72
|
+
for (const h of opts.header) {
|
|
73
|
+
const [name, ...rest] = h.split(':');
|
|
74
|
+
if (name && rest.length) headers[name.trim()] = rest.join(':').trim();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
64
78
|
const design = await extractDesignLanguage(url, {
|
|
65
79
|
width: opts.width,
|
|
66
80
|
height: parseInt(opts.height) || 800,
|
|
@@ -69,6 +83,8 @@ program
|
|
|
69
83
|
depth: opts.depth,
|
|
70
84
|
screenshots: opts.screenshots || opts.full,
|
|
71
85
|
outDir,
|
|
86
|
+
cookies: cookies.length > 0 ? cookies : undefined,
|
|
87
|
+
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
72
88
|
});
|
|
73
89
|
|
|
74
90
|
// Responsive capture
|
|
@@ -163,6 +179,25 @@ program
|
|
|
163
179
|
console.log(` ${chalk.gray('Design Score:')} ${gradeColor(`${s.overall}/100 (${s.grade})`)}${s.issues.length > 0 ? ` — ${s.issues.length} issues` : ''}`);
|
|
164
180
|
}
|
|
165
181
|
|
|
182
|
+
// New v5 extractors
|
|
183
|
+
if (design.gradients && design.gradients.count > 0) {
|
|
184
|
+
console.log(` ${chalk.gray('Gradients:')} ${design.gradients.count} unique gradients`);
|
|
185
|
+
}
|
|
186
|
+
if (design.zIndex && design.zIndex.allValues.length > 0) {
|
|
187
|
+
console.log(` ${chalk.gray('Z-Index:')} ${design.zIndex.allValues.length} layers${design.zIndex.issues.length > 0 ? ` (${design.zIndex.issues.length} issues)` : ''}`);
|
|
188
|
+
}
|
|
189
|
+
if (design.icons && design.icons.count > 0) {
|
|
190
|
+
console.log(` ${chalk.gray('Icons:')} ${design.icons.count} SVG icons (${design.icons.dominantStyle || 'mixed'})`);
|
|
191
|
+
}
|
|
192
|
+
if (design.fonts && design.fonts.fonts.length > 0) {
|
|
193
|
+
const sources = design.fonts.fonts.map(f => f.source).filter((v, i, a) => a.indexOf(v) === i);
|
|
194
|
+
console.log(` ${chalk.gray('Font Files:')} ${design.fonts.fonts.length} fonts (${sources.join(', ')})`);
|
|
195
|
+
}
|
|
196
|
+
if (design.images && design.images.patterns.length > 0) {
|
|
197
|
+
const total = design.images.patterns.reduce((s, p) => s + p.count, 0);
|
|
198
|
+
console.log(` ${chalk.gray('Images:')} ${total} images, ${design.images.patterns.length} style patterns`);
|
|
199
|
+
}
|
|
200
|
+
|
|
166
201
|
// Accessibility summary
|
|
167
202
|
if (design.accessibility) {
|
|
168
203
|
const a = design.accessibility;
|
|
@@ -486,4 +521,44 @@ program
|
|
|
486
521
|
}
|
|
487
522
|
});
|
|
488
523
|
|
|
524
|
+
// ── Apply command ──────────────────────────────────────────
|
|
525
|
+
program
|
|
526
|
+
.command('apply <url>')
|
|
527
|
+
.description('Extract and apply design directly to your project')
|
|
528
|
+
.option('-d, --dir <path>', 'project directory', '.')
|
|
529
|
+
.option('--framework <type>', 'force framework (tailwind, shadcn, css)')
|
|
530
|
+
.option('--cookie <cookies...>', 'cookies for authenticated pages')
|
|
531
|
+
.option('--header <headers...>', 'custom headers')
|
|
532
|
+
.action(async (url, opts) => {
|
|
533
|
+
if (!url.startsWith('http')) url = `https://${url}`;
|
|
534
|
+
|
|
535
|
+
console.log('');
|
|
536
|
+
console.log(chalk.bold(' designlang apply'));
|
|
537
|
+
console.log(chalk.gray(` ${url} → ${resolve(opts.dir)}`));
|
|
538
|
+
console.log('');
|
|
539
|
+
|
|
540
|
+
const spinner = ora('Extracting design...').start();
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
const result = await applyDesign(url, {
|
|
544
|
+
dir: resolve(opts.dir),
|
|
545
|
+
framework: opts.framework,
|
|
546
|
+
cookies: opts.cookie,
|
|
547
|
+
headers: opts.header ? Object.fromEntries(opts.header.map(h => { const [k, ...v] = h.split(':'); return [k.trim(), v.join(':').trim()]; })) : undefined,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
spinner.succeed(`Applied ${result.framework} design!`);
|
|
551
|
+
console.log('');
|
|
552
|
+
for (const f of result.applied) {
|
|
553
|
+
console.log(` ${chalk.green('✓')} ${chalk.cyan(f.file)} — ${f.type}`);
|
|
554
|
+
}
|
|
555
|
+
console.log('');
|
|
556
|
+
|
|
557
|
+
} catch (err) {
|
|
558
|
+
spinner.fail('Apply failed');
|
|
559
|
+
console.error(chalk.red(`\n ${err.message}\n`));
|
|
560
|
+
process.exit(1);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
489
564
|
program.parse();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Extract the complete design language from any website — colors, typography, spacing, shadows, and more. Outputs AI-optimized markdown, W3C design tokens, Tailwind config, and CSS variables.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/apply.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { extractDesignLanguage } from './index.js';
|
|
4
|
+
import { formatTailwind } from './formatters/tailwind.js';
|
|
5
|
+
import { formatCssVars } from './formatters/css-vars.js';
|
|
6
|
+
import { formatShadcnTheme } from './formatters/theme.js';
|
|
7
|
+
|
|
8
|
+
export async function applyDesign(url, options = {}) {
|
|
9
|
+
const { dir = '.', framework } = options;
|
|
10
|
+
const design = await extractDesignLanguage(url, options);
|
|
11
|
+
const detected = framework || detectFramework(dir);
|
|
12
|
+
const applied = [];
|
|
13
|
+
|
|
14
|
+
if (detected === 'tailwind' || detected === 'auto') {
|
|
15
|
+
const twPath = findFile(dir, ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs']);
|
|
16
|
+
if (twPath) {
|
|
17
|
+
writeFileSync(twPath, formatTailwind(design), 'utf-8');
|
|
18
|
+
applied.push({ file: twPath, type: 'tailwind' });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (detected === 'shadcn' || detected === 'auto') {
|
|
23
|
+
const globalsPath = findFile(dir, [
|
|
24
|
+
'app/globals.css', 'src/app/globals.css', 'styles/globals.css',
|
|
25
|
+
'src/styles/globals.css', 'src/index.css', 'app/global.css',
|
|
26
|
+
]);
|
|
27
|
+
if (globalsPath) {
|
|
28
|
+
const existing = readFileSync(globalsPath, 'utf-8');
|
|
29
|
+
const shadcnVars = formatShadcnTheme(design);
|
|
30
|
+
// Replace existing @layer base block or append
|
|
31
|
+
const layerRegex = /@layer\s+base\s*\{[\s\S]*?\n\}/;
|
|
32
|
+
const updated = layerRegex.test(existing)
|
|
33
|
+
? existing.replace(layerRegex, shadcnVars)
|
|
34
|
+
: existing + '\n\n' + shadcnVars;
|
|
35
|
+
writeFileSync(globalsPath, updated, 'utf-8');
|
|
36
|
+
applied.push({ file: globalsPath, type: 'shadcn' });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (detected === 'css' || detected === 'auto') {
|
|
41
|
+
const cssVarsContent = formatCssVars(design);
|
|
42
|
+
const cssPath = join(dir, 'design-variables.css');
|
|
43
|
+
writeFileSync(cssPath, cssVarsContent, 'utf-8');
|
|
44
|
+
applied.push({ file: cssPath, type: 'css-variables' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { design, applied, framework: detected };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function detectFramework(dir) {
|
|
51
|
+
if (findFile(dir, ['tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs'])) {
|
|
52
|
+
// Check for shadcn
|
|
53
|
+
if (findFile(dir, ['components.json'])) return 'shadcn';
|
|
54
|
+
return 'tailwind';
|
|
55
|
+
}
|
|
56
|
+
return 'auto';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findFile(dir, candidates) {
|
|
60
|
+
for (const c of candidates) {
|
|
61
|
+
const p = join(dir, c);
|
|
62
|
+
if (existsSync(p)) return p;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
package/src/crawler.js
CHANGED
|
@@ -5,13 +5,29 @@ import { join } from 'path';
|
|
|
5
5
|
const MAX_ELEMENTS = 5000;
|
|
6
6
|
|
|
7
7
|
export async function crawlPage(url, options = {}) {
|
|
8
|
-
const { width = 1280, height = 800, wait = 0, dark = false, depth = 0, screenshots = false, outDir = '' } = options;
|
|
8
|
+
const { width = 1280, height = 800, wait = 0, dark = false, depth = 0, screenshots = false, outDir = '', executablePath, browserArgs, cookies, headers } = options;
|
|
9
9
|
|
|
10
|
-
const browser = await chromium.launch({
|
|
10
|
+
const browser = await chromium.launch({
|
|
11
|
+
headless: true,
|
|
12
|
+
...(executablePath && { executablePath }),
|
|
13
|
+
...(browserArgs && { args: browserArgs }),
|
|
14
|
+
});
|
|
11
15
|
const context = await browser.newContext({
|
|
12
16
|
viewport: { width, height },
|
|
13
17
|
colorScheme: 'light',
|
|
18
|
+
...(headers && { extraHTTPHeaders: headers }),
|
|
14
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
|
+
}
|
|
15
31
|
const page = await context.newPage();
|
|
16
32
|
|
|
17
33
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
@@ -273,6 +289,69 @@ async function extractPageData(page) {
|
|
|
273
289
|
}
|
|
274
290
|
} catch { /* no access */ }
|
|
275
291
|
|
|
292
|
+
// SVG icons
|
|
293
|
+
results.icons = [];
|
|
294
|
+
for (const svg of document.querySelectorAll('svg')) {
|
|
295
|
+
const rect = svg.getBoundingClientRect();
|
|
296
|
+
if (rect.width > 4 && rect.width < 200 && rect.height > 4 && rect.height < 200) {
|
|
297
|
+
results.icons.push({
|
|
298
|
+
svg: svg.outerHTML,
|
|
299
|
+
width: rect.width,
|
|
300
|
+
height: rect.height,
|
|
301
|
+
viewBox: svg.getAttribute('viewBox') || '',
|
|
302
|
+
classList: Array.from(svg.classList).join(' '),
|
|
303
|
+
fill: svg.getAttribute('fill') || getComputedStyle(svg).fill || '',
|
|
304
|
+
stroke: svg.getAttribute('stroke') || getComputedStyle(svg).stroke || '',
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Font data
|
|
310
|
+
results.fontData = { fontFaces: [], googleFontsLinks: [], documentFonts: [] };
|
|
311
|
+
try {
|
|
312
|
+
for (const sheet of document.styleSheets) {
|
|
313
|
+
try {
|
|
314
|
+
for (const rule of sheet.cssRules) {
|
|
315
|
+
if (rule instanceof CSSFontFaceRule) {
|
|
316
|
+
results.fontData.fontFaces.push({
|
|
317
|
+
family: rule.style.getPropertyValue('font-family').replace(/['"]/g, ''),
|
|
318
|
+
style: rule.style.getPropertyValue('font-style') || 'normal',
|
|
319
|
+
weight: rule.style.getPropertyValue('font-weight') || '400',
|
|
320
|
+
src: rule.style.getPropertyValue('src') || '',
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
} catch { /* cross-origin */ }
|
|
325
|
+
}
|
|
326
|
+
} catch {}
|
|
327
|
+
for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com"]')) {
|
|
328
|
+
results.fontData.googleFontsLinks.push(link.href);
|
|
329
|
+
}
|
|
330
|
+
for (const font of document.fonts) {
|
|
331
|
+
results.fontData.documentFonts.push({ family: font.family.replace(/['"]/g, ''), style: font.style, weight: font.weight, status: font.status });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Image data
|
|
335
|
+
results.images = [];
|
|
336
|
+
for (const img of document.querySelectorAll('img, picture img, [role="img"]')) {
|
|
337
|
+
const rect = img.getBoundingClientRect();
|
|
338
|
+
if (rect.width < 5 || rect.height < 5) continue;
|
|
339
|
+
const cs = getComputedStyle(img);
|
|
340
|
+
results.images.push({
|
|
341
|
+
tag: img.tagName.toLowerCase(),
|
|
342
|
+
src: img.src || '',
|
|
343
|
+
width: rect.width,
|
|
344
|
+
height: rect.height,
|
|
345
|
+
objectFit: cs.objectFit,
|
|
346
|
+
objectPosition: cs.objectPosition,
|
|
347
|
+
borderRadius: cs.borderRadius,
|
|
348
|
+
filter: cs.filter,
|
|
349
|
+
opacity: cs.opacity,
|
|
350
|
+
aspectRatio: cs.aspectRatio,
|
|
351
|
+
classList: Array.from(img.classList).join(' '),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
276
355
|
return results;
|
|
277
356
|
}, MAX_ELEMENTS);
|
|
278
357
|
}
|
package/src/darkdiff.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function diffDarkMode(lightDesign, darkDesign) {
|
|
2
|
+
const changes = [];
|
|
3
|
+
|
|
4
|
+
// Color changes
|
|
5
|
+
const lightColors = new Set(lightDesign.colors.all.map(c => c.hex));
|
|
6
|
+
const darkColors = new Set(darkDesign.colors.all.map(c => c.hex));
|
|
7
|
+
|
|
8
|
+
const addedInDark = darkDesign.colors.all.filter(c => !lightColors.has(c.hex));
|
|
9
|
+
const removedInDark = lightDesign.colors.all.filter(c => !darkColors.has(c.hex));
|
|
10
|
+
|
|
11
|
+
if (addedInDark.length > 0 || removedInDark.length > 0) {
|
|
12
|
+
changes.push({
|
|
13
|
+
category: 'colors',
|
|
14
|
+
light: lightDesign.colors.all.length,
|
|
15
|
+
dark: darkDesign.colors.all.length,
|
|
16
|
+
added: addedInDark.map(c => c.hex),
|
|
17
|
+
removed: removedInDark.map(c => c.hex),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// CSS variable changes
|
|
22
|
+
const lightVars = flattenVars(lightDesign.variables);
|
|
23
|
+
const darkVars = flattenVars(darkDesign.variables);
|
|
24
|
+
const varChanges = [];
|
|
25
|
+
|
|
26
|
+
for (const [key, lightVal] of Object.entries(lightVars)) {
|
|
27
|
+
const darkVal = darkVars[key];
|
|
28
|
+
if (darkVal && darkVal !== lightVal) {
|
|
29
|
+
varChanges.push({ name: key, light: lightVal, dark: darkVal });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const newDarkVars = Object.entries(darkVars)
|
|
33
|
+
.filter(([key]) => !lightVars[key])
|
|
34
|
+
.map(([name, dark]) => ({ name, light: null, dark }));
|
|
35
|
+
|
|
36
|
+
if (varChanges.length > 0 || newDarkVars.length > 0) {
|
|
37
|
+
changes.push({
|
|
38
|
+
category: 'cssVariables',
|
|
39
|
+
changed: varChanges,
|
|
40
|
+
newInDark: newDarkVars,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
hasChanges: changes.length > 0,
|
|
46
|
+
changes,
|
|
47
|
+
summary: {
|
|
48
|
+
colorsChanged: (addedInDark.length + removedInDark.length) || 0,
|
|
49
|
+
variablesChanged: varChanges.length,
|
|
50
|
+
newDarkVariables: newDarkVars.length,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function flattenVars(variables) {
|
|
56
|
+
const flat = {};
|
|
57
|
+
for (const [, group] of Object.entries(variables)) {
|
|
58
|
+
if (typeof group === 'object') {
|
|
59
|
+
for (const [key, val] of Object.entries(group)) {
|
|
60
|
+
flat[key] = val;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return flat;
|
|
65
|
+
}
|
|
@@ -2,6 +2,8 @@ export function extractAnimations(computedStyles, keyframes) {
|
|
|
2
2
|
const transitionSet = new Set();
|
|
3
3
|
const easingSet = new Set();
|
|
4
4
|
const durationSet = new Set();
|
|
5
|
+
const animationNames = new Set();
|
|
6
|
+
const transitionProperties = {};
|
|
5
7
|
|
|
6
8
|
for (const el of computedStyles) {
|
|
7
9
|
if (el.transition && el.transition !== 'all 0s ease 0s' && el.transition !== 'none') {
|
|
@@ -13,16 +15,50 @@ export function extractAnimations(computedStyles, keyframes) {
|
|
|
13
15
|
|
|
14
16
|
const eMatch = el.transition.match(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\([^)]+\))/g);
|
|
15
17
|
if (eMatch) eMatch.forEach(e => easingSet.add(e));
|
|
18
|
+
|
|
19
|
+
// Extract which properties are animated
|
|
20
|
+
const parts = el.transition.split(',').map(s => s.trim());
|
|
21
|
+
for (const part of parts) {
|
|
22
|
+
const prop = part.split(/\s+/)[0];
|
|
23
|
+
if (prop && prop !== 'all') {
|
|
24
|
+
transitionProperties[prop] = (transitionProperties[prop] || 0) + 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Capture animation usage
|
|
30
|
+
if (el.animation && el.animation !== 'none 0s ease 0s 1 normal none running' && el.animation !== 'none') {
|
|
31
|
+
const nameMatch = el.animation.match(/^([\w-]+)/);
|
|
32
|
+
if (nameMatch && nameMatch[1] !== 'none') animationNames.add(nameMatch[1]);
|
|
16
33
|
}
|
|
17
34
|
}
|
|
18
35
|
|
|
36
|
+
// Enhanced keyframes with timing and properties changed
|
|
37
|
+
const enhancedKeyframes = keyframes.map(kf => {
|
|
38
|
+
const propertiesAnimated = new Set();
|
|
39
|
+
for (const step of kf.steps) {
|
|
40
|
+
const props = step.style.split(';').map(s => s.split(':')[0].trim()).filter(Boolean);
|
|
41
|
+
props.forEach(p => propertiesAnimated.add(p));
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
name: kf.name,
|
|
45
|
+
steps: kf.steps,
|
|
46
|
+
propertiesAnimated: [...propertiesAnimated],
|
|
47
|
+
isUsed: animationNames.has(kf.name),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Sort transition properties by usage
|
|
52
|
+
const sortedProps = Object.entries(transitionProperties)
|
|
53
|
+
.sort((a, b) => b[1] - a[1])
|
|
54
|
+
.map(([prop, count]) => ({ property: prop, count }));
|
|
55
|
+
|
|
19
56
|
return {
|
|
20
57
|
transitions: [...transitionSet],
|
|
21
58
|
easings: [...easingSet],
|
|
22
59
|
durations: [...durationSet],
|
|
23
|
-
keyframes:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
})),
|
|
60
|
+
keyframes: enhancedKeyframes,
|
|
61
|
+
transitionProperties: sortedProps,
|
|
62
|
+
animationNames: [...animationNames],
|
|
27
63
|
};
|
|
28
64
|
}
|
|
@@ -131,9 +131,32 @@ export function extractComponents(computedStyles) {
|
|
|
131
131
|
};
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// Generate CSS snippets for each component
|
|
135
|
+
for (const [type, data] of Object.entries(components)) {
|
|
136
|
+
if (data.baseStyle) {
|
|
137
|
+
const style = type === 'tables' ? { ...data.baseStyle } : data.baseStyle;
|
|
138
|
+
delete style.cellStyle;
|
|
139
|
+
data.css = styleToCss(`.${type.replace(/s$/, '')}`, style);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
134
143
|
return components;
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
function styleToCss(selector, style) {
|
|
147
|
+
const propMap = {
|
|
148
|
+
backgroundColor: 'background-color', color: 'color', fontSize: 'font-size',
|
|
149
|
+
fontWeight: 'font-weight', paddingTop: 'padding-top', paddingRight: 'padding-right',
|
|
150
|
+
paddingBottom: 'padding-bottom', paddingLeft: 'padding-left',
|
|
151
|
+
borderRadius: 'border-radius', boxShadow: 'box-shadow', borderColor: 'border-color',
|
|
152
|
+
maxWidth: 'max-width', position: 'position',
|
|
153
|
+
};
|
|
154
|
+
const lines = Object.entries(style)
|
|
155
|
+
.filter(([, v]) => v)
|
|
156
|
+
.map(([k, v]) => ` ${propMap[k] || k}: ${v};`);
|
|
157
|
+
return `${selector} {\n${lines.join('\n')}\n}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
137
160
|
function mostCommonStyle(elements, properties) {
|
|
138
161
|
const style = {};
|
|
139
162
|
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,80 @@
|
|
|
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 (const raw of rawGradients) {
|
|
10
|
+
if (seen.has(raw)) continue;
|
|
11
|
+
seen.add(raw);
|
|
12
|
+
gradients.push(parseGradient(raw));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return { gradients, count: gradients.length };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function splitGradients(value) {
|
|
20
|
+
// Split comma-separated gradient layers, respecting nested parens
|
|
21
|
+
const results = [];
|
|
22
|
+
let depth = 0, start = 0;
|
|
23
|
+
for (let i = 0; i < value.length; i++) {
|
|
24
|
+
if (value[i] === '(') depth++;
|
|
25
|
+
else if (value[i] === ')') depth--;
|
|
26
|
+
else if (value[i] === ',' && depth === 0) {
|
|
27
|
+
const chunk = value.slice(start, i).trim();
|
|
28
|
+
if (chunk.includes('gradient')) results.push(chunk);
|
|
29
|
+
start = i + 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const last = value.slice(start).trim();
|
|
33
|
+
if (last.includes('gradient')) results.push(last);
|
|
34
|
+
return results;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseGradient(raw) {
|
|
38
|
+
const typeMatch = raw.match(/^(repeating-)?(linear|radial|conic)-gradient/);
|
|
39
|
+
const type = typeMatch ? (typeMatch[1] || '') + typeMatch[2] : 'linear';
|
|
40
|
+
|
|
41
|
+
// Extract content inside outermost parens
|
|
42
|
+
const inner = raw.slice(raw.indexOf('(') + 1, raw.lastIndexOf(')'));
|
|
43
|
+
|
|
44
|
+
// Split top-level arguments by comma (respecting nested parens)
|
|
45
|
+
const args = [];
|
|
46
|
+
let depth = 0, start = 0;
|
|
47
|
+
for (let i = 0; i < inner.length; i++) {
|
|
48
|
+
if (inner[i] === '(') depth++;
|
|
49
|
+
else if (inner[i] === ')') depth--;
|
|
50
|
+
else if (inner[i] === ',' && depth === 0) {
|
|
51
|
+
args.push(inner.slice(start, i).trim());
|
|
52
|
+
start = i + 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
args.push(inner.slice(start).trim());
|
|
56
|
+
|
|
57
|
+
// First arg is direction/angle if it doesn't look like a color
|
|
58
|
+
let direction = null;
|
|
59
|
+
let stopArgs = args;
|
|
60
|
+
const first = args[0] || '';
|
|
61
|
+
if (/^(to |from |\d+deg|at )/.test(first) || /^(circle|ellipse)/.test(first)) {
|
|
62
|
+
direction = first;
|
|
63
|
+
stopArgs = args.slice(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stops = stopArgs.map(s => {
|
|
67
|
+
const posMatch = s.match(/([\d.]+%?)$/);
|
|
68
|
+
const position = posMatch ? posMatch[1] : null;
|
|
69
|
+
const color = position ? s.slice(0, posMatch.index).trim() : s.trim();
|
|
70
|
+
return { color, position };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const colorCount = stops.length;
|
|
74
|
+
let classification = 'subtle';
|
|
75
|
+
if (colorCount > 4) classification = 'complex';
|
|
76
|
+
else if (colorCount > 2) classification = 'bold';
|
|
77
|
+
else if (colorCount === 2) classification = 'brand';
|
|
78
|
+
|
|
79
|
+
return { raw, type, direction, stops, classification };
|
|
80
|
+
}
|