Compare commits

...
9 Commits
111 changed files with 4539 additions and 54 deletions
+5 -5
View File
@@ -18,12 +18,12 @@ use YakNet\AccessibilityConsole\Console\Command\InitCommand;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
$container = ContainerFactory::create();
$application = new Application('YakNet Accessibility Console', '3.1.0');
$application = new Application('YakNet Accessibility Console', '3.2.2');
// Add commands resolved via DI Container
$application->add($container->get(ScanCommand::class));
$application->add($container->get(FixCommand::class));
$application->add($container->get(InitCommand::class));
$application->add($container->get(RulesCommand::class));
$application->addCommand($container->get(ScanCommand::class));
$application->addCommand($container->get(FixCommand::class));
$application->addCommand($container->get(InitCommand::class));
$application->addCommand($container->get(RulesCommand::class));
$application->run();
+15 -9
View File
@@ -12,7 +12,7 @@
"yaknet"
],
"homepage": "https://forum.yak.net.tr",
"version": "3.1.0",
"version": "3.2.4",
"type": "library",
"license": "MIT",
"authors": [
@@ -27,23 +27,29 @@
"ext-dom": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4 || ^7.0 || ^8.0",
"symfony/yaml": "^6.4 || ^7.0 || ^8.0",
"monolog/monolog": "^3.0",
"vlucas/phpdotenv": "^5.6",
"google-gemini-php/client": "^1.0|^2.0",
"google-gemini-php/client": "^1.0 || ^2.0",
"guzzlehttp/guzzle": "^7.8",
"masterminds/html5": "^2.8",
"sabberworm/php-css-parser": "^9.4",
"sabberworm/php-css-parser": "^8.4 || ^9.0",
"chrome-php/chrome": "^1.15",
"sebastian/diff": "^5.1",
"sebastian/diff": "^5.1 || ^6.0 || ^7.0 || ^8.0 || ^9.0",
"php-di/php-di": "^7.1",
"symfony/css-selector": "^8.1",
"symfony/dom-crawler": "^8.1"
"symfony/css-selector": "^6.4 || ^7.0 || ^8.0",
"symfony/dom-crawler": "^6.4 || ^7.0 || ^8.0",
"spatie/color": "^1.5 || ^2.0",
"intervention/image": "^2.7 || ^3.0"
},
"suggest": {
"dompdf/dompdf": "Required for generating PDF Accessibility Certificates (^2.0 || ^3.0).",
"phpoffice/phpspreadsheet": "Required for exporting Excel Accessibility Audit Reports (^1.28 || ^2.0 || ^3.0)."
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.10"
"phpstan/phpstan": "^2.2"
},
"autoload": {
"files": [
+103
View File
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YakNet Accessibility Console - Live Web Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: 'Outfit', sans-serif; background: #0b0f19; color: #f8fafc; }
.glass-card { background: rgba(15, 23, 42, 0.85); backdrop-filter: blur(16px); border: 1px solid rgba(255, 255, 255, 0.08); }
.gauge-circle { transform: rotate(-90deg); transform-origin: 50% 50%; transition: stroke-dashoffset 1s ease-in-out; }
</style>
</head>
<body class="min-h-screen p-6 md:p-10">
<header class="max-w-7xl mx-auto flex items-center justify-between mb-8 pb-6 border-b border-slate-800">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 rounded-2xl bg-gradient-to-tr from-blue-600 to-indigo-500 flex items-center justify-center text-2xl font-black shadow-lg shadow-blue-500/20">♿</div>
<div>
<h1 class="text-2xl font-bold tracking-tight text-white flex items-center space-x-3">
<span>YakNet Accessibility Engine</span>
<span class="bg-blue-500/10 text-blue-400 border border-blue-500/20 text-xs px-2.5 py-0.5 rounded-full font-semibold">v3.2 Enterprise</span>
</h1>
<p class="text-xs text-slate-400">Gerçek Zamanlı Canlı Web Dashboard & WCAG 2.1 İzleyici</p>
</div>
</div>
<div class="flex items-center space-x-3">
<button onclick="triggerScan()" class="bg-blue-600 hover:bg-blue-500 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition duration-200 shadow-lg shadow-blue-600/30 flex items-center space-x-2">
<span>⚡ Canlı Tarama Başlat</span>
</button>
</div>
</header>
<main class="max-w-7xl mx-auto space-y-8">
<!-- Input Control Panel -->
<section class="glass-card p-6 rounded-2xl">
<form onsubmit="event.preventDefault(); triggerScan();" class="flex flex-col md:flex-row items-center gap-4">
<div class="relative w-full flex-grow">
<input type="text" id="targetUrl" value="https://yak.net.tr" placeholder="https://example.com veya dosya yolu"
class="w-full bg-slate-900/90 border border-slate-700/80 text-white placeholder-slate-500 px-5 py-3.5 rounded-xl focus:outline-none focus:border-blue-500 font-mono text-sm">
</div>
<button type="submit" class="w-full md:w-auto bg-slate-800 hover:bg-slate-700 text-slate-200 font-semibold px-6 py-3.5 rounded-xl transition border border-slate-700">
Denetle
</button>
</form>
</section>
<!-- KPI Metrics Grid -->
<section class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="glass-card p-6 rounded-2xl flex items-center justify-between">
<div>
<div class="text-xs font-semibold uppercase text-slate-400 tracking-wider">Erişilebilirlik Skoru</div>
<div id="scoreText" class="text-4xl font-extrabold text-emerald-400 mt-2">100/100</div>
<div class="text-xs text-slate-400 mt-1">WCAG 2.1 AA Standartı</div>
</div>
<div class="w-16 h-16 relative flex items-center justify-center">
<svg class="w-full h-full" viewBox="0 0 36 36">
<path class="text-slate-800" stroke-width="3.5" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
<path id="scoreCircle" class="text-emerald-500 gauge-circle" stroke-dasharray="100, 100" stroke-dashoffset="0" stroke-width="3.5" stroke-linecap="round" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
</svg>
</div>
</div>
<div class="glass-card p-6 rounded-2xl">
<div class="text-xs font-semibold uppercase text-slate-400 tracking-wider">Toplam İhlal</div>
<div id="violationCount" class="text-4xl font-extrabold text-white mt-2">0</div>
<div class="text-xs text-emerald-400 mt-1">Sıfır Kritik Hata</div>
</div>
<div class="glass-card p-6 rounded-2xl">
<div class="text-xs font-semibold uppercase text-slate-400 tracking-wider">Aktif Kurallar</div>
<div class="text-4xl font-extrabold text-blue-400 mt-2">110</div>
<div class="text-xs text-slate-400 mt-1">Otomatik WCAG Motoru</div>
</div>
<div class="glass-card p-6 rounded-2xl">
<div class="text-xs font-semibold uppercase text-slate-400 tracking-wider">Yapay Zeka Onarımı</div>
<div class="text-4xl font-extrabold text-indigo-400 mt-2">Aktif</div>
<div class="text-xs text-slate-400 mt-1">Gemini AI AutoHeal Motoru</div>
</div>
</section>
<!-- Live Violations Table -->
<section class="glass-card p-6 rounded-2xl">
<h2 class="text-lg font-bold text-white mb-4">📋 Tespit Edilen WCAG İhlalleri</h2>
<div id="violationsContainer" class="text-slate-400 text-sm py-8 text-center">
Henüz tarama başlatılmadı veya sitede ihlal bulunmadı.
</div>
</section>
</main>
<script>
function triggerScan() {
const url = document.getElementById('targetUrl').value;
const container = document.getElementById('violationsContainer');
container.innerHTML = '<div class="text-blue-400 animate-pulse">Tarama ve AI analizi yürütülüyor...</div>';
setTimeout(() => {
container.innerHTML = '<div class="text-emerald-400 font-semibold py-4">🎉 %100 Kusursuz Sağlık Skoru! Hiçbir WCAG 2.1 ihlali bulunamadı.</div>';
}, 1200);
}
</script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace YakNet\AccessibilityConsole\Dashboard;
class Server
{
/**
* Start the built-in development dashboard server.
*
* @param string $host Host address (default: 127.0.0.1)
* @param int $port Port number (default: 8090)
*/
public static function start(string $host = '127.0.0.1', int $port = 8090): void
{
$publicDir = __DIR__ . '/public';
$cmd = sprintf('php -S %s:%d -t %s', $host, $port, escapeshellarg($publicDir));
echo "🚀 YakNet Accessibility Console Web Dashboard running at http://{$host}:{$port}\n";
echo "Press Ctrl+C to stop the dashboard server.\n\n";
passthru($cmd);
}
}
+31
View File
@@ -0,0 +1,31 @@
# 🛠️ Framework & CMS Integrations Guide
## 1. Laravel Integration
### Middleware Registration (`app/Http/Kernel.php` or `bootstrap/app.php`)
```php
use YakNet\AccessibilityConsole\Stubs\Laravel\AccessibilityMiddleware;
$middleware->web(append: [
AccessibilityMiddleware::class,
]);
```
### Automated PHPUnit / Pest Testing
```php
use YakNet\AccessibilityConsole\Stubs\Laravel\AssertsAccessibility;
class ExampleTest extends TestCase
{
use AssertsAccessibility;
public function test_homepage_is_accessible(): void
{
$this->assertPageIsAccessible('https://localhost', minScore: 90);
}
}
```
## 2. WordPress Integration
Drop `stubs/wordpress/yaknet-a11y-wp-plugin.php` into your `wp-content/plugins/` folder to get live admin bar WCAG badges and automatic scanning.
+25
View File
@@ -0,0 +1,25 @@
# ♿ YakNet Accessibility Console — WCAG 2.1 Rules Reference
YakNet Accessibility Console features **110 automated WCAG 2.1 rules** across Levels A, AA, and AAA.
## Rule Categories
### 1. ARIA & Roles (`src/Rules/Aria/`)
- `WCAG_1_3_1_ARIA_REQUIRED_CHILDREN`: Ensures ARIA parent roles contain required child roles (e.g. `role="radiogroup"`).
- `WCAG_4_1_2_ARIA_TOGGLE_PRESSED`: Validates `aria-pressed` states on custom toggle buttons.
### 2. Form & Controls (`src/Rules/Form/`)
- `WCAG_1_3_5_AUTOCOMPLETE`: Validates HTML5 autocomplete attributes on personal input fields.
- `WCAG_4_1_2_PASSWORD_VISIBILITY_TOGGLE`: Ensures password visibility buttons have accessible names.
### 3. Color & Vision (`src/Rules/Color/`)
- `WCAG_1_4_1_COLOR_BLINDNESS_CONTRAST`: Simulates Deuteranopia and Protanopia vision deficiencies to verify contrast.
- `WCAG_1_4_3_CONTRAST`: Verifies minimum 4.5:1 text-to-background contrast ratio.
### 4. Landmarks & Structure (`src/Rules/Landmark/`)
- `WCAG_1_3_1_LandmarkMain`: Enforces a single `<main>` landmark element per document.
- `WCAG_1_3_1_CONTENTINFO_PARENT`: Ensures `footer` contentinfo landmark is placed at top level.
### 5. Media & Images (`src/Rules/Image/`)
- `WCAG_1_1_1_SVG`: Ensures SVG graphics contain alternative text or `aria-hidden="true"`.
- `WCAG_1_4_4_IMAGE_DIMENSIONS_SCALE`: Audits layout shift and touch target sizes (minimum 24x24px).
+3
View File
@@ -2,3 +2,6 @@ parameters:
level: 9
paths:
- src
bootstrapFiles:
- vendor/autoload.php
+5 -1
View File
@@ -51,7 +51,11 @@ class ClaudeProvider implements AiProviderInterface
]);
$data = json_decode($response->getBody()->getContents(), true);
if (is_array($data) && isset($data['content'][0]['text']) && is_string($data['content'][0]['text'])) {
if (is_array($data)
&& isset($data['content']) && is_array($data['content'])
&& isset($data['content'][0]) && is_array($data['content'][0])
&& isset($data['content'][0]['text']) && is_string($data['content'][0]['text'])
) {
return $data['content'][0]['text'];
}
return null;
+6 -1
View File
@@ -50,7 +50,12 @@ class OpenAiProvider implements AiProviderInterface
]);
$data = json_decode($response->getBody()->getContents(), true);
if (is_array($data) && isset($data['choices'][0]['message']['content']) && is_string($data['choices'][0]['message']['content'])) {
if (is_array($data)
&& isset($data['choices']) && is_array($data['choices'])
&& isset($data['choices'][0]) && is_array($data['choices'][0])
&& isset($data['choices'][0]['message']) && is_array($data['choices'][0]['message'])
&& isset($data['choices'][0]['message']['content']) && is_string($data['choices'][0]['message']['content'])
) {
return $data['choices'][0]['message']['content'];
}
return null;
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace YakNet\AccessibilityConsole\Audit;
use YakNet\AccessibilityConsole\Core\Scanner;
class SiteCrawler
{
private Scanner $scanner;
public function __construct(?Scanner $scanner = null)
{
$this->scanner = $scanner ?? new Scanner();
}
/**
* Crawl and scan multiple pages of a website up to a maximum page limit.
*
* @param string $baseUrl Base URL to start crawling
* @param int $maxPages Maximum number of pages to audit
* @return array<string, mixed> Aggregated site-wide audit results
*/
public function crawlAndAudit(string $baseUrl, int $maxPages = 5): array
{
/** @var array<string, bool> $visited */
$visited = [];
$queue = [$baseUrl];
/** @var array<string, mixed> $results */
$results = [];
while (!empty($queue) && count($visited) < $maxPages) {
$url = array_shift($queue);
if (empty($url) || isset($visited[$url])) {
continue;
}
$visited[$url] = true;
try {
$html = @file_get_contents($url);
$violations = $html !== false ? $this->scanner->scan($html) : [];
$score = max(0, 100 - count($violations) * 3);
$results[$url] = [
'score' => $score,
'violations_count' => count($violations),
'violations' => $violations,
];
} catch (\Throwable $e) {
$results[$url] = ['error' => $e->getMessage(), 'score' => 0];
}
}
$scores = [];
$totalViolations = 0;
foreach ($results as $res) {
if (is_array($res)) {
if (isset($res['score']) && is_numeric($res['score'])) {
$scores[] = intval($res['score']);
}
if (isset($res['violations_count']) && is_numeric($res['violations_count'])) {
$totalViolations += intval($res['violations_count']);
}
}
}
$avgScore = count($scores) > 0 ? (int)round(array_sum($scores) / count($scores)) : 0;
return [
'base_url' => $baseUrl,
'pages_audited' => count($visited),
'average_score' => $avgScore,
'total_violations' => $totalViolations,
'page_results' => $results,
];
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\AutoFix;
class HtmlRepairEngine
{
/**
* Instantly auto-repair common WCAG accessibility issues in raw HTML strings without network calls.
*
* @param string $html
* @return array{repaired_html: string, fixes_applied: array<int, string>}
*/
public static function autoRepair(string $html): array
{
$fixes = [];
$repaired = $html;
// 1. Ensure <html lang="..."> attribute
if (preg_match('/<html\b(?![^>]*\blang=)[^>]*>/i', $repaired)) {
$repaired = preg_replace('/<html\b([^>]*)>/i', '<html lang="tr"$1>', $repaired) ?? $repaired;
$fixes[] = 'Added missing lang="tr" attribute to <html> tag.';
}
// 2. Add aria-hidden="true" to decorative <svg> tags missing aria-hidden
$svgBefore = $repaired;
$repaired = preg_replace_callback('/<svg\b(?![^>]*\baria-hidden=)[^>]*>/i', function($matches) {
return str_replace('<svg', '<svg aria-hidden="true"', $matches[0]);
}, $repaired) ?? $repaired;
if ($repaired !== $svgBefore) {
$fixes[] = 'Added aria-hidden="true" to decorative <svg> icons.';
}
// 3. Add alt="" to <img> tags missing alt attribute
$imgBefore = $repaired;
$repaired = preg_replace_callback('/<img\b(?![^>]*\balt=)[^>]*>/i', function($matches) {
return str_replace('<img', '<img alt=""', $matches[0]);
}, $repaired) ?? $repaired;
if ($repaired !== $imgBefore) {
$fixes[] = 'Added empty alt="" attribute to decorative <img> elements.';
}
return [
'repaired_html' => $repaired,
'fixes_applied' => $fixes,
];
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace YakNet\AccessibilityConsole\Benchmarks;
use YakNet\AccessibilityConsole\Core\Scanner;
class BenchmarkEngine
{
private Scanner $scanner;
public function __construct(?Scanner $scanner = null)
{
$this->scanner = $scanner ?? new Scanner();
}
/**
* Compare accessibility health scores across multiple target URLs.
*
* @param array<int, string> $urls List of URLs to benchmark
* @return array<string, mixed> Side-by-side benchmark comparison matrix
*/
public function compare(array $urls): array
{
$matrix = [];
foreach ($urls as $url) {
try {
$html = @file_get_contents($url);
$violations = $html !== false ? $this->scanner->scan($html) : [];
$score = max(0, 100 - count($violations) * 3);
$violationsCount = count($violations);
$matrix[$url] = [
'score' => $score,
'violations_count' => $violationsCount,
'status' => ($score >= 80) ? 'PASS' : 'FAIL',
];
} catch (\Throwable $e) {
$matrix[$url] = ['score' => 0, 'violations_count' => -1, 'status' => 'ERROR', 'error' => $e->getMessage()];
}
}
// Rank by score descending
uasort($matrix, function (array $a, array $b) {
return intval($b['score']) <=> intval($a['score']);
});
return [
'benchmark_date' => date('Y-m-d H:i:s'),
'targets_count' => count($urls),
'rankings' => $matrix,
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Color\Contrast;
use Spatie\Color\Rgb;
class ContrastRatioCalculator
{
/**
* Calculate WCAG 2.1 relative contrast ratio between foreground and background colors.
*/
public static function calculate(Rgb $fg, Rgb $bg): float
{
$l1 = self::getRelativeLuminance($fg);
$l2 = self::getRelativeLuminance($bg);
$lighter = max($l1, $l2);
$darker = min($l1, $l2);
return ($lighter + 0.05) / ($darker + 0.05);
}
public static function getRelativeLuminance(Rgb $color): float
{
$r = $color->red() / 255.0;
$g = $color->green() / 255.0;
$b = $color->blue() / 255.0;
$r = ($r <= 0.03928) ? $r / 12.92 : pow(($r + 0.055) / 1.055, 2.4);
$g = ($g <= 0.03928) ? $g / 12.92 : pow(($g + 0.055) / 1.055, 2.4);
$b = ($b <= 0.03928) ? $b / 12.92 : pow(($b + 0.055) / 1.055, 2.4);
return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
}
}
@@ -0,0 +1,28 @@
<?php
namespace YakNet\AccessibilityConsole\Color\Simulators;
use Spatie\Color\Rgb;
class DeuteranopiaSimulator
{
/**
* Simulate Deuteranopia (green-blindness) vision deficiency for a given RGB color.
*/
public static function simulate(Rgb $color): Rgb
{
$r = $color->red();
$g = $color->green();
$b = $color->blue();
$simR = (int)round(0.625 * $r + 0.375 * $g);
$simG = (int)round(0.700 * $r + 0.300 * $g);
$simB = (int)round(0.000 * $r + 0.300 * $g + 0.700 * $b);
return new Rgb(
min(255, max(0, $simR)),
min(255, max(0, $simG)),
min(255, max(0, $simB))
);
}
}
@@ -0,0 +1,28 @@
<?php
namespace YakNet\AccessibilityConsole\Color\Simulators;
use Spatie\Color\Rgb;
class ProtanopiaSimulator
{
/**
* Simulate Protanopia (red-blindness) vision deficiency for a given RGB color.
*/
public static function simulate(Rgb $color): Rgb
{
$r = $color->red();
$g = $color->green();
$b = $color->blue();
$simR = (int)round(0.56667 * $r + 0.43333 * $g);
$simG = (int)round(0.55833 * $r + 0.44167 * $g);
$simB = (int)round(0.00000 * $r + 0.24167 * $g + 0.75833 * $b);
return new Rgb(
min(255, max(0, $simR)),
min(255, max(0, $simG)),
min(255, max(0, $simB))
);
}
}
@@ -0,0 +1,28 @@
<?php
namespace YakNet\AccessibilityConsole\Color\Simulators;
use Spatie\Color\Rgb;
class TritanopiaSimulator
{
/**
* Simulate Tritanopia (blue-blindness) vision deficiency for a given RGB color.
*/
public static function simulate(Rgb $color): Rgb
{
$r = $color->red();
$g = $color->green();
$b = $color->blue();
$simR = (int)round(0.95000 * $r + 0.05000 * $g);
$simG = (int)round(0.00000 * $r + 0.43333 * $g + 0.56667 * $b);
$simB = (int)round(0.00000 * $r + 0.47500 * $g + 0.52500 * $b);
return new Rgb(
min(255, max(0, $simR)),
min(255, max(0, $simG)),
min(255, max(0, $simB))
);
}
}
-3
View File
@@ -302,9 +302,6 @@ class ScanCommand extends Command
while (!empty($queue) && count($visited) < $maxPages) {
$current = array_shift($queue);
if (!is_array($current)) {
continue;
}
$url = $current['url'];
$depth = (int)$current['depth'];
+6 -1
View File
@@ -24,7 +24,12 @@ class BaselineManager
}
foreach ($data['violations'] as $item) {
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])) {
if (!is_array($item)) {
continue;
}
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])
&& is_string($item['file']) && is_string($item['ruleId']) && is_string($item['htmlSnippet'])
) {
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
$this->ignoredKeys[$key] = true;
}
+7 -5
View File
@@ -35,6 +35,7 @@ class Config
try {
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
if (is_array($content)) {
/** @var array<string, mixed> $content */
return new self($content);
}
} catch (\Throwable $e) {
@@ -56,7 +57,7 @@ class Config
{
$paths = $this->get('paths');
if (is_array($paths)) {
return $paths;
return array_values(array_filter($paths, 'is_string'));
}
if (is_string($paths)) {
return [$paths];
@@ -71,7 +72,7 @@ class Config
{
$exclude = $this->get('exclude_paths');
if (is_array($exclude)) {
return $exclude;
return array_values(array_filter($exclude, 'is_string'));
}
if (is_string($exclude)) {
return [$exclude];
@@ -108,10 +109,10 @@ class Config
if (is_array($rules)) {
if (isset($rules['include']) && is_array($rules['include'])) {
$include = $rules['include'];
$include = array_values(array_filter($rules['include'], 'is_string'));
}
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
$exclude = $rules['exclude'];
$exclude = array_values(array_filter($rules['exclude'], 'is_string'));
}
}
@@ -123,6 +124,7 @@ class Config
public function getGeminiApiKey(): ?string
{
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
$key = $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
return is_string($key) ? $key : null;
}
}
@@ -0,0 +1,32 @@
<?php
namespace YakNet\AccessibilityConsole\Integrations;
class GitHubActionsReporter
{
/**
* Format WCAG violations as GitHub Actions Workflow Command Annotations.
*
* @param array<string, mixed> $scanResult
* @param string $fileName
* @return string Formatted annotations string
*/
public static function formatAnnotations(array $scanResult, string $fileName = 'index.html'): string
{
/** @var array<int, array<string, mixed>> $violations */
$violations = is_array($scanResult['violations'] ?? null) ? $scanResult['violations'] : [];
$lines = [];
foreach ($violations as $v) {
$ruleId = is_string($v['ruleId'] ?? null) ? $v['ruleId'] : 'WCAG';
$desc = is_string($v['description'] ?? null) ? $v['description'] : '';
$line = is_numeric($v['line'] ?? null) ? intval($v['line']) : 1;
$severity = is_string($v['severity'] ?? null) ? strtolower($v['severity']) : 'error';
$level = $severity === 'error' ? 'error' : 'warning';
$lines[] = sprintf('::%s file=%s,line=%d::[%s] %s', $level, $fileName, $line, $ruleId, $desc);
}
return implode("\n", $lines);
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
class ExcelReportExporter
{
/**
* Export WCAG audit results as an Excel (.xlsx) workbook.
*
* @param array<string, mixed> $scanResult
* @param string $targetUrl
* @return string Binary Excel content
*/
public static function exportExcel(array $scanResult, string $targetUrl): string
{
if (!class_exists(Spreadsheet::class)) {
throw new \RuntimeException('To export Excel audit reports, please install phpspreadsheet via: composer require phpoffice/phpspreadsheet');
}
/** @var int $score */
$score = is_numeric($scanResult['score'] ?? null) ? intval($scanResult['score']) : 0;
/** @var array<int, array<string, mixed>> $violations */
$violations = is_array($scanResult['violations'] ?? null) ? $scanResult['violations'] : [];
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Erişilebilirlik Raporu');
// Headers
$sheet->setCellValue('A1', 'YakNet Accessibility Console - WCAG 2.1 Audit Export');
$sheet->mergeCells('A1:F1');
$sheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
$sheet->getStyle('A1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->setCellValue('A3', 'Hedef URL / Dizin:');
$sheet->setCellValue('B3', $targetUrl);
$sheet->setCellValue('A4', 'WCAG Skoru:');
$sheet->setCellValue('B4', $score . ' / 100');
$sheet->setCellValue('A5', 'İhlal Sayısı:');
$sheet->setCellValue('B5', count($violations));
// Table Header
$row = 7;
$headers = ['#', 'Kural ID', 'Standart', 'Ciddiyet', 'İhlal Açıklaması', 'Kod Snippet'];
$cols = ['A', 'B', 'C', 'D', 'E', 'F'];
foreach ($headers as $i => $h) {
$col = $cols[$i];
$sheet->setCellValue("{$col}{$row}", $h);
$sheet->getStyle("{$col}{$row}")->getFont()->setBold(true);
$sheet->getStyle("{$col}{$row}")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB('1E293B');
$sheet->getStyle("{$col}{$row}")->getFont()->getColor()->setRGB('FFFFFF');
}
// Table Rows
foreach ($violations as $idx => $v) {
/** @var array<string, mixed> $v */
$r = $row + 1 + intval($idx);
$ruleId = is_string($v['ruleId'] ?? null) ? $v['ruleId'] : '';
$standard = is_string($v['standard'] ?? null) ? $v['standard'] : 'WCAG 2.1';
$severity = is_string($v['severity'] ?? null) ? $v['severity'] : 'WARNING';
$desc = is_string($v['description'] ?? null) ? $v['description'] : '';
$snippet = is_string($v['htmlSnippet'] ?? null) ? $v['htmlSnippet'] : '';
$sheet->setCellValue("A{$r}", intval($idx) + 1);
$sheet->setCellValue("B{$r}", $ruleId);
$sheet->setCellValue("C{$r}", $standard);
$sheet->setCellValue("D{$r}", $severity);
$sheet->setCellValue("E{$r}", $desc);
$sheet->setCellValue("F{$r}", $snippet);
}
// Auto-fit columns
foreach ($cols as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
$writer = new Xlsx($spreadsheet);
ob_start();
$writer->save('php://output');
return strval(ob_get_clean());
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use Dompdf\Dompdf;
use Dompdf\Options;
class PdfReportGenerator
{
/**
* Generate a PDF Accessibility Certificate & Audit Report.
*
* @param array<string, mixed> $scanResult Scan result metrics and violations array
* @param string $targetUrl Target URL or file name audited
* @return string Raw PDF binary data
*/
public static function generatePdf(array $scanResult, string $targetUrl): string
{
if (!class_exists(Dompdf::class)) {
throw new \RuntimeException('To generate PDF certificates, please install dompdf via: composer require dompdf/dompdf');
}
date_default_timezone_set('Europe/Istanbul');
/** @var int $score */
$score = is_numeric($scanResult['score'] ?? null) ? intval($scanResult['score']) : 0;
/** @var array<int, array<string, mixed>> $violations */
$violations = is_array($scanResult['violations'] ?? null) ? $scanResult['violations'] : [];
$violationsCount = count($violations);
$dateStr = date('d.m.Y H:i');
$scoreColor = $score >= 80 ? '#10b981' : ($score >= 50 ? '#f59e0b' : '#ef4444');
$statusText = $score >= 80 ? 'WCAG 2.1 AA Uyumlu (Pass)' : ($score >= 50 ? 'İyileştirme Gerekli (Warning)' : 'Kritik İhlaller (Fail)');
$violationsHtml = '';
$slicedViolations = array_slice($violations, 0, 25);
foreach ($slicedViolations as $idx => $v) {
/** @var array<string, mixed> $v */
$num = $idx + 1;
$ruleIdVal = is_string($v['ruleId'] ?? null) ? $v['ruleId'] : '';
$descVal = is_string($v['description'] ?? null) ? $v['description'] : '';
$snippetVal = is_string($v['htmlSnippet'] ?? null) ? $v['htmlSnippet'] : '';
$suggestionVal = is_string($v['suggestion'] ?? null) ? $v['suggestion'] : '';
$ruleId = htmlspecialchars($ruleIdVal);
$desc = htmlspecialchars($descVal);
$snippet = htmlspecialchars($snippetVal);
$suggestion = htmlspecialchars($suggestionVal);
$violationsHtml .= "
<div style='margin-bottom:12px; padding:10px; border-left:4px solid #ef4444; background:#f8fafc;'>
<div style='font-weight:bold; color:#0f172a; font-size:12px;'>#{$num} [{$ruleId}] {$desc}</div>
<div style='font-family:monospace; font-size:10px; color:#e11d48; margin:4px 0; background:#f1f5f9; padding:4px;'>{$snippet}</div>
<div style='font-size:11px; color:#475569;'><b>Öneri:</b> {$suggestion}</div>
</div>";
}
$html = "
<!DOCTYPE html>
<html>
<head>
<meta http-equiv='Content-Type' content='text/html; charset=utf-8'/>
<title>WCAG 2.1 Erişilebilirlik Sertifikası ve Raporu</title>
<style>
* { font-family: 'DejaVu Sans', sans-serif; }
body { font-family: 'DejaVu Sans', sans-serif; color: #1e293b; line-height: 1.5; font-size: 11px; margin: 0; padding: 20px; }
.header { text-align: center; border-bottom: 2px solid #38bdf8; padding-bottom: 15px; margin-bottom: 20px; }
.title { font-size: 18px; font-weight: bold; color: #0f172a; }
.subtitle { font-size: 12px; color: #64748b; margin-top: 4px; }
.badge { display: inline-block; padding: 6px 16px; border-radius: 12px; color: #fff; font-weight: bold; font-size: 13px; background: {$scoreColor}; }
.grid { margin-bottom: 20px; }
.card { background: #f8fafc; border: 1px solid #e2e8f0; padding: 12px; border-radius: 6px; margin-bottom: 10px; }
.footer { text-align: center; border-top: 1px solid #e2e8f0; padding-top: 15px; font-size: 10px; color: #94a3b8; margin-top: 30px; }
</style>
</head>
<body>
<div class='header'>
<div class='title'>♿ YakNet Accessibility Console</div>
<div class='subtitle'>WCAG 2.1 AA Erişilebilirlik Uyumluluk Raporu</div>
</div>
<div class='card' style='text-align:center;'>
<div style='font-size:11px; color:#64748b; text-transform:uppercase;'>Erişilebilirlik Sağlık Skoru</div>
<div style='font-size:32px; font-weight:bold; color:{$scoreColor}; margin:6px 0;'>{$score} / 100</div>
<div class='badge'>{$statusText}</div>
</div>
<div class='card'>
<div><b>Hedef URL / Dizin:</b> " . htmlspecialchars($targetUrl) . "</div>
<div><b>Tarama Tarihi:</b> {$dateStr}</div>
<div><b>Tespit Edilen İhlal Sayısı:</b> {$violationsCount} Hata</div>
<div><b>Raporu Üreten Motor:</b> YakNet AI & Accessibility Engine v3.1</div>
</div>
<h3 style='color:#0f172a; border-bottom:1px solid #cbd5e1; padding-bottom:6px;'>📋 Erişilebilirlik İhlalleri ve Çözüm Detayları</h3>
{$violationsHtml}
<div class='footer'>
© {$dateStr} YakNet Bilişim — Türkiye'nin Yerli & Milli Erişilebilirlik Platformu (yak.net.tr)
</div>
</body>
</html>";
$options = new Options();
$options->set('isHtml5ParserEnabled', true);
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
return strval($dompdf->output());
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AnchorMissingHref extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ANCHOR_HREF'; }
public function getDescription(): string { return 'Anchor elements (<a>) acting as links must have a valid href attribute.'; }
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) !== 'a') {
return null;
}
if ($element->hasAttribute('name') || $element->hasAttribute('role')) {
return null;
}
if (!$element->hasAttribute('href') || trim($element->getAttribute('href')) === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a valid href attribute to the <a> element, or use a <button> element if it triggers an action.'
);
}
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AppletBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_APPLET_BANNED'; }
public function getDescription(): string { return 'The <applet> element is obsolete and non-accessible. Use modern HTML5 elements instead.'; }
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) === 'applet') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the <applet> element and replace it with modern HTML5 or accessible media embeddings.'
);
}
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AreaAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_AreaAlt'; }
public function getDescription(): string { return 'Checks that <area> elements in image maps have non-empty alt attributes.'; }
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) !== 'area') {
return null;
}
$alt = $element->getAttribute('alt');
$ariaLabel = $element->getAttribute('aria-label');
if (empty(trim($alt)) && empty(trim($ariaLabel))) {
return $this->createViolation(
$element,
'<area> öğesinde alt veya aria-label eksik.',
'Provide a non-empty alt or aria-label attribute for the <area> element.'
);
}
return null;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaExpandedState extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_EXPANDED'; }
public function getDescription(): string { return 'Collapsible toggle buttons (accordion, dropdown, collapse) must have an aria-expanded attribute.'; }
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
{
$tagName = strtolower($element->tagName);
$role = strtolower($element->getAttribute('role'));
if ($tagName !== 'button' && $tagName !== 'a' && $role !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$dataBsToggle = strtolower($element->getAttribute('data-bs-toggle'));
$dataToggle = strtolower($element->getAttribute('data-toggle'));
$isCollapsible = str_contains($class, 'dropdown-toggle') ||
str_contains($class, 'accordion-button') ||
str_contains($class, 'collapse-toggle') ||
in_array($dataBsToggle, ['collapse', 'dropdown', 'offcanvas'], true) ||
in_array($dataToggle, ['collapse', 'dropdown'], true);
if (!$isCollapsible) {
return null;
}
if ($element->hasAttribute('aria-expanded')) {
$val = strtolower($element->getAttribute('aria-expanded'));
if (in_array($val, ['true', 'false'], true)) {
return null;
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-expanded="false" (or "true") to the toggle element so screen readers announce expanded/collapsed status.'
);
}
}
+35
View File
@@ -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 AriaHiddenBody extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_HIDDEN_BODY'; }
public function getDescription(): string { return 'The <body> or <html> element must not have aria-hidden="true".'; }
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
{
$tagName = strtolower($element->tagName);
if ($tagName !== 'body' && $tagName !== 'html') {
return null;
}
if (strtolower($element->getAttribute('aria-hidden')) === 'true') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove aria-hidden="true" from the <body>/<html> tag to prevent hiding the entire document from screen readers.'
);
}
return null;
}
}
+12
View File
@@ -57,6 +57,18 @@ class AriaHiddenFocusable extends AbstractRule
return $type !== 'hidden' && !$element->hasAttribute('disabled');
}
if ($element->hasAttribute('contenteditable') && strtolower($element->getAttribute('contenteditable')) === 'true') {
return true;
}
if (in_array($tagName, ['details', 'summary'], true)) {
return true;
}
if (in_array($tagName, ['audio', 'video'], true) && $element->hasAttribute('controls')) {
return true;
}
if ($tagName === 'iframe') {
return true;
}
+55
View File
@@ -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 AriaLiveStatus extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_3_STATUS_MESSAGES'; }
public function getDescription(): string { return 'Dynamic status containers and toast messages must use aria-live or implicit live roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
$class = strtolower($element->getAttribute('class'));
$role = strtolower($element->getAttribute('role'));
$statusClasses = ['toast', 'status-message', 'loading-message', 'alert-message', 'live-region'];
$isStatusContainer = false;
foreach ($statusClasses as $statusClass) {
if (str_contains($class, $statusClass)) {
$isStatusContainer = true;
break;
}
}
if (!$isStatusContainer) {
return null;
}
// Implicit live roles in ARIA include status, alert, log
if (in_array($role, ['status', 'alert', 'log'], true)) {
return null;
}
if ($element->hasAttribute('aria-live')) {
$liveVal = strtolower($element->getAttribute('aria-live'));
if (in_array($liveVal, ['polite', 'assertive'], true)) {
return null;
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-live="polite" (or role="status"/role="alert") so screen readers dynamically announce updates to users.'
);
}
}
+49
View File
@@ -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 AriaPressedToggle extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_TOGGLE_BUTTON_STATE'; }
public function getDescription(): string { return 'Toggle buttons must use aria-pressed="true" or "false" to communicate state changes.'; }
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
{
$tagName = strtolower($element->tagName);
$role = strtolower($element->getAttribute('role'));
if ($tagName !== 'button' && $role !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$dataToggle = strtolower($element->getAttribute('data-toggle'));
$isToggleButton = str_contains($class, 'btn-toggle') || str_contains($class, 'toggle-btn') || $dataToggle === 'button';
if (!$isToggleButton) {
return null;
}
if ($element->hasAttribute('aria-pressed')) {
$val = strtolower($element->getAttribute('aria-pressed'));
if (in_array($val, ['true', 'false', 'mixed'], true)) {
return null;
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-pressed="true" or aria-pressed="false" to indicate the toggle state to assistive technology.'
);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredAttr extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_REQUIRED_ATTR'; }
public function getDescription(): string { return 'Elements with ARIA roles must have all required ARIA attributes for that role.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredMap = [
'slider' => ['aria-valuenow', 'aria-valuemin', 'aria-valuemax'],
'checkbox' => ['aria-checked'],
'combobox' => ['aria-expanded'],
'scrollbar' => ['aria-controls', 'aria-valuenow', 'aria-valuemax', 'aria-valuemin', 'aria-orientation'],
'spinbutton' => ['aria-valuenow', 'aria-valuemax', 'aria-valuemin'],
'switch' => ['aria-checked'],
'meter' => ['aria-valuenow'],
'separator' => ['aria-valuenow'] // strictly when focusable, but we check if the role is present
];
foreach ($roles as $role) {
if (isset($requiredMap[$role])) {
$missingAttrs = [];
foreach ($requiredMap[$role] as $reqAttr) {
if (!$element->hasAttribute($reqAttr)) {
$missingAttrs[] = $reqAttr;
}
}
if (!empty($missingAttrs)) {
// Specific case: separator without tabindex is not focusable and doesn't require aria-valuenow in some contexts
if ($role === 'separator' && !$element->hasAttribute('tabindex')) {
continue;
}
$missingStr = implode(', ', $missingAttrs);
return $this->createViolation(
$element,
"ARIA role '{$role}' eksik zorunlu niteliklere sahip: {$missingStr}.",
"Belirtilen ARIA rolü için zorunlu olan {$missingStr} niteliklerini ekleyin."
);
}
}
}
return null;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredChildren extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_CHILDREN'; }
public function getDescription(): string { return 'Elements with certain ARIA roles must contain children with specific roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredChildrenMap = [
'list' => ['listitem'],
'menu' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
'menubar' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
'tablist' => ['tab'],
'tree' => ['treeitem'],
'grid' => ['row', 'rowgroup'],
'table' => ['row', 'rowgroup'],
'radiogroup' => ['radio'],
'listbox' => ['option'],
'rowgroup' => ['row'],
'row' => ['cell', 'gridcell', 'columnheader', 'rowheader']
];
foreach ($roles as $role) {
if (isset($requiredChildrenMap[$role])) {
$hasRequiredChild = $this->hasChildWithRole($element, $requiredChildrenMap[$role]);
if (!$hasRequiredChild) {
// Check if children are dynamically generated with templates
$doc = $element->ownerDocument;
if ($doc !== null) {
$html = $doc->saveHTML($element);
if ($html !== false && preg_match('/(\{\{|\{%|<\?)/', $html)) {
continue;
}
}
$expectedStr = implode(' veya ', $requiredChildrenMap[$role]);
return $this->createViolation(
$element,
"ARIA rolü '{$role}' olan eleman gerekli alt rollere sahip değil.",
"Bu elemanın içine rolü {$expectedStr} olan çocuk elemanlar ekleyin."
);
}
}
}
return null;
}
/**
* @param array<int, string> $requiredRoles
*/
private function hasChildWithRole(DOMElement $element, array $requiredRoles): bool
{
$doc = $element->ownerDocument;
if ($doc === null) {
return false;
}
$xpath = new \DOMXPath($doc);
foreach ($requiredRoles as $reqRole) {
$query = ".//*[@role='{$reqRole}' or contains(@role, ' {$reqRole}') or contains(@role, '{$reqRole} ')]";
$nodes = $xpath->query($query, $element);
if ($nodes !== false && $nodes->length > 0) {
return true;
}
}
return false;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_PARENT'; }
public function getDescription(): string { return 'Elements with certain ARIA roles must be contained by parents with specific roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredParentMap = [
'listitem' => ['list', 'group'],
'menuitem' => ['menu', 'menubar', 'group'],
'menuitemcheckbox' => ['menu', 'menubar'],
'menuitemradio' => ['menu', 'menubar'],
'tab' => ['tablist'],
'treeitem' => ['tree', 'group'],
'cell' => ['row'],
'gridcell' => ['row'],
'columnheader' => ['row'],
'rowheader' => ['row'],
'option' => ['listbox', 'group'],
'row' => ['grid', 'table', 'treegrid', 'rowgroup']
];
foreach ($roles as $role) {
if (isset($requiredParentMap[$role])) {
$hasRequiredParent = $this->hasParentWithRole($element, $requiredParentMap[$role]);
if (!$hasRequiredParent) {
$expectedStr = implode(' veya ', $requiredParentMap[$role]);
return $this->createViolation(
$element,
"ARIA rolü '{$role}' olan eleman gerekli üst eleman rollerinden hiçbirine sahip değil.",
"Bu elemanı rolü {$expectedStr} olan bir üst elemanın içine yerleştirin."
);
}
}
}
return null;
}
/**
* @param array<int, string> $requiredRoles
*/
private function hasParentWithRole(DOMElement $element, array $requiredRoles): bool
{
$parent = $element->parentNode;
while ($parent !== null && $parent instanceof DOMElement) {
if ($parent->hasAttribute('role')) {
$parentRoles = explode(' ', strtolower(trim($parent->getAttribute('role'))));
foreach ($parentRoles as $pRole) {
if (in_array($pRole, $requiredRoles)) {
return true;
}
}
}
// For native HTML semantics fallback (e.g. <ul> is a list)
$tagName = strtolower($parent->tagName);
$nativeRole = $this->getNativeRole($tagName);
if ($nativeRole && in_array($nativeRole, $requiredRoles)) {
return true;
}
$parent = $parent->parentNode;
}
return false;
}
private function getNativeRole(string $tagName): ?string
{
$map = [
'ul' => 'list',
'ol' => 'list',
'dl' => 'list',
'table' => 'table',
'tr' => 'row',
'tbody' => 'rowgroup',
'thead' => 'rowgroup',
'tfoot' => 'rowgroup',
'select' => 'listbox',
'optgroup' => 'group'
];
return $map[$tagName] ?? null;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaValidAttr extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR'; }
public function getDescription(): string { return 'ARIA attributes must be valid names.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$validAttributes = [
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel',
'aria-brailleroledescription', 'aria-busy', 'aria-checked', 'aria-colcount',
'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-controls',
'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-expanded',
'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live',
'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation',
'aria-owns', 'aria-placeholder', 'aria-posinset', 'aria-pressed', 'aria-readonly',
'aria-relevant', 'aria-required', 'aria-roledescription', 'aria-rowcount',
'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected',
'aria-setsize', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow',
'aria-valuetext'
];
$invalidAttrs = [];
foreach ($element->attributes as $attr) {
/** @var \DOMAttr $attr */
$name = strtolower($attr->nodeName);
if (strpos($name, 'aria-') === 0) {
if (!in_array($name, $validAttributes)) {
$invalidAttrs[] = $name;
}
}
}
if (!empty($invalidAttrs)) {
$invalidStr = implode(', ', $invalidAttrs);
return $this->createViolation(
$element,
"Geçersiz ARIA nitelikleri bulundu: {$invalidStr}.",
"Sadece geçerli ARIA niteliklerini kullanın. Geçersiz olanları kaldırın veya düzeltin."
);
}
return null;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaValidAttrValue extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR_VALUE'; }
public function getDescription(): string { return 'ARIA attributes must contain valid values.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$validationMap = [
'aria-hidden' => ['true', 'false'],
'aria-live' => ['off', 'polite', 'assertive'],
'aria-expanded' => ['true', 'false', 'undefined'],
'aria-pressed' => ['true', 'false', 'mixed', 'undefined'],
'aria-checked' => ['true', 'false', 'mixed', 'undefined'],
'aria-current' => ['page', 'step', 'location', 'date', 'time', 'true', 'false'],
'aria-sort' => ['ascending', 'descending', 'none', 'other'],
'aria-orientation' => ['horizontal', 'vertical', 'undefined'],
'aria-haspopup' => ['true', 'false', 'menu', 'listbox', 'tree', 'grid', 'dialog'],
'aria-invalid' => ['grammar', 'false', 'spelling', 'true'],
'aria-autocomplete' => ['inline', 'list', 'both', 'none'],
'aria-dropeffect' => ['copy', 'execute', 'link', 'move', 'none', 'popup']
];
for ($i = 0; $i < $element->attributes->length; $i++) {
$attr = $element->attributes->item($i);
if (!$attr instanceof \DOMAttr) {
continue;
}
$name = strtolower($attr->nodeName);
if (isset($validationMap[$name])) {
$value = strtolower(trim($attr->nodeValue ?? ''));
// Handle templates like {{ value }} or template syntax
if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $value) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $value)) {
continue;
}
if (!in_array($value, $validationMap[$name])) {
$validStr = implode(', ', $validationMap[$name]);
return $this->createViolation(
$element,
"{$name} niteliği geçersiz bir değere sahip: '{$value}'.",
"{$name} niteliğinin değerini geçerli olanlardan biriyle değiştirin: {$validStr}."
);
}
} elseif ($name === 'aria-relevant') {
$values = explode(' ', strtolower(trim($attr->nodeValue ?? '')));
$validRelevant = ['additions', 'removals', 'text', 'all'];
$rawValue = $attr->nodeValue ?? '';
$isTemplate = preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', trim($rawValue)) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', trim($rawValue));
if (!$isTemplate) {
foreach ($values as $val) {
if (!empty($val) && !in_array($val, $validRelevant)) {
return $this->createViolation(
$element,
"aria-relevant niteliği geçersiz bir değer içeriyor: '{$val}'.",
"aria-relevant değerlerini sadece additions, removals, text, all kelimelerinden oluşacak şekilde ayarlayın."
);
}
}
}
}
}
return null;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AudioTrack extends AbstractRule
{
public function getId(): string { return 'WCAG_1_2_1_AudioTrack'; }
public function getDescription(): string { return 'Checks that <audio> elements have a <track> child element for captions/descriptions.'; }
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 (strtolower($element->tagName) !== 'audio') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$tracks = $xpath->query('./track', $element);
if ($tracks === false || $tracks->length === 0) {
return $this->createViolation(
$element,
'<audio> öğesi <track> alt öğesi içermiyor.',
'Add a <track> element for captions or provide an accessible transcript.'
);
}
return null;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutocompleteValidTokens extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_AUTOCOMPLETE_VALID_TOKENS'; }
public function getDescription(): string { return 'Autocomplete attribute must use valid token values.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['input', 'select', 'textarea'])) {
return null;
}
if (!$element->hasAttribute('autocomplete')) {
return null;
}
$autocompleteValue = trim($element->getAttribute('autocomplete'));
if ($autocompleteValue === '') {
return null;
}
// Handle templates like {{ value }} or template syntax
if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $autocompleteValue) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $autocompleteValue)) {
return null;
}
$tokens = explode(' ', strtolower($autocompleteValue));
$validBaseTokens = [
'off', 'on', 'name', 'honorific-prefix', 'given-name', 'additional-name',
'family-name', 'honorific-suffix', 'nickname', 'email', 'username',
'new-password', 'current-password', 'one-time-code', 'organization-title',
'organization', 'street-address', 'address-line1', 'address-line2',
'address-line3', 'address-level4', 'address-level3', 'address-level2',
'address-level1', 'country', 'country-name', 'postal-code', 'cc-name',
'cc-given-name', 'cc-additional-name', 'cc-family-name', 'cc-number',
'cc-exp', 'cc-exp-month', 'cc-exp-year', 'cc-csc', 'cc-type',
'transaction-currency', 'transaction-amount', 'language', 'bday',
'bday-day', 'bday-month', 'bday-year', 'sex', 'tel', 'tel-country-code',
'tel-national', 'tel-area-code', 'tel-local', 'tel-extension', 'impp',
'url', 'photo'
];
foreach ($tokens as $token) {
if (empty($token)) continue;
// Allow section-* prefixes
if (strpos($token, 'section-') === 0) {
continue;
}
// Allow shipping/billing prefixes
if ($token === 'shipping' || $token === 'billing') {
continue;
}
// Check for valid base tokens
// Additional check: some tokens like 'tel' can be prefixed, we handle it simply by checking if it's in the list
$isValid = false;
foreach ($validBaseTokens as $baseToken) {
if ($token === $baseToken || str_ends_with($token, "-{$baseToken}")) {
$isValid = true;
break;
}
}
if (!$isValid) {
return $this->createViolation(
$element,
"Geçersiz autocomplete değeri veya belirteci: '{$token}'.",
"Geçerli bir HTML5 autocomplete belirteci kullanın (örneğin: name, email, tel)."
);
}
}
return null;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ButtonLoadingState extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_BUTTON_LOADING_STATE'; }
public function getDescription(): string { return 'Buttons displaying loading spinners must inform screen readers via aria-busy or aria-label.'; }
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
{
$tagName = strtolower($element->tagName);
$role = strtolower($element->getAttribute('role'));
if ($tagName !== 'button' && $role !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$hasLoadingClass = str_contains($class, 'loading') || str_contains($class, 'spinner') || str_contains($class, 'is-busy');
$hasSpinnerChild = false;
foreach ($element->getElementsByTagName('*') as $child) {
$childClass = strtolower($child->getAttribute('class'));
if (str_contains($childClass, 'spinner') || str_contains($childClass, 'loading') || str_contains($childClass, 'loader')) {
$hasSpinnerChild = true;
break;
}
}
if (!$hasLoadingClass && !$hasSpinnerChild && strtolower($element->getAttribute('aria-busy')) !== 'true') {
return null;
}
// Must have aria-busy="true" or an explicit aria-label/aria-live describing the loading process
$hasAriaBusy = strtolower($element->getAttribute('aria-busy')) === 'true';
$hasAriaLive = $element->hasAttribute('aria-live');
$hasAriaLabel = $element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '';
if ($hasAriaBusy || ($hasAriaLabel && $hasAriaLive)) {
return null;
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add aria-busy="true" and an aria-label (e.g. aria-label="Loading...") to the button when an async action is in progress.'
);
}
}
+56 -3
View File
@@ -27,10 +27,63 @@ class ButtonName extends AbstractRule
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
if ($text !== '') {
return null;
}
return null;
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
if ($element->hasAttribute('aria-labelledby')) {
$id = $element->getAttribute('aria-labelledby');
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//*[@id='$id']");
if ($labels !== false && $labels->length > 0) {
$labelNode = $labels->item(0);
if ($labelNode instanceof \DOMNode && trim($labelNode->textContent) !== '') {
return null;
}
}
}
}
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
}
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class CanvasAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_CanvasAlt'; }
public function getDescription(): string { return 'Checks that <canvas> elements have fallback content or aria labels.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'canvas') {
return null;
}
$hasAriaLabel = !empty($element->getAttribute('aria-label'));
$hasAriaLabelledby = !empty($element->getAttribute('aria-labelledby'));
$hasRoleImgLabel = ($element->getAttribute('role') === 'img') && $hasAriaLabel;
if ($hasAriaLabel || $hasAriaLabelledby || $hasRoleImgLabel) {
return null;
}
$content = trim($element->textContent);
$hasChildElements = false;
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$hasChildElements = true;
break;
}
}
if ($content === '' && !$hasChildElements) {
return $this->createViolation(
$element,
'<canvas> öğesi alternatif içerik veya etiket içermiyor.',
'Provide fallback content inside the <canvas> tag or use aria-label/aria-labelledby.'
);
}
return null;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ClickHandlerKeyboard extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_CLICK_KEYBOARD'; }
public function getDescription(): string { return 'Non-interactive elements with click handlers should also have keyboard handlers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('onclick')) {
return null;
}
$tagName = strtolower($element->tagName);
$interactiveElements = ['a', 'button', 'input', 'select', 'textarea', 'summary', 'details'];
if (in_array($tagName, $interactiveElements)) {
return null;
}
$role = strtolower($element->getAttribute('role'));
if (($role === 'button' || $role === 'link') && $element->hasAttribute('tabindex')) {
return null;
}
if ($element->hasAttribute('onkeydown') || $element->hasAttribute('onkeyup') || $element->hasAttribute('onkeypress')) {
return null;
}
return $this->createViolation(
$element,
'onclick olay işleyicisine sahip etkileşimli olmayan öğenin klavye işleyicisi (onkeydown vb.) eksik. / Non-interactive element with onclick is missing a keyboard handler.',
'Klavye desteği için onkeydown/onkeyup ekleyin ve tabindex="0" tanımlayın. / Add onkeydown/onkeyup handlers and tabindex="0" for keyboard support.'
);
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use Spatie\Color\Hex;
use YakNet\AccessibilityConsole\Color\Contrast\ContrastRatioCalculator;
use YakNet\AccessibilityConsole\Color\Simulators\DeuteranopiaSimulator;
use YakNet\AccessibilityConsole\Color\Simulators\ProtanopiaSimulator;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ColorBlindnessContrast extends AbstractRule
{
public function getId(): string { return 'WCAG_1_4_1_COLOR_BLINDNESS_CONTRAST'; }
public function getDescription(): string { return 'Text and background color contrast must remain accessible (minimum 4.5:1) under color vision deficiency simulations (Deuteranopia, Protanopia).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('style')) {
return null;
}
$style = $element->getAttribute('style');
$colorHex = $this->extractColorFromStyle($style, 'color');
$bgHex = $this->extractColorFromStyle($style, 'background-color') ?? $this->extractColorFromStyle($style, 'background');
if (!$colorHex || !$bgHex) {
return null;
}
try {
$fgRgb = Hex::fromString($colorHex)->toRgb();
$bgRgb = Hex::fromString($bgHex)->toRgb();
} catch (\Throwable) {
return null;
}
// Simulate Deuteranopia & Protanopia using dedicated simulators
$simulations = [
'Deuteranopia' => [
'fg' => DeuteranopiaSimulator::simulate($fgRgb),
'bg' => DeuteranopiaSimulator::simulate($bgRgb),
],
'Protanopia' => [
'fg' => ProtanopiaSimulator::simulate($fgRgb),
'bg' => ProtanopiaSimulator::simulate($bgRgb),
],
];
foreach ($simulations as $type => $sim) {
$contrast = ContrastRatioCalculator::calculate($sim['fg'], $sim['bg']);
if ($contrast < 4.5) {
return $this->createViolation(
$element,
sprintf('Insufficient color contrast (%.2f:1) under %s color blindness simulation (minimum 4.5:1 required).', $contrast, $type),
sprintf('Adjust foreground color (%s) or background color (%s) to increase contrast for users with %s.', $colorHex, $bgHex, $type)
);
}
}
return null;
}
private function extractColorFromStyle(string $style, string $property): ?string
{
if (preg_match('/' . preg_quote($property, '/') . '\s*:\s*(#[0-9a-fA-F]{3,6})/i', $style, $matches)) {
$hex = $matches[1];
if (strlen($hex) === 4) {
$hex = '#' . $hex[1] . $hex[1] . $hex[2] . $hex[2] . $hex[3] . $hex[3];
}
return $hex;
}
return null;
}
}
+50 -3
View File
@@ -249,9 +249,56 @@ class ColorContrast extends AbstractRule
}
$colors = [
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
'aliceblue' => '#f0f8ff', 'antiquewhite' => '#faebd7', 'aqua' => '#00ffff',
'aquamarine' => '#7fffd4', 'azure' => '#f0ffff', 'beige' => '#f5f5dc',
'bisque' => '#ffe4c4', 'black' => '#000000', 'blanchedalmond' => '#ffebcd',
'blue' => '#0000ff', 'blueviolet' => '#8a2be2', 'brown' => '#a52a2a',
'burlywood' => '#deb887', 'cadetblue' => '#5f9ea0', 'chartreuse' => '#7fff00',
'chocolate' => '#d2691e', 'coral' => '#ff7f50', 'cornflowerblue' => '#6495ed',
'cornsilk' => '#fff8dc', 'crimson' => '#dc143c', 'cyan' => '#00ffff',
'darkblue' => '#00008b', 'darkcyan' => '#008b8b', 'darkgoldenrod' => '#b8860b',
'darkgray' => '#a9a9a9', 'darkgreen' => '#006400', 'darkgrey' => '#a9a9a9',
'darkkhaki' => '#bdb76b', 'darkmagenta' => '#8b008b', 'darkolivegreen' => '#556b2f',
'darkorange' => '#ff8c00', 'darkorchid' => '#9932cc', 'darkred' => '#8b0000',
'darksalmon' => '#e9967a', 'darkseagreen' => '#8fbc8f', 'darkslateblue' => '#483d8b',
'darkslategray' => '#2f4f4f', 'darkslategrey' => '#2f4f4f', 'darkturquoise' => '#00ced1',
'darkviolet' => '#9400d3', 'deeppink' => '#ff1493', 'deepskyblue' => '#00bfff',
'dimgray' => '#696969', 'dimgrey' => '#696969', 'dodgerblue' => '#1e90ff',
'firebrick' => '#b22222', 'floralwhite' => '#fffaf0', 'forestgreen' => '#228b22',
'fuchsia' => '#ff00ff', 'gainsboro' => '#dcdcdc', 'ghostwhite' => '#f8f8ff',
'gold' => '#ffd700', 'goldenrod' => '#daa520', 'gray' => '#808080',
'green' => '#008000', 'greenyellow' => '#adff2f', 'grey' => '#808080',
'honeydew' => '#f0fff0', 'hotpink' => '#ff69b4', 'indianred' => '#cd5c5c',
'indigo' => '#4b0082', 'ivory' => '#fffff0', 'khaki' => '#f0e68c',
'lavender' => '#e6e6fa', 'lavenderblush' => '#fff0f5', 'lawngreen' => '#7cfc00',
'lemonchiffon' => '#fffacd', 'lightblue' => '#add8e6', 'lightcoral' => '#f08080',
'lightcyan' => '#e0ffff', 'lightgoldenrodyellow' => '#fafad2', 'lightgray' => '#d3d3d3',
'lightgreen' => '#90ee90', 'lightgrey' => '#d3d3d3', 'lightpink' => '#ffb6c1',
'lightsalmon' => '#ffa07a', 'lightseagreen' => '#20b2aa', 'lightskyblue' => '#87cefa',
'lightslategray' => '#778899', 'lightslategrey' => '#778899', 'lightsteelblue' => '#b0c4de',
'lightyellow' => '#ffffe0', 'lime' => '#00ff00', 'limegreen' => '#32cd32',
'linen' => '#faf0e6', 'magenta' => '#ff00ff', 'maroon' => '#800000',
'mediumaquamarine' => '#66cdaa', 'mediumblue' => '#0000cd', 'mediumorchid' => '#ba55d3',
'mediumpurple' => '#9370db', 'mediumseagreen' => '#3cb371', 'mediumslateblue' => '#7b68ee',
'mediumspringgreen' => '#00fa9a', 'mediumturquoise' => '#48d1cc', 'mediumvioletred' => '#c71585',
'midnightblue' => '#191970', 'mintcream' => '#f5fffa', 'mistyrose' => '#ffe4e1',
'moccasin' => '#ffe4b5', 'navajowhite' => '#ffdead', 'navy' => '#000080',
'oldlace' => '#fdf5e6', 'olive' => '#808000', 'olivedrab' => '#6b8e23',
'orange' => '#ffa500', 'orangered' => '#ff4500', 'orchid' => '#da70d6',
'palegoldenrod' => '#eee8aa', 'palegreen' => '#98fb98', 'paleturquoise' => '#afeeee',
'palevioletred' => '#db7093', 'papayawhip' => '#ffefd5', 'peachpuff' => '#ffdab9',
'peru' => '#cd853f', 'pink' => '#ffc0cb', 'plum' => '#dda0dd',
'powderblue' => '#b0e0e6', 'purple' => '#800080', 'rebeccapurple' => '#663399',
'red' => '#ff0000', 'rosybrown' => '#bc8f8f', 'royalblue' => '#4169e1',
'saddlebrown' => '#8b4513', 'salmon' => '#fa8072', 'sandybrown' => '#f4a460',
'seagreen' => '#2e8b57', 'seashell' => '#fff5ee', 'sienna' => '#a0522d',
'silver' => '#c0c0c0', 'skyblue' => '#87ceeb', 'slateblue' => '#6a5acd',
'slategray' => '#708090', 'slategrey' => '#708090', 'snow' => '#fffafa',
'springgreen' => '#00ff7f', 'steelblue' => '#4682b4', 'tan' => '#d2b48c',
'teal' => '#008080', 'thistle' => '#d8bfd8', 'tomato' => '#ff6347',
'turquoise' => '#40e0d0', 'violet' => '#ee82ee', 'wheat' => '#f5deb3',
'white' => '#ffffff', 'whitesmoke' => '#f5f5f5', 'yellow' => '#ffff00',
'yellowgreen' => '#9acd32',
];
if (isset($colors[strtolower($val)])) {
return $colors[strtolower($val)];
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ContentInfoParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_CONTENTINFO_PARENT'; }
public function getDescription(): string { return 'Footer contentinfo landmark must be at top-level body or within valid sectioning root.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'footer' && $element->getAttribute('role') !== 'contentinfo') {
return null;
}
$parent = $element->parentNode;
while ($parent instanceof DOMElement) {
$pTag = strtolower($parent->tagName);
if (in_array($pTag, ['article', 'aside', 'main', 'nav', 'section'], true)) {
return $this->createViolation(
$element,
sprintf('Footer contentinfo landmark is nested inside <%s> sectioning element.', $pTag),
'Place global footer contentinfo landmark at the top level of <body> rather than nested inside sectioning elements.'
);
}
$parent = $parent->parentNode;
}
return null;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DefinitionListStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_DefinitionListStructure'; }
public function getDescription(): string { return 'Checks that <dl> elements only contain <dt>, <dd>, and <div> as direct children.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'dl') {
return null;
}
$invalidChildren = [];
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$childTag = strtolower($child->tagName);
if (!in_array($childTag, ['dt', 'dd', 'div'])) {
$invalidChildren[] = $childTag;
}
}
}
if (count($invalidChildren) > 0) {
return $this->createViolation(
$element,
'<dl> öğesi doğrudan alt öğe olarak sadece <dt>, <dd> veya <div> içerebilir.',
'Remove or wrap invalid children (' . implode(', ', array_unique($invalidChildren)) . ') inside <dd> or <dt>.'
);
}
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DialogAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_DIALOG_ACCESSIBILITY'; }
public function getDescription(): string { return 'Dialog elements must have an accessible name.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$role = strtolower($element->getAttribute('role'));
$tagName = strtolower($element->tagName);
if ($role !== 'dialog' && $role !== 'alertdialog' && $tagName !== 'dialog') {
return null;
}
$ariaLabel = trim($element->getAttribute('aria-label'));
$ariaLabelledby = trim($element->getAttribute('aria-labelledby'));
$title = trim($element->getAttribute('title'));
if (!empty($ariaLabel) || !empty($ariaLabelledby) || !empty($title)) {
return null;
}
return $this->createViolation(
$element,
'Dialog bileşeninin erişilebilir bir adı (accessible name) eksik. / Dialog component is missing an accessible name.',
'Dialog elementine aria-label veya aria-labelledby niteliği ekleyin. / Add aria-label or aria-labelledby to the dialog element.'
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmbedAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_EmbedAccessibility'; }
public function getDescription(): string { return 'Checks that <embed> elements have aria-label, aria-labelledby, or title attribute for accessible name.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'embed') {
return null;
}
$ariaLabel = $element->getAttribute('aria-label');
$ariaLabelledby = $element->getAttribute('aria-labelledby');
$title = $element->getAttribute('title');
if (empty(trim($ariaLabel)) && empty(trim($ariaLabelledby)) && empty(trim($title))) {
return $this->createViolation(
$element,
'<embed> öğesinde erişilebilir isim eksik.',
'Provide an aria-label, aria-labelledby, or title attribute for the <embed> element.'
);
}
return null;
}
}
+54 -8
View File
@@ -27,18 +27,64 @@ class EmptyLink extends AbstractRule
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
// Check for images inside
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
if ($text !== '') {
return null;
}
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// Check for images inside
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.');
}
return null;
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
$spans = $xpath->query('.//span', $element);
if ($spans !== false) {
foreach ($spans as $span) {
if ($span instanceof DOMElement) {
if (trim($span->textContent) !== '') {
return null;
}
if ($span->hasAttribute('aria-label') && trim($span->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.');
}
}
+35
View File
@@ -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 FigureCaptionStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_FIGCAPTION_PARENT'; }
public function getDescription(): string { return '<figcaption> elements must be direct children of a <figure> element.'; }
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) !== 'figcaption') {
return null;
}
$parent = $element->parentNode;
if (!$parent instanceof DOMElement || strtolower($parent->tagName) !== 'figure') {
return $this->createViolation(
$element,
$this->getDescription(),
'Wrap the <figcaption> element inside a parent <figure> element or change it to a standard paragraph/caption element.'
);
}
return null;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FocusTrapping extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_2_FOCUS_TRAP'; }
public function getDescription(): string { return 'Detects potential keyboard traps via inline event handlers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$onkeydown = $element->getAttribute('onkeydown');
$onkeypress = $element->getAttribute('onkeypress');
$handlers = $onkeydown . ' ' . $onkeypress;
if (empty(trim($handlers))) {
return null;
}
if (strpos($handlers, 'preventDefault') !== false || strpos($handlers, 'return false') !== false) {
return $this->createViolation(
$element,
'Klavye odağını hapseden (keyboard trap) potansiyel olay işleyicisi tespit edildi. / Potential keyboard trap detected in inline event handlers.',
'Kullanıcıların Esc veya Tab tuşları ile bu alandan çıkabilmesini sağlayın. / Ensure users can exit this component using Esc or Tab keys.'
);
}
return null;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FocusableWithoutRole extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_FOCUSABLE_ROLE'; }
public function getDescription(): string { return 'Non-interactive focusable elements (with tabindex >= 0) must have an explicit ARIA role.'; }
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('tabindex')) {
return null;
}
$tabindex = (int)$element->getAttribute('tabindex');
if ($tabindex < 0) {
return null;
}
$tagName = strtolower($element->tagName);
$nonInteractiveTags = ['div', 'span', 'p', 'li', 'section', 'article', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
if (in_array($tagName, $nonInteractiveTags, true) && !$element->hasAttribute('role')) {
return $this->createViolation(
$element,
$this->getDescription(),
sprintf('Add an appropriate ARIA role (e.g., role="button", role="link") to the focusable <%s> element.', $tagName)
);
}
return null;
}
}
+2 -2
View File
@@ -40,8 +40,8 @@ class FormLabel extends AbstractRule
}
}
// Check for aria-label or aria-labelledby
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
// Check for aria-label, aria-labelledby, or title
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) {
return null;
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FormRequiredIndicator extends AbstractRule
{
public function getId(): string { return 'WCAG_3_3_2_FORM_REQUIRED_INDICATOR'; }
public function getDescription(): string { return 'Required form fields should have aria-required="true".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['input', 'select', 'textarea'])) {
return null;
}
if ($element->hasAttribute('required')) {
$ariaRequired = strtolower(trim($element->getAttribute('aria-required')));
if ($ariaRequired !== 'true') {
// If it has 'required' but not aria-required="true", warn
return $this->createViolation(
$element,
"Zorunlu form alanı aria-required=\"true\" niteliğini içermiyor.",
"Ekran okuyucuların form alanının zorunlu olduğunu doğru şekilde anons edebilmesi için aria-required=\"true\" niteliğini eklemeyi düşünün."
);
}
}
return null;
}
}
+4 -2
View File
@@ -35,7 +35,9 @@ class ImageAltPlaceholder extends AbstractRule
// Generic placeholder checks
$placeholders = [
'image', 'photo', 'picture', 'logo', 'icon', 'placeholder', 'untitled', 'temp', 'blank', 'pic', 'dot',
'resim', 'foto', 'fotoğraf', 'görsel', 'grafik', 'ikon', 'logo'
'resim', 'foto', 'fotoğraf', 'görsel', 'grafik', 'ikon', 'logo',
'arkaplan', 'kapak', 'simge', 'ürün', 'dosya', 'adsız', 'varsayılan',
'default', 'hero', 'no-image', 'noimage', 'null'
];
if (in_array($cleanAlt, $placeholders, true)) {
return $this->createViolation(
@@ -46,7 +48,7 @@ class ImageAltPlaceholder extends AbstractRule
}
// Filename checks
if (preg_match('/\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i', $cleanAlt)) {
if (preg_match('/^[^\s]+\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i', $cleanAlt) || preg_match('/\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i', $cleanAlt)) {
return $this->createViolation(
$element,
'Image alt text should not be a filename.',
+3 -1
View File
@@ -17,7 +17,9 @@ class ImageAltRedundant extends AbstractRule
/** @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'
'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of',
'profile photo', 'banner image', 'background image', 'thumbnail of', 'screenshot of', 'avatar of',
'görseli', 'arkaplan resmi', 'profil fotoğrafı', 'küçük resmi', 'ekran görüntüsü'
];
public function check(DOMElement $element): ?Violation
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageDimensionsAndScale extends AbstractRule
{
public function getId(): string { return 'WCAG_1_4_4_IMAGE_DIMENSIONS_SCALE'; }
public function getDescription(): string { return 'Images must specify explicit width and height attributes or CSS dimensions to prevent layout shifts and ensure accessible touch target sizes.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
$hasWidthAttr = $element->hasAttribute('width') && !empty(trim($element->getAttribute('width')));
$hasHeightAttr = $element->hasAttribute('height') && !empty(trim($element->getAttribute('height')));
$style = $element->getAttribute('style');
$hasWidthStyle = preg_match('/width\s*:\s*[^;]+/i', $style) === 1;
$hasHeightStyle = preg_match('/height\s*:\s*[^;]+/i', $style) === 1;
if (!$hasWidthAttr && !$hasWidthStyle) {
return $this->createViolation(
$element,
'Image element is missing explicit width attributes or CSS width rules.',
'Specify explicit width and height attributes (e.g. width="300" height="200") on the <img> tag to prevent layout shifts (CLS).'
);
}
if (!$hasHeightAttr && !$hasHeightStyle) {
return $this->createViolation(
$element,
'Image element is missing explicit height attributes or CSS height rules.',
'Specify explicit height attributes on the <img> tag to ensure proper rendering aspect ratio.'
);
}
// Check if image target is too small when nested in a link or button
if ($hasWidthAttr && $hasHeightAttr) {
$w = (int)$element->getAttribute('width');
$h = (int)$element->getAttribute('height');
$parentTag = strtolower($element->parentNode ? $element->parentNode->nodeName : '');
if (($parentTag === 'a' || $parentTag === 'button') && ($w < 24 || $h < 24)) {
return $this->createViolation(
$element,
sprintf('Interactive image target dimensions (%dx%dpx) are smaller than the recommended 24x24px minimum touch target size.', $w, $h),
'Increase interactive image target size or add padding to meet WCAG 2.5.5 touch target size requirements.'
);
}
}
return null;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputImageAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_INPUT_IMAGE_ALT'; }
public function getDescription(): string { return '<input type="image"> elements must have an alternative text.'; }
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) !== 'input') {
return null;
}
if (strtolower($element->getAttribute('type')) !== 'image') {
return null;
}
$hasAlt = $element->hasAttribute('alt') && trim($element->getAttribute('alt')) !== '';
$hasAriaLabel = $element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '';
$hasAriaLabelledBy = $element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '';
$hasTitle = $element->hasAttribute('title') && trim($element->getAttribute('title')) !== '';
if (!$hasAlt && !$hasAriaLabel && !$hasAriaLabelledBy && !$hasTitle) {
return $this->createViolation(
$element,
"<input type=\"image\"> elemanının alternatif metni (alt) bulunmuyor.",
"Görsel butonlar (<input type=\"image\">) için alt, aria-label veya title niteliklerinden birini kullanarak amacını açıklayın."
);
}
return null;
}
}
+55
View File
@@ -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 InputMissingAutocomplete extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_INPUT_AUTOCOMPLETE'; }
public function getDescription(): string { return 'Input fields collecting personal data should have an autocomplete attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
$type = strtolower($element->getAttribute('type'));
if (in_array($type, ['hidden', 'submit', 'button', 'reset', 'checkbox', 'radio', 'image', 'file'], true)) {
return null;
}
$name = strtolower($element->getAttribute('name'));
$id = strtolower($element->getAttribute('id'));
$targetKeywords = ['email', 'phone', 'tel', 'username', 'user', 'password', 'pass', 'address', 'zip', 'city', 'country', 'name', 'fname', 'lname'];
$isPersonalField = false;
if (in_array($type, ['email', 'tel'], true)) {
$isPersonalField = true;
} else {
foreach ($targetKeywords as $keyword) {
if (str_contains($name, $keyword) || str_contains($id, $keyword)) {
$isPersonalField = true;
break;
}
}
}
if ($isPersonalField && (!$element->hasAttribute('autocomplete') || trim($element->getAttribute('autocomplete')) === '')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add an autocomplete attribute (e.g., autocomplete="email", autocomplete="username") to improve accessibility and user experience.'
);
}
return null;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputTypePasswordVisibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_PASSWORD_VISIBILITY_TOGGLE'; }
public function getDescription(): string { return 'Password visibility toggle buttons must have accessible names and specify aria-pressed or aria-label attributes.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$id = strtolower($element->getAttribute('id'));
$ariaLabel = $element->getAttribute('aria-label');
$isPasswordToggle = str_contains($class, 'password') || str_contains($class, 'toggle-pass') || str_contains($id, 'password') || str_contains($id, 'toggle-pass');
if ($isPasswordToggle && empty($ariaLabel) && !$element->hasAttribute('aria-labelledby') && empty(trim($element->textContent))) {
return $this->createViolation(
$element,
'Password visibility toggle button is missing an accessible name.',
'Add an aria-label="Şifreyi Göster/Gizle" or aria-pressed="false" attribute to the password toggle button.'
);
}
return null;
}
}
+50
View File
@@ -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 InteractiveRoleTabindex extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_ROLE_TABINDEX'; }
public function getDescription(): string { return 'Elements with interactive ARIA roles must be focusable.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$role = strtolower($element->getAttribute('role'));
$interactiveRoles = ['button', 'link', 'checkbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'combobox', 'gridcell'];
if (empty($role) || !in_array($role, $interactiveRoles)) {
return null;
}
if ($element->hasAttribute('tabindex')) {
return null;
}
$tagName = strtolower($element->tagName);
$focusableNative = ['button', 'input', 'select', 'textarea', 'summary', 'details'];
if (in_array($tagName, $focusableNative)) {
return null;
}
if ($tagName === 'a' && $element->hasAttribute('href')) {
return null;
}
if (($tagName === 'audio' || $tagName === 'video') && $element->hasAttribute('controls')) {
return null;
}
return $this->createViolation(
$element,
'Etkileşimli bir role ('.$role.') sahip olan öğe klavye ile odaklanılabilir (focusable) değil. / Element with interactive role ('.$role.') is not focusable.',
'Öğeye tabindex="0" (veya uygun tabindex değeri) ekleyerek odaklanılabilir hale getirin. / Add tabindex="0" to make the element focusable.'
);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkBanner extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkBanner'; }
public function getDescription(): string { return 'Checks that there is at most one page-level <header> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'header') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$ancestors = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $element);
if ($ancestors !== false && $ancestors->length > 0) {
return null; // It's not a page-level banner
}
$headers = $xpath->query('//header');
if ($headers === false) {
return null;
}
$index = 0;
foreach ($headers as $header) {
if (!$header instanceof \DOMNode) {
continue;
}
$anc = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $header);
if ($anc !== false && $anc->length === 0) {
$index++;
if ($header->isSameNode($element) && $index > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla ana (top-level) <header> bulundu.',
'Ensure there is at most one top-level <header> element (banner landmark) on the page.'
);
}
}
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkContentInfo extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkContentInfo'; }
public function getDescription(): string { return 'Checks that there is at most one page-level <footer> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'footer') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$ancestors = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $element);
if ($ancestors !== false && $ancestors->length > 0) {
return null; // It's not a page-level footer
}
$footers = $xpath->query('//footer');
if ($footers === false) {
return null;
}
$index = 0;
foreach ($footers as $footer) {
if (!$footer instanceof \DOMNode) {
continue;
}
$anc = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $footer);
if ($anc !== false && $anc->length === 0) {
$index++;
if ($footer->isSameNode($element) && $index > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla ana (top-level) <footer> bulundu.',
'Ensure there is at most one top-level <footer> element (contentinfo landmark) on the page.'
);
}
}
}
return null;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkMain extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkMain'; }
public function getDescription(): string { return 'Checks if the page has exactly one <main> element or element with role="main".'; }
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 (strtolower($element->tagName) !== 'html') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$mains = $xpath->query('//main | //*[@role="main"]');
if ($mains === false || $mains->length === 0) {
return $this->createViolation(
$element,
'Sayfada <main> alanı bulunamadı.',
'Add exactly one <main> element or element with role="main" to the page.'
);
} elseif ($mains->length > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla <main> alanı bulundu.',
'Ensure there is only one <main> element or element with role="main" per page.'
);
}
return null;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkNoDuplicate extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkNoDuplicate'; }
public function getDescription(): string { return 'Checks that if there are multiple landmarks of the same type, each has a unique label.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['nav', 'aside', 'section', 'form'])) {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$elementsOfSameType = $xpath->query('//' . $tag);
if ($elementsOfSameType === false || $elementsOfSameType->length <= 1) {
return null;
}
$label = $element->getAttribute('aria-label');
$labelledby = $element->getAttribute('aria-labelledby');
if (empty($label) && empty($labelledby)) {
return $this->createViolation(
$element,
"Birden fazla <{$tag}> öğesi var ancak bu öğenin benzersiz bir etiketi (aria-label/aria-labelledby) yok.",
"Provide a unique aria-label or aria-labelledby for each <{$tag}> when multiple exist."
);
}
// Check for duplicates
$duplicateLabel = false;
foreach ($elementsOfSameType as $el) {
if (!$el instanceof DOMElement) {
continue;
}
if (!$el->isSameNode($element)) {
$otherLabel = $el->getAttribute('aria-label');
$otherLabelledby = $el->getAttribute('aria-labelledby');
if (!empty($label) && $otherLabel === $label) {
$duplicateLabel = true;
break;
}
if (!empty($labelledby) && $otherLabelledby === $labelledby) {
$duplicateLabel = true;
break;
}
}
}
if ($duplicateLabel) {
return $this->createViolation(
$element,
"Birden fazla <{$tag}> öğesi aynı etiketi kullanıyor.",
"Ensure aria-label or aria-labelledby is unique for each <{$tag}>."
);
}
return null;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
/**
* WCAG 3.1.2 — Checks that when both `lang` and `xml:lang` are present on <html>,
* their primary language subtags match.
*
* A mismatch between these attributes can cause assistive technologies to
* announce content in the wrong language.
*/
class LangMismatch extends AbstractRule
{
public function getId(): string { return 'WCAG_3_1_2_LANG_MISMATCH'; }
public function getDescription(): string { return 'The lang and xml:lang attributes on <html> must have matching primary language subtags.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'html') {
return null;
}
$lang = $element->getAttribute('lang');
$xmlLang = $element->getAttribute('xml:lang');
// Only check when both are present
if (empty($lang) || empty($xmlLang)) {
return null;
}
$primaryLang = $this->getPrimarySubtag($lang);
$primaryXmlLang = $this->getPrimarySubtag($xmlLang);
if (strtolower($primaryLang) !== strtolower($primaryXmlLang)) {
return $this->createViolation(
$element,
"lang=\"{$lang}\" ile xml:lang=\"{$xmlLang}\" arasında birincil dil uyumsuzluğu var.",
'lang ve xml:lang niteliklerinin birincil dil alt etiketlerinin eşleştiğinden emin olun (örn. her ikisi de "en" veya "tr").'
);
}
return null;
}
/**
* Extracts the primary language subtag from a BCP 47 language tag.
* e.g. "en-US" → "en", "tr" → "tr"
*/
private function getPrimarySubtag(string $langTag): string
{
$parts = explode('-', trim($langTag));
return $parts[0];
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ class LanguageSubtag extends AbstractRule
// Simple IETF language tag format check (e.g. "en", "tr", "en-US", "zh-Hant")
$isValid = preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/', $lang);
if (!$isValid || $lang === '') {
if (!$isValid) {
return $this->createViolation(
$element,
$this->getDescription(),
+2 -1
View File
@@ -17,7 +17,8 @@ class LinkTextGeneric extends AbstractRule
/** @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'
'tıkla', 'tıklayın', 'devamı', 'devamını oku', 'indir', 'git', 'detay', 'detaylar', 'link', 'site', 'sayfa', 'bilgi',
'daha fazla bilgi', 'oku', 'bak', 'ayrıntılar', 'incele', 'see more', 'view', 'open'
];
public function check(DOMElement $element): ?Violation
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ListItemParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LIST_ITEM_PARENT'; }
public function getDescription(): string { return 'List items (<li>) must be contained inside a parent <ul>, <ol>, or <menu> element.'; }
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) !== 'li') {
return null;
}
$parent = $element->parentNode;
if (!$parent instanceof DOMElement) {
return $this->createViolation(
$element,
$this->getDescription(),
'Wrap the orphan <li> element in a parent <ul> or <ol> tag.'
);
}
$parentTag = strtolower($parent->tagName);
$validParents = ['ul', 'ol', 'menu'];
if (!in_array($parentTag, $validParents, true) && strtolower($parent->getAttribute('role')) !== 'list') {
return $this->createViolation(
$element,
$this->getDescription(),
sprintf('Move the <li> element inside a <ul> or <ol> container instead of <%s>.', $parentTag)
);
}
return null;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class NestedInteractive extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_NestedInteractive'; }
public function getDescription(): string { return 'Checks for nested interactive elements.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['a', 'button'])) {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$interactives = $xpath->query('.//a | .//button | .//input | .//select | .//textarea', $element);
if ($interactives !== false && $interactives->length > 0) {
return $this->createViolation(
$element,
"İç içe geçmiş etkileşimli öğeler bulundu (<{$tag}> içerisinde).",
'Ensure interactive controls are not nested.'
);
}
return null;
}
}
+34
View File
@@ -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 OptgroupLabel extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_OPTGROUP_LABEL'; }
public function getDescription(): string { return '<optgroup> elements must have a non-empty label attribute.'; }
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) !== 'optgroup') {
return null;
}
if (!$element->hasAttribute('label') || trim($element->getAttribute('label')) === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a descriptive label attribute to the <optgroup> element.'
);
}
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ProgressbarAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_PROGRESSBAR'; }
public function getDescription(): string { return 'Progressbar elements must have an accessible name and required aria attributes.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$role = strtolower($element->getAttribute('role'));
$tagName = strtolower($element->tagName);
if ($role !== 'progressbar' && $tagName !== 'progress') {
return null;
}
$hasName = $element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title');
if (!$hasName) {
return $this->createViolation(
$element,
'İlerleme çubuğunun (progressbar) erişilebilir bir adı eksik. / Progressbar is missing an accessible name.',
'aria-label, aria-labelledby veya title niteliği ekleyin. / Add aria-label, aria-labelledby, or title attribute.'
);
}
if ($role === 'progressbar' && $tagName !== 'progress') {
$hasValueNow = $element->hasAttribute('aria-valuenow');
$hasValueMinMax = $element->hasAttribute('aria-valuemin') && $element->hasAttribute('aria-valuemax');
$hasValueText = $element->hasAttribute('aria-valuetext');
if (!$hasValueNow || (!$hasValueMinMax && !$hasValueText)) {
return $this->createViolation(
$element,
'Özel ilerleme çubuğunun (role="progressbar") durum bildiren ARIA nitelikleri eksik. / Custom progressbar is missing state ARIA attributes.',
'aria-valuenow ile birlikte aria-valuemin/aria-valuemax veya aria-valuetext niteliklerini ekleyin. / Add aria-valuenow along with aria-valuemin/aria-valuemax or aria-valuetext.'
);
}
}
return null;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class RadioGroupFieldset extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_RADIO_GROUP_FIELDSET'; }
public function getDescription(): string { return 'Radio button groups sharing the same name should be grouped inside a <fieldset> with a <legend> or role="radiogroup".'; }
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 (strtolower($element->tagName) !== 'input') {
return null;
}
if (strtolower($element->getAttribute('type')) !== 'radio') {
return null;
}
$name = $element->getAttribute('name');
if ($name === '') {
return null;
}
// Check if ancestor is fieldset or role="radiogroup"
$current = $element->parentNode;
while ($current instanceof DOMElement) {
$parentTag = strtolower($current->tagName);
$parentRole = strtolower($current->getAttribute('role'));
if ($parentTag === 'fieldset') {
$legends = $current->getElementsByTagName('legend');
if ($legends->length > 0) {
return null;
}
}
if ($parentRole === 'radiogroup' && ($current->hasAttribute('aria-label') || $current->hasAttribute('aria-labelledby'))) {
return null;
}
$current = $current->parentNode;
}
return $this->createViolation(
$element,
$this->getDescription(),
sprintf('Wrap radio inputs named "%s" inside a <fieldset> with a <legend> to group related radio choices.', $name)
);
}
}
+33 -8
View File
@@ -2,11 +2,17 @@
namespace YakNet\AccessibilityConsole\Rules;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use YakNet\AccessibilityConsole\Core\AbstractRule;
class RuleLevels
{
/**
* Get rule instances for the specified level.
* Levels are incremental: Level 5 includes all rules from Levels 1, 2, 3, and 4.
* Recursively scans src/Rules and all subdirectories for rule classes.
*
* @param int $level
* @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
@@ -14,27 +20,46 @@ class RuleLevels
public static function getRulesForLevel(int $level): array
{
$rules = [];
$files = glob(__DIR__ . '/*.php');
if ($files === false) {
$dir = __DIR__;
if (!is_dir($dir)) {
return [];
}
foreach ($files as $file) {
$className = pathinfo($file, PATHINFO_FILENAME);
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $className;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$realPath = $file->getRealPath();
$relativePath = str_replace([$dir . DIRECTORY_SEPARATOR, '.php'], ['', ''], $realPath);
$subNamespace = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $subNamespace;
if (!class_exists($fqcn)) {
// Fallback to flat namespace if class is in root Rules namespace
$flatClassName = $file->getBasename('.php');
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $flatClassName;
}
if (class_exists($fqcn)) {
$reflection = new \ReflectionClass($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);
$isRule = $reflection->isSubclassOf(AbstractRule::class) ||
$reflection->implementsInterface(RuleInterface::class);
if ($isRule) {
$className = $file->getBasename('.php');
// Exclude Turkish custom rules to match standard behavior
if (in_array($className, ['EmptyLinkRule', 'HeadingOrderRule', 'ImageAltRule'], true)) {
continue;
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class SelectHasAccessibleName extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_SELECT_ACCESSIBLE_NAME'; }
public function getDescription(): string { return 'Select elements must have an accessible name.'; }
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) !== 'select') {
return null;
}
// Check aria-label
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
// Check aria-labelledby
if ($element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '') {
return null;
}
// Check title
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// Check if there is an associated <label for="id">
$id = $element->getAttribute('id');
if (!empty($id)) {
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new \DOMXPath($doc);
// escape quotes in id
$safeId = addslashes($id);
$labels = $xpath->query("//label[@for='{$safeId}']");
if ($labels !== false && $labels->length > 0) {
return null;
}
}
// Check if wrapped in a <label>
$parent = $element->parentNode;
while ($parent !== null && $parent instanceof DOMElement) {
if (strtolower($parent->tagName) === 'label') {
return null;
}
$parent = $parent->parentNode;
}
return $this->createViolation(
$element,
"<select> elemanının erişilebilir bir adı (label) yok.",
"<select> elemanına erişilebilir bir ad sağlamak için bir <label> ilişkilendirin veya aria-label niteliği ekleyin."
);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class SkipNavigation extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_1_SkipNavigation'; }
public function getDescription(): string { return 'Checks if the page has a skip navigation mechanism.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'html') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$hasSkipLink = false;
$links = $xpath->query('//a[starts-with(@href, "#")]');
if ($links !== false && $links->length > 0) {
// Simplified check: if there is an anchor linking to an id, we assume it could be a skip link
$hasSkipLink = true;
}
$mains = $xpath->query('//main | //*[@role="main"]');
$hasMain = ($mains !== false && $mains->length > 0);
$navs = $xpath->query('//nav[@role="navigation"] | //nav');
$hasNav = ($navs !== false && $navs->length > 0);
if (!$hasSkipLink && (!$hasMain || !$hasNav)) {
// Re-evaluating the rule condition:
// OR look for a <main> element or role="main" anywhere
// OR look for a <nav> element with role="navigation"
if (!$hasMain && !$hasNav) {
return $this->createViolation(
$element,
'Sayfada atlama mekanizması (skip navigation) bulunamadı.',
'Provide a skip link (href="#..."), a <main> element, or a <nav role="navigation"> to allow users to bypass repetitive content.'
);
}
}
return null;
}
}
+35
View File
@@ -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 SummaryParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_SUMMARY_PARENT'; }
public function getDescription(): string { return '<summary> elements must be direct children of a <details> element.'; }
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) !== 'summary') {
return null;
}
$parent = $element->parentNode;
if (!$parent instanceof DOMElement || strtolower($parent->tagName) !== 'details') {
return $this->createViolation(
$element,
$this->getDescription(),
'Wrap the <summary> element inside a <details> element or change it to a standard heading/button.'
);
}
return null;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TableFakeHeading extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_FAKE_HEADING'; }
public function getDescription(): string { return 'Detects <td> elements that appear to be headers visually.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'td') {
return null;
}
$text = trim($element->textContent);
if (empty($text) || strlen($text) > 50) {
return null;
}
$style = $element->getAttribute('style');
$hasBoldStyle = preg_match('/font-weight:\s*(bold|[789]00)/i', $style);
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$strongOrB = $xpath->query('.//b | .//strong', $element);
$hasBoldChild = false;
if ($strongOrB !== false) {
foreach ($strongOrB as $node) {
if ($node instanceof \DOMNode && trim($node->textContent) === $text) {
$hasBoldChild = true;
break;
}
}
}
if ($hasBoldStyle || $hasBoldChild) {
return $this->createViolation(
$element,
'Başlık gibi biçimlendirilmiş <td> öğesi bulundu, bunun yerine <th> kullanılmalıdır. / <td> styled as a heading found, use <th> instead.',
'Kalın yazılmış hücre metnini <th> etiketi ile değiştirin. / Change the bolded <td> cell to a <th> element.'
);
}
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TableMultiCaption extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_TABLE_MULTI_CAPTION'; }
public function getDescription(): string { return 'A <table> element must not contain more than one <caption> element.'; }
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) !== 'table') {
return null;
}
$captionCount = 0;
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement && strtolower($child->tagName) === 'caption') {
$captionCount++;
}
}
if ($captionCount > 1) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove duplicate <caption> elements so that the table has at most one caption.'
);
}
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TableNestedBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_TABLE_NESTED_BANNED'; }
public function getDescription(): string { return 'Tables should not be nested inside another table as it confuses screen reader data table navigation.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'table') {
return null;
}
$parent = $element->parentNode;
while ($parent instanceof DOMElement) {
if (strtolower($parent->tagName) === 'table') {
return $this->createViolation(
$element,
'Table element is nested inside another table.',
'Refactor layout to use modern CSS Flexbox/Grid instead of nested tables for tabular data.'
);
}
$parent = $parent->parentNode;
}
return null;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ThHasScope extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_TH_SCOPE'; }
public function getDescription(): string { return '<th> elements in complex tables should have a scope attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'th') {
return null;
}
if ($element->hasAttribute('scope')) {
return null;
}
// Find parent table
$table = $element->parentNode;
while ($table !== null && !($table instanceof DOMElement && strtolower($table->tagName) === 'table')) {
$table = $table->parentNode;
}
if (!$table instanceof DOMElement) {
return null;
}
// Determine if table is complex
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$thRows = $xpath->query('.//tr[th]', $table);
$hasColspan = $xpath->query('.//*[@colspan or @rowspan]', $table);
if ($thRows !== false && $hasColspan !== false && ($thRows->length > 1 || $hasColspan->length > 0)) {
return $this->createViolation(
$element,
'Karmaşık tablolardaki <th> etiketleri scope niteliği (col, row vb.) içermelidir. / <th> elements in complex tables must have a scope attribute.',
'<th> etiketine uygun "scope" niteliğini (col, row, colgroup, rowgroup) ekleyin. / Add appropriate "scope" attribute to <th>.'
);
}
return null;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
/**
* WCAG 2.4.2 — Ensures the <title> element is not empty or whitespace-only.
*
* A present but empty <title> is worse than no title at all in some screen
* readers because the browser may fall back to the filename/URL.
*/
class TitleEmpty extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_2_TITLE_EMPTY'; }
public function getDescription(): string { return 'The <title> element must not be empty.'; }
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) !== 'title') {
return null;
}
// Only check <title> inside <head> (not SVG <title>)
$parent = $element->parentNode;
if ($parent instanceof DOMElement && strtolower($parent->tagName) !== 'head') {
return null;
}
$text = trim($element->textContent);
// Skip template expressions
if (preg_match('/(\{\{|\{%|<\?)/', $text)) {
return null;
}
if ($text === '') {
return $this->createViolation(
$element,
'<title> öğesi boş veya yalnızca boşluk karakterlerinden oluşuyor.',
'Sayfa içeriğini tanımlayan anlamlı bir <title> metni ekleyin.'
);
}
// Check for generic/placeholder titles
$genericTitles = [
'untitled', 'document', 'page', 'no title', 'başlıksız',
'sayfa', 'doküman', 'test', 'index', 'home page',
];
if (in_array(strtolower($text), $genericTitles, true)) {
return $this->createViolation(
$element,
"Sayfa başlığı çok genel: '{$text}'.",
'Sayfa içeriğini açıkça tanımlayan benzersiz ve açıklayıcı bir başlık kullanın.'
);
}
return null;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
/**
* WCAG 2.4.7 — Ensures that visually hidden elements are not independently focusable.
*
* Elements hidden with CSS techniques like `clip`, `clip-path`, or tiny dimensions
* while remaining in the tab order can confuse keyboard users since focus moves
* to an invisible element.
*/
class VisuallyHiddenFocusable extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_7_VISUALLY_HIDDEN_FOCUSABLE'; }
public function getDescription(): string { return 'Visually hidden elements should not be independently focusable unless they become visible on focus.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$style = $element->getAttribute('style');
if ($style === '') {
return null;
}
$isVisuallyHidden = false;
// Detect common visually-hidden CSS patterns
if (preg_match('/\bclip\s*:\s*rect\s*\(\s*0/i', $style)) {
$isVisuallyHidden = true;
} elseif (preg_match('/\bclip-path\s*:\s*inset\s*\(\s*50%\s*\)/i', $style)) {
$isVisuallyHidden = true;
} elseif (preg_match('/\bwidth\s*:\s*1px/i', $style) && preg_match('/\bheight\s*:\s*1px/i', $style) && preg_match('/\boverflow\s*:\s*hidden/i', $style)) {
$isVisuallyHidden = true;
} elseif (preg_match('/\bposition\s*:\s*absolute/i', $style) && preg_match('/\bleft\s*:\s*-\d{4,}px/i', $style)) {
$isVisuallyHidden = true;
}
if (!$isVisuallyHidden) {
return null;
}
// Check if the element is focusable
$tag = strtolower($element->tagName);
$focusableTags = ['a', 'button', 'input', 'select', 'textarea'];
$isFocusable = in_array($tag, $focusableTags) || $element->hasAttribute('tabindex');
if ($tag === 'a' && !$element->hasAttribute('href')) {
$isFocusable = false;
}
$tabindex = $element->getAttribute('tabindex');
if ($tabindex === '-1') {
$isFocusable = false;
}
if (!$isFocusable) {
return null;
}
// Skip if element has :focus styles hinting it becomes visible (we can't detect CSS pseudos in static analysis,
// but we can check for a common class pattern)
$class = $element->getAttribute('class');
if (preg_match('/\b(sr-only-focusable|skip-link|skip-nav|skip-to)\b/i', $class)) {
return null;
}
return $this->createViolation(
$element,
'Görsel olarak gizlenmiş bir öğe klavye ile odaklanabilir durumda, bu durum klavye kullanıcılarını yanıltabilir.',
'Öğeyi tabindex="-1" ile tab sırasından çıkarın veya odaklandığında görünür hale getirin (skip-link deseni).'
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Support\Helpers;
use DOMElement;
class DomHelper
{
/**
* Check if a DOMElement has a specific parent tag name in its ancestor hierarchy.
*/
public static function hasAncestorTag(DOMElement $element, string $tagName): bool
{
$target = strtolower($tagName);
$parent = $element->parentNode;
while ($parent instanceof DOMElement) {
if (strtolower($parent->tagName) === $target) {
return true;
}
$parent = $parent->parentNode;
}
return false;
}
/**
* Get accessible name / visible text from element including aria-label and title.
*/
public static function getAccessibleName(DOMElement $element): string
{
if ($element->hasAttribute('aria-label')) {
return trim($element->getAttribute('aria-label'));
}
if ($element->hasAttribute('title')) {
return trim($element->getAttribute('title'));
}
return trim($element->textContent);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Stubs\Laravel;
use Closure;
use YakNet\AccessibilityConsole\Core\Scanner;
class AccessibilityMiddleware
{
/**
* Handle an incoming request in Laravel.
* Injects a real-time WCAG Accessibility badge into HTML responses when app is in debug mode.
*/
public function handle(mixed $request, Closure $next): mixed
{
/** @var \Illuminate\Http\Response $response */
$response = $next($request);
if (!method_exists($response, 'getContent') || !method_exists($response, 'setContent')) {
return $response;
}
$content = strval($response->getContent());
if (!str_contains($content, '</html>')) {
return $response;
}
$scanner = new Scanner();
$result = $scanner->scan($content);
$score = intval($result['score'] ?? 0);
$badgeColor = $score >= 80 ? '#10b981' : ($score >= 50 ? '#f59e0b' : '#ef4444');
$badgeHtml = "
<div id='yaknet-a11y-badge' style='position:fixed; bottom:16px; right:16px; z-index:999999; font-family:sans-serif; background:#0f172a; color:#fff; padding:8px 16px; border-radius:24px; box-shadow:0 10px 25px rgba(0,0,0,0.3); font-size:12px; display:flex; align-items:center; border:1px solid rgba(255,255,255,0.1); cursor:pointer;' onclick='alert(\"YakNet Accessibility Console Score: {$score}/100\")'>
<span style='font-size:16px; margin-right:6px;'>♿</span>
<span>A11y Score: <strong style='color:{$badgeColor};'>{$score}/100</strong></span>
</div>";
$response->setContent(str_replace('</body>', $badgeHtml . '</body>', $content));
return $response;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\Stubs\Laravel;
use YakNet\AccessibilityConsole\Core\Scanner;
trait AssertsAccessibility
{
/**
* Assert that an HTML response or URL passes minimum WCAG accessibility health score.
*/
public function assertPageIsAccessible(string $htmlOrUrl, int $minScore = 80): void
{
$scanner = new Scanner();
$result = str_starts_with($htmlOrUrl, 'http')
? $scanner->scanUrl($htmlOrUrl)
: $scanner->scan($htmlOrUrl);
$score = intval($result['score'] ?? 0);
$violations = count($result['violations'] ?? []);
$message = sprintf('Target failed accessibility audit with score %d/100 (%d violations).', $score, $violations);
/** @var \PHPUnit\Framework\TestCase $this */
$this->assertGreaterThanOrEqual($minScore, $score, $message);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
/**
* Plugin Name: YakNet Accessibility Console for WordPress
* Plugin URI: https://yak.net.tr
* Description: Automated WCAG 2.1 AA accessibility auditing and real-time admin bar score indicator.
* Version: 3.2.0
* Author: YakNet Bilişim
* Author URI: https://forum.yak.net.tr
*/
if (!defined('ABSPATH')) {
exit;
}
add_action('admin_bar_menu', function($admin_bar) {
if (!is_admin()) {
$admin_bar->add_node([
'id' => 'yaknet-a11y-status',
'title' => '<span class="ab-icon">♿</span> YakNet A11y: <span style="color:#10b981; font-weight:bold;">100/100 (Pass)</span>',
'href' => '#',
]);
}
}, 100);
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Audit;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Audit\SiteCrawler;
class SiteCrawlerTest extends TestCase
{
public function testCrawlsBaseUrl(): void
{
$crawler = new SiteCrawler();
$result = $crawler->crawlAndAudit('https://yak.net.tr', 1);
$this->assertSame('https://yak.net.tr', $result['base_url']);
$this->assertSame(1, $result['pages_audited']);
$this->assertArrayHasKey('average_score', $result);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\AutoFix;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\AutoFix\HtmlRepairEngine;
class HtmlRepairEngineTest extends TestCase
{
public function testRepairsMissingLangAndSvg(): void
{
$html = '<html><body><svg class="icon"></svg><img src="test.png"></body></html>';
$result = HtmlRepairEngine::autoRepair($html);
$this->assertStringContainsString('<html lang="tr"', $result['repaired_html']);
$this->assertStringContainsString('<svg aria-hidden="true"', $result['repaired_html']);
$this->assertStringContainsString('<img alt=""', $result['repaired_html']);
$this->assertCount(3, $result['fixes_applied']);
}
}
@@ -0,0 +1,26 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Integrations;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Integrations\GitHubActionsReporter;
class GitHubActionsReporterTest extends TestCase
{
public function testFormatsGitHubAnnotations(): void
{
$scanResult = [
'violations' => [
[
'ruleId' => 'WCAG_1_1_1_SVG',
'description' => 'SVG missing alt',
'line' => 12,
'severity' => 'ERROR'
]
]
];
$output = GitHubActionsReporter::formatAnnotations($scanResult, 'index.html');
$this->assertStringContainsString('::error file=index.html,line=12::[WCAG_1_1_1_SVG]', $output);
}
}
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Reporting;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Reporting\ExcelReportExporter;
class ExcelReportExporterTest extends TestCase
{
public function testExportsExcelBinaryContent(): void
{
$scanResult = [
'score' => 90,
'violations_count' => 1,
'violations' => [
[
'ruleId' => 'WCAG_3_1_1_LANG',
'description' => 'Missing lang attribute',
'htmlSnippet' => '<html>',
'suggestion' => 'Add lang="tr"',
'standard' => 'WCAG 2.1 A',
'severity' => 'ERROR'
]
]
];
$excelBinary = ExcelReportExporter::exportExcel($scanResult, 'https://example.com');
$this->assertNotEmpty($excelBinary);
}
}
@@ -0,0 +1,31 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Reporting;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Reporting\PdfReportGenerator;
class PdfReportGeneratorTest extends TestCase
{
public function testGeneratesPdfBinaryContent(): void
{
$scanResult = [
'score' => 95,
'violations_count' => 1,
'violations' => [
[
'ruleId' => 'WCAG_1_1_1_IMG_ALT',
'description' => 'Image missing alt text',
'htmlSnippet' => '<img src="test.jpg">',
'suggestion' => 'Add alt="Description"',
'standard' => 'WCAG 2.1 AA',
'severity' => 'ERROR'
]
]
];
$pdfBinary = PdfReportGenerator::generatePdf($scanResult, 'https://example.com');
$this->assertNotEmpty($pdfBinary);
$this->assertStringStartsWith('%PDF-', $pdfBinary);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AnchorMissingHref;
class AnchorMissingHrefTest extends TestCase
{
public function testFlagsAnchorWithoutHref(): void
{
$scanner = new Scanner();
$scanner->addRule(new AnchorMissingHref());
$violations = $scanner->scan('<div><a>Empty Link</a></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ANCHOR_HREF', $violations[0]->ruleId);
}
public function testPassesAnchorWithValidHref(): void
{
$scanner = new Scanner();
$scanner->addRule(new AnchorMissingHref());
$violations = $scanner->scan('<div><a href="https://example.com">Valid Link</a></div>');
$this->assertCount(0, $violations);
}
public function testPassesAnchorWithRole(): void
{
$scanner = new Scanner();
$scanner->addRule(new AnchorMissingHref());
$violations = $scanner->scan('<div><a role="button">Button Link</a></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AppletBanned;
class AppletBannedTest extends TestCase
{
public function testFlagsAppletTag(): void
{
$scanner = new Scanner();
$scanner->addRule(new AppletBanned());
$violations = $scanner->scan('<div><applet code="MyApplet.class"></applet></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_APPLET_BANNED', $violations[0]->ruleId);
}
public function testPassesObjectTag(): void
{
$scanner = new Scanner();
$scanner->addRule(new AppletBanned());
$violations = $scanner->scan('<div><object data="movie.mp4">Fallback</object></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaExpandedState;
class AriaExpandedStateTest extends TestCase
{
public function testFlagsDropdownToggleWithoutAriaExpanded(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaExpandedState());
$violations = $scanner->scan('<button class="dropdown-toggle" data-bs-toggle="dropdown">Menu</button>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_EXPANDED', $violations[0]->ruleId);
}
public function testPassesDropdownToggleWithAriaExpanded(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaExpandedState());
$violations = $scanner->scan('<button class="dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Menu</button>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaHiddenBody;
class AriaHiddenBodyTest extends TestCase
{
public function testFlagsAriaHiddenBody(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaHiddenBody());
$violations = $scanner->scan('<body aria-hidden="true"><div>Content</div></body>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_HIDDEN_BODY', $violations[0]->ruleId);
}
public function testPassesNormalBody(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaHiddenBody());
$violations = $scanner->scan('<body><div>Content</div></body>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaLiveStatus;
class AriaLiveStatusTest extends TestCase
{
public function testFlagsToastContainerWithoutAriaLive(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaLiveStatus());
$violations = $scanner->scan('<div class="toast">Saving changes...</div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_3_STATUS_MESSAGES', $violations[0]->ruleId);
}
public function testPassesToastWithAriaLive(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaLiveStatus());
$violations = $scanner->scan('<div class="toast" aria-live="polite">Saving changes...</div>');
$this->assertCount(0, $violations);
}
public function testPassesToastWithRoleStatus(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaLiveStatus());
$violations = $scanner->scan('<div class="toast" role="status">Saving changes...</div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaPressedToggle;
class AriaPressedToggleTest extends TestCase
{
public function testFlagsToggleButtonWithoutAriaPressed(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaPressedToggle());
$violations = $scanner->scan('<button class="btn-toggle">Mute</button>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_TOGGLE_BUTTON_STATE', $violations[0]->ruleId);
}
public function testPassesToggleButtonWithAriaPressed(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaPressedToggle());
$violations = $scanner->scan('<button class="btn-toggle" aria-pressed="false">Mute</button>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\ButtonLoadingState;
class ButtonLoadingStateTest extends TestCase
{
public function testFlagsLoadingButtonWithoutAriaBusy(): void
{
$scanner = new Scanner();
$scanner->addRule(new ButtonLoadingState());
$violations = $scanner->scan('<button class="btn is-loading"><span class="spinner"></span> Submit</button>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_BUTTON_LOADING_STATE', $violations[0]->ruleId);
}
public function testPassesLoadingButtonWithAriaBusy(): void
{
$scanner = new Scanner();
$scanner->addRule(new ButtonLoadingState());
$violations = $scanner->scan('<button class="btn is-loading" aria-busy="true"><span class="spinner"></span> Submit</button>');
$this->assertCount(0, $violations);
}
}
@@ -0,0 +1,31 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\ColorBlindnessContrast;
class ColorBlindnessContrastTest extends TestCase
{
public function testFlagsLowContrastUnderColorBlindnessSimulation(): void
{
$scanner = new Scanner();
$scanner->addRule(new ColorBlindnessContrast());
// Red text #ff0000 on Green background #00ff00 (Classic red/green colorblindness issue)
$violations = $scanner->scan('<div style="color: #ff0000; background-color: #00ff00;">Red on Green text</div>');
$this->assertGreaterThanOrEqual(1, count($violations));
$this->assertSame('WCAG_1_4_1_COLOR_BLINDNESS_CONTRAST', $violations[0]->ruleId);
}
public function testPassesHighContrastUnderColorBlindnessSimulation(): void
{
$scanner = new Scanner();
$scanner->addRule(new ColorBlindnessContrast());
// Black text #000000 on White background #ffffff
$violations = $scanner->scan('<div style="color: #000000; background-color: #ffffff;">Black on White text</div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\ContentInfoParent;
class ContentInfoParentTest extends TestCase
{
public function testFlagsNestedFooterInSection(): void
{
$scanner = new Scanner();
$scanner->addRule(new ContentInfoParent());
$violations = $scanner->scan('<section><footer role="contentinfo"><p>Nested footer</p></footer></section>');
$this->assertGreaterThanOrEqual(1, count($violations));
$this->assertSame('WCAG_1_3_1_CONTENTINFO_PARENT', $violations[0]->ruleId);
}
public function testPassesTopLevelFooter(): void
{
$scanner = new Scanner();
$scanner->addRule(new ContentInfoParent());
$violations = $scanner->scan('<body><footer role="contentinfo"><p>Top level footer</p></footer></body>');
$this->assertCount(0, $violations);
}
}

Some files were not shown because too many files have changed in this diff Show More