designlang 11.1.0 → 11.3.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/SECURITY.md +46 -0
- package/bin/design-extract.js +23 -0
- package/marketplace/SUBMISSION-PLAYBOOK.md +95 -0
- package/marketplace/chrome-listing.md +45 -0
- package/marketplace/claude-code-skill.md +52 -0
- package/marketplace/cursor-listing.md +55 -0
- package/marketplace/figma-listing.md +50 -0
- package/marketplace/raycast-listing.md +38 -0
- package/marketplace/vscode-listing.md +46 -0
- package/package.json +1 -1
- package/src/chat.js +356 -0
- package/src/clone.js +6 -2
- package/src/crawler.js +18 -8
- package/src/extractors/background-patterns.js +15 -4
- package/src/extractors/material-language.js +4 -2
- package/src/extractors/motion.js +14 -7
- package/src/formatters/design-md.js +319 -0
- package/src/formatters/markdown.js +1 -1
- package/src/formatters/prompt-pack.js +1 -1
- package/src/history.js +2 -4
- package/src/studio.js +15 -5
- package/src/sync.js +14 -18
package/src/chat.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// designlang chat — REPL over a live extraction.
|
|
2
|
+
//
|
|
3
|
+
// Heuristic-only in v12.0: the operations below cover the cases real users
|
|
4
|
+
// reach for first. LLM fallback ships in v12.1 (--smart). The router parses
|
|
5
|
+
// natural-ish English ("sharpen radii", "make it brutalist", "swap primary
|
|
6
|
+
// to #ff4800") into structured operations on the design object, re-derives
|
|
7
|
+
// tokens, and prints a tight diff.
|
|
8
|
+
|
|
9
|
+
import { createInterface } from 'readline';
|
|
10
|
+
import { stdin as input, stdout as output } from 'process';
|
|
11
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs';
|
|
12
|
+
import { join, resolve } from 'path';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { extractDesignLanguage } from './index.js';
|
|
15
|
+
import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
|
|
16
|
+
import { formatDesignMd } from './formatters/design-md.js';
|
|
17
|
+
import { formatTailwind } from './formatters/tailwind.js';
|
|
18
|
+
import { formatCssVars } from './formatters/css-vars.js';
|
|
19
|
+
import { nameFromUrl } from './utils.js';
|
|
20
|
+
|
|
21
|
+
function isHex(s) {
|
|
22
|
+
return typeof s === 'string' && /^#[0-9a-f]{3,8}$/i.test(s.trim());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hexToRgb(hex) {
|
|
26
|
+
const m = String(hex).trim().toLowerCase().replace(/^#/, '');
|
|
27
|
+
const full = m.length === 3 ? m.split('').map((c) => c + c).join('') : m.slice(0, 6);
|
|
28
|
+
return {
|
|
29
|
+
r: parseInt(full.slice(0, 2), 16) || 0,
|
|
30
|
+
g: parseInt(full.slice(2, 4), 16) || 0,
|
|
31
|
+
b: parseInt(full.slice(4, 6), 16) || 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rgbToHex({ r, g, b }) {
|
|
36
|
+
return '#' + [r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function opSharpenRadii(design, factor = 0.5) {
|
|
40
|
+
const radii = design.borders?.radii || [];
|
|
41
|
+
const next = radii.map((r) => ({ ...r, value: Math.max(0, Math.round((r.value || 0) * factor)) }));
|
|
42
|
+
const changes = next.map((r, i) => `${r.label || 'r' + i}: ${radii[i].value}px → ${r.value}px`);
|
|
43
|
+
return {
|
|
44
|
+
design: { ...design, borders: { ...(design.borders || {}), radii: next } },
|
|
45
|
+
changes: ['radii sharpened', ...changes],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function opSoftenRadii(design, factor = 2) {
|
|
50
|
+
const radii = design.borders?.radii || [];
|
|
51
|
+
const next = radii.map((r) => ({ ...r, value: Math.min(64, Math.round((r.value || 0) * factor) || 4) }));
|
|
52
|
+
const changes = next.map((r, i) => `${r.label || 'r' + i}: ${radii[i].value}px → ${r.value}px`);
|
|
53
|
+
return {
|
|
54
|
+
design: { ...design, borders: { ...(design.borders || {}), radii: next } },
|
|
55
|
+
changes: ['radii softened', ...changes],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function opDarkMode(design) {
|
|
60
|
+
const colors = design.colors || {};
|
|
61
|
+
const bgs = colors.backgrounds || ['#ffffff'];
|
|
62
|
+
const txt = colors.text || ['#171717'];
|
|
63
|
+
const swapped = { ...colors, backgrounds: txt.slice(), text: bgs.slice() };
|
|
64
|
+
return {
|
|
65
|
+
design: { ...design, colors: swapped },
|
|
66
|
+
changes: [
|
|
67
|
+
`background: ${bgs[0]} → ${txt[0]}`,
|
|
68
|
+
`foreground: ${txt[0]} → ${bgs[0]}`,
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function opMakeBrutalist(design) {
|
|
74
|
+
const radii = (design.borders?.radii || []).map((r) => ({ ...r, value: 0 }));
|
|
75
|
+
const shadows = (design.shadows?.values || []).map((s) => ({
|
|
76
|
+
...s,
|
|
77
|
+
raw: '4px 4px 0 0 currentColor',
|
|
78
|
+
value: '4px 4px 0 0 currentColor',
|
|
79
|
+
}));
|
|
80
|
+
const families = (design.typography?.families || []).slice();
|
|
81
|
+
const monoFam = families.find((f) => /mono|consol|courier|jet|sf-mono|geist mono/i.test(f.name)) || { name: 'JetBrains Mono', count: 1, weights: [400] };
|
|
82
|
+
return {
|
|
83
|
+
design: {
|
|
84
|
+
...design,
|
|
85
|
+
borders: { ...(design.borders || {}), radii },
|
|
86
|
+
shadows: { ...(design.shadows || {}), values: shadows },
|
|
87
|
+
typography: {
|
|
88
|
+
...(design.typography || {}),
|
|
89
|
+
families: [monoFam, ...families.filter((f) => f !== monoFam)].slice(0, 3),
|
|
90
|
+
},
|
|
91
|
+
materialLanguage: { ...(design.materialLanguage || {}), label: 'brutalist', confidence: 1.0 },
|
|
92
|
+
},
|
|
93
|
+
changes: [
|
|
94
|
+
'radii → 0 (sharp corners)',
|
|
95
|
+
'shadows → hard offset (4px 4px 0 0)',
|
|
96
|
+
'primary font → mono',
|
|
97
|
+
'material → brutalist',
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function opMakeGlass(design) {
|
|
103
|
+
const radii = (design.borders?.radii || []).map((r) => ({
|
|
104
|
+
...r,
|
|
105
|
+
value: Math.max(r.value || 8, 16),
|
|
106
|
+
}));
|
|
107
|
+
const shadows = (design.shadows?.values || []).map((s, i) => ({
|
|
108
|
+
...s,
|
|
109
|
+
raw: `0 ${8 + i * 4}px ${24 + i * 8}px rgba(0,0,0,0.08)`,
|
|
110
|
+
value: `0 ${8 + i * 4}px ${24 + i * 8}px rgba(0,0,0,0.08)`,
|
|
111
|
+
}));
|
|
112
|
+
return {
|
|
113
|
+
design: {
|
|
114
|
+
...design,
|
|
115
|
+
borders: { ...(design.borders || {}), radii },
|
|
116
|
+
shadows: { ...(design.shadows || {}), values: shadows },
|
|
117
|
+
materialLanguage: { ...(design.materialLanguage || {}), label: 'glass', confidence: 1.0 },
|
|
118
|
+
},
|
|
119
|
+
changes: [
|
|
120
|
+
'radii ≥ 16px (rounded)',
|
|
121
|
+
'shadows → soft, depth-stacked',
|
|
122
|
+
'material → glass',
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function opSwapColor(design, role, hex) {
|
|
128
|
+
if (!isHex(hex)) return { design, changes: [`error: ${hex} is not a hex color`] };
|
|
129
|
+
const colors = { ...(design.colors || {}) };
|
|
130
|
+
const before = colors[role]?.hex;
|
|
131
|
+
if (!before) {
|
|
132
|
+
return { design, changes: [`error: no ${role} color in this extraction (try primary, secondary, accent)`] };
|
|
133
|
+
}
|
|
134
|
+
const next = { ...colors[role], hex };
|
|
135
|
+
return {
|
|
136
|
+
design: { ...design, colors: { ...colors, [role]: next } },
|
|
137
|
+
changes: [`${role}: ${before} → ${hex}`],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function opSwapFont(design, name) {
|
|
142
|
+
const families = (design.typography?.families || []).slice();
|
|
143
|
+
const before = families[0]?.name || '—';
|
|
144
|
+
const replaced = [{ name, count: families[0]?.count || 0, weights: families[0]?.weights || [400, 600] }, ...families.slice(1)];
|
|
145
|
+
return {
|
|
146
|
+
design: { ...design, typography: { ...(design.typography || {}), families: replaced } },
|
|
147
|
+
changes: [`primary font: ${before} → ${name}`],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function opReset(_design, original) {
|
|
152
|
+
return { design: structuredClone(original), changes: ['reset to original extraction'] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseCommand(line) {
|
|
156
|
+
const s = String(line).trim().toLowerCase();
|
|
157
|
+
if (!s) return null;
|
|
158
|
+
|
|
159
|
+
if (s === 'help' || s === '?') return { kind: 'help' };
|
|
160
|
+
if (s === 'quit' || s === 'exit' || s === ':q') return { kind: 'quit' };
|
|
161
|
+
if (s === 'reset' || s === 'undo all') return { kind: 'reset' };
|
|
162
|
+
if (s === 'save' || s === 'export' || s === 'write') return { kind: 'save' };
|
|
163
|
+
if (s === 'show' || s === 'print' || s === 'state') return { kind: 'state' };
|
|
164
|
+
if (s.startsWith('show ') || s.startsWith('print ')) {
|
|
165
|
+
return { kind: 'show', what: s.split(/\s+/)[1] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (/(make it |make this |go )?brutalist/.test(s)) return { kind: 'op', op: 'brutalist' };
|
|
169
|
+
if (/(make it |make this |go )?glass(morph)?/.test(s)) return { kind: 'op', op: 'glass' };
|
|
170
|
+
if (/(dark mode|dark theme|invert|go dark)/.test(s)) return { kind: 'op', op: 'dark' };
|
|
171
|
+
if (/sharp(en)?( radii| corners)?/.test(s)) return { kind: 'op', op: 'sharpen' };
|
|
172
|
+
if (/(soft|round)(en)?( radii| corners)?/.test(s)) return { kind: 'op', op: 'soften' };
|
|
173
|
+
|
|
174
|
+
const colorRe = /(primary|secondary|accent)\s*(?:to|=|:)?\s*(#[0-9a-f]{3,8})/i;
|
|
175
|
+
const cm = colorRe.exec(line);
|
|
176
|
+
if (cm) return { kind: 'op', op: 'swap-color', role: cm[1].toLowerCase(), hex: cm[2] };
|
|
177
|
+
|
|
178
|
+
const fontRe = /(?:font|typeface)\s*(?:to|=|:)?\s*([A-Za-z][\w\s-]{1,40})/i;
|
|
179
|
+
const fm = fontRe.exec(line);
|
|
180
|
+
if (fm) return { kind: 'op', op: 'swap-font', name: fm[1].trim() };
|
|
181
|
+
|
|
182
|
+
return { kind: 'unknown', input: line };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function printHelp() {
|
|
186
|
+
console.log('');
|
|
187
|
+
console.log(chalk.bold(' Commands:'));
|
|
188
|
+
const rows = [
|
|
189
|
+
['sharpen / soften', 'halve / double every radius'],
|
|
190
|
+
['dark mode', 'swap background ↔ foreground'],
|
|
191
|
+
['brutalist', 'radii → 0, hard shadows, mono font'],
|
|
192
|
+
['glass', 'rounded radii, soft layered shadows'],
|
|
193
|
+
['primary #ff4800', 'swap a role color (primary | secondary | accent)'],
|
|
194
|
+
['font Inter', 'swap the primary font family'],
|
|
195
|
+
['show / state', 'print current palette + tokens'],
|
|
196
|
+
['reset', 'restore the original extraction'],
|
|
197
|
+
['save', 'write DTCG, Tailwind, CSS vars, DESIGN.md to ./chat-output'],
|
|
198
|
+
['quit', 'exit'],
|
|
199
|
+
];
|
|
200
|
+
for (const [cmd, desc] of rows) {
|
|
201
|
+
console.log(' ' + chalk.cyan(cmd.padEnd(28)) + chalk.gray(desc));
|
|
202
|
+
}
|
|
203
|
+
console.log('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function printState(design) {
|
|
207
|
+
const c = design.colors || {};
|
|
208
|
+
const t = design.typography || {};
|
|
209
|
+
const r = design.borders?.radii || [];
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log(chalk.bold(' Current state'));
|
|
212
|
+
console.log(' ' + chalk.gray('palette:'.padEnd(14)) + [c.primary?.hex, c.secondary?.hex, c.accent?.hex, c.backgrounds?.[0], c.text?.[0]].filter(Boolean).join(' · '));
|
|
213
|
+
console.log(' ' + chalk.gray('font:'.padEnd(14)) + (t.families?.[0]?.name || '—'));
|
|
214
|
+
console.log(' ' + chalk.gray('radii:'.padEnd(14)) + (r.map((x) => `${x.label || '?'}=${x.value}`).join(' · ') || '—'));
|
|
215
|
+
console.log(' ' + chalk.gray('material:'.padEnd(14)) + (design.materialLanguage?.label || 'flat'));
|
|
216
|
+
console.log('');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function applyOp(parsed, current) {
|
|
220
|
+
switch (parsed.op) {
|
|
221
|
+
case 'sharpen': return opSharpenRadii(current);
|
|
222
|
+
case 'soften': return opSoftenRadii(current);
|
|
223
|
+
case 'dark': return opDarkMode(current);
|
|
224
|
+
case 'brutalist': return opMakeBrutalist(current);
|
|
225
|
+
case 'glass': return opMakeGlass(current);
|
|
226
|
+
case 'swap-color': return opSwapColor(current, parsed.role, parsed.hex);
|
|
227
|
+
case 'swap-font': return opSwapFont(current, parsed.name);
|
|
228
|
+
default: return { design: current, changes: ['no-op'] };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function saveDesign(design, outDir) {
|
|
233
|
+
mkdirSync(outDir, { recursive: true });
|
|
234
|
+
const url = design.meta?.url || 'extraction';
|
|
235
|
+
const prefix = nameFromUrl(url);
|
|
236
|
+
const dtcg = formatDtcgTokens(design);
|
|
237
|
+
const written = [];
|
|
238
|
+
const write = (name, content) => {
|
|
239
|
+
const p = join(outDir, name);
|
|
240
|
+
writeFileSync(p, content, 'utf-8');
|
|
241
|
+
written.push(p);
|
|
242
|
+
};
|
|
243
|
+
write(`${prefix}-design-tokens.json`, JSON.stringify(dtcg, null, 2));
|
|
244
|
+
write(`${prefix}-tailwind.config.js`, formatTailwind(design));
|
|
245
|
+
write(`${prefix}-variables.css`, formatCssVars(design));
|
|
246
|
+
write(`${prefix}-DESIGN.md`, formatDesignMd(design));
|
|
247
|
+
return written;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function synthesizeDesignFromTokens(tokens, sourcePath) {
|
|
251
|
+
const findHex = (...paths) => {
|
|
252
|
+
for (const p of paths) {
|
|
253
|
+
const parts = p.split('.');
|
|
254
|
+
let v = tokens;
|
|
255
|
+
for (const k of parts) {
|
|
256
|
+
v = v?.[k];
|
|
257
|
+
if (!v) break;
|
|
258
|
+
}
|
|
259
|
+
if (v && typeof v.$value === 'string') return v.$value;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
};
|
|
263
|
+
const primary = findHex('color.primary', 'primitive.color.brand.primary', 'primitive.color.primary');
|
|
264
|
+
const secondary = findHex('color.secondary');
|
|
265
|
+
const accent = findHex('color.accent', 'primitive.color.brand.accent');
|
|
266
|
+
const bg = findHex('color.background', 'primitive.color.background.bg0', 'primitive.color.neutral.n100');
|
|
267
|
+
const fg = findHex('color.foreground', 'primitive.color.text.text0', 'primitive.color.foreground');
|
|
268
|
+
return {
|
|
269
|
+
meta: { url: `file://${sourcePath}`, title: 'imported tokens' },
|
|
270
|
+
colors: {
|
|
271
|
+
primary: primary ? { hex: primary, count: 1 } : null,
|
|
272
|
+
secondary: secondary ? { hex: secondary, count: 1 } : null,
|
|
273
|
+
accent: accent ? { hex: accent, count: 1 } : null,
|
|
274
|
+
backgrounds: bg ? [bg] : ['#ffffff'],
|
|
275
|
+
text: fg ? [fg] : ['#171717'],
|
|
276
|
+
neutrals: [],
|
|
277
|
+
all: [],
|
|
278
|
+
},
|
|
279
|
+
typography: { families: [{ name: 'system-ui', count: 1, weights: [400, 600] }], headings: [], body: { size: 16 } },
|
|
280
|
+
spacing: { base: 4, scale: [4, 8, 12, 16, 24, 32, 48, 64] },
|
|
281
|
+
shadows: { values: [{ label: 'md', raw: '0 4px 6px rgba(0,0,0,0.1)', value: '0 4px 6px rgba(0,0,0,0.1)' }] },
|
|
282
|
+
borders: { radii: [{ label: 'md', value: 8 }] },
|
|
283
|
+
breakpoints: [],
|
|
284
|
+
components: {},
|
|
285
|
+
variables: {},
|
|
286
|
+
materialLanguage: { label: 'flat', confidence: 0.5 },
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function runChat(target, opts = {}) {
|
|
291
|
+
const outDir = resolve(opts.out || './chat-output');
|
|
292
|
+
|
|
293
|
+
let design;
|
|
294
|
+
if (target && /\.json$/.test(target) && existsSync(target)) {
|
|
295
|
+
console.log(chalk.gray(` Loading tokens from ${target}…`));
|
|
296
|
+
const tokens = JSON.parse(readFileSync(target, 'utf-8'));
|
|
297
|
+
design = synthesizeDesignFromTokens(tokens, target);
|
|
298
|
+
} else {
|
|
299
|
+
let url = String(target);
|
|
300
|
+
if (!url.startsWith('http')) url = `https://${url}`;
|
|
301
|
+
console.log(chalk.gray(` Extracting ${url}… (this takes a few seconds)`));
|
|
302
|
+
design = await extractDesignLanguage(url);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const original = structuredClone(design);
|
|
306
|
+
|
|
307
|
+
console.log('');
|
|
308
|
+
console.log(chalk.bold(' designlang chat'));
|
|
309
|
+
console.log(chalk.gray(' type "help" for commands · Ctrl+D to quit'));
|
|
310
|
+
printState(design);
|
|
311
|
+
|
|
312
|
+
const rl = createInterface({ input, output, prompt: chalk.gray('> ') });
|
|
313
|
+
rl.prompt();
|
|
314
|
+
|
|
315
|
+
for await (const line of rl) {
|
|
316
|
+
const parsed = parseCommand(line);
|
|
317
|
+
if (!parsed) { rl.prompt(); continue; }
|
|
318
|
+
|
|
319
|
+
if (parsed.kind === 'help') { printHelp(); rl.prompt(); continue; }
|
|
320
|
+
if (parsed.kind === 'quit') { rl.close(); break; }
|
|
321
|
+
if (parsed.kind === 'state' || parsed.kind === 'show') { printState(design); rl.prompt(); continue; }
|
|
322
|
+
if (parsed.kind === 'reset') {
|
|
323
|
+
const r = opReset(design, original);
|
|
324
|
+
design = r.design;
|
|
325
|
+
r.changes.forEach((c) => console.log(' ' + chalk.gray('•') + ' ' + c));
|
|
326
|
+
printState(design);
|
|
327
|
+
rl.prompt();
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (parsed.kind === 'save') {
|
|
331
|
+
const files = saveDesign(design, outDir);
|
|
332
|
+
console.log('');
|
|
333
|
+
for (const f of files) console.log(' ' + chalk.green('✓') + ' ' + f);
|
|
334
|
+
console.log('');
|
|
335
|
+
rl.prompt();
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (parsed.kind === 'unknown') {
|
|
339
|
+
console.log(chalk.yellow(` Didn't catch that. Try "help" for commands.`));
|
|
340
|
+
rl.prompt();
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (parsed.kind === 'op') {
|
|
345
|
+
const r = applyOp(parsed, design);
|
|
346
|
+
design = r.design;
|
|
347
|
+
console.log('');
|
|
348
|
+
r.changes.forEach((c) => console.log(' ' + chalk.green('•') + ' ' + c));
|
|
349
|
+
console.log('');
|
|
350
|
+
rl.prompt();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
console.log('');
|
|
355
|
+
console.log(chalk.gray(' bye'));
|
|
356
|
+
}
|
package/src/clone.js
CHANGED
|
@@ -21,7 +21,11 @@ function dedupeConsecutive(order) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function sanitize(str, fallback = '') {
|
|
24
|
-
|
|
24
|
+
// Escape backslash FIRST so the subsequent escapes don't get re-escaped.
|
|
25
|
+
return String(str ?? fallback)
|
|
26
|
+
.replace(/\\/g, '\\\\')
|
|
27
|
+
.replace(/`/g, '\\`')
|
|
28
|
+
.replace(/\$\{/g, '\\${');
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
function titleFromUrl(url = '') {
|
|
@@ -352,7 +356,7 @@ button { font-family: inherit; }
|
|
|
352
356
|
|
|
353
357
|
// layout.js
|
|
354
358
|
writeFileSync(join(projectDir, 'src/app/layout.js'), `export const metadata = {
|
|
355
|
-
title: '${(design.meta.title || 'Cloned Design').replace(/'/g, "\\'")} · cloned',
|
|
359
|
+
title: '${(design.meta.title || 'Cloned Design').replace(/\\/g, '\\\\').replace(/'/g, "\\'")} · cloned',
|
|
356
360
|
description: 'Design cloned from ${url} with designlang.',
|
|
357
361
|
};
|
|
358
362
|
|
package/src/crawler.js
CHANGED
|
@@ -26,6 +26,7 @@ export async function crawlPage(url, options = {}) {
|
|
|
26
26
|
deepInteract = false,
|
|
27
27
|
selector,
|
|
28
28
|
channel,
|
|
29
|
+
wsEndpoint, // Remote browser (e.g. Browserless). When set, skips local launch.
|
|
29
30
|
} = options;
|
|
30
31
|
|
|
31
32
|
const launchArgs = [
|
|
@@ -38,14 +39,23 @@ export async function crawlPage(url, options = {}) {
|
|
|
38
39
|
launchArgs.push('--ignore-certificate-errors', '--ignore-ssl-errors');
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
// Prefer remote browser when wsEndpoint is provided (Browserless v2 / any
|
|
43
|
+
// Playwright-protocol WSS). Skips the @sparticuz/chromium 150MB cold-start
|
|
44
|
+
// tax on Vercel Functions entirely.
|
|
45
|
+
const usingRemote = !!wsEndpoint;
|
|
46
|
+
// Browserless v2 speaks CDP at the root endpoint — connectOverCDP works
|
|
47
|
+
// across Browserless and any other CDP-compatible service. connect() would
|
|
48
|
+
// require Playwright's protocol on a path like /playwright/chromium.
|
|
49
|
+
const browser = usingRemote
|
|
50
|
+
? await chromium.connectOverCDP(wsEndpoint, { timeout: 30000 })
|
|
51
|
+
: await chromium.launch({
|
|
52
|
+
headless: true,
|
|
53
|
+
...(executablePath && { executablePath }),
|
|
54
|
+
// channel: 'chrome' forces Playwright to use the system Chrome install
|
|
55
|
+
// instead of the 150MB bundled Chromium — see --system-chrome.
|
|
56
|
+
...(channel && { channel }),
|
|
57
|
+
args: launchArgs,
|
|
58
|
+
});
|
|
49
59
|
try {
|
|
50
60
|
const context = await browser.newContext({
|
|
51
61
|
viewport: { width, height },
|
|
@@ -7,18 +7,29 @@
|
|
|
7
7
|
// Pure function — reads `rawData.light.computedStyles`, which every extractor
|
|
8
8
|
// already has access to, plus the `modernColors` and any collected svgs.
|
|
9
9
|
|
|
10
|
+
// All pattern detectors operate on a length-capped string. Adversarial CSS
|
|
11
|
+
// background-image values (data URIs in particular) can run several KB; cap
|
|
12
|
+
// to 4KB so the regexes can never run quadratic over megabyte payloads.
|
|
13
|
+
const MAX_BG_LEN = 4096;
|
|
14
|
+
function cap(s) {
|
|
15
|
+
return typeof s === 'string' ? s.slice(0, MAX_BG_LEN) : '';
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
function looksLikeDotGrid(image) {
|
|
11
|
-
|
|
19
|
+
const s = cap(image);
|
|
20
|
+
// Bounded inner content (.{0,256}) instead of unbounded .* — no nested quantifier risk.
|
|
21
|
+
return /radial-gradient\([^)]{0,256}\)/i.test(s) && /repeat/i.test(s) && /\d{1,4}px\s{0,4}\d{1,4}px/.test(s);
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
function looksLikeLineGrid(image) {
|
|
15
25
|
// repeating-linear-gradient with a narrow colored band.
|
|
16
|
-
return /repeating-linear-gradient/i.test(image);
|
|
26
|
+
return /repeating-linear-gradient/i.test(cap(image));
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
function looksLikeNoise(image) {
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
const s = cap(image);
|
|
31
|
+
// Bounded character class instead of .+ — `.+` could backtrack on long data URIs.
|
|
32
|
+
return /feTurbulence|data:image\/svg[^"']{0,2048}fractalNoise/i.test(s) || /noise\.(png|svg|webp)/i.test(s);
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
function countRadialGradients(image) {
|
|
@@ -51,11 +51,13 @@ function shadowComplexity(shadowValues) {
|
|
|
51
51
|
if (!shadowValues.length) return { profile: 'none', avgBlur: 0, maxBlur: 0, insetCount: 0, hardShadowCount: 0, hasPair: false };
|
|
52
52
|
let insetCount = 0, hardShadowCount = 0, totalBlur = 0, maxBlur = 0, pairCount = 0;
|
|
53
53
|
for (const v of shadowValues) {
|
|
54
|
-
|
|
54
|
+
// Cap to defang ReDoS on adversarial CSS shadow values. Real values are <500 chars.
|
|
55
|
+
const raw = (typeof v === 'string' ? v : (v.value || '')).slice(0, 2000);
|
|
55
56
|
if (/inset/i.test(raw)) insetCount++;
|
|
56
57
|
// Blur is the third length in `offset-x offset-y blur [spread] color`. The
|
|
57
58
|
// `px` unit is common but optional — `0 0` is a valid zero-blur shadow.
|
|
58
|
-
|
|
59
|
+
// Bounded digit counts prevent polynomial backtracking on long digit runs.
|
|
60
|
+
const blurs = [...raw.matchAll(/(-?\d{1,8}(?:\.\d{1,4})?)(?:px)?\s+(-?\d{1,8}(?:\.\d{1,4})?)(?:px)?\s+(\d{1,8}(?:\.\d{1,4})?)(?:px)?/g)];
|
|
59
61
|
for (const m of blurs) {
|
|
60
62
|
const blur = parseFloat(m[3]);
|
|
61
63
|
totalBlur += blur;
|
package/src/extractors/motion.js
CHANGED
|
@@ -92,18 +92,25 @@ export function extractMotion(computedStyles, keyframes = []) {
|
|
|
92
92
|
|
|
93
93
|
for (const el of computedStyles) {
|
|
94
94
|
let isAnimating = false;
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
// Cap inputs to defang any pathological CSS that could trigger
|
|
96
|
+
// polynomial-time regex backtracking. Real values are <200 chars.
|
|
97
|
+
const transition = (el.transition || '').slice(0, 2000);
|
|
98
|
+
if (transition && transition !== 'all 0s ease 0s' && transition !== 'none') {
|
|
99
|
+
transitions.add(transition);
|
|
97
100
|
isAnimating = true;
|
|
98
|
-
|
|
99
|
-
for (const m of
|
|
100
|
-
|
|
101
|
+
// Tightened: bounded \d{1,8} and bounded fractional part — no nested quantifiers.
|
|
102
|
+
for (const m of transition.matchAll(/(?<![(\d])(\d{1,8}(?:\.\d{1,4})?m?s)(?![)\w])/g)) durations.push(MS(m[1]));
|
|
103
|
+
// Tightened: limit cubic-bezier/steps inner content to 64 chars.
|
|
104
|
+
for (const m of transition.matchAll(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\([^)]{1,64}\)|steps\([^)]{1,64}\))/g)) easingRaw.add(m[1]);
|
|
105
|
+
for (const part of transition.split(',')) {
|
|
101
106
|
const prop = part.trim().split(/\s+/)[0];
|
|
102
107
|
if (prop && prop !== 'all') transitionedProps[prop] = (transitionedProps[prop] || 0) + 1;
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
const animation = (el.animation || '').slice(0, 2000);
|
|
111
|
+
if (animation && animation !== 'none 0s ease 0s 1 normal none running' && animation !== 'none') {
|
|
112
|
+
// Tightened: bound the identifier length so backtracking is linear.
|
|
113
|
+
const nameMatch = animation.match(/([a-zA-Z_][\w-]{0,127})$/) || animation.match(/^([a-zA-Z_][\w-]{0,127})/);
|
|
107
114
|
if (nameMatch) {
|
|
108
115
|
const name = nameMatch[1];
|
|
109
116
|
if (name !== 'none' && name !== 'running' && name !== 'paused') {
|