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 | |
|---|---|---|---|
|
|
c42ab714ff | ||
|
|
b805741ef2 | ||
|
|
84f713e434 | ||
|
|
c6df5e2bdc |
+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",
|
||||||
|
|||||||
+20
-1
@@ -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>
|
||||||
@@ -30,6 +30,25 @@
|
|||||||
<!-- 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/dQw4w9WgXcQ" width="560" height="315"></iframe>
|
||||||
|
|
||||||
|
<!-- New WCAG Rule Violations for testing -->
|
||||||
|
|
||||||
|
<!-- LinkTextGeneric: Generic text without context -->
|
||||||
|
<a href="/read-more">Click here</a>
|
||||||
|
<a href="/devam-oku">devamını oku</a>
|
||||||
|
|
||||||
|
<!-- PlaceholderAsLabel: Input relying only on placeholder -->
|
||||||
|
<input type="text" placeholder="Search website...">
|
||||||
|
|
||||||
|
<!-- ImageAltRedundant: Redundant words in alt text -->
|
||||||
|
<img src="logo2.png" alt="logo of our company">
|
||||||
|
<img src="profile.jpg" alt="profile photo of john doe">
|
||||||
|
|
||||||
|
<!-- AriaHiddenFocusable: Focusable element hidden with aria-hidden="true" -->
|
||||||
|
<div aria-hidden="true">
|
||||||
|
<button type="button">Hidden click</button>
|
||||||
|
</div>
|
||||||
|
<a href="#" aria-hidden="true">Hidden link</a>
|
||||||
|
|
||||||
<p>This page has multiple accessibility issues.</p>
|
<p>This page has multiple accessibility issues.</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+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);
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
@@ -46,73 +49,195 @@ class ScanCommand extends Command
|
|||||||
|
|
||||||
$io->title("Accessibility Console - Scanning: $target");
|
$io->title("Accessibility Console - Scanning: $target");
|
||||||
|
|
||||||
// 1. Fetch HTML
|
// 1. Setup Scanner
|
||||||
$html = @file_get_contents($target);
|
|
||||||
if ($html === false || $html === '') {
|
|
||||||
$io->error("Could not read target: $target");
|
|
||||||
return Command::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Setup Scanner
|
|
||||||
$scanner = new Scanner();
|
$scanner = new Scanner();
|
||||||
foreach (StandardRuleSet::all() as $rule) {
|
foreach (StandardRuleSet::all() 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 (is_string($reportPath) && $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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class AriaHiddenFocusable extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_4_1_2_ARIA_HIDDEN_FOCUS'; }
|
||||||
|
public function getDescription(): string { return 'Focusable elements must not be hidden with aria-hidden="true".'; }
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
|
||||||
|
public function check(DOMElement $element): ?Violation
|
||||||
|
{
|
||||||
|
if ($element->getAttribute('aria-hidden') !== 'true') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isFocusable($element) || $this->hasFocusableDescendant($element)) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Remove aria-hidden="true" from the container or make its focusable elements non-focusable (e.g. by setting tabindex="-1" or disabled).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isFocusable(DOMElement $element): bool
|
||||||
|
{
|
||||||
|
$tagName = strtolower($element->tagName);
|
||||||
|
|
||||||
|
if ($element->hasAttribute('tabindex')) {
|
||||||
|
$tabindex = (int)$element->getAttribute('tabindex');
|
||||||
|
if ($tabindex < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tagName === 'a' && $element->hasAttribute('href')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($tagName, ['button', 'select', 'textarea'], true)) {
|
||||||
|
return !$element->hasAttribute('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tagName === 'input') {
|
||||||
|
$type = strtolower($element->getAttribute('type'));
|
||||||
|
return $type !== 'hidden' && !$element->hasAttribute('disabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tagName === 'iframe') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasFocusableDescendant(DOMElement $element): bool
|
||||||
|
{
|
||||||
|
$doc = $element->ownerDocument;
|
||||||
|
if ($doc === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$xpath = new \DOMXPath($doc);
|
||||||
|
$descendants = $xpath->query('.//*', $element);
|
||||||
|
if ($descendants === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($descendants as $descendant) {
|
||||||
|
if ($descendant instanceof DOMElement && $this->isFocusable($descendant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class PageTitle extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_2_4_2_PAGE_TITLE'; }
|
||||||
|
public function getDescription(): string { return 'Web pages must have a non-empty title element in the head.'; }
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
|
||||||
|
public function check(DOMElement $element): ?Violation
|
||||||
|
{
|
||||||
|
if (strtolower($element->tagName) !== 'head') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$titles = $element->getElementsByTagName('title');
|
||||||
|
|
||||||
|
if ($titles->length === 0) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Add a <title> element inside the <head>.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$titleText = '';
|
||||||
|
$firstTitle = $titles->item(0);
|
||||||
|
if ($firstTitle !== null) {
|
||||||
|
$titleText = trim($firstTitle->textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($titleText === '') {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Provide descriptive text inside the <title> element.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ class StandardRuleSet
|
|||||||
new DuplicateId(),
|
new DuplicateId(),
|
||||||
new AriaRole(),
|
new AriaRole(),
|
||||||
new IframeTitle(),
|
new IframeTitle(),
|
||||||
|
new LinkTextGeneric(),
|
||||||
|
new PlaceholderAsLabel(),
|
||||||
|
new ImageAltRedundant(),
|
||||||
|
new AriaHiddenFocusable(),
|
||||||
|
new PageTitle(),
|
||||||
];
|
];
|
||||||
return $rules;
|
return $rules;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,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;
|
||||||
}
|
}
|
||||||
|
|||||||
+1862
-71
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user