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.
- 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
|
@@ -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
|
-
}
|
package/src/core/merge-css.js
DELETED
|
@@ -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
|
-
}
|