designlang 1.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/.claude-plugin/marketplace.json +28 -0
- package/.claude-plugin/plugin.json +24 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/design-extract.js +104 -0
- package/package.json +39 -0
- package/skills/extract-design/SKILL.md +59 -0
- package/src/crawler.js +162 -0
- package/src/extractors/animations.js +28 -0
- package/src/extractors/borders.js +31 -0
- package/src/extractors/breakpoints.js +33 -0
- package/src/extractors/colors.js +87 -0
- package/src/extractors/components.js +66 -0
- package/src/extractors/shadows.js +27 -0
- package/src/extractors/spacing.js +37 -0
- package/src/extractors/typography.js +72 -0
- package/src/extractors/variables.js +22 -0
- package/src/formatters/css-vars.js +103 -0
- package/src/formatters/markdown.js +305 -0
- package/src/formatters/tailwind.js +81 -0
- package/src/formatters/tokens.js +61 -0
- package/src/index.js +48 -0
- package/src/utils.js +179 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { pxToRem } from '../utils.js';
|
|
2
|
+
|
|
3
|
+
export function formatMarkdown(design) {
|
|
4
|
+
const lines = [];
|
|
5
|
+
const { meta, colors, typography, spacing, shadows, borders, variables, breakpoints, animations, components } = design;
|
|
6
|
+
|
|
7
|
+
lines.push(`# Design Language: ${meta.title || 'Unknown Site'}`);
|
|
8
|
+
lines.push('');
|
|
9
|
+
lines.push(`> Extracted from \`${meta.url}\` on ${new Date(meta.timestamp).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`);
|
|
10
|
+
lines.push(`> ${meta.elementCount} elements analyzed`);
|
|
11
|
+
lines.push('');
|
|
12
|
+
lines.push('This document describes the complete design language of the website. It is structured for AI/LLM consumption — use it to faithfully recreate the visual design in any framework.');
|
|
13
|
+
lines.push('');
|
|
14
|
+
|
|
15
|
+
// ── Colors ──
|
|
16
|
+
lines.push('## Color Palette');
|
|
17
|
+
lines.push('');
|
|
18
|
+
if (colors.primary) {
|
|
19
|
+
lines.push('### Primary Colors');
|
|
20
|
+
lines.push('');
|
|
21
|
+
lines.push('| Role | Hex | RGB | HSL | Usage Count |');
|
|
22
|
+
lines.push('|------|-----|-----|-----|-------------|');
|
|
23
|
+
if (colors.primary) lines.push(`| Primary | \`${colors.primary.hex}\` | rgb(${colors.primary.rgb.r}, ${colors.primary.rgb.g}, ${colors.primary.rgb.b}) | hsl(${colors.primary.hsl.h}, ${colors.primary.hsl.s}%, ${colors.primary.hsl.l}%) | ${colors.primary.count} |`);
|
|
24
|
+
if (colors.secondary) lines.push(`| Secondary | \`${colors.secondary.hex}\` | rgb(${colors.secondary.rgb.r}, ${colors.secondary.rgb.g}, ${colors.secondary.rgb.b}) | hsl(${colors.secondary.hsl.h}, ${colors.secondary.hsl.s}%, ${colors.secondary.hsl.l}%) | ${colors.secondary.count} |`);
|
|
25
|
+
if (colors.accent) lines.push(`| Accent | \`${colors.accent.hex}\` | rgb(${colors.accent.rgb.r}, ${colors.accent.rgb.g}, ${colors.accent.rgb.b}) | hsl(${colors.accent.hsl.h}, ${colors.accent.hsl.s}%, ${colors.accent.hsl.l}%) | ${colors.accent.count} |`);
|
|
26
|
+
lines.push('');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (colors.neutrals.length > 0) {
|
|
30
|
+
lines.push('### Neutral Colors');
|
|
31
|
+
lines.push('');
|
|
32
|
+
lines.push('| Hex | HSL | Usage Count |');
|
|
33
|
+
lines.push('|-----|-----|-------------|');
|
|
34
|
+
for (const c of colors.neutrals.slice(0, 12)) {
|
|
35
|
+
lines.push(`| \`${c.hex}\` | hsl(${c.hsl.h}, ${c.hsl.s}%, ${c.hsl.l}%) | ${c.count} |`);
|
|
36
|
+
}
|
|
37
|
+
lines.push('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (colors.backgrounds.length > 0) {
|
|
41
|
+
lines.push('### Background Colors');
|
|
42
|
+
lines.push('');
|
|
43
|
+
lines.push(`Used on large-area elements: ${colors.backgrounds.map(h => `\`${h}\``).join(', ')}`);
|
|
44
|
+
lines.push('');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (colors.text.length > 0) {
|
|
48
|
+
lines.push('### Text Colors');
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push(`Text color palette: ${colors.text.map(h => `\`${h}\``).join(', ')}`);
|
|
51
|
+
lines.push('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (colors.gradients.length > 0) {
|
|
55
|
+
lines.push('### Gradients');
|
|
56
|
+
lines.push('');
|
|
57
|
+
for (const g of colors.gradients) {
|
|
58
|
+
lines.push('```css');
|
|
59
|
+
lines.push(`background-image: ${g};`);
|
|
60
|
+
lines.push('```');
|
|
61
|
+
lines.push('');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (colors.all.length > 0) {
|
|
66
|
+
lines.push('### Full Color Inventory');
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push('| Hex | Contexts | Count |');
|
|
69
|
+
lines.push('|-----|----------|-------|');
|
|
70
|
+
for (const c of colors.all.slice(0, 30)) {
|
|
71
|
+
lines.push(`| \`${c.hex}\` | ${c.contexts.join(', ')} | ${c.count} |`);
|
|
72
|
+
}
|
|
73
|
+
lines.push('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Typography ──
|
|
77
|
+
lines.push('## Typography');
|
|
78
|
+
lines.push('');
|
|
79
|
+
|
|
80
|
+
if (typography.families.length > 0) {
|
|
81
|
+
lines.push('### Font Families');
|
|
82
|
+
lines.push('');
|
|
83
|
+
for (const f of typography.families) {
|
|
84
|
+
lines.push(`- **${f.name}** — used for ${f.usage} (${f.count} elements)`);
|
|
85
|
+
}
|
|
86
|
+
lines.push('');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typography.scale.length > 0) {
|
|
90
|
+
lines.push('### Type Scale');
|
|
91
|
+
lines.push('');
|
|
92
|
+
lines.push('| Size (px) | Size (rem) | Weight | Line Height | Letter Spacing | Used On |');
|
|
93
|
+
lines.push('|-----------|------------|--------|-------------|----------------|---------|');
|
|
94
|
+
for (const s of typography.scale.slice(0, 15)) {
|
|
95
|
+
lines.push(`| ${s.size}px | ${pxToRem(s.size)}rem | ${s.weight} | ${s.lineHeight} | ${s.letterSpacing} | ${s.tags.slice(0, 4).join(', ')} |`);
|
|
96
|
+
}
|
|
97
|
+
lines.push('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (typography.headings.length > 0) {
|
|
101
|
+
lines.push('### Heading Scale');
|
|
102
|
+
lines.push('');
|
|
103
|
+
lines.push('```css');
|
|
104
|
+
for (const h of typography.headings) {
|
|
105
|
+
const tag = h.tags.find(t => /^h[1-6]$/.test(t)) || 'h';
|
|
106
|
+
lines.push(`${tag} { font-size: ${h.size}px; font-weight: ${h.weight}; line-height: ${h.lineHeight}; }`);
|
|
107
|
+
}
|
|
108
|
+
lines.push('```');
|
|
109
|
+
lines.push('');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typography.body) {
|
|
113
|
+
lines.push('### Body Text');
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push('```css');
|
|
116
|
+
lines.push(`body { font-size: ${typography.body.size}px; font-weight: ${typography.body.weight}; line-height: ${typography.body.lineHeight}; }`);
|
|
117
|
+
lines.push('```');
|
|
118
|
+
lines.push('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typography.weights.length > 0) {
|
|
122
|
+
lines.push('### Font Weights in Use');
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push(typography.weights.map(w => `\`${w.weight}\` (${w.count}x)`).join(', '));
|
|
125
|
+
lines.push('');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Spacing ──
|
|
129
|
+
lines.push('## Spacing');
|
|
130
|
+
lines.push('');
|
|
131
|
+
if (spacing.base) {
|
|
132
|
+
lines.push(`**Base unit:** ${spacing.base}px`);
|
|
133
|
+
lines.push('');
|
|
134
|
+
}
|
|
135
|
+
if (spacing.scale.length > 0) {
|
|
136
|
+
lines.push('| Token | Value | Rem |');
|
|
137
|
+
lines.push('|-------|-------|-----|');
|
|
138
|
+
for (const v of spacing.scale.slice(0, 20)) {
|
|
139
|
+
lines.push(`| spacing-${v} | ${v}px | ${pxToRem(v)}rem |`);
|
|
140
|
+
}
|
|
141
|
+
lines.push('');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Borders ──
|
|
145
|
+
if (borders.radii.length > 0) {
|
|
146
|
+
lines.push('## Border Radii');
|
|
147
|
+
lines.push('');
|
|
148
|
+
lines.push('| Label | Value | Count |');
|
|
149
|
+
lines.push('|-------|-------|-------|');
|
|
150
|
+
for (const r of borders.radii) {
|
|
151
|
+
lines.push(`| ${r.label} | ${r.value}px | ${r.count} |`);
|
|
152
|
+
}
|
|
153
|
+
lines.push('');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Shadows ──
|
|
157
|
+
if (shadows.values.length > 0) {
|
|
158
|
+
lines.push('## Box Shadows');
|
|
159
|
+
lines.push('');
|
|
160
|
+
for (const s of shadows.values) {
|
|
161
|
+
lines.push(`**${s.label}${s.inset ? ' (inset)' : ''}** — blur: ${s.blur}px`);
|
|
162
|
+
lines.push('```css');
|
|
163
|
+
lines.push(`box-shadow: ${s.raw};`);
|
|
164
|
+
lines.push('```');
|
|
165
|
+
lines.push('');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── CSS Variables ──
|
|
170
|
+
const varCategories = Object.entries(variables).filter(([, v]) => Object.keys(v).length > 0);
|
|
171
|
+
if (varCategories.length > 0) {
|
|
172
|
+
lines.push('## CSS Custom Properties');
|
|
173
|
+
lines.push('');
|
|
174
|
+
for (const [category, vars] of varCategories) {
|
|
175
|
+
lines.push(`### ${category.charAt(0).toUpperCase() + category.slice(1)}`);
|
|
176
|
+
lines.push('');
|
|
177
|
+
lines.push('```css');
|
|
178
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
179
|
+
lines.push(`${name}: ${value};`);
|
|
180
|
+
}
|
|
181
|
+
lines.push('```');
|
|
182
|
+
lines.push('');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Breakpoints ──
|
|
187
|
+
if (breakpoints.length > 0) {
|
|
188
|
+
lines.push('## Breakpoints');
|
|
189
|
+
lines.push('');
|
|
190
|
+
lines.push('| Name | Value | Type |');
|
|
191
|
+
lines.push('|------|-------|------|');
|
|
192
|
+
for (const bp of breakpoints) {
|
|
193
|
+
lines.push(`| ${bp.label} | ${bp.value}px | ${bp.type} |`);
|
|
194
|
+
}
|
|
195
|
+
lines.push('');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Animations ──
|
|
199
|
+
if (animations.transitions.length > 0 || animations.keyframes.length > 0) {
|
|
200
|
+
lines.push('## Transitions & Animations');
|
|
201
|
+
lines.push('');
|
|
202
|
+
|
|
203
|
+
if (animations.easings.length > 0) {
|
|
204
|
+
lines.push(`**Easing functions:** ${animations.easings.map(e => `\`${e}\``).join(', ')}`);
|
|
205
|
+
lines.push('');
|
|
206
|
+
}
|
|
207
|
+
if (animations.durations.length > 0) {
|
|
208
|
+
lines.push(`**Durations:** ${animations.durations.map(d => `\`${d}\``).join(', ')}`);
|
|
209
|
+
lines.push('');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (animations.transitions.length > 0) {
|
|
213
|
+
lines.push('### Common Transitions');
|
|
214
|
+
lines.push('');
|
|
215
|
+
lines.push('```css');
|
|
216
|
+
for (const t of animations.transitions.slice(0, 10)) {
|
|
217
|
+
lines.push(`transition: ${t};`);
|
|
218
|
+
}
|
|
219
|
+
lines.push('```');
|
|
220
|
+
lines.push('');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (animations.keyframes.length > 0) {
|
|
224
|
+
lines.push('### Keyframe Animations');
|
|
225
|
+
lines.push('');
|
|
226
|
+
for (const kf of animations.keyframes.slice(0, 10)) {
|
|
227
|
+
lines.push(`**${kf.name}**`);
|
|
228
|
+
lines.push('```css');
|
|
229
|
+
lines.push(`@keyframes ${kf.name} {`);
|
|
230
|
+
for (const step of kf.steps) {
|
|
231
|
+
lines.push(` ${step.offset} { ${step.style} }`);
|
|
232
|
+
}
|
|
233
|
+
lines.push('}');
|
|
234
|
+
lines.push('```');
|
|
235
|
+
lines.push('');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Components ──
|
|
241
|
+
if (Object.keys(components).length > 0) {
|
|
242
|
+
lines.push('## Component Patterns');
|
|
243
|
+
lines.push('');
|
|
244
|
+
lines.push('Detected UI component patterns and their most common styles:');
|
|
245
|
+
lines.push('');
|
|
246
|
+
|
|
247
|
+
for (const [name, comp] of Object.entries(components)) {
|
|
248
|
+
lines.push(`### ${name.charAt(0).toUpperCase() + name.slice(1)} (${comp.count} instances)`);
|
|
249
|
+
lines.push('');
|
|
250
|
+
lines.push('```css');
|
|
251
|
+
lines.push(`.${name.slice(0, -1)} {`);
|
|
252
|
+
for (const [prop, val] of Object.entries(comp.baseStyle)) {
|
|
253
|
+
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
254
|
+
lines.push(` ${cssProp}: ${val};`);
|
|
255
|
+
}
|
|
256
|
+
lines.push('}');
|
|
257
|
+
lines.push('```');
|
|
258
|
+
lines.push('');
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Dark Mode ──
|
|
263
|
+
if (design.darkMode) {
|
|
264
|
+
lines.push('## Dark Mode');
|
|
265
|
+
lines.push('');
|
|
266
|
+
lines.push('The site has a distinct dark mode color scheme:');
|
|
267
|
+
lines.push('');
|
|
268
|
+
const dc = design.darkMode.colors;
|
|
269
|
+
if (dc.primary) lines.push(`- **Primary:** \`${dc.primary.hex}\``);
|
|
270
|
+
if (dc.secondary) lines.push(`- **Secondary:** \`${dc.secondary.hex}\``);
|
|
271
|
+
if (dc.backgrounds.length > 0) lines.push(`- **Backgrounds:** ${dc.backgrounds.map(h => `\`${h}\``).join(', ')}`);
|
|
272
|
+
if (dc.text.length > 0) lines.push(`- **Text:** ${dc.text.slice(0, 5).map(h => `\`${h}\``).join(', ')}`);
|
|
273
|
+
lines.push('');
|
|
274
|
+
|
|
275
|
+
const darkVars = Object.entries(design.darkMode.variables).filter(([, v]) => Object.keys(v).length > 0);
|
|
276
|
+
if (darkVars.length > 0) {
|
|
277
|
+
lines.push('### Dark Mode CSS Variables');
|
|
278
|
+
lines.push('');
|
|
279
|
+
lines.push('```css');
|
|
280
|
+
for (const [, vars] of darkVars) {
|
|
281
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
282
|
+
lines.push(`${name}: ${value};`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
lines.push('```');
|
|
286
|
+
lines.push('');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Quick Start ──
|
|
291
|
+
lines.push('## Quick Start');
|
|
292
|
+
lines.push('');
|
|
293
|
+
lines.push('To recreate this design in a new project:');
|
|
294
|
+
lines.push('');
|
|
295
|
+
if (typography.families.length > 0) {
|
|
296
|
+
const fontName = typography.families[0].name;
|
|
297
|
+
lines.push(`1. **Install fonts:** Add \`${fontName}\` from Google Fonts or your font provider`);
|
|
298
|
+
}
|
|
299
|
+
lines.push(`2. **Import CSS variables:** Copy \`variables.css\` into your project`);
|
|
300
|
+
lines.push(`3. **Tailwind users:** Use the generated \`tailwind.config.js\` to extend your theme`);
|
|
301
|
+
lines.push(`4. **Design tokens:** Import \`design-tokens.json\` for tooling integration`);
|
|
302
|
+
lines.push('');
|
|
303
|
+
|
|
304
|
+
return lines.join('\n');
|
|
305
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export function formatTailwind(design) {
|
|
2
|
+
const config = {
|
|
3
|
+
colors: {},
|
|
4
|
+
fontFamily: {},
|
|
5
|
+
fontSize: {},
|
|
6
|
+
spacing: {},
|
|
7
|
+
borderRadius: {},
|
|
8
|
+
boxShadow: {},
|
|
9
|
+
screens: {},
|
|
10
|
+
zIndex: {},
|
|
11
|
+
transitionDuration: {},
|
|
12
|
+
transitionTimingFunction: {},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Colors
|
|
16
|
+
if (design.colors.primary) config.colors.primary = design.colors.primary.hex;
|
|
17
|
+
if (design.colors.secondary) config.colors.secondary = design.colors.secondary.hex;
|
|
18
|
+
if (design.colors.accent) config.colors.accent = design.colors.accent.hex;
|
|
19
|
+
for (let i = 0; i < design.colors.neutrals.length && i < 10; i++) {
|
|
20
|
+
config.colors[`neutral-${i * 100 || 50}`] = design.colors.neutrals[i].hex;
|
|
21
|
+
}
|
|
22
|
+
if (design.colors.backgrounds.length > 0) config.colors.background = design.colors.backgrounds[0];
|
|
23
|
+
if (design.colors.text.length > 0) config.colors.foreground = design.colors.text[0];
|
|
24
|
+
|
|
25
|
+
// Typography — first family becomes 'sans', second becomes 'mono' or 'heading'
|
|
26
|
+
for (let i = 0; i < design.typography.families.length; i++) {
|
|
27
|
+
const f = design.typography.families[i];
|
|
28
|
+
let key;
|
|
29
|
+
if (f.usage === 'headings') key = 'heading';
|
|
30
|
+
else if (f.usage === 'body') key = 'body';
|
|
31
|
+
else if (i === 0) key = 'sans';
|
|
32
|
+
else if (f.name.toLowerCase().includes('mono')) key = 'mono';
|
|
33
|
+
else key = i === 1 ? 'heading' : `font${i}`;
|
|
34
|
+
config.fontFamily[key] = [f.name, 'sans-serif'];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const s of design.typography.scale.slice(0, 15)) {
|
|
38
|
+
config.fontSize[`${s.size}`] = [`${s.size}px`, { lineHeight: s.lineHeight, letterSpacing: s.letterSpacing !== 'normal' ? s.letterSpacing : undefined }];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Spacing
|
|
42
|
+
for (const [name, value] of Object.entries(design.spacing.tokens)) {
|
|
43
|
+
config.spacing[name] = value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Border radius
|
|
47
|
+
for (const r of design.borders.radii) {
|
|
48
|
+
config.borderRadius[r.label] = `${r.value}px`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Shadows
|
|
52
|
+
for (const s of design.shadows.values) {
|
|
53
|
+
config.boxShadow[s.label] = s.raw;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Breakpoints
|
|
57
|
+
for (const bp of design.breakpoints) {
|
|
58
|
+
if (bp.type === 'min-width') {
|
|
59
|
+
config.screens[bp.label] = `${bp.value}px`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Clean empty objects
|
|
64
|
+
for (const [key, val] of Object.entries(config)) {
|
|
65
|
+
if (typeof val === 'object' && Object.keys(val).length === 0) delete config[key];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const configStr = JSON.stringify(config, null, 4)
|
|
69
|
+
// Unquote simple keys (letters, digits, underscores only)
|
|
70
|
+
.replace(/"([a-zA-Z_]\w*)":/g, '$1:')
|
|
71
|
+
// Replace double quotes with single quotes for values
|
|
72
|
+
.replace(/"/g, "'");
|
|
73
|
+
|
|
74
|
+
return `/** @type {import('tailwindcss').Config} */
|
|
75
|
+
export default {
|
|
76
|
+
theme: {
|
|
77
|
+
extend: ${configStr},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export function formatTokens(design) {
|
|
2
|
+
const tokens = {};
|
|
3
|
+
|
|
4
|
+
// Colors
|
|
5
|
+
tokens.color = {};
|
|
6
|
+
if (design.colors.primary) tokens.color.primary = { $value: design.colors.primary.hex, $type: 'color' };
|
|
7
|
+
if (design.colors.secondary) tokens.color.secondary = { $value: design.colors.secondary.hex, $type: 'color' };
|
|
8
|
+
if (design.colors.accent) tokens.color.accent = { $value: design.colors.accent.hex, $type: 'color' };
|
|
9
|
+
|
|
10
|
+
for (let i = 0; i < design.colors.neutrals.length && i < 10; i++) {
|
|
11
|
+
tokens.color[`neutral-${i}`] = { $value: design.colors.neutrals[i].hex, $type: 'color' };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < design.colors.backgrounds.length; i++) {
|
|
15
|
+
tokens.color[`background-${i}`] = { $value: design.colors.backgrounds[i], $type: 'color' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < design.colors.text.length && i < 5; i++) {
|
|
19
|
+
tokens.color[`text-${i}`] = { $value: design.colors.text[i], $type: 'color' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Typography
|
|
23
|
+
tokens.fontFamily = {};
|
|
24
|
+
for (const f of design.typography.families) {
|
|
25
|
+
tokens.fontFamily[f.usage === 'headings' ? 'heading' : f.usage === 'body' ? 'body' : f.name.toLowerCase().replace(/\s+/g, '-')] = {
|
|
26
|
+
$value: f.name,
|
|
27
|
+
$type: 'fontFamily',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
tokens.fontSize = {};
|
|
32
|
+
for (const s of design.typography.scale.slice(0, 15)) {
|
|
33
|
+
tokens.fontSize[`${s.size}`] = { $value: `${s.size}px`, $type: 'dimension' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Spacing
|
|
37
|
+
tokens.spacing = {};
|
|
38
|
+
for (const [name, value] of Object.entries(design.spacing.tokens)) {
|
|
39
|
+
tokens.spacing[name] = { $value: value, $type: 'dimension' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Border radius
|
|
43
|
+
tokens.borderRadius = {};
|
|
44
|
+
for (const r of design.borders.radii) {
|
|
45
|
+
tokens.borderRadius[r.label] = { $value: `${r.value}px`, $type: 'dimension' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Shadows
|
|
49
|
+
tokens.shadow = {};
|
|
50
|
+
for (const s of design.shadows.values) {
|
|
51
|
+
tokens.shadow[s.label] = { $value: s.raw, $type: 'shadow' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Breakpoints
|
|
55
|
+
tokens.breakpoint = {};
|
|
56
|
+
for (const bp of design.breakpoints) {
|
|
57
|
+
tokens.breakpoint[bp.label] = { $value: `${bp.value}px`, $type: 'dimension' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return JSON.stringify(tokens, null, 2);
|
|
61
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { crawlPage } from './crawler.js';
|
|
2
|
+
import { extractColors } from './extractors/colors.js';
|
|
3
|
+
import { extractTypography } from './extractors/typography.js';
|
|
4
|
+
import { extractSpacing } from './extractors/spacing.js';
|
|
5
|
+
import { extractShadows } from './extractors/shadows.js';
|
|
6
|
+
import { extractBorders } from './extractors/borders.js';
|
|
7
|
+
import { extractVariables } from './extractors/variables.js';
|
|
8
|
+
import { extractBreakpoints } from './extractors/breakpoints.js';
|
|
9
|
+
import { extractAnimations } from './extractors/animations.js';
|
|
10
|
+
import { extractComponents } from './extractors/components.js';
|
|
11
|
+
|
|
12
|
+
export async function extractDesignLanguage(url, options = {}) {
|
|
13
|
+
const rawData = await crawlPage(url, options);
|
|
14
|
+
const styles = rawData.light.computedStyles;
|
|
15
|
+
|
|
16
|
+
const design = {
|
|
17
|
+
meta: {
|
|
18
|
+
url: rawData.url,
|
|
19
|
+
title: rawData.title,
|
|
20
|
+
timestamp: new Date().toISOString(),
|
|
21
|
+
elementCount: styles.length,
|
|
22
|
+
},
|
|
23
|
+
colors: extractColors(styles),
|
|
24
|
+
typography: extractTypography(styles),
|
|
25
|
+
spacing: extractSpacing(styles),
|
|
26
|
+
shadows: extractShadows(styles),
|
|
27
|
+
borders: extractBorders(styles),
|
|
28
|
+
variables: extractVariables(rawData.light.cssVariables),
|
|
29
|
+
breakpoints: extractBreakpoints(rawData.light.mediaQueries),
|
|
30
|
+
animations: extractAnimations(styles, rawData.light.keyframes),
|
|
31
|
+
components: extractComponents(styles),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (rawData.dark) {
|
|
35
|
+
design.darkMode = {
|
|
36
|
+
colors: extractColors(rawData.dark.computedStyles),
|
|
37
|
+
variables: extractVariables(rawData.dark.cssVariables),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return design;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { crawlPage } from './crawler.js';
|
|
45
|
+
export { formatTokens } from './formatters/tokens.js';
|
|
46
|
+
export { formatMarkdown } from './formatters/markdown.js';
|
|
47
|
+
export { formatTailwind } from './formatters/tailwind.js';
|
|
48
|
+
export { formatCssVars } from './formatters/css-vars.js';
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Named CSS colors (subset — the 17 standard + common extras)
|
|
2
|
+
const NAMED_COLORS = {
|
|
3
|
+
transparent: { r: 0, g: 0, b: 0, a: 0 },
|
|
4
|
+
black: { r: 0, g: 0, b: 0, a: 1 },
|
|
5
|
+
white: { r: 255, g: 255, b: 255, a: 1 },
|
|
6
|
+
red: { r: 255, g: 0, b: 0, a: 1 },
|
|
7
|
+
green: { r: 0, g: 128, b: 0, a: 1 },
|
|
8
|
+
blue: { r: 0, g: 0, b: 255, a: 1 },
|
|
9
|
+
yellow: { r: 255, g: 255, b: 0, a: 1 },
|
|
10
|
+
cyan: { r: 0, g: 255, b: 255, a: 1 },
|
|
11
|
+
magenta: { r: 255, g: 0, b: 255, a: 1 },
|
|
12
|
+
gray: { r: 128, g: 128, b: 128, a: 1 },
|
|
13
|
+
grey: { r: 128, g: 128, b: 128, a: 1 },
|
|
14
|
+
orange: { r: 255, g: 165, b: 0, a: 1 },
|
|
15
|
+
purple: { r: 128, g: 0, b: 128, a: 1 },
|
|
16
|
+
pink: { r: 255, g: 192, b: 203, a: 1 },
|
|
17
|
+
navy: { r: 0, g: 0, b: 128, a: 1 },
|
|
18
|
+
teal: { r: 0, g: 128, b: 128, a: 1 },
|
|
19
|
+
silver: { r: 192, g: 192, b: 192, a: 1 },
|
|
20
|
+
maroon: { r: 128, g: 0, b: 0, a: 1 },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function parseColor(str) {
|
|
24
|
+
if (!str || str === 'none' || str === 'currentcolor' || str === 'inherit' || str === 'initial') return null;
|
|
25
|
+
str = str.trim().toLowerCase();
|
|
26
|
+
|
|
27
|
+
if (NAMED_COLORS[str]) return { ...NAMED_COLORS[str] };
|
|
28
|
+
|
|
29
|
+
// hex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
|
|
30
|
+
if (str.startsWith('#')) {
|
|
31
|
+
const hex = str.slice(1);
|
|
32
|
+
if (hex.length === 3) return { r: parseInt(hex[0] + hex[0], 16), g: parseInt(hex[1] + hex[1], 16), b: parseInt(hex[2] + hex[2], 16), a: 1 };
|
|
33
|
+
if (hex.length === 4) return { r: parseInt(hex[0] + hex[0], 16), g: parseInt(hex[1] + hex[1], 16), b: parseInt(hex[2] + hex[2], 16), a: parseInt(hex[3] + hex[3], 16) / 255 };
|
|
34
|
+
if (hex.length === 6) return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), a: 1 };
|
|
35
|
+
if (hex.length === 8) return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), a: parseInt(hex.slice(6, 8), 16) / 255 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// rgb(r, g, b) or rgba(r, g, b, a)
|
|
39
|
+
const rgbMatch = str.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/);
|
|
40
|
+
if (rgbMatch) {
|
|
41
|
+
return { r: +rgbMatch[1], g: +rgbMatch[2], b: +rgbMatch[3], a: rgbMatch[4] !== undefined ? +rgbMatch[4] : 1 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Modern syntax: rgb(r g b / a)
|
|
45
|
+
const rgbModern = str.match(/rgba?\(\s*(\d+)\s+(\d+)\s+(\d+)\s*(?:\/\s*([\d.]+%?))?\s*\)/);
|
|
46
|
+
if (rgbModern) {
|
|
47
|
+
let a = 1;
|
|
48
|
+
if (rgbModern[4] !== undefined) {
|
|
49
|
+
a = rgbModern[4].endsWith('%') ? parseFloat(rgbModern[4]) / 100 : +rgbModern[4];
|
|
50
|
+
}
|
|
51
|
+
return { r: +rgbModern[1], g: +rgbModern[2], b: +rgbModern[3], a };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// hsl(h, s%, l%) or hsla(h, s%, l%, a)
|
|
55
|
+
const hslMatch = str.match(/hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+))?\s*\)/);
|
|
56
|
+
if (hslMatch) {
|
|
57
|
+
const rgb = hslToRgb(+hslMatch[1], +hslMatch[2], +hslMatch[3]);
|
|
58
|
+
return { ...rgb, a: hslMatch[4] !== undefined ? +hslMatch[4] : 1 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function rgbToHex({ r, g, b }) {
|
|
65
|
+
const toHex = (n) => Math.round(n).toString(16).padStart(2, '0');
|
|
66
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function rgbToHsl({ r, g, b }) {
|
|
70
|
+
r /= 255; g /= 255; b /= 255;
|
|
71
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
72
|
+
const l = (max + min) / 2;
|
|
73
|
+
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
|
|
74
|
+
const d = max - min;
|
|
75
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
76
|
+
let h;
|
|
77
|
+
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
78
|
+
else if (max === g) h = ((b - r) / d + 2) / 6;
|
|
79
|
+
else h = ((r - g) / d + 4) / 6;
|
|
80
|
+
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hslToRgb(h, s, l) {
|
|
84
|
+
h /= 360; s /= 100; l /= 100;
|
|
85
|
+
if (s === 0) { const v = Math.round(l * 255); return { r: v, g: v, b: v }; }
|
|
86
|
+
const hue2rgb = (p, q, t) => {
|
|
87
|
+
if (t < 0) t += 1;
|
|
88
|
+
if (t > 1) t -= 1;
|
|
89
|
+
if (t < 1/6) return p + (q - p) * 6 * t;
|
|
90
|
+
if (t < 1/2) return q;
|
|
91
|
+
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
|
92
|
+
return p;
|
|
93
|
+
};
|
|
94
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
95
|
+
const p = 2 * l - q;
|
|
96
|
+
return {
|
|
97
|
+
r: Math.round(hue2rgb(p, q, h + 1/3) * 255),
|
|
98
|
+
g: Math.round(hue2rgb(p, q, h) * 255),
|
|
99
|
+
b: Math.round(hue2rgb(p, q, h - 1/3) * 255),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function colorDistance(c1, c2) {
|
|
104
|
+
return Math.sqrt((c1.r - c2.r) ** 2 + (c1.g - c2.g) ** 2 + (c1.b - c2.b) ** 2);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isSaturated({ r, g, b }) {
|
|
108
|
+
const { s } = rgbToHsl({ r, g, b });
|
|
109
|
+
return s > 10;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function clusterColors(colors, threshold = 15) {
|
|
113
|
+
const clusters = [];
|
|
114
|
+
for (const color of colors) {
|
|
115
|
+
const existing = clusters.find(c => colorDistance(c.representative, color.parsed) < threshold);
|
|
116
|
+
if (existing) {
|
|
117
|
+
existing.members.push(color);
|
|
118
|
+
existing.count += color.count;
|
|
119
|
+
} else {
|
|
120
|
+
clusters.push({ representative: color.parsed, hex: color.hex, members: [color], count: color.count });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return clusters.sort((a, b) => b.count - a.count);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function clusterValues(values, threshold) {
|
|
127
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
128
|
+
const groups = [];
|
|
129
|
+
for (const v of sorted) {
|
|
130
|
+
const last = groups[groups.length - 1];
|
|
131
|
+
if (last && Math.abs(v - last.representative) <= threshold) {
|
|
132
|
+
last.members.push(v);
|
|
133
|
+
} else {
|
|
134
|
+
groups.push({ representative: v, members: [v] });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return groups.map(g => g.representative);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function parseCSSValue(str) {
|
|
141
|
+
if (!str || str === 'normal' || str === 'auto' || str === 'none') return null;
|
|
142
|
+
const match = str.match(/^([\d.]+)(px|rem|em|%|vw|vh|pt)?$/);
|
|
143
|
+
if (!match) return null;
|
|
144
|
+
return { value: parseFloat(match[1]), unit: match[2] || '' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function remToPx(rem, base = 16) {
|
|
148
|
+
return rem * base;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function pxToRem(px, base = 16) {
|
|
152
|
+
return +(px / base).toFixed(4);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function safeName(str) {
|
|
156
|
+
return str.replace(/[^a-zA-Z0-9-_]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function nameFromUrl(url) {
|
|
160
|
+
try {
|
|
161
|
+
const hostname = new URL(url).hostname;
|
|
162
|
+
return safeName(hostname.replace(/^www\./, ''));
|
|
163
|
+
} catch {
|
|
164
|
+
return 'unknown-site';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function detectScale(values) {
|
|
169
|
+
if (values.length < 3) return { base: null, scale: values };
|
|
170
|
+
const candidates = [2, 4, 6, 8];
|
|
171
|
+
let bestBase = null;
|
|
172
|
+
let bestScore = 0;
|
|
173
|
+
for (const base of candidates) {
|
|
174
|
+
const score = values.filter(v => v > 0 && v % base === 0).length / values.length;
|
|
175
|
+
if (score > bestScore) { bestScore = score; bestBase = base; }
|
|
176
|
+
}
|
|
177
|
+
if (bestScore >= 0.6) return { base: bestBase, scale: values };
|
|
178
|
+
return { base: null, scale: values };
|
|
179
|
+
}
|