Git Komut Rehberi & Terminal Asistanı
Sıfırdan Yeni Bir Depo Başlatın:
Mevcut Bir Git Deposu veya Klasörü Gönderin:
Hataları Geri Alma, İptal & Temizlik:
Son commit'i geri al (yapılan değişiklikleri korur):
git reset --soft HEAD~1
Yapılan yerel değişiklikleri iptal et (çalışma ağacını temizler):
git restore .
Takip edilmeyen gereksiz dosyaları sil:
git clean -fd
Değişiklikleri geçici sakla ve geri getir (stash):
git stash git stash pop
Dal (Branch) ve Sürüm Etiketleri:
Yeni dal aç ve geçiş yap:
git checkout -b yeni-ozellik git push -u origin yeni-ozellik
Sürüm etiketi (tag) oluştur ve gönder:
git tag -a v1.0.0 -m "Sürüm 1.0.0" git push origin --tags
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5165f0be4 | ||
|
|
84c6521195 | ||
|
|
0134482c54 | ||
|
|
c9ffbdba08 | ||
|
|
2a97138fec | ||
|
|
c42ab714ff |
@@ -1,91 +1,139 @@
|
||||
# YakNet Accessibility Console 2.0 (AI-Powered)
|
||||
# YakNet Accessibility Console 2.9 (AI-Powered Static Analysis)
|
||||
|
||||
[](https://php.net)
|
||||
[](LICENSE)
|
||||
[](https://gemini.google.com)
|
||||
|
||||
**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1) hatalarını sadece tespit etmekle kalmayan, aynı zamanda **Yapay Zeka (AI)** desteğiyle bu hataları otomatik olarak düzeltebilen (Self-Healing) profesyonel bir PHP kütüphanesidir.
|
||||
**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1) hatalarını sadece tespit etmekle kalmayan, aynı zamanda **Yapay Zeka (AI)** desteğiyle bu hataları otomatik olarak düzeltebilen (Self-Healing) profesyonel bir PHP static analysis kütüphanesidir.
|
||||
|
||||
PHPStan ve PHP-CS-Fixer esintili yapısıyla, yerel görünüm şablonlarınızı (`.blade.php`, `.twig`, `.html`, `.php`) doğrudan tarayabilir, baseline desteğiyle mevcut hataları yok sayabilir ve CI/CD süreçlerinize tam entegre çalışabilir.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Öne Çıkan Özellikler
|
||||
|
||||
- **🤖 AI Self-Healing:** Tespit edilen WCAG hataları için Google Gemini AI üzerinden akıllı çözüm önerileri alır ve isterseniz kaynak kodunuzu (Blade, Twig, PHP) otomatik olarak tamir eder.
|
||||
- **⚡ Modern CLI:** Rust/Vite tarzı şık bir komut satırı arayüzü ile saniyeler içinde tarama ve düzeltme yapın.
|
||||
- **📊 Görsel Dashboard:** Tarama sonuçlarını modern ve şık bir HTML raporu olarak dışarı aktarın.
|
||||
- **🔍 Akıllı Kaynak Tespiti:** Hataları projenizdeki tam dosya yolu ve satır numarası ile eşleştirir.
|
||||
- **🛠️ Geliştirici Dostu:** PSR-4 uyumlu, modern PHP 8.2+ mimarisi ve genişletilebilir kural motoru.
|
||||
- **🤖 AI Self-Healing:** Tespit edilen WCAG hataları için Google Gemini AI üzerinden akıllı çözüm önerileri alır ve kaynak kodunuzu (Blade, Twig, PHP, HTML) otomatik olarak tamir eder.
|
||||
- **⚡ PHPStan Tarzı Analiz:** Tüm projeyi veya belirli dizinleri komut satırından tarayarak hataları tam dosya yolu ve satır numarası ile listeler.
|
||||
- **🛡️ Baseline Desteği:** `a11y-baseline.json` oluşturarak mevcut erişilebilirlik hatalarını yok sayabilir, CI/CD pipeline'ınızın sadece yeni hatalarda kırılmasını sağlayabilirsiniz.
|
||||
- **⚙️ YAML Konfigürasyonu:** `a11y.yaml` dosyası üzerinden tarama yollarını, hariç tutulacak klasörleri, kural seviyelerini ve özel kural setlerini yapılandırabilirsiniz.
|
||||
- **🔌 Çoklu Çıktı Formatları:**
|
||||
- `console`: Renkli ve düzenli terminal tablosu.
|
||||
- `json`: Makine tarafından okunabilir JSON çıktısı.
|
||||
- `github`: GitHub Actions PR süreçlerinde doğrudan inline hata göstermek için workflow formatı.
|
||||
- **📊 Görsel Raporlama:** Sonuçları modern bir HTML Dashboard raporu olarak dışarı aktarın.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Kurulum
|
||||
|
||||
Composer ile projenize hemen dahil edin:
|
||||
Composer ile projenize dahil edin:
|
||||
|
||||
```bash
|
||||
composer require yaknet/accessibility-console
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 CLI Kullanımı
|
||||
|
||||
Kütüphane, `vendor/bin/a11y` (veya kütüphane dizininde `bin/a11y`) üzerinden kullanılabilir.
|
||||
|
||||
### 1. Standart Tarama
|
||||
Bir URL'yi veya yerel bir HTML dosyasını tarayın:
|
||||
### 1. Yapılandırma Dosyası Oluşturma (`init`)
|
||||
Projenizin kök dizininde varsayılan bir `a11y.yaml` konfigürasyon dosyası oluşturur:
|
||||
```bash
|
||||
bin/a11y scan https://test-siteniz.com
|
||||
bin/a11y init
|
||||
```
|
||||
|
||||
### 2. AI Destekli Tarama ve Raporlama
|
||||
Yapay zeka önerileriyle birlikte tarama yapın ve sonucu HTML olarak kaydedin:
|
||||
### 2. Projeyi Tarama (`scan` / `analyse`)
|
||||
`a11y.yaml` dosyasındaki kurallara ve dizinlere göre tüm projeyi tarar:
|
||||
```bash
|
||||
bin/a11y scan https://test-siteniz.com --ai --report=sonuc.html
|
||||
bin/a11y scan
|
||||
```
|
||||
|
||||
### 3. Otomatik Düzeltme (Self-Healing) - **DEVRİMSEL**
|
||||
Tespit edilen hataları yapay zekaya düzelttirin ve dosyalarınızı güncelleyin:
|
||||
Belirli bir dizini veya dosyayı hedef göstermek için:
|
||||
```bash
|
||||
bin/a11y fix https://test-siteniz.com --project-path=/var/www/projeniz
|
||||
bin/a11y scan resources/views
|
||||
```
|
||||
URL veya tek bir yerel HTML dosyasını taramak için (crawler desteğiyle):
|
||||
```bash
|
||||
bin/a11y scan https://test-siteniz.com --crawl
|
||||
```
|
||||
|
||||
## 💻 Kütüphane Olarak Kullanım
|
||||
|
||||
Kendi PHP uygulamalarınızın içine entegre edin:
|
||||
|
||||
```php
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
|
||||
|
||||
// 1. Scanner'ı ve Kuralları Hazırla
|
||||
$scanner = new Scanner();
|
||||
foreach (StandardRuleSet::all() as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
|
||||
// 2. HTML İçeriğini Tara
|
||||
$html = file_get_contents('view.html');
|
||||
$violations = $scanner->scan($html);
|
||||
|
||||
// 3. Sonuçları İşle
|
||||
foreach ($violations as $v) {
|
||||
echo "Hata: {$v->message} (Kural: {$v->ruleId})\n";
|
||||
}
|
||||
### 3. Baseline Yönetimi
|
||||
Projeye ait mevcut tüm hataları `a11y-baseline.json` dosyasına kaydederek sonraki taramalarda yok sayılmasını sağlar:
|
||||
```bash
|
||||
bin/a11y scan --generate-baseline
|
||||
```
|
||||
|
||||
## ⚙️ Yapılandırma
|
||||
### 4. Format ve Raporlama Seçenekleri
|
||||
Hataları GitHub Actions formatında yazdırmak için:
|
||||
```bash
|
||||
bin/a11y scan --format=github
|
||||
```
|
||||
HTML Raporu kaydetmek için:
|
||||
```bash
|
||||
bin/a11y scan --report=report.html
|
||||
```
|
||||
|
||||
AI özelliklerini kullanabilmek için projenizin kök dizininde bir `.env` dosyası oluşturun ve **Google Gemini API** anahtarınızı ekleyin:
|
||||
### 5. AI Destekli Otomatik Düzeltme (`fix`)
|
||||
Tespit edilen tüm hataları yapay zekaya analiz ettirip dosyalarınızı otomatik olarak tamir ettirin:
|
||||
```bash
|
||||
bin/a11y fix
|
||||
```
|
||||
Sadece neyin değişeceğini görmek için (`--dry-run`):
|
||||
```bash
|
||||
bin/a11y fix --dry-run
|
||||
```
|
||||
|
||||
### 6. Kuralları Listeleme (`rules`)
|
||||
Mevcut tüm WCAG kurallarını seviyeleri ve açıklamalarıyla birlikte listeler:
|
||||
```bash
|
||||
bin/a11y rules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Yapılandırma (`a11y.yaml`)
|
||||
|
||||
Projenizi özelleştirmek için `a11y.yaml` dosyasını kullanabilirsiniz:
|
||||
|
||||
```yaml
|
||||
# Tarama yapılacak dizin veya dosyalar
|
||||
paths:
|
||||
- resources/views
|
||||
- public/templates
|
||||
|
||||
# Hariç tutulacak dizinler
|
||||
exclude_paths:
|
||||
- vendor
|
||||
- node_modules
|
||||
- storage
|
||||
|
||||
# WCAG seviyesi (1-9, varsayılan 4)
|
||||
level: 4
|
||||
|
||||
# Varsayılan çıktı formatı (console, json, github)
|
||||
format: console
|
||||
|
||||
# Baseline dosyası yolu
|
||||
baseline: a11y-baseline.json
|
||||
|
||||
# Kural özelleştirmeleri
|
||||
rules:
|
||||
exclude:
|
||||
- WCAG_1_3_1_FIELDSET # Bu kuralı çalıştırma
|
||||
include:
|
||||
- App\Rules\CustomAccessibilityRule # Özel kural ekle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Yapay Zeka API Anahtarı
|
||||
|
||||
AI düzeltme ve öneri özelliklerini kullanabilmek için projenizin kök dizininde bir `.env` dosyası oluşturup **Google Gemini API** anahtarınızı eklemeniz gerekir:
|
||||
|
||||
```env
|
||||
GEMINI_API_KEY=AIzaSyA...your_key_here
|
||||
```
|
||||
|
||||
## 🛠️ Mevcut Kurallar (WCAG 2.1)
|
||||
|
||||
- **A11Y_VIEWPORT:** Zoom engelleme kontrolü.
|
||||
- **WCAG_1_1_1_ALT:** Görsel alt metin kontrolü.
|
||||
- **WCAG_1_3_1_LABEL:** Form etiketi kontrolü.
|
||||
- **WCAG_1_3_1_FIELDSET:** Fieldset/Legend kontrolü.
|
||||
- **WCAG_1_3_1_HEADING:** Başlık hiyerarşisi kontrolü.
|
||||
- **WCAG_2_4_4_LINK_TEXT:** Boş link kontrolü.
|
||||
- **WCAG_3_1_1_LANG:** HTML dil etiketi kontrolü.
|
||||
- **WCAG_4_1_2_BUTTON:** Boş buton kontrolü.
|
||||
---
|
||||
|
||||
## 🤝 Katkıda Bulunma
|
||||
|
||||
|
||||
@@ -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/<?php%20echo%20$product['image'];%20?>\" width=\"100\" alt=\"<?php echo htmlspecialchars($product['name']); ?>\">\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/<?php%20echo%20$product['image'];%20?>\" 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/<?php%20echo%20$product['image'];%20?>\" 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>"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -6,11 +6,15 @@ require __DIR__ . '/../vendor/autoload.php';
|
||||
use Symfony\Component\Console\Application;
|
||||
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
|
||||
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
|
||||
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
|
||||
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
|
||||
|
||||
$application = new Application('YakNet Accessibility Console', '2.0.0');
|
||||
$application = new Application('YakNet Accessibility Console', '2.9.2');
|
||||
|
||||
// Add commands
|
||||
$application->add(new ScanCommand());
|
||||
$application->add(new FixCommand());
|
||||
$application->add(new InitCommand());
|
||||
$application->add(new RulesCommand());
|
||||
|
||||
$application->run();
|
||||
+6
-1
@@ -12,6 +12,7 @@
|
||||
"yaknet"
|
||||
],
|
||||
"homepage": "https://forum.yak.net.tr",
|
||||
"version": "3.0.0",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
@@ -30,7 +31,11 @@
|
||||
"symfony/yaml": "^6.4|^7.0",
|
||||
"monolog/monolog": "^3.0",
|
||||
"vlucas/phpdotenv": "^5.6",
|
||||
"google-gemini-php/client": "^1.0"
|
||||
"google-gemini-php/client": "^1.0",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"masterminds/html5": "^2.8",
|
||||
"sabberworm/php-css-parser": "^9.4",
|
||||
"chrome-php/chrome": "^1.15"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5",
|
||||
|
||||
+13
-1
@@ -28,7 +28,7 @@
|
||||
<div role="cool-widget" aria-controls="non-existent-popup">WAI-ARIA elements</div>
|
||||
|
||||
<!-- Missing Iframe Title -->
|
||||
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>
|
||||
<iframe src="https://www.youtube.com/embed/aqz-KE-bpKQ" width="560" height="315"></iframe>
|
||||
|
||||
<!-- New WCAG Rule Violations for testing -->
|
||||
|
||||
@@ -49,6 +49,18 @@
|
||||
</div>
|
||||
<a href="#" aria-hidden="true">Hidden link</a>
|
||||
|
||||
<!-- Empty heading violation -->
|
||||
<h2></h2>
|
||||
|
||||
<!-- Video missing track violation -->
|
||||
<video src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4" width="320" height="240" controls></video>
|
||||
|
||||
<!-- Empty label violation -->
|
||||
<label for="search-input"></label>
|
||||
|
||||
<!-- AriaFocusable violation -->
|
||||
<div role="button">Clickable div button without tabindex</div>
|
||||
|
||||
<p>This page has multiple accessibility issues.</p>
|
||||
</body>
|
||||
</html>
|
||||
+10
-19
@@ -1,19 +1,11 @@
|
||||
<?php
|
||||
|
||||
// 1. Setup Autoloading (Mocking Composer)
|
||||
require_once __DIR__ . '/../src/Scanner.php';
|
||||
require_once __DIR__ . '/../src/Violation.php';
|
||||
require_once __DIR__ . '/../src/SourceLocator.php';
|
||||
require_once __DIR__ . '/../src/Reporter/HtmlReporter.php';
|
||||
require_once __DIR__ . '/../src/Rule/RuleInterface.php';
|
||||
require_once __DIR__ . '/../src/Rules/ImgAltText.php';
|
||||
require_once __DIR__ . '/../src/Rules/FormLabel.php';
|
||||
require_once __DIR__ . '/../src/Rules/HtmlHasLang.php';
|
||||
require_once __DIR__ . '/../src/Rules/EmptyLink.php';
|
||||
// 1. Setup Autoloading
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use YakNet\AccessibilityConsole\Scanner;
|
||||
use YakNet\AccessibilityConsole\SourceLocator;
|
||||
use YakNet\AccessibilityConsole\Reporter\HtmlReporter;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
||||
use YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter;
|
||||
use YakNet\AccessibilityConsole\Rules\ImgAltText;
|
||||
use YakNet\AccessibilityConsole\Rules\FormLabel;
|
||||
|
||||
@@ -48,19 +40,18 @@ $violations = $scanner->scan($html);
|
||||
|
||||
if (!empty($violations)) {
|
||||
// Locate Sources
|
||||
// We point it to the 'views' directory so it knows where to look for the error source
|
||||
$locator = new SourceLocator(__DIR__ . '/views');
|
||||
|
||||
foreach ($violations as $violation) {
|
||||
$location = $locator->locate($violation->snippet);
|
||||
$location = $locator->locate($violation->htmlSnippet);
|
||||
if ($location) {
|
||||
$violation->setSourceLocation($location['file'], $location['line']);
|
||||
$violation->location = $location;
|
||||
}
|
||||
}
|
||||
|
||||
// Render Report
|
||||
$reporter = new HtmlReporter();
|
||||
$reportHtml = $reporter->render($violations);
|
||||
// Render Report (Format as results mapped by page URL)
|
||||
$reporter = new HtmlDashboardReporter();
|
||||
$reportHtml = $reporter->render(['http://localhost:8080/index.php' => $violations], 'demo/index.php');
|
||||
|
||||
// Inject into body
|
||||
$html = str_replace('</body>', $reportHtml . '</body>', $html);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<img src="avatar.png">
|
||||
<button></button>
|
||||
</body>
|
||||
</html>
|
||||
@@ -45,7 +45,7 @@ FIX: [Only the corrected HTML snippet]
|
||||
PROMPT;
|
||||
|
||||
try {
|
||||
$result = $this->client->generativeModel('gemini-2.5-flash')->generateContent($prompt);
|
||||
$result = $this->client->generativeModel('gemini-3.1-flash-lite')->generateContent($prompt);
|
||||
return trim($result->text());
|
||||
} catch (\Throwable $e) {
|
||||
return "AI Düzeltme Hatası: " . $e->getMessage();
|
||||
|
||||
@@ -9,9 +9,10 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use YakNet\AccessibilityConsole\AI\GeminiFixer;
|
||||
use YakNet\AccessibilityConsole\Core\Analyser;
|
||||
use YakNet\AccessibilityConsole\Core\Config;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
||||
|
||||
class FixCommand extends Command
|
||||
@@ -21,46 +22,183 @@ class FixCommand extends Command
|
||||
$this
|
||||
->setName('fix')
|
||||
->setDescription('Automatically fix accessibility violations using AI')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files');
|
||||
->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan and fix')
|
||||
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files')
|
||||
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
|
||||
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$target = $input->getArgument('target');
|
||||
if (!is_string($target)) {
|
||||
$io->error("Target must be a string URL or file path.");
|
||||
|
||||
// 1. Resolve configuration
|
||||
$configPathOption = $input->getOption('config');
|
||||
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
|
||||
$config = Config::fromYaml($configPath);
|
||||
|
||||
$apiKey = $config->getGeminiApiKey();
|
||||
if (!$apiKey) {
|
||||
$io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$levelOption = $input->getOption('level');
|
||||
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
|
||||
|
||||
$target = $input->getArgument('target');
|
||||
$isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://'));
|
||||
|
||||
// If target is a URL, run the legacy URL fixing logic
|
||||
if ($isUrl) {
|
||||
return $this->executeUrlFix($target, $level, $apiKey, $input, $output, $io);
|
||||
}
|
||||
|
||||
// --- LOCAL FILE / DIRECTORY SELF-HEALING ENGINE ---
|
||||
$io->title("AI Self-Healing Mode - Directory Static Analysis");
|
||||
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
|
||||
// Resolve paths to scan
|
||||
$paths = $config->getPaths();
|
||||
if (is_string($target) && $target !== '') {
|
||||
$paths = [$target];
|
||||
}
|
||||
|
||||
// Setup Analyser
|
||||
$scanner = new Scanner();
|
||||
$rules = RuleLevels::getRulesForLevel($level);
|
||||
foreach ($rules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
|
||||
// We run without baseline filtering so we can fix ALL errors in the project,
|
||||
// or we can optionally load baseline. Fixing everything is better for a general "fix" run.
|
||||
$analyser = new Analyser($config, $scanner, new \YakNet\AccessibilityConsole\Core\BaselineManager());
|
||||
|
||||
$io->note("Scanning targets for violations...");
|
||||
$result = $analyser->analyse($paths);
|
||||
$violations = $result['violations'];
|
||||
|
||||
if (empty($violations)) {
|
||||
$io->success("Everything looks good! No violations to fix.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations)));
|
||||
|
||||
$fixer = new GeminiFixer($apiKey);
|
||||
$fixedCount = 0;
|
||||
|
||||
// Group violations by file path
|
||||
$groupedViolations = [];
|
||||
foreach ($violations as $v) {
|
||||
$file = $v->location['file'] ?? '';
|
||||
if ($file !== '') {
|
||||
$groupedViolations[$file][] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($groupedViolations as $file => $fileViolations) {
|
||||
$io->section("Fixing file: $file");
|
||||
|
||||
// Sort violations by line number DESCENDING (bottom to top)
|
||||
// This prevents edits at the bottom of the file from invalidating line numbers above them!
|
||||
usort($fileViolations, function ($a, $b) {
|
||||
return ($b->location['line'] ?? 0) <=> ($a->location['line'] ?? 0);
|
||||
});
|
||||
|
||||
$absoluteFile = getcwd() . '/' . $file;
|
||||
if (!file_exists($absoluteFile) || !is_writable($absoluteFile)) {
|
||||
$io->warning("File not found or not writable: $file. Skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($fileViolations as $violation) {
|
||||
$line = $violation->location['line'] ?? 0;
|
||||
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
|
||||
|
||||
// Get AI Suggestion
|
||||
$rawSuggestion = $fixer->suggestFix($violation);
|
||||
$suggestion = $rawSuggestion;
|
||||
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
|
||||
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
||||
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
||||
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
||||
}
|
||||
|
||||
if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
|
||||
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||
continue;
|
||||
}
|
||||
|
||||
$io->writeln("<fg=green>Fix Found!</>");
|
||||
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>");
|
||||
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>");
|
||||
|
||||
if ($dryRun) {
|
||||
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply Fix
|
||||
if ($this->applyFix($absoluteFile, $violation->htmlSnippet, $suggestion)) {
|
||||
$io->writeln(" <fg=bright-green>✔ Applied to line $line</>");
|
||||
$fixedCount++;
|
||||
} else {
|
||||
$io->writeln(" <fg=red>✘ Failed to apply fix to file.</>");
|
||||
}
|
||||
$io->writeln("");
|
||||
}
|
||||
}
|
||||
|
||||
if ($fixedCount > 0) {
|
||||
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
|
||||
} else {
|
||||
$io->warning("No files were modified.");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy URL scan and fix logic
|
||||
*/
|
||||
private function executeUrlFix(string $target, int $level, string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
|
||||
{
|
||||
$projectPathOption = $input->getOption('project-path');
|
||||
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
|
||||
if ($projectPath === false) {
|
||||
$projectPath = '.';
|
||||
}
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$useJs = (bool)$input->getOption('js');
|
||||
|
||||
$config = new Config();
|
||||
$apiKey = $config->getGeminiApiKey();
|
||||
$io->title("AI Self-Healing Mode - Fixing URL: $target");
|
||||
|
||||
if (!$apiKey) {
|
||||
$io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key.");
|
||||
return Command::FAILURE;
|
||||
// Fetch HTML
|
||||
if ($useJs) {
|
||||
try {
|
||||
$chromeRenderer = new \YakNet\AccessibilityConsole\Core\ChromeRenderer();
|
||||
$html = $chromeRenderer->render($target);
|
||||
} catch (\Throwable $e) {
|
||||
$io->error("Failed JavaScript rendering: " . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} else {
|
||||
$html = @file_get_contents($target);
|
||||
}
|
||||
|
||||
$io->title("AI Self-Healing Mode - Fixing: $target");
|
||||
|
||||
// 1. Fetch HTML
|
||||
$html = @file_get_contents($target);
|
||||
if ($html === false || $html === '') {
|
||||
$io->error("Could not read target: $target");
|
||||
$io->error("Could not read target URL: $target");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 2. Scan
|
||||
// Scan
|
||||
$scanner = new Scanner();
|
||||
foreach (StandardRuleSet::all() as $rule) {
|
||||
$rules = RuleLevels::getRulesForLevel($level);
|
||||
foreach ($rules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
$violations = $scanner->scan($html);
|
||||
@@ -92,7 +230,6 @@ class FixCommand extends Command
|
||||
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
|
||||
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
||||
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
||||
// Clean up potential markdown
|
||||
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
||||
}
|
||||
|
||||
@@ -123,7 +260,7 @@ class FixCommand extends Command
|
||||
if ($fixedCount > 0) {
|
||||
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
|
||||
} else {
|
||||
$io->warning("No files were modified. Check error logs for details.");
|
||||
$io->warning("No files were modified.");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -138,8 +275,7 @@ class FixCommand extends Command
|
||||
if (str_contains($content, $originalSnippet)) {
|
||||
$newContent = str_replace($originalSnippet, $fixedSnippet, $content);
|
||||
} else {
|
||||
// Fallback: Fuzzy replacement (simplified for this demo)
|
||||
// In a real professional lib, we'd use more advanced regex or DOM patching
|
||||
// Fallback: Fuzzy replacement
|
||||
$newContent = str_replace(trim($originalSnippet), trim($fixedSnippet), $content);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -3,40 +3,236 @@
|
||||
namespace YakNet\AccessibilityConsole\Console\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use YakNet\AccessibilityConsole\AI\GeminiFixer;
|
||||
use YakNet\AccessibilityConsole\Core\Analyser;
|
||||
use YakNet\AccessibilityConsole\Core\BaselineManager;
|
||||
use YakNet\AccessibilityConsole\Core\Config;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
|
||||
use YakNet\AccessibilityConsole\Reporting\GithubFormatter;
|
||||
use YakNet\AccessibilityConsole\Reporting\JsonFormatter;
|
||||
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
||||
|
||||
class ScanCommand extends Command
|
||||
{
|
||||
protected static ?string $defaultName = 'scan';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('scan')
|
||||
->setDescription('Scan a URL or file for accessibility violations')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
|
||||
->setAliases(['analyse'])
|
||||
->setDescription('Scan local files, directories, or a URL for accessibility violations')
|
||||
->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan')
|
||||
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
|
||||
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
|
||||
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (console, json, github)')
|
||||
->addOption('generate-baseline', null, InputOption::VALUE_NONE, 'Generate a baseline file containing all current violations')
|
||||
->addOption('baseline', null, InputOption::VALUE_REQUIRED, 'Path to the baseline file')
|
||||
->addOption('no-progress', null, InputOption::VALUE_NONE, 'Disable progress bar')
|
||||
->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions')
|
||||
->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)')
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping');
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
|
||||
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively (URL scans only)')
|
||||
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth (URL scans only)', '3')
|
||||
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum pages to crawl (URL scans only)', '20')
|
||||
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$target = $input->getArgument('target');
|
||||
if (!is_string($target)) {
|
||||
$io->error("Target must be a string URL or file path.");
|
||||
return Command::FAILURE;
|
||||
|
||||
// 1. Resolve configuration
|
||||
$configPathOption = $input->getOption('config');
|
||||
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
|
||||
$config = Config::fromYaml($configPath);
|
||||
|
||||
// 2. Resolve parameters (CLI options override config file)
|
||||
$levelOption = $input->getOption('level');
|
||||
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
|
||||
|
||||
$formatOption = $input->getOption('format');
|
||||
$format = is_string($formatOption) ? $formatOption : $config->getFormat();
|
||||
|
||||
$baselinePathOption = $input->getOption('baseline');
|
||||
$baselinePath = is_string($baselinePathOption) ? $baselinePathOption : $config->getBaselinePath();
|
||||
if (!$baselinePath && $input->getOption('generate-baseline')) {
|
||||
$baselinePath = 'a11y-baseline.json'; // default baseline filename
|
||||
}
|
||||
|
||||
$target = $input->getArgument('target');
|
||||
$isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://'));
|
||||
|
||||
// If target is a URL, run the legacy URL scanning / crawling logic
|
||||
if ($isUrl) {
|
||||
return $this->executeUrlScan($target, $level, $input, $output, $io);
|
||||
}
|
||||
|
||||
// --- LOCAL DIRECTORY / FILE STATIC ANALYSIS ENGINE ---
|
||||
$io->title("Accessibility Console - Static Analysis");
|
||||
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
|
||||
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
|
||||
|
||||
// Resolve paths to scan
|
||||
$paths = $config->getPaths();
|
||||
if (is_string($target) && $target !== '') {
|
||||
$paths = [$target];
|
||||
}
|
||||
|
||||
// Setup Analyser
|
||||
$scanner = new Scanner();
|
||||
$rules = RuleLevels::getRulesForLevel($level);
|
||||
|
||||
// Filter rules based on config
|
||||
$rulesConfig = $config->getRulesConfig();
|
||||
$activeRules = [];
|
||||
foreach ($rules as $rule) {
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: (new \ReflectionClass($rule))->getShortName();
|
||||
|
||||
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
|
||||
continue;
|
||||
}
|
||||
$activeRules[$ruleId] = $rule;
|
||||
}
|
||||
|
||||
foreach ($rulesConfig['include'] as $customRuleClass) {
|
||||
if (class_exists($customRuleClass)) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($customRuleClass);
|
||||
if ($reflection->isInstantiable()) {
|
||||
$rule = $reflection->newInstance();
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: $reflection->getShortName();
|
||||
$activeRules[$ruleId] = $rule;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($activeRules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
|
||||
// Setup Baseline Manager
|
||||
$baselineManager = new BaselineManager();
|
||||
if ($baselinePath && !$input->getOption('generate-baseline')) {
|
||||
$baselineManager->load(getcwd() . '/' . $baselinePath);
|
||||
}
|
||||
|
||||
$analyser = new Analyser($config, $scanner, $baselineManager);
|
||||
|
||||
// Progress bar setup
|
||||
$progressBar = null;
|
||||
$showProgress = !$input->getOption('no-progress') && $format === 'console';
|
||||
$onProgress = null;
|
||||
|
||||
if ($showProgress) {
|
||||
$onProgress = function (string $file, int $index, int $total) use ($io, &$progressBar) {
|
||||
if ($progressBar === null) {
|
||||
$progressBar = new ProgressBar($io, $total);
|
||||
$progressBar->start();
|
||||
}
|
||||
$progressBar->setProgress($index);
|
||||
$progressBar->setMessage(basename($file), 'filename');
|
||||
};
|
||||
}
|
||||
|
||||
// Run analysis
|
||||
$result = $analyser->analyse($paths, $onProgress);
|
||||
|
||||
if ($progressBar) {
|
||||
$progressBar->finish();
|
||||
$io->newLine(2);
|
||||
}
|
||||
|
||||
$violations = $result['violations'];
|
||||
$baselinedCount = $result['baselinedCount'];
|
||||
|
||||
// AI Fix generation (if requested)
|
||||
if ($input->getOption('ai') && !empty($violations)) {
|
||||
$apiKey = $config->getGeminiApiKey();
|
||||
if ($apiKey) {
|
||||
$fixer = new GeminiFixer($apiKey);
|
||||
$io->comment("Requesting AI fix suggestions for violations...");
|
||||
foreach ($violations as $v) {
|
||||
$suggestion = $fixer->suggestFix($v);
|
||||
if ($suggestion) {
|
||||
$v->fixSuggestion = $suggestion;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$io->warning("GEMINI_API_KEY not found. AI suggestions skipped.");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate baseline if option is present
|
||||
if ($input->getOption('generate-baseline')) {
|
||||
if (!$baselinePath) {
|
||||
$io->error("No baseline path specified.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$baselineFile = getcwd() . '/' . $baselinePath;
|
||||
|
||||
// Re-run analysis without baseline filtering to catch all violations
|
||||
$unfilteredAnalyser = new Analyser($config, $scanner, new BaselineManager());
|
||||
$allResult = $unfilteredAnalyser->analyse($paths);
|
||||
$allViolations = $allResult['violations'];
|
||||
|
||||
if ($baselineManager->generate($allViolations, $baselineFile)) {
|
||||
$io->success(sprintf("Baseline generated successfully with %d violations at: %s", count($allViolations), $baselinePath));
|
||||
return Command::SUCCESS;
|
||||
} else {
|
||||
$io->error("Failed to generate baseline file.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
// Select formatter
|
||||
$formatter = new ConsoleFormatter();
|
||||
if ($format === 'json') {
|
||||
$formatter = new JsonFormatter();
|
||||
} elseif ($format === 'github') {
|
||||
$formatter = new GithubFormatter();
|
||||
}
|
||||
|
||||
$exitCode = $formatter->format($violations, $baselinedCount, $io);
|
||||
|
||||
// Export report if --report is specified
|
||||
$reportPath = $input->getOption('report');
|
||||
if (is_string($reportPath) && $reportPath !== '') {
|
||||
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
|
||||
if ($extension === 'json') {
|
||||
$serialized = [];
|
||||
foreach ($violations as $v) {
|
||||
$serialized[] = $v->toArray();
|
||||
}
|
||||
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
|
||||
} else {
|
||||
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
|
||||
// Map violations into page format
|
||||
$mappedViolations = ['Static Analysis' => $violations];
|
||||
file_put_contents($reportPath, $reporter->render($mappedViolations, 'Static Analysis'));
|
||||
}
|
||||
$io->success("Report saved to: $reportPath");
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy URL scanning and crawling logic
|
||||
*/
|
||||
private function executeUrlScan(string $target, int $level, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
|
||||
{
|
||||
$enableAi = (bool)$input->getOption('ai');
|
||||
$projectPathOption = $input->getOption('project-path');
|
||||
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
|
||||
@@ -44,79 +240,198 @@ class ScanCommand extends Command
|
||||
$projectPath = '.';
|
||||
}
|
||||
|
||||
$io->title("Accessibility Console - Scanning: $target");
|
||||
$io->title("Accessibility Console - Scanning URL: $target (Level $level)");
|
||||
|
||||
// 1. Fetch HTML
|
||||
$html = @file_get_contents($target);
|
||||
if ($html === false || $html === '') {
|
||||
$io->error("Could not read target: $target");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 2. Setup Scanner
|
||||
// 1. Setup Scanner
|
||||
$scanner = new Scanner();
|
||||
foreach (StandardRuleSet::all() as $rule) {
|
||||
$rules = RuleLevels::getRulesForLevel($level);
|
||||
foreach ($rules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
|
||||
// 3. Scan
|
||||
$violations = $scanner->scan($html);
|
||||
// 2. Fetch and Scan
|
||||
$crawl = (bool)$input->getOption('crawl');
|
||||
$useJs = (bool)$input->getOption('js');
|
||||
$chromeRenderer = $useJs ? new \YakNet\AccessibilityConsole\Core\ChromeRenderer() : null;
|
||||
$results = [];
|
||||
|
||||
if (empty($violations)) {
|
||||
if ($crawl) {
|
||||
$depthOption = $input->getOption('depth');
|
||||
$depthLimit = is_numeric($depthOption) ? (int)$depthOption : 3;
|
||||
$maxPagesOption = $input->getOption('max-pages');
|
||||
$maxPages = is_numeric($maxPagesOption) ? (int)$maxPagesOption : 20;
|
||||
|
||||
$io->comment("Starting recursive crawl from: $target (Max Depth: $depthLimit, Max Pages: $maxPages)");
|
||||
|
||||
$parsedBase = parse_url($target);
|
||||
$baseHost = $parsedBase['host'] ?? '';
|
||||
|
||||
$queue = [['url' => $target, 'depth' => 1]];
|
||||
$visited = [];
|
||||
|
||||
$client = new \GuzzleHttp\Client([
|
||||
'timeout' => 10,
|
||||
'allow_redirects' => true,
|
||||
'headers' => [
|
||||
'User-Agent' => 'YakNet Accessibility Console Crawler/1.0',
|
||||
]
|
||||
]);
|
||||
|
||||
while (!empty($queue) && count($visited) < $maxPages) {
|
||||
$current = array_shift($queue);
|
||||
if (!is_array($current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = $current['url'];
|
||||
$depth = (int)$current['depth'];
|
||||
|
||||
$cleanUrl = strtok($url, '#');
|
||||
if ($cleanUrl === false || isset($visited[$cleanUrl])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$visited[$cleanUrl] = true;
|
||||
$io->write(" <fg=gray>Crawling [Depth $depth]:</> <fg=cyan>$cleanUrl</>... ");
|
||||
|
||||
try {
|
||||
if ($chromeRenderer) {
|
||||
$html = $chromeRenderer->render($cleanUrl);
|
||||
} else {
|
||||
$response = $client->request('GET', $cleanUrl);
|
||||
$html = (string)$response->getBody();
|
||||
}
|
||||
$io->writeln("<fg=green>OK</>");
|
||||
|
||||
$pageViolations = $scanner->scan($html);
|
||||
$results[$cleanUrl] = $pageViolations;
|
||||
|
||||
if ($depth < $depthLimit) {
|
||||
$html5 = new \Masterminds\HTML5(['disable_html_ns' => true]);
|
||||
$doc = $html5->loadHTML($html);
|
||||
$links = $doc->getElementsByTagName('a');
|
||||
|
||||
foreach ($links as $link) {
|
||||
$href = $link->getAttribute('href');
|
||||
if (empty($href) || str_starts_with($href, 'javascript:') || str_starts_with($href, 'mailto:') || str_starts_with($href, 'tel:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$baseUri = new \GuzzleHttp\Psr7\Uri($cleanUrl);
|
||||
$relativeUri = new \GuzzleHttp\Psr7\Uri($href);
|
||||
$absoluteUri = \GuzzleHttp\Psr7\UriResolver::resolve($baseUri, $relativeUri);
|
||||
$absoluteUrl = (string)$absoluteUri;
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedAbsolute = parse_url($absoluteUrl);
|
||||
$absoluteHost = $parsedAbsolute['host'] ?? '';
|
||||
|
||||
if ($absoluteHost === $baseHost) {
|
||||
$cleanAbsolute = strtok($absoluteUrl, '#');
|
||||
if ($cleanAbsolute !== false && !isset($visited[$cleanAbsolute])) {
|
||||
$inQueue = false;
|
||||
foreach ($queue as $q) {
|
||||
if ($q['url'] === $cleanAbsolute) {
|
||||
$inQueue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$inQueue) {
|
||||
$queue[] = ['url' => $cleanAbsolute, 'depth' => $depth + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$io->writeln("<fg=red>FAIL (" . $e->getMessage() . ")</>");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($chromeRenderer) {
|
||||
try {
|
||||
$html = $chromeRenderer->render($target);
|
||||
} catch (\Throwable $e) {
|
||||
$io->error("Failed JavaScript rendering: " . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} else {
|
||||
$html = @file_get_contents($target);
|
||||
}
|
||||
|
||||
if ($html === false || $html === '') {
|
||||
$io->error("Could not read target URL: $target");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$violations = $scanner->scan($html);
|
||||
$results[$target] = $violations;
|
||||
}
|
||||
|
||||
$totalViolations = 0;
|
||||
foreach ($results as $url => $violations) {
|
||||
$totalViolations += count($violations);
|
||||
}
|
||||
|
||||
if ($totalViolations === 0) {
|
||||
$io->success("No violations found!");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->section("Found " . count($violations) . " violations");
|
||||
$io->section("Found a total of $totalViolations violations across " . count($results) . " pages");
|
||||
|
||||
// 4. Setup Helpers
|
||||
$locator = new SourceLocator($projectPath);
|
||||
$config = new Config();
|
||||
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
|
||||
|
||||
foreach ($violations as $violation) {
|
||||
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
|
||||
|
||||
// Show Snippet
|
||||
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
|
||||
|
||||
// Source Mapping
|
||||
$location = $locator->locate($violation->htmlSnippet);
|
||||
if ($location) {
|
||||
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
|
||||
foreach ($results as $url => $violations) {
|
||||
if (empty($violations)) {
|
||||
continue;
|
||||
}
|
||||
$io->section("Page: $url (" . count($violations) . " violations)");
|
||||
|
||||
// AI Fixer
|
||||
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)</>");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
$io->writeln("");
|
||||
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("");
|
||||
}
|
||||
}
|
||||
|
||||
$io->warning("Scanned " . count($violations) . " errors. Use --ai to get fix suggestions.");
|
||||
|
||||
// Export Report
|
||||
$reportPath = $input->getOption('report');
|
||||
if (is_string($reportPath) && $reportPath !== '') {
|
||||
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
|
||||
if ($extension === 'json') {
|
||||
$data = array_map(fn($v) => $v->toArray(), $violations);
|
||||
file_put_contents($reportPath, json_encode($data, JSON_PRETTY_PRINT));
|
||||
$serialized = [];
|
||||
foreach ($results as $url => $violations) {
|
||||
$serialized[$url] = array_map(fn($v) => $v->toArray(), $violations);
|
||||
}
|
||||
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
|
||||
} else {
|
||||
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
|
||||
file_put_contents($reportPath, $reporter->render($violations, $target));
|
||||
file_put_contents($reportPath, $reporter->render($results, $target));
|
||||
}
|
||||
$io->success("Report saved to: $reportPath");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@ abstract class AbstractRule
|
||||
*/
|
||||
abstract public function getSeverity(): Severity;
|
||||
|
||||
public function getLevel(): int
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
protected function createViolation(string $message, \DOMElement $element): Violation
|
||||
{
|
||||
$doc = $element->ownerDocument;
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
<?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) : '.';
|
||||
|
||||
$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,96 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
class BaselineManager
|
||||
{
|
||||
/** @var array<string, bool> */
|
||||
private array $ignoredKeys = [];
|
||||
|
||||
public function load(string $baselinePath): bool
|
||||
{
|
||||
if (!file_exists($baselinePath) || !is_readable($baselinePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = file_get_contents($baselinePath);
|
||||
if ($content === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data) || !isset($data['violations']) || !is_array($data['violations'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($data['violations'] as $item) {
|
||||
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])) {
|
||||
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
|
||||
$this->ignoredKeys[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Violation[] $violations
|
||||
*/
|
||||
public function generate(array $violations, string $baselinePath): bool
|
||||
{
|
||||
$serialized = [
|
||||
'generator' => 'YakNet Accessibility Console',
|
||||
'violations' => []
|
||||
];
|
||||
|
||||
foreach ($violations as $v) {
|
||||
$file = $v->location['file'] ?? '';
|
||||
$serialized['violations'][] = [
|
||||
'file' => $file,
|
||||
'ruleId' => $v->ruleId,
|
||||
'htmlSnippet' => $v->htmlSnippet
|
||||
];
|
||||
}
|
||||
|
||||
// Sort baseline violations by file and ruleId for cleaner git diffs
|
||||
usort($serialized['violations'], function ($a, $b) {
|
||||
$fileCmp = strcmp($a['file'], $b['file']);
|
||||
if ($fileCmp !== 0) {
|
||||
return $fileCmp;
|
||||
}
|
||||
return strcmp($a['ruleId'], $b['ruleId']);
|
||||
});
|
||||
|
||||
$json = json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dir = dirname($baselinePath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
return file_put_contents($baselinePath, $json) !== false;
|
||||
}
|
||||
|
||||
public function isIgnored(string $file, string $ruleId, string $htmlSnippet): bool
|
||||
{
|
||||
$key = $this->calculateKey($file, $ruleId, $htmlSnippet);
|
||||
return isset($this->ignoredKeys[$key]);
|
||||
}
|
||||
|
||||
private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string
|
||||
{
|
||||
// Normalize whitespaces and trim to prevent minor formatting updates from breaking baseline
|
||||
$normalizedSnippet = preg_replace('/\s+/', ' ', trim($htmlSnippet));
|
||||
if ($normalizedSnippet === null) {
|
||||
$normalizedSnippet = trim($htmlSnippet);
|
||||
}
|
||||
|
||||
// Normalize file separator to forward slashes for cross-platform compatibility
|
||||
$normalizedFile = str_replace('\\', '/', $file);
|
||||
|
||||
return md5($normalizedFile . '::' . $ruleId . '::' . $normalizedSnippet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
use HeadlessChromium\BrowserFactory;
|
||||
use HeadlessChromium\Navigation;
|
||||
|
||||
class ChromeRenderer
|
||||
{
|
||||
/**
|
||||
* Navigate to URL using headless Chrome and return fully rendered HTML.
|
||||
*/
|
||||
public function render(string $url): string
|
||||
{
|
||||
$browserFactory = new BrowserFactory();
|
||||
|
||||
$options = [
|
||||
'windowSize' => [1280, 1024],
|
||||
'noSandbox' => true,
|
||||
];
|
||||
|
||||
// Windows Chrome binary path auto-detection override
|
||||
$chromeBinary = $_ENV['CHROME_BINARY'] ?? getenv('CHROME_BINARY') ?: null;
|
||||
if (!$chromeBinary && PHP_OS_FAMILY === 'Windows') {
|
||||
$standardPaths = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe'
|
||||
];
|
||||
foreach ($standardPaths as $path) {
|
||||
if (file_exists($path)) {
|
||||
$chromeBinary = $path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($chromeBinary) {
|
||||
$options['chromeBinary'] = $chromeBinary;
|
||||
}
|
||||
|
||||
$browser = $browserFactory->createBrowser($options);
|
||||
|
||||
try {
|
||||
$page = $browser->createPage();
|
||||
|
||||
// Navigate and wait for onload event
|
||||
$navigation = $page->navigate($url);
|
||||
$navigation->waitForNavigation(\HeadlessChromium\Page::LOAD, 10000); // 10 seconds timeout
|
||||
|
||||
// Extra wait for async JS rendering to complete
|
||||
usleep(1500000); // 1.5s
|
||||
|
||||
$html = $page->getHtml();
|
||||
} finally {
|
||||
$browser->close();
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
+96
-1
@@ -13,11 +13,34 @@ class Config
|
||||
public function __construct(array $settings = [])
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->loadDotenv();
|
||||
}
|
||||
|
||||
private function loadDotenv(): void
|
||||
{
|
||||
$cwd = getcwd();
|
||||
if ($cwd && file_exists($cwd . '/.env')) {
|
||||
try {
|
||||
$dotenv = \Dotenv\Dotenv::createImmutable($cwd);
|
||||
$dotenv->safeLoad();
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore Dotenv errors silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromYaml(string $path): self
|
||||
{
|
||||
// Placeholder for YAML loading logic
|
||||
if (file_exists($path)) {
|
||||
try {
|
||||
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
|
||||
if (is_array($content)) {
|
||||
return new self($content);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
@@ -26,6 +49,78 @@ class Config
|
||||
return $this->settings[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getPaths(): array
|
||||
{
|
||||
$paths = $this->get('paths');
|
||||
if (is_array($paths)) {
|
||||
return $paths;
|
||||
}
|
||||
if (is_string($paths)) {
|
||||
return [$paths];
|
||||
}
|
||||
return ['.']; // default to current directory
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getExcludePaths(): array
|
||||
{
|
||||
$exclude = $this->get('exclude_paths');
|
||||
if (is_array($exclude)) {
|
||||
return $exclude;
|
||||
}
|
||||
if (is_string($exclude)) {
|
||||
return [$exclude];
|
||||
}
|
||||
return ['vendor', 'node_modules', 'tests', 'storage', '.git']; // sensible defaults
|
||||
}
|
||||
|
||||
public function getLevel(): int
|
||||
{
|
||||
$level = $this->get('level', 4);
|
||||
return is_numeric($level) ? (int)$level : 4;
|
||||
}
|
||||
|
||||
public function getBaselinePath(): ?string
|
||||
{
|
||||
$baseline = $this->get('baseline');
|
||||
return is_string($baseline) ? $baseline : null;
|
||||
}
|
||||
|
||||
public function getFormat(): string
|
||||
{
|
||||
$format = $this->get('format', 'console');
|
||||
return is_string($format) ? $format : 'console';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{include: array<int, string>, exclude: array<int, string>}
|
||||
*/
|
||||
public function getRulesConfig(): array
|
||||
{
|
||||
$rules = $this->get('rules', []);
|
||||
$include = [];
|
||||
$exclude = [];
|
||||
|
||||
if (is_array($rules)) {
|
||||
if (isset($rules['include']) && is_array($rules['include'])) {
|
||||
$include = $rules['include'];
|
||||
}
|
||||
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
|
||||
$exclude = $rules['exclude'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'include' => $include,
|
||||
'exclude' => $exclude
|
||||
];
|
||||
}
|
||||
|
||||
public function getGeminiApiKey(): ?string
|
||||
{
|
||||
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
|
||||
|
||||
@@ -28,9 +28,10 @@ class Scanner
|
||||
return [];
|
||||
}
|
||||
|
||||
$doc = new \DOMDocument();
|
||||
// Suppress warnings for malformed HTML
|
||||
@$doc->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
$html5 = new \Masterminds\HTML5([
|
||||
'disable_html_ns' => true,
|
||||
]);
|
||||
$doc = $html5->loadHTML($html);
|
||||
|
||||
return $this->engine->run($doc);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,14 @@ use YakNet\AccessibilityConsole\Core\Violation;
|
||||
class HtmlDashboardReporter
|
||||
{
|
||||
/**
|
||||
* @param Violation[] $violations
|
||||
* @param array<string, Violation[]> $results
|
||||
*/
|
||||
public function render(array $violations, string $target): string
|
||||
public function render(array $results, string $target): string
|
||||
{
|
||||
$count = count($violations);
|
||||
$totalPages = count($results);
|
||||
$date = date('Y-m-d H:i:s');
|
||||
|
||||
// Calculate severity and standard counts
|
||||
// Calculate severity, standard counts and overall average score
|
||||
$criticalCount = 0;
|
||||
$errorCount = 0;
|
||||
$warningCount = 0;
|
||||
@@ -24,110 +24,145 @@ class HtmlDashboardReporter
|
||||
$standardAA = 0;
|
||||
$standardAAA = 0;
|
||||
|
||||
foreach ($violations as $v) {
|
||||
switch ($v->severity) {
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::CRITICAL:
|
||||
$criticalCount++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::ERROR:
|
||||
$errorCount++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::WARNING:
|
||||
$warningCount++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::INFO:
|
||||
$infoCount++;
|
||||
break;
|
||||
$pageScores = [];
|
||||
$totalViolations = 0;
|
||||
|
||||
foreach ($results as $url => $violations) {
|
||||
$totalViolations += count($violations);
|
||||
$pageCrit = 0;
|
||||
$pageErr = 0;
|
||||
$pageWarn = 0;
|
||||
$pageInfo = 0;
|
||||
|
||||
foreach ($violations as $v) {
|
||||
switch ($v->severity) {
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::CRITICAL:
|
||||
$criticalCount++;
|
||||
$pageCrit++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::ERROR:
|
||||
$errorCount++;
|
||||
$pageErr++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::WARNING:
|
||||
$warningCount++;
|
||||
$pageWarn++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\Severity::INFO:
|
||||
$infoCount++;
|
||||
$pageInfo++;
|
||||
break;
|
||||
}
|
||||
|
||||
switch ($v->standard) {
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::A:
|
||||
$standardA++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::AA:
|
||||
$standardAA++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::AAA:
|
||||
$standardAAA++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch ($v->standard) {
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::A:
|
||||
$standardA++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::AA:
|
||||
$standardAA++;
|
||||
break;
|
||||
case \YakNet\AccessibilityConsole\Core\WCAGStandard::AAA:
|
||||
$standardAAA++;
|
||||
break;
|
||||
}
|
||||
$deductions = ($pageCrit * 15) + ($pageErr * 10) + ($pageWarn * 4) + ($pageInfo * 1);
|
||||
$pageScores[$url] = max(0, min(100, 100 - $deductions));
|
||||
}
|
||||
|
||||
// Calculate health score (0-100)
|
||||
$deductions = ($criticalCount * 15) + ($errorCount * 10) + ($warningCount * 4) + ($infoCount * 1);
|
||||
$score = max(0, min(100, 100 - $deductions));
|
||||
$averageScore = $totalPages > 0 ? (int)round(array_sum($pageScores) / $totalPages) : 100;
|
||||
|
||||
// Generate Page Selector options
|
||||
$pageOptions = "<option value=\"all\">Tüm Sayfalar ({$totalPages} sayfa, {$totalViolations} ihlal)</option>";
|
||||
foreach ($results as $url => $violations) {
|
||||
$escapedUrl = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
|
||||
$vCount = count($violations);
|
||||
$pageOptions .= "<option value=\"{$escapedUrl}\">{$escapedUrl} ({$vCount} ihlal)</option>";
|
||||
}
|
||||
|
||||
$cards = '';
|
||||
foreach ($violations as $index => $v) {
|
||||
$severityClass = strtolower($v->severity->value);
|
||||
$standardVal = $v->standard->value;
|
||||
$aiSuggestion = $v->fixSuggestion;
|
||||
$explanation = '';
|
||||
$fixedCode = '';
|
||||
$cardIndex = 0;
|
||||
foreach ($results as $url => $violations) {
|
||||
$escapedUrl = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
|
||||
foreach ($violations as $v) {
|
||||
$severityClass = strtolower($v->severity->value);
|
||||
$standardVal = $v->standard->value;
|
||||
$aiSuggestion = $v->fixSuggestion;
|
||||
$explanation = '';
|
||||
$fixedCode = '';
|
||||
|
||||
if ($aiSuggestion && str_contains($aiSuggestion, 'FIX:')) {
|
||||
preg_match('/EXPLANATION:(.*)FIX:(.*)/s', $aiSuggestion, $matches);
|
||||
$explanation = trim($matches[1] ?? '');
|
||||
$fixedCode = trim($matches[2] ?? '');
|
||||
$fixedCode = preg_replace('/^```html\s*|\s*```$/i', '', $fixedCode);
|
||||
} else {
|
||||
$fixedCode = $aiSuggestion;
|
||||
if ($aiSuggestion && str_contains($aiSuggestion, 'FIX:')) {
|
||||
preg_match('/EXPLANATION:(.*)FIX:(.*)/s', $aiSuggestion, $matches);
|
||||
$explanation = trim($matches[1] ?? '');
|
||||
$fixedCode = trim($matches[2] ?? '');
|
||||
$fixedCode = preg_replace('/^```html\s*|\s*```$/i', '', $fixedCode) ?? '';
|
||||
} else {
|
||||
$fixedCode = $aiSuggestion;
|
||||
}
|
||||
|
||||
$escapedSnippet = htmlspecialchars($v->htmlSnippet, ENT_QUOTES, 'UTF-8');
|
||||
$escapedFixed = $fixedCode ? htmlspecialchars($fixedCode, ENT_QUOTES, 'UTF-8') : '';
|
||||
$escapedExplanation = $explanation ? htmlspecialchars($explanation, ENT_QUOTES, 'UTF-8') : '';
|
||||
$locationStr = $v->location ? htmlspecialchars($v->location['file'] . ':' . (string)$v->location['line'], ENT_QUOTES, 'UTF-8') : 'N/A';
|
||||
$escapedRuleId = htmlspecialchars($v->ruleId, ENT_QUOTES, 'UTF-8');
|
||||
$escapedMessage = htmlspecialchars($v->message, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$hasAiFix = !empty($fixedCode);
|
||||
$aiTabButton = $hasAiFix ? "<button class=\"tab-btn\" id=\"tab-ai-{$cardIndex}\" role=\"tab\" aria-selected=\"false\" aria-controls=\"panel-ai-{$cardIndex}\" tabindex=\"-1\">Yapay Zeka Önerisi</button>" : "";
|
||||
$aiTabPanel = $hasAiFix ? "
|
||||
<div class=\"tab-panel\" id=\"panel-ai-{$cardIndex}\" role=\"tabpanel\" aria-labelledby=\"tab-ai-{$cardIndex}\" hidden>
|
||||
<div class=\"ai-recommendation\">
|
||||
" . ($escapedExplanation ? "<div class=\"ai-explanation\"><strong>Öneri Açıklaması:</strong> {$escapedExplanation}</div>" : "") . "
|
||||
<div class=\"code-wrapper\">
|
||||
<pre><code id=\"code-ai-{$cardIndex}\">{$escapedFixed}</code></pre>
|
||||
<button class=\"copy-btn\" aria-label=\"{$escapedRuleId} kuralı için önerilen düzeltmeyi kopyala\" onclick=\"copyCode('#code-ai-{$cardIndex}', this)\">
|
||||
<svg class=\"icon-copy\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>
|
||||
<span>Kopyala</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>" : "";
|
||||
|
||||
$cards .= "
|
||||
<div class=\"violation-card\" data-severity=\"{$severityClass}\" data-standard=\"{$standardVal}\" data-rule=\"{$escapedRuleId}\" data-message=\"{$escapedMessage}\" data-page=\"{$escapedUrl}\">
|
||||
<div class=\"card-header\">
|
||||
<div class=\"badge-row\">
|
||||
<span class=\"badge rule-badge\">{$escapedRuleId}</span>
|
||||
<span class=\"badge standard-badge\">WCAG {$standardVal}</span>
|
||||
<span class=\"badge severity-badge severity-{$severityClass}\">
|
||||
<span class=\"dot\"></span>
|
||||
" . ucfirst($severityClass) . "
|
||||
</span>
|
||||
</div>
|
||||
<div class=\"location-row\">
|
||||
<svg class=\"icon-location\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z\"></path><circle cx=\"12\" cy=\"10\" r=\"3\"></circle></svg>
|
||||
<span>{$locationStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\"card-body\">
|
||||
<h3 class=\"violation-msg\">{$escapedMessage}</h3>
|
||||
|
||||
<div class=\"code-viewer\">
|
||||
<div class=\"tab-list\" role=\"tablist\">
|
||||
<button class=\"tab-btn active\" id=\"tab-orig-{$cardIndex}\" role=\"tab\" aria-selected=\"true\" aria-controls=\"panel-orig-{$cardIndex}\">Mevcut Kod</button>
|
||||
{$aiTabButton}
|
||||
</div>
|
||||
<div class=\"tab-panel active\" id=\"panel-orig-{$cardIndex}\" role=\"tabpanel\" aria-labelledby=\"tab-orig-{$cardIndex}\">
|
||||
<pre><code>{$escapedSnippet}</code></pre>
|
||||
</div>
|
||||
{$aiTabPanel}
|
||||
</div>
|
||||
|
||||
<div class=\"page-meta-row\" style=\"margin-top: 12px; font-size: 0.8rem; color: var(--text-secondary); display: flex; align-items: center; gap: 6px;\">
|
||||
<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"width:14px;height:14px;color:var(--text-muted);\"><path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"></path><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"></path></svg>
|
||||
<span>Kaynak Sayfa: <strong style=\"word-break: break-all;\">{$escapedUrl}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>";
|
||||
|
||||
$cardIndex++;
|
||||
}
|
||||
|
||||
$escapedSnippet = htmlspecialchars($v->htmlSnippet, ENT_QUOTES, 'UTF-8');
|
||||
$escapedFixed = $fixedCode ? htmlspecialchars($fixedCode, ENT_QUOTES, 'UTF-8') : '';
|
||||
$escapedExplanation = $explanation ? htmlspecialchars($explanation, ENT_QUOTES, 'UTF-8') : '';
|
||||
$locationStr = $v->location ? htmlspecialchars($v->location['file'] . ':' . $v->location['line'], ENT_QUOTES, 'UTF-8') : 'N/A';
|
||||
$escapedRuleId = htmlspecialchars($v->ruleId, ENT_QUOTES, 'UTF-8');
|
||||
$escapedMessage = htmlspecialchars($v->message, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$hasAiFix = !empty($fixedCode);
|
||||
$aiTabButton = $hasAiFix ? "<button class=\"tab-btn\" id=\"tab-ai-{$index}\" role=\"tab\" aria-selected=\"false\" aria-controls=\"panel-ai-{$index}\" tabindex=\"-1\">Yapay Zeka Önerisi</button>" : "";
|
||||
$aiTabPanel = $hasAiFix ? "
|
||||
<div class=\"tab-panel\" id=\"panel-ai-{$index}\" role=\"tabpanel\" aria-labelledby=\"tab-ai-{$index}\" hidden>
|
||||
<div class=\"ai-recommendation\">
|
||||
" . ($escapedExplanation ? "<div class=\"ai-explanation\"><strong>Öneri Açıklaması:</strong> {$escapedExplanation}</div>" : "") . "
|
||||
<div class=\"code-wrapper\">
|
||||
<pre><code id=\"code-ai-{$index}\">{$escapedFixed}</code></pre>
|
||||
<button class=\"copy-btn\" aria-label=\"{$escapedRuleId} kuralı için önerilen düzeltmeyi kopyala\" onclick=\"copyCode('#code-ai-{$index}', this)\">
|
||||
<svg class=\"icon-copy\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path></svg>
|
||||
<span>Kopyala</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>" : "";
|
||||
|
||||
$cards .= "
|
||||
<div class=\"violation-card\" data-severity=\"{$severityClass}\" data-standard=\"{$standardVal}\" data-rule=\"{$escapedRuleId}\" data-message=\"{$escapedMessage}\">
|
||||
<div class=\"card-header\">
|
||||
<div class=\"badge-row\">
|
||||
<span class=\"badge rule-badge\">{$escapedRuleId}</span>
|
||||
<span class=\"badge standard-badge\">WCAG {$standardVal}</span>
|
||||
<span class=\"badge severity-badge severity-{$severityClass}\">
|
||||
<span class=\"dot\"></span>
|
||||
" . ucfirst($severityClass) . "
|
||||
</span>
|
||||
</div>
|
||||
<div class=\"location-row\">
|
||||
<svg class=\"icon-location\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z\"></path><circle cx=\"12\" cy=\"10\" r=\"3\"></circle></svg>
|
||||
<span>{$locationStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\"card-body\">
|
||||
<h3 class=\"violation-msg\">{$escapedMessage}</h3>
|
||||
|
||||
<div class=\"code-viewer\">
|
||||
<div class=\"tab-list\" role=\"tablist\">
|
||||
<button class=\"tab-btn active\" id=\"tab-orig-{$index}\" role=\"tab\" aria-selected=\"true\" aria-controls=\"panel-orig-{$index}\">Mevcut Kod</button>
|
||||
{$aiTabButton}
|
||||
</div>
|
||||
<div class=\"tab-panel active\" id=\"panel-orig-{$index}\" role=\"tabpanel\" aria-labelledby=\"tab-orig-{$index}\">
|
||||
<pre><code>{$escapedSnippet}</code></pre>
|
||||
</div>
|
||||
{$aiTabPanel}
|
||||
</div>
|
||||
</div>
|
||||
</div>";
|
||||
}
|
||||
|
||||
return <<<HTML
|
||||
@@ -378,7 +413,7 @@ class HtmlDashboardReporter
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 251.2;
|
||||
stroke-dashoffset: calc(251.2 - (251.2 * {$score}) / 100);
|
||||
stroke-dashoffset: calc(251.2 - (251.2 * {$averageScore}) / 100);
|
||||
transition: stroke-dashoffset 1.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@@ -466,7 +501,7 @@ class HtmlDashboardReporter
|
||||
|
||||
.search-group {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
flex: 2;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
@@ -506,6 +541,35 @@ class HtmlDashboardReporter
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-select-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-secondary);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
body.light-theme .select-input {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
body.light-theme .select-input option {
|
||||
background-color: white;
|
||||
color: #111827;
|
||||
}
|
||||
body.light-theme .select-input:focus {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -820,7 +884,7 @@ class HtmlDashboardReporter
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<div class="meta-tag" title="{$target}">Hedef: {$target}</div>
|
||||
<div class="meta-tag" title="{$target}">Hedef Site: {$target}</div>
|
||||
<div class="meta-tag">Tarih: {$date}</div>
|
||||
<button id="theme-toggle" class="theme-btn" aria-label="Açık/Karanlık tema değiştir" onclick="toggleTheme()">
|
||||
<!-- Moon icon -->
|
||||
@@ -838,13 +902,13 @@ class HtmlDashboardReporter
|
||||
<div class="gauge-container">
|
||||
<svg viewBox="0 0 100 100" class="gauge">
|
||||
<circle cx="50" cy="50" r="40" class="gauge-bg"></circle>
|
||||
<circle cx="50" cy="50" r="40" class="gauge-val " id="score-gauge" stroke-dasharray="251.2" stroke-dashoffset="251.2"></circle>
|
||||
<circle cx="50" cy="50" r="40" class="gauge-val" id="score-gauge" stroke-dasharray="251.2" stroke-dashoffset="251.2"></circle>
|
||||
</svg>
|
||||
<div class="gauge-label" id="score-val">0</div>
|
||||
</div>
|
||||
<div class="metric-desc">
|
||||
<h2>Erişilebilirlik Skoru</h2>
|
||||
<p>Sayfanın genel standartlara uygunluk derecesi.</p>
|
||||
<h2>Ortalama Sağlık Skoru</h2>
|
||||
<p>Tarama yapılan sayfaların ortalama puanı.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -882,20 +946,16 @@ class HtmlDashboardReporter
|
||||
<div class="glass-panel">
|
||||
<div class="summary-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">WCAG A</span>
|
||||
<span class="stat-val">{$standardA}</span>
|
||||
<span class="stat-label">Taranan Sayfa</span>
|
||||
<span class="stat-val">{$totalPages}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">WCAG AA</span>
|
||||
<span class="stat-val">{$standardAA}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">WCAG AAA</span>
|
||||
<span class="stat-val">{$standardAAA}</span>
|
||||
<span class="stat-label">WCAG A / AA / AAA</span>
|
||||
<span class="stat-val">{$standardA} / {$standardAA} / {$standardAAA}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Toplam İhlal</span>
|
||||
<span class="stat-val">{$count}</span>
|
||||
<span class="stat-val">{$totalViolations}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -909,6 +969,12 @@ class HtmlDashboardReporter
|
||||
</svg>
|
||||
<input type="search" id="search-box" class="search-input" placeholder="Kural veya hata açıklaması ara..." aria-label="Kural veya hata açıklaması ara" oninput="applyFilters()">
|
||||
</div>
|
||||
|
||||
<div class="page-select-group">
|
||||
<select id="page-select" class="select-input" aria-label="Taranan sayfaya göre filtrele" onchange="applyFilters()">
|
||||
{$pageOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<button class="filter-btn active" id="btn-all" onclick="setSeverityFilter('all')" aria-label="Tüm ihlalleri göster" aria-pressed="true">Hepsi</button>
|
||||
@@ -946,7 +1012,7 @@ class HtmlDashboardReporter
|
||||
<!-- JavaScript Logic -->
|
||||
<script>
|
||||
// Apply Circular Score Gauge Color and Animation
|
||||
const score = {$score};
|
||||
const score = {$averageScore};
|
||||
const gauge = document.getElementById('score-gauge');
|
||||
const scoreText = document.getElementById('score-val');
|
||||
|
||||
@@ -1143,6 +1209,7 @@ class HtmlDashboardReporter
|
||||
|
||||
function applyFilters() {
|
||||
const searchVal = document.getElementById('search-box').value.toLowerCase();
|
||||
const pageVal = document.getElementById('page-select').value;
|
||||
const cards = document.querySelectorAll('.violation-card');
|
||||
let visibleCount = 0;
|
||||
|
||||
@@ -1150,11 +1217,13 @@ class HtmlDashboardReporter
|
||||
const severity = card.getAttribute('data-severity');
|
||||
const rule = card.getAttribute('data-rule').toLowerCase();
|
||||
const message = card.getAttribute('data-message').toLowerCase();
|
||||
const page = card.getAttribute('data-page');
|
||||
|
||||
const matchesSeverity = (activeSeverity === 'all' || severity === activeSeverity);
|
||||
const matchesSearch = (!searchVal || rule.includes(searchVal) || message.includes(searchVal));
|
||||
const matchesPage = (pageVal === 'all' || page === pageVal);
|
||||
|
||||
if (matchesSeverity && matchesSearch) {
|
||||
if (matchesSeverity && matchesSearch && matchesPage) {
|
||||
card.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ abstract class AbstractRule implements RuleInterface
|
||||
abstract public function getStandard(): WCAGStandard;
|
||||
abstract public function getSeverity(): Severity;
|
||||
|
||||
public function getLevel(): int
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation
|
||||
{
|
||||
$doc = $element->ownerDocument;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class AriaHiddenFocusable extends AbstractRule
|
||||
public function getDescription(): string { return 'Focusable elements must not be hidden with aria-hidden="true".'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 5; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
@@ -78,4 +78,5 @@ class AriaRole extends AbstractRule
|
||||
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 3; }
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class ButtonName extends AbstractRule
|
||||
public function getDescription(): string { return 'Buttons must have discernible text.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 1; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
+222
-34
@@ -9,21 +9,38 @@ use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class ColorContrast extends AbstractRule
|
||||
{
|
||||
/** @var array<string, array<string, array{color?: string, background-color?: string}>> */
|
||||
private array $documentStylesCache = [];
|
||||
|
||||
public function getId(): string { return 'WCAG_1_4_3_CONTRAST'; }
|
||||
public function getDescription(): string { return 'Text must have sufficient contrast against the background (4.5:1 minimum).'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
|
||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||
public function getLevel(): int { return 5; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (!$element->hasAttribute('style')) {
|
||||
return null;
|
||||
}
|
||||
// 1. Parse inline styles (highest priority)
|
||||
$inlineStyle = $element->hasAttribute('style') ? $element->getAttribute('style') : '';
|
||||
$color = $this->parseColorValue($inlineStyle, 'color');
|
||||
$bg = $this->parseColorValue($inlineStyle, 'background-color') ?: $this->parseColorValue($inlineStyle, 'background');
|
||||
|
||||
$style = $element->getAttribute('style');
|
||||
|
||||
$color = $this->parseColorValue($style, 'color');
|
||||
$bg = $this->parseColorValue($style, 'background-color') ?: $this->parseColorValue($style, 'background');
|
||||
// 2. Fallback: Parse stylesheet styles
|
||||
if (($color === null || $bg === null) && $element->ownerDocument !== null) {
|
||||
$docStyles = $this->parseDocumentStyles($element->ownerDocument);
|
||||
|
||||
// Loop through all parsed styles. Later matching rules override earlier ones
|
||||
foreach ($docStyles as $selector => $styles) {
|
||||
if ($this->elementMatchesSelector($element, $selector)) {
|
||||
if ($color === null && isset($styles['color'])) {
|
||||
$color = $styles['color'];
|
||||
}
|
||||
if ($bg === null && isset($styles['background-color'])) {
|
||||
$bg = $styles['background-color'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($color === null || $bg === null) {
|
||||
return null;
|
||||
@@ -41,22 +58,11 @@ class ColorContrast extends AbstractRule
|
||||
|
||||
$ratio = ($cLum > $bgLum) ? ($cLum + 0.05) / ($bgLum + 0.05) : ($bgLum + 0.05) / ($cLum + 0.05);
|
||||
|
||||
// Check font size & weight for large text rules
|
||||
$isLarge = false;
|
||||
if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) {
|
||||
$val = (float)$matches[1];
|
||||
$unit = strtolower($matches[2]);
|
||||
if (($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5)) {
|
||||
$isLarge = true;
|
||||
}
|
||||
}
|
||||
if (preg_match('/font-weight\s*:\s*(bold|[7-9]00)/i', $style)) {
|
||||
if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) {
|
||||
$val = (float)$matches[1];
|
||||
$unit = strtolower($matches[2]);
|
||||
if (($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2)) {
|
||||
$isLarge = true;
|
||||
}
|
||||
}
|
||||
$fontSizeStyle = $this->parseFontSize($inlineStyle, $element);
|
||||
if ($fontSizeStyle !== null) {
|
||||
$isLarge = $this->isLargeText($fontSizeStyle, $this->parseFontWeight($inlineStyle, $element));
|
||||
}
|
||||
|
||||
$minRatio = $isLarge ? 3.0 : 4.5;
|
||||
@@ -73,21 +79,203 @@ class ColorContrast extends AbstractRule
|
||||
return null;
|
||||
}
|
||||
|
||||
private function 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)) {
|
||||
$value = trim($matches[1]);
|
||||
if (preg_match('/^#([0-9a-f]{3,6})$/i', $value)) {
|
||||
return $value;
|
||||
}
|
||||
$colors = [
|
||||
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
|
||||
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
|
||||
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
|
||||
];
|
||||
if (isset($colors[strtolower($value)])) {
|
||||
return $colors[strtolower($value)];
|
||||
}
|
||||
return $this->normalizeColorValue(trim($matches[1]));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -48,4 +48,5 @@ class DuplicateId extends AbstractRule
|
||||
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 3; }
|
||||
}
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -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 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;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class EmptyLink extends AbstractRule
|
||||
public function getDescription(): string { return 'Links must have discernible text.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 1; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ class FieldsetLegend extends AbstractRule
|
||||
public function getDescription(): string { return 'Fieldsets should have a legend.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||
public function getLevel(): int { return 2; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class FormLabel extends AbstractRule
|
||||
public function getDescription(): string { return 'Form inputs must have an associated label.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 2; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ class HeadingOrder extends AbstractRule
|
||||
public function getDescription(): string { return 'Headings should follow a logical nesting order.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||
public function getLevel(): int { return 2; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ class HtmlHasLang extends AbstractRule
|
||||
public function getDescription(): string { return 'The html element must have a lang attribute.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 1; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ class IframeTitle extends AbstractRule
|
||||
public function getDescription(): string { return 'Iframe elements must have a non-empty title attribute to describe their content.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 2; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ class ImgAltText extends AbstractRule
|
||||
public function getDescription(): string { return 'Images must have an alt attribute.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 1; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class PageTitle extends AbstractRule
|
||||
public function getDescription(): string { return 'Web pages must have a non-empty title element in the head.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 1; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
class RuleLevels
|
||||
{
|
||||
/**
|
||||
* Get rule instances for the specified level.
|
||||
* Levels are incremental: Level 5 includes all rules from Levels 1, 2, 3, and 4.
|
||||
*
|
||||
* @param int $level
|
||||
* @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
|
||||
*/
|
||||
public static function getRulesForLevel(int $level): array
|
||||
{
|
||||
$rules = [];
|
||||
$files = glob(__DIR__ . '/*.php');
|
||||
if ($files === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$className = pathinfo($file, PATHINFO_FILENAME);
|
||||
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $className;
|
||||
|
||||
if (class_exists($fqcn)) {
|
||||
$reflection = new \ReflectionClass($fqcn);
|
||||
|
||||
if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it is a concrete rule
|
||||
$isRule = $reflection->isSubclassOf(\YakNet\AccessibilityConsole\Core\AbstractRule::class) ||
|
||||
$reflection->implementsInterface(\YakNet\AccessibilityConsole\Rules\RuleInterface::class);
|
||||
|
||||
if ($isRule) {
|
||||
// Exclude Turkish custom rules to match standard behavior
|
||||
if (in_array($className, ['EmptyLinkRule', 'HeadingOrderRule', 'ImageAltRule'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$instance = $reflection->newInstance();
|
||||
|
||||
// Check level
|
||||
$ruleLevel = 4; // default
|
||||
if (method_exists($instance, 'getLevel')) {
|
||||
/** @var mixed $levelVal */
|
||||
$levelVal = $instance->getLevel();
|
||||
if (is_int($levelVal)) {
|
||||
$ruleLevel = $levelVal;
|
||||
}
|
||||
}
|
||||
|
||||
if ($ruleLevel <= $level) {
|
||||
/** @var \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule $instance */
|
||||
$rules[] = $instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort rules by class name to ensure deterministic order
|
||||
usort($rules, function ($a, $b) {
|
||||
return strcmp(get_class($a), get_class($b));
|
||||
});
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -5,31 +5,10 @@ namespace YakNet\AccessibilityConsole\Rules;
|
||||
class StandardRuleSet
|
||||
{
|
||||
/**
|
||||
* @return RuleInterface[]
|
||||
* @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
/** @var RuleInterface[] $rules */
|
||||
$rules = [
|
||||
new HtmlHasLang(),
|
||||
new ImgAltText(),
|
||||
new EmptyLink(),
|
||||
new ButtonName(),
|
||||
new FormLabel(),
|
||||
new FieldsetLegend(),
|
||||
new HeadingOrder(),
|
||||
new MetaViewport(),
|
||||
new TabindexOrder(),
|
||||
new ColorContrast(),
|
||||
new DuplicateId(),
|
||||
new AriaRole(),
|
||||
new IframeTitle(),
|
||||
new LinkTextGeneric(),
|
||||
new PlaceholderAsLabel(),
|
||||
new ImageAltRedundant(),
|
||||
new AriaHiddenFocusable(),
|
||||
new PageTitle(),
|
||||
];
|
||||
return $rules;
|
||||
return RuleLevels::getRulesForLevel(5);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ class TabindexOrder extends AbstractRule
|
||||
public function getDescription(): string { return 'Avoid positive tabindex values to maintain natural keyboard focus order.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||
public function getLevel(): int { return 3; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class TableHeaders extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_1_3_1_TABLE'; }
|
||||
public function getDescription(): string { return 'Data tables must have headers (th) or captions for screen readers.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||
public function getLevel(): int { return 2; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (strtolower($element->tagName) !== 'table') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip layout tables explicitly marked as presentation
|
||||
$role = strtolower($element->getAttribute('role'));
|
||||
if ($role === 'presentation' || $role === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there is at least one <th> or <caption>
|
||||
$thCount = $element->getElementsByTagName('th')->length;
|
||||
$captionCount = $element->getElementsByTagName('caption')->length;
|
||||
|
||||
if ($thCount === 0 && $captionCount === 0) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Add <th> header elements or a <caption> to the table, or set role="presentation" if it is only used for layout.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class VideoTrack extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_1_2_2_VIDEO_TRACK'; }
|
||||
public function getDescription(): string { return 'Video elements must contain at least one track element for captions or subtitles.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||
public function getLevel(): int { return 2; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (strtolower($element->tagName) !== 'video') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there is a track element inside
|
||||
$tracks = $element->getElementsByTagName('track');
|
||||
if ($tracks->length === 0) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Add a <track kind="captions"> or <track kind="subtitles"> element inside the video element.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user