gbu-accessibility-package 3.3.0 โ†’ 3.5.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/lib/fixer.js CHANGED
@@ -1236,6 +1236,9 @@ class AccessibilityFixer {
1236
1236
  altCreativity: config.altCreativity || 'balanced', // conservative, balanced, creative
1237
1237
  includeEmotions: config.includeEmotions || false,
1238
1238
  strictAltChecking: config.strictAltChecking || false,
1239
+ // New options for advanced features
1240
+ autoFixHeadings: config.autoFixHeadings || false, // Enable automatic heading fixes
1241
+ fixDescriptionLists: config.fixDescriptionLists || true, // Enable DL structure fixes
1239
1242
  ...config
1240
1243
  };
1241
1244
 
@@ -4093,6 +4096,998 @@ class AccessibilityFixer {
4093
4096
  return fixed;
4094
4097
  }
4095
4098
 
4099
+ async fixNestedInteractiveControls(directory = '.') {
4100
+ console.log(chalk.blue('๐ŸŽฏ Fixing nested interactive controls...'));
4101
+
4102
+ const htmlFiles = await this.findHtmlFiles(directory);
4103
+ const results = [];
4104
+ let totalIssuesFound = 0;
4105
+
4106
+ for (const file of htmlFiles) {
4107
+ try {
4108
+ const content = await fs.readFile(file, 'utf8');
4109
+ const issues = this.analyzeNestedInteractiveControls(content);
4110
+
4111
+ if (issues.length > 0) {
4112
+ console.log(chalk.cyan(`\n๐Ÿ“ ${file}:`));
4113
+ issues.forEach(issue => {
4114
+ console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
4115
+ if (issue.suggestion) {
4116
+ console.log(chalk.gray(` ๐Ÿ’ก ${issue.suggestion}`));
4117
+ }
4118
+ totalIssuesFound++;
4119
+ });
4120
+ }
4121
+
4122
+ const fixed = this.fixNestedInteractiveControlsInContent(content);
4123
+
4124
+ if (fixed !== content) {
4125
+ if (this.config.backupFiles) {
4126
+ await fs.writeFile(`${file}.backup`, content);
4127
+ }
4128
+
4129
+ if (!this.config.dryRun) {
4130
+ await fs.writeFile(file, fixed);
4131
+ }
4132
+
4133
+ console.log(chalk.green(`โœ… Fixed nested interactive controls in: ${file}`));
4134
+ results.push({ file, status: 'fixed', issues: issues.length });
4135
+ } else {
4136
+ results.push({ file, status: 'no-change', issues: issues.length });
4137
+ }
4138
+ } catch (error) {
4139
+ console.error(chalk.red(`โŒ Error processing ${file}: ${error.message}`));
4140
+ results.push({ file, status: 'error', error: error.message });
4141
+ }
4142
+ }
4143
+
4144
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Found ${totalIssuesFound} nested interactive control issues across ${results.length} files`));
4145
+ return results;
4146
+ }
4147
+
4148
+ analyzeNestedInteractiveControls(content) {
4149
+ const issues = [];
4150
+
4151
+ // Define interactive elements and their roles
4152
+ const interactiveElements = [
4153
+ { tag: 'button', role: 'button' },
4154
+ { tag: 'a', role: 'link', requiresHref: true },
4155
+ { tag: 'input', role: 'textbox|button|checkbox|radio|slider|spinbutton' },
4156
+ { tag: 'textarea', role: 'textbox' },
4157
+ { tag: 'select', role: 'combobox|listbox' },
4158
+ { tag: 'details', role: 'group' },
4159
+ { tag: 'summary', role: 'button' }
4160
+ ];
4161
+
4162
+ // Also check for elements with interactive roles
4163
+ const interactiveRoles = [
4164
+ 'button', 'link', 'textbox', 'checkbox', 'radio', 'slider',
4165
+ 'spinbutton', 'combobox', 'listbox', 'menuitem', 'tab',
4166
+ 'treeitem', 'gridcell', 'option'
4167
+ ];
4168
+
4169
+ // Find all interactive elements
4170
+ const interactiveSelectors = [];
4171
+
4172
+ // Add tag-based selectors
4173
+ interactiveElements.forEach(element => {
4174
+ if (element.requiresHref) {
4175
+ interactiveSelectors.push(`<${element.tag}[^>]*href[^>]*>`);
4176
+ } else {
4177
+ interactiveSelectors.push(`<${element.tag}[^>]*>`);
4178
+ }
4179
+ });
4180
+
4181
+ // Add role-based selectors
4182
+ interactiveRoles.forEach(role => {
4183
+ interactiveSelectors.push(`<[^>]*role\\s*=\\s*["']${role}["'][^>]*>`);
4184
+ });
4185
+
4186
+ // Create combined regex pattern
4187
+ const interactivePattern = new RegExp(interactiveSelectors.join('|'), 'gi');
4188
+
4189
+ // Find all interactive elements with their positions
4190
+ const interactiveMatches = [];
4191
+ let match;
4192
+
4193
+ while ((match = interactivePattern.exec(content)) !== null) {
4194
+ const element = match[0];
4195
+ const startPos = match.index;
4196
+ const endPos = this.findElementEndPosition(content, element, startPos);
4197
+
4198
+ if (endPos > startPos) {
4199
+ interactiveMatches.push({
4200
+ element: element,
4201
+ startPos: startPos,
4202
+ endPos: endPos,
4203
+ fullElement: content.substring(startPos, endPos)
4204
+ });
4205
+ }
4206
+ }
4207
+
4208
+ // Check for nesting
4209
+ for (let i = 0; i < interactiveMatches.length; i++) {
4210
+ const parent = interactiveMatches[i];
4211
+
4212
+ for (let j = 0; j < interactiveMatches.length; j++) {
4213
+ if (i === j) continue;
4214
+
4215
+ const child = interactiveMatches[j];
4216
+
4217
+ // Check if child is nested inside parent
4218
+ if (child.startPos > parent.startPos && child.endPos < parent.endPos) {
4219
+ const parentType = this.getInteractiveElementType(parent.element);
4220
+ const childType = this.getInteractiveElementType(child.element);
4221
+
4222
+ issues.push({
4223
+ type: '๐ŸŽฏ Nested interactive controls',
4224
+ description: `${childType} is nested inside ${parentType}`,
4225
+ parentElement: parent.element.substring(0, 100) + '...',
4226
+ childElement: child.element.substring(0, 100) + '...',
4227
+ suggestion: `Remove interactive role from parent or child, or restructure HTML to avoid nesting`
4228
+ });
4229
+ }
4230
+ }
4231
+ }
4232
+
4233
+ return issues;
4234
+ }
4235
+
4236
+ findElementEndPosition(content, startTag, startPos) {
4237
+ // Extract tag name from start tag
4238
+ const tagMatch = startTag.match(/<(\w+)/);
4239
+ if (!tagMatch) return startPos + startTag.length;
4240
+
4241
+ const tagName = tagMatch[1].toLowerCase();
4242
+
4243
+ // Self-closing tags
4244
+ if (startTag.endsWith('/>') || ['input', 'img', 'br', 'hr', 'meta', 'link'].includes(tagName)) {
4245
+ return startPos + startTag.length;
4246
+ }
4247
+
4248
+ // Find matching closing tag
4249
+ const closeTagPattern = new RegExp(`</${tagName}>`, 'i');
4250
+ const remainingContent = content.substring(startPos + startTag.length);
4251
+ const closeMatch = remainingContent.match(closeTagPattern);
4252
+
4253
+ if (closeMatch) {
4254
+ return startPos + startTag.length + closeMatch.index + closeMatch[0].length;
4255
+ }
4256
+
4257
+ // If no closing tag found, assume it ends at the start tag
4258
+ return startPos + startTag.length;
4259
+ }
4260
+
4261
+ getInteractiveElementType(element) {
4262
+ // Extract tag name
4263
+ const tagMatch = element.match(/<(\w+)/);
4264
+ const tagName = tagMatch ? tagMatch[1].toLowerCase() : 'element';
4265
+
4266
+ // Extract role if present
4267
+ const roleMatch = element.match(/role\s*=\s*["']([^"']+)["']/i);
4268
+ const role = roleMatch ? roleMatch[1] : null;
4269
+
4270
+ if (role) {
4271
+ return `${tagName}[role="${role}"]`;
4272
+ }
4273
+
4274
+ // Special cases
4275
+ if (tagName === 'a' && /href\s*=/i.test(element)) {
4276
+ return 'link';
4277
+ }
4278
+
4279
+ if (tagName === 'input') {
4280
+ const typeMatch = element.match(/type\s*=\s*["']([^"']+)["']/i);
4281
+ const inputType = typeMatch ? typeMatch[1] : 'text';
4282
+ return `input[type="${inputType}"]`;
4283
+ }
4284
+
4285
+ return tagName;
4286
+ }
4287
+
4288
+ fixNestedInteractiveControlsInContent(content) {
4289
+ let fixed = content;
4290
+
4291
+ // Strategy 1: Remove role attributes from parent containers that have interactive children
4292
+ const issues = this.analyzeNestedInteractiveControls(content);
4293
+
4294
+ issues.forEach(issue => {
4295
+ // Try to fix by removing role from parent element
4296
+ const parentRoleMatch = issue.parentElement.match(/role\s*=\s*["'][^"']*["']/i);
4297
+ if (parentRoleMatch) {
4298
+ const parentWithoutRole = issue.parentElement.replace(/\s*role\s*=\s*["'][^"']*["']/i, '');
4299
+ fixed = fixed.replace(issue.parentElement, parentWithoutRole);
4300
+ console.log(chalk.yellow(` ๐ŸŽฏ Removed role attribute from parent element to fix nesting`));
4301
+ }
4302
+ });
4303
+
4304
+ // Strategy 2: Convert div[role="button"] containing links to regular div
4305
+ fixed = fixed.replace(/<div([^>]*role\s*=\s*["']button["'][^>]*)>([\s\S]*?)<\/div>/gi, (match, attributes, content) => {
4306
+ // Check if content contains interactive elements
4307
+ const hasInteractiveChildren = /<(?:a\s[^>]*href|button|input|select|textarea)[^>]*>/i.test(content);
4308
+
4309
+ if (hasInteractiveChildren) {
4310
+ // Remove role="button" and any button-related attributes
4311
+ const cleanAttributes = attributes
4312
+ .replace(/\s*role\s*=\s*["']button["']/i, '')
4313
+ .replace(/\s*tabindex\s*=\s*["'][^"']*["']/i, '')
4314
+ .replace(/\s*onclick\s*=\s*["'][^"']*["']/i, '');
4315
+
4316
+ console.log(chalk.yellow(` ๐ŸŽฏ Converted div[role="button"] to regular div due to interactive children`));
4317
+ return `<div${cleanAttributes}>${content}</div>`;
4318
+ }
4319
+
4320
+ return match;
4321
+ });
4322
+
4323
+ // Strategy 3: Remove tabindex from parent containers with interactive children
4324
+ fixed = fixed.replace(/(<[^>]+)(\s+tabindex\s*=\s*["'][^"']*["'])([^>]*>[\s\S]*?<(?:a\s[^>]*href|button|input|select|textarea)[^>]*>[\s\S]*?<\/[^>]+>)/gi, (match, beforeTabindex, tabindexAttr, afterTabindex) => {
4325
+ console.log(chalk.yellow(` ๐ŸŽฏ Removed tabindex from parent element with interactive children`));
4326
+ return beforeTabindex + afterTabindex;
4327
+ });
4328
+
4329
+ return fixed;
4330
+ }
4331
+
4332
+ async fixAllAccessibilityIssues(directory = '.') {
4333
+ console.log(chalk.blue('๐Ÿš€ Starting comprehensive accessibility fixes...'));
4334
+ console.log('');
4335
+
4336
+ const results = {
4337
+ totalFiles: 0,
4338
+ fixedFiles: 0,
4339
+ totalIssues: 0,
4340
+ steps: []
4341
+ };
4342
+
4343
+ try {
4344
+ // Step 1: HTML lang attributes
4345
+ console.log(chalk.blue('๐Ÿ“ Step 1: HTML lang attributes...'));
4346
+ const langResults = await this.fixHtmlLang(directory);
4347
+ const langFixed = langResults.filter(r => r.status === 'fixed').length;
4348
+ results.steps.push({ step: 1, name: 'HTML lang attributes', fixed: langFixed });
4349
+
4350
+ // Step 2: Alt attributes
4351
+ console.log(chalk.blue('๐Ÿ–ผ๏ธ Step 2: Alt attributes...'));
4352
+ const altResults = await this.fixEmptyAltAttributes(directory);
4353
+ const altFixed = altResults.filter(r => r.status === 'fixed').length;
4354
+ const totalAltIssues = altResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4355
+ results.steps.push({ step: 2, name: 'Alt attributes', fixed: altFixed, issues: totalAltIssues });
4356
+
4357
+ // Step 3: Role attributes
4358
+ console.log(chalk.blue('๐ŸŽญ Step 3: Role attributes...'));
4359
+ const roleResults = await this.fixRoleAttributes(directory);
4360
+ const roleFixed = roleResults.filter(r => r.status === 'fixed').length;
4361
+ const totalRoleIssues = roleResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4362
+ results.steps.push({ step: 3, name: 'Role attributes', fixed: roleFixed, issues: totalRoleIssues });
4363
+
4364
+ // Step 4: Form labels
4365
+ console.log(chalk.blue('๐Ÿ“‹ Step 4: Form labels...'));
4366
+ const formResults = await this.fixFormLabels(directory);
4367
+ const formFixed = formResults.filter(r => r.status === 'fixed').length;
4368
+ const totalFormIssues = formResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4369
+ results.steps.push({ step: 4, name: 'Form labels', fixed: formFixed, issues: totalFormIssues });
4370
+
4371
+ // Step 5: Nested interactive controls (NEW!)
4372
+ console.log(chalk.blue('๐ŸŽฏ Step 5: Nested interactive controls...'));
4373
+ const nestedResults = await this.fixNestedInteractiveControls(directory);
4374
+ const nestedFixed = nestedResults.filter(r => r.status === 'fixed').length;
4375
+ const totalNestedIssues = nestedResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4376
+ results.steps.push({ step: 5, name: 'Nested interactive controls', fixed: nestedFixed, issues: totalNestedIssues });
4377
+
4378
+ // Step 6: Button names
4379
+ console.log(chalk.blue('๐Ÿ”˜ Step 6: Button names...'));
4380
+ const buttonResults = await this.fixButtonNames(directory);
4381
+ const buttonFixed = buttonResults.filter(r => r.status === 'fixed').length;
4382
+ const totalButtonIssues = buttonResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4383
+ results.steps.push({ step: 6, name: 'Button names', fixed: buttonFixed, issues: totalButtonIssues });
4384
+
4385
+ // Step 7: Link names
4386
+ console.log(chalk.blue('๐Ÿ”— Step 7: Link names...'));
4387
+ const linkResults = await this.fixLinkNames(directory);
4388
+ const linkFixed = linkResults.filter(r => r.status === 'fixed').length;
4389
+ const totalLinkIssues = linkResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4390
+ results.steps.push({ step: 7, name: 'Link names', fixed: linkFixed, issues: totalLinkIssues });
4391
+
4392
+ // Step 8: Landmarks
4393
+ console.log(chalk.blue('๐Ÿ›๏ธ Step 8: Landmarks...'));
4394
+ const landmarkResults = await this.fixLandmarks(directory);
4395
+ const landmarkFixed = landmarkResults.filter(r => r.status === 'fixed').length;
4396
+ const totalLandmarkIssues = landmarkResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4397
+ results.steps.push({ step: 8, name: 'Landmarks', fixed: landmarkFixed, issues: totalLandmarkIssues });
4398
+
4399
+ // Step 9: Heading analysis
4400
+ console.log(chalk.blue('๐Ÿ“‘ Step 9: Heading analysis...'));
4401
+ const headingResults = await this.analyzeHeadings(directory);
4402
+ const totalHeadingSuggestions = headingResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4403
+ results.steps.push({ step: 9, name: 'Heading analysis', suggestions: totalHeadingSuggestions });
4404
+ console.log(chalk.gray('๐Ÿ’ก Heading issues require manual review and cannot be auto-fixed'));
4405
+
4406
+ // Step 10: Broken links check
4407
+ console.log(chalk.blue('๐Ÿ”— Step 10: Broken links check...'));
4408
+ const brokenLinksResults = await this.checkBrokenLinks(directory);
4409
+ const totalBrokenLinks = brokenLinksResults.reduce((sum, r) => sum + (r.issues || 0), 0);
4410
+ results.steps.push({ step: 10, name: 'Broken links check', issues: totalBrokenLinks });
4411
+ console.log(chalk.gray('๐Ÿ’ก Broken link issues require manual review and cannot be auto-fixed'));
4412
+
4413
+ // Step 11: Cleanup duplicate roles
4414
+ console.log(chalk.blue('๐Ÿงน Step 11: Cleanup duplicate roles...'));
4415
+ const cleanupResults = await this.cleanupDuplicateRoles(directory);
4416
+ const cleanupFixed = cleanupResults.filter(r => r.status === 'fixed').length;
4417
+ results.steps.push({ step: 11, name: 'Cleanup duplicate roles', fixed: cleanupFixed });
4418
+
4419
+ // Calculate totals
4420
+ results.totalFiles = Math.max(
4421
+ langResults.length, altResults.length, roleResults.length, formResults.length,
4422
+ nestedResults.length, buttonResults.length, linkResults.length, landmarkResults.length,
4423
+ headingResults.length, brokenLinksResults.length, cleanupResults.length
4424
+ );
4425
+
4426
+ results.fixedFiles = new Set([
4427
+ ...langResults.filter(r => r.status === 'fixed').map(r => r.file),
4428
+ ...altResults.filter(r => r.status === 'fixed').map(r => r.file),
4429
+ ...roleResults.filter(r => r.status === 'fixed').map(r => r.file),
4430
+ ...formResults.filter(r => r.status === 'fixed').map(r => r.file),
4431
+ ...nestedResults.filter(r => r.status === 'fixed').map(r => r.file),
4432
+ ...buttonResults.filter(r => r.status === 'fixed').map(r => r.file),
4433
+ ...linkResults.filter(r => r.status === 'fixed').map(r => r.file),
4434
+ ...landmarkResults.filter(r => r.status === 'fixed').map(r => r.file),
4435
+ ...cleanupResults.filter(r => r.status === 'fixed').map(r => r.file)
4436
+ ]).size;
4437
+
4438
+ results.totalIssues = totalAltIssues + totalRoleIssues + totalFormIssues + totalNestedIssues +
4439
+ totalButtonIssues + totalLinkIssues + totalLandmarkIssues;
4440
+
4441
+ // Final summary
4442
+ console.log(chalk.green('\n๐ŸŽ‰ All accessibility fixes completed!'));
4443
+ console.log(chalk.blue('๐Ÿ“Š Final Summary:'));
4444
+ console.log(chalk.blue(` Total files scanned: ${results.totalFiles}`));
4445
+ console.log(chalk.blue(` Files fixed: ${results.fixedFiles}`));
4446
+ console.log(chalk.blue(` Total issues resolved: ${results.totalIssues}`));
4447
+
4448
+ if (this.config.dryRun) {
4449
+ console.log(chalk.yellow('\n๐Ÿ’ก This was a dry run. Use without --dry-run to apply changes.'));
4450
+ }
4451
+
4452
+ return results;
4453
+
4454
+ } catch (error) {
4455
+ console.error(chalk.red(`โŒ Error during comprehensive fixes: ${error.message}`));
4456
+ throw error;
4457
+ }
4458
+ }
4459
+
4460
+ async fixHeadingStructure(directory = '.') {
4461
+ console.log(chalk.blue('๐Ÿ“‘ Fixing heading structure...'));
4462
+
4463
+ const htmlFiles = await this.findHtmlFiles(directory);
4464
+ const results = [];
4465
+ let totalIssuesFound = 0;
4466
+ let totalIssuesFixed = 0;
4467
+
4468
+ for (const file of htmlFiles) {
4469
+ try {
4470
+ const content = await fs.readFile(file, 'utf8');
4471
+ const analysis = this.analyzeHeadingStructure(content);
4472
+
4473
+ if (analysis.issues.length > 0) {
4474
+ console.log(chalk.cyan(`\n๐Ÿ“ ${file}:`));
4475
+ analysis.issues.forEach(issue => {
4476
+ console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
4477
+ if (issue.suggestion) {
4478
+ console.log(chalk.gray(` ๐Ÿ’ก ${issue.suggestion}`));
4479
+ }
4480
+ totalIssuesFound++;
4481
+ });
4482
+ }
4483
+
4484
+ let fixed = content;
4485
+ let changesMade = false;
4486
+
4487
+ if (this.config.autoFixHeadings) {
4488
+ fixed = this.fixHeadingStructureInContent(content, analysis);
4489
+ changesMade = fixed !== content;
4490
+
4491
+ if (changesMade) {
4492
+ const fixedCount = this.countHeadingFixes(content, fixed);
4493
+ totalIssuesFixed += fixedCount;
4494
+
4495
+ if (this.config.backupFiles) {
4496
+ await fs.writeFile(`${file}.backup`, content);
4497
+ }
4498
+
4499
+ if (!this.config.dryRun) {
4500
+ await fs.writeFile(file, fixed);
4501
+ }
4502
+
4503
+ console.log(chalk.green(`โœ… Fixed heading structure in: ${file} (${fixedCount} fixes)`));
4504
+ results.push({ file, status: 'fixed', issues: analysis.issues.length, fixes: fixedCount });
4505
+ } else {
4506
+ results.push({ file, status: 'no-change', issues: analysis.issues.length });
4507
+ }
4508
+ } else {
4509
+ results.push({ file, status: 'analyzed', issues: analysis.issues.length });
4510
+ }
4511
+ } catch (error) {
4512
+ console.error(chalk.red(`โŒ Error processing ${file}: ${error.message}`));
4513
+ results.push({ file, status: 'error', error: error.message });
4514
+ }
4515
+ }
4516
+
4517
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Found ${totalIssuesFound} heading issues across ${results.length} files`));
4518
+ if (this.config.autoFixHeadings) {
4519
+ console.log(chalk.green(` Fixed ${totalIssuesFixed} heading issues automatically`));
4520
+ } else {
4521
+ console.log(chalk.gray('๐Ÿ’ก Use --auto-fix-headings option to enable automatic fixes'));
4522
+ }
4523
+
4524
+ return results;
4525
+ }
4526
+
4527
+ analyzeHeadingStructure(content) {
4528
+ const issues = [];
4529
+ const suggestions = [];
4530
+
4531
+ // Extract all headings with their levels, text, and positions
4532
+ const headingPattern = /<h([1-6])[^>]*>([\s\S]*?)<\/h[1-6]>/gi;
4533
+ const headings = [];
4534
+ let match;
4535
+
4536
+ while ((match = headingPattern.exec(content)) !== null) {
4537
+ const level = parseInt(match[1]);
4538
+ const rawText = match[2];
4539
+ const text = rawText.replace(/<[^>]*>/g, '').trim();
4540
+ const fullTag = match[0];
4541
+
4542
+ headings.push({
4543
+ level,
4544
+ text,
4545
+ rawText,
4546
+ fullTag,
4547
+ position: match.index,
4548
+ originalMatch: match[0]
4549
+ });
4550
+ }
4551
+
4552
+ if (headings.length === 0) {
4553
+ issues.push({
4554
+ type: '๐Ÿ“‘ No headings found',
4555
+ description: 'Page has no heading elements',
4556
+ suggestion: 'Add heading elements (h1-h6) to structure content',
4557
+ severity: 'error',
4558
+ fixable: false
4559
+ });
4560
+ return { issues, suggestions, headings };
4561
+ }
4562
+
4563
+ // Check for h1
4564
+ const h1Count = headings.filter(h => h.level === 1).length;
4565
+ if (h1Count === 0) {
4566
+ issues.push({
4567
+ type: '๐Ÿ“‘ Missing h1',
4568
+ description: 'Page should have exactly one h1 element',
4569
+ suggestion: 'Add an h1 element as the main page heading',
4570
+ severity: 'error',
4571
+ fixable: true,
4572
+ fix: 'add-h1'
4573
+ });
4574
+ } else if (h1Count > 1) {
4575
+ issues.push({
4576
+ type: '๐Ÿ“‘ Multiple h1 elements',
4577
+ description: `Found ${h1Count} h1 elements, should have only one`,
4578
+ suggestion: 'Convert extra h1 elements to h2-h6 as appropriate',
4579
+ severity: 'error',
4580
+ fixable: true,
4581
+ fix: 'fix-multiple-h1'
4582
+ });
4583
+ }
4584
+
4585
+ // Check heading order and hierarchy
4586
+ for (let i = 1; i < headings.length; i++) {
4587
+ const current = headings[i];
4588
+ const previous = headings[i - 1];
4589
+
4590
+ // Check for level skipping
4591
+ if (current.level > previous.level + 1) {
4592
+ issues.push({
4593
+ type: '๐Ÿ“‘ Heading level skip',
4594
+ description: `Heading level jumps from h${previous.level} to h${current.level}`,
4595
+ suggestion: `Use h${previous.level + 1} instead of h${current.level}`,
4596
+ severity: 'warning',
4597
+ fixable: true,
4598
+ fix: 'fix-level-skip',
4599
+ targetIndex: i,
4600
+ correctLevel: previous.level + 1
4601
+ });
4602
+ }
4603
+ }
4604
+
4605
+ // Check for empty headings
4606
+ headings.forEach((heading, index) => {
4607
+ if (!heading.text) {
4608
+ issues.push({
4609
+ type: '๐Ÿ“‘ Empty heading',
4610
+ description: `Heading ${index + 1} (h${heading.level}) is empty`,
4611
+ suggestion: 'Add descriptive text to the heading or remove it',
4612
+ severity: 'error',
4613
+ fixable: true,
4614
+ fix: 'fix-empty-heading',
4615
+ targetIndex: index
4616
+ });
4617
+ }
4618
+ });
4619
+
4620
+ // Check for consecutive headings with same level and similar content
4621
+ for (let i = 1; i < headings.length; i++) {
4622
+ const current = headings[i];
4623
+ const previous = headings[i - 1];
4624
+
4625
+ if (current.level === previous.level &&
4626
+ current.text.toLowerCase() === previous.text.toLowerCase() &&
4627
+ current.text.length > 0) {
4628
+ issues.push({
4629
+ type: '๐Ÿ“‘ Duplicate heading',
4630
+ description: `Duplicate h${current.level} heading: "${current.text}"`,
4631
+ suggestion: 'Make heading text unique or merge content',
4632
+ severity: 'warning',
4633
+ fixable: false
4634
+ });
4635
+ }
4636
+ }
4637
+
4638
+ return { issues, suggestions, headings };
4639
+ }
4640
+
4641
+ fixHeadingStructureInContent(content, analysis) {
4642
+ let fixed = content;
4643
+ const { issues, headings } = analysis;
4644
+
4645
+ // Sort issues by position (descending) to avoid position shifts
4646
+ const fixableIssues = issues
4647
+ .filter(issue => issue.fixable)
4648
+ .sort((a, b) => (b.targetIndex || 0) - (a.targetIndex || 0));
4649
+
4650
+ fixableIssues.forEach(issue => {
4651
+ switch (issue.fix) {
4652
+ case 'add-h1':
4653
+ fixed = this.addMissingH1(fixed);
4654
+ break;
4655
+
4656
+ case 'fix-multiple-h1':
4657
+ fixed = this.fixMultipleH1(fixed, headings);
4658
+ break;
4659
+
4660
+ case 'fix-level-skip':
4661
+ if (issue.targetIndex !== undefined && issue.correctLevel) {
4662
+ fixed = this.fixHeadingLevelSkip(fixed, headings[issue.targetIndex], issue.correctLevel);
4663
+ }
4664
+ break;
4665
+
4666
+ case 'fix-empty-heading':
4667
+ if (issue.targetIndex !== undefined) {
4668
+ fixed = this.fixEmptyHeading(fixed, headings[issue.targetIndex]);
4669
+ }
4670
+ break;
4671
+ }
4672
+ });
4673
+
4674
+ return fixed;
4675
+ }
4676
+
4677
+ addMissingH1(content) {
4678
+ // Try to find the first heading and convert it to h1
4679
+ const firstHeadingMatch = content.match(/<h([2-6])[^>]*>([\s\S]*?)<\/h[2-6]>/i);
4680
+
4681
+ if (firstHeadingMatch) {
4682
+ const level = firstHeadingMatch[1];
4683
+ const replacement = firstHeadingMatch[0].replace(
4684
+ new RegExp(`<h${level}([^>]*)>`, 'i'),
4685
+ '<h1$1>'
4686
+ ).replace(
4687
+ new RegExp(`</h${level}>`, 'i'),
4688
+ '</h1>'
4689
+ );
4690
+
4691
+ const result = content.replace(firstHeadingMatch[0], replacement);
4692
+ console.log(chalk.yellow(` ๐Ÿ“‘ Converted first h${level} to h1`));
4693
+ return result;
4694
+ }
4695
+
4696
+ // If no headings found, try to add h1 based on title or first significant text
4697
+ const titleMatch = content.match(/<title[^>]*>([^<]+)<\/title>/i);
4698
+ if (titleMatch) {
4699
+ const title = titleMatch[1].trim();
4700
+ // Insert h1 after opening body tag
4701
+ const bodyMatch = content.match(/(<body[^>]*>)/i);
4702
+ if (bodyMatch) {
4703
+ const h1Element = `\n <h1>${title}</h1>\n`;
4704
+ const result = content.replace(bodyMatch[1], bodyMatch[1] + h1Element);
4705
+ console.log(chalk.yellow(` ๐Ÿ“‘ Added h1 element with title: "${title}"`));
4706
+ return result;
4707
+ }
4708
+ }
4709
+
4710
+ return content;
4711
+ }
4712
+
4713
+ fixMultipleH1(content, headings) {
4714
+ const h1Elements = headings.filter(h => h.level === 1);
4715
+
4716
+ // Keep the first h1, convert others to h2
4717
+ for (let i = 1; i < h1Elements.length; i++) {
4718
+ const h1 = h1Elements[i];
4719
+ const replacement = h1.fullTag.replace(/<h1([^>]*)>/i, '<h2$1>').replace(/<\/h1>/i, '</h2>');
4720
+ content = content.replace(h1.fullTag, replacement);
4721
+ console.log(chalk.yellow(` ๐Ÿ“‘ Converted extra h1 to h2: "${h1.text}"`));
4722
+ }
4723
+
4724
+ return content;
4725
+ }
4726
+
4727
+ fixHeadingLevelSkip(content, heading, correctLevel) {
4728
+ const replacement = heading.fullTag
4729
+ .replace(new RegExp(`<h${heading.level}([^>]*)>`, 'i'), `<h${correctLevel}$1>`)
4730
+ .replace(new RegExp(`</h${heading.level}>`, 'i'), `</h${correctLevel}>`);
4731
+
4732
+ const result = content.replace(heading.fullTag, replacement);
4733
+ console.log(chalk.yellow(` ๐Ÿ“‘ Fixed level skip: h${heading.level} โ†’ h${correctLevel} for "${heading.text}"`));
4734
+ return result;
4735
+ }
4736
+
4737
+ fixEmptyHeading(content, heading) {
4738
+ // Generate meaningful text based on context
4739
+ const contextText = this.generateHeadingText(content, heading);
4740
+
4741
+ if (contextText) {
4742
+ const replacement = heading.fullTag.replace(
4743
+ /<h([1-6])([^>]*)>[\s\S]*?<\/h[1-6]>/i,
4744
+ `<h$1$2>${contextText}</h$1>`
4745
+ );
4746
+
4747
+ const result = content.replace(heading.fullTag, replacement);
4748
+ console.log(chalk.yellow(` ๐Ÿ“‘ Added text to empty heading: "${contextText}"`));
4749
+ return result;
4750
+ }
4751
+
4752
+ // If can't generate text, remove the empty heading
4753
+ const result = content.replace(heading.fullTag, '');
4754
+ console.log(chalk.yellow(` ๐Ÿ“‘ Removed empty h${heading.level} heading`));
4755
+ return result;
4756
+ }
4757
+
4758
+ generateHeadingText(content, heading) {
4759
+ const lang = this.config.language;
4760
+
4761
+ // Try to find nearby text content
4762
+ const position = heading.position;
4763
+ const contextRange = 500;
4764
+ const beforeContext = content.substring(Math.max(0, position - contextRange), position);
4765
+ const afterContext = content.substring(position + heading.fullTag.length, position + heading.fullTag.length + contextRange);
4766
+
4767
+ // Look for meaningful text in nearby paragraphs
4768
+ const nearbyText = (beforeContext + afterContext).replace(/<[^>]*>/g, ' ').trim();
4769
+ const words = nearbyText.split(/\s+/).filter(word => word.length > 2);
4770
+
4771
+ if (words.length > 0) {
4772
+ const meaningfulWords = words.slice(0, 3);
4773
+ return meaningfulWords.join(' ');
4774
+ }
4775
+
4776
+ // Fallback to generic text based on language
4777
+ const genericTexts = {
4778
+ ja: ['่ฆ‹ๅ‡บใ—', 'ใ‚ปใ‚ฏใ‚ทใƒงใƒณ', 'ใ‚ณใƒณใƒ†ใƒณใƒ„'],
4779
+ en: ['Heading', 'Section', 'Content'],
4780
+ vi: ['Tiรชu ฤ‘แป', 'Phแบงn', 'Nแป™i dung']
4781
+ };
4782
+
4783
+ const texts = genericTexts[lang] || genericTexts.en;
4784
+ return texts[0];
4785
+ }
4786
+
4787
+ countHeadingFixes(originalContent, fixedContent) {
4788
+ // Count the number of heading-related changes
4789
+ const originalHeadings = (originalContent.match(/<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/gi) || []).length;
4790
+ const fixedHeadings = (fixedContent.match(/<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/gi) || []).length;
4791
+
4792
+ // Simple heuristic: count tag changes
4793
+ let changes = 0;
4794
+
4795
+ // Count h1 additions
4796
+ const originalH1 = (originalContent.match(/<h1[^>]*>/gi) || []).length;
4797
+ const fixedH1 = (fixedContent.match(/<h1[^>]*>/gi) || []).length;
4798
+ changes += Math.abs(fixedH1 - originalH1);
4799
+
4800
+ // Count level changes (rough estimate)
4801
+ for (let level = 1; level <= 6; level++) {
4802
+ const originalCount = (originalContent.match(new RegExp(`<h${level}[^>]*>`, 'gi')) || []).length;
4803
+ const fixedCount = (fixedContent.match(new RegExp(`<h${level}[^>]*>`, 'gi')) || []).length;
4804
+ changes += Math.abs(fixedCount - originalCount);
4805
+ }
4806
+
4807
+ return Math.max(1, Math.floor(changes / 2)); // Rough estimate
4808
+ }
4809
+
4810
+ async fixDescriptionLists(directory = '.') {
4811
+ console.log(chalk.blue('๐Ÿ“‹ Fixing description list structure...'));
4812
+
4813
+ const htmlFiles = await this.findHtmlFiles(directory);
4814
+ const results = [];
4815
+ let totalIssuesFound = 0;
4816
+
4817
+ for (const file of htmlFiles) {
4818
+ try {
4819
+ const content = await fs.readFile(file, 'utf8');
4820
+ const issues = this.analyzeDescriptionListStructure(content);
4821
+
4822
+ if (issues.length > 0) {
4823
+ console.log(chalk.cyan(`\n๐Ÿ“ ${file}:`));
4824
+ issues.forEach(issue => {
4825
+ console.log(chalk.yellow(` ${issue.type}: ${issue.description}`));
4826
+ if (issue.suggestion) {
4827
+ console.log(chalk.gray(` ๐Ÿ’ก ${issue.suggestion}`));
4828
+ }
4829
+ totalIssuesFound++;
4830
+ });
4831
+ }
4832
+
4833
+ const fixed = this.fixDescriptionListStructureInContent(content);
4834
+
4835
+ if (fixed !== content) {
4836
+ if (this.config.backupFiles) {
4837
+ await fs.writeFile(`${file}.backup`, content);
4838
+ }
4839
+
4840
+ if (!this.config.dryRun) {
4841
+ await fs.writeFile(file, fixed);
4842
+ }
4843
+
4844
+ console.log(chalk.green(`โœ… Fixed description list structure in: ${file}`));
4845
+ results.push({ file, status: 'fixed', issues: issues.length });
4846
+ } else {
4847
+ results.push({ file, status: 'no-change', issues: issues.length });
4848
+ }
4849
+ } catch (error) {
4850
+ console.error(chalk.red(`โŒ Error processing ${file}: ${error.message}`));
4851
+ results.push({ file, status: 'error', error: error.message });
4852
+ }
4853
+ }
4854
+
4855
+ console.log(chalk.blue(`\n๐Ÿ“Š Summary: Found ${totalIssuesFound} description list issues across ${results.length} files`));
4856
+ return results;
4857
+ }
4858
+
4859
+ analyzeDescriptionListStructure(content) {
4860
+ const issues = [];
4861
+
4862
+ // Find all dl elements
4863
+ const dlPattern = /<dl[^>]*>([\s\S]*?)<\/dl>/gi;
4864
+ let dlMatch;
4865
+ let dlIndex = 0;
4866
+
4867
+ while ((dlMatch = dlPattern.exec(content)) !== null) {
4868
+ dlIndex++;
4869
+ const dlContent = dlMatch[1];
4870
+ const dlElement = dlMatch[0];
4871
+
4872
+ // Analyze the content inside dl
4873
+ const dtElements = (dlContent.match(/<dt[^>]*>[\s\S]*?<\/dt>/gi) || []);
4874
+ const ddElements = (dlContent.match(/<dd[^>]*>[\s\S]*?<\/dd>/gi) || []);
4875
+
4876
+ // Check for empty dl
4877
+ if (dtElements.length === 0 && ddElements.length === 0) {
4878
+ issues.push({
4879
+ type: '๐Ÿ“‹ Empty description list',
4880
+ description: `Description list ${dlIndex} is empty`,
4881
+ suggestion: 'Add dt/dd pairs or remove the empty dl element',
4882
+ severity: 'error',
4883
+ dlIndex,
4884
+ fix: 'remove-empty-dl'
4885
+ });
4886
+ continue;
4887
+ }
4888
+
4889
+ // Check for missing dt elements
4890
+ if (dtElements.length === 0 && ddElements.length > 0) {
4891
+ issues.push({
4892
+ type: '๐Ÿ“‹ Missing dt elements',
4893
+ description: `Description list ${dlIndex} has dd elements but no dt elements`,
4894
+ suggestion: 'Add dt elements to describe the dd content',
4895
+ severity: 'error',
4896
+ dlIndex,
4897
+ fix: 'add-missing-dt'
4898
+ });
4899
+ }
4900
+
4901
+ // Check for missing dd elements
4902
+ if (dtElements.length > 0 && ddElements.length === 0) {
4903
+ issues.push({
4904
+ type: '๐Ÿ“‹ Missing dd elements',
4905
+ description: `Description list ${dlIndex} has dt elements but no dd elements`,
4906
+ suggestion: 'Add dd elements to provide descriptions',
4907
+ severity: 'error',
4908
+ dlIndex,
4909
+ fix: 'add-missing-dd'
4910
+ });
4911
+ }
4912
+
4913
+ // Check for improper nesting (non-dt/dd elements directly in dl)
4914
+ const invalidChildren = dlContent.match(/<(?!dt|dd|\/dt|\/dd)[a-zA-Z][^>]*>/g);
4915
+ if (invalidChildren) {
4916
+ const invalidTags = [...new Set(invalidChildren.map(tag => tag.match(/<([a-zA-Z]+)/)[1]))];
4917
+ issues.push({
4918
+ type: '๐Ÿ“‹ Invalid dl children',
4919
+ description: `Description list ${dlIndex} contains invalid child elements: ${invalidTags.join(', ')}`,
4920
+ suggestion: 'Only dt and dd elements should be direct children of dl',
4921
+ severity: 'warning',
4922
+ dlIndex,
4923
+ fix: 'wrap-invalid-children'
4924
+ });
4925
+ }
4926
+
4927
+ // Check for empty dt/dd elements
4928
+ dtElements.forEach((dt, index) => {
4929
+ const dtText = dt.replace(/<[^>]*>/g, '').trim();
4930
+ if (!dtText) {
4931
+ issues.push({
4932
+ type: '๐Ÿ“‹ Empty dt element',
4933
+ description: `Empty dt element in description list ${dlIndex}`,
4934
+ suggestion: 'Add descriptive text to the dt element',
4935
+ severity: 'warning',
4936
+ dlIndex,
4937
+ dtIndex: index,
4938
+ fix: 'fix-empty-dt'
4939
+ });
4940
+ }
4941
+ });
4942
+
4943
+ ddElements.forEach((dd, index) => {
4944
+ const ddText = dd.replace(/<[^>]*>/g, '').trim();
4945
+ if (!ddText) {
4946
+ issues.push({
4947
+ type: '๐Ÿ“‹ Empty dd element',
4948
+ description: `Empty dd element in description list ${dlIndex}`,
4949
+ suggestion: 'Add descriptive content to the dd element',
4950
+ severity: 'warning',
4951
+ dlIndex,
4952
+ ddIndex: index,
4953
+ fix: 'fix-empty-dd'
4954
+ });
4955
+ }
4956
+ });
4957
+
4958
+ // Check for proper dt/dd pairing
4959
+ if (dtElements.length > 0 && ddElements.length > 0) {
4960
+ // Basic check: should have at least one dd for each dt
4961
+ if (ddElements.length < dtElements.length) {
4962
+ issues.push({
4963
+ type: '๐Ÿ“‹ Insufficient dd elements',
4964
+ description: `Description list ${dlIndex} has ${dtElements.length} dt elements but only ${ddElements.length} dd elements`,
4965
+ suggestion: 'Each dt should have at least one corresponding dd element',
4966
+ severity: 'warning',
4967
+ dlIndex
4968
+ });
4969
+ }
4970
+ }
4971
+ }
4972
+
4973
+ return issues;
4974
+ }
4975
+
4976
+ fixDescriptionListStructureInContent(content) {
4977
+ let fixed = content;
4978
+
4979
+ // Fix empty dl elements
4980
+ fixed = fixed.replace(/<dl[^>]*>\s*<\/dl>/gi, (match) => {
4981
+ console.log(chalk.yellow(` ๐Ÿ“‹ Removed empty description list`));
4982
+ return '';
4983
+ });
4984
+
4985
+ // Fix dl elements with only whitespace
4986
+ fixed = fixed.replace(/<dl[^>]*>[\s\n\r]*<\/dl>/gi, (match) => {
4987
+ console.log(chalk.yellow(` ๐Ÿ“‹ Removed empty description list`));
4988
+ return '';
4989
+ });
4990
+
4991
+ // Fix dl elements with invalid direct children
4992
+ fixed = fixed.replace(/<dl([^>]*)>([\s\S]*?)<\/dl>/gi, (match, attributes, content) => {
4993
+ // Extract dt and dd elements
4994
+ const dtElements = content.match(/<dt[^>]*>[\s\S]*?<\/dt>/gi) || [];
4995
+ const ddElements = content.match(/<dd[^>]*>[\s\S]*?<\/dd>/gi) || [];
4996
+
4997
+ // Find invalid children (not dt or dd)
4998
+ let cleanContent = content;
4999
+
5000
+ // Remove invalid direct children by wrapping them in dd
5001
+ cleanContent = cleanContent.replace(/<(?!dt|dd|\/dt|\/dd)([a-zA-Z][^>]*)>([\s\S]*?)<\/[a-zA-Z]+>/gi, (invalidMatch, tag, innerContent) => {
5002
+ console.log(chalk.yellow(` ๐Ÿ“‹ Wrapped invalid child element in dd`));
5003
+ return `<dd>${invalidMatch}</dd>`;
5004
+ });
5005
+
5006
+ // Handle text nodes that are not in dt/dd
5007
+ cleanContent = cleanContent.replace(/^([^<]+)(?=<(?:dt|dd))/gm, (textMatch) => {
5008
+ const trimmed = textMatch.trim();
5009
+ if (trimmed) {
5010
+ console.log(chalk.yellow(` ๐Ÿ“‹ Wrapped loose text in dd`));
5011
+ return `<dd>${trimmed}</dd>`;
5012
+ }
5013
+ return '';
5014
+ });
5015
+
5016
+ return `<dl${attributes}>${cleanContent}</dl>`;
5017
+ });
5018
+
5019
+ // Add missing dd elements for dt elements without corresponding dd
5020
+ fixed = fixed.replace(/<dl([^>]*)>([\s\S]*?)<\/dl>/gi, (match, attributes, content) => {
5021
+ const dtPattern = /<dt[^>]*>([\s\S]*?)<\/dt>/gi;
5022
+ const ddPattern = /<dd[^>]*>[\s\S]*?<\/dd>/gi;
5023
+
5024
+ const dtMatches = [...content.matchAll(dtPattern)];
5025
+ const ddMatches = [...content.matchAll(ddPattern)];
5026
+
5027
+ if (dtMatches.length > 0 && ddMatches.length === 0) {
5028
+ // Add dd elements after each dt
5029
+ let fixedContent = content;
5030
+
5031
+ // Process from end to beginning to maintain positions
5032
+ for (let i = dtMatches.length - 1; i >= 0; i--) {
5033
+ const dtMatch = dtMatches[i];
5034
+ const dtText = dtMatch[1].replace(/<[^>]*>/g, '').trim();
5035
+ const ddText = this.generateDescriptionForTerm(dtText);
5036
+
5037
+ const insertPosition = dtMatch.index + dtMatch[0].length;
5038
+ fixedContent = fixedContent.slice(0, insertPosition) +
5039
+ `\n <dd>${ddText}</dd>` +
5040
+ fixedContent.slice(insertPosition);
5041
+ }
5042
+
5043
+ console.log(chalk.yellow(` ๐Ÿ“‹ Added missing dd elements for ${dtMatches.length} dt elements`));
5044
+ return `<dl${attributes}>${fixedContent}</dl>`;
5045
+ }
5046
+
5047
+ return match;
5048
+ });
5049
+
5050
+ // Fix empty dt/dd elements
5051
+ fixed = fixed.replace(/<dt[^>]*>\s*<\/dt>/gi, (match) => {
5052
+ const lang = this.config.language;
5053
+ const defaultText = lang === 'ja' ? '้ …็›ฎ' : lang === 'vi' ? 'Mแปฅc' : 'Term';
5054
+ console.log(chalk.yellow(` ๐Ÿ“‹ Added text to empty dt element`));
5055
+ return match.replace(/>\s*</, `>${defaultText}<`);
5056
+ });
5057
+
5058
+ fixed = fixed.replace(/<dd[^>]*>\s*<\/dd>/gi, (match) => {
5059
+ const lang = this.config.language;
5060
+ const defaultText = lang === 'ja' ? '่ชฌๆ˜Ž' : lang === 'vi' ? 'Mรด tแบฃ' : 'Description';
5061
+ console.log(chalk.yellow(` ๐Ÿ“‹ Added text to empty dd element`));
5062
+ return match.replace(/>\s*</, `>${defaultText}<`);
5063
+ });
5064
+
5065
+ return fixed;
5066
+ }
5067
+
5068
+ generateDescriptionForTerm(termText) {
5069
+ const lang = this.config.language;
5070
+
5071
+ // Try to generate meaningful description based on term
5072
+ if (termText) {
5073
+ const descriptions = {
5074
+ ja: `${termText}ใฎ่ชฌๆ˜Ž`,
5075
+ en: `Description of ${termText}`,
5076
+ vi: `Mรด tแบฃ vแป ${termText}`
5077
+ };
5078
+ return descriptions[lang] || descriptions.en;
5079
+ }
5080
+
5081
+ // Fallback to generic description
5082
+ const fallbacks = {
5083
+ ja: '่ชฌๆ˜Ž',
5084
+ en: 'Description',
5085
+ vi: 'Mรด tแบฃ'
5086
+ };
5087
+
5088
+ return fallbacks[lang] || fallbacks.en;
5089
+ }
5090
+
4096
5091
  async findHtmlFiles(directory) {
4097
5092
  const files = [];
4098
5093