Compare commits

...
28 Commits
Author SHA1 Message Date
Enes Yakıştır 164828f9f7 fix: resolve PHPStan level 9 errors and bump version to v3.1.3 2026-07-29 06:31:35 +03:00
Enes Yakıştır 6f4799dc38 fix: bump version to 3.1.2 and resolve Symfony 8 compatibility issue 2026-07-14 00:28:58 +03:00
Enes Yakıştır c79ddfcc50 chore: broaden sebastian/diff requirement for phpunit 12 compatibility 2026-07-13 11:50:38 +03:00
Enes Yakıştır 5292c2c865 release: v3.1.0 premium smart core engines and 14 new accessibility rules 2026-07-13 10:54:25 +03:00
Enes Yakıştır 7f3bf3cb7d feat: add 5 new WCAG accessibility rules and bump version to 3.0.5 2026-07-08 12:38:47 +03:00
Enes Yakıştır 8e6ea62ba7 chore: bump version to v3.0.4 and fix Symfony 8 compatibility 2026-07-07 13:00:44 +03:00
Enes Yakıştır c138cbca65 chore: remove BOM from composer.json 2026-07-07 12:59:20 +03:00
Enes Yakıştır 18aa4641d2 chore: bump version to v3.0.3 and update configurations 2026-07-07 12:47:34 +03:00
Enes Yakıştır c4745d6cb3 fix: resolve all WCAG 2.1 violations in demo/broken.html and bump version to 3.0.2
- Added lang attribute to html element (WCAG 3.1.1)
- Added descriptive page title (WCAG 2.4.2)
- Added alt attributes to all images (WCAG 1.1.1)
- Removed redundant alt text words (WCAG 1.1.1)
- Fixed empty link and button with discernible text (WCAG 2.4.4, 4.1.2)
- Removed positive tabindex value (WCAG 2.4.3)
- Eliminated duplicate id=main-content (WCAG 4.1.1)
- Fixed insufficient color contrast ratio (WCAG 1.4.3)
- Replaced invalid ARIA role cool-widget with region (WCAG 4.1.2)
- Added title attribute to iframe (WCAG 4.1.2)
- Replaced generic link texts with descriptive ones (WCAG 2.4.4)
- Added associated label for placeholder-only input (WCAG 3.3.2)
- Removed aria-hidden from focusable elements (WCAG 4.1.2)
- Added non-empty heading text (WCAG 2.4.6)
- Added video captions track element (WCAG 1.2.2)
- Added non-empty label text (WCAG 1.3.1)
- Added tabindex=0 to role=button div (WCAG 2.1.1)
2026-06-23 01:25:08 +03:00
Enes Yakıştır a2b984df04 fix: resolve autoloading in vendor execution and eliminate template false positives 2026-06-23 01:15:35 +03:00
Enes Yakıştır d5165f0be4 feat: Release v3.0.0 - PHPStan-style analysis, configuration, baseline, and advanced WCAG rules 2026-06-23 01:02:15 +03:00
Enes Yakıştır 84c6521195 fix: update Gemini model to gemini-3.1-flash-lite in GeminiFixer
bump version to 2.9.2
2026-06-07 10:49:30 +03:00
Enes Yakıştır 0134482c54 fix: resolve string/int type mismatches in HtmlDashboardReporter
- preg_replace() return value guarded with '?? ''' to eliminate string|null type
- location line number explicitly cast to (string) before concatenation
- PHPStan level 9 confirms zero errors after fix
2026-06-07 10:20:55 +03:00
Enes Yakıştır c9ffbdba08 feat: extend scan level range 1-9 and confirm PHPStan level 9 zero errors 2026-06-07 10:18:15 +03:00
Enes Yakıştır 2a97138fec feat: implement dynamic rule discovery and incremental level filtering 2026-06-06 13:48:10 +03:00
Enes Yakıştır c42ab714ff feat: add guzzle-powered recursive website crawling and masterminds/html5 integration 2026-06-06 13:22:35 +03:00
Enes Yakıştır b805741ef2 feat: redesign HtmlDashboardReporter to premium responsive accessible dashboard 2026-06-06 10:20:50 +03:00
Enes Yakıştır 84f713e434 feat: add PageTitle accessibility rule (WCAG 2.4.2) 2026-06-06 10:16:39 +03:00
Enes Yakıştır c6df5e2bdc feat: add 4 new WCAG rules and optimize SourceLocator directory traversal 2026-06-06 10:15:24 +03:00
Enes Yakıştır 75171c6f96 build: upgrade PHPStan to level 9 and fix strict type errors 2026-06-06 09:36:46 +03:00
Enes Yakıştır 1ea2579d20 chore: configure PHPStan Level 6 and resolve strict-type warnings 2026-05-31 23:00:56 +03:00
Enes Yakıştır 873b4c4233 feat: implement 5 new advanced WCAG rules (ColorContrast, DuplicateId, TabindexOrder, AriaRole, IframeTitle) 2026-05-30 12:04:21 +03:00
Enes Yakıştır d219272487 fix: resolve strict types for ReflectionType and DOMNode 2026-05-30 11:13:17 +03:00
Enes Yakıştır 09f33650ec Fix AI suggestions, improve reporter UI, and fix RuleEngine compatibility issues 2026-05-13 13:09:01 +03:00
Enes Yakıştır 3a19c1c534 Fix model name to gemini-1.5-flash v2.2.3 2026-05-13 12:46:26 +03:00
Enes Yakıştır 40dc04d328 Add debug info to GeminiFixer v2.2.2 2026-05-13 12:21:24 +03:00
Enes Yakıştır 3f262b7b35 Fix Violation constructor parameters and Enum usage v2.2.1 2026-05-13 12:16:37 +03:00
Enes Yakıştır 30c981eb68 Migrate to modular strict rules v2.2.0 2026-05-13 12:13:32 +03:00
165 changed files with 12630 additions and 336 deletions
+2
View File
@@ -4,3 +4,5 @@
/rapor.html
/.phpunit.result.cache
/phpstan-baseline.neon
/.phpunit.cache/
+102 -54
View File
@@ -1,91 +1,139 @@
# YakNet Accessibility Console 2.0 (AI-Powered)
# YakNet Accessibility Console 2.9 (AI-Powered Static Analysis)
[![PHP Version](https://img.shields.io/badge/php-%5E8.2-blue.svg)](https://php.net)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![AI Powered](https://img.shields.io/badge/AI-Powered-purple.svg)](https://gemini.google.com)
**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1) hatalarını sadece tespit etmekle kalmayan, aynı zamanda **Yapay Zeka (AI)** desteğiyle bu hataları otomatik olarak düzeltebilen (Self-Healing) profesyonel bir PHP kütüphanesidir.
**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1) hatalarını sadece tespit etmekle kalmayan, aynı zamanda **Yapay Zeka (AI)** desteğiyle bu hataları otomatik olarak düzeltebilen (Self-Healing) profesyonel bir PHP static analysis kütüphanesidir.
PHPStan ve PHP-CS-Fixer esintili yapısıyla, yerel görünüm şablonlarınızı (`.blade.php`, `.twig`, `.html`, `.php`) doğrudan tarayabilir, baseline desteğiyle mevcut hataları yok sayabilir ve CI/CD süreçlerinize tam entegre çalışabilir.
---
## 🌟 Öne Çıkan Özellikler
- **🤖 AI Self-Healing:** Tespit edilen WCAG hataları için Google Gemini AI üzerinden akıllı çözüm önerileri alır ve isterseniz kaynak kodunuzu (Blade, Twig, PHP) otomatik olarak tamir eder.
- **⚡ Modern CLI:** Rust/Vite tarzı şık bir komut satırı arayüzü ile saniyeler içinde tarama ve düzeltme yapın.
- **📊 Görsel Dashboard:** Tarama sonuçlarını modern ve şık bir HTML raporu olarak dışarı aktarın.
- **🔍 Akıllı Kaynak Tespiti:** Hataları projenizdeki tam dosya yolu ve satır numarası ile eşleştirir.
- **🛠️ Geliştirici Dostu:** PSR-4 uyumlu, modern PHP 8.2+ mimarisi ve genişletilebilir kural motoru.
- **🤖 AI Self-Healing:** Tespit edilen WCAG hataları için Google Gemini AI üzerinden akıllı çözüm önerileri alır ve kaynak kodunuzu (Blade, Twig, PHP, HTML) otomatik olarak tamir eder.
- **⚡ PHPStan Tarzı Analiz:** Tüm projeyi veya belirli dizinleri komut satırından tarayarak hataları tam dosya yolu ve satır numarası ile listeler.
- **🛡️ Baseline Desteği:** `a11y-baseline.json` oluşturarak mevcut erişilebilirlik hatalarını yok sayabilir, CI/CD pipeline'ınızın sadece yeni hatalarda kırılmasını sağlayabilirsiniz.
- **⚙️ YAML Konfigürasyonu:** `a11y.yaml` dosyası üzerinden tarama yollarını, hariç tutulacak klasörleri, kural seviyelerini ve özel kural setlerini yapılandırabilirsiniz.
- **🔌 Çoklu Çıktı Formatları:**
- `console`: Renkli ve düzenli terminal tablosu.
- `json`: Makine tarafından okunabilir JSON çıktısı.
- `github`: GitHub Actions PR süreçlerinde doğrudan inline hata göstermek için workflow formatı.
- **📊 Görsel Raporlama:** Sonuçları modern bir HTML Dashboard raporu olarak dışarı aktarın.
---
## 📦 Kurulum
Composer ile projenize hemen dahil edin:
Composer ile projenize dahil edin:
```bash
composer require yaknet/accessibility-console
```
---
## 🚀 CLI Kullanımı
Kütüphane, `vendor/bin/a11y` (veya kütüphane dizininde `bin/a11y`) üzerinden kullanılabilir.
### 1. Standart Tarama
Bir URL'yi veya yerel bir HTML dosyasını tarayın:
### 1. Yapılandırma Dosyası Oluşturma (`init`)
Projenizin kök dizininde varsayılan bir `a11y.yaml` konfigürasyon dosyası oluşturur:
```bash
bin/a11y scan https://test-siteniz.com
bin/a11y init
```
### 2. AI Destekli Tarama ve Raporlama
Yapay zeka önerileriyle birlikte tarama yapın ve sonucu HTML olarak kaydedin:
### 2. Projeyi Tarama (`scan` / `analyse`)
`a11y.yaml` dosyasındaki kurallara ve dizinlere göre tüm projeyi tarar:
```bash
bin/a11y scan https://test-siteniz.com --ai --report=sonuc.html
bin/a11y scan
```
### 3. Otomatik Düzeltme (Self-Healing) - **DEVRİMSEL**
Tespit edilen hataları yapay zekaya düzelttirin ve dosyalarınızı güncelleyin:
Belirli bir dizini veya dosyayı hedef göstermek için:
```bash
bin/a11y fix https://test-siteniz.com --project-path=/var/www/projeniz
bin/a11y scan resources/views
```
URL veya tek bir yerel HTML dosyasını taramak için (crawler desteğiyle):
```bash
bin/a11y scan https://test-siteniz.com --crawl
```
## 💻 Kütüphane Olarak Kullanım
Kendi PHP uygulamalarınızın içine entegre edin:
```php
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
// 1. Scanner'ı ve Kuralları Hazırla
$scanner = new Scanner();
foreach (StandardRuleSet::all() as $rule) {
$scanner->addRule($rule);
}
// 2. HTML İçeriğini Tara
$html = file_get_contents('view.html');
$violations = $scanner->scan($html);
// 3. Sonuçları İşle
foreach ($violations as $v) {
echo "Hata: {$v->message} (Kural: {$v->ruleId})\n";
}
### 3. Baseline Yönetimi
Projeye ait mevcut tüm hataları `a11y-baseline.json` dosyasına kaydederek sonraki taramalarda yok sayılmasını sağlar:
```bash
bin/a11y scan --generate-baseline
```
## ⚙️ Yapılandırma
### 4. Format ve Raporlama Seçenekleri
Hataları GitHub Actions formatında yazdırmak için:
```bash
bin/a11y scan --format=github
```
HTML Raporu kaydetmek için:
```bash
bin/a11y scan --report=report.html
```
AI özelliklerini kullanabilmek için projenizin kök dizininde bir `.env` dosyası oluşturun ve **Google Gemini API** anahtarınızı ekleyin:
### 5. AI Destekli Otomatik Düzeltme (`fix`)
Tespit edilen tüm hataları yapay zekaya analiz ettirip dosyalarınızı otomatik olarak tamir ettirin:
```bash
bin/a11y fix
```
Sadece neyin değişeceğini görmek için (`--dry-run`):
```bash
bin/a11y fix --dry-run
```
### 6. Kuralları Listeleme (`rules`)
Mevcut tüm WCAG kurallarını seviyeleri ve açıklamalarıyla birlikte listeler:
```bash
bin/a11y rules
```
---
## ⚙️ Yapılandırma (`a11y.yaml`)
Projenizi özelleştirmek için `a11y.yaml` dosyasını kullanabilirsiniz:
```yaml
# Tarama yapılacak dizin veya dosyalar
paths:
- resources/views
- public/templates
# Hariç tutulacak dizinler
exclude_paths:
- vendor
- node_modules
- storage
# WCAG seviyesi (1-9, varsayılan 4)
level: 4
# Varsayılan çıktı formatı (console, json, github)
format: console
# Baseline dosyası yolu
baseline: a11y-baseline.json
# Kural özelleştirmeleri
rules:
exclude:
- WCAG_1_3_1_FIELDSET # Bu kuralı çalıştırma
include:
- App\Rules\CustomAccessibilityRule # Özel kural ekle
```
---
## 🔐 Yapay Zeka API Anahtarı
AI düzeltme ve öneri özelliklerini kullanabilmek için projenizin kök dizininde bir `.env` dosyası oluşturup **Google Gemini API** anahtarınızı eklemeniz gerekir:
```env
GEMINI_API_KEY=AIzaSyA...your_key_here
```
## 🛠️ Mevcut Kurallar (WCAG 2.1)
- **A11Y_VIEWPORT:** Zoom engelleme kontrolü.
- **WCAG_1_1_1_ALT:** Görsel alt metin kontrolü.
- **WCAG_1_3_1_LABEL:** Form etiketi kontrolü.
- **WCAG_1_3_1_FIELDSET:** Fieldset/Legend kontrolü.
- **WCAG_1_3_1_HEADING:** Başlık hiyerarşisi kontrolü.
- **WCAG_2_4_4_LINK_TEXT:** Boş link kontrolü.
- **WCAG_3_1_1_LANG:** HTML dil etiketi kontrolü.
- **WCAG_4_1_2_BUTTON:** Boş buton kontrolü.
---
## 🤝 Katkıda Bulunma
+210
View File
@@ -0,0 +1,210 @@
{
"generator": "YakNet Accessibility Console",
"violations": [
{
"file": "demo/broken.html",
"ruleId": "AriaRole",
"htmlSnippet": "<div role=\"cool-widget\" aria-controls=\"non-existent-popup\">WAI-ARIA elements</div>"
},
{
"file": "demo/broken.html",
"ruleId": "AriaRole",
"htmlSnippet": "<div role=\"cool-widget\" aria-controls=\"non-existent-popup\">WAI-ARIA elements</div>"
},
{
"file": "demo/broken.html",
"ruleId": "DuplicateId",
"htmlSnippet": "<h1 id=\"main-content\">Welcome to a broken page</h1>"
},
{
"file": "demo/broken.html",
"ruleId": "DuplicateId",
"htmlSnippet": "<div id=\"main-content\">This is a duplicate main-content ID div!</div>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"logo.png\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_1_1_ALT_REDUNDANT",
"htmlSnippet": "<img src=\"logo2.png\" alt=\"logo of our company\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_1_1_ALT_REDUNDANT",
"htmlSnippet": "<img src=\"profile.jpg\" alt=\"profile photo of john doe\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_2_2_VIDEO_TRACK",
"htmlSnippet": "<video src=\"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4\" width=\"320\" height=\"240\" controls=\"\"></video>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_3_1_EMPTY_HEADING",
"htmlSnippet": "<h2></h2>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_3_1_EMPTY_LABEL",
"htmlSnippet": "<label for=\"search-input\"></label>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_3_1_LABEL",
"htmlSnippet": "<input type=\"text\" placeholder=\"Search website...\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_1_1_ARIA_FOCUSABLE",
"htmlSnippet": "<div role=\"button\">Clickable div button without tabindex</div>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_2_PAGE_TITLE",
"htmlSnippet": "<head>\n <title></title>\n</head>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_3_TABINDEX",
"htmlSnippet": "<a href=\"/keyboard\" tabindex=\"5\">Keyboard test</a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_4_LINK_GENERIC",
"htmlSnippet": "<a href=\"/read-more\">Click here</a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_4_LINK_GENERIC",
"htmlSnippet": "<a href=\"/devam-oku\">devamını oku</a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_4_LINK_TEXT",
"htmlSnippet": "<a href=\"https://google.com\"></a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><head>\n <title></title>\n</head>\n<body>\n <h1 id=\"main-content\">Welcome to a broken page</h1>\n \n <!-- Missing alt attribute -->\n <img src=\"logo.png\">\n \n <!-- Empty link -->\n <a href=\"https://google.com\"></a>\n \n <!-- Empty button -->\n <button type=\"submit\"></button>\n \n <!-- Positive tabindex -->\n <a href=\"/keyboard\" tabindex=\"5\">Keyboard test</a>\n\n <!-- Duplicate ID -->\n <div id=\"main-content\">This is a duplicate main-content ID div!</div>\n\n <!-- Low Contrast -->\n <p style=\"color: #888888; background-color: #999999;\">This text has extremely low contrast ratio!</p>\n\n <!-- Invalid ARIA role and missing controls target -->\n <div role=\"cool-widget\" aria-controls=\"non-existent-popup\">WAI-ARIA elements</div>\n\n <!-- Missing Iframe Title -->\n <iframe src=\"https://www.youtube.com/embed/aqz-KE-bpKQ\" width=\"560\" height=\"315\"></iframe>\n\n <!-- New WCAG Rule Violations for testing -->\n \n <!-- LinkTextGeneric: Generic text without context -->\n <a href=\"/read-more\">Click here</a>\n <a href=\"/devam-oku\">devamını oku</a>\n \n <!-- PlaceholderAsLabel: Input relying only on placeholder -->\n <input type=\"text\" placeholder=\"Search website...\">\n\n <!-- ImageAltRedundant: Redundant words in alt text -->\n <img src=\"logo2.png\" alt=\"logo of our company\">\n <img src=\"profile.jpg\" alt=\"profile photo of john doe\">\n\n <!-- AriaHiddenFocusable: Focusable element hidden with aria-hidden=\"true\" -->\n <div aria-hidden=\"true\">\n <button type=\"button\">Hidden click</button>\n </div>\n <a href=\"#\" aria-hidden=\"true\">Hidden link</a>\n\n <!-- Empty heading violation -->\n <h2></h2>\n\n <!-- Video missing track violation -->\n <video src=\"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4\" width=\"320\" height=\"240\" controls=\"\"></video>\n\n <!-- Empty label violation -->\n <label for=\"search-input\"></label>\n\n <!-- AriaFocusable violation -->\n <div role=\"button\">Clickable div button without tabindex</div>\n\n <p>This page has multiple accessibility issues.</p>\n</body>\n\n</html>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_3_3_2_PLACEHOLDER_LABEL",
"htmlSnippet": "<input type=\"text\" placeholder=\"Search website...\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_4_1_2_BUTTON",
"htmlSnippet": "<button type=\"submit\"></button>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_4_1_2_IFRAME_TITLE",
"htmlSnippet": "<iframe src=\"https://www.youtube.com/embed/aqz-KE-bpKQ\" width=\"560\" height=\"315\"></iframe>"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/gadget.jpg\" width=\"100\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/widget.png\" width=\"100\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/thing.jpg\" width=\"100\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/views-fixed/contact.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Contact Us</h2>\n<p>Get in touch with us.</p>\n\n<form action=\"\" method=\"post\">\n <div style=\"margin-bottom: 1rem;\">\n <!-- FIXED: Wrapped in label -->\n <label>\n Name:\n <input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">\n </label>\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <!-- FIXED: Added for/id association -->\n <label for=\"email\">Email Address</label>\n <br>\n <input type=\"email\" id=\"email\" name=\"email\" style=\"padding: 8px; width: 100%;\">\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <label for=\"message\">Message</label>\n <br>\n <textarea id=\"message\" name=\"message\" rows=\"5\" style=\"width: 100%;\"></textarea>\n </div>\n\n <!-- FIXED: Added inner text or aria-label -->\n <a href=\"#\" class=\"btn\" aria-label=\"Send via WhatsApp\">\n <i class=\"fa fa-send\"></i> Send\n </a>\n\n <button type=\"submit\" class=\"btn\">Send Message</button>\n</form></html>"
},
{
"file": "demo/views-fixed/home.php",
"ruleId": "WCAG_1_3_1_EMPTY_HEADING",
"htmlSnippet": "<h3>\n <?php echo $product['name']; >\n </h3>"
},
{
"file": "demo/views-fixed/home.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Latest Products</h2>\n<p>Welcome to our accessible product catalog.</p>\n\n<?php $products = [\n ['id' => 1, 'name' => 'Super Gadget', 'image' => 'gadget.jpg', 'price' => '$99'],\n ['id' => 2, 'name' => 'Mega Widget', 'image' => 'widget.png', 'price' => '$49'],\n ['id' => 3, 'name' => 'Ultra Thing', 'image' => 'thing.jpg', 'price' => '$199'],\n];\n>\n\n<div class=\"products\">\n <?php foreach ($products as $product): >\n <div class=\"card\">\n <!-- FIXED: Added alt attribute describing the product -->\n <img src=\"images/&lt;?php%20echo%20$product['image'];%20?&gt;\" width=\"100\" alt=\"&lt;?php echo htmlspecialchars($product['name']); ?&gt;\">\n <h3>\n <?php echo $product['name']; >\n </h3>\n <p>\n <?php echo $product['price']; >\n </p>\n <button class=\"btn\">Buy Now</button>\n </div>\n <?php endforeach; >\n</div>\n\n<hr>\n\n<h3>Sponsors</h3>\n<div>\n <!-- FIXED: Added empty alt=\"\" for decorative images -->\n <img src=\"https://via.placeholder.com/150\" alt=\"\">\n <img src=\"https://via.placeholder.com/150\" alt=\"\">\n</div></html>"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_1_3_1_LABEL",
"htmlSnippet": "<input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_1_3_1_LABEL",
"htmlSnippet": "<input type=\"email\" name=\"email\" style=\"padding: 8px; width: 100%;\">"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_2_4_4_LINK_TEXT",
"htmlSnippet": "<a href=\"#\" class=\"btn\">\n <i class=\"fa fa-send\"></i> <!-- Empty link text error (if we had that rule) -->\n </a>"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Contact Us</h2>\n<p>Get in touch with us.</p>\n\n<form action=\"\" method=\"post\">\n <div style=\"margin-bottom: 1rem;\">\n <!-- ERROR: No label for input -->\n <div>Name:</div>\n <input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <!-- ERROR: Label exists but not associated with For/ID -->\n <label>Email Address</label>\n <br>\n <input type=\"email\" name=\"email\" style=\"padding: 8px; width: 100%;\">\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <label for=\"message\">Message</label>\n <br>\n <textarea id=\"message\" name=\"message\" rows=\"5\" style=\"width: 100%;\"></textarea>\n </div>\n\n <!-- ERROR: Submit button without clear text if it was an input type=image, but button is usually fine. \n Let's add a problematic icon link -->\n <a href=\"#\" class=\"btn\">\n <i class=\"fa fa-send\"></i> <!-- Empty link text error (if we had that rule) -->\n </a>\n\n <button type=\"submit\" class=\"btn\">Send Message</button>\n</form></html>"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_3_3_2_PLACEHOLDER_LABEL",
"htmlSnippet": "<input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/&lt;?php%20echo%20$product['image'];%20?&gt;\" width=\"100\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_3_1_EMPTY_HEADING",
"htmlSnippet": "<h3>\n <?php echo $product['name']; >\n </h3>"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Latest Products</h2>\n<p>Welcome to our dynamic product catalog. (Intentionally filled with errors)</p>\n\n<?php $products = [\n ['id' => 1, 'name' => 'Super Gadget', 'image' => 'gadget.jpg', 'price' => '$99'],\n ['id' => 2, 'name' => 'Mega Widget', 'image' => 'widget.png', 'price' => '$49'],\n ['id' => 3, 'name' => 'Ultra Thing', 'image' => 'thing.jpg', 'price' => '$199'],\n];\n>\n\n<div class=\"products\">\n <?php foreach ($products as $product): >\n <div class=\"card\">\n <!-- ERROR: Dynamic image without alt text -->\n <img src=\"images/&lt;?php%20echo%20$product['image'];%20?&gt;\" width=\"100\">\n <h3>\n <?php echo $product['name']; >\n </h3>\n <p>\n <?php echo $product['price']; >\n </p>\n <button class=\"btn\">Buy Now</button>\n <!-- Error: Button might need specific accessible name if icon-only, but text is okay here. -->\n </div>\n <?php endforeach; >\n</div>\n\n<hr>\n\n<h3>Sponsors</h3>\n<div>\n <!-- ERROR: Decorative image causing noise, missing alt=\"\" -->\n <img src=\"https://via.placeholder.com/150\">\n <img src=\"https://via.placeholder.com/150\">\n</div></html>"
},
{
"file": "demo/views/layout.php",
"ruleId": "WCAG_2_4_4_LINK_TEXT",
"htmlSnippet": "<a href=\"#\" class=\"btn\"></a>"
},
{
"file": "demo/views/layout.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><!-- Missing lang attribute error --><head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>YakNet Dynamic Demo</title>\n <style>\n body {\n font-family: sans-serif;\n margin: 0;\n padding: 0;\n line-height: 1.6;\n }\n\n header {\n background: #333;\n color: #fff;\n padding: 1rem;\n }\n\n nav a {\n color: #fff;\n margin-right: 1rem;\n text-decoration: none;\n }\n\n .container {\n padding: 2rem;\n max-width: 800px;\n margin: 0 auto;\n }\n\n .card {\n border: 1px solid #ddd;\n padding: 1rem;\n margin-bottom: 1rem;\n border-radius: 4px;\n }\n\n .btn {\n background: #007bff;\n color: white;\n padding: 0.5rem 1rem;\n border: none;\n cursor: pointer;\n }\n </style>\n</head>\n\n<body>\n <header>\n <div class=\"container\" style=\"padding: 0;\">\n <h1>My Dynamic Site</h1>\n <nav>\n <a href=\"?page=home\">Home</a>\n <a href=\"?page=contact\">Contact</a>\n <a href=\"?page=about\">About</a>\n <a href=\"#\" class=\"btn\"></a> <!-- Error: Empty Link -->\n </nav>\n </div>\n </header>\n\n <main class=\"container\">\n <?php include $viewPath; >\n </main>\n\n <footer style=\"text-align: center; padding: 2rem; background: #f4f4f4; margin-top: 2rem;\">\n ©\n <?php echo date('Y'); > YakNet Demo\n </footer>\n</body>\n\n</html>"
}
]
}
+32
View File
@@ -0,0 +1,32 @@
# YakNet Accessibility Console Configuration
# For detailed configuration, see: https://forum.yak.net.tr
# Paths to scan (can be directories or specific files)
paths:
- .
# Exclude directories or files
exclude_paths:
- vendor
- node_modules
- tests
- storage
- .git
# Accessibility scanning level (1-9, default 4)
level: 4
# Output format (console, json, html, github)
format: console
# Path to the baseline file for ignoring existing violations
baseline: a11y-baseline.json
# Rule adjustments
rules:
# Rules to exclude from analysis
exclude:
# - WCAG_1_3_1_FIELDSET
# Additional custom rules to run
include:
# - App\Rules\MyCustomRule
+18 -5
View File
@@ -1,16 +1,29 @@
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
require __DIR__ . '/../vendor/autoload.php';
} elseif (file_exists(__DIR__ . '/../../../autoload.php')) {
require __DIR__ . '/../../../autoload.php';
} else {
echo "Autoload file not found.\n";
exit(1);
}
use Symfony\Component\Console\Application;
use YakNet\AccessibilityConsole\Core\Container\ContainerFactory;
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
$application = new Application('YakNet Accessibility Console', '2.0.0');
$container = ContainerFactory::create();
$application = new Application('YakNet Accessibility Console', '3.1.2');
// Add commands
$application->add(new ScanCommand());
$application->add(new FixCommand());
// Add commands resolved via DI Container
$application->addCommand($container->get(ScanCommand::class));
$application->addCommand($container->get(FixCommand::class));
$application->addCommand($container->get(InitCommand::class));
$application->addCommand($container->get(RulesCommand::class));
$application->run();
+16 -4
View File
@@ -12,6 +12,7 @@
"yaknet"
],
"homepage": "https://forum.yak.net.tr",
"version": "3.1.3",
"type": "library",
"license": "MIT",
"authors": [
@@ -26,17 +27,28 @@
"ext-dom": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"symfony/console": "^6.4|^7.0",
"symfony/yaml": "^6.4|^7.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0",
"monolog/monolog": "^3.0",
"vlucas/phpdotenv": "^5.6",
"google-gemini-php/client": "^1.0"
"google-gemini-php/client": "^1.0|^2.0",
"guzzlehttp/guzzle": "^7.8",
"masterminds/html5": "^2.8",
"sabberworm/php-css-parser": "^9.4",
"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"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.10"
"phpstan/phpstan": "^2.2"
},
"autoload": {
"files": [
"src/accessibility-console.php"
],
"psr-4": {
"YakNet\\AccessibilityConsole\\": "src/"
}
+111 -10
View File
@@ -1,20 +1,121 @@
<!DOCTYPE html>
<html>
<html lang="tr"><!-- FIXED: Added lang attribute (WCAG 3.1.1) -->
<head>
<title>Broken Page</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Erişilebilir Demo Sayfa</title><!-- FIXED: Non-empty page title (WCAG 2.4.2) -->
<style>
body {
font-family: sans-serif;
margin: 0;
padding: 2rem;
background: #ffffff;
color: #1a1a1a; /* FIXED: High contrast text (WCAG 1.4.3) */
line-height: 1.6;
}
/* FIXED: Sufficient contrast (WCAG 1.4.3) - was #888 on #999, now dark on white */
.low-contrast-example {
color: #595959; /* 7:1 contrast on white */
background-color: #ffffff;
padding: 0.5rem;
}
label {
display: block;
margin-bottom: 0.25rem;
font-weight: 600;
}
input {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
max-width: 300px;
}
iframe {
border: none;
}
video {
display: block;
margin-top: 0.5rem;
}
[role="button"] {
cursor: pointer;
}
</style>
</head>
<body>
<h1>Welcome to a broken page</h1>
<h1 id="main-content">Erişilebilir Demo Sayfa</h1>
<!-- FIXED: Removed duplicate id="main-content" (WCAG 4.1.1) -->
<!-- Missing alt attribute -->
<img src="logo.png">
<!-- FIXED: Added alt attribute (WCAG 1.1.1) -->
<img src="logo.png" alt="YakNet Şirket">
<!-- Empty link -->
<a href="https://google.com"></a>
<!-- FIXED: Added descriptive link text (WCAG 2.4.4) -->
<a href="https://google.com">Google Ana Sayfasına Git</a>
<!-- Empty button -->
<button type="submit"></button>
<!-- FIXED: Added discernible button text (WCAG 4.1.2) -->
<button type="submit">Formu Gönder</button>
<p>This page has multiple accessibility issues.</p>
<!-- FIXED: Removed positive tabindex (WCAG 2.4.3) — use natural DOM order -->
<a href="/keyboard">Klavye Testi</a>
<!-- FIXED: Removed duplicate id="main-content" — unique wrapper instead -->
<div id="duplicate-content-wrapper">Bu bölümün içeriği buradadır.</div>
<!-- FIXED: High contrast text (WCAG 1.4.3) -->
<p class="low-contrast-example">Bu metnin kontrast oranı WCAG AA gereksinimlerini karşılamaktadır.</p>
<!-- FIXED: Valid WAI-ARIA role (WCAG 4.1.2) and removed reference to non-existent element -->
<div role="region" aria-label="WAI-ARIA Örnek Bölüm">WAI-ARIA bölüm örneği</div>
<!-- FIXED: Added descriptive title to iframe (WCAG 4.1.2) -->
<iframe
src="https://www.youtube.com/embed/aqz-KE-bpKQ"
width="560"
height="315"
title="YouTube Video: Örnek Video İçeriği"
></iframe>
<!-- FIXED: Descriptive link text instead of generic "click here" / "devamını oku" (WCAG 2.4.4) -->
<a href="/read-more">Makaleyi Devamı: WCAG Erişilebilirlik Kılavuzu</a>
<a href="/devam-oku">Şiir Serisinin Devamını Oku: "Bahar Rüzgarı"</a>
<!-- FIXED: Added associated <label> element (WCAG 1.3.1 & 3.3.2) -->
<div>
<label for="search-input">Web sitesinde ara</label>
<input type="text" id="search-input" placeholder="Arama teriminizi girin...">
</div>
<!-- FIXED: Removed redundant words "logo" and "photo" from alt texts (WCAG 1.1.1) -->
<img src="logo2.png" alt="Şirketimizin simgesi">
<img src="profile.jpg" alt="John Doe vesikalık">
<!-- FIXED: Removed aria-hidden="true" from focusable elements (WCAG 4.1.2) -->
<div>
<button type="button">Gizli Olmayan Tıklanabilir Düğme</button>
</div>
<a href="#">Normal Bağlantı</a>
<!-- FIXED: Added non-empty heading text (WCAG 2.4.6) -->
<h2>Alt Bölüm Başlığı</h2>
<!-- FIXED: Added <track> element for video captions (WCAG 1.2.2) -->
<video
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4"
width="320"
height="240"
controls
>
<track kind="captions" src="captions.vtt" srclang="tr" label="Türkçe Altyazı" default>
</video>
<!-- FIXED: Added non-empty label text (WCAG 1.3.1) -->
<label for="name-input">Ad Soyad</label>
<input type="text" id="name-input" name="name">
<!-- FIXED: Added tabindex="0" to make role="button" keyboard focusable (WCAG 2.1.1) -->
<div role="button" tabindex="0">Tıklanabilir Div Düğme (Erişilebilir)</div>
<p>Bu sayfa WCAG 2.1 erişilebilirlik standartlarına uygun olarak düzenlenmiştir.</p>
</body>
</html>
+10 -19
View File
@@ -1,19 +1,11 @@
<?php
// 1. Setup Autoloading (Mocking Composer)
require_once __DIR__ . '/../src/Scanner.php';
require_once __DIR__ . '/../src/Violation.php';
require_once __DIR__ . '/../src/SourceLocator.php';
require_once __DIR__ . '/../src/Reporter/HtmlReporter.php';
require_once __DIR__ . '/../src/Rule/RuleInterface.php';
require_once __DIR__ . '/../src/Rules/ImgAltText.php';
require_once __DIR__ . '/../src/Rules/FormLabel.php';
require_once __DIR__ . '/../src/Rules/HtmlHasLang.php';
require_once __DIR__ . '/../src/Rules/EmptyLink.php';
// 1. Setup Autoloading
require_once __DIR__ . '/../vendor/autoload.php';
use YakNet\AccessibilityConsole\Scanner;
use YakNet\AccessibilityConsole\SourceLocator;
use YakNet\AccessibilityConsole\Reporter\HtmlReporter;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Source\SourceLocator;
use YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter;
use YakNet\AccessibilityConsole\Rules\ImgAltText;
use YakNet\AccessibilityConsole\Rules\FormLabel;
@@ -48,19 +40,18 @@ $violations = $scanner->scan($html);
if (!empty($violations)) {
// Locate Sources
// We point it to the 'views' directory so it knows where to look for the error source
$locator = new SourceLocator(__DIR__ . '/views');
foreach ($violations as $violation) {
$location = $locator->locate($violation->snippet);
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$violation->setSourceLocation($location['file'], $location['line']);
$violation->location = $location;
}
}
// Render Report
$reporter = new HtmlReporter();
$reportHtml = $reporter->render($violations);
// Render Report (Format as results mapped by page URL)
$reporter = new HtmlDashboardReporter();
$reportHtml = $reporter->render(['http://localhost:8080/index.php' => $violations], 'demo/index.php');
// Inject into body
$html = str_replace('</body>', $reportHtml . '</body>', $html);
+10
View File
@@ -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>
+25
View File
@@ -0,0 +1,25 @@
<html>
<head>
<title>Test Page</title>
<meta http-equiv="refresh" content="30">
</head>
<body>
<img src="avatar.png" alt="Avatar">
<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>
</html>
+7
View File
@@ -0,0 +1,7 @@
parameters:
level: 9
paths:
- src
bootstrapFiles:
- vendor/autoload.php
+18
View File
@@ -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>
+73
View File
@@ -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.";
}
}
+23
View File
@@ -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;
}
+18 -16
View File
@@ -11,15 +11,23 @@ class GeminiFixer
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey && class_exists(Gemini::class)) {
$this->client = Gemini::client($this->apiKey);
if ($this->apiKey) {
try {
$this->client = \Gemini::client($this->apiKey);
} catch (\Throwable) {
$this->client = null;
}
}
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->apiKey) {
return "Hata: GEMINI_API_KEY bulunamadı.";
}
if (!$this->client) {
return null;
return "Hata: Gemini istemcisi başlatılamadı. Kütüphane eksik olabilir.";
}
$prompt = <<<PROMPT
@@ -30,23 +38,17 @@ Error: {$violation->message}
Severity: {$violation->severity->value}
Standard: {$violation->standard->value}
Please provide ONLY the fixed version of the HTML snippet.
Do not explain, do not add markdown code blocks, just return the corrected HTML.
If the error is about missing 'alt' text, add a meaningful alt text based on context.
If it's about ARIA roles, add the correct ones.
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]
FIX: [Only the corrected HTML snippet]
PROMPT;
try {
$result = $this->client->generativeModel('gemini-2.5-flash')->generateContent($prompt);
$suggestion = trim($result->text());
// Clean up if AI included markdown blocks anyway
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
return $suggestion;
$result = $this->client->generativeModel('gemini-3.1-flash-lite')->generateContent($prompt);
return trim($result->text());
} catch (\Throwable $e) {
// Log error if needed
return null;
return "AI Düzeltme Hatası: " . $e->getMessage();
}
}
}
+27
View File
@@ -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;
}
}
+66
View File
@@ -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();
}
}
}
+45
View File
@@ -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();
}
}
}
+49
View File
@@ -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;
}
}
+66
View File
@@ -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();
}
}
}
+223 -28
View File
@@ -8,10 +8,11 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
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\Config;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
use YakNet\AccessibilityConsole\Source\SourceLocator;
class FixCommand extends Command
@@ -21,38 +22,206 @@ class FixCommand extends Command
$this
->setName('fix')
->setDescription('Automatically fix accessibility violations using AI')
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files');
->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan and fix')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
$projectPath = $input->getOption('project-path') ?? getcwd();
$dryRun = $input->getOption('dry-run');
$config = new Config();
// 1. Resolve configuration
$configPathOption = $input->getOption('config');
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
$config = Config::fromYaml($configPath);
$apiKey = $config->getGeminiApiKey();
if (!$apiKey) {
$io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key.");
return Command::FAILURE;
}
$io->title("AI Self-Healing Mode - Fixing: $target");
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
// 1. Fetch HTML
$html = @file_get_contents($target);
if (!$html) {
$io->error("Could not read target: $target");
$target = $input->getArgument('target');
$isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://'));
// If target is a URL, run the legacy URL fixing logic
if ($isUrl) {
return $this->executeUrlFix($target, $level, $apiKey, $input, $output, $io);
}
// --- LOCAL FILE / DIRECTORY SELF-HEALING ENGINE ---
$io->title("AI Self-Healing Mode - Directory Static Analysis");
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
$dryRun = (bool)$input->getOption('dry-run');
// Resolve paths to scan
$paths = $config->getPaths();
if (is_string($target) && $target !== '') {
$paths = [$target];
}
// Setup Analyser
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
// We run without baseline filtering so we can fix ALL errors in the project,
// or we can optionally load baseline. Fixing everything is better for a general "fix" run.
$analyser = new Analyser($config, $scanner, new \YakNet\AccessibilityConsole\Core\BaselineManager());
$io->note("Scanning targets for violations...");
$result = $analyser->analyse($paths);
$violations = $result['violations'];
if (empty($violations)) {
$io->success("Everything looks good! No violations to fix.");
return Command::SUCCESS;
}
$io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations)));
$fixer = new AiFixerManager($apiKey);
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
$fixedCount = 0;
// Group violations by file path
$groupedViolations = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
if ($file !== '') {
$groupedViolations[$file][] = $v;
}
}
foreach ($groupedViolations as $file => $fileViolations) {
$io->section("Fixing file: $file");
// Sort violations by line number DESCENDING (bottom to top)
// This prevents edits at the bottom of the file from invalidating line numbers above them!
usort($fileViolations, function ($a, $b) {
return ($b->location['line'] ?? 0) <=> ($a->location['line'] ?? 0);
});
$absoluteFile = getcwd() . '/' . $file;
if (!file_exists($absoluteFile) || !is_writable($absoluteFile)) {
$io->warning("File not found or not writable: $file. Skipping.");
continue;
}
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
foreach ($fileViolations as $violation) {
$line = $violation->location['line'] ?? 0;
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
// Try Local Healer first
$rawSuggestion = $localHealer->heal($violation);
$isLocalFix = false;
if ($rawSuggestion !== null) {
$isLocalFix = true;
} else {
// Fallback to AI Suggestion
$rawSuggestion = $fixer->suggestFix($violation);
}
$suggestion = $rawSuggestion;
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
$suggestion = trim($matches[1] ?? $rawSuggestion);
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
}
if (!is_string($suggestion)) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
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) {
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
continue;
}
// Apply Fix
if ($this->applyFix($absoluteFile, $violation->htmlSnippet, $suggestion)) {
$io->writeln(" <fg=bright-green>✔ Applied to line $line</>");
$fixedCount++;
} else {
$io->writeln(" <fg=red>✘ Failed to apply fix to file.</>");
}
$io->writeln("");
}
}
if ($fixedCount > 0) {
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
} else {
$io->warning("No files were modified.");
}
return Command::SUCCESS;
}
/**
* Legacy URL scan and fix logic
*/
private function executeUrlFix(string $target, int $level, string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
{
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$dryRun = (bool)$input->getOption('dry-run');
$useJs = (bool)$input->getOption('js');
$io->title("AI Self-Healing Mode - Fixing URL: $target");
// Fetch HTML
if ($useJs) {
try {
$chromeRenderer = new \YakNet\AccessibilityConsole\Core\ChromeRenderer();
$html = $chromeRenderer->render($target);
} catch (\Throwable $e) {
$io->error("Failed JavaScript rendering: " . $e->getMessage());
return Command::FAILURE;
}
} else {
$html = @file_get_contents($target);
}
if ($html === false || $html === '') {
$io->error("Could not read target URL: $target");
return Command::FAILURE;
}
// 2. Scan
// Scan
$scanner = new Scanner();
foreach (StandardRuleSet::all() as $rule) {
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
$violations = $scanner->scan($html);
@@ -64,7 +233,8 @@ class FixCommand extends Command
$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);
$fixedCount = 0;
@@ -78,16 +248,42 @@ class FixCommand extends Command
continue;
}
// Get AI Suggestion
$suggestion = $fixer->suggestFix($violation);
if (!$suggestion) {
$io->writeln("<fg=red>Skipped (AI could not generate fix)</>");
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
$rawSuggestion = $localHealer->heal($violation);
$isLocalFix = false;
if ($rawSuggestion !== null) {
$isLocalFix = true;
} else {
$rawSuggestion = $fixer->suggestFix($violation);
}
$suggestion = $rawSuggestion;
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
$suggestion = trim($matches[1] ?? $rawSuggestion);
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
}
if (!is_string($suggestion)) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
$io->writeln("<fg=green>Fix Found!</>");
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>");
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>");
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
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) {
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
@@ -107,7 +303,7 @@ class FixCommand extends Command
if ($fixedCount > 0) {
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
} else {
$io->warning("No files were modified. Check error logs for details.");
$io->warning("No files were modified.");
}
return Command::SUCCESS;
@@ -122,8 +318,7 @@ class FixCommand extends Command
if (str_contains($content, $originalSnippet)) {
$newContent = str_replace($originalSnippet, $fixedSnippet, $content);
} else {
// Fallback: Fuzzy replacement (simplified for this demo)
// In a real professional lib, we'd use more advanced regex or DOM patching
// Fallback: Fuzzy replacement
$newContent = str_replace(trim($originalSnippet), trim($fixedSnippet), $content);
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class InitCommand extends Command
{
protected function configure(): void
{
$this
->setName('init')
->setDescription('Initialize a default a11y.yaml configuration file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$configPath = getcwd() . '/a11y.yaml';
if (file_exists($configPath)) {
$io->warning("Configuration file 'a11y.yaml' already exists in the current directory.");
return Command::SUCCESS;
}
$defaultConfig = <<<YAML
# YakNet Accessibility Console Configuration
# For detailed configuration, see: https://forum.yak.net.tr
# Paths to scan (can be directories or specific files)
paths:
- .
# Exclude directories or files
exclude_paths:
- vendor
- node_modules
- tests
- storage
- .git
# Accessibility scanning level (1-9, default 4)
level: 4
# Output format (console, json, html, github)
format: console
# Path to the baseline file for ignoring existing violations
# baseline: a11y-baseline.json
# Rule adjustments
rules:
# Rules to exclude from analysis
exclude:
# - WCAG_1_3_1_FIELDSET
# Additional custom rules to run
include:
# - App\Rules\MyCustomRule
YAML;
if (file_put_contents($configPath, $defaultConfig) !== false) {
$io->success("Created configuration file: a11y.yaml");
return Command::SUCCESS;
}
$io->error("Failed to create configuration file 'a11y.yaml'.");
return Command::FAILURE;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class RulesCommand extends Command
{
protected function configure(): void
{
$this
->setName('rules')
->setDescription('List all available accessibility scanning rules');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title("Accessibility Console - Available Rules");
$table = new Table($output);
$table->setHeaders(['Rule ID', 'Standard', 'Severity', 'Level', 'Description']);
$rules = RuleLevels::getRulesForLevel(9);
foreach ($rules as $rule) {
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: (new \ReflectionClass($rule))->getShortName();
$description = method_exists($rule, 'getDescription') ? $rule->getDescription() : '';
$standard = method_exists($rule, 'getStandard') ? $rule->getStandard()->value : '';
$severity = method_exists($rule, 'getSeverity') ? $rule->getSeverity()->value : '';
$level = method_exists($rule, 'getLevel') ? $rule->getLevel() : 4;
$table->addRow([
"<fg=cyan;options=bold>$ruleId</>",
$standard,
$severity,
$level,
$description
]);
}
$table->render();
$io->newLine();
$io->note("Total rules available: " . count($rules));
return Command::SUCCESS;
}
}
+402 -58
View File
@@ -3,111 +3,455 @@
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\AI\GeminiFixer;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\BaselineManager;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
use YakNet\AccessibilityConsole\Reporting\GithubFormatter;
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;
class ScanCommand extends Command
{
protected static $defaultName = 'scan';
protected function configure(): void
{
$this
->setName('scan')
->setDescription('Scan a URL or file for accessibility violations')
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
->setAliases(['analyse'])
->setDescription('Scan local files, directories, or a URL for accessibility violations')
->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (console, json, github)')
->addOption('generate-baseline', null, InputOption::VALUE_NONE, 'Generate a baseline file containing all current violations')
->addOption('baseline', null, InputOption::VALUE_REQUIRED, 'Path to the baseline file')
->addOption('no-progress', null, InputOption::VALUE_NONE, 'Disable progress bar')
->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions')
->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping');
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively (URL scans only)')
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth (URL scans only)', '3')
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum pages to crawl (URL scans only)', '20')
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
$enableAi = $input->getOption('ai');
$projectPath = $input->getOption('project-path') ?? getcwd();
$io->title("Accessibility Console - Scanning: $target");
// 1. Resolve configuration
$configPathOption = $input->getOption('config');
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
$config = Config::fromYaml($configPath);
// 1. Fetch HTML
$html = @file_get_contents($target);
if (!$html) {
$io->error("Could not read target: $target");
return Command::FAILURE;
// 2. Resolve parameters (CLI options override config file)
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
$formatOption = $input->getOption('format');
$format = is_string($formatOption) ? $formatOption : $config->getFormat();
$baselinePathOption = $input->getOption('baseline');
$baselinePath = is_string($baselinePathOption) ? $baselinePathOption : $config->getBaselinePath();
if (!$baselinePath && $input->getOption('generate-baseline')) {
$baselinePath = 'a11y-baseline.json'; // default baseline filename
}
// 2. Setup Scanner
$target = $input->getArgument('target');
$isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://'));
// If target is a URL, run the legacy URL scanning / crawling logic
if ($isUrl) {
return $this->executeUrlScan($target, $level, $input, $output, $io);
}
// --- LOCAL DIRECTORY / FILE STATIC ANALYSIS ENGINE ---
$io->title("Accessibility Console - Static Analysis");
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
// Resolve paths to scan
$paths = $config->getPaths();
if (is_string($target) && $target !== '') {
$paths = [$target];
}
// Setup Analyser
$scanner = new Scanner();
foreach (StandardRuleSet::all() as $rule) {
$rules = RuleLevels::getRulesForLevel($level);
// Filter rules based on config
$rulesConfig = $config->getRulesConfig();
$activeRules = [];
foreach ($rules as $rule) {
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: (new \ReflectionClass($rule))->getShortName();
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
continue;
}
$activeRules[$ruleId] = $rule;
}
foreach ($rulesConfig['include'] as $customRuleClass) {
if (class_exists($customRuleClass)) {
try {
$reflection = new \ReflectionClass($customRuleClass);
if ($reflection->isInstantiable()) {
$rule = $reflection->newInstance();
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: $reflection->getShortName();
$activeRules[$ruleId] = $rule;
}
} catch (\Throwable $e) {}
}
}
foreach ($activeRules as $rule) {
$scanner->addRule($rule);
}
// 3. Scan
$violations = $scanner->scan($html);
if (empty($violations)) {
$io->success("No violations found!");
return Command::SUCCESS;
// Setup Baseline Manager
$baselineManager = new BaselineManager();
if ($baselinePath && !$input->getOption('generate-baseline')) {
$baselineManager->load(getcwd() . '/' . $baselinePath);
}
$io->section("Found " . count($violations) . " violations");
$analyser = new Analyser($config, $scanner, $baselineManager);
// 4. Setup Helpers
$locator = new SourceLocator($projectPath);
$config = new Config();
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
// Progress bar setup
$progressBar = null;
$showProgress = !$input->getOption('no-progress') && $format === 'console';
$onProgress = null;
foreach ($violations as $violation) {
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
// Show Snippet
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
// Source Mapping
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
}
// AI Fixer
if ($enableAi && $fixer) {
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation);
if ($suggestion) {
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
} else {
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
if ($showProgress) {
$onProgress = function (string $file, int $index, int $total) use ($io, &$progressBar) {
if ($progressBar === null) {
$progressBar = new ProgressBar($io, $total);
$progressBar->start();
}
}
$io->writeln("");
$progressBar->setProgress($index);
$progressBar->setMessage(basename($file), 'filename');
};
}
$io->warning("Scanned " . count($violations) . " errors. Use --ai to get fix suggestions.");
// Run analysis
$result = $analyser->analyse($paths, $onProgress);
// Export Report
if ($progressBar) {
$progressBar->finish();
$io->newLine(2);
}
$violations = $result['violations'];
$baselinedCount = $result['baselinedCount'];
// AI Fix generation (if requested)
if ($input->getOption('ai') && !empty($violations)) {
$apiKey = $config->getGeminiApiKey();
if ($apiKey) {
$fixer = new GeminiFixer($apiKey);
$io->comment("Requesting AI fix suggestions for violations...");
foreach ($violations as $v) {
$suggestion = $fixer->suggestFix($v);
if ($suggestion) {
$v->fixSuggestion = $suggestion;
}
}
} else {
$io->warning("GEMINI_API_KEY not found. AI suggestions skipped.");
}
}
// Generate baseline if option is present
if ($input->getOption('generate-baseline')) {
if (!$baselinePath) {
$io->error("No baseline path specified.");
return Command::FAILURE;
}
$baselineFile = getcwd() . '/' . $baselinePath;
// Re-run analysis without baseline filtering to catch all violations
$unfilteredAnalyser = new Analyser($config, $scanner, new BaselineManager());
$allResult = $unfilteredAnalyser->analyse($paths);
$allViolations = $allResult['violations'];
if ($baselineManager->generate($allViolations, $baselineFile)) {
$io->success(sprintf("Baseline generated successfully with %d violations at: %s", count($allViolations), $baselinePath));
return Command::SUCCESS;
} else {
$io->error("Failed to generate baseline file.");
return Command::FAILURE;
}
}
// Select formatter
$formatter = new ConsoleFormatter();
if ($format === 'json') {
$formatter = new JsonFormatter();
} elseif ($format === 'github') {
$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);
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
$reportPath = $input->getOption('report');
if ($reportPath) {
if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
if ($extension === 'json') {
$data = array_map(fn($v) => $v->toArray(), $violations);
file_put_contents($reportPath, json_encode($data, JSON_PRETTY_PRINT));
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
} else {
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
file_put_contents($reportPath, $reporter->render($violations, $target));
// Map violations into page format
$mappedViolations = ['Static Analysis' => $violations];
file_put_contents($reportPath, $reporter->render($mappedViolations, 'Static Analysis'));
}
$io->success("Report saved to: $reportPath");
}
return Command::SUCCESS;
return $exitCode;
}
/**
* Legacy URL scanning and crawling logic
*/
private function executeUrlScan(string $target, int $level, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
{
$enableAi = (bool)$input->getOption('ai');
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$io->title("Accessibility Console - Scanning URL: $target (Level $level)");
// 1. Setup Scanner
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
// 2. Fetch and Scan
$crawl = (bool)$input->getOption('crawl');
$useJs = (bool)$input->getOption('js');
$chromeRenderer = $useJs ? new \YakNet\AccessibilityConsole\Core\ChromeRenderer() : null;
$results = [];
if ($crawl) {
$depthOption = $input->getOption('depth');
$depthLimit = is_numeric($depthOption) ? (int)$depthOption : 3;
$maxPagesOption = $input->getOption('max-pages');
$maxPages = is_numeric($maxPagesOption) ? (int)$maxPagesOption : 20;
$io->comment("Starting recursive crawl from: $target (Max Depth: $depthLimit, Max Pages: $maxPages)");
$parsedBase = parse_url($target);
$baseHost = $parsedBase['host'] ?? '';
$queue = [['url' => $target, 'depth' => 1]];
$visited = [];
$client = new \GuzzleHttp\Client([
'timeout' => 10,
'allow_redirects' => true,
'headers' => [
'User-Agent' => 'YakNet Accessibility Console Crawler/1.0',
]
]);
while (!empty($queue) && count($visited) < $maxPages) {
$current = array_shift($queue);
$url = $current['url'];
$depth = (int)$current['depth'];
$cleanUrl = strtok($url, '#');
if ($cleanUrl === false || isset($visited[$cleanUrl])) {
continue;
}
$visited[$cleanUrl] = true;
$io->write(" <fg=gray>Crawling [Depth $depth]:</> <fg=cyan>$cleanUrl</>... ");
try {
if ($chromeRenderer) {
$html = $chromeRenderer->render($cleanUrl);
} else {
$response = $client->request('GET', $cleanUrl);
$html = (string)$response->getBody();
}
$io->writeln("<fg=green>OK</>");
$pageViolations = $scanner->scan($html);
$results[$cleanUrl] = $pageViolations;
if ($depth < $depthLimit) {
$html5 = new \Masterminds\HTML5(['disable_html_ns' => true]);
$doc = $html5->loadHTML($html);
$links = $doc->getElementsByTagName('a');
foreach ($links as $link) {
$href = $link->getAttribute('href');
if (empty($href) || str_starts_with($href, 'javascript:') || str_starts_with($href, 'mailto:') || str_starts_with($href, 'tel:')) {
continue;
}
try {
$baseUri = new \GuzzleHttp\Psr7\Uri($cleanUrl);
$relativeUri = new \GuzzleHttp\Psr7\Uri($href);
$absoluteUri = \GuzzleHttp\Psr7\UriResolver::resolve($baseUri, $relativeUri);
$absoluteUrl = (string)$absoluteUri;
} catch (\Throwable) {
continue;
}
$parsedAbsolute = parse_url($absoluteUrl);
$absoluteHost = $parsedAbsolute['host'] ?? '';
if ($absoluteHost === $baseHost) {
$cleanAbsolute = strtok($absoluteUrl, '#');
if ($cleanAbsolute !== false && !isset($visited[$cleanAbsolute])) {
$inQueue = false;
foreach ($queue as $q) {
if ($q['url'] === $cleanAbsolute) {
$inQueue = true;
break;
}
}
if (!$inQueue) {
$queue[] = ['url' => $cleanAbsolute, 'depth' => $depth + 1];
}
}
}
}
}
} catch (\Throwable $e) {
$io->writeln("<fg=red>FAIL (" . $e->getMessage() . ")</>");
}
}
} else {
if ($chromeRenderer) {
try {
$html = $chromeRenderer->render($target);
} catch (\Throwable $e) {
$io->error("Failed JavaScript rendering: " . $e->getMessage());
return Command::FAILURE;
}
} else {
$html = @file_get_contents($target);
}
if ($html === false || $html === '') {
$io->error("Could not read target URL: $target");
return Command::FAILURE;
}
$violations = $scanner->scan($html);
$results[$target] = $violations;
}
$totalViolations = 0;
foreach ($results as $url => $violations) {
$totalViolations += count($violations);
}
if ($totalViolations === 0) {
$io->success("No violations found!");
return Command::SUCCESS;
}
$io->section("Found a total of $totalViolations violations across " . count($results) . " pages");
$locator = new SourceLocator($projectPath);
$config = new Config();
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
foreach ($results as $url => $violations) {
if (empty($violations)) {
continue;
}
$io->section("Page: $url (" . count($violations) . " violations)");
foreach ($violations as $violation) {
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
$violation->location = $location;
}
if ($enableAi && $fixer) {
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation);
if ($suggestion) {
$violation->fixSuggestion = $suggestion;
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
} else {
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
}
}
$io->writeln("");
}
}
$reportPath = $input->getOption('report');
if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
if ($extension === 'json') {
$serialized = [];
foreach ($results as $url => $violations) {
$serialized[$url] = array_map(fn($v) => $v->toArray(), $violations);
}
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
} else {
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
file_put_contents($reportPath, $reporter->render($results, $target));
}
$io->success("Report saved to: $reportPath");
}
return Command::FAILURE;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
abstract class AbstractRule
{
/**
* @return Violation[]
*/
abstract public function check(\DOMDocument $doc): array;
/**
* Get the WCAG standard Enum
*/
abstract public function getStandard(): WCAGStandard;
/**
* Get the severity level Enum
*/
abstract public function getSeverity(): Severity;
public function getLevel(): int
{
return 4;
}
protected function createViolation(string $message, \DOMElement $element): Violation
{
$doc = $element->ownerDocument;
$html = '';
if ($doc !== null) {
$htmlVal = $doc->saveHTML($element);
if (is_string($htmlVal)) {
$html = $htmlVal;
}
}
return new Violation(
ruleId: (new \ReflectionClass($this))->getShortName(),
message: $message,
severity: $this->getSeverity(),
standard: $this->getStandard(),
htmlSnippet: $html
);
}
}
+290
View File
@@ -0,0 +1,290 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class Analyser
{
private Config $config;
private Scanner $scanner;
private BaselineManager $baselineManager;
private string $projectRoot;
public function __construct(?Config $config = null, ?Scanner $scanner = null, ?BaselineManager $baselineManager = null)
{
$this->config = $config ?? new Config();
$this->scanner = $scanner ?? $this->setupScanner();
$this->baselineManager = $baselineManager ?? new BaselineManager();
$cwd = getcwd();
$this->projectRoot = $cwd ? str_replace('\\', '/', realpath($cwd) ?: $cwd) : '.';
if ($baselineManager === null) {
$baselinePath = $this->config->getBaselinePath();
if ($baselinePath) {
$this->baselineManager->load($this->projectRoot . '/' . $baselinePath);
}
}
}
private function setupScanner(): Scanner
{
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($this->config->getLevel());
$rulesConfig = $this->config->getRulesConfig();
$activeRules = [];
foreach ($rules as $rule) {
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: (new \ReflectionClass($rule))->getShortName();
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
continue;
}
$activeRules[$ruleId] = $rule;
}
// Include custom rules
foreach ($rulesConfig['include'] as $customRuleClass) {
if (class_exists($customRuleClass)) {
try {
$reflection = new \ReflectionClass($customRuleClass);
if ($reflection->isInstantiable()) {
$rule = $reflection->newInstance();
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: $reflection->getShortName();
$activeRules[$ruleId] = $rule;
}
} catch (\Throwable $e) {
// Ignore instantiation failures
}
}
}
foreach ($activeRules as $rule) {
$scanner->addRule($rule);
}
return $scanner;
}
/**
* Analyse files and return a list of violations.
*
* @param array<int, string>|null $pathsOverride
* @param callable|null $onProgress Function called for progress tracking: fn(string $file, int $index, int $total)
* @return array{violations: Violation[], baselinedCount: int}
*/
public function analyse(?array $pathsOverride = null, ?callable $onProgress = null): array
{
$paths = $pathsOverride ?? $this->config->getPaths();
$excludePaths = $this->config->getExcludePaths();
// Resolve all files to scan
$filesToScan = [];
foreach ($paths as $path) {
if (is_file($path)) {
$filesToScan[] = str_replace('\\', '/', realpath($path) ?: $path);
} elseif (is_dir($path)) {
$files = $this->findFiles($path, $excludePaths);
$filesToScan = array_merge($filesToScan, $files);
}
}
$filesToScan = array_unique($filesToScan);
$totalFiles = count($filesToScan);
$violations = [];
$baselinedCount = 0;
foreach ($filesToScan as $index => $file) {
if ($onProgress) {
$onProgress($file, $index + 1, $totalFiles);
}
$content = @file_get_contents($file);
if ($content === false || trim($content) === '') {
continue;
}
// Scan file content
try {
$fileViolations = $this->scanner->scan($content);
} catch (\Throwable $e) {
// Skip if parsing failed dramatically
continue;
}
$relativeFile = $this->makePathRelative($file);
foreach ($fileViolations as $violation) {
// Find line number using line-by-line helper
$line = $this->locateLineInContent($content, $violation->htmlSnippet);
// Store location in violation
$violation->location = [
'file' => $relativeFile,
'line' => $line
];
// Check if ignored in baseline
if ($this->baselineManager->isIgnored($relativeFile, $violation->ruleId, $violation->htmlSnippet)) {
$baselinedCount++;
} else {
$violations[] = $violation;
}
}
}
return [
'violations' => $violations,
'baselinedCount' => $baselinedCount
];
}
public function getBaselineManager(): BaselineManager
{
return $this->baselineManager;
}
public function makePathRelative(string $absolutePath): string
{
$absolutePath = str_replace('\\', '/', realpath($absolutePath) ?: $absolutePath);
if ($this->projectRoot !== '.' && str_starts_with($absolutePath, $this->projectRoot)) {
$relative = ltrim(substr($absolutePath, strlen($this->projectRoot)), '/');
return $relative === '' ? '.' : $relative;
}
return $absolutePath;
}
/**
* @param array<int, string> $excludePaths
* @return array<int, string>
*/
private function findFiles(string $dir, array $excludePaths): array
{
$realDir = realpath($dir);
if ($realDir === false) {
return [];
}
$files = [];
try {
$directory = new RecursiveDirectoryIterator($realDir, \FilesystemIterator::SKIP_DOTS);
$filter = new \RecursiveCallbackFilterIterator($directory, function ($current) use ($excludePaths) {
/** @var SplFileInfo $current */
$filename = $current->getFilename();
$pathname = str_replace('\\', '/', $current->getPathname());
// Exclude common directories/files
foreach ($excludePaths as $exclude) {
$normalizedExclude = str_replace('\\', '/', $exclude);
if ($filename === $normalizedExclude || str_contains($pathname, '/' . $normalizedExclude . '/')) {
return false;
}
}
return true;
});
$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
$extensions = ['php', 'html', 'twig', 'blade.php'];
foreach ($iterator as $file) {
/** @var SplFileInfo $file */
if ($file->isDir()) {
continue;
}
$filename = $file->getFilename();
$isValidExt = false;
foreach ($extensions as $ext) {
if (str_ends_with($filename, '.' . $ext) || ($ext === 'blade.php' && str_ends_with($filename, '.blade.php'))) {
$isValidExt = true;
break;
}
}
if ($isValidExt) {
$files[] = str_replace('\\', '/', $file->getPathname());
}
}
} catch (\Throwable $e) {
// Ignore directory iteration errors
}
return $files;
}
private function locateLineInContent(string $content, string $snippet): int
{
$replaced = preg_replace('/\s+/', ' ', $snippet);
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
if ($cleanSnippet === '') {
return 1;
}
$searchSnippet = mb_strlen($cleanSnippet) > 150 ? mb_substr($cleanSnippet, 0, 150) : $cleanSnippet;
$lines = explode("\n", $content);
// 1. Exact match pass
foreach ($lines as $index => $line) {
$replacedLine = preg_replace('/\s+/', ' ', $line);
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
if (str_contains($cleanLine, $searchSnippet)) {
return $index + 1;
}
}
// 2. Parse snippet to find tag and attributes for fuzzy match pass
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
$tagName = $matches[1] ?? null;
if (!$tagName) {
return 1;
}
preg_match_all('/([a-zA-Z0-9-]+)=["\']([^"\']*)["\']/', $snippet, $attrMatches, PREG_SET_ORDER);
$attributes = [];
foreach ($attrMatches as $match) {
$attributes[$match[1]] = $match[2];
}
$bestLine = 1;
$maxScore = 0;
foreach ($lines as $index => $line) {
$replacedLine = preg_replace('/\s+/', ' ', $line);
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
if (!str_contains(strtolower($cleanLine), '<' . strtolower($tagName))) {
continue;
}
$score = 1.0;
if (in_array(strtolower($tagName), ['html', 'head', 'body', 'title'], true)) {
$score += 1.0;
}
foreach ($attributes as $name => $value) {
if (str_contains($cleanLine, "$name=\"$value\"") || str_contains($cleanLine, "$name='$value'")) {
$score += 2.0;
} elseif (str_contains($cleanLine, $name . '=')) {
$score += 0.5;
}
}
if ($score > $maxScore) {
$maxScore = $score;
$bestLine = $index + 1;
}
}
return $bestLine;
}
}
@@ -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);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
class BaselineManager
{
/** @var array<string, bool> */
private array $ignoredKeys = [];
public function load(string $baselinePath): bool
{
if (!file_exists($baselinePath) || !is_readable($baselinePath)) {
return false;
}
$content = file_get_contents($baselinePath);
if ($content === false) {
return false;
}
$data = json_decode($content, true);
if (!is_array($data) || !isset($data['violations']) || !is_array($data['violations'])) {
return false;
}
foreach ($data['violations'] as $item) {
if (!is_array($item)) {
continue;
}
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])
&& is_string($item['file']) && is_string($item['ruleId']) && is_string($item['htmlSnippet'])
) {
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
$this->ignoredKeys[$key] = true;
}
}
return true;
}
/**
* @param Violation[] $violations
*/
public function generate(array $violations, string $baselinePath): bool
{
$serialized = [
'generator' => 'YakNet Accessibility Console',
'violations' => []
];
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
$serialized['violations'][] = [
'file' => $file,
'ruleId' => $v->ruleId,
'htmlSnippet' => $v->htmlSnippet
];
}
// Sort baseline violations by file and ruleId for cleaner git diffs
usort($serialized['violations'], function ($a, $b) {
$fileCmp = strcmp($a['file'], $b['file']);
if ($fileCmp !== 0) {
return $fileCmp;
}
return strcmp($a['ruleId'], $b['ruleId']);
});
$json = json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return false;
}
$dir = dirname($baselinePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return file_put_contents($baselinePath, $json) !== false;
}
public function isIgnored(string $file, string $ruleId, string $htmlSnippet): bool
{
$key = $this->calculateKey($file, $ruleId, $htmlSnippet);
return isset($this->ignoredKeys[$key]);
}
private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string
{
// Normalize snippet using fuzzy baseline matcher
$normalizedSnippet = \YakNet\AccessibilityConsole\Core\Baseline\FuzzyBaselineMatcher::normalize($htmlSnippet);
if ($normalizedSnippet === '') {
$normalizedSnippet = trim($htmlSnippet);
}
// Normalize file separator to forward slashes for cross-platform compatibility
$normalizedFile = str_replace('\\', '/', $file);
return md5($normalizedFile . '::' . $ruleId . '::' . $normalizedSnippet);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use HeadlessChromium\BrowserFactory;
use HeadlessChromium\Navigation;
class ChromeRenderer
{
/**
* Navigate to URL using headless Chrome and return fully rendered HTML.
*/
public function render(string $url): string
{
$browserFactory = new BrowserFactory();
$options = [
'windowSize' => [1280, 1024],
'noSandbox' => true,
];
// Windows Chrome binary path auto-detection override
$chromeBinary = $_ENV['CHROME_BINARY'] ?? getenv('CHROME_BINARY') ?: null;
if (!$chromeBinary && PHP_OS_FAMILY === 'Windows') {
$standardPaths = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files/Google/Chrome/Application/chrome.exe'
];
foreach ($standardPaths as $path) {
if (file_exists($path)) {
$chromeBinary = $path;
break;
}
}
}
if ($chromeBinary) {
$options['chromeBinary'] = $chromeBinary;
}
$browser = $browserFactory->createBrowser($options);
try {
$page = $browser->createPage();
// Navigate and wait for onload event
$navigation = $page->navigate($url);
$navigation->waitForNavigation(\HeadlessChromium\Page::LOAD, 10000); // 10 seconds timeout
// Extra wait for async JS rendering to complete
usleep(1500000); // 1.5s
$html = $page->getHtml();
} finally {
$browser->close();
}
return $html;
}
}
+103 -2
View File
@@ -4,16 +4,44 @@ namespace YakNet\AccessibilityConsole\Core;
class Config
{
/** @var array<string, mixed> */
private array $settings = [];
/**
* @param array<string, mixed> $settings
*/
public function __construct(array $settings = [])
{
$this->settings = $settings;
$this->loadDotenv();
}
private function loadDotenv(): void
{
$cwd = getcwd();
if ($cwd && file_exists($cwd . '/.env')) {
try {
$dotenv = \Dotenv\Dotenv::createImmutable($cwd);
$dotenv->safeLoad();
} catch (\Throwable $e) {
// Ignore Dotenv errors silently
}
}
}
public static function fromYaml(string $path): self
{
// Placeholder for YAML loading logic
if (file_exists($path)) {
try {
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
if (is_array($content)) {
/** @var array<string, mixed> $content */
return new self($content);
}
} catch (\Throwable $e) {
// Ignore parsing errors
}
}
return new self([]);
}
@@ -22,8 +50,81 @@ class Config
return $this->settings[$key] ?? $default;
}
/**
* @return array<int, string>
*/
public function getPaths(): array
{
$paths = $this->get('paths');
if (is_array($paths)) {
return array_values(array_filter($paths, 'is_string'));
}
if (is_string($paths)) {
return [$paths];
}
return ['.']; // default to current directory
}
/**
* @return array<int, string>
*/
public function getExcludePaths(): array
{
$exclude = $this->get('exclude_paths');
if (is_array($exclude)) {
return array_values(array_filter($exclude, 'is_string'));
}
if (is_string($exclude)) {
return [$exclude];
}
return ['vendor', 'node_modules', 'tests', 'storage', '.git']; // sensible defaults
}
public function getLevel(): int
{
$level = $this->get('level', 4);
return is_numeric($level) ? (int)$level : 4;
}
public function getBaselinePath(): ?string
{
$baseline = $this->get('baseline');
return is_string($baseline) ? $baseline : null;
}
public function getFormat(): string
{
$format = $this->get('format', 'console');
return is_string($format) ? $format : 'console';
}
/**
* @return array{include: array<int, string>, exclude: array<int, string>}
*/
public function getRulesConfig(): array
{
$rules = $this->get('rules', []);
$include = [];
$exclude = [];
if (is_array($rules)) {
if (isset($rules['include']) && is_array($rules['include'])) {
$include = array_values(array_filter($rules['include'], 'is_string'));
}
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
$exclude = array_values(array_filter($rules['exclude'], 'is_string'));
}
}
return [
'include' => $include,
'exclude' => $exclude
];
}
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;
}
}
+44
View File
@@ -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,
];
}
}
+58
View File
@@ -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();
}
}
+46
View File
@@ -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;
}
}
+27 -7
View File
@@ -2,23 +2,43 @@
namespace YakNet\AccessibilityConsole\Core;
use YakNet\AccessibilityConsole\Rules\RuleInterface;
class RuleEngine
{
/** @var RuleInterface[] */
/** @var array<int, mixed> */
private array $rules = [];
public function addRule(RuleInterface $rule): void
public function addRule(mixed $rule): void
{
$this->rules[] = $rule;
}
/**
* @return RuleInterface[]
* @return Violation[]
*/
public function getRules(): array
public function run(\DOMDocument $doc): array
{
return $this->rules;
$allViolations = [];
foreach ($this->rules as $rule) {
if ($rule instanceof AbstractRule) {
// Document-level rule (Core\AbstractRule style)
$violations = $rule->check($doc);
$allViolations = array_merge($allViolations, $violations);
} elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) {
// Element-level rule (Rules\RuleInterface style)
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*');
if ($elements !== false) {
foreach ($elements as $element) {
if ($element instanceof \DOMElement) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
}
}
}
}
}
}
return $allViolations;
}
}
+15 -40
View File
@@ -2,62 +2,37 @@
namespace YakNet\AccessibilityConsole\Core;
use DOMDocument;
use YakNet\AccessibilityConsole\Rules\RuleInterface;
class Scanner
{
private array $rules = [];
private RuleEngine $engine;
public function __construct(
private readonly ?RuleEngine $engine = null
) {
if ($this->engine) {
$this->rules = $this->engine->getRules();
}
public function __construct(?RuleEngine $engine = null)
{
$this->engine = $engine ?? new RuleEngine();
}
public function addRule(RuleInterface $rule): void
public function addRule(mixed $rule): void
{
$this->rules[] = $rule;
$this->engine->addRule($rule);
}
/**
* Scan HTML content for accessibility violations.
*
* @param string $html
* @return Violation[]
*/
public function scan(string $html): array
{
if (trim($html) === '') {
if (empty(trim($html))) {
return [];
}
$dom = new DOMDocument();
$previousState = libxml_use_internal_errors(true);
$loaded = $dom->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();
libxml_use_internal_errors($previousState);
$html5 = new \Masterminds\HTML5([
'disable_html_ns' => true,
]);
$doc = $html5->loadHTML($html);
if (!$loaded) {
return [];
}
$violations = [];
$elements = $dom->getElementsByTagName('*');
foreach ($elements as $element) {
if (!$element instanceof \DOMElement) continue;
foreach ($this->rules as $rule) {
try {
$violation = $rule->check($element);
if ($violation) {
$violations[] = $violation;
}
} catch (\Throwable) {
continue;
}
}
}
return $violations;
return $this->engine->run($doc);
}
}
+65
View File
@@ -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;
}
}
+7 -1
View File
@@ -2,8 +2,11 @@
namespace YakNet\AccessibilityConsole\Core;
readonly class Violation
class Violation
{
/**
* @param array{file: string, line: int}|null $location
*/
public function __construct(
public string $ruleId,
public string $message,
@@ -14,6 +17,9 @@ readonly class Violation
public ?string $fixSuggestion = null
) {}
/**
* @return array{ruleId: string, message: string, severity: string, standard: string, htmlSnippet: string, location: array{file: string, line: int}|null, fixSuggestion: string|null}
*/
public function toArray(): array
{
return [
+20
View File
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class HtmlParser implements ParserInterface
{
public function parse(string $content): string
{
return $content;
}
}
+15
View File
@@ -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;
}
+20
View File
@@ -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;
}
}
+32
View File
@@ -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,
];
}
}
+136
View File
@@ -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;
}
}
+27
View File
@@ -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();
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class ConsoleFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
if (empty($violations)) {
$io->success("No violations found!");
if ($baselinedCount > 0) {
$io->comment("($baselinedCount violations ignored by baseline)");
}
return Command::SUCCESS;
}
// Group by file
$grouped = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$grouped[$file][] = $v;
}
// Sort files alphabetically for stable order
ksort($grouped);
foreach ($grouped as $file => $fileViolations) {
$io->section("File: " . $file);
// Sort violations by line number
usort($fileViolations, function ($a, $b) {
return ($a->location['line'] ?? 0) <=> ($b->location['line'] ?? 0);
});
foreach ($fileViolations as $v) {
$line = $v->location['line'] ?? 0;
$severityColor = 'red';
if ($v->severity->value === 'warning') {
$severityColor = 'yellow';
} elseif ($v->severity->value === 'info') {
$severityColor = 'cyan';
}
// Format the output
$io->writeln(sprintf(
" <fg=gray>Line %d:</> <fg=%s;options=bold>[%s]</> %s",
$line,
$severityColor,
$v->ruleId,
$v->message
));
// Show snippet
$snippet = (string)preg_replace('/\s+/', ' ', trim($v->htmlSnippet));
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($snippet) . "</>");
if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') {
$io->writeln(" <fg=gray>AI Suggestion:</> <fg=green>" . htmlspecialchars(trim($v->fixSuggestion)) . "</>");
}
$io->writeln("");
}
}
$totalViolations = count($violations);
$io->error(sprintf(
"Found %d %s across %d %s.",
$totalViolations,
$totalViolations === 1 ? 'violation' : 'violations',
count($grouped),
count($grouped) === 1 ? 'file' : 'files'
));
if ($baselinedCount > 0) {
$io->comment("($baselinedCount violations ignored by baseline)");
}
return Command::FAILURE;
}
}
+44
View File
@@ -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;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
interface FormatterInterface
{
/**
* Format and output the analysis violations.
*
* @param Violation[] $violations
* @param int $baselinedCount
* @param SymfonyStyle $io
* @return int Exit code (Command::SUCCESS or Command::FAILURE)
*/
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int;
}
@@ -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;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class GithubFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
if (empty($violations)) {
return Command::SUCCESS;
}
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
$line = $v->location['line'] ?? 1;
// Normalize path separators to forward slashes for Github Actions compatibility
$file = str_replace('\\', '/', $file);
$type = ($v->severity->value === 'error') ? 'error' : 'warning';
$io->writeln(sprintf(
"::%s file=%s,line=%d::[%s] %s",
$type,
$file,
$line,
$v->ruleId,
$v->message
));
}
return Command::FAILURE;
}
}
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class JsonFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
$result = [
'totals' => [
'violations' => count($violations),
'baselined' => $baselinedCount
],
'violations' => $serialized
];
$io->write((string)json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+46
View File
@@ -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;
}
}
+57
View File
@@ -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;
}
}
+15 -1
View File
@@ -14,14 +14,28 @@ abstract class AbstractRule implements RuleInterface
abstract public function getStandard(): WCAGStandard;
abstract public function getSeverity(): Severity;
public function getLevel(): int
{
return 4;
}
protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation
{
$doc = $element->ownerDocument;
$html = '';
if ($doc !== null) {
$htmlVal = $doc->saveHTML($element);
if (is_string($htmlVal)) {
$html = $htmlVal;
}
}
return new Violation(
ruleId: $this->getId(),
message: $message,
severity: $this->getSeverity(),
standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element),
htmlSnippet: $html,
fixSuggestion: $suggestion
);
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AccesskeyBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_ACCESSKEY'; }
public function getDescription(): string { return 'The accesskey attribute should not be used as it conflicts with browser and screen reader shortcuts.'; }
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('accesskey')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the accesskey attribute. Use natural focus order and standard keyboard event listeners instead.'
);
}
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AreaAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_AreaAlt'; }
public function getDescription(): string { return 'Checks that <area> elements in image maps have non-empty alt attributes.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'area') {
return null;
}
$alt = $element->getAttribute('alt');
$ariaLabel = $element->getAttribute('aria-label');
if (empty(trim($alt)) && empty(trim($ariaLabel))) {
return $this->createViolation(
$element,
'<area> öğesinde alt veya aria-label eksik.',
'Provide a non-empty alt or aria-label attribute for the <area> element.'
);
}
return null;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaFocusable extends AbstractRule
{
/** @var array<int, string> */
private static array $interactiveRoles = [
'button', 'link', 'checkbox', 'menuitem', 'radio', 'textbox',
'searchbox', 'slider', 'spinbutton', 'switch', 'tab'
];
/** @var array<int, string> */
private static array $nativeFocusable = [
'a', 'button', 'input', 'select', 'textarea', 'iframe'
];
public function getId(): string { return 'WCAG_2_1_1_ARIA_FOCUSABLE'; }
public function getDescription(): string { return 'Elements with interactive ARIA roles must be focusable (e.g. have a tabindex attribute).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$role = strtolower(trim($element->getAttribute('role')));
if (!in_array($role, self::$interactiveRoles, true)) {
return null;
}
$tagName = strtolower($element->tagName);
if (in_array($tagName, self::$nativeFocusable, true)) {
return null;
}
if (!$element->hasAttribute('tabindex')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add tabindex="0" to make the element focusable via keyboard navigation.'
);
}
return null;
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaHiddenFocusable extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_HIDDEN_FOCUS'; }
public function getDescription(): string { return 'Focusable elements must not be hidden with aria-hidden="true".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
if ($element->getAttribute('aria-hidden') !== 'true') {
return null;
}
if ($this->isFocusable($element) || $this->hasFocusableDescendant($element)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove aria-hidden="true" from the container or make its focusable elements non-focusable (e.g. by setting tabindex="-1" or disabled).'
);
}
return null;
}
private function isFocusable(DOMElement $element): bool
{
$tagName = strtolower($element->tagName);
if ($element->hasAttribute('tabindex')) {
$tabindex = (int)$element->getAttribute('tabindex');
if ($tabindex < 0) {
return false;
}
return true;
}
if ($tagName === 'a' && $element->hasAttribute('href')) {
return true;
}
if (in_array($tagName, ['button', 'select', 'textarea'], true)) {
return !$element->hasAttribute('disabled');
}
if ($tagName === 'input') {
$type = strtolower($element->getAttribute('type'));
return $type !== 'hidden' && !$element->hasAttribute('disabled');
}
if ($element->hasAttribute('contenteditable') && strtolower($element->getAttribute('contenteditable')) === 'true') {
return true;
}
if (in_array($tagName, ['details', 'summary'], true)) {
return true;
}
if (in_array($tagName, ['audio', 'video'], true) && $element->hasAttribute('controls')) {
return true;
}
if ($tagName === 'iframe') {
return true;
}
return false;
}
private function hasFocusableDescendant(DOMElement $element): bool
{
$doc = $element->ownerDocument;
if ($doc === null) {
return false;
}
$xpath = new \DOMXPath($doc);
$descendants = $xpath->query('.//*', $element);
if ($descendants === false) {
return false;
}
foreach ($descendants as $descendant) {
if ($descendant instanceof DOMElement && $this->isFocusable($descendant)) {
return true;
}
}
return false;
}
}
+57
View File
@@ -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;
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredAttr extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_REQUIRED_ATTR'; }
public function getDescription(): string { return 'Elements with ARIA roles must have all required ARIA attributes for that role.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredMap = [
'slider' => ['aria-valuenow', 'aria-valuemin', 'aria-valuemax'],
'checkbox' => ['aria-checked'],
'combobox' => ['aria-expanded'],
'scrollbar' => ['aria-controls', 'aria-valuenow', 'aria-valuemax', 'aria-valuemin', 'aria-orientation'],
'spinbutton' => ['aria-valuenow', 'aria-valuemax', 'aria-valuemin'],
'switch' => ['aria-checked'],
'meter' => ['aria-valuenow'],
'separator' => ['aria-valuenow'] // strictly when focusable, but we check if the role is present
];
foreach ($roles as $role) {
if (isset($requiredMap[$role])) {
$missingAttrs = [];
foreach ($requiredMap[$role] as $reqAttr) {
if (!$element->hasAttribute($reqAttr)) {
$missingAttrs[] = $reqAttr;
}
}
if (!empty($missingAttrs)) {
// Specific case: separator without tabindex is not focusable and doesn't require aria-valuenow in some contexts
if ($role === 'separator' && !$element->hasAttribute('tabindex')) {
continue;
}
$missingStr = implode(', ', $missingAttrs);
return $this->createViolation(
$element,
"ARIA role '{$role}' eksik zorunlu niteliklere sahip: {$missingStr}.",
"Belirtilen ARIA rolü için zorunlu olan {$missingStr} niteliklerini ekleyin."
);
}
}
}
return null;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredChildren extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_CHILDREN'; }
public function getDescription(): string { return 'Elements with certain ARIA roles must contain children with specific roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredChildrenMap = [
'list' => ['listitem'],
'menu' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
'menubar' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
'tablist' => ['tab'],
'tree' => ['treeitem'],
'grid' => ['row', 'rowgroup'],
'table' => ['row', 'rowgroup'],
'radiogroup' => ['radio'],
'listbox' => ['option'],
'rowgroup' => ['row'],
'row' => ['cell', 'gridcell', 'columnheader', 'rowheader']
];
foreach ($roles as $role) {
if (isset($requiredChildrenMap[$role])) {
$hasRequiredChild = $this->hasChildWithRole($element, $requiredChildrenMap[$role]);
if (!$hasRequiredChild) {
// Check if children are dynamically generated with templates
$doc = $element->ownerDocument;
if ($doc !== null) {
$html = $doc->saveHTML($element);
if ($html !== false && preg_match('/(\{\{|\{%|<\?)/', $html)) {
continue;
}
}
$expectedStr = implode(' veya ', $requiredChildrenMap[$role]);
return $this->createViolation(
$element,
"ARIA rolü '{$role}' olan eleman gerekli alt rollere sahip değil.",
"Bu elemanın içine rolü {$expectedStr} olan çocuk elemanlar ekleyin."
);
}
}
}
return null;
}
/**
* @param array<int, string> $requiredRoles
*/
private function hasChildWithRole(DOMElement $element, array $requiredRoles): bool
{
$doc = $element->ownerDocument;
if ($doc === null) {
return false;
}
$xpath = new \DOMXPath($doc);
foreach ($requiredRoles as $reqRole) {
$query = ".//*[@role='{$reqRole}' or contains(@role, ' {$reqRole}') or contains(@role, '{$reqRole} ')]";
$nodes = $xpath->query($query, $element);
if ($nodes !== false && $nodes->length > 0) {
return true;
}
}
return false;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_PARENT'; }
public function getDescription(): string { return 'Elements with certain ARIA roles must be contained by parents with specific roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredParentMap = [
'listitem' => ['list', 'group'],
'menuitem' => ['menu', 'menubar', 'group'],
'menuitemcheckbox' => ['menu', 'menubar'],
'menuitemradio' => ['menu', 'menubar'],
'tab' => ['tablist'],
'treeitem' => ['tree', 'group'],
'cell' => ['row'],
'gridcell' => ['row'],
'columnheader' => ['row'],
'rowheader' => ['row'],
'option' => ['listbox', 'group'],
'row' => ['grid', 'table', 'treegrid', 'rowgroup']
];
foreach ($roles as $role) {
if (isset($requiredParentMap[$role])) {
$hasRequiredParent = $this->hasParentWithRole($element, $requiredParentMap[$role]);
if (!$hasRequiredParent) {
$expectedStr = implode(' veya ', $requiredParentMap[$role]);
return $this->createViolation(
$element,
"ARIA rolü '{$role}' olan eleman gerekli üst eleman rollerinden hiçbirine sahip değil.",
"Bu elemanı rolü {$expectedStr} olan bir üst elemanın içine yerleştirin."
);
}
}
}
return null;
}
/**
* @param array<int, string> $requiredRoles
*/
private function hasParentWithRole(DOMElement $element, array $requiredRoles): bool
{
$parent = $element->parentNode;
while ($parent !== null && $parent instanceof DOMElement) {
if ($parent->hasAttribute('role')) {
$parentRoles = explode(' ', strtolower(trim($parent->getAttribute('role'))));
foreach ($parentRoles as $pRole) {
if (in_array($pRole, $requiredRoles)) {
return true;
}
}
}
// For native HTML semantics fallback (e.g. <ul> is a list)
$tagName = strtolower($parent->tagName);
$nativeRole = $this->getNativeRole($tagName);
if ($nativeRole && in_array($nativeRole, $requiredRoles)) {
return true;
}
$parent = $parent->parentNode;
}
return false;
}
private function getNativeRole(string $tagName): ?string
{
$map = [
'ul' => 'list',
'ol' => 'list',
'dl' => 'list',
'table' => 'table',
'tr' => 'row',
'tbody' => 'rowgroup',
'thead' => 'rowgroup',
'tfoot' => 'rowgroup',
'select' => 'listbox',
'optgroup' => 'group'
];
return $map[$tagName] ?? null;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRole extends AbstractRule
{
/** @var array<int, string> */
private static array $validRoles = [
'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 'cell', 'checkbox',
'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'dialog', 'directory',
'document', 'feed', 'figure', 'form', 'grid', 'gridcell', 'group', 'heading', 'img', 'link',
'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'menu', 'menubar', 'menuitem',
'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'presentation',
'progressbar', 'radio', 'radiogroup', 'region', 'row', 'rowgroup', 'rowheader', 'scrollbar',
'search', 'searchbox', 'separator', 'slider', 'spinbutton', 'status', 'switch', 'tab', 'table',
'tablist', 'tabpanel', 'term', 'textbox', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid',
'treeitem'
];
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
// 1. Check WAI-ARIA Role Validity
$roleElements = $xpath->query('//*[@role]');
if ($roleElements !== false) {
foreach ($roleElements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$role = trim($el->getAttribute('role'));
if ($role === '') {
continue;
}
$roles = array_filter(explode(' ', $role));
foreach ($roles as $r) {
if (!in_array(strtolower($r), self::$validRoles, true)) {
$violations[] = $this->createViolation(
"Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.",
$el
);
}
}
}
}
// 2. Check aria-controls Targets Existence
$controlsElements = $xpath->query('//*[@aria-controls]');
if ($controlsElements !== false) {
foreach ($controlsElements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$targetsStr = trim($el->getAttribute('aria-controls'));
if ($targetsStr === '') {
continue;
}
$targets = array_filter(explode(' ', $targetsStr));
foreach ($targets as $targetId) {
$targetQuery = $xpath->query('//*[@id="' . $targetId . '"]');
if ($targetQuery !== false && $targetQuery->length === 0) {
$violations[] = $this->createViolation(
"Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.",
$el
);
}
}
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaValidAttr extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR'; }
public function getDescription(): string { return 'ARIA attributes must be valid names.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$validAttributes = [
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel',
'aria-brailleroledescription', 'aria-busy', 'aria-checked', 'aria-colcount',
'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-controls',
'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-expanded',
'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live',
'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation',
'aria-owns', 'aria-placeholder', 'aria-posinset', 'aria-pressed', 'aria-readonly',
'aria-relevant', 'aria-required', 'aria-roledescription', 'aria-rowcount',
'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected',
'aria-setsize', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow',
'aria-valuetext'
];
$invalidAttrs = [];
foreach ($element->attributes as $attr) {
/** @var \DOMAttr $attr */
$name = strtolower($attr->nodeName);
if (strpos($name, 'aria-') === 0) {
if (!in_array($name, $validAttributes)) {
$invalidAttrs[] = $name;
}
}
}
if (!empty($invalidAttrs)) {
$invalidStr = implode(', ', $invalidAttrs);
return $this->createViolation(
$element,
"Geçersiz ARIA nitelikleri bulundu: {$invalidStr}.",
"Sadece geçerli ARIA niteliklerini kullanın. Geçersiz olanları kaldırın veya düzeltin."
);
}
return null;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaValidAttrValue extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR_VALUE'; }
public function getDescription(): string { return 'ARIA attributes must contain valid values.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$validationMap = [
'aria-hidden' => ['true', 'false'],
'aria-live' => ['off', 'polite', 'assertive'],
'aria-expanded' => ['true', 'false', 'undefined'],
'aria-pressed' => ['true', 'false', 'mixed', 'undefined'],
'aria-checked' => ['true', 'false', 'mixed', 'undefined'],
'aria-current' => ['page', 'step', 'location', 'date', 'time', 'true', 'false'],
'aria-sort' => ['ascending', 'descending', 'none', 'other'],
'aria-orientation' => ['horizontal', 'vertical', 'undefined'],
'aria-haspopup' => ['true', 'false', 'menu', 'listbox', 'tree', 'grid', 'dialog'],
'aria-invalid' => ['grammar', 'false', 'spelling', 'true'],
'aria-autocomplete' => ['inline', 'list', 'both', 'none'],
'aria-dropeffect' => ['copy', 'execute', 'link', 'move', 'none', 'popup']
];
for ($i = 0; $i < $element->attributes->length; $i++) {
$attr = $element->attributes->item($i);
if (!$attr instanceof \DOMAttr) {
continue;
}
$name = strtolower($attr->nodeName);
if (isset($validationMap[$name])) {
$value = strtolower(trim($attr->nodeValue ?? ''));
// Handle templates like {{ value }} or template syntax
if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $value) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $value)) {
continue;
}
if (!in_array($value, $validationMap[$name])) {
$validStr = implode(', ', $validationMap[$name]);
return $this->createViolation(
$element,
"{$name} niteliği geçersiz bir değere sahip: '{$value}'.",
"{$name} niteliğinin değerini geçerli olanlardan biriyle değiştirin: {$validStr}."
);
}
} elseif ($name === 'aria-relevant') {
$values = explode(' ', strtolower(trim($attr->nodeValue ?? '')));
$validRelevant = ['additions', 'removals', 'text', 'all'];
$rawValue = $attr->nodeValue ?? '';
$isTemplate = preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', trim($rawValue)) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', trim($rawValue));
if (!$isTemplate) {
foreach ($values as $val) {
if (!empty($val) && !in_array($val, $validRelevant)) {
return $this->createViolation(
$element,
"aria-relevant niteliği geçersiz bir değer içeriyor: '{$val}'.",
"aria-relevant değerlerini sadece additions, removals, text, all kelimelerinden oluşacak şekilde ayarlayın."
);
}
}
}
}
}
return null;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AudioTrack extends AbstractRule
{
public function getId(): string { return 'WCAG_1_2_1_AudioTrack'; }
public function getDescription(): string { return 'Checks that <audio> elements have a <track> child element for captions/descriptions.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'audio') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$tracks = $xpath->query('./track', $element);
if ($tracks === false || $tracks->length === 0) {
return $this->createViolation(
$element,
'<audio> öğesi <track> alt öğesi içermiyor.',
'Add a <track> element for captions or provide an accessible transcript.'
);
}
return null;
}
}
+56
View File
@@ -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;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutocompleteValidTokens extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_AUTOCOMPLETE_VALID_TOKENS'; }
public function getDescription(): string { return 'Autocomplete attribute must use valid token values.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['input', 'select', 'textarea'])) {
return null;
}
if (!$element->hasAttribute('autocomplete')) {
return null;
}
$autocompleteValue = trim($element->getAttribute('autocomplete'));
if ($autocompleteValue === '') {
return null;
}
// Handle templates like {{ value }} or template syntax
if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $autocompleteValue) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $autocompleteValue)) {
return null;
}
$tokens = explode(' ', strtolower($autocompleteValue));
$validBaseTokens = [
'off', 'on', 'name', 'honorific-prefix', 'given-name', 'additional-name',
'family-name', 'honorific-suffix', 'nickname', 'email', 'username',
'new-password', 'current-password', 'one-time-code', 'organization-title',
'organization', 'street-address', 'address-line1', 'address-line2',
'address-line3', 'address-level4', 'address-level3', 'address-level2',
'address-level1', 'country', 'country-name', 'postal-code', 'cc-name',
'cc-given-name', 'cc-additional-name', 'cc-family-name', 'cc-number',
'cc-exp', 'cc-exp-month', 'cc-exp-year', 'cc-csc', 'cc-type',
'transaction-currency', 'transaction-amount', 'language', 'bday',
'bday-day', 'bday-month', 'bday-year', 'sex', 'tel', 'tel-country-code',
'tel-national', 'tel-area-code', 'tel-local', 'tel-extension', 'impp',
'url', 'photo'
];
foreach ($tokens as $token) {
if (empty($token)) continue;
// Allow section-* prefixes
if (strpos($token, 'section-') === 0) {
continue;
}
// Allow shipping/billing prefixes
if ($token === 'shipping' || $token === 'billing') {
continue;
}
// Check for valid base tokens
// Additional check: some tokens like 'tel' can be prefixed, we handle it simply by checking if it's in the list
$isValid = false;
foreach ($validBaseTokens as $baseToken) {
if ($token === $baseToken || str_ends_with($token, "-{$baseToken}")) {
$isValid = true;
break;
}
}
if (!$isValid) {
return $this->createViolation(
$element,
"Geçersiz autocomplete değeri veya belirteci: '{$token}'.",
"Geçerli bir HTML5 autocomplete belirteci kullanın (örneğin: name, email, tel)."
);
}
}
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class 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;
}
}
+49
View File
@@ -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; }
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class 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;
}
}
+63 -4
View File
@@ -13,6 +13,7 @@ class ButtonName extends AbstractRule
public function getDescription(): string { return 'Buttons must have discernible text.'; }
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
{
@@ -20,11 +21,69 @@ class ButtonName extends AbstractRule
return null;
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
return null;
$text = trim($element->textContent);
if ($text !== '') {
return null;
}
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
if ($element->hasAttribute('aria-labelledby')) {
$id = $element->getAttribute('aria-labelledby');
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//*[@id='$id']");
if ($labels !== false && $labels->length > 0) {
$labelNode = $labels->item(0);
if ($labelNode instanceof \DOMNode && trim($labelNode->textContent) !== '') {
return null;
}
}
}
}
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
}
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class CanvasAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_CanvasAlt'; }
public function getDescription(): string { return 'Checks that <canvas> elements have fallback content or aria labels.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'canvas') {
return null;
}
$hasAriaLabel = !empty($element->getAttribute('aria-label'));
$hasAriaLabelledby = !empty($element->getAttribute('aria-labelledby'));
$hasRoleImgLabel = ($element->getAttribute('role') === 'img') && $hasAriaLabel;
if ($hasAriaLabel || $hasAriaLabelledby || $hasRoleImgLabel) {
return null;
}
$content = trim($element->textContent);
$hasChildElements = false;
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$hasChildElements = true;
break;
}
}
if ($content === '' && !$hasChildElements) {
return $this->createViolation(
$element,
'<canvas> öğesi alternatif içerik veya etiket içermiyor.',
'Provide fallback content inside the <canvas> tag or use aria-label/aria-labelledby.'
);
}
return null;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ClickHandlerKeyboard extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_CLICK_KEYBOARD'; }
public function getDescription(): string { return 'Non-interactive elements with click handlers should also have keyboard handlers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('onclick')) {
return null;
}
$tagName = strtolower($element->tagName);
$interactiveElements = ['a', 'button', 'input', 'select', 'textarea', 'summary', 'details'];
if (in_array($tagName, $interactiveElements)) {
return null;
}
$role = strtolower($element->getAttribute('role'));
if (($role === 'button' || $role === 'link') && $element->hasAttribute('tabindex')) {
return null;
}
if ($element->hasAttribute('onkeydown') || $element->hasAttribute('onkeyup') || $element->hasAttribute('onkeypress')) {
return null;
}
return $this->createViolation(
$element,
'onclick olay işleyicisine sahip etkileşimli olmayan öğenin klavye işleyicisi (onkeydown vb.) eksik. / Non-interactive element with onclick is missing a keyboard handler.',
'Klavye desteği için onkeydown/onkeyup ekleyin ve tabindex="0" tanımlayın. / Add onkeydown/onkeyup handlers and tabindex="0" for keyboard support.'
);
}
}
+362
View File
@@ -0,0 +1,362 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ColorContrast extends AbstractRule
{
/** @var array<string, array<string, array{color?: string, background-color?: string}>> */
private array $documentStylesCache = [];
public function getId(): string { return 'WCAG_1_4_3_CONTRAST'; }
public function getDescription(): string { return 'Text must have sufficient contrast against the background (4.5:1 minimum).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
// 1. Parse inline styles (highest priority)
$inlineStyle = $element->hasAttribute('style') ? $element->getAttribute('style') : '';
$color = $this->parseColorValue($inlineStyle, 'color');
$bg = $this->parseColorValue($inlineStyle, 'background-color') ?: $this->parseColorValue($inlineStyle, 'background');
// 2. Fallback: Parse stylesheet styles
if (($color === null || $bg === null) && $element->ownerDocument !== null) {
$docStyles = $this->parseDocumentStyles($element->ownerDocument);
// Loop through all parsed styles. Later matching rules override earlier ones
foreach ($docStyles as $selector => $styles) {
if ($this->elementMatchesSelector($element, $selector)) {
if ($color === null && isset($styles['color'])) {
$color = $styles['color'];
}
if ($bg === null && isset($styles['background-color'])) {
$bg = $styles['background-color'];
}
}
}
}
if ($color === null || $bg === null) {
return null;
}
$cRGB = $this->hexToRgb($color);
$bgRGB = $this->hexToRgb($bg);
if ($cRGB === null || $bgRGB === null) {
return null;
}
$cLum = $this->getLuminance($cRGB[0], $cRGB[1], $cRGB[2]);
$bgLum = $this->getLuminance($bgRGB[0], $bgRGB[1], $bgRGB[2]);
$ratio = ($cLum > $bgLum) ? ($cLum + 0.05) / ($bgLum + 0.05) : ($bgLum + 0.05) / ($cLum + 0.05);
// Check font size & weight for large text rules
$isLarge = false;
$fontSizeStyle = $this->parseFontSize($inlineStyle, $element);
if ($fontSizeStyle !== null) {
$isLarge = $this->isLargeText($fontSizeStyle, $this->parseFontWeight($inlineStyle, $element));
}
$minRatio = $isLarge ? 3.0 : 4.5;
if ($ratio < $minRatio) {
$formattedRatio = round($ratio, 2);
return $this->createViolation(
$element,
"Insufficient contrast ratio of {$formattedRatio}:1. Minimum required is {$minRatio}:1.",
"Adjust colors to increase contrast. For example, text: {$color}, background: {$bg}."
);
}
return null;
}
private function parseFontSize(string $style, DOMElement $element): ?string
{
if (preg_match('/font-size\s*:\s*([^;]+)/i', $style, $matches)) {
return trim($matches[1]);
}
return null;
}
private function parseFontWeight(string $style, DOMElement $element): ?string
{
if (preg_match('/font-weight\s*:\s*([^;]+)/i', $style, $matches)) {
return trim($matches[1]);
}
return null;
}
private function isLargeText(string $size, ?string $weight): bool
{
if (preg_match('/([0-9.]+)(px|em|rem|pt)/i', $size, $matches)) {
$val = (float)$matches[1];
$unit = strtolower($matches[2]);
$isBold = $weight !== null && preg_match('/^(bold|[7-9]00)$/i', $weight);
if ($isBold) {
return ($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2);
} else {
return ($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5);
}
}
return false;
}
/**
* @return array<string, array{color?: string, background-color?: string}>
*/
private function parseDocumentStyles(\DOMDocument $doc): array
{
$hash = spl_object_hash($doc);
if (isset($this->documentStylesCache[$hash])) {
return $this->documentStylesCache[$hash];
}
$mergedStyles = [];
$styleTags = $doc->getElementsByTagName('style');
foreach ($styleTags as $tag) {
$cssText = $tag->textContent;
if (trim($cssText) === '') {
continue;
}
try {
$parser = new \Sabberworm\CSS\Parser($cssText);
$cssDoc = $parser->parse();
foreach ($cssDoc->getAllDeclarationBlocks() as $block) {
$rules = [];
foreach ($block->getDeclarations() as $decl) {
$prop = strtolower($decl->getPropertyName());
if ($prop === 'color' || $prop === 'background-color' || $prop === 'background') {
// Extract string value
$valObj = $decl->getValue();
$val = is_object($valObj) && method_exists($valObj, '__toString')
? (string)$valObj
: (is_string($valObj) ? $valObj : '');
if ($prop === 'color') {
$normalized = $this->normalizeColorValue($val);
if ($normalized !== null) {
$rules['color'] = $normalized;
}
} elseif ($prop === 'background-color') {
$normalized = $this->normalizeColorValue($val);
if ($normalized !== null) {
$rules['background-color'] = $normalized;
}
} elseif ($prop === 'background') {
$normalized = $this->extractColorFromBackground($val);
if ($normalized !== null) {
$rules['background-color'] = $normalized;
}
}
}
}
if (empty($rules)) {
continue;
}
foreach ($block->getSelectors() as $selector) {
$selectorString = trim($selector->getSelector());
if ($selectorString !== '') {
if (!isset($mergedStyles[$selectorString])) {
$mergedStyles[$selectorString] = [];
}
$mergedStyles[$selectorString] = array_merge($mergedStyles[$selectorString], $rules);
}
}
}
} catch (\Throwable $e) {
// Ignore CSS parser failures
}
}
$this->documentStylesCache[$hash] = $mergedStyles;
return $mergedStyles;
}
private function elementMatchesSelector(DOMElement $element, string $selector): bool
{
$parts = array_map('trim', explode(',', $selector));
foreach ($parts as $part) {
if ($part === '') {
continue;
}
if (str_starts_with($part, '#')) {
$id = substr($part, 1);
if ($element->getAttribute('id') === $id) {
return true;
}
continue;
}
if (str_starts_with($part, '.')) {
$class = substr($part, 1);
$classes = array_filter(array_map('trim', explode(' ', $element->getAttribute('class'))));
if (in_array($class, $classes, true)) {
return true;
}
continue;
}
if (strtolower($element->tagName) === strtolower($part)) {
return true;
}
if (preg_match('/^([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)$/', $part, $matches)) {
$tag = $matches[1];
$class = $matches[2];
$classes = array_filter(array_map('trim', explode(' ', $element->getAttribute('class'))));
if (strtolower($element->tagName) === strtolower($tag) && in_array($class, $classes, true)) {
return true;
}
}
if (preg_match('/^([a-zA-Z0-9_-]+)#([a-zA-Z0-9_-]+)$/', $part, $matches)) {
$tag = $matches[1];
$id = $matches[2];
if (strtolower($element->tagName) === strtolower($tag) && $element->getAttribute('id') === $id) {
return true;
}
}
}
return false;
}
private function normalizeColorValue(string $val): ?string
{
$val = trim($val);
if (preg_match('/^#([0-9a-fA-F]{3,6})$/', $val)) {
return $val;
}
if (preg_match('/rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)/i', $val, $matches)) {
return sprintf("#%02x%02x%02x", (int)$matches[1], (int)$matches[2], (int)$matches[3]);
}
$colors = [
'aliceblue' => '#f0f8ff', 'antiquewhite' => '#faebd7', 'aqua' => '#00ffff',
'aquamarine' => '#7fffd4', 'azure' => '#f0ffff', 'beige' => '#f5f5dc',
'bisque' => '#ffe4c4', 'black' => '#000000', 'blanchedalmond' => '#ffebcd',
'blue' => '#0000ff', 'blueviolet' => '#8a2be2', 'brown' => '#a52a2a',
'burlywood' => '#deb887', 'cadetblue' => '#5f9ea0', 'chartreuse' => '#7fff00',
'chocolate' => '#d2691e', 'coral' => '#ff7f50', 'cornflowerblue' => '#6495ed',
'cornsilk' => '#fff8dc', 'crimson' => '#dc143c', 'cyan' => '#00ffff',
'darkblue' => '#00008b', 'darkcyan' => '#008b8b', 'darkgoldenrod' => '#b8860b',
'darkgray' => '#a9a9a9', 'darkgreen' => '#006400', 'darkgrey' => '#a9a9a9',
'darkkhaki' => '#bdb76b', 'darkmagenta' => '#8b008b', 'darkolivegreen' => '#556b2f',
'darkorange' => '#ff8c00', 'darkorchid' => '#9932cc', 'darkred' => '#8b0000',
'darksalmon' => '#e9967a', 'darkseagreen' => '#8fbc8f', 'darkslateblue' => '#483d8b',
'darkslategray' => '#2f4f4f', 'darkslategrey' => '#2f4f4f', 'darkturquoise' => '#00ced1',
'darkviolet' => '#9400d3', 'deeppink' => '#ff1493', 'deepskyblue' => '#00bfff',
'dimgray' => '#696969', 'dimgrey' => '#696969', 'dodgerblue' => '#1e90ff',
'firebrick' => '#b22222', 'floralwhite' => '#fffaf0', 'forestgreen' => '#228b22',
'fuchsia' => '#ff00ff', 'gainsboro' => '#dcdcdc', 'ghostwhite' => '#f8f8ff',
'gold' => '#ffd700', 'goldenrod' => '#daa520', 'gray' => '#808080',
'green' => '#008000', 'greenyellow' => '#adff2f', 'grey' => '#808080',
'honeydew' => '#f0fff0', 'hotpink' => '#ff69b4', 'indianred' => '#cd5c5c',
'indigo' => '#4b0082', 'ivory' => '#fffff0', 'khaki' => '#f0e68c',
'lavender' => '#e6e6fa', 'lavenderblush' => '#fff0f5', 'lawngreen' => '#7cfc00',
'lemonchiffon' => '#fffacd', 'lightblue' => '#add8e6', 'lightcoral' => '#f08080',
'lightcyan' => '#e0ffff', 'lightgoldenrodyellow' => '#fafad2', 'lightgray' => '#d3d3d3',
'lightgreen' => '#90ee90', 'lightgrey' => '#d3d3d3', 'lightpink' => '#ffb6c1',
'lightsalmon' => '#ffa07a', 'lightseagreen' => '#20b2aa', 'lightskyblue' => '#87cefa',
'lightslategray' => '#778899', 'lightslategrey' => '#778899', 'lightsteelblue' => '#b0c4de',
'lightyellow' => '#ffffe0', 'lime' => '#00ff00', 'limegreen' => '#32cd32',
'linen' => '#faf0e6', 'magenta' => '#ff00ff', 'maroon' => '#800000',
'mediumaquamarine' => '#66cdaa', 'mediumblue' => '#0000cd', 'mediumorchid' => '#ba55d3',
'mediumpurple' => '#9370db', 'mediumseagreen' => '#3cb371', 'mediumslateblue' => '#7b68ee',
'mediumspringgreen' => '#00fa9a', 'mediumturquoise' => '#48d1cc', 'mediumvioletred' => '#c71585',
'midnightblue' => '#191970', 'mintcream' => '#f5fffa', 'mistyrose' => '#ffe4e1',
'moccasin' => '#ffe4b5', 'navajowhite' => '#ffdead', 'navy' => '#000080',
'oldlace' => '#fdf5e6', 'olive' => '#808000', 'olivedrab' => '#6b8e23',
'orange' => '#ffa500', 'orangered' => '#ff4500', 'orchid' => '#da70d6',
'palegoldenrod' => '#eee8aa', 'palegreen' => '#98fb98', 'paleturquoise' => '#afeeee',
'palevioletred' => '#db7093', 'papayawhip' => '#ffefd5', 'peachpuff' => '#ffdab9',
'peru' => '#cd853f', 'pink' => '#ffc0cb', 'plum' => '#dda0dd',
'powderblue' => '#b0e0e6', 'purple' => '#800080', 'rebeccapurple' => '#663399',
'red' => '#ff0000', 'rosybrown' => '#bc8f8f', 'royalblue' => '#4169e1',
'saddlebrown' => '#8b4513', 'salmon' => '#fa8072', 'sandybrown' => '#f4a460',
'seagreen' => '#2e8b57', 'seashell' => '#fff5ee', 'sienna' => '#a0522d',
'silver' => '#c0c0c0', 'skyblue' => '#87ceeb', 'slateblue' => '#6a5acd',
'slategray' => '#708090', 'slategrey' => '#708090', 'snow' => '#fffafa',
'springgreen' => '#00ff7f', 'steelblue' => '#4682b4', 'tan' => '#d2b48c',
'teal' => '#008080', 'thistle' => '#d8bfd8', 'tomato' => '#ff6347',
'turquoise' => '#40e0d0', 'violet' => '#ee82ee', 'wheat' => '#f5deb3',
'white' => '#ffffff', 'whitesmoke' => '#f5f5f5', 'yellow' => '#ffff00',
'yellowgreen' => '#9acd32',
];
if (isset($colors[strtolower($val)])) {
return $colors[strtolower($val)];
}
return null;
}
private function extractColorFromBackground(string $val): ?string
{
$parts = explode(' ', $val);
foreach ($parts as $part) {
$color = $this->normalizeColorValue($part);
if ($color !== null) {
return $color;
}
}
return null;
}
private function parseColorValue(string $style, string $property): ?string
{
if (preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:\s*([^;]+)/i', $style, $matches)) {
return $this->normalizeColorValue(trim($matches[1]));
}
return null;
}
/**
* @return array{0: int, 1: int, 2: int}|null
*/
private function hexToRgb(string $hex): ?array
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$r = (int)hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
$g = (int)hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
$b = (int)hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
return [$r, $g, $b];
} elseif (strlen($hex) === 6) {
$r = (int)hexdec(substr($hex, 0, 2));
$g = (int)hexdec(substr($hex, 2, 2));
$b = (int)hexdec(substr($hex, 4, 2));
return [$r, $g, $b];
}
return null;
}
private function getLuminance(float $r, float $g, float $b): float
{
$rs = $r / 255.0;
$gs = $g / 255.0;
$bs = $b / 255.0;
$rVal = ($rs <= 0.03928) ? $rs / 12.92 : pow(($rs + 0.055) / 1.055, 2.4);
$gVal = ($gs <= 0.03928) ? $gs / 12.92 : pow(($gs + 0.055) / 1.055, 2.4);
$bVal = ($bs <= 0.03928) ? $bs / 12.92 : pow(($bs + 0.055) / 1.055, 2.4);
return 0.2126 * $rVal + 0.7152 * $gVal + 0.0722 * $bVal;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DefinitionListStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_DefinitionListStructure'; }
public function getDescription(): string { return 'Checks that <dl> elements only contain <dt>, <dd>, and <div> as direct children.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'dl') {
return null;
}
$invalidChildren = [];
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$childTag = strtolower($child->tagName);
if (!in_array($childTag, ['dt', 'dd', 'div'])) {
$invalidChildren[] = $childTag;
}
}
}
if (count($invalidChildren) > 0) {
return $this->createViolation(
$element,
'<dl> öğesi doğrudan alt öğe olarak sadece <dt>, <dd> veya <div> içerebilir.',
'Remove or wrap invalid children (' . implode(', ', array_unique($invalidChildren)) . ') inside <dd> or <dt>.'
);
}
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DialogAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_DIALOG_ACCESSIBILITY'; }
public function getDescription(): string { return 'Dialog elements must have an accessible name.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$role = strtolower($element->getAttribute('role'));
$tagName = strtolower($element->tagName);
if ($role !== 'dialog' && $role !== 'alertdialog' && $tagName !== 'dialog') {
return null;
}
$ariaLabel = trim($element->getAttribute('aria-label'));
$ariaLabelledby = trim($element->getAttribute('aria-labelledby'));
$title = trim($element->getAttribute('title'));
if (!empty($ariaLabel) || !empty($ariaLabelledby) || !empty($title)) {
return null;
}
return $this->createViolation(
$element,
'Dialog bileşeninin erişilebilir bir adı (accessible name) eksik. / Dialog component is missing an accessible name.',
'Dialog elementine aria-label veya aria-labelledby niteliği ekleyin. / Add aria-label or aria-labelledby to the dialog element.'
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class 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;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DuplicateId extends AbstractRule
{
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*[@id]');
if ($elements === false) {
return [];
}
$ids = [];
foreach ($elements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$id = $el->getAttribute('id');
if ($id === '') {
continue;
}
if (!isset($ids[$id])) {
$ids[$id] = [];
}
$ids[$id][] = $el;
}
foreach ($ids as $id => $elementsWithId) {
if (count($elementsWithId) > 1) {
foreach ($elementsWithId as $el) {
$violations[] = $this->createViolation(
"Duplicate ID \"{$id}\" found. Element ID attribute must be unique across the document.",
$el
);
}
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmbedAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_EmbedAccessibility'; }
public function getDescription(): string { return 'Checks that <embed> elements have aria-label, aria-labelledby, or title attribute for accessible name.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'embed') {
return null;
}
$ariaLabel = $element->getAttribute('aria-label');
$ariaLabelledby = $element->getAttribute('aria-labelledby');
$title = $element->getAttribute('title');
if (empty(trim($ariaLabel)) && empty(trim($ariaLabelledby)) && empty(trim($title))) {
return $this->createViolation(
$element,
'<embed> öğesinde erişilebilir isim eksik.',
'Provide an aria-label, aria-labelledby, or title attribute for the <embed> element.'
);
}
return null;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyHeading extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_EMPTY_HEADING'; }
public function getDescription(): string { return 'Heading elements (h1-h6) must have discernible text content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (!preg_match('/^h([1-6])$/i', $element->tagName)) {
return null;
}
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add descriptive text or an aria-label to the heading element.'
);
}
return null;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyLabel extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_EMPTY_LABEL'; }
public function getDescription(): string { return 'Label elements must have discernible text content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'label') {
return null;
}
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
$text = trim($element->textContent);
if ($text === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add text content to the label element to describe its associated input.'
);
}
return null;
}
}
+60 -8
View File
@@ -13,6 +13,7 @@ class EmptyLink extends AbstractRule
public function getDescription(): string { return 'Links must have discernible text.'; }
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
{
@@ -20,19 +21,70 @@ class EmptyLink extends AbstractRule
return null;
}
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
// Check for images inside
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
if ($text !== '') {
return null;
}
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// Check for images inside
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.');
}
return null;
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
$spans = $xpath->query('.//span', $element);
if ($spans !== false) {
foreach ($spans as $span) {
if ($span instanceof DOMElement) {
if (trim($span->textContent) !== '') {
return null;
}
if ($span->hasAttribute('aria-label') && trim($span->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.');
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyLinkRule extends AbstractRule
{
public function check(\DOMDocument $doc): array
{
$violations = [];
$links = $doc->getElementsByTagName('a');
foreach ($links as $link) {
if (trim($link->textContent) === '' && !$link->hasAttribute('aria-label')) {
$violations[] = $this->createViolation(
"Bağlantı (a) etiketi metin içermiyor ve 'aria-label' tanımlanmamış. Ekran okuyucular bu linkin nereye gittiğini anlayamaz.",
$link
);
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::CRITICAL; }
}
+1
View File
@@ -13,6 +13,7 @@ class FieldsetLegend extends AbstractRule
public function getDescription(): string { return 'Fieldsets should have a legend.'; }
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
{
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FocusTrapping extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_2_FOCUS_TRAP'; }
public function getDescription(): string { return 'Detects potential keyboard traps via inline event handlers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$onkeydown = $element->getAttribute('onkeydown');
$onkeypress = $element->getAttribute('onkeypress');
$handlers = $onkeydown . ' ' . $onkeypress;
if (empty(trim($handlers))) {
return null;
}
if (strpos($handlers, 'preventDefault') !== false || strpos($handlers, 'return false') !== false) {
return $this->createViolation(
$element,
'Klavye odağını hapseden (keyboard trap) potansiyel olay işleyicisi tespit edildi. / Potential keyboard trap detected in inline event handlers.',
'Kullanıcıların Esc veya Tab tuşları ile bu alandan çıkabilmesini sağlayın. / Ensure users can exit this component using Esc or Tab keys.'
);
}
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FontTagBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_FONT_TAG_BANNED'; }
public function getDescription(): string { return 'The font tag is obsolete and should not be used. Use CSS for styling instead.'; }
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) === 'font') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the <font> tag and style the text using CSS instead.'
);
}
return null;
}
}
+10 -6
View File
@@ -13,6 +13,7 @@ class FormLabel extends AbstractRule
public function getDescription(): string { return 'Form inputs must have an associated label.'; }
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
{
@@ -29,15 +30,18 @@ class FormLabel extends AbstractRule
if ($element->hasAttribute('id')) {
$id = $element->getAttribute('id');
$xpath = new \DOMXPath($element->ownerDocument);
$labels = $xpath->query("//label[@for='$id']");
if ($labels->length > 0) {
return null;
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//label[@for='$id']");
if ($labels !== false && $labels->length > 0) {
return null;
}
}
}
// Check for aria-label or aria-labelledby
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
// Check for aria-label, aria-labelledby, or title
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) {
return null;
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FormRequiredIndicator extends AbstractRule
{
public function getId(): string { return 'WCAG_3_3_2_FORM_REQUIRED_INDICATOR'; }
public function getDescription(): string { return 'Required form fields should have aria-required="true".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['input', 'select', 'textarea'])) {
return null;
}
if ($element->hasAttribute('required')) {
$ariaRequired = strtolower(trim($element->getAttribute('aria-required')));
if ($ariaRequired !== 'true') {
// If it has 'required' but not aria-required="true", warn
return $this->createViolation(
$element,
"Zorunlu form alanı aria-required=\"true\" niteliğini içermiyor.",
"Ekran okuyucuların form alanının zorunlu olduğunu doğru şekilde anons edebilmesi için aria-required=\"true\" niteliğini eklemeyi düşünün."
);
}
}
return null;
}
}
+56
View File
@@ -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;
}
}
+1
View File
@@ -15,6 +15,7 @@ class HeadingOrder extends AbstractRule
public function getDescription(): string { return 'Headings should follow a logical nesting order.'; }
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
{
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class HeadingOrderRule extends AbstractRule
{
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
if ($headings === false) {
return [];
}
$lastLevel = 0;
foreach ($headings as $h) {
if (!$h instanceof \DOMElement) {
continue;
}
$level = (int)substr($h->tagName, 1);
if ($lastLevel > 0 && $level > $lastLevel + 1) {
$violations[] = $this->createViolation(
"Başlık hiyerarşisi bozuk. H{$lastLevel} etiketinden doğrudan H{$level} etiketine geçiş yapılmış. Sıralı olmalıdır.",
$h
);
}
$lastLevel = $level;
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
}
+1
View File
@@ -13,6 +13,7 @@ class HtmlHasLang extends AbstractRule
public function getDescription(): string { return 'The html element must have a lang attribute.'; }
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
{
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class IframeTitle extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_IFRAME_TITLE'; }
public function getDescription(): string { return 'Iframe elements must have a non-empty title attribute to describe their content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'iframe') {
return null;
}
if (!$element->hasAttribute('title') || trim($element->getAttribute('title')) === '') {
return $this->createViolation(
$element,
"Iframe element is missing a descriptive title attribute.",
"Add a descriptive title attribute, e.g., title=\"Embedded Map\" or title=\"Video Player\"."
);
}
return null;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class 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;
}
}
+61
View File
@@ -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;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltRedundant extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT_REDUNDANT'; }
public function getDescription(): string { return 'Image alt text should not contain redundant words like "image" or "photo".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
/** @var array<int, string> */
private array $redundantWords = [
'resmi', 'görüntüsü', 'fotoğrafı', 'grafiği', 'logosu', 'ikonu',
'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of',
'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
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
return null;
}
$alt = trim($element->getAttribute('alt'));
$cleanAlt = strtolower(preg_replace('/\s+/', ' ', $alt) ?? '');
if ($cleanAlt === '') {
return null;
}
foreach ($this->redundantWords as $word) {
if (str_starts_with($cleanAlt, $word) || str_contains($cleanAlt, ' ' . $word)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove redundant phrases like "' . $word . '" from the alt text. Screen readers already announce elements as images.'
);
}
}
return null;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltRule extends AbstractRule
{
public function check(\DOMDocument $doc): array
{
$violations = [];
$images = $doc->getElementsByTagName('img');
foreach ($images as $img) {
if (!$img->hasAttribute('alt') || trim($img->getAttribute('alt')) === '') {
$violations[] = $this->createViolation(
"Görüntü (img) etiketi 'alt' özniteliğine sahip değil veya boş. WCAG 1.1.1 uyarınca her görüntünün bir açıklaması olmalıdır.",
$img
);
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::CRITICAL; }
}
+1
View File
@@ -13,6 +13,7 @@ class ImgAltText extends AbstractRule
public function getDescription(): string { return 'Images must have an alt attribute.'; }
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
{

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