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 | |
|---|---|---|---|
|
|
2a97138fec | ||
|
|
c42ab714ff | ||
|
|
b805741ef2 | ||
|
|
84f713e434 | ||
|
|
c6df5e2bdc | ||
|
|
75171c6f96 | ||
|
|
1ea2579d20 |
+3
-1
@@ -30,7 +30,9 @@
|
|||||||
"symfony/yaml": "^6.4|^7.0",
|
"symfony/yaml": "^6.4|^7.0",
|
||||||
"monolog/monolog": "^3.0",
|
"monolog/monolog": "^3.0",
|
||||||
"vlucas/phpdotenv": "^5.6",
|
"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": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5",
|
||||||
|
|||||||
+33
-2
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Broken Page</title>
|
<title></title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 id="main-content">Welcome to a broken page</h1>
|
<h1 id="main-content">Welcome to a broken page</h1>
|
||||||
@@ -28,7 +28,38 @@
|
|||||||
<div role="cool-widget" aria-controls="non-existent-popup">WAI-ARIA elements</div>
|
<div role="cool-widget" aria-controls="non-existent-popup">WAI-ARIA elements</div>
|
||||||
|
|
||||||
<!-- Missing Iframe Title -->
|
<!-- 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 -->
|
||||||
|
|
||||||
|
<!-- LinkTextGeneric: Generic text without context -->
|
||||||
|
<a href="/read-more">Click here</a>
|
||||||
|
<a href="/devam-oku">devamını oku</a>
|
||||||
|
|
||||||
|
<!-- PlaceholderAsLabel: Input relying only on placeholder -->
|
||||||
|
<input type="text" placeholder="Search website...">
|
||||||
|
|
||||||
|
<!-- ImageAltRedundant: Redundant words in alt text -->
|
||||||
|
<img src="logo2.png" alt="logo of our company">
|
||||||
|
<img src="profile.jpg" alt="profile photo of john doe">
|
||||||
|
|
||||||
|
<!-- AriaHiddenFocusable: Focusable element hidden with aria-hidden="true" -->
|
||||||
|
<div aria-hidden="true">
|
||||||
|
<button type="button">Hidden click</button>
|
||||||
|
</div>
|
||||||
|
<a href="#" aria-hidden="true">Hidden link</a>
|
||||||
|
|
||||||
|
<!-- Empty heading violation -->
|
||||||
|
<h2></h2>
|
||||||
|
|
||||||
|
<!-- Video missing track violation -->
|
||||||
|
<video src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4" width="320" height="240" controls></video>
|
||||||
|
|
||||||
|
<!-- Empty label violation -->
|
||||||
|
<label for="search-input"></label>
|
||||||
|
|
||||||
|
<!-- AriaFocusable violation -->
|
||||||
|
<div role="button">Clickable div button without tabindex</div>
|
||||||
|
|
||||||
<p>This page has multiple accessibility issues.</p>
|
<p>This page has multiple accessibility issues.</p>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+10
-19
@@ -1,19 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// 1. Setup Autoloading (Mocking Composer)
|
// 1. Setup Autoloading
|
||||||
require_once __DIR__ . '/../src/Scanner.php';
|
require_once __DIR__ . '/../vendor/autoload.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';
|
|
||||||
|
|
||||||
use YakNet\AccessibilityConsole\Scanner;
|
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||||
use YakNet\AccessibilityConsole\SourceLocator;
|
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
||||||
use YakNet\AccessibilityConsole\Reporter\HtmlReporter;
|
use YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter;
|
||||||
use YakNet\AccessibilityConsole\Rules\ImgAltText;
|
use YakNet\AccessibilityConsole\Rules\ImgAltText;
|
||||||
use YakNet\AccessibilityConsole\Rules\FormLabel;
|
use YakNet\AccessibilityConsole\Rules\FormLabel;
|
||||||
|
|
||||||
@@ -48,19 +40,18 @@ $violations = $scanner->scan($html);
|
|||||||
|
|
||||||
if (!empty($violations)) {
|
if (!empty($violations)) {
|
||||||
// Locate Sources
|
// Locate Sources
|
||||||
// We point it to the 'views' directory so it knows where to look for the error source
|
|
||||||
$locator = new SourceLocator(__DIR__ . '/views');
|
$locator = new SourceLocator(__DIR__ . '/views');
|
||||||
|
|
||||||
foreach ($violations as $violation) {
|
foreach ($violations as $violation) {
|
||||||
$location = $locator->locate($violation->snippet);
|
$location = $locator->locate($violation->htmlSnippet);
|
||||||
if ($location) {
|
if ($location) {
|
||||||
$violation->setSourceLocation($location['file'], $location['line']);
|
$violation->location = $location;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render Report
|
// Render Report (Format as results mapped by page URL)
|
||||||
$reporter = new HtmlReporter();
|
$reporter = new HtmlDashboardReporter();
|
||||||
$reportHtml = $reporter->render($violations);
|
$reportHtml = $reporter->render(['http://localhost:8080/index.php' => $violations], 'demo/index.php');
|
||||||
|
|
||||||
// Inject into body
|
// Inject into body
|
||||||
$html = str_replace('</body>', $reportHtml . '</body>', $html);
|
$html = str_replace('</body>', $reportHtml . '</body>', $html);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
parameters:
|
||||||
|
level: 9
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
@@ -30,8 +30,16 @@ class FixCommand extends Command
|
|||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
$target = $input->getArgument('target');
|
$target = $input->getArgument('target');
|
||||||
$projectPath = $input->getOption('project-path') ?? getcwd();
|
if (!is_string($target)) {
|
||||||
$dryRun = $input->getOption('dry-run');
|
$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();
|
$config = new Config();
|
||||||
$apiKey = $config->getGeminiApiKey();
|
$apiKey = $config->getGeminiApiKey();
|
||||||
@@ -45,7 +53,7 @@ class FixCommand extends Command
|
|||||||
|
|
||||||
// 1. Fetch HTML
|
// 1. Fetch HTML
|
||||||
$html = @file_get_contents($target);
|
$html = @file_get_contents($target);
|
||||||
if (!$html) {
|
if ($html === false || $html === '') {
|
||||||
$io->error("Could not read target: $target");
|
$io->error("Could not read target: $target");
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ use YakNet\AccessibilityConsole\AI\GeminiFixer;
|
|||||||
use YakNet\AccessibilityConsole\Core\Config;
|
use YakNet\AccessibilityConsole\Core\Config;
|
||||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||||
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
|
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
|
||||||
|
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||||
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
||||||
|
|
||||||
class ScanCommand extends Command
|
class ScanCommand extends Command
|
||||||
{
|
{
|
||||||
protected static $defaultName = 'scan';
|
protected static ?string $defaultName = 'scan';
|
||||||
|
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
@@ -26,85 +27,227 @@ class ScanCommand extends Command
|
|||||||
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
|
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
|
||||||
->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions')
|
->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('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)')
|
||||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping');
|
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping')
|
||||||
|
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively')
|
||||||
|
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth', '3')
|
||||||
|
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum number of pages to crawl', '20')
|
||||||
|
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-5)', '4');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$io = new SymfonyStyle($input, $output);
|
$io = new SymfonyStyle($input, $output);
|
||||||
$target = $input->getArgument('target');
|
$target = $input->getArgument('target');
|
||||||
$enableAi = $input->getOption('ai');
|
if (!is_string($target)) {
|
||||||
$projectPath = $input->getOption('project-path') ?? getcwd();
|
$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");
|
$levelOption = $input->getOption('level');
|
||||||
|
$level = is_numeric($levelOption) ? (int)$levelOption : 4;
|
||||||
// 1. Fetch HTML
|
if ($level < 1 || $level > 5) {
|
||||||
$html = @file_get_contents($target);
|
$io->error("Scan level must be between 1 and 5.");
|
||||||
if (!$html) {
|
|
||||||
$io->error("Could not read target: $target");
|
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Setup Scanner
|
$io->title("Accessibility Console - Scanning: $target (Level $level)");
|
||||||
|
|
||||||
|
// 1. Setup Scanner
|
||||||
$scanner = new Scanner();
|
$scanner = new Scanner();
|
||||||
foreach (StandardRuleSet::all() as $rule) {
|
$rules = RuleLevels::getRulesForLevel($level);
|
||||||
|
foreach ($rules as $rule) {
|
||||||
$scanner->addRule($rule);
|
$scanner->addRule($rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Scan
|
// 2. Fetch and Scan
|
||||||
$violations = $scanner->scan($html);
|
$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!");
|
$io->success("No violations found!");
|
||||||
return Command::SUCCESS;
|
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);
|
$locator = new SourceLocator($projectPath);
|
||||||
$config = new Config();
|
$config = new Config();
|
||||||
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
|
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
|
||||||
|
|
||||||
foreach ($violations as $violation) {
|
foreach ($results as $url => $violations) {
|
||||||
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
|
if (empty($violations)) {
|
||||||
|
continue;
|
||||||
// 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->section("Page: $url (" . count($violations) . " violations)");
|
||||||
|
|
||||||
// AI Fixer
|
foreach ($violations as $violation) {
|
||||||
if ($enableAi && $fixer) {
|
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
|
||||||
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
|
|
||||||
$suggestion = $fixer->suggestFix($violation);
|
// Show Snippet
|
||||||
if ($suggestion) {
|
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
|
||||||
$violation->fixSuggestion = $suggestion;
|
|
||||||
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
|
// Source Mapping
|
||||||
} else {
|
$location = $locator->locate($violation->htmlSnippet);
|
||||||
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
|
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');
|
$reportPath = $input->getOption('report');
|
||||||
if ($reportPath) {
|
if (is_string($reportPath) && $reportPath !== '') {
|
||||||
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
|
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
|
||||||
if ($extension === 'json') {
|
if ($extension === 'json') {
|
||||||
$data = array_map(fn($v) => $v->toArray(), $violations);
|
$serialized = [];
|
||||||
file_put_contents($reportPath, json_encode($data, JSON_PRETTY_PRINT));
|
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 {
|
} else {
|
||||||
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
|
$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");
|
$io->success("Report saved to: $reportPath");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,14 +19,28 @@ abstract class AbstractRule
|
|||||||
*/
|
*/
|
||||||
abstract public function getSeverity(): Severity;
|
abstract public function getSeverity(): Severity;
|
||||||
|
|
||||||
|
public function getLevel(): int
|
||||||
|
{
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
protected function createViolation(string $message, \DOMElement $element): Violation
|
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(
|
return new Violation(
|
||||||
ruleId: (new \ReflectionClass($this))->getShortName(),
|
ruleId: (new \ReflectionClass($this))->getShortName(),
|
||||||
message: $message,
|
message: $message,
|
||||||
severity: $this->getSeverity(),
|
severity: $this->getSeverity(),
|
||||||
standard: $this->getStandard(),
|
standard: $this->getStandard(),
|
||||||
htmlSnippet: $element->ownerDocument->saveHTML($element)
|
htmlSnippet: $html
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,8 +4,12 @@ namespace YakNet\AccessibilityConsole\Core;
|
|||||||
|
|
||||||
class Config
|
class Config
|
||||||
{
|
{
|
||||||
|
/** @var array<string, mixed> */
|
||||||
private array $settings = [];
|
private array $settings = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $settings
|
||||||
|
*/
|
||||||
public function __construct(array $settings = [])
|
public function __construct(array $settings = [])
|
||||||
{
|
{
|
||||||
$this->settings = $settings;
|
$this->settings = $settings;
|
||||||
|
|||||||
+15
-19
@@ -4,7 +4,7 @@ namespace YakNet\AccessibilityConsole\Core;
|
|||||||
|
|
||||||
class RuleEngine
|
class RuleEngine
|
||||||
{
|
{
|
||||||
/** @var array */
|
/** @var array<int, mixed> */
|
||||||
private array $rules = [];
|
private array $rules = [];
|
||||||
|
|
||||||
public function addRule(mixed $rule): void
|
public function addRule(mixed $rule): void
|
||||||
@@ -19,25 +19,21 @@ class RuleEngine
|
|||||||
{
|
{
|
||||||
$allViolations = [];
|
$allViolations = [];
|
||||||
foreach ($this->rules as $rule) {
|
foreach ($this->rules as $rule) {
|
||||||
if (method_exists($rule, 'check')) {
|
if ($rule instanceof AbstractRule) {
|
||||||
// Determine if it's a document-level rule or element-level rule
|
// Document-level rule (Core\AbstractRule style)
|
||||||
$reflection = new \ReflectionMethod($rule, 'check');
|
$violations = $rule->check($doc);
|
||||||
$params = $reflection->getParameters();
|
$allViolations = array_merge($allViolations, $violations);
|
||||||
|
} elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) {
|
||||||
$type = $params[0]->getType();
|
// Element-level rule (Rules\RuleInterface style)
|
||||||
if (isset($params[0]) && $type instanceof \ReflectionNamedType && $type->getName() === 'DOMDocument') {
|
$xpath = new \DOMXPath($doc);
|
||||||
// Document-level rule (Core\AbstractRule style)
|
$elements = $xpath->query('//*');
|
||||||
$violations = $rule->check($doc);
|
if ($elements !== false) {
|
||||||
$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) {
|
foreach ($elements as $element) {
|
||||||
$violation = $rule->check($element);
|
if ($element instanceof \DOMElement) {
|
||||||
if ($violation) {
|
$violation = $rule->check($element);
|
||||||
$allViolations[] = $violation;
|
if ($violation) {
|
||||||
|
$allViolations[] = $violation;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ namespace YakNet\AccessibilityConsole\Core;
|
|||||||
|
|
||||||
class Scanner
|
class Scanner
|
||||||
{
|
{
|
||||||
public function __construct(private ?RuleEngine $engine = null)
|
private RuleEngine $engine;
|
||||||
|
|
||||||
|
public function __construct(?RuleEngine $engine = null)
|
||||||
{
|
{
|
||||||
if ($this->engine === null) {
|
$this->engine = $engine ?? new RuleEngine();
|
||||||
$this->engine = new RuleEngine();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addRule(mixed $rule): void
|
public function addRule(mixed $rule): void
|
||||||
@@ -28,9 +28,10 @@ class Scanner
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$doc = new \DOMDocument();
|
$html5 = new \Masterminds\HTML5([
|
||||||
// Suppress warnings for malformed HTML
|
'disable_html_ns' => true,
|
||||||
@$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
]);
|
||||||
|
$doc = $html5->loadHTML($html);
|
||||||
|
|
||||||
return $this->engine->run($doc);
|
return $this->engine->run($doc);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ namespace YakNet\AccessibilityConsole\Core;
|
|||||||
|
|
||||||
class Violation
|
class Violation
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param array{file: string, line: int}|null $location
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public string $ruleId,
|
public string $ruleId,
|
||||||
public string $message,
|
public string $message,
|
||||||
@@ -14,6 +17,9 @@ class Violation
|
|||||||
public ?string $fixSuggestion = null
|
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
|
public function toArray(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,14 +14,28 @@ abstract class AbstractRule implements RuleInterface
|
|||||||
abstract public function getStandard(): WCAGStandard;
|
abstract public function getStandard(): WCAGStandard;
|
||||||
abstract public function getSeverity(): Severity;
|
abstract public function getSeverity(): Severity;
|
||||||
|
|
||||||
|
public function getLevel(): int
|
||||||
|
{
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation
|
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(
|
return new Violation(
|
||||||
ruleId: $this->getId(),
|
ruleId: $this->getId(),
|
||||||
message: $message,
|
message: $message,
|
||||||
severity: $this->getSeverity(),
|
severity: $this->getSeverity(),
|
||||||
standard: $this->getStandard(),
|
standard: $this->getStandard(),
|
||||||
htmlSnippet: $element->ownerDocument->saveHTML($element),
|
htmlSnippet: $html,
|
||||||
fixSuggestion: $suggestion
|
fixSuggestion: $suggestion
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class AriaHiddenFocusable extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_4_1_2_ARIA_HIDDEN_FOCUS'; }
|
||||||
|
public function getDescription(): string { return 'Focusable elements must not be hidden with aria-hidden="true".'; }
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
public function getLevel(): int { return 5; }
|
||||||
|
|
||||||
|
public function check(DOMElement $element): ?Violation
|
||||||
|
{
|
||||||
|
if ($element->getAttribute('aria-hidden') !== 'true') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isFocusable($element) || $this->hasFocusableDescendant($element)) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Remove aria-hidden="true" from the container or make its focusable elements non-focusable (e.g. by setting tabindex="-1" or disabled).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isFocusable(DOMElement $element): bool
|
||||||
|
{
|
||||||
|
$tagName = strtolower($element->tagName);
|
||||||
|
|
||||||
|
if ($element->hasAttribute('tabindex')) {
|
||||||
|
$tabindex = (int)$element->getAttribute('tabindex');
|
||||||
|
if ($tabindex < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tagName === 'a' && $element->hasAttribute('href')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($tagName, ['button', 'select', 'textarea'], true)) {
|
||||||
|
return !$element->hasAttribute('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tagName === 'input') {
|
||||||
|
$type = strtolower($element->getAttribute('type'));
|
||||||
|
return $type !== 'hidden' && !$element->hasAttribute('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tagName === 'iframe') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasFocusableDescendant(DOMElement $element): bool
|
||||||
|
{
|
||||||
|
$doc = $element->ownerDocument;
|
||||||
|
if ($doc === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$xpath = new \DOMXPath($doc);
|
||||||
|
$descendants = $xpath->query('.//*', $element);
|
||||||
|
if ($descendants === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($descendants as $descendant) {
|
||||||
|
if ($descendant instanceof DOMElement && $this->isFocusable($descendant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-31
@@ -8,6 +8,7 @@ use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
|||||||
|
|
||||||
class AriaRole extends AbstractRule
|
class AriaRole extends AbstractRule
|
||||||
{
|
{
|
||||||
|
/** @var array<int, string> */
|
||||||
private static array $validRoles = [
|
private static array $validRoles = [
|
||||||
'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 'cell', 'checkbox',
|
'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 'cell', 'checkbox',
|
||||||
'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'dialog', 'directory',
|
'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'dialog', 'directory',
|
||||||
@@ -27,43 +28,47 @@ class AriaRole extends AbstractRule
|
|||||||
|
|
||||||
// 1. Check WAI-ARIA Role Validity
|
// 1. Check WAI-ARIA Role Validity
|
||||||
$roleElements = $xpath->query('//*[@role]');
|
$roleElements = $xpath->query('//*[@role]');
|
||||||
foreach ($roleElements as $el) {
|
if ($roleElements !== false) {
|
||||||
if (!$el instanceof \DOMElement) {
|
foreach ($roleElements as $el) {
|
||||||
continue;
|
if (!$el instanceof \DOMElement) {
|
||||||
}
|
continue;
|
||||||
$role = trim($el->getAttribute('role'));
|
}
|
||||||
if ($role === '') {
|
$role = trim($el->getAttribute('role'));
|
||||||
continue;
|
if ($role === '') {
|
||||||
}
|
continue;
|
||||||
$roles = array_filter(explode(' ', $role));
|
}
|
||||||
foreach ($roles as $r) {
|
$roles = array_filter(explode(' ', $role));
|
||||||
if (!in_array(strtolower($r), self::$validRoles, true)) {
|
foreach ($roles as $r) {
|
||||||
$violations[] = $this->createViolation(
|
if (!in_array(strtolower($r), self::$validRoles, true)) {
|
||||||
"Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.",
|
$violations[] = $this->createViolation(
|
||||||
$el
|
"Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.",
|
||||||
);
|
$el
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check aria-controls Targets Existence
|
// 2. Check aria-controls Targets Existence
|
||||||
$controlsElements = $xpath->query('//*[@aria-controls]');
|
$controlsElements = $xpath->query('//*[@aria-controls]');
|
||||||
foreach ($controlsElements as $el) {
|
if ($controlsElements !== false) {
|
||||||
if (!$el instanceof \DOMElement) {
|
foreach ($controlsElements as $el) {
|
||||||
continue;
|
if (!$el instanceof \DOMElement) {
|
||||||
}
|
continue;
|
||||||
$targetsStr = trim($el->getAttribute('aria-controls'));
|
}
|
||||||
if ($targetsStr === '') {
|
$targetsStr = trim($el->getAttribute('aria-controls'));
|
||||||
continue;
|
if ($targetsStr === '') {
|
||||||
}
|
continue;
|
||||||
$targets = array_filter(explode(' ', $targetsStr));
|
}
|
||||||
foreach ($targets as $targetId) {
|
$targets = array_filter(explode(' ', $targetsStr));
|
||||||
$targetQuery = $xpath->query('//*[@id="' . $targetId . '"]');
|
foreach ($targets as $targetId) {
|
||||||
if ($targetQuery->length === 0) {
|
$targetQuery = $xpath->query('//*[@id="' . $targetId . '"]');
|
||||||
$violations[] = $this->createViolation(
|
if ($targetQuery !== false && $targetQuery->length === 0) {
|
||||||
"Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.",
|
$violations[] = $this->createViolation(
|
||||||
$el
|
"Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.",
|
||||||
);
|
$el
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,4 +78,5 @@ class AriaRole extends AbstractRule
|
|||||||
|
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
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 getDescription(): string { return 'Buttons must have discernible text.'; }
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
public function getLevel(): int { return 1; }
|
||||||
|
|
||||||
public function check(DOMElement $element): ?Violation
|
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 getDescription(): string { return 'Text must have sufficient contrast against the background (4.5:1 minimum).'; }
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
|
||||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||||
|
public function getLevel(): int { return 5; }
|
||||||
|
|
||||||
public function check(DOMElement $element): ?Violation
|
public function check(DOMElement $element): ?Violation
|
||||||
{
|
{
|
||||||
@@ -92,18 +93,21 @@ class ColorContrast extends AbstractRule
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: int, 1: int, 2: int}|null
|
||||||
|
*/
|
||||||
private function hexToRgb(string $hex): ?array
|
private function hexToRgb(string $hex): ?array
|
||||||
{
|
{
|
||||||
$hex = ltrim($hex, '#');
|
$hex = ltrim($hex, '#');
|
||||||
if (strlen($hex) === 3) {
|
if (strlen($hex) === 3) {
|
||||||
$r = hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
|
$r = (int)hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
|
||||||
$g = hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
|
$g = (int)hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
|
||||||
$b = hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
|
$b = (int)hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
|
||||||
return [$r, $g, $b];
|
return [$r, $g, $b];
|
||||||
} elseif (strlen($hex) === 6) {
|
} elseif (strlen($hex) === 6) {
|
||||||
$r = hexdec(substr($hex, 0, 2));
|
$r = (int)hexdec(substr($hex, 0, 2));
|
||||||
$g = hexdec(substr($hex, 2, 2));
|
$g = (int)hexdec(substr($hex, 2, 2));
|
||||||
$b = hexdec(substr($hex, 4, 2));
|
$b = (int)hexdec(substr($hex, 4, 2));
|
||||||
return [$r, $g, $b];
|
return [$r, $g, $b];
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class DuplicateId extends AbstractRule
|
|||||||
$violations = [];
|
$violations = [];
|
||||||
$xpath = new \DOMXPath($doc);
|
$xpath = new \DOMXPath($doc);
|
||||||
$elements = $xpath->query('//*[@id]');
|
$elements = $xpath->query('//*[@id]');
|
||||||
|
if ($elements === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$ids = [];
|
$ids = [];
|
||||||
foreach ($elements as $el) {
|
foreach ($elements as $el) {
|
||||||
@@ -45,4 +48,5 @@ class DuplicateId extends AbstractRule
|
|||||||
|
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
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 getDescription(): string { return 'Links must have discernible text.'; }
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
public function getLevel(): int { return 1; }
|
||||||
|
|
||||||
public function check(DOMElement $element): ?Violation
|
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 getDescription(): string { return 'Fieldsets should have a legend.'; }
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||||
|
public function getLevel(): int { return 2; }
|
||||||
|
|
||||||
public function check(DOMElement $element): ?Violation
|
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 getDescription(): string { return 'Form inputs must have an associated label.'; }
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
public function getLevel(): int { return 2; }
|
||||||
|
|
||||||
public function check(DOMElement $element): ?Violation
|
public function check(DOMElement $element): ?Violation
|
||||||
{
|
{
|
||||||
@@ -29,10 +30,13 @@ class FormLabel extends AbstractRule
|
|||||||
|
|
||||||
if ($element->hasAttribute('id')) {
|
if ($element->hasAttribute('id')) {
|
||||||
$id = $element->getAttribute('id');
|
$id = $element->getAttribute('id');
|
||||||
$xpath = new \DOMXPath($element->ownerDocument);
|
$doc = $element->ownerDocument;
|
||||||
$labels = $xpath->query("//label[@for='$id']");
|
if ($doc !== null) {
|
||||||
if ($labels->length > 0) {
|
$xpath = new \DOMXPath($doc);
|
||||||
return null;
|
$labels = $xpath->query("//label[@for='$id']");
|
||||||
|
if ($labels !== false && $labels->length > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class HeadingOrder extends AbstractRule
|
|||||||
public function getDescription(): string { return 'Headings should follow a logical nesting order.'; }
|
public function getDescription(): string { return 'Headings should follow a logical nesting order.'; }
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||||
|
public function getLevel(): int { return 2; }
|
||||||
|
|
||||||
public function check(DOMElement $element): ?Violation
|
public function check(DOMElement $element): ?Violation
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class HeadingOrderRule extends AbstractRule
|
|||||||
$violations = [];
|
$violations = [];
|
||||||
$xpath = new \DOMXPath($doc);
|
$xpath = new \DOMXPath($doc);
|
||||||
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
|
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
|
||||||
|
if ($headings === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$lastLevel = 0;
|
$lastLevel = 0;
|
||||||
foreach ($headings as $h) {
|
foreach ($headings as $h) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class HtmlHasLang extends AbstractRule
|
|||||||
public function getDescription(): string { return 'The html element must have a lang attribute.'; }
|
public function getDescription(): string { return 'The html element must have a lang attribute.'; }
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
public function getLevel(): int { return 1; }
|
||||||
|
|
||||||
public function check(DOMElement $element): ?Violation
|
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 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 getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
public function getLevel(): int { return 2; }
|
||||||
|
|
||||||
public function check(DOMElement $element): ?Violation
|
public function check(DOMElement $element): ?Violation
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ class ImgAltText extends AbstractRule
|
|||||||
public function getDescription(): string { return 'Images must have an alt attribute.'; }
|
public function getDescription(): string { return 'Images must have an alt attribute.'; }
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
public function getLevel(): int { return 1; }
|
||||||
|
|
||||||
public function check(DOMElement $element): ?Violation
|
public function check(DOMElement $element): ?Violation
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class PageTitle extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_2_4_2_PAGE_TITLE'; }
|
||||||
|
public function getDescription(): string { return 'Web pages must have a non-empty title element in the head.'; }
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
public function getLevel(): int { return 1; }
|
||||||
|
|
||||||
|
public function check(DOMElement $element): ?Violation
|
||||||
|
{
|
||||||
|
if (strtolower($element->tagName) !== 'head') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$titles = $element->getElementsByTagName('title');
|
||||||
|
|
||||||
|
if ($titles->length === 0) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Add a <title> element inside the <head>.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$titleText = '';
|
||||||
|
$firstTitle = $titles->item(0);
|
||||||
|
if ($firstTitle !== null) {
|
||||||
|
$titleText = trim($firstTitle->textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($titleText === '') {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Provide descriptive text inside the <title> element.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,24 +5,10 @@ namespace YakNet\AccessibilityConsole\Rules;
|
|||||||
class StandardRuleSet
|
class StandardRuleSet
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return RuleInterface[]
|
* @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
|
||||||
*/
|
*/
|
||||||
public static function all(): array
|
public static function all(): array
|
||||||
{
|
{
|
||||||
return [
|
return RuleLevels::getRulesForLevel(5);
|
||||||
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(),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 getDescription(): string { return 'Avoid positive tabindex values to maintain natural keyboard focus order.'; }
|
||||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||||
|
public function getLevel(): int { return 3; }
|
||||||
|
|
||||||
public function check(DOMElement $element): ?Violation
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,19 +8,24 @@ use SplFileInfo;
|
|||||||
|
|
||||||
class SourceLocator
|
class SourceLocator
|
||||||
{
|
{
|
||||||
|
/** @var array<int, string> */
|
||||||
private array $extensions = ['php', 'html', 'twig', 'blade.php'];
|
private array $extensions = ['php', 'html', 'twig', 'blade.php'];
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $searchPath
|
private readonly string $searchPath
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{file: string, line: int}|null
|
||||||
|
*/
|
||||||
public function locate(string $snippet): ?array
|
public function locate(string $snippet): ?array
|
||||||
{
|
{
|
||||||
if (!is_dir($this->searchPath) || !is_readable($this->searchPath)) {
|
if (!is_dir($this->searchPath) || !is_readable($this->searchPath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cleanSnippet = trim(preg_replace('/\s+/', ' ', $snippet));
|
$replaced = preg_replace('/\s+/', ' ', $snippet);
|
||||||
|
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
|
||||||
if ($cleanSnippet === '') {
|
if ($cleanSnippet === '') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -29,7 +34,17 @@ class SourceLocator
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$directory = new RecursiveDirectoryIterator($this->searchPath, \FilesystemIterator::SKIP_DOTS);
|
$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) {
|
} catch (\UnexpectedValueException) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -76,6 +91,9 @@ class SourceLocator
|
|||||||
return false;
|
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
|
private function searchInFile(string $filepath, string $searchSnippet, string $fullSnippet): array
|
||||||
{
|
{
|
||||||
$content = file_get_contents($filepath);
|
$content = file_get_contents($filepath);
|
||||||
@@ -91,7 +109,8 @@ class SourceLocator
|
|||||||
|
|
||||||
foreach ($lines as $index => $line) {
|
foreach ($lines as $index => $line) {
|
||||||
$lineNumber = $index + 1;
|
$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)) {
|
if (str_contains($cleanLine, $searchSnippet)) {
|
||||||
return [
|
return [
|
||||||
@@ -118,6 +137,9 @@ class SourceLocator
|
|||||||
return ['type' => 'none'];
|
return ['type' => 'none'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{tag: string|null, attributes: array<string, string>}
|
||||||
|
*/
|
||||||
private function parseSnippet(string $snippet): array
|
private function parseSnippet(string $snippet): array
|
||||||
{
|
{
|
||||||
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
|
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
|
||||||
@@ -133,6 +155,9 @@ class SourceLocator
|
|||||||
return ['tag' => $tagName, 'attributes' => $attributes];
|
return ['tag' => $tagName, 'attributes' => $attributes];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{tag: string|null, attributes: array<string, string>} $parsed
|
||||||
|
*/
|
||||||
private function calculateFuzzyScore(string $line, array $parsed): float
|
private function calculateFuzzyScore(string $line, array $parsed): float
|
||||||
{
|
{
|
||||||
if (!$parsed['tag'] || !str_contains(strtolower($line), '<' . strtolower($parsed['tag']))) {
|
if (!$parsed['tag'] || !str_contains(strtolower($line), '<' . strtolower($parsed['tag']))) {
|
||||||
|
|||||||
+1866
-75
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user