gbu-accessibility-package 3.4.0 โ†’ 3.8.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/lib/fixer.js CHANGED
@@ -1236,6 +1236,9 @@ class AccessibilityFixer {
1236
1236
  altCreativity: config.altCreativity || 'balanced', // conservative, balanced, creative
1237
1237
  includeEmotions: config.includeEmotions || false,
1238
1238
  strictAltChecking: config.strictAltChecking || false,
1239
+ // New options for advanced features
1240
+ autoFixHeadings: config.autoFixHeadings || false, // Enable automatic heading fixes
1241
+ fixDescriptionLists: config.fixDescriptionLists || true, // Enable DL structure fixes
1239
1242
  ...config
1240
1243
  };
1241
1244
 
@@ -2271,10 +2274,13 @@ class AccessibilityFixer {
2271
2274
  console.log(chalk.yellow('\n๐Ÿ“‘ Step 8: Heading analysis...'));
2272
2275
  results.headings = await this.analyzeHeadings(directory);
2273
2276
 
2274
- // Step 9: Check broken links (no auto-fix)
2275
- console.log(chalk.yellow('\n๐Ÿ”— Step 9: Broken links check...'));
2277
+ // Step 9: Check broken links and missing resources (no auto-fix)
2278
+ console.log(chalk.yellow('\n๐Ÿ”— Step 9: External links check...'));
2276
2279
  results.brokenLinks = await this.checkBrokenLinks(directory);
2277
2280
 
2281
+ console.log(chalk.yellow('\n๐Ÿ“ Step 9b: Missing resources check...'));
2282
+ results.missingResources = await this.check404Resources(directory);
2283
+
2278
2284
  // Step 10: Cleanup duplicate roles
2279
2285
  console.log(chalk.yellow('\n๐Ÿงน Step 10: Cleanup duplicate roles...'));
2280
2286
  results.cleanup = await this.cleanupDuplicateRoles(directory);
@@ -2863,9 +2869,9 @@ class AccessibilityFixer {
2863
2869
  return fixed;
2864
2870
  }
2865
2871
 
2866
- // Check for broken links and 404 resources
2872
+ // Check for broken external links only
2867
2873
  async checkBrokenLinks(directory = '.') {
2868
- console.log(chalk.blue('๐Ÿ”— Checking for broken links and 404 resources...'));
2874
+ console.log(chalk.blue('๐Ÿ”— Checking for broken external links...'));
2869
2875
 
2870
2876
  const htmlFiles = await this.findHtmlFiles(directory);
2871
2877
  const results = [];
@@ -2873,7 +2879,7 @@ class AccessibilityFixer {
2873
2879
  for (const file of htmlFiles) {
2874
2880
  try {
2875
2881
  const content = await fs.readFile(file, 'utf8');
2876
- const issues = await this.analyzeBrokenLinks(content, file);
2882
+ const issues = await this.analyzeBrokenLinks(content, file, 'external-only');
2877
2883
 
2878
2884
  if (issues.length > 0) {
2879
2885
  console.log(chalk.cyan(`\n๐Ÿ“ ${file}:`));
@@ -2892,12 +2898,46 @@ class AccessibilityFixer {
2892
2898
  }
2893
2899
  }
2894
2900
 
2895
- console.log(chalk.blue(`\n๐Ÿ“Š Summary: Analyzed links in ${results.length} files`));
2901
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Analyzed external links in ${results.length} files`));
2896
2902
  console.log(chalk.gray('๐Ÿ’ก Broken link issues require manual review and cannot be auto-fixed'));
2897
2903
  return results;
2898
2904
  }
2899
2905
 
2900
- async analyzeBrokenLinks(content, filePath) {
2906
+ // Check for 404 resources (local files) only
2907
+ async check404Resources(directory = '.') {
2908
+ console.log(chalk.blue('๐Ÿ“ Checking for 404 resources (missing local files)...'));
2909
+
2910
+ const htmlFiles = await this.findHtmlFiles(directory);
2911
+ const results = [];
2912
+
2913
+ for (const file of htmlFiles) {
2914
+ try {
2915
+ const content = await fs.readFile(file, 'utf8');
2916
+ const issues = await this.analyzeBrokenLinks(content, file, 'local-only');
2917
+
2918
+ if (issues.length > 0) {
2919
+ console.log(chalk.cyan(`\n๐Ÿ“ ${file}:`));
2920
+ issues.forEach(issue => {
2921
+ console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
2922
+ if (issue.suggestion) {
2923
+ console.log(chalk.gray(` ๐Ÿ’ก ${issue.suggestion}`));
2924
+ }
2925
+ });
2926
+ }
2927
+
2928
+ results.push({ file, status: 'analyzed', issues: issues.length, missingResources: issues });
2929
+ } catch (error) {
2930
+ console.error(chalk.red(`โŒ Error processing ${file}: ${error.message}`));
2931
+ results.push({ file, status: 'error', error: error.message });
2932
+ }
2933
+ }
2934
+
2935
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Analyzed local resources in ${results.length} files`));
2936
+ console.log(chalk.gray('๐Ÿ’ก Missing resource issues require manual review and cannot be auto-fixed'));
2937
+ return results;
2938
+ }
2939
+
2940
+ async analyzeBrokenLinks(content, filePath, mode = 'all') {
2901
2941
  const issues = [];
2902
2942
  const path = require('path');
2903
2943
  const http = require('http');
@@ -2905,20 +2945,33 @@ class AccessibilityFixer {
2905
2945
  const { URL } = require('url');
2906
2946
 
2907
2947
  // Extract all links and resources
2908
- const linkPatterns = [
2909
- // Anchor links
2910
- { pattern: /<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Link', element: 'a' },
2911
- // Images
2912
- { pattern: /<img[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Image', element: 'img' },
2913
- // CSS links
2914
- { pattern: /<link[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'CSS', element: 'link' },
2915
- // Script sources
2916
- { pattern: /<script[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Script', element: 'script' },
2917
- // Video sources
2918
- { pattern: /<video[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Video', element: 'video' },
2919
- // Audio sources
2920
- { pattern: /<audio[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Audio', element: 'audio' }
2921
- ];
2948
+ let linkPatterns = [];
2949
+
2950
+ if (mode === 'external-only') {
2951
+ // Only anchor links for external link checking
2952
+ linkPatterns = [
2953
+ { pattern: /<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Link', element: 'a' }
2954
+ ];
2955
+ } else if (mode === 'local-only') {
2956
+ // Only resources (not anchor links) for 404 resource checking
2957
+ linkPatterns = [
2958
+ { pattern: /<img[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Image', element: 'img' },
2959
+ { pattern: /<link[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'CSS', element: 'link' },
2960
+ { pattern: /<script[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Script', element: 'script' },
2961
+ { pattern: /<video[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Video', element: 'video' },
2962
+ { pattern: /<audio[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Audio', element: 'audio' }
2963
+ ];
2964
+ } else {
2965
+ // All patterns for comprehensive checking
2966
+ linkPatterns = [
2967
+ { pattern: /<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Link', element: 'a' },
2968
+ { pattern: /<img[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Image', element: 'img' },
2969
+ { pattern: /<link[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'CSS', element: 'link' },
2970
+ { pattern: /<script[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Script', element: 'script' },
2971
+ { pattern: /<video[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Video', element: 'video' },
2972
+ { pattern: /<audio[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Audio', element: 'audio' }
2973
+ ];
2974
+ }
2922
2975
 
2923
2976
  const baseDir = path.dirname(filePath);
2924
2977
 
@@ -2926,7 +2979,7 @@ class AccessibilityFixer {
2926
2979
  let match;
2927
2980
  while ((match = linkPattern.pattern.exec(content)) !== null) {
2928
2981
  const url = match[1];
2929
- const issue = await this.checkSingleLink(url, baseDir, linkPattern.type, linkPattern.element);
2982
+ const issue = await this.checkSingleLink(url, baseDir, linkPattern.type, linkPattern.element, mode);
2930
2983
  if (issue) {
2931
2984
  issues.push(issue);
2932
2985
  }
@@ -2936,7 +2989,7 @@ class AccessibilityFixer {
2936
2989
  return issues;
2937
2990
  }
2938
2991
 
2939
- async checkSingleLink(url, baseDir, resourceType, elementType) {
2992
+ async checkSingleLink(url, baseDir, resourceType, elementType, mode = 'all') {
2940
2993
  // Skip certain URLs
2941
2994
  if (this.shouldSkipUrl(url)) {
2942
2995
  return null;
@@ -2944,10 +2997,16 @@ class AccessibilityFixer {
2944
2997
 
2945
2998
  try {
2946
2999
  if (this.isExternalUrl(url)) {
2947
- // Check external URLs
3000
+ // Check external URLs only if mode allows
3001
+ if (mode === 'local-only') {
3002
+ return null; // Skip external URLs in local-only mode
3003
+ }
2948
3004
  return await this.checkExternalUrl(url, resourceType, elementType);
2949
3005
  } else {
2950
- // Check local files
3006
+ // Check local files only if mode allows
3007
+ if (mode === 'external-only') {
3008
+ return null; // Skip local files in external-only mode
3009
+ }
2951
3010
  return await this.checkLocalFile(url, baseDir, resourceType, elementType);
2952
3011
  }
2953
3012
  } catch (error) {
@@ -3609,12 +3668,18 @@ class AccessibilityFixer {
3609
3668
  results.steps.push({ step: 8, name: 'Heading analysis', suggestions: totalHeadingSuggestions });
3610
3669
  console.log(chalk.gray('๐Ÿ’ก Heading issues require manual review and cannot be auto-fixed'));
3611
3670
 
3612
- // Step 9: Broken links check
3613
- console.log(chalk.blue('๐Ÿ”— Step 9: Broken links check...'));
3671
+ // Step 9: Broken links and missing resources check
3672
+ console.log(chalk.blue('๐Ÿ”— Step 9a: External links check...'));
3614
3673
  const brokenLinksResults = await this.checkBrokenLinks(directory);
3615
3674
  const totalBrokenLinks = brokenLinksResults.reduce((sum, r) => sum + (r.issues || 0), 0);
3616
- results.steps.push({ step: 9, name: 'Broken links check', issues: totalBrokenLinks });
3617
- console.log(chalk.gray('๐Ÿ’ก Broken link issues require manual review and cannot be auto-fixed'));
3675
+ results.steps.push({ step: '9a', name: 'External links check', issues: totalBrokenLinks });
3676
+
3677
+ console.log(chalk.blue('๐Ÿ“ Step 9b: Missing resources check...'));
3678
+ const missingResourcesResults = await this.check404Resources(directory);
3679
+ const totalMissingResources = missingResourcesResults.reduce((sum, r) => sum + (r.issues || 0), 0);
3680
+ results.steps.push({ step: '9b', name: 'Missing resources check', issues: totalMissingResources });
3681
+
3682
+ console.log(chalk.gray('๐Ÿ’ก Link and resource issues require manual review and cannot be auto-fixed'));
3618
3683
 
3619
3684
  // Step 10: Cleanup duplicate roles
3620
3685
  console.log(chalk.blue('๐Ÿงน Step 10: Cleanup duplicate roles...'));
@@ -3981,71 +4046,7 @@ class AccessibilityFixer {
3981
4046
  return results;
3982
4047
  }
3983
4048
 
3984
- async checkBrokenLinks(directory = '.') {
3985
- console.log(chalk.blue('๐Ÿ”— Checking for broken links and 404 resources...'));
3986
-
3987
- const htmlFiles = await this.findHtmlFiles(directory);
3988
- const results = [];
3989
- let totalIssuesFound = 0;
3990
-
3991
- for (const file of htmlFiles) {
3992
- try {
3993
- const content = await fs.readFile(file, 'utf8');
3994
- const issues = this.analyzeBrokenLinks(content, file);
3995
-
3996
- if (issues.length > 0) {
3997
- console.log(chalk.cyan(`\n๐Ÿ“ ${file}:`));
3998
- issues.forEach(issue => {
3999
- console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
4000
- if (issue.suggestion) {
4001
- console.log(chalk.gray(` ๐Ÿ’ก ${issue.suggestion}`));
4002
- }
4003
- totalIssuesFound++;
4004
- });
4005
- }
4006
-
4007
- results.push({ file, status: 'analyzed', issues: issues.length });
4008
- } catch (error) {
4009
- console.error(chalk.red(`โŒ Error processing ${file}: ${error.message}`));
4010
- results.push({ file, status: 'error', error: error.message });
4011
- }
4012
- }
4013
-
4014
- console.log(chalk.blue(`\n๐Ÿ“Š Summary: Analyzed links in ${results.length} files`));
4015
- console.log(chalk.gray('๐Ÿ’ก Broken link issues require manual review and cannot be auto-fixed'));
4016
- return results;
4017
- }
4018
4049
 
4019
- analyzeBrokenLinks(content, filePath) {
4020
- const issues = [];
4021
-
4022
- // Check for local image files
4023
- const imgPattern = /<img[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi;
4024
- let match;
4025
-
4026
- while ((match = imgPattern.exec(content)) !== null) {
4027
- const src = match[1];
4028
-
4029
- // Skip external URLs and data URLs
4030
- if (src.startsWith('http') || src.startsWith('data:') || src.startsWith('//')) {
4031
- continue;
4032
- }
4033
-
4034
- // Check if local file exists
4035
- const fullPath = path.resolve(path.dirname(filePath), src);
4036
- try {
4037
- require('fs').statSync(fullPath);
4038
- } catch (error) {
4039
- issues.push({
4040
- type: '๐Ÿ“ Image not found',
4041
- description: `img file does not exist: ${src}`,
4042
- suggestion: 'Create the missing file or update the image path'
4043
- });
4044
- }
4045
- }
4046
-
4047
- return issues;
4048
- }
4049
4050
 
4050
4051
  async cleanupDuplicateRoles(directory = '.') {
4051
4052
  console.log(chalk.blue('๐Ÿงน Cleaning up duplicate role attributes...'));
@@ -4400,12 +4401,18 @@ class AccessibilityFixer {
4400
4401
  results.steps.push({ step: 9, name: 'Heading analysis', suggestions: totalHeadingSuggestions });
4401
4402
  console.log(chalk.gray('๐Ÿ’ก Heading issues require manual review and cannot be auto-fixed'));
4402
4403
 
4403
- // Step 10: Broken links check
4404
- console.log(chalk.blue('๐Ÿ”— Step 10: Broken links check...'));
4404
+ // Step 10: Broken links and missing resources check
4405
+ console.log(chalk.blue('๐Ÿ”— Step 10a: External links check...'));
4405
4406
  const brokenLinksResults = await this.checkBrokenLinks(directory);
4406
4407
  const totalBrokenLinks = brokenLinksResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4407
- results.steps.push({ step: 10, name: 'Broken links check', issues: totalBrokenLinks });
4408
- console.log(chalk.gray('๐Ÿ’ก Broken link issues require manual review and cannot be auto-fixed'));
4408
+ results.steps.push({ step: '10a', name: 'External links check', issues: totalBrokenLinks });
4409
+
4410
+ console.log(chalk.blue('๐Ÿ“ Step 10b: Missing resources check...'));
4411
+ const missingResourcesResults = await this.check404Resources(directory);
4412
+ const totalMissingResources = missingResourcesResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4413
+ results.steps.push({ step: '10b', name: 'Missing resources check', issues: totalMissingResources });
4414
+
4415
+ console.log(chalk.gray('๐Ÿ’ก Link and resource issues require manual review and cannot be auto-fixed'));
4409
4416
 
4410
4417
  // Step 11: Cleanup duplicate roles
4411
4418
  console.log(chalk.blue('๐Ÿงน Step 11: Cleanup duplicate roles...'));
@@ -4454,37 +4461,2005 @@ class AccessibilityFixer {
4454
4461
  }
4455
4462
  }
4456
4463
 
4457
- async findHtmlFiles(directory) {
4458
- const files = [];
4464
+ async fixHeadingStructure(directory = '.') {
4465
+ console.log(chalk.blue('๐Ÿ“‘ Fixing heading structure...'));
4459
4466
 
4460
- // Check if the path is a file or directory
4461
- const stat = await fs.stat(directory);
4467
+ const htmlFiles = await this.findHtmlFiles(directory);
4468
+ const results = [];
4469
+ let totalIssuesFound = 0;
4470
+ let totalIssuesFixed = 0;
4462
4471
 
4463
- if (stat.isFile()) {
4464
- // If it's a file, check if it's HTML
4465
- if (directory.endsWith('.html')) {
4466
- files.push(directory);
4472
+ for (const file of htmlFiles) {
4473
+ try {
4474
+ const content = await fs.readFile(file, 'utf8');
4475
+ const analysis = this.analyzeHeadingStructure(content);
4476
+
4477
+ if (analysis.issues.length > 0) {
4478
+ console.log(chalk.cyan(`\n๐Ÿ“ ${file}:`));
4479
+ analysis.issues.forEach(issue => {
4480
+ console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
4481
+ if (issue.suggestion) {
4482
+ console.log(chalk.gray(` ๐Ÿ’ก ${issue.suggestion}`));
4483
+ }
4484
+ totalIssuesFound++;
4485
+ });
4486
+ }
4487
+
4488
+ let fixed = content;
4489
+ let changesMade = false;
4490
+
4491
+ if (this.config.autoFixHeadings) {
4492
+ fixed = this.fixHeadingStructureInContent(content, analysis);
4493
+ changesMade = fixed !== content;
4494
+
4495
+ if (changesMade) {
4496
+ const fixedCount = this.countHeadingFixes(content, fixed);
4497
+ totalIssuesFixed += fixedCount;
4498
+
4499
+ if (this.config.backupFiles) {
4500
+ await fs.writeFile(`${file}.backup`, content);
4501
+ }
4502
+
4503
+ if (!this.config.dryRun) {
4504
+ await fs.writeFile(file, fixed);
4505
+ }
4506
+
4507
+ console.log(chalk.green(`โœ… Fixed heading structure in: ${file} (${fixedCount} fixes)`));
4508
+ results.push({ file, status: 'fixed', issues: analysis.issues.length, fixes: fixedCount });
4509
+ } else {
4510
+ results.push({ file, status: 'no-change', issues: analysis.issues.length });
4511
+ }
4512
+ } else {
4513
+ results.push({ file, status: 'analyzed', issues: analysis.issues.length });
4514
+ }
4515
+ } catch (error) {
4516
+ console.error(chalk.red(`โŒ Error processing ${file}: ${error.message}`));
4517
+ results.push({ file, status: 'error', error: error.message });
4467
4518
  }
4468
- return files;
4469
4519
  }
4470
4520
 
4471
- // If it's a directory, scan recursively
4472
- async function scan(dir) {
4473
- const entries = await fs.readdir(dir, { withFileTypes: true });
4521
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Found ${totalIssuesFound} heading issues across ${results.length} files`));
4522
+ if (this.config.autoFixHeadings) {
4523
+ console.log(chalk.green(` Fixed ${totalIssuesFixed} heading issues automatically`));
4524
+ } else {
4525
+ console.log(chalk.gray('๐Ÿ’ก Use --auto-fix-headings option to enable automatic fixes'));
4526
+ }
4527
+
4528
+ return results;
4529
+ }
4530
+
4531
+ analyzeHeadingStructure(content) {
4532
+ const issues = [];
4533
+ const suggestions = [];
4534
+
4535
+ // Extract all headings with their levels, text, and positions
4536
+ const headingPattern = /<h([1-6])[^>]*>([\s\S]*?)<\/h[1-6]>/gi;
4537
+ const headings = [];
4538
+ let match;
4539
+
4540
+ while ((match = headingPattern.exec(content)) !== null) {
4541
+ const level = parseInt(match[1]);
4542
+ const rawText = match[2];
4543
+ const text = rawText.replace(/<[^>]*>/g, '').trim();
4544
+ const fullTag = match[0];
4474
4545
 
4475
- for (const entry of entries) {
4476
- const fullPath = path.join(dir, entry.name);
4477
-
4478
- if (entry.isDirectory() && !entry.name.startsWith('.')) {
4479
- await scan(fullPath);
4480
- } else if (entry.isFile() && entry.name.endsWith('.html')) {
4481
- files.push(fullPath);
4482
- }
4546
+ headings.push({
4547
+ level,
4548
+ text,
4549
+ rawText,
4550
+ fullTag,
4551
+ position: match.index,
4552
+ originalMatch: match[0]
4553
+ });
4554
+ }
4555
+
4556
+ if (headings.length === 0) {
4557
+ issues.push({
4558
+ type: '๐Ÿ“‘ No headings found',
4559
+ description: 'Page has no heading elements',
4560
+ suggestion: 'Add heading elements (h1-h6) to structure content',
4561
+ severity: 'error',
4562
+ fixable: false
4563
+ });
4564
+ return { issues, suggestions, headings };
4565
+ }
4566
+
4567
+ // Check for h1
4568
+ const h1Count = headings.filter(h => h.level === 1).length;
4569
+ if (h1Count === 0) {
4570
+ issues.push({
4571
+ type: '๐Ÿ“‘ Missing h1',
4572
+ description: 'Page should have exactly one h1 element',
4573
+ suggestion: 'Add an h1 element as the main page heading',
4574
+ severity: 'error',
4575
+ fixable: true,
4576
+ fix: 'add-h1'
4577
+ });
4578
+ } else if (h1Count > 1) {
4579
+ issues.push({
4580
+ type: '๐Ÿ“‘ Multiple h1 elements',
4581
+ description: `Found ${h1Count} h1 elements, should have only one`,
4582
+ suggestion: 'Convert extra h1 elements to h2-h6 as appropriate',
4583
+ severity: 'error',
4584
+ fixable: true,
4585
+ fix: 'fix-multiple-h1'
4586
+ });
4587
+ }
4588
+
4589
+ // Check heading order and hierarchy
4590
+ for (let i = 1; i < headings.length; i++) {
4591
+ const current = headings[i];
4592
+ const previous = headings[i - 1];
4593
+
4594
+ // Check for level skipping
4595
+ if (current.level > previous.level + 1) {
4596
+ issues.push({
4597
+ type: '๐Ÿ“‘ Heading level skip',
4598
+ description: `Heading level jumps from h${previous.level} to h${current.level}`,
4599
+ suggestion: `Use h${previous.level + 1} instead of h${current.level}`,
4600
+ severity: 'warning',
4601
+ fixable: true,
4602
+ fix: 'fix-level-skip',
4603
+ targetIndex: i,
4604
+ correctLevel: previous.level + 1
4605
+ });
4483
4606
  }
4484
4607
  }
4485
4608
 
4486
- await scan(directory);
4487
- return files;
4609
+ // Check for empty headings
4610
+ headings.forEach((heading, index) => {
4611
+ if (!heading.text) {
4612
+ issues.push({
4613
+ type: '๐Ÿ“‘ Empty heading',
4614
+ description: `Heading ${index + 1} (h${heading.level}) is empty`,
4615
+ suggestion: 'Add descriptive text to the heading or remove it',
4616
+ severity: 'error',
4617
+ fixable: true,
4618
+ fix: 'fix-empty-heading',
4619
+ targetIndex: index
4620
+ });
4621
+ }
4622
+ });
4623
+
4624
+ // Check for consecutive headings with same level and similar content
4625
+ for (let i = 1; i < headings.length; i++) {
4626
+ const current = headings[i];
4627
+ const previous = headings[i - 1];
4628
+
4629
+ if (current.level === previous.level &&
4630
+ current.text.toLowerCase() === previous.text.toLowerCase() &&
4631
+ current.text.length > 0) {
4632
+ issues.push({
4633
+ type: '๐Ÿ“‘ Duplicate heading',
4634
+ description: `Duplicate h${current.level} heading: "${current.text}"`,
4635
+ suggestion: 'Make heading text unique or merge content',
4636
+ severity: 'warning',
4637
+ fixable: false
4638
+ });
4639
+ }
4640
+ }
4641
+
4642
+ return { issues, suggestions, headings };
4643
+ }
4644
+
4645
+ fixHeadingStructureInContent(content, analysis) {
4646
+ let fixed = content;
4647
+ const { issues, headings } = analysis;
4648
+
4649
+ // Sort issues by position (descending) to avoid position shifts
4650
+ const fixableIssues = issues
4651
+ .filter(issue => issue.fixable)
4652
+ .sort((a, b) => (b.targetIndex || 0) - (a.targetIndex || 0));
4653
+
4654
+ fixableIssues.forEach(issue => {
4655
+ switch (issue.fix) {
4656
+ case 'add-h1':
4657
+ fixed = this.addMissingH1(fixed);
4658
+ break;
4659
+
4660
+ case 'fix-multiple-h1':
4661
+ fixed = this.fixMultipleH1(fixed, headings);
4662
+ break;
4663
+
4664
+ case 'fix-level-skip':
4665
+ if (issue.targetIndex !== undefined && issue.correctLevel) {
4666
+ fixed = this.fixHeadingLevelSkip(fixed, headings[issue.targetIndex], issue.correctLevel);
4667
+ }
4668
+ break;
4669
+
4670
+ case 'fix-empty-heading':
4671
+ if (issue.targetIndex !== undefined) {
4672
+ fixed = this.fixEmptyHeading(fixed, headings[issue.targetIndex]);
4673
+ }
4674
+ break;
4675
+ }
4676
+ });
4677
+
4678
+ return fixed;
4679
+ }
4680
+
4681
+ addMissingH1(content) {
4682
+ // Try to find the first heading and convert it to h1
4683
+ const firstHeadingMatch = content.match(/<h([2-6])[^>]*>([\s\S]*?)<\/h[2-6]>/i);
4684
+
4685
+ if (firstHeadingMatch) {
4686
+ const level = firstHeadingMatch[1];
4687
+ const replacement = firstHeadingMatch[0].replace(
4688
+ new RegExp(`<h${level}([^>]*)>`, 'i'),
4689
+ '<h1$1>'
4690
+ ).replace(
4691
+ new RegExp(`</h${level}>`, 'i'),
4692
+ '</h1>'
4693
+ );
4694
+
4695
+ const result = content.replace(firstHeadingMatch[0], replacement);
4696
+ console.log(chalk.yellow(` ๐Ÿ“‘ Converted first h${level} to h1`));
4697
+ return result;
4698
+ }
4699
+
4700
+ // If no headings found, try to add h1 based on title or first significant text
4701
+ const titleMatch = content.match(/<title[^>]*>([^<]+)<\/title>/i);
4702
+ if (titleMatch) {
4703
+ const title = titleMatch[1].trim();
4704
+ // Insert h1 after opening body tag
4705
+ const bodyMatch = content.match(/(<body[^>]*>)/i);
4706
+ if (bodyMatch) {
4707
+ const h1Element = `\n <h1>${title}</h1>\n`;
4708
+ const result = content.replace(bodyMatch[1], bodyMatch[1] + h1Element);
4709
+ console.log(chalk.yellow(` ๐Ÿ“‘ Added h1 element with title: "${title}"`));
4710
+ return result;
4711
+ }
4712
+ }
4713
+
4714
+ return content;
4715
+ }
4716
+
4717
+ fixMultipleH1(content, headings) {
4718
+ const h1Elements = headings.filter(h => h.level === 1);
4719
+
4720
+ // Keep the first h1, convert others to h2
4721
+ for (let i = 1; i < h1Elements.length; i++) {
4722
+ const h1 = h1Elements[i];
4723
+ const replacement = h1.fullTag.replace(/<h1([^>]*)>/i, '<h2$1>').replace(/<\/h1>/i, '</h2>');
4724
+ content = content.replace(h1.fullTag, replacement);
4725
+ console.log(chalk.yellow(` ๐Ÿ“‘ Converted extra h1 to h2: "${h1.text}"`));
4726
+ }
4727
+
4728
+ return content;
4729
+ }
4730
+
4731
+ fixHeadingLevelSkip(content, heading, correctLevel) {
4732
+ const replacement = heading.fullTag
4733
+ .replace(new RegExp(`<h${heading.level}([^>]*)>`, 'i'), `<h${correctLevel}$1>`)
4734
+ .replace(new RegExp(`</h${heading.level}>`, 'i'), `</h${correctLevel}>`);
4735
+
4736
+ const result = content.replace(heading.fullTag, replacement);
4737
+ console.log(chalk.yellow(` ๐Ÿ“‘ Fixed level skip: h${heading.level} โ†’ h${correctLevel} for "${heading.text}"`));
4738
+ return result;
4739
+ }
4740
+
4741
+ fixEmptyHeading(content, heading) {
4742
+ // Generate meaningful text based on context
4743
+ const contextText = this.generateHeadingText(content, heading);
4744
+
4745
+ if (contextText) {
4746
+ const replacement = heading.fullTag.replace(
4747
+ /<h([1-6])([^>]*)>[\s\S]*?<\/h[1-6]>/i,
4748
+ `<h$1$2>${contextText}</h$1>`
4749
+ );
4750
+
4751
+ const result = content.replace(heading.fullTag, replacement);
4752
+ console.log(chalk.yellow(` ๐Ÿ“‘ Added text to empty heading: "${contextText}"`));
4753
+ return result;
4754
+ }
4755
+
4756
+ // If can't generate text, remove the empty heading
4757
+ const result = content.replace(heading.fullTag, '');
4758
+ console.log(chalk.yellow(` ๐Ÿ“‘ Removed empty h${heading.level} heading`));
4759
+ return result;
4760
+ }
4761
+
4762
+ generateHeadingText(content, heading) {
4763
+ const lang = this.config.language;
4764
+
4765
+ // Try to find nearby text content
4766
+ const position = heading.position;
4767
+ const contextRange = 500;
4768
+ const beforeContext = content.substring(Math.max(0, position - contextRange), position);
4769
+ const afterContext = content.substring(position + heading.fullTag.length, position + heading.fullTag.length + contextRange);
4770
+
4771
+ // Look for meaningful text in nearby paragraphs
4772
+ const nearbyText = (beforeContext + afterContext).replace(/<[^>]*>/g, ' ').trim();
4773
+ const words = nearbyText.split(/\s+/).filter(word => word.length > 2);
4774
+
4775
+ if (words.length > 0) {
4776
+ const meaningfulWords = words.slice(0, 3);
4777
+ return meaningfulWords.join(' ');
4778
+ }
4779
+
4780
+ // Fallback to generic text based on language
4781
+ const genericTexts = {
4782
+ ja: ['่ฆ‹ๅ‡บใ—', 'ใ‚ปใ‚ฏใ‚ทใƒงใƒณ', 'ใ‚ณใƒณใƒ†ใƒณใƒ„'],
4783
+ en: ['Heading', 'Section', 'Content'],
4784
+ vi: ['Tiรชu ฤ‘แป', 'Phแบงn', 'Nแป™i dung']
4785
+ };
4786
+
4787
+ const texts = genericTexts[lang] || genericTexts.en;
4788
+ return texts[0];
4789
+ }
4790
+
4791
+ countHeadingFixes(originalContent, fixedContent) {
4792
+ // Count the number of heading-related changes
4793
+ const originalHeadings = (originalContent.match(/<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/gi) || []).length;
4794
+ const fixedHeadings = (fixedContent.match(/<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/gi) || []).length;
4795
+
4796
+ // Simple heuristic: count tag changes
4797
+ let changes = 0;
4798
+
4799
+ // Count h1 additions
4800
+ const originalH1 = (originalContent.match(/<h1[^>]*>/gi) || []).length;
4801
+ const fixedH1 = (fixedContent.match(/<h1[^>]*>/gi) || []).length;
4802
+ changes += Math.abs(fixedH1 - originalH1);
4803
+
4804
+ // Count level changes (rough estimate)
4805
+ for (let level = 1; level <= 6; level++) {
4806
+ const originalCount = (originalContent.match(new RegExp(`<h${level}[^>]*>`, 'gi')) || []).length;
4807
+ const fixedCount = (fixedContent.match(new RegExp(`<h${level}[^>]*>`, 'gi')) || []).length;
4808
+ changes += Math.abs(fixedCount - originalCount);
4809
+ }
4810
+
4811
+ return Math.max(1, Math.floor(changes / 2)); // Rough estimate
4812
+ }
4813
+
4814
+ async fixDescriptionLists(directory = '.') {
4815
+ console.log(chalk.blue('๐Ÿ“‹ Fixing description list structure...'));
4816
+
4817
+ const htmlFiles = await this.findHtmlFiles(directory);
4818
+ const results = [];
4819
+ let totalIssuesFound = 0;
4820
+
4821
+ for (const file of htmlFiles) {
4822
+ try {
4823
+ const content = await fs.readFile(file, 'utf8');
4824
+ const issues = this.analyzeDescriptionListStructure(content);
4825
+
4826
+ if (issues.length > 0) {
4827
+ console.log(chalk.cyan(`\n๐Ÿ“ ${file}:`));
4828
+ issues.forEach(issue => {
4829
+ console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
4830
+ if (issue.suggestion) {
4831
+ console.log(chalk.gray(` ๐Ÿ’ก ${issue.suggestion}`));
4832
+ }
4833
+ totalIssuesFound++;
4834
+ });
4835
+ }
4836
+
4837
+ const fixed = this.fixDescriptionListStructureInContent(content);
4838
+
4839
+ if (fixed !== content) {
4840
+ if (this.config.backupFiles) {
4841
+ await fs.writeFile(`${file}.backup`, content);
4842
+ }
4843
+
4844
+ if (!this.config.dryRun) {
4845
+ await fs.writeFile(file, fixed);
4846
+ }
4847
+
4848
+ console.log(chalk.green(`โœ… Fixed description list structure in: ${file}`));
4849
+ results.push({ file, status: 'fixed', issues: issues.length });
4850
+ } else {
4851
+ results.push({ file, status: 'no-change', issues: issues.length });
4852
+ }
4853
+ } catch (error) {
4854
+ console.error(chalk.red(`โŒ Error processing ${file}: ${error.message}`));
4855
+ results.push({ file, status: 'error', error: error.message });
4856
+ }
4857
+ }
4858
+
4859
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Found ${totalIssuesFound} description list issues across ${results.length} files`));
4860
+ return results;
4861
+ }
4862
+
4863
+ analyzeDescriptionListStructure(content) {
4864
+ const issues = [];
4865
+
4866
+ // Find all dl elements
4867
+ const dlPattern = /<dl[^>]*>([\s\S]*?)<\/dl>/gi;
4868
+ let dlMatch;
4869
+ let dlIndex = 0;
4870
+
4871
+ while ((dlMatch = dlPattern.exec(content)) !== null) {
4872
+ dlIndex++;
4873
+ const dlContent = dlMatch[1];
4874
+ const dlElement = dlMatch[0];
4875
+
4876
+ // Analyze the content inside dl
4877
+ const dtElements = (dlContent.match(/<dt[^>]*>[\s\S]*?<\/dt>/gi) || []);
4878
+ const ddElements = (dlContent.match(/<dd[^>]*>[\s\S]*?<\/dd>/gi) || []);
4879
+
4880
+ // Check for empty dl
4881
+ if (dtElements.length === 0 && ddElements.length === 0) {
4882
+ issues.push({
4883
+ type: '๐Ÿ“‹ Empty description list',
4884
+ description: `Description list ${dlIndex} is empty`,
4885
+ suggestion: 'Add dt/dd pairs or remove the empty dl element',
4886
+ severity: 'error',
4887
+ dlIndex,
4888
+ fix: 'remove-empty-dl'
4889
+ });
4890
+ continue;
4891
+ }
4892
+
4893
+ // Check for missing dt elements
4894
+ if (dtElements.length === 0 && ddElements.length > 0) {
4895
+ issues.push({
4896
+ type: '๐Ÿ“‹ Missing dt elements',
4897
+ description: `Description list ${dlIndex} has dd elements but no dt elements`,
4898
+ suggestion: 'Add dt elements to describe the dd content',
4899
+ severity: 'error',
4900
+ dlIndex,
4901
+ fix: 'add-missing-dt'
4902
+ });
4903
+ }
4904
+
4905
+ // Check for missing dd elements
4906
+ if (dtElements.length > 0 && ddElements.length === 0) {
4907
+ issues.push({
4908
+ type: '๐Ÿ“‹ Missing dd elements',
4909
+ description: `Description list ${dlIndex} has dt elements but no dd elements`,
4910
+ suggestion: 'Add dd elements to provide descriptions',
4911
+ severity: 'error',
4912
+ dlIndex,
4913
+ fix: 'add-missing-dd'
4914
+ });
4915
+ }
4916
+
4917
+ // Check for improper nesting (non-dt/dd elements directly in dl)
4918
+ const invalidChildren = dlContent.match(/<(?!dt|dd|\/dt|\/dd)[a-zA-Z][^>]*>/g);
4919
+ if (invalidChildren) {
4920
+ const invalidTags = [...new Set(invalidChildren.map(tag => tag.match(/<([a-zA-Z]+)/)[1]))];
4921
+ issues.push({
4922
+ type: '๐Ÿ“‹ Invalid dl children',
4923
+ description: `Description list ${dlIndex} contains invalid child elements: ${invalidTags.join(', ')}`,
4924
+ suggestion: 'Only dt and dd elements should be direct children of dl',
4925
+ severity: 'warning',
4926
+ dlIndex,
4927
+ fix: 'wrap-invalid-children'
4928
+ });
4929
+ }
4930
+
4931
+ // Check for empty dt/dd elements
4932
+ dtElements.forEach((dt, index) => {
4933
+ const dtText = dt.replace(/<[^>]*>/g, '').trim();
4934
+ if (!dtText) {
4935
+ issues.push({
4936
+ type: '๐Ÿ“‹ Empty dt element',
4937
+ description: `Empty dt element in description list ${dlIndex}`,
4938
+ suggestion: 'Add descriptive text to the dt element',
4939
+ severity: 'warning',
4940
+ dlIndex,
4941
+ dtIndex: index,
4942
+ fix: 'fix-empty-dt'
4943
+ });
4944
+ }
4945
+ });
4946
+
4947
+ ddElements.forEach((dd, index) => {
4948
+ const ddText = dd.replace(/<[^>]*>/g, '').trim();
4949
+ if (!ddText) {
4950
+ issues.push({
4951
+ type: '๐Ÿ“‹ Empty dd element',
4952
+ description: `Empty dd element in description list ${dlIndex}`,
4953
+ suggestion: 'Add descriptive content to the dd element',
4954
+ severity: 'warning',
4955
+ dlIndex,
4956
+ ddIndex: index,
4957
+ fix: 'fix-empty-dd'
4958
+ });
4959
+ }
4960
+ });
4961
+
4962
+ // Check for proper dt/dd pairing
4963
+ if (dtElements.length > 0 && ddElements.length > 0) {
4964
+ // Basic check: should have at least one dd for each dt
4965
+ if (ddElements.length < dtElements.length) {
4966
+ issues.push({
4967
+ type: '๐Ÿ“‹ Insufficient dd elements',
4968
+ description: `Description list ${dlIndex} has ${dtElements.length} dt elements but only ${ddElements.length} dd elements`,
4969
+ suggestion: 'Each dt should have at least one corresponding dd element',
4970
+ severity: 'warning',
4971
+ dlIndex
4972
+ });
4973
+ }
4974
+ }
4975
+ }
4976
+
4977
+ return issues;
4978
+ }
4979
+
4980
+ fixDescriptionListStructureInContent(content) {
4981
+ let fixed = content;
4982
+
4983
+ // Fix empty dl elements
4984
+ fixed = fixed.replace(/<dl[^>]*>\s*<\/dl>/gi, (match) => {
4985
+ console.log(chalk.yellow(` ๐Ÿ“‹ Removed empty description list`));
4986
+ return '';
4987
+ });
4988
+
4989
+ // Fix dl elements with only whitespace
4990
+ fixed = fixed.replace(/<dl[^>]*>[\s\n\r]*<\/dl>/gi, (match) => {
4991
+ console.log(chalk.yellow(` ๐Ÿ“‹ Removed empty description list`));
4992
+ return '';
4993
+ });
4994
+
4995
+ // Fix dl elements with invalid direct children
4996
+ fixed = fixed.replace(/<dl([^>]*)>([\s\S]*?)<\/dl>/gi, (match, attributes, content) => {
4997
+ // Extract dt and dd elements
4998
+ const dtElements = content.match(/<dt[^>]*>[\s\S]*?<\/dt>/gi) || [];
4999
+ const ddElements = content.match(/<dd[^>]*>[\s\S]*?<\/dd>/gi) || [];
5000
+
5001
+ // Find invalid children (not dt or dd)
5002
+ let cleanContent = content;
5003
+
5004
+ // Remove invalid direct children by wrapping them in dd
5005
+ cleanContent = cleanContent.replace(/<(?!dt|dd|\/dt|\/dd)([a-zA-Z][^>]*)>([\s\S]*?)<\/[a-zA-Z]+>/gi, (invalidMatch, tag, innerContent) => {
5006
+ console.log(chalk.yellow(` ๐Ÿ“‹ Wrapped invalid child element in dd`));
5007
+ return `<dd>${invalidMatch}</dd>`;
5008
+ });
5009
+
5010
+ // Handle text nodes that are not in dt/dd
5011
+ cleanContent = cleanContent.replace(/^([^<]+)(?=<(?:dt|dd))/gm, (textMatch) => {
5012
+ const trimmed = textMatch.trim();
5013
+ if (trimmed) {
5014
+ console.log(chalk.yellow(` ๐Ÿ“‹ Wrapped loose text in dd`));
5015
+ return `<dd>${trimmed}</dd>`;
5016
+ }
5017
+ return '';
5018
+ });
5019
+
5020
+ return `<dl${attributes}>${cleanContent}</dl>`;
5021
+ });
5022
+
5023
+ // Add missing dd elements for dt elements without corresponding dd
5024
+ fixed = fixed.replace(/<dl([^>]*)>([\s\S]*?)<\/dl>/gi, (match, attributes, content) => {
5025
+ const dtPattern = /<dt[^>]*>([\s\S]*?)<\/dt>/gi;
5026
+ const ddPattern = /<dd[^>]*>[\s\S]*?<\/dd>/gi;
5027
+
5028
+ const dtMatches = [...content.matchAll(dtPattern)];
5029
+ const ddMatches = [...content.matchAll(ddPattern)];
5030
+
5031
+ if (dtMatches.length > 0 && ddMatches.length === 0) {
5032
+ // Add dd elements after each dt
5033
+ let fixedContent = content;
5034
+
5035
+ // Process from end to beginning to maintain positions
5036
+ for (let i = dtMatches.length - 1; i >= 0; i--) {
5037
+ const dtMatch = dtMatches[i];
5038
+ const dtText = dtMatch[1].replace(/<[^>]*>/g, '').trim();
5039
+ const ddText = this.generateDescriptionForTerm(dtText);
5040
+
5041
+ const insertPosition = dtMatch.index + dtMatch[0].length;
5042
+ fixedContent = fixedContent.slice(0, insertPosition) +
5043
+ `\n <dd>${ddText}</dd>` +
5044
+ fixedContent.slice(insertPosition);
5045
+ }
5046
+
5047
+ console.log(chalk.yellow(` ๐Ÿ“‹ Added missing dd elements for ${dtMatches.length} dt elements`));
5048
+ return `<dl${attributes}>${fixedContent}</dl>`;
5049
+ }
5050
+
5051
+ return match;
5052
+ });
5053
+
5054
+ // Fix empty dt/dd elements
5055
+ fixed = fixed.replace(/<dt[^>]*>\s*<\/dt>/gi, (match) => {
5056
+ const lang = this.config.language;
5057
+ const defaultText = lang === 'ja' ? '้ …็›ฎ' : lang === 'vi' ? 'Mแปฅc' : 'Term';
5058
+ console.log(chalk.yellow(` ๐Ÿ“‹ Added text to empty dt element`));
5059
+ return match.replace(/>\s*</, `>${defaultText}<`);
5060
+ });
5061
+
5062
+ fixed = fixed.replace(/<dd[^>]*>\s*<\/dd>/gi, (match) => {
5063
+ const lang = this.config.language;
5064
+ const defaultText = lang === 'ja' ? '่ชฌๆ˜Ž' : lang === 'vi' ? 'Mรด tแบฃ' : 'Description';
5065
+ console.log(chalk.yellow(` ๐Ÿ“‹ Added text to empty dd element`));
5066
+ return match.replace(/>\s*</, `>${defaultText}<`);
5067
+ });
5068
+
5069
+ return fixed;
5070
+ }
5071
+
5072
+ generateDescriptionForTerm(termText) {
5073
+ const lang = this.config.language;
5074
+
5075
+ // Try to generate meaningful description based on term
5076
+ if (termText) {
5077
+ const descriptions = {
5078
+ ja: `${termText}ใฎ่ชฌๆ˜Ž`,
5079
+ en: `Description of ${termText}`,
5080
+ vi: `Mรด tแบฃ vแป ${termText}`
5081
+ };
5082
+ return descriptions[lang] || descriptions.en;
5083
+ }
5084
+
5085
+ // Fallback to generic description
5086
+ const fallbacks = {
5087
+ ja: '่ชฌๆ˜Ž',
5088
+ en: 'Description',
5089
+ vi: 'Mรด tแบฃ'
5090
+ };
5091
+
5092
+ return fallbacks[lang] || fallbacks.en;
5093
+ }
5094
+
5095
+ /**
5096
+ * Fix heading structure issues
5097
+ * Ensures proper heading hierarchy: only one h1, proper nesting, no duplicates in same section
5098
+ */
5099
+ async fixHeadingStructure(directory = '.') {
5100
+ console.log(chalk.blue('๐Ÿ“‘ Fixing heading structure...'));
5101
+
5102
+ const htmlFiles = await this.findHtmlFiles(directory);
5103
+ const results = [];
5104
+ let totalIssuesFound = 0;
5105
+ let totalFixesApplied = 0;
5106
+
5107
+ for (const file of htmlFiles) {
5108
+ try {
5109
+ const content = await fs.readFile(file, 'utf8');
5110
+ const analysis = this.analyzeHeadingStructure(content);
5111
+
5112
+ if (analysis.issues.length > 0) {
5113
+ console.log(chalk.cyan(`\n๐Ÿ“ ${file}:`));
5114
+ analysis.issues.forEach(issue => {
5115
+ console.log(chalk.yellow(` ๐Ÿ“‘ ${issue.type}: ${issue.description}`));
5116
+ console.log(chalk.gray(` ๐Ÿ’ก ${issue.suggestion}`));
5117
+ totalIssuesFound++;
5118
+ });
5119
+ }
5120
+
5121
+ let fixed = content;
5122
+ let fixesApplied = 0;
5123
+
5124
+ if (this.config.autoFixHeadings) {
5125
+ const fixResult = this.fixHeadingStructureInContent(content, analysis);
5126
+ fixed = fixResult.content;
5127
+ fixesApplied = fixResult.fixes;
5128
+ totalFixesApplied += fixesApplied;
5129
+
5130
+ if (fixesApplied > 0) {
5131
+ console.log(chalk.green(`โœ… Fixed heading structure in: ${file} (${fixesApplied} fixes)`));
5132
+ }
5133
+ }
5134
+
5135
+ if (fixed !== content) {
5136
+ if (this.config.backupFiles) {
5137
+ await fs.writeFile(`${file}.backup`, content);
5138
+ }
5139
+
5140
+ if (!this.config.dryRun) {
5141
+ await fs.writeFile(file, fixed);
5142
+ }
5143
+
5144
+ results.push({
5145
+ file,
5146
+ status: 'fixed',
5147
+ issues: analysis.issues.length,
5148
+ fixes: fixesApplied
5149
+ });
5150
+ } else {
5151
+ results.push({
5152
+ file,
5153
+ status: 'no-change',
5154
+ issues: analysis.issues.length,
5155
+ fixes: 0
5156
+ });
5157
+ }
5158
+ } catch (error) {
5159
+ console.error(chalk.red(`โŒ Error processing ${file}: ${error.message}`));
5160
+ results.push({ file, status: 'error', error: error.message });
5161
+ }
5162
+ }
5163
+
5164
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Found ${totalIssuesFound} heading issues across ${results.length} files`));
5165
+ if (this.config.autoFixHeadings) {
5166
+ console.log(chalk.blue(` Fixed ${totalFixesApplied} heading issues automatically`));
5167
+ } else {
5168
+ console.log(chalk.gray('๐Ÿ’ก Use --auto-fix-headings option to enable automatic fixes'));
5169
+ }
5170
+
5171
+ return results;
5172
+ }
5173
+
5174
+ /**
5175
+ * Analyze heading structure for issues
5176
+ */
5177
+ analyzeHeadingStructure(content) {
5178
+ const issues = [];
5179
+ const headings = [];
5180
+
5181
+ // Extract all headings with their positions and context
5182
+ const headingRegex = /<h([1-6])([^>]*)>(.*?)<\/h[1-6]>/gi;
5183
+ let match;
5184
+ let headingIndex = 0;
5185
+
5186
+ while ((match = headingRegex.exec(content)) !== null) {
5187
+ headingIndex++;
5188
+ const level = parseInt(match[1]);
5189
+ const attributes = match[2];
5190
+ const text = match[3].replace(/<[^>]*>/g, '').trim();
5191
+ const fullMatch = match[0];
5192
+ const position = match.index;
5193
+
5194
+ // Find the section context (look for parent elements)
5195
+ const beforeContent = content.substring(0, position);
5196
+ const sectionContext = this.findSectionContext(beforeContent);
5197
+
5198
+ headings.push({
5199
+ level,
5200
+ text,
5201
+ attributes,
5202
+ fullMatch,
5203
+ position,
5204
+ index: headingIndex,
5205
+ sectionContext
5206
+ });
5207
+ }
5208
+
5209
+ if (headings.length === 0) {
5210
+ return { issues, headings };
5211
+ }
5212
+
5213
+ // Check for multiple h1 elements
5214
+ const h1Elements = headings.filter(h => h.level === 1);
5215
+ if (h1Elements.length > 1) {
5216
+ issues.push({
5217
+ type: 'Multiple h1 elements',
5218
+ description: `Found ${h1Elements.length} h1 elements, should have only one`,
5219
+ suggestion: 'Convert extra h1 elements to h2-h6 as appropriate',
5220
+ severity: 'error',
5221
+ headings: h1Elements,
5222
+ fix: 'convert-extra-h1'
5223
+ });
5224
+ } else if (h1Elements.length === 0) {
5225
+ issues.push({
5226
+ type: 'Missing h1 element',
5227
+ description: 'Page should have exactly one h1 element',
5228
+ suggestion: 'Convert the first heading to h1 or add an h1 element',
5229
+ severity: 'error',
5230
+ fix: 'add-missing-h1'
5231
+ });
5232
+ }
5233
+
5234
+ // Check for heading level skipping
5235
+ for (let i = 1; i < headings.length; i++) {
5236
+ const current = headings[i];
5237
+ const previous = headings[i - 1];
5238
+
5239
+ if (current.level > previous.level + 1) {
5240
+ issues.push({
5241
+ type: 'Heading level skip',
5242
+ description: `Heading level jumps from h${previous.level} to h${current.level}`,
5243
+ suggestion: `Use h${previous.level + 1} instead of h${current.level}`,
5244
+ severity: 'warning',
5245
+ heading: current,
5246
+ previousHeading: previous,
5247
+ fix: 'fix-level-skip'
5248
+ });
5249
+ }
5250
+ }
5251
+
5252
+ // Check for empty headings
5253
+ headings.forEach(heading => {
5254
+ if (!heading.text || heading.text.length === 0) {
5255
+ issues.push({
5256
+ type: 'Empty heading',
5257
+ description: `Heading ${heading.index} (h${heading.level}) is empty`,
5258
+ suggestion: 'Add descriptive text to the heading or remove it',
5259
+ severity: 'warning',
5260
+ heading,
5261
+ fix: 'fix-empty-heading'
5262
+ });
5263
+ }
5264
+ });
5265
+
5266
+ // Check for duplicate headings in the same section
5267
+ const sectionGroups = {};
5268
+ headings.forEach(heading => {
5269
+ const sectionKey = heading.sectionContext || 'root';
5270
+ if (!sectionGroups[sectionKey]) {
5271
+ sectionGroups[sectionKey] = [];
5272
+ }
5273
+ sectionGroups[sectionKey].push(heading);
5274
+ });
5275
+
5276
+ Object.entries(sectionGroups).forEach(([section, sectionHeadings]) => {
5277
+ const textGroups = {};
5278
+ sectionHeadings.forEach(heading => {
5279
+ if (heading.text) {
5280
+ const normalizedText = heading.text.toLowerCase().trim();
5281
+ if (!textGroups[normalizedText]) {
5282
+ textGroups[normalizedText] = [];
5283
+ }
5284
+ textGroups[normalizedText].push(heading);
5285
+ }
5286
+ });
5287
+
5288
+ Object.entries(textGroups).forEach(([text, duplicates]) => {
5289
+ if (duplicates.length > 1 && duplicates[0].level === duplicates[1].level) {
5290
+ issues.push({
5291
+ type: 'Duplicate heading',
5292
+ description: `Duplicate h${duplicates[0].level} heading: "${text}"`,
5293
+ suggestion: 'Make heading text unique or merge content',
5294
+ severity: 'warning',
5295
+ headings: duplicates,
5296
+ section,
5297
+ fix: 'fix-duplicate-heading'
5298
+ });
5299
+ }
5300
+ });
5301
+ });
5302
+
5303
+ return { issues, headings };
5304
+ }
5305
+
5306
+ /**
5307
+ * Find section context for a heading
5308
+ */
5309
+ findSectionContext(beforeContent) {
5310
+ // Look for common section elements
5311
+ const sectionPatterns = [
5312
+ /<section[^>]*>/gi,
5313
+ /<article[^>]*>/gi,
5314
+ /<main[^>]*>/gi,
5315
+ /<div[^>]*class[^>]*section[^>]*>/gi,
5316
+ /<div[^>]*id[^>]*section[^>]*>/gi
5317
+ ];
5318
+
5319
+ let lastSectionMatch = null;
5320
+ let lastSectionPosition = -1;
5321
+
5322
+ sectionPatterns.forEach(pattern => {
5323
+ let match;
5324
+ pattern.lastIndex = 0; // Reset regex
5325
+ while ((match = pattern.exec(beforeContent)) !== null) {
5326
+ if (match.index > lastSectionPosition) {
5327
+ lastSectionPosition = match.index;
5328
+ lastSectionMatch = match[0];
5329
+ }
5330
+ }
5331
+ });
5332
+
5333
+ return lastSectionMatch ? `section_${lastSectionPosition}` : 'root';
5334
+ }
5335
+
5336
+ /**
5337
+ * Fix heading structure issues in content
5338
+ */
5339
+ fixHeadingStructureInContent(content, analysis) {
5340
+ let fixed = content;
5341
+ let fixesApplied = 0;
5342
+
5343
+ if (!analysis || !analysis.issues) {
5344
+ return { content: fixed, fixes: fixesApplied };
5345
+ }
5346
+
5347
+ // Sort issues by position (from end to start to avoid position shifts)
5348
+ const sortedIssues = analysis.issues
5349
+ .filter(issue => issue.fix)
5350
+ .sort((a, b) => {
5351
+ const posA = a.heading?.position || a.headings?.[0]?.position || 0;
5352
+ const posB = b.heading?.position || b.headings?.[0]?.position || 0;
5353
+ return posB - posA;
5354
+ });
5355
+
5356
+ sortedIssues.forEach(issue => {
5357
+ switch (issue.fix) {
5358
+ case 'convert-extra-h1':
5359
+ // Convert extra h1 elements to h2
5360
+ if (issue.headings && issue.headings.length > 1) {
5361
+ // Keep the first h1, convert others to h2
5362
+ for (let i = 1; i < issue.headings.length; i++) {
5363
+ const heading = issue.headings[i];
5364
+ const newHeading = heading.fullMatch.replace(/h1/gi, 'h2');
5365
+ fixed = fixed.replace(heading.fullMatch, newHeading);
5366
+ console.log(chalk.yellow(` ๐Ÿ“‘ Converted extra h1 to h2: "${heading.text}"`));
5367
+ fixesApplied++;
5368
+ }
5369
+ }
5370
+ break;
5371
+
5372
+ case 'add-missing-h1':
5373
+ // Convert the first heading to h1
5374
+ if (analysis.headings && analysis.headings.length > 0) {
5375
+ const firstHeading = analysis.headings[0];
5376
+ const newHeading = firstHeading.fullMatch.replace(
5377
+ new RegExp(`h${firstHeading.level}`, 'gi'),
5378
+ 'h1'
5379
+ );
5380
+ fixed = fixed.replace(firstHeading.fullMatch, newHeading);
5381
+ console.log(chalk.yellow(` ๐Ÿ“‘ Converted first heading to h1: "${firstHeading.text}"`));
5382
+ fixesApplied++;
5383
+ }
5384
+ break;
5385
+
5386
+ case 'fix-level-skip':
5387
+ // Fix heading level skipping
5388
+ if (issue.heading && issue.previousHeading) {
5389
+ const correctLevel = issue.previousHeading.level + 1;
5390
+ const newHeading = issue.heading.fullMatch.replace(
5391
+ new RegExp(`h${issue.heading.level}`, 'gi'),
5392
+ `h${correctLevel}`
5393
+ );
5394
+ fixed = fixed.replace(issue.heading.fullMatch, newHeading);
5395
+ console.log(chalk.yellow(` ๐Ÿ“‘ Fixed level skip: h${issue.heading.level} โ†’ h${correctLevel} for "${issue.heading.text}"`));
5396
+ fixesApplied++;
5397
+ }
5398
+ break;
5399
+
5400
+ case 'fix-empty-heading':
5401
+ // Add text to empty headings based on context
5402
+ if (issue.heading) {
5403
+ const contextText = this.generateHeadingText(issue.heading, content);
5404
+ const newHeading = issue.heading.fullMatch.replace(
5405
+ /(<h[1-6][^>]*>)(\s*)(.*?)(\s*)(<\/h[1-6]>)/i,
5406
+ `$1${contextText}$5`
5407
+ );
5408
+ fixed = fixed.replace(issue.heading.fullMatch, newHeading);
5409
+ console.log(chalk.yellow(` ๐Ÿ“‘ Added text to empty heading: "${contextText}"`));
5410
+ fixesApplied++;
5411
+ }
5412
+ break;
5413
+
5414
+ case 'fix-duplicate-heading':
5415
+ // Make duplicate headings unique by adding numbers
5416
+ if (issue.headings && issue.headings.length > 1) {
5417
+ for (let i = 1; i < issue.headings.length; i++) {
5418
+ const heading = issue.headings[i];
5419
+ const newText = `${heading.text} (${i + 1})`;
5420
+ const newHeading = heading.fullMatch.replace(
5421
+ heading.text,
5422
+ newText
5423
+ );
5424
+ fixed = fixed.replace(heading.fullMatch, newHeading);
5425
+ console.log(chalk.yellow(` ๐Ÿ“‘ Made duplicate heading unique: "${newText}"`));
5426
+ fixesApplied++;
5427
+ }
5428
+ }
5429
+ break;
5430
+ }
5431
+ });
5432
+
5433
+ return { content: fixed, fixes: fixesApplied };
5434
+ }
5435
+
5436
+ /**
5437
+ * Generate appropriate text for empty headings based on context
5438
+ */
5439
+ generateHeadingText(heading, content) {
5440
+ // Look for context around the heading
5441
+ const position = heading.position;
5442
+ const beforeText = content.substring(Math.max(0, position - 200), position);
5443
+ const afterText = content.substring(position + heading.fullMatch.length, position + heading.fullMatch.length + 200);
5444
+
5445
+ // Extract meaningful words from surrounding content
5446
+ const contextWords = (beforeText + ' ' + afterText)
5447
+ .replace(/<[^>]*>/g, ' ')
5448
+ .replace(/[^\w\s]/g, ' ')
5449
+ .split(/\s+/)
5450
+ .filter(word => word.length > 3 && !/^\d+$/.test(word))
5451
+ .slice(0, 3);
5452
+
5453
+ if (contextWords.length > 0) {
5454
+ return contextWords.join(' ');
5455
+ }
5456
+
5457
+ // Fallback based on heading level
5458
+ const levelNames = {
5459
+ 1: 'Main Content',
5460
+ 2: 'Section',
5461
+ 3: 'Subsection',
5462
+ 4: 'Details',
5463
+ 5: 'Information',
5464
+ 6: 'Notes'
5465
+ };
5466
+
5467
+ return levelNames[heading.level] || 'Content';
5468
+ }
5469
+
5470
+ async findHtmlFiles(directory) {
5471
+ const files = [];
5472
+
5473
+ // Check if the path is a file or directory
5474
+ const stat = await fs.stat(directory);
5475
+
5476
+ if (stat.isFile()) {
5477
+ // If it's a file, check if it's HTML
5478
+ if (directory.endsWith('.html')) {
5479
+ files.push(directory);
5480
+ }
5481
+ return files;
5482
+ }
5483
+
5484
+ // If it's a directory, scan recursively
5485
+ async function scan(dir) {
5486
+ const entries = await fs.readdir(dir, { withFileTypes: true });
5487
+
5488
+ for (const entry of entries) {
5489
+ const fullPath = path.join(dir, entry.name);
5490
+
5491
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
5492
+ await scan(fullPath);
5493
+ } else if (entry.isFile() && entry.name.endsWith('.html')) {
5494
+ files.push(fullPath);
5495
+ }
5496
+ }
5497
+ }
5498
+
5499
+ await scan(directory);
5500
+ return files;
5501
+ }
5502
+
5503
+ // Check for unused files in the project
5504
+ async checkUnusedFiles(directory = '.') {
5505
+ console.log(chalk.blue('๐Ÿ—‚๏ธ Checking for unused files...'));
5506
+
5507
+ const results = [];
5508
+ const allFiles = await this.findAllProjectFiles(directory);
5509
+ const referencedFiles = await this.findReferencedFiles(directory);
5510
+
5511
+ // Normalize paths for comparison
5512
+ const normalizedReferenced = new Set();
5513
+ referencedFiles.forEach(file => {
5514
+ normalizedReferenced.add(path.resolve(file));
5515
+ });
5516
+
5517
+ for (const file of allFiles) {
5518
+ const absolutePath = path.resolve(file);
5519
+
5520
+ // Skip certain files that are typically not referenced directly
5521
+ if (this.shouldSkipUnusedCheck(file)) {
5522
+ continue;
5523
+ }
5524
+
5525
+ if (!normalizedReferenced.has(absolutePath)) {
5526
+ const relativePath = path.relative(directory, file);
5527
+ const fileType = this.getFileType(file);
5528
+
5529
+ console.log(chalk.yellow(` ๐Ÿ—‘๏ธ Unused ${fileType}: ${relativePath}`));
5530
+
5531
+ results.push({
5532
+ type: `๐Ÿ—‘๏ธ Unused ${fileType}`,
5533
+ description: `File not referenced anywhere: ${relativePath}`,
5534
+ suggestion: `Consider removing if truly unused: ${relativePath}`,
5535
+ filePath: file,
5536
+ fileType: fileType,
5537
+ relativePath: relativePath
5538
+ });
5539
+ }
5540
+ }
5541
+
5542
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Found ${results.length} potentially unused files`));
5543
+ console.log(chalk.gray('๐Ÿ’ก Review carefully before removing - some files may be referenced dynamically'));
5544
+
5545
+ return results;
5546
+ }
5547
+
5548
+ async findAllProjectFiles(directory) {
5549
+ const files = [];
5550
+ const extensions = ['.html', '.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.pdf', '.mp4', '.webm', '.mp3', '.wav'];
5551
+
5552
+ async function scan(dir) {
5553
+ try {
5554
+ const entries = await fs.readdir(dir, { withFileTypes: true });
5555
+
5556
+ for (const entry of entries) {
5557
+ const fullPath = path.join(dir, entry.name);
5558
+
5559
+ if (entry.isDirectory()) {
5560
+ // Skip common directories that shouldn't be analyzed
5561
+ if (!this.shouldSkipDirectory(entry.name)) {
5562
+ await scan(fullPath);
5563
+ }
5564
+ } else if (entry.isFile()) {
5565
+ const ext = path.extname(entry.name).toLowerCase();
5566
+ if (extensions.includes(ext)) {
5567
+ files.push(fullPath);
5568
+ }
5569
+ }
5570
+ }
5571
+ } catch (error) {
5572
+ // Skip directories we can't read
5573
+ }
5574
+ }
5575
+
5576
+ await scan.call(this, directory);
5577
+ return files;
5578
+ }
5579
+
5580
+ async findReferencedFiles(directory) {
5581
+ const referenced = new Set();
5582
+ const htmlFiles = await this.findHtmlFiles(directory);
5583
+ const cssFiles = await this.findCssFiles(directory);
5584
+ const jsFiles = await this.findJsFiles(directory);
5585
+
5586
+ // Find references in HTML files
5587
+ for (const htmlFile of htmlFiles) {
5588
+ try {
5589
+ const content = await fs.readFile(htmlFile, 'utf8');
5590
+ const refs = this.extractFileReferences(content, path.dirname(htmlFile));
5591
+ refs.forEach(ref => referenced.add(ref));
5592
+ } catch (error) {
5593
+ // Skip files we can't read
5594
+ }
5595
+ }
5596
+
5597
+ // Find references in CSS files
5598
+ for (const cssFile of cssFiles) {
5599
+ try {
5600
+ const content = await fs.readFile(cssFile, 'utf8');
5601
+ const refs = this.extractCssReferences(content, path.dirname(cssFile));
5602
+ refs.forEach(ref => referenced.add(ref));
5603
+ } catch (error) {
5604
+ // Skip files we can't read
5605
+ }
5606
+ }
5607
+
5608
+ // Find references in JS files
5609
+ for (const jsFile of jsFiles) {
5610
+ try {
5611
+ const content = await fs.readFile(jsFile, 'utf8');
5612
+ const refs = this.extractJsReferences(content, path.dirname(jsFile));
5613
+ refs.forEach(ref => referenced.add(ref));
5614
+ } catch (error) {
5615
+ // Skip files we can't read
5616
+ }
5617
+ }
5618
+
5619
+ return Array.from(referenced);
5620
+ }
5621
+
5622
+ extractFileReferences(content, baseDir) {
5623
+ const references = [];
5624
+
5625
+ // HTML patterns for file references
5626
+ const patterns = [
5627
+ // Images
5628
+ /<img[^>]*src\s*=\s*["']([^"']+)["']/gi,
5629
+ // Links (CSS, other files)
5630
+ /<link[^>]*href\s*=\s*["']([^"']+)["']/gi,
5631
+ // Scripts
5632
+ /<script[^>]*src\s*=\s*["']([^"']+)["']/gi,
5633
+ // Anchors
5634
+ /<a[^>]*href\s*=\s*["']([^"']+)["']/gi,
5635
+ // Video/Audio
5636
+ /<(?:video|audio)[^>]*src\s*=\s*["']([^"']+)["']/gi,
5637
+ // Object/Embed
5638
+ /<(?:object|embed)[^>]*src\s*=\s*["']([^"']+)["']/gi,
5639
+ // Iframe
5640
+ /<iframe[^>]*src\s*=\s*["']([^"']+)["']/gi,
5641
+ // Meta (for icons)
5642
+ /<meta[^>]*content\s*=\s*["']([^"']+\.(ico|png|jpg|jpeg|svg))["']/gi
5643
+ ];
5644
+
5645
+ for (const pattern of patterns) {
5646
+ let match;
5647
+ while ((match = pattern.exec(content)) !== null) {
5648
+ const url = match[1];
5649
+ if (this.isLocalFile(url)) {
5650
+ const resolvedPath = this.resolveFilePath(url, baseDir);
5651
+ if (resolvedPath) {
5652
+ references.push(resolvedPath);
5653
+ }
5654
+ }
5655
+ }
5656
+ }
5657
+
5658
+ return references;
5659
+ }
5660
+
5661
+ extractCssReferences(content, baseDir) {
5662
+ const references = [];
5663
+
5664
+ // CSS patterns for file references
5665
+ const patterns = [
5666
+ // url() function
5667
+ /url\s*\(\s*["']?([^"')]+)["']?\s*\)/gi,
5668
+ // @import
5669
+ /@import\s+["']([^"']+)["']/gi
5670
+ ];
5671
+
5672
+ for (const pattern of patterns) {
5673
+ let match;
5674
+ while ((match = pattern.exec(content)) !== null) {
5675
+ const url = match[1];
5676
+ if (this.isLocalFile(url)) {
5677
+ const resolvedPath = this.resolveFilePath(url, baseDir);
5678
+ if (resolvedPath) {
5679
+ references.push(resolvedPath);
5680
+ }
5681
+ }
5682
+ }
5683
+ }
5684
+
5685
+ return references;
5686
+ }
5687
+
5688
+ extractJsReferences(content, baseDir) {
5689
+ const references = [];
5690
+
5691
+ // JavaScript patterns for file references
5692
+ const patterns = [
5693
+ // require() calls
5694
+ /require\s*\(\s*["']([^"']+)["']\s*\)/gi,
5695
+ // import statements
5696
+ /import\s+.*?from\s+["']([^"']+)["']/gi,
5697
+ // fetch() calls with local files
5698
+ /fetch\s*\(\s*["']([^"']*\.(html|css|js|json|xml))["']\s*\)/gi,
5699
+ // XMLHttpRequest
5700
+ /\.open\s*\(\s*["'][^"']*["']\s*,\s*["']([^"']*\.(html|css|js|json|xml))["']/gi
5701
+ ];
5702
+
5703
+ for (const pattern of patterns) {
5704
+ let match;
5705
+ while ((match = pattern.exec(content)) !== null) {
5706
+ const url = match[1];
5707
+ if (this.isLocalFile(url)) {
5708
+ const resolvedPath = this.resolveFilePath(url, baseDir);
5709
+ if (resolvedPath) {
5710
+ references.push(resolvedPath);
5711
+ }
5712
+ }
5713
+ }
5714
+ }
5715
+
5716
+ return references;
5717
+ }
5718
+
5719
+ async findCssFiles(directory) {
5720
+ const files = [];
5721
+
5722
+ async function scan(dir) {
5723
+ try {
5724
+ const entries = await fs.readdir(dir, { withFileTypes: true });
5725
+
5726
+ for (const entry of entries) {
5727
+ const fullPath = path.join(dir, entry.name);
5728
+
5729
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
5730
+ await scan(fullPath);
5731
+ } else if (entry.isFile() && entry.name.endsWith('.css')) {
5732
+ files.push(fullPath);
5733
+ }
5734
+ }
5735
+ } catch (error) {
5736
+ // Skip directories we can't read
5737
+ }
5738
+ }
5739
+
5740
+ await scan(directory);
5741
+ return files;
5742
+ }
5743
+
5744
+ async findJsFiles(directory) {
5745
+ const files = [];
5746
+
5747
+ async function scan(dir) {
5748
+ try {
5749
+ const entries = await fs.readdir(dir, { withFileTypes: true });
5750
+
5751
+ for (const entry of entries) {
5752
+ const fullPath = path.join(dir, entry.name);
5753
+
5754
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
5755
+ await scan(fullPath);
5756
+ } else if (entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.mjs'))) {
5757
+ files.push(fullPath);
5758
+ }
5759
+ }
5760
+ } catch (error) {
5761
+ // Skip directories we can't read
5762
+ }
5763
+ }
5764
+
5765
+ await scan(directory);
5766
+ return files;
5767
+ }
5768
+
5769
+ shouldSkipDirectory(dirName) {
5770
+ const skipDirs = [
5771
+ 'node_modules', '.git', '.svn', '.hg', 'bower_components',
5772
+ 'vendor', 'tmp', 'temp', '.cache', 'dist', 'build', 'coverage',
5773
+ '.nyc_output', '.vscode', '.idea', '__pycache__', '.DS_Store'
5774
+ ];
5775
+ return skipDirs.includes(dirName) || dirName.startsWith('.');
5776
+ }
5777
+
5778
+ shouldSkipUnusedCheck(filePath) {
5779
+ const fileName = path.basename(filePath);
5780
+ const dirName = path.basename(path.dirname(filePath));
5781
+
5782
+ // Skip certain file types and patterns
5783
+ const skipPatterns = [
5784
+ // Common files that might not be directly referenced
5785
+ /^(index|main|app)\.(html|js|css)$/i,
5786
+ /^(readme|license|changelog)/i,
5787
+ /^package\.json$/i,
5788
+ /^\.gitignore$/i,
5789
+ /^favicon\.(ico|png)$/i,
5790
+ /^robots\.txt$/i,
5791
+ /^sitemap\.xml$/i,
5792
+ // Backup files
5793
+ /\.backup$/i,
5794
+ // Test directories
5795
+ /test|spec|__tests__/i
5796
+ ];
5797
+
5798
+ return skipPatterns.some(pattern =>
5799
+ pattern.test(fileName) || pattern.test(dirName)
5800
+ );
5801
+ }
5802
+
5803
+ getFileType(filePath) {
5804
+ const ext = path.extname(filePath).toLowerCase();
5805
+ const typeMap = {
5806
+ '.html': 'HTML',
5807
+ '.css': 'CSS',
5808
+ '.js': 'JavaScript',
5809
+ '.mjs': 'JavaScript Module',
5810
+ '.jpg': 'Image',
5811
+ '.jpeg': 'Image',
5812
+ '.png': 'Image',
5813
+ '.gif': 'Image',
5814
+ '.svg': 'SVG',
5815
+ '.webp': 'Image',
5816
+ '.ico': 'Icon',
5817
+ '.pdf': 'PDF',
5818
+ '.mp4': 'Video',
5819
+ '.webm': 'Video',
5820
+ '.mp3': 'Audio',
5821
+ '.wav': 'Audio'
5822
+ };
5823
+
5824
+ return typeMap[ext] || 'File';
5825
+ }
5826
+
5827
+ isLocalFile(url) {
5828
+ return !url.startsWith('http://') &&
5829
+ !url.startsWith('https://') &&
5830
+ !url.startsWith('//') &&
5831
+ !url.startsWith('data:') &&
5832
+ !url.startsWith('mailto:') &&
5833
+ !url.startsWith('tel:') &&
5834
+ !url.startsWith('#');
5835
+ }
5836
+
5837
+ resolveFilePath(url, baseDir) {
5838
+ try {
5839
+ let filePath;
5840
+
5841
+ if (url.startsWith('/')) {
5842
+ // Absolute path from web root
5843
+ filePath = path.join(baseDir, url.substring(1));
5844
+ } else {
5845
+ // Relative path
5846
+ filePath = path.resolve(baseDir, url);
5847
+ }
5848
+
5849
+ return filePath;
5850
+ } catch (error) {
5851
+ return null;
5852
+ }
5853
+ }
5854
+
5855
+ // Check for dead code in CSS and JavaScript files
5856
+ async checkDeadCode(directory = '.') {
5857
+ console.log(chalk.blue('โ˜ ๏ธ Checking for dead code...'));
5858
+
5859
+ const results = [];
5860
+
5861
+ // Check CSS dead code
5862
+ const cssDeadCode = await this.checkDeadCss(directory);
5863
+ results.push(...cssDeadCode);
5864
+
5865
+ // Check JavaScript dead code
5866
+ const jsDeadCode = await this.checkDeadJs(directory);
5867
+ results.push(...jsDeadCode);
5868
+
5869
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Found ${results.length} potential dead code issues`));
5870
+ console.log(chalk.gray('๐Ÿ’ก Dead code analysis is heuristic - manual review recommended'));
5871
+
5872
+ return results;
5873
+ }
5874
+
5875
+ async checkDeadCss(directory) {
5876
+ console.log(chalk.cyan('๐ŸŽจ Analyzing CSS dead code...'));
5877
+
5878
+ const results = [];
5879
+ const cssFiles = await this.findCssFiles(directory);
5880
+ const htmlFiles = await this.findHtmlFiles(directory);
5881
+
5882
+ // Get all HTML content to check against
5883
+ let allHtmlContent = '';
5884
+ for (const htmlFile of htmlFiles) {
5885
+ try {
5886
+ const content = await fs.readFile(htmlFile, 'utf8');
5887
+ allHtmlContent += content + '\n';
5888
+ } catch (error) {
5889
+ // Skip files we can't read
5890
+ }
5891
+ }
5892
+
5893
+ for (const cssFile of cssFiles) {
5894
+ try {
5895
+ const content = await fs.readFile(cssFile, 'utf8');
5896
+ const deadRules = this.findDeadCssRules(content, allHtmlContent);
5897
+
5898
+ if (deadRules.length > 0) {
5899
+ const relativePath = path.relative(directory, cssFile);
5900
+ console.log(chalk.cyan(`\n๐Ÿ“ ${relativePath}:`));
5901
+
5902
+ deadRules.forEach(rule => {
5903
+ console.log(chalk.yellow(` โ˜ ๏ธ Potentially dead CSS: ${rule.selector}`));
5904
+ results.push({
5905
+ type: 'โ˜ ๏ธ Dead CSS rule',
5906
+ description: `CSS selector not found in HTML: ${rule.selector}`,
5907
+ suggestion: `Consider removing unused CSS rule: ${rule.selector}`,
5908
+ filePath: cssFile,
5909
+ relativePath: relativePath,
5910
+ selector: rule.selector,
5911
+ lineNumber: rule.lineNumber
5912
+ });
5913
+ });
5914
+ }
5915
+
5916
+ } catch (error) {
5917
+ console.error(chalk.red(`โŒ Error analyzing CSS ${cssFile}: ${error.message}`));
5918
+ }
5919
+ }
5920
+
5921
+ return results;
5922
+ }
5923
+
5924
+ findDeadCssRules(cssContent, htmlContent) {
5925
+ const deadRules = [];
5926
+
5927
+ // Simple CSS parser to extract selectors
5928
+ const cssRules = this.parseCssSelectors(cssContent);
5929
+
5930
+ for (const rule of cssRules) {
5931
+ if (this.isCssSelectorUsed(rule.selector, htmlContent)) {
5932
+ continue; // Selector is used
5933
+ }
5934
+
5935
+ // Skip certain selectors that are commonly dynamic
5936
+ if (this.shouldSkipCssSelector(rule.selector)) {
5937
+ continue;
5938
+ }
5939
+
5940
+ deadRules.push(rule);
5941
+ }
5942
+
5943
+ return deadRules;
5944
+ }
5945
+
5946
+ parseCssSelectors(cssContent) {
5947
+ const rules = [];
5948
+ const lines = cssContent.split('\n');
5949
+ let inRule = false;
5950
+
5951
+ for (let i = 0; i < lines.length; i++) {
5952
+ const line = lines[i].trim();
5953
+
5954
+ // Skip comments and empty lines
5955
+ if (line.startsWith('/*') || line.includes('*/') || !line) {
5956
+ continue;
5957
+ }
5958
+
5959
+ // Skip @rules like @media, @keyframes
5960
+ if (line.startsWith('@')) {
5961
+ continue;
5962
+ }
5963
+
5964
+ // Check if we're entering a rule block
5965
+ if (line.includes('{')) {
5966
+ inRule = true;
5967
+ // Extract selector part before the {
5968
+ const selectorPart = line.split('{')[0].trim();
5969
+ if (selectorPart && !selectorPart.includes(':') && !selectorPart.includes(';')) {
5970
+ rules.push({
5971
+ selector: selectorPart,
5972
+ lineNumber: i + 1
5973
+ });
5974
+ }
5975
+ continue;
5976
+ }
5977
+
5978
+ // Check if we're exiting a rule block
5979
+ if (line.includes('}')) {
5980
+ inRule = false;
5981
+ continue;
5982
+ }
5983
+
5984
+ // If we're not in a rule and line doesn't contain CSS properties, it might be a selector
5985
+ if (!inRule && !line.includes(':') && !line.includes(';') && line.length > 0) {
5986
+ rules.push({
5987
+ selector: line,
5988
+ lineNumber: i + 1
5989
+ });
5990
+ }
5991
+ }
5992
+
5993
+ return rules;
5994
+ }
5995
+
5996
+ isCssSelectorUsed(selector, htmlContent) {
5997
+ // Simple heuristic checks
5998
+
5999
+ // Check for class selectors
6000
+ if (selector.startsWith('.')) {
6001
+ const className = selector.substring(1).split(/[:\s>+~]/)[0];
6002
+ return htmlContent.includes(`class="${className}"`) ||
6003
+ htmlContent.includes(`class='${className}'`) ||
6004
+ htmlContent.includes(`class=".*${className}.*"`) ||
6005
+ htmlContent.includes(`class='.*${className}.*'`);
6006
+ }
6007
+
6008
+ // Check for ID selectors
6009
+ if (selector.startsWith('#')) {
6010
+ const idName = selector.substring(1).split(/[:\s>+~]/)[0];
6011
+ return htmlContent.includes(`id="${idName}"`) ||
6012
+ htmlContent.includes(`id='${idName}'`);
6013
+ }
6014
+
6015
+ // Check for tag selectors
6016
+ const tagMatch = selector.match(/^([a-zA-Z]+)/);
6017
+ if (tagMatch) {
6018
+ const tagName = tagMatch[1].toLowerCase();
6019
+ return htmlContent.toLowerCase().includes(`<${tagName}`);
6020
+ }
6021
+
6022
+ return true; // Conservative approach - assume complex selectors are used
6023
+ }
6024
+
6025
+ shouldSkipCssSelector(selector) {
6026
+ const skipPatterns = [
6027
+ // Pseudo-classes and pseudo-elements
6028
+ /:hover|:focus|:active|:visited|:before|:after/,
6029
+ // Media queries and complex selectors
6030
+ /@media|@keyframes|@font-face/,
6031
+ // Dynamic classes that might be added by JavaScript
6032
+ /\.active|\.selected|\.hidden|\.show|\.hide/,
6033
+ // Common framework classes
6034
+ /\.btn|\.form|\.nav|\.modal|\.tooltip/
6035
+ ];
6036
+
6037
+ return skipPatterns.some(pattern => pattern.test(selector));
6038
+ }
6039
+
6040
+ async checkDeadJs(directory) {
6041
+ console.log(chalk.cyan('๐Ÿ“œ Analyzing JavaScript dead code...'));
6042
+
6043
+ const results = [];
6044
+ const jsFiles = await this.findJsFiles(directory);
6045
+ const htmlFiles = await this.findHtmlFiles(directory);
6046
+
6047
+ // Get all HTML content to check against
6048
+ let allHtmlContent = '';
6049
+ for (const htmlFile of htmlFiles) {
6050
+ try {
6051
+ const content = await fs.readFile(htmlFile, 'utf8');
6052
+ allHtmlContent += content + '\n';
6053
+ } catch (error) {
6054
+ // Skip files we can't read
6055
+ }
6056
+ }
6057
+
6058
+ for (const jsFile of jsFiles) {
6059
+ try {
6060
+ const content = await fs.readFile(jsFile, 'utf8');
6061
+ const deadCode = this.findDeadJsCode(content, allHtmlContent, jsFile);
6062
+
6063
+ if (deadCode.length > 0) {
6064
+ const relativePath = path.relative(directory, jsFile);
6065
+ console.log(chalk.cyan(`\n๐Ÿ“ ${relativePath}:`));
6066
+
6067
+ deadCode.forEach(code => {
6068
+ console.log(chalk.yellow(` โ˜ ๏ธ Potentially dead JS: ${code.name}`));
6069
+ results.push({
6070
+ type: 'โ˜ ๏ธ Dead JavaScript',
6071
+ description: `${code.type} not referenced: ${code.name}`,
6072
+ suggestion: `Consider removing unused ${code.type.toLowerCase()}: ${code.name}`,
6073
+ filePath: jsFile,
6074
+ relativePath: relativePath,
6075
+ name: code.name,
6076
+ codeType: code.type,
6077
+ lineNumber: code.lineNumber
6078
+ });
6079
+ });
6080
+ }
6081
+
6082
+ } catch (error) {
6083
+ console.error(chalk.red(`โŒ Error analyzing JS ${jsFile}: ${error.message}`));
6084
+ }
6085
+ }
6086
+
6087
+ return results;
6088
+ }
6089
+
6090
+ findDeadJsCode(jsContent, htmlContent, jsFilePath) {
6091
+ const deadCode = [];
6092
+
6093
+ // Find function declarations
6094
+ const functions = this.parseJsFunctions(jsContent);
6095
+ const variables = this.parseJsVariables(jsContent);
6096
+
6097
+ // Check if functions are used
6098
+ for (const func of functions) {
6099
+ if (!this.isJsFunctionUsed(func.name, jsContent, htmlContent)) {
6100
+ deadCode.push({
6101
+ type: 'Function',
6102
+ name: func.name,
6103
+ lineNumber: func.lineNumber
6104
+ });
6105
+ }
6106
+ }
6107
+
6108
+ // Check if variables are used
6109
+ for (const variable of variables) {
6110
+ if (!this.isJsVariableUsed(variable.name, jsContent, htmlContent)) {
6111
+ deadCode.push({
6112
+ type: 'Variable',
6113
+ name: variable.name,
6114
+ lineNumber: variable.lineNumber
6115
+ });
6116
+ }
6117
+ }
6118
+
6119
+ return deadCode;
6120
+ }
6121
+
6122
+ parseJsFunctions(jsContent) {
6123
+ const functions = [];
6124
+ const lines = jsContent.split('\n');
6125
+
6126
+ for (let i = 0; i < lines.length; i++) {
6127
+ const line = lines[i];
6128
+
6129
+ // Function declarations
6130
+ const funcMatch = line.match(/function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/);
6131
+ if (funcMatch) {
6132
+ functions.push({
6133
+ name: funcMatch[1],
6134
+ lineNumber: i + 1
6135
+ });
6136
+ }
6137
+
6138
+ // Arrow functions assigned to variables
6139
+ const arrowMatch = line.match(/(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*\([^)]*\)\s*=>/);
6140
+ if (arrowMatch) {
6141
+ functions.push({
6142
+ name: arrowMatch[1],
6143
+ lineNumber: i + 1
6144
+ });
6145
+ }
6146
+ }
6147
+
6148
+ return functions;
6149
+ }
6150
+
6151
+ parseJsVariables(jsContent) {
6152
+ const variables = [];
6153
+ const lines = jsContent.split('\n');
6154
+
6155
+ for (let i = 0; i < lines.length; i++) {
6156
+ const line = lines[i];
6157
+
6158
+ // Variable declarations
6159
+ const varMatch = line.match(/(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/);
6160
+ if (varMatch && !line.includes('=') && !line.includes('function')) {
6161
+ variables.push({
6162
+ name: varMatch[1],
6163
+ lineNumber: i + 1
6164
+ });
6165
+ }
6166
+ }
6167
+
6168
+ return variables;
6169
+ }
6170
+
6171
+ isJsFunctionUsed(functionName, jsContent, htmlContent) {
6172
+ // Check if function is called in JS
6173
+ const jsCallPattern = new RegExp(`${functionName}\\s*\\(`, 'g');
6174
+ if (jsCallPattern.test(jsContent)) {
6175
+ return true;
6176
+ }
6177
+
6178
+ // Check if function is referenced in HTML (onclick, etc.)
6179
+ const htmlCallPattern = new RegExp(`${functionName}\\s*\\(`, 'g');
6180
+ if (htmlCallPattern.test(htmlContent)) {
6181
+ return true;
6182
+ }
6183
+
6184
+ // Check for event handlers in HTML
6185
+ const eventPattern = new RegExp(`on\\w+\\s*=\\s*["'][^"']*${functionName}`, 'g');
6186
+ if (eventPattern.test(htmlContent)) {
6187
+ return true;
6188
+ }
6189
+
6190
+ return false;
6191
+ }
6192
+
6193
+ isJsVariableUsed(variableName, jsContent, htmlContent) {
6194
+ // Create pattern that excludes the declaration line
6195
+ const lines = jsContent.split('\n');
6196
+ let usageFound = false;
6197
+
6198
+ for (let i = 0; i < lines.length; i++) {
6199
+ const line = lines[i];
6200
+
6201
+ // Skip the declaration line
6202
+ if (line.includes(`${variableName}`) &&
6203
+ (line.includes('const ') || line.includes('let ') || line.includes('var '))) {
6204
+ continue;
6205
+ }
6206
+
6207
+ // Check for usage
6208
+ const usagePattern = new RegExp(`\\b${variableName}\\b`);
6209
+ if (usagePattern.test(line)) {
6210
+ usageFound = true;
6211
+ break;
6212
+ }
6213
+ }
6214
+
6215
+ return usageFound;
6216
+ }
6217
+
6218
+ // Check file sizes and suggest optimizations
6219
+ async checkFileSizes(directory = '.') {
6220
+ console.log(chalk.blue('๐Ÿ“ Analyzing file sizes and suggesting optimizations...'));
6221
+
6222
+ const results = [];
6223
+ const allFiles = await this.findAllProjectFiles(directory);
6224
+ const sizeThresholds = {
6225
+ image: 500 * 1024, // 500KB for images
6226
+ // css: 100 * 1024, // 100KB for CSS
6227
+ js: 200 * 1024, // 200KB for JavaScript
6228
+ // html: 50 * 1024, // 50KB for HTML
6229
+ video: 10 * 1024 * 1024, // 10MB for videos
6230
+ audio: 5 * 1024 * 1024, // 5MB for audio
6231
+ other: 1 * 1024 * 1024 // 1MB for other files
6232
+ };
6233
+
6234
+ let totalSize = 0;
6235
+ const fileSizes = [];
6236
+
6237
+ for (const file of allFiles) {
6238
+ try {
6239
+ const stats = await require('fs').promises.stat(file);
6240
+ const fileSize = stats.size;
6241
+ const fileType = this.getFileCategory(file);
6242
+ const relativePath = path.relative(directory, file);
6243
+
6244
+ totalSize += fileSize;
6245
+ fileSizes.push({
6246
+ path: file,
6247
+ relativePath: relativePath,
6248
+ size: fileSize,
6249
+ type: fileType,
6250
+ sizeFormatted: this.formatFileSize(fileSize)
6251
+ });
6252
+
6253
+ // Check if file exceeds threshold
6254
+ const threshold = sizeThresholds[fileType] || sizeThresholds.other;
6255
+ if (fileSize > threshold) {
6256
+ const suggestions = this.getSizeOptimizationSuggestions(file, fileSize, fileType);
6257
+
6258
+ console.log(chalk.yellow(` ๐Ÿ“ Large ${fileType}: ${relativePath} (${this.formatFileSize(fileSize)})`));
6259
+ suggestions.forEach(suggestion => {
6260
+ console.log(chalk.gray(` ๐Ÿ’ก ${suggestion}`));
6261
+ });
6262
+
6263
+ results.push({
6264
+ type: `๐Ÿ“ Large ${fileType}`,
6265
+ description: `File size ${this.formatFileSize(fileSize)} exceeds recommended ${this.formatFileSize(threshold)}`,
6266
+ filePath: file,
6267
+ relativePath: relativePath,
6268
+ size: fileSize,
6269
+ sizeFormatted: this.formatFileSize(fileSize),
6270
+ threshold: threshold,
6271
+ thresholdFormatted: this.formatFileSize(threshold),
6272
+ suggestions: suggestions,
6273
+ fileType: fileType
6274
+ });
6275
+ }
6276
+ } catch (error) {
6277
+ // Skip files we can't read
6278
+ }
6279
+ }
6280
+
6281
+ // Sort files by size (largest first)
6282
+ fileSizes.sort((a, b) => b.size - a.size);
6283
+
6284
+ // Get type breakdown for return data only
6285
+ const typeBreakdown = this.getFileSizeBreakdown(fileSizes);
6286
+
6287
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Found ${results.length} files that could be optimized`));
6288
+ console.log(chalk.gray('๐Ÿ’ก File size analysis is based on common best practices'));
6289
+
6290
+ return {
6291
+ largeFiles: results,
6292
+ allFiles: fileSizes,
6293
+ totalSize: totalSize,
6294
+ totalFiles: fileSizes.length,
6295
+ typeBreakdown: typeBreakdown
6296
+ };
6297
+ }
6298
+
6299
+ getFileCategory(filePath) {
6300
+ const ext = path.extname(filePath).toLowerCase();
6301
+
6302
+ if (['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.bmp'].includes(ext)) {
6303
+ return 'image';
6304
+ } else if (['.css', '.scss', '.sass', '.less'].includes(ext)) {
6305
+ return 'css';
6306
+ } else if (['.js', '.mjs', '.ts', '.jsx', '.tsx'].includes(ext)) {
6307
+ return 'js';
6308
+ } else if (['.html', '.htm', '.xhtml'].includes(ext)) {
6309
+ return 'html';
6310
+ } else if (['.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv'].includes(ext)) {
6311
+ return 'video';
6312
+ } else if (['.mp3', '.wav', '.ogg', '.aac', '.flac'].includes(ext)) {
6313
+ return 'audio';
6314
+ } else if (['.pdf', '.doc', '.docx', '.txt'].includes(ext)) {
6315
+ return 'document';
6316
+ } else if (['.zip', '.rar', '.tar', '.gz', '.7z'].includes(ext)) {
6317
+ return 'archive';
6318
+ } else if (['.ttf', '.woff', '.woff2', '.otf', '.eot'].includes(ext)) {
6319
+ return 'font';
6320
+ } else {
6321
+ return 'other';
6322
+ }
6323
+ }
6324
+
6325
+ getFileIcon(fileType) {
6326
+ const icons = {
6327
+ image: '๐Ÿ–ผ๏ธ',
6328
+ css: '๐ŸŽจ',
6329
+ js: '๐Ÿ“œ',
6330
+ html: '๐Ÿ“„',
6331
+ video: '๐ŸŽฅ',
6332
+ audio: '๐ŸŽต',
6333
+ document: '๐Ÿ“ƒ',
6334
+ archive: '๐Ÿ“ฆ',
6335
+ font: '๐Ÿ”ค',
6336
+ other: '๐Ÿ“„'
6337
+ };
6338
+ return icons[fileType] || '๐Ÿ“„';
6339
+ }
6340
+
6341
+ formatFileSize(bytes) {
6342
+ if (bytes === 0) return '0 B';
6343
+
6344
+ const k = 1024;
6345
+ const sizes = ['B', 'KB', 'MB', 'GB'];
6346
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
6347
+
6348
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
6349
+ }
6350
+
6351
+ getSizeOptimizationSuggestions(filePath, fileSize, fileType) {
6352
+ const suggestions = [];
6353
+ const fileName = path.basename(filePath);
6354
+ const ext = path.extname(filePath).toLowerCase();
6355
+
6356
+ switch (fileType) {
6357
+ case 'image':
6358
+ if (['.jpg', '.jpeg'].includes(ext)) {
6359
+ suggestions.push('Compress JPEG with tools like ImageOptim, TinyPNG, or jpegoptim');
6360
+ suggestions.push('Consider converting to WebP format for better compression');
6361
+ if (fileSize > 1024 * 1024) {
6362
+ suggestions.push('Resize image dimensions if currently larger than needed');
6363
+ }
6364
+ } else if (ext === '.png') {
6365
+ suggestions.push('Compress PNG with tools like OptiPNG, PNGCrush, or TinyPNG');
6366
+ suggestions.push('Consider converting to WebP for smaller size');
6367
+ suggestions.push('Use JPEG for photos if transparency not needed');
6368
+ } else if (ext === '.svg') {
6369
+ suggestions.push('Minify SVG code with SVGO or similar tools');
6370
+ suggestions.push('Remove unnecessary metadata and comments');
6371
+ } else if (ext === '.gif') {
6372
+ suggestions.push('Consider converting to WebP or MP4 for animations');
6373
+ suggestions.push('Reduce color palette if possible');
6374
+ }
6375
+ break;
6376
+
6377
+ case 'js':
6378
+ suggestions.push('Minify JavaScript code with tools like UglifyJS or Terser');
6379
+ suggestions.push('Enable gzip/brotli compression on your web server');
6380
+ suggestions.push('Consider code splitting for large bundles');
6381
+ suggestions.push('Remove unused code and dead code elimination');
6382
+ if (fileSize > 500 * 1024) {
6383
+ suggestions.push('Consider breaking into smaller modules');
6384
+ }
6385
+ break;
6386
+
6387
+ case 'css':
6388
+ suggestions.push('Minify CSS with tools like CleanCSS or cssnano');
6389
+ suggestions.push('Remove unused CSS rules (run --dead-code analysis)');
6390
+ suggestions.push('Enable gzip/brotli compression on your web server');
6391
+ suggestions.push('Consider using CSS-in-JS or CSS modules for better tree-shaking');
6392
+ break;
6393
+
6394
+ case 'html':
6395
+ suggestions.push('Minify HTML by removing whitespace and comments');
6396
+ suggestions.push('Enable gzip/brotli compression on your web server');
6397
+ suggestions.push('Inline critical CSS for above-the-fold content');
6398
+ break;
6399
+
6400
+ case 'video':
6401
+ suggestions.push('Compress video with H.264 or H.265 codecs');
6402
+ suggestions.push('Consider multiple quality versions (720p, 1080p)');
6403
+ suggestions.push('Use streaming formats like HLS or DASH for large videos');
6404
+ suggestions.push('Consider hosting on CDN or video platforms');
6405
+ break;
6406
+
6407
+ case 'audio':
6408
+ suggestions.push('Use compressed formats like MP3 or AAC instead of WAV');
6409
+ suggestions.push('Reduce bitrate if quality allows (128-192 kbps often sufficient)');
6410
+ suggestions.push('Consider streaming for long audio files');
6411
+ break;
6412
+
6413
+ case 'font':
6414
+ suggestions.push('Use WOFF2 format for better compression');
6415
+ suggestions.push('Subset fonts to include only needed characters');
6416
+ suggestions.push('Consider using system fonts or web font alternatives');
6417
+ break;
6418
+
6419
+ case 'document':
6420
+ if (ext === '.pdf') {
6421
+ suggestions.push('Compress PDF with tools like Ghostscript or online compressors');
6422
+ suggestions.push('Reduce image quality in PDF if acceptable');
6423
+ }
6424
+ break;
6425
+
6426
+ default:
6427
+ suggestions.push('Enable compression on your web server');
6428
+ suggestions.push('Consider if this file is necessary in production');
6429
+ }
6430
+
6431
+ // General suggestions for all large files
6432
+ if (fileSize > 1024 * 1024) {
6433
+ suggestions.push('Consider lazy loading if not needed immediately');
6434
+ suggestions.push('Use CDN for better delivery performance');
6435
+ }
6436
+
6437
+ return suggestions;
6438
+ }
6439
+
6440
+ getFileSizeBreakdown(fileSizes) {
6441
+ const breakdown = {};
6442
+
6443
+ fileSizes.forEach(file => {
6444
+ if (!breakdown[file.type]) {
6445
+ breakdown[file.type] = {
6446
+ count: 0,
6447
+ totalSize: 0
6448
+ };
6449
+ }
6450
+ breakdown[file.type].count++;
6451
+ breakdown[file.type].totalSize += file.size;
6452
+ });
6453
+
6454
+ // Sort by total size
6455
+ const sortedBreakdown = {};
6456
+ Object.entries(breakdown)
6457
+ .sort(([,a], [,b]) => b.totalSize - a.totalSize)
6458
+ .forEach(([key, value]) => {
6459
+ sortedBreakdown[key] = value;
6460
+ });
6461
+
6462
+ return sortedBreakdown;
4488
6463
  }
4489
6464
  }
4490
6465