Compare commits

...
26 Commits
Author SHA1 Message Date
Enes Yakıştır 6f4799dc38 fix: bump version to 3.1.2 and resolve Symfony 8 compatibility issue 2026-07-14 00:28:58 +03:00
Enes Yakıştır c79ddfcc50 chore: broaden sebastian/diff requirement for phpunit 12 compatibility 2026-07-13 11:50:38 +03:00
Enes Yakıştır 5292c2c865 release: v3.1.0 premium smart core engines and 14 new accessibility rules 2026-07-13 10:54:25 +03:00
Enes Yakıştır 7f3bf3cb7d feat: add 5 new WCAG accessibility rules and bump version to 3.0.5 2026-07-08 12:38:47 +03:00
Enes Yakıştır 8e6ea62ba7 chore: bump version to v3.0.4 and fix Symfony 8 compatibility 2026-07-07 13:00:44 +03:00
Enes Yakıştır c138cbca65 chore: remove BOM from composer.json 2026-07-07 12:59:20 +03:00
Enes Yakıştır 18aa4641d2 chore: bump version to v3.0.3 and update configurations 2026-07-07 12:47:34 +03:00
Enes Yakıştır c4745d6cb3 fix: resolve all WCAG 2.1 violations in demo/broken.html and bump version to 3.0.2
- Added lang attribute to html element (WCAG 3.1.1)
- Added descriptive page title (WCAG 2.4.2)
- Added alt attributes to all images (WCAG 1.1.1)
- Removed redundant alt text words (WCAG 1.1.1)
- Fixed empty link and button with discernible text (WCAG 2.4.4, 4.1.2)
- Removed positive tabindex value (WCAG 2.4.3)
- Eliminated duplicate id=main-content (WCAG 4.1.1)
- Fixed insufficient color contrast ratio (WCAG 1.4.3)
- Replaced invalid ARIA role cool-widget with region (WCAG 4.1.2)
- Added title attribute to iframe (WCAG 4.1.2)
- Replaced generic link texts with descriptive ones (WCAG 2.4.4)
- Added associated label for placeholder-only input (WCAG 3.3.2)
- Removed aria-hidden from focusable elements (WCAG 4.1.2)
- Added non-empty heading text (WCAG 2.4.6)
- Added video captions track element (WCAG 1.2.2)
- Added non-empty label text (WCAG 1.3.1)
- Added tabindex=0 to role=button div (WCAG 2.1.1)
2026-06-23 01:25:08 +03:00
Enes Yakıştır a2b984df04 fix: resolve autoloading in vendor execution and eliminate template false positives 2026-06-23 01:15:35 +03:00
Enes Yakıştır d5165f0be4 feat: Release v3.0.0 - PHPStan-style analysis, configuration, baseline, and advanced WCAG rules 2026-06-23 01:02:15 +03:00
Enes Yakıştır 84c6521195 fix: update Gemini model to gemini-3.1-flash-lite in GeminiFixer
bump version to 2.9.2
2026-06-07 10:49:30 +03:00
Enes Yakıştır 0134482c54 fix: resolve string/int type mismatches in HtmlDashboardReporter
- preg_replace() return value guarded with '?? ''' to eliminate string|null type
- location line number explicitly cast to (string) before concatenation
- PHPStan level 9 confirms zero errors after fix
2026-06-07 10:20:55 +03:00
Enes Yakıştır c9ffbdba08 feat: extend scan level range 1-9 and confirm PHPStan level 9 zero errors 2026-06-07 10:18:15 +03:00
Enes Yakıştır 2a97138fec feat: implement dynamic rule discovery and incremental level filtering 2026-06-06 13:48:10 +03:00
Enes Yakıştır c42ab714ff feat: add guzzle-powered recursive website crawling and masterminds/html5 integration 2026-06-06 13:22:35 +03:00
Enes Yakıştır b805741ef2 feat: redesign HtmlDashboardReporter to premium responsive accessible dashboard 2026-06-06 10:20:50 +03:00
Enes Yakıştır 84f713e434 feat: add PageTitle accessibility rule (WCAG 2.4.2) 2026-06-06 10:16:39 +03:00
Enes Yakıştır c6df5e2bdc feat: add 4 new WCAG rules and optimize SourceLocator directory traversal 2026-06-06 10:15:24 +03:00
Enes Yakıştır 75171c6f96 build: upgrade PHPStan to level 9 and fix strict type errors 2026-06-06 09:36:46 +03:00
Enes Yakıştır 1ea2579d20 chore: configure PHPStan Level 6 and resolve strict-type warnings 2026-05-31 23:00:56 +03:00
Enes Yakıştır 873b4c4233 feat: implement 5 new advanced WCAG rules (ColorContrast, DuplicateId, TabindexOrder, AriaRole, IframeTitle) 2026-05-30 12:04:21 +03:00
Enes Yakıştır d219272487 fix: resolve strict types for ReflectionType and DOMNode 2026-05-30 11:13:17 +03:00
Enes Yakıştır 09f33650ec Fix AI suggestions, improve reporter UI, and fix RuleEngine compatibility issues 2026-05-13 13:09:01 +03:00
Enes Yakıştır 3a19c1c534 Fix model name to gemini-1.5-flash v2.2.3 2026-05-13 12:46:26 +03:00
Enes Yakıştır 40dc04d328 Add debug info to GeminiFixer v2.2.2 2026-05-13 12:21:24 +03:00
Enes Yakıştır 3f262b7b35 Fix Violation constructor parameters and Enum usage v2.2.1 2026-05-13 12:16:37 +03:00
134 changed files with 10560 additions and 296 deletions
+2
View File
@@ -4,3 +4,5 @@
/rapor.html /rapor.html
/.phpunit.result.cache /.phpunit.result.cache
/phpstan-baseline.neon /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) [![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) [![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) [![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 ## 🌟 Ö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. - **🤖 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.
- **⚡ Modern CLI:** Rust/Vite tarzı şık bir komut satırı arayüzü ile saniyeler içinde tarama ve düzeltme yapın. - **⚡ PHPStan Tarzı Analiz:** Tüm projeyi veya belirli dizinleri komut satırından tarayarak hataları tam dosya yolu ve satır numarası ile listeler.
- **📊 Görsel Dashboard:** Tarama sonuçlarını modern ve şık bir HTML raporu olarak dışarı aktarın. - **🛡️ 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.
- **🔍 Akıllı Kaynak Tespiti:** Hataları projenizdeki tam dosya yolu ve satır numarası ile eşleştirir. - **⚙️ YAML Konfigürasyonu:** `a11y.yaml` dosyası üzerinden tarama yollarını, hariç tutulacak klasörleri, kural seviyelerini ve özel kural setlerini yapılandırabilirsiniz.
- **🛠️ Geliştirici Dostu:** PSR-4 uyumlu, modern PHP 8.2+ mimarisi ve genişletilebilir kural motoru. - **🔌 Ç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 ## 📦 Kurulum
Composer ile projenize hemen dahil edin: Composer ile projenize dahil edin:
```bash ```bash
composer require yaknet/accessibility-console composer require yaknet/accessibility-console
``` ```
---
## 🚀 CLI Kullanımı ## 🚀 CLI Kullanımı
Kütüphane, `vendor/bin/a11y` (veya kütüphane dizininde `bin/a11y`) üzerinden kullanılabilir. ### 1. Yapılandırma Dosyası Oluşturma (`init`)
Projenizin kök dizininde varsayılan bir `a11y.yaml` konfigürasyon dosyası oluşturur:
### 1. Standart Tarama
Bir URL'yi veya yerel bir HTML dosyasını tarayın:
```bash ```bash
bin/a11y scan https://test-siteniz.com bin/a11y init
``` ```
### 2. AI Destekli Tarama ve Raporlama ### 2. Projeyi Tarama (`scan` / `analyse`)
Yapay zeka önerileriyle birlikte tarama yapın ve sonucu HTML olarak kaydedin: `a11y.yaml` dosyasındaki kurallara ve dizinlere göre tüm projeyi tarar:
```bash ```bash
bin/a11y scan https://test-siteniz.com --ai --report=sonuc.html bin/a11y scan
``` ```
Belirli bir dizini veya dosyayı hedef göstermek için:
### 3. Otomatik Düzeltme (Self-Healing) - **DEVRİMSEL**
Tespit edilen hataları yapay zekaya düzelttirin ve dosyalarınızı güncelleyin:
```bash ```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 ### 3. Baseline Yönetimi
Projeye ait mevcut tüm hataları `a11y-baseline.json` dosyasına kaydederek sonraki taramalarda yok sayılmasını sağlar:
Kendi PHP uygulamalarınızın içine entegre edin: ```bash
bin/a11y scan --generate-baseline
```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";
}
``` ```
## ⚙️ 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 ```env
GEMINI_API_KEY=AIzaSyA...your_key_here 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 ## 🤝 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 #!/usr/bin/env php
<?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 Symfony\Component\Console\Application;
use YakNet\AccessibilityConsole\Core\Container\ContainerFactory;
use YakNet\AccessibilityConsole\Console\Command\ScanCommand; use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
use YakNet\AccessibilityConsole\Console\Command\FixCommand; use YakNet\AccessibilityConsole\Console\Command\FixCommand;
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
$application = new Application('YakNet Accessibility Console', '2.0.0'); $container = ContainerFactory::create();
$application = new Application('YakNet Accessibility Console', '3.1.2');
// Add commands // Add commands resolved via DI Container
$application->add(new ScanCommand()); $application->addCommand($container->get(ScanCommand::class));
$application->add(new FixCommand()); $application->addCommand($container->get(FixCommand::class));
$application->addCommand($container->get(InitCommand::class));
$application->addCommand($container->get(RulesCommand::class));
$application->run(); $application->run();
+15 -3
View File
@@ -12,6 +12,7 @@
"yaknet" "yaknet"
], ],
"homepage": "https://forum.yak.net.tr", "homepage": "https://forum.yak.net.tr",
"version": "3.1.2",
"type": "library", "type": "library",
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
@@ -26,17 +27,28 @@
"ext-dom": "*", "ext-dom": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"symfony/console": "^6.4|^7.0", "symfony/console": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0", "symfony/yaml": "^6.4|^7.0|^8.0",
"monolog/monolog": "^3.0", "monolog/monolog": "^3.0",
"vlucas/phpdotenv": "^5.6", "vlucas/phpdotenv": "^5.6",
"google-gemini-php/client": "^1.0" "google-gemini-php/client": "^1.0|^2.0",
"guzzlehttp/guzzle": "^7.8",
"masterminds/html5": "^2.8",
"sabberworm/php-css-parser": "^9.4",
"chrome-php/chrome": "^1.15",
"sebastian/diff": "^5.1 || ^6.0 || ^7.0 || ^8.0 || ^9.0",
"php-di/php-di": "^7.1",
"symfony/css-selector": "^8.1",
"symfony/dom-crawler": "^8.1"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^10.5", "phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^1.10" "phpstan/phpstan": "^1.10"
}, },
"autoload": { "autoload": {
"files": [
"src/accessibility-console.php"
],
"psr-4": { "psr-4": {
"YakNet\\AccessibilityConsole\\": "src/" "YakNet\\AccessibilityConsole\\": "src/"
} }
+115 -14
View File
@@ -1,20 +1,121 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="tr"><!-- FIXED: Added lang attribute (WCAG 3.1.1) -->
<head> <head>
<title>Broken Page</title> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Erişilebilir Demo Sayfa</title><!-- FIXED: Non-empty page title (WCAG 2.4.2) -->
<style>
body {
font-family: sans-serif;
margin: 0;
padding: 2rem;
background: #ffffff;
color: #1a1a1a; /* FIXED: High contrast text (WCAG 1.4.3) */
line-height: 1.6;
}
/* FIXED: Sufficient contrast (WCAG 1.4.3) - was #888 on #999, now dark on white */
.low-contrast-example {
color: #595959; /* 7:1 contrast on white */
background-color: #ffffff;
padding: 0.5rem;
}
label {
display: block;
margin-bottom: 0.25rem;
font-weight: 600;
}
input {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
width: 100%;
max-width: 300px;
}
iframe {
border: none;
}
video {
display: block;
margin-top: 0.5rem;
}
[role="button"] {
cursor: pointer;
}
</style>
</head> </head>
<body> <body>
<h1>Welcome to a broken page</h1> <h1 id="main-content">Erişilebilir Demo Sayfa</h1>
<!-- FIXED: Removed duplicate id="main-content" (WCAG 4.1.1) -->
<!-- Missing alt attribute -->
<img src="logo.png"> <!-- FIXED: Added alt attribute (WCAG 1.1.1) -->
<img src="logo.png" alt="YakNet Şirket">
<!-- Empty link -->
<a href="https://google.com"></a> <!-- FIXED: Added descriptive link text (WCAG 2.4.4) -->
<a href="https://google.com">Google Ana Sayfasına Git</a>
<!-- Empty button -->
<button type="submit"></button> <!-- FIXED: Added discernible button text (WCAG 4.1.2) -->
<button type="submit">Formu Gönder</button>
<p>This page has multiple accessibility issues.</p>
<!-- FIXED: Removed positive tabindex (WCAG 2.4.3) — use natural DOM order -->
<a href="/keyboard">Klavye Testi</a>
<!-- FIXED: Removed duplicate id="main-content" — unique wrapper instead -->
<div id="duplicate-content-wrapper">Bu bölümün içeriği buradadır.</div>
<!-- FIXED: High contrast text (WCAG 1.4.3) -->
<p class="low-contrast-example">Bu metnin kontrast oranı WCAG AA gereksinimlerini karşılamaktadır.</p>
<!-- FIXED: Valid WAI-ARIA role (WCAG 4.1.2) and removed reference to non-existent element -->
<div role="region" aria-label="WAI-ARIA Örnek Bölüm">WAI-ARIA bölüm örneği</div>
<!-- FIXED: Added descriptive title to iframe (WCAG 4.1.2) -->
<iframe
src="https://www.youtube.com/embed/aqz-KE-bpKQ"
width="560"
height="315"
title="YouTube Video: Örnek Video İçeriği"
></iframe>
<!-- FIXED: Descriptive link text instead of generic "click here" / "devamını oku" (WCAG 2.4.4) -->
<a href="/read-more">Makaleyi Devamı: WCAG Erişilebilirlik Kılavuzu</a>
<a href="/devam-oku">Şiir Serisinin Devamını Oku: "Bahar Rüzgarı"</a>
<!-- FIXED: Added associated <label> element (WCAG 1.3.1 & 3.3.2) -->
<div>
<label for="search-input">Web sitesinde ara</label>
<input type="text" id="search-input" placeholder="Arama teriminizi girin...">
</div>
<!-- FIXED: Removed redundant words "logo" and "photo" from alt texts (WCAG 1.1.1) -->
<img src="logo2.png" alt="Şirketimizin simgesi">
<img src="profile.jpg" alt="John Doe vesikalık">
<!-- FIXED: Removed aria-hidden="true" from focusable elements (WCAG 4.1.2) -->
<div>
<button type="button">Gizli Olmayan Tıklanabilir Düğme</button>
</div>
<a href="#">Normal Bağlantı</a>
<!-- FIXED: Added non-empty heading text (WCAG 2.4.6) -->
<h2>Alt Bölüm Başlığı</h2>
<!-- FIXED: Added <track> element for video captions (WCAG 1.2.2) -->
<video
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4"
width="320"
height="240"
controls
>
<track kind="captions" src="captions.vtt" srclang="tr" label="Türkçe Altyazı" default>
</video>
<!-- FIXED: Added non-empty label text (WCAG 1.3.1) -->
<label for="name-input">Ad Soyad</label>
<input type="text" id="name-input" name="name">
<!-- FIXED: Added tabindex="0" to make role="button" keyboard focusable (WCAG 2.1.1) -->
<div role="button" tabindex="0">Tıklanabilir Div Düğme (Erişilebilir)</div>
<p>Bu sayfa WCAG 2.1 erişilebilirlik standartlarına uygun olarak düzenlenmiştir.</p>
</body> </body>
</html> </html>
+10 -19
View File
@@ -1,19 +1,11 @@
<?php <?php
// 1. Setup Autoloading (Mocking Composer) // 1. Setup Autoloading
require_once __DIR__ . '/../src/Scanner.php'; require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../src/Violation.php';
require_once __DIR__ . '/../src/SourceLocator.php';
require_once __DIR__ . '/../src/Reporter/HtmlReporter.php';
require_once __DIR__ . '/../src/Rule/RuleInterface.php';
require_once __DIR__ . '/../src/Rules/ImgAltText.php';
require_once __DIR__ . '/../src/Rules/FormLabel.php';
require_once __DIR__ . '/../src/Rules/HtmlHasLang.php';
require_once __DIR__ . '/../src/Rules/EmptyLink.php';
use YakNet\AccessibilityConsole\Scanner; use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\SourceLocator; use YakNet\AccessibilityConsole\Source\SourceLocator;
use YakNet\AccessibilityConsole\Reporter\HtmlReporter; use YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter;
use YakNet\AccessibilityConsole\Rules\ImgAltText; use YakNet\AccessibilityConsole\Rules\ImgAltText;
use YakNet\AccessibilityConsole\Rules\FormLabel; use YakNet\AccessibilityConsole\Rules\FormLabel;
@@ -48,19 +40,18 @@ $violations = $scanner->scan($html);
if (!empty($violations)) { if (!empty($violations)) {
// Locate Sources // Locate Sources
// We point it to the 'views' directory so it knows where to look for the error source
$locator = new SourceLocator(__DIR__ . '/views'); $locator = new SourceLocator(__DIR__ . '/views');
foreach ($violations as $violation) { foreach ($violations as $violation) {
$location = $locator->locate($violation->snippet); $location = $locator->locate($violation->htmlSnippet);
if ($location) { if ($location) {
$violation->setSourceLocation($location['file'], $location['line']); $violation->location = $location;
} }
} }
// Render Report // Render Report (Format as results mapped by page URL)
$reporter = new HtmlReporter(); $reporter = new HtmlDashboardReporter();
$reportHtml = $reporter->render($violations); $reportHtml = $reporter->render(['http://localhost:8080/index.php' => $violations], 'demo/index.php');
// Inject into body // Inject into body
$html = str_replace('</body>', $reportHtml . '</body>', $html); $html = str_replace('</body>', $reportHtml . '</body>', $html);
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Local Fix</title>
</head>
<body>
<span style="text-decoration: blink;">Hello blink</span>
<input type="text" name="username" autofocus />
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<html>
<head>
<title>Test Page</title>
<meta http-equiv="refresh" content="30">
</head>
<body>
<img src="avatar.png" alt="Avatar">
<button>Submit</button>
<a href="#">Click here for nothing</a>
<a href="javascript:void(0)">Do something</a>
<label for="nonexistent-id">Broken Label</label>
<table>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
<tr>
<td scope="row">Data 1</td>
<td>Data 2</td>
</tr>
</table>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
parameters:
level: 9
paths:
- src
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="YakNet Accessibility Console Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\AI\Providers\GeminiProvider;
use YakNet\AccessibilityConsole\AI\Providers\OpenAiProvider;
use YakNet\AccessibilityConsole\AI\Providers\ClaudeProvider;
use YakNet\AccessibilityConsole\AI\Providers\MockAiProvider;
use YakNet\AccessibilityConsole\Core\Violation;
class AiFixerManager
{
/** @var array<string, AiProviderInterface> */
private array $providers = [];
private ?string $activeProviderName = null;
public function __construct(?string $geminiApiKey = null, ?string $openAiApiKey = null, ?string $claudeApiKey = null)
{
// Register default providers
if ($geminiApiKey) {
$this->registerProvider('gemini', new GeminiProvider($geminiApiKey));
$this->activeProviderName = 'gemini';
}
if ($openAiApiKey) {
$this->registerProvider('openai', new OpenAiProvider($openAiApiKey));
if (!$this->activeProviderName) {
$this->activeProviderName = 'openai';
}
}
if ($claudeApiKey) {
$this->registerProvider('claude', new ClaudeProvider($claudeApiKey));
if (!$this->activeProviderName) {
$this->activeProviderName = 'claude';
}
}
// Always register mock provider as fallback/testing option
$this->registerProvider('mock', new MockAiProvider());
if (!$this->activeProviderName) {
$this->activeProviderName = 'mock';
}
}
public function registerProvider(string $name, AiProviderInterface $provider): void
{
$this->providers[strtolower($name)] = $provider;
}
public function setActiveProvider(string $name): void
{
$key = strtolower($name);
if (isset($this->providers[$key])) {
$this->activeProviderName = $key;
}
}
public function getActiveProvider(): ?AiProviderInterface
{
if ($this->activeProviderName && isset($this->providers[$this->activeProviderName])) {
return $this->providers[$this->activeProviderName];
}
return null;
}
public function suggestFix(Violation $violation): ?string
{
$provider = $this->getActiveProvider();
if ($provider) {
return $provider->suggestFix($violation);
}
return "No active AI provider configured.";
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\Core\Violation;
interface AiProviderInterface
{
/**
* Get the name of this AI provider.
*
* @return string
*/
public function getName(): string;
/**
* Suggest a fix for the given violation.
*
* @param Violation $violation
* @return string|null
*/
public function suggestFix(Violation $violation): ?string;
}
+18 -16
View File
@@ -11,15 +11,23 @@ class GeminiFixer
public function __construct(private readonly ?string $apiKey) public function __construct(private readonly ?string $apiKey)
{ {
if ($this->apiKey && class_exists(Gemini::class)) { if ($this->apiKey) {
$this->client = Gemini::client($this->apiKey); try {
$this->client = \Gemini::client($this->apiKey);
} catch (\Throwable) {
$this->client = null;
}
} }
} }
public function suggestFix(Violation $violation): ?string public function suggestFix(Violation $violation): ?string
{ {
if (!$this->apiKey) {
return "Hata: GEMINI_API_KEY bulunamadı.";
}
if (!$this->client) { if (!$this->client) {
return null; return "Hata: Gemini istemcisi başlatılamadı. Kütüphane eksik olabilir.";
} }
$prompt = <<<PROMPT $prompt = <<<PROMPT
@@ -30,23 +38,17 @@ Error: {$violation->message}
Severity: {$violation->severity->value} Severity: {$violation->severity->value}
Standard: {$violation->standard->value} Standard: {$violation->standard->value}
Please provide ONLY the fixed version of the HTML snippet. Please provide a short explanation of the fix (in Turkish) and the fixed version of the HTML snippet.
Do not explain, do not add markdown code blocks, just return the corrected HTML. Use the following EXACT format:
If the error is about missing 'alt' text, add a meaningful alt text based on context. EXPLANATION: [A short explanation of what you fixed and why]
If it's about ARIA roles, add the correct ones. FIX: [Only the corrected HTML snippet]
PROMPT; PROMPT;
try { try {
$result = $this->client->generativeModel('gemini-2.5-flash')->generateContent($prompt); $result = $this->client->generativeModel('gemini-3.1-flash-lite')->generateContent($prompt);
$suggestion = trim($result->text()); return trim($result->text());
// Clean up if AI included markdown blocks anyway
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
return $suggestion;
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Log error if needed return "AI Düzeltme Hatası: " . $e->getMessage();
return null;
} }
} }
} }
+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;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?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'][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;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?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'][0]['message']['content']) && is_string($data['choices'][0]['message']['content'])) {
return $data['choices'][0]['message']['content'];
}
return null;
} catch (\Throwable $e) {
return "OpenAI Error: " . $e->getMessage();
}
}
}
+223 -28
View File
@@ -8,10 +8,11 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\AI\GeminiFixer; use YakNet\AccessibilityConsole\AI\AiFixerManager;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\Config; use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner; use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet; use YakNet\AccessibilityConsole\Rules\RuleLevels;
use YakNet\AccessibilityConsole\Source\SourceLocator; use YakNet\AccessibilityConsole\Source\SourceLocator;
class FixCommand extends Command class FixCommand extends Command
@@ -21,38 +22,206 @@ class FixCommand extends Command
$this $this
->setName('fix') ->setName('fix')
->setDescription('Automatically fix accessibility violations using AI') ->setDescription('Automatically fix accessibility violations using AI')
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan') ->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan and fix')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping') ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files'); ->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 protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
$projectPath = $input->getOption('project-path') ?? getcwd(); // 1. Resolve configuration
$dryRun = $input->getOption('dry-run'); $configPathOption = $input->getOption('config');
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
$config = Config::fromYaml($configPath);
$config = new Config();
$apiKey = $config->getGeminiApiKey(); $apiKey = $config->getGeminiApiKey();
if (!$apiKey) { if (!$apiKey) {
$io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key."); $io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key.");
return Command::FAILURE; return Command::FAILURE;
} }
$io->title("AI Self-Healing Mode - Fixing: $target"); $levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
// 1. Fetch HTML $target = $input->getArgument('target');
$html = @file_get_contents($target); $isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://'));
if (!$html) {
$io->error("Could not read target: $target"); // If target is a URL, run the legacy URL fixing logic
if ($isUrl) {
return $this->executeUrlFix($target, $level, $apiKey, $input, $output, $io);
}
// --- LOCAL FILE / DIRECTORY SELF-HEALING ENGINE ---
$io->title("AI Self-Healing Mode - Directory Static Analysis");
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
$dryRun = (bool)$input->getOption('dry-run');
// Resolve paths to scan
$paths = $config->getPaths();
if (is_string($target) && $target !== '') {
$paths = [$target];
}
// Setup Analyser
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
// We run without baseline filtering so we can fix ALL errors in the project,
// or we can optionally load baseline. Fixing everything is better for a general "fix" run.
$analyser = new Analyser($config, $scanner, new \YakNet\AccessibilityConsole\Core\BaselineManager());
$io->note("Scanning targets for violations...");
$result = $analyser->analyse($paths);
$violations = $result['violations'];
if (empty($violations)) {
$io->success("Everything looks good! No violations to fix.");
return Command::SUCCESS;
}
$io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations)));
$fixer = new AiFixerManager($apiKey);
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
$fixedCount = 0;
// Group violations by file path
$groupedViolations = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
if ($file !== '') {
$groupedViolations[$file][] = $v;
}
}
foreach ($groupedViolations as $file => $fileViolations) {
$io->section("Fixing file: $file");
// Sort violations by line number DESCENDING (bottom to top)
// This prevents edits at the bottom of the file from invalidating line numbers above them!
usort($fileViolations, function ($a, $b) {
return ($b->location['line'] ?? 0) <=> ($a->location['line'] ?? 0);
});
$absoluteFile = getcwd() . '/' . $file;
if (!file_exists($absoluteFile) || !is_writable($absoluteFile)) {
$io->warning("File not found or not writable: $file. Skipping.");
continue;
}
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
foreach ($fileViolations as $violation) {
$line = $violation->location['line'] ?? 0;
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
// Try Local Healer first
$rawSuggestion = $localHealer->heal($violation);
$isLocalFix = false;
if ($rawSuggestion !== null) {
$isLocalFix = true;
} else {
// Fallback to AI Suggestion
$rawSuggestion = $fixer->suggestFix($violation);
}
$suggestion = $rawSuggestion;
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
$suggestion = trim($matches[1] ?? $rawSuggestion);
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
}
if (!is_string($suggestion)) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue;
}
if ($isLocalFix) {
$io->writeln("<fg=green>Local Fix Found!</>");
} else {
$io->writeln("<fg=green>AI Fix Found!</>");
}
$io->writeln(" <fg=gray>Diff:</>");
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
$io->writeln($indentedDiff);
if ($dryRun) {
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
continue;
}
// Apply Fix
if ($this->applyFix($absoluteFile, $violation->htmlSnippet, $suggestion)) {
$io->writeln(" <fg=bright-green>✔ Applied to line $line</>");
$fixedCount++;
} else {
$io->writeln(" <fg=red>✘ Failed to apply fix to file.</>");
}
$io->writeln("");
}
}
if ($fixedCount > 0) {
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
} else {
$io->warning("No files were modified.");
}
return Command::SUCCESS;
}
/**
* Legacy URL scan and fix logic
*/
private function executeUrlFix(string $target, int $level, string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
{
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$dryRun = (bool)$input->getOption('dry-run');
$useJs = (bool)$input->getOption('js');
$io->title("AI Self-Healing Mode - Fixing URL: $target");
// Fetch HTML
if ($useJs) {
try {
$chromeRenderer = new \YakNet\AccessibilityConsole\Core\ChromeRenderer();
$html = $chromeRenderer->render($target);
} catch (\Throwable $e) {
$io->error("Failed JavaScript rendering: " . $e->getMessage());
return Command::FAILURE;
}
} else {
$html = @file_get_contents($target);
}
if ($html === false || $html === '') {
$io->error("Could not read target URL: $target");
return Command::FAILURE; return Command::FAILURE;
} }
// 2. Scan // Scan
$scanner = new Scanner(); $scanner = new Scanner();
foreach (StandardRuleSet::all() as $rule) { $rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule); $scanner->addRule($rule);
} }
$violations = $scanner->scan($html); $violations = $scanner->scan($html);
@@ -64,7 +233,8 @@ class FixCommand extends Command
$io->note("Found " . count($violations) . " violations. Starting AI analysis..."); $io->note("Found " . count($violations) . " violations. Starting AI analysis...");
$fixer = new GeminiFixer($apiKey); $fixer = new AiFixerManager($apiKey);
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
$locator = new SourceLocator($projectPath); $locator = new SourceLocator($projectPath);
$fixedCount = 0; $fixedCount = 0;
@@ -78,16 +248,42 @@ class FixCommand extends Command
continue; continue;
} }
// Get AI Suggestion $localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
$suggestion = $fixer->suggestFix($violation); $rawSuggestion = $localHealer->heal($violation);
if (!$suggestion) { $isLocalFix = false;
$io->writeln("<fg=red>Skipped (AI could not generate fix)</>");
if ($rawSuggestion !== null) {
$isLocalFix = true;
} else {
$rawSuggestion = $fixer->suggestFix($violation);
}
$suggestion = $rawSuggestion;
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
$suggestion = trim($matches[1] ?? $rawSuggestion);
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
}
if (!is_string($suggestion)) {
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
continue; continue;
} }
$io->writeln("<fg=green>Fix Found!</>"); if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>"); $io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>"); continue;
}
if ($isLocalFix) {
$io->writeln("<fg=green>Local Fix Found!</>");
} else {
$io->writeln("<fg=green>AI Fix Found!</>");
}
$io->writeln(" <fg=gray>Diff:</>");
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
$io->writeln($indentedDiff);
if ($dryRun) { if ($dryRun) {
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>"); $io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
@@ -107,7 +303,7 @@ class FixCommand extends Command
if ($fixedCount > 0) { if ($fixedCount > 0) {
$io->success("Self-healing complete! $fixedCount violations were automatically fixed."); $io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
} else { } else {
$io->warning("No files were modified. Check error logs for details."); $io->warning("No files were modified.");
} }
return Command::SUCCESS; return Command::SUCCESS;
@@ -122,8 +318,7 @@ class FixCommand extends Command
if (str_contains($content, $originalSnippet)) { if (str_contains($content, $originalSnippet)) {
$newContent = str_replace($originalSnippet, $fixedSnippet, $content); $newContent = str_replace($originalSnippet, $fixedSnippet, $content);
} else { } else {
// Fallback: Fuzzy replacement (simplified for this demo) // Fallback: Fuzzy replacement
// In a real professional lib, we'd use more advanced regex or DOM patching
$newContent = str_replace(trim($originalSnippet), trim($fixedSnippet), $content); $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;
}
}
+405 -58
View File
@@ -3,111 +3,458 @@
namespace YakNet\AccessibilityConsole\Console\Command; namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\AI\GeminiFixer; use YakNet\AccessibilityConsole\AI\GeminiFixer;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\BaselineManager;
use YakNet\AccessibilityConsole\Core\Config; use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner; 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; use YakNet\AccessibilityConsole\Source\SourceLocator;
class ScanCommand extends Command class ScanCommand extends Command
{ {
protected static $defaultName = 'scan';
protected function configure(): void protected function configure(): void
{ {
$this $this
->setName('scan') ->setName('scan')
->setDescription('Scan a URL or file for accessibility violations') ->setAliases(['analyse'])
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan') ->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('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions')
->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)') ->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping'); ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively (URL scans only)')
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth (URL scans only)', '3')
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum pages to crawl (URL scans only)', '20')
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
$enableAi = $input->getOption('ai'); // 1. Resolve configuration
$projectPath = $input->getOption('project-path') ?? getcwd(); $configPathOption = $input->getOption('config');
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
$config = Config::fromYaml($configPath);
$io->title("Accessibility Console - Scanning: $target"); // 2. Resolve parameters (CLI options override config file)
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
// 1. Fetch HTML $formatOption = $input->getOption('format');
$html = @file_get_contents($target); $format = is_string($formatOption) ? $formatOption : $config->getFormat();
if (!$html) {
$io->error("Could not read target: $target"); $baselinePathOption = $input->getOption('baseline');
return Command::FAILURE; $baselinePath = is_string($baselinePathOption) ? $baselinePathOption : $config->getBaselinePath();
if (!$baselinePath && $input->getOption('generate-baseline')) {
$baselinePath = 'a11y-baseline.json'; // default baseline filename
} }
// 2. Setup Scanner $target = $input->getArgument('target');
$isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://'));
// If target is a URL, run the legacy URL scanning / crawling logic
if ($isUrl) {
return $this->executeUrlScan($target, $level, $input, $output, $io);
}
// --- LOCAL DIRECTORY / FILE STATIC ANALYSIS ENGINE ---
$io->title("Accessibility Console - Static Analysis");
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
// Resolve paths to scan
$paths = $config->getPaths();
if (is_string($target) && $target !== '') {
$paths = [$target];
}
// Setup Analyser
$scanner = new Scanner(); $scanner = new Scanner();
foreach (StandardRuleSet::all() as $rule) { $rules = RuleLevels::getRulesForLevel($level);
// Filter rules based on config
$rulesConfig = $config->getRulesConfig();
$activeRules = [];
foreach ($rules as $rule) {
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: (new \ReflectionClass($rule))->getShortName();
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
continue;
}
$activeRules[$ruleId] = $rule;
}
foreach ($rulesConfig['include'] as $customRuleClass) {
if (class_exists($customRuleClass)) {
try {
$reflection = new \ReflectionClass($customRuleClass);
if ($reflection->isInstantiable()) {
$rule = $reflection->newInstance();
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: $reflection->getShortName();
$activeRules[$ruleId] = $rule;
}
} catch (\Throwable $e) {}
}
}
foreach ($activeRules as $rule) {
$scanner->addRule($rule); $scanner->addRule($rule);
} }
// 3. Scan // Setup Baseline Manager
$violations = $scanner->scan($html); $baselineManager = new BaselineManager();
if ($baselinePath && !$input->getOption('generate-baseline')) {
if (empty($violations)) { $baselineManager->load(getcwd() . '/' . $baselinePath);
$io->success("No violations found!");
return Command::SUCCESS;
} }
$io->section("Found " . count($violations) . " violations"); $analyser = new Analyser($config, $scanner, $baselineManager);
// 4. Setup Helpers // Progress bar setup
$locator = new SourceLocator($projectPath); $progressBar = null;
$config = new Config(); $showProgress = !$input->getOption('no-progress') && $format === 'console';
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null; $onProgress = null;
foreach ($violations as $violation) { if ($showProgress) {
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>"); $onProgress = function (string $file, int $index, int $total) use ($io, &$progressBar) {
if ($progressBar === null) {
// Show Snippet $progressBar = new ProgressBar($io, $total);
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>"); $progressBar->start();
// Source Mapping
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
}
// AI Fixer
if ($enableAi && $fixer) {
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation);
if ($suggestion) {
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
} else {
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
} }
} $progressBar->setProgress($index);
$progressBar->setMessage(basename($file), 'filename');
$io->writeln(""); };
} }
$io->warning("Scanned " . count($violations) . " errors. Use --ai to get fix suggestions."); // Run analysis
$result = $analyser->analyse($paths, $onProgress);
if ($progressBar) {
$progressBar->finish();
$io->newLine(2);
}
// Export Report $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'); $reportPath = $input->getOption('report');
if ($reportPath) { if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION); $extension = pathinfo($reportPath, PATHINFO_EXTENSION);
if ($extension === 'json') { if ($extension === 'json') {
$data = array_map(fn($v) => $v->toArray(), $violations); $serialized = [];
file_put_contents($reportPath, json_encode($data, JSON_PRETTY_PRINT)); foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
} else { } else {
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter(); $reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
file_put_contents($reportPath, $reporter->render($violations, $target)); // Map violations into page format
$mappedViolations = ['Static Analysis' => $violations];
file_put_contents($reportPath, $reporter->render($mappedViolations, 'Static Analysis'));
} }
$io->success("Report saved to: $reportPath"); $io->success("Report saved to: $reportPath");
} }
return Command::SUCCESS; return $exitCode;
}
/**
* Legacy URL scanning and crawling logic
*/
private function executeUrlScan(string $target, int $level, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
{
$enableAi = (bool)$input->getOption('ai');
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$io->title("Accessibility Console - Scanning URL: $target (Level $level)");
// 1. Setup Scanner
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
// 2. Fetch and Scan
$crawl = (bool)$input->getOption('crawl');
$useJs = (bool)$input->getOption('js');
$chromeRenderer = $useJs ? new \YakNet\AccessibilityConsole\Core\ChromeRenderer() : null;
$results = [];
if ($crawl) {
$depthOption = $input->getOption('depth');
$depthLimit = is_numeric($depthOption) ? (int)$depthOption : 3;
$maxPagesOption = $input->getOption('max-pages');
$maxPages = is_numeric($maxPagesOption) ? (int)$maxPagesOption : 20;
$io->comment("Starting recursive crawl from: $target (Max Depth: $depthLimit, Max Pages: $maxPages)");
$parsedBase = parse_url($target);
$baseHost = $parsedBase['host'] ?? '';
$queue = [['url' => $target, 'depth' => 1]];
$visited = [];
$client = new \GuzzleHttp\Client([
'timeout' => 10,
'allow_redirects' => true,
'headers' => [
'User-Agent' => 'YakNet Accessibility Console Crawler/1.0',
]
]);
while (!empty($queue) && count($visited) < $maxPages) {
$current = array_shift($queue);
if (!is_array($current)) {
continue;
}
$url = $current['url'];
$depth = (int)$current['depth'];
$cleanUrl = strtok($url, '#');
if ($cleanUrl === false || isset($visited[$cleanUrl])) {
continue;
}
$visited[$cleanUrl] = true;
$io->write(" <fg=gray>Crawling [Depth $depth]:</> <fg=cyan>$cleanUrl</>... ");
try {
if ($chromeRenderer) {
$html = $chromeRenderer->render($cleanUrl);
} else {
$response = $client->request('GET', $cleanUrl);
$html = (string)$response->getBody();
}
$io->writeln("<fg=green>OK</>");
$pageViolations = $scanner->scan($html);
$results[$cleanUrl] = $pageViolations;
if ($depth < $depthLimit) {
$html5 = new \Masterminds\HTML5(['disable_html_ns' => true]);
$doc = $html5->loadHTML($html);
$links = $doc->getElementsByTagName('a');
foreach ($links as $link) {
$href = $link->getAttribute('href');
if (empty($href) || str_starts_with($href, 'javascript:') || str_starts_with($href, 'mailto:') || str_starts_with($href, 'tel:')) {
continue;
}
try {
$baseUri = new \GuzzleHttp\Psr7\Uri($cleanUrl);
$relativeUri = new \GuzzleHttp\Psr7\Uri($href);
$absoluteUri = \GuzzleHttp\Psr7\UriResolver::resolve($baseUri, $relativeUri);
$absoluteUrl = (string)$absoluteUri;
} catch (\Throwable) {
continue;
}
$parsedAbsolute = parse_url($absoluteUrl);
$absoluteHost = $parsedAbsolute['host'] ?? '';
if ($absoluteHost === $baseHost) {
$cleanAbsolute = strtok($absoluteUrl, '#');
if ($cleanAbsolute !== false && !isset($visited[$cleanAbsolute])) {
$inQueue = false;
foreach ($queue as $q) {
if ($q['url'] === $cleanAbsolute) {
$inQueue = true;
break;
}
}
if (!$inQueue) {
$queue[] = ['url' => $cleanAbsolute, 'depth' => $depth + 1];
}
}
}
}
}
} catch (\Throwable $e) {
$io->writeln("<fg=red>FAIL (" . $e->getMessage() . ")</>");
}
}
} else {
if ($chromeRenderer) {
try {
$html = $chromeRenderer->render($target);
} catch (\Throwable $e) {
$io->error("Failed JavaScript rendering: " . $e->getMessage());
return Command::FAILURE;
}
} else {
$html = @file_get_contents($target);
}
if ($html === false || $html === '') {
$io->error("Could not read target URL: $target");
return Command::FAILURE;
}
$violations = $scanner->scan($html);
$results[$target] = $violations;
}
$totalViolations = 0;
foreach ($results as $url => $violations) {
$totalViolations += count($violations);
}
if ($totalViolations === 0) {
$io->success("No violations found!");
return Command::SUCCESS;
}
$io->section("Found a total of $totalViolations violations across " . count($results) . " pages");
$locator = new SourceLocator($projectPath);
$config = new Config();
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
foreach ($results as $url => $violations) {
if (empty($violations)) {
continue;
}
$io->section("Page: $url (" . count($violations) . " violations)");
foreach ($violations as $violation) {
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
$violation->location = $location;
}
if ($enableAi && $fixer) {
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation);
if ($suggestion) {
$violation->fixSuggestion = $suggestion;
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
} else {
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
}
}
$io->writeln("");
}
}
$reportPath = $input->getOption('report');
if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
if ($extension === 'json') {
$serialized = [];
foreach ($results as $url => $violations) {
$serialized[$url] = array_map(fn($v) => $v->toArray(), $violations);
}
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
} else {
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
file_put_contents($reportPath, $reporter->render($results, $target));
}
$io->success("Report saved to: $reportPath");
}
return Command::FAILURE;
} }
} }
+21 -6
View File
@@ -10,22 +10,37 @@ abstract class AbstractRule
abstract public function check(\DOMDocument $doc): array; abstract public function check(\DOMDocument $doc): array;
/** /**
* Get the WCAG standard ID (e.g., WCAG 2.1 1.1.1) * Get the WCAG standard Enum
*/ */
abstract public function getStandardId(): string; abstract public function getStandard(): WCAGStandard;
/** /**
* Get the severity level (Critical, Major, Minor) * Get the severity level Enum
*/ */
abstract public function getSeverity(): string; abstract public function getSeverity(): Severity;
public function getLevel(): int
{
return 4;
}
protected function createViolation(string $message, \DOMElement $element): Violation protected function createViolation(string $message, \DOMElement $element): Violation
{ {
$doc = $element->ownerDocument;
$html = '';
if ($doc !== null) {
$htmlVal = $doc->saveHTML($element);
if (is_string($htmlVal)) {
$html = $htmlVal;
}
}
return new Violation( return new Violation(
ruleId: (new \ReflectionClass($this))->getShortName(),
message: $message, message: $message,
htmlSnippet: $element->ownerDocument->saveHTML($element),
severity: $this->getSeverity(), severity: $this->getSeverity(),
standard: $this->getStandardId() standard: $this->getStandard(),
htmlSnippet: $html
); );
} }
} }
+290
View File
@@ -0,0 +1,290 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class Analyser
{
private Config $config;
private Scanner $scanner;
private BaselineManager $baselineManager;
private string $projectRoot;
public function __construct(?Config $config = null, ?Scanner $scanner = null, ?BaselineManager $baselineManager = null)
{
$this->config = $config ?? new Config();
$this->scanner = $scanner ?? $this->setupScanner();
$this->baselineManager = $baselineManager ?? new BaselineManager();
$cwd = getcwd();
$this->projectRoot = $cwd ? str_replace('\\', '/', realpath($cwd) ?: $cwd) : '.';
if ($baselineManager === null) {
$baselinePath = $this->config->getBaselinePath();
if ($baselinePath) {
$this->baselineManager->load($this->projectRoot . '/' . $baselinePath);
}
}
}
private function setupScanner(): Scanner
{
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($this->config->getLevel());
$rulesConfig = $this->config->getRulesConfig();
$activeRules = [];
foreach ($rules as $rule) {
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: (new \ReflectionClass($rule))->getShortName();
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
continue;
}
$activeRules[$ruleId] = $rule;
}
// Include custom rules
foreach ($rulesConfig['include'] as $customRuleClass) {
if (class_exists($customRuleClass)) {
try {
$reflection = new \ReflectionClass($customRuleClass);
if ($reflection->isInstantiable()) {
$rule = $reflection->newInstance();
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: $reflection->getShortName();
$activeRules[$ruleId] = $rule;
}
} catch (\Throwable $e) {
// Ignore instantiation failures
}
}
}
foreach ($activeRules as $rule) {
$scanner->addRule($rule);
}
return $scanner;
}
/**
* Analyse files and return a list of violations.
*
* @param array<int, string>|null $pathsOverride
* @param callable|null $onProgress Function called for progress tracking: fn(string $file, int $index, int $total)
* @return array{violations: Violation[], baselinedCount: int}
*/
public function analyse(?array $pathsOverride = null, ?callable $onProgress = null): array
{
$paths = $pathsOverride ?? $this->config->getPaths();
$excludePaths = $this->config->getExcludePaths();
// Resolve all files to scan
$filesToScan = [];
foreach ($paths as $path) {
if (is_file($path)) {
$filesToScan[] = str_replace('\\', '/', realpath($path) ?: $path);
} elseif (is_dir($path)) {
$files = $this->findFiles($path, $excludePaths);
$filesToScan = array_merge($filesToScan, $files);
}
}
$filesToScan = array_unique($filesToScan);
$totalFiles = count($filesToScan);
$violations = [];
$baselinedCount = 0;
foreach ($filesToScan as $index => $file) {
if ($onProgress) {
$onProgress($file, $index + 1, $totalFiles);
}
$content = @file_get_contents($file);
if ($content === false || trim($content) === '') {
continue;
}
// Scan file content
try {
$fileViolations = $this->scanner->scan($content);
} catch (\Throwable $e) {
// Skip if parsing failed dramatically
continue;
}
$relativeFile = $this->makePathRelative($file);
foreach ($fileViolations as $violation) {
// Find line number using line-by-line helper
$line = $this->locateLineInContent($content, $violation->htmlSnippet);
// Store location in violation
$violation->location = [
'file' => $relativeFile,
'line' => $line
];
// Check if ignored in baseline
if ($this->baselineManager->isIgnored($relativeFile, $violation->ruleId, $violation->htmlSnippet)) {
$baselinedCount++;
} else {
$violations[] = $violation;
}
}
}
return [
'violations' => $violations,
'baselinedCount' => $baselinedCount
];
}
public function getBaselineManager(): BaselineManager
{
return $this->baselineManager;
}
public function makePathRelative(string $absolutePath): string
{
$absolutePath = str_replace('\\', '/', realpath($absolutePath) ?: $absolutePath);
if ($this->projectRoot !== '.' && str_starts_with($absolutePath, $this->projectRoot)) {
$relative = ltrim(substr($absolutePath, strlen($this->projectRoot)), '/');
return $relative === '' ? '.' : $relative;
}
return $absolutePath;
}
/**
* @param array<int, string> $excludePaths
* @return array<int, string>
*/
private function findFiles(string $dir, array $excludePaths): array
{
$realDir = realpath($dir);
if ($realDir === false) {
return [];
}
$files = [];
try {
$directory = new RecursiveDirectoryIterator($realDir, \FilesystemIterator::SKIP_DOTS);
$filter = new \RecursiveCallbackFilterIterator($directory, function ($current) use ($excludePaths) {
/** @var SplFileInfo $current */
$filename = $current->getFilename();
$pathname = str_replace('\\', '/', $current->getPathname());
// Exclude common directories/files
foreach ($excludePaths as $exclude) {
$normalizedExclude = str_replace('\\', '/', $exclude);
if ($filename === $normalizedExclude || str_contains($pathname, '/' . $normalizedExclude . '/')) {
return false;
}
}
return true;
});
$iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST);
$extensions = ['php', 'html', 'twig', 'blade.php'];
foreach ($iterator as $file) {
/** @var SplFileInfo $file */
if ($file->isDir()) {
continue;
}
$filename = $file->getFilename();
$isValidExt = false;
foreach ($extensions as $ext) {
if (str_ends_with($filename, '.' . $ext) || ($ext === 'blade.php' && str_ends_with($filename, '.blade.php'))) {
$isValidExt = true;
break;
}
}
if ($isValidExt) {
$files[] = str_replace('\\', '/', $file->getPathname());
}
}
} catch (\Throwable $e) {
// Ignore directory iteration errors
}
return $files;
}
private function locateLineInContent(string $content, string $snippet): int
{
$replaced = preg_replace('/\s+/', ' ', $snippet);
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
if ($cleanSnippet === '') {
return 1;
}
$searchSnippet = mb_strlen($cleanSnippet) > 150 ? mb_substr($cleanSnippet, 0, 150) : $cleanSnippet;
$lines = explode("\n", $content);
// 1. Exact match pass
foreach ($lines as $index => $line) {
$replacedLine = preg_replace('/\s+/', ' ', $line);
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
if (str_contains($cleanLine, $searchSnippet)) {
return $index + 1;
}
}
// 2. Parse snippet to find tag and attributes for fuzzy match pass
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
$tagName = $matches[1] ?? null;
if (!$tagName) {
return 1;
}
preg_match_all('/([a-zA-Z0-9-]+)=["\']([^"\']*)["\']/', $snippet, $attrMatches, PREG_SET_ORDER);
$attributes = [];
foreach ($attrMatches as $match) {
$attributes[$match[1]] = $match[2];
}
$bestLine = 1;
$maxScore = 0;
foreach ($lines as $index => $line) {
$replacedLine = preg_replace('/\s+/', ' ', $line);
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
if (!str_contains(strtolower($cleanLine), '<' . strtolower($tagName))) {
continue;
}
$score = 1.0;
if (in_array(strtolower($tagName), ['html', 'head', 'body', 'title'], true)) {
$score += 1.0;
}
foreach ($attributes as $name => $value) {
if (str_contains($cleanLine, "$name=\"$value\"") || str_contains($cleanLine, "$name='$value'")) {
$score += 2.0;
} elseif (str_contains($cleanLine, $name . '=')) {
$score += 0.5;
}
}
if ($score > $maxScore) {
$maxScore = $score;
$bestLine = $index + 1;
}
}
return $bestLine;
}
}
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Baseline;
class FuzzyBaselineMatcher
{
/**
* Determine if two HTML snippets are fuzzy matched.
*
* @param string $snippetA
* @param string $snippetB
* @return bool
*/
public static function match(string $snippetA, string $snippetB): bool
{
$normA = self::normalize($snippetA);
$normB = self::normalize($snippetB);
if ($normA === '' || $normB === '') {
return false;
}
return $normA === $normB;
}
/**
* Normalize HTML snippet by stripping formatting, converting to lowercase,
* and sorting attributes.
*
* @param string $html
* @return string
*/
public static function normalize(string $html): string
{
$html = trim($html);
if ($html === '') {
return '';
}
// Parse using simple regex to sort attributes and lowercase tag
if (preg_match('/^<([a-zA-Z0-9:-]+)(.*?)>$/s', $html, $matches)) {
$tag = strtolower($matches[1]);
$attrsContent = $matches[2];
// Parse attributes
preg_match_all('/([a-zA-Z0-9:-]+)=["\'](.*?)["\']/s', $attrsContent, $attrMatches, PREG_SET_ORDER);
$attrs = [];
foreach ($attrMatches as $match) {
$attrs[strtolower($match[1])] = trim($match[2]);
}
ksort($attrs);
$attrStr = '';
foreach ($attrs as $name => $value) {
$attrStr .= sprintf(' %s="%s"', $name, $value);
}
return sprintf('<%s%s>', $tag, $attrStr);
}
// Fallback: Strip all whitespace and lowercase
$cleaned = preg_replace('/\s+/', '', $html) ?: '';
return strtolower($cleaned);
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
class BaselineManager
{
/** @var array<string, bool> */
private array $ignoredKeys = [];
public function load(string $baselinePath): bool
{
if (!file_exists($baselinePath) || !is_readable($baselinePath)) {
return false;
}
$content = file_get_contents($baselinePath);
if ($content === false) {
return false;
}
$data = json_decode($content, true);
if (!is_array($data) || !isset($data['violations']) || !is_array($data['violations'])) {
return false;
}
foreach ($data['violations'] as $item) {
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])) {
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
$this->ignoredKeys[$key] = true;
}
}
return true;
}
/**
* @param Violation[] $violations
*/
public function generate(array $violations, string $baselinePath): bool
{
$serialized = [
'generator' => 'YakNet Accessibility Console',
'violations' => []
];
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
$serialized['violations'][] = [
'file' => $file,
'ruleId' => $v->ruleId,
'htmlSnippet' => $v->htmlSnippet
];
}
// Sort baseline violations by file and ruleId for cleaner git diffs
usort($serialized['violations'], function ($a, $b) {
$fileCmp = strcmp($a['file'], $b['file']);
if ($fileCmp !== 0) {
return $fileCmp;
}
return strcmp($a['ruleId'], $b['ruleId']);
});
$json = json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return false;
}
$dir = dirname($baselinePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return file_put_contents($baselinePath, $json) !== false;
}
public function isIgnored(string $file, string $ruleId, string $htmlSnippet): bool
{
$key = $this->calculateKey($file, $ruleId, $htmlSnippet);
return isset($this->ignoredKeys[$key]);
}
private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string
{
// Normalize 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;
}
}
+100 -1
View File
@@ -4,16 +4,43 @@ namespace YakNet\AccessibilityConsole\Core;
class Config class Config
{ {
/** @var array<string, mixed> */
private array $settings = []; private array $settings = [];
/**
* @param array<string, mixed> $settings
*/
public function __construct(array $settings = []) public function __construct(array $settings = [])
{ {
$this->settings = $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 public static function fromYaml(string $path): self
{ {
// Placeholder for YAML loading logic if (file_exists($path)) {
try {
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
if (is_array($content)) {
return new self($content);
}
} catch (\Throwable $e) {
// Ignore parsing errors
}
}
return new self([]); return new self([]);
} }
@@ -22,6 +49,78 @@ class Config
return $this->settings[$key] ?? $default; return $this->settings[$key] ?? $default;
} }
/**
* @return array<int, string>
*/
public function getPaths(): array
{
$paths = $this->get('paths');
if (is_array($paths)) {
return $paths;
}
if (is_string($paths)) {
return [$paths];
}
return ['.']; // default to current directory
}
/**
* @return array<int, string>
*/
public function getExcludePaths(): array
{
$exclude = $this->get('exclude_paths');
if (is_array($exclude)) {
return $exclude;
}
if (is_string($exclude)) {
return [$exclude];
}
return ['vendor', 'node_modules', 'tests', 'storage', '.git']; // sensible defaults
}
public function getLevel(): int
{
$level = $this->get('level', 4);
return is_numeric($level) ? (int)$level : 4;
}
public function getBaselinePath(): ?string
{
$baseline = $this->get('baseline');
return is_string($baseline) ? $baseline : null;
}
public function getFormat(): string
{
$format = $this->get('format', 'console');
return is_string($format) ? $format : 'console';
}
/**
* @return array{include: array<int, string>, exclude: array<int, string>}
*/
public function getRulesConfig(): array
{
$rules = $this->get('rules', []);
$include = [];
$exclude = [];
if (is_array($rules)) {
if (isset($rules['include']) && is_array($rules['include'])) {
$include = $rules['include'];
}
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
$exclude = $rules['exclude'];
}
}
return [
'include' => $include,
'exclude' => $exclude
];
}
public function getGeminiApiKey(): ?string public function getGeminiApiKey(): ?string
{ {
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null; return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_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;
}
}
+21 -4
View File
@@ -4,10 +4,10 @@ namespace YakNet\AccessibilityConsole\Core;
class RuleEngine class RuleEngine
{ {
/** @var AbstractRule[] */ /** @var array<int, mixed> */
private array $rules = []; private array $rules = [];
public function addRule(AbstractRule $rule): void public function addRule(mixed $rule): void
{ {
$this->rules[] = $rule; $this->rules[] = $rule;
} }
@@ -19,8 +19,25 @@ class RuleEngine
{ {
$allViolations = []; $allViolations = [];
foreach ($this->rules as $rule) { foreach ($this->rules as $rule) {
$violations = $rule->check($doc); if ($rule instanceof AbstractRule) {
$allViolations = array_merge($allViolations, $violations); // Document-level rule (Core\AbstractRule style)
$violations = $rule->check($doc);
$allViolations = array_merge($allViolations, $violations);
} elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) {
// Element-level rule (Rules\RuleInterface style)
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*');
if ($elements !== false) {
foreach ($elements as $element) {
if ($element instanceof \DOMElement) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
}
}
}
}
}
} }
return $allViolations; return $allViolations;
} }
+13 -7
View File
@@ -4,11 +4,16 @@ namespace YakNet\AccessibilityConsole\Core;
class Scanner class Scanner
{ {
public function __construct(private ?RuleEngine $engine = null) private RuleEngine $engine;
public function __construct(?RuleEngine $engine = null)
{ {
if ($this->engine === null) { $this->engine = $engine ?? new RuleEngine();
$this->engine = new RuleEngine(); }
}
public function addRule(mixed $rule): void
{
$this->engine->addRule($rule);
} }
/** /**
@@ -23,9 +28,10 @@ class Scanner
return []; return [];
} }
$doc = new \DOMDocument(); $html5 = new \Masterminds\HTML5([
// Suppress warnings for malformed HTML 'disable_html_ns' => true,
@$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); ]);
$doc = $html5->loadHTML($html);
return $this->engine->run($doc); return $this->engine->run($doc);
} }
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace YakNet\AccessibilityConsole\Core\SelfHealing;
use YakNet\AccessibilityConsole\Core\Violation;
class LocalHealer
{
/**
* Attempt to deterministically fix simple violations locally.
*
* @param Violation $violation
* @return string|null
*/
public function heal(Violation $violation): ?string
{
$snippet = $violation->htmlSnippet;
$ruleId = $violation->ruleId;
switch ($ruleId) {
case 'WCAG_2_2_2_BLINK':
$fixed = str_replace(
['<blink>', '</blink>', '<BLINK>', '</BLINK>'],
['<span style="text-decoration: blink;">', '</span>', '<span style="text-decoration: blink;">', '</span>'],
$snippet
);
return "EXPLANATION: Banned <blink> tag replaced with a span and text-decoration style.\nFIX: " . $fixed;
case 'WCAG_2_4_3_AUTOFOCUS':
$fixed = preg_replace('/\s+autofocus(=["\'](.*?)["\'])?/i', '', $snippet) ?: $snippet;
return "EXPLANATION: Autofocus attribute removed to prevent screen reader interruption on load.\nFIX: " . $fixed;
case 'WCAG_1_3_1_TABLE_SUMMARY':
$fixed = preg_replace('/\s+summary=["\'](.*?)["\']/i', '', $snippet) ?: $snippet;
return "EXPLANATION: Deprecated table summary attribute removed. Use caption elements instead.\nFIX: " . $fixed;
case 'WCAG_2_1_1_SCROLLABLE_FOCUS':
if (!str_contains($snippet, 'tabindex')) {
$fixed = preg_replace('/(style=["\'](.*?)["\'])/i', '$1 tabindex="0"', $snippet) ?: $snippet;
return "EXPLANATION: Added tabindex=\"0\" to scrollable region to allow keyboard scrolling.\nFIX: " . $fixed;
}
break;
case 'WCAG_1_3_1_PRESENTATION_TAGS':
if (preg_match('/<center>(.*?)<\/center>/is', $snippet, $matches)) {
$fixed = '<div style="text-align: center;">' . $matches[1] . '</div>';
return "EXPLANATION: Center element replaced with div and text-align CSS.\nFIX: " . $fixed;
}
if (preg_match('/<strike>(.*?)<\/strike>/is', $snippet, $matches)) {
$fixed = '<span style="text-decoration: line-through;">' . $matches[1] . '</span>';
return "EXPLANATION: Strike element replaced with span and line-through decoration CSS.\nFIX: " . $fixed;
}
break;
case 'WCAG_3_1_1_LANG':
if (preg_match('/<html(\s+[^>]*>|>)/i', $snippet)) {
$fixed = preg_replace('/<html/i', '<html lang="en"', $snippet) ?: $snippet;
return "EXPLANATION: Injected default lang=\"en\" to HTML element.\nFIX: " . $fixed;
}
break;
}
return null;
}
}
+7 -1
View File
@@ -2,8 +2,11 @@
namespace YakNet\AccessibilityConsole\Core; namespace YakNet\AccessibilityConsole\Core;
readonly class Violation class Violation
{ {
/**
* @param array{file: string, line: int}|null $location
*/
public function __construct( public function __construct(
public string $ruleId, public string $ruleId,
public string $message, public string $message,
@@ -14,6 +17,9 @@ readonly class Violation
public ?string $fixSuggestion = null public ?string $fixSuggestion = null
) {} ) {}
/**
* @return array{ruleId: string, message: string, severity: string, standard: string, htmlSnippet: string, location: array{file: string, line: int}|null, fixSuggestion: string|null}
*/
public function toArray(): array public function toArray(): array
{ {
return [ return [
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class BladeParser implements ParserInterface
{
public function parse(string $content): string
{
// Replace {!! ... !!} with static text
$content = preg_replace('/\{\!\!(.*?)\!\!\}/s', 'StaticRawBladeContent', $content) ?? $content;
// Replace {{ ... }} with static text
$content = preg_replace('/\{\{\s*(.*?)\s*\}\}/s', 'StaticBladeContent', $content) ?? $content;
// Strip Blade directives like @if, @else, @endif, @foreach, @endforeach, etc.
$content = preg_replace('/@[a-zA-Z0-9_]+\s*(\(.*?\))?/i', '', $content) ?? $content;
return $content;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class HtmlParser implements ParserInterface
{
public function parse(string $content): string
{
return $content;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
interface ParserInterface
{
/**
* Parse template file contents and strip out dynamic content placeholders
* to return clean, standard HTML for scanning.
*
* @param string $content
* @return string
*/
public function parse(string $content): string;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class TwigParser implements ParserInterface
{
public function parse(string $content): string
{
// Strip Twig comments {# ... #}
$content = preg_replace('/\{\#.*?\#\}/s', '', $content) ?? $content;
// Replace Twig output {{ ... }} with static text
$content = preg_replace('/\{\{\s*(.*?)\s*\}\}/s', 'StaticTwigOutput', $content) ?? $content;
// Strip Twig statements {% ... %}
$content = preg_replace('/\{\%.*?\%\}/s', '', $content) ?? $content;
return $content;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagCriterion
{
public function __construct(
public string $id,
public string $title,
public string $level,
public string $url,
public string $description,
public string $badExample,
public string $goodExample
) {}
/**
* @return array{id: string, title: string, level: string, url: string, description: string, badExample: string, goodExample: string}
*/
public function toArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'level' => $this->level,
'url' => $this->url,
'description' => $this->description,
'badExample' => $this->badExample,
'goodExample' => $this->goodExample,
];
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagDatabase
{
/** @var array<string, WcagCriterion> */
private static array $criteria = [];
public static function load(): void
{
if (!empty(self::$criteria)) {
return;
}
self::add(new WcagCriterion(
id: 'WCAG_2_2_2_BLINK',
title: '2.2.2 Pause, Stop, Hide',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html',
description: 'The <blink> tag causes flashing text, presenting significant cognitive and visual accessibility barriers.',
badExample: '<blink>Blinking Text</blink>',
goodExample: '<span class="attention-style">Styled Text</span>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_DUPLICATE_H1',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: 'Pages should contain exactly one h1 element to present a clear hierarchy and entry point for screen readers.',
badExample: '<h1>Title</h1><h1>Another Title</h1>',
goodExample: '<h1>Title</h1><h2>Subtitle</h2>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_5_AUTOCOMPLETE',
title: '1.3.5 Identify Input Purpose',
level: 'AA',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html',
description: 'Inputs collecting user personal info must have autocomplete attributes to assist users with cognitive impairments.',
badExample: '<input type="email" name="email">',
goodExample: '<input type="email" name="email" autocomplete="email">'
));
self::add(new WcagCriterion(
id: 'WCAG_2_1_1_SCROLLABLE_FOCUS',
title: '2.1.1 Keyboard',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html',
description: 'Scrollable regions must be focusable using keyboard navigation (tabindex="0") so keyboard-only users can scroll them.',
badExample: '<div style="overflow: scroll; height: 100px;">Scrollable content</div>',
goodExample: '<div style="overflow: scroll; height: 100px;" tabindex="0">Scrollable content</div>'
));
self::add(new WcagCriterion(
id: 'WCAG_4_1_2_ARIA_LABELLEDBY',
title: '4.1.2 Name, Role, Value',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
description: 'aria-labelledby and aria-describedby attributes must refer to existing element IDs to prevent screen readers from reading empty contexts.',
badExample: '<button aria-labelledby="missing_id">Submit</button>',
goodExample: '<div id="btn_lbl">Submit Form</div><button aria-labelledby="btn_lbl">Submit</button>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_1_1_ALT_PLACEHOLDER',
title: '1.1.1 Non-text Content',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
description: 'Alt attributes must not contain placeholder words or file names. They should provide a meaningful description of the image content.',
badExample: '<img src="logo.png" alt="logo">',
goodExample: '<img src="logo.png" alt="YakNet Company Logo">'
));
self::add(new WcagCriterion(
id: 'WCAG_1_1_1_SVG',
title: '1.1.1 Non-text Content',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
description: 'Inline SVGs must either contain alternative descriptions or be hidden from assistive technologies using aria-hidden="true".',
badExample: '<svg><circle cx="5" cy="5" r="5"></svg>',
goodExample: '<svg aria-hidden="true"><circle cx="5" cy="5" r="5"></svg>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_TABLE_SUMMARY',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: 'The summary attribute on <table> is deprecated in HTML5. Use <caption> elements for tables description instead.',
badExample: '<table summary="Table Summary">',
goodExample: '<table><caption>Table Description</caption>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_PRESENTATION_TAGS',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: 'Obsolete presentation tags like <center> and <strike> are banned. Use standard CSS classes or styling.',
badExample: '<center>Centered Text</center>',
goodExample: '<span style="text-align: center; display: block;">Centered Text</span>'
));
self::add(new WcagCriterion(
id: 'WCAG_2_4_3_AUTOFOCUS',
title: '2.4.3 Focus Order',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html',
description: 'autofocus attribute can cause keyboard traps and disorient blind/low-vision users on page load. Focus should be managed contextually.',
badExample: '<input name="search" autofocus>',
goodExample: '<input name="search">'
));
}
public static function add(WcagCriterion $criterion): void
{
self::$criteria[$criterion->id] = $criterion;
}
public static function get(string $ruleId): ?WcagCriterion
{
self::load();
return self::$criteria[$ruleId] ?? null;
}
/**
* @return array<string, WcagCriterion>
*/
public static function all(): array
{
self::load();
return self::$criteria;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagReference
{
/**
* Look up the WCAG Success Criterion for a given rule ID.
*
* @param string $ruleId
* @return WcagCriterion|null
*/
public static function lookup(string $ruleId): ?WcagCriterion
{
return WcagDatabase::get($ruleId);
}
/**
* Get a list of all WCAG criteria mapped in the console.
*
* @return array<string, WcagCriterion>
*/
public static function getCatalog(): array
{
return WcagDatabase::all();
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class ConsoleFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
if (empty($violations)) {
$io->success("No violations found!");
if ($baselinedCount > 0) {
$io->comment("($baselinedCount violations ignored by baseline)");
}
return Command::SUCCESS;
}
// Group by file
$grouped = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$grouped[$file][] = $v;
}
// Sort files alphabetically for stable order
ksort($grouped);
foreach ($grouped as $file => $fileViolations) {
$io->section("File: " . $file);
// Sort violations by line number
usort($fileViolations, function ($a, $b) {
return ($a->location['line'] ?? 0) <=> ($b->location['line'] ?? 0);
});
foreach ($fileViolations as $v) {
$line = $v->location['line'] ?? 0;
$severityColor = 'red';
if ($v->severity->value === 'warning') {
$severityColor = 'yellow';
} elseif ($v->severity->value === 'info') {
$severityColor = 'cyan';
}
// Format the output
$io->writeln(sprintf(
" <fg=gray>Line %d:</> <fg=%s;options=bold>[%s]</> %s",
$line,
$severityColor,
$v->ruleId,
$v->message
));
// Show snippet
$snippet = (string)preg_replace('/\s+/', ' ', trim($v->htmlSnippet));
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($snippet) . "</>");
if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') {
$io->writeln(" <fg=gray>AI Suggestion:</> <fg=green>" . htmlspecialchars(trim($v->fixSuggestion)) . "</>");
}
$io->writeln("");
}
}
$totalViolations = count($violations);
$io->error(sprintf(
"Found %d %s across %d %s.",
$totalViolations,
$totalViolations === 1 ? 'violation' : 'violations',
count($grouped),
count($grouped) === 1 ? 'file' : 'files'
));
if ($baselinedCount > 0) {
$io->comment("($baselinedCount violations ignored by baseline)");
}
return Command::FAILURE;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class CsvFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$out = fopen('php://temp', 'w+');
if ($out === false) {
return Command::FAILURE;
}
// Write header
fputcsv($out, ['Rule ID', 'Severity', 'Standard', 'File', 'Line', 'Message', 'HTML Snippet', 'Fix Suggestion'], ',', '"', '\\');
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
fputcsv($out, [
$v->ruleId,
$v->severity->value,
$v->standard->value,
$file,
$line,
$v->message,
$v->htmlSnippet,
$v->fixSuggestion
], ',', '"', '\\');
}
rewind($out);
$csv = stream_get_contents($out);
fclose($out);
$io->write($csv ?: '');
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
interface FormatterInterface
{
/**
* Format and output the analysis violations.
*
* @param Violation[] $violations
* @param int $baselinedCount
* @param SymfonyStyle $io
* @return int Exit code (Command::SUCCESS or Command::FAILURE)
*/
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int;
}
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\Severity;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class GitLabCodeQualityFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$quality = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 1;
// Map our severity to GitLab code quality severities:
// info, minor, major, critical, blocker
$gitLabSeverity = 'major';
if ($v->severity === Severity::CRITICAL) {
$gitLabSeverity = 'critical';
} elseif ($v->severity === Severity::WARNING) {
$gitLabSeverity = 'minor';
}
$quality[] = [
'description' => sprintf('[%s] %s', $v->ruleId, $v->message),
'fingerprint' => md5($v->ruleId . ':' . $file . ':' . $line . ':' . $v->htmlSnippet),
'severity' => $gitLabSeverity,
'location' => [
'path' => $file,
'lines' => [
'begin' => $line
]
]
];
}
$io->write((string)json_encode($quality, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class GithubFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
if (empty($violations)) {
return Command::SUCCESS;
}
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
$line = $v->location['line'] ?? 1;
// Normalize path separators to forward slashes for Github Actions compatibility
$file = str_replace('\\', '/', $file);
$type = ($v->severity->value === 'error') ? 'error' : 'warning';
$io->writeln(sprintf(
"::%s file=%s,line=%d::[%s] %s",
$type,
$file,
$line,
$v->ruleId,
$v->message
));
}
return Command::FAILURE;
}
}
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class JsonFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
$result = [
'totals' => [
'violations' => count($violations),
'baselined' => $baselinedCount
],
'violations' => $serialized
];
$io->write((string)json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class MarkdownFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$md = [];
$md[] = "# Accessibility Scan Report";
$md[] = "";
$md[] = sprintf("- **Total Violations:** %d", count($violations));
$md[] = sprintf("- **Baselined Violations:** %d", $baselinedCount);
$md[] = "";
$md[] = "## Detailed Violations";
$md[] = "";
if (empty($violations)) {
$md[] = "*No violations found!* 🎉";
} else {
foreach ($violations as $index => $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
$md[] = sprintf("### %d. %s [%s - %s]", $index + 1, $v->ruleId, strtoupper($v->severity->value), $v->standard->value);
$md[] = sprintf("- **File:** `%s` on line %d", $file, $line);
$md[] = sprintf("- **Message:** %s", $v->message);
if ($v->fixSuggestion) {
$md[] = sprintf("- **Fix Suggestion:** %s", $v->fixSuggestion);
}
$md[] = "- **Snippet:**";
$md[] = " ```html";
$md[] = " " . trim($v->htmlSnippet);
$md[] = " ```";
$md[] = "";
}
}
$io->write(implode("\n", $md));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class XmlFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$dom = new \DOMDocument('1.0', 'utf-8');
$dom->formatOutput = true;
$root = $dom->createElement('report');
$root->setAttribute('totalViolations', (string)count($violations));
$root->setAttribute('baselinedViolations', (string)$baselinedCount);
$dom->appendChild($root);
$violationsNode = $dom->createElement('violations');
$root->appendChild($violationsNode);
foreach ($violations as $v) {
$node = $dom->createElement('violation');
$node->setAttribute('ruleId', $v->ruleId);
$node->setAttribute('severity', $v->severity->value);
$node->setAttribute('standard', $v->standard->value);
$msg = $dom->createElement('message', htmlspecialchars($v->message));
$node->appendChild($msg);
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
$loc = $dom->createElement('location');
$loc->setAttribute('file', $file);
$loc->setAttribute('line', (string)$line);
$node->appendChild($loc);
$snippet = $dom->createElement('htmlSnippet');
$snippet->appendChild($dom->createCDATASection($v->htmlSnippet));
$node->appendChild($snippet);
if ($v->fixSuggestion) {
$sug = $dom->createElement('suggestion', htmlspecialchars($v->fixSuggestion));
$node->appendChild($sug);
}
$violationsNode->appendChild($node);
}
$io->write($dom->saveXML() ?: '');
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+15 -1
View File
@@ -14,14 +14,28 @@ abstract class AbstractRule implements RuleInterface
abstract public function getStandard(): WCAGStandard; abstract public function getStandard(): WCAGStandard;
abstract public function getSeverity(): Severity; abstract public function getSeverity(): Severity;
public function getLevel(): int
{
return 4;
}
protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation
{ {
$doc = $element->ownerDocument;
$html = '';
if ($doc !== null) {
$htmlVal = $doc->saveHTML($element);
if (is_string($htmlVal)) {
$html = $htmlVal;
}
}
return new Violation( return new Violation(
ruleId: $this->getId(), ruleId: $this->getId(),
message: $message, message: $message,
severity: $this->getSeverity(), severity: $this->getSeverity(),
standard: $this->getStandard(), standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element), htmlSnippet: $html,
fixSuggestion: $suggestion fixSuggestion: $suggestion
); );
} }
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AccesskeyBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_ACCESSKEY'; }
public function getDescription(): string { return 'The accesskey attribute should not be used as it conflicts with browser and screen reader shortcuts.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if ($element->hasAttribute('accesskey')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the accesskey attribute. Use natural focus order and standard keyboard event listeners instead.'
);
}
return null;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaFocusable extends AbstractRule
{
/** @var array<int, string> */
private static array $interactiveRoles = [
'button', 'link', 'checkbox', 'menuitem', 'radio', 'textbox',
'searchbox', 'slider', 'spinbutton', 'switch', 'tab'
];
/** @var array<int, string> */
private static array $nativeFocusable = [
'a', 'button', 'input', 'select', 'textarea', 'iframe'
];
public function getId(): string { return 'WCAG_2_1_1_ARIA_FOCUSABLE'; }
public function getDescription(): string { return 'Elements with interactive ARIA roles must be focusable (e.g. have a tabindex attribute).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$role = strtolower(trim($element->getAttribute('role')));
if (!in_array($role, self::$interactiveRoles, true)) {
return null;
}
$tagName = strtolower($element->tagName);
if (in_array($tagName, self::$nativeFocusable, true)) {
return null;
}
if (!$element->hasAttribute('tabindex')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add tabindex="0" to make the element focusable via keyboard navigation.'
);
}
return null;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaHiddenFocusable extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_HIDDEN_FOCUS'; }
public function getDescription(): string { return 'Focusable elements must not be hidden with aria-hidden="true".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
if ($element->getAttribute('aria-hidden') !== 'true') {
return null;
}
if ($this->isFocusable($element) || $this->hasFocusableDescendant($element)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove aria-hidden="true" from the container or make its focusable elements non-focusable (e.g. by setting tabindex="-1" or disabled).'
);
}
return null;
}
private function isFocusable(DOMElement $element): bool
{
$tagName = strtolower($element->tagName);
if ($element->hasAttribute('tabindex')) {
$tabindex = (int)$element->getAttribute('tabindex');
if ($tabindex < 0) {
return false;
}
return true;
}
if ($tagName === 'a' && $element->hasAttribute('href')) {
return true;
}
if (in_array($tagName, ['button', 'select', 'textarea'], true)) {
return !$element->hasAttribute('disabled');
}
if ($tagName === 'input') {
$type = strtolower($element->getAttribute('type'));
return $type !== 'hidden' && !$element->hasAttribute('disabled');
}
if ($tagName === 'iframe') {
return true;
}
return false;
}
private function hasFocusableDescendant(DOMElement $element): bool
{
$doc = $element->ownerDocument;
if ($doc === null) {
return false;
}
$xpath = new \DOMXPath($doc);
$descendants = $xpath->query('.//*', $element);
if ($descendants === false) {
return false;
}
foreach ($descendants as $descendant) {
if ($descendant instanceof DOMElement && $this->isFocusable($descendant)) {
return true;
}
}
return false;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaLabelledbyExists extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_ARIA_LABELLEDBY'; }
public function getDescription(): string { return 'aria-labelledby and aria-describedby attributes must refer to existing element IDs.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
$attrs = ['aria-labelledby', 'aria-describedby'];
foreach ($attrs as $attr) {
if ($element->hasAttribute($attr)) {
$ids = preg_split('/\s+/', trim($element->getAttribute($attr)));
if ($ids === false) {
continue;
}
$doc = $element->ownerDocument;
if (!$doc) {
continue;
}
foreach ($ids as $id) {
if ($id === '') {
continue;
}
$target = $doc->getElementById($id);
if (!$target) {
// XPath fallback in case DOMDocument getElementById fails (which is common without DTD/XML schemas)
$xpath = new \DOMXPath($doc);
$query = $xpath->query("//*[@id='" . addslashes($id) . "']");
if ($query === false || $query->length === 0) {
return $this->createViolation(
$element,
"The attribute $attr refers to a non-existent ID: \"$id\".",
"Ensure the element referenced by ID \"$id\" exists in the document, or correct the ID spelling."
);
}
}
}
}
}
return null;
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRole extends AbstractRule
{
/** @var array<int, string> */
private static array $validRoles = [
'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 'cell', 'checkbox',
'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'dialog', 'directory',
'document', 'feed', 'figure', 'form', 'grid', 'gridcell', 'group', 'heading', 'img', 'link',
'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'menu', 'menubar', 'menuitem',
'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'presentation',
'progressbar', 'radio', 'radiogroup', 'region', 'row', 'rowgroup', 'rowheader', 'scrollbar',
'search', 'searchbox', 'separator', 'slider', 'spinbutton', 'status', 'switch', 'tab', 'table',
'tablist', 'tabpanel', 'term', 'textbox', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid',
'treeitem'
];
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
// 1. Check WAI-ARIA Role Validity
$roleElements = $xpath->query('//*[@role]');
if ($roleElements !== false) {
foreach ($roleElements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$role = trim($el->getAttribute('role'));
if ($role === '') {
continue;
}
$roles = array_filter(explode(' ', $role));
foreach ($roles as $r) {
if (!in_array(strtolower($r), self::$validRoles, true)) {
$violations[] = $this->createViolation(
"Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.",
$el
);
}
}
}
}
// 2. Check aria-controls Targets Existence
$controlsElements = $xpath->query('//*[@aria-controls]');
if ($controlsElements !== false) {
foreach ($controlsElements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$targetsStr = trim($el->getAttribute('aria-controls'));
if ($targetsStr === '') {
continue;
}
$targets = array_filter(explode(' ', $targetsStr));
foreach ($targets as $targetId) {
$targetQuery = $xpath->query('//*[@id="' . $targetId . '"]');
if ($targetQuery !== false && $targetQuery->length === 0) {
$violations[] = $this->createViolation(
"Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.",
$el
);
}
}
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
}
+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;
}
}
+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;
}
}
+6
View File
@@ -13,6 +13,7 @@ class ButtonName extends AbstractRule
public function getDescription(): string { return 'Buttons must have discernible text.'; } public function getDescription(): string { return 'Buttons must have discernible text.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; } public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
@@ -20,6 +21,11 @@ class ButtonName extends AbstractRule
return null; return null;
} }
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
$text = trim($element->textContent); $text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) { if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.'); return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.');
+315
View File
@@ -0,0 +1,315 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ColorContrast extends AbstractRule
{
/** @var array<string, array<string, array{color?: string, background-color?: string}>> */
private array $documentStylesCache = [];
public function getId(): string { return 'WCAG_1_4_3_CONTRAST'; }
public function getDescription(): string { return 'Text must have sufficient contrast against the background (4.5:1 minimum).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
// 1. Parse inline styles (highest priority)
$inlineStyle = $element->hasAttribute('style') ? $element->getAttribute('style') : '';
$color = $this->parseColorValue($inlineStyle, 'color');
$bg = $this->parseColorValue($inlineStyle, 'background-color') ?: $this->parseColorValue($inlineStyle, 'background');
// 2. Fallback: Parse stylesheet styles
if (($color === null || $bg === null) && $element->ownerDocument !== null) {
$docStyles = $this->parseDocumentStyles($element->ownerDocument);
// Loop through all parsed styles. Later matching rules override earlier ones
foreach ($docStyles as $selector => $styles) {
if ($this->elementMatchesSelector($element, $selector)) {
if ($color === null && isset($styles['color'])) {
$color = $styles['color'];
}
if ($bg === null && isset($styles['background-color'])) {
$bg = $styles['background-color'];
}
}
}
}
if ($color === null || $bg === null) {
return null;
}
$cRGB = $this->hexToRgb($color);
$bgRGB = $this->hexToRgb($bg);
if ($cRGB === null || $bgRGB === null) {
return null;
}
$cLum = $this->getLuminance($cRGB[0], $cRGB[1], $cRGB[2]);
$bgLum = $this->getLuminance($bgRGB[0], $bgRGB[1], $bgRGB[2]);
$ratio = ($cLum > $bgLum) ? ($cLum + 0.05) / ($bgLum + 0.05) : ($bgLum + 0.05) / ($cLum + 0.05);
// Check font size & weight for large text rules
$isLarge = false;
$fontSizeStyle = $this->parseFontSize($inlineStyle, $element);
if ($fontSizeStyle !== null) {
$isLarge = $this->isLargeText($fontSizeStyle, $this->parseFontWeight($inlineStyle, $element));
}
$minRatio = $isLarge ? 3.0 : 4.5;
if ($ratio < $minRatio) {
$formattedRatio = round($ratio, 2);
return $this->createViolation(
$element,
"Insufficient contrast ratio of {$formattedRatio}:1. Minimum required is {$minRatio}:1.",
"Adjust colors to increase contrast. For example, text: {$color}, background: {$bg}."
);
}
return null;
}
private function parseFontSize(string $style, DOMElement $element): ?string
{
if (preg_match('/font-size\s*:\s*([^;]+)/i', $style, $matches)) {
return trim($matches[1]);
}
return null;
}
private function parseFontWeight(string $style, DOMElement $element): ?string
{
if (preg_match('/font-weight\s*:\s*([^;]+)/i', $style, $matches)) {
return trim($matches[1]);
}
return null;
}
private function isLargeText(string $size, ?string $weight): bool
{
if (preg_match('/([0-9.]+)(px|em|rem|pt)/i', $size, $matches)) {
$val = (float)$matches[1];
$unit = strtolower($matches[2]);
$isBold = $weight !== null && preg_match('/^(bold|[7-9]00)$/i', $weight);
if ($isBold) {
return ($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2);
} else {
return ($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5);
}
}
return false;
}
/**
* @return array<string, array{color?: string, background-color?: string}>
*/
private function parseDocumentStyles(\DOMDocument $doc): array
{
$hash = spl_object_hash($doc);
if (isset($this->documentStylesCache[$hash])) {
return $this->documentStylesCache[$hash];
}
$mergedStyles = [];
$styleTags = $doc->getElementsByTagName('style');
foreach ($styleTags as $tag) {
$cssText = $tag->textContent;
if (trim($cssText) === '') {
continue;
}
try {
$parser = new \Sabberworm\CSS\Parser($cssText);
$cssDoc = $parser->parse();
foreach ($cssDoc->getAllDeclarationBlocks() as $block) {
$rules = [];
foreach ($block->getDeclarations() as $decl) {
$prop = strtolower($decl->getPropertyName());
if ($prop === 'color' || $prop === 'background-color' || $prop === 'background') {
// Extract string value
$valObj = $decl->getValue();
$val = is_object($valObj) && method_exists($valObj, '__toString')
? (string)$valObj
: (is_string($valObj) ? $valObj : '');
if ($prop === 'color') {
$normalized = $this->normalizeColorValue($val);
if ($normalized !== null) {
$rules['color'] = $normalized;
}
} elseif ($prop === 'background-color') {
$normalized = $this->normalizeColorValue($val);
if ($normalized !== null) {
$rules['background-color'] = $normalized;
}
} elseif ($prop === 'background') {
$normalized = $this->extractColorFromBackground($val);
if ($normalized !== null) {
$rules['background-color'] = $normalized;
}
}
}
}
if (empty($rules)) {
continue;
}
foreach ($block->getSelectors() as $selector) {
$selectorString = trim($selector->getSelector());
if ($selectorString !== '') {
if (!isset($mergedStyles[$selectorString])) {
$mergedStyles[$selectorString] = [];
}
$mergedStyles[$selectorString] = array_merge($mergedStyles[$selectorString], $rules);
}
}
}
} catch (\Throwable $e) {
// Ignore CSS parser failures
}
}
$this->documentStylesCache[$hash] = $mergedStyles;
return $mergedStyles;
}
private function elementMatchesSelector(DOMElement $element, string $selector): bool
{
$parts = array_map('trim', explode(',', $selector));
foreach ($parts as $part) {
if ($part === '') {
continue;
}
if (str_starts_with($part, '#')) {
$id = substr($part, 1);
if ($element->getAttribute('id') === $id) {
return true;
}
continue;
}
if (str_starts_with($part, '.')) {
$class = substr($part, 1);
$classes = array_filter(array_map('trim', explode(' ', $element->getAttribute('class'))));
if (in_array($class, $classes, true)) {
return true;
}
continue;
}
if (strtolower($element->tagName) === strtolower($part)) {
return true;
}
if (preg_match('/^([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)$/', $part, $matches)) {
$tag = $matches[1];
$class = $matches[2];
$classes = array_filter(array_map('trim', explode(' ', $element->getAttribute('class'))));
if (strtolower($element->tagName) === strtolower($tag) && in_array($class, $classes, true)) {
return true;
}
}
if (preg_match('/^([a-zA-Z0-9_-]+)#([a-zA-Z0-9_-]+)$/', $part, $matches)) {
$tag = $matches[1];
$id = $matches[2];
if (strtolower($element->tagName) === strtolower($tag) && $element->getAttribute('id') === $id) {
return true;
}
}
}
return false;
}
private function normalizeColorValue(string $val): ?string
{
$val = trim($val);
if (preg_match('/^#([0-9a-fA-F]{3,6})$/', $val)) {
return $val;
}
if (preg_match('/rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)/i', $val, $matches)) {
return sprintf("#%02x%02x%02x", (int)$matches[1], (int)$matches[2], (int)$matches[3]);
}
$colors = [
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
];
if (isset($colors[strtolower($val)])) {
return $colors[strtolower($val)];
}
return null;
}
private function extractColorFromBackground(string $val): ?string
{
$parts = explode(' ', $val);
foreach ($parts as $part) {
$color = $this->normalizeColorValue($part);
if ($color !== null) {
return $color;
}
}
return null;
}
private function parseColorValue(string $style, string $property): ?string
{
if (preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:\s*([^;]+)/i', $style, $matches)) {
return $this->normalizeColorValue(trim($matches[1]));
}
return null;
}
/**
* @return array{0: int, 1: int, 2: int}|null
*/
private function hexToRgb(string $hex): ?array
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$r = (int)hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
$g = (int)hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
$b = (int)hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
return [$r, $g, $b];
} elseif (strlen($hex) === 6) {
$r = (int)hexdec(substr($hex, 0, 2));
$g = (int)hexdec(substr($hex, 2, 2));
$b = (int)hexdec(substr($hex, 4, 2));
return [$r, $g, $b];
}
return null;
}
private function getLuminance(float $r, float $g, float $b): float
{
$rs = $r / 255.0;
$gs = $g / 255.0;
$bs = $b / 255.0;
$rVal = ($rs <= 0.03928) ? $rs / 12.92 : pow(($rs + 0.055) / 1.055, 2.4);
$gVal = ($gs <= 0.03928) ? $gs / 12.92 : pow(($gs + 0.055) / 1.055, 2.4);
$bVal = ($bs <= 0.03928) ? $bs / 12.92 : pow(($bs + 0.055) / 1.055, 2.4);
return 0.2126 * $rVal + 0.7152 * $gVal + 0.0722 * $bVal;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DuplicateH1 extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_DUPLICATE_H1'; }
public function getDescription(): string { return 'Pages should not contain more than one h1 element.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'h1') {
return null;
}
$doc = $element->ownerDocument;
if ($doc) {
$h1s = $doc->getElementsByTagName('h1');
if ($h1s->length > 1 && $h1s->item(0) !== $element) {
return $this->createViolation(
$element,
$this->getDescription(),
'Change this heading to an h2 or lower level heading, keeping only one h1 per page.'
);
}
}
return null;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DuplicateId extends AbstractRule
{
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*[@id]');
if ($elements === false) {
return [];
}
$ids = [];
foreach ($elements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$id = $el->getAttribute('id');
if ($id === '') {
continue;
}
if (!isset($ids[$id])) {
$ids[$id] = [];
}
$ids[$id][] = $el;
}
foreach ($ids as $id => $elementsWithId) {
if (count($elementsWithId) > 1) {
foreach ($elementsWithId as $el) {
$violations[] = $this->createViolation(
"Duplicate ID \"{$id}\" found. Element ID attribute must be unique across the document.",
$el
);
}
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyHeading extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_EMPTY_HEADING'; }
public function getDescription(): string { return 'Heading elements (h1-h6) must have discernible text content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (!preg_match('/^h([1-6])$/i', $element->tagName)) {
return null;
}
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add descriptive text or an aria-label to the heading element.'
);
}
return null;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyLabel extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_EMPTY_LABEL'; }
public function getDescription(): string { return 'Label elements must have discernible text content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'label') {
return null;
}
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
$text = trim($element->textContent);
if ($text === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add text content to the label element to describe its associated input.'
);
}
return null;
}
}
+6
View File
@@ -13,6 +13,7 @@ class EmptyLink extends AbstractRule
public function getDescription(): string { return 'Links must have discernible text.'; } public function getDescription(): string { return 'Links must have discernible text.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; } public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
@@ -20,6 +21,11 @@ class EmptyLink extends AbstractRule
return null; return null;
} }
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
$text = trim($element->textContent); $text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) { if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
// Check for images inside // Check for images inside
+4 -2
View File
@@ -3,6 +3,8 @@
namespace YakNet\AccessibilityConsole\Rules; namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule; use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyLinkRule extends AbstractRule class EmptyLinkRule extends AbstractRule
{ {
@@ -22,6 +24,6 @@ class EmptyLinkRule extends AbstractRule
return $violations; return $violations;
} }
public function getStandardId(): string { return 'WCAG 2.1 2.4.4'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): string { return 'Critical'; } public function getSeverity(): Severity { return Severity::CRITICAL; }
} }
+1
View File
@@ -13,6 +13,7 @@ class FieldsetLegend extends AbstractRule
public function getDescription(): string { return 'Fieldsets should have a legend.'; } public function getDescription(): string { return 'Fieldsets should have a legend.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; } public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FontTagBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_FONT_TAG_BANNED'; }
public function getDescription(): string { return 'The font tag is obsolete and should not be used. Use CSS for styling instead.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) === 'font') {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the <font> tag and style the text using CSS instead.'
);
}
return null;
}
}
+8 -4
View File
@@ -13,6 +13,7 @@ class FormLabel extends AbstractRule
public function getDescription(): string { return 'Form inputs must have an associated label.'; } public function getDescription(): string { return 'Form inputs must have an associated label.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; } public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
@@ -29,10 +30,13 @@ class FormLabel extends AbstractRule
if ($element->hasAttribute('id')) { if ($element->hasAttribute('id')) {
$id = $element->getAttribute('id'); $id = $element->getAttribute('id');
$xpath = new \DOMXPath($element->ownerDocument); $doc = $element->ownerDocument;
$labels = $xpath->query("//label[@for='$id']"); if ($doc !== null) {
if ($labels->length > 0) { $xpath = new \DOMXPath($doc);
return null; $labels = $xpath->query("//label[@for='$id']");
if ($labels !== false && $labels->length > 0) {
return null;
}
} }
} }
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FormSubmitButton extends AbstractRule
{
public function getId(): string { return 'WCAG_3_2_2_FORM_SUBMIT'; }
public function getDescription(): string { return 'Every form must contain a submit button to enable keyboard submission.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'form') {
return null;
}
$hasSubmit = false;
$buttons = $element->getElementsByTagName('button');
foreach ($buttons as $btn) {
$type = strtolower($btn->getAttribute('type') ?: 'submit');
if ($type === 'submit') {
$hasSubmit = true;
break;
}
}
if (!$hasSubmit) {
$inputs = $element->getElementsByTagName('input');
foreach ($inputs as $input) {
$type = strtolower($input->getAttribute('type'));
if ($type === 'submit' || $type === 'image') {
$hasSubmit = true;
break;
}
}
}
if (!$hasSubmit) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a submit control, such as a <button type="submit"> or <input type="submit"> inside the form.'
);
}
return null;
}
}
+1
View File
@@ -15,6 +15,7 @@ class HeadingOrder extends AbstractRule
public function getDescription(): string { return 'Headings should follow a logical nesting order.'; } public function getDescription(): string { return 'Headings should follow a logical nesting order.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; } public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
+10 -2
View File
@@ -3,6 +3,8 @@
namespace YakNet\AccessibilityConsole\Rules; namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule; use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class HeadingOrderRule extends AbstractRule class HeadingOrderRule extends AbstractRule
{ {
@@ -11,9 +13,15 @@ class HeadingOrderRule extends AbstractRule
$violations = []; $violations = [];
$xpath = new \DOMXPath($doc); $xpath = new \DOMXPath($doc);
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6'); $headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
if ($headings === false) {
return [];
}
$lastLevel = 0; $lastLevel = 0;
foreach ($headings as $h) { foreach ($headings as $h) {
if (!$h instanceof \DOMElement) {
continue;
}
$level = (int)substr($h->tagName, 1); $level = (int)substr($h->tagName, 1);
if ($lastLevel > 0 && $level > $lastLevel + 1) { if ($lastLevel > 0 && $level > $lastLevel + 1) {
$violations[] = $this->createViolation( $violations[] = $this->createViolation(
@@ -26,6 +34,6 @@ class HeadingOrderRule extends AbstractRule
return $violations; return $violations;
} }
public function getStandardId(): string { return 'WCAG 2.1 1.3.1'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): string { return 'Major'; } public function getSeverity(): Severity { return Severity::WARNING; }
} }
+1
View File
@@ -13,6 +13,7 @@ class HtmlHasLang extends AbstractRule
public function getDescription(): string { return 'The html element must have a lang attribute.'; } public function getDescription(): string { return 'The html element must have a lang attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; } public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class IframeTitle extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_IFRAME_TITLE'; }
public function getDescription(): string { return 'Iframe elements must have a non-empty title attribute to describe their content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'iframe') {
return null;
}
if (!$element->hasAttribute('title') || trim($element->getAttribute('title')) === '') {
return $this->createViolation(
$element,
"Iframe element is missing a descriptive title attribute.",
"Add a descriptive title attribute, e.g., title=\"Embedded Map\" or title=\"Video Player\"."
);
}
return null;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltLong extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT_LONG'; }
public function getDescription(): string { return 'Image alt text should be concise (less than 150 characters).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
return null;
}
$alt = trim($element->getAttribute('alt'));
if (mb_strlen($alt) > 150) {
return $this->createViolation(
$element,
$this->getDescription(),
'Shorten the alt text description to be under 150 characters, and move longer explanations to standard page content.'
);
}
return null;
}
}
+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 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'
];
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('/\.(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;
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltRedundant extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT_REDUNDANT'; }
public function getDescription(): string { return 'Image alt text should not contain redundant words like "image" or "photo".'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
/** @var array<int, string> */
private array $redundantWords = [
'resmi', 'görüntüsü', 'fotoğrafı', 'grafiği', 'logosu', 'ikonu',
'image of', 'photo of', 'picture of', 'graphic of', 'logo of', 'icon of'
];
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
return null;
}
$alt = trim($element->getAttribute('alt'));
$cleanAlt = strtolower(preg_replace('/\s+/', ' ', $alt) ?? '');
if ($cleanAlt === '') {
return null;
}
foreach ($this->redundantWords as $word) {
if (str_starts_with($cleanAlt, $word) || str_contains($cleanAlt, ' ' . $word)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove redundant phrases like "' . $word . '" from the alt text. Screen readers already announce elements as images.'
);
}
}
return null;
}
}
+4 -2
View File
@@ -3,6 +3,8 @@
namespace YakNet\AccessibilityConsole\Rules; namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule; use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageAltRule extends AbstractRule class ImageAltRule extends AbstractRule
{ {
@@ -22,6 +24,6 @@ class ImageAltRule extends AbstractRule
return $violations; return $violations;
} }
public function getStandardId(): string { return 'WCAG 2.1 1.1.1'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): string { return 'Critical'; } public function getSeverity(): Severity { return Severity::CRITICAL; }
} }
+1
View File
@@ -13,6 +13,7 @@ class ImgAltText extends AbstractRule
public function getDescription(): string { return 'Images must have an alt attribute.'; } public function getDescription(): string { return 'Images must have an alt attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; } public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation public function check(DOMElement $element): ?Violation
{ {
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputTypeButtonDiscernible extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_INPUT_BUTTON'; }
public function getDescription(): string { return 'Input buttons (button, submit, reset) must have discernible text.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
$type = strtolower($element->getAttribute('type'));
if (in_array($type, ['button', 'submit', 'reset'], true)) {
$value = trim($element->getAttribute('value'));
$ariaLabel = trim($element->getAttribute('aria-label'));
$title = trim($element->getAttribute('title'));
if ($value === '' && $ariaLabel === '' && $title === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a non-empty value, aria-label, or title attribute to the input button.'
);
}
}
return null;
}
}
+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;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LanguageSubtag extends AbstractRule
{
public function getId(): string { return 'WCAG_3_1_2_LANG'; }
public function getDescription(): string { return 'Language attributes (lang) must use a valid language code format.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('lang')) {
return null;
}
$lang = trim($element->getAttribute('lang'));
// Simple IETF language tag format check (e.g. "en", "tr", "en-US", "zh-Hant")
$isValid = preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/', $lang);
if (!$isValid || $lang === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Use a valid IETF language code format for the lang attribute (e.g., "tr", "en", "en-US").'
);
}
return null;
}
}
+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;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LinkTextGeneric extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_4_LINK_GENERIC'; }
public function getDescription(): string { return 'Links must have descriptive text. Generic text like "click here" or "read more" is not accessible.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
/** @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'
];
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'a') {
return null;
}
$text = trim($element->textContent);
$cleanText = strtolower(preg_replace('/\s+/', ' ', $text) ?? '');
if (in_array($cleanText, $this->genericWords, true)) {
// Check for descriptive attributes
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) {
return null;
}
return $this->createViolation(
$element,
$this->getDescription(),
'Change the link text to describe its target, or add an aria-label attribute to provide context.'
);
}
return null;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class 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;
}
}
+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 MetaRefreshBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_2_1_META_REFRESH'; }
public function getDescription(): string { return 'Do not use meta http-equiv="refresh" for automatic page redirects or updates as it disorients users.'; }
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) !== 'meta') {
return null;
}
if ($element->hasAttribute('http-equiv')) {
$httpEquiv = trim($element->getAttribute('http-equiv'));
if (strtolower($httpEquiv) === 'refresh') {
return $this->createViolation(
$element,
$this->getDescription(),
'Use server-side redirects (e.g. HTTP 301/302) or standard JavaScript routing instead of meta refresh.'
);
}
}
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class NoFocusOutline extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_7_FOCUS_OUTLINE'; }
public function getDescription(): string { return 'Focus outlines should not be disabled in inline styles without providing custom focus styles.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 5; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('style')) {
return null;
}
$style = strtolower($element->getAttribute('style'));
// Match outline: none or outline: 0
if (preg_match('/\boutline\s*:\s*(none|0)\b/', $style)) {
return $this->createViolation(
$element,
$this->getDescription(),
'Avoid disabling focus outlines using outline: none or outline: 0 in inline styles. Ensure keyboard users can see where focus is.'
);
}
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ObjectAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_OBJECT'; }
public function getDescription(): string { return 'Object elements must have alternative text or descriptive content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'object') {
return null;
}
// Object can have title attribute
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// Or object can contain fallback text content.
// We clean up child tags like <param> or <embed> to find actual descriptive text.
$text = '';
foreach ($element->childNodes as $child) {
if ($child instanceof \DOMText) {
$text .= $child->textContent;
} elseif ($child instanceof DOMElement) {
$tagName = strtolower($child->tagName);
if ($tagName !== 'param' && $tagName !== 'embed') {
$text .= $child->textContent;
}
}
}
if (trim($text) === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Add fallback text inside the <object> element or provide a descriptive title attribute.'
);
}
return null;
}
}
+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 PageTitle extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_2_PAGE_TITLE'; }
public function getDescription(): string { return 'Web pages must have a non-empty title element in the head.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'head') {
return null;
}
$titles = $element->getElementsByTagName('title');
if ($titles->length === 0) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a <title> element inside the <head>.'
);
}
$titleText = '';
$firstTitle = $titles->item(0);
if ($firstTitle !== null) {
$titleText = trim($firstTitle->textContent);
}
if ($titleText === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Provide descriptive text inside the <title> element.'
);
}
return null;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class PlaceholderAsLabel extends AbstractRule
{
public function getId(): string { return 'WCAG_3_3_2_PLACEHOLDER_LABEL'; }
public function getDescription(): string { return 'Inputs should not use placeholder text as their only label.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function check(DOMElement $element): ?Violation
{
$tags = ['input', 'select', 'textarea'];
if (!in_array(strtolower($element->tagName), $tags, true)) {
return null;
}
// Skip hidden inputs, buttons, etc.
$type = strtolower($element->getAttribute('type'));
if ($type === 'hidden' || $type === 'submit' || $type === 'button' || $type === 'reset') {
return null;
}
// If it doesn't have a placeholder, this rule doesn't apply (FormLabel handles general missing labels)
if (!$element->hasAttribute('placeholder')) {
return null;
}
// Check if there is an aria-label or aria-labelledby
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) {
return null;
}
// Check for associated label element
if ($element->hasAttribute('id')) {
$id = $element->getAttribute('id');
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//label[@for='$id']");
if ($labels !== false && $labels->length > 0) {
return null;
}
}
}
// Check if wrapped in label
$parent = $element->parentNode;
while ($parent) {
if ($parent instanceof DOMElement && strtolower($parent->tagName) === 'label') {
return null;
}
$parent = $parent->parentNode;
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add a <label for="..."> or an aria-label attribute. Placeholder text is not a reliable label replacement.'
);
}
}

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