codescoop 1.0.0 → 1.0.3

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 CHANGED
@@ -31,6 +31,7 @@ CodeScoop analyzes your project and generates a forensic report containing:
31
31
  * **Ghost Classes:** Identifies classes in HTML that have *no* matching CSS
32
32
  * **JS Forensics:** Finds jQuery listeners and vanilla JS events targeting your elements
33
33
  * **Variable Resolution:** Inlines values for `var(--primary)` so you don't need to hunt them down
34
+ * **Asset Inventory:** Lists all images, videos, fonts, canvas elements with availability status
34
35
 
35
36
  ---
36
37
 
@@ -44,6 +45,20 @@ Don't just list files. **Solve specificity wars.** CodeScoop calculates specific
44
45
 
45
46
  Cleaning up legacy code? CodeScoop flags classes in your HTML that have **zero matching CSS rules** in your project. Delete them with confidence.
46
47
 
48
+ ### Asset Extraction & Verification
49
+
50
+ CodeScoop automatically extracts and checks all assets from your component:
51
+
52
+ * **Images:** From `<img>`, CSS `background-image`, video posters
53
+ * **Media:** Videos, audio files with availability status
54
+ * **Fonts:** CSS `@font-face` references
55
+ * **Canvas/WebGL:** Detects canvas elements for 3D rendering context
56
+ * **SVG:** Inline and external SVG graphics
57
+
58
+ **For Debugging:** See which images are missing (404) and where they're referenced
59
+
60
+ **For AI Conversion:** LLM gets complete asset list to include in React components
61
+
47
62
  ### Smart Extraction
48
63
 
49
64
  It doesn't dump the whole file. It extracts *only* the rules that affect your specific component.
@@ -228,17 +243,51 @@ Frontend analysis is hard. Here is how CodeScoop solves the common "black holes"
228
243
  * **Problem:** Modern features like Shadow DOM (`::part`) and CSS Houdini (`@property`) are often ignored by traditional parsers.
229
244
  * **Solution:** CodeScoop includes native support for these features, correctly identifying `::part()` and `::slotted()` rules and extracting structured `@property` definitions.
230
245
 
246
+ ### 6. CSS Modules & CSS-in-JS Detection
247
+ * **Problem:** Build-time hashing (CSS Modules: `Button_primary_a8f3d`) and runtime CSS-in-JS (Styled Components: `sc-bdVaJa`) create dynamic class names that look like "missing" CSS to static analyzers.
248
+ * **Solution:** Our **Smart Filter** recognizes 50+ patterns including:
249
+ * CSS Modules hashes (`_hash` suffix patterns)
250
+ * Styled Components (`sc-xxxxx`)
251
+ * Emotion (`emotion-0`, `css-xxxxx`)
252
+ * JSS (`jss0`, `jss1`)
253
+ These are excluded from Ghost Class reports, keeping output focused on actual issues.
254
+
255
+ ---
256
+
257
+ ## What We DON'T Handle (And Why)
258
+
259
+ While CodeScoop solves 95%+ of CSS analysis problems, some scenarios are outside the scope of static analysis:
260
+
261
+ ### Runtime CSS-in-JS Generation
262
+ **Example:** `styled.div\`color: ${props => props.color};\``
263
+ * **Why:** Styles are generated at runtime based on component props/state. No static CSS file exists to analyze.
264
+ * **Workaround:** Use `--for-conversion` mode which hints at CSS-in-JS patterns in your JavaScript.
265
+
266
+ ### Dynamic className Manipulation
267
+ **Example:** `element.classList.add('dynamic-class-' + userId)`
268
+ * **Why:** Class names are computed at runtime. Static analysis can't predict all possible values.
269
+ * **Impact:** These may appear as "Ghost Classes" if not defined in your CSS.
270
+ * **Workaround:** Use `js-` or `is-` prefixes for JS-generated classes (auto-filtered).
271
+
272
+ ### Source Maps
273
+ **Why:** Line numbers from the original source are "good enough" for debugging. Parsing `.css.map` files adds significant complexity for minimal benefit.
274
+
275
+ ### CSS Custom Media Queries (Draft Spec)
276
+ **Example:** `@custom-media --mobile (max-width: 768px);`
277
+ * **Why:** Requires PostCSS plugin preprocessing. Very low adoption (< 1% of sites).
278
+ * **Status:** Not planned unless adoption increases.
279
+
231
280
  ---
232
281
 
233
282
  ## Contributing
234
283
 
235
284
  Found a bug? Want a feature? PRs welcome!
236
-
237
- ```bash
238
- git clone https://github.com/yourusername/codescoop.git
239
- cd codescoop && npm install
240
- node bin/codescoop.js test/sample.html -s ".navbar"
241
- ```
285
+
286
+ ```bash
287
+ git clone https://github.com/lumos021/codescoop.git
288
+ cd codescoop && npm install
289
+ node bin/codescoop.js test/sample.html -s ".navbar"
290
+ ```
242
291
 
243
292
  ---
244
293
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codescoop",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "Extract HTML component dependencies for AI-powered React/Next.js conversion",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -38,16 +38,16 @@
38
38
  "blade",
39
39
  "twig"
40
40
  ],
41
- "author": "",
41
+ "author": "Lumos021",
42
42
  "license": "MIT",
43
43
  "repository": {
44
44
  "type": "git",
45
- "url": "https://github.com/yourusername/codescoop.git"
45
+ "url": "https://github.com/lumos021/codescoop.git"
46
46
  },
47
47
  "bugs": {
48
- "url": "https://github.com/yourusername/codescoop/issues"
48
+ "url": "https://github.com/lumos021/codescoop/issues"
49
49
  },
50
- "homepage": "https://github.com/yourusername/codescoop#readme",
50
+ "homepage": "https://github.com/lumos021/codescoop#readme",
51
51
  "engines": {
52
52
  "node": ">=16.0.0"
53
53
  },
package/src/index.js CHANGED
@@ -182,7 +182,37 @@ async function runAnalysis(options) {
182
182
  const variableData = await extractVariablesFromMatches(allCSSMatches, allCSSFiles, projectDir);
183
183
  log(`Found ${variableData.usedVariables.length} variables used`);
184
184
 
185
- // Step 11: Generate markdown output
185
+ // Step 11: Extract and check assets
186
+ log('\nExtracting assets...');
187
+ const { extractCSSAssets } = require('./parsers/css-analyzer');
188
+ const { checkAssetAvailability } = require('./utils/asset-checker');
189
+
190
+ // Get HTML assets (already extracted in targetInfo)
191
+ const htmlAssets = targetInfo.assets || { images: [], videos: [], audio: [], icons: [], canvas: [], svgs: [] };
192
+
193
+ // Extract CSS assets
194
+ const cssAssets = extractCSSAssets(cssResults);
195
+
196
+ // Combine all assets
197
+ const allAssets = [
198
+ ...htmlAssets.images.map(a => ({ ...a, source: 'html' })),
199
+ ...htmlAssets.videos.map(a => ({ ...a, source: 'html' })),
200
+ ...htmlAssets.audio.map(a => ({ ...a, source: 'html' })),
201
+ ...htmlAssets.icons.map(a => ({ ...a, source: 'html' })),
202
+ ...cssAssets.map(a => ({ ...a, source: 'css' }))
203
+ ];
204
+
205
+ // Check asset availability
206
+ const assetStatus = await checkAssetAvailability(
207
+ allAssets,
208
+ projectDir || process.cwd(),
209
+ htmlPath,
210
+ { checkRemote: false } // Can be controlled by CLI flag later
211
+ );
212
+
213
+ log(`Assets: ${assetStatus.total} total, ${assetStatus.available} available, ${assetStatus.missing} missing`);
214
+
215
+ // Step 12: Generate markdown output
186
216
  log('\nGenerating markdown report...');
187
217
  const analysis = {
188
218
  targetInfo,
@@ -197,6 +227,8 @@ async function runAnalysis(options) {
197
227
  inlineScripts,
198
228
  missingImports,
199
229
  variableData,
230
+ htmlAssets,
231
+ assetStatus,
200
232
  generatedAt: new Date().toISOString(),
201
233
  // Output options
202
234
  outputOptions: {
@@ -27,6 +27,8 @@ function generateMarkdown(analysis) {
27
27
  inlineScripts,
28
28
  missingImports,
29
29
  variableData = {},
30
+ htmlAssets = {},
31
+ assetStatus = {},
30
32
  generatedAt,
31
33
  outputOptions = {}
32
34
  } = analysis;
@@ -117,6 +119,12 @@ function generateMarkdown(analysis) {
117
119
  sections.push(houdiniSection);
118
120
  }
119
121
 
122
+ // Assets & Resources
123
+ const assetsSection = generateAssetsSection(htmlAssets, assetStatus);
124
+ if (assetsSection) {
125
+ sections.push(assetsSection);
126
+ }
127
+
120
128
  // CSS Dependencies (custom code only)
121
129
  const linkedCSS = filteredCssResults.filter(r => r.isLinked);
122
130
  const unlinkedCSS = filteredCssResults.filter(r => !r.isLinked);
@@ -554,9 +562,129 @@ function updateSummaryForAdvancedFeatures(cssResults) {
554
562
  return summaryAddition;
555
563
  }
556
564
 
565
+ /**
566
+ * Generate Assets & Resources section
567
+ */
568
+ function generateAssetsSection(htmlAssets, assetStatus) {
569
+ const { details = [] } = assetStatus;
570
+
571
+ if (details.length === 0) {
572
+ return null; // No assets to report
573
+ }
574
+
575
+ let content = `## Assets & Resources\n\n`;
576
+
577
+ // Summary
578
+ content += `> **Total:** ${assetStatus.total} | `;
579
+ content += `**Available:** ${assetStatus.available} | `;
580
+ content += `**Missing:** ${assetStatus.missing} | `;
581
+ content += `**External:** ${assetStatus.external} | `;
582
+ content += `**Embedded:** ${assetStatus.embedded}\n\n`;
583
+
584
+ // Group by category
585
+ const images = details.filter(a => a.type.includes('img') || a.type.includes('background') || a.type.includes('poster'));
586
+ const videos = details.filter(a => a.type.includes('video') && !a.type.includes('poster'));
587
+ const audio = details.filter(a => a.type.includes('audio'));
588
+ const fonts = details.filter(a => a.type.includes('font'));
589
+ const icons = details.filter(a => a.type === 'icon');
590
+ const canvas = htmlAssets.canvas || [];
591
+ const svgs = htmlAssets.svgs || [];
592
+
593
+ // Images
594
+ if (images.length > 0) {
595
+ content += `### Images (${images.length})\n\n`;
596
+ content += `| Path | Type | Status | Size | Location |\n`;
597
+ content += `|------|------|--------|------|----------|\n`;
598
+
599
+ images.forEach(img => {
600
+ const status = img.status === 'OK' ? 'OK' :
601
+ img.status === 'EMBEDDED' ? 'Embedded' :
602
+ img.status === 'EXTERNAL' ? 'External' :
603
+ '**NOT FOUND**';
604
+ const size = img.sizeFormatted || '-';
605
+ const location = img.location || 'HTML';
606
+ content += `| \`${img.src}\` | ${img.type} | ${status} | ${size} | ${location} |\n`;
607
+ });
608
+ content += `\n`;
609
+ }
610
+
611
+ // Videos
612
+ if (videos.length > 0) {
613
+ content += `### Videos (${videos.length})\n\n`;
614
+ videos.forEach(vid => {
615
+ const status = vid.status === 'OK' ? 'OK' : vid.status === 'EXTERNAL' ? 'External' : '**NOT FOUND**';
616
+ content += `- \`${vid.src}\` - ${status}`;
617
+ if (vid.sizeFormatted) content += ` (${vid.sizeFormatted})`;
618
+ content += `\n`;
619
+ });
620
+ content += `\n`;
621
+ }
622
+
623
+ // Audio
624
+ if (audio.length > 0) {
625
+ content += `### Audio (${audio.length})\n\n`;
626
+ audio.forEach(aud => {
627
+ const status = aud.status === 'OK' ? 'OK' : aud.status === 'EXTERNAL' ? 'External' : '**NOT FOUND**';
628
+ content += `- \`${aud.src}\` - ${status}\n`;
629
+ });
630
+ content += `\n`;
631
+ }
632
+
633
+ // Fonts
634
+ if (fonts.length > 0) {
635
+ content += `### Fonts (${fonts.length})\n\n`;
636
+ fonts.forEach(font => {
637
+ const status = font.status === 'OK' ? 'OK' : font.status === 'EXTERNAL' ? 'External' : '**NOT FOUND**';
638
+ content += `- \`${font.src}\` - ${status}`;
639
+ if (font.sizeFormatted) content += ` (${font.sizeFormatted})`;
640
+ content += `\n`;
641
+ });
642
+ content += `\n`;
643
+ }
644
+
645
+ // Icons
646
+ if (icons.length > 0) {
647
+ content += `### Icons (${icons.length})\n\n`;
648
+ icons.forEach(icon => {
649
+ content += `- \`${icon.src}\` (${icon.rel || 'icon'})\n`;
650
+ });
651
+ content += `\n`;
652
+ }
653
+
654
+ // Canvas (for WebGL/3D context)
655
+ if (canvas.length > 0) {
656
+ content += `### Canvas Elements (${canvas.length})\n\n`;
657
+ content += `> **Note:** Canvas elements may be used for WebGL/3D rendering. LLM can infer library (Three.js, Babylon.js) from associated JavaScript.\n\n`;
658
+ canvas.forEach(c => {
659
+ content += `- ID: \`${c.id}\` | Size: ${c.width} x ${c.height}\n`;
660
+ });
661
+ content += `\n`;
662
+ }
663
+
664
+ // SVG
665
+ if (svgs.length > 0) {
666
+ content += `### Inline SVG (${svgs.length})\n\n`;
667
+ content += `> Inline SVG graphics detected. Full SVG code is included in the HTML section.\n\n`;
668
+ svgs.forEach(s => {
669
+ content += `- ID: \`${s.id}\``;
670
+ if (s.viewBox) content += ` | ViewBox: ${s.viewBox}`;
671
+ content += `\n`;
672
+ });
673
+ content += `\n`;
674
+ }
675
+
676
+ // Missing assets warning
677
+ if (assetStatus.missing > 0) {
678
+ content += `> **Warning:** ${assetStatus.missing} asset(s) are missing. These may cause broken images or functionality.\n\n`;
679
+ }
680
+
681
+ return content;
682
+ }
683
+
557
684
  module.exports = {
558
685
  generateMarkdown,
559
686
  generateShadowDOMSection,
560
687
  generateHoudiniSection,
561
- updateSummaryForAdvancedFeatures
688
+ updateSummaryForAdvancedFeatures,
689
+ generateAssetsSection
562
690
  };
@@ -480,9 +480,63 @@ function getCacheStats() {
480
480
  };
481
481
  }
482
482
 
483
+ /**
484
+ * Extract assets from CSS (background images, fonts, etc.)
485
+ * @param {Array} cssResults - Array of CSS analysis results
486
+ * @returns {Array} Array of asset objects
487
+ */
488
+ function extractCSSAssets(cssResults) {
489
+ const assets = [];
490
+ const urlRegex = /url\(['"]?([^'"()]+)['"]?\)/g;
491
+
492
+ for (const result of cssResults) {
493
+ const allRules = [
494
+ ...(result.matches || []),
495
+ ...(result.shadowDOMRules || [])
496
+ ];
497
+
498
+ for (const match of allRules) {
499
+ const content = match.content || '';
500
+
501
+ // Extract all url() references
502
+ let urlMatch;
503
+ while ((urlMatch = urlRegex.exec(content)) !== null) {
504
+ const url = urlMatch[1].trim();
505
+
506
+ // Determine asset type based on context
507
+ let assetType = 'css-unknown';
508
+
509
+ if (content.includes('background')) {
510
+ assetType = 'css-background';
511
+ } else if (content.includes('list-style')) {
512
+ assetType = 'css-list-style';
513
+ } else if (content.includes('content:')) {
514
+ assetType = 'css-content';
515
+ } else if (content.includes('cursor:')) {
516
+ assetType = 'css-cursor';
517
+ } else if (content.includes('@font-face')) {
518
+ assetType = 'css-font';
519
+ } else if (content.includes('mask') || content.includes('clip-path')) {
520
+ assetType = 'css-mask';
521
+ }
522
+
523
+ assets.push({
524
+ src: url,
525
+ type: assetType,
526
+ location: `${path.basename(result.filePath)}:${match.startLine}`,
527
+ selector: match.selector
528
+ });
529
+ }
530
+ }
531
+ }
532
+
533
+ return assets;
534
+ }
535
+
483
536
  module.exports = {
484
537
  analyzeCSS,
485
538
  formatCSS,
486
539
  clearCache,
487
- getCacheStats
540
+ getCacheStats,
541
+ extractCSSAssets
488
542
  };
@@ -160,6 +160,9 @@ function extractTargetElement($, htmlContent, options) {
160
160
  // Generate a summary description
161
161
  const summary = generateSummary(tagName, classes, ids);
162
162
 
163
+ // Extract assets from target element
164
+ const assets = extractAssets(targetElement, $);
165
+
163
166
  return {
164
167
  html: targetHtml,
165
168
  classes,
@@ -172,7 +175,8 @@ function extractTargetElement($, htmlContent, options) {
172
175
  summary,
173
176
  selector: selector || `lines ${lineRange}`,
174
177
  matchCount,
175
- warning
178
+ warning,
179
+ assets
176
180
  };
177
181
  }
178
182
 
@@ -448,8 +452,175 @@ function getHTMLStructure($) {
448
452
  return structure;
449
453
  }
450
454
 
455
+ /**
456
+ * Extract all assets (images, videos, audio, icons, canvas, SVG) from target element
457
+ * @param {CheerioElement} element - Target element
458
+ * @param {CheerioAPI} $ - Cheerio instance
459
+ * @returns {Object} Assets grouped by type
460
+ */
461
+ function extractAssets(element, $) {
462
+ const assets = {
463
+ images: [],
464
+ videos: [],
465
+ audio: [],
466
+ icons: [],
467
+ canvas: [],
468
+ svgs: []
469
+ };
470
+
471
+ // Extract IMG tags
472
+ element.find('img').each((_, el) => {
473
+ const $el = $(el);
474
+ const src = $el.attr('src');
475
+ const srcset = $el.attr('srcset');
476
+ const alt = $el.attr('alt') || '';
477
+
478
+ if (src) {
479
+ assets.images.push({
480
+ src,
481
+ type: 'img',
482
+ alt,
483
+ srcset: srcset || null
484
+ });
485
+ }
486
+
487
+ // Parse srcset for additional images
488
+ if (srcset) {
489
+ const srcsetUrls = srcset.split(',').map(s => s.trim().split(' ')[0]);
490
+ srcsetUrls.forEach(url => {
491
+ if (url && url !== src) {
492
+ assets.images.push({
493
+ src: url,
494
+ type: 'img-srcset',
495
+ alt
496
+ });
497
+ }
498
+ });
499
+ }
500
+ });
501
+
502
+ // Extract PICTURE sources
503
+ element.find('picture source').each((_, el) => {
504
+ const $el = $(el);
505
+ const srcset = $el.attr('srcset');
506
+ if (srcset) {
507
+ const srcsetUrls = srcset.split(',').map(s => s.trim().split(' ')[0]);
508
+ srcsetUrls.forEach(url => {
509
+ assets.images.push({
510
+ src: url,
511
+ type: 'picture-source'
512
+ });
513
+ });
514
+ }
515
+ });
516
+
517
+ // Extract VIDEO tags
518
+ element.find('video').each((_, el) => {
519
+ const $el = $(el);
520
+ const src = $el.attr('src');
521
+ const poster = $el.attr('poster');
522
+
523
+ if (src) {
524
+ assets.videos.push({
525
+ src,
526
+ type: 'video',
527
+ poster
528
+ });
529
+ }
530
+
531
+ if (poster) {
532
+ assets.images.push({
533
+ src: poster,
534
+ type: 'video-poster'
535
+ });
536
+ }
537
+
538
+ // Extract source tags inside video
539
+ $el.find('source').each((_, source) => {
540
+ const sourceSrc = $(source).attr('src');
541
+ if (sourceSrc) {
542
+ assets.videos.push({
543
+ src: sourceSrc,
544
+ type: 'video-source'
545
+ });
546
+ }
547
+ });
548
+ });
549
+
550
+ // Extract AUDIO tags
551
+ element.find('audio').each((_, el) => {
552
+ const $el = $(el);
553
+ const src = $el.attr('src');
554
+
555
+ if (src) {
556
+ assets.audio.push({
557
+ src,
558
+ type: 'audio'
559
+ });
560
+ }
561
+
562
+ // Extract source tags inside audio
563
+ $el.find('source').each((_, source) => {
564
+ const sourceSrc = $(source).attr('src');
565
+ if (sourceSrc) {
566
+ assets.audio.push({
567
+ src: sourceSrc,
568
+ type: 'audio-source'
569
+ });
570
+ }
571
+ });
572
+ });
573
+
574
+ // Extract LINK icons (favicon, apple-touch-icon, etc.)
575
+ element.find('link[rel*="icon"]').each((_, el) => {
576
+ const $el = $(el);
577
+ const href = $el.attr('href');
578
+ const rel = $el.attr('rel');
579
+
580
+ if (href) {
581
+ assets.icons.push({
582
+ src: href,
583
+ type: 'icon',
584
+ rel
585
+ });
586
+ }
587
+ });
588
+
589
+ // Extract CANVAS elements (for WebGL detection)
590
+ element.find('canvas').each((_, el) => {
591
+ const $el = $(el);
592
+ const id = $el.attr('id');
593
+ const width = $el.attr('width');
594
+ const height = $el.attr('height');
595
+
596
+ assets.canvas.push({
597
+ id: id || 'unnamed',
598
+ width: width || 'auto',
599
+ height: height || 'auto',
600
+ type: 'canvas'
601
+ });
602
+ });
603
+
604
+ // Extract inline SVG elements (count only, not full content)
605
+ element.find('svg').each((_, el) => {
606
+ const $el = $(el);
607
+ const id = $el.attr('id');
608
+ const viewBox = $el.attr('viewBox');
609
+
610
+ assets.svgs.push({
611
+ id: id || 'unnamed',
612
+ viewBox: viewBox || null,
613
+ type: 'inline-svg',
614
+ inline: true
615
+ });
616
+ });
617
+
618
+ return assets;
619
+ }
620
+
451
621
  module.exports = {
452
622
  parseHTML,
453
623
  extractTargetElement,
454
- getHTMLStructure
624
+ getHTMLStructure,
625
+ extractAssets
455
626
  };
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Asset Availability Checker
3
+ * Verifies that images, fonts, and other assets exist and are accessible
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * Check if a local file exists and get its stats
11
+ * @param {string} assetPath - Absolute path to the asset
12
+ * @returns {Object} Status object
13
+ */
14
+ function checkLocalAsset(assetPath) {
15
+ try {
16
+ if (fs.existsSync(assetPath)) {
17
+ const stats = fs.statSync(assetPath);
18
+ return {
19
+ status: 'OK',
20
+ exists: true,
21
+ size: stats.size,
22
+ sizeFormatted: formatBytes(stats.size)
23
+ };
24
+ }
25
+ } catch (error) {
26
+ return {
27
+ status: 'ERROR',
28
+ exists: false,
29
+ error: error.message
30
+ };
31
+ }
32
+
33
+ return {
34
+ status: 'NOT FOUND',
35
+ exists: false
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Check if a remote asset is accessible (HEAD request)
41
+ * @param {string} url - URL to check
42
+ * @returns {Promise<Object>} Status object
43
+ */
44
+ async function checkRemoteAsset(url) {
45
+ // Skip for now - avoid network calls by default
46
+ // Can be enabled with --check-remote flag
47
+ return {
48
+ status: 'EXTERNAL',
49
+ exists: null, // unknown
50
+ url: url,
51
+ note: 'Use --check-remote to verify external URLs'
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Resolve asset path relative to HTML file or project directory
57
+ * @param {string} assetSrc - Asset source from HTML/CSS
58
+ * @param {string} basePath - Base path (HTML file dir or project dir)
59
+ * @returns {string} Resolved absolute path
60
+ */
61
+ function resolveAssetPath(assetSrc, basePath) {
62
+ // Handle absolute URLs
63
+ if (assetSrc.startsWith('http://') || assetSrc.startsWith('https://') || assetSrc.startsWith('//')) {
64
+ return assetSrc;
65
+ }
66
+
67
+ // Handle data URLs
68
+ if (assetSrc.startsWith('data:')) {
69
+ return 'EMBEDDED_DATA_URL';
70
+ }
71
+
72
+ // Handle root-relative paths (/)
73
+ if (assetSrc.startsWith('/')) {
74
+ // Relative to project root
75
+ return path.join(basePath, assetSrc.substring(1));
76
+ }
77
+
78
+ // Handle relative paths
79
+ return path.join(basePath, assetSrc);
80
+ }
81
+
82
+ /**
83
+ * Check availability of multiple assets
84
+ * @param {Array} assets - Array of asset objects {src, type, location}
85
+ * @param {string} projectDir - Project directory
86
+ * @param {string} htmlPath - HTML file path
87
+ * @param {Object} options - Options {checkRemote: boolean}
88
+ * @returns {Promise<Object>} Availability report
89
+ */
90
+ async function checkAssetAvailability(assets, projectDir, htmlPath, options = {}) {
91
+ const { checkRemote = false } = options;
92
+
93
+ const basePath = htmlPath ? path.dirname(htmlPath) : projectDir;
94
+ const results = [];
95
+
96
+ let totalCount = 0;
97
+ let availableCount = 0;
98
+ let missingCount = 0;
99
+ let externalCount = 0;
100
+ let embeddedCount = 0;
101
+
102
+ for (const asset of assets) {
103
+ totalCount++;
104
+
105
+ const resolvedPath = resolveAssetPath(asset.src, basePath);
106
+ let checkResult;
107
+
108
+ // Data URL (embedded)
109
+ if (resolvedPath === 'EMBEDDED_DATA_URL') {
110
+ embeddedCount++;
111
+ checkResult = {
112
+ status: 'EMBEDDED',
113
+ exists: true,
114
+ note: 'Data URL (embedded in HTML/CSS)'
115
+ };
116
+ }
117
+ // External URL
118
+ else if (resolvedPath.startsWith('http')) {
119
+ externalCount++;
120
+ if (checkRemote) {
121
+ checkResult = await checkRemoteAsset(resolvedPath);
122
+ } else {
123
+ checkResult = {
124
+ status: 'EXTERNAL',
125
+ exists: null,
126
+ url: resolvedPath
127
+ };
128
+ }
129
+ }
130
+ // Local file
131
+ else {
132
+ checkResult = checkLocalAsset(resolvedPath);
133
+ if (checkResult.exists) {
134
+ availableCount++;
135
+ } else {
136
+ missingCount++;
137
+ }
138
+ }
139
+
140
+ results.push({
141
+ src: asset.src,
142
+ type: asset.type,
143
+ location: asset.location,
144
+ resolvedPath,
145
+ ...checkResult
146
+ });
147
+ }
148
+
149
+ return {
150
+ total: totalCount,
151
+ available: availableCount,
152
+ missing: missingCount,
153
+ external: externalCount,
154
+ embedded: embeddedCount,
155
+ details: results
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Format bytes to human-readable string
161
+ */
162
+ function formatBytes(bytes) {
163
+ if (bytes === 0) return '0 B';
164
+ const k = 1024;
165
+ const sizes = ['B', 'KB', 'MB'];
166
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
167
+ return Math.round(bytes / Math.pow(k, i)) + ' ' + sizes[i];
168
+ }
169
+
170
+ module.exports = {
171
+ checkAssetAvailability,
172
+ resolveAssetPath,
173
+ checkLocalAsset,
174
+ formatBytes
175
+ };
@@ -112,9 +112,23 @@ function detectGhostClasses(targetInfo, cssResults = [], cssLibraryResults = [],
112
112
  /^animate__/, /^aos-/, /^fade/, /^slide/, /^zoom/,
113
113
 
114
114
  // Utility libraries
115
- /^u-/, /^util-/, /^helper-/
115
+ /^u-/, /^util-/, /^helper-/,
116
+
117
+ // CSS Modules (build-time hash patterns)
118
+ // Examples: Button_primary_a8f3d, styles_header_x9k2l, MyComponent__button__2x3d
119
+ /^[A-Z][a-zA-Z0-9]*_[a-zA-Z0-9]+_[a-z0-9]{4,}$/, // CamelCase_name_hash
120
+ /_[a-z0-9]{5,}$/, // Any class ending with _hash (5+ chars)
121
+ /^[a-z]+__[a-z]+__[a-z0-9]{4,}$/, // BEM-like with hash: component__element__hash
122
+
123
+ // CSS-in-JS (Styled Components, Emotion)
124
+ // Examples: sc-bdVaJa, css-1x2y3z, emotion-0
125
+ /^sc-[a-zA-Z0-9]{5,}$/, // Styled Components: sc-xxxxx
126
+ /^css-[a-z0-9]{5,}$/, // Generic CSS-in-JS: css-xxxxx
127
+ /^emotion-\d+$/, // Emotion: emotion-0, emotion-1
128
+ /^jss\d+$/ // JSS: jss0, jss1
116
129
  ];
117
130
 
131
+
118
132
  // Find ghost classes (in HTML but not in any CSS)
119
133
  const ghostClasses = classes.filter(cls => {
120
134
  const className = cls.startsWith('.') ? cls : '.' + cls;
@@ -17,6 +17,27 @@ function calculateSpecificity(selector) {
17
17
  return [0, 0, 0, 0];
18
18
  }
19
19
 
20
+ // Special case: :where() always has 0,0,0 specificity per W3C spec
21
+ // Check if selector contains :where() but NOT inside :is() or other pseudo-classes
22
+ if (selector.includes(':where(')) {
23
+ // If the selector is ONLY :where(...) content, return 0,0,0
24
+ // Example: :where(.menu, .nav) -> 0,0,0
25
+ // But :is(.menu):where(.active) -> calculate :is() part only
26
+
27
+ // Simple heuristic: if selector starts with :where(, it's purely :where()
28
+ if (selector.trim().startsWith(':where(')) {
29
+ return [0, 0, 0, 0];
30
+ }
31
+
32
+ // More complex: has both :is() and :where()
33
+ // For now, strip :where() parts and calculate the rest
34
+ const withoutWhere = selector.replace(/:where\([^)]+\)/g, '');
35
+ if (withoutWhere.trim().length === 0) {
36
+ return [0, 0, 0, 0];
37
+ }
38
+ selector = withoutWhere;
39
+ }
40
+
20
41
  try {
21
42
  const result = calculate(selector);
22
43
  if (result) {