gbu-accessibility-package 3.11.0 → 3.12.1

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
@@ -5833,6 +5833,163 @@ class AccessibilityFixer {
5833
5833
  return files;
5834
5834
  }
5835
5835
 
5836
+ // Check meta tags in HTML files
5837
+ async checkMetaTags(directory = '.') {
5838
+ console.log(chalk.blue('🔍 Checking meta tags for typos and syntax errors...'));
5839
+
5840
+ const htmlFiles = await this.findHtmlFiles(directory);
5841
+ let totalFiles = 0;
5842
+ let filesWithErrors = 0;
5843
+ let skippedFiles = 0;
5844
+ let totalErrors = 0;
5845
+
5846
+ for (const file of htmlFiles) {
5847
+ try {
5848
+ const content = await fs.readFile(file, 'utf8');
5849
+ const analysis = this.analyzeMetaTags(content, file);
5850
+ const relativePath = path.relative(directory, file);
5851
+
5852
+ if (analysis.isIncludeFile) {
5853
+ skippedFiles++;
5854
+ console.log(chalk.gray(`⏭️ Skipped: ${relativePath} (include file)`));
5855
+ continue;
5856
+ }
5857
+
5858
+ totalFiles++;
5859
+
5860
+ if (analysis.errors.length > 0) {
5861
+ filesWithErrors++;
5862
+ totalErrors += analysis.errors.length;
5863
+ console.log(chalk.red(`\n❌ ${relativePath}`));
5864
+ analysis.errors.forEach((error, index) => {
5865
+ console.log(chalk.red(` ${index + 1}. ${error}`));
5866
+ });
5867
+ } else {
5868
+ console.log(chalk.green(`✅ ${relativePath} - No errors`));
5869
+ }
5870
+ } catch (error) {
5871
+ console.log(chalk.red(`❌ Error reading ${file}: ${error.message}`));
5872
+ }
5873
+ }
5874
+
5875
+ console.log(chalk.blue('\n📊 Summary:'));
5876
+ console.log(` Total files checked: ${totalFiles}`);
5877
+ console.log(` Files skipped (includes): ${skippedFiles}`);
5878
+ console.log(chalk.red(` Files with errors: ${filesWithErrors}`));
5879
+ console.log(chalk.red(` Total errors found: ${totalErrors}`));
5880
+ console.log(chalk.green(` Files OK: ${totalFiles - filesWithErrors}`));
5881
+
5882
+ if (filesWithErrors > 0) {
5883
+ console.log(chalk.yellow(`\n💡 Sử dụng --meta-fix để tự động sửa các lỗi này`));
5884
+ }
5885
+ }
5886
+
5887
+ // Fix meta tags in HTML files
5888
+ async fixMetaTags(directory = '.', options = {}) {
5889
+ const { dryRun = false, backup = false } = options;
5890
+ console.log(chalk.blue('🔧 Fixing meta tag typos and syntax errors...'));
5891
+
5892
+ const htmlFiles = await this.findHtmlFiles(directory);
5893
+ let totalFiles = 0;
5894
+ let fixedFiles = 0;
5895
+ let skippedFiles = 0;
5896
+ let totalFixes = 0;
5897
+
5898
+ for (const file of htmlFiles) {
5899
+ try {
5900
+ const content = await fs.readFile(file, 'utf8');
5901
+ const analysis = this.analyzeMetaTags(content, file);
5902
+ const relativePath = path.relative(directory, file);
5903
+
5904
+ if (analysis.isIncludeFile) {
5905
+ skippedFiles++;
5906
+ console.log(chalk.gray(`⏭️ Skipped: ${relativePath} (include file)`));
5907
+ continue;
5908
+ }
5909
+
5910
+ totalFiles++;
5911
+
5912
+ if (analysis.fixable.length === 0) {
5913
+ console.log(chalk.green(`✅ ${relativePath} - No errors to fix`));
5914
+ continue;
5915
+ }
5916
+
5917
+ console.log(chalk.yellow(`\n🔧 Fixing: ${relativePath}`));
5918
+
5919
+ let newContent = content;
5920
+ let fixCount = 0;
5921
+
5922
+ // Group fixes by tag to handle multiple fixes on same tag
5923
+ const tagFixes = new Map();
5924
+ for (const fix of analysis.fixable) {
5925
+ if (!tagFixes.has(fix.fullTag)) {
5926
+ tagFixes.set(fix.fullTag, []);
5927
+ }
5928
+ tagFixes.get(fix.fullTag).push(fix);
5929
+ }
5930
+
5931
+ // Apply all fixes for each tag
5932
+ for (const [originalTag, fixes] of tagFixes) {
5933
+ let newTag = originalTag;
5934
+
5935
+ for (const fix of fixes) {
5936
+ if (fix.type === 'property') {
5937
+ // Fix property name typo
5938
+ newTag = newTag.replace(
5939
+ new RegExp(`((?:name|property)\\s*=\\s*["'])${fix.wrong.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(["'])`, 'i'),
5940
+ `$1${fix.correct}$2`
5941
+ );
5942
+ console.log(chalk.green(` ✓ Fixed property: ${fix.wrong} → ${fix.correct}`));
5943
+ fixCount++;
5944
+ } else if (fix.type === 'content') {
5945
+ // Fix content value typo
5946
+ newTag = newTag.replace(
5947
+ new RegExp(`(content\\s*=\\s*["'])${fix.wrong.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(["'])`, 'i'),
5948
+ `$1${fix.correct}$2`
5949
+ );
5950
+ console.log(chalk.green(` ✓ Fixed ${fix.property} value: ${fix.wrong} → ${fix.correct}`));
5951
+ fixCount++;
5952
+ }
5953
+ }
5954
+
5955
+ // Replace the original tag with the fixed tag
5956
+ newContent = newContent.replace(originalTag, newTag);
5957
+ }
5958
+
5959
+ // Save file if modified
5960
+ if (fixCount > 0) {
5961
+ if (!dryRun) {
5962
+ // Create backup if enabled
5963
+ if (backup) {
5964
+ const backupPath = `${file}.bak`;
5965
+ await fs.writeFile(backupPath, content, 'utf8');
5966
+ }
5967
+
5968
+ await fs.writeFile(file, newContent, 'utf8');
5969
+ console.log(chalk.green(` 💾 Saved ${fixCount} fix(es) to ${relativePath}`));
5970
+ fixedFiles++;
5971
+ totalFixes += fixCount;
5972
+ } else {
5973
+ console.log(chalk.blue(` 🔍 Dry run - would fix ${fixCount} error(s) in ${relativePath}`));
5974
+ totalFixes += fixCount;
5975
+ }
5976
+ }
5977
+ } catch (error) {
5978
+ console.log(chalk.red(`❌ Error processing ${file}: ${error.message}`));
5979
+ }
5980
+ }
5981
+
5982
+ console.log(chalk.blue('\n📊 Summary:'));
5983
+ console.log(` Total files checked: ${totalFiles}`);
5984
+ console.log(` Files skipped (includes): ${skippedFiles}`);
5985
+ console.log(chalk.green(` Files fixed: ${fixedFiles}`));
5986
+ console.log(chalk.green(` Total fixes applied: ${totalFixes}`));
5987
+
5988
+ if (dryRun) {
5989
+ console.log(chalk.blue('\n💡 This was a dry run. Use without --dry-run to apply changes.'));
5990
+ }
5991
+ }
5992
+
5836
5993
  // Check for unused files in the project - enhanced for comprehensive project-wide scanning
5837
5994
  async checkUnusedFiles(directory = '.') {
5838
5995
  console.log(chalk.blue('🗂️ Analyzing unused files across entire project...'));
@@ -5916,6 +6073,134 @@ class AccessibilityFixer {
5916
6073
  };
5917
6074
  }
5918
6075
 
6076
+ resolveUnusedFilesListPath(directory = '.', listFile = 'unused-files-list.txt') {
6077
+ const scanDirectory = path.resolve(directory);
6078
+ const listFileName = listFile || 'unused-files-list.txt';
6079
+
6080
+ if (path.isAbsolute(listFileName)) {
6081
+ return listFileName;
6082
+ }
6083
+
6084
+ return path.join(scanDirectory, listFileName);
6085
+ }
6086
+
6087
+ async generateUnusedFilesList(directory = '.', listFile = 'unused-files-list.txt') {
6088
+ const scanDirectory = path.resolve(directory);
6089
+ const outputPath = this.resolveUnusedFilesListPath(scanDirectory, listFile);
6090
+ const unusedResults = await this.checkUnusedFiles(scanDirectory);
6091
+ const fileContent = unusedResults.unusedFiles
6092
+ .map(file => file.relativePath.replace(/\\/g, '/'))
6093
+ .join('\n');
6094
+
6095
+ await fs.writeFile(outputPath, fileContent ? `${fileContent}\n` : '', 'utf8');
6096
+
6097
+ console.log(chalk.green(`📝 Đã tạo danh sách file dư thừa tại: ${outputPath}`));
6098
+ console.log(chalk.gray(`📊 Ghi ${unusedResults.unusedCount} path vào file list`));
6099
+
6100
+ return {
6101
+ ...unusedResults,
6102
+ outputPath
6103
+ };
6104
+ }
6105
+
6106
+ async deleteUnusedFilesFromList(directory = '.', listFile = 'unused-files-list.txt', options = {}) {
6107
+ const scanDirectory = path.resolve(directory);
6108
+ const listPath = this.resolveUnusedFilesListPath(scanDirectory, listFile);
6109
+ const dryRun = Boolean(options.dryRun);
6110
+ const deletedFiles = [];
6111
+ const missingFiles = [];
6112
+ const skippedEntries = [];
6113
+
6114
+ const content = await fs.readFile(listPath, 'utf8');
6115
+ const entries = [...new Set(
6116
+ content
6117
+ .split(/\r?\n/)
6118
+ .map(line => line.trim())
6119
+ .filter(line => line && !line.startsWith('#'))
6120
+ )];
6121
+
6122
+ for (const entry of entries) {
6123
+ const normalizedEntry = entry.replace(/\\/g, '/');
6124
+ const resolvedPath = path.resolve(scanDirectory, normalizedEntry);
6125
+ const relativeToScan = path.relative(scanDirectory, resolvedPath);
6126
+
6127
+ if (relativeToScan.startsWith('..') || path.isAbsolute(relativeToScan)) {
6128
+ skippedEntries.push({
6129
+ path: entry,
6130
+ reason: 'Path nằm ngoài thư mục target'
6131
+ });
6132
+ continue;
6133
+ }
6134
+
6135
+ if (resolvedPath === path.resolve(listPath)) {
6136
+ skippedEntries.push({
6137
+ path: entry,
6138
+ reason: 'Bỏ qua chính file list'
6139
+ });
6140
+ continue;
6141
+ }
6142
+
6143
+ try {
6144
+ const stats = await fs.lstat(resolvedPath);
6145
+
6146
+ if (!stats.isFile() && !stats.isSymbolicLink()) {
6147
+ skippedEntries.push({
6148
+ path: entry,
6149
+ reason: 'Không phải file'
6150
+ });
6151
+ continue;
6152
+ }
6153
+
6154
+ if (!dryRun) {
6155
+ await fs.unlink(resolvedPath);
6156
+ }
6157
+
6158
+ deletedFiles.push({
6159
+ path: resolvedPath,
6160
+ relativePath: relativeToScan.replace(/\\/g, '/')
6161
+ });
6162
+ } catch (error) {
6163
+ if (error.code === 'ENOENT') {
6164
+ missingFiles.push({
6165
+ path: entry,
6166
+ reason: 'File không tồn tại'
6167
+ });
6168
+ continue;
6169
+ }
6170
+
6171
+ skippedEntries.push({
6172
+ path: entry,
6173
+ reason: error.message
6174
+ });
6175
+ }
6176
+ }
6177
+
6178
+ console.log(chalk.green(
6179
+ dryRun
6180
+ ? `🧪 Dry run: ${deletedFiles.length} file trong list sẽ được xóa`
6181
+ : `🗑️ Đã xóa ${deletedFiles.length} file từ list`
6182
+ ));
6183
+
6184
+ if (missingFiles.length > 0) {
6185
+ console.log(chalk.yellow(`⚠️ ${missingFiles.length} path không còn tồn tại`));
6186
+ }
6187
+
6188
+ if (skippedEntries.length > 0) {
6189
+ console.log(chalk.yellow(`⚠️ ${skippedEntries.length} path bị bỏ qua để đảm bảo an toàn hoặc không hợp lệ`));
6190
+ }
6191
+
6192
+ return {
6193
+ listPath,
6194
+ deletedFiles,
6195
+ deletedCount: deletedFiles.length,
6196
+ missingFiles,
6197
+ missingCount: missingFiles.length,
6198
+ skippedEntries,
6199
+ skippedCount: skippedEntries.length,
6200
+ dryRun
6201
+ };
6202
+ }
6203
+
5919
6204
  // Enhanced file reference checking - now works with relative paths
5920
6205
  isFileReferenced(filePath, relativePath, referencedFiles, projectRoot) {
5921
6206
  // Check various possible reference formats
@@ -6124,12 +6409,14 @@ class AccessibilityFixer {
6124
6409
  /<a[^>]*href\s*=\s*["']([^"'#]+\.html?)["']/gi,
6125
6410
  // Video/Audio
6126
6411
  /<(?:video|audio)[^>]*src\s*=\s*["']([^"']+)["']/gi,
6412
+ // Video poster
6413
+ /<video[^>]*poster\s*=\s*["']([^"']+)["']/gi,
6127
6414
  // Object/Embed
6128
6415
  /<(?:object|embed)[^>]*src\s*=\s*["']([^"']+)["']/gi,
6129
6416
  // Iframe
6130
6417
  /<iframe[^>]*src\s*=\s*["']([^"']+)["']/gi,
6131
- // Meta (for icons)
6132
- /<meta[^>]*content\s*=\s*["']([^"']+\.(ico|png|jpg|jpeg|svg))["']/gi,
6418
+ // Meta tags (for OG images, icons) - including absolute URLs
6419
+ /<meta[^>]*content\s*=\s*["']([^"']+)["']/gi,
6133
6420
  // Server Side Includes (SSI)
6134
6421
  /<!--#include\s+virtual\s*=\s*["']([^"']+)["']\s*-->/gi,
6135
6422
  /<!--#include\s+file\s*=\s*["']([^"']+)["']\s*-->/gi
@@ -6140,17 +6427,49 @@ class AccessibilityFixer {
6140
6427
  while ((match = pattern.exec(content)) !== null) {
6141
6428
  const url = match[1];
6142
6429
 
6143
- // Handle srcset format: "image1.jpg 1x, image2.jpg 2x"
6430
+ // Handle srcset format: "image1.jpg 1x, image2.jpg 2x" or "image1.jpg 480w, image2.jpg 800w"
6144
6431
  if (pattern.source.includes('srcset')) {
6145
- const srcsetUrls = url.split(',').map(item => item.trim().split(' ')[0]);
6146
- srcsetUrls.forEach(srcUrl => {
6147
- if (this.isLocalFile(srcUrl)) {
6148
- this.addNormalizedUrl(references, srcUrl);
6432
+ // Split by comma to get individual srcset entries
6433
+ const srcsetEntries = url.split(',');
6434
+ srcsetEntries.forEach(entry => {
6435
+ // Each entry format: "url descriptor" where descriptor is 1x, 2x, 480w, etc.
6436
+ // Split by whitespace and take the first part (the URL)
6437
+ const parts = entry.trim().split(/\s+/);
6438
+ const srcUrl = parts[0];
6439
+
6440
+ if (srcUrl) {
6441
+ const normalizedSrcUrl = this.normalizeReferenceUrl(srcUrl);
6442
+ if (!normalizedSrcUrl) {
6443
+ return;
6444
+ }
6445
+
6446
+ // Check if it's a local file
6447
+ if (this.isLocalFile(normalizedSrcUrl)) {
6448
+ this.addNormalizedUrl(references, normalizedSrcUrl);
6449
+ } else if (this.isAbsoluteUrlToLocalFile(normalizedSrcUrl)) {
6450
+ // Extract local path from absolute URL (e.g., https://example.com/assets/img/file.png -> /assets/img/file.png)
6451
+ const localPath = this.extractLocalPathFromAbsoluteUrl(normalizedSrcUrl);
6452
+ if (localPath) {
6453
+ this.addNormalizedUrl(references, localPath);
6454
+ }
6455
+ }
6149
6456
  }
6150
6457
  });
6151
6458
  } else {
6152
- if (this.isLocalFile(url)) {
6153
- this.addNormalizedUrl(references, url);
6459
+ const normalizedUrl = this.normalizeReferenceUrl(url);
6460
+ if (!normalizedUrl) {
6461
+ continue;
6462
+ }
6463
+
6464
+ // Regular src/href/content attributes
6465
+ if (this.isLocalFile(normalizedUrl)) {
6466
+ this.addNormalizedUrl(references, normalizedUrl);
6467
+ } else if (this.isAbsoluteUrlToLocalFile(normalizedUrl)) {
6468
+ // Extract local path from absolute URL
6469
+ const localPath = this.extractLocalPathFromAbsoluteUrl(normalizedUrl);
6470
+ if (localPath) {
6471
+ this.addNormalizedUrl(references, localPath);
6472
+ }
6154
6473
  }
6155
6474
  }
6156
6475
  }
@@ -6159,15 +6478,77 @@ class AccessibilityFixer {
6159
6478
  return references;
6160
6479
  }
6161
6480
 
6481
+ // Check if URL is an absolute URL that points to a local file (same domain or project assets)
6482
+ isAbsoluteUrlToLocalFile(url) {
6483
+ const normalizedUrl = this.normalizeReferenceUrl(url);
6484
+ if (!normalizedUrl) {
6485
+ return false;
6486
+ }
6487
+
6488
+ if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
6489
+ return false;
6490
+ }
6491
+
6492
+ // Check if URL contains common asset paths (customize based on your project structure)
6493
+ // This helps identify URLs like: https://www.example.com/assets/img/file.png
6494
+ return normalizedUrl.includes('/assets/') ||
6495
+ normalizedUrl.includes('/static/') ||
6496
+ normalizedUrl.includes('/img/') ||
6497
+ normalizedUrl.includes('/images/') ||
6498
+ normalizedUrl.includes('/css/') ||
6499
+ normalizedUrl.includes('/js/') ||
6500
+ normalizedUrl.includes('/media/') ||
6501
+ normalizedUrl.includes('/fonts/') ||
6502
+ normalizedUrl.match(/\.(jpg|jpeg|png|gif|svg|webp|css|js|ico|pdf|mp4|webm)$/i);
6503
+ }
6504
+
6505
+ // Extract local path from absolute URL
6506
+ // e.g., "https://www.example.com/assets/img/file.png" -> "/assets/img/file.png"
6507
+ extractLocalPathFromAbsoluteUrl(url) {
6508
+ try {
6509
+ const normalizedUrl = this.normalizeReferenceUrl(url);
6510
+ if (!normalizedUrl) {
6511
+ return null;
6512
+ }
6513
+
6514
+ const urlObj = new URL(normalizedUrl);
6515
+ return urlObj.pathname; // Returns "/assets/img/file.png"
6516
+ } catch (error) {
6517
+ return null;
6518
+ }
6519
+ }
6520
+
6521
+ normalizeReferenceUrl(url) {
6522
+ if (typeof url !== 'string') {
6523
+ return null;
6524
+ }
6525
+
6526
+ const trimmedUrl = url.trim();
6527
+ if (!trimmedUrl) {
6528
+ return null;
6529
+ }
6530
+
6531
+ const withoutHash = trimmedUrl.split('#')[0];
6532
+ const withoutQuery = withoutHash.split('?')[0];
6533
+ const normalizedUrl = withoutQuery.trim().replace(/\\/g, '/');
6534
+
6535
+ return normalizedUrl || null;
6536
+ }
6537
+
6162
6538
  // Helper method to add normalized URL variations
6163
6539
  addNormalizedUrl(references, url) {
6540
+ const normalizedUrl = this.normalizeReferenceUrl(url);
6541
+ if (!normalizedUrl) {
6542
+ return;
6543
+ }
6544
+
6164
6545
  // Store original URL and normalized versions for matching
6165
- references.push(url);
6166
- if (url.startsWith('/')) {
6167
- references.push(url.substring(1)); // Remove leading slash
6546
+ references.push(normalizedUrl);
6547
+ if (normalizedUrl.startsWith('/')) {
6548
+ references.push(normalizedUrl.substring(1)); // Remove leading slash
6168
6549
  }
6169
- if (!url.startsWith('./') && !url.startsWith('/')) {
6170
- references.push('./' + url); // Add leading ./
6550
+ if (!normalizedUrl.startsWith('./') && !normalizedUrl.startsWith('/')) {
6551
+ references.push('./' + normalizedUrl); // Add leading ./
6171
6552
  }
6172
6553
  }
6173
6554
 
@@ -6187,7 +6568,7 @@ class AccessibilityFixer {
6187
6568
  for (const pattern of patterns) {
6188
6569
  let match;
6189
6570
  while ((match = pattern.exec(content)) !== null) {
6190
- const url = match[1];
6571
+ const url = this.normalizeReferenceUrl(match[1]);
6191
6572
  if (this.isLocalFile(url)) {
6192
6573
  this.addNormalizedUrl(references, url);
6193
6574
  }
@@ -6213,15 +6594,15 @@ class AccessibilityFixer {
6213
6594
  // XMLHttpRequest
6214
6595
  /\.open\s*\(\s*["'][^"']*["']\s*,\s*["']([^"']+)["']/gi,
6215
6596
  // String literals that look like paths
6216
- /["']([^"']*\.(html|css|js|json|xml|jpg|jpeg|png|gif|svg|webp|ico))["']/gi,
6597
+ /["']([^"']*\.(html|css|js|json|xml|jpg|jpeg|png|gif|svg|webp|ico)(?:[?#][^"']*)?)["']/gi,
6217
6598
  // Template literals with paths
6218
- /`([^`]*\.(html|css|js|json|xml|jpg|jpeg|png|gif|svg|webp|ico))`/gi
6599
+ /`([^`]*\.(html|css|js|json|xml|jpg|jpeg|png|gif|svg|webp|ico)(?:[?#][^`]*)?)`/gi
6219
6600
  ];
6220
6601
 
6221
6602
  for (const pattern of patterns) {
6222
6603
  let match;
6223
6604
  while ((match = pattern.exec(content)) !== null) {
6224
- const url = match[1];
6605
+ const url = this.normalizeReferenceUrl(match[1]);
6225
6606
  if (this.isLocalFile(url)) {
6226
6607
  this.addNormalizedUrl(references, url);
6227
6608
  }
@@ -6321,7 +6702,7 @@ class AccessibilityFixer {
6321
6702
  for (const pattern of patterns) {
6322
6703
  let match;
6323
6704
  while ((match = pattern.exec(content)) !== null) {
6324
- const url = match[1];
6705
+ const url = this.normalizeReferenceUrl(match[1]);
6325
6706
  if (this.isLocalFile(url)) {
6326
6707
  this.addNormalizedUrl(references, url);
6327
6708
  }
@@ -6353,7 +6734,7 @@ class AccessibilityFixer {
6353
6734
  for (const pattern of patterns) {
6354
6735
  let match;
6355
6736
  while ((match = pattern.exec(content)) !== null) {
6356
- const url = match[1];
6737
+ const url = this.normalizeReferenceUrl(match[1]);
6357
6738
  if (this.isLocalFile(url)) {
6358
6739
  this.addNormalizedUrl(references, url);
6359
6740
  }
@@ -6487,13 +6868,18 @@ class AccessibilityFixer {
6487
6868
  }
6488
6869
 
6489
6870
  isLocalFile(url) {
6490
- return !url.startsWith('http://') &&
6491
- !url.startsWith('https://') &&
6492
- !url.startsWith('//') &&
6493
- !url.startsWith('data:') &&
6494
- !url.startsWith('mailto:') &&
6495
- !url.startsWith('tel:') &&
6496
- !url.startsWith('#');
6871
+ const normalizedUrl = this.normalizeReferenceUrl(url);
6872
+ if (!normalizedUrl) {
6873
+ return false;
6874
+ }
6875
+
6876
+ return !normalizedUrl.startsWith('http://') &&
6877
+ !normalizedUrl.startsWith('https://') &&
6878
+ !normalizedUrl.startsWith('//') &&
6879
+ !normalizedUrl.startsWith('data:') &&
6880
+ !normalizedUrl.startsWith('mailto:') &&
6881
+ !normalizedUrl.startsWith('tel:') &&
6882
+ !normalizedUrl.startsWith('#');
6497
6883
  }
6498
6884
 
6499
6885
  resolveFilePath(url, baseDir) {
@@ -7167,9 +7553,14 @@ class AccessibilityFixer {
7167
7553
  // Extract file references from JSON values
7168
7554
  const extractFromObject = (obj) => {
7169
7555
  if (typeof obj === 'string') {
7556
+ const normalizedValue = this.normalizeReferenceUrl(obj);
7557
+ if (!normalizedValue) {
7558
+ return;
7559
+ }
7560
+
7170
7561
  // Check if it looks like a file path
7171
- if (obj.includes('.') && (obj.includes('/') || obj.includes('\\'))) {
7172
- const resolved = this.resolveFilePath(obj, baseDir);
7562
+ if (/\.(html?|css|js|json|xml|jpe?g|png|gif|svg|webp|ico)$/i.test(normalizedValue)) {
7563
+ const resolved = this.resolveFilePath(normalizedValue, baseDir);
7173
7564
  if (resolved) {
7174
7565
  references.push(resolved);
7175
7566
  }
@@ -7208,7 +7599,7 @@ class AccessibilityFixer {
7208
7599
  patterns.forEach(pattern => {
7209
7600
  let match;
7210
7601
  while ((match = pattern.exec(content)) !== null) {
7211
- const filePath = match[1];
7602
+ const filePath = this.normalizeReferenceUrl(match[1]);
7212
7603
 
7213
7604
  if (this.isLocalFile(filePath)) {
7214
7605
  const resolved = this.resolveFilePath(filePath, baseDir);
@@ -7246,970 +7637,282 @@ class AccessibilityFixer {
7246
7637
  return null;
7247
7638
  }
7248
7639
 
7249
- // Check Meta Tags and Open Graph Protocol
7250
- async checkMetaTags(directory = '.') {
7251
- console.log(chalk.blue('🏷️ Đang kiểm tra Meta Tags Open Graph Protocol...'));
7640
+ /**
7641
+ * Generate comprehensive Excel report
7642
+ * Covers: Meta Tags, Accessibility, Forms, Buttons, Headings, Broken Links, Unused Files, File Size, GTM
7643
+ */
7644
+ async generateFullReport(directory = '.', outputPath = null) {
7645
+ const ExcelJS = require('exceljs');
7646
+ const startTime = Date.now();
7252
7647
 
7253
- const htmlFiles = await this.findHtmlFiles(directory);
7254
- const results = [];
7648
+ console.log(chalk.blue('📊 Đang tạo báo cáo toàn diện...'));
7649
+ console.log(chalk.gray(`Thư mục: ${path.resolve(directory)}`));
7650
+ console.log('');
7255
7651
 
7256
- for (const file of htmlFiles) {
7257
- try {
7258
- const content = await fs.readFile(file, 'utf8');
7259
- const metaAnalysis = this.analyzeMetaTags(content, file);
7260
-
7261
- // Skip SSI include files
7262
- if (metaAnalysis.isIncludeFile) {
7263
- results.push({
7264
- file,
7265
- status: 'skipped',
7266
- reason: 'SSI include file',
7267
- metaAnalysis
7268
- });
7269
- continue;
7270
- }
7271
-
7272
- if (metaAnalysis.issues.length > 0 || metaAnalysis.warnings.length > 0 || metaAnalysis.recommendations.length > 0) {
7273
- console.log(chalk.cyan(`\n📁 ${path.relative(directory, file)}:`));
7274
-
7275
- // Show status for required OG tags
7276
- if (metaAnalysis.hasOpenGraph) {
7277
- console.log(chalk.green(` ✅ Open Graph Protocol: Đã cài đặt`));
7278
-
7279
- // Required OG tags status
7280
- metaAnalysis.requiredTags.forEach(tag => {
7281
- if (tag.present) {
7282
- console.log(chalk.green(` ✅ ${tag.name}: ${tag.value ? (tag.value.substring(0, 50) + (tag.value.length > 50 ? '...' : '')) : '(có)'}`));
7283
- } else {
7284
- console.log(chalk.red(` ❌ ${tag.name}: Thiếu`));
7285
- }
7286
- });
7287
- } else {
7288
- console.log(chalk.yellow(` ⚠️ Open Graph Protocol: Chưa cài đặt`));
7289
- }
7290
-
7291
- // Show Twitter Card status
7292
- if (metaAnalysis.hasTwitterCard) {
7293
- console.log(chalk.green(` ✅ Twitter Card: Đã cài đặt`));
7294
- }
7295
-
7296
- // Show issues (errors)
7297
- metaAnalysis.issues.forEach(issue => {
7298
- console.log(chalk.red(` ❌ ${issue.type}: ${issue.description}`));
7299
- if (issue.suggestion) {
7300
- console.log(chalk.gray(` 💡 ${issue.suggestion}`));
7301
- }
7302
- });
7303
-
7304
- // Show warnings
7305
- metaAnalysis.warnings.forEach(warning => {
7306
- console.log(chalk.yellow(` ⚠️ ${warning.type}: ${warning.description}`));
7307
- if (warning.suggestion) {
7308
- console.log(chalk.gray(` 💡 ${warning.suggestion}`));
7309
- }
7310
- });
7311
-
7312
- // Show recommendations (optional improvements)
7313
- if (metaAnalysis.recommendations.length > 0) {
7314
- metaAnalysis.recommendations.forEach(rec => {
7315
- console.log(chalk.blue(` 💎 ${rec.type}: ${rec.description}`));
7316
- if (rec.suggestion) {
7317
- console.log(chalk.gray(` ℹ️ ${rec.suggestion}`));
7318
- }
7319
- });
7320
- }
7321
- }
7322
-
7323
- results.push({
7324
- file,
7325
- status: 'analyzed',
7326
- metaAnalysis
7327
- });
7328
- } catch (error) {
7329
- console.error(chalk.red(`❌ Lỗi khi xử lý ${file}: ${error.message}`));
7330
- results.push({ file, status: 'error', error: error.message });
7652
+ // Initialize workbook
7653
+ const workbook = new ExcelJS.Workbook();
7654
+ workbook.creator = 'GBU Accessibility Tool';
7655
+ workbook.created = new Date();
7656
+
7657
+ // Store all results
7658
+ const allResults = {
7659
+ meta: [],
7660
+ accessibility: [],
7661
+ forms: [],
7662
+ buttons: [],
7663
+ headings: [],
7664
+ brokenLinks: [],
7665
+ missingResources: [],
7666
+ unusedFiles: [],
7667
+ fileSize: [],
7668
+ gtm: [],
7669
+ summary: {
7670
+ totalFiles: 0,
7671
+ totalIssues: 0,
7672
+ totalWarnings: 0,
7673
+ totalRecommendations: 0
7331
7674
  }
7332
- }
7675
+ };
7333
7676
 
7334
- const filesAnalyzed = results.filter(r => r.status === 'analyzed').length;
7335
- const filesSkipped = results.filter(r => r.status === 'skipped').length;
7336
- const filesWithOG = results.filter(r => r.metaAnalysis?.hasOpenGraph).length;
7337
- const filesWithIssues = results.filter(r => r.metaAnalysis?.issues?.length > 0).length;
7338
- const filesWithWarnings = results.filter(r => r.metaAnalysis?.warnings?.length > 0).length;
7339
- const filesWithRecs = results.filter(r => r.metaAnalysis?.recommendations?.length > 0).length;
7340
- const totalIssues = results.reduce((sum, r) => sum + (r.metaAnalysis?.issues?.length || 0), 0);
7341
- const totalWarnings = results.reduce((sum, r) => sum + (r.metaAnalysis?.warnings?.length || 0), 0);
7342
- const totalRecs = results.reduce((sum, r) => sum + (r.metaAnalysis?.recommendations?.length || 0), 0);
7677
+ // Style definitions
7678
+ const headerStyle = {
7679
+ font: { bold: true, color: { argb: 'FFFFFFFF' } },
7680
+ fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } },
7681
+ alignment: { horizontal: 'center', vertical: 'middle' },
7682
+ border: {
7683
+ top: { style: 'thin' },
7684
+ left: { style: 'thin' },
7685
+ bottom: { style: 'thin' },
7686
+ right: { style: 'thin' }
7687
+ }
7688
+ };
7343
7689
 
7344
- console.log(chalk.blue(`\n📊 Tóm tắt: Đã phân tích ${filesAnalyzed} file`));
7345
- if (filesSkipped > 0) {
7346
- console.log(chalk.gray(` ⏭️ Bỏ qua: ${filesSkipped} file (SSI include)`));
7347
- }
7348
- console.log(chalk.green(` ✅ File có Open Graph: ${filesWithOG}`));
7349
- if (totalIssues > 0) {
7350
- console.log(chalk.red(` ❌ File có lỗi: ${filesWithIssues} (${totalIssues} vấn đề)`));
7351
- }
7352
- if (totalWarnings > 0) {
7353
- console.log(chalk.yellow(` ⚠️ File có cảnh báo: ${filesWithWarnings} (${totalWarnings} cảnh báo)`));
7354
- }
7355
- if (totalRecs > 0) {
7356
- console.log(chalk.blue(` 💎 File có khuyến nghị: ${filesWithRecs} (${totalRecs} khuyến nghị)`));
7357
- }
7358
- console.log(chalk.gray('💡 Open Graph Protocol cần có 4 thẻ bắt buộc: og:title, og:type, og:image, og:url'));
7690
+ const errorStyle = {
7691
+ font: { color: { argb: 'FFFF0000' } },
7692
+ fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFC7CE' } }
7693
+ };
7359
7694
 
7360
- return results;
7361
- }
7362
-
7363
- analyzeMetaTags(content, filePath) {
7364
- const result = {
7365
- hasOpenGraph: false,
7366
- hasTwitterCard: false,
7367
- hasBasicMeta: false,
7368
- isIncludeFile: false, // New: track if this is an SSI include file
7369
- requiredTags: [],
7370
- optionalTags: [],
7371
- twitterTags: [],
7372
- basicTags: [],
7373
- issues: [],
7374
- warnings: [],
7375
- recommendations: [], // New: optional improvements, not errors
7376
- syntaxErrors: [],
7377
- typos: [] // New: track typos for auto-fix
7695
+ const warningStyle = {
7696
+ font: { color: { argb: 'FF9C5700' } },
7697
+ fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFEB9C' } }
7378
7698
  };
7379
7699
 
7380
- // Check if this is an SSI include file or partial file (should be skipped)
7381
- // Method 1: File contains SSI include directive
7382
- const ssiIncludePattern = /<!--#include\s+(virtual|file)\s*=\s*["'][^"']+["']\s*-->/i;
7383
- if (ssiIncludePattern.test(content)) {
7384
- result.isIncludeFile = true;
7385
- result.skipReason = 'Chứa SSI include directive';
7386
- return result; // Skip analysis for include files
7387
- }
7388
-
7389
- // Method 2: File path contains /inc/ directory (common pattern for partials)
7390
- if (filePath && /[\/\\]inc[\/\\]/i.test(filePath)) {
7391
- result.isIncludeFile = true;
7392
- result.skipReason = 'File partial trong thư mục /inc/';
7393
- return result;
7394
- }
7395
-
7396
- // Method 3: File doesn't have DOCTYPE or <html> tag (likely a partial)
7397
- const hasDoctype = /<!DOCTYPE\s+html/i.test(content);
7398
- const hasHtmlTag = /<html[\s>]/i.test(content);
7399
- if (!hasDoctype && !hasHtmlTag) {
7400
- result.isIncludeFile = true;
7401
- result.skipReason = 'File partial (không có DOCTYPE/html)';
7402
- return result;
7403
- }
7404
-
7405
- // Required OG Tags according to Open Graph Protocol
7406
- const requiredOGTags = ['og:title', 'og:type', 'og:image', 'og:url'];
7407
- const optionalOGTags = ['og:description', 'og:site_name', 'og:locale', 'og:image:width', 'og:image:height', 'og:image:alt', 'og:image:secure_url', 'og:audio', 'og:video', 'og:determiner'];
7408
- const twitterCardTags = ['twitter:card', 'twitter:title', 'twitter:description', 'twitter:image', 'twitter:site', 'twitter:creator'];
7409
- const basicMetaTags = ['description', 'keywords', 'author', 'viewport', 'robots'];
7410
-
7411
- // All valid OG property names for typo detection
7412
- const allValidOGProperties = [
7413
- 'og:title', 'og:type', 'og:image', 'og:url', 'og:description', 'og:site_name',
7414
- 'og:locale', 'og:locale:alternate', 'og:audio', 'og:video', 'og:determiner',
7415
- 'og:image:url', 'og:image:secure_url', 'og:image:type', 'og:image:width', 'og:image:height', 'og:image:alt',
7416
- 'og:video:url', 'og:video:secure_url', 'og:video:type', 'og:video:width', 'og:video:height',
7417
- 'og:audio:url', 'og:audio:secure_url', 'og:audio:type',
7418
- // Article type
7419
- 'article:published_time', 'article:modified_time', 'article:expiration_time', 'article:author', 'article:section', 'article:tag',
7420
- // Profile type
7421
- 'profile:first_name', 'profile:last_name', 'profile:username', 'profile:gender',
7422
- // Book type
7423
- 'book:author', 'book:isbn', 'book:release_date', 'book:tag',
7424
- // Music type
7425
- 'music:duration', 'music:album', 'music:album:disc', 'music:album:track', 'music:musician', 'music:song', 'music:song:disc', 'music:song:track', 'music:release_date', 'music:creator',
7426
- // Video type
7427
- 'video:actor', 'video:actor:role', 'video:director', 'video:writer', 'video:duration', 'video:release_date', 'video:tag', 'video:series'
7428
- ];
7429
-
7430
- // All valid Twitter Card property names for typo detection
7431
- const allValidTwitterProperties = [
7432
- 'twitter:card', 'twitter:site', 'twitter:site:id', 'twitter:creator', 'twitter:creator:id',
7433
- 'twitter:title', 'twitter:description', 'twitter:image', 'twitter:image:alt',
7434
- // Player card
7435
- 'twitter:player', 'twitter:player:width', 'twitter:player:height', 'twitter:player:stream',
7436
- // App card
7437
- 'twitter:app:name:iphone', 'twitter:app:id:iphone', 'twitter:app:url:iphone',
7438
- 'twitter:app:name:ipad', 'twitter:app:id:ipad', 'twitter:app:url:ipad',
7439
- 'twitter:app:name:googleplay', 'twitter:app:id:googleplay', 'twitter:app:url:googleplay',
7440
- 'twitter:app:country'
7441
- ];
7700
+ const recommendationStyle = {
7701
+ font: { color: { argb: 'FF0070C0' } },
7702
+ fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDDEBF7' } }
7703
+ };
7442
7704
 
7443
- // Common typos mapping to correct property names
7444
- const typoCorrections = {
7445
- // og:site_name typos
7446
- 'og:sitename': 'og:site_name',
7447
- 'og:site-name': 'og:site_name',
7448
- 'og:site_nam': 'og:site_name',
7449
- 'og:sie_name': 'og:site_name',
7450
- 'og:site_nmae': 'og:site_name',
7451
- 'og:sitname': 'og:site_name',
7452
- 'og:stie_name': 'og:site_name',
7453
- // og:title typos
7454
- 'og:titel': 'og:title',
7455
- 'og:tittle': 'og:title',
7456
- 'og:tite': 'og:title',
7457
- 'og:tile': 'og:title',
7458
- // og:type typos
7459
- 'og:tpye': 'og:type',
7460
- 'og:tyep': 'og:type',
7461
- 'og:typ': 'og:type',
7462
- // og:image typos
7463
- 'og:imge': 'og:image',
7464
- 'og:iamge': 'og:image',
7465
- 'og:img': 'og:image',
7466
- 'og:imag': 'og:image',
7467
- 'og:imagee': 'og:image',
7468
- // og:url typos
7469
- 'og:rul': 'og:url',
7470
- 'og:uri': 'og:url',
7471
- 'og:ulr': 'og:url',
7472
- // og:description typos
7473
- 'og:desc': 'og:description',
7474
- 'og:descripton': 'og:description',
7475
- 'og:desciption': 'og:description',
7476
- 'og:descripion': 'og:description',
7477
- 'og:descriptoin': 'og:description',
7478
- // og:locale typos
7479
- 'og:local': 'og:locale',
7480
- 'og:locael': 'og:locale',
7481
- 'og:lcoale': 'og:locale',
7482
- // og:image dimensions typos
7483
- 'og:image_width': 'og:image:width',
7484
- 'og:image_height': 'og:image:height',
7485
- 'og:imagewidth': 'og:image:width',
7486
- 'og:imageheight': 'og:image:height',
7487
- 'og:image-width': 'og:image:width',
7488
- 'og:image-height': 'og:image:height',
7489
- // og:image:alt typos
7490
- 'og:image_alt': 'og:image:alt',
7491
- 'og:imagealt': 'og:image:alt',
7492
- 'og:image-alt': 'og:image:alt',
7493
- // og:video typos
7494
- 'og:vedio': 'og:video',
7495
- 'og:vidoe': 'og:video',
7496
- // og:audio typos
7497
- 'og:aduio': 'og:audio',
7498
- 'og:auido': 'og:audio',
7499
- // Twitter card typos
7500
- 'twitter:crad': 'twitter:card',
7501
- 'twitter:card_type': 'twitter:card',
7502
- 'twitter:titel': 'twitter:title',
7503
- 'twitter:tittle': 'twitter:title',
7504
- 'twitter:desc': 'twitter:description',
7505
- 'twitter:img': 'twitter:image',
7506
- 'twitter:imge': 'twitter:image',
7507
- 'twitter:sit': 'twitter:site',
7508
- 'twitter:autor': 'twitter:creator',
7509
- 'twitter:author': 'twitter:creator'
7705
+ const successStyle = {
7706
+ font: { color: { argb: 'FF006100' } },
7707
+ fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } }
7510
7708
  };
7709
+
7710
+ // ============ SUMMARY SHEET ============
7711
+ const summarySheet = workbook.addWorksheet('📋 Summary', {
7712
+ properties: { tabColor: { argb: 'FF4472C4' } }
7713
+ });
7511
7714
 
7512
- // Valid og:type values
7513
- const validOGTypes = [
7514
- 'website', 'article', 'book', 'profile', 'video.movie', 'video.episode',
7515
- 'video.tv_show', 'video.other', 'music.song', 'music.album', 'music.playlist',
7516
- 'music.radio_station', 'product', 'place', 'business.business'
7715
+ summarySheet.columns = [
7716
+ { header: 'Thống kê', key: 'stat', width: 35 },
7717
+ { header: 'Giá trị', key: 'value', width: 20 }
7517
7718
  ];
7518
7719
 
7519
- // Valid twitter:card values
7520
- const validTwitterCards = ['summary', 'summary_large_image', 'app', 'player'];
7521
-
7522
- // Extract all meta tags
7523
- const metaTagPattern = /<meta\s+([^>]+)>/gi;
7524
- let match;
7525
- const allMetaTags = [];
7526
-
7527
- while ((match = metaTagPattern.exec(content)) !== null) {
7528
- const attributes = match[1];
7529
- const tag = this.parseMetaTagAttributes(attributes);
7530
- tag.raw = match[0];
7531
- tag.index = match.index; // Track position for fixing
7532
- allMetaTags.push(tag);
7533
- }
7534
-
7535
- // Check for typos in OG and Twitter property names
7536
- allMetaTags.forEach(tag => {
7537
- const propertyName = tag.property || tag.name;
7538
- if (!propertyName) return;
7539
-
7540
- const propertyLower = propertyName.toLowerCase();
7541
-
7542
- // Check if it looks like an OG or Twitter tag
7543
- if (propertyLower.startsWith('og:') || propertyLower.startsWith('twitter:')) {
7544
- // Step 1: Check against known common typos first (fast lookup)
7545
- if (typoCorrections[propertyLower]) {
7546
- const correctProperty = typoCorrections[propertyLower];
7547
- result.typos.push({
7548
- found: propertyName,
7549
- expected: correctProperty,
7550
- raw: tag.raw,
7551
- content: tag.content,
7552
- isProperty: !!tag.property, // Was it using property="" or name=""?
7553
- index: tag.index
7554
- });
7555
-
7556
- result.issues.push({
7557
- type: 'Lỗi chính tả OG/Twitter',
7558
- description: `"${propertyName}" có vẻ là lỗi chính tả của "${correctProperty}"`,
7559
- suggestion: `Sửa thành: <meta property="${correctProperty}" content="${tag.content || '...'}">`
7560
- });
7561
- }
7562
- // Step 2: Check if property is not in valid list - find closest match
7563
- else {
7564
- let isValid = false;
7565
- let validList = [];
7566
-
7567
- if (propertyLower.startsWith('og:')) {
7568
- isValid = allValidOGProperties.includes(propertyLower);
7569
- validList = allValidOGProperties;
7570
- } else if (propertyLower.startsWith('twitter:')) {
7571
- isValid = allValidTwitterProperties.includes(propertyLower);
7572
- validList = allValidTwitterProperties;
7573
- }
7574
-
7575
- // If not valid, find closest match using Levenshtein distance
7576
- if (!isValid) {
7577
- const closestMatch = this.findClosestOGProperty(propertyLower, validList);
7578
-
7579
- // Use dynamic threshold based on property length
7580
- // Shorter properties need smaller distance, longer can have bigger
7581
- const maxDistance = Math.max(2, Math.floor(propertyLower.length * 0.3));
7582
-
7583
- if (closestMatch && closestMatch.distance <= maxDistance) {
7584
- result.typos.push({
7585
- found: propertyName,
7586
- expected: closestMatch.property,
7587
- raw: tag.raw,
7588
- content: tag.content,
7589
- isProperty: !!tag.property,
7590
- index: tag.index,
7591
- distance: closestMatch.distance
7592
- });
7593
-
7594
- // If very close (distance 1-2), treat as definite typo (error)
7595
- if (closestMatch.distance <= 2) {
7596
- result.issues.push({
7597
- type: 'Lỗi chính tả OG/Twitter',
7598
- description: `"${propertyName}" là lỗi chính tả của "${closestMatch.property}"`,
7599
- suggestion: `Sửa thành: <meta property="${closestMatch.property}" content="${tag.content || '...'}">`
7600
- });
7601
- } else {
7602
- // If further away (distance 3+), treat as warning (possible typo)
7603
- result.warnings.push({
7604
- type: 'Property không hợp lệ',
7605
- description: `"${propertyName}" không phải là property chuẩn. Có thể bạn muốn dùng "${closestMatch.property}"?`,
7606
- suggestion: `Sửa thành: <meta property="${closestMatch.property}" content="${tag.content || '...'}">`
7607
- });
7608
- }
7609
- } else {
7610
- // No close match found - report as unknown property
7611
- result.warnings.push({
7612
- type: 'Property không xác định',
7613
- description: `"${propertyName}" không phải là property OG/Twitter chuẩn`,
7614
- suggestion: `Kiểm tra tên property. Tham khảo: https://ogp.me/ hoặc https://developer.twitter.com/en/docs/twitter-for-websites/cards`
7615
- });
7616
- }
7617
- }
7618
- }
7619
- }
7620
- });
7621
-
7622
- // Check for basic meta tags
7623
- basicMetaTags.forEach(tagName => {
7624
- const tag = allMetaTags.find(t => t.name === tagName);
7625
- result.basicTags.push({
7626
- name: tagName,
7627
- present: !!tag,
7628
- value: tag?.content || null
7629
- });
7630
-
7631
- if (tag) {
7632
- result.hasBasicMeta = true;
7633
- }
7720
+ summarySheet.getRow(1).eachCell(cell => {
7721
+ cell.style = headerStyle;
7634
7722
  });
7635
-
7636
- // Check description meta tag
7637
- const descriptionTag = allMetaTags.find(t => t.name === 'description');
7638
- if (!descriptionTag) {
7639
- result.warnings.push({
7640
- type: 'Thiếu Meta Description',
7641
- description: 'Không tìm thấy thẻ meta description',
7642
- suggestion: 'Thêm <meta name="description" content="Mô tả trang web của bạn">'
7643
- });
7644
- } else if (!descriptionTag.content || descriptionTag.content.length < 50) {
7645
- result.warnings.push({
7646
- type: 'Meta Description ngắn',
7647
- description: `Meta description chỉ có ${descriptionTag.content?.length || 0} ký tự`,
7648
- suggestion: 'Meta description nên có độ dài 50-160 ký tự để tối ưu SEO'
7649
- });
7650
- } else if (descriptionTag.content.length > 160) {
7651
- result.warnings.push({
7652
- type: 'Meta Description dài',
7653
- description: `Meta description có ${descriptionTag.content.length} ký tự, vượt quá 160 ký tự`,
7654
- suggestion: 'Meta description nên có độ dài 50-160 ký tự để hiển thị đầy đủ trên Google'
7655
- });
7656
- }
7657
-
7658
- // Check viewport meta tag
7659
- const viewportTag = allMetaTags.find(t => t.name === 'viewport');
7660
- if (!viewportTag) {
7661
- result.warnings.push({
7662
- type: 'Thiếu Viewport',
7663
- description: 'Không tìm thấy thẻ meta viewport',
7664
- suggestion: 'Thêm <meta name="viewport" content="width=device-width, initial-scale=1.0">'
7665
- });
7666
- }
7667
-
7668
- // Check for Open Graph tags
7669
- requiredOGTags.forEach(tagName => {
7670
- // Check for correct syntax (property attribute, not name)
7671
- const correctTag = allMetaTags.find(t => t.property === tagName);
7672
- const wrongSyntaxTag = allMetaTags.find(t => t.name === tagName);
7723
+
7724
+ // ============ 1. META TAGS CHECK ============
7725
+ console.log(chalk.blue('🏷️ Đang kiểm tra Meta Tags...'));
7726
+ try {
7727
+ const metaResults = await this.checkMetaTagsForReport(directory);
7728
+ allResults.meta = metaResults;
7673
7729
 
7674
- const tagInfo = {
7675
- name: tagName,
7676
- present: !!correctTag,
7677
- value: correctTag?.content || null,
7678
- wrongSyntax: !!wrongSyntaxTag && !correctTag
7679
- };
7730
+ const metaSheet = workbook.addWorksheet('🏷️ Meta Tags');
7731
+ metaSheet.columns = [
7732
+ { header: 'File', key: 'file', width: 50 },
7733
+ { header: 'Loại', key: 'type', width: 15 },
7734
+ { header: 'Vấn đề', key: 'issue', width: 40 },
7735
+ { header: 'Mô tả', key: 'description', width: 50 },
7736
+ { header: 'Đề xuất', key: 'suggestion', width: 60 },
7737
+ { header: 'Mức độ', key: 'severity', width: 15 }
7738
+ ];
7680
7739
 
7681
- result.requiredTags.push(tagInfo);
7740
+ metaSheet.getRow(1).eachCell(cell => {
7741
+ cell.style = headerStyle;
7742
+ });
7682
7743
 
7683
- if (correctTag) {
7684
- result.hasOpenGraph = true;
7744
+ for (const result of metaResults) {
7745
+ if (result.status === 'skipped') continue;
7685
7746
 
7686
- // Validate content
7687
- if (!correctTag.content || correctTag.content.trim() === '') {
7688
- result.issues.push({
7689
- type: 'Giá trị rỗng',
7690
- description: `${tagName} không có giá trị`,
7691
- suggestion: `Thêm giá trị cho thuộc tính content của ${tagName}`
7692
- });
7693
- }
7747
+ const relativePath = path.relative(directory, result.file);
7694
7748
 
7695
- // Specific validations
7696
- if (tagName === 'og:type' && !validOGTypes.includes(correctTag.content?.toLowerCase())) {
7697
- result.warnings.push({
7698
- type: 'og:type không hợp lệ',
7699
- description: `Giá trị "${correctTag.content}" không phải là og:type chuẩn`,
7700
- suggestion: `Sử dụng một trong các giá trị: ${validOGTypes.slice(0, 5).join(', ')}...`
7749
+ for (const issue of (result.metaAnalysis?.issues || [])) {
7750
+ const row = metaSheet.addRow({
7751
+ file: relativePath,
7752
+ type: 'Error',
7753
+ issue: issue.type,
7754
+ description: issue.description,
7755
+ suggestion: issue.suggestion || '',
7756
+ severity: '❌ Lỗi'
7701
7757
  });
7758
+ row.getCell('severity').style = errorStyle;
7759
+ allResults.summary.totalIssues++;
7702
7760
  }
7703
7761
 
7704
- if (tagName === 'og:url') {
7705
- if (correctTag.content && !correctTag.content.match(/^https?:\/\//)) {
7706
- result.issues.push({
7707
- type: 'og:url không hợp lệ',
7708
- description: 'og:url phải là URL tuyệt đối (bắt đầu bằng http:// hoặc https://)',
7709
- suggestion: 'Sử dụng URL đầy đủ: https://example.com/path'
7710
- });
7711
- }
7712
- }
7713
-
7714
- if (tagName === 'og:image') {
7715
- if (correctTag.content && !correctTag.content.match(/^https?:\/\//)) {
7716
- result.warnings.push({
7717
- type: 'og:image không phải URL tuyệt đối',
7718
- description: 'og:image nên là URL tuyệt đối để hoạt động tốt trên các nền tảng',
7719
- suggestion: 'Sử dụng URL đầy đủ: https://example.com/images/og-image.jpg'
7720
- });
7721
- }
7762
+ for (const warning of (result.metaAnalysis?.warnings || [])) {
7763
+ const row = metaSheet.addRow({
7764
+ file: relativePath,
7765
+ type: 'Warning',
7766
+ issue: warning.type,
7767
+ description: warning.description,
7768
+ suggestion: warning.suggestion || '',
7769
+ severity: '⚠️ Cảnh báo'
7770
+ });
7771
+ row.getCell('severity').style = warningStyle;
7772
+ allResults.summary.totalWarnings++;
7722
7773
  }
7723
7774
 
7724
- if (tagName === 'og:title') {
7725
- if (correctTag.content && correctTag.content.length > 60) {
7726
- result.warnings.push({
7727
- type: 'og:title dài',
7728
- description: `og:title có ${correctTag.content.length} ký tự, nên dưới 60 ký tự`,
7729
- suggestion: 'Rút gọn tiêu đề để hiển thị đầy đủ trên các nền tảng'
7730
- });
7731
- }
7775
+ for (const rec of (result.metaAnalysis?.recommendations || [])) {
7776
+ const row = metaSheet.addRow({
7777
+ file: relativePath,
7778
+ type: 'Recommendation',
7779
+ issue: rec.type,
7780
+ description: rec.description,
7781
+ suggestion: rec.suggestion || '',
7782
+ severity: '💎 Khuyến nghị'
7783
+ });
7784
+ row.getCell('severity').style = recommendationStyle;
7785
+ allResults.summary.totalRecommendations++;
7732
7786
  }
7733
-
7734
- } else if (wrongSyntaxTag) {
7735
- // Wrong syntax detected - track for auto-fix
7736
- result.syntaxErrors.push({
7737
- tag: tagName,
7738
- found: `name="${tagName}"`,
7739
- expected: `property="${tagName}"`,
7740
- raw: wrongSyntaxTag.raw,
7741
- content: wrongSyntaxTag.content,
7742
- index: wrongSyntaxTag.index
7743
- });
7744
-
7745
- result.issues.push({
7746
- type: 'Sai cú pháp OG',
7747
- description: `${tagName} sử dụng name="" thay vì property=""`,
7748
- suggestion: `Thay đổi từ <meta name="${tagName}" ...> thành <meta property="${tagName}" ...>`
7749
- });
7750
- } else {
7751
- result.issues.push({
7752
- type: 'Thiếu thẻ OG bắt buộc',
7753
- description: `Thiếu ${tagName}`,
7754
- suggestion: `Thêm <meta property="${tagName}" content="...">`
7755
- });
7756
- }
7757
- });
7758
-
7759
- // Check optional OG tags
7760
- optionalOGTags.forEach(tagName => {
7761
- const tag = allMetaTags.find(t => t.property === tagName);
7762
- result.optionalTags.push({
7763
- name: tagName,
7764
- present: !!tag,
7765
- value: tag?.content || null
7766
- });
7767
- });
7768
-
7769
- // Check og:image dimensions - these are recommendations, not required
7770
- const ogImage = allMetaTags.find(t => t.property === 'og:image');
7771
- if (ogImage) {
7772
- const ogImageWidth = allMetaTags.find(t => t.property === 'og:image:width');
7773
- const ogImageHeight = allMetaTags.find(t => t.property === 'og:image:height');
7774
- const ogImageAlt = allMetaTags.find(t => t.property === 'og:image:alt');
7775
-
7776
- if (!ogImageWidth || !ogImageHeight) {
7777
- result.recommendations.push({
7778
- type: 'Kích thước og:image',
7779
- description: 'Cân nhắc thêm kích thước hình ảnh og:image',
7780
- suggestion: 'Thêm og:image:width và og:image:height để tối ưu hiển thị. Khuyến nghị: 1200x630 pixels'
7781
- });
7782
7787
  }
7783
7788
 
7784
- if (!ogImageAlt) {
7785
- result.recommendations.push({
7786
- type: 'og:image:alt',
7787
- description: 'Cân nhắc thêm alt text cho og:image',
7788
- suggestion: 'Thêm <meta property="og:image:alt" content="Mô tả hình ảnh"> để cải thiện accessibility'
7789
- });
7790
- }
7789
+ metaSheet.autoFilter = 'A1:F1';
7790
+ console.log(chalk.green(` ✅ Meta Tags: ${metaResults.filter(r => r.status === 'analyzed').length} file`));
7791
+ } catch (error) {
7792
+ console.log(chalk.yellow(` ⚠️ Meta Tags: ${error.message}`));
7791
7793
  }
7792
-
7793
- // Check for Twitter Card tags
7794
- twitterCardTags.forEach(tagName => {
7795
- const tag = allMetaTags.find(t => t.name === tagName || t.property === tagName);
7796
- result.twitterTags.push({
7797
- name: tagName,
7798
- present: !!tag,
7799
- value: tag?.content || null
7794
+
7795
+ // ============ 2. ACCESSIBILITY CHECK ============
7796
+ console.log(chalk.blue('♿ Đang kiểm tra Accessibility (Alt, Aria)...'));
7797
+ try {
7798
+ const accessResults = await this.checkAccessibilityForReport(directory);
7799
+ allResults.accessibility = accessResults;
7800
+
7801
+ const accessSheet = workbook.addWorksheet('♿ Accessibility');
7802
+ accessSheet.columns = [
7803
+ { header: 'File', key: 'file', width: 50 },
7804
+ { header: 'Loại lỗi', key: 'type', width: 20 },
7805
+ { header: 'Mô tả', key: 'description', width: 60 },
7806
+ { header: 'Element', key: 'element', width: 40 },
7807
+ { header: 'Mức độ', key: 'severity', width: 15 }
7808
+ ];
7809
+
7810
+ accessSheet.getRow(1).eachCell(cell => {
7811
+ cell.style = headerStyle;
7800
7812
  });
7801
7813
 
7802
- if (tag) {
7803
- result.hasTwitterCard = true;
7804
-
7805
- // Validate twitter:card value
7806
- if (tagName === 'twitter:card' && !validTwitterCards.includes(tag.content?.toLowerCase())) {
7807
- result.issues.push({
7808
- type: 'twitter:card không hợp lệ',
7809
- description: `Giá trị "${tag.content}" không phải là twitter:card hợp lệ`,
7810
- suggestion: `Sử dụng một trong các giá trị: ${validTwitterCards.join(', ')}`
7814
+ for (const result of accessResults) {
7815
+ const relativePath = path.relative(directory, result.file);
7816
+ for (const issue of (result.issues || [])) {
7817
+ const row = accessSheet.addRow({
7818
+ file: relativePath,
7819
+ type: issue.type,
7820
+ description: issue.description,
7821
+ element: issue.element || '',
7822
+ severity: ' Lỗi'
7811
7823
  });
7824
+ row.getCell('severity').style = errorStyle;
7825
+ allResults.summary.totalIssues++;
7812
7826
  }
7813
7827
  }
7814
- });
7815
-
7816
- // Suggest Twitter Card if OG exists but Twitter Card doesn't
7817
- if (result.hasOpenGraph && !result.hasTwitterCard) {
7818
- result.warnings.push({
7819
- type: 'Thiếu Twitter Card',
7820
- description: 'Đã có Open Graph nhưng chưa có Twitter Card',
7821
- suggestion: 'Thêm Twitter Card để tối ưu hiển thị trên Twitter. Ví dụ: <meta name="twitter:card" content="summary_large_image">'
7822
- });
7823
- }
7824
-
7825
- // Check for canonical URL - this is a recommendation, not required
7826
- const canonicalPattern = /<link\s+[^>]*rel\s*=\s*["']canonical["'][^>]*>/i;
7827
- const canonicalMatch = content.match(canonicalPattern);
7828
- if (!canonicalMatch) {
7829
- result.recommendations.push({
7830
- type: 'Canonical URL',
7831
- description: 'Chưa có thẻ link rel="canonical"',
7832
- suggestion: 'Cân nhắc thêm <link rel="canonical" href="..."> để tránh duplicate content'
7833
- });
7834
- }
7835
-
7836
- // Check title tag
7837
- const titlePattern = /<title[^>]*>([^<]*)<\/title>/i;
7838
- const titleMatch = content.match(titlePattern);
7839
- if (!titleMatch || !titleMatch[1] || titleMatch[1].trim() === '') {
7840
- result.issues.push({
7841
- type: 'Thiếu hoặc rỗng Title',
7842
- description: 'Thẻ <title> không có hoặc rỗng',
7843
- suggestion: 'Thêm tiêu đề cho trang: <title>Tiêu đề trang</title>'
7844
- });
7845
- } else if (titleMatch[1].length > 60) {
7846
- result.warnings.push({
7847
- type: 'Title dài',
7848
- description: `Title có ${titleMatch[1].length} ký tự, nên dưới 60 ký tự`,
7849
- suggestion: 'Rút gọn tiêu đề để hiển thị đầy đủ trên Google'
7850
- });
7851
- }
7852
-
7853
- // Check consistency between title and og:title
7854
- const ogTitle = allMetaTags.find(t => t.property === 'og:title');
7855
- if (titleMatch && ogTitle && titleMatch[1].trim() && ogTitle.content) {
7856
- if (titleMatch[1].trim().toLowerCase() !== ogTitle.content.toLowerCase()) {
7857
- // This is actually okay, just noting it
7858
- // Different titles for different purposes can be intentional
7859
- }
7860
- }
7861
-
7862
- return result;
7863
- }
7864
-
7865
- // Find closest matching OG property using Levenshtein distance
7866
- findClosestOGProperty(typo, validProperties) {
7867
- let minDistance = Infinity;
7868
- let closestProperty = null;
7869
-
7870
- for (const property of validProperties) {
7871
- const distance = this.levenshteinDistance(typo, property);
7872
- if (distance < minDistance) {
7873
- minDistance = distance;
7874
- closestProperty = property;
7875
- }
7876
- }
7877
-
7878
- return { property: closestProperty, distance: minDistance };
7879
- }
7880
-
7881
- // Levenshtein distance algorithm for typo detection
7882
- levenshteinDistance(str1, str2) {
7883
- const m = str1.length;
7884
- const n = str2.length;
7885
-
7886
- // Create a matrix
7887
- const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
7888
-
7889
- // Initialize first row and column
7890
- for (let i = 0; i <= m; i++) dp[i][0] = i;
7891
- for (let j = 0; j <= n; j++) dp[0][j] = j;
7892
-
7893
- // Fill the matrix
7894
- for (let i = 1; i <= m; i++) {
7895
- for (let j = 1; j <= n; j++) {
7896
- if (str1[i - 1] === str2[j - 1]) {
7897
- dp[i][j] = dp[i - 1][j - 1];
7898
- } else {
7899
- dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
7900
- }
7901
- }
7828
+
7829
+ accessSheet.autoFilter = 'A1:E1';
7830
+ console.log(chalk.green(` ✅ Accessibility: ${accessResults.length} file vấn đề`));
7831
+ } catch (error) {
7832
+ console.log(chalk.yellow(` ⚠️ Accessibility: ${error.message}`));
7902
7833
  }
7903
-
7904
- return dp[m][n];
7905
- }
7906
7834
 
7907
- // Auto-fix Meta Tags
7908
- async fixMetaTags(directory = '.') {
7909
- console.log(chalk.blue('🔧 Đang sửa Meta Tags và Open Graph Protocol...'));
7910
-
7911
- const htmlFiles = await this.findHtmlFiles(directory);
7912
- const results = [];
7913
-
7914
- for (const file of htmlFiles) {
7915
- try {
7916
- let content = await fs.readFile(file, 'utf8');
7917
- const originalContent = content;
7918
- const metaAnalysis = this.analyzeMetaTags(content, file);
7919
-
7920
- // Skip SSI include files
7921
- if (metaAnalysis.isIncludeFile) {
7922
- results.push({
7923
- file,
7924
- status: 'skipped',
7925
- reason: 'SSI include file',
7926
- fixes: 0
7927
- });
7928
- continue;
7929
- }
7930
-
7931
- let fixCount = 0;
7932
- const fixes = [];
7933
-
7934
- // Fix typos in OG/Twitter property names
7935
- if (metaAnalysis.typos.length > 0) {
7936
- for (const typo of metaAnalysis.typos) {
7937
- const attribute = typo.isProperty ? 'property' : 'name';
7938
- // Create the corrected meta tag
7939
- const correctedTag = `<meta property="${typo.expected}" content="${typo.content || ''}">`;
7940
-
7941
- // Replace the typo with the correct tag
7942
- content = content.replace(typo.raw, correctedTag);
7943
- fixCount++;
7944
- fixes.push({
7945
- type: 'Sửa lỗi chính tả',
7946
- from: typo.found,
7947
- to: typo.expected
7948
- });
7949
- }
7950
- }
7951
-
7952
- // Fix syntax errors (name="" -> property="" for OG tags)
7953
- if (metaAnalysis.syntaxErrors.length > 0) {
7954
- for (const error of metaAnalysis.syntaxErrors) {
7955
- // Create the corrected meta tag with property instead of name
7956
- const correctedTag = `<meta property="${error.tag}" content="${error.content || ''}">`;
7957
-
7958
- // Replace the wrong syntax with the correct one
7959
- content = content.replace(error.raw, correctedTag);
7960
- fixCount++;
7961
- fixes.push({
7962
- type: 'Sửa cú pháp OG',
7963
- from: `name="${error.tag}"`,
7964
- to: `property="${error.tag}"`
7965
- });
7966
- }
7967
- }
7968
-
7969
- // Save the file if changes were made
7970
- if (fixCount > 0 && content !== originalContent) {
7971
- if (!this.config.dryRun) {
7972
- if (this.config.backupFiles) {
7973
- await fs.writeFile(file + '.backup', originalContent);
7974
- }
7975
- await fs.writeFile(file, content);
7976
- }
7977
-
7978
- console.log(chalk.cyan(`\n📁 ${path.relative(directory, file)}:`));
7979
- console.log(chalk.green(` ✅ Đã sửa ${fixCount} vấn đề:`));
7980
- fixes.forEach(fix => {
7981
- console.log(chalk.gray(` - ${fix.type}: ${fix.from} → ${fix.to}`));
7982
- });
7983
-
7984
- results.push({
7985
- file,
7986
- status: 'fixed',
7987
- fixes: fixCount,
7988
- details: fixes
7989
- });
7990
- } else if (fixCount === 0 && (metaAnalysis.typos.length > 0 || metaAnalysis.syntaxErrors.length > 0)) {
7991
- results.push({
7992
- file,
7993
- status: 'no-changes',
7994
- fixes: 0
7995
- });
7996
- } else {
7997
- results.push({
7998
- file,
7999
- status: 'ok',
8000
- fixes: 0
7835
+ // ============ 3. FORMS CHECK ============
7836
+ console.log(chalk.blue('📋 Đang kiểm tra Form Labels...'));
7837
+ try {
7838
+ const formsResults = await this.checkFormsForReport(directory);
7839
+ allResults.forms = formsResults;
7840
+
7841
+ const formsSheet = workbook.addWorksheet('📋 Forms');
7842
+ formsSheet.columns = [
7843
+ { header: 'File', key: 'file', width: 50 },
7844
+ { header: 'Loại lỗi', key: 'type', width: 25 },
7845
+ { header: 'Mô tả', key: 'description', width: 60 },
7846
+ { header: 'Element', key: 'element', width: 40 },
7847
+ { header: 'Mức độ', key: 'severity', width: 15 }
7848
+ ];
7849
+
7850
+ formsSheet.getRow(1).eachCell(cell => {
7851
+ cell.style = headerStyle;
7852
+ });
7853
+
7854
+ for (const result of formsResults) {
7855
+ const relativePath = path.relative(directory, result.file);
7856
+ for (const issue of (result.issues || [])) {
7857
+ const row = formsSheet.addRow({
7858
+ file: relativePath,
7859
+ type: issue.type,
7860
+ description: issue.description,
7861
+ element: issue.element || '',
7862
+ severity: '❌ Lỗi'
8001
7863
  });
7864
+ row.getCell('severity').style = errorStyle;
7865
+ allResults.summary.totalIssues++;
8002
7866
  }
8003
-
8004
- } catch (error) {
8005
- console.error(chalk.red(`❌ Lỗi khi xử lý ${file}: ${error.message}`));
8006
- results.push({ file, status: 'error', error: error.message });
8007
7867
  }
7868
+
7869
+ formsSheet.autoFilter = 'A1:E1';
7870
+ console.log(chalk.green(` ✅ Forms: ${formsResults.length} file có vấn đề`));
7871
+ } catch (error) {
7872
+ console.log(chalk.yellow(` ⚠️ Forms: ${error.message}`));
8008
7873
  }
8009
-
8010
- const filesFixed = results.filter(r => r.status === 'fixed').length;
8011
- const totalFixes = results.reduce((sum, r) => sum + (r.fixes || 0), 0);
8012
-
8013
- console.log(chalk.blue(`\n📊 Tóm tắt: Đã xử lý ${results.length} file`));
8014
- if (filesFixed > 0) {
8015
- console.log(chalk.green(` ✅ Đã sửa: ${filesFixed} file (${totalFixes} vấn đề)`));
8016
- } else {
8017
- console.log(chalk.green(` ✅ Không có lỗi cú pháp hoặc chính tả cần sửa`));
8018
- }
8019
-
8020
- if (this.config.dryRun) {
8021
- console.log(chalk.cyan('💡 Đây là chế độ xem trước. Sử dụng không có --dry-run để áp dụng thay đổi.'));
8022
- }
8023
-
8024
- return results;
8025
- }
8026
-
8027
- parseMetaTagAttributes(attributeString) {
8028
- const result = {};
8029
-
8030
- // Parse name attribute
8031
- const nameMatch = attributeString.match(/name\s*=\s*["']([^"']+)["']/i);
8032
- if (nameMatch) {
8033
- result.name = nameMatch[1].toLowerCase();
8034
- }
8035
-
8036
- // Parse property attribute (for Open Graph)
8037
- const propertyMatch = attributeString.match(/property\s*=\s*["']([^"']+)["']/i);
8038
- if (propertyMatch) {
8039
- result.property = propertyMatch[1].toLowerCase();
8040
- }
8041
-
8042
- // Parse content attribute
8043
- const contentMatch = attributeString.match(/content\s*=\s*["']([^"']*)["']/i);
8044
- if (contentMatch) {
8045
- result.content = contentMatch[1];
8046
- }
8047
-
8048
- // Parse charset attribute
8049
- const charsetMatch = attributeString.match(/charset\s*=\s*["']?([^"'\s>]+)["']?/i);
8050
- if (charsetMatch) {
8051
- result.charset = charsetMatch[1];
8052
- }
8053
-
8054
- // Parse http-equiv attribute
8055
- const httpEquivMatch = attributeString.match(/http-equiv\s*=\s*["']([^"']+)["']/i);
8056
- if (httpEquivMatch) {
8057
- result.httpEquiv = httpEquivMatch[1];
8058
- }
8059
-
8060
- return result;
8061
- }
8062
7874
 
8063
- /**
8064
- * Generate Full Report - Tạo báo cáo Excel toàn diện
8065
- * Bao gồm tất cả các loại kiểm tra: Meta, Headings, Links, Unused Files, Dead Code, File Size, GTM
8066
- */
8067
- async generateFullReport(directory = '.', outputPath = null) {
8068
- const ExcelJS = require('exceljs');
8069
- const startTime = Date.now();
8070
-
8071
- console.log(chalk.blue('📊 Đang tạo báo cáo toàn diện...'));
8072
- console.log(chalk.gray(`Thư mục: ${path.resolve(directory)}`));
8073
- console.log('');
8074
-
8075
- // Initialize workbook
8076
- const workbook = new ExcelJS.Workbook();
8077
- workbook.creator = 'GBU Accessibility Tool';
8078
- workbook.created = new Date();
8079
-
8080
- // Store all results
8081
- const allResults = {
8082
- meta: [],
8083
- headings: [],
8084
- brokenLinks: [],
8085
- missingResources: [],
8086
- unusedFiles: [],
8087
- deadCode: [],
8088
- fileSize: [],
8089
- gtm: [],
8090
- summary: {
8091
- totalFiles: 0,
8092
- totalIssues: 0,
8093
- totalWarnings: 0,
8094
- totalRecommendations: 0
8095
- }
8096
- };
8097
-
8098
- // Style definitions
8099
- const headerStyle = {
8100
- font: { bold: true, color: { argb: 'FFFFFFFF' } },
8101
- fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } },
8102
- alignment: { horizontal: 'center', vertical: 'middle' },
8103
- border: {
8104
- top: { style: 'thin' },
8105
- left: { style: 'thin' },
8106
- bottom: { style: 'thin' },
8107
- right: { style: 'thin' }
8108
- }
8109
- };
8110
-
8111
- const errorStyle = {
8112
- font: { color: { argb: 'FFFF0000' } },
8113
- fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFC7CE' } }
8114
- };
8115
-
8116
- const warningStyle = {
8117
- font: { color: { argb: 'FF9C5700' } },
8118
- fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFEB9C' } }
8119
- };
8120
-
8121
- const recommendationStyle = {
8122
- font: { color: { argb: 'FF0070C0' } },
8123
- fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDDEBF7' } }
8124
- };
8125
-
8126
- const successStyle = {
8127
- font: { color: { argb: 'FF006100' } },
8128
- fill: { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC6EFCE' } }
8129
- };
8130
-
8131
- // Create Summary sheet FIRST so it appears as the first tab
8132
- const summarySheet = workbook.addWorksheet('📋 Summary', {
8133
- properties: { tabColor: { argb: 'FF4472C4' } }
8134
- });
8135
-
8136
- // ============ 1. META TAGS CHECK ============
8137
- console.log(chalk.blue('🏷️ Đang kiểm tra Meta Tags...'));
7875
+ // ============ 4. BUTTONS CHECK ============
7876
+ console.log(chalk.blue('🔘 Đang kiểm tra Button Names...'));
8138
7877
  try {
8139
- const metaResults = await this.checkMetaTagsForReport(directory);
8140
- allResults.meta = metaResults;
7878
+ const buttonsResults = await this.checkButtonsForReport(directory);
7879
+ allResults.buttons = buttonsResults;
8141
7880
 
8142
- const metaSheet = workbook.addWorksheet('🏷️ Meta Tags');
8143
- metaSheet.columns = [
8144
- { header: 'File', key: 'file', width: 50 },
8145
- { header: 'Loại', key: 'type', width: 15 },
8146
- { header: 'Vấn đề', key: 'issue', width: 40 },
8147
- { header: 'Mô tả', key: 'description', width: 50 },
8148
- { header: 'Đề xuất', key: 'suggestion', width: 60 },
7881
+ const buttonsSheet = workbook.addWorksheet('🔘 Buttons');
7882
+ buttonsSheet.columns = [
7883
+ { header: 'File', key: 'file', width: 50 },
7884
+ { header: 'Loại lỗi', key: 'type', width: 25 },
7885
+ { header: ' tả', key: 'description', width: 60 },
7886
+ { header: 'Element', key: 'element', width: 40 },
8149
7887
  { header: 'Mức độ', key: 'severity', width: 15 }
8150
7888
  ];
8151
7889
 
8152
- // Apply header style
8153
- metaSheet.getRow(1).eachCell(cell => {
7890
+ buttonsSheet.getRow(1).eachCell(cell => {
8154
7891
  cell.style = headerStyle;
8155
7892
  });
8156
7893
 
8157
- let metaRowNum = 2;
8158
- for (const result of metaResults) {
8159
- if (result.status === 'skipped') continue;
8160
-
7894
+ for (const result of buttonsResults) {
8161
7895
  const relativePath = path.relative(directory, result.file);
8162
-
8163
- // Add issues
8164
- for (const issue of (result.metaAnalysis?.issues || [])) {
8165
- const row = metaSheet.addRow({
7896
+ for (const issue of (result.issues || [])) {
7897
+ const row = buttonsSheet.addRow({
8166
7898
  file: relativePath,
8167
- type: 'Error',
8168
- issue: issue.type,
7899
+ type: issue.type,
8169
7900
  description: issue.description,
8170
- suggestion: issue.suggestion || '',
7901
+ element: issue.element || '',
8171
7902
  severity: '❌ Lỗi'
8172
7903
  });
8173
7904
  row.getCell('severity').style = errorStyle;
8174
7905
  allResults.summary.totalIssues++;
8175
7906
  }
8176
-
8177
- // Add warnings
8178
- for (const warning of (result.metaAnalysis?.warnings || [])) {
8179
- const row = metaSheet.addRow({
8180
- file: relativePath,
8181
- type: 'Warning',
8182
- issue: warning.type,
8183
- description: warning.description,
8184
- suggestion: warning.suggestion || '',
8185
- severity: '⚠️ Cảnh báo'
8186
- });
8187
- row.getCell('severity').style = warningStyle;
8188
- allResults.summary.totalWarnings++;
8189
- }
8190
-
8191
- // Add recommendations
8192
- for (const rec of (result.metaAnalysis?.recommendations || [])) {
8193
- const row = metaSheet.addRow({
8194
- file: relativePath,
8195
- type: 'Recommendation',
8196
- issue: rec.type,
8197
- description: rec.description,
8198
- suggestion: rec.suggestion || '',
8199
- severity: '💎 Khuyến nghị'
8200
- });
8201
- row.getCell('severity').style = recommendationStyle;
8202
- allResults.summary.totalRecommendations++;
8203
- }
8204
7907
  }
8205
7908
 
8206
- metaSheet.autoFilter = 'A1:F1';
8207
- console.log(chalk.green(` ✅ Meta Tags: ${metaResults.filter(r => r.status === 'analyzed').length} file`));
7909
+ buttonsSheet.autoFilter = 'A1:E1';
7910
+ console.log(chalk.green(` ✅ Buttons: ${buttonsResults.length} file vấn đề`));
8208
7911
  } catch (error) {
8209
- console.log(chalk.yellow(` ⚠️ Meta Tags: ${error.message}`));
7912
+ console.log(chalk.yellow(` ⚠️ Buttons: ${error.message}`));
8210
7913
  }
8211
7914
 
8212
- // ============ 2. HEADINGS CHECK ============
7915
+ // ============ 5. HEADINGS CHECK ============
8213
7916
  console.log(chalk.blue('📑 Đang kiểm tra Heading Structure...'));
8214
7917
  try {
8215
7918
  const headingsResults = await this.analyzeHeadingsForReport(directory);
@@ -8254,7 +7957,7 @@ class AccessibilityFixer {
8254
7957
  console.log(chalk.yellow(` ⚠️ Headings: ${error.message}`));
8255
7958
  }
8256
7959
 
8257
- // ============ 3. BROKEN LINKS CHECK ============
7960
+ // ============ 6. BROKEN LINKS CHECK ============
8258
7961
  console.log(chalk.blue('🔗 Đang kiểm tra Broken Links...'));
8259
7962
  try {
8260
7963
  const linksResults = await this.checkBrokenLinksForReport(directory);
@@ -8304,7 +8007,7 @@ class AccessibilityFixer {
8304
8007
  console.log(chalk.yellow(` ⚠️ Broken Links: ${error.message}`));
8305
8008
  }
8306
8009
 
8307
- // ============ 4. UNUSED FILES CHECK ============
8010
+ // ============ 7. UNUSED FILES CHECK ============
8308
8011
  console.log(chalk.blue('📁 Đang kiểm tra Unused Files...'));
8309
8012
  try {
8310
8013
  const unusedResults = await this.checkUnusedFilesForReport(directory);
@@ -8323,10 +8026,14 @@ class AccessibilityFixer {
8323
8026
  });
8324
8027
 
8325
8028
  for (const file of unusedResults) {
8029
+ const filePath = typeof file === 'string' ? file : file.path;
8030
+ const fileType = (typeof file === 'object' && file.type) ? file.type : path.extname(filePath).slice(1).toUpperCase();
8031
+ const fileSize = (typeof file === 'object' && file.size) ? this.formatFileSize(file.size) : '-';
8032
+
8326
8033
  const row = unusedSheet.addRow({
8327
- file: file.path || file,
8328
- type: file.type || path.extname(file).slice(1).toUpperCase(),
8329
- size: file.size ? this.formatFileSize(file.size) : '-',
8034
+ file: filePath,
8035
+ type: fileType,
8036
+ size: fileSize,
8330
8037
  note: 'Không được tham chiếu trong project'
8331
8038
  });
8332
8039
  row.getCell('note').style = warningStyle;
@@ -8339,50 +8046,13 @@ class AccessibilityFixer {
8339
8046
  console.log(chalk.yellow(` ⚠️ Unused Files: ${error.message}`));
8340
8047
  }
8341
8048
 
8342
- // ============ 5. DEAD CODE CHECK ============
8343
- console.log(chalk.blue('💀 Đang kiểm tra Dead Code...'));
8344
- try {
8345
- const deadCodeResults = await this.checkDeadCodeForReport(directory);
8346
- allResults.deadCode = deadCodeResults;
8347
-
8348
- const deadCodeSheet = workbook.addWorksheet('💀 Dead Code');
8349
- deadCodeSheet.columns = [
8350
- { header: 'File', key: 'file', width: 50 },
8351
- { header: 'Loại', key: 'type', width: 15 },
8352
- { header: 'Selector/Function', key: 'selector', width: 40 },
8353
- { header: 'Dòng', key: 'line', width: 10 },
8354
- { header: 'Ghi chú', key: 'note', width: 40 }
8355
- ];
8356
-
8357
- deadCodeSheet.getRow(1).eachCell(cell => {
8358
- cell.style = headerStyle;
8359
- });
8360
-
8361
- for (const item of deadCodeResults) {
8362
- const row = deadCodeSheet.addRow({
8363
- file: item.file || '',
8364
- type: item.type || 'CSS',
8365
- selector: item.selector || item.name || '',
8366
- line: item.line || '-',
8367
- note: 'Không được sử dụng'
8368
- });
8369
- row.getCell('note').style = warningStyle;
8370
- allResults.summary.totalWarnings++;
8371
- }
8372
-
8373
- deadCodeSheet.autoFilter = 'A1:E1';
8374
- console.log(chalk.green(` ✅ Dead Code: ${deadCodeResults.length} item`));
8375
- } catch (error) {
8376
- console.log(chalk.yellow(` ⚠️ Dead Code: ${error.message}`));
8377
- }
8378
-
8379
- // ============ 6. FILE SIZE CHECK ============
8380
- console.log(chalk.blue('📦 Đang kiểm tra File Size...'));
8049
+ // ============ 8. FILE SIZE CHECK ============
8050
+ console.log(chalk.blue('📦 Đang kiểm tra File Size (>1MB)...'));
8381
8051
  try {
8382
8052
  const fileSizeResults = await this.checkFileSizeForReport(directory);
8383
8053
  allResults.fileSize = fileSizeResults;
8384
8054
 
8385
- const sizeSheet = workbook.addWorksheet('📦 File Size');
8055
+ const sizeSheet = workbook.addWorksheet('📦 Large Files');
8386
8056
  sizeSheet.columns = [
8387
8057
  { header: 'File', key: 'file', width: 50 },
8388
8058
  { header: 'Loại', key: 'type', width: 15 },
@@ -8408,12 +8078,12 @@ class AccessibilityFixer {
8408
8078
  }
8409
8079
 
8410
8080
  sizeSheet.autoFilter = 'A1:E1';
8411
- console.log(chalk.green(` ✅ File Size: ${fileSizeResults.length} file`));
8081
+ console.log(chalk.green(` ✅ Large Files: ${fileSizeResults.length} file`));
8412
8082
  } catch (error) {
8413
8083
  console.log(chalk.yellow(` ⚠️ File Size: ${error.message}`));
8414
8084
  }
8415
8085
 
8416
- // ============ 7. GTM CHECK ============
8086
+ // ============ 9. GTM CHECK ============
8417
8087
  console.log(chalk.blue('🏷️ Đang kiểm tra Google Tag Manager...'));
8418
8088
  try {
8419
8089
  const gtmResults = await this.checkGTMForReport(directory);
@@ -8425,8 +8095,8 @@ class AccessibilityFixer {
8425
8095
  { header: 'Trạng thái', key: 'status', width: 20 },
8426
8096
  { header: 'Head Script', key: 'head', width: 15 },
8427
8097
  { header: 'Body Noscript', key: 'body', width: 15 },
8428
- { header: 'Container ID', key: 'containerId', width: 20 },
8429
- { header: 'Vấn đề', key: 'issues', width: 40 }
8098
+ { header: 'Container ID', key: 'containerId', width: 30 },
8099
+ { header: 'Vấn đề', key: 'issues', width: 50 }
8430
8100
  ];
8431
8101
 
8432
8102
  gtmSheet.getRow(1).eachCell(cell => {
@@ -8456,16 +8126,7 @@ class AccessibilityFixer {
8456
8126
  console.log(chalk.yellow(` ⚠️ GTM: ${error.message}`));
8457
8127
  }
8458
8128
 
8459
- // ============ 8. SUMMARY SHEET - Populate data ============
8460
- summarySheet.columns = [
8461
- { header: 'Thống kê', key: 'stat', width: 35 },
8462
- { header: 'Giá trị', key: 'value', width: 20 }
8463
- ];
8464
-
8465
- summarySheet.getRow(1).eachCell(cell => {
8466
- cell.style = headerStyle;
8467
- });
8468
-
8129
+ // ============ FILL SUMMARY DATA ============
8469
8130
  const endTime = Date.now();
8470
8131
  const duration = ((endTime - startTime) / 1000).toFixed(2);
8471
8132
 
@@ -8475,13 +8136,15 @@ class AccessibilityFixer {
8475
8136
  { stat: '⏱️ Thời gian xử lý', value: `${duration} giây` },
8476
8137
  { stat: '', value: '' },
8477
8138
  { stat: '🏷️ Meta Tags được phân tích', value: allResults.meta.filter(r => r.status === 'analyzed').length },
8139
+ { stat: '♿ Accessibility Issues (Alt & Aria)', value: allResults.accessibility.length },
8140
+ { stat: '📋 Form Issues', value: allResults.forms.length },
8141
+ { stat: '🔘 Button Issues', value: allResults.buttons.length },
8478
8142
  { stat: '📑 Heading được phân tích', value: allResults.headings.length },
8479
- { stat: '🔗 Broken Links', value: allResults.brokenLinks.length },
8143
+ { stat: '🔗 Broken External Links', value: allResults.brokenLinks.length },
8480
8144
  { stat: '📁 Missing Resources (404)', value: allResults.missingResources.length },
8481
8145
  { stat: '📁 Unused Files', value: allResults.unusedFiles.length },
8482
- { stat: '💀 Dead Code', value: allResults.deadCode.length },
8483
- { stat: '📦 Large Files', value: allResults.fileSize.length },
8484
- { stat: '🏷️ GTM Files', value: allResults.gtm.length },
8146
+ { stat: '📦 Large Files (>1MB)', value: allResults.fileSize.length },
8147
+ { stat: '🏷️ GTM Issues', value: allResults.gtm.length },
8485
8148
  { stat: '', value: '' },
8486
8149
  { stat: '❌ Tổng số lỗi (Errors)', value: allResults.summary.totalIssues },
8487
8150
  { stat: '⚠️ Tổng số cảnh báo (Warnings)', value: allResults.summary.totalWarnings },
@@ -8544,6 +8207,337 @@ class AccessibilityFixer {
8544
8207
  return results;
8545
8208
  }
8546
8209
 
8210
+ analyzeMetaTags(content, filePath) {
8211
+ const relativePath = path.relative(process.cwd(), filePath);
8212
+
8213
+ // Skip include files (SSI, partials)
8214
+ const isIncludeFile = /(?:include|partial|component|ssi|header|footer|nav|sidebar)/i.test(relativePath);
8215
+
8216
+ if (isIncludeFile) {
8217
+ return { isIncludeFile: true, errors: [], fixable: [] };
8218
+ }
8219
+
8220
+ const errors = []; // Lỗi cần sửa
8221
+ const fixable = []; // Các lỗi có thể tự động sửa
8222
+
8223
+ // Dictionary of common typos and corrections
8224
+ const propertyTypos = {
8225
+ // Open Graph typos
8226
+ 'og:titel': 'og:title',
8227
+ 'og:tittle': 'og:title',
8228
+ 'og:tilte': 'og:title',
8229
+ 'og:discription': 'og:description',
8230
+ 'og:descripion': 'og:description',
8231
+ 'og:desciption': 'og:description',
8232
+ 'og:imge': 'og:image',
8233
+ 'og:iamge': 'og:image',
8234
+ 'og:typ': 'og:type',
8235
+ 'og:tipe': 'og:type',
8236
+ 'og:sit_name': 'og:site_name',
8237
+ 'og:sitename': 'og:site_name',
8238
+ 'og:local': 'og:locale',
8239
+
8240
+ // Twitter typos
8241
+ 'twitter:car': 'twitter:card',
8242
+ 'twitter:titel': 'twitter:title',
8243
+ 'twitter:tittle': 'twitter:title',
8244
+ 'twitter:discription': 'twitter:description',
8245
+ 'twitter:descripion': 'twitter:description',
8246
+ 'twitter:imge': 'twitter:image',
8247
+ 'twitter:iamge': 'twitter:image',
8248
+ 'twitter:sit': 'twitter:site',
8249
+ 'twitter:creater': 'twitter:creator',
8250
+
8251
+ // Description typos
8252
+ 'discription': 'description',
8253
+ 'descripion': 'description',
8254
+ 'desciption': 'description',
8255
+
8256
+ // Viewport typos
8257
+ 'viewpor': 'viewport',
8258
+ 'veiwport': 'viewport',
8259
+
8260
+ // Keywords typos
8261
+ 'keyword': 'keywords',
8262
+ 'keywrods': 'keywords',
8263
+ 'keyowrds': 'keywords',
8264
+
8265
+ // Author typos
8266
+ 'auther': 'author',
8267
+ 'autor': 'author'
8268
+ };
8269
+
8270
+ const contentTypos = {
8271
+ // Common og:type values
8272
+ 'websit': 'website',
8273
+ 'web-site': 'website',
8274
+ 'artical': 'article',
8275
+ 'aticle': 'article',
8276
+
8277
+ // Twitter card types
8278
+ 'summary_larg_image': 'summary_large_image',
8279
+ 'summary-large-image': 'summary_large_image',
8280
+ 'summay': 'summary',
8281
+
8282
+ // Locale typos
8283
+ 'ja_jp': 'ja_JP',
8284
+ 'en_us': 'en_US',
8285
+ 'en-us': 'en_US',
8286
+ 'vi_vn': 'vi_VN'
8287
+ };
8288
+
8289
+ // Extract all meta tags with their full content
8290
+ const metaPattern = /<meta\s+([^>]+)>/gi;
8291
+ const metaTags = [];
8292
+ let match;
8293
+
8294
+ while ((match = metaPattern.exec(content)) !== null) {
8295
+ const fullTag = match[0];
8296
+ const attrs = match[1];
8297
+ const nameMatch = attrs.match(/(?:name|property)\s*=\s*["']([^"']+)["']/i);
8298
+ const contentMatch = attrs.match(/content\s*=\s*["']([^"']*)["']/i);
8299
+
8300
+ if (nameMatch) {
8301
+ metaTags.push({
8302
+ fullTag,
8303
+ property: nameMatch[1],
8304
+ content: contentMatch ? contentMatch[1] : '',
8305
+ position: match.index
8306
+ });
8307
+ }
8308
+ }
8309
+
8310
+ // Check for typos in property names
8311
+ for (const tag of metaTags) {
8312
+ const property = tag.property;
8313
+ const content = tag.content;
8314
+
8315
+ // Check property typo
8316
+ if (propertyTypos[property.toLowerCase()]) {
8317
+ const correct = propertyTypos[property.toLowerCase()];
8318
+ errors.push(`Lỗi chính tả property: "${property}" → "${correct}"`);
8319
+ fixable.push({
8320
+ type: 'property',
8321
+ wrong: property,
8322
+ correct: correct,
8323
+ fullTag: tag.fullTag
8324
+ });
8325
+ }
8326
+
8327
+ // Check content typo for specific properties
8328
+ const normalizedProperty = propertyTypos[property.toLowerCase()] || property;
8329
+
8330
+ if (normalizedProperty === 'og:type') {
8331
+ if (contentTypos[content.toLowerCase()]) {
8332
+ const correct = contentTypos[content.toLowerCase()];
8333
+ errors.push(`Lỗi giá trị og:type: "${content}" → "${correct}"`);
8334
+ fixable.push({
8335
+ type: 'content',
8336
+ property: property,
8337
+ wrong: content,
8338
+ correct: correct,
8339
+ fullTag: tag.fullTag
8340
+ });
8341
+ }
8342
+ }
8343
+
8344
+ if (normalizedProperty === 'twitter:card') {
8345
+ if (contentTypos[content.toLowerCase()]) {
8346
+ const correct = contentTypos[content.toLowerCase()];
8347
+ errors.push(`Lỗi giá trị twitter:card: "${content}" → "${correct}"`);
8348
+ fixable.push({
8349
+ type: 'content',
8350
+ property: property,
8351
+ wrong: content,
8352
+ correct: correct,
8353
+ fullTag: tag.fullTag
8354
+ });
8355
+ }
8356
+ }
8357
+
8358
+ if (normalizedProperty === 'og:locale') {
8359
+ // Check exact match first, then lowercase
8360
+ if (contentTypos[content]) {
8361
+ const correct = contentTypos[content];
8362
+ errors.push(`Lỗi giá trị og:locale: "${content}" → "${correct}"`);
8363
+ fixable.push({
8364
+ type: 'content',
8365
+ property: property,
8366
+ wrong: content,
8367
+ correct: correct,
8368
+ fullTag: tag.fullTag
8369
+ });
8370
+ } else if (contentTypos[content.toLowerCase()] && content !== contentTypos[content.toLowerCase()]) {
8371
+ const correct = contentTypos[content.toLowerCase()];
8372
+ errors.push(`Lỗi giá trị og:locale: "${content}" → "${correct}"`);
8373
+ fixable.push({
8374
+ type: 'content',
8375
+ property: property,
8376
+ wrong: content,
8377
+ correct: correct,
8378
+ fullTag: tag.fullTag
8379
+ });
8380
+ }
8381
+ }
8382
+
8383
+ // Check for syntax errors
8384
+ // 1. Missing content attribute
8385
+ if (!content && !property.startsWith('charset')) {
8386
+ errors.push(`Meta tag "${property}" thiếu thuộc tính content`);
8387
+ }
8388
+
8389
+ // 2. Empty content
8390
+ if (content === '' && ['og:title', 'og:description', 'twitter:title', 'twitter:description', 'description'].includes(property)) {
8391
+ errors.push(`Meta tag "${property}" có content rỗng`);
8392
+ }
8393
+
8394
+ // 3. Wrong quotes (mixed single/double)
8395
+ if (tag.fullTag.match(/name=['"]/) && tag.fullTag.match(/content=["'][^"']*['"]/) &&
8396
+ ((tag.fullTag.includes('name="') && tag.fullTag.includes("content='")) ||
8397
+ (tag.fullTag.includes("name='") && tag.fullTag.includes('content="')))) {
8398
+ errors.push(`Meta tag "${property}" sử dụng lẫn lộn dấu ngoặc đơn/kép`);
8399
+ }
8400
+ }
8401
+
8402
+ return {
8403
+ isIncludeFile: false,
8404
+ errors,
8405
+ fixable,
8406
+ metaTags,
8407
+ hasErrors: errors.length > 0
8408
+ };
8409
+ }
8410
+
8411
+ async checkAccessibilityForReport(directory) {
8412
+ const htmlFiles = await this.findHtmlFiles(directory);
8413
+ const results = [];
8414
+
8415
+ for (const file of htmlFiles) {
8416
+ try {
8417
+ const content = await fs.readFile(file, 'utf8');
8418
+ const issues = [];
8419
+
8420
+ // 1. Check Images (Alt text)
8421
+ const imgPattern = /<img\s+[^>]*>/gi;
8422
+ let match;
8423
+ while ((match = imgPattern.exec(content)) !== null) {
8424
+ const imgTag = match[0];
8425
+ const altMatch = imgTag.match(/alt=["']([^"']*)["']/i);
8426
+
8427
+ if (!altMatch) {
8428
+ issues.push({ type: 'Missing Alt', description: 'Thẻ img thiếu thuộc tính alt', element: imgTag });
8429
+ } else if (altMatch[1].trim() === '') {
8430
+ // Empty alt is ONLY okay for decorative images with role="presentation" or aria-hidden="true"
8431
+ const isDecorative = imgTag.includes('role="presentation"') || imgTag.includes('aria-hidden="true"');
8432
+ if (!isDecorative) {
8433
+ issues.push({ type: 'Empty Alt', description: 'Alt text bị bỏ trống (không phải ảnh trang trí)', element: imgTag });
8434
+ }
8435
+ }
8436
+ }
8437
+
8438
+ // 2. Check Aria Labels - always report empty aria-label as error
8439
+ const ariaPattern = /<([a-z][a-z0-9]*)\s+[^>]*aria-label=["']([^"']*)["'][^>]*>/gi;
8440
+ while ((match = ariaPattern.exec(content)) !== null) {
8441
+ const ariaValue = match[2].trim();
8442
+ if (ariaValue === '') {
8443
+ issues.push({ type: 'Empty Aria-Label', description: 'Thuộc tính aria-label bị bỏ trống', element: `<${match[1]} aria-label=""...>` });
8444
+ }
8445
+ }
8446
+
8447
+ if (issues.length > 0) {
8448
+ results.push({ file, issues });
8449
+ }
8450
+ } catch (error) {
8451
+ // Skip
8452
+ }
8453
+ }
8454
+ return results;
8455
+ }
8456
+
8457
+ async checkFormsForReport(directory) {
8458
+ const htmlFiles = await this.findHtmlFiles(directory);
8459
+ const results = [];
8460
+
8461
+ for (const file of htmlFiles) {
8462
+ try {
8463
+ const content = await fs.readFile(file, 'utf8');
8464
+ const issues = [];
8465
+
8466
+ // Check Forms (Labels)
8467
+ const inputPattern = /<input\s+[^>]*>/gi;
8468
+ let match;
8469
+ while ((match = inputPattern.exec(content)) !== null) {
8470
+ const inputTag = match[0];
8471
+ const typeMatch = inputTag.match(/type=["']([^"']*)["']/i);
8472
+ const type = typeMatch ? typeMatch[1].toLowerCase() : 'text';
8473
+
8474
+ if (['text', 'password', 'email', 'search', 'tel', 'url', 'number', 'date'].includes(type)) {
8475
+ const hasId = inputTag.match(/id=["']([^"']*)["']/i);
8476
+ const hasAriaLabel = inputTag.includes('aria-label');
8477
+ const hasAriaLabelledBy = inputTag.includes('aria-labelledby');
8478
+
8479
+ if (!hasAriaLabel && !hasAriaLabelledBy) {
8480
+ let hasLabel = false;
8481
+ if (hasId) {
8482
+ const id = hasId[1];
8483
+ const labelPattern = new RegExp(`<label\\s+[^>]*for=["']${id}["']`, 'i');
8484
+ if (labelPattern.test(content)) {
8485
+ hasLabel = true;
8486
+ }
8487
+ }
8488
+
8489
+ if (!hasLabel) {
8490
+ issues.push({ type: 'Missing Form Label', description: `Input type="${type}" thiếu label hoặc aria-label`, element: inputTag });
8491
+ }
8492
+ }
8493
+ }
8494
+ }
8495
+
8496
+ if (issues.length > 0) {
8497
+ results.push({ file, issues });
8498
+ }
8499
+ } catch (error) {
8500
+ // Skip
8501
+ }
8502
+ }
8503
+ return results;
8504
+ }
8505
+
8506
+ async checkButtonsForReport(directory) {
8507
+ const htmlFiles = await this.findHtmlFiles(directory);
8508
+ const results = [];
8509
+
8510
+ for (const file of htmlFiles) {
8511
+ try {
8512
+ const content = await fs.readFile(file, 'utf8');
8513
+ const issues = [];
8514
+
8515
+ // Check Buttons (Name/Content)
8516
+ const buttonPattern = /<button\s*([^>]*)>([\s\S]*?)<\/button>/gi;
8517
+ let match;
8518
+ while ((match = buttonPattern.exec(content)) !== null) {
8519
+ const attrs = match[1];
8520
+ const innerContent = match[2].trim();
8521
+ const hasAriaLabel = attrs.includes('aria-label');
8522
+ const hasAriaLabelledBy = attrs.includes('aria-labelledby');
8523
+
8524
+ if (innerContent === '' && !hasAriaLabel && !hasAriaLabelledBy) {
8525
+ if (!innerContent.match(/<img\s+[^>]*alt=["'][^"']+["']/i)) {
8526
+ issues.push({ type: 'Empty Button', description: 'Button rỗng không có text hoặc aria-label', element: `<button ${attrs}>...` });
8527
+ }
8528
+ }
8529
+ }
8530
+
8531
+ if (issues.length > 0) {
8532
+ results.push({ file, issues });
8533
+ }
8534
+ } catch (error) {
8535
+ // Skip
8536
+ }
8537
+ }
8538
+ return results;
8539
+ }
8540
+
8547
8541
  async analyzeHeadingsForReport(directory) {
8548
8542
  const htmlFiles = await this.findHtmlFiles(directory);
8549
8543
  const results = [];
@@ -8616,52 +8610,97 @@ class AccessibilityFixer {
8616
8610
  }
8617
8611
 
8618
8612
  async checkBrokenLinksForReport(directory) {
8619
- return { brokenLinks: [], missingResources: [] };
8620
- // Simplified - actual implementation would check links
8613
+ const htmlFiles = await this.findHtmlFiles(directory);
8614
+ const missingResources = [];
8615
+
8616
+ for (const file of htmlFiles) {
8617
+ try {
8618
+ const content = await fs.readFile(file, 'utf8');
8619
+ const fileDir = path.dirname(file);
8620
+
8621
+ // Check src attributes (images, scripts)
8622
+ const srcPattern = /src=["']([^"']+)["']/gi;
8623
+ let match;
8624
+ while ((match = srcPattern.exec(content)) !== null) {
8625
+ const src = match[1];
8626
+ // Skip external links, data URIs, and template variables
8627
+ if (src.startsWith('http') || src.startsWith('//') || src.startsWith('data:') || src.includes('{{') || src.includes('<%')) continue;
8628
+
8629
+ const cleanSrc = src.split('?')[0].split('#')[0];
8630
+ // Handle absolute paths (starting with /) - resolve from project root
8631
+ const absolutePath = cleanSrc.startsWith('/')
8632
+ ? path.join(directory, cleanSrc)
8633
+ : path.resolve(fileDir, cleanSrc);
8634
+
8635
+ try {
8636
+ await fs.access(absolutePath);
8637
+ } catch {
8638
+ missingResources.push({
8639
+ source: path.relative(directory, file),
8640
+ path: src,
8641
+ type: 'Missing Resource'
8642
+ });
8643
+ }
8644
+ }
8645
+
8646
+ // Check href attributes (css, links) - only check local files
8647
+ const hrefPattern = /href=["']([^"']+)["']/gi;
8648
+ while ((match = hrefPattern.exec(content)) !== null) {
8649
+ const href = match[1];
8650
+ // Skip external, anchors, javascript, mailto, tel
8651
+ if (href.startsWith('http') || href.startsWith('//') || href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:') || href.startsWith('tel:')) continue;
8652
+
8653
+ const cleanHref = href.split('?')[0].split('#')[0];
8654
+ // Handle absolute paths (starting with /) - resolve from project root
8655
+ const absolutePath = cleanHref.startsWith('/')
8656
+ ? path.join(directory, cleanHref)
8657
+ : path.resolve(fileDir, cleanHref);
8658
+
8659
+ try {
8660
+ await fs.access(absolutePath);
8661
+ } catch {
8662
+ missingResources.push({
8663
+ source: path.relative(directory, file),
8664
+ path: href,
8665
+ type: 'Broken Local Link'
8666
+ });
8667
+ }
8668
+ }
8669
+ } catch (error) {
8670
+ // Skip
8671
+ }
8672
+ }
8673
+
8674
+ return { brokenLinks: [], missingResources };
8621
8675
  }
8622
8676
 
8623
8677
  async checkUnusedFilesForReport(directory) {
8624
- return [];
8625
- // Simplified - actual implementation would check unused files
8626
- }
8627
-
8628
- async checkDeadCodeForReport(directory) {
8629
- return [];
8630
- // Simplified - actual implementation would check dead code
8678
+ // Use the same logic as checkUnusedFiles for consistency
8679
+ const result = await this.checkUnusedFiles(directory, { returnDetails: true });
8680
+ return result.unusedFiles || [];
8631
8681
  }
8632
8682
 
8633
8683
  async checkFileSizeForReport(directory) {
8634
8684
  const results = [];
8635
- const thresholds = {
8636
- '.jpg': 200 * 1024,
8637
- '.jpeg': 200 * 1024,
8638
- '.png': 200 * 1024,
8639
- '.gif': 500 * 1024,
8640
- '.svg': 50 * 1024,
8641
- '.css': 100 * 1024,
8642
- '.js': 200 * 1024,
8643
- '.html': 100 * 1024
8644
- };
8685
+ const ONE_MB = 1024 * 1024;
8645
8686
 
8646
8687
  try {
8647
8688
  const files = await this.findAllFiles(directory);
8648
8689
  for (const file of files) {
8649
8690
  try {
8650
8691
  const stats = await fs.stat(file);
8651
- const ext = path.extname(file).toLowerCase();
8652
- const threshold = thresholds[ext];
8653
-
8654
- if (threshold && stats.size > threshold) {
8692
+ if (stats.size > ONE_MB) {
8693
+ const ext = path.extname(file).toLowerCase();
8655
8694
  results.push({
8656
8695
  file: path.relative(directory, file),
8657
8696
  type: ext.slice(1).toUpperCase(),
8658
8697
  size: stats.size,
8659
- threshold: threshold,
8698
+ threshold: ONE_MB,
8660
8699
  suggestion: this.getFileSizeSuggestion(ext)
8661
8700
  });
8662
8701
  }
8663
8702
  } catch (e) {
8664
- // Skip files that can't be read
8703
+ // Skip
8665
8704
  }
8666
8705
  }
8667
8706
  } catch (error) {
@@ -8680,9 +8719,11 @@ class AccessibilityFixer {
8680
8719
  '.svg': 'Tối ưu SVG với SVGO',
8681
8720
  '.css': 'Minify CSS và loại bỏ code không dùng',
8682
8721
  '.js': 'Minify JS, tree-shaking và code splitting',
8683
- '.html': 'Minify HTML và loại bỏ comments'
8722
+ '.html': 'Minify HTML và loại bỏ comments',
8723
+ '.mp4': 'Nén video hoặc dùng dịch vụ streaming',
8724
+ '.pdf': 'Nén PDF'
8684
8725
  };
8685
- return suggestions[ext] || 'Cân nhắc tối ưu hóa file';
8726
+ return suggestions[ext] || 'File > 1MB. Cân nhắc tối ưu hóa hoặc lazy loading.';
8686
8727
  }
8687
8728
 
8688
8729
  async checkGTMForReport(directory) {
@@ -8693,34 +8734,55 @@ class AccessibilityFixer {
8693
8734
  try {
8694
8735
  const content = await fs.readFile(file, 'utf8');
8695
8736
 
8696
- // Check for DOCTYPE (skip partials)
8697
8737
  if (!/<html/i.test(content)) continue;
8698
8738
 
8699
- const gtmHeadPattern = /<!--\s*Google Tag Manager\s*-->[\s\S]*?<script[\s\S]*?gtm\.js[\s\S]*?<\/script>[\s\S]*?<!--\s*End Google Tag Manager\s*-->/i;
8700
- const gtmBodyPattern = /<!--\s*Google Tag Manager \(noscript\)\s*-->[\s\S]*?<noscript>[\s\S]*?googletagmanager\.com[\s\S]*?<\/noscript>[\s\S]*?<!--\s*End Google Tag Manager \(noscript\)\s*-->/i;
8701
- const gtmIdPattern = /GTM-[A-Z0-9]+/i;
8739
+ // Find all GTM IDs
8740
+ const gtmIdPattern = /GTM-[A-Z0-9]+/g;
8741
+ const matches = content.match(gtmIdPattern) || [];
8742
+ const uniqueIds = [...new Set(matches)];
8743
+
8744
+ // Count GTM head scripts
8745
+ const gtmHeadPattern = /<script[\s\S]*?gtm\.js[\s\S]*?<\/script>/gi;
8746
+ const headScripts = content.match(gtmHeadPattern) || [];
8747
+ const headScriptCount = headScripts.filter(script => script.includes('googletagmanager.com/gtm.js')).length;
8748
+
8749
+ // Count GTM body noscripts
8750
+ const gtmBodyPattern = /<noscript>[\s\S]*?googletagmanager\.com\/ns\.html[\s\S]*?<\/noscript>/gi;
8751
+ const bodyNoscripts = content.match(gtmBodyPattern) || [];
8752
+ const bodyNoscriptCount = bodyNoscripts.length;
8702
8753
 
8703
- const hasHeadScript = gtmHeadPattern.test(content);
8704
- const hasBodyNoscript = gtmBodyPattern.test(content);
8705
- const containerId = content.match(gtmIdPattern)?.[0] || null;
8706
- const hasGTM = hasHeadScript || hasBodyNoscript;
8754
+ const hasGTM = uniqueIds.length > 0;
8755
+ const hasHeadScript = headScriptCount > 0;
8756
+ const hasBodyNoscript = bodyNoscriptCount > 0;
8707
8757
 
8708
8758
  const issues = [];
8709
- if (hasHeadScript && !hasBodyNoscript) {
8710
- issues.push('Thiếu noscript trong body');
8711
- }
8712
- if (!hasHeadScript && hasBodyNoscript) {
8713
- issues.push('Thiếu script trong head');
8759
+
8760
+ // Check for missing or mismatched parts
8761
+ if (hasGTM) {
8762
+ if (!hasHeadScript) {
8763
+ issues.push('Thiếu GTM script trong <head>');
8764
+ } else if (headScriptCount !== uniqueIds.length) {
8765
+ issues.push(`Số lượng GTM script trong <head> (${headScriptCount}) không khớp với số GTM ID (${uniqueIds.length})`);
8766
+ }
8767
+
8768
+ if (!hasBodyNoscript) {
8769
+ issues.push('Thiếu GTM noscript trong <body>');
8770
+ } else if (bodyNoscriptCount !== uniqueIds.length) {
8771
+ issues.push(`Số lượng GTM noscript trong <body> (${bodyNoscriptCount}) không khớp với số GTM ID (${uniqueIds.length})`);
8772
+ }
8714
8773
  }
8715
8774
 
8716
- results.push({
8717
- file: path.relative(directory, file),
8718
- hasGTM,
8719
- hasHeadScript,
8720
- hasBodyNoscript,
8721
- containerId,
8722
- issues
8723
- });
8775
+ // Only add to results if there's an issue or no GTM
8776
+ if (!hasGTM || issues.length > 0) {
8777
+ results.push({
8778
+ file: path.relative(directory, file),
8779
+ hasGTM,
8780
+ hasHeadScript,
8781
+ hasBodyNoscript,
8782
+ containerId: uniqueIds.length > 0 ? `${uniqueIds.join(', ')} (${uniqueIds.length} GTM)` : '-',
8783
+ issues
8784
+ });
8785
+ }
8724
8786
  } catch (error) {
8725
8787
  // Skip files with errors
8726
8788
  }
@@ -8751,12 +8813,6 @@ class AccessibilityFixer {
8751
8813
  await walk(directory);
8752
8814
  return files;
8753
8815
  }
8754
-
8755
- formatFileSize(bytes) {
8756
- if (bytes < 1024) return bytes + ' B';
8757
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
8758
- return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
8759
- }
8760
8816
  }
8761
8817
 
8762
- module.exports = AccessibilityFixer;
8818
+ module.exports = AccessibilityFixer;