Compare commits

..
8 Commits
27 changed files with 959 additions and 45 deletions
+36 -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,40 @@
<!-- 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/dQw4w9WgXcQ" 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>
<p>This page has multiple accessibility issues.</p>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
parameters:
level: 9
paths:
- src
+16 -13
View File
@@ -11,15 +11,23 @@ class GeminiFixer
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey && class_exists(Gemini::class)) {
$this->client = Gemini::client($this->apiKey);
if ($this->apiKey) {
try {
$this->client = \Gemini::client($this->apiKey);
} catch (\Throwable) {
$this->client = null;
}
}
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->apiKey) {
return "Hata: GEMINI_API_KEY bulunamadı.";
}
if (!$this->client) {
return null;
return "Hata: Gemini istemcisi başlatılamadı. Kütüphane eksik olabilir.";
}
$prompt = <<<PROMPT
@@ -30,20 +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 {
$result = $this->client->generativeModel('gemini-2.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;
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;
}
+14 -5
View File
@@ -16,7 +16,7 @@ use YakNet\AccessibilityConsole\Source\SourceLocator;
class ScanCommand extends Command
{
protected static $defaultName = 'scan';
protected static ?string $defaultName = 'scan';
protected function configure(): void
{
@@ -33,14 +33,22 @@ class ScanCommand extends Command
{
$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) {
if ($html === false || $html === '') {
$io->error("Could not read target: $target");
return Command::FAILURE;
}
@@ -83,6 +91,7 @@ class ScanCommand extends Command
$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)</>");
@@ -96,7 +105,7 @@ class ScanCommand extends Command
// 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);
+10 -1
View File
@@ -21,12 +21,21 @@ abstract class AbstractRule
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;
+21 -4
View File
@@ -4,10 +4,10 @@ namespace YakNet\AccessibilityConsole\Core;
class RuleEngine
{
/** @var AbstractRule[] */
/** @var array<int, mixed> */
private array $rules = [];
public function addRule(AbstractRule $rule): void
public function addRule(mixed $rule): void
{
$this->rules[] = $rule;
}
@@ -19,8 +19,25 @@ class RuleEngine
{
$allViolations = [];
foreach ($this->rules as $rule) {
$violations = $rule->check($doc);
$allViolations = array_merge($allViolations, $violations);
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) {
if ($element instanceof \DOMElement) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
}
}
}
}
}
}
return $allViolations;
}
+9 -4
View File
@@ -4,11 +4,16 @@ 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
{
$this->engine->addRule($rule);
}
/**
+7 -1
View File
@@ -2,8 +2,11 @@
namespace YakNet\AccessibilityConsole\Core;
readonly class Violation
class Violation
{
/**
* @param array{file: string, line: int}|null $location
*/
public function __construct(
public string $ruleId,
public string $message,
@@ -14,6 +17,9 @@ readonly 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 [
+22
View File
@@ -17,11 +17,29 @@ class HtmlDashboardReporter
$rows = '';
foreach ($violations as $v) {
$severityClass = strtolower($v->severity->value);
$aiSuggestion = $v->fixSuggestion;
$explanation = '';
$fixedCode = '';
if ($aiSuggestion && str_contains($aiSuggestion, 'FIX:')) {
preg_match('/EXPLANATION:(.*)FIX:(.*)/s', $aiSuggestion, $matches);
$explanation = trim($matches[1] ?? '');
$fixedCode = trim($matches[2] ?? '');
// Clean up potential markdown
$fixedCode = preg_replace('/^```html\s*|\s*```$/i', '', $fixedCode);
} else {
$fixedCode = $aiSuggestion;
}
$rows .= "
<tr class='severity-{$severityClass}'>
<td><span class='badge'>{$v->ruleId}</span></td>
<td>{$v->message}</td>
<td><pre><code>" . htmlspecialchars($v->htmlSnippet) . "</code></pre></td>
<td>
" . ($explanation ? "<div class='ai-explanation'><strong>Öneri:</strong> {$explanation}</div>" : "") . "
" . ($fixedCode ? "<pre class='ai-fix'><code>" . htmlspecialchars($fixedCode) . "</code></pre>" : "<span class='text-muted'>Manual fix required</span>") . "
</td>
<td>" . ($v->location ? "{$v->location['file']}:{$v->location['line']}" : 'N/A') . "</td>
</tr>";
}
@@ -43,8 +61,11 @@ class HtmlDashboardReporter
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; }
pre { background: #272822; color: #f8f8f2; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 12px; }
.ai-explanation { margin-bottom: 8px; font-size: 13px; color: #27ae60; line-height: 1.4; }
.ai-fix { border-left: 4px solid #2ecc71; background: #1e1e1e; margin-top: 5px; }
.badge { background: #34495e; color: white; padding: 4px 8px; border-radius: 4px; font-size: 11px; }
.severity-error { background: #fff5f5; }
.text-muted { color: #95a5a6; font-size: 0.9em; font-style: italic; }
</style>
</head>
<body>
@@ -69,6 +90,7 @@ class HtmlDashboardReporter
<th>Rule ID</th>
<th>Issue</th>
<th>Code Snippet</th>
<th>AI Recommendation</th>
<th>Source Location</th>
</tr>
</thead>
+10 -1
View File
@@ -16,12 +16,21 @@ abstract class AbstractRule implements RuleInterface
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
);
}
+87
View File
@@ -0,0 +1,87 @@
<?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 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;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?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; }
}
+127
View File
@@ -0,0 +1,127 @@
<?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 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;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?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; }
}
+7 -4
View File
@@ -29,10 +29,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;
}
}
}
+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(
+33
View File
@@ -0,0 +1,33 @@
<?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 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;
}
}
+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;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?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 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.'
);
}
}
+13 -1
View File
@@ -9,7 +9,8 @@ class StandardRuleSet
*/
public static function all(): array
{
return [
/** @var RuleInterface[] $rules */
$rules = [
new HtmlHasLang(),
new ImgAltText(),
new EmptyLink(),
@@ -18,6 +19,17 @@ class StandardRuleSet
new FieldsetLegend(),
new HeadingOrder(),
new MetaViewport(),
new TabindexOrder(),
new ColorContrast(),
new DuplicateId(),
new AriaRole(),
new IframeTitle(),
new LinkTextGeneric(),
new PlaceholderAsLabel(),
new ImageAltRedundant(),
new AriaHiddenFocusable(),
new PageTitle(),
];
return $rules;
}
}
+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 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 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;
}
}
+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']))) {
+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>A11y Report - demo/broken.html</title>
<style>
body { font-family: 'Inter', sans-serif; background: #f4f7f6; color: #333; margin: 0; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }
h1 { color: #2c3e50; margin-top: 0; }
.summary { display: flex; gap: 20px; margin-bottom: 30px; }
.card { background: #3498db; color: white; padding: 20px; border-radius: 8px; flex: 1; text-align: center; }
.card.error { background: #e74c3c; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; }
pre { background: #272822; color: #f8f8f2; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 12px; }
.ai-fix { border-left: 4px solid #2ecc71; background: #1e1e1e; }
.badge { background: #34495e; color: white; padding: 4px 8px; border-radius: 4px; font-size: 11px; }
.severity-error { background: #fff5f5; }
.text-muted { color: #95a5a6; font-size: 0.9em; font-style: italic; }
</style>
</head>
<body>
<div class="container">
<h1>Accessibility Report</h1>
<p>Target: <strong>demo/broken.html</strong> | Generated: 2026-05-13 09:46:04</p>
<div class="summary">
<div class="card error">
<h2>4</h2>
<p>Violations Found</p>
</div>
<div class="card">
<h2>WCAG 2.1</h2>
<p>Standard Level</p>
</div>
</div>
<table>
<thead>
<tr>
<th>Rule ID</th>
<th>Issue</th>
<th>Code Snippet</th>
<th>AI Recommendation</th>
<th>Source Location</th>
</tr>
</thead>
<tbody>
<tr class='severity-error'>
<td><span class='badge'>WCAG_3_1_1_LANG</span></td>
<td>The html element must have a lang attribute.</td>
<td><pre><code>&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Broken Page&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Welcome to a broken page&lt;/h1&gt;
&lt;!-- Missing alt attribute --&gt;
&lt;img src=&quot;logo.png&quot;&gt;
&lt;!-- Empty link --&gt;
&lt;a href=&quot;https://google.com&quot;&gt;&lt;/a&gt;
&lt;!-- Empty button --&gt;
&lt;button type=&quot;submit&quot;&gt;&lt;/button&gt;
&lt;p&gt;This page has multiple accessibility issues.&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre></td>
<td><pre class='ai-fix'><code>Hata: GEMINI_API_KEY bulunamadı.</code></pre></td>
<td>N/A</td>
</tr>
<tr class='severity-error'>
<td><span class='badge'>WCAG_1_1_1_ALT</span></td>
<td>Images must have an alt attribute.</td>
<td><pre><code>&lt;img src=&quot;logo.png&quot;&gt;</code></pre></td>
<td><pre class='ai-fix'><code>Hata: GEMINI_API_KEY bulunamadı.</code></pre></td>
<td>N/A</td>
</tr>
<tr class='severity-error'>
<td><span class='badge'>WCAG_2_4_4_LINK_TEXT</span></td>
<td>Links must have discernible text.</td>
<td><pre><code>&lt;a href=&quot;https://google.com&quot;&gt;&lt;/a&gt;</code></pre></td>
<td><pre class='ai-fix'><code>Hata: GEMINI_API_KEY bulunamadı.</code></pre></td>
<td>N/A</td>
</tr>
<tr class='severity-error'>
<td><span class='badge'>WCAG_4_1_2_BUTTON</span></td>
<td>Buttons must have discernible text.</td>
<td><pre><code>&lt;button type=&quot;submit&quot;&gt;&lt;/button&gt;</code></pre></td>
<td><pre class='ai-fix'><code>Hata: GEMINI_API_KEY bulunamadı.</code></pre></td>
<td>N/A</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>