Compare commits

..
14 Commits
Author SHA1 Message Date
Enes Yakıştır c9ffbdba08 feat: extend scan level range 1-9 and confirm PHPStan level 9 zero errors 2026-06-07 10:18:15 +03:00
Enes Yakıştır 2a97138fec feat: implement dynamic rule discovery and incremental level filtering 2026-06-06 13:48:10 +03:00
Enes Yakıştır c42ab714ff feat: add guzzle-powered recursive website crawling and masterminds/html5 integration 2026-06-06 13:22:35 +03:00
Enes Yakıştır b805741ef2 feat: redesign HtmlDashboardReporter to premium responsive accessible dashboard 2026-06-06 10:20:50 +03:00
Enes Yakıştır 84f713e434 feat: add PageTitle accessibility rule (WCAG 2.4.2) 2026-06-06 10:16:39 +03:00
Enes Yakıştır c6df5e2bdc feat: add 4 new WCAG rules and optimize SourceLocator directory traversal 2026-06-06 10:15:24 +03:00
Enes Yakıştır 75171c6f96 build: upgrade PHPStan to level 9 and fix strict type errors 2026-06-06 09:36:46 +03:00
Enes Yakıştır 1ea2579d20 chore: configure PHPStan Level 6 and resolve strict-type warnings 2026-05-31 23:00:56 +03:00
Enes Yakıştır 873b4c4233 feat: implement 5 new advanced WCAG rules (ColorContrast, DuplicateId, TabindexOrder, AriaRole, IframeTitle) 2026-05-30 12:04:21 +03:00
Enes Yakıştır d219272487 fix: resolve strict types for ReflectionType and DOMNode 2026-05-30 11:13:17 +03:00
Enes Yakıştır 09f33650ec Fix AI suggestions, improve reporter UI, and fix RuleEngine compatibility issues 2026-05-13 13:09:01 +03:00
Enes Yakıştır 3a19c1c534 Fix model name to gemini-1.5-flash v2.2.3 2026-05-13 12:46:26 +03:00
Enes Yakıştır 40dc04d328 Add debug info to GeminiFixer v2.2.2 2026-05-13 12:21:24 +03:00
Enes Yakıştır 3f262b7b35 Fix Violation constructor parameters and Enum usage v2.2.1 2026-05-13 12:16:37 +03:00
42 changed files with 4413 additions and 184 deletions
+3 -1
View File
@@ -30,7 +30,9 @@
"symfony/yaml": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0",
"monolog/monolog": "^3.0", "monolog/monolog": "^3.0",
"vlucas/phpdotenv": "^5.6", "vlucas/phpdotenv": "^5.6",
"google-gemini-php/client": "^1.0" "google-gemini-php/client": "^1.0",
"guzzlehttp/guzzle": "^7.8",
"masterminds/html5": "^2.8"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^10.5", "phpunit/phpunit": "^10.5",
+48 -2
View File
@@ -1,10 +1,10 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Broken Page</title> <title></title>
</head> </head>
<body> <body>
<h1>Welcome to a broken page</h1> <h1 id="main-content">Welcome to a broken page</h1>
<!-- Missing alt attribute --> <!-- Missing alt attribute -->
<img src="logo.png"> <img src="logo.png">
@@ -15,6 +15,52 @@
<!-- Empty button --> <!-- Empty button -->
<button type="submit"></button> <button type="submit"></button>
<!-- Positive tabindex -->
<a href="/keyboard" tabindex="5">Keyboard test</a>
<!-- Duplicate ID -->
<div id="main-content">This is a duplicate main-content ID div!</div>
<!-- Low Contrast -->
<p style="color: #888888; background-color: #999999;">This text has extremely low contrast ratio!</p>
<!-- Invalid ARIA role and missing controls target -->
<div role="cool-widget" aria-controls="non-existent-popup">WAI-ARIA elements</div>
<!-- Missing Iframe Title -->
<iframe src="https://www.youtube.com/embed/aqz-KE-bpKQ" width="560" height="315"></iframe>
<!-- New WCAG Rule Violations for testing -->
<!-- LinkTextGeneric: Generic text without context -->
<a href="/read-more">Click here</a>
<a href="/devam-oku">devamını oku</a>
<!-- PlaceholderAsLabel: Input relying only on placeholder -->
<input type="text" placeholder="Search website...">
<!-- ImageAltRedundant: Redundant words in alt text -->
<img src="logo2.png" alt="logo of our company">
<img src="profile.jpg" alt="profile photo of john doe">
<!-- AriaHiddenFocusable: Focusable element hidden with aria-hidden="true" -->
<div aria-hidden="true">
<button type="button">Hidden click</button>
</div>
<a href="#" aria-hidden="true">Hidden link</a>
<!-- Empty heading violation -->
<h2></h2>
<!-- Video missing track violation -->
<video src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4" width="320" height="240" controls></video>
<!-- Empty label violation -->
<label for="search-input"></label>
<!-- AriaFocusable violation -->
<div role="button">Clickable div button without tabindex</div>
<p>This page has multiple accessibility issues.</p> <p>This page has multiple accessibility issues.</p>
</body> </body>
</html> </html>
+10 -19
View File
@@ -1,19 +1,11 @@
<?php <?php
// 1. Setup Autoloading (Mocking Composer) // 1. Setup Autoloading
require_once __DIR__ . '/../src/Scanner.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../src/Violation.php';
require_once __DIR__ . '/../src/SourceLocator.php';
require_once __DIR__ . '/../src/Reporter/HtmlReporter.php';
require_once __DIR__ . '/../src/Rule/RuleInterface.php';
require_once __DIR__ . '/../src/Rules/ImgAltText.php';
require_once __DIR__ . '/../src/Rules/FormLabel.php';
require_once __DIR__ . '/../src/Rules/HtmlHasLang.php';
require_once __DIR__ . '/../src/Rules/EmptyLink.php';
use YakNet\AccessibilityConsole\Scanner; use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\SourceLocator; use YakNet\AccessibilityConsole\Source\SourceLocator;
use YakNet\AccessibilityConsole\Reporter\HtmlReporter; use YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter;
use YakNet\AccessibilityConsole\Rules\ImgAltText; use YakNet\AccessibilityConsole\Rules\ImgAltText;
use YakNet\AccessibilityConsole\Rules\FormLabel; use YakNet\AccessibilityConsole\Rules\FormLabel;
@@ -48,19 +40,18 @@ $violations = $scanner->scan($html);
if (!empty($violations)) { if (!empty($violations)) {
// Locate Sources // Locate Sources
// We point it to the 'views' directory so it knows where to look for the error source
$locator = new SourceLocator(__DIR__ . '/views'); $locator = new SourceLocator(__DIR__ . '/views');
foreach ($violations as $violation) { foreach ($violations as $violation) {
$location = $locator->locate($violation->snippet); $location = $locator->locate($violation->htmlSnippet);
if ($location) { if ($location) {
$violation->setSourceLocation($location['file'], $location['line']); $violation->location = $location;
} }
} }
// Render Report // Render Report (Format as results mapped by page URL)
$reporter = new HtmlReporter(); $reporter = new HtmlDashboardReporter();
$reportHtml = $reporter->render($violations); $reportHtml = $reporter->render(['http://localhost:8080/index.php' => $violations], 'demo/index.php');
// Inject into body // Inject into body
$html = str_replace('</body>', $reportHtml . '</body>', $html); $html = str_replace('</body>', $reportHtml . '</body>', $html);
+4
View File
@@ -0,0 +1,4 @@
parameters:
level: 9
paths:
- src
+17 -15
View File
@@ -11,15 +11,23 @@ class GeminiFixer
public function __construct(private readonly ?string $apiKey) public function __construct(private readonly ?string $apiKey)
{ {
if ($this->apiKey && class_exists(Gemini::class)) { if ($this->apiKey) {
$this->client = Gemini::client($this->apiKey); try {
$this->client = \Gemini::client($this->apiKey);
} catch (\Throwable) {
$this->client = null;
}
} }
} }
public function suggestFix(Violation $violation): ?string public function suggestFix(Violation $violation): ?string
{ {
if (!$this->apiKey) {
return "Hata: GEMINI_API_KEY bulunamadı.";
}
if (!$this->client) { if (!$this->client) {
return null; return "Hata: Gemini istemcisi başlatılamadı. Kütüphane eksik olabilir.";
} }
$prompt = <<<PROMPT $prompt = <<<PROMPT
@@ -30,23 +38,17 @@ Error: {$violation->message}
Severity: {$violation->severity->value} Severity: {$violation->severity->value}
Standard: {$violation->standard->value} Standard: {$violation->standard->value}
Please provide ONLY the fixed version of the HTML snippet. Please provide a short explanation of the fix (in Turkish) and the fixed version of the HTML snippet.
Do not explain, do not add markdown code blocks, just return the corrected HTML. Use the following EXACT format:
If the error is about missing 'alt' text, add a meaningful alt text based on context. EXPLANATION: [A short explanation of what you fixed and why]
If it's about ARIA roles, add the correct ones. FIX: [Only the corrected HTML snippet]
PROMPT; PROMPT;
try { try {
$result = $this->client->generativeModel('gemini-2.5-flash')->generateContent($prompt); $result = $this->client->generativeModel('gemini-2.5-flash')->generateContent($prompt);
$suggestion = trim($result->text()); return trim($result->text());
// Clean up if AI included markdown blocks anyway
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
return $suggestion;
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Log error if needed return "AI Düzeltme Hatası: " . $e->getMessage();
return null;
} }
} }
} }
+22 -6
View File
@@ -30,8 +30,16 @@ class FixCommand extends Command
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target'); $target = $input->getArgument('target');
$projectPath = $input->getOption('project-path') ?? getcwd(); if (!is_string($target)) {
$dryRun = $input->getOption('dry-run'); $io->error("Target must be a string URL or file path.");
return Command::FAILURE;
}
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$dryRun = (bool)$input->getOption('dry-run');
$config = new Config(); $config = new Config();
$apiKey = $config->getGeminiApiKey(); $apiKey = $config->getGeminiApiKey();
@@ -45,7 +53,7 @@ class FixCommand extends Command
// 1. Fetch HTML // 1. Fetch HTML
$html = @file_get_contents($target); $html = @file_get_contents($target);
if (!$html) { if ($html === false || $html === '') {
$io->error("Could not read target: $target"); $io->error("Could not read target: $target");
return Command::FAILURE; return Command::FAILURE;
} }
@@ -79,9 +87,17 @@ class FixCommand extends Command
} }
// Get AI Suggestion // Get AI Suggestion
$suggestion = $fixer->suggestFix($violation); $rawSuggestion = $fixer->suggestFix($violation);
if (!$suggestion) { $suggestion = $rawSuggestion;
$io->writeln("<fg=red>Skipped (AI could not generate fix)</>"); if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
$suggestion = trim($matches[1] ?? $rawSuggestion);
// Clean up potential markdown
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
}
if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue; continue;
} }
+167 -23
View File
@@ -12,11 +12,12 @@ use YakNet\AccessibilityConsole\AI\GeminiFixer;
use YakNet\AccessibilityConsole\Core\Config; use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner; use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet; use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
use YakNet\AccessibilityConsole\Source\SourceLocator; use YakNet\AccessibilityConsole\Source\SourceLocator;
class ScanCommand extends Command class ScanCommand extends Command
{ {
protected static $defaultName = 'scan'; protected static ?string $defaultName = 'scan';
protected function configure(): void protected function configure(): void
{ {
@@ -26,46 +27,184 @@ class ScanCommand extends Command
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan') ->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions') ->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions')
->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)') ->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping'); ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping')
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively')
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth', '3')
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum number of pages to crawl', '20')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)', '4');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target'); $target = $input->getArgument('target');
$enableAi = $input->getOption('ai'); if (!is_string($target)) {
$projectPath = $input->getOption('project-path') ?? getcwd(); $io->error("Target must be a string URL or file path.");
return Command::FAILURE;
}
$enableAi = (bool)$input->getOption('ai');
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$io->title("Accessibility Console - Scanning: $target"); $levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : 4;
// 1. Fetch HTML if ($level < 1 || $level > 9) {
$html = @file_get_contents($target); $io->error("Scan level must be between 1 and 9.");
if (!$html) {
$io->error("Could not read target: $target");
return Command::FAILURE; return Command::FAILURE;
} }
// 2. Setup Scanner $io->title("Accessibility Console - Scanning: $target (Level $level)");
// 1. Setup Scanner
$scanner = new Scanner(); $scanner = new Scanner();
foreach (StandardRuleSet::all() as $rule) { $rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule); $scanner->addRule($rule);
} }
// 3. Scan // 2. Fetch and Scan
$violations = $scanner->scan($html); $isUrl = str_starts_with($target, 'http://') || str_starts_with($target, 'https://');
$crawl = (bool)$input->getOption('crawl') && $isUrl;
if (empty($violations)) { /** @var array<string, \YakNet\AccessibilityConsole\Core\Violation[]> $results */
$results = [];
if ($crawl) {
$depthOption = $input->getOption('depth');
$depthLimit = is_numeric($depthOption) ? (int)$depthOption : 3;
$maxPagesOption = $input->getOption('max-pages');
$maxPages = is_numeric($maxPagesOption) ? (int)$maxPagesOption : 20;
$io->comment("Starting recursive crawl from: $target (Max Depth: $depthLimit, Max Pages: $maxPages)");
$parsedBase = parse_url($target);
$baseHost = $parsedBase['host'] ?? '';
$queue = [
['url' => $target, 'depth' => 1]
];
$visited = [];
$client = new \GuzzleHttp\Client([
'timeout' => 10,
'allow_redirects' => true,
'headers' => [
'User-Agent' => 'YakNet Accessibility Console Crawler/1.0',
]
]);
while (!empty($queue) && count($visited) < $maxPages) {
$current = array_shift($queue);
if (!is_array($current)) {
continue;
}
$url = $current['url'];
$depth = (int)$current['depth'];
// Remove fragment
$cleanUrl = strtok($url, '#');
if ($cleanUrl === false || isset($visited[$cleanUrl])) {
continue;
}
$visited[$cleanUrl] = true;
$io->write(" <fg=gray>Crawling [Depth $depth]:</> <fg=cyan>$cleanUrl</>... ");
try {
$response = $client->request('GET', $cleanUrl);
$html = (string)$response->getBody();
$io->writeln("<fg=green>OK</>");
// Scan the page HTML
$pageViolations = $scanner->scan($html);
$results[$cleanUrl] = $pageViolations;
// Extract links if depth is less than limit
if ($depth < $depthLimit) {
$html5 = new \Masterminds\HTML5(['disable_html_ns' => true]);
$doc = $html5->loadHTML($html);
$links = $doc->getElementsByTagName('a');
foreach ($links as $link) {
$href = $link->getAttribute('href');
if (empty($href) || str_starts_with($href, 'javascript:') || str_starts_with($href, 'mailto:') || str_starts_with($href, 'tel:')) {
continue;
}
// Resolve relative URI
try {
$baseUri = new \GuzzleHttp\Psr7\Uri($cleanUrl);
$relativeUri = new \GuzzleHttp\Psr7\Uri($href);
$absoluteUri = \GuzzleHttp\Psr7\UriResolver::resolve($baseUri, $relativeUri);
$absoluteUrl = (string)$absoluteUri;
} catch (\Throwable) {
continue;
}
// Check host compatibility
$parsedAbsolute = parse_url($absoluteUrl);
$absoluteHost = $parsedAbsolute['host'] ?? '';
if ($absoluteHost === $baseHost) {
// Add to queue if not already visited or in queue
$cleanAbsolute = strtok($absoluteUrl, '#');
if ($cleanAbsolute !== false && !isset($visited[$cleanAbsolute])) {
$inQueue = false;
foreach ($queue as $q) {
if ($q['url'] === $cleanAbsolute) {
$inQueue = true;
break;
}
}
if (!$inQueue) {
$queue[] = ['url' => $cleanAbsolute, 'depth' => $depth + 1];
}
}
}
}
}
} catch (\Throwable $e) {
$io->writeln("<fg=red>FAIL (" . $e->getMessage() . ")</>");
}
}
} else {
// Standard single page/file scan
$html = @file_get_contents($target);
if ($html === false || $html === '') {
$io->error("Could not read target: $target");
return Command::FAILURE;
}
$violations = $scanner->scan($html);
$results[$target] = $violations;
}
$totalViolations = 0;
foreach ($results as $url => $violations) {
$totalViolations += count($violations);
}
if ($totalViolations === 0) {
$io->success("No violations found!"); $io->success("No violations found!");
return Command::SUCCESS; return Command::SUCCESS;
} }
$io->section("Found " . count($violations) . " violations"); $io->section("Found a total of $totalViolations violations across " . count($results) . " pages");
// 4. Setup Helpers // 3. Setup Helpers
$locator = new SourceLocator($projectPath); $locator = new SourceLocator($projectPath);
$config = new Config(); $config = new Config();
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null; $fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
foreach ($results as $url => $violations) {
if (empty($violations)) {
continue;
}
$io->section("Page: $url (" . count($violations) . " violations)");
foreach ($violations as $violation) { foreach ($violations as $violation) {
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>"); $io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
@@ -83,6 +222,7 @@ class ScanCommand extends Command
$io->write(" <fg=gray>AI Fix Suggestion:</> "); $io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation); $suggestion = $fixer->suggestFix($violation);
if ($suggestion) { if ($suggestion) {
$violation->fixSuggestion = $suggestion;
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>"); $io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
} else { } else {
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>"); $io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
@@ -91,19 +231,23 @@ class ScanCommand extends Command
$io->writeln(""); $io->writeln("");
} }
}
$io->warning("Scanned " . count($violations) . " errors. Use --ai to get fix suggestions."); $io->warning("Scanned " . $totalViolations . " errors. Use --ai to get fix suggestions.");
// Export Report // 4. Export Report
$reportPath = $input->getOption('report'); $reportPath = $input->getOption('report');
if ($reportPath) { if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION); $extension = pathinfo($reportPath, PATHINFO_EXTENSION);
if ($extension === 'json') { if ($extension === 'json') {
$data = array_map(fn($v) => $v->toArray(), $violations); $serialized = [];
file_put_contents($reportPath, json_encode($data, JSON_PRETTY_PRINT)); foreach ($results as $url => $violations) {
$serialized[$url] = array_map(fn($v) => $v->toArray(), $violations);
}
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
} else { } else {
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter(); $reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
file_put_contents($reportPath, $reporter->render($violations, $target)); file_put_contents($reportPath, $reporter->render($results, $target));
} }
$io->success("Report saved to: $reportPath"); $io->success("Report saved to: $reportPath");
} }
+21 -6
View File
@@ -10,22 +10,37 @@ abstract class AbstractRule
abstract public function check(\DOMDocument $doc): array; abstract public function check(\DOMDocument $doc): array;
/** /**
* Get the WCAG standard ID (e.g., WCAG 2.1 1.1.1) * Get the WCAG standard Enum
*/ */
abstract public function getStandardId(): string; abstract public function getStandard(): WCAGStandard;
/** /**
* Get the severity level (Critical, Major, Minor) * Get the severity level Enum
*/ */
abstract public function getSeverity(): string; abstract public function getSeverity(): Severity;
public function getLevel(): int
{
return 4;
}
protected function createViolation(string $message, \DOMElement $element): Violation protected function createViolation(string $message, \DOMElement $element): Violation
{ {
$doc = $element->ownerDocument;
$html = '';
if ($doc !== null) {
$htmlVal = $doc->saveHTML($element);
if (is_string($htmlVal)) {
$html = $htmlVal;
}
}
return new Violation( return new Violation(
ruleId: (new \ReflectionClass($this))->getShortName(),
message: $message, message: $message,
htmlSnippet: $element->ownerDocument->saveHTML($element),
severity: $this->getSeverity(), severity: $this->getSeverity(),
standard: $this->getStandardId() standard: $this->getStandard(),
htmlSnippet: $html
); );
} }
} }
+4
View File
@@ -4,8 +4,12 @@ namespace YakNet\AccessibilityConsole\Core;
class Config class Config
{ {
/** @var array<string, mixed> */
private array $settings = []; private array $settings = [];
/**
* @param array<string, mixed> $settings
*/
public function __construct(array $settings = []) public function __construct(array $settings = [])
{ {
$this->settings = $settings; $this->settings = $settings;
+19 -2
View File
@@ -4,10 +4,10 @@ namespace YakNet\AccessibilityConsole\Core;
class RuleEngine class RuleEngine
{ {
/** @var AbstractRule[] */ /** @var array<int, mixed> */
private array $rules = []; private array $rules = [];
public function addRule(AbstractRule $rule): void public function addRule(mixed $rule): void
{ {
$this->rules[] = $rule; $this->rules[] = $rule;
} }
@@ -19,8 +19,25 @@ class RuleEngine
{ {
$allViolations = []; $allViolations = [];
foreach ($this->rules as $rule) { foreach ($this->rules as $rule) {
if ($rule instanceof AbstractRule) {
// Document-level rule (Core\AbstractRule style)
$violations = $rule->check($doc); $violations = $rule->check($doc);
$allViolations = array_merge($allViolations, $violations); $allViolations = array_merge($allViolations, $violations);
} elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) {
// Element-level rule (Rules\RuleInterface style)
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*');
if ($elements !== false) {
foreach ($elements as $element) {
if ($element instanceof \DOMElement) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
}
}
}
}
}
} }
return $allViolations; return $allViolations;
} }
+12 -6
View File
@@ -4,11 +4,16 @@ namespace YakNet\AccessibilityConsole\Core;
class Scanner class Scanner
{ {
public function __construct(private ?RuleEngine $engine = null) private RuleEngine $engine;
public function __construct(?RuleEngine $engine = null)
{ {
if ($this->engine === null) { $this->engine = $engine ?? new RuleEngine();
$this->engine = new RuleEngine();
} }
public function addRule(mixed $rule): void
{
$this->engine->addRule($rule);
} }
/** /**
@@ -23,9 +28,10 @@ class Scanner
return []; return [];
} }
$doc = new \DOMDocument(); $html5 = new \Masterminds\HTML5([
// Suppress warnings for malformed HTML 'disable_html_ns' => true,
@$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); ]);
$doc = $html5->loadHTML($html);
return $this->engine->run($doc); return $this->engine->run($doc);
} }
+7 -1
View File
@@ -2,8 +2,11 @@
namespace YakNet\AccessibilityConsole\Core; namespace YakNet\AccessibilityConsole\Core;
readonly class Violation class Violation
{ {
/**
* @param array{file: string, line: int}|null $location
*/
public function __construct( public function __construct(
public string $ruleId, public string $ruleId,
public string $message, public string $message,
@@ -14,6 +17,9 @@ readonly class Violation
public ?string $fixSuggestion = null public ?string $fixSuggestion = null
) {} ) {}
/**
* @return array{ruleId: string, message: string, severity: string, standard: string, htmlSnippet: string, location: array{file: string, line: int}|null, fixSuggestion: string|null}
*/
public function toArray(): array public function toArray(): array
{ {
return [ return [
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -14,14 +14,28 @@ abstract class AbstractRule implements RuleInterface
abstract public function getStandard(): WCAGStandard; abstract public function getStandard(): WCAGStandard;
abstract public function getSeverity(): Severity; abstract public function getSeverity(): Severity;
public function getLevel(): int
{
return 4;
}
protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation
{ {
$doc = $element->ownerDocument;
$html = '';
if ($doc !== null) {
$htmlVal = $doc->saveHTML($element);
if (is_string($htmlVal)) {
$html = $htmlVal;
}
}
return new Violation( return new Violation(
ruleId: $this->getId(), ruleId: $this->getId(),
message: $message, message: $message,
severity: $this->getSeverity(), severity: $this->getSeverity(),
standard: $this->getStandard(), standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element), htmlSnippet: $html,
fixSuggestion: $suggestion fixSuggestion: $suggestion
); );
} }
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaFocusable extends AbstractRule
{
/** @var array<int, string> */
private static array $interactiveRoles = [
'button', 'link', 'checkbox', 'menuitem', 'radio', 'textbox',
'searchbox', 'slider', 'spinbutton', 'switch', 'tab'
];
/** @var array<int, string> */
private static array $nativeFocusable = [
'a', 'button', 'input', 'select', 'textarea', 'iframe'
];
public function getId(): string { return 'WCAG_2_1_1_ARIA_FOCUSABLE'; }
public function getDescription(): string { return 'Elements with interactive ARIA roles must be focusable (e.g. have a tabindex attribute).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$role = strtolower(trim($element->getAttribute('role')));
if (!in_array($role, self::$interactiveRoles, true)) {
return null;
}
$tagName = strtolower($element->tagName);
if (in_array($tagName, self::$nativeFocusable, true)) {
return null;
}
if (!$element->hasAttribute('tabindex')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add tabindex="0" to make the element focusable via keyboard navigation.'
);
}
return null;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaHiddenFocusable extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_HIDDEN_FOCUS'; }
public function getDescription(): string { return 'Focusable elements must not be hidden with aria-hidden="true".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
if ($element->getAttribute('aria-hidden') !== 'true') {
return null;
}
if ($this->isFocusable($element) || $this->hasFocusableDescendant($element)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove aria-hidden="true" from the container or make its focusable elements non-focusable (e.g. by setting tabindex="-1" or disabled).'
);
}
return null;
}
private function isFocusable(DOMElement $element): bool
{
$tagName = strtolower($element->tagName);
if ($element->hasAttribute('tabindex')) {
$tabindex = (int)$element->getAttribute('tabindex');
if ($tabindex < 0) {
return false;
}
return true;
}
if ($tagName === 'a' && $element->hasAttribute('href')) {
return true;
}
if (in_array($tagName, ['button', 'select', 'textarea'], true)) {
return !$element->hasAttribute('disabled');
}
if ($tagName === 'input') {
$type = strtolower($element->getAttribute('type'));
return $type !== 'hidden' && !$element->hasAttribute('disabled');
}
if ($tagName === 'iframe') {
return true;
}
return false;
}
private function hasFocusableDescendant(DOMElement $element): bool
{
$doc = $element->ownerDocument;
if ($doc === null) {
return false;
}
$xpath = new \DOMXPath($doc);
$descendants = $xpath->query('.//*', $element);
if ($descendants === false) {
return false;
}
foreach ($descendants as $descendant) {
if ($descendant instanceof DOMElement && $this->isFocusable($descendant)) {
return true;
}
}
return false;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRole extends AbstractRule
{
/** @var array<int, string> */
private static array $validRoles = [
'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 'cell', 'checkbox',
'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'dialog', 'directory',
'document', 'feed', 'figure', 'form', 'grid', 'gridcell', 'group', 'heading', 'img', 'link',
'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'menu', 'menubar', 'menuitem',
'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'presentation',
'progressbar', 'radio', 'radiogroup', 'region', 'row', 'rowgroup', 'rowheader', 'scrollbar',
'search', 'searchbox', 'separator', 'slider', 'spinbutton', 'status', 'switch', 'tab', 'table',
'tablist', 'tabpanel', 'term', 'textbox', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid',
'treeitem'
];
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
// 1. Check WAI-ARIA Role Validity
$roleElements = $xpath->query('//*[@role]');
if ($roleElements !== false) {
foreach ($roleElements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$role = trim($el->getAttribute('role'));
if ($role === '') {
continue;
}
$roles = array_filter(explode(' ', $role));
foreach ($roles as $r) {
if (!in_array(strtolower($r), self::$validRoles, true)) {
$violations[] = $this->createViolation(
"Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.",
$el
);
}
}
}
}
// 2. Check aria-controls Targets Existence
$controlsElements = $xpath->query('//*[@aria-controls]');
if ($controlsElements !== false) {
foreach ($controlsElements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$targetsStr = trim($el->getAttribute('aria-controls'));
if ($targetsStr === '') {
continue;
}
$targets = array_filter(explode(' ', $targetsStr));
foreach ($targets as $targetId) {
$targetQuery = $xpath->query('//*[@id="' . $targetId . '"]');
if ($targetQuery !== false && $targetQuery->length === 0) {
$violations[] = $this->createViolation(
"Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.",
$el
);
}
}
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
}
+1
View File
@@ -13,6 +13,7 @@ class ButtonName extends AbstractRule
public function getDescription(): string { return 'Buttons must have discernible text.'; } public function getDescription(): string { return 'Buttons must have discernible text.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; } public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ColorContrast extends AbstractRule
{
public function getId(): string { return 'WCAG_1_4_3_CONTRAST'; }
public function getDescription(): string { return 'Text must have sufficient contrast against the background (4.5:1 minimum).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('style')) {
return null;
}
$style = $element->getAttribute('style');
$color = $this->parseColorValue($style, 'color');
$bg = $this->parseColorValue($style, 'background-color') ?: $this->parseColorValue($style, 'background');
if ($color === null || $bg === null) {
return null;
}
$cRGB = $this->hexToRgb($color);
$bgRGB = $this->hexToRgb($bg);
if ($cRGB === null || $bgRGB === null) {
return null;
}
$cLum = $this->getLuminance($cRGB[0], $cRGB[1], $cRGB[2]);
$bgLum = $this->getLuminance($bgRGB[0], $bgRGB[1], $bgRGB[2]);
$ratio = ($cLum > $bgLum) ? ($cLum + 0.05) / ($bgLum + 0.05) : ($bgLum + 0.05) / ($cLum + 0.05);
$isLarge = false;
if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) {
$val = (float)$matches[1];
$unit = strtolower($matches[2]);
if (($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5)) {
$isLarge = true;
}
}
if (preg_match('/font-weight\s*:\s*(bold|[7-9]00)/i', $style)) {
if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) {
$val = (float)$matches[1];
$unit = strtolower($matches[2]);
if (($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2)) {
$isLarge = true;
}
}
}
$minRatio = $isLarge ? 3.0 : 4.5;
if ($ratio < $minRatio) {
$formattedRatio = round($ratio, 2);
return $this->createViolation(
$element,
"Insufficient contrast ratio of {$formattedRatio}:1. Minimum required is {$minRatio}:1.",
"Adjust colors to increase contrast. For example, text: {$color}, background: {$bg}."
);
}
return null;
}
private function parseColorValue(string $style, string $property): ?string
{
if (preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:\s*([^;]+)/i', $style, $matches)) {
$value = trim($matches[1]);
if (preg_match('/^#([0-9a-f]{3,6})$/i', $value)) {
return $value;
}
$colors = [
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
];
if (isset($colors[strtolower($value)])) {
return $colors[strtolower($value)];
}
}
return null;
}
/**
* @return array{0: int, 1: int, 2: int}|null
*/
private function hexToRgb(string $hex): ?array
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$r = (int)hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
$g = (int)hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
$b = (int)hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
return [$r, $g, $b];
} elseif (strlen($hex) === 6) {
$r = (int)hexdec(substr($hex, 0, 2));
$g = (int)hexdec(substr($hex, 2, 2));
$b = (int)hexdec(substr($hex, 4, 2));
return [$r, $g, $b];
}
return null;
}
private function getLuminance(float $r, float $g, float $b): float
{
$rs = $r / 255.0;
$gs = $g / 255.0;
$bs = $b / 255.0;
$rVal = ($rs <= 0.03928) ? $rs / 12.92 : pow(($rs + 0.055) / 1.055, 2.4);
$gVal = ($gs <= 0.03928) ? $gs / 12.92 : pow(($gs + 0.055) / 1.055, 2.4);
$bVal = ($bs <= 0.03928) ? $bs / 12.92 : pow(($bs + 0.055) / 1.055, 2.4);
return 0.2126 * $rVal + 0.7152 * $gVal + 0.0722 * $bVal;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DuplicateId extends AbstractRule
{
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*[@id]');
if ($elements === false) {
return [];
}
$ids = [];
foreach ($elements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$id = $el->getAttribute('id');
if ($id === '') {
continue;
}
if (!isset($ids[$id])) {
$ids[$id] = [];
}
$ids[$id][] = $el;
}
foreach ($ids as $id => $elementsWithId) {
if (count($elementsWithId) > 1) {
foreach ($elementsWithId as $el) {
$violations[] = $this->createViolation(
"Duplicate ID \"{$id}\" found. Element ID attribute must be unique across the document.",
$el
);
}
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyHeading extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_EMPTY_HEADING'; }
public function getDescription(): string { return 'Heading elements (h1-h6) must have discernible text content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (!preg_match('/^h([1-6])$/i', $element->tagName)) {
return null;
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add descriptive text or an aria-label to the heading element.'
);
}
return null;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyLabel extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_EMPTY_LABEL'; }
public function getDescription(): string { return 'Label elements must have discernible text content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'label') {
return null;
}
$text = trim($element->textContent);
if ($text === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add text content to the label element to describe its associated input.'
);
}
return null;
}
}
+1
View File
@@ -13,6 +13,7 @@ class EmptyLink extends AbstractRule
public function getDescription(): string { return 'Links must have discernible text.'; } public function getDescription(): string { return 'Links must have discernible text.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; } public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
+4 -2
View File
@@ -3,6 +3,8 @@
namespace YakNet\AccessibilityConsole\Rules; namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule; use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyLinkRule extends AbstractRule class EmptyLinkRule extends AbstractRule
{ {
@@ -22,6 +24,6 @@ class EmptyLinkRule extends AbstractRule
return $violations; return $violations;
} }
public function getStandardId(): string { return 'WCAG 2.1 2.4.4'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): string { return 'Critical'; } public function getSeverity(): Severity { return Severity::CRITICAL; }
} }
+1
View File
@@ -13,6 +13,7 @@ class FieldsetLegend extends AbstractRule
public function getDescription(): string { return 'Fieldsets should have a legend.'; } public function getDescription(): string { return 'Fieldsets should have a legend.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; } public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
+6 -2
View File
@@ -13,6 +13,7 @@ class FormLabel extends AbstractRule
public function getDescription(): string { return 'Form inputs must have an associated label.'; } public function getDescription(): string { return 'Form inputs must have an associated label.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; } public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
@@ -29,12 +30,15 @@ class FormLabel extends AbstractRule
if ($element->hasAttribute('id')) { if ($element->hasAttribute('id')) {
$id = $element->getAttribute('id'); $id = $element->getAttribute('id');
$xpath = new \DOMXPath($element->ownerDocument); $doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//label[@for='$id']"); $labels = $xpath->query("//label[@for='$id']");
if ($labels->length > 0) { if ($labels !== false && $labels->length > 0) {
return null; return null;
} }
} }
}
// Check for aria-label or aria-labelledby // Check for aria-label or aria-labelledby
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) { if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
+1
View File
@@ -15,6 +15,7 @@ class HeadingOrder extends AbstractRule
public function getDescription(): string { return 'Headings should follow a logical nesting order.'; } public function getDescription(): string { return 'Headings should follow a logical nesting order.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; } public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
+10 -2
View File
@@ -3,6 +3,8 @@
namespace YakNet\AccessibilityConsole\Rules; namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule; use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class HeadingOrderRule extends AbstractRule class HeadingOrderRule extends AbstractRule
{ {
@@ -11,9 +13,15 @@ class HeadingOrderRule extends AbstractRule
$violations = []; $violations = [];
$xpath = new \DOMXPath($doc); $xpath = new \DOMXPath($doc);
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6'); $headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
if ($headings === false) {
return [];
}
$lastLevel = 0; $lastLevel = 0;
foreach ($headings as $h) { foreach ($headings as $h) {
if (!$h instanceof \DOMElement) {
continue;
}
$level = (int)substr($h->tagName, 1); $level = (int)substr($h->tagName, 1);
if ($lastLevel > 0 && $level > $lastLevel + 1) { if ($lastLevel > 0 && $level > $lastLevel + 1) {
$violations[] = $this->createViolation( $violations[] = $this->createViolation(
@@ -26,6 +34,6 @@ class HeadingOrderRule extends AbstractRule
return $violations; return $violations;
} }
public function getStandardId(): string { return 'WCAG 2.1 1.3.1'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): string { return 'Major'; } public function getSeverity(): Severity { return Severity::WARNING; }
} }
+1
View File
@@ -13,6 +13,7 @@ class HtmlHasLang extends AbstractRule
public function getDescription(): string { return 'The html element must have a lang attribute.'; } public function getDescription(): string { return 'The html element must have a lang attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; } public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class IframeTitle extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_IFRAME_TITLE'; }
public function getDescription(): string { return 'Iframe elements must have a non-empty title attribute to describe their content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'iframe') {
return null;
}
if (!$element->hasAttribute('title') || trim($element->getAttribute('title')) === '') {
return $this->createViolation(
$element,
"Iframe element is missing a descriptive title attribute.",
"Add a descriptive title attribute, e.g., title=\"Embedded Map\" or title=\"Video Player\"."
);
}
return null;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltRedundant extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT_REDUNDANT'; }
public function getDescription(): string { return 'Image alt text should not contain redundant words like "image" or "photo".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
/** @var array<int, string> */
private array $redundantWords = [
'resmi', 'görüntüsü', 'fotoğrafı', 'grafiği', 'logosu', 'ikonu',
'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of'
];
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
return null;
}
$alt = trim($element->getAttribute('alt'));
$cleanAlt = strtolower(preg_replace('/\s+/', ' ', $alt) ?? '');
if ($cleanAlt === '') {
return null;
}
foreach ($this->redundantWords as $word) {
if (str_starts_with($cleanAlt, $word) || str_contains($cleanAlt, ' ' . $word)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove redundant phrases like "' . $word . '" from the alt text. Screen readers already announce elements as images.'
);
}
}
return null;
}
}
+4 -2
View File
@@ -3,6 +3,8 @@
namespace YakNet\AccessibilityConsole\Rules; namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule; use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltRule extends AbstractRule class ImageAltRule extends AbstractRule
{ {
@@ -22,6 +24,6 @@ class ImageAltRule extends AbstractRule
return $violations; return $violations;
} }
public function getStandardId(): string { return 'WCAG 2.1 1.1.1'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): string { return 'Critical'; } public function getSeverity(): Severity { return Severity::CRITICAL; }
} }
+1
View File
@@ -13,6 +13,7 @@ class ImgAltText extends AbstractRule
public function getDescription(): string { return 'Images must have an alt attribute.'; } public function getDescription(): string { return 'Images must have an alt attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; } public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LinkTextGeneric extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_4_LINK_GENERIC'; }
public function getDescription(): string { return 'Links must have descriptive text. Generic text like "click here" or "read more" is not accessible.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
/** @var array<int, string> */
private array $genericWords = [
'click here', 'click', 'read more', 'more', 'here', 'download', 'go', 'details', 'link', 'website', 'page', 'info',
'tıkla', 'tıklayın', 'devamı', 'devamını oku', 'indir', 'git', 'detay', 'detaylar', 'link', 'site', 'sayfa', 'bilgi'
];
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'a') {
return null;
}
$text = trim($element->textContent);
$cleanText = strtolower(preg_replace('/\s+/', ' ', $text) ?? '');
if (in_array($cleanText, $this->genericWords, true)) {
// Check for descriptive attributes
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) {
return null;
}
return $this->createViolation(
$element,
$this->getDescription(),
'Change the link text to describe its target, or add an aria-label attribute to provide context.'
);
}
return null;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class PageTitle extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_2_PAGE_TITLE'; }
public function getDescription(): string { return 'Web pages must have a non-empty title element in the head.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'head') {
return null;
}
$titles = $element->getElementsByTagName('title');
if ($titles->length === 0) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a <title> element inside the <head>.'
);
}
$titleText = '';
$firstTitle = $titles->item(0);
if ($firstTitle !== null) {
$titleText = trim($firstTitle->textContent);
}
if ($titleText === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Provide descriptive text inside the <title> element.'
);
}
return null;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class PlaceholderAsLabel extends AbstractRule
{
public function getId(): string { return 'WCAG_3_3_2_PLACEHOLDER_LABEL'; }
public function getDescription(): string { return 'Inputs should not use placeholder text as their only label.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function check(DOMElement $element): ?Violation
{
$tags = ['input', 'select', 'textarea'];
if (!in_array(strtolower($element->tagName), $tags, true)) {
return null;
}
// Skip hidden inputs, buttons, etc.
$type = strtolower($element->getAttribute('type'));
if ($type === 'hidden' || $type === 'submit' || $type === 'button' || $type === 'reset') {
return null;
}
// If it doesn't have a placeholder, this rule doesn't apply (FormLabel handles general missing labels)
if (!$element->hasAttribute('placeholder')) {
return null;
}
// Check if there is an aria-label or aria-labelledby
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
return null;
}
// Check for associated label element
if ($element->hasAttribute('id')) {
$id = $element->getAttribute('id');
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//label[@for='$id']");
if ($labels !== false && $labels->length > 0) {
return null;
}
}
}
// Check if wrapped in label
$parent = $element->parentNode;
while ($parent) {
if ($parent instanceof DOMElement && strtolower($parent->tagName) === 'label') {
return null;
}
$parent = $parent->parentNode;
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add a <label for="..."> or an aria-label attribute. Placeholder text is not a reliable label replacement.'
);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
class RuleLevels
{
/**
* Get rule instances for the specified level.
* Levels are incremental: Level 5 includes all rules from Levels 1, 2, 3, and 4.
*
* @param int $level
* @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
*/
public static function getRulesForLevel(int $level): array
{
$rules = [];
$files = glob(__DIR__ . '/*.php');
if ($files === false) {
return [];
}
foreach ($files as $file) {
$className = pathinfo($file, PATHINFO_FILENAME);
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $className;
if (class_exists($fqcn)) {
$reflection = new \ReflectionClass($fqcn);
if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) {
continue;
}
// Check if it is a concrete rule
$isRule = $reflection->isSubclassOf(\YakNet\AccessibilityConsole\Core\AbstractRule::class) ||
$reflection->implementsInterface(\YakNet\AccessibilityConsole\Rules\RuleInterface::class);
if ($isRule) {
// Exclude Turkish custom rules to match standard behavior
if (in_array($className, ['EmptyLinkRule', 'HeadingOrderRule', 'ImageAltRule'], true)) {
continue;
}
$instance = $reflection->newInstance();
// Check level
$ruleLevel = 4; // default
if (method_exists($instance, 'getLevel')) {
/** @var mixed $levelVal */
$levelVal = $instance->getLevel();
if (is_int($levelVal)) {
$ruleLevel = $levelVal;
}
}
if ($ruleLevel <= $level) {
/** @var \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule $instance */
$rules[] = $instance;
}
}
}
}
// Sort rules by class name to ensure deterministic order
usort($rules, function ($a, $b) {
return strcmp(get_class($a), get_class($b));
});
return $rules;
}
}
+2 -11
View File
@@ -5,19 +5,10 @@ namespace YakNet\AccessibilityConsole\Rules;
class StandardRuleSet class StandardRuleSet
{ {
/** /**
* @return RuleInterface[] * @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
*/ */
public static function all(): array public static function all(): array
{ {
return [ return RuleLevels::getRulesForLevel(5);
new HtmlHasLang(),
new ImgAltText(),
new EmptyLink(),
new ButtonName(),
new FormLabel(),
new FieldsetLegend(),
new HeadingOrder(),
new MetaViewport(),
];
} }
} }
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TabindexOrder extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_3_TABINDEX'; }
public function getDescription(): string { return 'Avoid positive tabindex values to maintain natural keyboard focus order.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if ($element->hasAttribute('tabindex')) {
$tabindex = $element->getAttribute('tabindex');
if (is_numeric($tabindex)) {
$val = (int)$tabindex;
if ($val > 0) {
return $this->createViolation(
$element,
"Avoid positive tabindex values (tabindex=\"{$val}\") to preserve the natural tab order.",
"Remove the positive tabindex or change it to \"0\" or \"-1\"."
);
}
}
}
return null;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class VideoTrack extends AbstractRule
{
public function getId(): string { return 'WCAG_1_2_2_VIDEO_TRACK'; }
public function getDescription(): string { return 'Video elements must contain at least one track element for captions or subtitles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'video') {
return null;
}
// Check if there is a track element inside
$tracks = $element->getElementsByTagName('track');
if ($tracks->length === 0) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a <track kind="captions"> or <track kind="subtitles"> element inside the video element.'
);
}
return null;
}
}
+28 -3
View File
@@ -8,19 +8,24 @@ use SplFileInfo;
class SourceLocator class SourceLocator
{ {
/** @var array<int, string> */
private array $extensions = ['php', 'html', 'twig', 'blade.php']; private array $extensions = ['php', 'html', 'twig', 'blade.php'];
public function __construct( public function __construct(
private readonly string $searchPath private readonly string $searchPath
) {} ) {}
/**
* @return array{file: string, line: int}|null
*/
public function locate(string $snippet): ?array public function locate(string $snippet): ?array
{ {
if (!is_dir($this->searchPath) || !is_readable($this->searchPath)) { if (!is_dir($this->searchPath) || !is_readable($this->searchPath)) {
return null; return null;
} }
$cleanSnippet = trim(preg_replace('/\s+/', ' ', $snippet)); $replaced = preg_replace('/\s+/', ' ', $snippet);
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
if ($cleanSnippet === '') { if ($cleanSnippet === '') {
return null; return null;
} }
@@ -29,7 +34,17 @@ class SourceLocator
try { try {
$directory = new RecursiveDirectoryIterator($this->searchPath, \FilesystemIterator::SKIP_DOTS); $directory = new RecursiveDirectoryIterator($this->searchPath, \FilesystemIterator::SKIP_DOTS);
$iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); $filter = new \RecursiveCallbackFilterIterator($directory, function ($current) {
/** @var SplFileInfo $current */
if ($current->isDir()) {
$filename = $current->getFilename();
if ($filename === 'vendor' || $filename === '.git' || $filename === 'node_modules') {
return false;
}
}
return true;
});
$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
} catch (\UnexpectedValueException) { } catch (\UnexpectedValueException) {
return null; return null;
} }
@@ -76,6 +91,9 @@ class SourceLocator
return false; return false;
} }
/**
* @return array{type: 'exact', location: array{file: string, line: int}}|array{type: 'fuzzy', score: float, location: array{file: string, line: int}}|array{type: 'none'}
*/
private function searchInFile(string $filepath, string $searchSnippet, string $fullSnippet): array private function searchInFile(string $filepath, string $searchSnippet, string $fullSnippet): array
{ {
$content = file_get_contents($filepath); $content = file_get_contents($filepath);
@@ -91,7 +109,8 @@ class SourceLocator
foreach ($lines as $index => $line) { foreach ($lines as $index => $line) {
$lineNumber = $index + 1; $lineNumber = $index + 1;
$cleanLine = trim(preg_replace('/\s+/', ' ', $line)); $replacedLine = preg_replace('/\s+/', ' ', $line);
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
if (str_contains($cleanLine, $searchSnippet)) { if (str_contains($cleanLine, $searchSnippet)) {
return [ return [
@@ -118,6 +137,9 @@ class SourceLocator
return ['type' => 'none']; return ['type' => 'none'];
} }
/**
* @return array{tag: string|null, attributes: array<string, string>}
*/
private function parseSnippet(string $snippet): array private function parseSnippet(string $snippet): array
{ {
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches); preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
@@ -133,6 +155,9 @@ class SourceLocator
return ['tag' => $tagName, 'attributes' => $attributes]; return ['tag' => $tagName, 'attributes' => $attributes];
} }
/**
* @param array{tag: string|null, attributes: array<string, string>} $parsed
*/
private function calculateFuzzyScore(string $line, array $parsed): float private function calculateFuzzyScore(string $line, array $parsed): float
{ {
if (!$parsed['tag'] || !str_contains(strtolower($line), '<' . strtolower($parsed['tag']))) { if (!$parsed['tag'] || !str_contains(strtolower($line), '<' . strtolower($parsed['tag']))) {
+1892
View File
File diff suppressed because it is too large Load Diff