Compare commits

..
4 Commits
16 changed files with 521 additions and 22 deletions
+16 -1
View File
@@ -4,7 +4,7 @@
<title>Broken Page</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,21 @@
<!-- 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>
<p>This page has multiple accessibility issues.</p>
</body>
</html>
+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();
}
+11 -3
View File
@@ -79,9 +79,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;
}
+1
View File
@@ -83,6 +83,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)</>");
+23 -2
View File
@@ -4,10 +4,10 @@ namespace YakNet\AccessibilityConsole\Core;
class RuleEngine
{
/** @var AbstractRule[] */
/** @var array */
private array $rules = [];
public function addRule(AbstractRule $rule): void
public function addRule(mixed $rule): void
{
$this->rules[] = $rule;
}
@@ -19,8 +19,29 @@ 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();
$type = $params[0]->getType();
if (isset($params[0]) && $type instanceof \ReflectionNamedType && $type->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('//*');
foreach ($elements as $element) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
}
}
}
}
}
return $allViolations;
}
+5
View File
@@ -11,6 +11,11 @@ class Scanner
}
}
public function addRule(mixed $rule): void
{
$this->engine->addRule($rule);
}
/**
* Scan HTML content for accessibility violations.
*
+1 -1
View File
@@ -2,7 +2,7 @@
namespace YakNet\AccessibilityConsole\Core;
readonly class Violation
class Violation
{
public function __construct(
public string $ruleId,
+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>
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRole extends AbstractRule
{
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]');
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]');
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->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; }
}
+124
View File
@@ -0,0 +1,124 @@
<?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;
}
private function hexToRgb(string $hex): ?array
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$r = hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
$g = hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
$b = hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
return [$r, $g, $b];
} elseif (strlen($hex) === 6) {
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = 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;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?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]');
$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; }
}
+3
View File
@@ -16,6 +16,9 @@ class HeadingOrderRule extends AbstractRule
$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;
}
}
+5
View File
@@ -18,6 +18,11 @@ class StandardRuleSet
new FieldsetLegend(),
new HeadingOrder(),
new MetaViewport(),
new TabindexOrder(),
new ColorContrast(),
new DuplicateId(),
new AriaRole(),
new IframeTitle(),
];
}
}
+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;
}
}
+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>