design-clone 2.1.0 → 2.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.
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
@@ -0,0 +1,172 @@
1
+ /**
2
+ * CSS Selector Matcher for CSS Filtering
3
+ *
4
+ * Checks whether CSS selectors match elements in analyzed HTML.
5
+ * Uses css-tree AST walking to evaluate TypeSelector, IdSelector,
6
+ * ClassSelector nodes against the HTML analysis result sets.
7
+ */
8
+
9
+ import { ALWAYS_KEEP_PATTERNS } from './filter-css-html-analyzer.js';
10
+
11
+ /**
12
+ * Check if a single CSS selector matches any element in the HTML.
13
+ * @param {Object} selectorAst - css-tree Selector AST node
14
+ * @param {Object} htmlAnalysis - Result from analyzeHtml
15
+ * @param {Object} csstree - css-tree module reference
16
+ * @returns {boolean}
17
+ */
18
+ export function selectorMatches(selectorAst, htmlAnalysis, csstree) {
19
+ const { tags, ids, classes } = htmlAnalysis;
20
+ let matches = true;
21
+ let hasSpecificSelector = false;
22
+
23
+ csstree.walk(selectorAst, {
24
+ enter(node) {
25
+ switch (node.type) {
26
+ case 'TypeSelector':
27
+ hasSpecificSelector = true;
28
+ if (node.name !== '*' && !tags.has(node.name.toLowerCase())) {
29
+ matches = false;
30
+ }
31
+ break;
32
+
33
+ case 'IdSelector':
34
+ hasSpecificSelector = true;
35
+ if (!ids.has(node.name)) {
36
+ matches = false;
37
+ }
38
+ break;
39
+
40
+ case 'ClassSelector':
41
+ hasSpecificSelector = true;
42
+ if (!classes.has(node.name)) {
43
+ matches = false;
44
+ }
45
+ break;
46
+
47
+ case 'AttributeSelector':
48
+ // Be lenient — hard to check attribute values accurately
49
+ hasSpecificSelector = true;
50
+ break;
51
+
52
+ case 'PseudoClassSelector':
53
+ case 'PseudoElementSelector':
54
+ // Always keep — state-based or decorative
55
+ break;
56
+ }
57
+ }
58
+ });
59
+
60
+ // No specific selectors found → keep the rule
61
+ if (!hasSpecificSelector) return true;
62
+
63
+ return matches;
64
+ }
65
+
66
+ /**
67
+ * Check if any selector in a SelectorList matches the HTML.
68
+ * @param {Object} selectorList - css-tree SelectorList AST node
69
+ * @param {Object} htmlAnalysis - Result from analyzeHtml
70
+ * @param {Object} csstree - css-tree module reference
71
+ * @returns {boolean}
72
+ */
73
+ export function selectorListMatches(selectorList, htmlAnalysis, csstree) {
74
+ let anyMatch = false;
75
+
76
+ csstree.walk(selectorList, {
77
+ visit: 'Selector',
78
+ enter(node) {
79
+ if (selectorMatches(node, htmlAnalysis, csstree)) {
80
+ anyMatch = true;
81
+ }
82
+ }
83
+ });
84
+
85
+ return anyMatch;
86
+ }
87
+
88
+ /**
89
+ * Check if a selector text should always be kept (html, body, *, :root).
90
+ * @param {string} selectorText
91
+ * @returns {boolean}
92
+ */
93
+ export function shouldAlwaysKeep(selectorText) {
94
+ return ALWAYS_KEEP_PATTERNS.some(pattern => pattern.test(selectorText.trim()));
95
+ }
96
+
97
+ /**
98
+ * Filter CSS AST rules based on HTML analysis.
99
+ * Mutates the AST in-place by removing non-matching rules.
100
+ * @param {Object} cssAst - css-tree AST
101
+ * @param {Object} htmlAnalysis - Result from analyzeHtml
102
+ * @param {Object} csstree - css-tree module reference
103
+ * @param {boolean} verbose - Enable verbose logging
104
+ * @returns {Promise<{ totalRules: number, keptRules: number, removedRules: number, atRules: number, mediaQueries: number, deadCode: Object|null }>}
105
+ */
106
+ export async function filterCssRules(cssAst, htmlAnalysis, csstree, verbose, aggressiveFilter = false) {
107
+ const stats = {
108
+ totalRules: 0,
109
+ keptRules: 0,
110
+ removedRules: 0,
111
+ atRules: 0,
112
+ mediaQueries: 0
113
+ };
114
+
115
+ const nodesToRemove = [];
116
+
117
+ csstree.walk(cssAst, {
118
+ visit: 'Rule',
119
+ enter(node, item, list) {
120
+ stats.totalRules++;
121
+
122
+ if (node.prelude && node.prelude.type === 'SelectorList') {
123
+ const selectorText = csstree.generate(node.prelude);
124
+
125
+ if (shouldAlwaysKeep(selectorText)) {
126
+ stats.keptRules++;
127
+ return;
128
+ }
129
+
130
+ if (!selectorListMatches(node.prelude, htmlAnalysis, csstree)) {
131
+ nodesToRemove.push({ item, list });
132
+ stats.removedRules++;
133
+ } else {
134
+ stats.keptRules++;
135
+ }
136
+ } else {
137
+ stats.keptRules++;
138
+ }
139
+ }
140
+ });
141
+
142
+ for (const { item, list } of nodesToRemove) {
143
+ if (list) list.remove(item);
144
+ }
145
+
146
+ csstree.walk(cssAst, {
147
+ visit: 'Atrule',
148
+ enter(node) {
149
+ stats.atRules++;
150
+ if (node.name === 'media') stats.mediaQueries++;
151
+ }
152
+ });
153
+
154
+ // Pass 2: dead code removal (aggressive mode only)
155
+ let deadCode = null;
156
+ if (aggressiveFilter) {
157
+ try {
158
+ const { runDeadCodePass } = await import('./filter-css-dead-code.js');
159
+ deadCode = runDeadCodePass(cssAst, csstree);
160
+ } catch { /* dead code pass optional */ }
161
+ }
162
+
163
+ if (verbose) {
164
+ console.error(`[CSS Filter] Total rules: ${stats.totalRules}`);
165
+ console.error(`[CSS Filter] Kept: ${stats.keptRules} (${Math.round(stats.keptRules / stats.totalRules * 100)}%)`);
166
+ console.error(`[CSS Filter] Removed: ${stats.removedRules}`);
167
+ console.error(`[CSS Filter] At-rules: ${stats.atRules} (${stats.mediaQueries} media queries)`);
168
+ if (deadCode) console.error(`[CSS Filter] Dead code: ${deadCode.emptyMediaRemoved} empty @media, ${deadCode.orphanKeyframesRemoved} orphan @keyframes, ${deadCode.unusedCustomPropsRemoved} unused vars`);
169
+ }
170
+
171
+ return { ...stats, deadCode };
172
+ }
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Filter CSS to remove unused selectors
4
+ *
5
+ * Usage:
6
+ * node filter-css.js --html source.html --css source-raw.css --output source.css
7
+ *
8
+ * Options:
9
+ * --html Path to cleaned HTML file (required)
10
+ * --css Path to raw CSS file (required)
11
+ * --output Path for filtered CSS output (required)
12
+ * --verbose Enable verbose logging
13
+ *
14
+ * Uses css-tree for AST parsing and selector analysis.
15
+ * Memory: Max 10MB CSS input. Large files may cause high memory usage during AST parsing.
16
+ * Reduction: Typical 20-30% reduction. Complex selectors kept conservatively.
17
+ */
18
+
19
+ import fs from 'fs/promises';
20
+ import path from 'path';
21
+ import { parseArgs } from '../../utils/helpers.js';
22
+ import { SIZE_LIMITS } from '../../shared/config.js';
23
+ import { filterCssRules } from './filter-css-selector-matcher.js';
24
+ import { analyzeHtml, validatePath, sanitizeCss } from './filter-css-html-analyzer.js';
25
+ import { createError } from '../../shared/error-codes.js';
26
+
27
+ // Dependency check for css-tree
28
+ let csstree;
29
+ try {
30
+ csstree = await import('css-tree');
31
+ } catch {
32
+ console.error(JSON.stringify({
33
+ success: false,
34
+ error: 'css-tree not installed',
35
+ hint: 'Run: npm install css-tree'
36
+ }, null, 2));
37
+ process.exit(1);
38
+ }
39
+
40
+ /**
41
+ * Main filtering function
42
+ * @param {string} htmlPath - Path to HTML file
43
+ * @param {string} cssPath - Path to raw CSS file
44
+ * @param {string} outputPath - Path for filtered CSS output
45
+ * @param {boolean} verbose - Enable verbose logging
46
+ * @param {string|null} allowedDir - Base directory for path validation (optional)
47
+ * @returns {Promise<Object>} Result object
48
+ */
49
+ async function filterCssFile(htmlPath, cssPath, outputPath, verbose = false, allowedDir = null, aggressiveFilter = false) {
50
+ const startTime = Date.now();
51
+
52
+ const resolvedHtml = allowedDir ? validatePath(htmlPath, allowedDir) : path.resolve(htmlPath);
53
+ const resolvedCss = allowedDir ? validatePath(cssPath, allowedDir) : path.resolve(cssPath);
54
+ const resolvedOutput = allowedDir ? validatePath(outputPath, allowedDir) : path.resolve(outputPath);
55
+
56
+ let html, css;
57
+ try {
58
+ [html, css] = await Promise.all([
59
+ fs.readFile(resolvedHtml, 'utf-8'),
60
+ fs.readFile(resolvedCss, 'utf-8')
61
+ ]);
62
+ } catch (readError) {
63
+ const failedFile = readError.path || 'unknown';
64
+ throw new Error(`Failed to read file "${failedFile}": ${readError.message}`);
65
+ }
66
+
67
+ const inputSize = Buffer.byteLength(css, 'utf-8');
68
+
69
+ if (inputSize > SIZE_LIMITS.MAX_CSS_INPUT) {
70
+ throw createError('CSS_SIZE_EXCEEDED', {
71
+ file: resolvedCss,
72
+ size: `${(inputSize / 1024 / 1024).toFixed(1)}MB`,
73
+ limit: `${SIZE_LIMITS.MAX_CSS_INPUT / 1024 / 1024}MB`
74
+ });
75
+ }
76
+
77
+ if (verbose) console.error(`[CSS Filter] Input CSS size: ${(inputSize / 1024).toFixed(1)}KB`);
78
+
79
+ const htmlAnalysis = analyzeHtml(html);
80
+ if (verbose) {
81
+ console.error(`[CSS Filter] HTML Analysis:`);
82
+ console.error(` Tags: ${htmlAnalysis.tags.size}`);
83
+ console.error(` IDs: ${htmlAnalysis.ids.size}`);
84
+ console.error(` Classes: ${htmlAnalysis.classes.size}`);
85
+ console.error(` Attributes: ${htmlAnalysis.attributes.size}`);
86
+ }
87
+
88
+ let filteredCss, stats;
89
+
90
+ // Chunked processing for large CSS files
91
+ if (inputSize > (SIZE_LIMITS.CSS_CHUNK_THRESHOLD || 2 * 1024 * 1024)) {
92
+ if (verbose) console.error(`[CSS Filter] Large CSS (${(inputSize / 1024 / 1024).toFixed(1)}MB) — using chunked processing`);
93
+ try {
94
+ const { splitCssAtTopLevel, processChunks } = await import('./css-chunker.js');
95
+ const chunks = splitCssAtTopLevel(css);
96
+ const result = await processChunks(chunks, async (chunkCss) => {
97
+ const chunkAst = csstree.parse(chunkCss, { parseRulePrelude: true, parseValue: false });
98
+ const chunkStats = await filterCssRules(chunkAst, htmlAnalysis, csstree, false);
99
+ return { css: csstree.generate(chunkAst), stats: chunkStats };
100
+ });
101
+ filteredCss = sanitizeCss(result.css);
102
+ stats = result.stats;
103
+ } catch (chunkError) {
104
+ if (verbose) console.error(`[CSS Filter] Chunked processing failed, falling back to full parse: ${chunkError.message}`);
105
+ // Fall through to standard path below
106
+ filteredCss = null;
107
+ }
108
+ }
109
+
110
+ // Standard full-parse path (or fallback from chunked failure)
111
+ if (!filteredCss) {
112
+ let ast;
113
+ try {
114
+ ast = csstree.parse(css, { parseRulePrelude: true, parseValue: false });
115
+ } catch (parseError) {
116
+ if (verbose) {
117
+ console.error(`[CSS Filter] Parse error: ${parseError.message}`);
118
+ console.error(`[CSS Filter] Attempting lenient parse...`);
119
+ }
120
+ try {
121
+ ast = csstree.parse(css, { parseRulePrelude: false, parseValue: false });
122
+ } catch (lenientError) {
123
+ throw createError('CSS_PARSE_FAILED', { parseError: lenientError.message });
124
+ }
125
+ }
126
+
127
+ stats = await filterCssRules(ast, htmlAnalysis, csstree, verbose, !!aggressiveFilter);
128
+ filteredCss = sanitizeCss(csstree.generate(ast));
129
+ }
130
+
131
+ const outputSize = Buffer.byteLength(filteredCss, 'utf-8');
132
+
133
+ try {
134
+ await fs.writeFile(resolvedOutput, filteredCss, 'utf-8');
135
+ } catch (writeError) {
136
+ throw new Error(`Failed to write output "${resolvedOutput}": ${writeError.message}`);
137
+ }
138
+
139
+ const duration = Date.now() - startTime;
140
+ const reductionPercent = Math.round((1 - outputSize / inputSize) * 100);
141
+
142
+ if (verbose) {
143
+ console.error(`[CSS Filter] Output CSS size: ${(outputSize / 1024).toFixed(1)}KB`);
144
+ console.error(`[CSS Filter] Reduction: ${reductionPercent}%`);
145
+ console.error(`[CSS Filter] Duration: ${duration}ms`);
146
+ }
147
+
148
+ return {
149
+ success: true,
150
+ input: { html: resolvedHtml, css: resolvedCss, cssSize: inputSize },
151
+ output: { path: resolvedOutput, size: outputSize },
152
+ htmlAnalysis: {
153
+ tags: htmlAnalysis.tags.size,
154
+ ids: htmlAnalysis.ids.size,
155
+ classes: htmlAnalysis.classes.size
156
+ },
157
+ stats: {
158
+ ...stats,
159
+ reduction: `${reductionPercent}%`,
160
+ durationMs: duration
161
+ }
162
+ };
163
+ }
164
+
165
+ /**
166
+ * CLI entry point
167
+ */
168
+ async function main() {
169
+ const args = parseArgs(process.argv.slice(2));
170
+
171
+ if (!args.html || !args.css || !args.output) {
172
+ console.error('Usage: node filter-css.js --html source.html --css source-raw.css --output source.css [--verbose]');
173
+ process.exit(1);
174
+ }
175
+
176
+ try {
177
+ const isClonePx = process.argv.some(a => a.includes('clone-px') || a.includes('capture-hover'));
178
+ const aggressive = args['aggressive-filter'] === 'true' ||
179
+ (args['aggressive-filter'] !== 'false' && isClonePx);
180
+ const result = await filterCssFile(
181
+ args.html,
182
+ args.css,
183
+ args.output,
184
+ args.verbose === 'true' || args.verbose === true,
185
+ null,
186
+ aggressive
187
+ );
188
+ console.log(JSON.stringify(result, null, 2));
189
+ process.exit(0);
190
+ } catch (error) {
191
+ console.error(JSON.stringify({ success: false, error: error.message }, null, 2));
192
+ process.exit(1);
193
+ }
194
+ }
195
+
196
+ // Export for module use (backward-compatible — all original exports preserved)
197
+ export { filterCssFile, analyzeHtml, validatePath, sanitizeCss };
198
+
199
+ const isMainModule = process.argv[1] && (
200
+ process.argv[1].endsWith('filter-css.js') ||
201
+ process.argv[1].includes('filter-css')
202
+ );
203
+
204
+ if (isMainModule) {
205
+ main();
206
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * CSS At-Rule Processor for Merging
3
+ *
4
+ * Handles extraction and deduplication of @font-face, @keyframes,
5
+ * @charset, @import, and @media rules during CSS merging.
6
+ * All functions receive the css-tree module as a parameter (no top-level import)
7
+ * so this module stays side-effect free.
8
+ */
9
+
10
+ /**
11
+ * Generate a hash key for a CSS rule (selector + declarations).
12
+ * @param {Object} node - css-tree Rule node
13
+ * @param {Object} csstree - css-tree module
14
+ * @returns {string}
15
+ */
16
+ export function getRuleHash(node, csstree) {
17
+ const selector = csstree.generate(node.prelude);
18
+ const declarations = csstree.generate(node.block);
19
+ return `${selector}|${declarations}`;
20
+ }
21
+
22
+ /**
23
+ * Extract font-family value from a @font-face rule.
24
+ * @param {Object} node - css-tree Atrule node
25
+ * @param {Object} csstree - css-tree module
26
+ * @returns {string}
27
+ */
28
+ export function extractFontFamily(node, csstree) {
29
+ let family = '';
30
+ csstree.walk(node, {
31
+ visit: 'Declaration',
32
+ enter(decl) {
33
+ if (decl.property === 'font-family') {
34
+ family = csstree.generate(decl.value).replace(/["']/g, '').trim();
35
+ }
36
+ }
37
+ });
38
+ return family;
39
+ }
40
+
41
+ /**
42
+ * Extract src value from a @font-face rule.
43
+ * @param {Object} node - css-tree Atrule node
44
+ * @param {Object} csstree - css-tree module
45
+ * @returns {string}
46
+ */
47
+ export function extractFontSrc(node, csstree) {
48
+ let src = '';
49
+ csstree.walk(node, {
50
+ visit: 'Declaration',
51
+ enter(decl) {
52
+ if (decl.property === 'src') {
53
+ src = csstree.generate(decl.value);
54
+ }
55
+ }
56
+ });
57
+ return src;
58
+ }
59
+
60
+ /**
61
+ * Extract animation name from a @keyframes rule.
62
+ * @param {Object} node - css-tree Atrule node
63
+ * @param {Object} csstree - css-tree module
64
+ * @returns {string}
65
+ */
66
+ export function extractKeyframeName(node, csstree) {
67
+ return node.prelude ? csstree.generate(node.prelude).trim() : '';
68
+ }
69
+
70
+ /**
71
+ * Process a single at-rule node, updating the shared collections.
72
+ * Handles: @charset, @import, @font-face, @keyframes, @media, and others.
73
+ *
74
+ * @param {Object} node - css-tree Atrule node
75
+ * @param {Object} csstree - css-tree module
76
+ * @param {Object} opts - Merge options (deduplicateFontFaces, deduplicateKeyframes, combineMediaQueries)
77
+ * @param {Object} collections - Shared mutable state:
78
+ * { seenFontFaces, seenKeyframes, seenCharset, imports, mediaGroups, outputNodes, stats }
79
+ */
80
+ export function processAtrule(node, csstree, opts, collections) {
81
+ const { seenFontFaces, seenKeyframes, seenCharset, imports, mediaGroups, outputNodes, stats } = collections;
82
+ const name = node.name.toLowerCase();
83
+
84
+ if (name === 'charset') {
85
+ if (!seenCharset.found) {
86
+ seenCharset.found = true;
87
+ seenCharset.node = node;
88
+ }
89
+ return;
90
+ }
91
+
92
+ if (name === 'import') {
93
+ imports.push(node);
94
+ return;
95
+ }
96
+
97
+ if (name === 'font-face') {
98
+ stats.inputRules++;
99
+ if (opts.deduplicateFontFaces) {
100
+ const family = extractFontFamily(node, csstree);
101
+ const src = extractFontSrc(node, csstree);
102
+ const key = `${family}|${src}`;
103
+ if (!seenFontFaces.has(key)) {
104
+ seenFontFaces.set(key, node);
105
+ outputNodes.push({ type: 'fontface', node });
106
+ } else {
107
+ stats.fontFacesDeduped++;
108
+ }
109
+ } else {
110
+ outputNodes.push({ type: 'fontface', node });
111
+ }
112
+ return;
113
+ }
114
+
115
+ if (name === 'keyframes' || name === '-webkit-keyframes') {
116
+ stats.inputRules++;
117
+ if (opts.deduplicateKeyframes) {
118
+ const animName = extractKeyframeName(node, csstree);
119
+ if (!seenKeyframes.has(animName)) {
120
+ seenKeyframes.set(animName, node);
121
+ outputNodes.push({ type: 'keyframes', node });
122
+ } else {
123
+ stats.keyframesDeduped++;
124
+ }
125
+ } else {
126
+ outputNodes.push({ type: 'keyframes', node });
127
+ }
128
+ return;
129
+ }
130
+
131
+ if (name === 'media') {
132
+ const condition = node.prelude ? csstree.generate(node.prelude) : '';
133
+
134
+ if (opts.combineMediaQueries && condition) {
135
+ if (!mediaGroups.has(condition)) mediaGroups.set(condition, []);
136
+
137
+ csstree.walk(node.block, {
138
+ visit: 'Rule',
139
+ enter(rule) {
140
+ stats.inputRules++;
141
+ const hash = getRuleHash(rule, csstree);
142
+ const groupRules = mediaGroups.get(condition);
143
+ if (!groupRules.some(r => r.hash === hash)) {
144
+ groupRules.push({ hash, node: rule });
145
+ } else {
146
+ stats.duplicateRulesRemoved++;
147
+ }
148
+ }
149
+ });
150
+ } else {
151
+ outputNodes.push({ type: 'atrule', node });
152
+ }
153
+ return;
154
+ }
155
+
156
+ // @supports, @page, etc. — keep as-is
157
+ outputNodes.push({ type: 'atrule', node });
158
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * CSS Merge File I/O
3
+ *
4
+ * Reads multiple CSS files from disk, merges them via mergeStylesheets,
5
+ * and writes the result. Separates file system concerns from merge logic.
6
+ */
7
+
8
+ import fs from 'fs/promises';
9
+ import path from 'path';
10
+ import { mergeStylesheets } from './merge-css.js';
11
+
12
+ /**
13
+ * Merge multiple CSS files into a single output file.
14
+ * @param {string[]} cssFiles - Array of CSS file paths
15
+ * @param {string} outputPath - Output file path
16
+ * @param {Object} options - Merge options (passed through to mergeStylesheets)
17
+ * @returns {Promise<Object>} Merge result with success, input, output, stats
18
+ */
19
+ export async function mergeCssFiles(cssFiles, outputPath, options = {}) {
20
+ const startTime = Date.now();
21
+ const cssContents = [];
22
+ let totalInputSize = 0;
23
+
24
+ for (const filePath of cssFiles) {
25
+ try {
26
+ const content = await fs.readFile(filePath, 'utf-8');
27
+ cssContents.push(content);
28
+ totalInputSize += Buffer.byteLength(content, 'utf-8');
29
+ } catch (err) {
30
+ console.error(`[WARN] Could not read ${filePath}: ${err.message}`);
31
+ }
32
+ }
33
+
34
+ if (cssContents.length === 0) {
35
+ return {
36
+ success: false,
37
+ error: 'No CSS files could be read',
38
+ input: { files: cssFiles, totalSize: 0, totalRules: 0 },
39
+ output: null,
40
+ stats: null
41
+ };
42
+ }
43
+
44
+ const { css, stats } = mergeStylesheets(cssContents, options);
45
+ const outputSize = Buffer.byteLength(css, 'utf-8');
46
+ await fs.writeFile(outputPath, css, 'utf-8');
47
+
48
+ const duration = Date.now() - startTime;
49
+ const reduction = totalInputSize > 0
50
+ ? Math.round((1 - outputSize / totalInputSize) * 100)
51
+ : 0;
52
+
53
+ return {
54
+ success: true,
55
+ input: {
56
+ files: cssFiles,
57
+ fileCount: cssFiles.length,
58
+ totalSize: totalInputSize,
59
+ totalRules: stats.inputRules
60
+ },
61
+ output: {
62
+ path: path.resolve(outputPath),
63
+ size: outputSize,
64
+ rules: stats.outputRules
65
+ },
66
+ stats: { ...stats, reduction: `${reduction}%`, durationMs: duration }
67
+ };
68
+ }