Git Komut Rehberi & Terminal Asistanı
Sıfırdan Yeni Bir Depo Başlatın:
Mevcut Bir Git Deposu veya Klasörü Gönderin:
Hataları Geri Alma, İptal & Temizlik:
Son commit'i geri al (yapılan değişiklikleri korur):
git reset --soft HEAD~1
Yapılan yerel değişiklikleri iptal et (çalışma ağacını temizler):
git restore .
Takip edilmeyen gereksiz dosyaları sil:
git clean -fd
Değişiklikleri geçici sakla ve geri getir (stash):
git stash git stash pop
Dal (Branch) ve Sürüm Etiketleri:
Yeni dal aç ve geçiş yap:
git checkout -b yeni-ozellik git push -u origin yeni-ozellik
Sürüm etiketi (tag) oluştur ve gönder:
git tag -a v1.0.0 -m "Sürüm 1.0.0" git push origin --tags
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73fbf485d2 | ||
|
|
58b1618d96 | ||
|
|
164828f9f7 | ||
|
|
6f4799dc38 | ||
|
|
c79ddfcc50 | ||
|
|
5292c2c865 | ||
|
|
7f3bf3cb7d | ||
|
|
8e6ea62ba7 |
@@ -4,3 +4,5 @@
|
|||||||
/rapor.html
|
/rapor.html
|
||||||
/.phpunit.result.cache
|
/.phpunit.result.cache
|
||||||
/phpstan-baseline.neon
|
/phpstan-baseline.neon
|
||||||
|
/.phpunit.cache/
|
||||||
|
|
||||||
@@ -11,17 +11,19 @@ if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
use Symfony\Component\Console\Application;
|
use Symfony\Component\Console\Application;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Container\ContainerFactory;
|
||||||
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
|
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
|
||||||
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
|
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
|
||||||
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
|
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
|
||||||
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
|
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
|
||||||
|
|
||||||
$application = new Application('YakNet Accessibility Console', '3.0.1');
|
$container = ContainerFactory::create();
|
||||||
|
$application = new Application('YakNet Accessibility Console', '3.2.0');
|
||||||
|
|
||||||
// Add commands
|
// Add commands resolved via DI Container
|
||||||
$application->add(new ScanCommand());
|
$application->addCommand($container->get(ScanCommand::class));
|
||||||
$application->add(new FixCommand());
|
$application->addCommand($container->get(FixCommand::class));
|
||||||
$application->add(new InitCommand());
|
$application->addCommand($container->get(InitCommand::class));
|
||||||
$application->add(new RulesCommand());
|
$application->addCommand($container->get(RulesCommand::class));
|
||||||
|
|
||||||
$application->run();
|
$application->run();
|
||||||
+15
-5
@@ -12,12 +12,12 @@
|
|||||||
"yaknet"
|
"yaknet"
|
||||||
],
|
],
|
||||||
"homepage": "https://forum.yak.net.tr",
|
"homepage": "https://forum.yak.net.tr",
|
||||||
"version": "3.0.3",
|
"version": "3.2.0",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
"name": "YakNet BiliÅŸim",
|
"name": "YakNet Bilişim",
|
||||||
"email": "[email protected]",
|
"email": "[email protected]",
|
||||||
"role": "Developer"
|
"role": "Developer"
|
||||||
}
|
}
|
||||||
@@ -35,13 +35,24 @@
|
|||||||
"guzzlehttp/guzzle": "^7.8",
|
"guzzlehttp/guzzle": "^7.8",
|
||||||
"masterminds/html5": "^2.8",
|
"masterminds/html5": "^2.8",
|
||||||
"sabberworm/php-css-parser": "^9.4",
|
"sabberworm/php-css-parser": "^9.4",
|
||||||
"chrome-php/chrome": "^1.15"
|
"chrome-php/chrome": "^1.15",
|
||||||
|
"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",
|
||||||
|
"spatie/color": "^1.5",
|
||||||
|
"intervention/image": "^3.0",
|
||||||
|
"dompdf/dompdf": "^3.0",
|
||||||
|
"phpoffice/phpspreadsheet": "^2.0 || ^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5",
|
||||||
"phpstan/phpstan": "^1.10"
|
"phpstan/phpstan": "^2.2"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/accessibility-console.php"
|
||||||
|
],
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"YakNet\\AccessibilityConsole\\": "src/"
|
"YakNet\\AccessibilityConsole\\": "src/"
|
||||||
}
|
}
|
||||||
@@ -57,4 +68,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Test Local Fix</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<span style="text-decoration: blink;">Hello blink</span>
|
||||||
|
<input type="text" name="username" autofocus />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Test Page</title>
|
<title>Test Page</title>
|
||||||
|
<meta http-equiv="refresh" content="30">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<img src="avatar.png">
|
<img src="avatar.png" alt="Avatar">
|
||||||
<button></button>
|
<button>Submit</button>
|
||||||
|
<a href="#">Click here for nothing</a>
|
||||||
|
<a href="javascript:void(0)">Do something</a>
|
||||||
|
<label for="nonexistent-id">Broken Label</label>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Header 1</th>
|
||||||
|
<th>Header 2</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td scope="row">Data 1</td>
|
||||||
|
<td>Data 2</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -2,3 +2,6 @@ parameters:
|
|||||||
level: 9
|
level: 9
|
||||||
paths:
|
paths:
|
||||||
- src
|
- src
|
||||||
|
bootstrapFiles:
|
||||||
|
- vendor/autoload.php
|
||||||
|
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
executionOrder="depends,defects"
|
||||||
|
requireCoverageMetadata="false"
|
||||||
|
beStrictAboutCoverageMetadata="false"
|
||||||
|
beStrictAboutOutputDuringTests="true"
|
||||||
|
failOnRisky="true"
|
||||||
|
failOnWarning="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="YakNet Accessibility Console Test Suite">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\AI;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\AI\Providers\GeminiProvider;
|
||||||
|
use YakNet\AccessibilityConsole\AI\Providers\OpenAiProvider;
|
||||||
|
use YakNet\AccessibilityConsole\AI\Providers\ClaudeProvider;
|
||||||
|
use YakNet\AccessibilityConsole\AI\Providers\MockAiProvider;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
|
||||||
|
class AiFixerManager
|
||||||
|
{
|
||||||
|
/** @var array<string, AiProviderInterface> */
|
||||||
|
private array $providers = [];
|
||||||
|
private ?string $activeProviderName = null;
|
||||||
|
|
||||||
|
public function __construct(?string $geminiApiKey = null, ?string $openAiApiKey = null, ?string $claudeApiKey = null)
|
||||||
|
{
|
||||||
|
// Register default providers
|
||||||
|
if ($geminiApiKey) {
|
||||||
|
$this->registerProvider('gemini', new GeminiProvider($geminiApiKey));
|
||||||
|
$this->activeProviderName = 'gemini';
|
||||||
|
}
|
||||||
|
if ($openAiApiKey) {
|
||||||
|
$this->registerProvider('openai', new OpenAiProvider($openAiApiKey));
|
||||||
|
if (!$this->activeProviderName) {
|
||||||
|
$this->activeProviderName = 'openai';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($claudeApiKey) {
|
||||||
|
$this->registerProvider('claude', new ClaudeProvider($claudeApiKey));
|
||||||
|
if (!$this->activeProviderName) {
|
||||||
|
$this->activeProviderName = 'claude';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always register mock provider as fallback/testing option
|
||||||
|
$this->registerProvider('mock', new MockAiProvider());
|
||||||
|
if (!$this->activeProviderName) {
|
||||||
|
$this->activeProviderName = 'mock';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerProvider(string $name, AiProviderInterface $provider): void
|
||||||
|
{
|
||||||
|
$this->providers[strtolower($name)] = $provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setActiveProvider(string $name): void
|
||||||
|
{
|
||||||
|
$key = strtolower($name);
|
||||||
|
if (isset($this->providers[$key])) {
|
||||||
|
$this->activeProviderName = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveProvider(): ?AiProviderInterface
|
||||||
|
{
|
||||||
|
if ($this->activeProviderName && isset($this->providers[$this->activeProviderName])) {
|
||||||
|
return $this->providers[$this->activeProviderName];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suggestFix(Violation $violation): ?string
|
||||||
|
{
|
||||||
|
$provider = $this->getActiveProvider();
|
||||||
|
if ($provider) {
|
||||||
|
return $provider->suggestFix($violation);
|
||||||
|
}
|
||||||
|
return "No active AI provider configured.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\AI;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
|
||||||
|
interface AiProviderInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the name of this AI provider.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getName(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest a fix for the given violation.
|
||||||
|
*
|
||||||
|
* @param Violation $violation
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function suggestFix(Violation $violation): ?string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\AI;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
|
||||||
|
class PromptBuilder
|
||||||
|
{
|
||||||
|
public static function build(Violation $violation): string
|
||||||
|
{
|
||||||
|
return <<<PROMPT
|
||||||
|
You are a WCAG 2.1 Accessibility expert.
|
||||||
|
A static analysis tool found an accessibility issue in this HTML snippet.
|
||||||
|
|
||||||
|
Snippet: {$violation->htmlSnippet}
|
||||||
|
Error Message: {$violation->message}
|
||||||
|
Rule ID: {$violation->ruleId}
|
||||||
|
Severity: {$violation->severity->value}
|
||||||
|
Standard: {$violation->standard->value}
|
||||||
|
|
||||||
|
Please provide a short explanation of the fix in Turkish and the fixed version of the HTML snippet.
|
||||||
|
Use the following EXACT format:
|
||||||
|
EXPLANATION: [A short explanation of what you fixed and why, in Turkish]
|
||||||
|
FIX: [Only the corrected HTML snippet, no surrounding explanation]
|
||||||
|
PROMPT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\AI\Providers;
|
||||||
|
|
||||||
|
use GuzzleHttp\Client as GuzzleClient;
|
||||||
|
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
|
||||||
|
use YakNet\AccessibilityConsole\AI\PromptBuilder;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
|
||||||
|
class ClaudeProvider implements AiProviderInterface
|
||||||
|
{
|
||||||
|
private ?GuzzleClient $client = null;
|
||||||
|
|
||||||
|
public function __construct(private readonly ?string $apiKey)
|
||||||
|
{
|
||||||
|
if ($this->apiKey) {
|
||||||
|
$this->client = new GuzzleClient([
|
||||||
|
'base_uri' => 'https://api.anthropic.com/v1/',
|
||||||
|
'headers' => [
|
||||||
|
'x-api-key' => $this->apiKey,
|
||||||
|
'anthropic-version' => '2023-06-01',
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
],
|
||||||
|
'timeout' => 10.0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'Anthropic Claude';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suggestFix(Violation $violation): ?string
|
||||||
|
{
|
||||||
|
if (!$this->client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prompt = PromptBuilder::build($violation);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->client->post('messages', [
|
||||||
|
'json' => [
|
||||||
|
'model' => 'claude-3-5-haiku-20241022',
|
||||||
|
'max_tokens' => 1000,
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'user', 'content' => $prompt]
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = json_decode($response->getBody()->getContents(), true);
|
||||||
|
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;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return "Claude Error: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\AI\Providers;
|
||||||
|
|
||||||
|
use Gemini;
|
||||||
|
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
|
||||||
|
use YakNet\AccessibilityConsole\AI\PromptBuilder;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
|
||||||
|
class GeminiProvider implements AiProviderInterface
|
||||||
|
{
|
||||||
|
private ?\Gemini\Client $client = null;
|
||||||
|
|
||||||
|
public function __construct(private readonly ?string $apiKey)
|
||||||
|
{
|
||||||
|
if ($this->apiKey) {
|
||||||
|
try {
|
||||||
|
$this->client = \Gemini::client($this->apiKey);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->client = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'Google Gemini';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suggestFix(Violation $violation): ?string
|
||||||
|
{
|
||||||
|
if (!$this->apiKey || !$this->client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prompt = PromptBuilder::build($violation);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->client->generativeModel('gemini-3.1-flash-lite')->generateContent($prompt);
|
||||||
|
return trim($result->text());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return "Gemini Error: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\AI\Providers;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
|
||||||
|
class MockAiProvider implements AiProviderInterface
|
||||||
|
{
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'Mock AI';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suggestFix(Violation $violation): ?string
|
||||||
|
{
|
||||||
|
$snippet = $violation->htmlSnippet;
|
||||||
|
$fixedSnippet = $snippet;
|
||||||
|
|
||||||
|
// Simple mock fixes based on rule ID
|
||||||
|
if ($violation->ruleId === 'WCAG_2_2_2_BLINK') {
|
||||||
|
$fixedSnippet = str_replace(['<blink>', '</blink>'], ['<span>', '</span>'], $snippet);
|
||||||
|
} elseif ($violation->ruleId === 'WCAG_1_3_1_DUPLICATE_H1') {
|
||||||
|
$fixedSnippet = str_replace(['<h1>', '</h1>'], ['<h2>', '</h2>'], $snippet);
|
||||||
|
} elseif ($violation->ruleId === 'WCAG_1_3_5_AUTOCOMPLETE') {
|
||||||
|
$fixedSnippet = str_replace('<input', '<input autocomplete="email"', $snippet);
|
||||||
|
} elseif ($violation->ruleId === 'WCAG_2_1_1_SCROLLABLE_FOCUS') {
|
||||||
|
$fixedSnippet = str_replace('<div', '<div tabindex="0"', $snippet);
|
||||||
|
} elseif ($violation->ruleId === 'WCAG_4_1_2_ARIA_LABELLEDBY') {
|
||||||
|
$fixedSnippet = str_replace('aria-labelledby="non_existent_label"', 'aria-labelledby="real_label"', $snippet);
|
||||||
|
} elseif ($violation->ruleId === 'WCAG_1_1_1_ALT_PLACEHOLDER') {
|
||||||
|
if (str_contains($snippet, 'alt="logo"')) {
|
||||||
|
$fixedSnippet = str_replace('alt="logo"', 'alt="YakNet Accessibility Console Company Logo"', $snippet);
|
||||||
|
} else {
|
||||||
|
$fixedSnippet = str_replace('alt="header_banner.jpg"', 'alt="YakNet Accessibility Console Marketing Banner"', $snippet);
|
||||||
|
}
|
||||||
|
} elseif ($violation->ruleId === 'WCAG_1_1_1_SVG') {
|
||||||
|
$fixedSnippet = str_replace('<svg', '<svg aria-hidden="true"', $snippet);
|
||||||
|
} elseif ($violation->ruleId === 'WCAG_1_3_1_TABLE_SUMMARY') {
|
||||||
|
$fixedSnippet = preg_replace('/summary=".*?"/i', '', $snippet) ?: $snippet;
|
||||||
|
} elseif ($violation->ruleId === 'WCAG_1_3_1_PRESENTATION_TAGS') {
|
||||||
|
$fixedSnippet = str_replace(['<center>', '</center>', '<strike>', '</strike>'], ['<span style="text-align: center; display: block;">', '</span>', '<span style="text-decoration: line-through;">', '</span>'], $snippet);
|
||||||
|
} elseif ($violation->ruleId === 'WCAG_2_4_3_AUTOFOCUS') {
|
||||||
|
$fixedSnippet = str_replace(' autofocus', '', $snippet);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "EXPLANATION: Mocked correction for accessibility validation.\nFIX: " . $fixedSnippet;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\AI\Providers;
|
||||||
|
|
||||||
|
use GuzzleHttp\Client as GuzzleClient;
|
||||||
|
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
|
||||||
|
use YakNet\AccessibilityConsole\AI\PromptBuilder;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
|
||||||
|
class OpenAiProvider implements AiProviderInterface
|
||||||
|
{
|
||||||
|
private ?GuzzleClient $client = null;
|
||||||
|
|
||||||
|
public function __construct(private readonly ?string $apiKey)
|
||||||
|
{
|
||||||
|
if ($this->apiKey) {
|
||||||
|
$this->client = new GuzzleClient([
|
||||||
|
'base_uri' => 'https://api.openai.com/v1/',
|
||||||
|
'headers' => [
|
||||||
|
'Authorization' => 'Bearer ' . $this->apiKey,
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
],
|
||||||
|
'timeout' => 10.0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'OpenAI GPT';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function suggestFix(Violation $violation): ?string
|
||||||
|
{
|
||||||
|
if (!$this->client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prompt = PromptBuilder::build($violation);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->client->post('chat/completions', [
|
||||||
|
'json' => [
|
||||||
|
'model' => 'gpt-4o-mini',
|
||||||
|
'messages' => [
|
||||||
|
['role' => 'user', 'content' => $prompt]
|
||||||
|
],
|
||||||
|
'temperature' => 0.2,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = json_decode($response->getBody()->getContents(), true);
|
||||||
|
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;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return "OpenAI Error: " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use Symfony\Component\Console\Input\InputInterface;
|
|||||||
use Symfony\Component\Console\Input\InputOption;
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
use YakNet\AccessibilityConsole\AI\GeminiFixer;
|
use YakNet\AccessibilityConsole\AI\AiFixerManager;
|
||||||
use YakNet\AccessibilityConsole\Core\Analyser;
|
use YakNet\AccessibilityConsole\Core\Analyser;
|
||||||
use YakNet\AccessibilityConsole\Core\Config;
|
use YakNet\AccessibilityConsole\Core\Config;
|
||||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||||
@@ -89,7 +89,8 @@ class FixCommand extends Command
|
|||||||
|
|
||||||
$io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations)));
|
$io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations)));
|
||||||
|
|
||||||
$fixer = new GeminiFixer($apiKey);
|
$fixer = new AiFixerManager($apiKey);
|
||||||
|
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
|
||||||
$fixedCount = 0;
|
$fixedCount = 0;
|
||||||
|
|
||||||
// Group violations by file path
|
// Group violations by file path
|
||||||
@@ -116,27 +117,49 @@ class FixCommand extends Command
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
|
||||||
|
|
||||||
foreach ($fileViolations as $violation) {
|
foreach ($fileViolations as $violation) {
|
||||||
$line = $violation->location['line'] ?? 0;
|
$line = $violation->location['line'] ?? 0;
|
||||||
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
|
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
|
||||||
|
|
||||||
// Get AI Suggestion
|
// Try Local Healer first
|
||||||
$rawSuggestion = $fixer->suggestFix($violation);
|
$rawSuggestion = $localHealer->heal($violation);
|
||||||
|
$isLocalFix = false;
|
||||||
|
|
||||||
|
if ($rawSuggestion !== null) {
|
||||||
|
$isLocalFix = true;
|
||||||
|
} else {
|
||||||
|
// Fallback to AI Suggestion
|
||||||
|
$rawSuggestion = $fixer->suggestFix($violation);
|
||||||
|
}
|
||||||
|
|
||||||
$suggestion = $rawSuggestion;
|
$suggestion = $rawSuggestion;
|
||||||
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
|
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
|
||||||
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
||||||
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
||||||
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
|
if (!is_string($suggestion)) {
|
||||||
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$io->writeln("<fg=green>Fix Found!</>");
|
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
|
||||||
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>");
|
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||||
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>");
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isLocalFix) {
|
||||||
|
$io->writeln("<fg=green>Local Fix Found!</>");
|
||||||
|
} else {
|
||||||
|
$io->writeln("<fg=green>AI Fix Found!</>");
|
||||||
|
}
|
||||||
|
$io->writeln(" <fg=gray>Diff:</>");
|
||||||
|
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
|
||||||
|
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
|
||||||
|
$io->writeln($indentedDiff);
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
|
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
|
||||||
@@ -210,7 +233,8 @@ class FixCommand extends Command
|
|||||||
|
|
||||||
$io->note("Found " . count($violations) . " violations. Starting AI analysis...");
|
$io->note("Found " . count($violations) . " violations. Starting AI analysis...");
|
||||||
|
|
||||||
$fixer = new GeminiFixer($apiKey);
|
$fixer = new AiFixerManager($apiKey);
|
||||||
|
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
|
||||||
$locator = new SourceLocator($projectPath);
|
$locator = new SourceLocator($projectPath);
|
||||||
$fixedCount = 0;
|
$fixedCount = 0;
|
||||||
|
|
||||||
@@ -224,23 +248,42 @@ class FixCommand extends Command
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get AI Suggestion
|
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
|
||||||
$rawSuggestion = $fixer->suggestFix($violation);
|
$rawSuggestion = $localHealer->heal($violation);
|
||||||
|
$isLocalFix = false;
|
||||||
|
|
||||||
|
if ($rawSuggestion !== null) {
|
||||||
|
$isLocalFix = true;
|
||||||
|
} else {
|
||||||
|
$rawSuggestion = $fixer->suggestFix($violation);
|
||||||
|
}
|
||||||
|
|
||||||
$suggestion = $rawSuggestion;
|
$suggestion = $rawSuggestion;
|
||||||
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
|
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
|
||||||
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
||||||
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
||||||
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
|
if (!is_string($suggestion)) {
|
||||||
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$io->writeln("<fg=green>Fix Found!</>");
|
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
|
||||||
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>");
|
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||||
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>");
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isLocalFix) {
|
||||||
|
$io->writeln("<fg=green>Local Fix Found!</>");
|
||||||
|
} else {
|
||||||
|
$io->writeln("<fg=green>AI Fix Found!</>");
|
||||||
|
}
|
||||||
|
$io->writeln(" <fg=gray>Diff:</>");
|
||||||
|
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
|
||||||
|
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
|
||||||
|
$io->writeln($indentedDiff);
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
|
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
|||||||
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
|
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
|
||||||
use YakNet\AccessibilityConsole\Reporting\GithubFormatter;
|
use YakNet\AccessibilityConsole\Reporting\GithubFormatter;
|
||||||
use YakNet\AccessibilityConsole\Reporting\JsonFormatter;
|
use YakNet\AccessibilityConsole\Reporting\JsonFormatter;
|
||||||
|
use YakNet\AccessibilityConsole\Reporting\XmlFormatter;
|
||||||
|
use YakNet\AccessibilityConsole\Reporting\MarkdownFormatter;
|
||||||
|
use YakNet\AccessibilityConsole\Reporting\CsvFormatter;
|
||||||
|
use YakNet\AccessibilityConsole\Reporting\GitLabCodeQualityFormatter;
|
||||||
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
||||||
|
|
||||||
class ScanCommand extends Command
|
class ScanCommand extends Command
|
||||||
@@ -202,10 +206,29 @@ class ScanCommand extends Command
|
|||||||
$formatter = new JsonFormatter();
|
$formatter = new JsonFormatter();
|
||||||
} elseif ($format === 'github') {
|
} elseif ($format === 'github') {
|
||||||
$formatter = new GithubFormatter();
|
$formatter = new GithubFormatter();
|
||||||
|
} elseif ($format === 'xml') {
|
||||||
|
$formatter = new XmlFormatter();
|
||||||
|
} elseif ($format === 'markdown') {
|
||||||
|
$formatter = new MarkdownFormatter();
|
||||||
|
} elseif ($format === 'csv') {
|
||||||
|
$formatter = new CsvFormatter();
|
||||||
|
} elseif ($format === 'gitlab') {
|
||||||
|
$formatter = new GitLabCodeQualityFormatter();
|
||||||
}
|
}
|
||||||
|
|
||||||
$exitCode = $formatter->format($violations, $baselinedCount, $io);
|
$exitCode = $formatter->format($violations, $baselinedCount, $io);
|
||||||
|
|
||||||
|
if ($format === 'console') {
|
||||||
|
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
|
||||||
|
$io->section("Accessibility Ratings");
|
||||||
|
$color = $metrics['score'] >= 90 ? 'green' : ($metrics['score'] >= 70 ? 'yellow' : 'red');
|
||||||
|
$io->writeln(sprintf("Health Score: <fg=%s>%d/100</>", $color, $metrics['score']));
|
||||||
|
$io->writeln(sprintf(" - Screen Reader / Visual Impact: <fg=cyan>%d%%</>", $metrics['visualImpact']));
|
||||||
|
$io->writeln(sprintf(" - Keyboard / Motor Impact: <fg=cyan>%d%%</>", $metrics['motorImpact']));
|
||||||
|
$io->writeln(sprintf(" - Cognitive / Distraction Impact: <fg=cyan>%d%%</>", $metrics['cognitiveImpact']));
|
||||||
|
$io->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
// Export report if --report is specified
|
// Export report if --report is specified
|
||||||
$reportPath = $input->getOption('report');
|
$reportPath = $input->getOption('report');
|
||||||
if (is_string($reportPath) && $reportPath !== '') {
|
if (is_string($reportPath) && $reportPath !== '') {
|
||||||
@@ -279,9 +302,6 @@ class ScanCommand extends Command
|
|||||||
|
|
||||||
while (!empty($queue) && count($visited) < $maxPages) {
|
while (!empty($queue) && count($visited) < $maxPages) {
|
||||||
$current = array_shift($queue);
|
$current = array_shift($queue);
|
||||||
if (!is_array($current)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = $current['url'];
|
$url = $current['url'];
|
||||||
$depth = (int)$current['depth'];
|
$depth = (int)$current['depth'];
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Core\Baseline;
|
||||||
|
|
||||||
|
class FuzzyBaselineMatcher
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if two HTML snippets are fuzzy matched.
|
||||||
|
*
|
||||||
|
* @param string $snippetA
|
||||||
|
* @param string $snippetB
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function match(string $snippetA, string $snippetB): bool
|
||||||
|
{
|
||||||
|
$normA = self::normalize($snippetA);
|
||||||
|
$normB = self::normalize($snippetB);
|
||||||
|
|
||||||
|
if ($normA === '' || $normB === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normA === $normB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize HTML snippet by stripping formatting, converting to lowercase,
|
||||||
|
* and sorting attributes.
|
||||||
|
*
|
||||||
|
* @param string $html
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function normalize(string $html): string
|
||||||
|
{
|
||||||
|
$html = trim($html);
|
||||||
|
if ($html === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse using simple regex to sort attributes and lowercase tag
|
||||||
|
if (preg_match('/^<([a-zA-Z0-9:-]+)(.*?)>$/s', $html, $matches)) {
|
||||||
|
$tag = strtolower($matches[1]);
|
||||||
|
$attrsContent = $matches[2];
|
||||||
|
|
||||||
|
// Parse attributes
|
||||||
|
preg_match_all('/([a-zA-Z0-9:-]+)=["\'](.*?)["\']/s', $attrsContent, $attrMatches, PREG_SET_ORDER);
|
||||||
|
|
||||||
|
$attrs = [];
|
||||||
|
foreach ($attrMatches as $match) {
|
||||||
|
$attrs[strtolower($match[1])] = trim($match[2]);
|
||||||
|
}
|
||||||
|
ksort($attrs);
|
||||||
|
|
||||||
|
$attrStr = '';
|
||||||
|
foreach ($attrs as $name => $value) {
|
||||||
|
$attrStr .= sprintf(' %s="%s"', $name, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('<%s%s>', $tag, $attrStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Strip all whitespace and lowercase
|
||||||
|
$cleaned = preg_replace('/\s+/', '', $html) ?: '';
|
||||||
|
return strtolower($cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,12 @@ class BaselineManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($data['violations'] as $item) {
|
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']);
|
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
|
||||||
$this->ignoredKeys[$key] = true;
|
$this->ignoredKeys[$key] = true;
|
||||||
}
|
}
|
||||||
@@ -82,9 +87,9 @@ class BaselineManager
|
|||||||
|
|
||||||
private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string
|
private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string
|
||||||
{
|
{
|
||||||
// Normalize whitespaces and trim to prevent minor formatting updates from breaking baseline
|
// Normalize snippet using fuzzy baseline matcher
|
||||||
$normalizedSnippet = preg_replace('/\s+/', ' ', trim($htmlSnippet));
|
$normalizedSnippet = \YakNet\AccessibilityConsole\Core\Baseline\FuzzyBaselineMatcher::normalize($htmlSnippet);
|
||||||
if ($normalizedSnippet === null) {
|
if ($normalizedSnippet === '') {
|
||||||
$normalizedSnippet = trim($htmlSnippet);
|
$normalizedSnippet = trim($htmlSnippet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -35,6 +35,7 @@ class Config
|
|||||||
try {
|
try {
|
||||||
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
|
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
|
||||||
if (is_array($content)) {
|
if (is_array($content)) {
|
||||||
|
/** @var array<string, mixed> $content */
|
||||||
return new self($content);
|
return new self($content);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -56,7 +57,7 @@ class Config
|
|||||||
{
|
{
|
||||||
$paths = $this->get('paths');
|
$paths = $this->get('paths');
|
||||||
if (is_array($paths)) {
|
if (is_array($paths)) {
|
||||||
return $paths;
|
return array_values(array_filter($paths, 'is_string'));
|
||||||
}
|
}
|
||||||
if (is_string($paths)) {
|
if (is_string($paths)) {
|
||||||
return [$paths];
|
return [$paths];
|
||||||
@@ -71,7 +72,7 @@ class Config
|
|||||||
{
|
{
|
||||||
$exclude = $this->get('exclude_paths');
|
$exclude = $this->get('exclude_paths');
|
||||||
if (is_array($exclude)) {
|
if (is_array($exclude)) {
|
||||||
return $exclude;
|
return array_values(array_filter($exclude, 'is_string'));
|
||||||
}
|
}
|
||||||
if (is_string($exclude)) {
|
if (is_string($exclude)) {
|
||||||
return [$exclude];
|
return [$exclude];
|
||||||
@@ -108,10 +109,10 @@ class Config
|
|||||||
|
|
||||||
if (is_array($rules)) {
|
if (is_array($rules)) {
|
||||||
if (isset($rules['include']) && is_array($rules['include'])) {
|
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'])) {
|
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
|
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,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Core\Config;
|
||||||
|
|
||||||
|
class SmartPresetBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Analyze HTML content features to suggest rules presets.
|
||||||
|
*
|
||||||
|
* @param string $html
|
||||||
|
* @return array{suggestedLevel: int, tagsCheckedCount: array<string, int>, recommendedExclusions: string[]}
|
||||||
|
*/
|
||||||
|
public function analyze(string $html): array
|
||||||
|
{
|
||||||
|
$tagsChecked = ['img' => 0, 'form' => 0, 'table' => 0, 'svg' => 0, 'video' => 0];
|
||||||
|
|
||||||
|
foreach (array_keys($tagsChecked) as $tag) {
|
||||||
|
$tagsChecked[$tag] = substr_count(strtolower($html), '<' . $tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate recommendations
|
||||||
|
$suggestedLevel = 3; // Standard baseline
|
||||||
|
if ($tagsChecked['form'] > 5 || $tagsChecked['table'] > 3) {
|
||||||
|
$suggestedLevel = 4; // Higher complexity requires more strict tests
|
||||||
|
}
|
||||||
|
if ($tagsChecked['video'] > 0) {
|
||||||
|
$suggestedLevel = 5; // Video elements need caption checks, standard level 5
|
||||||
|
}
|
||||||
|
|
||||||
|
$exclusions = [];
|
||||||
|
if ($tagsChecked['svg'] === 0) {
|
||||||
|
$exclusions[] = 'WCAG_1_1_1_SVG';
|
||||||
|
}
|
||||||
|
if ($tagsChecked['video'] === 0) {
|
||||||
|
$exclusions[] = 'WCAG_1_2_2_VIDEO_TRACK';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'suggestedLevel' => $suggestedLevel,
|
||||||
|
'tagsCheckedCount' => $tagsChecked,
|
||||||
|
'recommendedExclusions' => $exclusions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Core\Container;
|
||||||
|
|
||||||
|
use DI\ContainerBuilder;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Config;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Analyser;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||||
|
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
|
||||||
|
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
|
||||||
|
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
|
||||||
|
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
|
||||||
|
|
||||||
|
class ContainerFactory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create and configure the PHP-DI container.
|
||||||
|
*
|
||||||
|
* @return ContainerInterface
|
||||||
|
*/
|
||||||
|
public static function create(): ContainerInterface
|
||||||
|
{
|
||||||
|
$builder = new ContainerBuilder();
|
||||||
|
|
||||||
|
$builder->addDefinitions([
|
||||||
|
Config::class => function () {
|
||||||
|
$configPath = getcwd() . '/a11y.yaml';
|
||||||
|
return Config::fromYaml($configPath);
|
||||||
|
},
|
||||||
|
Scanner::class => function () {
|
||||||
|
return new Scanner();
|
||||||
|
},
|
||||||
|
Analyser::class => function (ContainerInterface $c) {
|
||||||
|
$config = $c->get(Config::class);
|
||||||
|
$scanner = $c->get(Scanner::class);
|
||||||
|
return new Analyser(
|
||||||
|
$config instanceof Config ? $config : null,
|
||||||
|
$scanner instanceof Scanner ? $scanner : null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ScanCommand::class => function () {
|
||||||
|
return new ScanCommand();
|
||||||
|
},
|
||||||
|
FixCommand::class => function () {
|
||||||
|
return new FixCommand();
|
||||||
|
},
|
||||||
|
InitCommand::class => function () {
|
||||||
|
return new InitCommand();
|
||||||
|
},
|
||||||
|
RulesCommand::class => function () {
|
||||||
|
return new RulesCommand();
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $builder->build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Core\Diff;
|
||||||
|
|
||||||
|
use SebastianBergmann\Diff\Differ;
|
||||||
|
use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
|
||||||
|
|
||||||
|
class VisualDiffGenerator
|
||||||
|
{
|
||||||
|
private Differ $differ;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->differ = new Differ(new UnifiedDiffOutputBuilder("--- Original\n+++ Healed\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a colorized git-style unified diff string.
|
||||||
|
*
|
||||||
|
* @param string $original
|
||||||
|
* @param string $fixed
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function generate(string $original, string $fixed): string
|
||||||
|
{
|
||||||
|
$diff = $this->differ->diff($original, $fixed);
|
||||||
|
$lines = explode("\n", $diff);
|
||||||
|
|
||||||
|
$colorized = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (str_starts_with($line, '+') && !str_starts_with($line, '+++')) {
|
||||||
|
$colorized[] = "<fg=green>" . htmlspecialchars($line) . "</>";
|
||||||
|
} elseif (str_starts_with($line, '-') && !str_starts_with($line, '---')) {
|
||||||
|
$colorized[] = "<fg=red>" . htmlspecialchars($line) . "</>";
|
||||||
|
} elseif (str_starts_with($line, '@@')) {
|
||||||
|
$colorized[] = "<fg=cyan>" . htmlspecialchars($line) . "</>";
|
||||||
|
} elseif (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
|
||||||
|
$colorized[] = "<fg=gray>" . htmlspecialchars($line) . "</>";
|
||||||
|
} else {
|
||||||
|
$colorized[] = htmlspecialchars($line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $colorized);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Core\Metrics;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
|
||||||
|
class AccessibilityScoreCalculator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Calculate accessibility score (0-100) and user impact summaries.
|
||||||
|
*
|
||||||
|
* @param Violation[] $violations
|
||||||
|
* @return array{score: int, visualImpact: int, motorImpact: int, cognitiveImpact: int}
|
||||||
|
*/
|
||||||
|
public static function calculate(array $violations): array
|
||||||
|
{
|
||||||
|
$score = 100;
|
||||||
|
|
||||||
|
$visualCount = 0;
|
||||||
|
$motorCount = 0;
|
||||||
|
$cognitiveCount = 0;
|
||||||
|
|
||||||
|
foreach ($violations as $v) {
|
||||||
|
// Deduct points based on severity
|
||||||
|
$deduction = 3; // Warning default
|
||||||
|
if ($v->severity === Severity::CRITICAL) {
|
||||||
|
$deduction = 15;
|
||||||
|
} elseif ($v->severity === Severity::ERROR) {
|
||||||
|
$deduction = 8;
|
||||||
|
}
|
||||||
|
$score -= $deduction;
|
||||||
|
|
||||||
|
// Map rule ID to disabled user groups impact
|
||||||
|
$id = $v->ruleId;
|
||||||
|
if (self::isVisual($id)) {
|
||||||
|
$visualCount++;
|
||||||
|
}
|
||||||
|
if (self::isMotor($id)) {
|
||||||
|
$motorCount++;
|
||||||
|
}
|
||||||
|
if (self::isCognitive($id)) {
|
||||||
|
$cognitiveCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$score = max(0, $score);
|
||||||
|
|
||||||
|
// Normalize counts to percentage indicators of severity
|
||||||
|
$total = count($violations);
|
||||||
|
$visualImpact = $total > 0 ? (int)(($visualCount / $total) * 100) : 0;
|
||||||
|
$motorImpact = $total > 0 ? (int)(($motorCount / $total) * 100) : 0;
|
||||||
|
$cognitiveImpact = $total > 0 ? (int)(($cognitiveCount / $total) * 100) : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $score,
|
||||||
|
'visualImpact' => $visualImpact,
|
||||||
|
'motorImpact' => $motorImpact,
|
||||||
|
'cognitiveImpact' => $cognitiveImpact,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isVisual(string $ruleId): bool
|
||||||
|
{
|
||||||
|
$visualKeywords = ['ALT', 'LABEL', 'TITLE', 'ARIA', 'LANG', 'CONTRAST', 'COLOR', 'TEXT', 'HEADING'];
|
||||||
|
foreach ($visualKeywords as $key) {
|
||||||
|
if (str_contains($ruleId, $key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isMotor(string $ruleId): bool
|
||||||
|
{
|
||||||
|
$motorKeywords = ['ACCESSKEY', 'FOCUS', 'TABINDEX', 'KEYBOARD', 'LINK_JAVASCRIPT', 'AUTOFOCUS', 'SCROLLABLE'];
|
||||||
|
foreach ($motorKeywords as $key) {
|
||||||
|
if (str_contains($ruleId, $key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isCognitive(string $ruleId): bool
|
||||||
|
{
|
||||||
|
$cognitiveKeywords = ['MARQUEE', 'BLINK', 'REFRESH', 'AUTOPLAY', 'AUTOCOMPLETE'];
|
||||||
|
foreach ($cognitiveKeywords as $key) {
|
||||||
|
if (str_contains($ruleId, $key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Core\SelfHealing;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
|
||||||
|
class LocalHealer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Attempt to deterministically fix simple violations locally.
|
||||||
|
*
|
||||||
|
* @param Violation $violation
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function heal(Violation $violation): ?string
|
||||||
|
{
|
||||||
|
$snippet = $violation->htmlSnippet;
|
||||||
|
$ruleId = $violation->ruleId;
|
||||||
|
|
||||||
|
switch ($ruleId) {
|
||||||
|
case 'WCAG_2_2_2_BLINK':
|
||||||
|
$fixed = str_replace(
|
||||||
|
['<blink>', '</blink>', '<BLINK>', '</BLINK>'],
|
||||||
|
['<span style="text-decoration: blink;">', '</span>', '<span style="text-decoration: blink;">', '</span>'],
|
||||||
|
$snippet
|
||||||
|
);
|
||||||
|
return "EXPLANATION: Banned <blink> tag replaced with a span and text-decoration style.\nFIX: " . $fixed;
|
||||||
|
|
||||||
|
case 'WCAG_2_4_3_AUTOFOCUS':
|
||||||
|
$fixed = preg_replace('/\s+autofocus(=["\'](.*?)["\'])?/i', '', $snippet) ?: $snippet;
|
||||||
|
return "EXPLANATION: Autofocus attribute removed to prevent screen reader interruption on load.\nFIX: " . $fixed;
|
||||||
|
|
||||||
|
case 'WCAG_1_3_1_TABLE_SUMMARY':
|
||||||
|
$fixed = preg_replace('/\s+summary=["\'](.*?)["\']/i', '', $snippet) ?: $snippet;
|
||||||
|
return "EXPLANATION: Deprecated table summary attribute removed. Use caption elements instead.\nFIX: " . $fixed;
|
||||||
|
|
||||||
|
case 'WCAG_2_1_1_SCROLLABLE_FOCUS':
|
||||||
|
if (!str_contains($snippet, 'tabindex')) {
|
||||||
|
$fixed = preg_replace('/(style=["\'](.*?)["\'])/i', '$1 tabindex="0"', $snippet) ?: $snippet;
|
||||||
|
return "EXPLANATION: Added tabindex=\"0\" to scrollable region to allow keyboard scrolling.\nFIX: " . $fixed;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'WCAG_1_3_1_PRESENTATION_TAGS':
|
||||||
|
if (preg_match('/<center>(.*?)<\/center>/is', $snippet, $matches)) {
|
||||||
|
$fixed = '<div style="text-align: center;">' . $matches[1] . '</div>';
|
||||||
|
return "EXPLANATION: Center element replaced with div and text-align CSS.\nFIX: " . $fixed;
|
||||||
|
}
|
||||||
|
if (preg_match('/<strike>(.*?)<\/strike>/is', $snippet, $matches)) {
|
||||||
|
$fixed = '<span style="text-decoration: line-through;">' . $matches[1] . '</span>';
|
||||||
|
return "EXPLANATION: Strike element replaced with span and line-through decoration CSS.\nFIX: " . $fixed;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'WCAG_3_1_1_LANG':
|
||||||
|
if (preg_match('/<html(\s+[^>]*>|>)/i', $snippet)) {
|
||||||
|
$fixed = preg_replace('/<html/i', '<html lang="en"', $snippet) ?: $snippet;
|
||||||
|
return "EXPLANATION: Injected default lang=\"en\" to HTML element.\nFIX: " . $fixed;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Parser;
|
||||||
|
|
||||||
|
class BladeParser implements ParserInterface
|
||||||
|
{
|
||||||
|
public function parse(string $content): string
|
||||||
|
{
|
||||||
|
// Replace {!! ... !!} with static text
|
||||||
|
$content = preg_replace('/\{\!\!(.*?)\!\!\}/s', 'StaticRawBladeContent', $content) ?? $content;
|
||||||
|
|
||||||
|
// Replace {{ ... }} with static text
|
||||||
|
$content = preg_replace('/\{\{\s*(.*?)\s*\}\}/s', 'StaticBladeContent', $content) ?? $content;
|
||||||
|
|
||||||
|
// Strip Blade directives like @if, @else, @endif, @foreach, @endforeach, etc.
|
||||||
|
$content = preg_replace('/@[a-zA-Z0-9_]+\s*(\(.*?\))?/i', '', $content) ?? $content;
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Parser;
|
||||||
|
|
||||||
|
class HtmlParser implements ParserInterface
|
||||||
|
{
|
||||||
|
public function parse(string $content): string
|
||||||
|
{
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Parser;
|
||||||
|
|
||||||
|
interface ParserInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Parse template file contents and strip out dynamic content placeholders
|
||||||
|
* to return clean, standard HTML for scanning.
|
||||||
|
*
|
||||||
|
* @param string $content
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function parse(string $content): string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Parser;
|
||||||
|
|
||||||
|
class TwigParser implements ParserInterface
|
||||||
|
{
|
||||||
|
public function parse(string $content): string
|
||||||
|
{
|
||||||
|
// Strip Twig comments {# ... #}
|
||||||
|
$content = preg_replace('/\{\#.*?\#\}/s', '', $content) ?? $content;
|
||||||
|
|
||||||
|
// Replace Twig output {{ ... }} with static text
|
||||||
|
$content = preg_replace('/\{\{\s*(.*?)\s*\}\}/s', 'StaticTwigOutput', $content) ?? $content;
|
||||||
|
|
||||||
|
// Strip Twig statements {% ... %}
|
||||||
|
$content = preg_replace('/\{\%.*?\%\}/s', '', $content) ?? $content;
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Reference;
|
||||||
|
|
||||||
|
class WcagCriterion
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $id,
|
||||||
|
public string $title,
|
||||||
|
public string $level,
|
||||||
|
public string $url,
|
||||||
|
public string $description,
|
||||||
|
public string $badExample,
|
||||||
|
public string $goodExample
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{id: string, title: string, level: string, url: string, description: string, badExample: string, goodExample: string}
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->title,
|
||||||
|
'level' => $this->level,
|
||||||
|
'url' => $this->url,
|
||||||
|
'description' => $this->description,
|
||||||
|
'badExample' => $this->badExample,
|
||||||
|
'goodExample' => $this->goodExample,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Reference;
|
||||||
|
|
||||||
|
class WcagDatabase
|
||||||
|
{
|
||||||
|
/** @var array<string, WcagCriterion> */
|
||||||
|
private static array $criteria = [];
|
||||||
|
|
||||||
|
public static function load(): void
|
||||||
|
{
|
||||||
|
if (!empty(self::$criteria)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::add(new WcagCriterion(
|
||||||
|
id: 'WCAG_2_2_2_BLINK',
|
||||||
|
title: '2.2.2 Pause, Stop, Hide',
|
||||||
|
level: 'A',
|
||||||
|
url: 'https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html',
|
||||||
|
description: 'The <blink> tag causes flashing text, presenting significant cognitive and visual accessibility barriers.',
|
||||||
|
badExample: '<blink>Blinking Text</blink>',
|
||||||
|
goodExample: '<span class="attention-style">Styled Text</span>'
|
||||||
|
));
|
||||||
|
|
||||||
|
self::add(new WcagCriterion(
|
||||||
|
id: 'WCAG_1_3_1_DUPLICATE_H1',
|
||||||
|
title: '1.3.1 Info and Relationships',
|
||||||
|
level: 'A',
|
||||||
|
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
|
||||||
|
description: 'Pages should contain exactly one h1 element to present a clear hierarchy and entry point for screen readers.',
|
||||||
|
badExample: '<h1>Title</h1><h1>Another Title</h1>',
|
||||||
|
goodExample: '<h1>Title</h1><h2>Subtitle</h2>'
|
||||||
|
));
|
||||||
|
|
||||||
|
self::add(new WcagCriterion(
|
||||||
|
id: 'WCAG_1_3_5_AUTOCOMPLETE',
|
||||||
|
title: '1.3.5 Identify Input Purpose',
|
||||||
|
level: 'AA',
|
||||||
|
url: 'https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html',
|
||||||
|
description: 'Inputs collecting user personal info must have autocomplete attributes to assist users with cognitive impairments.',
|
||||||
|
badExample: '<input type="email" name="email">',
|
||||||
|
goodExample: '<input type="email" name="email" autocomplete="email">'
|
||||||
|
));
|
||||||
|
|
||||||
|
self::add(new WcagCriterion(
|
||||||
|
id: 'WCAG_2_1_1_SCROLLABLE_FOCUS',
|
||||||
|
title: '2.1.1 Keyboard',
|
||||||
|
level: 'A',
|
||||||
|
url: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html',
|
||||||
|
description: 'Scrollable regions must be focusable using keyboard navigation (tabindex="0") so keyboard-only users can scroll them.',
|
||||||
|
badExample: '<div style="overflow: scroll; height: 100px;">Scrollable content</div>',
|
||||||
|
goodExample: '<div style="overflow: scroll; height: 100px;" tabindex="0">Scrollable content</div>'
|
||||||
|
));
|
||||||
|
|
||||||
|
self::add(new WcagCriterion(
|
||||||
|
id: 'WCAG_4_1_2_ARIA_LABELLEDBY',
|
||||||
|
title: '4.1.2 Name, Role, Value',
|
||||||
|
level: 'A',
|
||||||
|
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
|
||||||
|
description: 'aria-labelledby and aria-describedby attributes must refer to existing element IDs to prevent screen readers from reading empty contexts.',
|
||||||
|
badExample: '<button aria-labelledby="missing_id">Submit</button>',
|
||||||
|
goodExample: '<div id="btn_lbl">Submit Form</div><button aria-labelledby="btn_lbl">Submit</button>'
|
||||||
|
));
|
||||||
|
|
||||||
|
self::add(new WcagCriterion(
|
||||||
|
id: 'WCAG_1_1_1_ALT_PLACEHOLDER',
|
||||||
|
title: '1.1.1 Non-text Content',
|
||||||
|
level: 'A',
|
||||||
|
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
|
||||||
|
description: 'Alt attributes must not contain placeholder words or file names. They should provide a meaningful description of the image content.',
|
||||||
|
badExample: '<img src="logo.png" alt="logo">',
|
||||||
|
goodExample: '<img src="logo.png" alt="YakNet Company Logo">'
|
||||||
|
));
|
||||||
|
|
||||||
|
self::add(new WcagCriterion(
|
||||||
|
id: 'WCAG_1_1_1_SVG',
|
||||||
|
title: '1.1.1 Non-text Content',
|
||||||
|
level: 'A',
|
||||||
|
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
|
||||||
|
description: 'Inline SVGs must either contain alternative descriptions or be hidden from assistive technologies using aria-hidden="true".',
|
||||||
|
badExample: '<svg><circle cx="5" cy="5" r="5"></svg>',
|
||||||
|
goodExample: '<svg aria-hidden="true"><circle cx="5" cy="5" r="5"></svg>'
|
||||||
|
));
|
||||||
|
|
||||||
|
self::add(new WcagCriterion(
|
||||||
|
id: 'WCAG_1_3_1_TABLE_SUMMARY',
|
||||||
|
title: '1.3.1 Info and Relationships',
|
||||||
|
level: 'A',
|
||||||
|
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
|
||||||
|
description: 'The summary attribute on <table> is deprecated in HTML5. Use <caption> elements for tables description instead.',
|
||||||
|
badExample: '<table summary="Table Summary">',
|
||||||
|
goodExample: '<table><caption>Table Description</caption>'
|
||||||
|
));
|
||||||
|
|
||||||
|
self::add(new WcagCriterion(
|
||||||
|
id: 'WCAG_1_3_1_PRESENTATION_TAGS',
|
||||||
|
title: '1.3.1 Info and Relationships',
|
||||||
|
level: 'A',
|
||||||
|
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
|
||||||
|
description: 'Obsolete presentation tags like <center> and <strike> are banned. Use standard CSS classes or styling.',
|
||||||
|
badExample: '<center>Centered Text</center>',
|
||||||
|
goodExample: '<span style="text-align: center; display: block;">Centered Text</span>'
|
||||||
|
));
|
||||||
|
|
||||||
|
self::add(new WcagCriterion(
|
||||||
|
id: 'WCAG_2_4_3_AUTOFOCUS',
|
||||||
|
title: '2.4.3 Focus Order',
|
||||||
|
level: 'A',
|
||||||
|
url: 'https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html',
|
||||||
|
description: 'autofocus attribute can cause keyboard traps and disorient blind/low-vision users on page load. Focus should be managed contextually.',
|
||||||
|
badExample: '<input name="search" autofocus>',
|
||||||
|
goodExample: '<input name="search">'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function add(WcagCriterion $criterion): void
|
||||||
|
{
|
||||||
|
self::$criteria[$criterion->id] = $criterion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(string $ruleId): ?WcagCriterion
|
||||||
|
{
|
||||||
|
self::load();
|
||||||
|
return self::$criteria[$ruleId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, WcagCriterion>
|
||||||
|
*/
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
self::load();
|
||||||
|
return self::$criteria;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Reference;
|
||||||
|
|
||||||
|
class WcagReference
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Look up the WCAG Success Criterion for a given rule ID.
|
||||||
|
*
|
||||||
|
* @param string $ruleId
|
||||||
|
* @return WcagCriterion|null
|
||||||
|
*/
|
||||||
|
public static function lookup(string $ruleId): ?WcagCriterion
|
||||||
|
{
|
||||||
|
return WcagDatabase::get($ruleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a list of all WCAG criteria mapped in the console.
|
||||||
|
*
|
||||||
|
* @return array<string, WcagCriterion>
|
||||||
|
*/
|
||||||
|
public static function getCatalog(): array
|
||||||
|
{
|
||||||
|
return WcagDatabase::all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Reporting;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
|
||||||
|
class CsvFormatter implements FormatterInterface
|
||||||
|
{
|
||||||
|
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
|
||||||
|
{
|
||||||
|
$out = fopen('php://temp', 'w+');
|
||||||
|
if ($out === false) {
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write header
|
||||||
|
fputcsv($out, ['Rule ID', 'Severity', 'Standard', 'File', 'Line', 'Message', 'HTML Snippet', 'Fix Suggestion'], ',', '"', '\\');
|
||||||
|
|
||||||
|
foreach ($violations as $v) {
|
||||||
|
$file = $v->location['file'] ?? 'unknown';
|
||||||
|
$line = $v->location['line'] ?? 0;
|
||||||
|
fputcsv($out, [
|
||||||
|
$v->ruleId,
|
||||||
|
$v->severity->value,
|
||||||
|
$v->standard->value,
|
||||||
|
$file,
|
||||||
|
$line,
|
||||||
|
$v->message,
|
||||||
|
$v->htmlSnippet,
|
||||||
|
$v->fixSuggestion
|
||||||
|
], ',', '"', '\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
rewind($out);
|
||||||
|
$csv = stream_get_contents($out);
|
||||||
|
fclose($out);
|
||||||
|
|
||||||
|
$io->write($csv ?: '');
|
||||||
|
|
||||||
|
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
/** @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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Reporting;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
|
||||||
|
class GitLabCodeQualityFormatter implements FormatterInterface
|
||||||
|
{
|
||||||
|
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
|
||||||
|
{
|
||||||
|
$quality = [];
|
||||||
|
|
||||||
|
foreach ($violations as $v) {
|
||||||
|
$file = $v->location['file'] ?? 'unknown';
|
||||||
|
$line = $v->location['line'] ?? 1;
|
||||||
|
|
||||||
|
// Map our severity to GitLab code quality severities:
|
||||||
|
// info, minor, major, critical, blocker
|
||||||
|
$gitLabSeverity = 'major';
|
||||||
|
if ($v->severity === Severity::CRITICAL) {
|
||||||
|
$gitLabSeverity = 'critical';
|
||||||
|
} elseif ($v->severity === Severity::WARNING) {
|
||||||
|
$gitLabSeverity = 'minor';
|
||||||
|
}
|
||||||
|
|
||||||
|
$quality[] = [
|
||||||
|
'description' => sprintf('[%s] %s', $v->ruleId, $v->message),
|
||||||
|
'fingerprint' => md5($v->ruleId . ':' . $file . ':' . $line . ':' . $v->htmlSnippet),
|
||||||
|
'severity' => $gitLabSeverity,
|
||||||
|
'location' => [
|
||||||
|
'path' => $file,
|
||||||
|
'lines' => [
|
||||||
|
'begin' => $line
|
||||||
|
]
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->write((string)json_encode($quality, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Reporting;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
|
||||||
|
class MarkdownFormatter implements FormatterInterface
|
||||||
|
{
|
||||||
|
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
|
||||||
|
{
|
||||||
|
$md = [];
|
||||||
|
$md[] = "# Accessibility Scan Report";
|
||||||
|
$md[] = "";
|
||||||
|
$md[] = sprintf("- **Total Violations:** %d", count($violations));
|
||||||
|
$md[] = sprintf("- **Baselined Violations:** %d", $baselinedCount);
|
||||||
|
$md[] = "";
|
||||||
|
$md[] = "## Detailed Violations";
|
||||||
|
$md[] = "";
|
||||||
|
|
||||||
|
if (empty($violations)) {
|
||||||
|
$md[] = "*No violations found!* 🎉";
|
||||||
|
} else {
|
||||||
|
foreach ($violations as $index => $v) {
|
||||||
|
$file = $v->location['file'] ?? 'unknown';
|
||||||
|
$line = $v->location['line'] ?? 0;
|
||||||
|
$md[] = sprintf("### %d. %s [%s - %s]", $index + 1, $v->ruleId, strtoupper($v->severity->value), $v->standard->value);
|
||||||
|
$md[] = sprintf("- **File:** `%s` on line %d", $file, $line);
|
||||||
|
$md[] = sprintf("- **Message:** %s", $v->message);
|
||||||
|
if ($v->fixSuggestion) {
|
||||||
|
$md[] = sprintf("- **Fix Suggestion:** %s", $v->fixSuggestion);
|
||||||
|
}
|
||||||
|
$md[] = "- **Snippet:**";
|
||||||
|
$md[] = " ```html";
|
||||||
|
$md[] = " " . trim($v->htmlSnippet);
|
||||||
|
$md[] = " ```";
|
||||||
|
$md[] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->write(implode("\n", $md));
|
||||||
|
|
||||||
|
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
/** @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 charset='utf-8'>
|
||||||
|
<title>WCAG 2.1 Erişilebilirlik Sertifikası ve Raporu</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; color: #1e293b; line-height: 1.5; font-size: 12px; margin: 0; padding: 20px; }
|
||||||
|
.header { text-align: center; border-bottom: 2px solid #38bdf8; padding-bottom: 15px; margin-bottom: 20px; }
|
||||||
|
.title { font-size: 20px; font-weight: bold; color: #0f172a; }
|
||||||
|
.subtitle { font-size: 13px; color: #64748b; margin-top: 4px; }
|
||||||
|
.badge { display: inline-block; padding: 6px 16px; border-radius: 12px; color: #fff; font-weight: bold; font-size: 14px; 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Reporting;
|
||||||
|
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
|
||||||
|
class XmlFormatter implements FormatterInterface
|
||||||
|
{
|
||||||
|
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
|
||||||
|
{
|
||||||
|
$dom = new \DOMDocument('1.0', 'utf-8');
|
||||||
|
$dom->formatOutput = true;
|
||||||
|
|
||||||
|
$root = $dom->createElement('report');
|
||||||
|
$root->setAttribute('totalViolations', (string)count($violations));
|
||||||
|
$root->setAttribute('baselinedViolations', (string)$baselinedCount);
|
||||||
|
$dom->appendChild($root);
|
||||||
|
|
||||||
|
$violationsNode = $dom->createElement('violations');
|
||||||
|
$root->appendChild($violationsNode);
|
||||||
|
|
||||||
|
foreach ($violations as $v) {
|
||||||
|
$node = $dom->createElement('violation');
|
||||||
|
$node->setAttribute('ruleId', $v->ruleId);
|
||||||
|
$node->setAttribute('severity', $v->severity->value);
|
||||||
|
$node->setAttribute('standard', $v->standard->value);
|
||||||
|
|
||||||
|
$msg = $dom->createElement('message', htmlspecialchars($v->message));
|
||||||
|
$node->appendChild($msg);
|
||||||
|
|
||||||
|
$file = $v->location['file'] ?? 'unknown';
|
||||||
|
$line = $v->location['line'] ?? 0;
|
||||||
|
|
||||||
|
$loc = $dom->createElement('location');
|
||||||
|
$loc->setAttribute('file', $file);
|
||||||
|
$loc->setAttribute('line', (string)$line);
|
||||||
|
$node->appendChild($loc);
|
||||||
|
|
||||||
|
$snippet = $dom->createElement('htmlSnippet');
|
||||||
|
$snippet->appendChild($dom->createCDATASection($v->htmlSnippet));
|
||||||
|
$node->appendChild($snippet);
|
||||||
|
|
||||||
|
if ($v->fixSuggestion) {
|
||||||
|
$sug = $dom->createElement('suggestion', htmlspecialchars($v->fixSuggestion));
|
||||||
|
$node->appendChild($sug);
|
||||||
|
}
|
||||||
|
|
||||||
|
$violationsNode->appendChild($node);
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->write($dom->saveXML() ?: '');
|
||||||
|
|
||||||
|
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,18 @@ class AriaHiddenFocusable extends AbstractRule
|
|||||||
return $type !== 'hidden' && !$element->hasAttribute('disabled');
|
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') {
|
if ($tagName === 'iframe') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class AriaLabelledbyExists extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_4_1_2_ARIA_LABELLEDBY'; }
|
||||||
|
public function getDescription(): string { return 'aria-labelledby and aria-describedby attributes must refer to existing element IDs.'; }
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$attrs = ['aria-labelledby', 'aria-describedby'];
|
||||||
|
foreach ($attrs as $attr) {
|
||||||
|
if ($element->hasAttribute($attr)) {
|
||||||
|
$ids = preg_split('/\s+/', trim($element->getAttribute($attr)));
|
||||||
|
if ($ids === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$doc = $element->ownerDocument;
|
||||||
|
if (!$doc) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $doc->getElementById($id);
|
||||||
|
if (!$target) {
|
||||||
|
// XPath fallback in case DOMDocument getElementById fails (which is common without DTD/XML schemas)
|
||||||
|
$xpath = new \DOMXPath($doc);
|
||||||
|
$query = $xpath->query("//*[@id='" . addslashes($id) . "']");
|
||||||
|
if ($query === false || $query->length === 0) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
"The attribute $attr refers to a non-existent ID: \"$id\".",
|
||||||
|
"Ensure the element referenced by ID \"$id\" exists in the document, or correct the ID spelling."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class AutocompleteValid extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_1_3_5_AUTOCOMPLETE'; }
|
||||||
|
public function getDescription(): string { return 'Input fields that collect common user information must have a valid autocomplete attribute.'; }
|
||||||
|
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) !== 'input') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = strtolower($element->getAttribute('type') ?: 'text');
|
||||||
|
if (in_array($type, ['hidden', 'submit', 'button', 'reset', 'file', 'checkbox', 'radio', 'image'], true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = strtolower($element->getAttribute('name'));
|
||||||
|
$id = strtolower($element->getAttribute('id'));
|
||||||
|
|
||||||
|
$personalFields = ['email', 'tel', 'phone', 'zip', 'postal', 'address', 'username', 'password', 'name', 'fname', 'lname', 'search'];
|
||||||
|
$isPersonal = false;
|
||||||
|
|
||||||
|
if ($type === 'email' || $type === 'tel' || $type === 'password') {
|
||||||
|
$isPersonal = true;
|
||||||
|
} else {
|
||||||
|
foreach ($personalFields as $field) {
|
||||||
|
if (str_contains($name, $field) || str_contains($id, $field)) {
|
||||||
|
$isPersonal = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isPersonal && !$element->hasAttribute('autocomplete')) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Add a valid autocomplete attribute (e.g., autocomplete="email" or autocomplete="current-password").'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 AutofocusBanned extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_2_4_3_AUTOFOCUS'; }
|
||||||
|
public function getDescription(): string { return 'Form controls should not use the autofocus attribute as it disorients screen reader and keyboard users.'; }
|
||||||
|
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('autofocus')) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Remove the autofocus attribute and let the user navigate to the form field naturally, or use script-based focus with care.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
use YakNet\AccessibilityConsole\Core\AbstractRule;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class BannedSelectors extends AbstractRule
|
||||||
|
{
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $bannedSelectors = [
|
||||||
|
'center' => 'Obsolete <center> tag. Use CSS text-align instead.',
|
||||||
|
'blink' => 'Obsolete <blink> tag. Avoid blinking/flashing content.',
|
||||||
|
'marquee' => 'Obsolete <marquee> tag. Avoid moving content.',
|
||||||
|
'font' => 'Obsolete <font> tag. Use CSS font styling instead.',
|
||||||
|
'*[align]' => 'Deprecated align attribute. Use CSS text-align or float instead.',
|
||||||
|
'*[bgcolor]' => 'Deprecated bgcolor attribute. Use CSS background-color instead.',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function check(\DOMDocument $doc): array
|
||||||
|
{
|
||||||
|
$violations = [];
|
||||||
|
$crawler = new Crawler($doc);
|
||||||
|
|
||||||
|
foreach ($this->bannedSelectors as $selector => $message) {
|
||||||
|
try {
|
||||||
|
$nodes = $crawler->filter($selector);
|
||||||
|
foreach ($nodes as $node) {
|
||||||
|
if ($node instanceof \DOMElement) {
|
||||||
|
$violations[] = $this->createViolation(
|
||||||
|
sprintf('[Banned Selector: "%s"] %s', $selector, $message),
|
||||||
|
$node
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Skip invalid selector configurations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
public function getLevel(): int { return 2; }
|
||||||
|
}
|
||||||
@@ -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 BlinkTagBanned extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_2_2_2_BLINK'; }
|
||||||
|
public function getDescription(): string { return 'The <blink> tag is obsolete and should not be used as it causes text to flash/blink.'; }
|
||||||
|
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) === 'blink') {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Remove the <blink> tag and use CSS styling/animations if needed, or preferably avoid blinking content.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,10 +27,63 @@ class ButtonName extends AbstractRule
|
|||||||
}
|
}
|
||||||
|
|
||||||
$text = trim($element->textContent);
|
$text = trim($element->textContent);
|
||||||
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
|
if ($text !== '') {
|
||||||
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
|
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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,9 +249,56 @@ class ColorContrast extends AbstractRule
|
|||||||
}
|
}
|
||||||
|
|
||||||
$colors = [
|
$colors = [
|
||||||
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
|
'aliceblue' => '#f0f8ff', 'antiquewhite' => '#faebd7', 'aqua' => '#00ffff',
|
||||||
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
|
'aquamarine' => '#7fffd4', 'azure' => '#f0ffff', 'beige' => '#f5f5dc',
|
||||||
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
|
'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)])) {
|
if (isset($colors[strtolower($val)])) {
|
||||||
return $colors[strtolower($val)];
|
return $colors[strtolower($val)];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 DuplicateH1 extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_1_3_1_DUPLICATE_H1'; }
|
||||||
|
public function getDescription(): string { return 'Pages should not contain more than one h1 element.'; }
|
||||||
|
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) !== 'h1') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$doc = $element->ownerDocument;
|
||||||
|
if ($doc) {
|
||||||
|
$h1s = $doc->getElementsByTagName('h1');
|
||||||
|
if ($h1s->length > 1 && $h1s->item(0) !== $element) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Change this heading to an h2 or lower level heading, keeping only one h1 per page.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -27,18 +27,64 @@ class EmptyLink extends AbstractRule
|
|||||||
}
|
}
|
||||||
|
|
||||||
$text = trim($element->textContent);
|
$text = trim($element->textContent);
|
||||||
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
|
if ($text !== '') {
|
||||||
// Check for images inside
|
return null;
|
||||||
$imgs = $element->getElementsByTagName('img');
|
}
|
||||||
foreach ($imgs as $img) {
|
|
||||||
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
|
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 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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,8 +40,8 @@ class FormLabel extends AbstractRule
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for aria-label or aria-labelledby
|
// Check for aria-label, aria-labelledby, or title
|
||||||
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
|
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class FormSubmitButton extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_3_2_2_FORM_SUBMIT'; }
|
||||||
|
public function getDescription(): string { return 'Every form must contain a submit button to enable keyboard submission.'; }
|
||||||
|
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) !== 'form') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasSubmit = false;
|
||||||
|
|
||||||
|
$buttons = $element->getElementsByTagName('button');
|
||||||
|
foreach ($buttons as $btn) {
|
||||||
|
$type = strtolower($btn->getAttribute('type') ?: 'submit');
|
||||||
|
if ($type === 'submit') {
|
||||||
|
$hasSubmit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasSubmit) {
|
||||||
|
$inputs = $element->getElementsByTagName('input');
|
||||||
|
foreach ($inputs as $input) {
|
||||||
|
$type = strtolower($input->getAttribute('type'));
|
||||||
|
if ($type === 'submit' || $type === 'image') {
|
||||||
|
$hasSubmit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasSubmit) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Add a submit control, such as a <button type="submit"> or <input type="submit"> inside the form.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ImageAltLong extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_1_1_1_ALT_LONG'; }
|
||||||
|
public function getDescription(): string { return 'Image alt text should be concise (less than 150 characters).'; }
|
||||||
|
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) !== 'img') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$element->hasAttribute('alt')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alt = trim($element->getAttribute('alt'));
|
||||||
|
if (mb_strlen($alt) > 150) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Shorten the alt text description to be under 150 characters, and move longer explanations to standard page content.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace YakNet\AccessibilityConsole\Rules;
|
||||||
|
|
||||||
|
use DOMElement;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Severity;
|
||||||
|
use YakNet\AccessibilityConsole\Core\Violation;
|
||||||
|
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||||
|
|
||||||
|
class ImageAltPlaceholder extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_1_1_1_ALT_PLACEHOLDER'; }
|
||||||
|
public function getDescription(): string { return 'Image alt text should not be a generic placeholder or filename.'; }
|
||||||
|
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) !== 'img') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$element->hasAttribute('alt')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alt = trim($element->getAttribute('alt'));
|
||||||
|
if ($alt === '') {
|
||||||
|
return null; // Empty alt is allowed for decorative images (WCAG 1.1.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanAlt = strtolower($alt);
|
||||||
|
|
||||||
|
// Generic placeholder checks
|
||||||
|
$placeholders = [
|
||||||
|
'image', 'photo', 'picture', 'logo', 'icon', 'placeholder', 'untitled', 'temp', 'blank', 'pic', 'dot',
|
||||||
|
'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(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Provide a specific description of what the image shows, rather than using a generic word.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filename checks
|
||||||
|
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.',
|
||||||
|
'Provide a descriptive alternative text instead of the image filename.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,9 @@ class ImageAltRedundant extends AbstractRule
|
|||||||
/** @var array<int, string> */
|
/** @var array<int, string> */
|
||||||
private array $redundantWords = [
|
private array $redundantWords = [
|
||||||
'resmi', 'görüntüsü', 'fotoğrafı', 'grafiği', 'logosu', 'ikonu',
|
'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
|
public function check(DOMElement $element): ?Violation
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 LabelTargetExists extends AbstractRule
|
||||||
|
{
|
||||||
|
public function getId(): string { return 'WCAG_1_3_1_LABEL_TARGET'; }
|
||||||
|
public function getDescription(): string { return 'Label elements with a \'for\' attribute must target an existing element ID.'; }
|
||||||
|
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||||
|
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||||
|
public function getLevel(): int { return 2; }
|
||||||
|
|
||||||
|
public function check(DOMElement $element): ?Violation
|
||||||
|
{
|
||||||
|
if (strtolower($element->tagName) !== 'label') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($element->hasAttribute('for')) {
|
||||||
|
$forId = trim($element->getAttribute('for'));
|
||||||
|
if ($forId === '') {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
'Provide a valid element ID in the for attribute.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$doc = $element->ownerDocument;
|
||||||
|
if ($doc !== null) {
|
||||||
|
$xpath = new \DOMXPath($doc);
|
||||||
|
$elements = $xpath->query('//*[@id="' . $forId . '"]');
|
||||||
|
if ($elements === false || $elements->length === 0) {
|
||||||
|
return $this->createViolation(
|
||||||
|
$element,
|
||||||
|
$this->getDescription(),
|
||||||
|
"Ensure the 'for' attribute value (\"$forId\") matches the ID of a form input element."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user