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/CHANGELOG.md +8 -78
- package/README-vi.md +151 -149
- package/README.md +150 -1
- package/cli.js +96 -66
- package/lib/fixer.js +1107 -1051
- package/package.json +6 -13
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*["']([^"']
|
|
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
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
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
|
-
|
|
6153
|
-
|
|
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(
|
|
6166
|
-
if (
|
|
6167
|
-
references.push(
|
|
6546
|
+
references.push(normalizedUrl);
|
|
6547
|
+
if (normalizedUrl.startsWith('/')) {
|
|
6548
|
+
references.push(normalizedUrl.substring(1)); // Remove leading slash
|
|
6168
6549
|
}
|
|
6169
|
-
if (!
|
|
6170
|
-
references.push('./' +
|
|
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
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
!
|
|
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 (
|
|
7172
|
-
const resolved = this.resolveFilePath(
|
|
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
|
-
|
|
7250
|
-
|
|
7251
|
-
|
|
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
|
-
|
|
7254
|
-
|
|
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
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
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
|
-
|
|
7335
|
-
const
|
|
7336
|
-
|
|
7337
|
-
|
|
7338
|
-
|
|
7339
|
-
|
|
7340
|
-
|
|
7341
|
-
|
|
7342
|
-
|
|
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
|
-
|
|
7345
|
-
|
|
7346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
|
|
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
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
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
|
-
|
|
7513
|
-
|
|
7514
|
-
|
|
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
|
-
|
|
7520
|
-
|
|
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
|
-
//
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
|
|
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
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
7678
|
-
|
|
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
|
-
|
|
7740
|
+
metaSheet.getRow(1).eachCell(cell => {
|
|
7741
|
+
cell.style = headerStyle;
|
|
7742
|
+
});
|
|
7682
7743
|
|
|
7683
|
-
|
|
7684
|
-
result.
|
|
7744
|
+
for (const result of metaResults) {
|
|
7745
|
+
if (result.status === 'skipped') continue;
|
|
7685
7746
|
|
|
7686
|
-
|
|
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
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
type: '
|
|
7699
|
-
|
|
7700
|
-
|
|
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
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
7712
|
-
|
|
7713
|
-
|
|
7714
|
-
|
|
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
|
-
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
|
|
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
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
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
|
-
//
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
7797
|
-
|
|
7798
|
-
|
|
7799
|
-
|
|
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
|
-
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
7809
|
-
|
|
7810
|
-
|
|
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
|
-
|
|
7817
|
-
|
|
7818
|
-
|
|
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 có 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
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
7923
|
-
|
|
7924
|
-
|
|
7925
|
-
|
|
7926
|
-
|
|
7927
|
-
|
|
7928
|
-
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
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
|
-
|
|
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
|
|
8140
|
-
allResults.
|
|
7878
|
+
const buttonsResults = await this.checkButtonsForReport(directory);
|
|
7879
|
+
allResults.buttons = buttonsResults;
|
|
8141
7880
|
|
|
8142
|
-
const
|
|
8143
|
-
|
|
8144
|
-
{ header: 'File', key: 'file', width: 50 },
|
|
8145
|
-
{ header: 'Loại', key: 'type', width:
|
|
8146
|
-
{ header: '
|
|
8147
|
-
{ header: '
|
|
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: 'Mô 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
|
-
|
|
8153
|
-
metaSheet.getRow(1).eachCell(cell => {
|
|
7890
|
+
buttonsSheet.getRow(1).eachCell(cell => {
|
|
8154
7891
|
cell.style = headerStyle;
|
|
8155
7892
|
});
|
|
8156
7893
|
|
|
8157
|
-
|
|
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
|
-
|
|
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:
|
|
8168
|
-
issue: issue.type,
|
|
7899
|
+
type: issue.type,
|
|
8169
7900
|
description: issue.description,
|
|
8170
|
-
|
|
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
|
-
|
|
8207
|
-
console.log(chalk.green(` ✅
|
|
7909
|
+
buttonsSheet.autoFilter = 'A1:E1';
|
|
7910
|
+
console.log(chalk.green(` ✅ Buttons: ${buttonsResults.length} file có vấn đề`));
|
|
8208
7911
|
} catch (error) {
|
|
8209
|
-
console.log(chalk.yellow(` ⚠️
|
|
7912
|
+
console.log(chalk.yellow(` ⚠️ Buttons: ${error.message}`));
|
|
8210
7913
|
}
|
|
8211
7914
|
|
|
8212
|
-
// ============
|
|
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
|
-
// ============
|
|
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
|
-
// ============
|
|
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:
|
|
8328
|
-
type:
|
|
8329
|
-
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
|
-
// ============
|
|
8343
|
-
console.log(chalk.blue('
|
|
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('📦
|
|
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(` ✅
|
|
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
|
-
// ============
|
|
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:
|
|
8429
|
-
{ header: 'Vấn đề', key: 'issues', width:
|
|
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
|
-
// ============
|
|
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: '
|
|
8483
|
-
{ stat: '
|
|
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
|
-
|
|
8620
|
-
|
|
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
|
-
|
|
8625
|
-
|
|
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
|
|
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
|
-
|
|
8652
|
-
|
|
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:
|
|
8698
|
+
threshold: ONE_MB,
|
|
8660
8699
|
suggestion: this.getFileSizeSuggestion(ext)
|
|
8661
8700
|
});
|
|
8662
8701
|
}
|
|
8663
8702
|
} catch (e) {
|
|
8664
|
-
// Skip
|
|
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
|
|
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
|
-
|
|
8700
|
-
const
|
|
8701
|
-
const
|
|
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
|
|
8704
|
-
const
|
|
8705
|
-
const
|
|
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
|
-
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
|
|
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
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
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;
|