Compare commits

..
14 Commits
Author SHA1 Message Date
Enes Yakıştır 164828f9f7 fix: resolve PHPStan level 9 errors and bump version to v3.1.3 2026-07-29 06:31:35 +03:00
Enes Yakıştır 6f4799dc38 fix: bump version to 3.1.2 and resolve Symfony 8 compatibility issue 2026-07-14 00:28:58 +03:00
Enes Yakıştır c79ddfcc50 chore: broaden sebastian/diff requirement for phpunit 12 compatibility 2026-07-13 11:50:38 +03:00
Enes Yakıştır 5292c2c865 release: v3.1.0 premium smart core engines and 14 new accessibility rules 2026-07-13 10:54:25 +03:00
Enes Yakıştır 7f3bf3cb7d feat: add 5 new WCAG accessibility rules and bump version to 3.0.5 2026-07-08 12:38:47 +03:00
Enes Yakıştır 8e6ea62ba7 chore: bump version to v3.0.4 and fix Symfony 8 compatibility 2026-07-07 13:00:44 +03:00
Enes Yakıştır c138cbca65 chore: remove BOM from composer.json 2026-07-07 12:59:20 +03:00
Enes Yakıştır 18aa4641d2 chore: bump version to v3.0.3 and update configurations 2026-07-07 12:47:34 +03:00
Enes Yakıştır c4745d6cb3 fix: resolve all WCAG 2.1 violations in demo/broken.html and bump version to 3.0.2
- Added lang attribute to html element (WCAG 3.1.1)
- Added descriptive page title (WCAG 2.4.2)
- Added alt attributes to all images (WCAG 1.1.1)
- Removed redundant alt text words (WCAG 1.1.1)
- Fixed empty link and button with discernible text (WCAG 2.4.4, 4.1.2)
- Removed positive tabindex value (WCAG 2.4.3)
- Eliminated duplicate id=main-content (WCAG 4.1.1)
- Fixed insufficient color contrast ratio (WCAG 1.4.3)
- Replaced invalid ARIA role cool-widget with region (WCAG 4.1.2)
- Added title attribute to iframe (WCAG 4.1.2)
- Replaced generic link texts with descriptive ones (WCAG 2.4.4)
- Added associated label for placeholder-only input (WCAG 3.3.2)
- Removed aria-hidden from focusable elements (WCAG 4.1.2)
- Added non-empty heading text (WCAG 2.4.6)
- Added video captions track element (WCAG 1.2.2)
- Added non-empty label text (WCAG 1.3.1)
- Added tabindex=0 to role=button div (WCAG 2.1.1)
2026-06-23 01:25:08 +03:00
Enes Yakıştır a2b984df04 fix: resolve autoloading in vendor execution and eliminate template false positives 2026-06-23 01:15:35 +03:00
Enes Yakıştır d5165f0be4 feat: Release v3.0.0 - PHPStan-style analysis, configuration, baseline, and advanced WCAG rules 2026-06-23 01:02:15 +03:00
Enes Yakıştır 84c6521195 fix: update Gemini model to gemini-3.1-flash-lite in GeminiFixer
bump version to 2.9.2
2026-06-07 10:49:30 +03:00
Enes Yakıştır 0134482c54 fix: resolve string/int type mismatches in HtmlDashboardReporter
- preg_replace() return value guarded with '?? ''' to eliminate string|null type
- location line number explicitly cast to (string) before concatenation
- PHPStan level 9 confirms zero errors after fix
2026-06-07 10:20:55 +03:00
Enes Yakıştır c9ffbdba08 feat: extend scan level range 1-9 and confirm PHPStan level 9 zero errors 2026-06-07 10:18:15 +03:00
139 changed files with 8214 additions and 253 deletions
+2
View File
@@ -4,3 +4,5 @@
/rapor.html
/.phpunit.result.cache
/phpstan-baseline.neon
/.phpunit.cache/
+102 -54
View File
@@ -1,91 +1,139 @@
# YakNet Accessibility Console 2.0 (AI-Powered)
# YakNet Accessibility Console 2.9 (AI-Powered Static Analysis)
[![PHP Version](https://img.shields.io/badge/php-%5E8.2-blue.svg)](https://php.net)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![AI Powered](https://img.shields.io/badge/AI-Powered-purple.svg)](https://gemini.google.com)
**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1) hatalarını sadece tespit etmekle kalmayan, aynı zamanda **Yapay Zeka (AI)** desteğiyle bu hataları otomatik olarak düzeltebilen (Self-Healing) profesyonel bir PHP kütüphanesidir.
**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1) hatalarını sadece tespit etmekle kalmayan, aynı zamanda **Yapay Zeka (AI)** desteğiyle bu hataları otomatik olarak düzeltebilen (Self-Healing) profesyonel bir PHP static analysis kütüphanesidir.
PHPStan ve PHP-CS-Fixer esintili yapısıyla, yerel görünüm şablonlarınızı (`.blade.php`, `.twig`, `.html`, `.php`) doğrudan tarayabilir, baseline desteğiyle mevcut hataları yok sayabilir ve CI/CD süreçlerinize tam entegre çalışabilir.
---
## 🌟 Öne Çıkan Özellikler
- **🤖 AI Self-Healing:** Tespit edilen WCAG hataları için Google Gemini AI üzerinden akıllı çözüm önerileri alır ve isterseniz kaynak kodunuzu (Blade, Twig, PHP) otomatik olarak tamir eder.
- **⚡ Modern CLI:** Rust/Vite tarzı şık bir komut satırı arayüzü ile saniyeler içinde tarama ve düzeltme yapın.
- **📊 Görsel Dashboard:** Tarama sonuçlarını modern ve şık bir HTML raporu olarak dışarı aktarın.
- **🔍 Akıllı Kaynak Tespiti:** Hataları projenizdeki tam dosya yolu ve satır numarası ile eşleştirir.
- **🛠️ Geliştirici Dostu:** PSR-4 uyumlu, modern PHP 8.2+ mimarisi ve genişletilebilir kural motoru.
- **🤖 AI Self-Healing:** Tespit edilen WCAG hataları için Google Gemini AI üzerinden akıllı çözüm önerileri alır ve kaynak kodunuzu (Blade, Twig, PHP, HTML) otomatik olarak tamir eder.
- **⚡ PHPStan Tarzı Analiz:** Tüm projeyi veya belirli dizinleri komut satırından tarayarak hataları tam dosya yolu ve satır numarası ile listeler.
- **🛡️ Baseline Desteği:** `a11y-baseline.json` oluşturarak mevcut erişilebilirlik hatalarını yok sayabilir, CI/CD pipeline'ınızın sadece yeni hatalarda kırılmasını sağlayabilirsiniz.
- **⚙️ YAML Konfigürasyonu:** `a11y.yaml` dosyası üzerinden tarama yollarını, hariç tutulacak klasörleri, kural seviyelerini ve özel kural setlerini yapılandırabilirsiniz.
- **🔌 Çoklu Çıktı Formatları:**
- `console`: Renkli ve düzenli terminal tablosu.
- `json`: Makine tarafından okunabilir JSON çıktısı.
- `github`: GitHub Actions PR süreçlerinde doğrudan inline hata göstermek için workflow formatı.
- **📊 Görsel Raporlama:** Sonuçları modern bir HTML Dashboard raporu olarak dışarı aktarın.
---
## 📦 Kurulum
Composer ile projenize hemen dahil edin:
Composer ile projenize dahil edin:
```bash
composer require yaknet/accessibility-console
```
---
## 🚀 CLI Kullanımı
Kütüphane, `vendor/bin/a11y` (veya kütüphane dizininde `bin/a11y`) üzerinden kullanılabilir.
### 1. Standart Tarama
Bir URL'yi veya yerel bir HTML dosyasını tarayın:
### 1. Yapılandırma Dosyası Oluşturma (`init`)
Projenizin kök dizininde varsayılan bir `a11y.yaml` konfigürasyon dosyası oluşturur:
```bash
bin/a11y scan https://test-siteniz.com
bin/a11y init
```
### 2. AI Destekli Tarama ve Raporlama
Yapay zeka önerileriyle birlikte tarama yapın ve sonucu HTML olarak kaydedin:
### 2. Projeyi Tarama (`scan` / `analyse`)
`a11y.yaml` dosyasındaki kurallara ve dizinlere göre tüm projeyi tarar:
```bash
bin/a11y scan https://test-siteniz.com --ai --report=sonuc.html
bin/a11y scan
```
### 3. Otomatik Düzeltme (Self-Healing) - **DEVRİMSEL**
Tespit edilen hataları yapay zekaya düzelttirin ve dosyalarınızı güncelleyin:
Belirli bir dizini veya dosyayı hedef göstermek için:
```bash
bin/a11y fix https://test-siteniz.com --project-path=/var/www/projeniz
bin/a11y scan resources/views
```
URL veya tek bir yerel HTML dosyasını taramak için (crawler desteğiyle):
```bash
bin/a11y scan https://test-siteniz.com --crawl
```
## 💻 Kütüphane Olarak Kullanım
Kendi PHP uygulamalarınızın içine entegre edin:
```php
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
// 1. Scanner'ı ve Kuralları Hazırla
$scanner = new Scanner();
foreach (StandardRuleSet::all() as $rule) {
$scanner->addRule($rule);
}
// 2. HTML İçeriğini Tara
$html = file_get_contents('view.html');
$violations = $scanner->scan($html);
// 3. Sonuçları İşle
foreach ($violations as $v) {
echo "Hata: {$v->message} (Kural: {$v->ruleId})\n";
}
### 3. Baseline Yönetimi
Projeye ait mevcut tüm hataları `a11y-baseline.json` dosyasına kaydederek sonraki taramalarda yok sayılmasını sağlar:
```bash
bin/a11y scan --generate-baseline
```
## ⚙️ Yapılandırma
### 4. Format ve Raporlama Seçenekleri
Hataları GitHub Actions formatında yazdırmak için:
```bash
bin/a11y scan --format=github
```
HTML Raporu kaydetmek için:
```bash
bin/a11y scan --report=report.html
```
AI özelliklerini kullanabilmek için projenizin kök dizininde bir `.env` dosyası oluşturun ve **Google Gemini API** anahtarınızı ekleyin:
### 5. AI Destekli Otomatik Düzeltme (`fix`)
Tespit edilen tüm hataları yapay zekaya analiz ettirip dosyalarınızı otomatik olarak tamir ettirin:
```bash
bin/a11y fix
```
Sadece neyin değişeceğini görmek için (`--dry-run`):
```bash
bin/a11y fix --dry-run
```
### 6. Kuralları Listeleme (`rules`)
Mevcut tüm WCAG kurallarını seviyeleri ve açıklamalarıyla birlikte listeler:
```bash
bin/a11y rules
```
---
## ⚙️ Yapılandırma (`a11y.yaml`)
Projenizi özelleştirmek için `a11y.yaml` dosyasını kullanabilirsiniz:
```yaml
# Tarama yapılacak dizin veya dosyalar
paths:
- resources/views
- public/templates
# Hariç tutulacak dizinler
exclude_paths:
- vendor
- node_modules
- storage
# WCAG seviyesi (1-9, varsayılan 4)
level: 4
# Varsayılan çıktı formatı (console, json, github)
format: console
# Baseline dosyası yolu
baseline: a11y-baseline.json
# Kural özelleştirmeleri
rules:
exclude:
- WCAG_1_3_1_FIELDSET # Bu kuralı çalıştırma
include:
- App\Rules\CustomAccessibilityRule # Özel kural ekle
```
---
## 🔐 Yapay Zeka API Anahtarı
AI düzeltme ve öneri özelliklerini kullanabilmek için projenizin kök dizininde bir `.env` dosyası oluşturup **Google Gemini API** anahtarınızı eklemeniz gerekir:
```env
GEMINI_API_KEY=AIzaSyA...your_key_here
```
## 🛠️ Mevcut Kurallar (WCAG 2.1)
- **A11Y_VIEWPORT:** Zoom engelleme kontrolü.
- **WCAG_1_1_1_ALT:** Görsel alt metin kontrolü.
- **WCAG_1_3_1_LABEL:** Form etiketi kontrolü.
- **WCAG_1_3_1_FIELDSET:** Fieldset/Legend kontrolü.
- **WCAG_1_3_1_HEADING:** Başlık hiyerarşisi kontrolü.
- **WCAG_2_4_4_LINK_TEXT:** Boş link kontrolü.
- **WCAG_3_1_1_LANG:** HTML dil etiketi kontrolü.
- **WCAG_4_1_2_BUTTON:** Boş buton kontrolü.
---
## 🤝 Katkıda Bulunma
+210
View File
@@ -0,0 +1,210 @@
{
"generator": "YakNet Accessibility Console",
"violations": [
{
"file": "demo/broken.html",
"ruleId": "AriaRole",
"htmlSnippet": "<div role=\"cool-widget\" aria-controls=\"non-existent-popup\">WAI-ARIA elements</div>"
},
{
"file": "demo/broken.html",
"ruleId": "AriaRole",
"htmlSnippet": "<div role=\"cool-widget\" aria-controls=\"non-existent-popup\">WAI-ARIA elements</div>"
},
{
"file": "demo/broken.html",
"ruleId": "DuplicateId",
"htmlSnippet": "<h1 id=\"main-content\">Welcome to a broken page</h1>"
},
{
"file": "demo/broken.html",
"ruleId": "DuplicateId",
"htmlSnippet": "<div id=\"main-content\">This is a duplicate main-content ID div!</div>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"logo.png\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_1_1_ALT_REDUNDANT",
"htmlSnippet": "<img src=\"logo2.png\" alt=\"logo of our company\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_1_1_ALT_REDUNDANT",
"htmlSnippet": "<img src=\"profile.jpg\" alt=\"profile photo of john doe\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_2_2_VIDEO_TRACK",
"htmlSnippet": "<video src=\"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4\" width=\"320\" height=\"240\" controls=\"\"></video>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_3_1_EMPTY_HEADING",
"htmlSnippet": "<h2></h2>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_3_1_EMPTY_LABEL",
"htmlSnippet": "<label for=\"search-input\"></label>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_3_1_LABEL",
"htmlSnippet": "<input type=\"text\" placeholder=\"Search website...\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_1_1_ARIA_FOCUSABLE",
"htmlSnippet": "<div role=\"button\">Clickable div button without tabindex</div>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_2_PAGE_TITLE",
"htmlSnippet": "<head>\n <title></title>\n</head>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_3_TABINDEX",
"htmlSnippet": "<a href=\"/keyboard\" tabindex=\"5\">Keyboard test</a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_4_LINK_GENERIC",
"htmlSnippet": "<a href=\"/read-more\">Click here</a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_4_LINK_GENERIC",
"htmlSnippet": "<a href=\"/devam-oku\">devamını oku</a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_4_LINK_TEXT",
"htmlSnippet": "<a href=\"https://google.com\"></a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><head>\n <title></title>\n</head>\n<body>\n <h1 id=\"main-content\">Welcome to a broken page</h1>\n \n <!-- Missing alt attribute -->\n <img src=\"logo.png\">\n \n <!-- Empty link -->\n <a href=\"https://google.com\"></a>\n \n <!-- Empty button -->\n <button type=\"submit\"></button>\n \n <!-- Positive tabindex -->\n <a href=\"/keyboard\" tabindex=\"5\">Keyboard test</a>\n\n <!-- Duplicate ID -->\n <div id=\"main-content\">This is a duplicate main-content ID div!</div>\n\n <!-- Low Contrast -->\n <p style=\"color: #888888; background-color: #999999;\">This text has extremely low contrast ratio!</p>\n\n <!-- Invalid ARIA role and missing controls target -->\n <div role=\"cool-widget\" aria-controls=\"non-existent-popup\">WAI-ARIA elements</div>\n\n <!-- Missing Iframe Title -->\n <iframe src=\"https://www.youtube.com/embed/aqz-KE-bpKQ\" width=\"560\" height=\"315\"></iframe>\n\n <!-- New WCAG Rule Violations for testing -->\n \n <!-- LinkTextGeneric: Generic text without context -->\n <a href=\"/read-more\">Click here</a>\n <a href=\"/devam-oku\">devamını oku</a>\n \n <!-- PlaceholderAsLabel: Input relying only on placeholder -->\n <input type=\"text\" placeholder=\"Search website...\">\n\n <!-- ImageAltRedundant: Redundant words in alt text -->\n <img src=\"logo2.png\" alt=\"logo of our company\">\n <img src=\"profile.jpg\" alt=\"profile photo of john doe\">\n\n <!-- AriaHiddenFocusable: Focusable element hidden with aria-hidden=\"true\" -->\n <div aria-hidden=\"true\">\n <button type=\"button\">Hidden click</button>\n </div>\n <a href=\"#\" aria-hidden=\"true\">Hidden link</a>\n\n <!-- Empty heading violation -->\n <h2></h2>\n\n <!-- Video missing track violation -->\n <video src=\"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4\" width=\"320\" height=\"240\" controls=\"\"></video>\n\n <!-- Empty label violation -->\n <label for=\"search-input\"></label>\n\n <!-- AriaFocusable violation -->\n <div role=\"button\">Clickable div button without tabindex</div>\n\n <p>This page has multiple accessibility issues.</p>\n</body>\n\n</html>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_3_3_2_PLACEHOLDER_LABEL",
"htmlSnippet": "<input type=\"text\" placeholder=\"Search website...\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_4_1_2_BUTTON",
"htmlSnippet": "<button type=\"submit\"></button>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_4_1_2_IFRAME_TITLE",
"htmlSnippet": "<iframe src=\"https://www.youtube.com/embed/aqz-KE-bpKQ\" width=\"560\" height=\"315\"></iframe>"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/gadget.jpg\" width=\"100\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/widget.png\" width=\"100\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/thing.jpg\" width=\"100\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/views-fixed/contact.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Contact Us</h2>\n<p>Get in touch with us.</p>\n\n<form action=\"\" method=\"post\">\n <div style=\"margin-bottom: 1rem;\">\n <!-- FIXED: Wrapped in label -->\n <label>\n Name:\n <input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">\n </label>\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <!-- FIXED: Added for/id association -->\n <label for=\"email\">Email Address</label>\n <br>\n <input type=\"email\" id=\"email\" name=\"email\" style=\"padding: 8px; width: 100%;\">\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <label for=\"message\">Message</label>\n <br>\n <textarea id=\"message\" name=\"message\" rows=\"5\" style=\"width: 100%;\"></textarea>\n </div>\n\n <!-- FIXED: Added inner text or aria-label -->\n <a href=\"#\" class=\"btn\" aria-label=\"Send via WhatsApp\">\n <i class=\"fa fa-send\"></i> Send\n </a>\n\n <button type=\"submit\" class=\"btn\">Send Message</button>\n</form></html>"
},
{
"file": "demo/views-fixed/home.php",
"ruleId": "WCAG_1_3_1_EMPTY_HEADING",
"htmlSnippet": "<h3>\n <?php echo $product['name']; >\n </h3>"
},
{
"file": "demo/views-fixed/home.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Latest Products</h2>\n<p>Welcome to our accessible product catalog.</p>\n\n<?php $products = [\n ['id' => 1, 'name' => 'Super Gadget', 'image' => 'gadget.jpg', 'price' => '$99'],\n ['id' => 2, 'name' => 'Mega Widget', 'image' => 'widget.png', 'price' => '$49'],\n ['id' => 3, 'name' => 'Ultra Thing', 'image' => 'thing.jpg', 'price' => '$199'],\n];\n>\n\n<div class=\"products\">\n <?php foreach ($products as $product): >\n <div class=\"card\">\n <!-- FIXED: Added alt attribute describing the product -->\n <img src=\"images/&lt;?php%20echo%20$product['image'];%20?&gt;\" width=\"100\" alt=\"&lt;?php echo htmlspecialchars($product['name']); ?&gt;\">\n <h3>\n <?php echo $product['name']; >\n </h3>\n <p>\n <?php echo $product['price']; >\n </p>\n <button class=\"btn\">Buy Now</button>\n </div>\n <?php endforeach; >\n</div>\n\n<hr>\n\n<h3>Sponsors</h3>\n<div>\n <!-- FIXED: Added empty alt=\"\" for decorative images -->\n <img src=\"https://via.placeholder.com/150\" alt=\"\">\n <img src=\"https://via.placeholder.com/150\" alt=\"\">\n</div></html>"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_1_3_1_LABEL",
"htmlSnippet": "<input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_1_3_1_LABEL",
"htmlSnippet": "<input type=\"email\" name=\"email\" style=\"padding: 8px; width: 100%;\">"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_2_4_4_LINK_TEXT",
"htmlSnippet": "<a href=\"#\" class=\"btn\">\n <i class=\"fa fa-send\"></i> <!-- Empty link text error (if we had that rule) -->\n </a>"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Contact Us</h2>\n<p>Get in touch with us.</p>\n\n<form action=\"\" method=\"post\">\n <div style=\"margin-bottom: 1rem;\">\n <!-- ERROR: No label for input -->\n <div>Name:</div>\n <input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <!-- ERROR: Label exists but not associated with For/ID -->\n <label>Email Address</label>\n <br>\n <input type=\"email\" name=\"email\" style=\"padding: 8px; width: 100%;\">\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <label for=\"message\">Message</label>\n <br>\n <textarea id=\"message\" name=\"message\" rows=\"5\" style=\"width: 100%;\"></textarea>\n </div>\n\n <!-- ERROR: Submit button without clear text if it was an input type=image, but button is usually fine. \n Let's add a problematic icon link -->\n <a href=\"#\" class=\"btn\">\n <i class=\"fa fa-send\"></i> <!-- Empty link text error (if we had that rule) -->\n </a>\n\n <button type=\"submit\" class=\"btn\">Send Message</button>\n</form></html>"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_3_3_2_PLACEHOLDER_LABEL",
"htmlSnippet": "<input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/&lt;?php%20echo%20$product['image'];%20?&gt;\" width=\"100\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_3_1_EMPTY_HEADING",
"htmlSnippet": "<h3>\n <?php echo $product['name']; >\n </h3>"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Latest Products</h2>\n<p>Welcome to our dynamic product catalog. (Intentionally filled with errors)</p>\n\n<?php $products = [\n ['id' => 1, 'name' => 'Super Gadget', 'image' => 'gadget.jpg', 'price' => '$99'],\n ['id' => 2, 'name' => 'Mega Widget', 'image' => 'widget.png', 'price' => '$49'],\n ['id' => 3, 'name' => 'Ultra Thing', 'image' => 'thing.jpg', 'price' => '$199'],\n];\n>\n\n<div class=\"products\">\n <?php foreach ($products as $product): >\n <div class=\"card\">\n <!-- ERROR: Dynamic image without alt text -->\n <img src=\"images/&lt;?php%20echo%20$product['image'];%20?&gt;\" width=\"100\">\n <h3>\n <?php echo $product['name']; >\n </h3>\n <p>\n <?php echo $product['price']; >\n </p>\n <button class=\"btn\">Buy Now</button>\n <!-- Error: Button might need specific accessible name if icon-only, but text is okay here. -->\n </div>\n <?php endforeach; >\n</div>\n\n<hr>\n\n<h3>Sponsors</h3>\n<div>\n <!-- ERROR: Decorative image causing noise, missing alt=\"\" -->\n <img src=\"https://via.placeholder.com/150\">\n <img src=\"https://via.placeholder.com/150\">\n</div></html>"
},
{
"file": "demo/views/layout.php",
"ruleId": "WCAG_2_4_4_LINK_TEXT",
"htmlSnippet": "<a href=\"#\" class=\"btn\"></a>"
},
{
"file": "demo/views/layout.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><!-- Missing lang attribute error --><head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>YakNet Dynamic Demo</title>\n <style>\n body {\n font-family: sans-serif;\n margin: 0;\n padding: 0;\n line-height: 1.6;\n }\n\n header {\n background: #333;\n color: #fff;\n padding: 1rem;\n }\n\n nav a {\n color: #fff;\n margin-right: 1rem;\n text-decoration: none;\n }\n\n .container {\n padding: 2rem;\n max-width: 800px;\n margin: 0 auto;\n }\n\n .card {\n border: 1px solid #ddd;\n padding: 1rem;\n margin-bottom: 1rem;\n border-radius: 4px;\n }\n\n .btn {\n background: #007bff;\n color: white;\n padding: 0.5rem 1rem;\n border: none;\n cursor: pointer;\n }\n </style>\n</head>\n\n<body>\n <header>\n <div class=\"container\" style=\"padding: 0;\">\n <h1>My Dynamic Site</h1>\n <nav>\n <a href=\"?page=home\">Home</a>\n <a href=\"?page=contact\">Contact</a>\n <a href=\"?page=about\">About</a>\n <a href=\"#\" class=\"btn\"></a> <!-- Error: Empty Link -->\n </nav>\n </div>\n </header>\n\n <main class=\"container\">\n <?php include $viewPath; >\n </main>\n\n <footer style=\"text-align: center; padding: 2rem; background: #f4f4f4; margin-top: 2rem;\">\n ©\n <?php echo date('Y'); > YakNet Demo\n </footer>\n</body>\n\n</html>"
}
]
}
+32
View File
@@ -0,0 +1,32 @@
# YakNet Accessibility Console Configuration
# For detailed configuration, see: https://forum.yak.net.tr
# Paths to scan (can be directories or specific files)
paths:
- .
# Exclude directories or files
exclude_paths:
- vendor
- node_modules
- tests
- storage
- .git
# Accessibility scanning level (1-9, default 4)
level: 4
# Output format (console, json, html, github)
format: console
# Path to the baseline file for ignoring existing violations
baseline: a11y-baseline.json
# Rule adjustments
rules:
# Rules to exclude from analysis
exclude:
# - WCAG_1_3_1_FIELDSET
# Additional custom rules to run
include:
# - App\Rules\MyCustomRule
+18 -5
View File
@@ -1,16 +1,29 @@
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
require __DIR__ . '/../vendor/autoload.php';
} elseif (file_exists(__DIR__ . '/../../../autoload.php')) {
require __DIR__ . '/../../../autoload.php';
} else {
echo "Autoload file not found.\n";
exit(1);
}
use Symfony\Component\Console\Application;
use YakNet\AccessibilityConsole\Core\Container\ContainerFactory;
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
$application = new Application('YakNet Accessibility Console', '2.0.0');
$container = ContainerFactory::create();
$application = new Application('YakNet Accessibility Console', '3.1.2');
// Add commands
$application->add(new ScanCommand());
$application->add(new FixCommand());
// Add commands resolved via DI Container
$application->addCommand($container->get(ScanCommand::class));
$application->addCommand($container->get(FixCommand::class));
$application->addCommand($container->get(InitCommand::class));
$application->addCommand($container->get(RulesCommand::class));
$application->run();
+15 -5
View File
@@ -12,6 +12,7 @@
"yaknet"
],
"homepage": "https://forum.yak.net.tr",
"version": "3.1.3",
"type": "library",
"license": "MIT",
"authors": [
@@ -26,19 +27,28 @@
"ext-dom": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"symfony/console": "^6.4|^7.0",
"symfony/yaml": "^6.4|^7.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0",
"monolog/monolog": "^3.0",
"vlucas/phpdotenv": "^5.6",
"google-gemini-php/client": "^1.0",
"google-gemini-php/client": "^1.0|^2.0",
"guzzlehttp/guzzle": "^7.8",
"masterminds/html5": "^2.8"
"masterminds/html5": "^2.8",
"sabberworm/php-css-parser": "^9.4",
"chrome-php/chrome": "^1.15",
"sebastian/diff": "^5.1 || ^6.0 || ^7.0 || ^8.0 || ^9.0",
"php-di/php-di": "^7.1",
"symfony/css-selector": "^8.1",
"symfony/dom-crawler": "^8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.10"
"phpstan/phpstan": "^2.2"
},
"autoload": {
"files": [
"src/accessibility-console.php"
],
"psr-4": {
"YakNet\\AccessibilityConsole\\": "src/"
}
+102 -47
View File
@@ -1,66 +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>
<!-- Missing alt attribute -->
<img src="logo.png">
<!-- Empty link -->
<a href="https://google.com"></a>
<!-- Empty button -->
<button type="submit"></button>
<!-- Positive tabindex -->
<a href="/keyboard" tabindex="5">Keyboard test</a>
<h1 id="main-content">Erişilebilir Demo Sayfa</h1>
<!-- FIXED: Removed duplicate id="main-content" (WCAG 4.1.1) -->
<!-- Duplicate ID -->
<div id="main-content">This is a duplicate main-content ID div!</div>
<!-- FIXED: Added alt attribute (WCAG 1.1.1) -->
<img src="logo.png" alt="YakNet Şirket">
<!-- Low Contrast -->
<p style="color: #888888; background-color: #999999;">This text has extremely low contrast ratio!</p>
<!-- FIXED: Added descriptive link text (WCAG 2.4.4) -->
<a href="https://google.com">Google Ana Sayfasına Git</a>
<!-- Invalid ARIA role and missing controls target -->
<div role="cool-widget" aria-controls="non-existent-popup">WAI-ARIA elements</div>
<!-- FIXED: Added discernible button text (WCAG 4.1.2) -->
<button type="submit">Formu Gönder</button>
<!-- Missing Iframe Title -->
<iframe src="https://www.youtube.com/embed/aqz-KE-bpKQ" width="560" height="315"></iframe>
<!-- FIXED: Removed positive tabindex (WCAG 2.4.3) — use natural DOM order -->
<a href="/keyboard">Klavye Testi</a>
<!-- New WCAG Rule Violations for testing -->
<!-- LinkTextGeneric: Generic text without context -->
<a href="/read-more">Click here</a>
<a href="/devam-oku">devamını oku</a>
<!-- PlaceholderAsLabel: Input relying only on placeholder -->
<input type="text" placeholder="Search website...">
<!-- 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>
<!-- 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">
<!-- FIXED: High contrast text (WCAG 1.4.3) -->
<p class="low-contrast-example">Bu metnin kontrast oranı WCAG AA gereksinimlerini karşılamaktadır.</p>
<!-- AriaHiddenFocusable: Focusable element hidden with aria-hidden="true" -->
<div aria-hidden="true">
<button type="button">Hidden click</button>
<!-- FIXED: Valid WAI-ARIA role (WCAG 4.1.2) and removed reference to non-existent element -->
<div role="region" aria-label="WAI-ARIA Örnek Bölüm">WAI-ARIA bölüm örneği</div>
<!-- FIXED: Added descriptive title to iframe (WCAG 4.1.2) -->
<iframe
src="https://www.youtube.com/embed/aqz-KE-bpKQ"
width="560"
height="315"
title="YouTube Video: Örnek Video İçeriği"
></iframe>
<!-- FIXED: Descriptive link text instead of generic "click here" / "devamını oku" (WCAG 2.4.4) -->
<a href="/read-more">Makaleyi Devamı: WCAG Erişilebilirlik Kılavuzu</a>
<a href="/devam-oku">Şiir Serisinin Devamını Oku: "Bahar Rüzgarı"</a>
<!-- FIXED: Added associated <label> element (WCAG 1.3.1 & 3.3.2) -->
<div>
<label for="search-input">Web sitesinde ara</label>
<input type="text" id="search-input" placeholder="Arama teriminizi girin...">
</div>
<a href="#" aria-hidden="true">Hidden link</a>
<!-- Empty heading violation -->
<h2></h2>
<!-- 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">
<!-- Video missing track violation -->
<video src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4" width="320" height="240" controls></video>
<!-- 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>
<!-- Empty label violation -->
<label for="search-input"></label>
<!-- FIXED: Added non-empty heading text (WCAG 2.4.6) -->
<h2>Alt Bölüm Başlığı</h2>
<!-- AriaFocusable violation -->
<div role="button">Clickable div button without tabindex</div>
<!-- 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>
<p>This page has multiple accessibility issues.</p>
<!-- 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
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Local Fix</title>
</head>
<body>
<span style="text-decoration: blink;">Hello blink</span>
<input type="text" name="username" autofocus />
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<html>
<head>
<title>Test Page</title>
<meta http-equiv="refresh" content="30">
</head>
<body>
<img src="avatar.png" alt="Avatar">
<button>Submit</button>
<a href="#">Click here for nothing</a>
<a href="javascript:void(0)">Do something</a>
<label for="nonexistent-id">Broken Label</label>
<table>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
<tr>
<td scope="row">Data 1</td>
<td>Data 2</td>
</tr>
</table>
</body>
</html>
+3
View File
@@ -2,3 +2,6 @@ parameters:
level: 9
paths:
- src
bootstrapFiles:
- vendor/autoload.php
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="YakNet Accessibility Console Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\AI\Providers\GeminiProvider;
use YakNet\AccessibilityConsole\AI\Providers\OpenAiProvider;
use YakNet\AccessibilityConsole\AI\Providers\ClaudeProvider;
use YakNet\AccessibilityConsole\AI\Providers\MockAiProvider;
use YakNet\AccessibilityConsole\Core\Violation;
class AiFixerManager
{
/** @var array<string, AiProviderInterface> */
private array $providers = [];
private ?string $activeProviderName = null;
public function __construct(?string $geminiApiKey = null, ?string $openAiApiKey = null, ?string $claudeApiKey = null)
{
// Register default providers
if ($geminiApiKey) {
$this->registerProvider('gemini', new GeminiProvider($geminiApiKey));
$this->activeProviderName = 'gemini';
}
if ($openAiApiKey) {
$this->registerProvider('openai', new OpenAiProvider($openAiApiKey));
if (!$this->activeProviderName) {
$this->activeProviderName = 'openai';
}
}
if ($claudeApiKey) {
$this->registerProvider('claude', new ClaudeProvider($claudeApiKey));
if (!$this->activeProviderName) {
$this->activeProviderName = 'claude';
}
}
// Always register mock provider as fallback/testing option
$this->registerProvider('mock', new MockAiProvider());
if (!$this->activeProviderName) {
$this->activeProviderName = 'mock';
}
}
public function registerProvider(string $name, AiProviderInterface $provider): void
{
$this->providers[strtolower($name)] = $provider;
}
public function setActiveProvider(string $name): void
{
$key = strtolower($name);
if (isset($this->providers[$key])) {
$this->activeProviderName = $key;
}
}
public function getActiveProvider(): ?AiProviderInterface
{
if ($this->activeProviderName && isset($this->providers[$this->activeProviderName])) {
return $this->providers[$this->activeProviderName];
}
return null;
}
public function suggestFix(Violation $violation): ?string
{
$provider = $this->getActiveProvider();
if ($provider) {
return $provider->suggestFix($violation);
}
return "No active AI provider configured.";
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\Core\Violation;
interface AiProviderInterface
{
/**
* Get the name of this AI provider.
*
* @return string
*/
public function getName(): string;
/**
* Suggest a fix for the given violation.
*
* @param Violation $violation
* @return string|null
*/
public function suggestFix(Violation $violation): ?string;
}
+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();
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\Core\Violation;
class PromptBuilder
{
public static function build(Violation $violation): string
{
return <<<PROMPT
You are a WCAG 2.1 Accessibility expert.
A static analysis tool found an accessibility issue in this HTML snippet.
Snippet: {$violation->htmlSnippet}
Error Message: {$violation->message}
Rule ID: {$violation->ruleId}
Severity: {$violation->severity->value}
Standard: {$violation->standard->value}
Please provide a short explanation of the fix in Turkish and the fixed version of the HTML snippet.
Use the following EXACT format:
EXPLANATION: [A short explanation of what you fixed and why, in Turkish]
FIX: [Only the corrected HTML snippet, no surrounding explanation]
PROMPT;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use GuzzleHttp\Client as GuzzleClient;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\AI\PromptBuilder;
use YakNet\AccessibilityConsole\Core\Violation;
class ClaudeProvider implements AiProviderInterface
{
private ?GuzzleClient $client = null;
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey) {
$this->client = new GuzzleClient([
'base_uri' => 'https://api.anthropic.com/v1/',
'headers' => [
'x-api-key' => $this->apiKey,
'anthropic-version' => '2023-06-01',
'Content-Type' => 'application/json',
],
'timeout' => 10.0,
]);
}
}
public function getName(): string
{
return 'Anthropic Claude';
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->client) {
return null;
}
$prompt = PromptBuilder::build($violation);
try {
$response = $this->client->post('messages', [
'json' => [
'model' => 'claude-3-5-haiku-20241022',
'max_tokens' => 1000,
'messages' => [
['role' => 'user', 'content' => $prompt]
],
],
]);
$data = json_decode($response->getBody()->getContents(), true);
if (is_array($data)
&& isset($data['content']) && is_array($data['content'])
&& isset($data['content'][0]) && is_array($data['content'][0])
&& isset($data['content'][0]['text']) && is_string($data['content'][0]['text'])
) {
return $data['content'][0]['text'];
}
return null;
} catch (\Throwable $e) {
return "Claude Error: " . $e->getMessage();
}
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use Gemini;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\AI\PromptBuilder;
use YakNet\AccessibilityConsole\Core\Violation;
class GeminiProvider implements AiProviderInterface
{
private ?\Gemini\Client $client = null;
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey) {
try {
$this->client = \Gemini::client($this->apiKey);
} catch (\Throwable) {
$this->client = null;
}
}
}
public function getName(): string
{
return 'Google Gemini';
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->apiKey || !$this->client) {
return null;
}
$prompt = PromptBuilder::build($violation);
try {
$result = $this->client->generativeModel('gemini-3.1-flash-lite')->generateContent($prompt);
return trim($result->text());
} catch (\Throwable $e) {
return "Gemini Error: " . $e->getMessage();
}
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\Core\Violation;
class MockAiProvider implements AiProviderInterface
{
public function getName(): string
{
return 'Mock AI';
}
public function suggestFix(Violation $violation): ?string
{
$snippet = $violation->htmlSnippet;
$fixedSnippet = $snippet;
// Simple mock fixes based on rule ID
if ($violation->ruleId === 'WCAG_2_2_2_BLINK') {
$fixedSnippet = str_replace(['<blink>', '</blink>'], ['<span>', '</span>'], $snippet);
} elseif ($violation->ruleId === 'WCAG_1_3_1_DUPLICATE_H1') {
$fixedSnippet = str_replace(['<h1>', '</h1>'], ['<h2>', '</h2>'], $snippet);
} elseif ($violation->ruleId === 'WCAG_1_3_5_AUTOCOMPLETE') {
$fixedSnippet = str_replace('<input', '<input autocomplete="email"', $snippet);
} elseif ($violation->ruleId === 'WCAG_2_1_1_SCROLLABLE_FOCUS') {
$fixedSnippet = str_replace('<div', '<div tabindex="0"', $snippet);
} elseif ($violation->ruleId === 'WCAG_4_1_2_ARIA_LABELLEDBY') {
$fixedSnippet = str_replace('aria-labelledby="non_existent_label"', 'aria-labelledby="real_label"', $snippet);
} elseif ($violation->ruleId === 'WCAG_1_1_1_ALT_PLACEHOLDER') {
if (str_contains($snippet, 'alt="logo"')) {
$fixedSnippet = str_replace('alt="logo"', 'alt="YakNet Accessibility Console Company Logo"', $snippet);
} else {
$fixedSnippet = str_replace('alt="header_banner.jpg"', 'alt="YakNet Accessibility Console Marketing Banner"', $snippet);
}
} elseif ($violation->ruleId === 'WCAG_1_1_1_SVG') {
$fixedSnippet = str_replace('<svg', '<svg aria-hidden="true"', $snippet);
} elseif ($violation->ruleId === 'WCAG_1_3_1_TABLE_SUMMARY') {
$fixedSnippet = preg_replace('/summary=".*?"/i', '', $snippet) ?: $snippet;
} elseif ($violation->ruleId === 'WCAG_1_3_1_PRESENTATION_TAGS') {
$fixedSnippet = str_replace(['<center>', '</center>', '<strike>', '</strike>'], ['<span style="text-align: center; display: block;">', '</span>', '<span style="text-decoration: line-through;">', '</span>'], $snippet);
} elseif ($violation->ruleId === 'WCAG_2_4_3_AUTOFOCUS') {
$fixedSnippet = str_replace(' autofocus', '', $snippet);
}
return "EXPLANATION: Mocked correction for accessibility validation.\nFIX: " . $fixedSnippet;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use GuzzleHttp\Client as GuzzleClient;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\AI\PromptBuilder;
use YakNet\AccessibilityConsole\Core\Violation;
class OpenAiProvider implements AiProviderInterface
{
private ?GuzzleClient $client = null;
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey) {
$this->client = new GuzzleClient([
'base_uri' => 'https://api.openai.com/v1/',
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
],
'timeout' => 10.0,
]);
}
}
public function getName(): string
{
return 'OpenAI GPT';
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->client) {
return null;
}
$prompt = PromptBuilder::build($violation);
try {
$response = $this->client->post('chat/completions', [
'json' => [
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'user', 'content' => $prompt]
],
'temperature' => 0.2,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
if (is_array($data)
&& isset($data['choices']) && is_array($data['choices'])
&& isset($data['choices'][0]) && is_array($data['choices'][0])
&& isset($data['choices'][0]['message']) && is_array($data['choices'][0]['message'])
&& isset($data['choices'][0]['message']['content']) && is_string($data['choices'][0]['message']['content'])
) {
return $data['choices'][0]['message']['content'];
}
return null;
} catch (\Throwable $e) {
return "OpenAI Error: " . $e->getMessage();
}
}
}
+212 -33
View File
@@ -8,10 +8,11 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\AI\GeminiFixer;
use YakNet\AccessibilityConsole\AI\AiFixerManager;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
use YakNet\AccessibilityConsole\Source\SourceLocator;
class FixCommand extends Command
@@ -21,46 +22,206 @@ class FixCommand extends Command
$this
->setName('fix')
->setDescription('Automatically fix accessibility violations using AI')
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files');
->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan and fix')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
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 AiFixerManager($apiKey);
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
$fixedCount = 0;
// Group violations by file path
$groupedViolations = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
if ($file !== '') {
$groupedViolations[$file][] = $v;
}
}
foreach ($groupedViolations as $file => $fileViolations) {
$io->section("Fixing file: $file");
// Sort violations by line number DESCENDING (bottom to top)
// This prevents edits at the bottom of the file from invalidating line numbers above them!
usort($fileViolations, function ($a, $b) {
return ($b->location['line'] ?? 0) <=> ($a->location['line'] ?? 0);
});
$absoluteFile = getcwd() . '/' . $file;
if (!file_exists($absoluteFile) || !is_writable($absoluteFile)) {
$io->warning("File not found or not writable: $file. Skipping.");
continue;
}
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
foreach ($fileViolations as $violation) {
$line = $violation->location['line'] ?? 0;
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
// Try Local Healer first
$rawSuggestion = $localHealer->heal($violation);
$isLocalFix = false;
if ($rawSuggestion !== null) {
$isLocalFix = true;
} else {
// Fallback to AI Suggestion
$rawSuggestion = $fixer->suggestFix($violation);
}
$suggestion = $rawSuggestion;
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
$suggestion = trim($matches[1] ?? $rawSuggestion);
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
}
if (!is_string($suggestion)) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
if ($isLocalFix) {
$io->writeln("<fg=green>Local Fix Found!</>");
} else {
$io->writeln("<fg=green>AI Fix Found!</>");
}
$io->writeln(" <fg=gray>Diff:</>");
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
$io->writeln($indentedDiff);
if ($dryRun) {
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
continue;
}
// Apply Fix
if ($this->applyFix($absoluteFile, $violation->htmlSnippet, $suggestion)) {
$io->writeln(" <fg=bright-green>✔ Applied to line $line</>");
$fixedCount++;
} else {
$io->writeln(" <fg=red>✘ Failed to apply fix to file.</>");
}
$io->writeln("");
}
}
if ($fixedCount > 0) {
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
} else {
$io->warning("No files were modified.");
}
return Command::SUCCESS;
}
/**
* Legacy URL scan and fix logic
*/
private function executeUrlFix(string $target, int $level, string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
{
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$dryRun = (bool)$input->getOption('dry-run');
$useJs = (bool)$input->getOption('js');
$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.");
return Command::FAILURE;
// Fetch HTML
if ($useJs) {
try {
$chromeRenderer = new \YakNet\AccessibilityConsole\Core\ChromeRenderer();
$html = $chromeRenderer->render($target);
} catch (\Throwable $e) {
$io->error("Failed JavaScript rendering: " . $e->getMessage());
return Command::FAILURE;
}
} else {
$html = @file_get_contents($target);
}
$io->title("AI Self-Healing Mode - Fixing: $target");
// 1. Fetch HTML
$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);
@@ -72,7 +233,8 @@ class FixCommand extends Command
$io->note("Found " . count($violations) . " violations. Starting AI analysis...");
$fixer = new GeminiFixer($apiKey);
$fixer = new AiFixerManager($apiKey);
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
$locator = new SourceLocator($projectPath);
$fixedCount = 0;
@@ -86,24 +248,42 @@ class FixCommand extends Command
continue;
}
// Get AI Suggestion
$rawSuggestion = $fixer->suggestFix($violation);
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
$rawSuggestion = $localHealer->heal($violation);
$isLocalFix = false;
if ($rawSuggestion !== null) {
$isLocalFix = true;
} else {
$rawSuggestion = $fixer->suggestFix($violation);
}
$suggestion = $rawSuggestion;
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
$suggestion = trim($matches[1] ?? $rawSuggestion);
// Clean up potential markdown
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
}
if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
if (!is_string($suggestion)) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
$io->writeln("<fg=green>Fix Found!</>");
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>");
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>");
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
if ($isLocalFix) {
$io->writeln("<fg=green>Local Fix Found!</>");
} else {
$io->writeln("<fg=green>AI Fix Found!</>");
}
$io->writeln(" <fg=gray>Diff:</>");
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
$io->writeln($indentedDiff);
if ($dryRun) {
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
@@ -123,7 +303,7 @@ class FixCommand extends Command
if ($fixedCount > 0) {
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
} else {
$io->warning("No files were modified. Check error logs for details.");
$io->warning("No files were modified.");
}
return Command::SUCCESS;
@@ -138,8 +318,7 @@ class FixCommand extends Command
if (str_contains($content, $originalSnippet)) {
$newContent = str_replace($originalSnippet, $fixedSnippet, $content);
} else {
// Fallback: Fuzzy replacement (simplified for this demo)
// In a real professional lib, we'd use more advanced regex or DOM patching
// Fallback: Fuzzy replacement
$newContent = str_replace(trim($originalSnippet), trim($fixedSnippet), $content);
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class InitCommand extends Command
{
protected function configure(): void
{
$this
->setName('init')
->setDescription('Initialize a default a11y.yaml configuration file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$configPath = getcwd() . '/a11y.yaml';
if (file_exists($configPath)) {
$io->warning("Configuration file 'a11y.yaml' already exists in the current directory.");
return Command::SUCCESS;
}
$defaultConfig = <<<YAML
# YakNet Accessibility Console Configuration
# For detailed configuration, see: https://forum.yak.net.tr
# Paths to scan (can be directories or specific files)
paths:
- .
# Exclude directories or files
exclude_paths:
- vendor
- node_modules
- tests
- storage
- .git
# Accessibility scanning level (1-9, default 4)
level: 4
# Output format (console, json, html, github)
format: console
# Path to the baseline file for ignoring existing violations
# baseline: a11y-baseline.json
# Rule adjustments
rules:
# Rules to exclude from analysis
exclude:
# - WCAG_1_3_1_FIELDSET
# Additional custom rules to run
include:
# - App\Rules\MyCustomRule
YAML;
if (file_put_contents($configPath, $defaultConfig) !== false) {
$io->success("Created configuration file: a11y.yaml");
return Command::SUCCESS;
}
$io->error("Failed to create configuration file 'a11y.yaml'.");
return Command::FAILURE;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class RulesCommand extends Command
{
protected function configure(): void
{
$this
->setName('rules')
->setDescription('List all available accessibility scanning rules');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title("Accessibility Console - Available Rules");
$table = new Table($output);
$table->setHeaders(['Rule ID', 'Standard', 'Severity', 'Level', 'Description']);
$rules = RuleLevels::getRulesForLevel(9);
foreach ($rules as $rule) {
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: (new \ReflectionClass($rule))->getShortName();
$description = method_exists($rule, 'getDescription') ? $rule->getDescription() : '';
$standard = method_exists($rule, 'getStandard') ? $rule->getStandard()->value : '';
$severity = method_exists($rule, 'getSeverity') ? $rule->getSeverity()->value : '';
$level = method_exists($rule, 'getLevel') ? $rule->getLevel() : 4;
$table->addRow([
"<fg=cyan;options=bold>$ruleId</>",
$standard,
$severity,
$level,
$description
]);
}
$table->render();
$io->newLine();
$io->note("Total rules available: " . count($rules));
return Command::SUCCESS;
}
}
+254 -54
View File
@@ -3,45 +3,259 @@
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\AI\GeminiFixer;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\BaselineManager;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
use YakNet\AccessibilityConsole\Reporting\GithubFormatter;
use YakNet\AccessibilityConsole\Reporting\JsonFormatter;
use YakNet\AccessibilityConsole\Reporting\XmlFormatter;
use YakNet\AccessibilityConsole\Reporting\MarkdownFormatter;
use YakNet\AccessibilityConsole\Reporting\CsvFormatter;
use YakNet\AccessibilityConsole\Reporting\GitLabCodeQualityFormatter;
use YakNet\AccessibilityConsole\Source\SourceLocator;
class ScanCommand extends Command
{
protected static ?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('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively')
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth', '3')
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum number of pages to crawl', '20')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-5)', '4');
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively (URL scans only)')
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth (URL scans only)', '3')
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum pages to crawl (URL scans only)', '20')
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
if (!is_string($target)) {
$io->error("Target must be a string URL or file path.");
return Command::FAILURE;
// 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');
$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();
} elseif ($format === 'xml') {
$formatter = new XmlFormatter();
} elseif ($format === 'markdown') {
$formatter = new MarkdownFormatter();
} elseif ($format === 'csv') {
$formatter = new CsvFormatter();
} elseif ($format === 'gitlab') {
$formatter = new GitLabCodeQualityFormatter();
}
$exitCode = $formatter->format($violations, $baselinedCount, $io);
if ($format === 'console') {
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
$io->section("Accessibility Ratings");
$color = $metrics['score'] >= 90 ? 'green' : ($metrics['score'] >= 70 ? 'yellow' : 'red');
$io->writeln(sprintf("Health Score: <fg=%s>%d/100</>", $color, $metrics['score']));
$io->writeln(sprintf(" - Screen Reader / Visual Impact: <fg=cyan>%d%%</>", $metrics['visualImpact']));
$io->writeln(sprintf(" - Keyboard / Motor Impact: <fg=cyan>%d%%</>", $metrics['motorImpact']));
$io->writeln(sprintf(" - Cognitive / Distraction Impact: <fg=cyan>%d%%</>", $metrics['cognitiveImpact']));
$io->newLine();
}
// Export report if --report is specified
$reportPath = $input->getOption('report');
if (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();
@@ -49,14 +263,7 @@ class ScanCommand extends Command
$projectPath = '.';
}
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : 4;
if ($level < 1 || $level > 5) {
$io->error("Scan level must be between 1 and 5.");
return Command::FAILURE;
}
$io->title("Accessibility Console - Scanning: $target (Level $level)");
$io->title("Accessibility Console - Scanning URL: $target (Level $level)");
// 1. Setup Scanner
$scanner = new Scanner();
@@ -66,10 +273,9 @@ class ScanCommand extends Command
}
// 2. Fetch and Scan
$isUrl = str_starts_with($target, 'http://') || str_starts_with($target, 'https://');
$crawl = (bool)$input->getOption('crawl') && $isUrl;
/** @var array<string, \YakNet\AccessibilityConsole\Core\Violation[]> $results */
$crawl = (bool)$input->getOption('crawl');
$useJs = (bool)$input->getOption('js');
$chromeRenderer = $useJs ? new \YakNet\AccessibilityConsole\Core\ChromeRenderer() : null;
$results = [];
if ($crawl) {
@@ -83,9 +289,7 @@ class ScanCommand extends Command
$parsedBase = parse_url($target);
$baseHost = $parsedBase['host'] ?? '';
$queue = [
['url' => $target, 'depth' => 1]
];
$queue = [['url' => $target, 'depth' => 1]];
$visited = [];
$client = new \GuzzleHttp\Client([
@@ -98,14 +302,10 @@ class ScanCommand extends Command
while (!empty($queue) && count($visited) < $maxPages) {
$current = array_shift($queue);
if (!is_array($current)) {
continue;
}
$url = $current['url'];
$depth = (int)$current['depth'];
// Remove fragment
$cleanUrl = strtok($url, '#');
if ($cleanUrl === false || isset($visited[$cleanUrl])) {
continue;
@@ -115,15 +315,17 @@ class ScanCommand extends Command
$io->write(" <fg=gray>Crawling [Depth $depth]:</> <fg=cyan>$cleanUrl</>... ");
try {
$response = $client->request('GET', $cleanUrl);
$html = (string)$response->getBody();
if ($chromeRenderer) {
$html = $chromeRenderer->render($cleanUrl);
} else {
$response = $client->request('GET', $cleanUrl);
$html = (string)$response->getBody();
}
$io->writeln("<fg=green>OK</>");
// Scan the page HTML
$pageViolations = $scanner->scan($html);
$results[$cleanUrl] = $pageViolations;
// Extract links if depth is less than limit
if ($depth < $depthLimit) {
$html5 = new \Masterminds\HTML5(['disable_html_ns' => true]);
$doc = $html5->loadHTML($html);
@@ -135,7 +337,6 @@ class ScanCommand extends Command
continue;
}
// Resolve relative URI
try {
$baseUri = new \GuzzleHttp\Psr7\Uri($cleanUrl);
$relativeUri = new \GuzzleHttp\Psr7\Uri($href);
@@ -145,12 +346,10 @@ class ScanCommand extends Command
continue;
}
// Check host compatibility
$parsedAbsolute = parse_url($absoluteUrl);
$absoluteHost = $parsedAbsolute['host'] ?? '';
if ($absoluteHost === $baseHost) {
// Add to queue if not already visited or in queue
$cleanAbsolute = strtok($absoluteUrl, '#');
if ($cleanAbsolute !== false && !isset($visited[$cleanAbsolute])) {
$inQueue = false;
@@ -172,10 +371,19 @@ class ScanCommand extends Command
}
}
} else {
// Standard single page/file scan
$html = @file_get_contents($target);
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: $target");
$io->error("Could not read target URL: $target");
return Command::FAILURE;
}
$violations = $scanner->scan($html);
@@ -194,7 +402,6 @@ class ScanCommand extends Command
$io->section("Found a total of $totalViolations violations across " . count($results) . " pages");
// 3. Setup Helpers
$locator = new SourceLocator($projectPath);
$config = new Config();
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
@@ -207,17 +414,14 @@ class ScanCommand extends Command
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);
@@ -228,14 +432,10 @@ class ScanCommand extends Command
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
}
}
$io->writeln("");
}
}
$io->warning("Scanned " . $totalViolations . " errors. Use --ai to get fix suggestions.");
// 4. Export Report
$reportPath = $input->getOption('report');
if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
@@ -252,6 +452,6 @@ class ScanCommand extends Command
$io->success("Report saved to: $reportPath");
}
return Command::SUCCESS;
return Command::FAILURE;
}
}
+290
View File
@@ -0,0 +1,290 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class Analyser
{
private Config $config;
private Scanner $scanner;
private BaselineManager $baselineManager;
private string $projectRoot;
public function __construct(?Config $config = null, ?Scanner $scanner = null, ?BaselineManager $baselineManager = null)
{
$this->config = $config ?? new Config();
$this->scanner = $scanner ?? $this->setupScanner();
$this->baselineManager = $baselineManager ?? new BaselineManager();
$cwd = getcwd();
$this->projectRoot = $cwd ? str_replace('\\', '/', realpath($cwd) ?: $cwd) : '.';
if ($baselineManager === null) {
$baselinePath = $this->config->getBaselinePath();
if ($baselinePath) {
$this->baselineManager->load($this->projectRoot . '/' . $baselinePath);
}
}
}
private function setupScanner(): Scanner
{
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($this->config->getLevel());
$rulesConfig = $this->config->getRulesConfig();
$activeRules = [];
foreach ($rules as $rule) {
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: (new \ReflectionClass($rule))->getShortName();
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
continue;
}
$activeRules[$ruleId] = $rule;
}
// Include custom rules
foreach ($rulesConfig['include'] as $customRuleClass) {
if (class_exists($customRuleClass)) {
try {
$reflection = new \ReflectionClass($customRuleClass);
if ($reflection->isInstantiable()) {
$rule = $reflection->newInstance();
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: $reflection->getShortName();
$activeRules[$ruleId] = $rule;
}
} catch (\Throwable $e) {
// Ignore instantiation failures
}
}
}
foreach ($activeRules as $rule) {
$scanner->addRule($rule);
}
return $scanner;
}
/**
* Analyse files and return a list of violations.
*
* @param array<int, string>|null $pathsOverride
* @param callable|null $onProgress Function called for progress tracking: fn(string $file, int $index, int $total)
* @return array{violations: Violation[], baselinedCount: int}
*/
public function analyse(?array $pathsOverride = null, ?callable $onProgress = null): array
{
$paths = $pathsOverride ?? $this->config->getPaths();
$excludePaths = $this->config->getExcludePaths();
// Resolve all files to scan
$filesToScan = [];
foreach ($paths as $path) {
if (is_file($path)) {
$filesToScan[] = str_replace('\\', '/', realpath($path) ?: $path);
} elseif (is_dir($path)) {
$files = $this->findFiles($path, $excludePaths);
$filesToScan = array_merge($filesToScan, $files);
}
}
$filesToScan = array_unique($filesToScan);
$totalFiles = count($filesToScan);
$violations = [];
$baselinedCount = 0;
foreach ($filesToScan as $index => $file) {
if ($onProgress) {
$onProgress($file, $index + 1, $totalFiles);
}
$content = @file_get_contents($file);
if ($content === false || trim($content) === '') {
continue;
}
// Scan file content
try {
$fileViolations = $this->scanner->scan($content);
} catch (\Throwable $e) {
// Skip if parsing failed dramatically
continue;
}
$relativeFile = $this->makePathRelative($file);
foreach ($fileViolations as $violation) {
// Find line number using line-by-line helper
$line = $this->locateLineInContent($content, $violation->htmlSnippet);
// Store location in violation
$violation->location = [
'file' => $relativeFile,
'line' => $line
];
// Check if ignored in baseline
if ($this->baselineManager->isIgnored($relativeFile, $violation->ruleId, $violation->htmlSnippet)) {
$baselinedCount++;
} else {
$violations[] = $violation;
}
}
}
return [
'violations' => $violations,
'baselinedCount' => $baselinedCount
];
}
public function getBaselineManager(): BaselineManager
{
return $this->baselineManager;
}
public function makePathRelative(string $absolutePath): string
{
$absolutePath = str_replace('\\', '/', realpath($absolutePath) ?: $absolutePath);
if ($this->projectRoot !== '.' && str_starts_with($absolutePath, $this->projectRoot)) {
$relative = ltrim(substr($absolutePath, strlen($this->projectRoot)), '/');
return $relative === '' ? '.' : $relative;
}
return $absolutePath;
}
/**
* @param array<int, string> $excludePaths
* @return array<int, string>
*/
private function findFiles(string $dir, array $excludePaths): array
{
$realDir = realpath($dir);
if ($realDir === false) {
return [];
}
$files = [];
try {
$directory = new RecursiveDirectoryIterator($realDir, \FilesystemIterator::SKIP_DOTS);
$filter = new \RecursiveCallbackFilterIterator($directory, function ($current) use ($excludePaths) {
/** @var SplFileInfo $current */
$filename = $current->getFilename();
$pathname = str_replace('\\', '/', $current->getPathname());
// Exclude common directories/files
foreach ($excludePaths as $exclude) {
$normalizedExclude = str_replace('\\', '/', $exclude);
if ($filename === $normalizedExclude || str_contains($pathname, '/' . $normalizedExclude . '/')) {
return false;
}
}
return true;
});
$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
$extensions = ['php', 'html', 'twig', 'blade.php'];
foreach ($iterator as $file) {
/** @var SplFileInfo $file */
if ($file->isDir()) {
continue;
}
$filename = $file->getFilename();
$isValidExt = false;
foreach ($extensions as $ext) {
if (str_ends_with($filename, '.' . $ext) || ($ext === 'blade.php' && str_ends_with($filename, '.blade.php'))) {
$isValidExt = true;
break;
}
}
if ($isValidExt) {
$files[] = str_replace('\\', '/', $file->getPathname());
}
}
} catch (\Throwable $e) {
// Ignore directory iteration errors
}
return $files;
}
private function locateLineInContent(string $content, string $snippet): int
{
$replaced = preg_replace('/\s+/', ' ', $snippet);
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
if ($cleanSnippet === '') {
return 1;
}
$searchSnippet = mb_strlen($cleanSnippet) > 150 ? mb_substr($cleanSnippet, 0, 150) : $cleanSnippet;
$lines = explode("\n", $content);
// 1. Exact match pass
foreach ($lines as $index => $line) {
$replacedLine = preg_replace('/\s+/', ' ', $line);
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
if (str_contains($cleanLine, $searchSnippet)) {
return $index + 1;
}
}
// 2. Parse snippet to find tag and attributes for fuzzy match pass
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
$tagName = $matches[1] ?? null;
if (!$tagName) {
return 1;
}
preg_match_all('/([a-zA-Z0-9-]+)=["\']([^"\']*)["\']/', $snippet, $attrMatches, PREG_SET_ORDER);
$attributes = [];
foreach ($attrMatches as $match) {
$attributes[$match[1]] = $match[2];
}
$bestLine = 1;
$maxScore = 0;
foreach ($lines as $index => $line) {
$replacedLine = preg_replace('/\s+/', ' ', $line);
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
if (!str_contains(strtolower($cleanLine), '<' . strtolower($tagName))) {
continue;
}
$score = 1.0;
if (in_array(strtolower($tagName), ['html', 'head', 'body', 'title'], true)) {
$score += 1.0;
}
foreach ($attributes as $name => $value) {
if (str_contains($cleanLine, "$name=\"$value\"") || str_contains($cleanLine, "$name='$value'")) {
$score += 2.0;
} elseif (str_contains($cleanLine, $name . '=')) {
$score += 0.5;
}
}
if ($score > $maxScore) {
$maxScore = $score;
$bestLine = $index + 1;
}
}
return $bestLine;
}
}
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Baseline;
class FuzzyBaselineMatcher
{
/**
* Determine if two HTML snippets are fuzzy matched.
*
* @param string $snippetA
* @param string $snippetB
* @return bool
*/
public static function match(string $snippetA, string $snippetB): bool
{
$normA = self::normalize($snippetA);
$normB = self::normalize($snippetB);
if ($normA === '' || $normB === '') {
return false;
}
return $normA === $normB;
}
/**
* Normalize HTML snippet by stripping formatting, converting to lowercase,
* and sorting attributes.
*
* @param string $html
* @return string
*/
public static function normalize(string $html): string
{
$html = trim($html);
if ($html === '') {
return '';
}
// Parse using simple regex to sort attributes and lowercase tag
if (preg_match('/^<([a-zA-Z0-9:-]+)(.*?)>$/s', $html, $matches)) {
$tag = strtolower($matches[1]);
$attrsContent = $matches[2];
// Parse attributes
preg_match_all('/([a-zA-Z0-9:-]+)=["\'](.*?)["\']/s', $attrsContent, $attrMatches, PREG_SET_ORDER);
$attrs = [];
foreach ($attrMatches as $match) {
$attrs[strtolower($match[1])] = trim($match[2]);
}
ksort($attrs);
$attrStr = '';
foreach ($attrs as $name => $value) {
$attrStr .= sprintf(' %s="%s"', $name, $value);
}
return sprintf('<%s%s>', $tag, $attrStr);
}
// Fallback: Strip all whitespace and lowercase
$cleaned = preg_replace('/\s+/', '', $html) ?: '';
return strtolower($cleaned);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
class BaselineManager
{
/** @var array<string, bool> */
private array $ignoredKeys = [];
public function load(string $baselinePath): bool
{
if (!file_exists($baselinePath) || !is_readable($baselinePath)) {
return false;
}
$content = file_get_contents($baselinePath);
if ($content === false) {
return false;
}
$data = json_decode($content, true);
if (!is_array($data) || !isset($data['violations']) || !is_array($data['violations'])) {
return false;
}
foreach ($data['violations'] as $item) {
if (!is_array($item)) {
continue;
}
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])
&& is_string($item['file']) && is_string($item['ruleId']) && is_string($item['htmlSnippet'])
) {
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
$this->ignoredKeys[$key] = true;
}
}
return true;
}
/**
* @param Violation[] $violations
*/
public function generate(array $violations, string $baselinePath): bool
{
$serialized = [
'generator' => 'YakNet Accessibility Console',
'violations' => []
];
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
$serialized['violations'][] = [
'file' => $file,
'ruleId' => $v->ruleId,
'htmlSnippet' => $v->htmlSnippet
];
}
// Sort baseline violations by file and ruleId for cleaner git diffs
usort($serialized['violations'], function ($a, $b) {
$fileCmp = strcmp($a['file'], $b['file']);
if ($fileCmp !== 0) {
return $fileCmp;
}
return strcmp($a['ruleId'], $b['ruleId']);
});
$json = json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return false;
}
$dir = dirname($baselinePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return file_put_contents($baselinePath, $json) !== false;
}
public function isIgnored(string $file, string $ruleId, string $htmlSnippet): bool
{
$key = $this->calculateKey($file, $ruleId, $htmlSnippet);
return isset($this->ignoredKeys[$key]);
}
private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string
{
// Normalize snippet using fuzzy baseline matcher
$normalizedSnippet = \YakNet\AccessibilityConsole\Core\Baseline\FuzzyBaselineMatcher::normalize($htmlSnippet);
if ($normalizedSnippet === '') {
$normalizedSnippet = trim($htmlSnippet);
}
// Normalize file separator to forward slashes for cross-platform compatibility
$normalizedFile = str_replace('\\', '/', $file);
return md5($normalizedFile . '::' . $ruleId . '::' . $normalizedSnippet);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use HeadlessChromium\BrowserFactory;
use HeadlessChromium\Navigation;
class ChromeRenderer
{
/**
* Navigate to URL using headless Chrome and return fully rendered HTML.
*/
public function render(string $url): string
{
$browserFactory = new BrowserFactory();
$options = [
'windowSize' => [1280, 1024],
'noSandbox' => true,
];
// Windows Chrome binary path auto-detection override
$chromeBinary = $_ENV['CHROME_BINARY'] ?? getenv('CHROME_BINARY') ?: null;
if (!$chromeBinary && PHP_OS_FAMILY === 'Windows') {
$standardPaths = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files/Google/Chrome/Application/chrome.exe'
];
foreach ($standardPaths as $path) {
if (file_exists($path)) {
$chromeBinary = $path;
break;
}
}
}
if ($chromeBinary) {
$options['chromeBinary'] = $chromeBinary;
}
$browser = $browserFactory->createBrowser($options);
try {
$page = $browser->createPage();
// Navigate and wait for onload event
$navigation = $page->navigate($url);
$navigation->waitForNavigation(\HeadlessChromium\Page::LOAD, 10000); // 10 seconds timeout
// Extra wait for async JS rendering to complete
usleep(1500000); // 1.5s
$html = $page->getHtml();
} finally {
$browser->close();
}
return $html;
}
}
+99 -2
View File
@@ -13,11 +13,35 @@ 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)) {
/** @var array<string, mixed> $content */
return new self($content);
}
} catch (\Throwable $e) {
// Ignore parsing errors
}
}
return new self([]);
}
@@ -26,8 +50,81 @@ class Config
return $this->settings[$key] ?? $default;
}
/**
* @return array<int, string>
*/
public function getPaths(): array
{
$paths = $this->get('paths');
if (is_array($paths)) {
return array_values(array_filter($paths, 'is_string'));
}
if (is_string($paths)) {
return [$paths];
}
return ['.']; // default to current directory
}
/**
* @return array<int, string>
*/
public function getExcludePaths(): array
{
$exclude = $this->get('exclude_paths');
if (is_array($exclude)) {
return array_values(array_filter($exclude, 'is_string'));
}
if (is_string($exclude)) {
return [$exclude];
}
return ['vendor', 'node_modules', 'tests', 'storage', '.git']; // sensible defaults
}
public function getLevel(): int
{
$level = $this->get('level', 4);
return is_numeric($level) ? (int)$level : 4;
}
public function getBaselinePath(): ?string
{
$baseline = $this->get('baseline');
return is_string($baseline) ? $baseline : null;
}
public function getFormat(): string
{
$format = $this->get('format', 'console');
return is_string($format) ? $format : 'console';
}
/**
* @return array{include: array<int, string>, exclude: array<int, string>}
*/
public function getRulesConfig(): array
{
$rules = $this->get('rules', []);
$include = [];
$exclude = [];
if (is_array($rules)) {
if (isset($rules['include']) && is_array($rules['include'])) {
$include = array_values(array_filter($rules['include'], 'is_string'));
}
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
$exclude = array_values(array_filter($rules['exclude'], 'is_string'));
}
}
return [
'include' => $include,
'exclude' => $exclude
];
}
public function getGeminiApiKey(): ?string
{
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
$key = $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
return is_string($key) ? $key : null;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Config;
class SmartPresetBuilder
{
/**
* Analyze HTML content features to suggest rules presets.
*
* @param string $html
* @return array{suggestedLevel: int, tagsCheckedCount: array<string, int>, recommendedExclusions: string[]}
*/
public function analyze(string $html): array
{
$tagsChecked = ['img' => 0, 'form' => 0, 'table' => 0, 'svg' => 0, 'video' => 0];
foreach (array_keys($tagsChecked) as $tag) {
$tagsChecked[$tag] = substr_count(strtolower($html), '<' . $tag);
}
// Calculate recommendations
$suggestedLevel = 3; // Standard baseline
if ($tagsChecked['form'] > 5 || $tagsChecked['table'] > 3) {
$suggestedLevel = 4; // Higher complexity requires more strict tests
}
if ($tagsChecked['video'] > 0) {
$suggestedLevel = 5; // Video elements need caption checks, standard level 5
}
$exclusions = [];
if ($tagsChecked['svg'] === 0) {
$exclusions[] = 'WCAG_1_1_1_SVG';
}
if ($tagsChecked['video'] === 0) {
$exclusions[] = 'WCAG_1_2_2_VIDEO_TRACK';
}
return [
'suggestedLevel' => $suggestedLevel,
'tagsCheckedCount' => $tagsChecked,
'recommendedExclusions' => $exclusions,
];
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Container;
use DI\ContainerBuilder;
use Psr\Container\ContainerInterface;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
class ContainerFactory
{
/**
* Create and configure the PHP-DI container.
*
* @return ContainerInterface
*/
public static function create(): ContainerInterface
{
$builder = new ContainerBuilder();
$builder->addDefinitions([
Config::class => function () {
$configPath = getcwd() . '/a11y.yaml';
return Config::fromYaml($configPath);
},
Scanner::class => function () {
return new Scanner();
},
Analyser::class => function (ContainerInterface $c) {
$config = $c->get(Config::class);
$scanner = $c->get(Scanner::class);
return new Analyser(
$config instanceof Config ? $config : null,
$scanner instanceof Scanner ? $scanner : null
);
},
ScanCommand::class => function () {
return new ScanCommand();
},
FixCommand::class => function () {
return new FixCommand();
},
InitCommand::class => function () {
return new InitCommand();
},
RulesCommand::class => function () {
return new RulesCommand();
},
]);
return $builder->build();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Diff;
use SebastianBergmann\Diff\Differ;
use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
class VisualDiffGenerator
{
private Differ $differ;
public function __construct()
{
$this->differ = new Differ(new UnifiedDiffOutputBuilder("--- Original\n+++ Healed\n"));
}
/**
* Generate a colorized git-style unified diff string.
*
* @param string $original
* @param string $fixed
* @return string
*/
public function generate(string $original, string $fixed): string
{
$diff = $this->differ->diff($original, $fixed);
$lines = explode("\n", $diff);
$colorized = [];
foreach ($lines as $line) {
if (str_starts_with($line, '+') && !str_starts_with($line, '+++')) {
$colorized[] = "<fg=green>" . htmlspecialchars($line) . "</>";
} elseif (str_starts_with($line, '-') && !str_starts_with($line, '---')) {
$colorized[] = "<fg=red>" . htmlspecialchars($line) . "</>";
} elseif (str_starts_with($line, '@@')) {
$colorized[] = "<fg=cyan>" . htmlspecialchars($line) . "</>";
} elseif (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
$colorized[] = "<fg=gray>" . htmlspecialchars($line) . "</>";
} else {
$colorized[] = htmlspecialchars($line);
}
}
return implode("\n", $colorized);
}
}
@@ -0,0 +1,95 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Metrics;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\Severity;
class AccessibilityScoreCalculator
{
/**
* Calculate accessibility score (0-100) and user impact summaries.
*
* @param Violation[] $violations
* @return array{score: int, visualImpact: int, motorImpact: int, cognitiveImpact: int}
*/
public static function calculate(array $violations): array
{
$score = 100;
$visualCount = 0;
$motorCount = 0;
$cognitiveCount = 0;
foreach ($violations as $v) {
// Deduct points based on severity
$deduction = 3; // Warning default
if ($v->severity === Severity::CRITICAL) {
$deduction = 15;
} elseif ($v->severity === Severity::ERROR) {
$deduction = 8;
}
$score -= $deduction;
// Map rule ID to disabled user groups impact
$id = $v->ruleId;
if (self::isVisual($id)) {
$visualCount++;
}
if (self::isMotor($id)) {
$motorCount++;
}
if (self::isCognitive($id)) {
$cognitiveCount++;
}
}
$score = max(0, $score);
// Normalize counts to percentage indicators of severity
$total = count($violations);
$visualImpact = $total > 0 ? (int)(($visualCount / $total) * 100) : 0;
$motorImpact = $total > 0 ? (int)(($motorCount / $total) * 100) : 0;
$cognitiveImpact = $total > 0 ? (int)(($cognitiveCount / $total) * 100) : 0;
return [
'score' => $score,
'visualImpact' => $visualImpact,
'motorImpact' => $motorImpact,
'cognitiveImpact' => $cognitiveImpact,
];
}
private static function isVisual(string $ruleId): bool
{
$visualKeywords = ['ALT', 'LABEL', 'TITLE', 'ARIA', 'LANG', 'CONTRAST', 'COLOR', 'TEXT', 'HEADING'];
foreach ($visualKeywords as $key) {
if (str_contains($ruleId, $key)) {
return true;
}
}
return false;
}
private static function isMotor(string $ruleId): bool
{
$motorKeywords = ['ACCESSKEY', 'FOCUS', 'TABINDEX', 'KEYBOARD', 'LINK_JAVASCRIPT', 'AUTOFOCUS', 'SCROLLABLE'];
foreach ($motorKeywords as $key) {
if (str_contains($ruleId, $key)) {
return true;
}
}
return false;
}
private static function isCognitive(string $ruleId): bool
{
$cognitiveKeywords = ['MARQUEE', 'BLINK', 'REFRESH', 'AUTOPLAY', 'AUTOCOMPLETE'];
foreach ($cognitiveKeywords as $key) {
if (str_contains($ruleId, $key)) {
return true;
}
}
return false;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace YakNet\AccessibilityConsole\Core\SelfHealing;
use YakNet\AccessibilityConsole\Core\Violation;
class LocalHealer
{
/**
* Attempt to deterministically fix simple violations locally.
*
* @param Violation $violation
* @return string|null
*/
public function heal(Violation $violation): ?string
{
$snippet = $violation->htmlSnippet;
$ruleId = $violation->ruleId;
switch ($ruleId) {
case 'WCAG_2_2_2_BLINK':
$fixed = str_replace(
['<blink>', '</blink>', '<BLINK>', '</BLINK>'],
['<span style="text-decoration: blink;">', '</span>', '<span style="text-decoration: blink;">', '</span>'],
$snippet
);
return "EXPLANATION: Banned <blink> tag replaced with a span and text-decoration style.\nFIX: " . $fixed;
case 'WCAG_2_4_3_AUTOFOCUS':
$fixed = preg_replace('/\s+autofocus(=["\'](.*?)["\'])?/i', '', $snippet) ?: $snippet;
return "EXPLANATION: Autofocus attribute removed to prevent screen reader interruption on load.\nFIX: " . $fixed;
case 'WCAG_1_3_1_TABLE_SUMMARY':
$fixed = preg_replace('/\s+summary=["\'](.*?)["\']/i', '', $snippet) ?: $snippet;
return "EXPLANATION: Deprecated table summary attribute removed. Use caption elements instead.\nFIX: " . $fixed;
case 'WCAG_2_1_1_SCROLLABLE_FOCUS':
if (!str_contains($snippet, 'tabindex')) {
$fixed = preg_replace('/(style=["\'](.*?)["\'])/i', '$1 tabindex="0"', $snippet) ?: $snippet;
return "EXPLANATION: Added tabindex=\"0\" to scrollable region to allow keyboard scrolling.\nFIX: " . $fixed;
}
break;
case 'WCAG_1_3_1_PRESENTATION_TAGS':
if (preg_match('/<center>(.*?)<\/center>/is', $snippet, $matches)) {
$fixed = '<div style="text-align: center;">' . $matches[1] . '</div>';
return "EXPLANATION: Center element replaced with div and text-align CSS.\nFIX: " . $fixed;
}
if (preg_match('/<strike>(.*?)<\/strike>/is', $snippet, $matches)) {
$fixed = '<span style="text-decoration: line-through;">' . $matches[1] . '</span>';
return "EXPLANATION: Strike element replaced with span and line-through decoration CSS.\nFIX: " . $fixed;
}
break;
case 'WCAG_3_1_1_LANG':
if (preg_match('/<html(\s+[^>]*>|>)/i', $snippet)) {
$fixed = preg_replace('/<html/i', '<html lang="en"', $snippet) ?: $snippet;
return "EXPLANATION: Injected default lang=\"en\" to HTML element.\nFIX: " . $fixed;
}
break;
}
return null;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class BladeParser implements ParserInterface
{
public function parse(string $content): string
{
// Replace {!! ... !!} with static text
$content = preg_replace('/\{\!\!(.*?)\!\!\}/s', 'StaticRawBladeContent', $content) ?? $content;
// Replace {{ ... }} with static text
$content = preg_replace('/\{\{\s*(.*?)\s*\}\}/s', 'StaticBladeContent', $content) ?? $content;
// Strip Blade directives like @if, @else, @endif, @foreach, @endforeach, etc.
$content = preg_replace('/@[a-zA-Z0-9_]+\s*(\(.*?\))?/i', '', $content) ?? $content;
return $content;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class HtmlParser implements ParserInterface
{
public function parse(string $content): string
{
return $content;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
interface ParserInterface
{
/**
* Parse template file contents and strip out dynamic content placeholders
* to return clean, standard HTML for scanning.
*
* @param string $content
* @return string
*/
public function parse(string $content): string;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class TwigParser implements ParserInterface
{
public function parse(string $content): string
{
// Strip Twig comments {# ... #}
$content = preg_replace('/\{\#.*?\#\}/s', '', $content) ?? $content;
// Replace Twig output {{ ... }} with static text
$content = preg_replace('/\{\{\s*(.*?)\s*\}\}/s', 'StaticTwigOutput', $content) ?? $content;
// Strip Twig statements {% ... %}
$content = preg_replace('/\{\%.*?\%\}/s', '', $content) ?? $content;
return $content;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagCriterion
{
public function __construct(
public string $id,
public string $title,
public string $level,
public string $url,
public string $description,
public string $badExample,
public string $goodExample
) {}
/**
* @return array{id: string, title: string, level: string, url: string, description: string, badExample: string, goodExample: string}
*/
public function toArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'level' => $this->level,
'url' => $this->url,
'description' => $this->description,
'badExample' => $this->badExample,
'goodExample' => $this->goodExample,
];
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagDatabase
{
/** @var array<string, WcagCriterion> */
private static array $criteria = [];
public static function load(): void
{
if (!empty(self::$criteria)) {
return;
}
self::add(new WcagCriterion(
id: 'WCAG_2_2_2_BLINK',
title: '2.2.2 Pause, Stop, Hide',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html',
description: 'The <blink> tag causes flashing text, presenting significant cognitive and visual accessibility barriers.',
badExample: '<blink>Blinking Text</blink>',
goodExample: '<span class="attention-style">Styled Text</span>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_DUPLICATE_H1',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: 'Pages should contain exactly one h1 element to present a clear hierarchy and entry point for screen readers.',
badExample: '<h1>Title</h1><h1>Another Title</h1>',
goodExample: '<h1>Title</h1><h2>Subtitle</h2>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_5_AUTOCOMPLETE',
title: '1.3.5 Identify Input Purpose',
level: 'AA',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html',
description: 'Inputs collecting user personal info must have autocomplete attributes to assist users with cognitive impairments.',
badExample: '<input type="email" name="email">',
goodExample: '<input type="email" name="email" autocomplete="email">'
));
self::add(new WcagCriterion(
id: 'WCAG_2_1_1_SCROLLABLE_FOCUS',
title: '2.1.1 Keyboard',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html',
description: 'Scrollable regions must be focusable using keyboard navigation (tabindex="0") so keyboard-only users can scroll them.',
badExample: '<div style="overflow: scroll; height: 100px;">Scrollable content</div>',
goodExample: '<div style="overflow: scroll; height: 100px;" tabindex="0">Scrollable content</div>'
));
self::add(new WcagCriterion(
id: 'WCAG_4_1_2_ARIA_LABELLEDBY',
title: '4.1.2 Name, Role, Value',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
description: 'aria-labelledby and aria-describedby attributes must refer to existing element IDs to prevent screen readers from reading empty contexts.',
badExample: '<button aria-labelledby="missing_id">Submit</button>',
goodExample: '<div id="btn_lbl">Submit Form</div><button aria-labelledby="btn_lbl">Submit</button>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_1_1_ALT_PLACEHOLDER',
title: '1.1.1 Non-text Content',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
description: 'Alt attributes must not contain placeholder words or file names. They should provide a meaningful description of the image content.',
badExample: '<img src="logo.png" alt="logo">',
goodExample: '<img src="logo.png" alt="YakNet Company Logo">'
));
self::add(new WcagCriterion(
id: 'WCAG_1_1_1_SVG',
title: '1.1.1 Non-text Content',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
description: 'Inline SVGs must either contain alternative descriptions or be hidden from assistive technologies using aria-hidden="true".',
badExample: '<svg><circle cx="5" cy="5" r="5"></svg>',
goodExample: '<svg aria-hidden="true"><circle cx="5" cy="5" r="5"></svg>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_TABLE_SUMMARY',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: 'The summary attribute on <table> is deprecated in HTML5. Use <caption> elements for tables description instead.',
badExample: '<table summary="Table Summary">',
goodExample: '<table><caption>Table Description</caption>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_PRESENTATION_TAGS',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: 'Obsolete presentation tags like <center> and <strike> are banned. Use standard CSS classes or styling.',
badExample: '<center>Centered Text</center>',
goodExample: '<span style="text-align: center; display: block;">Centered Text</span>'
));
self::add(new WcagCriterion(
id: 'WCAG_2_4_3_AUTOFOCUS',
title: '2.4.3 Focus Order',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html',
description: 'autofocus attribute can cause keyboard traps and disorient blind/low-vision users on page load. Focus should be managed contextually.',
badExample: '<input name="search" autofocus>',
goodExample: '<input name="search">'
));
}
public static function add(WcagCriterion $criterion): void
{
self::$criteria[$criterion->id] = $criterion;
}
public static function get(string $ruleId): ?WcagCriterion
{
self::load();
return self::$criteria[$ruleId] ?? null;
}
/**
* @return array<string, WcagCriterion>
*/
public static function all(): array
{
self::load();
return self::$criteria;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagReference
{
/**
* Look up the WCAG Success Criterion for a given rule ID.
*
* @param string $ruleId
* @return WcagCriterion|null
*/
public static function lookup(string $ruleId): ?WcagCriterion
{
return WcagDatabase::get($ruleId);
}
/**
* Get a list of all WCAG criteria mapped in the console.
*
* @return array<string, WcagCriterion>
*/
public static function getCatalog(): array
{
return WcagDatabase::all();
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class ConsoleFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
if (empty($violations)) {
$io->success("No violations found!");
if ($baselinedCount > 0) {
$io->comment("($baselinedCount violations ignored by baseline)");
}
return Command::SUCCESS;
}
// Group by file
$grouped = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$grouped[$file][] = $v;
}
// Sort files alphabetically for stable order
ksort($grouped);
foreach ($grouped as $file => $fileViolations) {
$io->section("File: " . $file);
// Sort violations by line number
usort($fileViolations, function ($a, $b) {
return ($a->location['line'] ?? 0) <=> ($b->location['line'] ?? 0);
});
foreach ($fileViolations as $v) {
$line = $v->location['line'] ?? 0;
$severityColor = 'red';
if ($v->severity->value === 'warning') {
$severityColor = 'yellow';
} elseif ($v->severity->value === 'info') {
$severityColor = 'cyan';
}
// Format the output
$io->writeln(sprintf(
" <fg=gray>Line %d:</> <fg=%s;options=bold>[%s]</> %s",
$line,
$severityColor,
$v->ruleId,
$v->message
));
// Show snippet
$snippet = (string)preg_replace('/\s+/', ' ', trim($v->htmlSnippet));
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($snippet) . "</>");
if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') {
$io->writeln(" <fg=gray>AI Suggestion:</> <fg=green>" . htmlspecialchars(trim($v->fixSuggestion)) . "</>");
}
$io->writeln("");
}
}
$totalViolations = count($violations);
$io->error(sprintf(
"Found %d %s across %d %s.",
$totalViolations,
$totalViolations === 1 ? 'violation' : 'violations',
count($grouped),
count($grouped) === 1 ? 'file' : 'files'
));
if ($baselinedCount > 0) {
$io->comment("($baselinedCount violations ignored by baseline)");
}
return Command::FAILURE;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class CsvFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$out = fopen('php://temp', 'w+');
if ($out === false) {
return Command::FAILURE;
}
// Write header
fputcsv($out, ['Rule ID', 'Severity', 'Standard', 'File', 'Line', 'Message', 'HTML Snippet', 'Fix Suggestion'], ',', '"', '\\');
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
fputcsv($out, [
$v->ruleId,
$v->severity->value,
$v->standard->value,
$file,
$line,
$v->message,
$v->htmlSnippet,
$v->fixSuggestion
], ',', '"', '\\');
}
rewind($out);
$csv = stream_get_contents($out);
fclose($out);
$io->write($csv ?: '');
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
interface FormatterInterface
{
/**
* Format and output the analysis violations.
*
* @param Violation[] $violations
* @param int $baselinedCount
* @param SymfonyStyle $io
* @return int Exit code (Command::SUCCESS or Command::FAILURE)
*/
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int;
}
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\Severity;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class GitLabCodeQualityFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$quality = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 1;
// Map our severity to GitLab code quality severities:
// info, minor, major, critical, blocker
$gitLabSeverity = 'major';
if ($v->severity === Severity::CRITICAL) {
$gitLabSeverity = 'critical';
} elseif ($v->severity === Severity::WARNING) {
$gitLabSeverity = 'minor';
}
$quality[] = [
'description' => sprintf('[%s] %s', $v->ruleId, $v->message),
'fingerprint' => md5($v->ruleId . ':' . $file . ':' . $line . ':' . $v->htmlSnippet),
'severity' => $gitLabSeverity,
'location' => [
'path' => $file,
'lines' => [
'begin' => $line
]
]
];
}
$io->write((string)json_encode($quality, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class GithubFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
if (empty($violations)) {
return Command::SUCCESS;
}
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
$line = $v->location['line'] ?? 1;
// Normalize path separators to forward slashes for Github Actions compatibility
$file = str_replace('\\', '/', $file);
$type = ($v->severity->value === 'error') ? 'error' : 'warning';
$io->writeln(sprintf(
"::%s file=%s,line=%d::[%s] %s",
$type,
$file,
$line,
$v->ruleId,
$v->message
));
}
return Command::FAILURE;
}
}
+2 -2
View File
@@ -96,7 +96,7 @@ class HtmlDashboardReporter
preg_match('/EXPLANATION:(.*)FIX:(.*)/s', $aiSuggestion, $matches);
$explanation = trim($matches[1] ?? '');
$fixedCode = trim($matches[2] ?? '');
$fixedCode = preg_replace('/^```html\s*|\s*```$/i', '', $fixedCode);
$fixedCode = preg_replace('/^```html\s*|\s*```$/i', '', $fixedCode) ?? '';
} else {
$fixedCode = $aiSuggestion;
}
@@ -104,7 +104,7 @@ class HtmlDashboardReporter
$escapedSnippet = htmlspecialchars($v->htmlSnippet, ENT_QUOTES, 'UTF-8');
$escapedFixed = $fixedCode ? htmlspecialchars($fixedCode, ENT_QUOTES, 'UTF-8') : '';
$escapedExplanation = $explanation ? htmlspecialchars($explanation, ENT_QUOTES, 'UTF-8') : '';
$locationStr = $v->location ? htmlspecialchars($v->location['file'] . ':' . $v->location['line'], ENT_QUOTES, 'UTF-8') : 'N/A';
$locationStr = $v->location ? htmlspecialchars($v->location['file'] . ':' . (string)$v->location['line'], ENT_QUOTES, 'UTF-8') : 'N/A';
$escapedRuleId = htmlspecialchars($v->ruleId, ENT_QUOTES, 'UTF-8');
$escapedMessage = htmlspecialchars($v->message, ENT_QUOTES, 'UTF-8');
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class JsonFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
$result = [
'totals' => [
'violations' => count($violations),
'baselined' => $baselinedCount
],
'violations' => $serialized
];
$io->write((string)json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class MarkdownFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$md = [];
$md[] = "# Accessibility Scan Report";
$md[] = "";
$md[] = sprintf("- **Total Violations:** %d", count($violations));
$md[] = sprintf("- **Baselined Violations:** %d", $baselinedCount);
$md[] = "";
$md[] = "## Detailed Violations";
$md[] = "";
if (empty($violations)) {
$md[] = "*No violations found!* 🎉";
} else {
foreach ($violations as $index => $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
$md[] = sprintf("### %d. %s [%s - %s]", $index + 1, $v->ruleId, strtoupper($v->severity->value), $v->standard->value);
$md[] = sprintf("- **File:** `%s` on line %d", $file, $line);
$md[] = sprintf("- **Message:** %s", $v->message);
if ($v->fixSuggestion) {
$md[] = sprintf("- **Fix Suggestion:** %s", $v->fixSuggestion);
}
$md[] = "- **Snippet:**";
$md[] = " ```html";
$md[] = " " . trim($v->htmlSnippet);
$md[] = " ```";
$md[] = "";
}
}
$io->write(implode("\n", $md));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class XmlFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$dom = new \DOMDocument('1.0', 'utf-8');
$dom->formatOutput = true;
$root = $dom->createElement('report');
$root->setAttribute('totalViolations', (string)count($violations));
$root->setAttribute('baselinedViolations', (string)$baselinedCount);
$dom->appendChild($root);
$violationsNode = $dom->createElement('violations');
$root->appendChild($violationsNode);
foreach ($violations as $v) {
$node = $dom->createElement('violation');
$node->setAttribute('ruleId', $v->ruleId);
$node->setAttribute('severity', $v->severity->value);
$node->setAttribute('standard', $v->standard->value);
$msg = $dom->createElement('message', htmlspecialchars($v->message));
$node->appendChild($msg);
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
$loc = $dom->createElement('location');
$loc->setAttribute('file', $file);
$loc->setAttribute('line', (string)$line);
$node->appendChild($loc);
$snippet = $dom->createElement('htmlSnippet');
$snippet->appendChild($dom->createCDATASection($v->htmlSnippet));
$node->appendChild($snippet);
if ($v->fixSuggestion) {
$sug = $dom->createElement('suggestion', htmlspecialchars($v->fixSuggestion));
$node->appendChild($sug);
}
$violationsNode->appendChild($node);
}
$io->write($dom->saveXML() ?: '');
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AccesskeyBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_ACCESSKEY'; }
public function getDescription(): string { return 'The accesskey attribute should not be used as it conflicts with browser and screen reader shortcuts.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if ($element->hasAttribute('accesskey')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the accesskey attribute. Use natural focus order and standard keyboard event listeners instead.'
);
}
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AreaAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_AreaAlt'; }
public function getDescription(): string { return 'Checks that <area> elements in image maps have non-empty alt attributes.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'area') {
return null;
}
$alt = $element->getAttribute('alt');
$ariaLabel = $element->getAttribute('aria-label');
if (empty(trim($alt)) && empty(trim($ariaLabel))) {
return $this->createViolation(
$element,
'<area> öğesinde alt veya aria-label eksik.',
'Provide a non-empty alt or aria-label attribute for the <area> element.'
);
}
return null;
}
}
+12
View File
@@ -57,6 +57,18 @@ class AriaHiddenFocusable extends AbstractRule
return $type !== 'hidden' && !$element->hasAttribute('disabled');
}
if ($element->hasAttribute('contenteditable') && strtolower($element->getAttribute('contenteditable')) === 'true') {
return true;
}
if (in_array($tagName, ['details', 'summary'], true)) {
return true;
}
if (in_array($tagName, ['audio', 'video'], true) && $element->hasAttribute('controls')) {
return true;
}
if ($tagName === 'iframe') {
return true;
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaLabelledbyExists extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_LABELLEDBY'; }
public function getDescription(): string { return 'aria-labelledby and aria-describedby attributes must refer to existing element IDs.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$attrs = ['aria-labelledby', 'aria-describedby'];
foreach ($attrs as $attr) {
if ($element->hasAttribute($attr)) {
$ids = preg_split('/\s+/', trim($element->getAttribute($attr)));
if ($ids === false) {
continue;
}
$doc = $element->ownerDocument;
if (!$doc) {
continue;
}
foreach ($ids as $id) {
if ($id === '') {
continue;
}
$target = $doc->getElementById($id);
if (!$target) {
// XPath fallback in case DOMDocument getElementById fails (which is common without DTD/XML schemas)
$xpath = new \DOMXPath($doc);
$query = $xpath->query("//*[@id='" . addslashes($id) . "']");
if ($query === false || $query->length === 0) {
return $this->createViolation(
$element,
"The attribute $attr refers to a non-existent ID: \"$id\".",
"Ensure the element referenced by ID \"$id\" exists in the document, or correct the ID spelling."
);
}
}
}
}
}
return null;
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredAttr extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_REQUIRED_ATTR'; }
public function getDescription(): string { return 'Elements with ARIA roles must have all required ARIA attributes for that role.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredMap = [
'slider' => ['aria-valuenow', 'aria-valuemin', 'aria-valuemax'],
'checkbox' => ['aria-checked'],
'combobox' => ['aria-expanded'],
'scrollbar' => ['aria-controls', 'aria-valuenow', 'aria-valuemax', 'aria-valuemin', 'aria-orientation'],
'spinbutton' => ['aria-valuenow', 'aria-valuemax', 'aria-valuemin'],
'switch' => ['aria-checked'],
'meter' => ['aria-valuenow'],
'separator' => ['aria-valuenow'] // strictly when focusable, but we check if the role is present
];
foreach ($roles as $role) {
if (isset($requiredMap[$role])) {
$missingAttrs = [];
foreach ($requiredMap[$role] as $reqAttr) {
if (!$element->hasAttribute($reqAttr)) {
$missingAttrs[] = $reqAttr;
}
}
if (!empty($missingAttrs)) {
// Specific case: separator without tabindex is not focusable and doesn't require aria-valuenow in some contexts
if ($role === 'separator' && !$element->hasAttribute('tabindex')) {
continue;
}
$missingStr = implode(', ', $missingAttrs);
return $this->createViolation(
$element,
"ARIA role '{$role}' eksik zorunlu niteliklere sahip: {$missingStr}.",
"Belirtilen ARIA rolü için zorunlu olan {$missingStr} niteliklerini ekleyin."
);
}
}
}
return null;
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredChildren extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_CHILDREN'; }
public function getDescription(): string { return 'Elements with certain ARIA roles must contain children with specific roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredChildrenMap = [
'list' => ['listitem'],
'menu' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
'menubar' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
'tablist' => ['tab'],
'tree' => ['treeitem'],
'grid' => ['row', 'rowgroup'],
'table' => ['row', 'rowgroup'],
'radiogroup' => ['radio'],
'listbox' => ['option'],
'rowgroup' => ['row'],
'row' => ['cell', 'gridcell', 'columnheader', 'rowheader']
];
foreach ($roles as $role) {
if (isset($requiredChildrenMap[$role])) {
$hasRequiredChild = $this->hasChildWithRole($element, $requiredChildrenMap[$role]);
if (!$hasRequiredChild) {
// Check if children are dynamically generated with templates
$doc = $element->ownerDocument;
if ($doc !== null) {
$html = $doc->saveHTML($element);
if ($html !== false && preg_match('/(\{\{|\{%|<\?)/', $html)) {
continue;
}
}
$expectedStr = implode(' veya ', $requiredChildrenMap[$role]);
return $this->createViolation(
$element,
"ARIA rolü '{$role}' olan eleman gerekli alt rollere sahip değil.",
"Bu elemanın içine rolü {$expectedStr} olan çocuk elemanlar ekleyin."
);
}
}
}
return null;
}
/**
* @param array<int, string> $requiredRoles
*/
private function hasChildWithRole(DOMElement $element, array $requiredRoles): bool
{
$doc = $element->ownerDocument;
if ($doc === null) {
return false;
}
$xpath = new \DOMXPath($doc);
foreach ($requiredRoles as $reqRole) {
$query = ".//*[@role='{$reqRole}' or contains(@role, ' {$reqRole}') or contains(@role, '{$reqRole} ')]";
$nodes = $xpath->query($query, $element);
if ($nodes !== false && $nodes->length > 0) {
return true;
}
}
return false;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRequiredParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_PARENT'; }
public function getDescription(): string { return 'Elements with certain ARIA roles must be contained by parents with specific roles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
$requiredParentMap = [
'listitem' => ['list', 'group'],
'menuitem' => ['menu', 'menubar', 'group'],
'menuitemcheckbox' => ['menu', 'menubar'],
'menuitemradio' => ['menu', 'menubar'],
'tab' => ['tablist'],
'treeitem' => ['tree', 'group'],
'cell' => ['row'],
'gridcell' => ['row'],
'columnheader' => ['row'],
'rowheader' => ['row'],
'option' => ['listbox', 'group'],
'row' => ['grid', 'table', 'treegrid', 'rowgroup']
];
foreach ($roles as $role) {
if (isset($requiredParentMap[$role])) {
$hasRequiredParent = $this->hasParentWithRole($element, $requiredParentMap[$role]);
if (!$hasRequiredParent) {
$expectedStr = implode(' veya ', $requiredParentMap[$role]);
return $this->createViolation(
$element,
"ARIA rolü '{$role}' olan eleman gerekli üst eleman rollerinden hiçbirine sahip değil.",
"Bu elemanı rolü {$expectedStr} olan bir üst elemanın içine yerleştirin."
);
}
}
}
return null;
}
/**
* @param array<int, string> $requiredRoles
*/
private function hasParentWithRole(DOMElement $element, array $requiredRoles): bool
{
$parent = $element->parentNode;
while ($parent !== null && $parent instanceof DOMElement) {
if ($parent->hasAttribute('role')) {
$parentRoles = explode(' ', strtolower(trim($parent->getAttribute('role'))));
foreach ($parentRoles as $pRole) {
if (in_array($pRole, $requiredRoles)) {
return true;
}
}
}
// For native HTML semantics fallback (e.g. <ul> is a list)
$tagName = strtolower($parent->tagName);
$nativeRole = $this->getNativeRole($tagName);
if ($nativeRole && in_array($nativeRole, $requiredRoles)) {
return true;
}
$parent = $parent->parentNode;
}
return false;
}
private function getNativeRole(string $tagName): ?string
{
$map = [
'ul' => 'list',
'ol' => 'list',
'dl' => 'list',
'table' => 'table',
'tr' => 'row',
'tbody' => 'rowgroup',
'thead' => 'rowgroup',
'tfoot' => 'rowgroup',
'select' => 'listbox',
'optgroup' => 'group'
];
return $map[$tagName] ?? null;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaValidAttr extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR'; }
public function getDescription(): string { return 'ARIA attributes must be valid names.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$validAttributes = [
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel',
'aria-brailleroledescription', 'aria-busy', 'aria-checked', 'aria-colcount',
'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-controls',
'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-expanded',
'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live',
'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation',
'aria-owns', 'aria-placeholder', 'aria-posinset', 'aria-pressed', 'aria-readonly',
'aria-relevant', 'aria-required', 'aria-roledescription', 'aria-rowcount',
'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected',
'aria-setsize', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow',
'aria-valuetext'
];
$invalidAttrs = [];
foreach ($element->attributes as $attr) {
/** @var \DOMAttr $attr */
$name = strtolower($attr->nodeName);
if (strpos($name, 'aria-') === 0) {
if (!in_array($name, $validAttributes)) {
$invalidAttrs[] = $name;
}
}
}
if (!empty($invalidAttrs)) {
$invalidStr = implode(', ', $invalidAttrs);
return $this->createViolation(
$element,
"Geçersiz ARIA nitelikleri bulundu: {$invalidStr}.",
"Sadece geçerli ARIA niteliklerini kullanın. Geçersiz olanları kaldırın veya düzeltin."
);
}
return null;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaValidAttrValue extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR_VALUE'; }
public function getDescription(): string { return 'ARIA attributes must contain valid values.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$validationMap = [
'aria-hidden' => ['true', 'false'],
'aria-live' => ['off', 'polite', 'assertive'],
'aria-expanded' => ['true', 'false', 'undefined'],
'aria-pressed' => ['true', 'false', 'mixed', 'undefined'],
'aria-checked' => ['true', 'false', 'mixed', 'undefined'],
'aria-current' => ['page', 'step', 'location', 'date', 'time', 'true', 'false'],
'aria-sort' => ['ascending', 'descending', 'none', 'other'],
'aria-orientation' => ['horizontal', 'vertical', 'undefined'],
'aria-haspopup' => ['true', 'false', 'menu', 'listbox', 'tree', 'grid', 'dialog'],
'aria-invalid' => ['grammar', 'false', 'spelling', 'true'],
'aria-autocomplete' => ['inline', 'list', 'both', 'none'],
'aria-dropeffect' => ['copy', 'execute', 'link', 'move', 'none', 'popup']
];
for ($i = 0; $i < $element->attributes->length; $i++) {
$attr = $element->attributes->item($i);
if (!$attr instanceof \DOMAttr) {
continue;
}
$name = strtolower($attr->nodeName);
if (isset($validationMap[$name])) {
$value = strtolower(trim($attr->nodeValue ?? ''));
// Handle templates like {{ value }} or template syntax
if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $value) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $value)) {
continue;
}
if (!in_array($value, $validationMap[$name])) {
$validStr = implode(', ', $validationMap[$name]);
return $this->createViolation(
$element,
"{$name} niteliği geçersiz bir değere sahip: '{$value}'.",
"{$name} niteliğinin değerini geçerli olanlardan biriyle değiştirin: {$validStr}."
);
}
} elseif ($name === 'aria-relevant') {
$values = explode(' ', strtolower(trim($attr->nodeValue ?? '')));
$validRelevant = ['additions', 'removals', 'text', 'all'];
$rawValue = $attr->nodeValue ?? '';
$isTemplate = preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', trim($rawValue)) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', trim($rawValue));
if (!$isTemplate) {
foreach ($values as $val) {
if (!empty($val) && !in_array($val, $validRelevant)) {
return $this->createViolation(
$element,
"aria-relevant niteliği geçersiz bir değer içeriyor: '{$val}'.",
"aria-relevant değerlerini sadece additions, removals, text, all kelimelerinden oluşacak şekilde ayarlayın."
);
}
}
}
}
}
return null;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AudioTrack extends AbstractRule
{
public function getId(): string { return 'WCAG_1_2_1_AudioTrack'; }
public function getDescription(): string { return 'Checks that <audio> elements have a <track> child element for captions/descriptions.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'audio') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$tracks = $xpath->query('./track', $element);
if ($tracks === false || $tracks->length === 0) {
return $this->createViolation(
$element,
'<audio> öğesi <track> alt öğesi içermiyor.',
'Add a <track> element for captions or provide an accessible transcript.'
);
}
return null;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutocompleteValid extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_AUTOCOMPLETE'; }
public function getDescription(): string { return 'Input fields that collect common user information must have a valid autocomplete attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
$type = strtolower($element->getAttribute('type') ?: 'text');
if (in_array($type, ['hidden', 'submit', 'button', 'reset', 'file', 'checkbox', 'radio', 'image'], true)) {
return null;
}
$name = strtolower($element->getAttribute('name'));
$id = strtolower($element->getAttribute('id'));
$personalFields = ['email', 'tel', 'phone', 'zip', 'postal', 'address', 'username', 'password', 'name', 'fname', 'lname', 'search'];
$isPersonal = false;
if ($type === 'email' || $type === 'tel' || $type === 'password') {
$isPersonal = true;
} else {
foreach ($personalFields as $field) {
if (str_contains($name, $field) || str_contains($id, $field)) {
$isPersonal = true;
break;
}
}
}
if ($isPersonal && !$element->hasAttribute('autocomplete')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a valid autocomplete attribute (e.g., autocomplete="email" or autocomplete="current-password").'
);
}
return null;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutocompleteValidTokens extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_AUTOCOMPLETE_VALID_TOKENS'; }
public function getDescription(): string { return 'Autocomplete attribute must use valid token values.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['input', 'select', 'textarea'])) {
return null;
}
if (!$element->hasAttribute('autocomplete')) {
return null;
}
$autocompleteValue = trim($element->getAttribute('autocomplete'));
if ($autocompleteValue === '') {
return null;
}
// Handle templates like {{ value }} or template syntax
if (preg_match('/^(\{\{|\{%).*(\}\}|\%\})$/', $autocompleteValue) || preg_match('/^' . preg_quote('<' . '?', '/') . '.*' . preg_quote('?' . '>', '/') . '$/', $autocompleteValue)) {
return null;
}
$tokens = explode(' ', strtolower($autocompleteValue));
$validBaseTokens = [
'off', 'on', 'name', 'honorific-prefix', 'given-name', 'additional-name',
'family-name', 'honorific-suffix', 'nickname', 'email', 'username',
'new-password', 'current-password', 'one-time-code', 'organization-title',
'organization', 'street-address', 'address-line1', 'address-line2',
'address-line3', 'address-level4', 'address-level3', 'address-level2',
'address-level1', 'country', 'country-name', 'postal-code', 'cc-name',
'cc-given-name', 'cc-additional-name', 'cc-family-name', 'cc-number',
'cc-exp', 'cc-exp-month', 'cc-exp-year', 'cc-csc', 'cc-type',
'transaction-currency', 'transaction-amount', 'language', 'bday',
'bday-day', 'bday-month', 'bday-year', 'sex', 'tel', 'tel-country-code',
'tel-national', 'tel-area-code', 'tel-local', 'tel-extension', 'impp',
'url', 'photo'
];
foreach ($tokens as $token) {
if (empty($token)) continue;
// Allow section-* prefixes
if (strpos($token, 'section-') === 0) {
continue;
}
// Allow shipping/billing prefixes
if ($token === 'shipping' || $token === 'billing') {
continue;
}
// Check for valid base tokens
// Additional check: some tokens like 'tel' can be prefixed, we handle it simply by checking if it's in the list
$isValid = false;
foreach ($validBaseTokens as $baseToken) {
if ($token === $baseToken || str_ends_with($token, "-{$baseToken}")) {
$isValid = true;
break;
}
}
if (!$isValid) {
return $this->createViolation(
$element,
"Geçersiz autocomplete değeri veya belirteci: '{$token}'.",
"Geçerli bir HTML5 autocomplete belirteci kullanın (örneğin: name, email, tel)."
);
}
}
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AutofocusBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_3_AUTOFOCUS'; }
public function getDescription(): string { return 'Form controls should not use the autofocus attribute as it disorients screen reader and keyboard users.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if ($element->hasAttribute('autofocus')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the autofocus attribute and let the user navigate to the form field naturally, or use script-based focus with care.'
);
}
return null;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use Symfony\Component\DomCrawler\Crawler;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class BannedSelectors extends AbstractRule
{
/** @var array<string, string> */
private array $bannedSelectors = [
'center' => 'Obsolete <center> tag. Use CSS text-align instead.',
'blink' => 'Obsolete <blink> tag. Avoid blinking/flashing content.',
'marquee' => 'Obsolete <marquee> tag. Avoid moving content.',
'font' => 'Obsolete <font> tag. Use CSS font styling instead.',
'*[align]' => 'Deprecated align attribute. Use CSS text-align or float instead.',
'*[bgcolor]' => 'Deprecated bgcolor attribute. Use CSS background-color instead.',
];
public function check(\DOMDocument $doc): array
{
$violations = [];
$crawler = new Crawler($doc);
foreach ($this->bannedSelectors as $selector => $message) {
try {
$nodes = $crawler->filter($selector);
foreach ($nodes as $node) {
if ($node instanceof \DOMElement) {
$violations[] = $this->createViolation(
sprintf('[Banned Selector: "%s"] %s', $selector, $message),
$node
);
}
}
} catch (\Throwable $e) {
// Skip invalid selector configurations
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class BlinkTagBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_2_2_BLINK'; }
public function getDescription(): string { return 'The <blink> tag is obsolete and should not be used as it causes text to flash/blink.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) === 'blink') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the <blink> tag and use CSS styling/animations if needed, or preferably avoid blinking content.'
);
}
return null;
}
}
+62 -4
View File
@@ -21,11 +21,69 @@ class ButtonName extends AbstractRule
return null;
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
return null;
$text = trim($element->textContent);
if ($text !== '') {
return null;
}
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
if ($element->hasAttribute('aria-labelledby')) {
$id = $element->getAttribute('aria-labelledby');
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//*[@id='$id']");
if ($labels !== false && $labels->length > 0) {
$labelNode = $labels->item(0);
if ($labelNode instanceof \DOMNode && trim($labelNode->textContent) !== '') {
return null;
}
}
}
}
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
}
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class CanvasAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_CanvasAlt'; }
public function getDescription(): string { return 'Checks that <canvas> elements have fallback content or aria labels.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'canvas') {
return null;
}
$hasAriaLabel = !empty($element->getAttribute('aria-label'));
$hasAriaLabelledby = !empty($element->getAttribute('aria-labelledby'));
$hasRoleImgLabel = ($element->getAttribute('role') === 'img') && $hasAriaLabel;
if ($hasAriaLabel || $hasAriaLabelledby || $hasRoleImgLabel) {
return null;
}
$content = trim($element->textContent);
$hasChildElements = false;
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$hasChildElements = true;
break;
}
}
if ($content === '' && !$hasChildElements) {
return $this->createViolation(
$element,
'<canvas> öğesi alternatif içerik veya etiket içermiyor.',
'Provide fallback content inside the <canvas> tag or use aria-label/aria-labelledby.'
);
}
return null;
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ClickHandlerKeyboard extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_CLICK_KEYBOARD'; }
public function getDescription(): string { return 'Non-interactive elements with click handlers should also have keyboard handlers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('onclick')) {
return null;
}
$tagName = strtolower($element->tagName);
$interactiveElements = ['a', 'button', 'input', 'select', 'textarea', 'summary', 'details'];
if (in_array($tagName, $interactiveElements)) {
return null;
}
$role = strtolower($element->getAttribute('role'));
if (($role === 'button' || $role === 'link') && $element->hasAttribute('tabindex')) {
return null;
}
if ($element->hasAttribute('onkeydown') || $element->hasAttribute('onkeyup') || $element->hasAttribute('onkeypress')) {
return null;
}
return $this->createViolation(
$element,
'onclick olay işleyicisine sahip etkileşimli olmayan öğenin klavye işleyicisi (onkeydown vb.) eksik. / Non-interactive element with onclick is missing a keyboard handler.',
'Klavye desteği için onkeydown/onkeyup ekleyin ve tabindex="0" tanımlayın. / Add onkeydown/onkeyup handlers and tabindex="0" for keyboard support.'
);
}
}
+268 -34
View File
@@ -9,6 +9,9 @@ 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; }
@@ -17,14 +20,27 @@ class ColorContrast extends AbstractRule
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');
$style = $element->getAttribute('style');
$color = $this->parseColorValue($style, 'color');
$bg = $this->parseColorValue($style, 'background-color') ?: $this->parseColorValue($style, 'background');
// 2. Fallback: Parse stylesheet styles
if (($color === null || $bg === null) && $element->ownerDocument !== null) {
$docStyles = $this->parseDocumentStyles($element->ownerDocument);
// Loop through all parsed styles. Later matching rules override earlier ones
foreach ($docStyles as $selector => $styles) {
if ($this->elementMatchesSelector($element, $selector)) {
if ($color === null && isset($styles['color'])) {
$color = $styles['color'];
}
if ($bg === null && isset($styles['background-color'])) {
$bg = $styles['background-color'];
}
}
}
}
if ($color === null || $bg === null) {
return null;
@@ -42,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;
@@ -74,21 +79,250 @@ class ColorContrast extends AbstractRule
return null;
}
private function parseFontSize(string $style, DOMElement $element): ?string
{
if (preg_match('/font-size\s*:\s*([^;]+)/i', $style, $matches)) {
return trim($matches[1]);
}
return null;
}
private function parseFontWeight(string $style, DOMElement $element): ?string
{
if (preg_match('/font-weight\s*:\s*([^;]+)/i', $style, $matches)) {
return trim($matches[1]);
}
return null;
}
private function isLargeText(string $size, ?string $weight): bool
{
if (preg_match('/([0-9.]+)(px|em|rem|pt)/i', $size, $matches)) {
$val = (float)$matches[1];
$unit = strtolower($matches[2]);
$isBold = $weight !== null && preg_match('/^(bold|[7-9]00)$/i', $weight);
if ($isBold) {
return ($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2);
} else {
return ($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5);
}
}
return false;
}
/**
* @return array<string, array{color?: string, background-color?: string}>
*/
private function parseDocumentStyles(\DOMDocument $doc): array
{
$hash = spl_object_hash($doc);
if (isset($this->documentStylesCache[$hash])) {
return $this->documentStylesCache[$hash];
}
$mergedStyles = [];
$styleTags = $doc->getElementsByTagName('style');
foreach ($styleTags as $tag) {
$cssText = $tag->textContent;
if (trim($cssText) === '') {
continue;
}
try {
$parser = new \Sabberworm\CSS\Parser($cssText);
$cssDoc = $parser->parse();
foreach ($cssDoc->getAllDeclarationBlocks() as $block) {
$rules = [];
foreach ($block->getDeclarations() as $decl) {
$prop = strtolower($decl->getPropertyName());
if ($prop === 'color' || $prop === 'background-color' || $prop === 'background') {
// Extract string value
$valObj = $decl->getValue();
$val = is_object($valObj) && method_exists($valObj, '__toString')
? (string)$valObj
: (is_string($valObj) ? $valObj : '');
if ($prop === 'color') {
$normalized = $this->normalizeColorValue($val);
if ($normalized !== null) {
$rules['color'] = $normalized;
}
} elseif ($prop === 'background-color') {
$normalized = $this->normalizeColorValue($val);
if ($normalized !== null) {
$rules['background-color'] = $normalized;
}
} elseif ($prop === 'background') {
$normalized = $this->extractColorFromBackground($val);
if ($normalized !== null) {
$rules['background-color'] = $normalized;
}
}
}
}
if (empty($rules)) {
continue;
}
foreach ($block->getSelectors() as $selector) {
$selectorString = trim($selector->getSelector());
if ($selectorString !== '') {
if (!isset($mergedStyles[$selectorString])) {
$mergedStyles[$selectorString] = [];
}
$mergedStyles[$selectorString] = array_merge($mergedStyles[$selectorString], $rules);
}
}
}
} catch (\Throwable $e) {
// Ignore CSS parser failures
}
}
$this->documentStylesCache[$hash] = $mergedStyles;
return $mergedStyles;
}
private function elementMatchesSelector(DOMElement $element, string $selector): bool
{
$parts = array_map('trim', explode(',', $selector));
foreach ($parts as $part) {
if ($part === '') {
continue;
}
if (str_starts_with($part, '#')) {
$id = substr($part, 1);
if ($element->getAttribute('id') === $id) {
return true;
}
continue;
}
if (str_starts_with($part, '.')) {
$class = substr($part, 1);
$classes = array_filter(array_map('trim', explode(' ', $element->getAttribute('class'))));
if (in_array($class, $classes, true)) {
return true;
}
continue;
}
if (strtolower($element->tagName) === strtolower($part)) {
return true;
}
if (preg_match('/^([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)$/', $part, $matches)) {
$tag = $matches[1];
$class = $matches[2];
$classes = array_filter(array_map('trim', explode(' ', $element->getAttribute('class'))));
if (strtolower($element->tagName) === strtolower($tag) && in_array($class, $classes, true)) {
return true;
}
}
if (preg_match('/^([a-zA-Z0-9_-]+)#([a-zA-Z0-9_-]+)$/', $part, $matches)) {
$tag = $matches[1];
$id = $matches[2];
if (strtolower($element->tagName) === strtolower($tag) && $element->getAttribute('id') === $id) {
return true;
}
}
}
return false;
}
private function normalizeColorValue(string $val): ?string
{
$val = trim($val);
if (preg_match('/^#([0-9a-fA-F]{3,6})$/', $val)) {
return $val;
}
if (preg_match('/rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)/i', $val, $matches)) {
return sprintf("#%02x%02x%02x", (int)$matches[1], (int)$matches[2], (int)$matches[3]);
}
$colors = [
'aliceblue' => '#f0f8ff', 'antiquewhite' => '#faebd7', 'aqua' => '#00ffff',
'aquamarine' => '#7fffd4', 'azure' => '#f0ffff', 'beige' => '#f5f5dc',
'bisque' => '#ffe4c4', 'black' => '#000000', 'blanchedalmond' => '#ffebcd',
'blue' => '#0000ff', 'blueviolet' => '#8a2be2', 'brown' => '#a52a2a',
'burlywood' => '#deb887', 'cadetblue' => '#5f9ea0', 'chartreuse' => '#7fff00',
'chocolate' => '#d2691e', 'coral' => '#ff7f50', 'cornflowerblue' => '#6495ed',
'cornsilk' => '#fff8dc', 'crimson' => '#dc143c', 'cyan' => '#00ffff',
'darkblue' => '#00008b', 'darkcyan' => '#008b8b', 'darkgoldenrod' => '#b8860b',
'darkgray' => '#a9a9a9', 'darkgreen' => '#006400', 'darkgrey' => '#a9a9a9',
'darkkhaki' => '#bdb76b', 'darkmagenta' => '#8b008b', 'darkolivegreen' => '#556b2f',
'darkorange' => '#ff8c00', 'darkorchid' => '#9932cc', 'darkred' => '#8b0000',
'darksalmon' => '#e9967a', 'darkseagreen' => '#8fbc8f', 'darkslateblue' => '#483d8b',
'darkslategray' => '#2f4f4f', 'darkslategrey' => '#2f4f4f', 'darkturquoise' => '#00ced1',
'darkviolet' => '#9400d3', 'deeppink' => '#ff1493', 'deepskyblue' => '#00bfff',
'dimgray' => '#696969', 'dimgrey' => '#696969', 'dodgerblue' => '#1e90ff',
'firebrick' => '#b22222', 'floralwhite' => '#fffaf0', 'forestgreen' => '#228b22',
'fuchsia' => '#ff00ff', 'gainsboro' => '#dcdcdc', 'ghostwhite' => '#f8f8ff',
'gold' => '#ffd700', 'goldenrod' => '#daa520', 'gray' => '#808080',
'green' => '#008000', 'greenyellow' => '#adff2f', 'grey' => '#808080',
'honeydew' => '#f0fff0', 'hotpink' => '#ff69b4', 'indianred' => '#cd5c5c',
'indigo' => '#4b0082', 'ivory' => '#fffff0', 'khaki' => '#f0e68c',
'lavender' => '#e6e6fa', 'lavenderblush' => '#fff0f5', 'lawngreen' => '#7cfc00',
'lemonchiffon' => '#fffacd', 'lightblue' => '#add8e6', 'lightcoral' => '#f08080',
'lightcyan' => '#e0ffff', 'lightgoldenrodyellow' => '#fafad2', 'lightgray' => '#d3d3d3',
'lightgreen' => '#90ee90', 'lightgrey' => '#d3d3d3', 'lightpink' => '#ffb6c1',
'lightsalmon' => '#ffa07a', 'lightseagreen' => '#20b2aa', 'lightskyblue' => '#87cefa',
'lightslategray' => '#778899', 'lightslategrey' => '#778899', 'lightsteelblue' => '#b0c4de',
'lightyellow' => '#ffffe0', 'lime' => '#00ff00', 'limegreen' => '#32cd32',
'linen' => '#faf0e6', 'magenta' => '#ff00ff', 'maroon' => '#800000',
'mediumaquamarine' => '#66cdaa', 'mediumblue' => '#0000cd', 'mediumorchid' => '#ba55d3',
'mediumpurple' => '#9370db', 'mediumseagreen' => '#3cb371', 'mediumslateblue' => '#7b68ee',
'mediumspringgreen' => '#00fa9a', 'mediumturquoise' => '#48d1cc', 'mediumvioletred' => '#c71585',
'midnightblue' => '#191970', 'mintcream' => '#f5fffa', 'mistyrose' => '#ffe4e1',
'moccasin' => '#ffe4b5', 'navajowhite' => '#ffdead', 'navy' => '#000080',
'oldlace' => '#fdf5e6', 'olive' => '#808000', 'olivedrab' => '#6b8e23',
'orange' => '#ffa500', 'orangered' => '#ff4500', 'orchid' => '#da70d6',
'palegoldenrod' => '#eee8aa', 'palegreen' => '#98fb98', 'paleturquoise' => '#afeeee',
'palevioletred' => '#db7093', 'papayawhip' => '#ffefd5', 'peachpuff' => '#ffdab9',
'peru' => '#cd853f', 'pink' => '#ffc0cb', 'plum' => '#dda0dd',
'powderblue' => '#b0e0e6', 'purple' => '#800080', 'rebeccapurple' => '#663399',
'red' => '#ff0000', 'rosybrown' => '#bc8f8f', 'royalblue' => '#4169e1',
'saddlebrown' => '#8b4513', 'salmon' => '#fa8072', 'sandybrown' => '#f4a460',
'seagreen' => '#2e8b57', 'seashell' => '#fff5ee', 'sienna' => '#a0522d',
'silver' => '#c0c0c0', 'skyblue' => '#87ceeb', 'slateblue' => '#6a5acd',
'slategray' => '#708090', 'slategrey' => '#708090', 'snow' => '#fffafa',
'springgreen' => '#00ff7f', 'steelblue' => '#4682b4', 'tan' => '#d2b48c',
'teal' => '#008080', 'thistle' => '#d8bfd8', 'tomato' => '#ff6347',
'turquoise' => '#40e0d0', 'violet' => '#ee82ee', 'wheat' => '#f5deb3',
'white' => '#ffffff', 'whitesmoke' => '#f5f5f5', 'yellow' => '#ffff00',
'yellowgreen' => '#9acd32',
];
if (isset($colors[strtolower($val)])) {
return $colors[strtolower($val)];
}
return null;
}
private function extractColorFromBackground(string $val): ?string
{
$parts = explode(' ', $val);
foreach ($parts as $part) {
$color = $this->normalizeColorValue($part);
if ($color !== null) {
return $color;
}
}
return null;
}
private function parseColorValue(string $style, string $property): ?string
{
if (preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:\s*([^;]+)/i', $style, $matches)) {
$value = trim($matches[1]);
if (preg_match('/^#([0-9a-f]{3,6})$/i', $value)) {
return $value;
}
$colors = [
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
];
if (isset($colors[strtolower($value)])) {
return $colors[strtolower($value)];
}
return $this->normalizeColorValue(trim($matches[1]));
}
return null;
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DefinitionListStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_DefinitionListStructure'; }
public function getDescription(): string { return 'Checks that <dl> elements only contain <dt>, <dd>, and <div> as direct children.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'dl') {
return null;
}
$invalidChildren = [];
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$childTag = strtolower($child->tagName);
if (!in_array($childTag, ['dt', 'dd', 'div'])) {
$invalidChildren[] = $childTag;
}
}
}
if (count($invalidChildren) > 0) {
return $this->createViolation(
$element,
'<dl> öğesi doğrudan alt öğe olarak sadece <dt>, <dd> veya <div> içerebilir.',
'Remove or wrap invalid children (' . implode(', ', array_unique($invalidChildren)) . ') inside <dd> or <dt>.'
);
}
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DialogAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_DIALOG_ACCESSIBILITY'; }
public function getDescription(): string { return 'Dialog elements must have an accessible name.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$role = strtolower($element->getAttribute('role'));
$tagName = strtolower($element->tagName);
if ($role !== 'dialog' && $role !== 'alertdialog' && $tagName !== 'dialog') {
return null;
}
$ariaLabel = trim($element->getAttribute('aria-label'));
$ariaLabelledby = trim($element->getAttribute('aria-labelledby'));
$title = trim($element->getAttribute('title'));
if (!empty($ariaLabel) || !empty($ariaLabelledby) || !empty($title)) {
return null;
}
return $this->createViolation(
$element,
'Dialog bileşeninin erişilebilir bir adı (accessible name) eksik. / Dialog component is missing an accessible name.',
'Dialog elementine aria-label veya aria-labelledby niteliği ekleyin. / Add aria-label or aria-labelledby to the dialog element.'
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DuplicateH1 extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_DUPLICATE_H1'; }
public function getDescription(): string { return 'Pages should not contain more than one h1 element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'h1') {
return null;
}
$doc = $element->ownerDocument;
if ($doc) {
$h1s = $doc->getElementsByTagName('h1');
if ($h1s->length > 1 && $h1s->item(0) !== $element) {
return $this->createViolation(
$element,
$this->getDescription(),
'Change this heading to an h2 or lower level heading, keeping only one h1 per page.'
);
}
}
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmbedAccessibility extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_EmbedAccessibility'; }
public function getDescription(): string { return 'Checks that <embed> elements have aria-label, aria-labelledby, or title attribute for accessible name.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'embed') {
return null;
}
$ariaLabel = $element->getAttribute('aria-label');
$ariaLabelledby = $element->getAttribute('aria-labelledby');
$title = $element->getAttribute('title');
if (empty(trim($ariaLabel)) && empty(trim($ariaLabelledby)) && empty(trim($title))) {
return $this->createViolation(
$element,
'<embed> öğesinde erişilebilir isim eksik.',
'Provide an aria-label, aria-labelledby, or title attribute for the <embed> element.'
);
}
return null;
}
}
+5
View File
@@ -21,6 +21,11 @@ class EmptyHeading 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(
+5
View File
@@ -21,6 +21,11 @@ class EmptyLabel 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 === '') {
return $this->createViolation(
+59 -8
View File
@@ -21,19 +21,70 @@ class EmptyLink extends AbstractRule
return null;
}
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
// Check for images inside
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
if ($text !== '') {
return null;
}
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// Check for images inside
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.');
}
return null;
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
$spans = $xpath->query('.//span', $element);
if ($spans !== false) {
foreach ($spans as $span) {
if ($span instanceof DOMElement) {
if (trim($span->textContent) !== '') {
return null;
}
if ($span->hasAttribute('aria-label') && trim($span->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
}
return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FocusTrapping extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_2_FOCUS_TRAP'; }
public function getDescription(): string { return 'Detects potential keyboard traps via inline event handlers.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$onkeydown = $element->getAttribute('onkeydown');
$onkeypress = $element->getAttribute('onkeypress');
$handlers = $onkeydown . ' ' . $onkeypress;
if (empty(trim($handlers))) {
return null;
}
if (strpos($handlers, 'preventDefault') !== false || strpos($handlers, 'return false') !== false) {
return $this->createViolation(
$element,
'Klavye odağını hapseden (keyboard trap) potansiyel olay işleyicisi tespit edildi. / Potential keyboard trap detected in inline event handlers.',
'Kullanıcıların Esc veya Tab tuşları ile bu alandan çıkabilmesini sağlayın. / Ensure users can exit this component using Esc or Tab keys.'
);
}
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FontTagBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_FONT_TAG_BANNED'; }
public function getDescription(): string { return 'The font tag is obsolete and should not be used. Use CSS for styling instead.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) === 'font') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the <font> tag and style the text using CSS instead.'
);
}
return null;
}
}
+2 -2
View File
@@ -40,8 +40,8 @@ class FormLabel extends AbstractRule
}
}
// Check for aria-label or aria-labelledby
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
// Check for aria-label, aria-labelledby, or title
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) {
return null;
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FormRequiredIndicator extends AbstractRule
{
public function getId(): string { return 'WCAG_3_3_2_FORM_REQUIRED_INDICATOR'; }
public function getDescription(): string { return 'Required form fields should have aria-required="true".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['input', 'select', 'textarea'])) {
return null;
}
if ($element->hasAttribute('required')) {
$ariaRequired = strtolower(trim($element->getAttribute('aria-required')));
if ($ariaRequired !== 'true') {
// If it has 'required' but not aria-required="true", warn
return $this->createViolation(
$element,
"Zorunlu form alanı aria-required=\"true\" niteliğini içermiyor.",
"Ekran okuyucuların form alanının zorunlu olduğunu doğru şekilde anons edebilmesi için aria-required=\"true\" niteliğini eklemeyi düşünün."
);
}
}
return null;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FormSubmitButton extends AbstractRule
{
public function getId(): string { return 'WCAG_3_2_2_FORM_SUBMIT'; }
public function getDescription(): string { return 'Every form must contain a submit button to enable keyboard submission.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'form') {
return null;
}
$hasSubmit = false;
$buttons = $element->getElementsByTagName('button');
foreach ($buttons as $btn) {
$type = strtolower($btn->getAttribute('type') ?: 'submit');
if ($type === 'submit') {
$hasSubmit = true;
break;
}
}
if (!$hasSubmit) {
$inputs = $element->getElementsByTagName('input');
foreach ($inputs as $input) {
$type = strtolower($input->getAttribute('type'));
if ($type === 'submit' || $type === 'image') {
$hasSubmit = true;
break;
}
}
}
if (!$hasSubmit) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a submit control, such as a <button type="submit"> or <input type="submit"> inside the form.'
);
}
return null;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltLong extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT_LONG'; }
public function getDescription(): string { return 'Image alt text should be concise (less than 150 characters).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
return null;
}
$alt = trim($element->getAttribute('alt'));
if (mb_strlen($alt) > 150) {
return $this->createViolation(
$element,
$this->getDescription(),
'Shorten the alt text description to be under 150 characters, and move longer explanations to standard page content.'
);
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltPlaceholder extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT_PLACEHOLDER'; }
public function getDescription(): string { return 'Image alt text should not be a generic placeholder or filename.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
return null;
}
$alt = trim($element->getAttribute('alt'));
if ($alt === '') {
return null; // Empty alt is allowed for decorative images (WCAG 1.1.1)
}
$cleanAlt = strtolower($alt);
// Generic placeholder checks
$placeholders = [
'image', 'photo', 'picture', 'logo', 'icon', 'placeholder', 'untitled', 'temp', 'blank', 'pic', 'dot',
'resim', 'foto', 'fotoğraf', 'görsel', 'grafik', 'ikon', 'logo',
'arkaplan', 'kapak', 'simge', 'ürün', 'dosya', 'adsız', 'varsayılan',
'default', 'hero', 'no-image', 'noimage', 'null'
];
if (in_array($cleanAlt, $placeholders, true)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Provide a specific description of what the image shows, rather than using a generic word.'
);
}
// Filename checks
if (preg_match('/^[^\s]+\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i', $cleanAlt) || preg_match('/\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i', $cleanAlt)) {
return $this->createViolation(
$element,
'Image alt text should not be a filename.',
'Provide a descriptive alternative text instead of the image filename.'
);
}
return null;
}
}
+3 -1
View File
@@ -17,7 +17,9 @@ class ImageAltRedundant extends AbstractRule
/** @var array<int, string> */
private array $redundantWords = [
'resmi', 'görüntüsü', 'fotoğrafı', 'grafiği', 'logosu', 'ikonu',
'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of'
'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of',
'profile photo', 'banner image', 'background image', 'thumbnail of', 'screenshot of', 'avatar of',
'görseli', 'arkaplan resmi', 'profil fotoğrafı', 'küçük resmi', 'ekran görüntüsü'
];
public function check(DOMElement $element): ?Violation
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputImageAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_INPUT_IMAGE_ALT'; }
public function getDescription(): string { return '<input type="image"> elements must have an alternative text.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
if (strtolower($element->getAttribute('type')) !== 'image') {
return null;
}
$hasAlt = $element->hasAttribute('alt') && trim($element->getAttribute('alt')) !== '';
$hasAriaLabel = $element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '';
$hasAriaLabelledBy = $element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '';
$hasTitle = $element->hasAttribute('title') && trim($element->getAttribute('title')) !== '';
if (!$hasAlt && !$hasAriaLabel && !$hasAriaLabelledBy && !$hasTitle) {
return $this->createViolation(
$element,
"<input type=\"image\"> elemanının alternatif metni (alt) bulunmuyor.",
"Görsel butonlar (<input type=\"image\">) için alt, aria-label veya title niteliklerinden birini kullanarak amacını açıklayın."
);
}
return null;
}
}
+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;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InteractiveRoleTabindex extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_ROLE_TABINDEX'; }
public function getDescription(): string { return 'Elements with interactive ARIA roles must be focusable.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$role = strtolower($element->getAttribute('role'));
$interactiveRoles = ['button', 'link', 'checkbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'combobox', 'gridcell'];
if (empty($role) || !in_array($role, $interactiveRoles)) {
return null;
}
if ($element->hasAttribute('tabindex')) {
return null;
}
$tagName = strtolower($element->tagName);
$focusableNative = ['button', 'input', 'select', 'textarea', 'summary', 'details'];
if (in_array($tagName, $focusableNative)) {
return null;
}
if ($tagName === 'a' && $element->hasAttribute('href')) {
return null;
}
if (($tagName === 'audio' || $tagName === 'video') && $element->hasAttribute('controls')) {
return null;
}
return $this->createViolation(
$element,
'Etkileşimli bir role ('.$role.') sahip olan öğe klavye ile odaklanılabilir (focusable) değil. / Element with interactive role ('.$role.') is not focusable.',
'Öğeye tabindex="0" (veya uygun tabindex değeri) ekleyerek odaklanılabilir hale getirin. / Add tabindex="0" to make the element focusable.'
);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LabelTargetExists extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LABEL_TARGET'; }
public function getDescription(): string { return 'Label elements with a \'for\' attribute must target an existing element ID.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'label') {
return null;
}
if ($element->hasAttribute('for')) {
$forId = trim($element->getAttribute('for'));
if ($forId === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Provide a valid element ID in the for attribute.'
);
}
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*[@id="' . $forId . '"]');
if ($elements === false || $elements->length === 0) {
return $this->createViolation(
$element,
$this->getDescription(),
"Ensure the 'for' attribute value (\"$forId\") matches the ID of a form input element."
);
}
}
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkBanner extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkBanner'; }
public function getDescription(): string { return 'Checks that there is at most one page-level <header> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'header') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$ancestors = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $element);
if ($ancestors !== false && $ancestors->length > 0) {
return null; // It's not a page-level banner
}
$headers = $xpath->query('//header');
if ($headers === false) {
return null;
}
$index = 0;
foreach ($headers as $header) {
if (!$header instanceof \DOMNode) {
continue;
}
$anc = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $header);
if ($anc !== false && $anc->length === 0) {
$index++;
if ($header->isSameNode($element) && $index > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla ana (top-level) <header> bulundu.',
'Ensure there is at most one top-level <header> element (banner landmark) on the page.'
);
}
}
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkContentInfo extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkContentInfo'; }
public function getDescription(): string { return 'Checks that there is at most one page-level <footer> element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 4; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'footer') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$ancestors = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $element);
if ($ancestors !== false && $ancestors->length > 0) {
return null; // It's not a page-level footer
}
$footers = $xpath->query('//footer');
if ($footers === false) {
return null;
}
$index = 0;
foreach ($footers as $footer) {
if (!$footer instanceof \DOMNode) {
continue;
}
$anc = $xpath->query('ancestor::article | ancestor::section | ancestor::aside | ancestor::nav | ancestor::main', $footer);
if ($anc !== false && $anc->length === 0) {
$index++;
if ($footer->isSameNode($element) && $index > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla ana (top-level) <footer> bulundu.',
'Ensure there is at most one top-level <footer> element (contentinfo landmark) on the page.'
);
}
}
}
return null;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkMain extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkMain'; }
public function getDescription(): string { return 'Checks if the page has exactly one <main> element or element with role="main".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'html') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$mains = $xpath->query('//main | //*[@role="main"]');
if ($mains === false || $mains->length === 0) {
return $this->createViolation(
$element,
'Sayfada <main> alanı bulunamadı.',
'Add exactly one <main> element or element with role="main" to the page.'
);
} elseif ($mains->length > 1) {
return $this->createViolation(
$element,
'Sayfada birden fazla <main> alanı bulundu.',
'Ensure there is only one <main> element or element with role="main" per page.'
);
}
return null;
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LandmarkNoDuplicate extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LandmarkNoDuplicate'; }
public function getDescription(): string { return 'Checks that if there are multiple landmarks of the same type, each has a unique label.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['nav', 'aside', 'section', 'form'])) {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$elementsOfSameType = $xpath->query('//' . $tag);
if ($elementsOfSameType === false || $elementsOfSameType->length <= 1) {
return null;
}
$label = $element->getAttribute('aria-label');
$labelledby = $element->getAttribute('aria-labelledby');
if (empty($label) && empty($labelledby)) {
return $this->createViolation(
$element,
"Birden fazla <{$tag}> öğesi var ancak bu öğenin benzersiz bir etiketi (aria-label/aria-labelledby) yok.",
"Provide a unique aria-label or aria-labelledby for each <{$tag}> when multiple exist."
);
}
// Check for duplicates
$duplicateLabel = false;
foreach ($elementsOfSameType as $el) {
if (!$el instanceof DOMElement) {
continue;
}
if (!$el->isSameNode($element)) {
$otherLabel = $el->getAttribute('aria-label');
$otherLabelledby = $el->getAttribute('aria-labelledby');
if (!empty($label) && $otherLabel === $label) {
$duplicateLabel = true;
break;
}
if (!empty($labelledby) && $otherLabelledby === $labelledby) {
$duplicateLabel = true;
break;
}
}
}
if ($duplicateLabel) {
return $this->createViolation(
$element,
"Birden fazla <{$tag}> öğesi aynı etiketi kullanıyor.",
"Ensure aria-label or aria-labelledby is unique for each <{$tag}>."
);
}
return null;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
/**
* WCAG 3.1.2 — Checks that when both `lang` and `xml:lang` are present on <html>,
* their primary language subtags match.
*
* A mismatch between these attributes can cause assistive technologies to
* announce content in the wrong language.
*/
class LangMismatch extends AbstractRule
{
public function getId(): string { return 'WCAG_3_1_2_LANG_MISMATCH'; }
public function getDescription(): string { return 'The lang and xml:lang attributes on <html> must have matching primary language subtags.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'html') {
return null;
}
$lang = $element->getAttribute('lang');
$xmlLang = $element->getAttribute('xml:lang');
// Only check when both are present
if (empty($lang) || empty($xmlLang)) {
return null;
}
$primaryLang = $this->getPrimarySubtag($lang);
$primaryXmlLang = $this->getPrimarySubtag($xmlLang);
if (strtolower($primaryLang) !== strtolower($primaryXmlLang)) {
return $this->createViolation(
$element,
"lang=\"{$lang}\" ile xml:lang=\"{$xmlLang}\" arasında birincil dil uyumsuzluğu var.",
'lang ve xml:lang niteliklerinin birincil dil alt etiketlerinin eşleştiğinden emin olun (örn. her ikisi de "en" veya "tr").'
);
}
return null;
}
/**
* Extracts the primary language subtag from a BCP 47 language tag.
* e.g. "en-US" → "en", "tr" → "tr"
*/
private function getPrimarySubtag(string $langTag): string
{
$parts = explode('-', trim($langTag));
return $parts[0];
}
}
+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) {
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;
}
}
+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 LinkHrefJavascript extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_LINK_JAVASCRIPT'; }
public function getDescription(): string { return 'Links must not use javascript: void(0) or # as their href. Use buttons for scripting actions.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'a') {
return null;
}
if ($element->hasAttribute('href')) {
$href = trim($element->getAttribute('href'));
// Check for javascript: links or '#' (which represents a placeholder/scripting target)
if (str_starts_with(strtolower($href), 'javascript:') || $href === '#') {
return $this->createViolation(
$element,
$this->getDescription(),
'Convert the link to a <button> element or provide a valid destination URL.'
);
}
}
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;
}
}
+2 -1
View File
@@ -17,7 +17,8 @@ class LinkTextGeneric extends AbstractRule
/** @var array<int, string> */
private array $genericWords = [
'click here', 'click', 'read more', 'more', 'here', 'download', 'go', 'details', 'link', 'website', 'page', 'info',
'tıkla', 'tıklayın', 'devamı', 'devamını oku', 'indir', 'git', 'detay', 'detaylar', 'link', 'site', 'sayfa', 'bilgi'
'tıkla', 'tıklayın', 'devamı', 'devamını oku', 'indir', 'git', 'detay', 'detaylar', 'link', 'site', 'sayfa', 'bilgi',
'daha fazla bilgi', 'oku', 'bak', 'ayrıntılar', 'incele', 'see more', 'view', 'open'
];
public function check(DOMElement $element): ?Violation
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LinkTextUrl extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_4_LINK_URL'; }
public function getDescription(): string { return 'Link text should not be a raw URL.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'a') {
return null;
}
$text = trim($element->textContent);
if (preg_match('/^(https?:\/\/|www\.)[^\s]+$/i', $text)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Replace the raw URL link text with a descriptive label explaining where the link goes.'
);
}
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 ListStructure extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LIST_STRUCTURE'; }
public function getDescription(): string { return 'Lists (ul, ol) must only contain list item elements (li) as direct children.'; }
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
{
$tag = strtolower($element->tagName);
if ($tag !== 'ul' && $tag !== 'ol') {
return null;
}
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$childTag = strtolower($child->tagName);
if (!in_array($childTag, ['li', 'script', 'template'], true)) {
return $this->createViolation(
$element,
sprintf('List element <%s> contains invalid direct child <%s>. Only <li> elements are allowed.', $tag, $childTag),
'Wrap the child element in an <li> tag, or move it outside the list structure.'
);
}
}
}
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;
}
}

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