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.
- package/README.md +13 -34
- package/SKILL.md +69 -45
- package/bin/cli.js +22 -4
- package/bin/commands/clone-site.js +31 -171
- package/bin/commands/help.js +19 -6
- package/bin/commands/init.js +9 -86
- package/bin/commands/uninstall.js +105 -0
- package/bin/commands/update.js +70 -0
- package/bin/commands/verify.js +7 -14
- package/bin/utils/paths.js +28 -0
- package/bin/utils/validate.js +2 -22
- package/bin/utils/version.js +23 -0
- package/docs/code-standards.md +789 -0
- package/docs/codebase-summary.md +533 -286
- package/docs/index.md +74 -0
- package/docs/project-overview-pdr.md +797 -0
- package/docs/system-architecture.md +718 -0
- package/package.json +14 -17
- package/src/ai/prompts/design-tokens/basic.md +80 -0
- package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
- package/src/ai/prompts/design-tokens/section.md +48 -0
- package/src/ai/prompts/design-tokens/with-css.md +87 -0
- package/src/ai/prompts/structure-analysis/basic.md +55 -0
- package/src/ai/prompts/structure-analysis/with-context.md +59 -0
- package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
- package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
- package/src/ai/prompts/ux-audit/aggregation.md +42 -0
- package/src/ai/prompts/ux-audit/desktop.md +92 -0
- package/src/ai/prompts/ux-audit/mobile.md +93 -0
- package/src/ai/prompts/ux-audit/tablet.md +92 -0
- package/src/core/animation/animation-extractor-ast.js +183 -0
- package/src/core/animation/animation-extractor-output.js +152 -0
- package/src/core/animation/animation-extractor.js +178 -0
- package/src/core/animation/state-capture-detection.js +200 -0
- package/src/core/animation/state-capture.js +193 -0
- package/src/core/capture/browser-context-pool.js +96 -0
- package/src/core/capture/multi-page-screenshot-page.js +110 -0
- package/src/core/capture/multi-page-screenshot.js +208 -0
- package/src/core/capture/screenshot-extraction.js +186 -0
- package/src/core/capture/screenshot-helpers.js +175 -0
- package/src/core/capture/screenshot-orchestrator.js +174 -0
- package/src/core/capture/screenshot-viewport.js +93 -0
- package/src/core/capture/screenshot.js +192 -0
- package/src/core/content/content-counter-dom.js +191 -0
- package/src/core/content/content-counter.js +76 -0
- package/src/core/css/breakpoint-detector.js +66 -0
- package/src/core/css/chromium-defaults.json +23 -0
- package/src/core/css/computed-style-extractor.js +102 -0
- package/src/core/css/css-chunker.js +103 -0
- package/src/core/css/filter-css-dead-code.js +120 -0
- package/src/core/css/filter-css-html-analyzer.js +110 -0
- package/src/core/css/filter-css-selector-matcher.js +172 -0
- package/src/core/css/filter-css.js +206 -0
- package/src/core/css/merge-css-atrule-processor.js +158 -0
- package/src/core/css/merge-css-file-io.js +68 -0
- package/src/core/css/merge-css.js +148 -0
- package/src/core/detection/framework-detector-routing.js +68 -0
- package/src/core/detection/framework-detector-signals.js +65 -0
- package/src/core/detection/framework-detector.js +198 -0
- package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
- package/src/core/dimension/dimension-extractor.js +317 -0
- package/src/core/dimension/dimension-output-ai-summary.js +111 -0
- package/src/core/dimension/dimension-output.js +173 -0
- package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
- package/src/core/dimension/dom-tree-analyzer.js +191 -0
- package/src/core/discovery/app-state-snapshot-capture.js +195 -0
- package/src/core/discovery/app-state-snapshot-utils.js +178 -0
- package/src/core/discovery/app-state-snapshot.js +131 -0
- package/src/core/discovery/discover-pages-routes.js +84 -0
- package/src/core/discovery/discover-pages-utils.js +177 -0
- package/src/core/discovery/discover-pages.js +191 -0
- package/src/core/html/html-extractor-inline-styler.js +70 -0
- package/src/core/html/html-extractor.js +147 -0
- package/src/core/html/semantic-enhancer-mappings.js +200 -0
- package/src/core/html/semantic-enhancer-page.js +148 -0
- package/src/core/html/semantic-enhancer.js +135 -0
- package/src/core/links/rewrite-links-css-rewriter.js +53 -0
- package/src/core/links/rewrite-links.js +173 -0
- package/src/core/media/asset-validator.js +118 -0
- package/src/core/media/extract-assets-downloader.js +187 -0
- package/src/core/media/extract-assets-page-scraper.js +115 -0
- package/src/core/media/extract-assets.js +159 -0
- package/src/core/media/video-capture-convert.js +200 -0
- package/src/core/media/video-capture.js +201 -0
- package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
- package/src/core/section/section-cropper-helpers.js +43 -0
- package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
- package/src/core/section/section-detector-strategies.js +139 -0
- package/src/core/section/section-detector-utils.js +100 -0
- package/src/core/section/section-detector.js +88 -0
- package/src/core/tests/test-section-cropper.js +2 -2
- package/src/core/tests/test-section-detector.js +2 -2
- package/src/post-process/enhance-assets.js +29 -4
- package/src/post-process/fetch-images-unsplash-client.js +123 -0
- package/src/post-process/fetch-images.js +60 -263
- package/src/post-process/inject-gosnap.js +88 -0
- package/src/post-process/inject-icons-svg-replacer.js +76 -0
- package/src/post-process/inject-icons.js +47 -200
- package/src/route-discoverers/base-discoverer-utils.js +137 -0
- package/src/route-discoverers/base-discoverer.js +29 -118
- package/src/route-discoverers/index.js +1 -1
- package/src/shared/config.js +38 -0
- package/src/shared/error-codes.js +31 -0
- package/src/shared/viewports.js +46 -0
- package/src/utils/browser.js +0 -7
- package/src/utils/helpers.js +4 -0
- package/src/utils/log.js +12 -0
- package/src/utils/playwright-loader.js +76 -0
- package/src/utils/playwright.js +3 -69
- package/src/utils/progress.js +32 -0
- package/src/verification/generate-audit-report-css-fixes.js +52 -0
- package/src/verification/generate-audit-report-sections.js +158 -0
- package/src/verification/generate-audit-report.js +5 -281
- package/src/verification/quality-scorer.js +92 -0
- package/src/verification/verify-footer-checks.js +103 -0
- package/src/verification/verify-footer-helpers.js +178 -0
- package/src/verification/verify-footer.js +23 -381
- package/src/verification/verify-header-checks.js +104 -0
- package/src/verification/verify-header-helpers.js +156 -0
- package/src/verification/verify-header.js +23 -365
- package/src/verification/verify-layout-report.js +101 -0
- package/src/verification/verify-layout.js +13 -259
- package/src/verification/verify-menu-checks.js +104 -0
- package/src/verification/verify-menu-helpers.js +112 -0
- package/src/verification/verify-menu.js +17 -285
- package/src/verification/verify-slider-checks.js +115 -0
- package/src/verification/verify-slider-constants.js +65 -0
- package/src/verification/verify-slider-helpers.js +164 -0
- package/src/verification/verify-slider.js +23 -414
- package/.env.example +0 -14
- package/docs/basic-clone.md +0 -63
- package/docs/cli-reference.md +0 -316
- package/docs/design-clone-architecture.md +0 -492
- package/docs/pixel-perfect.md +0 -117
- package/docs/project-roadmap.md +0 -382
- package/docs/troubleshooting.md +0 -170
- package/requirements.txt +0 -5
- package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
- package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
- package/src/ai/analyze-structure.py +0 -375
- package/src/ai/extract-design-tokens.py +0 -782
- package/src/ai/prompts/__init__.py +0 -2
- package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
- package/src/ai/prompts/design_tokens.py +0 -316
- package/src/ai/prompts/structure_analysis.py +0 -592
- package/src/ai/prompts/ux_audit.py +0 -198
- package/src/ai/ux-audit.js +0 -596
- package/src/core/animation-extractor.js +0 -526
- package/src/core/app-state-snapshot.js +0 -511
- package/src/core/content-counter.js +0 -342
- package/src/core/design-tokens.js +0 -103
- package/src/core/dimension-extractor.js +0 -438
- package/src/core/dimension-output.js +0 -305
- package/src/core/discover-pages.js +0 -542
- package/src/core/dom-tree-analyzer.js +0 -298
- package/src/core/extract-assets.js +0 -468
- package/src/core/filter-css.js +0 -499
- package/src/core/framework-detector.js +0 -538
- package/src/core/html-extractor.js +0 -212
- package/src/core/merge-css.js +0 -407
- package/src/core/multi-page-screenshot.js +0 -380
- package/src/core/rewrite-links.js +0 -226
- package/src/core/screenshot.js +0 -701
- package/src/core/section-detector.js +0 -386
- package/src/core/semantic-enhancer.js +0 -492
- package/src/core/state-capture.js +0 -598
- package/src/core/video-capture.js +0 -546
- package/src/utils/__init__.py +0 -16
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/env.py +0 -134
- /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
- /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
- /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
|
+
}
|