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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content Counter
|
|
3
|
+
*
|
|
4
|
+
* Parse page DOM to extract exact content counts for:
|
|
5
|
+
* - Grid items, list items, cards
|
|
6
|
+
* - Navigation links
|
|
7
|
+
* - Sections/containers
|
|
8
|
+
* - Images, buttons, forms
|
|
9
|
+
*
|
|
10
|
+
* Outputs content-counts.json for use in structure analysis.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { domCountingFn } from './content-counter-dom.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Count content items in page DOM
|
|
17
|
+
* @param {import('playwright').Page} page - Playwright page
|
|
18
|
+
* @returns {Promise<object>} Content counts
|
|
19
|
+
*/
|
|
20
|
+
export async function extractContentCounts(page) {
|
|
21
|
+
return await page.evaluate(domCountingFn);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate concise content summary for prompt injection
|
|
26
|
+
* @param {object} counts - Content counts from extractContentCounts
|
|
27
|
+
* @returns {string} Summary text
|
|
28
|
+
*/
|
|
29
|
+
export function generateContentSummary(counts) {
|
|
30
|
+
const lines = [
|
|
31
|
+
'## EXACT CONTENT COUNTS (from DOM parsing)',
|
|
32
|
+
''
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Sections
|
|
36
|
+
lines.push(`### Sections: ${counts.sections.total} total`);
|
|
37
|
+
counts.sections.details.slice(0, 10).forEach(s => {
|
|
38
|
+
lines.push(`- ${s.selector}: ${s.childCount} children${s.visible ? '' : ' (hidden)'}`);
|
|
39
|
+
});
|
|
40
|
+
lines.push('');
|
|
41
|
+
|
|
42
|
+
// Grids with item counts
|
|
43
|
+
lines.push(`### Grid/Flex Containers: ${counts.grids.total} total`);
|
|
44
|
+
counts.grids.details.slice(0, 15).forEach(g => {
|
|
45
|
+
const visibilityNote = g.hiddenItems > 0 ? ` (+${g.hiddenItems} hidden)` : '';
|
|
46
|
+
lines.push(`- ${g.selector}: ${g.visibleItems} visible items${visibilityNote}`);
|
|
47
|
+
});
|
|
48
|
+
lines.push('');
|
|
49
|
+
|
|
50
|
+
// Repeated items
|
|
51
|
+
if (Object.keys(counts.repeatedItems.byType).length > 0) {
|
|
52
|
+
lines.push('### Repeated Items:');
|
|
53
|
+
Object.entries(counts.repeatedItems.byType).forEach(([type, data]) => {
|
|
54
|
+
const hiddenNote = data.hidden > 0 ? ` (+${data.hidden} hidden)` : '';
|
|
55
|
+
lines.push(`- ${type}: ${data.visible} visible${hiddenNote}`);
|
|
56
|
+
});
|
|
57
|
+
lines.push('');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Links and media
|
|
61
|
+
lines.push('### Navigation & Media:');
|
|
62
|
+
lines.push(`- Header links: ${counts.navigation.headerLinks}`);
|
|
63
|
+
lines.push(`- Footer links: ${counts.navigation.footerLinks}`);
|
|
64
|
+
lines.push(`- Images: ${counts.media.images}`);
|
|
65
|
+
lines.push(`- SVG icons: ${counts.media.svgIcons}`);
|
|
66
|
+
lines.push('');
|
|
67
|
+
|
|
68
|
+
// Critical instruction
|
|
69
|
+
lines.push('### GENERATION INSTRUCTION:');
|
|
70
|
+
lines.push('When generating HTML, use EXACTLY these item counts:');
|
|
71
|
+
Object.entries(counts.summary.recommendedItemCounts).forEach(([selector, count]) => {
|
|
72
|
+
lines.push(`- ${selector}: ${count} items`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Breakpoint Detector
|
|
3
|
+
*
|
|
4
|
+
* Extracts responsive breakpoint widths from @media queries in CSS.
|
|
5
|
+
* Used by --detect-breakpoints flag to capture at actual design breakpoints
|
|
6
|
+
* instead of fixed viewport widths.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { VIEWPORTS } from '../../shared/viewports.js';
|
|
10
|
+
|
|
11
|
+
const MEDIA_WIDTH_RE = /\(\s*(?:min|max)-width\s*:\s*(\d+(?:\.\d+)?)\s*(px|em|rem)\s*\)/g;
|
|
12
|
+
const EM_TO_PX = 16;
|
|
13
|
+
const MAX_BREAKPOINTS = 6;
|
|
14
|
+
const DESKTOP_BASELINE = 1440;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract breakpoint widths from CSS @media queries
|
|
18
|
+
* @param {string} cssContent - Raw CSS string
|
|
19
|
+
* @returns {{ breakpoints: number[], mediaQueries: Array<{query: string, width: number}> }}
|
|
20
|
+
*/
|
|
21
|
+
export function detectBreakpoints(cssContent) {
|
|
22
|
+
const widths = new Set();
|
|
23
|
+
const queries = [];
|
|
24
|
+
let match;
|
|
25
|
+
|
|
26
|
+
// Find all @media blocks and extract width values
|
|
27
|
+
const mediaRe = /@media\s*([^{]+)\{/g;
|
|
28
|
+
while ((match = mediaRe.exec(cssContent)) !== null) {
|
|
29
|
+
const query = match[1].trim();
|
|
30
|
+
let widthMatch;
|
|
31
|
+
MEDIA_WIDTH_RE.lastIndex = 0;
|
|
32
|
+
|
|
33
|
+
while ((widthMatch = MEDIA_WIDTH_RE.exec(query)) !== null) {
|
|
34
|
+
let px = parseFloat(widthMatch[1]);
|
|
35
|
+
const unit = widthMatch[2];
|
|
36
|
+
|
|
37
|
+
if (unit === 'em' || unit === 'rem') px = Math.round(px * EM_TO_PX);
|
|
38
|
+
if (px >= 320 && px <= 2560) {
|
|
39
|
+
widths.add(px);
|
|
40
|
+
queries.push({ query, width: px });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sorted = [...widths].sort((a, b) => a - b).slice(0, MAX_BREAKPOINTS);
|
|
46
|
+
return { breakpoints: sorted, mediaQueries: queries };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Replace fixed viewports with detected breakpoints, keep desktop baseline.
|
|
51
|
+
* @param {number[]} detected - Detected widths
|
|
52
|
+
* @returns {Object} Viewport configs keyed by name
|
|
53
|
+
*/
|
|
54
|
+
export function mergeWithFixed(detected) {
|
|
55
|
+
const all = [...new Set([DESKTOP_BASELINE, ...detected])].sort((a, b) => b - a);
|
|
56
|
+
const capped = all.slice(0, MAX_BREAKPOINTS);
|
|
57
|
+
const result = {};
|
|
58
|
+
|
|
59
|
+
for (const w of capped) {
|
|
60
|
+
const existing = Object.entries(VIEWPORTS).find(([, v]) => v.width === w);
|
|
61
|
+
const name = existing ? existing[0] : `bp-${w}`;
|
|
62
|
+
result[name] = { width: w, height: Math.round(w * 0.625), deviceScaleFactor: 1 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"div": { "display": "block", "position": "static", "margin": "0px", "padding": "0px", "font-size": "16px", "font-weight": "400", "color": "rgb(0, 0, 0)", "background-color": "rgba(0, 0, 0, 0)", "border": "0px none rgb(0, 0, 0)", "text-align": "start", "line-height": "normal", "opacity": "1" },
|
|
3
|
+
"span": { "display": "inline", "position": "static", "margin": "0px", "padding": "0px", "font-size": "16px", "font-weight": "400", "color": "rgb(0, 0, 0)", "background-color": "rgba(0, 0, 0, 0)" },
|
|
4
|
+
"p": { "display": "block", "position": "static", "margin": "16px 0px", "padding": "0px", "font-size": "16px", "font-weight": "400" },
|
|
5
|
+
"h1": { "display": "block", "font-size": "32px", "font-weight": "700", "margin": "21.44px 0px" },
|
|
6
|
+
"h2": { "display": "block", "font-size": "24px", "font-weight": "700", "margin": "19.92px 0px" },
|
|
7
|
+
"h3": { "display": "block", "font-size": "18.72px", "font-weight": "700", "margin": "18.72px 0px" },
|
|
8
|
+
"h4": { "display": "block", "font-size": "16px", "font-weight": "700", "margin": "21.28px 0px" },
|
|
9
|
+
"h5": { "display": "block", "font-size": "13.28px", "font-weight": "700", "margin": "22.18px 0px" },
|
|
10
|
+
"h6": { "display": "block", "font-size": "10.72px", "font-weight": "700", "margin": "24.98px 0px" },
|
|
11
|
+
"a": { "display": "inline", "color": "rgb(0, 0, 238)", "text-decoration": "underline" },
|
|
12
|
+
"ul": { "display": "block", "margin": "16px 0px", "padding": "0px 0px 0px 40px" },
|
|
13
|
+
"li": { "display": "list-item" },
|
|
14
|
+
"img": { "display": "inline" },
|
|
15
|
+
"section": { "display": "block" },
|
|
16
|
+
"article": { "display": "block" },
|
|
17
|
+
"nav": { "display": "block" },
|
|
18
|
+
"header": { "display": "block" },
|
|
19
|
+
"footer": { "display": "block" },
|
|
20
|
+
"main": { "display": "block" },
|
|
21
|
+
"button": { "display": "inline-block", "padding": "1px 6px", "font-size": "13.333px" },
|
|
22
|
+
"input": { "display": "inline-block", "padding": "1px 2px", "font-size": "13.333px" }
|
|
23
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computed Style Gap-Fill Extractor
|
|
3
|
+
*
|
|
4
|
+
* Extracts computed styles for visible elements that aren't covered by
|
|
5
|
+
* stylesheet CSS. Diffs against Chromium defaults to produce minimal
|
|
6
|
+
* gap-fill rules for styles set via JS or inline attributes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
let defaults = null;
|
|
15
|
+
|
|
16
|
+
async function loadDefaults() {
|
|
17
|
+
if (!defaults) {
|
|
18
|
+
const raw = await fs.readFile(path.join(__dirname, 'chromium-defaults.json'), 'utf-8');
|
|
19
|
+
defaults = JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
return defaults;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const KEY_PROPERTIES = [
|
|
25
|
+
'display', 'position', 'margin', 'padding', 'width', 'height',
|
|
26
|
+
'font-size', 'font-weight', 'font-family', 'color', 'background-color',
|
|
27
|
+
'border', 'border-radius', 'flex-direction', 'justify-content',
|
|
28
|
+
'align-items', 'gap', 'grid-template-columns', 'overflow',
|
|
29
|
+
'text-align', 'line-height', 'text-decoration', 'opacity', 'z-index',
|
|
30
|
+
'box-shadow', 'transform'
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const MAX_ELEMENTS = 1000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract computed styles that fill gaps in stylesheet CSS
|
|
37
|
+
* @param {import('playwright').Page} page - Playwright page
|
|
38
|
+
* @param {string} existingCssContent - Already-extracted CSS content
|
|
39
|
+
* @returns {Promise<{css: string, rules: number, stats: Object}>}
|
|
40
|
+
*/
|
|
41
|
+
export async function extractComputedGapFill(page, existingCssContent) {
|
|
42
|
+
const baseline = await loadDefaults();
|
|
43
|
+
|
|
44
|
+
const elementStyles = await page.evaluate(({ maxEls, props }) => {
|
|
45
|
+
const visible = [...document.querySelectorAll('*')]
|
|
46
|
+
.filter(el => {
|
|
47
|
+
const rect = el.getBoundingClientRect();
|
|
48
|
+
return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight * 2;
|
|
49
|
+
})
|
|
50
|
+
.slice(0, maxEls);
|
|
51
|
+
|
|
52
|
+
return visible.map(el => {
|
|
53
|
+
const cs = window.getComputedStyle(el);
|
|
54
|
+
const tag = el.tagName.toLowerCase();
|
|
55
|
+
const cls = el.className && typeof el.className === 'string'
|
|
56
|
+
? `.${[...el.classList].join('.')}` : '';
|
|
57
|
+
const id = el.id ? `#${el.id}` : '';
|
|
58
|
+
const selector = id || (tag + cls) || tag;
|
|
59
|
+
const styles = {};
|
|
60
|
+
for (const prop of props) {
|
|
61
|
+
const val = cs.getPropertyValue(prop);
|
|
62
|
+
if (val) styles[prop] = val;
|
|
63
|
+
}
|
|
64
|
+
return { tag, selector, styles };
|
|
65
|
+
});
|
|
66
|
+
}, { maxEls: MAX_ELEMENTS, props: KEY_PROPERTIES });
|
|
67
|
+
|
|
68
|
+
// Diff against baseline
|
|
69
|
+
const gapRules = [];
|
|
70
|
+
for (const el of elementStyles) {
|
|
71
|
+
const baseStyles = baseline[el.tag] || {};
|
|
72
|
+
const gaps = {};
|
|
73
|
+
let hasGap = false;
|
|
74
|
+
for (const [prop, val] of Object.entries(el.styles)) {
|
|
75
|
+
if (val !== baseStyles[prop] && !existingCssContent.includes(val)) {
|
|
76
|
+
gaps[prop] = val;
|
|
77
|
+
hasGap = true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (hasGap) gapRules.push({ selector: el.selector, properties: gaps });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Deduplicate by selector
|
|
84
|
+
const merged = new Map();
|
|
85
|
+
for (const rule of gapRules) {
|
|
86
|
+
const existing = merged.get(rule.selector);
|
|
87
|
+
if (existing) Object.assign(existing.properties, rule.properties);
|
|
88
|
+
else merged.set(rule.selector, rule);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rules = [...merged.values()];
|
|
92
|
+
const css = rules.map(r => {
|
|
93
|
+
const props = Object.entries(r.properties).map(([k, v]) => ` ${k}: ${v};`).join('\n');
|
|
94
|
+
return `${r.selector} {\n${props}\n}`;
|
|
95
|
+
}).join('\n\n');
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
css,
|
|
99
|
+
rules: rules.length,
|
|
100
|
+
stats: { elementsAnalyzed: elementStyles.length, gapRulesGenerated: rules.length }
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Chunker for Streaming Processing
|
|
3
|
+
*
|
|
4
|
+
* Splits large CSS files at top-level block boundaries for independent
|
|
5
|
+
* processing. Never splits inside nested {} blocks to preserve rule integrity.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CHUNK_SIZE = 1024 * 1024; // 1MB
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Split CSS at top-level closing braces into chunks.
|
|
12
|
+
* Preserves complete rule blocks - never splits inside {} nesting.
|
|
13
|
+
* @param {string} cssString - Raw CSS content
|
|
14
|
+
* @param {number} targetSize - Target chunk size in bytes
|
|
15
|
+
* @returns {string[]} CSS chunks
|
|
16
|
+
*/
|
|
17
|
+
export function splitCssAtTopLevel(cssString, targetSize = DEFAULT_CHUNK_SIZE) {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
let depth = 0;
|
|
20
|
+
let chunkStart = 0;
|
|
21
|
+
let lastTopLevelClose = 0;
|
|
22
|
+
let inSingleQuote = false;
|
|
23
|
+
let inDoubleQuote = false;
|
|
24
|
+
let inComment = false;
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < cssString.length; i++) {
|
|
27
|
+
const ch = cssString[i];
|
|
28
|
+
const prev = i > 0 ? cssString[i - 1] : '';
|
|
29
|
+
|
|
30
|
+
// Track comment state
|
|
31
|
+
if (!inSingleQuote && !inDoubleQuote) {
|
|
32
|
+
if (!inComment && ch === '/' && cssString[i + 1] === '*') {
|
|
33
|
+
inComment = true;
|
|
34
|
+
i++; // skip '*'
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (inComment && ch === '*' && cssString[i + 1] === '/') {
|
|
38
|
+
inComment = false;
|
|
39
|
+
i++; // skip '/'
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (inComment) continue;
|
|
44
|
+
|
|
45
|
+
// Track string state (skip escaped quotes)
|
|
46
|
+
if (!inDoubleQuote && ch === "'" && prev !== '\\') {
|
|
47
|
+
inSingleQuote = !inSingleQuote;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!inSingleQuote && ch === '"' && prev !== '\\') {
|
|
51
|
+
inDoubleQuote = !inDoubleQuote;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (inSingleQuote || inDoubleQuote) continue;
|
|
55
|
+
|
|
56
|
+
// Count braces only at top level (outside strings and comments)
|
|
57
|
+
if (ch === '{') depth++;
|
|
58
|
+
else if (ch === '}') {
|
|
59
|
+
depth--;
|
|
60
|
+
if (depth === 0) {
|
|
61
|
+
lastTopLevelClose = i + 1;
|
|
62
|
+
if (lastTopLevelClose - chunkStart >= targetSize) {
|
|
63
|
+
chunks.push(cssString.slice(chunkStart, lastTopLevelClose));
|
|
64
|
+
chunkStart = lastTopLevelClose;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Remainder
|
|
71
|
+
if (chunkStart < cssString.length) {
|
|
72
|
+
chunks.push(cssString.slice(chunkStart));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return chunks;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Process CSS chunks independently, merge results.
|
|
80
|
+
* On per-chunk failure, keeps original chunk content (conservative).
|
|
81
|
+
* @param {string[]} chunks - CSS chunks
|
|
82
|
+
* @param {function} parseAndFilter - Function that parses and filters a CSS string
|
|
83
|
+
* @returns {{ css: string, stats: { totalRules: number, keptRules: number, removedRules: number } }}
|
|
84
|
+
*/
|
|
85
|
+
export async function processChunks(chunks, parseAndFilter) {
|
|
86
|
+
const results = [];
|
|
87
|
+
const mergedStats = { totalRules: 0, keptRules: 0, removedRules: 0 };
|
|
88
|
+
|
|
89
|
+
for (const chunk of chunks) {
|
|
90
|
+
try {
|
|
91
|
+
const result = await parseAndFilter(chunk);
|
|
92
|
+
results.push(result.css);
|
|
93
|
+
mergedStats.totalRules += result.stats.totalRules || 0;
|
|
94
|
+
mergedStats.keptRules += result.stats.keptRules || 0;
|
|
95
|
+
mergedStats.removedRules += result.stats.removedRules || 0;
|
|
96
|
+
} catch {
|
|
97
|
+
// On chunk failure, keep original chunk (conservative)
|
|
98
|
+
results.push(chunk);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { css: results.join('\n'), stats: mergedStats };
|
|
103
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS Dead Code Removal (Pass 2)
|
|
3
|
+
*
|
|
4
|
+
* After selector-based filtering (pass 1), removes:
|
|
5
|
+
* - Empty @media blocks with no remaining rules
|
|
6
|
+
* - Orphan @keyframes not referenced by any animation property
|
|
7
|
+
* - Unused custom properties (CSS variables) not referenced by var()
|
|
8
|
+
*
|
|
9
|
+
* Only runs when --aggressive-filter is enabled.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Remove @media blocks that contain no rules after pass 1 filtering
|
|
14
|
+
* @param {Object} ast - css-tree AST
|
|
15
|
+
* @param {Object} csstree - css-tree module
|
|
16
|
+
* @returns {number} Count of removed blocks
|
|
17
|
+
*/
|
|
18
|
+
export function removeEmptyMediaQueries(ast, csstree) {
|
|
19
|
+
const empty = [];
|
|
20
|
+
csstree.walk(ast, {
|
|
21
|
+
visit: 'Atrule',
|
|
22
|
+
enter(node, item, list) {
|
|
23
|
+
if (node.name === 'media' && node.block) {
|
|
24
|
+
let hasChildren = false;
|
|
25
|
+
csstree.walk(node.block, { visit: 'Rule', enter() { hasChildren = true; } });
|
|
26
|
+
if (!hasChildren) empty.push({ item, list });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
for (const { item, list } of empty) {
|
|
31
|
+
if (list) list.remove(item);
|
|
32
|
+
}
|
|
33
|
+
return empty.length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Remove @keyframes not referenced by any animation-name or animation property
|
|
38
|
+
* @param {Object} ast - css-tree AST
|
|
39
|
+
* @param {Object} csstree - css-tree module
|
|
40
|
+
* @returns {number} Count of removed keyframes
|
|
41
|
+
*/
|
|
42
|
+
export function removeOrphanKeyframes(ast, csstree) {
|
|
43
|
+
const usedNames = new Set();
|
|
44
|
+
csstree.walk(ast, {
|
|
45
|
+
visit: 'Declaration',
|
|
46
|
+
enter(node) {
|
|
47
|
+
if (node.property === 'animation-name' || node.property === 'animation') {
|
|
48
|
+
const value = csstree.generate(node.value);
|
|
49
|
+
// Extract first token (animation-name) from shorthand or direct value
|
|
50
|
+
for (const name of value.split(/[\s,]+/)) {
|
|
51
|
+
if (name && name !== 'none' && name !== 'initial' && name !== 'inherit') {
|
|
52
|
+
usedNames.add(name);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const orphans = [];
|
|
60
|
+
csstree.walk(ast, {
|
|
61
|
+
visit: 'Atrule',
|
|
62
|
+
enter(node, item, list) {
|
|
63
|
+
if (node.name === 'keyframes' && node.prelude) {
|
|
64
|
+
const kfName = csstree.generate(node.prelude).trim();
|
|
65
|
+
if (!usedNames.has(kfName)) orphans.push({ item, list });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
for (const { item, list } of orphans) {
|
|
70
|
+
if (list) list.remove(item);
|
|
71
|
+
}
|
|
72
|
+
return orphans.length;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Remove custom property declarations not referenced by var()
|
|
77
|
+
* @param {Object} ast - css-tree AST
|
|
78
|
+
* @param {Object} csstree - css-tree module
|
|
79
|
+
* @returns {number} Count of removed declarations
|
|
80
|
+
*/
|
|
81
|
+
export function removeUnusedCustomProps(ast, csstree) {
|
|
82
|
+
const usedVars = new Set();
|
|
83
|
+
csstree.walk(ast, {
|
|
84
|
+
visit: 'Function',
|
|
85
|
+
enter(node) {
|
|
86
|
+
if (node.name === 'var' && node.children && node.children.first) {
|
|
87
|
+
const name = csstree.generate(node.children.first);
|
|
88
|
+
if (name.startsWith('--')) usedVars.add(name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const unused = [];
|
|
94
|
+
csstree.walk(ast, {
|
|
95
|
+
visit: 'Declaration',
|
|
96
|
+
enter(node, item, list) {
|
|
97
|
+
if (node.property.startsWith('--') && !usedVars.has(node.property)) {
|
|
98
|
+
unused.push({ item, list });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
for (const { item, list } of unused) {
|
|
103
|
+
if (list) list.remove(item);
|
|
104
|
+
}
|
|
105
|
+
return unused.length;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run all dead code removal passes
|
|
110
|
+
* @param {Object} ast - css-tree AST
|
|
111
|
+
* @param {Object} csstree - css-tree module
|
|
112
|
+
* @returns {{ emptyMediaRemoved: number, orphanKeyframesRemoved: number, unusedCustomPropsRemoved: number }}
|
|
113
|
+
*/
|
|
114
|
+
export function runDeadCodePass(ast, csstree) {
|
|
115
|
+
return {
|
|
116
|
+
emptyMediaRemoved: removeEmptyMediaQueries(ast, csstree),
|
|
117
|
+
orphanKeyframesRemoved: removeOrphanKeyframes(ast, csstree),
|
|
118
|
+
unusedCustomPropsRemoved: removeUnusedCustomProps(ast, csstree)
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Analyzer for CSS Filtering
|
|
3
|
+
*
|
|
4
|
+
* Parses HTML content to extract tags, IDs, classes, and attributes
|
|
5
|
+
* used as input for CSS selector matching during CSS filtering.
|
|
6
|
+
* Also exports shared constants and sanitization utilities.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from 'path';
|
|
10
|
+
|
|
11
|
+
// Rules that should always be kept (critical for layout)
|
|
12
|
+
export const ALWAYS_KEEP_PATTERNS = [
|
|
13
|
+
/^html$/i,
|
|
14
|
+
/^body$/i,
|
|
15
|
+
/^\*$/,
|
|
16
|
+
/^:root$/i
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// At-rules that should always be kept
|
|
20
|
+
export const KEEP_AT_RULES = ['font-face', 'keyframes', 'import', 'charset', 'namespace'];
|
|
21
|
+
|
|
22
|
+
// CSS injection patterns to sanitize (XSS vectors)
|
|
23
|
+
export const CSS_INJECTION_PATTERNS = [
|
|
24
|
+
/expression\s*\(/gi,
|
|
25
|
+
/-moz-binding\s*:/gi,
|
|
26
|
+
/url\s*\(\s*["']?javascript:/gi,
|
|
27
|
+
/url\s*\(\s*["']?data:text\/html/gi,
|
|
28
|
+
/behavior\s*:/gi,
|
|
29
|
+
/@import\s+["']?javascript:/gi
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse HTML and build sets of all possible selector matches.
|
|
34
|
+
* Uses regex for speed (no DOM parser needed).
|
|
35
|
+
* @param {string} html - HTML content to analyze
|
|
36
|
+
* @returns {{ tags: Set<string>, ids: Set<string>, classes: Set<string>, attributes: Set<string> }}
|
|
37
|
+
*/
|
|
38
|
+
export function analyzeHtml(html) {
|
|
39
|
+
const tags = new Set();
|
|
40
|
+
const ids = new Set();
|
|
41
|
+
const classes = new Set();
|
|
42
|
+
const attributes = new Set();
|
|
43
|
+
|
|
44
|
+
const tagMatches = html.matchAll(/<([a-z][a-z0-9]*)/gi);
|
|
45
|
+
for (const match of tagMatches) {
|
|
46
|
+
tags.add(match[1].toLowerCase());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const idMatches = html.matchAll(/\bid=["']([^"']+)["']/gi);
|
|
50
|
+
for (const match of idMatches) {
|
|
51
|
+
ids.add(match[1]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const classMatches = html.matchAll(/\bclass=["']([^"']+)["']/gi);
|
|
55
|
+
for (const match of classMatches) {
|
|
56
|
+
match[1].split(/\s+/).forEach(c => {
|
|
57
|
+
const trimmed = c.trim();
|
|
58
|
+
if (trimmed) classes.add(trimmed);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const attrMatches = html.matchAll(/\s(data-[a-z0-9-]+)/gi);
|
|
63
|
+
for (const match of attrMatches) {
|
|
64
|
+
attributes.add(match[1].toLowerCase());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const commonAttrs = [
|
|
68
|
+
'href', 'src', 'type', 'name', 'value', 'disabled', 'checked',
|
|
69
|
+
'selected', 'readonly', 'required', 'placeholder', 'role',
|
|
70
|
+
'aria-hidden', 'aria-label', 'aria-expanded', 'target', 'rel'
|
|
71
|
+
];
|
|
72
|
+
commonAttrs.forEach(attr => {
|
|
73
|
+
if (html.includes(attr + '=') || html.includes(attr + ' ') || html.includes(attr + '>')) {
|
|
74
|
+
attributes.add(attr);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return { tags, ids, classes, attributes };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Validate file path is within allowed directory (prevents path traversal).
|
|
83
|
+
* @param {string} filePath - Path to validate
|
|
84
|
+
* @param {string} allowedDir - Directory paths must be within (defaults to cwd)
|
|
85
|
+
* @returns {string} Resolved absolute path
|
|
86
|
+
* @throws {Error} If path is outside allowed directory
|
|
87
|
+
*/
|
|
88
|
+
export function validatePath(filePath, allowedDir = process.cwd()) {
|
|
89
|
+
const resolved = path.resolve(filePath);
|
|
90
|
+
const allowed = path.resolve(allowedDir);
|
|
91
|
+
|
|
92
|
+
if (!resolved.startsWith(allowed + path.sep) && resolved !== allowed) {
|
|
93
|
+
throw new Error(`Path "${filePath}" is outside allowed directory "${allowedDir}"`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return resolved;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sanitize CSS output to remove potential XSS vectors.
|
|
101
|
+
* @param {string} css - CSS string to sanitize
|
|
102
|
+
* @returns {string} Sanitized CSS
|
|
103
|
+
*/
|
|
104
|
+
export function sanitizeCss(css) {
|
|
105
|
+
let sanitized = css;
|
|
106
|
+
for (const pattern of CSS_INJECTION_PATTERNS) {
|
|
107
|
+
sanitized = sanitized.replace(pattern, '/* [sanitized] */');
|
|
108
|
+
}
|
|
109
|
+
return sanitized;
|
|
110
|
+
}
|