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.
@@ -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
+ }