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 +55 -6
- package/package.json +5 -5
- package/src/index.js +33 -1
- package/src/output/markdown.js +129 -1
- package/src/parsers/css-analyzer.js +55 -1
- package/src/parsers/html-parser.js +173 -2
- package/src/utils/asset-checker.js +175 -0
- package/src/utils/ghost-detector.js +15 -1
- package/src/utils/specificity-calculator.js +21 -0
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/
|
|
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.
|
|
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/
|
|
45
|
+
"url": "https://github.com/lumos021/codescoop.git"
|
|
46
46
|
},
|
|
47
47
|
"bugs": {
|
|
48
|
-
"url": "https://github.com/
|
|
48
|
+
"url": "https://github.com/lumos021/codescoop/issues"
|
|
49
49
|
},
|
|
50
|
-
"homepage": "https://github.com/
|
|
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:
|
|
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: {
|
package/src/output/markdown.js
CHANGED
|
@@ -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) {
|