design-clone 2.1.0 → 3.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.
Files changed (177) hide show
  1. package/README.md +13 -34
  2. package/SKILL.md +69 -45
  3. package/bin/cli.js +22 -4
  4. package/bin/commands/clone-site.js +31 -171
  5. package/bin/commands/help.js +19 -6
  6. package/bin/commands/init.js +9 -86
  7. package/bin/commands/uninstall.js +105 -0
  8. package/bin/commands/update.js +70 -0
  9. package/bin/commands/verify.js +7 -14
  10. package/bin/utils/paths.js +28 -0
  11. package/bin/utils/validate.js +2 -22
  12. package/bin/utils/version.js +23 -0
  13. package/docs/code-standards.md +789 -0
  14. package/docs/codebase-summary.md +533 -286
  15. package/docs/index.md +74 -0
  16. package/docs/project-overview-pdr.md +797 -0
  17. package/docs/system-architecture.md +718 -0
  18. package/package.json +14 -17
  19. package/src/ai/prompts/design-tokens/basic.md +80 -0
  20. package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
  21. package/src/ai/prompts/design-tokens/section.md +48 -0
  22. package/src/ai/prompts/design-tokens/with-css.md +87 -0
  23. package/src/ai/prompts/structure-analysis/basic.md +55 -0
  24. package/src/ai/prompts/structure-analysis/with-context.md +59 -0
  25. package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
  26. package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
  27. package/src/ai/prompts/ux-audit/aggregation.md +42 -0
  28. package/src/ai/prompts/ux-audit/desktop.md +92 -0
  29. package/src/ai/prompts/ux-audit/mobile.md +93 -0
  30. package/src/ai/prompts/ux-audit/tablet.md +92 -0
  31. package/src/core/animation/animation-extractor-ast.js +183 -0
  32. package/src/core/animation/animation-extractor-output.js +152 -0
  33. package/src/core/animation/animation-extractor.js +178 -0
  34. package/src/core/animation/state-capture-detection.js +200 -0
  35. package/src/core/animation/state-capture.js +193 -0
  36. package/src/core/capture/browser-context-pool.js +96 -0
  37. package/src/core/capture/multi-page-screenshot-page.js +110 -0
  38. package/src/core/capture/multi-page-screenshot.js +208 -0
  39. package/src/core/capture/screenshot-extraction.js +186 -0
  40. package/src/core/capture/screenshot-helpers.js +175 -0
  41. package/src/core/capture/screenshot-orchestrator.js +174 -0
  42. package/src/core/capture/screenshot-viewport.js +93 -0
  43. package/src/core/capture/screenshot.js +192 -0
  44. package/src/core/content/content-counter-dom.js +191 -0
  45. package/src/core/content/content-counter.js +76 -0
  46. package/src/core/css/breakpoint-detector.js +66 -0
  47. package/src/core/css/chromium-defaults.json +23 -0
  48. package/src/core/css/computed-style-extractor.js +102 -0
  49. package/src/core/css/css-chunker.js +103 -0
  50. package/src/core/css/filter-css-dead-code.js +120 -0
  51. package/src/core/css/filter-css-html-analyzer.js +110 -0
  52. package/src/core/css/filter-css-selector-matcher.js +172 -0
  53. package/src/core/css/filter-css.js +206 -0
  54. package/src/core/css/merge-css-atrule-processor.js +158 -0
  55. package/src/core/css/merge-css-file-io.js +68 -0
  56. package/src/core/css/merge-css.js +148 -0
  57. package/src/core/detection/framework-detector-routing.js +68 -0
  58. package/src/core/detection/framework-detector-signals.js +65 -0
  59. package/src/core/detection/framework-detector.js +198 -0
  60. package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
  61. package/src/core/dimension/dimension-extractor.js +317 -0
  62. package/src/core/dimension/dimension-output-ai-summary.js +111 -0
  63. package/src/core/dimension/dimension-output.js +173 -0
  64. package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
  65. package/src/core/dimension/dom-tree-analyzer.js +191 -0
  66. package/src/core/discovery/app-state-snapshot-capture.js +195 -0
  67. package/src/core/discovery/app-state-snapshot-utils.js +178 -0
  68. package/src/core/discovery/app-state-snapshot.js +131 -0
  69. package/src/core/discovery/discover-pages-routes.js +84 -0
  70. package/src/core/discovery/discover-pages-utils.js +177 -0
  71. package/src/core/discovery/discover-pages.js +191 -0
  72. package/src/core/html/html-extractor-inline-styler.js +70 -0
  73. package/src/core/html/html-extractor.js +147 -0
  74. package/src/core/html/semantic-enhancer-mappings.js +200 -0
  75. package/src/core/html/semantic-enhancer-page.js +148 -0
  76. package/src/core/html/semantic-enhancer.js +135 -0
  77. package/src/core/links/rewrite-links-css-rewriter.js +53 -0
  78. package/src/core/links/rewrite-links.js +173 -0
  79. package/src/core/media/asset-validator.js +118 -0
  80. package/src/core/media/extract-assets-downloader.js +187 -0
  81. package/src/core/media/extract-assets-page-scraper.js +115 -0
  82. package/src/core/media/extract-assets.js +159 -0
  83. package/src/core/media/video-capture-convert.js +200 -0
  84. package/src/core/media/video-capture.js +201 -0
  85. package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
  86. package/src/core/section/section-cropper-helpers.js +43 -0
  87. package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
  88. package/src/core/section/section-detector-strategies.js +139 -0
  89. package/src/core/section/section-detector-utils.js +100 -0
  90. package/src/core/section/section-detector.js +88 -0
  91. package/src/core/tests/test-section-cropper.js +2 -2
  92. package/src/core/tests/test-section-detector.js +2 -2
  93. package/src/post-process/enhance-assets.js +29 -4
  94. package/src/post-process/fetch-images-unsplash-client.js +123 -0
  95. package/src/post-process/fetch-images.js +60 -263
  96. package/src/post-process/inject-gosnap.js +88 -0
  97. package/src/post-process/inject-icons-svg-replacer.js +76 -0
  98. package/src/post-process/inject-icons.js +47 -200
  99. package/src/route-discoverers/base-discoverer-utils.js +137 -0
  100. package/src/route-discoverers/base-discoverer.js +29 -118
  101. package/src/route-discoverers/index.js +1 -1
  102. package/src/shared/config.js +38 -0
  103. package/src/shared/error-codes.js +31 -0
  104. package/src/shared/viewports.js +46 -0
  105. package/src/utils/browser.js +0 -7
  106. package/src/utils/helpers.js +4 -0
  107. package/src/utils/log.js +12 -0
  108. package/src/utils/playwright-loader.js +76 -0
  109. package/src/utils/playwright.js +3 -69
  110. package/src/utils/progress.js +32 -0
  111. package/src/verification/generate-audit-report-css-fixes.js +52 -0
  112. package/src/verification/generate-audit-report-sections.js +158 -0
  113. package/src/verification/generate-audit-report.js +5 -281
  114. package/src/verification/quality-scorer.js +92 -0
  115. package/src/verification/verify-footer-checks.js +103 -0
  116. package/src/verification/verify-footer-helpers.js +178 -0
  117. package/src/verification/verify-footer.js +23 -381
  118. package/src/verification/verify-header-checks.js +104 -0
  119. package/src/verification/verify-header-helpers.js +156 -0
  120. package/src/verification/verify-header.js +23 -365
  121. package/src/verification/verify-layout-report.js +101 -0
  122. package/src/verification/verify-layout.js +13 -259
  123. package/src/verification/verify-menu-checks.js +104 -0
  124. package/src/verification/verify-menu-helpers.js +112 -0
  125. package/src/verification/verify-menu.js +17 -285
  126. package/src/verification/verify-slider-checks.js +115 -0
  127. package/src/verification/verify-slider-constants.js +65 -0
  128. package/src/verification/verify-slider-helpers.js +164 -0
  129. package/src/verification/verify-slider.js +23 -414
  130. package/.env.example +0 -14
  131. package/docs/basic-clone.md +0 -63
  132. package/docs/cli-reference.md +0 -316
  133. package/docs/design-clone-architecture.md +0 -492
  134. package/docs/pixel-perfect.md +0 -117
  135. package/docs/project-roadmap.md +0 -382
  136. package/docs/troubleshooting.md +0 -170
  137. package/requirements.txt +0 -5
  138. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  139. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  140. package/src/ai/analyze-structure.py +0 -375
  141. package/src/ai/extract-design-tokens.py +0 -782
  142. package/src/ai/prompts/__init__.py +0 -2
  143. package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  144. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  145. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  146. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  147. package/src/ai/prompts/design_tokens.py +0 -316
  148. package/src/ai/prompts/structure_analysis.py +0 -592
  149. package/src/ai/prompts/ux_audit.py +0 -198
  150. package/src/ai/ux-audit.js +0 -596
  151. package/src/core/animation-extractor.js +0 -526
  152. package/src/core/app-state-snapshot.js +0 -511
  153. package/src/core/content-counter.js +0 -342
  154. package/src/core/design-tokens.js +0 -103
  155. package/src/core/dimension-extractor.js +0 -438
  156. package/src/core/dimension-output.js +0 -305
  157. package/src/core/discover-pages.js +0 -542
  158. package/src/core/dom-tree-analyzer.js +0 -298
  159. package/src/core/extract-assets.js +0 -468
  160. package/src/core/filter-css.js +0 -499
  161. package/src/core/framework-detector.js +0 -538
  162. package/src/core/html-extractor.js +0 -212
  163. package/src/core/merge-css.js +0 -407
  164. package/src/core/multi-page-screenshot.js +0 -380
  165. package/src/core/rewrite-links.js +0 -226
  166. package/src/core/screenshot.js +0 -701
  167. package/src/core/section-detector.js +0 -386
  168. package/src/core/semantic-enhancer.js +0 -492
  169. package/src/core/state-capture.js +0 -598
  170. package/src/core/video-capture.js +0 -546
  171. package/src/utils/__init__.py +0 -16
  172. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  173. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  174. package/src/utils/env.py +0 -134
  175. /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
  176. /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
  177. /package/src/core/{page-readiness.js → page-prep/page-readiness.js} +0 -0
@@ -1,212 +0,0 @@
1
- /**
2
- * HTML Extractor
3
- *
4
- * Extract and clean HTML from page, removing scripts,
5
- * event handlers, and framework-specific attributes.
6
- * Optionally enhances with WordPress-compatible semantic structure.
7
- */
8
-
9
- import { LAYOUT_PROPERTIES } from './css-extractor.js';
10
- import { enhanceSemanticHTMLInPage } from './semantic-enhancer.js';
11
-
12
- // Size limits
13
- export const MAX_HTML_SIZE = 10 * 1024 * 1024; // 10MB limit
14
- export const MAX_DOM_ELEMENTS = 50000; // Warn on large DOMs
15
-
16
- // JS framework attribute patterns to remove
17
- export const JS_FRAMEWORK_PATTERNS = [
18
- /^data-react/i, /^data-vue/i, /^data-ng/i, /^ng-/i,
19
- /^data-svelte/i, /^x-/i, /^hx-/i, /^v-/i,
20
- /^data-alpine/i, /^wire:/i, /^@/
21
- ];
22
-
23
- // Properties to inline on critical elements (layout only, not visual)
24
- // Uses shared LAYOUT_PROPERTIES from css-extractor (DRY)
25
- export const INLINE_LAYOUT_PROPS = [
26
- ...LAYOUT_PROPERTIES.display,
27
- ...LAYOUT_PROPERTIES.grid,
28
- ...LAYOUT_PROPERTIES.position,
29
- ...LAYOUT_PROPERTIES.sizing,
30
- ...LAYOUT_PROPERTIES.box.slice(0, 2) // boxSizing, overflow only (skip overflowX/Y, border)
31
- ];
32
-
33
- // Criteria for critical elements (no sticky - avoid scroll context side effects)
34
- export const CRITICAL_DISPLAY = ['flex', 'inline-flex', 'grid', 'inline-grid'];
35
- export const CRITICAL_POSITION = ['absolute', 'fixed'];
36
-
37
- /**
38
- * Extract and clean HTML from page
39
- * @param {Page} page - Playwright page
40
- * @param {Array} frameworkPatterns - Patterns to remove
41
- * @returns {Promise<{html: string, warnings: string[], elementCount: number}>}
42
- */
43
- export async function extractCleanHtml(page, frameworkPatterns = JS_FRAMEWORK_PATTERNS) {
44
- return await page.evaluate(({ patterns, inlineProps, criticalDisplay, criticalPosition }) => {
45
- const warnings = [];
46
-
47
- // Check DOM size
48
- const elementCount = document.querySelectorAll('*').length;
49
- if (elementCount > 50000) {
50
- warnings.push(`Large DOM: ${elementCount} elements`);
51
- }
52
-
53
- // Clone document to avoid modifying live page
54
- const doc = document.documentElement.cloneNode(true);
55
-
56
- // Remove scripts and noscript
57
- doc.querySelectorAll('script, noscript').forEach(el => el.remove());
58
- doc.querySelectorAll('svg script, svg a[href^="javascript:"]').forEach(el => el.remove());
59
-
60
- // Sanitize CSS links
61
- doc.querySelectorAll('link[rel="stylesheet"]').forEach(link => {
62
- const href = link.getAttribute('href') || '';
63
- if (href.startsWith('javascript:') || href.startsWith('data:')) {
64
- link.remove();
65
- }
66
- });
67
-
68
- // Sanitize inline styles
69
- doc.querySelectorAll('style').forEach(style => {
70
- const content = style.textContent || '';
71
- if (content.match(/@import\s+url\s*\(\s*['"]?(javascript|data):/i)) {
72
- style.remove();
73
- }
74
- });
75
-
76
- // Convert patterns to regex
77
- const patternRegexes = patterns.map(p => new RegExp(p.source, p.flags));
78
-
79
- // Remove event handlers and framework attributes
80
- const allElements = doc.querySelectorAll('*');
81
- allElements.forEach(el => {
82
- const attrs = [...el.attributes];
83
- attrs.forEach(attr => {
84
- if (attr.name.startsWith('on')) {
85
- el.removeAttribute(attr.name);
86
- }
87
- if (patternRegexes.some(p => p.test(attr.name))) {
88
- el.removeAttribute(attr.name);
89
- }
90
- });
91
- });
92
-
93
- // Inline computed styles on critical elements (flex/grid/positioned)
94
- // Using index-based matching for reliability
95
- const inlineStyles = [];
96
- let inlinedCount = 0;
97
-
98
- document.querySelectorAll('*').forEach((liveEl, idx) => {
99
- const style = getComputedStyle(liveEl);
100
- const display = style.display;
101
- const position = style.position;
102
-
103
- // Only critical elements (flex/grid containers, absolute/fixed positioned)
104
- if (criticalDisplay.includes(display) || criticalPosition.includes(position)) {
105
- const props = [];
106
- inlineProps.forEach(prop => {
107
- const val = style[prop];
108
- // Skip defaults/empty values
109
- if (val && val !== 'auto' && val !== 'none' && val !== 'normal' &&
110
- val !== '0px' && val !== 'static' && val !== 'visible' &&
111
- val !== 'content-box') {
112
- // Convert camelCase to kebab-case
113
- const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
114
- props.push(`${cssProp}: ${val}`);
115
- }
116
- });
117
-
118
- // Always include display for critical elements
119
- if (!props.some(p => p.startsWith('display:'))) {
120
- props.unshift(`display: ${display}`);
121
- }
122
-
123
- if (props.length > 0) {
124
- inlineStyles.push({ idx, style: props.join('; ') });
125
- }
126
- }
127
- });
128
-
129
- // Apply to cloned doc using index matching
130
- const clonedElements = doc.querySelectorAll('*');
131
- inlineStyles.forEach(({ idx, style }) => {
132
- if (clonedElements[idx]) {
133
- const existing = clonedElements[idx].getAttribute('style') || '';
134
- clonedElements[idx].setAttribute('style',
135
- existing ? `${existing}; ${style}` : style);
136
- inlinedCount++;
137
- }
138
- });
139
-
140
- // Track for warnings
141
- if (inlinedCount > 100) {
142
- warnings.push(`Inlined ${inlinedCount} critical elements`);
143
- }
144
-
145
- // Remove hidden elements
146
- doc.querySelectorAll('[hidden], [style*="display: none"], [style*="display:none"]')
147
- .forEach(el => el.remove());
148
-
149
- // Remove empty style tags
150
- doc.querySelectorAll('style:empty').forEach(el => el.remove());
151
-
152
- // Remove HTML comments
153
- const removeComments = (node) => {
154
- const children = [...node.childNodes];
155
- children.forEach(child => {
156
- if (child.nodeType === 8) {
157
- child.remove();
158
- } else if (child.nodeType === 1) {
159
- removeComments(child);
160
- }
161
- });
162
- };
163
- removeComments(doc);
164
-
165
- // Build clean HTML
166
- const html = '<!DOCTYPE html>\n<html lang="' +
167
- (document.documentElement.lang || 'en') + '">\n' +
168
- doc.innerHTML + '\n</html>';
169
-
170
- return { html, warnings, elementCount, inlinedCount };
171
- }, {
172
- patterns: frameworkPatterns.map(r => ({ source: r.source, flags: r.flags })),
173
- inlineProps: INLINE_LAYOUT_PROPS,
174
- criticalDisplay: CRITICAL_DISPLAY,
175
- criticalPosition: CRITICAL_POSITION
176
- });
177
- }
178
-
179
- /**
180
- * Extract, clean, and optionally enhance HTML with semantic structure
181
- * @param {Page} page - Playwright page
182
- * @param {Object} options - Configuration options
183
- * @param {boolean} [options.enhanceSemantic=true] - Add WordPress semantic IDs/classes/roles
184
- * @param {Array} [options.frameworkPatterns] - Custom framework patterns to remove
185
- * @returns {Promise<{html: string, warnings: string[], elementCount: number, semanticStats?: Object}>}
186
- */
187
- export async function extractAndEnhanceHtml(page, options = {}) {
188
- const {
189
- enhanceSemantic = true,
190
- frameworkPatterns = JS_FRAMEWORK_PATTERNS
191
- } = options;
192
-
193
- // First extract clean HTML
194
- const result = await extractCleanHtml(page, frameworkPatterns);
195
-
196
- // Apply semantic enhancement if enabled
197
- if (enhanceSemantic) {
198
- try {
199
- const enhanced = await enhanceSemanticHTMLInPage(page, result.html);
200
- return {
201
- ...result,
202
- html: enhanced.html,
203
- semanticStats: enhanced.stats
204
- };
205
- } catch (err) {
206
- result.warnings.push(`Semantic enhancement failed: ${err.message}`);
207
- return result;
208
- }
209
- }
210
-
211
- return result;
212
- }
@@ -1,407 +0,0 @@
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
- }