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 | |
|---|---|---|---|
|
|
b805741ef2 | ||
|
|
84f713e434 | ||
|
|
c6df5e2bdc | ||
|
|
75171c6f96 | ||
|
|
1ea2579d20 | ||
|
|
873b4c4233 | ||
|
|
d219272487 | ||
|
|
09f33650ec | ||
|
|
3a19c1c534 |
+36
-2
@@ -1,10 +1,10 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Broken Page</title>
|
<title></title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Welcome to a broken page</h1>
|
<h1 id="main-content">Welcome to a broken page</h1>
|
||||||
|
|
||||||
<!-- Missing alt attribute -->
|
<!-- Missing alt attribute -->
|
||||||
<img src="logo.png">
|
<img src="logo.png">
|
||||||
@@ -15,6 +15,40 @@
|
|||||||
<!-- Empty button -->
|
<!-- Empty button -->
|
||||||
<button type="submit"></button>
|
<button type="submit"></button>
|
||||||
|
|
||||||
|
<!-- Positive tabindex -->
|
||||||
|
<a href="/keyboard" tabindex="5">Keyboard test</a>
|
||||||
|
|
||||||
|
<!-- Duplicate ID -->
|
||||||
|
<div id="main-content">This is a duplicate main-content ID div!</div>
|
||||||
|
|
||||||
|
<!-- Low Contrast -->
|
||||||
|
<p style="color: #888888; background-color: #999999;">This text has extremely low contrast ratio!</p>
|
||||||
|
|
||||||
|
<!-- Invalid ARIA role and missing controls target -->
|
||||||
|
<div role="cool-widget" aria-controls="non-existent-popup">WAI-ARIA elements</div>
|
||||||
|
|
||||||
|
<!-- Missing Iframe Title -->
|
||||||
|
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
parameters:
|
||||||
|
level: 9
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
+16
-13
@@ -11,15 +11,23 @@ class GeminiFixer
|
|||||||
|
|
||||||
public function __construct(private readonly ?string $apiKey)
|
public function __construct(private readonly ?string $apiKey)
|
||||||
{
|
{
|
||||||
if ($this->apiKey && class_exists(Gemini::class)) {
|
if ($this->apiKey) {
|
||||||
$this->client = Gemini::client($this->apiKey);
|
try {
|
||||||
|
$this->client = \Gemini::client($this->apiKey);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->client = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function suggestFix(Violation $violation): ?string
|
public function suggestFix(Violation $violation): ?string
|
||||||
{
|
{
|
||||||
|
if (!$this->apiKey) {
|
||||||
|
return "Hata: GEMINI_API_KEY bulunamadı.";
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->client) {
|
if (!$this->client) {
|
||||||
return null;
|
return "Hata: Gemini istemcisi başlatılamadı. Kütüphane eksik olabilir.";
|
||||||
}
|
}
|
||||||
|
|
||||||
$prompt = <<<PROMPT
|
$prompt = <<<PROMPT
|
||||||
@@ -30,20 +38,15 @@ Error: {$violation->message}
|
|||||||
Severity: {$violation->severity->value}
|
Severity: {$violation->severity->value}
|
||||||
Standard: {$violation->standard->value}
|
Standard: {$violation->standard->value}
|
||||||
|
|
||||||
Please provide ONLY the fixed version of the HTML snippet.
|
Please provide a short explanation of the fix (in Turkish) and the fixed version of the HTML snippet.
|
||||||
Do not explain, do not add markdown code blocks, just return the corrected HTML.
|
Use the following EXACT format:
|
||||||
If the error is about missing 'alt' text, add a meaningful alt text based on context.
|
EXPLANATION: [A short explanation of what you fixed and why]
|
||||||
If it's about ARIA roles, add the correct ones.
|
FIX: [Only the corrected HTML snippet]
|
||||||
PROMPT;
|
PROMPT;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $this->client->generativeModel('gemini-2.5-flash')->generateContent($prompt);
|
$result = $this->client->generativeModel('gemini-2.5-flash')->generateContent($prompt);
|
||||||
$suggestion = trim($result->text());
|
return trim($result->text());
|
||||||
|
|
||||||
// Clean up if AI included markdown blocks anyway
|
|
||||||
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
|
||||||
|
|
||||||
return $suggestion;
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return "AI Düzeltme Hatası: " . $e->getMessage();
|
return "AI Düzeltme Hatası: " . $e->getMessage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -79,9 +87,17 @@ class FixCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get AI Suggestion
|
// Get AI Suggestion
|
||||||
$suggestion = $fixer->suggestFix($violation);
|
$rawSuggestion = $fixer->suggestFix($violation);
|
||||||
if (!$suggestion) {
|
$suggestion = $rawSuggestion;
|
||||||
$io->writeln("<fg=red>Skipped (AI could not generate fix)</>");
|
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
|
||||||
|
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
||||||
|
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
||||||
|
// Clean up potential markdown
|
||||||
|
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
|
||||||
|
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ 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
|
||||||
{
|
{
|
||||||
@@ -33,14 +33,22 @@ class ScanCommand extends Command
|
|||||||
{
|
{
|
||||||
$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");
|
$io->title("Accessibility Console - Scanning: $target");
|
||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
@@ -83,6 +91,7 @@ class ScanCommand extends Command
|
|||||||
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
|
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
|
||||||
$suggestion = $fixer->suggestFix($violation);
|
$suggestion = $fixer->suggestFix($violation);
|
||||||
if ($suggestion) {
|
if ($suggestion) {
|
||||||
|
$violation->fixSuggestion = $suggestion;
|
||||||
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
|
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
|
||||||
} else {
|
} else {
|
||||||
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
|
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
|
||||||
@@ -96,7 +105,7 @@ class ScanCommand extends Command
|
|||||||
|
|
||||||
// Export Report
|
// 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);
|
$data = array_map(fn($v) => $v->toArray(), $violations);
|
||||||
|
|||||||
@@ -21,12 +21,21 @@ abstract class AbstractRule
|
|||||||
|
|
||||||
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;
|
||||||
|
|||||||
+19
-2
@@ -4,10 +4,10 @@ namespace YakNet\AccessibilityConsole\Core;
|
|||||||
|
|
||||||
class RuleEngine
|
class RuleEngine
|
||||||
{
|
{
|
||||||
/** @var AbstractRule[] */
|
/** @var array<int, mixed> */
|
||||||
private array $rules = [];
|
private array $rules = [];
|
||||||
|
|
||||||
public function addRule(AbstractRule $rule): void
|
public function addRule(mixed $rule): void
|
||||||
{
|
{
|
||||||
$this->rules[] = $rule;
|
$this->rules[] = $rule;
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,25 @@ class RuleEngine
|
|||||||
{
|
{
|
||||||
$allViolations = [];
|
$allViolations = [];
|
||||||
foreach ($this->rules as $rule) {
|
foreach ($this->rules as $rule) {
|
||||||
|
if ($rule instanceof AbstractRule) {
|
||||||
|
// Document-level rule (Core\AbstractRule style)
|
||||||
$violations = $rule->check($doc);
|
$violations = $rule->check($doc);
|
||||||
$allViolations = array_merge($allViolations, $violations);
|
$allViolations = array_merge($allViolations, $violations);
|
||||||
|
} elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) {
|
||||||
|
// Element-level rule (Rules\RuleInterface style)
|
||||||
|
$xpath = new \DOMXPath($doc);
|
||||||
|
$elements = $xpath->query('//*');
|
||||||
|
if ($elements !== false) {
|
||||||
|
foreach ($elements as $element) {
|
||||||
|
if ($element instanceof \DOMElement) {
|
||||||
|
$violation = $rule->check($element);
|
||||||
|
if ($violation) {
|
||||||
|
$allViolations[] = $violation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return $allViolations;
|
return $allViolations;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ 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
|
||||||
|
{
|
||||||
|
$this->engine->addRule($rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
namespace YakNet\AccessibilityConsole\Core;
|
namespace YakNet\AccessibilityConsole\Core;
|
||||||
|
|
||||||
readonly 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 @@ readonly 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
@@ -16,12 +16,21 @@ abstract class AbstractRule implements RuleInterface
|
|||||||
|
|
||||||
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,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,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\Core\AbstractRule;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class AriaRole extends AbstractRule
|
||||||
|
{
|
||||||
|
/** @var array<int, string> */
|
||||||
|
private static array $validRoles = [
|
||||||
|
'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 'cell', 'checkbox',
|
||||||
|
'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'dialog', 'directory',
|
||||||
|
'document', 'feed', 'figure', 'form', 'grid', 'gridcell', 'group', 'heading', 'img', 'link',
|
||||||
|
'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'menu', 'menubar', 'menuitem',
|
||||||
|
'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'presentation',
|
||||||
|
'progressbar', 'radio', 'radiogroup', 'region', 'row', 'rowgroup', 'rowheader', 'scrollbar',
|
||||||
|
'search', 'searchbox', 'separator', 'slider', 'spinbutton', 'status', 'switch', 'tab', 'table',
|
||||||
|
'tablist', 'tabpanel', 'term', 'textbox', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid',
|
||||||
|
'treeitem'
|
||||||
|
];
|
||||||
|
|
||||||
|
public function check(\DOMDocument $doc): array
|
||||||
|
{
|
||||||
|
$violations = [];
|
||||||
|
$xpath = new \DOMXPath($doc);
|
||||||
|
|
||||||
|
// 1. Check WAI-ARIA Role Validity
|
||||||
|
$roleElements = $xpath->query('//*[@role]');
|
||||||
|
if ($roleElements !== false) {
|
||||||
|
foreach ($roleElements as $el) {
|
||||||
|
if (!$el instanceof \DOMElement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$role = trim($el->getAttribute('role'));
|
||||||
|
if ($role === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$roles = array_filter(explode(' ', $role));
|
||||||
|
foreach ($roles as $r) {
|
||||||
|
if (!in_array(strtolower($r), self::$validRoles, true)) {
|
||||||
|
$violations[] = $this->createViolation(
|
||||||
|
"Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.",
|
||||||
|
$el
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check aria-controls Targets Existence
|
||||||
|
$controlsElements = $xpath->query('//*[@aria-controls]');
|
||||||
|
if ($controlsElements !== false) {
|
||||||
|
foreach ($controlsElements as $el) {
|
||||||
|
if (!$el instanceof \DOMElement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$targetsStr = trim($el->getAttribute('aria-controls'));
|
||||||
|
if ($targetsStr === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$targets = array_filter(explode(' ', $targetsStr));
|
||||||
|
foreach ($targets as $targetId) {
|
||||||
|
$targetQuery = $xpath->query('//*[@id="' . $targetId . '"]');
|
||||||
|
if ($targetQuery !== false && $targetQuery->length === 0) {
|
||||||
|
$violations[] = $this->createViolation(
|
||||||
|
"Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.",
|
||||||
|
$el
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class ColorContrast extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_1_4_3_CONTRAST'; }
|
||||||
|
public function getDescription(): string { return 'Text must have sufficient contrast against the background (4.5:1 minimum).'; }
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
|
||||||
|
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||||
|
|
||||||
|
public function check(DOMElement $element): ?Violation
|
||||||
|
{
|
||||||
|
if (!$element->hasAttribute('style')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$style = $element->getAttribute('style');
|
||||||
|
|
||||||
|
$color = $this->parseColorValue($style, 'color');
|
||||||
|
$bg = $this->parseColorValue($style, 'background-color') ?: $this->parseColorValue($style, 'background');
|
||||||
|
|
||||||
|
if ($color === null || $bg === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cRGB = $this->hexToRgb($color);
|
||||||
|
$bgRGB = $this->hexToRgb($bg);
|
||||||
|
|
||||||
|
if ($cRGB === null || $bgRGB === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cLum = $this->getLuminance($cRGB[0], $cRGB[1], $cRGB[2]);
|
||||||
|
$bgLum = $this->getLuminance($bgRGB[0], $bgRGB[1], $bgRGB[2]);
|
||||||
|
|
||||||
|
$ratio = ($cLum > $bgLum) ? ($cLum + 0.05) / ($bgLum + 0.05) : ($bgLum + 0.05) / ($cLum + 0.05);
|
||||||
|
|
||||||
|
$isLarge = false;
|
||||||
|
if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) {
|
||||||
|
$val = (float)$matches[1];
|
||||||
|
$unit = strtolower($matches[2]);
|
||||||
|
if (($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5)) {
|
||||||
|
$isLarge = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (preg_match('/font-weight\s*:\s*(bold|[7-9]00)/i', $style)) {
|
||||||
|
if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) {
|
||||||
|
$val = (float)$matches[1];
|
||||||
|
$unit = strtolower($matches[2]);
|
||||||
|
if (($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2)) {
|
||||||
|
$isLarge = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$minRatio = $isLarge ? 3.0 : 4.5;
|
||||||
|
|
||||||
|
if ($ratio < $minRatio) {
|
||||||
|
$formattedRatio = round($ratio, 2);
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
"Insufficient contrast ratio of {$formattedRatio}:1. Minimum required is {$minRatio}:1.",
|
||||||
|
"Adjust colors to increase contrast. For example, text: {$color}, background: {$bg}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseColorValue(string $style, string $property): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:\s*([^;]+)/i', $style, $matches)) {
|
||||||
|
$value = trim($matches[1]);
|
||||||
|
if (preg_match('/^#([0-9a-f]{3,6})$/i', $value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
$colors = [
|
||||||
|
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
|
||||||
|
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
|
||||||
|
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
|
||||||
|
];
|
||||||
|
if (isset($colors[strtolower($value)])) {
|
||||||
|
return $colors[strtolower($value)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: int, 1: int, 2: int}|null
|
||||||
|
*/
|
||||||
|
private function hexToRgb(string $hex): ?array
|
||||||
|
{
|
||||||
|
$hex = ltrim($hex, '#');
|
||||||
|
if (strlen($hex) === 3) {
|
||||||
|
$r = (int)hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
|
||||||
|
$g = (int)hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
|
||||||
|
$b = (int)hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
|
||||||
|
return [$r, $g, $b];
|
||||||
|
} elseif (strlen($hex) === 6) {
|
||||||
|
$r = (int)hexdec(substr($hex, 0, 2));
|
||||||
|
$g = (int)hexdec(substr($hex, 2, 2));
|
||||||
|
$b = (int)hexdec(substr($hex, 4, 2));
|
||||||
|
return [$r, $g, $b];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLuminance(float $r, float $g, float $b): float
|
||||||
|
{
|
||||||
|
$rs = $r / 255.0;
|
||||||
|
$gs = $g / 255.0;
|
||||||
|
$bs = $b / 255.0;
|
||||||
|
|
||||||
|
$rVal = ($rs <= 0.03928) ? $rs / 12.92 : pow(($rs + 0.055) / 1.055, 2.4);
|
||||||
|
$gVal = ($gs <= 0.03928) ? $gs / 12.92 : pow(($gs + 0.055) / 1.055, 2.4);
|
||||||
|
$bVal = ($bs <= 0.03928) ? $bs / 12.92 : pow(($bs + 0.055) / 1.055, 2.4);
|
||||||
|
|
||||||
|
return 0.2126 * $rVal + 0.7152 * $gVal + 0.0722 * $bVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\Core\AbstractRule;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class DuplicateId extends AbstractRule
|
||||||
|
{
|
||||||
|
public function check(\DOMDocument $doc): array
|
||||||
|
{
|
||||||
|
$violations = [];
|
||||||
|
$xpath = new \DOMXPath($doc);
|
||||||
|
$elements = $xpath->query('//*[@id]');
|
||||||
|
if ($elements === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
foreach ($elements as $el) {
|
||||||
|
if (!$el instanceof \DOMElement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$id = $el->getAttribute('id');
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isset($ids[$id])) {
|
||||||
|
$ids[$id] = [];
|
||||||
|
}
|
||||||
|
$ids[$id][] = $el;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($ids as $id => $elementsWithId) {
|
||||||
|
if (count($elementsWithId) > 1) {
|
||||||
|
foreach ($elementsWithId as $el) {
|
||||||
|
$violations[] = $this->createViolation(
|
||||||
|
"Duplicate ID \"{$id}\" found. Element ID attribute must be unique across the document.",
|
||||||
|
$el
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
}
|
||||||
@@ -29,12 +29,15 @@ 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;
|
||||||
|
if ($doc !== null) {
|
||||||
|
$xpath = new \DOMXPath($doc);
|
||||||
$labels = $xpath->query("//label[@for='$id']");
|
$labels = $xpath->query("//label[@for='$id']");
|
||||||
if ($labels->length > 0) {
|
if ($labels !== false && $labels->length > 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for aria-label or aria-labelledby
|
// Check for aria-label or aria-labelledby
|
||||||
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
|
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
|
||||||
|
|||||||
@@ -13,9 +13,15 @@ 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) {
|
||||||
|
if (!$h instanceof \DOMElement) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$level = (int)substr($h->tagName, 1);
|
$level = (int)substr($h->tagName, 1);
|
||||||
if ($lastLevel > 0 && $level > $lastLevel + 1) {
|
if ($lastLevel > 0 && $level > $lastLevel + 1) {
|
||||||
$violations[] = $this->createViolation(
|
$violations[] = $this->createViolation(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class IframeTitle extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_4_1_2_IFRAME_TITLE'; }
|
||||||
|
public function getDescription(): string { return 'Iframe elements must have a non-empty title attribute to describe their content.'; }
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
|
||||||
|
public function check(DOMElement $element): ?Violation
|
||||||
|
{
|
||||||
|
if (strtolower($element->tagName) !== 'iframe') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$element->hasAttribute('title') || trim($element->getAttribute('title')) === '') {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
"Iframe element is missing a descriptive title attribute.",
|
||||||
|
"Add a descriptive title attribute, e.g., title=\"Embedded Map\" or title=\"Video Player\"."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ class StandardRuleSet
|
|||||||
*/
|
*/
|
||||||
public static function all(): array
|
public static function all(): array
|
||||||
{
|
{
|
||||||
return [
|
/** @var RuleInterface[] $rules */
|
||||||
|
$rules = [
|
||||||
new HtmlHasLang(),
|
new HtmlHasLang(),
|
||||||
new ImgAltText(),
|
new ImgAltText(),
|
||||||
new EmptyLink(),
|
new EmptyLink(),
|
||||||
@@ -18,6 +19,17 @@ class StandardRuleSet
|
|||||||
new FieldsetLegend(),
|
new FieldsetLegend(),
|
||||||
new HeadingOrder(),
|
new HeadingOrder(),
|
||||||
new MetaViewport(),
|
new MetaViewport(),
|
||||||
|
new TabindexOrder(),
|
||||||
|
new ColorContrast(),
|
||||||
|
new DuplicateId(),
|
||||||
|
new AriaRole(),
|
||||||
|
new IframeTitle(),
|
||||||
|
new LinkTextGeneric(),
|
||||||
|
new PlaceholderAsLabel(),
|
||||||
|
new ImageAltRedundant(),
|
||||||
|
new AriaHiddenFocusable(),
|
||||||
|
new PageTitle(),
|
||||||
];
|
];
|
||||||
|
return $rules;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class TabindexOrder extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_2_4_3_TABINDEX'; }
|
||||||
|
public function getDescription(): string { return 'Avoid positive tabindex values to maintain natural keyboard focus order.'; }
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
|
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||||
|
|
||||||
|
public function check(DOMElement $element): ?Violation
|
||||||
|
{
|
||||||
|
if ($element->hasAttribute('tabindex')) {
|
||||||
|
$tabindex = $element->getAttribute('tabindex');
|
||||||
|
if (is_numeric($tabindex)) {
|
||||||
|
$val = (int)$tabindex;
|
||||||
|
if ($val > 0) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
"Avoid positive tabindex values (tabindex=\"{$val}\") to preserve the natural tab order.",
|
||||||
|
"Remove the positive tabindex or change it to \"0\" or \"-1\"."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']))) {
|
||||||
|
|||||||
+1892
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user