design-clone 1.0.2 → 1.1.1
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/SKILL.md +53 -0
- package/bin/cli.js +16 -0
- package/bin/commands/clone-site.js +324 -0
- package/bin/commands/help.js +16 -4
- package/bin/commands/init.js +29 -1
- package/commands/design/clone-site.md +135 -0
- package/docs/troubleshooting.md +72 -0
- package/package.json +2 -1
- package/src/core/css-extractor.js +38 -13
- package/src/core/design-tokens.js +103 -0
- package/src/core/discover-pages.js +314 -0
- package/src/core/html-extractor.js +72 -3
- package/src/core/merge-css.js +407 -0
- package/src/core/multi-page-screenshot.js +377 -0
- package/src/core/rewrite-links.js +226 -0
- package/src/core/screenshot.js +18 -1
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Merge & Deduplication
|
|
3
|
+
*
|
|
4
|
+
* Combines multiple CSS files into a single stylesheet with deduplication.
|
|
5
|
+
* Preserves cascade order (first occurrence wins).
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { mergeCssFiles } from './merge-css.js';
|
|
9
|
+
* const result = await mergeCssFiles(['a.css', 'b.css'], 'merged.css');
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs/promises';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
// Import css-tree (already in package.json)
|
|
16
|
+
let csstree;
|
|
17
|
+
try {
|
|
18
|
+
csstree = await import('css-tree');
|
|
19
|
+
} catch {
|
|
20
|
+
console.error('css-tree not installed. Run: npm install css-tree');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Reuse from filter-css.js
|
|
25
|
+
import { sanitizeCss, validatePath } from './filter-css.js';
|
|
26
|
+
|
|
27
|
+
// Default options
|
|
28
|
+
const DEFAULT_OPTIONS = {
|
|
29
|
+
combineMediaQueries: true,
|
|
30
|
+
deduplicateFontFaces: true,
|
|
31
|
+
deduplicateKeyframes: true,
|
|
32
|
+
removeEmptyRules: true
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate hash for a CSS rule (selector + declarations)
|
|
37
|
+
* @param {Object} node - css-tree Rule node
|
|
38
|
+
* @returns {string} Hash string
|
|
39
|
+
*/
|
|
40
|
+
function getRuleHash(node) {
|
|
41
|
+
const selector = csstree.generate(node.prelude);
|
|
42
|
+
const declarations = csstree.generate(node.block);
|
|
43
|
+
return `${selector}|${declarations}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract font-family value from @font-face rule
|
|
48
|
+
* @param {Object} node - css-tree Atrule node
|
|
49
|
+
* @returns {string} Font family name
|
|
50
|
+
*/
|
|
51
|
+
function extractFontFamily(node) {
|
|
52
|
+
let family = '';
|
|
53
|
+
csstree.walk(node, {
|
|
54
|
+
visit: 'Declaration',
|
|
55
|
+
enter(decl) {
|
|
56
|
+
if (decl.property === 'font-family') {
|
|
57
|
+
family = csstree.generate(decl.value).replace(/["']/g, '').trim();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
return family;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract src value from @font-face rule
|
|
66
|
+
* @param {Object} node - css-tree Atrule node
|
|
67
|
+
* @returns {string} Font src
|
|
68
|
+
*/
|
|
69
|
+
function extractFontSrc(node) {
|
|
70
|
+
let src = '';
|
|
71
|
+
csstree.walk(node, {
|
|
72
|
+
visit: 'Declaration',
|
|
73
|
+
enter(decl) {
|
|
74
|
+
if (decl.property === 'src') {
|
|
75
|
+
src = csstree.generate(decl.value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return src;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract animation name from @keyframes rule
|
|
84
|
+
* @param {Object} node - css-tree Atrule node
|
|
85
|
+
* @returns {string} Animation name
|
|
86
|
+
*/
|
|
87
|
+
function extractKeyframeName(node) {
|
|
88
|
+
return node.prelude ? csstree.generate(node.prelude).trim() : '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Merge multiple CSS strings with deduplication
|
|
93
|
+
* @param {string[]} cssContents - Array of CSS strings
|
|
94
|
+
* @param {Object} options - Merge options
|
|
95
|
+
* @returns {Object} { css, stats }
|
|
96
|
+
*/
|
|
97
|
+
export function mergeStylesheets(cssContents, options = {}) {
|
|
98
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
99
|
+
const stats = {
|
|
100
|
+
inputRules: 0,
|
|
101
|
+
outputRules: 0,
|
|
102
|
+
duplicateRulesRemoved: 0,
|
|
103
|
+
fontFacesDeduped: 0,
|
|
104
|
+
keyframesDeduped: 0,
|
|
105
|
+
mediaQueriesCombined: 0
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Collections for different rule types
|
|
109
|
+
const seenRules = new Map(); // hash -> rule node
|
|
110
|
+
const seenFontFaces = new Map(); // family|src -> node
|
|
111
|
+
const seenKeyframes = new Map(); // name -> node
|
|
112
|
+
const seenCharset = { found: false, node: null };
|
|
113
|
+
const imports = [];
|
|
114
|
+
const mediaGroups = new Map(); // condition -> rules[]
|
|
115
|
+
|
|
116
|
+
// Collected output nodes (in order)
|
|
117
|
+
const outputNodes = [];
|
|
118
|
+
|
|
119
|
+
// Process each CSS file
|
|
120
|
+
for (const css of cssContents) {
|
|
121
|
+
if (!css || typeof css !== 'string') continue;
|
|
122
|
+
|
|
123
|
+
let ast;
|
|
124
|
+
try {
|
|
125
|
+
ast = csstree.parse(css, {
|
|
126
|
+
parseRulePrelude: true,
|
|
127
|
+
parseValue: false
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Skip invalid CSS
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Walk through all nodes
|
|
135
|
+
csstree.walk(ast, {
|
|
136
|
+
visit: 'Atrule',
|
|
137
|
+
enter(node) {
|
|
138
|
+
const name = node.name.toLowerCase();
|
|
139
|
+
|
|
140
|
+
// @charset - keep first only
|
|
141
|
+
if (name === 'charset') {
|
|
142
|
+
if (!seenCharset.found) {
|
|
143
|
+
seenCharset.found = true;
|
|
144
|
+
seenCharset.node = node;
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// @import - keep all in order
|
|
150
|
+
if (name === 'import') {
|
|
151
|
+
imports.push(node);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// @font-face - dedupe by family+src
|
|
156
|
+
if (name === 'font-face') {
|
|
157
|
+
stats.inputRules++;
|
|
158
|
+
if (opts.deduplicateFontFaces) {
|
|
159
|
+
const family = extractFontFamily(node);
|
|
160
|
+
const src = extractFontSrc(node);
|
|
161
|
+
const key = `${family}|${src}`;
|
|
162
|
+
if (!seenFontFaces.has(key)) {
|
|
163
|
+
seenFontFaces.set(key, node);
|
|
164
|
+
outputNodes.push({ type: 'fontface', node });
|
|
165
|
+
} else {
|
|
166
|
+
stats.fontFacesDeduped++;
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
outputNodes.push({ type: 'fontface', node });
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// @keyframes - dedupe by name
|
|
175
|
+
if (name === 'keyframes' || name === '-webkit-keyframes') {
|
|
176
|
+
stats.inputRules++;
|
|
177
|
+
if (opts.deduplicateKeyframes) {
|
|
178
|
+
const animName = extractKeyframeName(node);
|
|
179
|
+
if (!seenKeyframes.has(animName)) {
|
|
180
|
+
seenKeyframes.set(animName, node);
|
|
181
|
+
outputNodes.push({ type: 'keyframes', node });
|
|
182
|
+
} else {
|
|
183
|
+
stats.keyframesDeduped++;
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
outputNodes.push({ type: 'keyframes', node });
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// @media - collect for combining or keep as-is
|
|
192
|
+
if (name === 'media') {
|
|
193
|
+
const condition = node.prelude ? csstree.generate(node.prelude) : '';
|
|
194
|
+
|
|
195
|
+
if (opts.combineMediaQueries && condition) {
|
|
196
|
+
if (!mediaGroups.has(condition)) {
|
|
197
|
+
mediaGroups.set(condition, []);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Extract rules from this media block
|
|
201
|
+
csstree.walk(node.block, {
|
|
202
|
+
visit: 'Rule',
|
|
203
|
+
enter(rule) {
|
|
204
|
+
stats.inputRules++;
|
|
205
|
+
const hash = getRuleHash(rule);
|
|
206
|
+
const groupRules = mediaGroups.get(condition);
|
|
207
|
+
|
|
208
|
+
// Check if already in this media group
|
|
209
|
+
const exists = groupRules.some(r => r.hash === hash);
|
|
210
|
+
if (!exists) {
|
|
211
|
+
groupRules.push({ hash, node: rule });
|
|
212
|
+
} else {
|
|
213
|
+
stats.duplicateRulesRemoved++;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
outputNodes.push({ type: 'atrule', node });
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Other @rules (supports, page, etc.) - keep as-is
|
|
224
|
+
outputNodes.push({ type: 'atrule', node });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Walk regular rules
|
|
229
|
+
csstree.walk(ast, {
|
|
230
|
+
visit: 'Rule',
|
|
231
|
+
enter(node, item, list) {
|
|
232
|
+
// Skip if inside @media (handled above)
|
|
233
|
+
let parent = list;
|
|
234
|
+
while (parent && parent.data) {
|
|
235
|
+
if (parent.data.type === 'Atrule') return;
|
|
236
|
+
parent = parent.parent;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
stats.inputRules++;
|
|
240
|
+
const hash = getRuleHash(node);
|
|
241
|
+
|
|
242
|
+
if (!seenRules.has(hash)) {
|
|
243
|
+
seenRules.set(hash, node);
|
|
244
|
+
outputNodes.push({ type: 'rule', node });
|
|
245
|
+
} else {
|
|
246
|
+
stats.duplicateRulesRemoved++;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Build output AST
|
|
253
|
+
const outputAst = {
|
|
254
|
+
type: 'StyleSheet',
|
|
255
|
+
children: new csstree.List()
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Add @charset first (if any)
|
|
259
|
+
if (seenCharset.node) {
|
|
260
|
+
outputAst.children.push(seenCharset.node);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Add @imports
|
|
264
|
+
for (const imp of imports) {
|
|
265
|
+
outputAst.children.push(imp);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Add collected nodes
|
|
269
|
+
for (const item of outputNodes) {
|
|
270
|
+
outputAst.children.push(item.node);
|
|
271
|
+
if (item.type === 'rule' || item.type === 'fontface' || item.type === 'keyframes') {
|
|
272
|
+
stats.outputRules++;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Add combined media queries
|
|
277
|
+
if (opts.combineMediaQueries) {
|
|
278
|
+
for (const [condition, rules] of mediaGroups) {
|
|
279
|
+
if (rules.length === 0) continue;
|
|
280
|
+
|
|
281
|
+
stats.mediaQueriesCombined++;
|
|
282
|
+
|
|
283
|
+
// Create combined media rule
|
|
284
|
+
const mediaBlock = {
|
|
285
|
+
type: 'Block',
|
|
286
|
+
children: new csstree.List()
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
for (const r of rules) {
|
|
290
|
+
mediaBlock.children.push(r.node);
|
|
291
|
+
stats.outputRules++;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const mediaRule = {
|
|
295
|
+
type: 'Atrule',
|
|
296
|
+
name: 'media',
|
|
297
|
+
prelude: csstree.parse(condition, { context: 'mediaQueryList' }),
|
|
298
|
+
block: mediaBlock
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
outputAst.children.push(mediaRule);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Generate output CSS
|
|
306
|
+
let outputCss = csstree.generate(outputAst);
|
|
307
|
+
|
|
308
|
+
// Sanitize output
|
|
309
|
+
outputCss = sanitizeCss(outputCss);
|
|
310
|
+
|
|
311
|
+
return { css: outputCss, stats };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Merge multiple CSS files into single output file
|
|
316
|
+
* @param {string[]} cssFiles - Array of CSS file paths
|
|
317
|
+
* @param {string} outputPath - Output file path
|
|
318
|
+
* @param {Object} options - Merge options
|
|
319
|
+
* @returns {Promise<Object>} Merge result
|
|
320
|
+
*/
|
|
321
|
+
export async function mergeCssFiles(cssFiles, outputPath, options = {}) {
|
|
322
|
+
const startTime = Date.now();
|
|
323
|
+
|
|
324
|
+
// Read all CSS files
|
|
325
|
+
const cssContents = [];
|
|
326
|
+
let totalInputSize = 0;
|
|
327
|
+
|
|
328
|
+
for (const filePath of cssFiles) {
|
|
329
|
+
try {
|
|
330
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
331
|
+
cssContents.push(content);
|
|
332
|
+
totalInputSize += Buffer.byteLength(content, 'utf-8');
|
|
333
|
+
} catch (err) {
|
|
334
|
+
// Skip files that can't be read
|
|
335
|
+
console.error(`[WARN] Could not read ${filePath}: ${err.message}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (cssContents.length === 0) {
|
|
340
|
+
return {
|
|
341
|
+
success: false,
|
|
342
|
+
error: 'No CSS files could be read',
|
|
343
|
+
input: { files: cssFiles, totalSize: 0, totalRules: 0 },
|
|
344
|
+
output: null,
|
|
345
|
+
stats: null
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Merge stylesheets
|
|
350
|
+
const { css, stats } = mergeStylesheets(cssContents, options);
|
|
351
|
+
|
|
352
|
+
// Write output
|
|
353
|
+
const outputSize = Buffer.byteLength(css, 'utf-8');
|
|
354
|
+
await fs.writeFile(outputPath, css, 'utf-8');
|
|
355
|
+
|
|
356
|
+
const duration = Date.now() - startTime;
|
|
357
|
+
const reduction = totalInputSize > 0
|
|
358
|
+
? Math.round((1 - outputSize / totalInputSize) * 100)
|
|
359
|
+
: 0;
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
success: true,
|
|
363
|
+
input: {
|
|
364
|
+
files: cssFiles,
|
|
365
|
+
fileCount: cssFiles.length,
|
|
366
|
+
totalSize: totalInputSize,
|
|
367
|
+
totalRules: stats.inputRules
|
|
368
|
+
},
|
|
369
|
+
output: {
|
|
370
|
+
path: path.resolve(outputPath),
|
|
371
|
+
size: outputSize,
|
|
372
|
+
rules: stats.outputRules
|
|
373
|
+
},
|
|
374
|
+
stats: {
|
|
375
|
+
...stats,
|
|
376
|
+
reduction: `${reduction}%`,
|
|
377
|
+
durationMs: duration
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// CLI support
|
|
383
|
+
const isMainModule = process.argv[1] && (
|
|
384
|
+
process.argv[1].endsWith('merge-css.js') ||
|
|
385
|
+
process.argv[1].includes('merge-css')
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
if (isMainModule) {
|
|
389
|
+
const args = process.argv.slice(2);
|
|
390
|
+
|
|
391
|
+
if (args.length < 2) {
|
|
392
|
+
console.error('Usage: node merge-css.js <output.css> <input1.css> [input2.css] ...');
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const [outputPath, ...inputFiles] = args;
|
|
397
|
+
|
|
398
|
+
mergeCssFiles(inputFiles, outputPath)
|
|
399
|
+
.then(result => {
|
|
400
|
+
console.log(JSON.stringify(result, null, 2));
|
|
401
|
+
process.exit(result.success ? 0 : 1);
|
|
402
|
+
})
|
|
403
|
+
.catch(err => {
|
|
404
|
+
console.error(JSON.stringify({ success: false, error: err.message }));
|
|
405
|
+
process.exit(1);
|
|
406
|
+
});
|
|
407
|
+
}
|