Compare commits

...
10 Commits
40 changed files with 4346 additions and 263 deletions
+3 -1
View File
@@ -30,7 +30,9 @@
"symfony/yaml": "^6.4|^7.0",
"monolog/monolog": "^3.0",
"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": {
"phpunit/phpunit": "^10.5",
+48 -2
View File
@@ -1,10 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Broken Page</title>
<title></title>
</head>
<body>
<h1>Welcome to a broken page</h1>
<h1 id="main-content">Welcome to a broken page</h1>
<!-- Missing alt attribute -->
<img src="logo.png">
@@ -15,6 +15,52 @@
<!-- Empty 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>
</body>
</html>
+10 -19
View File
@@ -1,19 +1,11 @@
<?php
// 1. Setup Autoloading (Mocking Composer)
require_once __DIR__ . '/../src/Scanner.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';
// 1. Setup Autoloading
require_once __DIR__ . '/../vendor/autoload.php';
use YakNet\AccessibilityConsole\Scanner;
use YakNet\AccessibilityConsole\SourceLocator;
use YakNet\AccessibilityConsole\Reporter\HtmlReporter;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Source\SourceLocator;
use YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter;
use YakNet\AccessibilityConsole\Rules\ImgAltText;
use YakNet\AccessibilityConsole\Rules\FormLabel;
@@ -48,19 +40,18 @@ $violations = $scanner->scan($html);
if (!empty($violations)) {
// Locate Sources
// We point it to the 'views' directory so it knows where to look for the error source
$locator = new SourceLocator(__DIR__ . '/views');
foreach ($violations as $violation) {
$location = $locator->locate($violation->snippet);
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$violation->setSourceLocation($location['file'], $location['line']);
$violation->location = $location;
}
}
// Render Report
$reporter = new HtmlReporter();
$reportHtml = $reporter->render($violations);
// Render Report (Format as results mapped by page URL)
$reporter = new HtmlDashboardReporter();
$reportHtml = $reporter->render(['http://localhost:8080/index.php' => $violations], 'demo/index.php');
// Inject into body
$html = str_replace('</body>', $reportHtml . '</body>', $html);
+4
View File
@@ -0,0 +1,4 @@
parameters:
level: 9
paths:
- src
+6 -12
View File
@@ -38,21 +38,15 @@ Error: {$violation->message}
Severity: {$violation->severity->value}
Standard: {$violation->standard->value}
Please provide ONLY the fixed version of the HTML snippet.
Do not explain, do not add markdown code blocks, just return the corrected HTML.
If the error is about missing 'alt' text, add a meaningful alt text based on context.
If it's about ARIA roles, add the correct ones.
Please provide a short explanation of the fix (in Turkish) and the fixed version of the HTML snippet.
Use the following EXACT format:
EXPLANATION: [A short explanation of what you fixed and why]
FIX: [Only the corrected HTML snippet]
PROMPT;
try {
// Stable model: gemini-1.5-flash
$result = $this->client->generativeModel('gemini-1.5-flash')->generateContent($prompt);
$suggestion = trim($result->text());
// Clean up if AI included markdown blocks anyway
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
return $suggestion;
$result = $this->client->generativeModel('gemini-2.5-flash')->generateContent($prompt);
return trim($result->text());
} catch (\Throwable $e) {
return "AI Düzeltme Hatası: " . $e->getMessage();
}
+22 -6
View File
@@ -30,8 +30,16 @@ class FixCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
$projectPath = $input->getOption('project-path') ?? getcwd();
$dryRun = $input->getOption('dry-run');
if (!is_string($target)) {
$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();
$apiKey = $config->getGeminiApiKey();
@@ -45,7 +53,7 @@ class FixCommand extends Command
// 1. Fetch HTML
$html = @file_get_contents($target);
if (!$html) {
if ($html === false || $html === '') {
$io->error("Could not read target: $target");
return Command::FAILURE;
}
@@ -79,9 +87,17 @@ class FixCommand extends Command
}
// Get AI Suggestion
$suggestion = $fixer->suggestFix($violation);
if (!$suggestion) {
$io->writeln("<fg=red>Skipped (AI could not generate fix)</>");
$rawSuggestion = $fixer->suggestFix($violation);
$suggestion = $rawSuggestion;
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;
}
+187 -44
View File
@@ -12,11 +12,12 @@ use YakNet\AccessibilityConsole\AI\GeminiFixer;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
use YakNet\AccessibilityConsole\Source\SourceLocator;
class ScanCommand extends Command
{
protected static $defaultName = 'scan';
protected static ?string $defaultName = 'scan';
protected function configure(): void
{
@@ -26,85 +27,227 @@ class ScanCommand extends Command
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
->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('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
{
$io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
$enableAi = $input->getOption('ai');
$projectPath = $input->getOption('project-path') ?? getcwd();
if (!is_string($target)) {
$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");
// 1. Fetch HTML
$html = @file_get_contents($target);
if (!$html) {
$io->error("Could not read target: $target");
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : 4;
if ($level < 1 || $level > 5) {
$io->error("Scan level must be between 1 and 5.");
return Command::FAILURE;
}
// 2. Setup Scanner
$io->title("Accessibility Console - Scanning: $target (Level $level)");
// 1. Setup Scanner
$scanner = new Scanner();
foreach (StandardRuleSet::all() as $rule) {
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
// 3. Scan
$violations = $scanner->scan($html);
// 2. Fetch and Scan
$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!");
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);
$config = new Config();
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
foreach ($violations as $violation) {
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
// 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']}</>");
foreach ($results as $url => $violations) {
if (empty($violations)) {
continue;
}
$io->section("Page: $url (" . count($violations) . " violations)");
// 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)</>");
foreach ($violations as $violation) {
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
// 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->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');
if ($reportPath) {
if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
if ($extension === 'json') {
$data = array_map(fn($v) => $v->toArray(), $violations);
file_put_contents($reportPath, json_encode($data, JSON_PRETTY_PRINT));
$serialized = [];
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 {
$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");
}
+15 -1
View File
@@ -19,14 +19,28 @@ abstract class AbstractRule
*/
abstract public function getSeverity(): Severity;
public function getLevel(): int
{
return 4;
}
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(
ruleId: (new \ReflectionClass($this))->getShortName(),
message: $message,
severity: $this->getSeverity(),
standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element)
htmlSnippet: $html
);
}
}
+4
View File
@@ -4,8 +4,12 @@ namespace YakNet\AccessibilityConsole\Core;
class Config
{
/** @var array<string, mixed> */
private array $settings = [];
/**
* @param array<string, mixed> $settings
*/
public function __construct(array $settings = [])
{
$this->settings = $settings;
+15 -18
View File
@@ -4,7 +4,7 @@ namespace YakNet\AccessibilityConsole\Core;
class RuleEngine
{
/** @var array */
/** @var array<int, mixed> */
private array $rules = [];
public function addRule(mixed $rule): void
@@ -19,24 +19,21 @@ class RuleEngine
{
$allViolations = [];
foreach ($this->rules as $rule) {
if (method_exists($rule, 'check')) {
// Determine if it's a document-level rule or element-level rule
$reflection = new \ReflectionMethod($rule, 'check');
$params = $reflection->getParameters();
if (isset($params[0]) && $params[0]->getType() && $params[0]->getType()->getName() === 'DOMDocument') {
// Document-level rule (Core\AbstractRule style)
$violations = $rule->check($doc);
$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('//*');
if ($rule instanceof AbstractRule) {
// Document-level rule (Core\AbstractRule style)
$violations = $rule->check($doc);
$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) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
if ($element instanceof \DOMElement) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
}
}
}
}
+8 -7
View File
@@ -4,11 +4,11 @@ namespace YakNet\AccessibilityConsole\Core;
class Scanner
{
public function __construct(private ?RuleEngine $engine = null)
private RuleEngine $engine;
public function __construct(?RuleEngine $engine = null)
{
if ($this->engine === null) {
$this->engine = new RuleEngine();
}
$this->engine = $engine ?? new RuleEngine();
}
public function addRule(mixed $rule): void
@@ -28,9 +28,10 @@ class Scanner
return [];
}
$doc = new \DOMDocument();
// Suppress warnings for malformed HTML
@$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
$html5 = new \Masterminds\HTML5([
'disable_html_ns' => true,
]);
$doc = $html5->loadHTML($html);
return $this->engine->run($doc);
}
+6
View File
@@ -4,6 +4,9 @@ namespace YakNet\AccessibilityConsole\Core;
class Violation
{
/**
* @param array{file: string, line: int}|null $location
*/
public function __construct(
public string $ruleId,
public string $message,
@@ -14,6 +17,9 @@ class Violation
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
{
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 getSeverity(): Severity;
public function getLevel(): int
{
return 4;
}
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(
ruleId: $this->getId(),
message: $message,
severity: $this->getSeverity(),
standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element),
htmlSnippet: $html,
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 getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
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 getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
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 getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
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 getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
@@ -29,10 +30,13 @@ class FormLabel extends AbstractRule
if ($element->hasAttribute('id')) {
$id = $element->getAttribute('id');
$xpath = new \DOMXPath($element->ownerDocument);
$labels = $xpath->query("//label[@for='$id']");
if ($labels->length > 0) {
return null;
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$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 getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
+6
View File
@@ -13,9 +13,15 @@ class HeadingOrderRule extends AbstractRule
$violations = [];
$xpath = new \DOMXPath($doc);
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
if ($headings === false) {
return [];
}
$lastLevel = 0;
foreach ($headings as $h) {
if (!$h instanceof \DOMElement) {
continue;
}
$level = (int)substr($h->tagName, 1);
if ($lastLevel > 0 && $level > $lastLevel + 1) {
$violations[] = $this->createViolation(
+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 getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
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 getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
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
{
/**
* @return RuleInterface[]
* @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
*/
public static function all(): array
{
return [
new HtmlHasLang(),
new ImgAltText(),
new EmptyLink(),
new ButtonName(),
new FormLabel(),
new FieldsetLegend(),
new HeadingOrder(),
new MetaViewport(),
];
return RuleLevels::getRulesForLevel(5);
}
}
+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
{
/** @var array<int, string> */
private array $extensions = ['php', 'html', 'twig', 'blade.php'];
public function __construct(
private readonly string $searchPath
) {}
/**
* @return array{file: string, line: int}|null
*/
public function locate(string $snippet): ?array
{
if (!is_dir($this->searchPath) || !is_readable($this->searchPath)) {
return null;
}
$cleanSnippet = trim(preg_replace('/\s+/', ' ', $snippet));
$replaced = preg_replace('/\s+/', ' ', $snippet);
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
if ($cleanSnippet === '') {
return null;
}
@@ -29,7 +34,17 @@ class SourceLocator
try {
$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) {
return null;
}
@@ -76,6 +91,9 @@ class SourceLocator
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
{
$content = file_get_contents($filepath);
@@ -91,7 +109,8 @@ class SourceLocator
foreach ($lines as $index => $line) {
$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)) {
return [
@@ -118,6 +137,9 @@ class SourceLocator
return ['type' => 'none'];
}
/**
* @return array{tag: string|null, attributes: array<string, string>}
*/
private function parseSnippet(string $snippet): array
{
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
@@ -133,6 +155,9 @@ class SourceLocator
return ['tag' => $tagName, 'attributes' => $attributes];
}
/**
* @param array{tag: string|null, attributes: array<string, string>} $parsed
*/
private function calculateFuzzyScore(string $line, array $parsed): float
{
if (!$parsed['tag'] || !str_contains(strtolower($line), '<' . strtolower($parsed['tag']))) {
+1866 -75
View File
File diff suppressed because it is too large Load Diff