gbu-accessibility-package 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PACKAGE_SUMMARY.md +191 -0
- package/QUICK_START.md +137 -0
- package/README.md +305 -0
- package/cli.js +157 -0
- package/demo/demo.js +73 -0
- package/demo/sample.html +47 -0
- package/demo/sample.html.backup +47 -0
- package/example.js +121 -0
- package/index.js +8 -0
- package/lib/enhancer.js +163 -0
- package/lib/fixer.js +764 -0
- package/lib/tester.js +157 -0
- package/package.json +62 -0
package/lib/fixer.js
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility Fixer
|
|
3
|
+
* Automated fixes for common accessibility issues
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs').promises;
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const chalk = require('chalk');
|
|
9
|
+
|
|
10
|
+
class AccessibilityFixer {
|
|
11
|
+
constructor(config = {}) {
|
|
12
|
+
this.config = {
|
|
13
|
+
backupFiles: config.backupFiles !== false,
|
|
14
|
+
language: config.language || 'ja',
|
|
15
|
+
dryRun: config.dryRun || false,
|
|
16
|
+
...config
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async fixHtmlLang(directory = '.') {
|
|
21
|
+
console.log(chalk.blue('š Fixing HTML lang attributes...'));
|
|
22
|
+
|
|
23
|
+
const htmlFiles = await this.findHtmlFiles(directory);
|
|
24
|
+
const results = [];
|
|
25
|
+
|
|
26
|
+
for (const file of htmlFiles) {
|
|
27
|
+
try {
|
|
28
|
+
const content = await fs.readFile(file, 'utf8');
|
|
29
|
+
const fixed = this.fixLangAttribute(content);
|
|
30
|
+
|
|
31
|
+
if (fixed !== content) {
|
|
32
|
+
if (this.config.backupFiles) {
|
|
33
|
+
await fs.writeFile(`${file}.backup`, content);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!this.config.dryRun) {
|
|
37
|
+
await fs.writeFile(file, fixed);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
console.log(chalk.green(`ā
Fixed lang attribute in: ${file}`));
|
|
41
|
+
results.push({ file, status: 'fixed' });
|
|
42
|
+
} else {
|
|
43
|
+
results.push({ file, status: 'no-change' });
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error(chalk.red(`ā Error processing ${file}: ${error.message}`));
|
|
47
|
+
results.push({ file, status: 'error', error: error.message });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async fixEmptyAltAttributes(directory = '.') {
|
|
55
|
+
console.log(chalk.blue('š¼ļø Fixing empty alt attributes...'));
|
|
56
|
+
|
|
57
|
+
const htmlFiles = await this.findHtmlFiles(directory);
|
|
58
|
+
const results = [];
|
|
59
|
+
let totalIssuesFound = 0;
|
|
60
|
+
|
|
61
|
+
for (const file of htmlFiles) {
|
|
62
|
+
try {
|
|
63
|
+
const content = await fs.readFile(file, 'utf8');
|
|
64
|
+
const issues = this.analyzeAltAttributes(content);
|
|
65
|
+
|
|
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
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const fixed = this.fixAltAttributes(content);
|
|
75
|
+
|
|
76
|
+
if (fixed !== content) {
|
|
77
|
+
if (this.config.backupFiles) {
|
|
78
|
+
await fs.writeFile(`${file}.backup`, content);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!this.config.dryRun) {
|
|
82
|
+
await fs.writeFile(file, fixed);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log(chalk.green(`ā
Fixed alt attributes in: ${file}`));
|
|
86
|
+
results.push({ file, status: 'fixed', issues: issues.length });
|
|
87
|
+
} else {
|
|
88
|
+
results.push({ file, status: 'no-change', issues: issues.length });
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(chalk.red(`ā Error processing ${file}: ${error.message}`));
|
|
92
|
+
results.push({ file, status: 'error', error: error.message });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log(chalk.blue(`\nš Summary: Found ${totalIssuesFound} alt attribute issues across ${results.length} files`));
|
|
97
|
+
return results;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
analyzeAltAttributes(content) {
|
|
101
|
+
const issues = [];
|
|
102
|
+
const imgRegex = /<img[^>]*>/gi;
|
|
103
|
+
const imgTags = content.match(imgRegex) || [];
|
|
104
|
+
|
|
105
|
+
imgTags.forEach((imgTag, index) => {
|
|
106
|
+
const hasAlt = /alt\s*=/i.test(imgTag);
|
|
107
|
+
const hasEmptyAlt = /alt\s*=\s*[""''][""'']/i.test(imgTag);
|
|
108
|
+
const src = imgTag.match(/src\s*=\s*["']([^"']+)["']/i);
|
|
109
|
+
const srcValue = src ? src[1] : 'unknown';
|
|
110
|
+
|
|
111
|
+
if (!hasAlt) {
|
|
112
|
+
issues.push({
|
|
113
|
+
type: 'ā Missing alt',
|
|
114
|
+
description: `Image ${index + 1} (${srcValue}) has no alt attribute`,
|
|
115
|
+
imgTag: imgTag.substring(0, 100) + '...'
|
|
116
|
+
});
|
|
117
|
+
} else if (hasEmptyAlt) {
|
|
118
|
+
issues.push({
|
|
119
|
+
type: 'ā ļø Empty alt',
|
|
120
|
+
description: `Image ${index + 1} (${srcValue}) has empty alt attribute`,
|
|
121
|
+
imgTag: imgTag.substring(0, 100) + '...'
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return issues;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async fixRoleAttributes(directory = '.') {
|
|
130
|
+
console.log(chalk.blue('š Fixing role attributes...'));
|
|
131
|
+
|
|
132
|
+
const htmlFiles = await this.findHtmlFiles(directory);
|
|
133
|
+
const results = [];
|
|
134
|
+
let totalIssuesFound = 0;
|
|
135
|
+
|
|
136
|
+
for (const file of htmlFiles) {
|
|
137
|
+
try {
|
|
138
|
+
const content = await fs.readFile(file, 'utf8');
|
|
139
|
+
const issues = this.analyzeRoleAttributes(content);
|
|
140
|
+
|
|
141
|
+
if (issues.length > 0) {
|
|
142
|
+
console.log(chalk.cyan(`\nš ${file}:`));
|
|
143
|
+
issues.forEach(issue => {
|
|
144
|
+
console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
|
|
145
|
+
totalIssuesFound++;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const fixed = this.fixRoleAttributesInContent(content);
|
|
150
|
+
|
|
151
|
+
if (fixed !== content) {
|
|
152
|
+
if (this.config.backupFiles) {
|
|
153
|
+
await fs.writeFile(`${file}.backup`, content);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!this.config.dryRun) {
|
|
157
|
+
await fs.writeFile(file, fixed);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(chalk.green(`ā
Fixed role attributes in: ${file}`));
|
|
161
|
+
results.push({ file, status: 'fixed', issues: issues.length });
|
|
162
|
+
} else {
|
|
163
|
+
results.push({ file, status: 'no-change', issues: issues.length });
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error(chalk.red(`ā Error processing ${file}: ${error.message}`));
|
|
167
|
+
results.push({ file, status: 'error', error: error.message });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log(chalk.blue(`\nš Summary: Found ${totalIssuesFound} role attribute issues across ${results.length} files`));
|
|
172
|
+
return results;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async addMainLandmarks(directory = '.') {
|
|
176
|
+
console.log(chalk.yellow('šļø Main landmark detection (manual review required)...'));
|
|
177
|
+
|
|
178
|
+
const htmlFiles = await this.findHtmlFiles(directory);
|
|
179
|
+
const suggestions = [];
|
|
180
|
+
|
|
181
|
+
for (const file of htmlFiles) {
|
|
182
|
+
const content = await fs.readFile(file, 'utf8');
|
|
183
|
+
|
|
184
|
+
if (!content.includes('<main')) {
|
|
185
|
+
const mainCandidates = this.findMainContentCandidates(content);
|
|
186
|
+
suggestions.push({
|
|
187
|
+
file,
|
|
188
|
+
candidates: mainCandidates,
|
|
189
|
+
recommendation: 'Add <main> element around primary content'
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return suggestions;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fixLangAttribute(content) {
|
|
198
|
+
const langValue = this.config.language;
|
|
199
|
+
|
|
200
|
+
return content
|
|
201
|
+
.replace(/<html class="no-js" lang="">/g, `<html class="no-js" lang="${langValue}">`)
|
|
202
|
+
.replace(/<html class="no-js">/g, `<html class="no-js" lang="${langValue}">`)
|
|
203
|
+
.replace(/<html lang="">/g, `<html lang="${langValue}">`)
|
|
204
|
+
.replace(/<html>/g, `<html lang="${langValue}">`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
fixAltAttributes(content) {
|
|
208
|
+
let fixed = content;
|
|
209
|
+
let changesMade = false;
|
|
210
|
+
|
|
211
|
+
// Find all img tags and process them
|
|
212
|
+
const imgRegex = /<img[^>]*>/gi;
|
|
213
|
+
const imgTags = content.match(imgRegex) || [];
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < imgTags.length; i++) {
|
|
216
|
+
const imgTag = imgTags[i];
|
|
217
|
+
let newImgTag = imgTag;
|
|
218
|
+
|
|
219
|
+
// Check if img has alt attribute
|
|
220
|
+
const hasAlt = /alt\s*=/i.test(imgTag);
|
|
221
|
+
const hasEmptyAlt = /alt\s*=\s*[""'']\s*[""'']/i.test(imgTag);
|
|
222
|
+
|
|
223
|
+
if (!hasAlt) {
|
|
224
|
+
// Add alt attribute if missing - use contextual analysis
|
|
225
|
+
const altText = this.generateAltText(imgTag, content, i);
|
|
226
|
+
newImgTag = imgTag.replace(/(<img[^>]*)(>)/i, `$1 alt="${altText}"$2`);
|
|
227
|
+
changesMade = true;
|
|
228
|
+
console.log(chalk.yellow(` ā ļø Added missing alt attribute: ${imgTag.substring(0, 50)}...`));
|
|
229
|
+
console.log(chalk.green(` ā "${altText}"`));
|
|
230
|
+
} else if (hasEmptyAlt) {
|
|
231
|
+
// Fix empty alt attributes based on context
|
|
232
|
+
const altText = this.generateAltText(imgTag, content, i);
|
|
233
|
+
newImgTag = imgTag.replace(/alt\s*=\s*[""''][""'']/i, `alt="${altText}"`);
|
|
234
|
+
changesMade = true;
|
|
235
|
+
console.log(chalk.yellow(` āļø Fixed empty alt attribute: ${imgTag.substring(0, 50)}...`));
|
|
236
|
+
console.log(chalk.green(` ā "${altText}"`));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (newImgTag !== imgTag) {
|
|
240
|
+
fixed = fixed.replace(imgTag, newImgTag);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return fixed;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
generateAltText(imgTag, htmlContent = '', imgIndex = 0) {
|
|
248
|
+
const src = imgTag.match(/src\s*=\s*["']([^"']+)["']/i);
|
|
249
|
+
const srcValue = src ? src[1].toLowerCase() : '';
|
|
250
|
+
|
|
251
|
+
// Try to find contextual text around the image
|
|
252
|
+
const contextualText = this.findContextualText(imgTag, htmlContent, imgIndex);
|
|
253
|
+
if (contextualText) {
|
|
254
|
+
return contextualText;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Generate appropriate alt text based on image source
|
|
258
|
+
if (srcValue.includes('logo')) {
|
|
259
|
+
return 'ćć“';
|
|
260
|
+
} else if (srcValue.includes('icon')) {
|
|
261
|
+
return 'ć¢ć¤ć³ć³';
|
|
262
|
+
} else if (srcValue.includes('banner')) {
|
|
263
|
+
return 'ććć¼';
|
|
264
|
+
} else if (srcValue.includes('button')) {
|
|
265
|
+
return 'ććæć³';
|
|
266
|
+
} else if (srcValue.includes('arrow')) {
|
|
267
|
+
return 'ē¢å°';
|
|
268
|
+
} else if (srcValue.includes('calendar')) {
|
|
269
|
+
return 'ć«ć¬ć³ćć¼';
|
|
270
|
+
} else if (srcValue.includes('video')) {
|
|
271
|
+
return 'ćććŖ';
|
|
272
|
+
} else if (srcValue.includes('chart') || srcValue.includes('graph')) {
|
|
273
|
+
return 'ć°ć©ć';
|
|
274
|
+
} else if (srcValue.includes('photo') || srcValue.includes('img')) {
|
|
275
|
+
return 'åē';
|
|
276
|
+
} else {
|
|
277
|
+
return 'ē»å';
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
findContextualText(imgTag, htmlContent, imgIndex) {
|
|
282
|
+
if (!htmlContent) return null;
|
|
283
|
+
|
|
284
|
+
// Find the position of this specific img tag in the content
|
|
285
|
+
const imgPosition = this.findImgPosition(imgTag, htmlContent, imgIndex);
|
|
286
|
+
if (imgPosition === -1) return null;
|
|
287
|
+
|
|
288
|
+
// Extract surrounding context (500 chars before and after)
|
|
289
|
+
const contextStart = Math.max(0, imgPosition - 500);
|
|
290
|
+
const contextEnd = Math.min(htmlContent.length, imgPosition + imgTag.length + 500);
|
|
291
|
+
const context = htmlContent.substring(contextStart, contextEnd);
|
|
292
|
+
|
|
293
|
+
// Try different strategies to find relevant text
|
|
294
|
+
const strategies = [
|
|
295
|
+
() => this.findTitleAttribute(imgTag),
|
|
296
|
+
() => this.findDtText(context, imgTag),
|
|
297
|
+
() => this.findParentLinkText(context, imgTag),
|
|
298
|
+
() => this.findNearbyHeadings(context, imgTag),
|
|
299
|
+
() => this.findFigcaptionText(context, imgTag),
|
|
300
|
+
() => this.findNearbyText(context, imgTag),
|
|
301
|
+
() => this.findAriaLabel(imgTag)
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
for (const strategy of strategies) {
|
|
305
|
+
const result = strategy();
|
|
306
|
+
if (result && result.trim().length > 0 && result.trim().length <= 100) {
|
|
307
|
+
return this.cleanText(result);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
findImgPosition(imgTag, htmlContent, imgIndex) {
|
|
315
|
+
const imgRegex = /<img[^>]*>/gi;
|
|
316
|
+
let match;
|
|
317
|
+
let currentIndex = 0;
|
|
318
|
+
|
|
319
|
+
while ((match = imgRegex.exec(htmlContent)) !== null) {
|
|
320
|
+
if (currentIndex === imgIndex) {
|
|
321
|
+
return match.index;
|
|
322
|
+
}
|
|
323
|
+
currentIndex++;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return -1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
findTitleAttribute(imgTag) {
|
|
330
|
+
const titleMatch = imgTag.match(/title\s*=\s*["']([^"']+)["']/i);
|
|
331
|
+
return titleMatch ? titleMatch[1] : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
findAriaLabel(imgTag) {
|
|
335
|
+
const ariaMatch = imgTag.match(/aria-label\s*=\s*["']([^"']+)["']/i);
|
|
336
|
+
return ariaMatch ? ariaMatch[1] : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
findDtText(context, imgTag) {
|
|
340
|
+
// Look for dt (definition term) elements near the image
|
|
341
|
+
const imgPos = context.indexOf(imgTag.substring(0, 50));
|
|
342
|
+
if (imgPos === -1) return null;
|
|
343
|
+
|
|
344
|
+
// Get surrounding context (larger range for dt detection)
|
|
345
|
+
const contextStart = Math.max(0, imgPos - 800);
|
|
346
|
+
const contextEnd = Math.min(context.length, imgPos + imgTag.length + 800);
|
|
347
|
+
const surroundingContext = context.substring(contextStart, contextEnd);
|
|
348
|
+
|
|
349
|
+
// Look for dt elements in various container patterns
|
|
350
|
+
const dtPatterns = [
|
|
351
|
+
// Pattern 1: dt inside dl near image
|
|
352
|
+
/<dl[^>]*>[\s\S]*?<dt[^>]*>([^<]+)<\/dt>[\s\S]*?<\/dl>/gi,
|
|
353
|
+
// Pattern 2: dt in definition list with dd containing image
|
|
354
|
+
/<dt[^>]*>([^<]+)<\/dt>[\s\S]*?<dd[^>]*>[\s\S]*?<img[^>]*>[\s\S]*?<\/dd>/gi,
|
|
355
|
+
// Pattern 3: dt followed by content containing image
|
|
356
|
+
/<dt[^>]*>([^<]+)<\/dt>[\s\S]{0,500}?<img[^>]*>/gi,
|
|
357
|
+
// Pattern 4: Simple dt near image
|
|
358
|
+
/<dt[^>]*>([^<]+)<\/dt>/gi
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
for (const pattern of dtPatterns) {
|
|
362
|
+
const matches = [...surroundingContext.matchAll(pattern)];
|
|
363
|
+
|
|
364
|
+
for (const match of matches) {
|
|
365
|
+
// Check if this dt is related to our image
|
|
366
|
+
if (this.isRelatedToImage(match[0], imgTag, surroundingContext)) {
|
|
367
|
+
const dtText = match[1].trim();
|
|
368
|
+
if (dtText && dtText.length > 0 && dtText.length <= 100) {
|
|
369
|
+
console.log(chalk.blue(` š
Found dt text: "${dtText}"`));
|
|
370
|
+
return dtText;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
isRelatedToImage(dtBlock, imgTag, context) {
|
|
380
|
+
// Check if the dt block contains or is near the image
|
|
381
|
+
const dtPos = context.indexOf(dtBlock);
|
|
382
|
+
const imgPos = context.indexOf(imgTag.substring(0, 50));
|
|
383
|
+
|
|
384
|
+
if (dtPos === -1 || imgPos === -1) return false;
|
|
385
|
+
|
|
386
|
+
// If image is within the dt block
|
|
387
|
+
if (dtBlock.includes(imgTag.substring(0, 50))) {
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// If dt and image are close to each other (within 600 characters)
|
|
392
|
+
const distance = Math.abs(dtPos - imgPos);
|
|
393
|
+
return distance <= 600;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
findParentLinkText(context, imgTag) {
|
|
397
|
+
// Find if image is inside a link and get link text
|
|
398
|
+
const linkPattern = /<a[^>]*>([^<]*<img[^>]*>[^<]*)<\/a>/gi;
|
|
399
|
+
const matches = context.match(linkPattern);
|
|
400
|
+
|
|
401
|
+
if (matches) {
|
|
402
|
+
for (const match of matches) {
|
|
403
|
+
if (match.includes(imgTag.substring(0, 50))) {
|
|
404
|
+
// Extract text content from the link
|
|
405
|
+
const textMatch = match.match(/>([^<]+)</g);
|
|
406
|
+
if (textMatch) {
|
|
407
|
+
const linkText = textMatch
|
|
408
|
+
.map(t => t.replace(/[><]/g, '').trim())
|
|
409
|
+
.filter(t => t.length > 0 && !t.includes('img'))
|
|
410
|
+
.join(' ');
|
|
411
|
+
if (linkText) return linkText;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
findNearbyHeadings(context, imgTag) {
|
|
421
|
+
// Look for headings (h1-h6) near the image
|
|
422
|
+
const headingPattern = /<h[1-6][^>]*>([^<]+)<\/h[1-6]>/gi;
|
|
423
|
+
const headings = [];
|
|
424
|
+
let match;
|
|
425
|
+
|
|
426
|
+
while ((match = headingPattern.exec(context)) !== null) {
|
|
427
|
+
headings.push({
|
|
428
|
+
text: match[1].trim(),
|
|
429
|
+
position: match.index
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (headings.length > 0) {
|
|
434
|
+
// Find the closest heading
|
|
435
|
+
const imgPos = context.indexOf(imgTag.substring(0, 50));
|
|
436
|
+
let closest = headings[0];
|
|
437
|
+
let minDistance = Math.abs(closest.position - imgPos);
|
|
438
|
+
|
|
439
|
+
for (const heading of headings) {
|
|
440
|
+
const distance = Math.abs(heading.position - imgPos);
|
|
441
|
+
if (distance < minDistance) {
|
|
442
|
+
closest = heading;
|
|
443
|
+
minDistance = distance;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return closest.text;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
findFigcaptionText(context, imgTag) {
|
|
454
|
+
// Look for figcaption associated with the image
|
|
455
|
+
const figurePattern = /<figure[^>]*>[\s\S]*?<figcaption[^>]*>([^<]+)<\/figcaption>[\s\S]*?<\/figure>/gi;
|
|
456
|
+
const matches = context.match(figurePattern);
|
|
457
|
+
|
|
458
|
+
if (matches) {
|
|
459
|
+
for (const match of matches) {
|
|
460
|
+
if (match.includes(imgTag.substring(0, 50))) {
|
|
461
|
+
const captionMatch = match.match(/<figcaption[^>]*>([^<]+)<\/figcaption>/i);
|
|
462
|
+
if (captionMatch) {
|
|
463
|
+
return captionMatch[1].trim();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
findNearbyText(context, imgTag) {
|
|
473
|
+
// Look for text in nearby elements (p, div, span)
|
|
474
|
+
const imgPos = context.indexOf(imgTag.substring(0, 50));
|
|
475
|
+
if (imgPos === -1) return null;
|
|
476
|
+
|
|
477
|
+
// Get text before and after the image
|
|
478
|
+
const beforeText = context.substring(Math.max(0, imgPos - 200), imgPos);
|
|
479
|
+
const afterText = context.substring(imgPos + imgTag.length, imgPos + imgTag.length + 200);
|
|
480
|
+
|
|
481
|
+
// Extract meaningful text from nearby elements
|
|
482
|
+
const textPattern = /<(?:p|div|span|h[1-6])[^>]*>([^<]+)<\/(?:p|div|span|h[1-6])>/gi;
|
|
483
|
+
const texts = [];
|
|
484
|
+
|
|
485
|
+
let match;
|
|
486
|
+
while ((match = textPattern.exec(beforeText + afterText)) !== null) {
|
|
487
|
+
const text = match[1].trim();
|
|
488
|
+
if (text.length > 5 && text.length <= 50) {
|
|
489
|
+
texts.push(text);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Return the most relevant text (shortest meaningful one)
|
|
494
|
+
if (texts.length > 0) {
|
|
495
|
+
return texts.sort((a, b) => a.length - b.length)[0];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
cleanText(text) {
|
|
502
|
+
return text
|
|
503
|
+
.replace(/\s+/g, ' ')
|
|
504
|
+
.replace(/[^\w\s\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/g, '')
|
|
505
|
+
.trim()
|
|
506
|
+
.substring(0, 100);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
analyzeRoleAttributes(content) {
|
|
510
|
+
const issues = [];
|
|
511
|
+
|
|
512
|
+
// Define elements that should have specific roles
|
|
513
|
+
const roleRules = [
|
|
514
|
+
{
|
|
515
|
+
selector: /<button[^>]*>/gi,
|
|
516
|
+
expectedRole: 'button',
|
|
517
|
+
description: 'Button element should have role="button" or be implicit'
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
selector: /<a[^>]*href[^>]*>/gi,
|
|
521
|
+
expectedRole: 'link',
|
|
522
|
+
description: 'Link element should have role="link" or be implicit'
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
selector: /<img[^>]*>/gi,
|
|
526
|
+
expectedRole: 'img',
|
|
527
|
+
description: 'Image element should have role="img" or be implicit'
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
selector: /<ul[^>]*>/gi,
|
|
531
|
+
expectedRole: 'list',
|
|
532
|
+
description: 'Unordered list should have role="list"'
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
selector: /<ol[^>]*>/gi,
|
|
536
|
+
expectedRole: 'list',
|
|
537
|
+
description: 'Ordered list should have role="list"'
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
selector: /<li[^>]*>/gi,
|
|
541
|
+
expectedRole: 'listitem',
|
|
542
|
+
description: 'List item should have role="listitem"'
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
selector: /<nav[^>]*>/gi,
|
|
546
|
+
expectedRole: 'navigation',
|
|
547
|
+
description: 'Navigation element should have role="navigation"'
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
selector: /<main[^>]*>/gi,
|
|
551
|
+
expectedRole: 'main',
|
|
552
|
+
description: 'Main element should have role="main"'
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
selector: /<header[^>]*>/gi,
|
|
556
|
+
expectedRole: 'banner',
|
|
557
|
+
description: 'Header element should have role="banner"'
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
selector: /<footer[^>]*>/gi,
|
|
561
|
+
expectedRole: 'contentinfo',
|
|
562
|
+
description: 'Footer element should have role="contentinfo"'
|
|
563
|
+
}
|
|
564
|
+
];
|
|
565
|
+
|
|
566
|
+
// Check for images that need role="img"
|
|
567
|
+
const images = content.match(/<img[^>]*>/gi) || [];
|
|
568
|
+
images.forEach((img, index) => {
|
|
569
|
+
if (!img.includes('role=')) {
|
|
570
|
+
issues.push({
|
|
571
|
+
type: 'š¼ļø Missing role',
|
|
572
|
+
description: `Image ${index + 1} should have role="img"`,
|
|
573
|
+
element: img.substring(0, 100) + '...'
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// Check for button elements with onclick that need role
|
|
579
|
+
const buttonsWithOnclick = content.match(/<button[^>]*onclick[^>]*>/gi) || [];
|
|
580
|
+
buttonsWithOnclick.forEach((button, index) => {
|
|
581
|
+
if (!button.includes('role=')) {
|
|
582
|
+
issues.push({
|
|
583
|
+
type: 'š Missing role',
|
|
584
|
+
description: `Button ${index + 1} with onclick should have role="button"`,
|
|
585
|
+
element: button.substring(0, 100) + '...'
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// Check for anchor elements that need role
|
|
591
|
+
const anchors = content.match(/<a[^>]*href[^>]*>/gi) || [];
|
|
592
|
+
anchors.forEach((anchor, index) => {
|
|
593
|
+
if (!anchor.includes('role=')) {
|
|
594
|
+
issues.push({
|
|
595
|
+
type: 'š Missing role',
|
|
596
|
+
description: `Anchor ${index + 1} should have role="link"`,
|
|
597
|
+
element: anchor.substring(0, 100) + '...'
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// Check for any element with onclick that needs role
|
|
603
|
+
const elementsWithOnclick = content.match(/<(?!a|button)[a-zA-Z][a-zA-Z0-9]*[^>]*onclick[^>]*>/gi) || [];
|
|
604
|
+
elementsWithOnclick.forEach((element, index) => {
|
|
605
|
+
if (!element.includes('role=')) {
|
|
606
|
+
const tagMatch = element.match(/<([a-zA-Z][a-zA-Z0-9]*)/);
|
|
607
|
+
const tagName = tagMatch ? tagMatch[1] : 'element';
|
|
608
|
+
issues.push({
|
|
609
|
+
type: 'š Missing role',
|
|
610
|
+
description: `${tagName} ${index + 1} with onclick should have role="button"`,
|
|
611
|
+
element: element.substring(0, 100) + '...'
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Check for clickable divs that should have button role
|
|
617
|
+
const clickableDivs = content.match(/<div[^>]*(?:onclick|class="[^"]*(?:btn|button|click)[^"]*")[^>]*>/gi) || [];
|
|
618
|
+
clickableDivs.forEach((div, index) => {
|
|
619
|
+
if (!div.includes('role=')) {
|
|
620
|
+
issues.push({
|
|
621
|
+
type: 'š Missing role',
|
|
622
|
+
description: `Clickable div ${index + 1} should have role="button"`,
|
|
623
|
+
element: div.substring(0, 100) + '...'
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// Check for elements with tabindex that might need roles
|
|
629
|
+
const tabindexElements = content.match(/<(?!a|button|input|select|textarea)[^>]*tabindex\s*=\s*[""']?[0-9-]+[""']?[^>]*>/gi) || [];
|
|
630
|
+
tabindexElements.forEach((element, index) => {
|
|
631
|
+
if (!element.includes('role=')) {
|
|
632
|
+
issues.push({
|
|
633
|
+
type: 'āØļø Missing role',
|
|
634
|
+
description: `Focusable element ${index + 1} should have appropriate role`,
|
|
635
|
+
element: element.substring(0, 100) + '...'
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
return issues;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
fixRoleAttributesInContent(content) {
|
|
644
|
+
let fixed = content;
|
|
645
|
+
|
|
646
|
+
// Fix all images - add role="img" (only if no role exists)
|
|
647
|
+
fixed = fixed.replace(
|
|
648
|
+
/<img([^>]*)(?!.*role\s*=)([^>]*>)/gi,
|
|
649
|
+
(match, attrs, end) => {
|
|
650
|
+
console.log(chalk.yellow(` š¼ļø Added role="img" to image element`));
|
|
651
|
+
return `<img${attrs} role="img"${end}`;
|
|
652
|
+
}
|
|
653
|
+
);
|
|
654
|
+
|
|
655
|
+
// Fix button elements with onclick - add role="button"
|
|
656
|
+
fixed = fixed.replace(
|
|
657
|
+
/<button([^>]*onclick[^>]*)(?!.*role\s*=)([^>]*>)/gi,
|
|
658
|
+
(match, attrs, end) => {
|
|
659
|
+
console.log(chalk.yellow(` š Added role="button" to button with onclick`));
|
|
660
|
+
return `<button${attrs} role="button"${end}`;
|
|
661
|
+
}
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
// Fix anchor elements - add role="link"
|
|
665
|
+
fixed = fixed.replace(
|
|
666
|
+
/<a([^>]*href[^>]*)(?!.*role\s*=)([^>]*>)/gi,
|
|
667
|
+
(match, attrs, end) => {
|
|
668
|
+
console.log(chalk.yellow(` š Added role="link" to anchor element`));
|
|
669
|
+
return `<a${attrs} role="link"${end}`;
|
|
670
|
+
}
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
// Fix any element with onclick (except a and button) - add role="button"
|
|
674
|
+
fixed = fixed.replace(
|
|
675
|
+
/<((?!a|button)[a-zA-Z][a-zA-Z0-9]*)([^>]*onclick[^>]*)(?!.*role\s*=)([^>]*>)/gi,
|
|
676
|
+
(match, tag, attrs, end) => {
|
|
677
|
+
console.log(chalk.yellow(` š Added role="button" to ${tag} with onclick`));
|
|
678
|
+
return `<${tag}${attrs} role="button"${end}`;
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
// Fix clickable divs - add role="button"
|
|
683
|
+
fixed = fixed.replace(
|
|
684
|
+
/<div([^>]*class="[^"]*(?:btn|button|click)[^"]*"[^>]*)(?!.*role\s*=)([^>]*>)/gi,
|
|
685
|
+
(match, attrs, end) => {
|
|
686
|
+
console.log(chalk.yellow(` š Added role="button" to clickable div`));
|
|
687
|
+
return `<div${attrs} role="button"${end}`;
|
|
688
|
+
}
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
// Fix focusable elements with tabindex
|
|
692
|
+
fixed = fixed.replace(
|
|
693
|
+
/<(div|span)([^>]*tabindex\s*=\s*[""']?[0-9-]+[""']?[^>]*)(?!.*role\s*=)([^>]*>)/gi,
|
|
694
|
+
(match, tag, attrs, end) => {
|
|
695
|
+
console.log(chalk.yellow(` āØļø Added role="button" to focusable ${tag}`));
|
|
696
|
+
return `<${tag}${attrs} role="button"${end}`;
|
|
697
|
+
}
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
// Fix navigation lists that should be menus
|
|
701
|
+
fixed = fixed.replace(
|
|
702
|
+
/<ul([^>]*class="[^"]*(?:nav|menu)[^"]*"[^>]*)(?!.*role\s*=)([^>]*>)/gi,
|
|
703
|
+
(match, attrs, end) => {
|
|
704
|
+
console.log(chalk.yellow(` š Added role="menubar" to navigation list`));
|
|
705
|
+
return `<ul${attrs} role="menubar"${end}`;
|
|
706
|
+
}
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
// Fix list items in navigation menus
|
|
710
|
+
fixed = fixed.replace(
|
|
711
|
+
/<li([^>]*class="[^"]*(?:nav|menu)[^"]*"[^>]*)(?!.*role\s*=)([^>]*>)/gi,
|
|
712
|
+
(match, attrs, end) => {
|
|
713
|
+
console.log(chalk.yellow(` š Added role="menuitem" to navigation list item`));
|
|
714
|
+
return `<li${attrs} role="menuitem"${end}`;
|
|
715
|
+
}
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
return fixed;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
findMainContentCandidates(content) {
|
|
722
|
+
const candidates = [];
|
|
723
|
+
|
|
724
|
+
// Look for common main content patterns
|
|
725
|
+
const patterns = [
|
|
726
|
+
/<div[^>]*class="[^"]*main[^"]*"/gi,
|
|
727
|
+
/<div[^>]*class="[^"]*content[^"]*"/gi,
|
|
728
|
+
/<section[^>]*class="[^"]*main[^"]*"/gi,
|
|
729
|
+
/<article/gi
|
|
730
|
+
];
|
|
731
|
+
|
|
732
|
+
patterns.forEach(pattern => {
|
|
733
|
+
const matches = content.match(pattern);
|
|
734
|
+
if (matches) {
|
|
735
|
+
candidates.push(...matches);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
return candidates;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async findHtmlFiles(directory) {
|
|
743
|
+
const files = [];
|
|
744
|
+
|
|
745
|
+
async function scan(dir) {
|
|
746
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
747
|
+
|
|
748
|
+
for (const entry of entries) {
|
|
749
|
+
const fullPath = path.join(dir, entry.name);
|
|
750
|
+
|
|
751
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
752
|
+
await scan(fullPath);
|
|
753
|
+
} else if (entry.isFile() && entry.name.endsWith('.html')) {
|
|
754
|
+
files.push(fullPath);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
await scan(directory);
|
|
760
|
+
return files;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
module.exports = AccessibilityFixer;
|