design-clone 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +13 -34
  2. package/SKILL.md +69 -45
  3. package/bin/cli.js +22 -4
  4. package/bin/commands/clone-site.js +31 -171
  5. package/bin/commands/help.js +19 -6
  6. package/bin/commands/init.js +9 -86
  7. package/bin/commands/uninstall.js +105 -0
  8. package/bin/commands/update.js +70 -0
  9. package/bin/commands/verify.js +7 -14
  10. package/bin/utils/paths.js +28 -0
  11. package/bin/utils/validate.js +2 -22
  12. package/bin/utils/version.js +23 -0
  13. package/docs/code-standards.md +789 -0
  14. package/docs/codebase-summary.md +533 -286
  15. package/docs/index.md +74 -0
  16. package/docs/project-overview-pdr.md +797 -0
  17. package/docs/system-architecture.md +718 -0
  18. package/package.json +14 -17
  19. package/src/ai/prompts/design-tokens/basic.md +80 -0
  20. package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
  21. package/src/ai/prompts/design-tokens/section.md +48 -0
  22. package/src/ai/prompts/design-tokens/with-css.md +87 -0
  23. package/src/ai/prompts/structure-analysis/basic.md +55 -0
  24. package/src/ai/prompts/structure-analysis/with-context.md +59 -0
  25. package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
  26. package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
  27. package/src/ai/prompts/ux-audit/aggregation.md +42 -0
  28. package/src/ai/prompts/ux-audit/desktop.md +92 -0
  29. package/src/ai/prompts/ux-audit/mobile.md +93 -0
  30. package/src/ai/prompts/ux-audit/tablet.md +92 -0
  31. package/src/core/animation/animation-extractor-ast.js +183 -0
  32. package/src/core/animation/animation-extractor-output.js +152 -0
  33. package/src/core/animation/animation-extractor.js +178 -0
  34. package/src/core/animation/state-capture-detection.js +200 -0
  35. package/src/core/animation/state-capture.js +193 -0
  36. package/src/core/capture/browser-context-pool.js +96 -0
  37. package/src/core/capture/multi-page-screenshot-page.js +110 -0
  38. package/src/core/capture/multi-page-screenshot.js +208 -0
  39. package/src/core/capture/screenshot-extraction.js +186 -0
  40. package/src/core/capture/screenshot-helpers.js +175 -0
  41. package/src/core/capture/screenshot-orchestrator.js +174 -0
  42. package/src/core/capture/screenshot-viewport.js +93 -0
  43. package/src/core/capture/screenshot.js +192 -0
  44. package/src/core/content/content-counter-dom.js +191 -0
  45. package/src/core/content/content-counter.js +76 -0
  46. package/src/core/css/breakpoint-detector.js +66 -0
  47. package/src/core/css/chromium-defaults.json +23 -0
  48. package/src/core/css/computed-style-extractor.js +102 -0
  49. package/src/core/css/css-chunker.js +103 -0
  50. package/src/core/css/filter-css-dead-code.js +120 -0
  51. package/src/core/css/filter-css-html-analyzer.js +110 -0
  52. package/src/core/css/filter-css-selector-matcher.js +172 -0
  53. package/src/core/css/filter-css.js +206 -0
  54. package/src/core/css/merge-css-atrule-processor.js +158 -0
  55. package/src/core/css/merge-css-file-io.js +68 -0
  56. package/src/core/css/merge-css.js +148 -0
  57. package/src/core/detection/framework-detector-routing.js +68 -0
  58. package/src/core/detection/framework-detector-signals.js +65 -0
  59. package/src/core/detection/framework-detector.js +198 -0
  60. package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
  61. package/src/core/dimension/dimension-extractor.js +317 -0
  62. package/src/core/dimension/dimension-output-ai-summary.js +111 -0
  63. package/src/core/dimension/dimension-output.js +173 -0
  64. package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
  65. package/src/core/dimension/dom-tree-analyzer.js +191 -0
  66. package/src/core/discovery/app-state-snapshot-capture.js +195 -0
  67. package/src/core/discovery/app-state-snapshot-utils.js +178 -0
  68. package/src/core/discovery/app-state-snapshot.js +131 -0
  69. package/src/core/discovery/discover-pages-routes.js +84 -0
  70. package/src/core/discovery/discover-pages-utils.js +177 -0
  71. package/src/core/discovery/discover-pages.js +191 -0
  72. package/src/core/html/html-extractor-inline-styler.js +70 -0
  73. package/src/core/html/html-extractor.js +147 -0
  74. package/src/core/html/semantic-enhancer-mappings.js +200 -0
  75. package/src/core/html/semantic-enhancer-page.js +148 -0
  76. package/src/core/html/semantic-enhancer.js +135 -0
  77. package/src/core/links/rewrite-links-css-rewriter.js +53 -0
  78. package/src/core/links/rewrite-links.js +173 -0
  79. package/src/core/media/asset-validator.js +118 -0
  80. package/src/core/media/extract-assets-downloader.js +187 -0
  81. package/src/core/media/extract-assets-page-scraper.js +115 -0
  82. package/src/core/media/extract-assets.js +159 -0
  83. package/src/core/media/video-capture-convert.js +200 -0
  84. package/src/core/media/video-capture.js +201 -0
  85. package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +37 -39
  86. package/src/core/section/section-cropper-helpers.js +43 -0
  87. package/src/core/{section-cropper.js → section/section-cropper.js} +11 -88
  88. package/src/core/section/section-detector-strategies.js +139 -0
  89. package/src/core/section/section-detector-utils.js +100 -0
  90. package/src/core/section/section-detector.js +88 -0
  91. package/src/core/tests/test-section-cropper.js +2 -2
  92. package/src/core/tests/test-section-detector.js +2 -2
  93. package/src/post-process/enhance-assets.js +29 -4
  94. package/src/post-process/fetch-images-unsplash-client.js +123 -0
  95. package/src/post-process/fetch-images.js +60 -263
  96. package/src/post-process/inject-gosnap.js +88 -0
  97. package/src/post-process/inject-icons-svg-replacer.js +76 -0
  98. package/src/post-process/inject-icons.js +47 -200
  99. package/src/route-discoverers/base-discoverer-utils.js +137 -0
  100. package/src/route-discoverers/base-discoverer.js +29 -118
  101. package/src/route-discoverers/index.js +1 -1
  102. package/src/shared/config.js +38 -0
  103. package/src/shared/error-codes.js +31 -0
  104. package/src/shared/viewports.js +46 -0
  105. package/src/utils/browser.js +0 -7
  106. package/src/utils/helpers.js +4 -0
  107. package/src/utils/log.js +12 -0
  108. package/src/utils/playwright-loader.js +76 -0
  109. package/src/utils/playwright.js +3 -69
  110. package/src/utils/progress.js +32 -0
  111. package/src/verification/generate-audit-report-css-fixes.js +52 -0
  112. package/src/verification/generate-audit-report-sections.js +158 -0
  113. package/src/verification/generate-audit-report.js +5 -281
  114. package/src/verification/quality-scorer.js +92 -0
  115. package/src/verification/verify-footer-checks.js +103 -0
  116. package/src/verification/verify-footer-helpers.js +178 -0
  117. package/src/verification/verify-footer.js +23 -381
  118. package/src/verification/verify-header-checks.js +104 -0
  119. package/src/verification/verify-header-helpers.js +156 -0
  120. package/src/verification/verify-header.js +23 -365
  121. package/src/verification/verify-layout-report.js +101 -0
  122. package/src/verification/verify-layout.js +13 -259
  123. package/src/verification/verify-menu-checks.js +104 -0
  124. package/src/verification/verify-menu-helpers.js +112 -0
  125. package/src/verification/verify-menu.js +17 -285
  126. package/src/verification/verify-slider-checks.js +115 -0
  127. package/src/verification/verify-slider-constants.js +65 -0
  128. package/src/verification/verify-slider-helpers.js +164 -0
  129. package/src/verification/verify-slider.js +23 -414
  130. package/.env.example +0 -14
  131. package/docs/basic-clone.md +0 -63
  132. package/docs/cli-reference.md +0 -316
  133. package/docs/design-clone-architecture.md +0 -492
  134. package/docs/pixel-perfect.md +0 -117
  135. package/docs/project-roadmap.md +0 -382
  136. package/docs/troubleshooting.md +0 -170
  137. package/requirements.txt +0 -5
  138. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  139. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  140. package/src/ai/analyze-structure.py +0 -375
  141. package/src/ai/extract-design-tokens.py +0 -782
  142. package/src/ai/prompts/__init__.py +0 -2
  143. package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  144. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  145. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  146. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  147. package/src/ai/prompts/design_tokens.py +0 -316
  148. package/src/ai/prompts/structure_analysis.py +0 -592
  149. package/src/ai/prompts/ux_audit.py +0 -198
  150. package/src/ai/ux-audit.js +0 -596
  151. package/src/core/animation-extractor.js +0 -526
  152. package/src/core/app-state-snapshot.js +0 -511
  153. package/src/core/content-counter.js +0 -342
  154. package/src/core/design-tokens.js +0 -103
  155. package/src/core/dimension-extractor.js +0 -438
  156. package/src/core/dimension-output.js +0 -305
  157. package/src/core/discover-pages.js +0 -542
  158. package/src/core/dom-tree-analyzer.js +0 -298
  159. package/src/core/extract-assets.js +0 -468
  160. package/src/core/filter-css.js +0 -499
  161. package/src/core/framework-detector.js +0 -538
  162. package/src/core/html-extractor.js +0 -212
  163. package/src/core/merge-css.js +0 -407
  164. package/src/core/multi-page-screenshot.js +0 -380
  165. package/src/core/rewrite-links.js +0 -226
  166. package/src/core/screenshot.js +0 -701
  167. package/src/core/section-detector.js +0 -386
  168. package/src/core/semantic-enhancer.js +0 -492
  169. package/src/core/state-capture.js +0 -598
  170. package/src/core/video-capture.js +0 -546
  171. package/src/utils/__init__.py +0 -16
  172. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  173. package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
  174. package/src/utils/env.py +0 -134
  175. /package/src/core/{css-extractor.js → css/css-extractor.js} +0 -0
  176. /package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +0 -0
  177. /package/src/core/{page-readiness.js → page-prep/page-readiness.js} +0 -0
@@ -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
+ }