Git Komut Rehberi & Terminal Asistanı
Sıfırdan Yeni Bir Depo Başlatın:
Mevcut Bir Git Deposu veya Klasörü Gönderin:
Hataları Geri Alma, İptal & Temizlik:
Son commit'i geri al (yapılan değişiklikleri korur):
git reset --soft HEAD~1
Yapılan yerel değişiklikleri iptal et (çalışma ağacını temizler):
git restore .
Takip edilmeyen gereksiz dosyaları sil:
git clean -fd
Değişiklikleri geçici sakla ve geri getir (stash):
git stash git stash pop
Dal (Branch) ve Sürüm Etiketleri:
Yeni dal aç ve geçiş yap:
git checkout -b yeni-ozellik git push -u origin yeni-ozellik
Sürüm etiketi (tag) oluştur ve gönder:
git tag -a v1.0.0 -m "Sürüm 1.0.0" git push origin --tags
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9ffbdba08 | ||
|
|
2a97138fec | ||
|
|
c42ab714ff |
+3
-1
@@ -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",
|
||||
|
||||
+13
-1
@@ -28,7 +28,7 @@
|
||||
<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>
|
||||
<iframe src="https://www.youtube.com/embed/aqz-KE-bpKQ" width="560" height="315"></iframe>
|
||||
|
||||
<!-- New WCAG Rule Violations for testing -->
|
||||
|
||||
@@ -49,6 +49,18 @@
|
||||
</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
@@ -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);
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -26,7 +27,11 @@ 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-9)', '4');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
@@ -44,75 +49,205 @@ class ScanCommand extends Command
|
||||
$projectPath = '.';
|
||||
}
|
||||
|
||||
$io->title("Accessibility Console - Scanning: $target");
|
||||
|
||||
// 1. Fetch HTML
|
||||
$html = @file_get_contents($target);
|
||||
if ($html === false || $html === '') {
|
||||
$io->error("Could not read target: $target");
|
||||
$levelOption = $input->getOption('level');
|
||||
$level = is_numeric($levelOption) ? (int)$levelOption : 4;
|
||||
if ($level < 1 || $level > 9) {
|
||||
$io->error("Scan level must be between 1 and 9.");
|
||||
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 (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");
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,14 @@ use YakNet\AccessibilityConsole\Core\Violation;
|
||||
class HtmlDashboardReporter
|
||||
{
|
||||
/**
|
||||
* @param Violation[] $violations
|
||||
* @param array<string, Violation[]> $results
|
||||
*/
|
||||
public function render(array $violations, string $target): string
|
||||
public function render(array $results, string $target): string
|
||||
{
|
||||
$count = count($violations);
|
||||
$totalPages = count($results);
|
||||
$date = date('Y-m-d H:i:s');
|
||||
|
||||
// Calculate severity and standard counts
|
||||
// Calculate severity, standard counts and overall average score
|
||||
$criticalCount = 0;
|
||||
$errorCount = 0;
|
||||
$warningCount = 0;
|
||||
@@ -24,110 +24,145 @@ class HtmlDashboardReporter
|
||||
$standardAA = 0;
|
||||
$standardAAA = 0;
|
||||
|
||||
foreach ($violations as $v) {
|
||||
switch ($v->severity) {
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::CRITICAL:
|
||||
$criticalCount++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::ERROR:
|
||||
$errorCount++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::WARNING:
|
||||
$warningCount++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::INFO:
|
||||
$infoCount++;
|
||||
break;
|
||||
$pageScores = [];
|
||||
$totalViolations = 0;
|
||||
|
||||
foreach ($results as $url => $violations) {
|
||||
$totalViolations += count($violations);
|
||||
$pageCrit = 0;
|
||||
$pageErr = 0;
|
||||
$pageWarn = 0;
|
||||
$pageInfo = 0;
|
||||
|
||||
foreach ($violations as $v) {
|
||||
switch ($v->severity) {
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::CRITICAL:
|
||||
$criticalCount++;
|
||||
$pageCrit++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::ERROR:
|
||||
$errorCount++;
|
||||
$pageErr++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::WARNING:
|
||||
$warningCount++;
|
||||
$pageWarn++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::INFO:
|
||||
$infoCount++;
|
||||
$pageInfo++;
|
||||
break;
|
||||
}
|
||||
|
||||
switch ($v->standard) {
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::A:
|
||||
$standardA++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::AA:
|
||||
$standardAA++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::AAA:
|
||||
$standardAAA++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch ($v->standard) {
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::A:
|
||||
$standardA++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::AA:
|
||||
$standardAA++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::AAA:
|
||||
$standardAAA++;
|
||||
break;
|
||||
}
|
||||
$deductions = ($pageCrit * 15) + ($pageErr * 10) + ($pageWarn * 4) + ($pageInfo * 1);
|
||||
$pageScores[$url] = max(0, min(100, 100 - $deductions));
|
||||
}
|
||||
|
||||
// Calculate health score (0-100)
|
||||
$deductions = ($criticalCount * 15) + ($errorCount * 10) + ($warningCount * 4) + ($infoCount * 1);
|
||||
$score = max(0, min(100, 100 - $deductions));
|
||||
$averageScore = $totalPages > 0 ? (int)round(array_sum($pageScores) / $totalPages) : 100;
|
||||
|
||||
// Generate Page Selector options
|
||||
$pageOptions = "<option value=\"all\">Tüm Sayfalar ({$totalPages} sayfa, {$totalViolations} ihlal)</option>";
|
||||
foreach ($results as $url => $violations) {
|
||||
$escapedUrl = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
|
||||
$vCount = count($violations);
|
||||
$pageOptions .= "<option value=\"{$escapedUrl}\">{$escapedUrl} ({$vCount} ihlal)</option>";
|
||||
}
|
||||
|
||||
$cards = '';
|
||||
foreach ($violations as $index => $v) {
|
||||
$severityClass = strtolower($v->severity->value);
|
||||
$standardVal = $v->standard->value;
|
||||
$aiSuggestion = $v->fixSuggestion;
|
||||
$explanation = '';
|
||||
$fixedCode = '';
|
||||
$cardIndex = 0;
|
||||
foreach ($results as $url => $violations) {
|
||||
$escapedUrl = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
|
||||
foreach ($violations as $v) {
|
||||
$severityClass = strtolower($v->severity->value);
|
||||
$standardVal = $v->standard->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] ?? '');
|
||||
$fixedCode = preg_replace('/^```html\s*|\s*```$/i', '', $fixedCode);
|
||||
} else {
|
||||
$fixedCode = $aiSuggestion;
|
||||
if ($aiSuggestion && str_contains($aiSuggestion, 'FIX:')) {
|
||||
preg_match('/EXPLANATION:(.*)FIX:(.*)/s', $aiSuggestion, $matches);
|
||||
$explanation = trim($matches[1] ?? '');
|
||||
$fixedCode = trim($matches[2] ?? '');
|
||||
$fixedCode = preg_replace('/^```html\s*|\s*```$/i', '', $fixedCode);
|
||||
} else {
|
||||
$fixedCode = $aiSuggestion;
|
||||
}
|
||||
|
||||
$escapedSnippet = htmlspecialchars($v->htmlSnippet, ENT_QUOTES, 'UTF-8');
|
||||
$escapedFixed = $fixedCode ? htmlspecialchars($fixedCode, ENT_QUOTES, 'UTF-8') : '';
|
||||
$escapedExplanation = $explanation ? htmlspecialchars($explanation, ENT_QUOTES, 'UTF-8') : '';
|
||||
$locationStr = $v->location ? htmlspecialchars($v->location['file'] . ':' . $v->location['line'], ENT_QUOTES, 'UTF-8') : 'N/A';
|
||||
$escapedRuleId = htmlspecialchars($v->ruleId, ENT_QUOTES, 'UTF-8');
|
||||
$escapedMessage = htmlspecialchars($v->message, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$hasAiFix = !empty($fixedCode);
|
||||
$aiTabButton = $hasAiFix ? "<button class=\"tab-btn\" id=\"tab-ai-{$cardIndex}\" role=\"tab\" aria-selected=\"false\" aria-controls=\"panel-ai-{$cardIndex}\" tabindex=\"-1\">Yapay Zeka Önerisi</button>" : "";
|
||||
$aiTabPanel = $hasAiFix ? "
|
||||
<div class=\"tab-panel\" id=\"panel-ai-{$cardIndex}\" role=\"tabpanel\" aria-labelledby=\"tab-ai-{$cardIndex}\" hidden>
|
||||
<div class=\"ai-recommendation\">
|
||||
" . ($escapedExplanation ? "<div class=\"ai-explanation\"><strong>Öneri Açıklaması:</strong> {$escapedExplanation}</div>" : "") . "
|
||||
<div class=\"code-wrapper\">
|
||||
<pre><code id=\"code-ai-{$cardIndex}\">{$escapedFixed}</code></pre>
|
||||
<button class=\"copy-btn\" aria-label=\"{$escapedRuleId} kuralı için önerilen düzeltmeyi kopyala\" onclick=\"copyCode('#code-ai-{$cardIndex}', this)\">
|
||||
<svg class=\"icon-copy\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>
|
||||
<span>Kopyala</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>" : "";
|
||||
|
||||
$cards .= "
|
||||
<div class=\"violation-card\" data-severity=\"{$severityClass}\" data-standard=\"{$standardVal}\" data-rule=\"{$escapedRuleId}\" data-message=\"{$escapedMessage}\" data-page=\"{$escapedUrl}\">
|
||||
<div class=\"card-header\">
|
||||
<div class=\"badge-row\">
|
||||
<span class=\"badge rule-badge\">{$escapedRuleId}</span>
|
||||
<span class=\"badge standard-badge\">WCAG {$standardVal}</span>
|
||||
<span class=\"badge severity-badge severity-{$severityClass}\">
|
||||
<span class=\"dot\"></span>
|
||||
" . ucfirst($severityClass) . "
|
||||
</span>
|
||||
</div>
|
||||
<div class=\"location-row\">
|
||||
<svg class=\"icon-location\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z\"></path><circle cx=\"12\" cy=\"10\" r=\"3\"></circle></svg>
|
||||
<span>{$locationStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\"card-body\">
|
||||
<h3 class=\"violation-msg\">{$escapedMessage}</h3>
|
||||
|
||||
<div class=\"code-viewer\">
|
||||
<div class=\"tab-list\" role=\"tablist\">
|
||||
<button class=\"tab-btn active\" id=\"tab-orig-{$cardIndex}\" role=\"tab\" aria-selected=\"true\" aria-controls=\"panel-orig-{$cardIndex}\">Mevcut Kod</button>
|
||||
{$aiTabButton}
|
||||
</div>
|
||||
<div class=\"tab-panel active\" id=\"panel-orig-{$cardIndex}\" role=\"tabpanel\" aria-labelledby=\"tab-orig-{$cardIndex}\">
|
||||
<pre><code>{$escapedSnippet}</code></pre>
|
||||
</div>
|
||||
{$aiTabPanel}
|
||||
</div>
|
||||
|
||||
<div class=\"page-meta-row\" style=\"margin-top: 12px; font-size: 0.8rem; color: var(--text-secondary); display: flex; align-items: center; gap: 6px;\">
|
||||
<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"width:14px;height:14px;color:var(--text-muted);\"><path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"></path><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"></path></svg>
|
||||
<span>Kaynak Sayfa: <strong style=\"word-break: break-all;\">{$escapedUrl}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>";
|
||||
|
||||
$cardIndex++;
|
||||
}
|
||||
|
||||
$escapedSnippet = htmlspecialchars($v->htmlSnippet, ENT_QUOTES, 'UTF-8');
|
||||
$escapedFixed = $fixedCode ? htmlspecialchars($fixedCode, ENT_QUOTES, 'UTF-8') : '';
|
||||
$escapedExplanation = $explanation ? htmlspecialchars($explanation, ENT_QUOTES, 'UTF-8') : '';
|
||||
$locationStr = $v->location ? htmlspecialchars($v->location['file'] . ':' . $v->location['line'], ENT_QUOTES, 'UTF-8') : 'N/A';
|
||||
$escapedRuleId = htmlspecialchars($v->ruleId, ENT_QUOTES, 'UTF-8');
|
||||
$escapedMessage = htmlspecialchars($v->message, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$hasAiFix = !empty($fixedCode);
|
||||
$aiTabButton = $hasAiFix ? "<button class=\"tab-btn\" id=\"tab-ai-{$index}\" role=\"tab\" aria-selected=\"false\" aria-controls=\"panel-ai-{$index}\" tabindex=\"-1\">Yapay Zeka Önerisi</button>" : "";
|
||||
$aiTabPanel = $hasAiFix ? "
|
||||
<div class=\"tab-panel\" id=\"panel-ai-{$index}\" role=\"tabpanel\" aria-labelledby=\"tab-ai-{$index}\" hidden>
|
||||
<div class=\"ai-recommendation\">
|
||||
" . ($escapedExplanation ? "<div class=\"ai-explanation\"><strong>Öneri Açıklaması:</strong> {$escapedExplanation}</div>" : "") . "
|
||||
<div class=\"code-wrapper\">
|
||||
<pre><code id=\"code-ai-{$index}\">{$escapedFixed}</code></pre>
|
||||
<button class=\"copy-btn\" aria-label=\"{$escapedRuleId} kuralı için önerilen düzeltmeyi kopyala\" onclick=\"copyCode('#code-ai-{$index}', this)\">
|
||||
<svg class=\"icon-copy\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>
|
||||
<span>Kopyala</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>" : "";
|
||||
|
||||
$cards .= "
|
||||
<div class=\"violation-card\" data-severity=\"{$severityClass}\" data-standard=\"{$standardVal}\" data-rule=\"{$escapedRuleId}\" data-message=\"{$escapedMessage}\">
|
||||
<div class=\"card-header\">
|
||||
<div class=\"badge-row\">
|
||||
<span class=\"badge rule-badge\">{$escapedRuleId}</span>
|
||||
<span class=\"badge standard-badge\">WCAG {$standardVal}</span>
|
||||
<span class=\"badge severity-badge severity-{$severityClass}\">
|
||||
<span class=\"dot\"></span>
|
||||
" . ucfirst($severityClass) . "
|
||||
</span>
|
||||
</div>
|
||||
<div class=\"location-row\">
|
||||
<svg class=\"icon-location\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z\"></path><circle cx=\"12\" cy=\"10\" r=\"3\"></circle></svg>
|
||||
<span>{$locationStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\"card-body\">
|
||||
<h3 class=\"violation-msg\">{$escapedMessage}</h3>
|
||||
|
||||
<div class=\"code-viewer\">
|
||||
<div class=\"tab-list\" role=\"tablist\">
|
||||
<button class=\"tab-btn active\" id=\"tab-orig-{$index}\" role=\"tab\" aria-selected=\"true\" aria-controls=\"panel-orig-{$index}\">Mevcut Kod</button>
|
||||
{$aiTabButton}
|
||||
</div>
|
||||
<div class=\"tab-panel active\" id=\"panel-orig-{$index}\" role=\"tabpanel\" aria-labelledby=\"tab-orig-{$index}\">
|
||||
<pre><code>{$escapedSnippet}</code></pre>
|
||||
</div>
|
||||
{$aiTabPanel}
|
||||
</div>
|
||||
</div>
|
||||
</div>";
|
||||
}
|
||||
|
||||
return <<<HTML
|
||||
@@ -378,7 +413,7 @@ class HtmlDashboardReporter
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 251.2;
|
||||
stroke-dashoffset: calc(251.2 - (251.2 * {$score}) / 100);
|
||||
stroke-dashoffset: calc(251.2 - (251.2 * {$averageScore}) / 100);
|
||||
transition: stroke-dashoffset 1.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@@ -466,7 +501,7 @@ class HtmlDashboardReporter
|
||||
|
||||
.search-group {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
flex: 2;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
@@ -506,6 +541,35 @@ class HtmlDashboardReporter
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-select-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-secondary);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
body.light-theme .select-input {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
body.light-theme .select-input option {
|
||||
background-color: white;
|
||||
color: #111827;
|
||||
}
|
||||
body.light-theme .select-input:focus {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -820,7 +884,7 @@ class HtmlDashboardReporter
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<div class="meta-tag" title="{$target}">Hedef: {$target}</div>
|
||||
<div class="meta-tag" title="{$target}">Hedef Site: {$target}</div>
|
||||
<div class="meta-tag">Tarih: {$date}</div>
|
||||
<button id="theme-toggle" class="theme-btn" aria-label="Açık/Karanlık tema değiştir" onclick="toggleTheme()">
|
||||
<!-- Moon icon -->
|
||||
@@ -838,13 +902,13 @@ class HtmlDashboardReporter
|
||||
<div class="gauge-container">
|
||||
<svg viewBox="0 0 100 100" class="gauge">
|
||||
<circle cx="50" cy="50" r="40" class="gauge-bg"></circle>
|
||||
<circle cx="50" cy="50" r="40" class="gauge-val " id="score-gauge" stroke-dasharray="251.2" stroke-dashoffset="251.2"></circle>
|
||||
<circle cx="50" cy="50" r="40" class="gauge-val" id="score-gauge" stroke-dasharray="251.2" stroke-dashoffset="251.2"></circle>
|
||||
</svg>
|
||||
<div class="gauge-label" id="score-val">0</div>
|
||||
</div>
|
||||
<div class="metric-desc">
|
||||
<h2>Erişilebilirlik Skoru</h2>
|
||||
<p>Sayfanın genel standartlara uygunluk derecesi.</p>
|
||||
<h2>Ortalama Sağlık Skoru</h2>
|
||||
<p>Tarama yapılan sayfaların ortalama puanı.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -882,20 +946,16 @@ class HtmlDashboardReporter
|
||||
<div class="glass-panel">
|
||||
<div class="summary-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">WCAG A</span>
|
||||
<span class="stat-val">{$standardA}</span>
|
||||
<span class="stat-label">Taranan Sayfa</span>
|
||||
<span class="stat-val">{$totalPages}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">WCAG AA</span>
|
||||
<span class="stat-val">{$standardAA}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">WCAG AAA</span>
|
||||
<span class="stat-val">{$standardAAA}</span>
|
||||
<span class="stat-label">WCAG A / AA / AAA</span>
|
||||
<span class="stat-val">{$standardA} / {$standardAA} / {$standardAAA}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Toplam İhlal</span>
|
||||
<span class="stat-val">{$count}</span>
|
||||
<span class="stat-val">{$totalViolations}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -909,6 +969,12 @@ class HtmlDashboardReporter
|
||||
</svg>
|
||||
<input type="search" id="search-box" class="search-input" placeholder="Kural veya hata açıklaması ara..." aria-label="Kural veya hata açıklaması ara" oninput="applyFilters()">
|
||||
</div>
|
||||
|
||||
<div class="page-select-group">
|
||||
<select id="page-select" class="select-input" aria-label="Taranan sayfaya göre filtrele" onchange="applyFilters()">
|
||||
{$pageOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button class="filter-btn active" id="btn-all" onclick="setSeverityFilter('all')" aria-label="Tüm ihlalleri göster" aria-pressed="true">Hepsi</button>
|
||||
@@ -946,7 +1012,7 @@ class HtmlDashboardReporter
|
||||
<!-- JavaScript Logic -->
|
||||
<script>
|
||||
// Apply Circular Score Gauge Color and Animation
|
||||
const score = {$score};
|
||||
const score = {$averageScore};
|
||||
const gauge = document.getElementById('score-gauge');
|
||||
const scoreText = document.getElementById('score-val');
|
||||
|
||||
@@ -1143,6 +1209,7 @@ class HtmlDashboardReporter
|
||||
|
||||
function applyFilters() {
|
||||
const searchVal = document.getElementById('search-box').value.toLowerCase();
|
||||
const pageVal = document.getElementById('page-select').value;
|
||||
const cards = document.querySelectorAll('.violation-card');
|
||||
let visibleCount = 0;
|
||||
|
||||
@@ -1150,11 +1217,13 @@ class HtmlDashboardReporter
|
||||
const severity = card.getAttribute('data-severity');
|
||||
const rule = card.getAttribute('data-rule').toLowerCase();
|
||||
const message = card.getAttribute('data-message').toLowerCase();
|
||||
const page = card.getAttribute('data-page');
|
||||
|
||||
const matchesSeverity = (activeSeverity === 'all' || severity === activeSeverity);
|
||||
const matchesSearch = (!searchVal || rule.includes(searchVal) || message.includes(searchVal));
|
||||
const matchesPage = (pageVal === 'all' || page === pageVal);
|
||||
|
||||
if (matchesSeverity && matchesSearch) {
|
||||
if (matchesSeverity && matchesSearch && matchesPage) {
|
||||
card.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
|
||||
@@ -14,6 +14,11 @@ 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class AriaHiddenFocusable extends AbstractRule
|
||||
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
|
||||
{
|
||||
|
||||
@@ -78,4 +78,5 @@ class AriaRole extends AbstractRule
|
||||
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 3; }
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ class ColorContrast extends AbstractRule
|
||||
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
|
||||
{
|
||||
|
||||
@@ -48,4 +48,5 @@ class DuplicateId extends AbstractRule
|
||||
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 3; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ class IframeTitle extends AbstractRule
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ class PageTitle extends AbstractRule
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,31 +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
|
||||
{
|
||||
/** @var RuleInterface[] $rules */
|
||||
$rules = [
|
||||
new HtmlHasLang(),
|
||||
new ImgAltText(),
|
||||
new EmptyLink(),
|
||||
new ButtonName(),
|
||||
new FormLabel(),
|
||||
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;
|
||||
return RuleLevels::getRulesForLevel(5);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class TabindexOrder extends AbstractRule
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user