Compare commits

...
6 Commits
14 changed files with 345 additions and 70 deletions
+8 -3
View File
@@ -17,7 +17,7 @@
"authors": [
{
"name": "YakNet Bilişim",
"email": "[email protected].tr",
"email": "[email protected].tr",
"role": "Developer"
}
],
@@ -45,5 +45,10 @@
"bin/a11y"
],
"minimum-stability": "stable",
"prefer-stable": true
}
"prefer-stable": true,
"config": {
"allow-plugins": {
"php-http/discovery": true
}
}
}
+18 -16
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,23 +38,17 @@ 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->geminiPro()->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) {
// Log error if needed
return null;
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)</>");
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
abstract class AbstractRule
{
/**
* @return Violation[]
*/
abstract public function check(\DOMDocument $doc): array;
/**
* Get the WCAG standard Enum
*/
abstract public function getStandard(): WCAGStandard;
/**
* Get the severity level Enum
*/
abstract public function getSeverity(): Severity;
protected function createViolation(string $message, \DOMElement $element): Violation
{
return new Violation(
ruleId: (new \ReflectionClass($this))->getShortName(),
message: $message,
severity: $this->getSeverity(),
standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element)
);
}
}
+30 -7
View File
@@ -2,23 +2,46 @@
namespace YakNet\AccessibilityConsole\Core;
use YakNet\AccessibilityConsole\Rules\RuleInterface;
class RuleEngine
{
/** @var RuleInterface[] */
/** @var array */
private array $rules = [];
public function addRule(RuleInterface $rule): void
public function addRule(mixed $rule): void
{
$this->rules[] = $rule;
}
/**
* @return RuleInterface[]
* @return Violation[]
*/
public function getRules(): array
public function run(\DOMDocument $doc): array
{
return $this->rules;
$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('//*');
foreach ($elements as $element) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
}
}
}
}
}
return $allViolations;
}
}
+14 -40
View File
@@ -2,62 +2,36 @@
namespace YakNet\AccessibilityConsole\Core;
use DOMDocument;
use YakNet\AccessibilityConsole\Rules\RuleInterface;
class Scanner
{
private array $rules = [];
public function __construct(
private readonly ?RuleEngine $engine = null
) {
if ($this->engine) {
$this->rules = $this->engine->getRules();
public function __construct(private ?RuleEngine $engine = null)
{
if ($this->engine === null) {
$this->engine = new RuleEngine();
}
}
public function addRule(RuleInterface $rule): void
public function addRule(mixed $rule): void
{
$this->rules[] = $rule;
$this->engine->addRule($rule);
}
/**
* Scan HTML content for accessibility violations.
*
* @param string $html
* @return Violation[]
*/
public function scan(string $html): array
{
if (trim($html) === '') {
if (empty(trim($html))) {
return [];
}
$dom = new DOMDocument();
$previousState = libxml_use_internal_errors(true);
$loaded = $dom->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();
libxml_use_internal_errors($previousState);
$doc = new \DOMDocument();
// Suppress warnings for malformed HTML
@$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
if (!$loaded) {
return [];
}
$violations = [];
$elements = $dom->getElementsByTagName('*');
foreach ($elements as $element) {
if (!$element instanceof \DOMElement) continue;
foreach ($this->rules as $rule) {
try {
$violation = $rule->check($element);
if ($violation) {
$violations[] = $violation;
}
} catch (\Throwable) {
continue;
}
}
}
return $violations;
return $this->engine->run($doc);
}
}
+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>
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyLinkRule extends AbstractRule
{
public function check(\DOMDocument $doc): array
{
$violations = [];
$links = $doc->getElementsByTagName('a');
foreach ($links as $link) {
if (trim($link->textContent) === '' && !$link->hasAttribute('aria-label')) {
$violations[] = $this->createViolation(
"Bağlantı (a) etiketi metin içermiyor ve 'aria-label' tanımlanmamış. Ekran okuyucular bu linkin nereye gittiğini anlayamaz.",
$link
);
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::CRITICAL; }
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class HeadingOrderRule extends AbstractRule
{
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
$lastLevel = 0;
foreach ($headings as $h) {
$level = (int)substr($h->tagName, 1);
if ($lastLevel > 0 && $level > $lastLevel + 1) {
$violations[] = $this->createViolation(
"Başlık hiyerarşisi bozuk. H{$lastLevel} etiketinden doğrudan H{$level} etiketine geçiş yapılmış. Sıralı olmalıdır.",
$h
);
}
$lastLevel = $level;
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltRule extends AbstractRule
{
public function check(\DOMDocument $doc): array
{
$violations = [];
$images = $doc->getElementsByTagName('img');
foreach ($images as $img) {
if (!$img->hasAttribute('alt') || trim($img->getAttribute('alt')) === '') {
$violations[] = $this->createViolation(
"Görüntü (img) etiketi 'alt' özniteliğine sahip değil veya boş. WCAG 1.1.1 uyarınca her görüntünün bir açıklaması olmalıdır.",
$img
);
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::CRITICAL; }
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\RuleEngine;
class StrictRuleSet extends RuleEngine
{
public function __construct()
{
$this->addRule(new ImageAltRule());
$this->addRule(new HeadingOrderRule());
$this->addRule(new EmptyLinkRule());
// Daha fazla kural buraya eklenebilir
}
}
+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>