designlang 4.0.0 → 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/README.md +56 -2
- package/bin/design-extract.js +76 -1
- package/package.json +1 -1
- package/src/apply.js +65 -0
- package/src/crawler.js +91 -8
- 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/interactions.js +3 -2
- package/src/extractors/responsive.js +3 -2
- package/src/extractors/zindex.js +65 -0
- package/src/formatters/markdown.js +98 -0
- package/src/index.js +13 -1
- package/website/.claude/launch.json +11 -0
- package/website/AGENTS.md +5 -0
- package/website/CLAUDE.md +1 -0
- package/website/README.md +36 -0
- package/website/app/api/extract/route.js +85 -0
- package/website/app/components/Extractor.js +184 -0
- package/website/app/favicon.ico +0 -0
- package/website/app/globals.css +723 -0
- package/website/app/layout.js +19 -0
- package/website/app/page.js +175 -0
- package/website/jsconfig.json +7 -0
- package/website/next.config.mjs +15 -0
- package/website/package-lock.json +1268 -0
- package/website/package.json +18 -0
- package/website/public/file.svg +1 -0
- package/website/public/globe.svg +1 -0
- package/website/public/next.svg +1 -0
- package/website/public/vercel.svg +1 -0
- package/website/public/window.svg +1 -0
- package/designlang.png +0 -0
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<h1 align="center">
|
|
2
|
+
<h1 align="center">DESIGNLANG</h1>
|
|
3
3
|
<p align="center">Reverse-engineer any website's complete design system in one command.</p>
|
|
4
4
|
</p>
|
|
5
5
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
<a href="https://www.npmjs.com/package/designlang"><img src="https://img.shields.io/npm/v/designlang?color=blue&label=npm" alt="npm version"></a>
|
|
8
8
|
<a href="https://github.com/Manavarya09/design-extract/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Manavarya09/design-extract" alt="license"></a>
|
|
9
9
|
<a href="https://nodejs.org"><img src="https://img.shields.io/node/v/designlang" alt="node version"></a>
|
|
10
|
+
<a href="https://website-five-lime-65.vercel.app"><img src="https://img.shields.io/badge/website-live-red" alt="website"></a>
|
|
10
11
|
</p>
|
|
11
12
|
|
|
12
13
|
---
|
|
@@ -127,6 +128,47 @@ designlang brands stripe.com vercel.com github.com linear.app
|
|
|
127
128
|
|
|
128
129
|
Generates a matrix with color overlap analysis, typography comparison, spacing systems, and accessibility scores. Outputs both `brands.md` and `brands.html`.
|
|
129
130
|
|
|
131
|
+
### 6. Clone Command
|
|
132
|
+
|
|
133
|
+
Generate a working Next.js app with the extracted design applied:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
designlang clone https://stripe.com
|
|
137
|
+
cd cloned-design && npm install && npm run dev
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
One command → a running app with the site's colors, fonts, spacing, and component patterns.
|
|
141
|
+
|
|
142
|
+
### 7. Design System Scoring
|
|
143
|
+
|
|
144
|
+
Rate any site's design quality across 7 categories:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
designlang score https://vercel.com
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
68/100 Grade: D
|
|
152
|
+
|
|
153
|
+
Color Discipline ██████████░░░░░░░░░░ 50
|
|
154
|
+
Typography ██████████████░░░░░░ 70
|
|
155
|
+
Spacing System ████████████████░░░░ 80
|
|
156
|
+
Shadows ██████████░░░░░░░░░░ 50
|
|
157
|
+
Border Radii ████████░░░░░░░░░░░░ 40
|
|
158
|
+
Accessibility ███████████████████░ 94
|
|
159
|
+
Tokenization ████████████████████ 100
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### 8. Watch Mode
|
|
163
|
+
|
|
164
|
+
Monitor a site for design changes:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
designlang watch https://stripe.com --interval 60
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Checks hourly and alerts when colors, fonts, or accessibility scores change.
|
|
171
|
+
|
|
130
172
|
## All Features
|
|
131
173
|
|
|
132
174
|
| Feature | Flag / Command | Description |
|
|
@@ -134,12 +176,16 @@ Generates a matrix with color overlap analysis, typography comparison, spacing s
|
|
|
134
176
|
| Base extraction | `designlang <url>` | Colors, typography, spacing, shadows, radii, CSS vars, breakpoints, animations, components |
|
|
135
177
|
| Layout system | automatic | Grid patterns, flex usage, container widths, gap values |
|
|
136
178
|
| Accessibility | automatic | WCAG 2.1 contrast ratios for all fg/bg pairs |
|
|
179
|
+
| Design scoring | automatic | 7-category quality rating (A-F) with actionable issues |
|
|
137
180
|
| Dark mode | `--dark` | Extracts dark color scheme |
|
|
138
181
|
| Multi-page | `--depth <n>` | Crawl N internal pages for site-wide tokens |
|
|
139
182
|
| Screenshots | `--screenshots` | Capture buttons, cards, inputs, nav, hero, full page |
|
|
140
183
|
| Responsive | `--responsive` | Crawl at 4 viewports, map breakpoint changes |
|
|
141
184
|
| Interactions | `--interactions` | Capture hover/focus/active state transitions |
|
|
142
185
|
| Everything | `--full` | Enable screenshots + responsive + interactions |
|
|
186
|
+
| Clone | `designlang clone <url>` | Generate a working Next.js starter with extracted design |
|
|
187
|
+
| Score | `designlang score <url>` | Rate design quality with visual bar chart breakdown |
|
|
188
|
+
| Watch | `designlang watch <url>` | Monitor for design changes on interval |
|
|
143
189
|
| Diff | `designlang diff <A> <B>` | Compare two sites (MD + HTML) |
|
|
144
190
|
| Multi-brand | `designlang brands <urls...>` | N-site comparison matrix |
|
|
145
191
|
| Sync | `designlang sync <url>` | Update local tokens from live site |
|
|
@@ -167,6 +213,9 @@ Options:
|
|
|
167
213
|
--verbose Detailed progress output
|
|
168
214
|
|
|
169
215
|
Commands:
|
|
216
|
+
clone <url> Generate a working Next.js starter from extracted design
|
|
217
|
+
score <url> Rate design quality (7 categories, A-F, bar chart)
|
|
218
|
+
watch <url> Monitor for design changes on interval
|
|
170
219
|
diff <urlA> <urlB> Compare two sites' design languages
|
|
171
220
|
brands <urls...> Multi-brand comparison matrix
|
|
172
221
|
sync <url> Sync local tokens with live site
|
|
@@ -203,12 +252,13 @@ Running `designlang https://vercel.com --full`:
|
|
|
203
252
|
Shadows: 11 unique shadows
|
|
204
253
|
Radii: 10 unique values
|
|
205
254
|
Breakpoints: 45 breakpoints
|
|
206
|
-
Components:
|
|
255
|
+
Components: 11 types detected
|
|
207
256
|
CSS Vars: 407 custom properties
|
|
208
257
|
Layout: 55 grids, 492 flex containers
|
|
209
258
|
Responsive: 4 viewports, 3 breakpoint changes
|
|
210
259
|
Interactions: 8 state changes captured
|
|
211
260
|
A11y: 94% WCAG score (7 failing pairs)
|
|
261
|
+
Design Score: 68/100 (D) — 4 issues
|
|
212
262
|
```
|
|
213
263
|
|
|
214
264
|
## How It Works
|
|
@@ -230,6 +280,10 @@ npx skills add Manavarya09/design-extract
|
|
|
230
280
|
|
|
231
281
|
In Claude Code, use `/extract-design <url>`.
|
|
232
282
|
|
|
283
|
+
## Website
|
|
284
|
+
|
|
285
|
+
**[website-five-lime-65.vercel.app](https://website-five-lime-65.vercel.app)** — the brutalist product page.
|
|
286
|
+
|
|
233
287
|
## Contributing
|
|
234
288
|
|
|
235
289
|
See [CONTRIBUTING.md](CONTRIBUTING.md). PRs welcome!
|
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,18 +5,36 @@ 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
|
-
await page.goto(url, { waitUntil: '
|
|
33
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
34
|
+
// Wait for network to settle — but don't hang on sites with persistent connections
|
|
35
|
+
await page.waitForLoadState('networkidle').catch(() => {});
|
|
18
36
|
if (wait > 0) await page.waitForTimeout(wait);
|
|
19
|
-
await page.evaluate(() => document.fonts.ready);
|
|
37
|
+
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
20
38
|
|
|
21
39
|
const title = await page.title();
|
|
22
40
|
const lightData = await extractPageData(page);
|
|
@@ -33,8 +51,9 @@ export async function crawlPage(url, options = {}) {
|
|
|
33
51
|
const internalLinks = await discoverInternalLinks(page, url, depth);
|
|
34
52
|
for (const link of internalLinks) {
|
|
35
53
|
try {
|
|
36
|
-
await page.goto(link, { waitUntil: '
|
|
37
|
-
await page.
|
|
54
|
+
await page.goto(link, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
55
|
+
await page.waitForLoadState('networkidle').catch(() => {});
|
|
56
|
+
await page.evaluate(() => document.fonts.ready).catch(() => {});
|
|
38
57
|
const pageData = await extractPageData(page);
|
|
39
58
|
additionalPages.push({ url: link, data: pageData });
|
|
40
59
|
} catch { /* skip failed pages */ }
|
|
@@ -50,8 +69,9 @@ export async function crawlPage(url, options = {}) {
|
|
|
50
69
|
colorScheme: 'dark',
|
|
51
70
|
});
|
|
52
71
|
const darkPage = await darkContext.newPage();
|
|
53
|
-
await darkPage.goto(url, { waitUntil: '
|
|
54
|
-
await darkPage.
|
|
72
|
+
await darkPage.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
73
|
+
await darkPage.waitForLoadState('networkidle').catch(() => {});
|
|
74
|
+
await darkPage.evaluate(() => document.fonts.ready).catch(() => {});
|
|
55
75
|
darkData = await extractPageData(darkPage);
|
|
56
76
|
await darkContext.close();
|
|
57
77
|
} else {
|
|
@@ -269,6 +289,69 @@ async function extractPageData(page) {
|
|
|
269
289
|
}
|
|
270
290
|
} catch { /* no access */ }
|
|
271
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
|
+
|
|
272
355
|
return results;
|
|
273
356
|
}, MAX_ELEMENTS);
|
|
274
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) {
|