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
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/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILES = ['.designlangrc', 'designlang.config.json', '.designlangrc.json'];
|
|
5
|
+
|
|
6
|
+
export function loadConfig(dir = process.cwd()) {
|
|
7
|
+
for (const name of CONFIG_FILES) {
|
|
8
|
+
const path = join(dir, name);
|
|
9
|
+
if (existsSync(path)) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
12
|
+
} catch { return {}; }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function mergeConfig(cliOpts, config) {
|
|
19
|
+
// CLI flags take precedence over config file
|
|
20
|
+
return {
|
|
21
|
+
ignore: cliOpts.ignore || config.ignore || [],
|
|
22
|
+
width: cliOpts.width || config.width || 1280,
|
|
23
|
+
height: cliOpts.height || config.height || 800,
|
|
24
|
+
wait: cliOpts.wait || config.wait || 0,
|
|
25
|
+
dark: cliOpts.dark || config.dark || false,
|
|
26
|
+
depth: cliOpts.depth || config.depth || 0,
|
|
27
|
+
screenshots: cliOpts.screenshots || config.screenshots || false,
|
|
28
|
+
framework: cliOpts.framework || config.framework,
|
|
29
|
+
responsive: cliOpts.responsive || config.responsive || false,
|
|
30
|
+
interactions: cliOpts.interactions || config.interactions || false,
|
|
31
|
+
full: cliOpts.full || config.full || false,
|
|
32
|
+
cookie: cliOpts.cookie || config.cookies,
|
|
33
|
+
header: cliOpts.header || config.headers,
|
|
34
|
+
out: cliOpts.out || config.out || './design-extract-output',
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/crawler.js
CHANGED
|
@@ -4,91 +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 = '' } = 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
|
-
const browser = await chromium.launch({
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
22
|
+
const browser = await chromium.launch({
|
|
23
|
+
headless: true,
|
|
24
|
+
...(executablePath && { executablePath }),
|
|
25
|
+
...(browserArgs && { args: browserArgs }),
|
|
14
26
|
});
|
|
15
|
-
|
|
27
|
+
try {
|
|
28
|
+
const context = await browser.newContext({
|
|
29
|
+
viewport: { width, height },
|
|
30
|
+
colorScheme: 'light',
|
|
31
|
+
...(headers && { extraHTTPHeaders: headers }),
|
|
32
|
+
});
|
|
16
33
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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();
|
|
22
45
|
|
|
23
|
-
|
|
24
|
-
|
|
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(() => {});
|
|
25
51
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (screenshots && outDir) {
|
|
29
|
-
componentScreenshots = await captureComponentScreenshots(page, outDir);
|
|
30
|
-
}
|
|
52
|
+
const title = await page.title();
|
|
53
|
+
const lightData = await extractPageData(page, ignore);
|
|
31
54
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
for (const link of internalLinks) {
|
|
37
|
-
try {
|
|
38
|
-
await page.goto(link, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
39
|
-
await page.waitForLoadState('networkidle').catch(() => {});
|
|
40
|
-
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
41
|
-
const pageData = await extractPageData(page);
|
|
42
|
-
additionalPages.push({ url: link, data: pageData });
|
|
43
|
-
} catch { /* skip failed pages */ }
|
|
55
|
+
// Component screenshots
|
|
56
|
+
let componentScreenshots = {};
|
|
57
|
+
if (screenshots && outDir) {
|
|
58
|
+
componentScreenshots = await captureComponentScreenshots(page, outDir);
|
|
44
59
|
}
|
|
45
|
-
}
|
|
46
60
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
} else {
|
|
62
|
-
await context.close();
|
|
63
|
-
}
|
|
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
|
+
}
|
|
64
75
|
|
|
65
|
-
|
|
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
|
+
}
|
|
66
93
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
});
|
|
74
110
|
}
|
|
75
|
-
// Deduplicate media queries and keyframes
|
|
76
|
-
lightData.mediaQueries = [...new Set(lightData.mediaQueries)];
|
|
77
|
-
const seenKf = new Set();
|
|
78
|
-
lightData.keyframes = lightData.keyframes.filter(kf => {
|
|
79
|
-
if (seenKf.has(kf.name)) return false;
|
|
80
|
-
seenKf.add(kf.name);
|
|
81
|
-
return true;
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
111
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
+
}
|
|
92
122
|
}
|
|
93
123
|
|
|
94
124
|
function mergeStyles(primary, additionalPages) {
|
|
@@ -158,19 +188,39 @@ export async function captureComponentScreenshots(page, outDir) {
|
|
|
158
188
|
return result;
|
|
159
189
|
}
|
|
160
190
|
|
|
161
|
-
async function extractPageData(page) {
|
|
162
|
-
|
|
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
|
+
|
|
163
204
|
const results = {
|
|
164
205
|
computedStyles: [],
|
|
165
206
|
cssVariables: {},
|
|
166
207
|
mediaQueries: [],
|
|
167
208
|
keyframes: [],
|
|
209
|
+
crossOriginSheets: [],
|
|
168
210
|
};
|
|
169
211
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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, []);
|
|
174
224
|
|
|
175
225
|
for (const el of elements) {
|
|
176
226
|
const cs = getComputedStyle(el);
|
|
@@ -201,7 +251,10 @@ async function extractPageData(page) {
|
|
|
201
251
|
marginLeft: cs.marginLeft,
|
|
202
252
|
gap: cs.gap,
|
|
203
253
|
borderRadius: cs.borderRadius,
|
|
254
|
+
borderWidth: cs.borderWidth,
|
|
255
|
+
borderStyle: cs.borderStyle,
|
|
204
256
|
boxShadow: cs.boxShadow,
|
|
257
|
+
textShadow: cs.textShadow,
|
|
205
258
|
zIndex: cs.zIndex,
|
|
206
259
|
transition: cs.transition,
|
|
207
260
|
animation: cs.animation,
|
|
@@ -232,7 +285,7 @@ async function extractPageData(page) {
|
|
|
232
285
|
}
|
|
233
286
|
}
|
|
234
287
|
}
|
|
235
|
-
} catch {
|
|
288
|
+
} catch { if (sheet.href) results.crossOriginSheets.push(sheet.href); }
|
|
236
289
|
}
|
|
237
290
|
} catch { /* no access */ }
|
|
238
291
|
|
|
@@ -252,7 +305,7 @@ async function extractPageData(page) {
|
|
|
252
305
|
results.mediaQueries.push(rule.conditionText || rule.media.mediaText);
|
|
253
306
|
}
|
|
254
307
|
}
|
|
255
|
-
} catch { /* cross-origin */ }
|
|
308
|
+
} catch { /* cross-origin — already tracked */ }
|
|
256
309
|
}
|
|
257
310
|
} catch { /* no access */ }
|
|
258
311
|
|
|
@@ -269,10 +322,122 @@ async function extractPageData(page) {
|
|
|
269
322
|
results.keyframes.push({ name: rule.name, steps });
|
|
270
323
|
}
|
|
271
324
|
}
|
|
272
|
-
} catch { /* cross-origin */ }
|
|
325
|
+
} catch { /* cross-origin — already tracked */ }
|
|
273
326
|
}
|
|
274
327
|
} catch { /* no access */ }
|
|
275
328
|
|
|
329
|
+
// SVG icons
|
|
330
|
+
results.icons = [];
|
|
331
|
+
for (const svg of document.querySelectorAll('svg')) {
|
|
332
|
+
const rect = svg.getBoundingClientRect();
|
|
333
|
+
if (rect.width > 4 && rect.width < 200 && rect.height > 4 && rect.height < 200) {
|
|
334
|
+
results.icons.push({
|
|
335
|
+
svg: svg.outerHTML,
|
|
336
|
+
width: rect.width,
|
|
337
|
+
height: rect.height,
|
|
338
|
+
viewBox: svg.getAttribute('viewBox') || '',
|
|
339
|
+
classList: Array.from(svg.classList).join(' '),
|
|
340
|
+
fill: svg.getAttribute('fill') || getComputedStyle(svg).fill || '',
|
|
341
|
+
stroke: svg.getAttribute('stroke') || getComputedStyle(svg).stroke || '',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Font data
|
|
347
|
+
results.fontData = { fontFaces: [], googleFontsLinks: [], documentFonts: [] };
|
|
348
|
+
try {
|
|
349
|
+
for (const sheet of document.styleSheets) {
|
|
350
|
+
try {
|
|
351
|
+
for (const rule of sheet.cssRules) {
|
|
352
|
+
if (rule instanceof CSSFontFaceRule) {
|
|
353
|
+
results.fontData.fontFaces.push({
|
|
354
|
+
family: rule.style.getPropertyValue('font-family').replace(/['"]/g, ''),
|
|
355
|
+
style: rule.style.getPropertyValue('font-style') || 'normal',
|
|
356
|
+
weight: rule.style.getPropertyValue('font-weight') || '400',
|
|
357
|
+
src: rule.style.getPropertyValue('src') || '',
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
} catch { /* cross-origin — already tracked */ }
|
|
362
|
+
}
|
|
363
|
+
} catch {}
|
|
364
|
+
for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com"]')) {
|
|
365
|
+
results.fontData.googleFontsLinks.push(link.href);
|
|
366
|
+
}
|
|
367
|
+
for (const font of document.fonts) {
|
|
368
|
+
results.fontData.documentFonts.push({ family: font.family.replace(/['"]/g, ''), style: font.style, weight: font.weight, status: font.status });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Image data
|
|
372
|
+
results.images = [];
|
|
373
|
+
for (const img of document.querySelectorAll('img, picture img, [role="img"]')) {
|
|
374
|
+
const rect = img.getBoundingClientRect();
|
|
375
|
+
if (rect.width < 5 || rect.height < 5) continue;
|
|
376
|
+
const cs = getComputedStyle(img);
|
|
377
|
+
results.images.push({
|
|
378
|
+
tag: img.tagName.toLowerCase(),
|
|
379
|
+
src: img.src || '',
|
|
380
|
+
width: rect.width,
|
|
381
|
+
height: rect.height,
|
|
382
|
+
objectFit: cs.objectFit,
|
|
383
|
+
objectPosition: cs.objectPosition,
|
|
384
|
+
borderRadius: cs.borderRadius,
|
|
385
|
+
filter: cs.filter,
|
|
386
|
+
opacity: cs.opacity,
|
|
387
|
+
aspectRatio: cs.aspectRatio,
|
|
388
|
+
classList: Array.from(img.classList).join(' '),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
276
392
|
return results;
|
|
277
|
-
}, 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
|
+
}
|
|
278
443
|
}
|
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,27 +2,95 @@ 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') {
|
|
8
10
|
transitionSet.add(el.transition);
|
|
9
11
|
|
|
10
|
-
// Extract
|
|
11
|
-
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);
|
|
12
14
|
if (dMatch) dMatch.forEach(d => durationSet.add(d));
|
|
13
15
|
|
|
14
|
-
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);
|
|
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 with full shorthand parsing
|
|
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]);
|
|
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
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Enhanced keyframes with timing and properties changed
|
|
45
|
+
const enhancedKeyframes = keyframes.map(kf => {
|
|
46
|
+
const propertiesAnimated = new Set();
|
|
47
|
+
for (const step of kf.steps) {
|
|
48
|
+
const props = step.style.split(';').map(s => s.split(':')[0].trim()).filter(Boolean);
|
|
49
|
+
props.forEach(p => propertiesAnimated.add(p));
|
|
16
50
|
}
|
|
51
|
+
return {
|
|
52
|
+
name: kf.name,
|
|
53
|
+
steps: kf.steps,
|
|
54
|
+
propertiesAnimated: [...propertiesAnimated],
|
|
55
|
+
isUsed: animationNames.has(kf.name),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Sort transition properties by usage
|
|
60
|
+
const sortedProps = Object.entries(transitionProperties)
|
|
61
|
+
.sort((a, b) => b[1] - a[1])
|
|
62
|
+
.map(([prop, count]) => ({ property: prop, count }));
|
|
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);
|
|
17
86
|
}
|
|
18
87
|
|
|
19
88
|
return {
|
|
20
89
|
transitions: [...transitionSet],
|
|
21
|
-
easings:
|
|
90
|
+
easings: classifiedEasings,
|
|
22
91
|
durations: [...durationSet],
|
|
23
|
-
keyframes:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
})),
|
|
92
|
+
keyframes: enhancedKeyframes,
|
|
93
|
+
transitionProperties: sortedProps,
|
|
94
|
+
animationNames: [...animationNames],
|
|
27
95
|
};
|
|
28
96
|
}
|