gbu-accessibility-package 3.1.3 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/fixer.js CHANGED
@@ -7,14 +7,1253 @@ const fs = require('fs').promises;
7
7
  const path = require('path');
8
8
  const chalk = require('chalk');
9
9
 
10
+ /**
11
+ * Enhanced Alt Text Generator
12
+ * Tạo alt text thông minh và đa dạng dựa trên AI và ngữ cảnh
13
+ */
14
+ class EnhancedAltGenerator {
15
+ constructor(config = {}) {
16
+ this.config = {
17
+ language: config.language || 'ja',
18
+ creativity: config.creativity || 'balanced', // conservative, balanced, creative
19
+ includeEmotions: config.includeEmotions || false,
20
+ includeBrandContext: config.includeBrandContext || true,
21
+ maxLength: config.maxLength || 125,
22
+ ...config
23
+ };
24
+
25
+ // Từ điển đa ngôn ngữ
26
+ this.vocabulary = this.initializeVocabulary();
27
+ }
28
+
29
+ initializeVocabulary() {
30
+ return {
31
+ ja: {
32
+ types: {
33
+ person: ['人物', '人', '男性', '女性', '子供', '大人'],
34
+ object: ['物', '商品', 'アイテム', '製品'],
35
+ nature: ['自然', '風景', '景色', '環境'],
36
+ building: ['建物', '建築', '構造物', '施設'],
37
+ food: ['食べ物', '料理', '食品', 'グルメ'],
38
+ technology: ['技術', 'テクノロジー', '機器', 'デバイス'],
39
+ art: ['芸術', 'アート', '作品', 'デザイン'],
40
+ vehicle: ['乗り物', '車両', '交通手段']
41
+ },
42
+ emotions: {
43
+ positive: ['明るい', '楽しい', '美しい', '素晴らしい', '魅力的な'],
44
+ neutral: ['シンプルな', '清潔な', '整然とした', 'プロフェッショナルな'],
45
+ dynamic: ['活気のある', 'エネルギッシュな', 'ダイナミックな', '力強い']
46
+ },
47
+ actions: {
48
+ showing: ['示している', '表示している', '見せている'],
49
+ working: ['作業している', '働いている', '取り組んでいる'],
50
+ enjoying: ['楽しんでいる', '満喫している', '味わっている'],
51
+ creating: ['作成している', '制作している', '開発している']
52
+ },
53
+ contexts: {
54
+ business: ['ビジネス', '企業', '会社', '職場'],
55
+ education: ['教育', '学習', '研修', 'トレーニング'],
56
+ lifestyle: ['ライフスタイル', '日常', '生活', '暮らし'],
57
+ technology: ['IT', 'デジタル', 'オンライン', 'ウェブ']
58
+ }
59
+ },
60
+ en: {
61
+ types: {
62
+ person: ['person', 'people', 'individual', 'team', 'group'],
63
+ object: ['object', 'item', 'product', 'tool', 'equipment'],
64
+ nature: ['nature', 'landscape', 'scenery', 'environment'],
65
+ building: ['building', 'architecture', 'structure', 'facility'],
66
+ food: ['food', 'cuisine', 'dish', 'meal', 'delicacy'],
67
+ technology: ['technology', 'device', 'gadget', 'equipment'],
68
+ art: ['art', 'artwork', 'design', 'creation'],
69
+ vehicle: ['vehicle', 'transportation', 'automobile']
70
+ },
71
+ emotions: {
72
+ positive: ['bright', 'cheerful', 'beautiful', 'wonderful', 'attractive'],
73
+ neutral: ['simple', 'clean', 'organized', 'professional'],
74
+ dynamic: ['vibrant', 'energetic', 'dynamic', 'powerful']
75
+ },
76
+ actions: {
77
+ showing: ['showing', 'displaying', 'presenting'],
78
+ working: ['working', 'operating', 'engaging'],
79
+ enjoying: ['enjoying', 'experiencing', 'savoring'],
80
+ creating: ['creating', 'developing', 'building']
81
+ },
82
+ contexts: {
83
+ business: ['business', 'corporate', 'company', 'workplace'],
84
+ education: ['education', 'learning', 'training', 'academic'],
85
+ lifestyle: ['lifestyle', 'daily life', 'personal', 'casual'],
86
+ technology: ['technology', 'digital', 'online', 'web']
87
+ }
88
+ },
89
+ vi: {
90
+ types: {
91
+ person: ['người', 'con người', 'cá nhân', 'nhóm', 'đội ngũ'],
92
+ object: ['vật', 'đồ vật', 'sản phẩm', 'công cụ', 'thiết bị'],
93
+ nature: ['thiên nhiên', 'phong cảnh', 'cảnh quan', 'môi trường'],
94
+ building: ['tòa nhà', 'kiến trúc', 'công trình', 'cơ sở'],
95
+ food: ['thức ăn', 'món ăn', 'ẩm thực', 'đặc sản'],
96
+ technology: ['công nghệ', 'thiết bị', 'máy móc', 'kỹ thuật'],
97
+ art: ['nghệ thuật', 'tác phẩm', 'thiết kế', 'sáng tạo'],
98
+ vehicle: ['phương tiện', 'xe cộ', 'giao thông']
99
+ },
100
+ emotions: {
101
+ positive: ['tươi sáng', 'vui vẻ', 'đẹp đẽ', 'tuyệt vời', 'hấp dẫn'],
102
+ neutral: ['đơn giản', 'sạch sẽ', 'ngăn nắp', 'chuyên nghiệp'],
103
+ dynamic: ['sôi động', 'năng động', 'mạnh mẽ', 'đầy năng lượng']
104
+ },
105
+ actions: {
106
+ showing: ['đang hiển thị', 'đang trình bày', 'đang thể hiện'],
107
+ working: ['đang làm việc', 'đang hoạt động', 'đang thực hiện'],
108
+ enjoying: ['đang thưởng thức', 'đang tận hưởng', 'đang trải nghiệm'],
109
+ creating: ['đang tạo ra', 'đang phát triển', 'đang xây dựng']
110
+ },
111
+ contexts: {
112
+ business: ['kinh doanh', 'doanh nghiệp', 'công ty', 'nơi làm việc'],
113
+ education: ['giáo dục', 'học tập', 'đào tạo', 'học thuật'],
114
+ lifestyle: ['lối sống', 'cuộc sống', 'cá nhân', 'thường ngày'],
115
+ technology: ['công nghệ', 'số hóa', 'trực tuyến', 'web']
116
+ }
117
+ }
118
+ };
119
+ }
120
+
121
+ generateDiverseAltText(imgTag, htmlContent, analysis) {
122
+ const strategies = [
123
+ () => this.generateContextualAlt(analysis),
124
+ () => this.generateSemanticAlt(analysis),
125
+ () => this.generateEmotionalAlt(analysis),
126
+ () => this.generateActionBasedAlt(analysis),
127
+ () => this.generateBrandAwareAlt(analysis),
128
+ () => this.generateTechnicalAlt(analysis)
129
+ ];
130
+
131
+ const selectedStrategies = this.selectStrategies(strategies, analysis);
132
+
133
+ for (const strategy of selectedStrategies) {
134
+ const result = strategy();
135
+ if (result && this.validateAltText(result)) {
136
+ return this.refineAltText(result, analysis);
137
+ }
138
+ }
139
+
140
+ return this.generateFallbackAlt(analysis);
141
+ }
142
+
143
+ generateContextualAlt(analysis) {
144
+ const { context, structural, imageType } = analysis;
145
+
146
+ if (structural.figcaption) {
147
+ return this.enhanceWithVocabulary(structural.figcaption, imageType);
148
+ }
149
+
150
+ if (structural.parentLink) {
151
+ const linkText = this.extractLinkText(structural.parentLink);
152
+ if (linkText) {
153
+ return this.createLinkAlt(linkText, imageType);
154
+ }
155
+ }
156
+
157
+ const contextElements = this.extractContextElements(context);
158
+ if (contextElements.nearbyHeading) {
159
+ return this.createHeadingBasedAlt(contextElements.nearbyHeading, imageType);
160
+ }
161
+
162
+ if (contextElements.surroundingText) {
163
+ return this.createTextBasedAlt(contextElements.surroundingText, imageType);
164
+ }
165
+
166
+ return null;
167
+ }
168
+
169
+ generateSemanticAlt(analysis) {
170
+ const { src, imageType, context } = analysis;
171
+ const lang = this.config.language;
172
+ const vocab = this.vocabulary[lang];
173
+
174
+ const semanticInfo = this.analyzeSemanticContent(src, context);
175
+
176
+ if (!semanticInfo.mainSubject) return null;
177
+
178
+ let altParts = [];
179
+
180
+ const subjectWord = this.selectVocabularyWord(vocab.types[semanticInfo.category] || [], 'random');
181
+ if (subjectWord) {
182
+ altParts.push(subjectWord);
183
+ }
184
+
185
+ if (semanticInfo.description) {
186
+ altParts.push(semanticInfo.description);
187
+ }
188
+
189
+ if (semanticInfo.context && this.config.includeBrandContext) {
190
+ const contextWord = this.selectVocabularyWord(vocab.contexts[semanticInfo.context] || [], 'first');
191
+ if (contextWord) {
192
+ altParts.push(`${contextWord}の`);
193
+ }
194
+ }
195
+
196
+ return this.combineAltParts(altParts);
197
+ }
198
+
199
+ generateEmotionalAlt(analysis) {
200
+ if (!this.config.includeEmotions) return null;
201
+
202
+ const { imageType, context } = analysis;
203
+ const lang = this.config.language;
204
+ const vocab = this.vocabulary[lang];
205
+
206
+ const emotionalTone = this.analyzeEmotionalTone(context);
207
+
208
+ if (!emotionalTone) return null;
209
+
210
+ const emotionWords = vocab.emotions[emotionalTone] || [];
211
+ const emotionWord = this.selectVocabularyWord(emotionWords, 'random');
212
+
213
+ if (!emotionWord) return null;
214
+
215
+ const baseAlt = this.generateBasicAlt(analysis);
216
+
217
+ return lang === 'ja' ?
218
+ `${emotionWord}${baseAlt}` :
219
+ `${emotionWord} ${baseAlt}`;
220
+ }
221
+
222
+ generateActionBasedAlt(analysis) {
223
+ const { context, imageType } = analysis;
224
+ const lang = this.config.language;
225
+ const vocab = this.vocabulary[lang];
226
+
227
+ const detectedAction = this.detectAction(context);
228
+
229
+ if (!detectedAction) return null;
230
+
231
+ const actionWords = vocab.actions[detectedAction] || [];
232
+ const actionWord = this.selectVocabularyWord(actionWords, 'random');
233
+
234
+ if (!actionWord) return null;
235
+
236
+ const subject = this.detectSubject(context, imageType);
237
+
238
+ return lang === 'ja' ?
239
+ `${subject}${actionWord}様子` :
240
+ `${subject} ${actionWord}`;
241
+ }
242
+
243
+ generateBrandAwareAlt(analysis) {
244
+ if (!this.config.includeBrandContext) return null;
245
+
246
+ const { context, src } = analysis;
247
+
248
+ const brandInfo = this.extractBrandInfo(context, src);
249
+
250
+ if (!brandInfo.name) return null;
251
+
252
+ const baseAlt = this.generateBasicAlt(analysis);
253
+
254
+ return `${brandInfo.name}の${baseAlt}`;
255
+ }
256
+
257
+ generateTechnicalAlt(analysis) {
258
+ const { imageType, src, context } = analysis;
259
+
260
+ if (imageType !== 'data-visualization' && imageType !== 'complex') {
261
+ return null;
262
+ }
263
+
264
+ const technicalInfo = this.extractTechnicalInfo(context, src);
265
+
266
+ if (!technicalInfo.type) return null;
267
+
268
+ let altParts = [technicalInfo.type];
269
+
270
+ if (technicalInfo.data) {
271
+ altParts.push(technicalInfo.data);
272
+ }
273
+
274
+ if (technicalInfo.trend) {
275
+ altParts.push(technicalInfo.trend);
276
+ }
277
+
278
+ return this.combineAltParts(altParts);
279
+ }
280
+
281
+ selectStrategies(strategies, analysis) {
282
+ const { creativity } = this.config;
283
+ const { imageType } = analysis;
284
+
285
+ switch (creativity) {
286
+ case 'conservative':
287
+ return strategies.slice(0, 2);
288
+ case 'creative':
289
+ return strategies;
290
+ default:
291
+ if (imageType === 'decorative') {
292
+ return strategies.slice(0, 1);
293
+ } else if (imageType === 'data-visualization') {
294
+ return [strategies[0], strategies[5]];
295
+ } else {
296
+ return strategies.slice(0, 4);
297
+ }
298
+ }
299
+ }
300
+
301
+ validateAltText(altText) {
302
+ if (!altText || typeof altText !== 'string') return false;
303
+
304
+ const trimmed = altText.trim();
305
+
306
+ if (trimmed.length < 2 || trimmed.length > this.config.maxLength) {
307
+ return false;
308
+ }
309
+
310
+ const forbiddenWords = ['image', 'picture', 'photo', '画像', '写真'];
311
+ const hasForbidenWord = forbiddenWords.some(word =>
312
+ trimmed.toLowerCase().includes(word.toLowerCase())
313
+ );
314
+
315
+ if (hasForbidenWord) return false;
316
+
317
+ const placeholders = ['[', ']', 'placeholder', 'dummy'];
318
+ const hasPlaceholder = placeholders.some(placeholder =>
319
+ trimmed.toLowerCase().includes(placeholder)
320
+ );
321
+
322
+ return !hasPlaceholder;
323
+ }
324
+
325
+ refineAltText(altText, analysis) {
326
+ let refined = altText.trim();
327
+
328
+ refined = refined.replace(/[<>]/g, '');
329
+ refined = refined.replace(/\s+/g, ' ');
330
+
331
+ if (refined.length > this.config.maxLength) {
332
+ refined = refined.substring(0, this.config.maxLength - 3) + '...';
333
+ }
334
+
335
+ if (this.config.language === 'en') {
336
+ refined = refined.charAt(0).toUpperCase() + refined.slice(1);
337
+ }
338
+
339
+ return refined;
340
+ }
341
+
342
+ // Helper methods
343
+ extractContextElements(context) {
344
+ return {
345
+ nearbyHeading: this.findNearbyHeading(context),
346
+ surroundingText: this.extractSurroundingText(context),
347
+ listContext: this.findListContext(context),
348
+ tableContext: this.findTableContext(context)
349
+ };
350
+ }
351
+
352
+ analyzeSemanticContent(src, context) {
353
+ const srcLower = (src || '').toLowerCase();
354
+ const contextLower = context.toLowerCase();
355
+
356
+ let category = 'object';
357
+
358
+ if (this.containsKeywords(srcLower + ' ' + contextLower, ['person', 'people', 'man', 'woman', '人', '人物'])) {
359
+ category = 'person';
360
+ } else if (this.containsKeywords(srcLower + ' ' + contextLower, ['nature', 'landscape', '自然', '風景'])) {
361
+ category = 'nature';
362
+ } else if (this.containsKeywords(srcLower + ' ' + contextLower, ['building', 'architecture', '建物', '建築'])) {
363
+ category = 'building';
364
+ } else if (this.containsKeywords(srcLower + ' ' + contextLower, ['food', 'restaurant', '食べ物', '料理'])) {
365
+ category = 'food';
366
+ } else if (this.containsKeywords(srcLower + ' ' + contextLower, ['tech', 'computer', 'device', '技術', 'コンピューター'])) {
367
+ category = 'technology';
368
+ }
369
+
370
+ return {
371
+ category,
372
+ mainSubject: this.extractMainSubject(context),
373
+ description: this.extractDescription(context),
374
+ context: this.detectContextType(context)
375
+ };
376
+ }
377
+
378
+ analyzeEmotionalTone(context) {
379
+ const contextLower = context.toLowerCase();
380
+
381
+ if (this.containsKeywords(contextLower, ['success', 'happy', 'great', 'excellent', '成功', '素晴らしい', '優秀'])) {
382
+ return 'positive';
383
+ }
384
+
385
+ if (this.containsKeywords(contextLower, ['action', 'energy', 'dynamic', 'power', 'アクション', 'エネルギー', 'ダイナミック'])) {
386
+ return 'dynamic';
387
+ }
388
+
389
+ return 'neutral';
390
+ }
391
+
392
+ detectAction(context) {
393
+ const contextLower = context.toLowerCase();
394
+
395
+ if (this.containsKeywords(contextLower, ['show', 'display', 'present', '表示', '示す'])) {
396
+ return 'showing';
397
+ } else if (this.containsKeywords(contextLower, ['work', 'operate', 'use', '作業', '操作', '使用'])) {
398
+ return 'working';
399
+ } else if (this.containsKeywords(contextLower, ['enjoy', 'experience', 'taste', '楽しむ', '体験', '味わう'])) {
400
+ return 'enjoying';
401
+ } else if (this.containsKeywords(contextLower, ['create', 'build', 'develop', '作成', '構築', '開発'])) {
402
+ return 'creating';
403
+ }
404
+
405
+ return null;
406
+ }
407
+
408
+ detectSubject(context, imageType) {
409
+ const lang = this.config.language;
410
+ const vocab = this.vocabulary[lang];
411
+
412
+ const typeVocab = vocab.types[imageType] || vocab.types.object;
413
+ return this.selectVocabularyWord(typeVocab, 'first') || (lang === 'ja' ? '画像' : 'image');
414
+ }
415
+
416
+ extractBrandInfo(context, src) {
417
+ const brandPatterns = [
418
+ /company[^>]*>([^<]+)/i,
419
+ /brand[^>]*>([^<]+)/i,
420
+ /<title[^>]*>([^<]+)/i,
421
+ /alt\s*=\s*["']([^"']*logo[^"']*)["']/i
422
+ ];
423
+
424
+ for (const pattern of brandPatterns) {
425
+ const match = context.match(pattern);
426
+ if (match) {
427
+ return { name: match[1].trim().replace(/\s*logo\s*/i, '') };
428
+ }
429
+ }
430
+
431
+ return { name: null };
432
+ }
433
+
434
+ extractTechnicalInfo(context, src) {
435
+ const contextLower = context.toLowerCase();
436
+ const srcLower = (src || '').toLowerCase();
437
+
438
+ let type = null;
439
+ let data = null;
440
+ let trend = null;
441
+
442
+ if (this.containsKeywords(srcLower + ' ' + contextLower, ['chart', 'graph', 'グラフ', 'チャート'])) {
443
+ if (this.containsKeywords(contextLower, ['bar', 'column', '棒'])) {
444
+ type = this.config.language === 'ja' ? '棒グラフ' : 'Bar chart';
445
+ } else if (this.containsKeywords(contextLower, ['pie', '円'])) {
446
+ type = this.config.language === 'ja' ? '円グラフ' : 'Pie chart';
447
+ } else if (this.containsKeywords(contextLower, ['line', '線'])) {
448
+ type = this.config.language === 'ja' ? '線グラフ' : 'Line chart';
449
+ } else {
450
+ type = this.config.language === 'ja' ? 'グラフ' : 'Chart';
451
+ }
452
+ }
453
+
454
+ const numberPattern = /(\d+(?:\.\d+)?)\s*%?/g;
455
+ const numbers = contextLower.match(numberPattern);
456
+ if (numbers && numbers.length > 0) {
457
+ data = numbers.slice(0, 3).join(', ');
458
+ }
459
+
460
+ if (this.containsKeywords(contextLower, ['increase', 'rise', 'up', '増加', '上昇', '向上'])) {
461
+ trend = this.config.language === 'ja' ? '増加傾向' : 'increasing trend';
462
+ } else if (this.containsKeywords(contextLower, ['decrease', 'fall', 'down', '減少', '下降', '低下'])) {
463
+ trend = this.config.language === 'ja' ? '減少傾向' : 'decreasing trend';
464
+ }
465
+
466
+ return { type, data, trend };
467
+ }
468
+
469
+ containsKeywords(text, keywords) {
470
+ return keywords.some(keyword => text.includes(keyword.toLowerCase()));
471
+ }
472
+
473
+ selectVocabularyWord(words, strategy = 'random') {
474
+ if (!words || words.length === 0) return null;
475
+
476
+ switch (strategy) {
477
+ case 'first':
478
+ return words[0];
479
+ case 'random':
480
+ return words[Math.floor(Math.random() * words.length)];
481
+ case 'shortest':
482
+ return words.reduce((shortest, word) =>
483
+ word.length < shortest.length ? word : shortest
484
+ );
485
+ default:
486
+ return words[0];
487
+ }
488
+ }
489
+
490
+ combineAltParts(parts) {
491
+ const lang = this.config.language;
492
+ const validParts = parts.filter(part => part && part.trim());
493
+
494
+ if (validParts.length === 0) return null;
495
+
496
+ if (lang === 'ja') {
497
+ return validParts.join('');
498
+ } else {
499
+ return validParts.join(' ');
500
+ }
501
+ }
502
+
503
+ generateBasicAlt(analysis) {
504
+ const { imageType, src } = analysis;
505
+ const lang = this.config.language;
506
+ const vocab = this.vocabulary[lang];
507
+
508
+ const typeWords = vocab.types[imageType] || vocab.types.object;
509
+ return this.selectVocabularyWord(typeWords, 'first') || (lang === 'ja' ? '画像' : 'image');
510
+ }
511
+
512
+ generateFallbackAlt(analysis) {
513
+ const { src } = analysis;
514
+ const lang = this.config.language;
515
+
516
+ if (src) {
517
+ const filename = src.split('/').pop().split('.')[0];
518
+ const cleaned = filename.replace(/[-_]/g, ' ').trim();
519
+
520
+ if (cleaned && cleaned.length > 0) {
521
+ return lang === 'ja' ?
522
+ cleaned.replace(/\b\w/g, l => l.toUpperCase()) :
523
+ cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
524
+ }
525
+ }
526
+
527
+ return lang === 'ja' ? '画像' : 'Image';
528
+ }
529
+
530
+ enhanceWithVocabulary(text, imageType) {
531
+ const lang = this.config.language;
532
+ const vocab = this.vocabulary[lang];
533
+
534
+ const typeWords = vocab.types[imageType];
535
+ if (typeWords && typeWords.length > 0) {
536
+ const typeWord = this.selectVocabularyWord(typeWords, 'random');
537
+
538
+ return lang === 'ja' ?
539
+ `${typeWord}:${text}` :
540
+ `${typeWord}: ${text}`;
541
+ }
542
+
543
+ return text;
544
+ }
545
+
546
+ createLinkAlt(linkText, imageType) {
547
+ const lang = this.config.language;
548
+
549
+ return lang === 'ja' ?
550
+ `${linkText}へのリンク` :
551
+ `Link to ${linkText}`;
552
+ }
553
+
554
+ createHeadingBasedAlt(heading, imageType) {
555
+ const lang = this.config.language;
556
+ const vocab = this.vocabulary[lang];
557
+
558
+ const typeWords = vocab.types[imageType] || [];
559
+ const typeWord = this.selectVocabularyWord(typeWords, 'first');
560
+
561
+ if (typeWord) {
562
+ return lang === 'ja' ?
563
+ `${heading}の${typeWord}` :
564
+ `${typeWord} of ${heading}`;
565
+ }
566
+
567
+ return heading;
568
+ }
569
+
570
+ createTextBasedAlt(text, imageType) {
571
+ const words = text.split(/\s+/).filter(word => word.length > 2);
572
+ const keyWords = words.slice(0, 3).join(' ');
573
+
574
+ const lang = this.config.language;
575
+ const vocab = this.vocabulary[lang];
576
+
577
+ const typeWords = vocab.types[imageType] || [];
578
+ const typeWord = this.selectVocabularyWord(typeWords, 'first');
579
+
580
+ if (typeWord && keyWords) {
581
+ return lang === 'ja' ?
582
+ `${keyWords}の${typeWord}` :
583
+ `${typeWord} showing ${keyWords}`;
584
+ }
585
+
586
+ return keyWords || (lang === 'ja' ? '画像' : 'Image');
587
+ }
588
+
589
+ extractMainSubject(context) {
590
+ const sentences = context.split(/[.!?。!?]/);
591
+ const firstSentence = sentences[0];
592
+
593
+ if (firstSentence) {
594
+ const words = firstSentence.split(/\s+/);
595
+ return words.slice(0, 3).join(' ');
596
+ }
597
+
598
+ return null;
599
+ }
600
+
601
+ extractDescription(context) {
602
+ const descriptiveWords = context.match(/\b(beautiful|amazing|professional|modern|elegant|美しい|素晴らしい|プロフェッショナル|モダン|エレガント)\b/gi);
603
+
604
+ return descriptiveWords ? descriptiveWords[0] : null;
605
+ }
606
+
607
+ detectContextType(context) {
608
+ const contextLower = context.toLowerCase();
609
+
610
+ if (this.containsKeywords(contextLower, ['business', 'company', 'corporate', 'ビジネス', '企業', '会社'])) {
611
+ return 'business';
612
+ } else if (this.containsKeywords(contextLower, ['education', 'learning', 'school', '教育', '学習', '学校'])) {
613
+ return 'education';
614
+ } else if (this.containsKeywords(contextLower, ['technology', 'tech', 'digital', '技術', 'テクノロジー', 'デジタル'])) {
615
+ return 'technology';
616
+ } else if (this.containsKeywords(contextLower, ['lifestyle', 'personal', 'daily', 'ライフスタイル', '個人', '日常'])) {
617
+ return 'lifestyle';
618
+ }
619
+
620
+ return null;
621
+ }
622
+
623
+ findNearbyHeading(context) {
624
+ const headingRegex = /<h[1-6][^>]*>([^<]+)<\/h[1-6]>/gi;
625
+ const match = headingRegex.exec(context);
626
+ return match ? match[1].trim() : null;
627
+ }
628
+
629
+ extractSurroundingText(context) {
630
+ const textOnly = context.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
631
+ const words = textOnly.split(' ');
632
+
633
+ const meaningfulWords = words.filter(word =>
634
+ word.length > 2 && !/^\d+$/.test(word) && !/^[^\w]+$/.test(word)
635
+ ).slice(0, 8);
636
+
637
+ return meaningfulWords.join(' ');
638
+ }
639
+
640
+ findListContext(context) {
641
+ const listItemRegex = /<li[^>]*>([^<]+)<\/li>/gi;
642
+ const match = listItemRegex.exec(context);
643
+ return match ? match[1].trim() : null;
644
+ }
645
+
646
+ findTableContext(context) {
647
+ const tableCellRegex = /<t[hd][^>]*>([^<]+)<\/t[hd]>/gi;
648
+ const match = tableCellRegex.exec(context);
649
+ return match ? match[1].trim() : null;
650
+ }
651
+
652
+ extractLinkText(linkTag) {
653
+ const textMatch = linkTag.match(/>([^<]+)</);
654
+ return textMatch ? textMatch[1].trim() : null;
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Enhanced Alt Attribute Checker
660
+ * Cải tiến tính năng kiểm tra alt attribute đa dạng và toàn diện hơn
661
+ */
662
+ class EnhancedAltChecker {
663
+ constructor(config = {}) {
664
+ this.config = {
665
+ language: config.language || 'ja',
666
+ strictMode: config.strictMode || false,
667
+ checkDecorative: config.checkDecorative || true,
668
+ checkInformative: config.checkInformative || true,
669
+ checkComplex: config.checkComplex || true,
670
+ maxAltLength: config.maxAltLength || 125,
671
+ minAltLength: config.minAltLength || 3,
672
+ ...config
673
+ };
674
+ }
675
+
676
+ analyzeAltAttributes(content) {
677
+ const issues = [];
678
+ const imgRegex = /<img[^>]*>/gi;
679
+ const imgTags = content.match(imgRegex) || [];
680
+
681
+ imgTags.forEach((imgTag, index) => {
682
+ const analysis = this.analyzeImageContext(imgTag, content, index);
683
+ const altIssues = this.checkAltQuality(imgTag, analysis);
684
+
685
+ if (altIssues.length > 0) {
686
+ issues.push({
687
+ imageIndex: index + 1,
688
+ imgTag: imgTag,
689
+ src: analysis.src,
690
+ context: analysis.context,
691
+ issues: altIssues,
692
+ recommendations: this.generateRecommendations(imgTag, analysis)
693
+ });
694
+ }
695
+ });
696
+
697
+ return issues;
698
+ }
699
+
700
+ analyzeImageContext(imgTag, htmlContent, imgIndex) {
701
+ const src = this.extractAttribute(imgTag, 'src');
702
+ const alt = this.extractAttribute(imgTag, 'alt');
703
+ const title = this.extractAttribute(imgTag, 'title');
704
+ const ariaLabel = this.extractAttribute(imgTag, 'aria-label');
705
+ const role = this.extractAttribute(imgTag, 'role');
706
+
707
+ const position = this.findImagePosition(imgTag, htmlContent, imgIndex);
708
+ const surroundingContext = this.extractSurroundingContext(htmlContent, position, 1000);
709
+
710
+ const imageType = this.classifyImageType(imgTag, surroundingContext, src);
711
+
712
+ const structuralContext = this.analyzeStructuralContext(surroundingContext, imgTag);
713
+
714
+ return {
715
+ src,
716
+ alt,
717
+ title,
718
+ ariaLabel,
719
+ role,
720
+ imageType,
721
+ context: surroundingContext,
722
+ structural: structuralContext,
723
+ position
724
+ };
725
+ }
726
+
727
+ checkAltQuality(imgTag, analysis) {
728
+ const issues = [];
729
+ const { alt, imageType, src } = analysis;
730
+
731
+ if (!this.hasAttribute(imgTag, 'alt')) {
732
+ issues.push({
733
+ type: 'MISSING_ALT',
734
+ severity: 'ERROR',
735
+ message: 'Thiếu thuộc tính alt',
736
+ description: 'Tất cả hình ảnh phải có thuộc tính alt'
737
+ });
738
+ return issues;
739
+ }
740
+
741
+ if (alt === '') {
742
+ if (imageType === 'decorative') {
743
+ return issues;
744
+ } else {
745
+ issues.push({
746
+ type: 'EMPTY_ALT',
747
+ severity: 'ERROR',
748
+ message: 'Alt text rỗng cho hình ảnh có nội dung',
749
+ description: 'Hình ảnh có nội dung cần alt text mô tả'
750
+ });
751
+ }
752
+ }
753
+
754
+ if (alt && alt.length > this.config.maxAltLength) {
755
+ issues.push({
756
+ type: 'ALT_TOO_LONG',
757
+ severity: 'WARNING',
758
+ message: `Alt text quá dài (${alt.length} ký tự)`,
759
+ description: `Nên giới hạn dưới ${this.config.maxAltLength} ký tự`
760
+ });
761
+ }
762
+
763
+ if (alt && alt.length < this.config.minAltLength && imageType !== 'decorative') {
764
+ issues.push({
765
+ type: 'ALT_TOO_SHORT',
766
+ severity: 'WARNING',
767
+ message: `Alt text quá ngắn (${alt.length} ký tự)`,
768
+ description: 'Alt text nên mô tả đầy đủ nội dung hình ảnh'
769
+ });
770
+ }
771
+
772
+ const contentIssues = this.checkAltContent(alt, src, imageType);
773
+ issues.push(...contentIssues);
774
+
775
+ const consistencyIssues = this.checkAttributeConsistency(analysis);
776
+ issues.push(...consistencyIssues);
777
+
778
+ const typeSpecificIssues = this.checkTypeSpecificRequirements(analysis);
779
+ issues.push(...typeSpecificIssues);
780
+
781
+ return issues;
782
+ }
783
+
784
+ classifyImageType(imgTag, context, src) {
785
+ const srcLower = (src || '').toLowerCase();
786
+ const contextLower = context.toLowerCase();
787
+
788
+ if (this.isDecorativeImage(imgTag, context, src)) {
789
+ return 'decorative';
790
+ }
791
+
792
+ if (this.isDataVisualization(srcLower, contextLower)) {
793
+ return 'data-visualization';
794
+ }
795
+
796
+ if (this.isComplexImage(srcLower, contextLower)) {
797
+ return 'complex';
798
+ }
799
+
800
+ if (this.isLogo(srcLower, contextLower)) {
801
+ return 'logo';
802
+ }
803
+
804
+ if (this.isFunctionalIcon(imgTag, context, srcLower)) {
805
+ return 'functional-icon';
806
+ }
807
+
808
+ if (this.isContentImage(contextLower)) {
809
+ return 'content';
810
+ }
811
+
812
+ return 'informative';
813
+ }
814
+
815
+ checkAltContent(alt, src, imageType) {
816
+ const issues = [];
817
+
818
+ if (!alt) return issues;
819
+
820
+ const altLower = alt.toLowerCase();
821
+ const srcLower = (src || '').toLowerCase();
822
+
823
+ const forbiddenWords = [
824
+ 'image', 'picture', 'photo', 'graphic', 'img',
825
+ '画像', '写真', 'イメージ', '図', '図表'
826
+ ];
827
+
828
+ const foundForbidden = forbiddenWords.find(word => altLower.includes(word));
829
+ if (foundForbidden) {
830
+ issues.push({
831
+ type: 'REDUNDANT_WORDS',
832
+ severity: 'WARNING',
833
+ message: `Alt text chứa từ thừa: "${foundForbidden}"`,
834
+ description: 'Không cần nói "hình ảnh" trong alt text'
835
+ });
836
+ }
837
+
838
+ if (src) {
839
+ const filename = src.split('/').pop().split('.')[0];
840
+ if (altLower.includes(filename.toLowerCase())) {
841
+ issues.push({
842
+ type: 'FILENAME_IN_ALT',
843
+ severity: 'WARNING',
844
+ message: 'Alt text chứa tên file',
845
+ description: 'Nên mô tả nội dung thay vì tên file'
846
+ });
847
+ }
848
+ }
849
+
850
+ const genericTexts = [
851
+ 'click here', 'read more', 'learn more', 'see more',
852
+ 'ここをクリック', '詳細', 'もっと見る'
853
+ ];
854
+
855
+ const foundGeneric = genericTexts.find(text => altLower.includes(text));
856
+ if (foundGeneric) {
857
+ issues.push({
858
+ type: 'GENERIC_ALT',
859
+ severity: 'ERROR',
860
+ message: `Alt text quá chung chung: "${foundGeneric}"`,
861
+ description: 'Nên mô tả cụ thể nội dung hình ảnh'
862
+ });
863
+ }
864
+
865
+ if (imageType === 'data-visualization' && !this.hasDataDescription(alt)) {
866
+ issues.push({
867
+ type: 'MISSING_DATA_DESCRIPTION',
868
+ severity: 'ERROR',
869
+ message: 'Biểu đồ thiếu mô tả dữ liệu',
870
+ description: 'Biểu đồ cần mô tả xu hướng và dữ liệu chính'
871
+ });
872
+ }
873
+
874
+ return issues;
875
+ }
876
+
877
+ generateRecommendations(imgTag, analysis) {
878
+ const recommendations = [];
879
+ const { imageType, context, src, alt } = analysis;
880
+
881
+ switch (imageType) {
882
+ case 'decorative':
883
+ recommendations.push({
884
+ type: 'DECORATIVE',
885
+ suggestion: 'alt=""',
886
+ reason: 'Hình trang trí nên có alt rỗng'
887
+ });
888
+ break;
889
+
890
+ case 'logo':
891
+ const brandName = this.extractBrandName(context, src);
892
+ recommendations.push({
893
+ type: 'LOGO',
894
+ suggestion: brandName ? `alt="${brandName} logo"` : 'alt="Company logo"',
895
+ reason: 'Logo nên bao gồm tên thương hiệu'
896
+ });
897
+ break;
898
+
899
+ case 'functional-icon':
900
+ const action = this.extractIconAction(context, imgTag);
901
+ recommendations.push({
902
+ type: 'FUNCTIONAL',
903
+ suggestion: action ? `alt="${action}"` : 'alt="[Mô tả chức năng]"',
904
+ reason: 'Icon chức năng nên mô tả hành động'
905
+ });
906
+ break;
907
+
908
+ case 'data-visualization':
909
+ recommendations.push({
910
+ type: 'DATA_VIZ',
911
+ suggestion: 'alt="[Loại biểu đồ]: [Xu hướng chính] [Dữ liệu quan trọng]"',
912
+ reason: 'Biểu đồ cần mô tả loại, xu hướng và dữ liệu chính'
913
+ });
914
+ break;
915
+
916
+ case 'complex':
917
+ recommendations.push({
918
+ type: 'COMPLEX',
919
+ suggestion: 'alt="[Mô tả ngắn]" + longdesc hoặc mô tả chi tiết bên dưới',
920
+ reason: 'Hình phức tạp cần mô tả ngắn trong alt và mô tả dài riêng'
921
+ });
922
+ break;
923
+
924
+ default:
925
+ const contextualAlt = this.generateContextualAlt(analysis);
926
+ recommendations.push({
927
+ type: 'CONTEXTUAL',
928
+ suggestion: `alt="${contextualAlt}"`,
929
+ reason: 'Mô tả dựa trên ngữ cảnh xung quanh'
930
+ });
931
+ }
932
+
933
+ return recommendations;
934
+ }
935
+
936
+ generateContextualAlt(analysis) {
937
+ const { context, src, structural } = analysis;
938
+
939
+ const nearbyHeading = this.findNearbyHeading(context);
940
+ if (nearbyHeading) {
941
+ return nearbyHeading;
942
+ }
943
+
944
+ if (structural.parentLink) {
945
+ const linkText = this.extractLinkText(structural.parentLink);
946
+ if (linkText) {
947
+ return linkText;
948
+ }
949
+ }
950
+
951
+ if (structural.figcaption) {
952
+ return structural.figcaption;
953
+ }
954
+
955
+ const surroundingText = this.extractSurroundingText(context);
956
+ if (surroundingText) {
957
+ return surroundingText;
958
+ }
959
+
960
+ return this.generateFallbackAlt(src);
961
+ }
962
+
963
+ // Helper methods
964
+ isDecorativeImage(imgTag, context, src) {
965
+ const decorativeIndicators = [
966
+ 'decoration', 'border', 'spacer', 'divider',
967
+ 'background', 'texture', 'pattern'
968
+ ];
969
+
970
+ const srcLower = (src || '').toLowerCase();
971
+ return decorativeIndicators.some(indicator => srcLower.includes(indicator));
972
+ }
973
+
974
+ isDataVisualization(src, context) {
975
+ const dataIndicators = [
976
+ 'chart', 'graph', 'plot', 'diagram', 'infographic',
977
+ 'グラフ', '図表', 'チャート'
978
+ ];
979
+
980
+ return dataIndicators.some(indicator =>
981
+ src.includes(indicator) || context.includes(indicator)
982
+ );
983
+ }
984
+
985
+ isComplexImage(src, context) {
986
+ const complexIndicators = [
987
+ 'flowchart', 'timeline', 'map', 'blueprint', 'schematic',
988
+ 'フローチャート', '地図', '設計図'
989
+ ];
990
+
991
+ return complexIndicators.some(indicator =>
992
+ src.includes(indicator) || context.includes(indicator)
993
+ );
994
+ }
995
+
996
+ isLogo(src, context) {
997
+ const logoIndicators = ['logo', 'brand', 'ロゴ', 'ブランド'];
998
+ return logoIndicators.some(indicator =>
999
+ src.includes(indicator) || context.includes(indicator)
1000
+ );
1001
+ }
1002
+
1003
+ isFunctionalIcon(imgTag, context, src) {
1004
+ const iconIndicators = ['icon', 'btn', 'button', 'アイコン', 'ボタン'];
1005
+ const hasClickHandler = /onclick|href/i.test(context);
1006
+
1007
+ return (iconIndicators.some(indicator => src.includes(indicator)) || hasClickHandler);
1008
+ }
1009
+
1010
+ isContentImage(context) {
1011
+ const contentIndicators = [
1012
+ 'article', 'content', 'story', 'news',
1013
+ '記事', 'コンテンツ', 'ニュース'
1014
+ ];
1015
+
1016
+ return contentIndicators.some(indicator => context.includes(indicator));
1017
+ }
1018
+
1019
+ extractAttribute(imgTag, attributeName) {
1020
+ const regex = new RegExp(`${attributeName}\\s*=\\s*["']([^"']*)["']`, 'i');
1021
+ const match = imgTag.match(regex);
1022
+ return match ? match[1] : null;
1023
+ }
1024
+
1025
+ hasAttribute(imgTag, attributeName) {
1026
+ const regex = new RegExp(`${attributeName}\\s*=`, 'i');
1027
+ return regex.test(imgTag);
1028
+ }
1029
+
1030
+ findImagePosition(imgTag, htmlContent, imgIndex) {
1031
+ const imgRegex = /<img[^>]*>/gi;
1032
+ let match;
1033
+ let currentIndex = 0;
1034
+
1035
+ while ((match = imgRegex.exec(htmlContent)) !== null) {
1036
+ if (currentIndex === imgIndex) {
1037
+ return match.index;
1038
+ }
1039
+ currentIndex++;
1040
+ }
1041
+
1042
+ return -1;
1043
+ }
1044
+
1045
+ extractSurroundingContext(htmlContent, position, range) {
1046
+ if (position === -1) return '';
1047
+
1048
+ const start = Math.max(0, position - range);
1049
+ const end = Math.min(htmlContent.length, position + range);
1050
+
1051
+ return htmlContent.substring(start, end);
1052
+ }
1053
+
1054
+ analyzeStructuralContext(context, imgTag) {
1055
+ return {
1056
+ parentLink: this.findParentElement(context, imgTag, 'a'),
1057
+ parentFigure: this.findParentElement(context, imgTag, 'figure'),
1058
+ figcaption: this.findSiblingElement(context, imgTag, 'figcaption'),
1059
+ parentButton: this.findParentElement(context, imgTag, 'button')
1060
+ };
1061
+ }
1062
+
1063
+ findParentElement(context, imgTag, tagName) {
1064
+ const imgIndex = context.indexOf(imgTag);
1065
+ if (imgIndex === -1) return null;
1066
+
1067
+ const beforeImg = context.substring(0, imgIndex);
1068
+ const openTagRegex = new RegExp(`<${tagName}[^>]*>`, 'gi');
1069
+ const closeTagRegex = new RegExp(`</${tagName}>`, 'gi');
1070
+
1071
+ let openTags = 0;
1072
+ let lastOpenMatch = null;
1073
+
1074
+ let match;
1075
+ while ((match = openTagRegex.exec(beforeImg)) !== null) {
1076
+ lastOpenMatch = match;
1077
+ openTags++;
1078
+ }
1079
+
1080
+ while ((match = closeTagRegex.exec(beforeImg)) !== null) {
1081
+ openTags--;
1082
+ }
1083
+
1084
+ return openTags > 0 ? lastOpenMatch[0] : null;
1085
+ }
1086
+
1087
+ findSiblingElement(context, imgTag, tagName) {
1088
+ const imgIndex = context.indexOf(imgTag);
1089
+ if (imgIndex === -1) return null;
1090
+
1091
+ const afterImg = context.substring(imgIndex + imgTag.length);
1092
+ const siblingRegex = new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'i');
1093
+ const match = afterImg.match(siblingRegex);
1094
+
1095
+ return match ? match[1].trim() : null;
1096
+ }
1097
+
1098
+ checkAttributeConsistency(analysis) {
1099
+ const issues = [];
1100
+ const { alt, title, ariaLabel } = analysis;
1101
+
1102
+ if (alt && ariaLabel && alt !== ariaLabel) {
1103
+ issues.push({
1104
+ type: 'INCONSISTENT_LABELS',
1105
+ severity: 'WARNING',
1106
+ message: 'Alt text và aria-label không nhất quán',
1107
+ description: 'Alt và aria-label nên có nội dung giống nhau'
1108
+ });
1109
+ }
1110
+
1111
+ if (title && alt && title === alt) {
1112
+ issues.push({
1113
+ type: 'REDUNDANT_TITLE',
1114
+ severity: 'INFO',
1115
+ message: 'Title attribute trùng với alt text',
1116
+ description: 'Title có thể bỏ đi để tránh lặp lại'
1117
+ });
1118
+ }
1119
+
1120
+ return issues;
1121
+ }
1122
+
1123
+ checkTypeSpecificRequirements(analysis) {
1124
+ const issues = [];
1125
+ const { imageType, alt, structural } = analysis;
1126
+
1127
+ switch (imageType) {
1128
+ case 'functional-icon':
1129
+ if (structural.parentLink && !alt) {
1130
+ issues.push({
1131
+ type: 'FUNCTIONAL_MISSING_ALT',
1132
+ severity: 'ERROR',
1133
+ message: 'Icon chức năng trong link thiếu alt text',
1134
+ description: 'Icon có chức năng phải có alt mô tả hành động'
1135
+ });
1136
+ }
1137
+ break;
1138
+
1139
+ case 'logo':
1140
+ if (alt && !alt.toLowerCase().includes('logo')) {
1141
+ issues.push({
1142
+ type: 'LOGO_MISSING_CONTEXT',
1143
+ severity: 'WARNING',
1144
+ message: 'Logo thiếu từ khóa "logo" trong alt text',
1145
+ description: 'Logo nên bao gồm từ "logo" để rõ ràng'
1146
+ });
1147
+ }
1148
+ break;
1149
+ }
1150
+
1151
+ return issues;
1152
+ }
1153
+
1154
+ hasDataDescription(alt) {
1155
+ const dataKeywords = [
1156
+ 'increase', 'decrease', 'trend', 'percent', '%',
1157
+ '増加', '減少', 'トレンド', 'パーセント'
1158
+ ];
1159
+
1160
+ return dataKeywords.some(keyword =>
1161
+ alt.toLowerCase().includes(keyword.toLowerCase())
1162
+ );
1163
+ }
1164
+
1165
+ findNearbyHeading(context) {
1166
+ const headingRegex = /<h[1-6][^>]*>([^<]+)<\/h[1-6]>/gi;
1167
+ const match = headingRegex.exec(context);
1168
+ return match ? match[1].trim() : null;
1169
+ }
1170
+
1171
+ extractLinkText(linkTag) {
1172
+ const textMatch = linkTag.match(/>([^<]+)</);
1173
+ return textMatch ? textMatch[1].trim() : null;
1174
+ }
1175
+
1176
+ extractSurroundingText(context) {
1177
+ const textOnly = context.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
1178
+ const words = textOnly.split(' ');
1179
+
1180
+ const meaningfulWords = words.filter(word =>
1181
+ word.length > 2 && !/^\d+$/.test(word)
1182
+ ).slice(0, 5);
1183
+
1184
+ return meaningfulWords.join(' ');
1185
+ }
1186
+
1187
+ generateFallbackAlt(src) {
1188
+ if (!src) return '画像';
1189
+
1190
+ const filename = src.split('/').pop().split('.')[0];
1191
+
1192
+ return filename
1193
+ .replace(/[-_]/g, ' ')
1194
+ .replace(/\b\w/g, l => l.toUpperCase())
1195
+ .trim() || '画像';
1196
+ }
1197
+
1198
+ extractBrandName(context, src) {
1199
+ const brandPatterns = [
1200
+ /company[^>]*>([^<]+)/i,
1201
+ /brand[^>]*>([^<]+)/i,
1202
+ /logo[^>]*>([^<]+)/i
1203
+ ];
1204
+
1205
+ for (const pattern of brandPatterns) {
1206
+ const match = context.match(pattern);
1207
+ if (match) return match[1].trim();
1208
+ }
1209
+
1210
+ return null;
1211
+ }
1212
+
1213
+ extractIconAction(context, imgTag) {
1214
+ const actionPatterns = [
1215
+ /title\s*=\s*["']([^"']+)["']/i,
1216
+ /aria-label\s*=\s*["']([^"']+)["']/i,
1217
+ /onclick[^>]*>([^<]+)/i
1218
+ ];
1219
+
1220
+ for (const pattern of actionPatterns) {
1221
+ const match = imgTag.match(pattern) || context.match(pattern);
1222
+ if (match) return match[1].trim();
1223
+ }
1224
+
1225
+ return null;
1226
+ }
1227
+ }
1228
+
10
1229
  class AccessibilityFixer {
11
1230
  constructor(config = {}) {
12
1231
  this.config = {
13
1232
  backupFiles: config.backupFiles === true,
14
1233
  language: config.language || 'ja',
15
1234
  dryRun: config.dryRun || false,
1235
+ enhancedAltMode: config.enhancedAltMode || false,
1236
+ altCreativity: config.altCreativity || 'balanced', // conservative, balanced, creative
1237
+ includeEmotions: config.includeEmotions || false,
1238
+ strictAltChecking: config.strictAltChecking || false,
16
1239
  ...config
17
1240
  };
1241
+
1242
+ // Initialize enhanced alt tools
1243
+ this.enhancedAltChecker = new EnhancedAltChecker({
1244
+ language: this.config.language,
1245
+ strictMode: this.config.strictAltChecking,
1246
+ checkDecorative: true,
1247
+ checkInformative: true,
1248
+ checkComplex: true
1249
+ });
1250
+
1251
+ this.enhancedAltGenerator = new EnhancedAltGenerator({
1252
+ language: this.config.language,
1253
+ creativity: this.config.altCreativity,
1254
+ includeEmotions: this.config.includeEmotions,
1255
+ includeBrandContext: true
1256
+ });
18
1257
  }
19
1258
 
20
1259
  async fixHtmlLang(directory = '.') {
@@ -57,18 +1296,50 @@ class AccessibilityFixer {
57
1296
  const htmlFiles = await this.findHtmlFiles(directory);
58
1297
  const results = [];
59
1298
  let totalIssuesFound = 0;
1299
+ let enhancedIssues = []; // Declare here to avoid scope issues
60
1300
 
61
1301
  for (const file of htmlFiles) {
62
1302
  try {
63
1303
  const content = await fs.readFile(file, 'utf8');
64
- const issues = this.analyzeAltAttributes(content);
65
1304
 
66
- if (issues.length > 0) {
67
- console.log(chalk.cyan(`\n📁 ${file}:`));
68
- issues.forEach(issue => {
69
- console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
70
- totalIssuesFound++;
71
- });
1305
+ // Use enhanced alt checker if enabled
1306
+ if (this.config.enhancedAltMode) {
1307
+ enhancedIssues = this.enhancedAltChecker.analyzeAltAttributes(content);
1308
+
1309
+ if (enhancedIssues.length > 0) {
1310
+ console.log(chalk.cyan(`\n📁 ${file}:`));
1311
+ enhancedIssues.forEach(issue => {
1312
+ console.log(chalk.yellow(` 🔍 Image ${issue.imageIndex} (${issue.src}):`));
1313
+ issue.issues.forEach(subIssue => {
1314
+ const icon = subIssue.severity === 'ERROR' ? '❌' :
1315
+ subIssue.severity === 'WARNING' ? '⚠️' : 'ℹ️';
1316
+ console.log(chalk.yellow(` ${icon} ${subIssue.message}`));
1317
+ console.log(chalk.gray(` ${subIssue.description}`));
1318
+ });
1319
+
1320
+ // Show recommendations
1321
+ if (issue.recommendations.length > 0) {
1322
+ console.log(chalk.blue(` 💡 Recommendations:`));
1323
+ issue.recommendations.forEach(rec => {
1324
+ console.log(chalk.blue(` ${rec.suggestion}`));
1325
+ console.log(chalk.gray(` ${rec.reason}`));
1326
+ });
1327
+ }
1328
+ totalIssuesFound += issue.issues.length;
1329
+ });
1330
+ }
1331
+ } else {
1332
+ // Use original analysis
1333
+ const issues = this.analyzeAltAttributes(content);
1334
+
1335
+ if (issues.length > 0) {
1336
+ console.log(chalk.cyan(`\n📁 ${file}:`));
1337
+ issues.forEach(issue => {
1338
+ console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
1339
+ totalIssuesFound++;
1340
+ });
1341
+ }
1342
+ enhancedIssues = issues; // For consistency in results calculation
72
1343
  }
73
1344
 
74
1345
  const fixed = this.fixAltAttributes(content);
@@ -83,9 +1354,11 @@ class AccessibilityFixer {
83
1354
  }
84
1355
 
85
1356
  console.log(chalk.green(`✅ Fixed alt attributes in: ${file}`));
86
- results.push({ file, status: 'fixed', issues: issues.length });
1357
+ results.push({ file, status: 'fixed', issues: this.config.enhancedAltMode ?
1358
+ enhancedIssues.reduce((sum, ei) => sum + (ei.issues ? ei.issues.length : 1), 0) : enhancedIssues.length });
87
1359
  } else {
88
- results.push({ file, status: 'no-change', issues: issues.length });
1360
+ results.push({ file, status: 'no-change', issues: this.config.enhancedAltMode ?
1361
+ enhancedIssues.reduce((sum, ei) => sum + (ei.issues ? ei.issues.length : 1), 0) : enhancedIssues.length });
89
1362
  }
90
1363
  } catch (error) {
91
1364
  console.error(chalk.red(`❌ Error processing ${file}: ${error.message}`));
@@ -94,6 +1367,9 @@ class AccessibilityFixer {
94
1367
  }
95
1368
 
96
1369
  console.log(chalk.blue(`\n📊 Summary: Found ${totalIssuesFound} alt attribute issues across ${results.length} files`));
1370
+ if (this.config.enhancedAltMode) {
1371
+ console.log(chalk.gray(` 🔍 Enhanced analysis mode: Comprehensive quality checking enabled`));
1372
+ }
97
1373
  return results;
98
1374
  }
99
1375
 
@@ -245,6 +1521,21 @@ class AccessibilityFixer {
245
1521
  }
246
1522
 
247
1523
  generateAltText(imgTag, htmlContent = '', imgIndex = 0) {
1524
+ // Use enhanced alt generator if enabled
1525
+ if (this.config.enhancedAltMode) {
1526
+ try {
1527
+ const analysis = this.enhancedAltChecker.analyzeImageContext(imgTag, htmlContent, imgIndex);
1528
+ const enhancedAlt = this.enhancedAltGenerator.generateDiverseAltText(imgTag, htmlContent, analysis);
1529
+
1530
+ if (enhancedAlt && enhancedAlt.trim().length > 0) {
1531
+ return enhancedAlt;
1532
+ }
1533
+ } catch (error) {
1534
+ console.warn(chalk.yellow(`⚠️ Enhanced alt generation failed, falling back to basic mode: ${error.message}`));
1535
+ }
1536
+ }
1537
+
1538
+ // Fallback to original method
248
1539
  const src = imgTag.match(/src\s*=\s*["']([^"']+)["']/i);
249
1540
  const srcValue = src ? src[1].toLowerCase() : '';
250
1541
 
@@ -943,7 +2234,8 @@ class AccessibilityFixer {
943
2234
  buttons: [],
944
2235
  links: [],
945
2236
  landmarks: [],
946
- headings: [] // Analysis only
2237
+ headings: [], // Analysis only
2238
+ brokenLinks: [] // Analysis only
947
2239
  };
948
2240
 
949
2241
  try {
@@ -979,8 +2271,12 @@ class AccessibilityFixer {
979
2271
  console.log(chalk.yellow('\n📑 Step 8: Heading analysis...'));
980
2272
  results.headings = await this.analyzeHeadings(directory);
981
2273
 
982
- // Step 9: Cleanup duplicate roles
983
- console.log(chalk.yellow('\n🧹 Step 9: Cleanup duplicate roles...'));
2274
+ // Step 9: Check broken links (no auto-fix)
2275
+ console.log(chalk.yellow('\n🔗 Step 9: Broken links check...'));
2276
+ results.brokenLinks = await this.checkBrokenLinks(directory);
2277
+
2278
+ // Step 10: Cleanup duplicate roles
2279
+ console.log(chalk.yellow('\n🧹 Step 10: Cleanup duplicate roles...'));
984
2280
  results.cleanup = await this.cleanupDuplicateRoles(directory);
985
2281
 
986
2282
  // Summary
@@ -1567,6 +2863,215 @@ class AccessibilityFixer {
1567
2863
  return fixed;
1568
2864
  }
1569
2865
 
2866
+ // Check for broken links and 404 resources
2867
+ async checkBrokenLinks(directory = '.') {
2868
+ console.log(chalk.blue('🔗 Checking for broken links and 404 resources...'));
2869
+
2870
+ const htmlFiles = await this.findHtmlFiles(directory);
2871
+ const results = [];
2872
+
2873
+ for (const file of htmlFiles) {
2874
+ try {
2875
+ const content = await fs.readFile(file, 'utf8');
2876
+ const issues = await this.analyzeBrokenLinks(content, file);
2877
+
2878
+ if (issues.length > 0) {
2879
+ console.log(chalk.cyan(`\n📁 ${file}:`));
2880
+ issues.forEach(issue => {
2881
+ console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
2882
+ if (issue.suggestion) {
2883
+ console.log(chalk.gray(` 💡 ${issue.suggestion}`));
2884
+ }
2885
+ });
2886
+ }
2887
+
2888
+ results.push({ file, status: 'analyzed', issues: issues.length, brokenLinks: issues });
2889
+ } catch (error) {
2890
+ console.error(chalk.red(`❌ Error processing ${file}: ${error.message}`));
2891
+ results.push({ file, status: 'error', error: error.message });
2892
+ }
2893
+ }
2894
+
2895
+ console.log(chalk.blue(`\n📊 Summary: Analyzed links in ${results.length} files`));
2896
+ console.log(chalk.gray('💡 Broken link issues require manual review and cannot be auto-fixed'));
2897
+ return results;
2898
+ }
2899
+
2900
+ async analyzeBrokenLinks(content, filePath) {
2901
+ const issues = [];
2902
+ const path = require('path');
2903
+ const http = require('http');
2904
+ const https = require('https');
2905
+ const { URL } = require('url');
2906
+
2907
+ // Extract all links and resources
2908
+ const linkPatterns = [
2909
+ // Anchor links
2910
+ { pattern: /<a[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Link', element: 'a' },
2911
+ // Images
2912
+ { pattern: /<img[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Image', element: 'img' },
2913
+ // CSS links
2914
+ { pattern: /<link[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'CSS', element: 'link' },
2915
+ // Script sources
2916
+ { pattern: /<script[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Script', element: 'script' },
2917
+ // Video sources
2918
+ { pattern: /<video[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Video', element: 'video' },
2919
+ // Audio sources
2920
+ { pattern: /<audio[^>]*src\s*=\s*["']([^"']+)["'][^>]*>/gi, type: 'Audio', element: 'audio' }
2921
+ ];
2922
+
2923
+ const baseDir = path.dirname(filePath);
2924
+
2925
+ for (const linkPattern of linkPatterns) {
2926
+ let match;
2927
+ while ((match = linkPattern.pattern.exec(content)) !== null) {
2928
+ const url = match[1];
2929
+ const issue = await this.checkSingleLink(url, baseDir, linkPattern.type, linkPattern.element);
2930
+ if (issue) {
2931
+ issues.push(issue);
2932
+ }
2933
+ }
2934
+ }
2935
+
2936
+ return issues;
2937
+ }
2938
+
2939
+ async checkSingleLink(url, baseDir, resourceType, elementType) {
2940
+ // Skip certain URLs
2941
+ if (this.shouldSkipUrl(url)) {
2942
+ return null;
2943
+ }
2944
+
2945
+ try {
2946
+ if (this.isExternalUrl(url)) {
2947
+ // Check external URLs
2948
+ return await this.checkExternalUrl(url, resourceType, elementType);
2949
+ } else {
2950
+ // Check local files
2951
+ return await this.checkLocalFile(url, baseDir, resourceType, elementType);
2952
+ }
2953
+ } catch (error) {
2954
+ return {
2955
+ type: `🔗 ${resourceType} check error`,
2956
+ description: `Failed to check ${elementType}: ${url}`,
2957
+ suggestion: `Verify the ${resourceType.toLowerCase()} manually: ${error.message}`,
2958
+ url: url,
2959
+ resourceType: resourceType
2960
+ };
2961
+ }
2962
+ }
2963
+
2964
+ shouldSkipUrl(url) {
2965
+ const skipPatterns = [
2966
+ /^#/, // Anchor links
2967
+ /^mailto:/, // Email links
2968
+ /^tel:/, // Phone links
2969
+ /^javascript:/, // JavaScript links
2970
+ /^data:/, // Data URLs
2971
+ /^\{\{.*\}\}$/, // Template variables
2972
+ /^\$\{.*\}$/, // Template literals
2973
+ /^<%.*%>$/, // Template tags
2974
+ /^\/\/$/, // Protocol-relative empty
2975
+ /^https?:\/\/localhost/, // Localhost URLs
2976
+ /^https?:\/\/127\.0\.0\.1/, // Local IP
2977
+ /^https?:\/\/0\.0\.0\.0/ // All interfaces IP
2978
+ ];
2979
+
2980
+ return skipPatterns.some(pattern => pattern.test(url));
2981
+ }
2982
+
2983
+ isExternalUrl(url) {
2984
+ return /^https?:\/\//.test(url);
2985
+ }
2986
+
2987
+ async checkExternalUrl(url, resourceType, elementType) {
2988
+ return new Promise((resolve) => {
2989
+ const http = require('http');
2990
+ const https = require('https');
2991
+ const { URL } = require('url');
2992
+
2993
+ const urlObj = new URL(url);
2994
+ const client = urlObj.protocol === 'https:' ? https : http;
2995
+
2996
+ const timeout = 5000; // 5 second timeout
2997
+ const req = client.request({
2998
+ hostname: urlObj.hostname,
2999
+ port: urlObj.port,
3000
+ path: urlObj.pathname + urlObj.search,
3001
+ method: 'HEAD',
3002
+ timeout: timeout,
3003
+ headers: {
3004
+ 'User-Agent': 'GBU-Accessibility-Checker/3.1.0'
3005
+ }
3006
+ }, (res) => {
3007
+ if (res.statusCode >= 400) {
3008
+ resolve({
3009
+ type: `🔗 ${resourceType} ${res.statusCode}`,
3010
+ description: `${elementType} returns ${res.statusCode}: ${url}`,
3011
+ suggestion: `Update or remove the broken ${resourceType.toLowerCase()} reference`,
3012
+ url: url,
3013
+ statusCode: res.statusCode,
3014
+ resourceType: resourceType
3015
+ });
3016
+ } else {
3017
+ resolve(null); // No issue
3018
+ }
3019
+ });
3020
+
3021
+ req.on('error', (error) => {
3022
+ resolve({
3023
+ type: `🔗 ${resourceType} unreachable`,
3024
+ description: `${elementType} cannot be reached: ${url}`,
3025
+ suggestion: `Check network connection or update the ${resourceType.toLowerCase()} URL`,
3026
+ url: url,
3027
+ error: error.message,
3028
+ resourceType: resourceType
3029
+ });
3030
+ });
3031
+
3032
+ req.on('timeout', () => {
3033
+ req.destroy();
3034
+ resolve({
3035
+ type: `🔗 ${resourceType} timeout`,
3036
+ description: `${elementType} request timed out: ${url}`,
3037
+ suggestion: `The ${resourceType.toLowerCase()} server may be slow or unreachable`,
3038
+ url: url,
3039
+ resourceType: resourceType
3040
+ });
3041
+ });
3042
+
3043
+ req.end();
3044
+ });
3045
+ }
3046
+
3047
+ async checkLocalFile(url, baseDir, resourceType, elementType) {
3048
+ const path = require('path');
3049
+
3050
+ // Handle relative URLs
3051
+ let filePath;
3052
+ if (url.startsWith('/')) {
3053
+ // Absolute path from web root - we'll check relative to baseDir
3054
+ filePath = path.join(baseDir, url.substring(1));
3055
+ } else {
3056
+ // Relative path
3057
+ filePath = path.resolve(baseDir, url);
3058
+ }
3059
+
3060
+ try {
3061
+ await require('fs').promises.access(filePath);
3062
+ return null; // File exists, no issue
3063
+ } catch (error) {
3064
+ return {
3065
+ type: `📁 ${resourceType} not found`,
3066
+ description: `${elementType} file does not exist: ${url}`,
3067
+ suggestion: `Create the missing file or update the ${resourceType.toLowerCase()} path`,
3068
+ url: url,
3069
+ filePath: filePath,
3070
+ resourceType: resourceType
3071
+ };
3072
+ }
3073
+ }
3074
+
1570
3075
  // Analyze headings (no auto-fix, only suggestions)
1571
3076
  async analyzeHeadings(directory = '.') {
1572
3077
  console.log(chalk.blue('📑 Analyzing heading structure...'));