Compare commits

...
8 Commits
39 changed files with 4323 additions and 264 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
+11 -3
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;
} }
+187 -44
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,85 +27,227 @@ 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-5)', '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 > 5) {
$html = @file_get_contents($target); $io->error("Scan level must be between 1 and 5.");
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 ($violations as $violation) { foreach ($results as $url => $violations) {
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>"); if (empty($violations)) {
continue;
// Show Snippet
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
// Source Mapping
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
} }
$io->section("Page: $url (" . count($violations) . " violations)");
// AI Fixer foreach ($violations as $violation) {
if ($enableAi && $fixer) { $io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation); // Show Snippet
if ($suggestion) { $io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
$violation->fixSuggestion = $suggestion;
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>"); // Source Mapping
} else { $location = $locator->locate($violation->htmlSnippet);
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>"); if ($location) {
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
} }
}
$io->writeln(""); // AI Fixer
if ($enableAi && $fixer) {
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation);
if ($suggestion) {
$violation->fixSuggestion = $suggestion;
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
} else {
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
}
}
$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");
} }
+15 -1
View File
@@ -19,14 +19,28 @@ abstract class AbstractRule
*/ */
abstract public function getSeverity(): Severity; 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(), ruleId: (new \ReflectionClass($this))->getShortName(),
message: $message, message: $message,
severity: $this->getSeverity(), severity: $this->getSeverity(),
standard: $this->getStandard(), standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element) 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;
+15 -19
View File
@@ -4,7 +4,7 @@ namespace YakNet\AccessibilityConsole\Core;
class RuleEngine class RuleEngine
{ {
/** @var array */ /** @var array<int, mixed> */
private array $rules = []; private array $rules = [];
public function addRule(mixed $rule): void public function addRule(mixed $rule): void
@@ -19,25 +19,21 @@ class RuleEngine
{ {
$allViolations = []; $allViolations = [];
foreach ($this->rules as $rule) { foreach ($this->rules as $rule) {
if (method_exists($rule, 'check')) { if ($rule instanceof AbstractRule) {
// Determine if it's a document-level rule or element-level rule // Document-level rule (Core\AbstractRule style)
$reflection = new \ReflectionMethod($rule, 'check'); $violations = $rule->check($doc);
$params = $reflection->getParameters(); $allViolations = array_merge($allViolations, $violations);
} elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) {
$type = $params[0]->getType(); // Element-level rule (Rules\RuleInterface style)
if (isset($params[0]) && $type instanceof \ReflectionNamedType && $type->getName() === 'DOMDocument') { $xpath = new \DOMXPath($doc);
// Document-level rule (Core\AbstractRule style) $elements = $xpath->query('//*');
$violations = $rule->check($doc); if ($elements !== false) {
$allViolations = array_merge($allViolations, $violations);
} else {
// Element-level rule (Rules\RuleInterface style)
// We need to iterate over all elements
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*');
foreach ($elements as $element) { foreach ($elements as $element) {
$violation = $rule->check($element); if ($element instanceof \DOMElement) {
if ($violation) { $violation = $rule->check($element);
$allViolations[] = $violation; if ($violation) {
$allViolations[] = $violation;
}
} }
} }
} }
+8 -7
View File
@@ -4,11 +4,11 @@ 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 public function addRule(mixed $rule): void
@@ -28,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);
} }
+6
View File
@@ -4,6 +4,9 @@ namespace YakNet\AccessibilityConsole\Core;
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 @@ 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
{ {
+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
{ {
+8 -4
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,10 +30,13 @@ 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;
$labels = $xpath->query("//label[@for='$id']"); if ($doc !== null) {
if ($labels->length > 0) { $xpath = new \DOMXPath($doc);
return null; $labels = $xpath->query("//label[@for='$id']");
if ($labels !== false && $labels->length > 0) {
return null;
}
} }
} }
+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
{ {
+3
View File
@@ -13,6 +13,9 @@ 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) {
+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;
}
}
+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']))) {
+1866 -75
View File
File diff suppressed because it is too large Load Diff