Compare commits

...
9 Commits
Author SHA1 Message Date
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
56 changed files with 5822 additions and 403 deletions
+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
+12 -1
View File
@@ -1,16 +1,27 @@
#!/usr/bin/env php
<?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\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');
$application = new Application('YakNet Accessibility Console', '3.0.1');
// Add commands
$application->add(new ScanCommand());
$application->add(new FixCommand());
$application->add(new InitCommand());
$application->add(new RulesCommand());
$application->run();
+6 -1
View File
@@ -12,6 +12,7 @@
"yaknet"
],
"homepage": "https://forum.yak.net.tr",
"version": "3.0.2",
"type": "library",
"license": "MIT",
"authors": [
@@ -30,7 +31,11 @@
"symfony/yaml": "^6.4|^7.0",
"monolog/monolog": "^3.0",
"vlucas/phpdotenv": "^5.6",
"google-gemini-php/client": "^1.0"
"google-gemini-php/client": "^1.0",
"guzzlehttp/guzzle": "^7.8",
"masterminds/html5": "^2.8",
"sabberworm/php-css-parser": "^9.4",
"chrome-php/chrome": "^1.15"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
+103 -36
View File
@@ -1,54 +1,121 @@
<!DOCTYPE html>
<html>
<html lang="tr"><!-- FIXED: Added lang attribute (WCAG 3.1.1) -->
<head>
<title></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 id="main-content">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>
<!-- Positive tabindex -->
<a href="/keyboard" tabindex="5">Keyboard test</a>
<!-- FIXED: Removed positive tabindex (WCAG 2.4.3) — use natural DOM order -->
<a href="/keyboard">Klavye Testi</a>
<!-- Duplicate ID -->
<div id="main-content">This is a duplicate main-content ID div!</div>
<!-- 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>
<!-- Low Contrast -->
<p style="color: #888888; background-color: #999999;">This text has extremely low contrast ratio!</p>
<!-- FIXED: High contrast text (WCAG 1.4.3) -->
<p class="low-contrast-example">Bu metnin kontrast oranı WCAG AA gereksinimlerini karşılamaktadır.</p>
<!-- Invalid ARIA role and missing controls target -->
<div role="cool-widget" aria-controls="non-existent-popup">WAI-ARIA elements</div>
<!-- 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>
<!-- Missing Iframe Title -->
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>
<!-- 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>
<!-- New WCAG Rule Violations for testing -->
<!-- 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>
<!-- LinkTextGeneric: Generic text without context -->
<a href="/read-more">Click here</a>
<a href="/devam-oku">devamını oku</a>
<!-- PlaceholderAsLabel: Input relying only on placeholder -->
<input type="text" placeholder="Search website...">
<!-- ImageAltRedundant: Redundant words in alt text -->
<img src="logo2.png" alt="logo of our company">
<img src="profile.jpg" alt="profile photo of john doe">
<!-- AriaHiddenFocusable: Focusable element hidden with aria-hidden="true" -->
<div aria-hidden="true">
<button type="button">Hidden click</button>
<!-- 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>
<a href="#" aria-hidden="true">Hidden link</a>
<p>This page has multiple accessibility issues.</p>
<!-- 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);
+9
View File
@@ -0,0 +1,9 @@
<html>
<head>
<title>Test Page</title>
</head>
<body>
<img src="avatar.png">
<button></button>
</body>
</html>
+1 -1
View File
@@ -45,7 +45,7 @@ FIX: [Only the corrected HTML snippet]
PROMPT;
try {
$result = $this->client->generativeModel('gemini-2.5-flash')->generateContent($prompt);
$result = $this->client->generativeModel('gemini-3.1-flash-lite')->generateContent($prompt);
return trim($result->text());
} catch (\Throwable $e) {
return "AI Düzeltme Hatası: " . $e->getMessage();
+158 -22
View File
@@ -9,9 +9,10 @@ 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\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,46 +22,183 @@ 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');
if (!is_string($target)) {
$io->error("Target must be a string URL or file path.");
// 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;
}
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
$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 GeminiFixer($apiKey);
$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;
}
foreach ($fileViolations as $violation) {
$line = $violation->location['line'] ?? 0;
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
// Get AI Suggestion
$rawSuggestion = $fixer->suggestFix($violation);
$suggestion = $rawSuggestion;
if ($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 (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
$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 ($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');
$config = new Config();
$apiKey = $config->getGeminiApiKey();
$io->title("AI Self-Healing Mode - Fixing URL: $target");
if (!$apiKey) {
$io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key.");
// 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;
}
$io->title("AI Self-Healing Mode - Fixing: $target");
// 1. Fetch HTML
} else {
$html = @file_get_contents($target);
}
if ($html === false || $html === '') {
$io->error("Could not read target: $target");
$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);
@@ -92,7 +230,6 @@ class FixCommand extends Command
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
$suggestion = trim($matches[1] ?? $rawSuggestion);
// Clean up potential markdown
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
}
@@ -123,7 +260,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;
@@ -138,8 +275,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;
}
}
+350 -35
View File
@@ -3,40 +3,236 @@
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\Source\SourceLocator;
class ScanCommand extends Command
{
protected static ?string $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);
// 1. Resolve configuration
$configPathOption = $input->getOption('config');
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
$config = Config::fromYaml($configPath);
// 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
}
$target = $input->getArgument('target');
if (!is_string($target)) {
$io->error("Target must be a string URL or file path.");
$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();
$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);
}
// Setup Baseline Manager
$baselineManager = new BaselineManager();
if ($baselinePath && !$input->getOption('generate-baseline')) {
$baselineManager->load(getcwd() . '/' . $baselinePath);
}
$analyser = new Analyser($config, $scanner, $baselineManager);
// Progress bar setup
$progressBar = null;
$showProgress = !$input->getOption('no-progress') && $format === 'console';
$onProgress = null;
if ($showProgress) {
$onProgress = function (string $file, int $index, int $total) use ($io, &$progressBar) {
if ($progressBar === null) {
$progressBar = new ProgressBar($io, $total);
$progressBar->start();
}
$progressBar->setProgress($index);
$progressBar->setMessage(basename($file), 'filename');
};
}
// Run analysis
$result = $analyser->analyse($paths, $onProgress);
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();
}
$exitCode = $formatter->format($violations, $baselinedCount, $io);
// Export report if --report is specified
$reportPath = $input->getOption('report');
if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
if ($extension === 'json') {
$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();
// 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 $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();
@@ -44,49 +240,168 @@ class ScanCommand extends Command
$projectPath = '.';
}
$io->title("Accessibility Console - Scanning: $target");
$io->title("Accessibility Console - Scanning URL: $target (Level $level)");
// 1. Fetch HTML
$html = @file_get_contents($target);
if ($html === false || $html === '') {
$io->error("Could not read target: $target");
return Command::FAILURE;
}
// 2. Setup Scanner
// 1. Setup Scanner
$scanner = new Scanner();
foreach (StandardRuleSet::all() as $rule) {
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
// 3. Scan
$violations = $scanner->scan($html);
// 2. Fetch and Scan
$crawl = (bool)$input->getOption('crawl');
$useJs = (bool)$input->getOption('js');
$chromeRenderer = $useJs ? new \YakNet\AccessibilityConsole\Core\ChromeRenderer() : null;
$results = [];
if (empty($violations)) {
if ($crawl) {
$depthOption = $input->getOption('depth');
$depthLimit = is_numeric($depthOption) ? (int)$depthOption : 3;
$maxPagesOption = $input->getOption('max-pages');
$maxPages = is_numeric($maxPagesOption) ? (int)$maxPagesOption : 20;
$io->comment("Starting recursive crawl from: $target (Max Depth: $depthLimit, Max Pages: $maxPages)");
$parsedBase = parse_url($target);
$baseHost = $parsedBase['host'] ?? '';
$queue = [['url' => $target, 'depth' => 1]];
$visited = [];
$client = new \GuzzleHttp\Client([
'timeout' => 10,
'allow_redirects' => true,
'headers' => [
'User-Agent' => 'YakNet Accessibility Console Crawler/1.0',
]
]);
while (!empty($queue) && count($visited) < $maxPages) {
$current = array_shift($queue);
if (!is_array($current)) {
continue;
}
$url = $current['url'];
$depth = (int)$current['depth'];
$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 " . count($violations) . " violations");
$io->section("Found a total of $totalViolations violations across " . count($results) . " pages");
// 4. Setup Helpers
$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}</>");
// 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']}</>");
$violation->location = $location;
}
// AI Fixer
if ($enableAi && $fixer) {
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation);
@@ -97,26 +412,26 @@ class ScanCommand extends Command
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
}
}
$io->writeln("");
}
}
$io->warning("Scanned " . count($violations) . " errors. Use --ai to get fix suggestions.");
// Export Report
$reportPath = $input->getOption('report');
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 ($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($violations, $target));
file_put_contents($reportPath, $reporter->render($results, $target));
}
$io->success("Report saved to: $reportPath");
}
return Command::SUCCESS;
return Command::FAILURE;
}
}
+5
View File
@@ -19,6 +19,11 @@ abstract class AbstractRule
*/
abstract public function getSeverity(): Severity;
public function getLevel(): int
{
return 4;
}
protected function createViolation(string $message, \DOMElement $element): Violation
{
$doc = $element->ownerDocument;
+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;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?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 (isset($item['file'], $item['ruleId'], $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 whitespaces and trim to prevent minor formatting updates from breaking baseline
$normalizedSnippet = preg_replace('/\s+/', ' ', trim($htmlSnippet));
if ($normalizedSnippet === null) {
$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;
}
}
+96 -1
View File
@@ -13,11 +13,34 @@ class Config
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)) {
return new self($content);
}
} catch (\Throwable $e) {
// Ignore parsing errors
}
}
return new self([]);
}
@@ -26,6 +49,78 @@ class Config
return $this->settings[$key] ?? $default;
}
/**
* @return array<int, string>
*/
public function getPaths(): array
{
$paths = $this->get('paths');
if (is_array($paths)) {
return $paths;
}
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 $exclude;
}
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 = $rules['include'];
}
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
$exclude = $rules['exclude'];
}
}
return [
'include' => $include,
'exclude' => $exclude
];
}
public function getGeminiApiKey(): ?string
{
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
+4 -3
View File
@@ -28,9 +28,10 @@ class Scanner
return [];
}
$doc = new \DOMDocument();
// Suppress warnings for malformed HTML
@$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
$html5 = new \Masterminds\HTML5([
'disable_html_ns' => true,
]);
$doc = $html5->loadHTML($html);
return $this->engine->run($doc);
}
+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;
}
}
+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;
}
+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;
}
}
+5
View File
@@ -14,6 +14,11 @@ 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;
+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;
}
}
+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;
}
}
+1
View File
@@ -13,6 +13,7 @@ class AriaHiddenFocusable extends AbstractRule
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
{
+1
View File
@@ -78,4 +78,5 @@ class AriaRole extends AbstractRule
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
}
+6
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,6 +21,11 @@ class ButtonName 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')) {
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
+217 -29
View File
@@ -9,21 +9,38 @@ 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
{
if (!$element->hasAttribute('style')) {
return null;
// 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'];
}
}
}
}
$style = $element->getAttribute('style');
$color = $this->parseColorValue($style, 'color');
$bg = $this->parseColorValue($style, 'background-color') ?: $this->parseColorValue($style, 'background');
if ($color === null || $bg === null) {
return null;
@@ -41,22 +58,11 @@ class ColorContrast extends AbstractRule
$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;
if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) {
$val = (float)$matches[1];
$unit = strtolower($matches[2]);
if (($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5)) {
$isLarge = true;
}
}
if (preg_match('/font-weight\s*:\s*(bold|[7-9]00)/i', $style)) {
if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) {
$val = (float)$matches[1];
$unit = strtolower($matches[2]);
if (($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2)) {
$isLarge = true;
}
}
$fontSizeStyle = $this->parseFontSize($inlineStyle, $element);
if ($fontSizeStyle !== null) {
$isLarge = $this->isLargeText($fontSizeStyle, $this->parseFontWeight($inlineStyle, $element));
}
$minRatio = $isLarge ? 3.0 : 4.5;
@@ -73,21 +79,203 @@ class ColorContrast extends AbstractRule
return null;
}
private function parseColorValue(string $style, string $property): ?string
private function parseFontSize(string $style, DOMElement $element): ?string
{
if (preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:\s*([^;]+)/i', $style, $matches)) {
$value = trim($matches[1]);
if (preg_match('/^#([0-9a-f]{3,6})$/i', $value)) {
return $value;
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 = [
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
];
if (isset($colors[strtolower($value)])) {
return $colors[strtolower($value)];
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;
}
+1
View File
@@ -48,4 +48,5 @@ class DuplicateId extends AbstractRule
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
}
+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;
}
}
+6
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,6 +21,11 @@ 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
+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
{
+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;
}
}
+1
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
{
+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
{
+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
{
+1
View File
@@ -13,6 +13,7 @@ class IframeTitle extends AbstractRule
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
{
+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
{
+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 InputTypeButtonDiscernible extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_INPUT_BUTTON'; }
public function getDescription(): string { return 'Input buttons (button, submit, reset) must have discernible text.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
$type = strtolower($element->getAttribute('type'));
if (in_array($type, ['button', 'submit', 'reset'], true)) {
$value = trim($element->getAttribute('value'));
$ariaLabel = trim($element->getAttribute('aria-label'));
$title = trim($element->getAttribute('title'));
if ($value === '' && $ariaLabel === '' && $title === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a non-empty value, aria-label, or title attribute to the input button.'
);
}
}
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 LanguageSubtag extends AbstractRule
{
public function getId(): string { return 'WCAG_3_1_2_LANG'; }
public function getDescription(): string { return 'Language attributes (lang) must use a valid language code format.'; }
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('lang')) {
return null;
}
$lang = trim($element->getAttribute('lang'));
// Simple IETF language tag format check (e.g. "en", "tr", "en-US", "zh-Hant")
$isValid = preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/', $lang);
if (!$isValid || $lang === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Use a valid IETF language code format for the lang attribute (e.g., "tr", "en", "en-US").'
);
}
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 LinkTargetBlank extends AbstractRule
{
public function getId(): string { return 'WCAG_LINK_TARGET_BLANK'; }
public function getDescription(): string { return 'Links with target="_blank" should have rel="noopener" or rel="noreferrer".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'a') {
return null;
}
if (strtolower($element->getAttribute('target')) === '_blank') {
$rel = strtolower($element->getAttribute('rel'));
if (!str_contains($rel, 'noopener') && !str_contains($rel, 'noreferrer')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add rel="noopener" or rel="noreferrer" to the link to improve security, performance, and screen reader notification.'
);
}
}
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 MarqueeBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_2_2_MARQUEE'; }
public function getDescription(): string { return 'The marquee element is obsolete and presents significant accessibility barriers.'; }
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) === 'marquee') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the marquee tag and use CSS animations or transitions instead.'
);
}
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class MediaAutoplay extends AbstractRule
{
public function getId(): string { return 'WCAG_1_4_2_AUTOPLAY'; }
public function getDescription(): string { return 'Audio and video elements must not autoplay with sound, as it interferes with screen readers and user control.'; }
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
{
$tagName = strtolower($element->tagName);
if ($tagName !== 'video' && $tagName !== 'audio') {
return null;
}
if ($element->hasAttribute('autoplay')) {
// If autoplay is active, it must be muted to be acceptable
if (!$element->hasAttribute('muted')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the autoplay attribute, or add the muted attribute to prevent the media from playing sound automatically.'
);
}
}
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 NoFocusOutline extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_7_FOCUS_OUTLINE'; }
public function getDescription(): string { return 'Focus outlines should not be disabled in inline styles without providing custom focus styles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('style')) {
return null;
}
$style = strtolower($element->getAttribute('style'));
// Match outline: none or outline: 0
if (preg_match('/\boutline\s*:\s*(none|0)\b/', $style)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Avoid disabling focus outlines using outline: none or outline: 0 in inline styles. Ensure keyboard users can see where focus is.'
);
}
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ObjectAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_OBJECT'; }
public function getDescription(): string { return 'Object elements must have alternative text or descriptive 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) !== 'object') {
return null;
}
// Object can have title attribute
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// Or object can contain fallback text content.
// We clean up child tags like <param> or <embed> to find actual descriptive text.
$text = '';
foreach ($element->childNodes as $child) {
if ($child instanceof \DOMText) {
$text .= $child->textContent;
} elseif ($child instanceof DOMElement) {
$tagName = strtolower($child->tagName);
if ($tagName !== 'param' && $tagName !== 'embed') {
$text .= $child->textContent;
}
}
}
if (trim($text) === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add fallback text inside the <object> element or provide a descriptive title attribute.'
);
}
return null;
}
}
+1
View File
@@ -13,6 +13,7 @@ class PageTitle extends AbstractRule
public function getDescription(): string { return 'Web pages must have a non-empty title element in the head.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation
{
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
class RuleLevels
{
/**
* Get rule instances for the specified level.
* Levels are incremental: Level 5 includes all rules from Levels 1, 2, 3, and 4.
*
* @param int $level
* @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
*/
public static function getRulesForLevel(int $level): array
{
$rules = [];
$files = glob(__DIR__ . '/*.php');
if ($files === false) {
return [];
}
foreach ($files as $file) {
$className = pathinfo($file, PATHINFO_FILENAME);
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $className;
if (class_exists($fqcn)) {
$reflection = new \ReflectionClass($fqcn);
if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) {
continue;
}
// Check if it is a concrete rule
$isRule = $reflection->isSubclassOf(\YakNet\AccessibilityConsole\Core\AbstractRule::class) ||
$reflection->implementsInterface(\YakNet\AccessibilityConsole\Rules\RuleInterface::class);
if ($isRule) {
// Exclude Turkish custom rules to match standard behavior
if (in_array($className, ['EmptyLinkRule', 'HeadingOrderRule', 'ImageAltRule'], true)) {
continue;
}
$instance = $reflection->newInstance();
// Check level
$ruleLevel = 4; // default
if (method_exists($instance, 'getLevel')) {
/** @var mixed $levelVal */
$levelVal = $instance->getLevel();
if (is_int($levelVal)) {
$ruleLevel = $levelVal;
}
}
if ($ruleLevel <= $level) {
/** @var \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule $instance */
$rules[] = $instance;
}
}
}
}
// Sort rules by class name to ensure deterministic order
usort($rules, function ($a, $b) {
return strcmp(get_class($a), get_class($b));
});
return $rules;
}
}
+2 -23
View File
@@ -5,31 +5,10 @@ namespace YakNet\AccessibilityConsole\Rules;
class StandardRuleSet
{
/**
* @return RuleInterface[]
* @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
*/
public static function all(): array
{
/** @var RuleInterface[] $rules */
$rules = [
new HtmlHasLang(),
new ImgAltText(),
new EmptyLink(),
new ButtonName(),
new FormLabel(),
new FieldsetLegend(),
new HeadingOrder(),
new MetaViewport(),
new TabindexOrder(),
new ColorContrast(),
new DuplicateId(),
new AriaRole(),
new IframeTitle(),
new LinkTextGeneric(),
new PlaceholderAsLabel(),
new ImageAltRedundant(),
new AriaHiddenFocusable(),
new PageTitle(),
];
return $rules;
return RuleLevels::getRulesForLevel(5);
}
}
+1
View File
@@ -13,6 +13,7 @@ class TabindexOrder extends AbstractRule
public function getDescription(): string { return 'Avoid positive tabindex values to maintain natural keyboard focus order.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
+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 TableHeaders extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_TABLE'; }
public function getDescription(): string { return 'Data tables must have headers (th) or captions for screen readers.'; }
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) !== 'table') {
return null;
}
// Skip layout tables explicitly marked as presentation
$role = strtolower($element->getAttribute('role'));
if ($role === 'presentation' || $role === 'none') {
return null;
}
// Check if there is at least one <th> or <caption>
$thCount = $element->getElementsByTagName('th')->length;
$captionCount = $element->getElementsByTagName('caption')->length;
if ($thCount === 0 && $captionCount === 0) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add <th> header elements or a <caption> to the table, or set role="presentation" if it is only used for layout.'
);
}
return null;
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class VideoTrack extends AbstractRule
{
public function getId(): string { return 'WCAG_1_2_2_VIDEO_TRACK'; }
public function getDescription(): string { return 'Video elements must contain at least one track element for captions or subtitles.'; }
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) !== 'video') {
return null;
}
// Check if there is a track element inside
$tracks = $element->getElementsByTagName('track');
if ($tracks->length === 0) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a <track kind="captions"> or <track kind="subtitles"> element inside the video element.'
);
}
return null;
}
}
+1861 -70
View File
File diff suppressed because it is too large Load Diff