gbu-accessibility-package
Version:
Comprehensive accessibility fixes and project optimization for HTML files. Smart context-aware alt text generation, form labels, button names, link names, landmarks, heading analysis, WCAG-compliant role attributes, unused files detection, dead code analy
1,566 lines (1,279 loc) • 257 kB
JavaScript
/**
* Accessibility Fixer
* Automated fixes for common accessibility issues
*/
const fs = require('fs').promises;
const path = require('path');
const chalk = require('chalk');
/**
* Enhanced Alt Text Generator
* Tạo alt text thông minh và đa dạng dựa trên AI và ngữ cảnh
*/
class EnhancedAltGenerator {
constructor(config = {}) {
this.config = {
language: config.language || 'ja',
creativity: config.creativity || 'balanced', // conservative, balanced, creative
includeEmotions: config.includeEmotions || false,
includeBrandContext: config.includeBrandContext || true,
maxLength: config.maxLength || 125,
...config
};
// Từ điển đa ngôn ngữ
this.vocabulary = this.initializeVocabulary();
}
initializeVocabulary() {
return {
ja: {
types: {
person: ['人物', '人', '男性', '女性', '子供', '大人'],
object: ['物', '商品', 'アイテム', '製品'],
nature: ['自然', '風景', '景色', '環境'],
building: ['建物', '建築', '構造物', '施設'],
food: ['食べ物', '料理', '食品', 'グルメ'],
technology: ['技術', 'テクノロジー', '機器', 'デバイス'],
art: ['芸術', 'アート', '作品', 'デザイン'],
vehicle: ['乗り物', '車両', '交通手段']
},
emotions: {
positive: ['明るい', '楽しい', '美しい', '素晴らしい', '魅力的な'],
neutral: ['シンプルな', '清潔な', '整然とした', 'プロフェッショナルな'],
dynamic: ['活気のある', 'エネルギッシュな', 'ダイナミックな', '力強い']
},
actions: {
showing: ['示している', '表示している', '見せている'],
working: ['作業している', '働いている', '取り組んでいる'],
enjoying: ['楽しんでいる', '満喫している', '味わっている'],
creating: ['作成している', '制作している', '開発している']
},
contexts: {
business: ['ビジネス', '企業', '会社', '職場'],
education: ['教育', '学習', '研修', 'トレーニング'],
lifestyle: ['ライフスタイル', '日常', '生活', '暮らし'],
technology: ['IT', 'デジタル', 'オンライン', 'ウェブ']
}
},
en: {
types: {
person: ['person', 'people', 'individual', 'team', 'group'],
object: ['object', 'item', 'product', 'tool', 'equipment'],
nature: ['nature', 'landscape', 'scenery', 'environment'],
building: ['building', 'architecture', 'structure', 'facility'],
food: ['food', 'cuisine', 'dish', 'meal', 'delicacy'],
technology: ['technology', 'device', 'gadget', 'equipment'],
art: ['art', 'artwork', 'design', 'creation'],
vehicle: ['vehicle', 'transportation', 'automobile']
},
emotions: {
positive: ['bright', 'cheerful', 'beautiful', 'wonderful', 'attractive'],
neutral: ['simple', 'clean', 'organized', 'professional'],
dynamic: ['vibrant', 'energetic', 'dynamic', 'powerful']
},
actions: {
showing: ['showing', 'displaying', 'presenting'],
working: ['working', 'operating', 'engaging'],
enjoying: ['enjoying', 'experiencing', 'savoring'],
creating: ['creating', 'developing', 'building']
},
contexts: {
business: ['business', 'corporate', 'company', 'workplace'],
education: ['education', 'learning', 'training', 'academic'],
lifestyle: ['lifestyle', 'daily life', 'personal', 'casual'],
technology: ['technology', 'digital', 'online', 'web']
}
},
vi: {
types: {
person: ['người', 'con người', 'cá nhân', 'nhóm', 'đội ngũ'],
object: ['vật', 'đồ vật', 'sản phẩm', 'công cụ', 'thiết bị'],
nature: ['thiên nhiên', 'phong cảnh', 'cảnh quan', 'môi trường'],
building: ['tòa nhà', 'kiến trúc', 'công trình', 'cơ sở'],
food: ['thức ăn', 'món ăn', 'ẩm thực', 'đặc sản'],
technology: ['công nghệ', 'thiết bị', 'máy móc', 'kỹ thuật'],
art: ['nghệ thuật', 'tác phẩm', 'thiết kế', 'sáng tạo'],
vehicle: ['phương tiện', 'xe cộ', 'giao thông']
},
emotions: {
positive: ['tươi sáng', 'vui vẻ', 'đẹp đẽ', 'tuyệt vời', 'hấp dẫn'],
neutral: ['đơn giản', 'sạch sẽ', 'ngăn nắp', 'chuyên nghiệp'],
dynamic: ['sôi động', 'năng động', 'mạnh mẽ', 'đầy năng lượng']
},
actions: {
showing: ['đang hiển thị', 'đang trình bày', 'đang thể hiện'],
working: ['đang làm việc', 'đang hoạt động', 'đang thực hiện'],
enjoying: ['đang thưởng thức', 'đang tận hưởng', 'đang trải nghiệm'],
creating: ['đang tạo ra', 'đang phát triển', 'đang xây dựng']
},
contexts: {
business: ['kinh doanh', 'doanh nghiệp', 'công ty', 'nơi làm việc'],
education: ['giáo dục', 'học tập', 'đào tạo', 'học thuật'],
lifestyle: ['lối sống', 'cuộc sống', 'cá nhân', 'thường ngày'],
technology: ['công nghệ', 'số hóa', 'trực tuyến', 'web']
}
}
};
}
generateDiverseAltText(imgTag, htmlContent, analysis) {
const strategies = [
() => this.generateContextualAlt(analysis),
() => this.generateSemanticAlt(analysis),
() => this.generateEmotionalAlt(analysis),
() => this.generateActionBasedAlt(analysis),
() => this.generateBrandAwareAlt(analysis),
() => this.generateTechnicalAlt(analysis)
];
const selectedStrategies = this.selectStrategies(strategies, analysis);
for (const strategy of selectedStrategies) {
const result = strategy();
if (result && this.validateAltText(result)) {
return this.refineAltText(result, analysis);
}
}
return this.generateFallbackAlt(analysis);
}
generateContextualAlt(analysis) {
const { context, structural, imageType } = analysis;
if (structural.figcaption) {
return this.enhanceWithVocabulary(structural.figcaption, imageType);
}
if (structural.parentLink) {
const linkText = this.extractLinkText(structural.parentLink);
if (linkText) {
return this.createLinkAlt(linkText, imageType);
}
}
const contextElements = this.extractContextElements(context);
if (contextElements.nearbyHeading) {
return this.createHeadingBasedAlt(contextElements.nearbyHeading, imageType);
}
if (contextElements.surroundingText) {
return this.createTextBasedAlt(contextElements.surroundingText, imageType);
}
return null;
}
generateSemanticAlt(analysis) {
const { src, imageType, context } = analysis;
const lang = this.config.language;
const vocab = this.vocabulary[lang];
const semanticInfo = this.analyzeSemanticContent(src, context);
if (!semanticInfo.mainSubject) return null;
let altParts = [];
const subjectWord = this.selectVocabularyWord(vocab.types[semanticInfo.category] || [], 'random');
if (subjectWord) {
altParts.push(subjectWord);
}
if (semanticInfo.description) {
altParts.push(semanticInfo.description);
}
if (semanticInfo.context && this.config.includeBrandContext) {
const contextWord = this.selectVocabularyWord(vocab.contexts[semanticInfo.context] || [], 'first');
if (contextWord) {
altParts.push(`${contextWord}の`);
}
}
return this.combineAltParts(altParts);
}
generateEmotionalAlt(analysis) {
if (!this.config.includeEmotions) return null;
const { imageType, context } = analysis;
const lang = this.config.language;
const vocab = this.vocabulary[lang];
const emotionalTone = this.analyzeEmotionalTone(context);
if (!emotionalTone) return null;
const emotionWords = vocab.emotions[emotionalTone] || [];
const emotionWord = this.selectVocabularyWord(emotionWords, 'random');
if (!emotionWord) return null;
const baseAlt = this.generateBasicAlt(analysis);
return lang === 'ja' ?
`${emotionWord}${baseAlt}` :
`${emotionWord} ${baseAlt}`;
}
generateActionBasedAlt(analysis) {
const { context, imageType } = analysis;
const lang = this.config.language;
const vocab = this.vocabulary[lang];
const detectedAction = this.detectAction(context);
if (!detectedAction) return null;
const actionWords = vocab.actions[detectedAction] || [];
const actionWord = this.selectVocabularyWord(actionWords, 'random');
if (!actionWord) return null;
const subject = this.detectSubject(context, imageType);
return lang === 'ja' ?
`${subject}${actionWord}様子` :
`${subject} ${actionWord}`;
}
generateBrandAwareAlt(analysis) {
if (!this.config.includeBrandContext) return null;
const { context, src } = analysis;
const brandInfo = this.extractBrandInfo(context, src);
if (!brandInfo.name) return null;
const baseAlt = this.generateBasicAlt(analysis);
return `${brandInfo.name}の${baseAlt}`;
}
generateTechnicalAlt(analysis) {
const { imageType, src, context } = analysis;
if (imageType !== 'data-visualization' && imageType !== 'complex') {
return null;
}
const technicalInfo = this.extractTechnicalInfo(context, src);
if (!technicalInfo.type) return null;
let altParts = [technicalInfo.type];
if (technicalInfo.data) {
altParts.push(technicalInfo.data);
}
if (technicalInfo.trend) {
altParts.push(technicalInfo.trend);
}
return this.combineAltParts(altParts);
}
selectStrategies(strategies, analysis) {
const { creativity } = this.config;
const { imageType } = analysis;
switch (creativity) {
case 'conservative':
return strategies.slice(0, 2);
case 'creative':
return strategies;
default:
if (imageType === 'decorative') {
return strategies.slice(0, 1);
} else if (imageType === 'data-visualization') {
return [strategies[0], strategies[5]];
} else {
return strategies.slice(0, 4);
}
}
}
validateAltText(altText) {
if (!altText || typeof altText !== 'string') return false;
const trimmed = altText.trim();
if (trimmed.length < 2 || trimmed.length > this.config.maxLength) {
return false;
}
const forbiddenWords = ['image', 'picture', 'photo', '画像', '写真'];
const hasForbidenWord = forbiddenWords.some(word =>
trimmed.toLowerCase().includes(word.toLowerCase())
);
if (hasForbidenWord) return false;
const placeholders = ['[', ']', 'placeholder', 'dummy'];
const hasPlaceholder = placeholders.some(placeholder =>
trimmed.toLowerCase().includes(placeholder)
);
return !hasPlaceholder;
}
refineAltText(altText, analysis) {
let refined = altText.trim();
refined = refined.replace(/[<>]/g, '');
refined = refined.replace(/\s+/g, ' ');
if (refined.length > this.config.maxLength) {
refined = refined.substring(0, this.config.maxLength - 3) + '...';
}
if (this.config.language === 'en') {
refined = refined.charAt(0).toUpperCase() + refined.slice(1);
}
return refined;
}
// Helper methods
extractContextElements(context) {
return {
nearbyHeading: this.findNearbyHeading(context),
surroundingText: this.extractSurroundingText(context),
listContext: this.findListContext(context),
tableContext: this.findTableContext(context)
};
}
analyzeSemanticContent(src, context) {
const srcLower = (src || '').toLowerCase();
const contextLower = context.toLowerCase();
let category = 'object';
if (this.containsKeywords(srcLower + ' ' + contextLower, ['person', 'people', 'man', 'woman', '人', '人物'])) {
category = 'person';
} else if (this.containsKeywords(srcLower + ' ' + contextLower, ['nature', 'landscape', '自然', '風景'])) {
category = 'nature';
} else if (this.containsKeywords(srcLower + ' ' + contextLower, ['building', 'architecture', '建物', '建築'])) {
category = 'building';
} else if (this.containsKeywords(srcLower + ' ' + contextLower, ['food', 'restaurant', '食べ物', '料理'])) {
category = 'food';
} else if (this.containsKeywords(srcLower + ' ' + contextLower, ['tech', 'computer', 'device', '技術', 'コンピューター'])) {
category = 'technology';
}
return {
category,
mainSubject: this.extractMainSubject(context),
description: this.extractDescription(context),
context: this.detectContextType(context)
};
}
analyzeEmotionalTone(context) {
const contextLower = context.toLowerCase();
if (this.containsKeywords(contextLower, ['success', 'happy', 'great', 'excellent', '成功', '素晴らしい', '優秀'])) {
return 'positive';
}
if (this.containsKeywords(contextLower, ['action', 'energy', 'dynamic', 'power', 'アクション', 'エネルギー', 'ダイナミック'])) {
return 'dynamic';
}
return 'neutral';
}
detectAction(context) {
const contextLower = context.toLowerCase();
if (this.containsKeywords(contextLower, ['show', 'display', 'present', '表示', '示す'])) {
return 'showing';
} else if (this.containsKeywords(contextLower, ['work', 'operate', 'use', '作業', '操作', '使用'])) {
return 'working';
} else if (this.containsKeywords(contextLower, ['enjoy', 'experience', 'taste', '楽しむ', '体験', '味わう'])) {
return 'enjoying';
} else if (this.containsKeywords(contextLower, ['create', 'build', 'develop', '作成', '構築', '開発'])) {
return 'creating';
}
return null;
}
detectSubject(context, imageType) {
const lang = this.config.language;
const vocab = this.vocabulary[lang];
const typeVocab = vocab.types[imageType] || vocab.types.object;
return this.selectVocabularyWord(typeVocab, 'first') || (lang === 'ja' ? '画像' : 'image');
}
extractBrandInfo(context, src) {
const brandPatterns = [
/company[^>]*>([^<]+)/i,
/brand[^>]*>([^<]+)/i,
/<title[^>]*>([^<]+)/i,
/alt\s*=\s*["']([^"']*logo[^"']*)["']/i
];
for (const pattern of brandPatterns) {
const match = context.match(pattern);
if (match) {
return { name: match[1].trim().replace(/\s*logo\s*/i, '') };
}
}
return { name: null };
}
extractTechnicalInfo(context, src) {
const contextLower = context.toLowerCase();
const srcLower = (src || '').toLowerCase();
let type = null;
let data = null;
let trend = null;
if (this.containsKeywords(srcLower + ' ' + contextLower, ['chart', 'graph', 'グラフ', 'チャート'])) {
if (this.containsKeywords(contextLower, ['bar', 'column', '棒'])) {
type = this.config.language === 'ja' ? '棒グラフ' : 'Bar chart';
} else if (this.containsKeywords(contextLower, ['pie', '円'])) {
type = this.config.language === 'ja' ? '円グラフ' : 'Pie chart';
} else if (this.containsKeywords(contextLower, ['line', '線'])) {
type = this.config.language === 'ja' ? '線グラフ' : 'Line chart';
} else {
type = this.config.language === 'ja' ? 'グラフ' : 'Chart';
}
}
const numberPattern = /(\d+(?:\.\d+)?)\s*%?/g;
const numbers = contextLower.match(numberPattern);
if (numbers && numbers.length > 0) {
data = numbers.slice(0, 3).join(', ');
}
if (this.containsKeywords(contextLower, ['increase', 'rise', 'up', '増加', '上昇', '向上'])) {
trend = this.config.language === 'ja' ? '増加傾向' : 'increasing trend';
} else if (this.containsKeywords(contextLower, ['decrease', 'fall', 'down', '減少', '下降', '低下'])) {
trend = this.config.language === 'ja' ? '減少傾向' : 'decreasing trend';
}
return { type, data, trend };
}
containsKeywords(text, keywords) {
return keywords.some(keyword => text.includes(keyword.toLowerCase()));
}
selectVocabularyWord(words, strategy = 'random') {
if (!words || words.length === 0) return null;
switch (strategy) {
case 'first':
return words[0];
case 'random':
return words[Math.floor(Math.random() * words.length)];
case 'shortest':
return words.reduce((shortest, word) =>
word.length < shortest.length ? word : shortest
);
default:
return words[0];
}
}
combineAltParts(parts) {
const lang = this.config.language;
const validParts = parts.filter(part => part && part.trim());
if (validParts.length === 0) return null;
if (lang === 'ja') {
return validParts.join('');
} else {
return validParts.join(' ');
}
}
generateBasicAlt(analysis) {
const { imageType, src } = analysis;
const lang = this.config.language;
const vocab = this.vocabulary[lang];
const typeWords = vocab.types[imageType] || vocab.types.object;
return this.selectVocabularyWord(typeWords, 'first') || (lang === 'ja' ? '画像' : 'image');
}
generateFallbackAlt(analysis) {
const { src } = analysis;
const lang = this.config.language;
if (src) {
const filename = src.split('/').pop().split('.')[0];
const cleaned = filename.replace(/[-_]/g, ' ').trim();
if (cleaned && cleaned.length > 0) {
return lang === 'ja' ?
cleaned.replace(/\b\w/g, l => l.toUpperCase()) :
cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
}
return lang === 'ja' ? '画像' : 'Image';
}
enhanceWithVocabulary(text, imageType) {
const lang = this.config.language;
const vocab = this.vocabulary[lang];
const typeWords = vocab.types[imageType];
if (typeWords && typeWords.length > 0) {
const typeWord = this.selectVocabularyWord(typeWords, 'random');
return lang === 'ja' ?
`${typeWord}:${text}` :
`${typeWord}: ${text}`;
}
return text;
}
createLinkAlt(linkText, imageType) {
const lang = this.config.language;
return lang === 'ja' ?
`${linkText}へのリンク` :
`Link to ${linkText}`;
}
createHeadingBasedAlt(heading, imageType) {
const lang = this.config.language;
const vocab = this.vocabulary[lang];
const typeWords = vocab.types[imageType] || [];
const typeWord = this.selectVocabularyWord(typeWords, 'first');
if (typeWord) {
return lang === 'ja' ?
`${heading}の${typeWord}` :
`${typeWord} of ${heading}`;
}
return heading;
}
createTextBasedAlt(text, imageType) {
const words = text.split(/\s+/).filter(word => word.length > 2);
const keyWords = words.slice(0, 3).join(' ');
const lang = this.config.language;
const vocab = this.vocabulary[lang];
const typeWords = vocab.types[imageType] || [];
const typeWord = this.selectVocabularyWord(typeWords, 'first');
if (typeWord && keyWords) {
return lang === 'ja' ?
`${keyWords}の${typeWord}` :
`${typeWord} showing ${keyWords}`;
}
return keyWords || (lang === 'ja' ? '画像' : 'Image');
}
extractMainSubject(context) {
const sentences = context.split(/[.!?。!?]/);
const firstSentence = sentences[0];
if (firstSentence) {
const words = firstSentence.split(/\s+/);
return words.slice(0, 3).join(' ');
}
return null;
}
extractDescription(context) {
const descriptiveWords = context.match(/\b(beautiful|amazing|professional|modern|elegant|美しい|素晴らしい|プロフェッショナル|モダン|エレガント)\b/gi);
return descriptiveWords ? descriptiveWords[0] : null;
}
detectContextType(context) {
const contextLower = context.toLowerCase();
if (this.containsKeywords(contextLower, ['business', 'company', 'corporate', 'ビジネス', '企業', '会社'])) {
return 'business';
} else if (this.containsKeywords(contextLower, ['education', 'learning', 'school', '教育', '学習', '学校'])) {
return 'education';
} else if (this.containsKeywords(contextLower, ['technology', 'tech', 'digital', '技術', 'テクノロジー', 'デジタル'])) {
return 'technology';
} else if (this.containsKeywords(contextLower, ['lifestyle', 'personal', 'daily', 'ライフスタイル', '個人', '日常'])) {
return 'lifestyle';
}
return null;
}
findNearbyHeading(context) {
const headingRegex = /<h[1-6][^>]*>([^<]+)<\/h[1-6]>/gi;
const match = headingRegex.exec(context);
return match ? match[1].trim() : null;
}
extractSurroundingText(context) {
const textOnly = context.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const words = textOnly.split(' ');
const meaningfulWords = words.filter(word =>
word.length > 2 && !/^\d+$/.test(word) && !/^[^\w]+$/.test(word)
).slice(0, 8);
return meaningfulWords.join(' ');
}
findListContext(context) {
const listItemRegex = /<li[^>]*>([^<]+)<\/li>/gi;
const match = listItemRegex.exec(context);
return match ? match[1].trim() : null;
}
findTableContext(context) {
const tableCellRegex = /<t[hd][^>]*>([^<]+)<\/t[hd]>/gi;
const match = tableCellRegex.exec(context);
return match ? match[1].trim() : null;
}
extractLinkText(linkTag) {
const textMatch = linkTag.match(/>([^<]+)</);
return textMatch ? textMatch[1].trim() : null;
}
}
/**
* Enhanced Alt Attribute Checker
* Cải tiến tính năng kiểm tra alt attribute đa dạng và toàn diện hơn
*/
class EnhancedAltChecker {
constructor(config = {}) {
this.config = {
language: config.language || 'ja',
strictMode: config.strictMode || false,
checkDecorative: config.checkDecorative || true,
checkInformative: config.checkInformative || true,
checkComplex: config.checkComplex || true,
maxAltLength: config.maxAltLength || 125,
minAltLength: config.minAltLength || 3,
...config
};
}
analyzeAltAttributes(content) {
const issues = [];
const imgRegex = /<img[^>]*>/gi;
const imgTags = content.match(imgRegex) || [];
imgTags.forEach((imgTag, index) => {
const analysis = this.analyzeImageContext(imgTag, content, index);
const altIssues = this.checkAltQuality(imgTag, analysis);
if (altIssues.length > 0) {
issues.push({
imageIndex: index + 1,
imgTag: imgTag,
src: analysis.src,
context: analysis.context,
issues: altIssues,
recommendations: this.generateRecommendations(imgTag, analysis)
});
}
});
return issues;
}
analyzeImageContext(imgTag, htmlContent, imgIndex) {
const src = this.extractAttribute(imgTag, 'src');
const alt = this.extractAttribute(imgTag, 'alt');
const title = this.extractAttribute(imgTag, 'title');
const ariaLabel = this.extractAttribute(imgTag, 'aria-label');
const role = this.extractAttribute(imgTag, 'role');
const position = this.findImagePosition(imgTag, htmlContent, imgIndex);
const surroundingContext = this.extractSurroundingContext(htmlContent, position, 1000);
const imageType = this.classifyImageType(imgTag, surroundingContext, src);
const structuralContext = this.analyzeStructuralContext(surroundingContext, imgTag);
return {
src,
alt,
title,
ariaLabel,
role,
imageType,
context: surroundingContext,
structural: structuralContext,
position
};
}
checkAltQuality(imgTag, analysis) {
const issues = [];
const { alt, imageType, src } = analysis;
if (!this.hasAttribute(imgTag, 'alt')) {
issues.push({
type: 'MISSING_ALT',
severity: 'ERROR',
message: 'Thiếu thuộc tính alt',
description: 'Tất cả hình ảnh phải có thuộc tính alt'
});
return issues;
}
if (alt === '') {
if (imageType === 'decorative') {
return issues;
} else {
issues.push({
type: 'EMPTY_ALT',
severity: 'ERROR',
message: 'Alt text rỗng cho hình ảnh có nội dung',
description: 'Hình ảnh có nội dung cần alt text mô tả'
});
}
}
if (alt && alt.length > this.config.maxAltLength) {
issues.push({
type: 'ALT_TOO_LONG',
severity: 'WARNING',
message: `Alt text quá dài (${alt.length} ký tự)`,
description: `Nên giới hạn dưới ${this.config.maxAltLength} ký tự`
});
}
if (alt && alt.length < this.config.minAltLength && imageType !== 'decorative') {
issues.push({
type: 'ALT_TOO_SHORT',
severity: 'WARNING',
message: `Alt text quá ngắn (${alt.length} ký tự)`,
description: 'Alt text nên mô tả đầy đủ nội dung hình ảnh'
});
}
const contentIssues = this.checkAltContent(alt, src, imageType);
issues.push(...contentIssues);
const consistencyIssues = this.checkAttributeConsistency(analysis);
issues.push(...consistencyIssues);
const typeSpecificIssues = this.checkTypeSpecificRequirements(analysis);
issues.push(...typeSpecificIssues);
return issues;
}
classifyImageType(imgTag, context, src) {
const srcLower = (src || '').toLowerCase();
const contextLower = context.toLowerCase();
if (this.isDecorativeImage(imgTag, context, src)) {
return 'decorative';
}
if (this.isDataVisualization(srcLower, contextLower)) {
return 'data-visualization';
}
if (this.isComplexImage(srcLower, contextLower)) {
return 'complex';
}
if (this.isLogo(srcLower, contextLower)) {
return 'logo';
}
if (this.isFunctionalIcon(imgTag, context, srcLower)) {
return 'functional-icon';
}
if (this.isContentImage(contextLower)) {
return 'content';
}
return 'informative';
}
checkAltContent(alt, src, imageType) {
const issues = [];
if (!alt) return issues;
const altLower = alt.toLowerCase();
const srcLower = (src || '').toLowerCase();
const forbiddenWords = [
'image', 'picture', 'photo', 'graphic', 'img',
'画像', '写真', 'イメージ', '図', '図表'
];
const foundForbidden = forbiddenWords.find(word => altLower.includes(word));
if (foundForbidden) {
issues.push({
type: 'REDUNDANT_WORDS',
severity: 'WARNING',
message: `Alt text chứa từ thừa: "${foundForbidden}"`,
description: 'Không cần nói "hình ảnh" trong alt text'
});
}
if (src) {
const filename = src.split('/').pop().split('.')[0];
if (altLower.includes(filename.toLowerCase())) {
issues.push({
type: 'FILENAME_IN_ALT',
severity: 'WARNING',
message: 'Alt text chứa tên file',
description: 'Nên mô tả nội dung thay vì tên file'
});
}
}
const genericTexts = [
'click here', 'read more', 'learn more', 'see more',
'ここをクリック', '詳細', 'もっと見る'
];
const foundGeneric = genericTexts.find(text => altLower.includes(text));
if (foundGeneric) {
issues.push({
type: 'GENERIC_ALT',
severity: 'ERROR',
message: `Alt text quá chung chung: "${foundGeneric}"`,
description: 'Nên mô tả cụ thể nội dung hình ảnh'
});
}
if (imageType === 'data-visualization' && !this.hasDataDescription(alt)) {
issues.push({
type: 'MISSING_DATA_DESCRIPTION',
severity: 'ERROR',
message: 'Biểu đồ thiếu mô tả dữ liệu',
description: 'Biểu đồ cần mô tả xu hướng và dữ liệu chính'
});
}
return issues;
}
generateRecommendations(imgTag, analysis) {
const recommendations = [];
const { imageType, context, src, alt } = analysis;
switch (imageType) {
case 'decorative':
recommendations.push({
type: 'DECORATIVE',
suggestion: 'alt=""',
reason: 'Hình trang trí nên có alt rỗng'
});
break;
case 'logo':
const brandName = this.extractBrandName(context, src);
recommendations.push({
type: 'LOGO',
suggestion: brandName ? `alt="${brandName} logo"` : 'alt="Company logo"',
reason: 'Logo nên bao gồm tên thương hiệu'
});
break;
case 'functional-icon':
const action = this.extractIconAction(context, imgTag);
recommendations.push({
type: 'FUNCTIONAL',
suggestion: action ? `alt="${action}"` : 'alt="[Mô tả chức năng]"',
reason: 'Icon chức năng nên mô tả hành động'
});
break;
case 'data-visualization':
recommendations.push({
type: 'DATA_VIZ',
suggestion: 'alt="[Loại biểu đồ]: [Xu hướng chính] [Dữ liệu quan trọng]"',
reason: 'Biểu đồ cần mô tả loại, xu hướng và dữ liệu chính'
});
break;
case 'complex':
recommendations.push({
type: 'COMPLEX',
suggestion: 'alt="[Mô tả ngắn]" + longdesc hoặc mô tả chi tiết bên dưới',
reason: 'Hình phức tạp cần mô tả ngắn trong alt và mô tả dài riêng'
});
break;
default:
const contextualAlt = this.generateContextualAlt(analysis);
recommendations.push({
type: 'CONTEXTUAL',
suggestion: `alt="${contextualAlt}"`,
reason: 'Mô tả dựa trên ngữ cảnh xung quanh'
});
}
return recommendations;
}
generateContextualAlt(analysis) {
const { context, src, structural } = analysis;
const nearbyHeading = this.findNearbyHeading(context);
if (nearbyHeading) {
return nearbyHeading;
}
if (structural.parentLink) {
const linkText = this.extractLinkText(structural.parentLink);
if (linkText) {
return linkText;
}
}
if (structural.figcaption) {
return structural.figcaption;
}
const surroundingText = this.extractSurroundingText(context);
if (surroundingText) {
return surroundingText;
}
return this.generateFallbackAlt(src);
}
// Helper methods
isDecorativeImage(imgTag, context, src) {
const decorativeIndicators = [
'decoration', 'border', 'spacer', 'divider',
'background', 'texture', 'pattern'
];
const srcLower = (src || '').toLowerCase();
return decorativeIndicators.some(indicator => srcLower.includes(indicator));
}
isDataVisualization(src, context) {
const dataIndicators = [
'chart', 'graph', 'plot', 'diagram', 'infographic',
'グラフ', '図表', 'チャート'
];
return dataIndicators.some(indicator =>
src.includes(indicator) || context.includes(indicator)
);
}
isComplexImage(src, context) {
const complexIndicators = [
'flowchart', 'timeline', 'map', 'blueprint', 'schematic',
'フローチャート', '地図', '設計図'
];
return complexIndicators.some(indicator =>
src.includes(indicator) || context.includes(indicator)
);
}
isLogo(src, context) {
const logoIndicators = ['logo', 'brand', 'ロゴ', 'ブランド'];
return logoIndicators.some(indicator =>
src.includes(indicator) || context.includes(indicator)
);
}
isFunctionalIcon(imgTag, context, src) {
const iconIndicators = ['icon', 'btn', 'button', 'アイコン', 'ボタン'];
const hasClickHandler = /onclick|href/i.test(context);
return (iconIndicators.some(indicator => src.includes(indicator)) || hasClickHandler);
}
isContentImage(context) {
const contentIndicators = [
'article', 'content', 'story', 'news',
'記事', 'コンテンツ', 'ニュース'
];
return contentIndicators.some(indicator => context.includes(indicator));
}
extractAttribute(imgTag, attributeName) {
const regex = new RegExp(`${attributeName}\\s*=\\s*["']([^"']*)["']`, 'i');
const match = imgTag.match(regex);
return match ? match[1] : null;
}
hasAttribute(imgTag, attributeName) {
const regex = new RegExp(`${attributeName}\\s*=`, 'i');
return regex.test(imgTag);
}
findImagePosition(imgTag, htmlContent, imgIndex) {
const imgRegex = /<img[^>]*>/gi;
let match;
let currentIndex = 0;
while ((match = imgRegex.exec(htmlContent)) !== null) {
if (currentIndex === imgIndex) {
return match.index;
}
currentIndex++;
}
return -1;
}
extractSurroundingContext(htmlContent, position, range) {
if (position === -1) return '';
const start = Math.max(0, position - range);
const end = Math.min(htmlContent.length, position + range);
return htmlContent.substring(start, end);
}
analyzeStructuralContext(context, imgTag) {
return {
parentLink: this.findParentElement(context, imgTag, 'a'),
parentFigure: this.findParentElement(context, imgTag, 'figure'),
figcaption: this.findSiblingElement(context, imgTag, 'figcaption'),
parentButton: this.findParentElement(context, imgTag, 'button')
};
}
findParentElement(context, imgTag, tagName) {
const imgIndex = context.indexOf(imgTag);
if (imgIndex === -1) return null;
const beforeImg = context.substring(0, imgIndex);
const openTagRegex = new RegExp(`<${tagName}[^>]*>`, 'gi');
const closeTagRegex = new RegExp(`</${tagName}>`, 'gi');
let openTags = 0;
let lastOpenMatch = null;
let match;
while ((match = openTagRegex.exec(beforeImg)) !== null) {
lastOpenMatch = match;
openTags++;
}
while ((match = closeTagRegex.exec(beforeImg)) !== null) {
openTags--;
}
return openTags > 0 ? lastOpenMatch[0] : null;
}
findSiblingElement(context, imgTag, tagName) {
const imgIndex = context.indexOf(imgTag);
if (imgIndex === -1) return null;
const afterImg = context.substring(imgIndex + imgTag.length);
const siblingRegex = new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'i');
const match = afterImg.match(siblingRegex);
return match ? match[1].trim() : null;
}
checkAttributeConsistency(analysis) {
const issues = [];
const { alt, title, ariaLabel } = analysis;
if (alt && ariaLabel && alt !== ariaLabel) {
issues.push({
type: 'INCONSISTENT_LABELS',
severity: 'WARNING',
message: 'Alt text và aria-label không nhất quán',
description: 'Alt và aria-label nên có nội dung giống nhau'
});
}
if (title && alt && title === alt) {
issues.push({
type: 'REDUNDANT_TITLE',
severity: 'INFO',
message: 'Title attribute trùng với alt text',
description: 'Title có thể bỏ đi để tránh lặp lại'
});
}
return issues;
}
checkTypeSpecificRequirements(analysis) {
const issues = [];
const { imageType, alt, structural } = analysis;
switch (imageType) {
case 'functional-icon':
if (structural.parentLink && !alt) {
issues.push({
type: 'FUNCTIONAL_MISSING_ALT',
severity: 'ERROR',
message: 'Icon chức năng trong link thiếu alt text',
description: 'Icon có chức năng phải có alt mô tả hành động'
});
}
break;
case 'logo':
if (alt && !alt.toLowerCase().includes('logo')) {
issues.push({
type: 'LOGO_MISSING_CONTEXT',
severity: 'WARNING',
message: 'Logo thiếu từ khóa "logo" trong alt text',
description: 'Logo nên bao gồm từ "logo" để rõ ràng'
});
}
break;
}
return issues;
}
hasDataDescription(alt) {
const dataKeywords = [
'increase', 'decrease', 'trend', 'percent', '%',
'増加', '減少', 'トレンド', 'パーセント'
];
return dataKeywords.some(keyword =>
alt.toLowerCase().includes(keyword.toLowerCase())
);
}
findNearbyHeading(context) {
const headingRegex = /<h[1-6][^>]*>([^<]+)<\/h[1-6]>/gi;
const match = headingRegex.exec(context);
return match ? match[1].trim() : null;
}
extractLinkText(linkTag) {
const textMatch = linkTag.match(/>([^<]+)</);
return textMatch ? textMatch[1].trim() : null;
}
extractSurroundingText(context) {
const textOnly = context.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
const words = textOnly.split(' ');
const meaningfulWords = words.filter(word =>
word.length > 2 && !/^\d+$/.test(word)
).slice(0, 5);
return meaningfulWords.join(' ');
}
generateFallbackAlt(src) {
if (!src) return '画像';
const filename = src.split('/').pop().split('.')[0];
return filename
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase())
.trim() || '画像';
}
extractBrandName(context, src) {
const brandPatterns = [
/company[^>]*>([^<]+)/i,
/brand[^>]*>([^<]+)/i,
/logo[^>]*>([^<]+)/i
];
for (const pattern of brandPatterns) {
const match = context.match(pattern);
if (match) return match[1].trim();
}
return null;
}
extractIconAction(context, imgTag) {
const actionPatterns = [
/title\s*=\s*["']([^"']+)["']/i,
/aria-label\s*=\s*["']([^"']+)["']/i,
/onclick[^>]*>([^<]+)/i
];
for (const pattern of actionPatterns) {
const match = imgTag.match(pattern) || context.match(pattern);
if (match) return match[1].trim();
}
return null;
}
}
class AccessibilityFixer {
constructor(config = {}) {
this.config = {
backupFiles: config.backupFiles === true,
language: config.language || 'ja',
dryRun: config.dryRun || false,
enhancedAltMode: config.enhancedAltMode || false,
altCreativity: config.altCreativity || 'balanced', // conservative, balanced, creative
includeEmotions: config.includeEmotions || false,
strictAltChecking: config.strictAltChecking || false,
// New options for advanced features
autoFixHeadings: config.autoFixHeadings || false, // Enable automatic heading fixes
fixDescriptionLists: config.fixDescriptionLists || true, // Enable DL structure fixes
...config
};
// Initialize enhanced alt tools
this.enhancedAltChecker = new EnhancedAltChecker({
language: this.config.language,
strictMode: this.config.strictAltChecking,
checkDecorative: true,
checkInformative: true,
checkComplex: true
});
this.enhancedAltGenerator = new EnhancedAltGenerator({
language: this.config.language,
creativity: this.config.altCreativity,
includeEmotions: this.config.includeEmotions,
includeBrandContext: true
});
}
async fixHtmlLang(directory = '.') {
console.log(chalk.blue('📝 Đang sửa thuộc tính HTML lang...'));
const htmlFiles = await this.findHtmlFiles(directory);
const results = [];
for (const file of htmlFiles) {
try {
const content = await fs.readFile(file, 'utf8');
const fixed = this.fixLangAttribute(content);
if (fixed !== content) {
if (this.config.backupFiles) {
await fs.writeFile(`${file}.backup`, content);
}
if (!this.config.dryRun) {
await fs.writeFile(file, fixed);
}
console.log(chalk.green(`✅ Fixed lang attribute in: ${file}`));
results.push({ file, status: 'fixed' });
} else {
results.push({ file, status: 'no-change' });
}
} catch (error) {
console.error(chalk.red(`❌ Error processing ${file}: ${error.message}`));
results.push({ file, status: 'error', error: error.message });
}
}
return results;
}
async fixEmptyAltAttributes(directory = '.') {
console.log(chalk.blue('🖼️ Đang sửa thuộc tính alt rỗng...'));
const htmlFiles = await this.findHtmlFiles(directory);
const results = [];
let totalIssuesFound = 0;
let enhancedIssues = []; // Declare here to avoid scope issues
for (const file of htmlFiles) {
try {
const content = await fs.readFile(file, 'utf8');
// Use enhanced alt checker if enabled
if (this.config.enhancedAltMode) {
enhancedIssues = this.enhancedAltChecker.analyzeAltAttributes(content);
if (enhancedIssues.length > 0) {
console.log(chalk.cyan(`\n📁 ${file}:`));
enhancedIssues.forEach(issue => {
console.log(chalk.yellow(` 🔍 Image ${issue.imageIndex} (${issue.src}):`));
issue.issues.forEach(subIssue => {
const icon = subIssue.severity === 'ERROR' ? '❌' :
subIssue.severity === 'WARNING' ? '⚠️' : 'ℹ️';
console.log(chalk.yellow(` ${icon} ${subIssue.message}`));
console.log(chalk.gray(` ${subIssue.description}`));
});
// Show recommendations
if (issue.recommendations.length > 0) {
console.log(chalk.blue(` 💡 Recommendations:`));
issue.recommendations.forEach(rec => {
console.log(chalk.blue(` ${rec.suggestion}`));
console.log(chalk.gray(` ${rec.reason}`));
});
}
totalIssuesFound += issue.issues.length;
});
}
} else {
// Use original analysis
const issues = this.analyzeAltAttributes(content);
if (issues.length > 0) {
console.log(chalk.cyan(`\n📁 ${file}:`));
issues.forEach(issue => {
console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
totalIssuesFound++;
});
}
enhancedIssues = issues; // For consistency in results calculation
}
const fixed = this.fixAltAttributes(content);
if (fixed !== content) {
if (this.config.backupFiles) {
await fs.writeFile(`${file}.backup`, content);
}
if (!this.config.dryRun) {
await fs.writeFile(file, fixed);
}
console.log(chalk.green(`✅ Fixed alt attributes in: ${file}`));
results.push({ file, status: 'fixed', issues: this.config.enhancedAltMode ?
enhancedIssues.reduce((sum, ei) => sum + (ei.issues ? ei.issues.length : 1), 0) : enhancedIssues.length });
} else {
results.push({ file, status: 'no-change', issues: this.config.enhancedAltMode ?
enhancedIssues.reduce((sum, ei) => sum + (ei.issues ? ei.issues.length : 1), 0) : enhancedIssues.length });
}
} catch (error) {
console.error(chalk.red(`❌ Error processing ${file}: ${error.message}`));
results.push({ file, status: 'error', error: error.message });
}
}
console.log(chalk.blue(`\n📊 Tóm tắt: Tìm thấy ${totalIssuesFound} vấn đề thuộc tính alt trong ${results.length} file`));
if (this.config.enhancedAltMode) {
console.log(chalk.gray(` 🔍 Enhanced analysis mode: Comprehensive quality checking enabled`));
}
return results;
}
analyzeAltAttributes(content) {
const issues = [];
const imgRegex = /<img[^>]*>/gi;
const imgTags = content.match(imgRegex) || [];
imgTags.forEach((imgTag, index) => {
const hasAlt = /alt\s*=/i.test(imgTag);
const hasEmptyAlt = /alt\s*=\s*[""''][""'']/i.test(imgTag);
const src = imgTag.match(/src\s*=\s*["']([^"']+)["']/i);
const srcValue = src ? src[1] : 'unknown';
if (!hasAlt) {
issues.push({
type: '❌ Missing alt',
description: `Image ${index + 1} (${srcValue}) has no alt attribute`,
imgTag: imgTag.substring(0, 100) + '...'
});
} else if (hasEmptyAlt) {
issues.push({
type: '⚠️ Empty alt',
description: `Image ${index + 1} (${srcValue}) has empty alt attribute`,
imgTag: imgTag.substring(0, 100) + '...'
});
}
});
return issues;
}
async fixRoleAttributes(directory = '.') {
console.log(chalk.blue('🎭 Đang sửa thuộc tính role...'));
const htmlFiles = await this.findHtmlFiles(directory);
const results = [];
let totalIssuesFound = 0;
for (const file of htmlFiles) {
try {
const content = await fs.readFile(file, 'utf8');
const issues = this.analyzeRoleAttributes(content);
if (issues.length > 0) {
console.log(chalk.cyan(`\n📁 ${file}:`));
issues.forEach(issue => {
console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
totalIssuesFound++;
});
}
const fixed = this.fixRoleAttributesInContent(content);
if (fixed !== content) {
if (this.config.backupFiles) {
await fs.writeFile(`${file}.backup`, content);
}
if (!this.config.dryRun) {
await fs.writeFile(file, fixed);
}
console.log(chalk.green(`✅ Fixed role attributes in: ${file}`));
results.push({ file, status: 'fixed', issues: issues.length });
} else {
results.push({ file, status: 'no-change', issues: issues.length });
}
} catch (error) {
console.error(chalk.red(`❌ Error processing ${file}: ${error.message}`));
results.push({ file, status: 'error', error: error.message });
}
}
console.log(chalk.blue(`\n📊 Tóm tắt: Tìm thấy ${totalIssuesFound} vấn đề thuộc tính role trong ${results.length} file`));
return results;
}
// Fix aria-labels for images and other elements
async fixAriaLabels(directory = '.') {
console.log(chalk.blue('🏷️ Fixing aria-label attributes...'));
const htmlFiles = await this.findHtmlFiles(directory);
const results = [];
let totalIssuesFound = 0;
for (const file of htmlFiles) {
try {
const content = await fs.readFile(file, 'utf8');
const fixed = this.fixAriaLabelsInContent(content);
if (fixed.content !== content) {
if (!this.config.dryRun) {
if (this.config.backupFiles) {
await fs.writeFile(`${file}.backup`, content);
}
await fs.writeFile(file, fixed.content);
}
console.log(chalk.green(`✅ Fixed aria-label attributes in: ${file}`));
totalIssuesFound += fixed.changes;
}
results.push({ file, status: 'processed', changes: fixed.changes });
} catch (error) {
console.error(chalk.red(`❌ Error processing ${file}: ${error.message}`));
results.push({ file, status: 'error', error: error.message });
}
}
console.log(chalk.blue(`\n📊 Tóm tắt: Tìm thấy ${totalIssuesFound} vấn đề aria-label trong ${results.length} file`));
return results;
}
fixAriaLabelsInContent(content) {
let fixed = content;
let changes = 0;
// Fix images - add aria-label from alt text
fixed = fixed.replace(
/<img([^>]*>)/gi,
(match) => {
// Check if aria-label already exists
if (/aria-label\s*=/i.test(match)) {
return match; // Return unchanged if aria-label already exists
}
// Extract alt text to use for aria-label
const altMatch = match.match(/alt\s*=\s*["']([^"']*)["']/i);
if (altMatch && altMatch[1].trim()) {
const altText = altMatch[1].trim();
const updatedImg = match.replace(/(<img[^>]*?)(\s*>)/i, `$1 aria-label="${altText}"$2`);
console.log(chalk.yellow(` 🏷️ Added aria-label="${altText}" to image element`));
changes++;
return updatedImg;
}
return match;
}
);
// Fix buttons without aria-label but with text content
fixed = fixed.replace(
/<button([^>]*>)(.*?)<\/button>/gi,
(match, attributes, content) => {
// Skip if aria-label already exists
if (/aria-label\s*=/i.test(attributes)) {
return match;
}
// Extract text content for aria-label
const textContent = content.replace(/<[^>]*>/g, '').trim();
if (textContent) {
const updatedButton = match.replace(/(<button[^>]*?)(\s*>)/i, `$1 aria-label="${textContent}"$2`);
console.log(chalk.yellow(` 🏷️ Added aria-label="${textContent}" to button element`));
changes++;
return updatedButton;
}
return match;
}
);
// Fix links without aria-label but with text content
fixed = fixed.replace(
/<a([^>]*href[^>]*>)(.*?)<\/a>/gi,
(match, attributes, content) => {
// Skip if aria-label already exists
if (/aria-label\s*=/i.test(attributes)) {
return match;
}
// Skip if it's just text content (not generic)
const textContent = content.replace(/<[^>]*>/g, '').trim();
const genericTexts = ['link', 'click here', 'read more', 'more info', 'here', 'this'];
if (textContent && genericTexts.some(generic =>
textContent.toLowerCase().includes(generic.toLowerCase()))) {
// Extract meaningful context or use href for generic links
const hrefMatch = attributes.match(/href\s*=\s*["']([^"']*)["']/i);
if (hrefMatch && hrefMatch[1]) {
const href = hrefMatch[1];
let ariaLabel = textContent;
// Improve generic link text
if (href.includes('mailto:')) {
ariaLabel = `Email: ${href.replace('mailto:', '')}`;
} else if (href.includes('tel:')) {
ariaLabel = `Phone: ${href.replace('tel:', '')}`;
} else if (href.startsWith('http')) {
const domain = new URL(hre