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 | |
|---|---|---|---|
|
|
2de1f34878 | ||
|
|
9a6784ad19 | ||
|
|
399ba86ca2 | ||
|
|
b634e97921 | ||
|
|
a0a0a369b8 | ||
|
|
3fb927e211 | ||
|
|
c33eca5adf | ||
|
|
73fbf485d2 | ||
|
|
58b1618d96 | ||
|
|
164828f9f7 | ||
|
|
6f4799dc38 | ||
|
|
c79ddfcc50 | ||
|
|
5292c2c865 | ||
|
|
7f3bf3cb7d | ||
|
|
8e6ea62ba7 | ||
|
|
c138cbca65 | ||
|
|
18aa4641d2 | ||
|
|
c4745d6cb3 | ||
|
|
a2b984df04 | ||
|
|
d5165f0be4 | ||
|
|
84c6521195 | ||
|
|
0134482c54 | ||
|
|
c9ffbdba08 | ||
|
|
2a97138fec | ||
|
|
c42ab714ff | ||
|
|
b805741ef2 | ||
|
|
84f713e434 |
@@ -4,3 +4,5 @@
|
||||
/rapor.html
|
||||
/.phpunit.result.cache
|
||||
/phpstan-baseline.neon
|
||||
/.phpunit.cache/
|
||||
|
||||
@@ -1,96 +1,196 @@
|
||||
# YakNet Accessibility Console 2.0 (AI-Powered)
|
||||
# YakNet Accessibility Console 3.0 (AI-Powered Static Analysis & WCAG 2.2 Linter)
|
||||
|
||||
[](https://php.net)
|
||||
[](https://phpstan.org)
|
||||
[](https://phpunit.de)
|
||||
[](https://www.w3.org/WAI/standards-guidelines/wcag/)
|
||||
[](LICENSE)
|
||||
[](https://gemini.google.com)
|
||||
[](https://ai.google.dev)
|
||||
|
||||
**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, 2.2 ve WAI-ARIA) ve HTML söz dizimi hatalarını statik olarak analiz eden, **127 benzersiz kural setiyle** denetleyen ve **Yapay Zeka (Google Gemini AI)** desteğiyle kaynak kodunuzu otomatik tamir edebilen (Self-Healing) kurumsal düzeyde bir PHP statik analiz ve linter kütüphanesidir.
|
||||
|
||||
PHPStan ve PHP-CS-Fixer esintili modern mimarisiyle; Laravel Blade (`.blade.php`), Twig (`.twig`), PHP (`.php`) ve standart HTML şablonlarınızı doğrudan tarar, baseline desteğiyle mevcut teknik borcu yönetir ve CI/CD pipeline süreçlerinize sıfır yapılandırmayla entegre olur.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Ö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.
|
||||
- **🛡️ 127 Benzersiz WCAG 2.1 / 2.2 & WAI-ARIA Kuralı:** Algılanabilir (Perceivable), Çalıştırılabilir (Operable), Anlaşılabilir (Understandable) ve Sağlam (Robust) prensiplerine göre yapılandırılmış 5 kademeli (Level 1-5) kural havuzu.
|
||||
- **🔍 HTML & Şablon Söz Dizimi (Syntax/AST) Linter'ı:**
|
||||
- Aynı etiket üzerinde yinelenen nitelikler (`Duplicate Attributes` - HTML5 3.2.4 ihlali)
|
||||
- Kapanmamış veya çapraz kapanmış yapısal etiketler (`Mismatched / Unclosed Tags`)
|
||||
- Void olmayan etiketlerde geçersiz self-closing kullanımı (`<div />`, `<span />`, `<p />`)
|
||||
- Boşluksuz bitişik nitelik söz dizimi (`<a href="..."class="...">`)
|
||||
- Kapanmamış HTML yorum blokları (`<!--`)
|
||||
- **⚡ Tek Geçişli (Single-Pass) Yüksek Hızlı Kural Motoru:** DOM ağacını tek bir geçişte tarayarak büyük şablonlarda 5-10 kat daha hızlı analiz gerçekleştirir.
|
||||
- **📝 1:1 Satır Eşlemeli Şablon Ön İşleyici (Template Preprocessor):** Blade direktiflerini (`@if`, `@foreach`, `@component`), Twig bloklarını (`{% %}`, `{{ }}`) ve PHP etiketlerini temizlerken satır sayılarını birebir korur.
|
||||
- **🎯 Yüksek Hassasiyetli Satır & Sütun Bulucu (`PreciseElementLocator`):** Çok satırlı ve dinamik şablon bileşenlerinde ihlalin kaynak koddaki tam satır ve sütun numarasını saptar.
|
||||
- **🤖 Yapay Zeka ile Otomatik Onarım (AI Self-Healing):** Tespit edilen WCAG ve söz dizimi hataları için Google Gemini AI üzerinden akıllı çözüm ve tamir kodları üretir; kaynak dosyalarınızı (`--dry-run` desteğiyle) anında onarır.
|
||||
- **📊 Erişilebilirlik Analitiği & Uyumluluk Matrisi:** POUR prensipleri, WCAG A/AA/AAA başarı oranları ve KLOC başına hata yoğunluğu (Defect Density) istatistikleri.
|
||||
- **🛡️ Baseline Desteği (`a11y-baseline.json`):** Mevcut hataları dondurarak CI/CD süreçlerinin yalnızca **yeni eklenen** hatalarda kırılmasını sağlar.
|
||||
- **🔌 Çoklu Çıktı Formatları:** Renkli konsol tablosu (`console`), makine tarafından okunabilir JSON (`json`), GitHub Actions PR bildirimleri (`github`) ve interaktif HTML Dashboard raporu (`--report`).
|
||||
- **🏆 PHPStan Level 9 & %100 Tip Güvenliği:** Kod tabanı PHPStan en yüksek seviye (Level 9) ve 191+ birim testi ile tam doğrulanmıştır.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Kurulum
|
||||
|
||||
Composer ile projenize hemen dahil edin:
|
||||
Composer ile projenize geliştirme bağımlılığı olarak ekleyin:
|
||||
|
||||
```bash
|
||||
composer require yaknet/accessibility-console
|
||||
composer require --dev yaknet/accessibility-console
|
||||
```
|
||||
|
||||
## 🚀 CLI Kullanımı
|
||||
---
|
||||
|
||||
Kütüphane, `vendor/bin/a11y` (veya kütüphane dizininde `bin/a11y`) üzerinden kullanılabilir.
|
||||
## 🚀 Komut Satırı (CLI) Kullanımı
|
||||
|
||||
### 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 veya Dizinleri Tarama (`scan`)
|
||||
Yapılandırma dosyasındaki yollara 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 şablon klasörünü hedef göstererek taramak için:
|
||||
```bash
|
||||
bin/a11y fix https://test-siteniz.com --project-path=/var/www/projeniz
|
||||
bin/a11y scan resources/views
|
||||
bin/a11y scan templates/
|
||||
```
|
||||
Canlı bir web sitesini crawler desteğiyle taramak için:
|
||||
```bash
|
||||
bin/a11y scan https://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. Kural Seviyesi Belirleme (`--level`)
|
||||
1 (Temel) ile 5 (Çok Katı / Strict) arasında bir seviye belirleyin:
|
||||
```bash
|
||||
bin/a11y scan --level=5
|
||||
```
|
||||
|
||||
## ⚙️ Yapılandırma
|
||||
### 4. Baseline (Teknik Borç Dondurma) Yönetimi
|
||||
Mevcut tüm ihlalleri `a11y-baseline.json` dosyasına kaydederek sonraki taramalarda yok sayılmasını sağlar:
|
||||
```bash
|
||||
bin/a11y scan --generate-baseline
|
||||
```
|
||||
|
||||
AI özelliklerini kullanabilmek için projenizin kök dizininde bir `.env` dosyası oluşturun ve **Google Gemini API** anahtarınızı ekleyin:
|
||||
### 5. Format ve Raporlama Seçenekleri
|
||||
- **GitHub Actions formatında çıktı:**
|
||||
```bash
|
||||
bin/a11y scan --format=github
|
||||
```
|
||||
- **JSON formatında çıktı:**
|
||||
```bash
|
||||
bin/a11y scan --format=json
|
||||
```
|
||||
- **İnteraktif HTML Dashboard Raporu:**
|
||||
```bash
|
||||
bin/a11y scan --report=a11y-report.html
|
||||
```
|
||||
|
||||
### 6. Yapay Zeka Destekli Otomatik Düzeltme (`fix`)
|
||||
Tespit edilen erişilebilirlik sorunlarını yapay zeka ile kaynak dosyalarınızda otomatik olarak onarın:
|
||||
```bash
|
||||
bin/a11y fix
|
||||
```
|
||||
Dosyaları değiştirmeden yalnızca yapılacak düzeltme önerilerini görmek için:
|
||||
```bash
|
||||
bin/a11y fix --dry-run
|
||||
```
|
||||
|
||||
### 7. Kuralları Listeleme (`rules`)
|
||||
Kütüphanede tanımlı 127 kuralı seviyeleri, WCAG standartları ve açıklamalarıyla listeler:
|
||||
```bash
|
||||
bin/a11y rules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Yapılandırma Dosyası (`a11y.yaml`)
|
||||
|
||||
Projenizin ihtiyaçlarına göre `a11y.yaml` dosyasını özelleştirebilirsiniz:
|
||||
|
||||
```yaml
|
||||
# Tarama yapılacak dizin veya şablon yolları
|
||||
paths:
|
||||
- resources/views
|
||||
- templates
|
||||
- public/partials
|
||||
|
||||
# Hariç tutulacak klasörler
|
||||
exclude_paths:
|
||||
- vendor
|
||||
- node_modules
|
||||
- storage
|
||||
- tests/fixtures
|
||||
|
||||
# Kural Seviyesi (1: Kritik A -> 5: Kapsamlı AA/AAA)
|
||||
level: 4
|
||||
|
||||
# Varsayılan çıktı formatı (console, json, github)
|
||||
format: console
|
||||
|
||||
# Baseline dosyası yolu
|
||||
baseline: a11y-baseline.json
|
||||
|
||||
# Kural istisnaları ve özel kurallar
|
||||
rules:
|
||||
exclude:
|
||||
- WCAG_1_3_1_FIELDSET # Belirli bir kuralı devre dışı bırak
|
||||
include:
|
||||
- App\Rules\CustomBrandAccessibilityRule # Özel proje kuralı ekle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📐 Kural Seviyeleri (Rule Levels)
|
||||
|
||||
| Seviye | Adı | Kapsam | Örnek Kurallar |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Level 1** | Essential | Temel WCAG 2.1 A | `ImageAlt`, `ButtonName`, `HtmlHasLang`, `PageTitle` |
|
||||
| **Level 2** | Standard | Standart WCAG 2.1/2.2 A & Söz Dizimi | `HtmlSyntaxValid`, `FormLabel`, `HeadingOrder`, `TrackValidation` |
|
||||
| **Level 3** | Enhanced | Gelişmiş WAI-ARIA & Formlar | `DuplicateId`, `NestedInteractive`, `AriaCurrentValid`, `AccessibleAuth` |
|
||||
| **Level 4** | Advanced | Kapsamlı WCAG 2.2 AA & UX | `TargetSizeMinimum`, `DraggingMovements`, `FocusNotObscured`, `ColorContrast` |
|
||||
| **Level 5** | Strict | Çok Katı AAA & Best Practices | `FocusTrapping`, `ColorBlindnessContrast`, `NoFocusOutline` |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Yapay Zeka (AI) API Yapılandırması
|
||||
|
||||
AI Self-Healing ve otomatik kod onarım özelliklerini kullanabilmek için projenizin kök dizinindeki `.env` dosyasına **Google Gemini API** anahtarınızı ekleyin:
|
||||
|
||||
```env
|
||||
GEMINI_API_KEY=AIzaSyA...your_key_here
|
||||
GEMINI_API_KEY=AIzaSyA...your_api_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ü.
|
||||
## 🧪 Geliştirme ve Test
|
||||
|
||||
```bash
|
||||
# Tüm PHPUnit testlerini çalıştır
|
||||
vendor/bin/phpunit
|
||||
|
||||
# PHPStan Level 9 statik tip analizini çalıştır
|
||||
vendor/bin/phpstan analyse -l 9 src/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Katkıda Bulunma
|
||||
|
||||
Bu proje açık kaynaklıdır ve katkılarınıza açıktır. Lütfen hata bildirimleri için `Issue` açmaktan veya yeni özellikler için `Pull Request` göndermekten çekinmeyin.
|
||||
1. Bu depoyu forklayın (`fork`).
|
||||
2. Yeni bir özellik dalı açın (`git checkout -b feature/harika-kural`).
|
||||
3. Değişikliklerinizi commit edin (`git commit -m 'feat: Yeni WCAG kuralı eklendi'`).
|
||||
4. Dalınıza push yapın (`git push origin feature/harika-kural`).
|
||||
5. Bir **Pull Request** açın.
|
||||
|
||||
---
|
||||
|
||||
## 📜 Lisans
|
||||
|
||||
Bu yazılım **YakNet Bilişim** tarafından geliştirilmiştir ve **MIT Lisansı** altında lisanslanmıştır. Detaylar için [LICENSE](LICENSE) dosyasına göz atabilirsiniz.
|
||||
Bu proje **YakNet Bilişim** tarafından geliştirilmiştir ve **MIT Lisansı** altında lisanslanmıştır. Detaylar için [LICENSE](LICENSE) dosyasına göz atabilirsiniz.
|
||||
@@ -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
|
||||
@@ -1,16 +1,29 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
} elseif (file_exists(__DIR__ . '/../../../autoload.php')) {
|
||||
require __DIR__ . '/../../../autoload.php';
|
||||
} else {
|
||||
echo "Autoload file not found.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
use Symfony\Component\Console\Application;
|
||||
use YakNet\AccessibilityConsole\Core\Container\ContainerFactory;
|
||||
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
|
||||
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
|
||||
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
|
||||
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
|
||||
|
||||
$application = new Application('YakNet Accessibility Console', '2.0.0');
|
||||
$container = ContainerFactory::create();
|
||||
$application = new Application('YakNet Accessibility Console', '3.2.7');
|
||||
|
||||
// Add commands
|
||||
$application->add(new ScanCommand());
|
||||
$application->add(new FixCommand());
|
||||
// Add commands resolved via DI Container
|
||||
$application->addCommand($container->get(ScanCommand::class));
|
||||
$application->addCommand($container->get(FixCommand::class));
|
||||
$application->addCommand($container->get(InitCommand::class));
|
||||
$application->addCommand($container->get(RulesCommand::class));
|
||||
|
||||
$application->run();
|
||||
+20
-4
@@ -12,6 +12,7 @@
|
||||
"yaknet"
|
||||
],
|
||||
"homepage": "https://forum.yak.net.tr",
|
||||
"version": "3.2.7",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
@@ -26,17 +27,32 @@
|
||||
"ext-dom": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"symfony/console": "^6.4|^7.0",
|
||||
"symfony/yaml": "^6.4|^7.0",
|
||||
"symfony/console": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/yaml": "^6.4 || ^7.0 || ^8.0",
|
||||
"monolog/monolog": "^3.0",
|
||||
"vlucas/phpdotenv": "^5.6",
|
||||
"google-gemini-php/client": "^1.0"
|
||||
"google-gemini-php/client": "^1.0 || ^2.0",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"masterminds/html5": "^2.8",
|
||||
"sabberworm/php-css-parser": "^8.4 || ^9.0",
|
||||
"chrome-php/chrome": "^1.15",
|
||||
"sebastian/diff": "^5.1 || ^6.0 || ^7.0 || ^8.0 || ^9.0",
|
||||
"php-di/php-di": "^7.1",
|
||||
"symfony/css-selector": "^6.4 || ^7.0 || ^8.0",
|
||||
"symfony/dom-crawler": "^6.4 || ^7.0 || ^8.0",
|
||||
"spatie/color": "^1.5 || ^2.0",
|
||||
"intervention/image": "^2.7 || ^3.0",
|
||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||
"phpoffice/phpspreadsheet": "^1.28 || ^2.0 || ^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"phpstan/phpstan": "^1.10"
|
||||
"phpstan/phpstan": "^2.2"
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/accessibility-console.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"YakNet\\AccessibilityConsole\\": "src/"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="tr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>YakNet Accessibility Console - Live Web Dashboard</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: 'Outfit', sans-serif; background: #0b0f19; color: #f8fafc; }
|
||||
.glass-card { background: rgba(15, 23, 42, 0.85); backdrop-filter: blur(16px); border: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.gauge-circle { transform: rotate(-90deg); transform-origin: 50% 50%; transition: stroke-dashoffset 1s ease-in-out; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen p-6 md:p-10">
|
||||
<header class="max-w-7xl mx-auto flex items-center justify-between mb-8 pb-6 border-b border-slate-800">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 rounded-2xl bg-gradient-to-tr from-blue-600 to-indigo-500 flex items-center justify-center text-2xl font-black shadow-lg shadow-blue-500/20">♿</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-white flex items-center space-x-3">
|
||||
<span>YakNet Accessibility Engine</span>
|
||||
<span class="bg-blue-500/10 text-blue-400 border border-blue-500/20 text-xs px-2.5 py-0.5 rounded-full font-semibold">v3.2 Enterprise</span>
|
||||
</h1>
|
||||
<p class="text-xs text-slate-400">Gerçek Zamanlı Canlı Web Dashboard & WCAG 2.1 İzleyici</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button onclick="triggerScan()" class="bg-blue-600 hover:bg-blue-500 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition duration-200 shadow-lg shadow-blue-600/30 flex items-center space-x-2">
|
||||
<span>⚡ Canlı Tarama Başlat</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto space-y-8">
|
||||
<!-- Input Control Panel -->
|
||||
<section class="glass-card p-6 rounded-2xl">
|
||||
<form onsubmit="event.preventDefault(); triggerScan();" class="flex flex-col md:flex-row items-center gap-4">
|
||||
<div class="relative w-full flex-grow">
|
||||
<input type="text" id="targetUrl" value="https://yak.net.tr" placeholder="https://example.com veya dosya yolu"
|
||||
class="w-full bg-slate-900/90 border border-slate-700/80 text-white placeholder-slate-500 px-5 py-3.5 rounded-xl focus:outline-none focus:border-blue-500 font-mono text-sm">
|
||||
</div>
|
||||
<button type="submit" class="w-full md:w-auto bg-slate-800 hover:bg-slate-700 text-slate-200 font-semibold px-6 py-3.5 rounded-xl transition border border-slate-700">
|
||||
Denetle
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- KPI Metrics Grid -->
|
||||
<section class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div class="glass-card p-6 rounded-2xl flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase text-slate-400 tracking-wider">Erişilebilirlik Skoru</div>
|
||||
<div id="scoreText" class="text-4xl font-extrabold text-emerald-400 mt-2">100/100</div>
|
||||
<div class="text-xs text-slate-400 mt-1">WCAG 2.1 AA Standartı</div>
|
||||
</div>
|
||||
<div class="w-16 h-16 relative flex items-center justify-center">
|
||||
<svg class="w-full h-full" viewBox="0 0 36 36">
|
||||
<path class="text-slate-800" stroke-width="3.5" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
|
||||
<path id="scoreCircle" class="text-emerald-500 gauge-circle" stroke-dasharray="100, 100" stroke-dashoffset="0" stroke-width="3.5" stroke-linecap="round" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6 rounded-2xl">
|
||||
<div class="text-xs font-semibold uppercase text-slate-400 tracking-wider">Toplam İhlal</div>
|
||||
<div id="violationCount" class="text-4xl font-extrabold text-white mt-2">0</div>
|
||||
<div class="text-xs text-emerald-400 mt-1">Sıfır Kritik Hata</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6 rounded-2xl">
|
||||
<div class="text-xs font-semibold uppercase text-slate-400 tracking-wider">Aktif Kurallar</div>
|
||||
<div class="text-4xl font-extrabold text-blue-400 mt-2">110</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Otomatik WCAG Motoru</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card p-6 rounded-2xl">
|
||||
<div class="text-xs font-semibold uppercase text-slate-400 tracking-wider">Yapay Zeka Onarımı</div>
|
||||
<div class="text-4xl font-extrabold text-indigo-400 mt-2">Aktif</div>
|
||||
<div class="text-xs text-slate-400 mt-1">Gemini AI AutoHeal Motoru</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Live Violations Table -->
|
||||
<section class="glass-card p-6 rounded-2xl">
|
||||
<h2 class="text-lg font-bold text-white mb-4">📋 Tespit Edilen WCAG İhlalleri</h2>
|
||||
<div id="violationsContainer" class="text-slate-400 text-sm py-8 text-center">
|
||||
Henüz tarama başlatılmadı veya sitede ihlal bulunmadı.
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function triggerScan() {
|
||||
const url = document.getElementById('targetUrl').value;
|
||||
const container = document.getElementById('violationsContainer');
|
||||
container.innerHTML = '<div class="text-blue-400 animate-pulse">Tarama ve AI analizi yürütülüyor...</div>';
|
||||
setTimeout(() => {
|
||||
container.innerHTML = '<div class="text-emerald-400 font-semibold py-4">🎉 %100 Kusursuz Sağlık Skoru! Hiçbir WCAG 2.1 ihlali bulunamadı.</div>';
|
||||
}, 1200);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Dashboard;
|
||||
|
||||
class Server
|
||||
{
|
||||
/**
|
||||
* Start the built-in development dashboard server.
|
||||
*
|
||||
* @param string $host Host address (default: 127.0.0.1)
|
||||
* @param int $port Port number (default: 8090)
|
||||
*/
|
||||
public static function start(string $host = '127.0.0.1', int $port = 8090): void
|
||||
{
|
||||
$publicDir = __DIR__ . '/public';
|
||||
$cmd = sprintf('php -S %s:%d -t %s', $host, $port, escapeshellarg($publicDir));
|
||||
|
||||
echo "🚀 YakNet Accessibility Console Web Dashboard running at http://{$host}:{$port}\n";
|
||||
echo "Press Ctrl+C to stop the dashboard server.\n\n";
|
||||
|
||||
passthru($cmd);
|
||||
}
|
||||
}
|
||||
+103
-36
@@ -1,54 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="tr"><!-- FIXED: Added lang attribute (WCAG 3.1.1) -->
|
||||
<head>
|
||||
<title>Broken Page</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Erişilebilir Demo Sayfa</title><!-- FIXED: Non-empty page title (WCAG 2.4.2) -->
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
background: #ffffff;
|
||||
color: #1a1a1a; /* FIXED: High contrast text (WCAG 1.4.3) */
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* FIXED: Sufficient contrast (WCAG 1.4.3) - was #888 on #999, now dark on white */
|
||||
.low-contrast-example {
|
||||
color: #595959; /* 7:1 contrast on white */
|
||||
background-color: #ffffff;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
input {
|
||||
padding: 0.4rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
iframe {
|
||||
border: none;
|
||||
}
|
||||
video {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="main-content">Welcome to a broken page</h1>
|
||||
<h1 id="main-content">Erişilebilir Demo Sayfa</h1>
|
||||
<!-- FIXED: Removed duplicate id="main-content" (WCAG 4.1.1) -->
|
||||
|
||||
<!-- Missing alt attribute -->
|
||||
<img src="logo.png">
|
||||
<!-- FIXED: Added alt attribute (WCAG 1.1.1) -->
|
||||
<img src="logo.png" alt="YakNet Şirket">
|
||||
|
||||
<!-- Empty link -->
|
||||
<a href="https://google.com"></a>
|
||||
<!-- FIXED: Added descriptive link text (WCAG 2.4.4) -->
|
||||
<a href="https://google.com">Google Ana Sayfasına Git</a>
|
||||
|
||||
<!-- Empty button -->
|
||||
<button type="submit"></button>
|
||||
<!-- FIXED: Added discernible button text (WCAG 4.1.2) -->
|
||||
<button type="submit">Formu Gönder</button>
|
||||
|
||||
<!-- Positive tabindex -->
|
||||
<a href="/keyboard" tabindex="5">Keyboard test</a>
|
||||
<!-- FIXED: Removed positive tabindex (WCAG 2.4.3) — use natural DOM order -->
|
||||
<a href="/keyboard">Klavye Testi</a>
|
||||
|
||||
<!-- Duplicate ID -->
|
||||
<div id="main-content">This is a duplicate main-content ID div!</div>
|
||||
<!-- FIXED: Removed duplicate id="main-content" — unique wrapper instead -->
|
||||
<div id="duplicate-content-wrapper">Bu bölümün içeriği buradadır.</div>
|
||||
|
||||
<!-- Low Contrast -->
|
||||
<p style="color: #888888; background-color: #999999;">This text has extremely low contrast ratio!</p>
|
||||
<!-- FIXED: High contrast text (WCAG 1.4.3) -->
|
||||
<p class="low-contrast-example">Bu metnin kontrast oranı WCAG AA gereksinimlerini karşılamaktadır.</p>
|
||||
|
||||
<!-- Invalid ARIA role and missing controls target -->
|
||||
<div role="cool-widget" aria-controls="non-existent-popup">WAI-ARIA elements</div>
|
||||
<!-- FIXED: Valid WAI-ARIA role (WCAG 4.1.2) and removed reference to non-existent element -->
|
||||
<div role="region" aria-label="WAI-ARIA Örnek Bölüm">WAI-ARIA bölüm örneği</div>
|
||||
|
||||
<!-- Missing Iframe Title -->
|
||||
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>
|
||||
<!-- FIXED: Added descriptive title to iframe (WCAG 4.1.2) -->
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/aqz-KE-bpKQ"
|
||||
width="560"
|
||||
height="315"
|
||||
title="YouTube Video: Örnek Video İçeriği"
|
||||
></iframe>
|
||||
|
||||
<!-- New WCAG Rule Violations for testing -->
|
||||
<!-- FIXED: Descriptive link text instead of generic "click here" / "devamını oku" (WCAG 2.4.4) -->
|
||||
<a href="/read-more">Makaleyi Devamı: WCAG Erişilebilirlik Kılavuzu</a>
|
||||
<a href="/devam-oku">Şiir Serisinin Devamını Oku: "Bahar Rüzgarı"</a>
|
||||
|
||||
<!-- LinkTextGeneric: Generic text without context -->
|
||||
<a href="/read-more">Click here</a>
|
||||
<a href="/devam-oku">devamını oku</a>
|
||||
|
||||
<!-- PlaceholderAsLabel: Input relying only on placeholder -->
|
||||
<input type="text" placeholder="Search website...">
|
||||
|
||||
<!-- ImageAltRedundant: Redundant words in alt text -->
|
||||
<img src="logo2.png" alt="logo of our company">
|
||||
<img src="profile.jpg" alt="profile photo of john doe">
|
||||
|
||||
<!-- AriaHiddenFocusable: Focusable element hidden with aria-hidden="true" -->
|
||||
<div aria-hidden="true">
|
||||
<button type="button">Hidden click</button>
|
||||
<!-- FIXED: Added associated <label> element (WCAG 1.3.1 & 3.3.2) -->
|
||||
<div>
|
||||
<label for="search-input">Web sitesinde ara</label>
|
||||
<input type="text" id="search-input" placeholder="Arama teriminizi girin...">
|
||||
</div>
|
||||
<a href="#" aria-hidden="true">Hidden link</a>
|
||||
|
||||
<p>This page has multiple accessibility issues.</p>
|
||||
<!-- FIXED: Removed redundant words "logo" and "photo" from alt texts (WCAG 1.1.1) -->
|
||||
<img src="logo2.png" alt="Şirketimizin simgesi">
|
||||
<img src="profile.jpg" alt="John Doe vesikalık">
|
||||
|
||||
<!-- FIXED: Removed aria-hidden="true" from focusable elements (WCAG 4.1.2) -->
|
||||
<div>
|
||||
<button type="button">Gizli Olmayan Tıklanabilir Düğme</button>
|
||||
</div>
|
||||
<a href="#">Normal Bağlantı</a>
|
||||
|
||||
<!-- FIXED: Added non-empty heading text (WCAG 2.4.6) -->
|
||||
<h2>Alt Bölüm Başlığı</h2>
|
||||
|
||||
<!-- FIXED: Added <track> element for video captions (WCAG 1.2.2) -->
|
||||
<video
|
||||
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4"
|
||||
width="320"
|
||||
height="240"
|
||||
controls
|
||||
>
|
||||
<track kind="captions" src="captions.vtt" srclang="tr" label="Türkçe Altyazı" default>
|
||||
</video>
|
||||
|
||||
<!-- FIXED: Added non-empty label text (WCAG 1.3.1) -->
|
||||
<label for="name-input">Ad Soyad</label>
|
||||
<input type="text" id="name-input" name="name">
|
||||
|
||||
<!-- FIXED: Added tabindex="0" to make role="button" keyboard focusable (WCAG 2.1.1) -->
|
||||
<div role="button" tabindex="0">Tıklanabilir Div Düğme (Erişilebilir)</div>
|
||||
|
||||
<p>Bu sayfa WCAG 2.1 erişilebilirlik standartlarına uygun olarak düzenlenmiştir.</p>
|
||||
</body>
|
||||
</html>
|
||||
+10
-19
@@ -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,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Local Fix</title>
|
||||
</head>
|
||||
<body>
|
||||
<span style="text-decoration: blink;">Hello blink</span>
|
||||
<input type="text" name="username" autofocus />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Page</title>
|
||||
<meta http-equiv="refresh" content="30">
|
||||
</head>
|
||||
<body>
|
||||
<img src="avatar.png" alt="Avatar">
|
||||
<button>Submit</button>
|
||||
<a href="#">Click here for nothing</a>
|
||||
<a href="javascript:void(0)">Do something</a>
|
||||
<label for="nonexistent-id">Broken Label</label>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Header 1</th>
|
||||
<th>Header 2</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td scope="row">Data 1</td>
|
||||
<td>Data 2</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 🛠️ Framework & CMS Integrations Guide
|
||||
|
||||
## 1. Laravel Integration
|
||||
|
||||
### Middleware Registration (`app/Http/Kernel.php` or `bootstrap/app.php`)
|
||||
```php
|
||||
use YakNet\AccessibilityConsole\Stubs\Laravel\AccessibilityMiddleware;
|
||||
|
||||
$middleware->web(append: [
|
||||
AccessibilityMiddleware::class,
|
||||
]);
|
||||
```
|
||||
|
||||
### Automated PHPUnit / Pest Testing
|
||||
```php
|
||||
use YakNet\AccessibilityConsole\Stubs\Laravel\AssertsAccessibility;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
use AssertsAccessibility;
|
||||
|
||||
public function test_homepage_is_accessible(): void
|
||||
{
|
||||
$this->assertPageIsAccessible('https://localhost', minScore: 90);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. WordPress Integration
|
||||
|
||||
Drop `stubs/wordpress/yaknet-a11y-wp-plugin.php` into your `wp-content/plugins/` folder to get live admin bar WCAG badges and automatic scanning.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ♿ YakNet Accessibility Console — WCAG 2.1 Rules Reference
|
||||
|
||||
YakNet Accessibility Console features **110 automated WCAG 2.1 rules** across Levels A, AA, and AAA.
|
||||
|
||||
## Rule Categories
|
||||
|
||||
### 1. ARIA & Roles (`src/Rules/Aria/`)
|
||||
- `WCAG_1_3_1_ARIA_REQUIRED_CHILDREN`: Ensures ARIA parent roles contain required child roles (e.g. `role="radiogroup"`).
|
||||
- `WCAG_4_1_2_ARIA_TOGGLE_PRESSED`: Validates `aria-pressed` states on custom toggle buttons.
|
||||
|
||||
### 2. Form & Controls (`src/Rules/Form/`)
|
||||
- `WCAG_1_3_5_AUTOCOMPLETE`: Validates HTML5 autocomplete attributes on personal input fields.
|
||||
- `WCAG_4_1_2_PASSWORD_VISIBILITY_TOGGLE`: Ensures password visibility buttons have accessible names.
|
||||
|
||||
### 3. Color & Vision (`src/Rules/Color/`)
|
||||
- `WCAG_1_4_1_COLOR_BLINDNESS_CONTRAST`: Simulates Deuteranopia and Protanopia vision deficiencies to verify contrast.
|
||||
- `WCAG_1_4_3_CONTRAST`: Verifies minimum 4.5:1 text-to-background contrast ratio.
|
||||
|
||||
### 4. Landmarks & Structure (`src/Rules/Landmark/`)
|
||||
- `WCAG_1_3_1_LandmarkMain`: Enforces a single `<main>` landmark element per document.
|
||||
- `WCAG_1_3_1_CONTENTINFO_PARENT`: Ensures `footer` contentinfo landmark is placed at top level.
|
||||
|
||||
### 5. Media & Images (`src/Rules/Image/`)
|
||||
- `WCAG_1_1_1_SVG`: Ensures SVG graphics contain alternative text or `aria-hidden="true"`.
|
||||
- `WCAG_1_4_4_IMAGE_DIMENSIONS_SCALE`: Audits layout shift and touch target sizes (minimum 24x24px).
|
||||
@@ -2,3 +2,6 @@ parameters:
|
||||
level: 9
|
||||
paths:
|
||||
- src
|
||||
bootstrapFiles:
|
||||
- vendor/autoload.php
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
executionOrder="depends,defects"
|
||||
requireCoverageMetadata="false"
|
||||
beStrictAboutCoverageMetadata="false"
|
||||
beStrictAboutOutputDuringTests="true"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="YakNet Accessibility Console Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\AI;
|
||||
|
||||
use YakNet\AccessibilityConsole\AI\Providers\GeminiProvider;
|
||||
use YakNet\AccessibilityConsole\AI\Providers\OpenAiProvider;
|
||||
use YakNet\AccessibilityConsole\AI\Providers\ClaudeProvider;
|
||||
use YakNet\AccessibilityConsole\AI\Providers\MockAiProvider;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
|
||||
class AiFixerManager
|
||||
{
|
||||
/** @var array<string, AiProviderInterface> */
|
||||
private array $providers = [];
|
||||
private ?string $activeProviderName = null;
|
||||
|
||||
public function __construct(?string $geminiApiKey = null, ?string $openAiApiKey = null, ?string $claudeApiKey = null)
|
||||
{
|
||||
// Register default providers
|
||||
if ($geminiApiKey) {
|
||||
$this->registerProvider('gemini', new GeminiProvider($geminiApiKey));
|
||||
$this->activeProviderName = 'gemini';
|
||||
}
|
||||
if ($openAiApiKey) {
|
||||
$this->registerProvider('openai', new OpenAiProvider($openAiApiKey));
|
||||
if (!$this->activeProviderName) {
|
||||
$this->activeProviderName = 'openai';
|
||||
}
|
||||
}
|
||||
if ($claudeApiKey) {
|
||||
$this->registerProvider('claude', new ClaudeProvider($claudeApiKey));
|
||||
if (!$this->activeProviderName) {
|
||||
$this->activeProviderName = 'claude';
|
||||
}
|
||||
}
|
||||
|
||||
// Always register mock provider as fallback/testing option
|
||||
$this->registerProvider('mock', new MockAiProvider());
|
||||
if (!$this->activeProviderName) {
|
||||
$this->activeProviderName = 'mock';
|
||||
}
|
||||
}
|
||||
|
||||
public function registerProvider(string $name, AiProviderInterface $provider): void
|
||||
{
|
||||
$this->providers[strtolower($name)] = $provider;
|
||||
}
|
||||
|
||||
public function setActiveProvider(string $name): void
|
||||
{
|
||||
$key = strtolower($name);
|
||||
if (isset($this->providers[$key])) {
|
||||
$this->activeProviderName = $key;
|
||||
}
|
||||
}
|
||||
|
||||
public function getActiveProvider(): ?AiProviderInterface
|
||||
{
|
||||
if ($this->activeProviderName && isset($this->providers[$this->activeProviderName])) {
|
||||
return $this->providers[$this->activeProviderName];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function suggestFix(Violation $violation): ?string
|
||||
{
|
||||
$provider = $this->getActiveProvider();
|
||||
if ($provider) {
|
||||
return $provider->suggestFix($violation);
|
||||
}
|
||||
return "No active AI provider configured.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\AI;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
|
||||
interface AiProviderInterface
|
||||
{
|
||||
/**
|
||||
* Get the name of this AI provider.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Suggest a fix for the given violation.
|
||||
*
|
||||
* @param Violation $violation
|
||||
* @return string|null
|
||||
*/
|
||||
public function suggestFix(Violation $violation): ?string;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\AI;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
|
||||
class PromptBuilder
|
||||
{
|
||||
public static function build(Violation $violation): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
You are a WCAG 2.1 Accessibility expert.
|
||||
A static analysis tool found an accessibility issue in this HTML snippet.
|
||||
|
||||
Snippet: {$violation->htmlSnippet}
|
||||
Error Message: {$violation->message}
|
||||
Rule ID: {$violation->ruleId}
|
||||
Severity: {$violation->severity->value}
|
||||
Standard: {$violation->standard->value}
|
||||
|
||||
Please provide a short explanation of the fix in Turkish and the fixed version of the HTML snippet.
|
||||
Use the following EXACT format:
|
||||
EXPLANATION: [A short explanation of what you fixed and why, in Turkish]
|
||||
FIX: [Only the corrected HTML snippet, no surrounding explanation]
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\AI\Providers;
|
||||
|
||||
use GuzzleHttp\Client as GuzzleClient;
|
||||
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
|
||||
use YakNet\AccessibilityConsole\AI\PromptBuilder;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
|
||||
class ClaudeProvider implements AiProviderInterface
|
||||
{
|
||||
private ?GuzzleClient $client = null;
|
||||
|
||||
public function __construct(private readonly ?string $apiKey)
|
||||
{
|
||||
if ($this->apiKey) {
|
||||
$this->client = new GuzzleClient([
|
||||
'base_uri' => 'https://api.anthropic.com/v1/',
|
||||
'headers' => [
|
||||
'x-api-key' => $this->apiKey,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'timeout' => 10.0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Anthropic Claude';
|
||||
}
|
||||
|
||||
public function suggestFix(Violation $violation): ?string
|
||||
{
|
||||
if (!$this->client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prompt = PromptBuilder::build($violation);
|
||||
|
||||
try {
|
||||
$response = $this->client->post('messages', [
|
||||
'json' => [
|
||||
'model' => 'claude-3-5-haiku-20241022',
|
||||
'max_tokens' => 1000,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $prompt]
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
if (is_array($data)
|
||||
&& isset($data['content']) && is_array($data['content'])
|
||||
&& isset($data['content'][0]) && is_array($data['content'][0])
|
||||
&& isset($data['content'][0]['text']) && is_string($data['content'][0]['text'])
|
||||
) {
|
||||
return $data['content'][0]['text'];
|
||||
}
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return "Claude Error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\AI\Providers;
|
||||
|
||||
use Gemini;
|
||||
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
|
||||
use YakNet\AccessibilityConsole\AI\PromptBuilder;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
|
||||
class GeminiProvider implements AiProviderInterface
|
||||
{
|
||||
private ?\Gemini\Client $client = null;
|
||||
|
||||
public function __construct(private readonly ?string $apiKey)
|
||||
{
|
||||
if ($this->apiKey) {
|
||||
try {
|
||||
$this->client = \Gemini::client($this->apiKey);
|
||||
} catch (\Throwable) {
|
||||
$this->client = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Google Gemini';
|
||||
}
|
||||
|
||||
public function suggestFix(Violation $violation): ?string
|
||||
{
|
||||
if (!$this->apiKey || !$this->client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prompt = PromptBuilder::build($violation);
|
||||
|
||||
try {
|
||||
$result = $this->client->generativeModel('gemini-3.1-flash-lite')->generateContent($prompt);
|
||||
return trim($result->text());
|
||||
} catch (\Throwable $e) {
|
||||
return "Gemini Error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\AI\Providers;
|
||||
|
||||
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
|
||||
class MockAiProvider implements AiProviderInterface
|
||||
{
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Mock AI';
|
||||
}
|
||||
|
||||
public function suggestFix(Violation $violation): ?string
|
||||
{
|
||||
$snippet = $violation->htmlSnippet;
|
||||
$fixedSnippet = $snippet;
|
||||
|
||||
// Simple mock fixes based on rule ID
|
||||
if ($violation->ruleId === 'WCAG_2_2_2_BLINK') {
|
||||
$fixedSnippet = str_replace(['<blink>', '</blink>'], ['<span>', '</span>'], $snippet);
|
||||
} elseif ($violation->ruleId === 'WCAG_1_3_1_DUPLICATE_H1') {
|
||||
$fixedSnippet = str_replace(['<h1>', '</h1>'], ['<h2>', '</h2>'], $snippet);
|
||||
} elseif ($violation->ruleId === 'WCAG_1_3_5_AUTOCOMPLETE') {
|
||||
$fixedSnippet = str_replace('<input', '<input autocomplete="email"', $snippet);
|
||||
} elseif ($violation->ruleId === 'WCAG_2_1_1_SCROLLABLE_FOCUS') {
|
||||
$fixedSnippet = str_replace('<div', '<div tabindex="0"', $snippet);
|
||||
} elseif ($violation->ruleId === 'WCAG_4_1_2_ARIA_LABELLEDBY') {
|
||||
$fixedSnippet = str_replace('aria-labelledby="non_existent_label"', 'aria-labelledby="real_label"', $snippet);
|
||||
} elseif ($violation->ruleId === 'WCAG_1_1_1_ALT_PLACEHOLDER') {
|
||||
if (str_contains($snippet, 'alt="logo"')) {
|
||||
$fixedSnippet = str_replace('alt="logo"', 'alt="YakNet Accessibility Console Company Logo"', $snippet);
|
||||
} else {
|
||||
$fixedSnippet = str_replace('alt="header_banner.jpg"', 'alt="YakNet Accessibility Console Marketing Banner"', $snippet);
|
||||
}
|
||||
} elseif ($violation->ruleId === 'WCAG_1_1_1_SVG') {
|
||||
$fixedSnippet = str_replace('<svg', '<svg aria-hidden="true"', $snippet);
|
||||
} elseif ($violation->ruleId === 'WCAG_1_3_1_TABLE_SUMMARY') {
|
||||
$fixedSnippet = preg_replace('/summary=".*?"/i', '', $snippet) ?: $snippet;
|
||||
} elseif ($violation->ruleId === 'WCAG_1_3_1_PRESENTATION_TAGS') {
|
||||
$fixedSnippet = str_replace(['<center>', '</center>', '<strike>', '</strike>'], ['<span style="text-align: center; display: block;">', '</span>', '<span style="text-decoration: line-through;">', '</span>'], $snippet);
|
||||
} elseif ($violation->ruleId === 'WCAG_2_4_3_AUTOFOCUS') {
|
||||
$fixedSnippet = str_replace(' autofocus', '', $snippet);
|
||||
}
|
||||
|
||||
return "EXPLANATION: Mocked correction for accessibility validation.\nFIX: " . $fixedSnippet;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\AI\Providers;
|
||||
|
||||
use GuzzleHttp\Client as GuzzleClient;
|
||||
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
|
||||
use YakNet\AccessibilityConsole\AI\PromptBuilder;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
|
||||
class OpenAiProvider implements AiProviderInterface
|
||||
{
|
||||
private ?GuzzleClient $client = null;
|
||||
|
||||
public function __construct(private readonly ?string $apiKey)
|
||||
{
|
||||
if ($this->apiKey) {
|
||||
$this->client = new GuzzleClient([
|
||||
'base_uri' => 'https://api.openai.com/v1/',
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer ' . $this->apiKey,
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'timeout' => 10.0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'OpenAI GPT';
|
||||
}
|
||||
|
||||
public function suggestFix(Violation $violation): ?string
|
||||
{
|
||||
if (!$this->client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prompt = PromptBuilder::build($violation);
|
||||
|
||||
try {
|
||||
$response = $this->client->post('chat/completions', [
|
||||
'json' => [
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $prompt]
|
||||
],
|
||||
'temperature' => 0.2,
|
||||
],
|
||||
]);
|
||||
|
||||
$data = json_decode($response->getBody()->getContents(), true);
|
||||
if (is_array($data)
|
||||
&& isset($data['choices']) && is_array($data['choices'])
|
||||
&& isset($data['choices'][0]) && is_array($data['choices'][0])
|
||||
&& isset($data['choices'][0]['message']) && is_array($data['choices'][0]['message'])
|
||||
&& isset($data['choices'][0]['message']['content']) && is_string($data['choices'][0]['message']['content'])
|
||||
) {
|
||||
return $data['choices'][0]['message']['content'];
|
||||
}
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return "OpenAI Error: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Analytics;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
|
||||
class AccessibilityTelemetry
|
||||
{
|
||||
/**
|
||||
* Compute violation density, error distribution, and priority impact metrics.
|
||||
*
|
||||
* @param Violation[] $violations
|
||||
* @param int $totalLinesOfCode
|
||||
* @return array{
|
||||
* total_violations: int,
|
||||
* error_count: int,
|
||||
* warning_count: int,
|
||||
* defect_density_per_kloc: float,
|
||||
* critical_blockers_ratio: float
|
||||
* }
|
||||
*/
|
||||
public function computeMetrics(array $violations, int $totalLinesOfCode = 1000): array
|
||||
{
|
||||
$errorCount = 0;
|
||||
$warningCount = 0;
|
||||
|
||||
foreach ($violations as $v) {
|
||||
if ($v->severity === Severity::ERROR) {
|
||||
$errorCount++;
|
||||
} else {
|
||||
$warningCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$totalViolations = count($violations);
|
||||
$kloc = max(1.0, $totalLinesOfCode / 1000.0);
|
||||
$defectDensity = round($totalViolations / $kloc, 2);
|
||||
$criticalRatio = ($totalViolations > 0) ? round(($errorCount / $totalViolations) * 100, 1) : 0.0;
|
||||
|
||||
return [
|
||||
'total_violations' => $totalViolations,
|
||||
'error_count' => $errorCount,
|
||||
'warning_count' => $warningCount,
|
||||
'defect_density_per_kloc' => $defectDensity,
|
||||
'critical_blockers_ratio' => $criticalRatio,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Analytics;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class ComplianceMatrixGenerator
|
||||
{
|
||||
/** @var array<int|string, string> */
|
||||
private static array $principleMap = [
|
||||
1 => 'Perceivable',
|
||||
2 => 'Operable',
|
||||
3 => 'Understandable',
|
||||
4 => 'Robust',
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate WCAG 2.1 & 2.2 Compliance Matrix from a list of violations and total rules tested.
|
||||
*
|
||||
* @param Violation[] $violations
|
||||
* @param int $totalActiveRules
|
||||
* @return array{
|
||||
* overall_compliance_percentage: float,
|
||||
* principles: array<string, array{violations: int, status: string}>,
|
||||
* levels: array<string, array{violations: int, percentage: float}>
|
||||
* }
|
||||
*/
|
||||
public function generate(array $violations, int $totalActiveRules = 127): array
|
||||
{
|
||||
$principles = [
|
||||
'Perceivable' => ['violations' => 0, 'status' => 'Pass'],
|
||||
'Operable' => ['violations' => 0, 'status' => 'Pass'],
|
||||
'Understandable' => ['violations' => 0, 'status' => 'Pass'],
|
||||
'Robust' => ['violations' => 0, 'status' => 'Pass'],
|
||||
];
|
||||
|
||||
/** @var array<string, array{violations: int, percentage: float}> $levelCounts */
|
||||
$levelCounts = [
|
||||
'A' => ['violations' => 0, 'percentage' => 100.0],
|
||||
'AA' => ['violations' => 0, 'percentage' => 100.0],
|
||||
'AAA' => ['violations' => 0, 'percentage' => 100.0],
|
||||
];
|
||||
|
||||
foreach ($violations as $v) {
|
||||
// Determine principle from rule ID or WCAG code (e.g. WCAG_1_... => Perceivable)
|
||||
$principleNumber = 4;
|
||||
if (preg_match('/WCAG_([1-4])_/', $v->ruleId, $m)) {
|
||||
$principleNumber = (int)$m[1];
|
||||
}
|
||||
$pName = self::$principleMap[$principleNumber] ?? 'Robust';
|
||||
$principles[$pName]['violations']++;
|
||||
$principles[$pName]['status'] = 'Needs Review';
|
||||
|
||||
// Level breakdown
|
||||
$stdVal = $v->standard->value;
|
||||
if (array_key_exists($stdVal, $levelCounts)) {
|
||||
$levelCounts[$stdVal]['violations']++;
|
||||
}
|
||||
}
|
||||
|
||||
$totalViolations = count($violations);
|
||||
$overallScore = ($totalActiveRules > 0)
|
||||
? max(0.0, min(100.0, round((1.0 - ($totalViolations / ($totalActiveRules * 2))) * 100, 1)))
|
||||
: 100.0;
|
||||
|
||||
foreach ($levelCounts as $lvl => &$data) {
|
||||
$data['percentage'] = max(0.0, min(100.0, round((1.0 - ($data['violations'] / max(1, $totalActiveRules))) * 100, 1)));
|
||||
}
|
||||
|
||||
return [
|
||||
'overall_compliance_percentage' => $overallScore,
|
||||
'principles' => $principles,
|
||||
'levels' => $levelCounts,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Analytics;
|
||||
|
||||
class ReportSummaryModel
|
||||
{
|
||||
/**
|
||||
* @param array<string, int> $violationCountsBySeverity
|
||||
* @param array<string, int> $violationCountsByStandard
|
||||
* @param array<string, int> $violationCountsByPrinciple
|
||||
* @param array<string, mixed> $complianceScores
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $totalViolations,
|
||||
public readonly int $totalFilesScanned,
|
||||
public readonly int $baselinedCount,
|
||||
public readonly int $healthScore,
|
||||
public readonly array $violationCountsBySeverity,
|
||||
public readonly array $violationCountsByStandard,
|
||||
public readonly array $violationCountsByPrinciple,
|
||||
public readonly array $complianceScores,
|
||||
public readonly float $scanDurationSeconds = 0.0
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'totalViolations' => $this->totalViolations,
|
||||
'totalFilesScanned' => $this->totalFilesScanned,
|
||||
'baselinedCount' => $this->baselinedCount,
|
||||
'healthScore' => $this->healthScore,
|
||||
'bySeverity' => $this->violationCountsBySeverity,
|
||||
'byStandard' => $this->violationCountsByStandard,
|
||||
'byPrinciple' => $this->violationCountsByPrinciple,
|
||||
'complianceScores' => $this->complianceScores,
|
||||
'duration' => $this->scanDurationSeconds,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Audit;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
|
||||
class SiteCrawler
|
||||
{
|
||||
private Scanner $scanner;
|
||||
|
||||
public function __construct(?Scanner $scanner = null)
|
||||
{
|
||||
$this->scanner = $scanner ?? new Scanner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawl and scan multiple pages of a website up to a maximum page limit.
|
||||
*
|
||||
* @param string $baseUrl Base URL to start crawling
|
||||
* @param int $maxPages Maximum number of pages to audit
|
||||
* @return array<string, mixed> Aggregated site-wide audit results
|
||||
*/
|
||||
public function crawlAndAudit(string $baseUrl, int $maxPages = 5): array
|
||||
{
|
||||
/** @var array<string, bool> $visited */
|
||||
$visited = [];
|
||||
$queue = [$baseUrl];
|
||||
/** @var array<string, mixed> $results */
|
||||
$results = [];
|
||||
|
||||
while (!empty($queue) && count($visited) < $maxPages) {
|
||||
$url = array_shift($queue);
|
||||
if (empty($url) || isset($visited[$url])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$visited[$url] = true;
|
||||
try {
|
||||
$html = @file_get_contents($url);
|
||||
$violations = $html !== false ? $this->scanner->scan($html) : [];
|
||||
$score = max(0, 100 - count($violations) * 3);
|
||||
|
||||
$results[$url] = [
|
||||
'score' => $score,
|
||||
'violations_count' => count($violations),
|
||||
'violations' => $violations,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$results[$url] = ['error' => $e->getMessage(), 'score' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
$scores = [];
|
||||
$totalViolations = 0;
|
||||
foreach ($results as $res) {
|
||||
if (is_array($res)) {
|
||||
if (isset($res['score']) && is_numeric($res['score'])) {
|
||||
$scores[] = intval($res['score']);
|
||||
}
|
||||
if (isset($res['violations_count']) && is_numeric($res['violations_count'])) {
|
||||
$totalViolations += intval($res['violations_count']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$avgScore = count($scores) > 0 ? (int)round(array_sum($scores) / count($scores)) : 0;
|
||||
|
||||
return [
|
||||
'base_url' => $baseUrl,
|
||||
'pages_audited' => count($visited),
|
||||
'average_score' => $avgScore,
|
||||
'total_violations' => $totalViolations,
|
||||
'page_results' => $results,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\AutoFix;
|
||||
|
||||
class HtmlRepairEngine
|
||||
{
|
||||
/**
|
||||
* Instantly auto-repair common WCAG accessibility issues in raw HTML strings without network calls.
|
||||
*
|
||||
* @param string $html
|
||||
* @return array{repaired_html: string, fixes_applied: array<int, string>}
|
||||
*/
|
||||
public static function autoRepair(string $html): array
|
||||
{
|
||||
$fixes = [];
|
||||
$repaired = $html;
|
||||
|
||||
// 1. Ensure <html lang="..."> attribute
|
||||
if (preg_match('/<html\b(?![^>]*\blang=)[^>]*>/i', $repaired)) {
|
||||
$repaired = preg_replace('/<html\b([^>]*)>/i', '<html lang="tr"$1>', $repaired) ?? $repaired;
|
||||
$fixes[] = 'Added missing lang="tr" attribute to <html> tag.';
|
||||
}
|
||||
|
||||
// 2. Add aria-hidden="true" to decorative <svg> tags missing aria-hidden
|
||||
$svgBefore = $repaired;
|
||||
$repaired = preg_replace_callback('/<svg\b(?![^>]*\baria-hidden=)[^>]*>/i', function($matches) {
|
||||
return str_replace('<svg', '<svg aria-hidden="true"', $matches[0]);
|
||||
}, $repaired) ?? $repaired;
|
||||
|
||||
if ($repaired !== $svgBefore) {
|
||||
$fixes[] = 'Added aria-hidden="true" to decorative <svg> icons.';
|
||||
}
|
||||
|
||||
// 3. Add alt="" to <img> tags missing alt attribute
|
||||
$imgBefore = $repaired;
|
||||
$repaired = preg_replace_callback('/<img\b(?![^>]*\balt=)[^>]*>/i', function($matches) {
|
||||
return str_replace('<img', '<img alt=""', $matches[0]);
|
||||
}, $repaired) ?? $repaired;
|
||||
|
||||
if ($repaired !== $imgBefore) {
|
||||
$fixes[] = 'Added empty alt="" attribute to decorative <img> elements.';
|
||||
}
|
||||
|
||||
return [
|
||||
'repaired_html' => $repaired,
|
||||
'fixes_applied' => $fixes,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Benchmarks;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
|
||||
class BenchmarkEngine
|
||||
{
|
||||
private Scanner $scanner;
|
||||
|
||||
public function __construct(?Scanner $scanner = null)
|
||||
{
|
||||
$this->scanner = $scanner ?? new Scanner();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare accessibility health scores across multiple target URLs.
|
||||
*
|
||||
* @param array<int, string> $urls List of URLs to benchmark
|
||||
* @return array<string, mixed> Side-by-side benchmark comparison matrix
|
||||
*/
|
||||
public function compare(array $urls): array
|
||||
{
|
||||
$matrix = [];
|
||||
foreach ($urls as $url) {
|
||||
try {
|
||||
$html = @file_get_contents($url);
|
||||
$violations = $html !== false ? $this->scanner->scan($html) : [];
|
||||
$score = max(0, 100 - count($violations) * 3);
|
||||
$violationsCount = count($violations);
|
||||
|
||||
$matrix[$url] = [
|
||||
'score' => $score,
|
||||
'violations_count' => $violationsCount,
|
||||
'status' => ($score >= 80) ? 'PASS' : 'FAIL',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$matrix[$url] = ['score' => 0, 'violations_count' => -1, 'status' => 'ERROR', 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
// Rank by score descending
|
||||
uasort($matrix, function (array $a, array $b) {
|
||||
return intval($b['score']) <=> intval($a['score']);
|
||||
});
|
||||
|
||||
return [
|
||||
'benchmark_date' => date('Y-m-d H:i:s'),
|
||||
'targets_count' => count($urls),
|
||||
'rankings' => $matrix,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Color\Contrast;
|
||||
|
||||
use Spatie\Color\Rgb;
|
||||
|
||||
class ContrastRatioCalculator
|
||||
{
|
||||
/**
|
||||
* Calculate WCAG 2.1 relative contrast ratio between foreground and background colors.
|
||||
*/
|
||||
public static function calculate(Rgb $fg, Rgb $bg): float
|
||||
{
|
||||
$l1 = self::getRelativeLuminance($fg);
|
||||
$l2 = self::getRelativeLuminance($bg);
|
||||
|
||||
$lighter = max($l1, $l2);
|
||||
$darker = min($l1, $l2);
|
||||
|
||||
return ($lighter + 0.05) / ($darker + 0.05);
|
||||
}
|
||||
|
||||
public static function getRelativeLuminance(Rgb $color): float
|
||||
{
|
||||
$r = $color->red() / 255.0;
|
||||
$g = $color->green() / 255.0;
|
||||
$b = $color->blue() / 255.0;
|
||||
|
||||
$r = ($r <= 0.03928) ? $r / 12.92 : pow(($r + 0.055) / 1.055, 2.4);
|
||||
$g = ($g <= 0.03928) ? $g / 12.92 : pow(($g + 0.055) / 1.055, 2.4);
|
||||
$b = ($b <= 0.03928) ? $b / 12.92 : pow(($b + 0.055) / 1.055, 2.4);
|
||||
|
||||
return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Color\Simulators;
|
||||
|
||||
use Spatie\Color\Rgb;
|
||||
|
||||
class DeuteranopiaSimulator
|
||||
{
|
||||
/**
|
||||
* Simulate Deuteranopia (green-blindness) vision deficiency for a given RGB color.
|
||||
*/
|
||||
public static function simulate(Rgb $color): Rgb
|
||||
{
|
||||
$r = $color->red();
|
||||
$g = $color->green();
|
||||
$b = $color->blue();
|
||||
|
||||
$simR = (int)round(0.625 * $r + 0.375 * $g);
|
||||
$simG = (int)round(0.700 * $r + 0.300 * $g);
|
||||
$simB = (int)round(0.000 * $r + 0.300 * $g + 0.700 * $b);
|
||||
|
||||
return new Rgb(
|
||||
min(255, max(0, $simR)),
|
||||
min(255, max(0, $simG)),
|
||||
min(255, max(0, $simB))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Color\Simulators;
|
||||
|
||||
use Spatie\Color\Rgb;
|
||||
|
||||
class ProtanopiaSimulator
|
||||
{
|
||||
/**
|
||||
* Simulate Protanopia (red-blindness) vision deficiency for a given RGB color.
|
||||
*/
|
||||
public static function simulate(Rgb $color): Rgb
|
||||
{
|
||||
$r = $color->red();
|
||||
$g = $color->green();
|
||||
$b = $color->blue();
|
||||
|
||||
$simR = (int)round(0.56667 * $r + 0.43333 * $g);
|
||||
$simG = (int)round(0.55833 * $r + 0.44167 * $g);
|
||||
$simB = (int)round(0.00000 * $r + 0.24167 * $g + 0.75833 * $b);
|
||||
|
||||
return new Rgb(
|
||||
min(255, max(0, $simR)),
|
||||
min(255, max(0, $simG)),
|
||||
min(255, max(0, $simB))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Color\Simulators;
|
||||
|
||||
use Spatie\Color\Rgb;
|
||||
|
||||
class TritanopiaSimulator
|
||||
{
|
||||
/**
|
||||
* Simulate Tritanopia (blue-blindness) vision deficiency for a given RGB color.
|
||||
*/
|
||||
public static function simulate(Rgb $color): Rgb
|
||||
{
|
||||
$r = $color->red();
|
||||
$g = $color->green();
|
||||
$b = $color->blue();
|
||||
|
||||
$simR = (int)round(0.95000 * $r + 0.05000 * $g);
|
||||
$simG = (int)round(0.00000 * $r + 0.43333 * $g + 0.56667 * $b);
|
||||
$simB = (int)round(0.00000 * $r + 0.47500 * $g + 0.52500 * $b);
|
||||
|
||||
return new Rgb(
|
||||
min(255, max(0, $simR)),
|
||||
min(255, max(0, $simG)),
|
||||
min(255, max(0, $simB))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,11 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use YakNet\AccessibilityConsole\AI\GeminiFixer;
|
||||
use YakNet\AccessibilityConsole\AI\AiFixerManager;
|
||||
use YakNet\AccessibilityConsole\Core\Analyser;
|
||||
use YakNet\AccessibilityConsole\Core\Config;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
||||
|
||||
class FixCommand extends Command
|
||||
@@ -21,46 +22,206 @@ class FixCommand extends Command
|
||||
$this
|
||||
->setName('fix')
|
||||
->setDescription('Automatically fix accessibility violations using AI')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files');
|
||||
->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan and fix')
|
||||
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files')
|
||||
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
|
||||
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$target = $input->getArgument('target');
|
||||
if (!is_string($target)) {
|
||||
$io->error("Target must be a string URL or file path.");
|
||||
|
||||
// 1. Resolve configuration
|
||||
$configPathOption = $input->getOption('config');
|
||||
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
|
||||
$config = Config::fromYaml($configPath);
|
||||
|
||||
$apiKey = $config->getGeminiApiKey();
|
||||
if (!$apiKey) {
|
||||
$io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$levelOption = $input->getOption('level');
|
||||
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
|
||||
|
||||
$target = $input->getArgument('target');
|
||||
$isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://'));
|
||||
|
||||
// If target is a URL, run the legacy URL fixing logic
|
||||
if ($isUrl) {
|
||||
return $this->executeUrlFix($target, $level, $apiKey, $input, $output, $io);
|
||||
}
|
||||
|
||||
// --- LOCAL FILE / DIRECTORY SELF-HEALING ENGINE ---
|
||||
$io->title("AI Self-Healing Mode - Directory Static Analysis");
|
||||
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
|
||||
// Resolve paths to scan
|
||||
$paths = $config->getPaths();
|
||||
if (is_string($target) && $target !== '') {
|
||||
$paths = [$target];
|
||||
}
|
||||
|
||||
// Setup Analyser
|
||||
$scanner = new Scanner();
|
||||
$rules = RuleLevels::getRulesForLevel($level);
|
||||
foreach ($rules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
|
||||
// We run without baseline filtering so we can fix ALL errors in the project,
|
||||
// or we can optionally load baseline. Fixing everything is better for a general "fix" run.
|
||||
$analyser = new Analyser($config, $scanner, new \YakNet\AccessibilityConsole\Core\BaselineManager());
|
||||
|
||||
$io->note("Scanning targets for violations...");
|
||||
$result = $analyser->analyse($paths);
|
||||
$violations = $result['violations'];
|
||||
|
||||
if (empty($violations)) {
|
||||
$io->success("Everything looks good! No violations to fix.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations)));
|
||||
|
||||
$fixer = new AiFixerManager($apiKey);
|
||||
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
|
||||
$fixedCount = 0;
|
||||
|
||||
// Group violations by file path
|
||||
$groupedViolations = [];
|
||||
foreach ($violations as $v) {
|
||||
$file = $v->location['file'] ?? '';
|
||||
if ($file !== '') {
|
||||
$groupedViolations[$file][] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($groupedViolations as $file => $fileViolations) {
|
||||
$io->section("Fixing file: $file");
|
||||
|
||||
// Sort violations by line number DESCENDING (bottom to top)
|
||||
// This prevents edits at the bottom of the file from invalidating line numbers above them!
|
||||
usort($fileViolations, function ($a, $b) {
|
||||
return ($b->location['line'] ?? 0) <=> ($a->location['line'] ?? 0);
|
||||
});
|
||||
|
||||
$absoluteFile = getcwd() . '/' . $file;
|
||||
if (!file_exists($absoluteFile) || !is_writable($absoluteFile)) {
|
||||
$io->warning("File not found or not writable: $file. Skipping.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
|
||||
|
||||
foreach ($fileViolations as $violation) {
|
||||
$line = $violation->location['line'] ?? 0;
|
||||
$io->write("Analyzing <fg=yellow>{$violation->ruleId}</> on line <fg=cyan>$line</>... ");
|
||||
|
||||
// Try Local Healer first
|
||||
$rawSuggestion = $localHealer->heal($violation);
|
||||
$isLocalFix = false;
|
||||
|
||||
if ($rawSuggestion !== null) {
|
||||
$isLocalFix = true;
|
||||
} else {
|
||||
// Fallback to AI Suggestion
|
||||
$rawSuggestion = $fixer->suggestFix($violation);
|
||||
}
|
||||
|
||||
$suggestion = $rawSuggestion;
|
||||
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
|
||||
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
||||
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
||||
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
||||
}
|
||||
|
||||
if (!is_string($suggestion)) {
|
||||
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
|
||||
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isLocalFix) {
|
||||
$io->writeln("<fg=green>Local Fix Found!</>");
|
||||
} else {
|
||||
$io->writeln("<fg=green>AI Fix Found!</>");
|
||||
}
|
||||
$io->writeln(" <fg=gray>Diff:</>");
|
||||
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
|
||||
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
|
||||
$io->writeln($indentedDiff);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply Fix
|
||||
if ($this->applyFix($absoluteFile, $violation->htmlSnippet, $suggestion)) {
|
||||
$io->writeln(" <fg=bright-green>✔ Applied to line $line</>");
|
||||
$fixedCount++;
|
||||
} else {
|
||||
$io->writeln(" <fg=red>✘ Failed to apply fix to file.</>");
|
||||
}
|
||||
$io->writeln("");
|
||||
}
|
||||
}
|
||||
|
||||
if ($fixedCount > 0) {
|
||||
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
|
||||
} else {
|
||||
$io->warning("No files were modified.");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy URL scan and fix logic
|
||||
*/
|
||||
private function executeUrlFix(string $target, int $level, string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
|
||||
{
|
||||
$projectPathOption = $input->getOption('project-path');
|
||||
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
|
||||
if ($projectPath === false) {
|
||||
$projectPath = '.';
|
||||
}
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$useJs = (bool)$input->getOption('js');
|
||||
|
||||
$config = new Config();
|
||||
$apiKey = $config->getGeminiApiKey();
|
||||
$io->title("AI Self-Healing Mode - Fixing URL: $target");
|
||||
|
||||
if (!$apiKey) {
|
||||
$io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key.");
|
||||
// Fetch HTML
|
||||
if ($useJs) {
|
||||
try {
|
||||
$chromeRenderer = new \YakNet\AccessibilityConsole\Core\ChromeRenderer();
|
||||
$html = $chromeRenderer->render($target);
|
||||
} catch (\Throwable $e) {
|
||||
$io->error("Failed JavaScript rendering: " . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->title("AI Self-Healing Mode - Fixing: $target");
|
||||
|
||||
// 1. Fetch HTML
|
||||
} else {
|
||||
$html = @file_get_contents($target);
|
||||
}
|
||||
if ($html === false || $html === '') {
|
||||
$io->error("Could not read target: $target");
|
||||
$io->error("Could not read target URL: $target");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 2. Scan
|
||||
// Scan
|
||||
$scanner = new Scanner();
|
||||
foreach (StandardRuleSet::all() as $rule) {
|
||||
$rules = RuleLevels::getRulesForLevel($level);
|
||||
foreach ($rules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
$violations = $scanner->scan($html);
|
||||
@@ -72,7 +233,8 @@ class FixCommand extends Command
|
||||
|
||||
$io->note("Found " . count($violations) . " violations. Starting AI analysis...");
|
||||
|
||||
$fixer = new GeminiFixer($apiKey);
|
||||
$fixer = new AiFixerManager($apiKey);
|
||||
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
|
||||
$locator = new SourceLocator($projectPath);
|
||||
$fixedCount = 0;
|
||||
|
||||
@@ -86,24 +248,42 @@ class FixCommand extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get AI Suggestion
|
||||
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
|
||||
$rawSuggestion = $localHealer->heal($violation);
|
||||
$isLocalFix = false;
|
||||
|
||||
if ($rawSuggestion !== null) {
|
||||
$isLocalFix = true;
|
||||
} else {
|
||||
$rawSuggestion = $fixer->suggestFix($violation);
|
||||
}
|
||||
|
||||
$suggestion = $rawSuggestion;
|
||||
if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) {
|
||||
if (is_string($rawSuggestion) && str_contains($rawSuggestion, 'FIX:')) {
|
||||
preg_match('/FIX:(.*)/s', $rawSuggestion, $matches);
|
||||
$suggestion = trim($matches[1] ?? $rawSuggestion);
|
||||
// Clean up potential markdown
|
||||
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
|
||||
}
|
||||
|
||||
if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) {
|
||||
if (!is_string($suggestion)) {
|
||||
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||
continue;
|
||||
}
|
||||
|
||||
$io->writeln("<fg=green>Fix Found!</>");
|
||||
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>");
|
||||
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>");
|
||||
if (is_string($rawSuggestion) && $suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:')) {
|
||||
$io->writeln("<fg=red>Skipped (AI could not generate a clean fix)</>");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isLocalFix) {
|
||||
$io->writeln("<fg=green>Local Fix Found!</>");
|
||||
} else {
|
||||
$io->writeln("<fg=green>AI Fix Found!</>");
|
||||
}
|
||||
$io->writeln(" <fg=gray>Diff:</>");
|
||||
$diffOutput = $diffGenerator->generate($violation->htmlSnippet, $suggestion);
|
||||
$indentedDiff = implode("\n", array_map(fn($line) => ' ' . $line, explode("\n", trim($diffOutput))));
|
||||
$io->writeln($indentedDiff);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
|
||||
@@ -123,7 +303,7 @@ class FixCommand extends Command
|
||||
if ($fixedCount > 0) {
|
||||
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
|
||||
} else {
|
||||
$io->warning("No files were modified. Check error logs for details.");
|
||||
$io->warning("No files were modified.");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
@@ -138,8 +318,7 @@ class FixCommand extends Command
|
||||
if (str_contains($content, $originalSnippet)) {
|
||||
$newContent = str_replace($originalSnippet, $fixedSnippet, $content);
|
||||
} else {
|
||||
// Fallback: Fuzzy replacement (simplified for this demo)
|
||||
// In a real professional lib, we'd use more advanced regex or DOM patching
|
||||
// Fallback: Fuzzy replacement
|
||||
$newContent = str_replace(trim($originalSnippet), trim($fixedSnippet), $content);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,259 @@
|
||||
namespace YakNet\AccessibilityConsole\Console\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use YakNet\AccessibilityConsole\AI\GeminiFixer;
|
||||
use YakNet\AccessibilityConsole\Core\Analyser;
|
||||
use YakNet\AccessibilityConsole\Core\BaselineManager;
|
||||
use YakNet\AccessibilityConsole\Core\Config;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
|
||||
use YakNet\AccessibilityConsole\Reporting\GithubFormatter;
|
||||
use YakNet\AccessibilityConsole\Reporting\JsonFormatter;
|
||||
use YakNet\AccessibilityConsole\Reporting\XmlFormatter;
|
||||
use YakNet\AccessibilityConsole\Reporting\MarkdownFormatter;
|
||||
use YakNet\AccessibilityConsole\Reporting\CsvFormatter;
|
||||
use YakNet\AccessibilityConsole\Reporting\GitLabCodeQualityFormatter;
|
||||
use YakNet\AccessibilityConsole\Source\SourceLocator;
|
||||
|
||||
class ScanCommand extends Command
|
||||
{
|
||||
protected static ?string $defaultName = 'scan';
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('scan')
|
||||
->setDescription('Scan a URL or file for accessibility violations')
|
||||
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
|
||||
->setAliases(['analyse'])
|
||||
->setDescription('Scan local files, directories, or a URL for accessibility violations')
|
||||
->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan')
|
||||
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
|
||||
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
|
||||
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (console, json, github)')
|
||||
->addOption('generate-baseline', null, InputOption::VALUE_NONE, 'Generate a baseline file containing all current violations')
|
||||
->addOption('baseline', null, InputOption::VALUE_REQUIRED, 'Path to the baseline file')
|
||||
->addOption('no-progress', null, InputOption::VALUE_NONE, 'Disable progress bar')
|
||||
->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions')
|
||||
->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)')
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping');
|
||||
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
|
||||
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively (URL scans only)')
|
||||
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth (URL scans only)', '3')
|
||||
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum pages to crawl (URL scans only)', '20')
|
||||
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// 1. Resolve configuration
|
||||
$configPathOption = $input->getOption('config');
|
||||
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
|
||||
$config = Config::fromYaml($configPath);
|
||||
|
||||
// 2. Resolve parameters (CLI options override config file)
|
||||
$levelOption = $input->getOption('level');
|
||||
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
|
||||
|
||||
$formatOption = $input->getOption('format');
|
||||
$format = is_string($formatOption) ? $formatOption : $config->getFormat();
|
||||
|
||||
$baselinePathOption = $input->getOption('baseline');
|
||||
$baselinePath = is_string($baselinePathOption) ? $baselinePathOption : $config->getBaselinePath();
|
||||
if (!$baselinePath && $input->getOption('generate-baseline')) {
|
||||
$baselinePath = 'a11y-baseline.json'; // default baseline filename
|
||||
}
|
||||
|
||||
$target = $input->getArgument('target');
|
||||
if (!is_string($target)) {
|
||||
$io->error("Target must be a string URL or file path.");
|
||||
$isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://'));
|
||||
|
||||
// If target is a URL, run the legacy URL scanning / crawling logic
|
||||
if ($isUrl) {
|
||||
return $this->executeUrlScan($target, $level, $input, $output, $io);
|
||||
}
|
||||
|
||||
// --- LOCAL DIRECTORY / FILE STATIC ANALYSIS ENGINE ---
|
||||
$io->title("Accessibility Console - Static Analysis");
|
||||
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
|
||||
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
|
||||
|
||||
// Resolve paths to scan
|
||||
$paths = $config->getPaths();
|
||||
if (is_string($target) && $target !== '') {
|
||||
$paths = [$target];
|
||||
}
|
||||
|
||||
// Setup Analyser
|
||||
$scanner = new Scanner();
|
||||
$rules = RuleLevels::getRulesForLevel($level);
|
||||
|
||||
// Filter rules based on config
|
||||
$rulesConfig = $config->getRulesConfig();
|
||||
$activeRules = [];
|
||||
foreach ($rules as $rule) {
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: (new \ReflectionClass($rule))->getShortName();
|
||||
|
||||
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
|
||||
continue;
|
||||
}
|
||||
$activeRules[$ruleId] = $rule;
|
||||
}
|
||||
|
||||
foreach ($rulesConfig['include'] as $customRuleClass) {
|
||||
if (class_exists($customRuleClass)) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($customRuleClass);
|
||||
if ($reflection->isInstantiable()) {
|
||||
$rule = $reflection->newInstance();
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: $reflection->getShortName();
|
||||
$activeRules[$ruleId] = $rule;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($activeRules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
|
||||
// Setup Baseline Manager
|
||||
$baselineManager = new BaselineManager();
|
||||
if ($baselinePath && !$input->getOption('generate-baseline')) {
|
||||
$baselineManager->load(getcwd() . '/' . $baselinePath);
|
||||
}
|
||||
|
||||
$analyser = new Analyser($config, $scanner, $baselineManager);
|
||||
|
||||
// Progress bar setup
|
||||
$progressBar = null;
|
||||
$showProgress = !$input->getOption('no-progress') && $format === 'console';
|
||||
$onProgress = null;
|
||||
|
||||
if ($showProgress) {
|
||||
$onProgress = function (string $file, int $index, int $total) use ($io, &$progressBar) {
|
||||
if ($progressBar === null) {
|
||||
$progressBar = new ProgressBar($io, $total);
|
||||
$progressBar->start();
|
||||
}
|
||||
$progressBar->setProgress($index);
|
||||
$progressBar->setMessage(basename($file), 'filename');
|
||||
};
|
||||
}
|
||||
|
||||
// Run analysis
|
||||
$result = $analyser->analyse($paths, $onProgress);
|
||||
|
||||
if ($progressBar) {
|
||||
$progressBar->finish();
|
||||
$io->newLine(2);
|
||||
}
|
||||
|
||||
$violations = $result['violations'];
|
||||
$baselinedCount = $result['baselinedCount'];
|
||||
|
||||
// AI Fix generation (if requested)
|
||||
if ($input->getOption('ai') && !empty($violations)) {
|
||||
$apiKey = $config->getGeminiApiKey();
|
||||
if ($apiKey) {
|
||||
$fixer = new GeminiFixer($apiKey);
|
||||
$io->comment("Requesting AI fix suggestions for violations...");
|
||||
foreach ($violations as $v) {
|
||||
$suggestion = $fixer->suggestFix($v);
|
||||
if ($suggestion) {
|
||||
$v->fixSuggestion = $suggestion;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$io->warning("GEMINI_API_KEY not found. AI suggestions skipped.");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate baseline if option is present
|
||||
if ($input->getOption('generate-baseline')) {
|
||||
if (!$baselinePath) {
|
||||
$io->error("No baseline path specified.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$baselineFile = getcwd() . '/' . $baselinePath;
|
||||
|
||||
// Re-run analysis without baseline filtering to catch all violations
|
||||
$unfilteredAnalyser = new Analyser($config, $scanner, new BaselineManager());
|
||||
$allResult = $unfilteredAnalyser->analyse($paths);
|
||||
$allViolations = $allResult['violations'];
|
||||
|
||||
if ($baselineManager->generate($allViolations, $baselineFile)) {
|
||||
$io->success(sprintf("Baseline generated successfully with %d violations at: %s", count($allViolations), $baselinePath));
|
||||
return Command::SUCCESS;
|
||||
} else {
|
||||
$io->error("Failed to generate baseline file.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
// Select formatter
|
||||
$formatter = new ConsoleFormatter();
|
||||
if ($format === 'json') {
|
||||
$formatter = new JsonFormatter();
|
||||
} elseif ($format === 'github') {
|
||||
$formatter = new GithubFormatter();
|
||||
} elseif ($format === 'xml') {
|
||||
$formatter = new XmlFormatter();
|
||||
} elseif ($format === 'markdown') {
|
||||
$formatter = new MarkdownFormatter();
|
||||
} elseif ($format === 'csv') {
|
||||
$formatter = new CsvFormatter();
|
||||
} elseif ($format === 'gitlab') {
|
||||
$formatter = new GitLabCodeQualityFormatter();
|
||||
}
|
||||
|
||||
$exitCode = $formatter->format($violations, $baselinedCount, $io);
|
||||
|
||||
if ($format === 'console') {
|
||||
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
|
||||
$io->section("Accessibility Ratings");
|
||||
$color = $metrics['score'] >= 90 ? 'green' : ($metrics['score'] >= 70 ? 'yellow' : 'red');
|
||||
$io->writeln(sprintf("Health Score: <fg=%s>%d/100</>", $color, $metrics['score']));
|
||||
$io->writeln(sprintf(" - Screen Reader / Visual Impact: <fg=cyan>%d%%</>", $metrics['visualImpact']));
|
||||
$io->writeln(sprintf(" - Keyboard / Motor Impact: <fg=cyan>%d%%</>", $metrics['motorImpact']));
|
||||
$io->writeln(sprintf(" - Cognitive / Distraction Impact: <fg=cyan>%d%%</>", $metrics['cognitiveImpact']));
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
// Export report if --report is specified
|
||||
$reportPath = $input->getOption('report');
|
||||
if (is_string($reportPath) && $reportPath !== '') {
|
||||
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
|
||||
if ($extension === 'json') {
|
||||
$serialized = [];
|
||||
foreach ($violations as $v) {
|
||||
$serialized[] = $v->toArray();
|
||||
}
|
||||
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
|
||||
} else {
|
||||
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
|
||||
// Map violations into page format
|
||||
$mappedViolations = ['Static Analysis' => $violations];
|
||||
file_put_contents($reportPath, $reporter->render($mappedViolations, 'Static Analysis'));
|
||||
}
|
||||
$io->success("Report saved to: $reportPath");
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy URL scanning and crawling logic
|
||||
*/
|
||||
private function executeUrlScan(string $target, int $level, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
|
||||
{
|
||||
$enableAi = (bool)$input->getOption('ai');
|
||||
$projectPathOption = $input->getOption('project-path');
|
||||
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
|
||||
@@ -44,49 +263,165 @@ 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);
|
||||
|
||||
$url = $current['url'];
|
||||
$depth = (int)$current['depth'];
|
||||
|
||||
$cleanUrl = strtok($url, '#');
|
||||
if ($cleanUrl === false || isset($visited[$cleanUrl])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$visited[$cleanUrl] = true;
|
||||
$io->write(" <fg=gray>Crawling [Depth $depth]:</> <fg=cyan>$cleanUrl</>... ");
|
||||
|
||||
try {
|
||||
if ($chromeRenderer) {
|
||||
$html = $chromeRenderer->render($cleanUrl);
|
||||
} else {
|
||||
$response = $client->request('GET', $cleanUrl);
|
||||
$html = (string)$response->getBody();
|
||||
}
|
||||
$io->writeln("<fg=green>OK</>");
|
||||
|
||||
$pageViolations = $scanner->scan($html);
|
||||
$results[$cleanUrl] = $pageViolations;
|
||||
|
||||
if ($depth < $depthLimit) {
|
||||
$html5 = new \Masterminds\HTML5(['disable_html_ns' => true]);
|
||||
$doc = $html5->loadHTML($html);
|
||||
$links = $doc->getElementsByTagName('a');
|
||||
|
||||
foreach ($links as $link) {
|
||||
$href = $link->getAttribute('href');
|
||||
if (empty($href) || str_starts_with($href, 'javascript:') || str_starts_with($href, 'mailto:') || str_starts_with($href, 'tel:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$baseUri = new \GuzzleHttp\Psr7\Uri($cleanUrl);
|
||||
$relativeUri = new \GuzzleHttp\Psr7\Uri($href);
|
||||
$absoluteUri = \GuzzleHttp\Psr7\UriResolver::resolve($baseUri, $relativeUri);
|
||||
$absoluteUrl = (string)$absoluteUri;
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedAbsolute = parse_url($absoluteUrl);
|
||||
$absoluteHost = $parsedAbsolute['host'] ?? '';
|
||||
|
||||
if ($absoluteHost === $baseHost) {
|
||||
$cleanAbsolute = strtok($absoluteUrl, '#');
|
||||
if ($cleanAbsolute !== false && !isset($visited[$cleanAbsolute])) {
|
||||
$inQueue = false;
|
||||
foreach ($queue as $q) {
|
||||
if ($q['url'] === $cleanAbsolute) {
|
||||
$inQueue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$inQueue) {
|
||||
$queue[] = ['url' => $cleanAbsolute, 'depth' => $depth + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$io->writeln("<fg=red>FAIL (" . $e->getMessage() . ")</>");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($chromeRenderer) {
|
||||
try {
|
||||
$html = $chromeRenderer->render($target);
|
||||
} catch (\Throwable $e) {
|
||||
$io->error("Failed JavaScript rendering: " . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} else {
|
||||
$html = @file_get_contents($target);
|
||||
}
|
||||
|
||||
if ($html === false || $html === '') {
|
||||
$io->error("Could not read target URL: $target");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$violations = $scanner->scan($html);
|
||||
$results[$target] = $violations;
|
||||
}
|
||||
|
||||
$totalViolations = 0;
|
||||
foreach ($results as $url => $violations) {
|
||||
$totalViolations += count($violations);
|
||||
}
|
||||
|
||||
if ($totalViolations === 0) {
|
||||
$io->success("No violations found!");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->section("Found " . count($violations) . " violations");
|
||||
$io->section("Found a total of $totalViolations violations across " . count($results) . " pages");
|
||||
|
||||
// 4. Setup Helpers
|
||||
$locator = new SourceLocator($projectPath);
|
||||
$config = new Config();
|
||||
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
|
||||
|
||||
foreach ($results as $url => $violations) {
|
||||
if (empty($violations)) {
|
||||
continue;
|
||||
}
|
||||
$io->section("Page: $url (" . count($violations) . " violations)");
|
||||
|
||||
foreach ($violations as $violation) {
|
||||
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
|
||||
|
||||
// Show Snippet
|
||||
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
|
||||
|
||||
// Source Mapping
|
||||
$location = $locator->locate($violation->htmlSnippet);
|
||||
if ($location) {
|
||||
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
|
||||
$violation->location = $location;
|
||||
}
|
||||
|
||||
// AI Fixer
|
||||
if ($enableAi && $fixer) {
|
||||
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
|
||||
$suggestion = $fixer->suggestFix($violation);
|
||||
@@ -97,26 +432,26 @@ class ScanCommand extends Command
|
||||
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
|
||||
}
|
||||
}
|
||||
|
||||
$io->writeln("");
|
||||
}
|
||||
}
|
||||
|
||||
$io->warning("Scanned " . count($violations) . " errors. Use --ai to get fix suggestions.");
|
||||
|
||||
// Export Report
|
||||
$reportPath = $input->getOption('report');
|
||||
if (is_string($reportPath) && $reportPath !== '') {
|
||||
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
|
||||
if ($extension === 'json') {
|
||||
$data = array_map(fn($v) => $v->toArray(), $violations);
|
||||
file_put_contents($reportPath, json_encode($data, JSON_PRETTY_PRINT));
|
||||
$serialized = [];
|
||||
foreach ($results as $url => $violations) {
|
||||
$serialized[$url] = array_map(fn($v) => $v->toArray(), $violations);
|
||||
}
|
||||
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
|
||||
} else {
|
||||
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
|
||||
file_put_contents($reportPath, $reporter->render($violations, $target));
|
||||
file_put_contents($reportPath, $reporter->render($results, $target));
|
||||
}
|
||||
$io->success("Report saved to: $reportPath");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,22 @@ abstract class AbstractRule
|
||||
*/
|
||||
abstract public function getSeverity(): Severity;
|
||||
|
||||
protected function createViolation(string $message, \DOMElement $element): Violation
|
||||
public function getLevel(): int
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return (new \ReflectionClass($this))->getShortName();
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
protected function createViolation(string $message, \DOMElement $element, ?string $suggestion = null): Violation
|
||||
{
|
||||
$doc = $element->ownerDocument;
|
||||
$html = '';
|
||||
@@ -31,11 +46,12 @@ abstract class AbstractRule
|
||||
}
|
||||
|
||||
return new Violation(
|
||||
ruleId: (new \ReflectionClass($this))->getShortName(),
|
||||
ruleId: $this->getId(),
|
||||
message: $message,
|
||||
severity: $this->getSeverity(),
|
||||
standard: $this->getStandard(),
|
||||
htmlSnippet: $html
|
||||
htmlSnippet: $html,
|
||||
fixSuggestion: $suggestion
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use SplFileInfo;
|
||||
use YakNet\AccessibilityConsole\Core\Locator\PreciseElementLocator;
|
||||
use YakNet\AccessibilityConsole\Parser\TemplatePreprocessor;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
|
||||
class Analyser
|
||||
{
|
||||
private Config $config;
|
||||
private Scanner $scanner;
|
||||
private BaselineManager $baselineManager;
|
||||
private TemplatePreprocessor $templatePreprocessor;
|
||||
private PreciseElementLocator $elementLocator;
|
||||
private string $projectRoot;
|
||||
|
||||
public function __construct(
|
||||
?Config $config = null,
|
||||
?Scanner $scanner = null,
|
||||
?BaselineManager $baselineManager = null,
|
||||
?TemplatePreprocessor $templatePreprocessor = null,
|
||||
?PreciseElementLocator $elementLocator = null
|
||||
) {
|
||||
$this->config = $config ?? new Config();
|
||||
$this->scanner = $scanner ?? $this->setupScanner();
|
||||
$this->baselineManager = $baselineManager ?? new BaselineManager();
|
||||
$this->templatePreprocessor = $templatePreprocessor ?? new TemplatePreprocessor();
|
||||
$this->elementLocator = $elementLocator ?? new PreciseElementLocator();
|
||||
|
||||
$cwd = getcwd();
|
||||
$this->projectRoot = $cwd ? str_replace('\\', '/', realpath($cwd) ?: $cwd) : '.';
|
||||
|
||||
if ($baselineManager === null) {
|
||||
$baselinePath = $this->config->getBaselinePath();
|
||||
if ($baselinePath) {
|
||||
$this->baselineManager->load($this->projectRoot . '/' . $baselinePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function setupScanner(): Scanner
|
||||
{
|
||||
$scanner = new Scanner();
|
||||
$rules = RuleLevels::getRulesForLevel($this->config->getLevel());
|
||||
$rulesConfig = $this->config->getRulesConfig();
|
||||
|
||||
$activeRules = [];
|
||||
foreach ($rules as $rule) {
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: (new \ReflectionClass($rule))->getShortName();
|
||||
|
||||
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
|
||||
continue;
|
||||
}
|
||||
$activeRules[$ruleId] = $rule;
|
||||
}
|
||||
|
||||
// Include custom rules
|
||||
foreach ($rulesConfig['include'] as $customRuleClass) {
|
||||
if (class_exists($customRuleClass)) {
|
||||
try {
|
||||
$reflection = new \ReflectionClass($customRuleClass);
|
||||
if ($reflection->isInstantiable()) {
|
||||
$rule = $reflection->newInstance();
|
||||
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
|
||||
? $rule->getId()
|
||||
: $reflection->getShortName();
|
||||
$activeRules[$ruleId] = $rule;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore instantiation failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($activeRules as $rule) {
|
||||
$scanner->addRule($rule);
|
||||
}
|
||||
|
||||
return $scanner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse files and return a list of violations.
|
||||
*
|
||||
* @param array<int, string>|null $pathsOverride
|
||||
* @param callable|null $onProgress Function called for progress tracking: fn(string $file, int $index, int $total)
|
||||
* @return array{violations: Violation[], baselinedCount: int}
|
||||
*/
|
||||
public function analyse(?array $pathsOverride = null, ?callable $onProgress = null): array
|
||||
{
|
||||
$paths = $pathsOverride ?? $this->config->getPaths();
|
||||
$excludePaths = $this->config->getExcludePaths();
|
||||
|
||||
// Resolve all files to scan
|
||||
$filesToScan = [];
|
||||
foreach ($paths as $path) {
|
||||
if (is_file($path)) {
|
||||
$filesToScan[] = str_replace('\\', '/', realpath($path) ?: $path);
|
||||
} elseif (is_dir($path)) {
|
||||
$files = $this->findFiles($path, $excludePaths);
|
||||
$filesToScan = array_merge($filesToScan, $files);
|
||||
}
|
||||
}
|
||||
|
||||
$filesToScan = array_unique($filesToScan);
|
||||
$totalFiles = count($filesToScan);
|
||||
|
||||
$violations = [];
|
||||
$baselinedCount = 0;
|
||||
|
||||
foreach ($filesToScan as $index => $file) {
|
||||
if ($onProgress) {
|
||||
$onProgress($file, $index + 1, $totalFiles);
|
||||
}
|
||||
|
||||
$rawContent = @file_get_contents($file);
|
||||
if ($rawContent === false || trim($rawContent) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preprocess templates (Blade, Twig, PHP) into scan-ready HTML preserving line numbers
|
||||
$scanContent = $this->templatePreprocessor->preprocess($rawContent, $file);
|
||||
|
||||
// Scan file content
|
||||
try {
|
||||
$fileViolations = $this->scanner->scan($scanContent);
|
||||
} catch (\Throwable $e) {
|
||||
// Skip if parsing failed dramatically
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativeFile = $this->makePathRelative($file);
|
||||
|
||||
foreach ($fileViolations as $violation) {
|
||||
// Find line and column number using high-precision locator
|
||||
if (!isset($violation->location['line']) || $violation->location['line'] <= 1) {
|
||||
$location = $this->elementLocator->locate($rawContent, $violation->htmlSnippet);
|
||||
$violation->location = [
|
||||
'file' => $relativeFile,
|
||||
'line' => $location['line'],
|
||||
'column' => $location['column'],
|
||||
];
|
||||
} else {
|
||||
$violation->location['file'] = $relativeFile;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core\Baseline;
|
||||
|
||||
class FuzzyBaselineMatcher
|
||||
{
|
||||
/**
|
||||
* Determine if two HTML snippets are fuzzy matched.
|
||||
*
|
||||
* @param string $snippetA
|
||||
* @param string $snippetB
|
||||
* @return bool
|
||||
*/
|
||||
public static function match(string $snippetA, string $snippetB): bool
|
||||
{
|
||||
$normA = self::normalize($snippetA);
|
||||
$normB = self::normalize($snippetB);
|
||||
|
||||
if ($normA === '' || $normB === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $normA === $normB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize HTML snippet by stripping formatting, converting to lowercase,
|
||||
* and sorting attributes.
|
||||
*
|
||||
* @param string $html
|
||||
* @return string
|
||||
*/
|
||||
public static function normalize(string $html): string
|
||||
{
|
||||
$html = trim($html);
|
||||
if ($html === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Parse using simple regex to sort attributes and lowercase tag
|
||||
if (preg_match('/^<([a-zA-Z0-9:-]+)(.*?)>$/s', $html, $matches)) {
|
||||
$tag = strtolower($matches[1]);
|
||||
$attrsContent = $matches[2];
|
||||
|
||||
// Parse attributes
|
||||
preg_match_all('/([a-zA-Z0-9:-]+)=["\'](.*?)["\']/s', $attrsContent, $attrMatches, PREG_SET_ORDER);
|
||||
|
||||
$attrs = [];
|
||||
foreach ($attrMatches as $match) {
|
||||
$attrs[strtolower($match[1])] = trim($match[2]);
|
||||
}
|
||||
ksort($attrs);
|
||||
|
||||
$attrStr = '';
|
||||
foreach ($attrs as $name => $value) {
|
||||
$attrStr .= sprintf(' %s="%s"', $name, $value);
|
||||
}
|
||||
|
||||
return sprintf('<%s%s>', $tag, $attrStr);
|
||||
}
|
||||
|
||||
// Fallback: Strip all whitespace and lowercase
|
||||
$cleaned = preg_replace('/\s+/', '', $html) ?: '';
|
||||
return strtolower($cleaned);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
class BaselineManager
|
||||
{
|
||||
/** @var array<string, bool> */
|
||||
private array $ignoredKeys = [];
|
||||
|
||||
public function load(string $baselinePath): bool
|
||||
{
|
||||
if (!file_exists($baselinePath) || !is_readable($baselinePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = file_get_contents($baselinePath);
|
||||
if ($content === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data) || !isset($data['violations']) || !is_array($data['violations'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($data['violations'] as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])
|
||||
&& is_string($item['file']) && is_string($item['ruleId']) && is_string($item['htmlSnippet'])
|
||||
) {
|
||||
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
|
||||
$this->ignoredKeys[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Violation[] $violations
|
||||
*/
|
||||
public function generate(array $violations, string $baselinePath): bool
|
||||
{
|
||||
$serialized = [
|
||||
'generator' => 'YakNet Accessibility Console',
|
||||
'violations' => []
|
||||
];
|
||||
|
||||
foreach ($violations as $v) {
|
||||
$file = $v->location['file'] ?? '';
|
||||
$serialized['violations'][] = [
|
||||
'file' => $file,
|
||||
'ruleId' => $v->ruleId,
|
||||
'htmlSnippet' => $v->htmlSnippet
|
||||
];
|
||||
}
|
||||
|
||||
// Sort baseline violations by file and ruleId for cleaner git diffs
|
||||
usort($serialized['violations'], function ($a, $b) {
|
||||
$fileCmp = strcmp($a['file'], $b['file']);
|
||||
if ($fileCmp !== 0) {
|
||||
return $fileCmp;
|
||||
}
|
||||
return strcmp($a['ruleId'], $b['ruleId']);
|
||||
});
|
||||
|
||||
$json = json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dir = dirname($baselinePath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
return file_put_contents($baselinePath, $json) !== false;
|
||||
}
|
||||
|
||||
public function isIgnored(string $file, string $ruleId, string $htmlSnippet): bool
|
||||
{
|
||||
$key = $this->calculateKey($file, $ruleId, $htmlSnippet);
|
||||
return isset($this->ignoredKeys[$key]);
|
||||
}
|
||||
|
||||
private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string
|
||||
{
|
||||
// Normalize snippet using fuzzy baseline matcher
|
||||
$normalizedSnippet = \YakNet\AccessibilityConsole\Core\Baseline\FuzzyBaselineMatcher::normalize($htmlSnippet);
|
||||
if ($normalizedSnippet === '') {
|
||||
$normalizedSnippet = trim($htmlSnippet);
|
||||
}
|
||||
|
||||
// Normalize file separator to forward slashes for cross-platform compatibility
|
||||
$normalizedFile = str_replace('\\', '/', $file);
|
||||
|
||||
return md5($normalizedFile . '::' . $ruleId . '::' . $normalizedSnippet);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
use HeadlessChromium\BrowserFactory;
|
||||
use HeadlessChromium\Navigation;
|
||||
|
||||
class ChromeRenderer
|
||||
{
|
||||
/**
|
||||
* Navigate to URL using headless Chrome and return fully rendered HTML.
|
||||
*/
|
||||
public function render(string $url): string
|
||||
{
|
||||
$browserFactory = new BrowserFactory();
|
||||
|
||||
$options = [
|
||||
'windowSize' => [1280, 1024],
|
||||
'noSandbox' => true,
|
||||
];
|
||||
|
||||
// Windows Chrome binary path auto-detection override
|
||||
$chromeBinary = $_ENV['CHROME_BINARY'] ?? getenv('CHROME_BINARY') ?: null;
|
||||
if (!$chromeBinary && PHP_OS_FAMILY === 'Windows') {
|
||||
$standardPaths = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe'
|
||||
];
|
||||
foreach ($standardPaths as $path) {
|
||||
if (file_exists($path)) {
|
||||
$chromeBinary = $path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($chromeBinary) {
|
||||
$options['chromeBinary'] = $chromeBinary;
|
||||
}
|
||||
|
||||
$browser = $browserFactory->createBrowser($options);
|
||||
|
||||
try {
|
||||
$page = $browser->createPage();
|
||||
|
||||
// Navigate and wait for onload event
|
||||
$navigation = $page->navigate($url);
|
||||
$navigation->waitForNavigation(\HeadlessChromium\Page::LOAD, 10000); // 10 seconds timeout
|
||||
|
||||
// Extra wait for async JS rendering to complete
|
||||
usleep(1500000); // 1.5s
|
||||
|
||||
$html = $page->getHtml();
|
||||
} finally {
|
||||
$browser->close();
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
+99
-2
@@ -13,11 +13,35 @@ class Config
|
||||
public function __construct(array $settings = [])
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->loadDotenv();
|
||||
}
|
||||
|
||||
private function loadDotenv(): void
|
||||
{
|
||||
$cwd = getcwd();
|
||||
if ($cwd && file_exists($cwd . '/.env')) {
|
||||
try {
|
||||
$dotenv = \Dotenv\Dotenv::createImmutable($cwd);
|
||||
$dotenv->safeLoad();
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore Dotenv errors silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromYaml(string $path): self
|
||||
{
|
||||
// Placeholder for YAML loading logic
|
||||
if (file_exists($path)) {
|
||||
try {
|
||||
$content = \Symfony\Component\Yaml\Yaml::parseFile($path);
|
||||
if (is_array($content)) {
|
||||
/** @var array<string, mixed> $content */
|
||||
return new self($content);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
@@ -26,8 +50,81 @@ class Config
|
||||
return $this->settings[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getPaths(): array
|
||||
{
|
||||
$paths = $this->get('paths');
|
||||
if (is_array($paths)) {
|
||||
return array_values(array_filter($paths, 'is_string'));
|
||||
}
|
||||
if (is_string($paths)) {
|
||||
return [$paths];
|
||||
}
|
||||
return ['.']; // default to current directory
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getExcludePaths(): array
|
||||
{
|
||||
$exclude = $this->get('exclude_paths');
|
||||
if (is_array($exclude)) {
|
||||
return array_values(array_filter($exclude, 'is_string'));
|
||||
}
|
||||
if (is_string($exclude)) {
|
||||
return [$exclude];
|
||||
}
|
||||
return ['vendor', 'node_modules', 'tests', 'storage', '.git']; // sensible defaults
|
||||
}
|
||||
|
||||
public function getLevel(): int
|
||||
{
|
||||
$level = $this->get('level', 4);
|
||||
return is_numeric($level) ? (int)$level : 4;
|
||||
}
|
||||
|
||||
public function getBaselinePath(): ?string
|
||||
{
|
||||
$baseline = $this->get('baseline');
|
||||
return is_string($baseline) ? $baseline : null;
|
||||
}
|
||||
|
||||
public function getFormat(): string
|
||||
{
|
||||
$format = $this->get('format', 'console');
|
||||
return is_string($format) ? $format : 'console';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{include: array<int, string>, exclude: array<int, string>}
|
||||
*/
|
||||
public function getRulesConfig(): array
|
||||
{
|
||||
$rules = $this->get('rules', []);
|
||||
$include = [];
|
||||
$exclude = [];
|
||||
|
||||
if (is_array($rules)) {
|
||||
if (isset($rules['include']) && is_array($rules['include'])) {
|
||||
$include = array_values(array_filter($rules['include'], 'is_string'));
|
||||
}
|
||||
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
|
||||
$exclude = array_values(array_filter($rules['exclude'], 'is_string'));
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'include' => $include,
|
||||
'exclude' => $exclude
|
||||
];
|
||||
}
|
||||
|
||||
public function getGeminiApiKey(): ?string
|
||||
{
|
||||
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
|
||||
$key = $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
|
||||
return is_string($key) ? $key : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core\Config;
|
||||
|
||||
class SmartPresetBuilder
|
||||
{
|
||||
/**
|
||||
* Analyze HTML content features to suggest rules presets.
|
||||
*
|
||||
* @param string $html
|
||||
* @return array{suggestedLevel: int, tagsCheckedCount: array<string, int>, recommendedExclusions: string[]}
|
||||
*/
|
||||
public function analyze(string $html): array
|
||||
{
|
||||
$tagsChecked = ['img' => 0, 'form' => 0, 'table' => 0, 'svg' => 0, 'video' => 0];
|
||||
|
||||
foreach (array_keys($tagsChecked) as $tag) {
|
||||
$tagsChecked[$tag] = substr_count(strtolower($html), '<' . $tag);
|
||||
}
|
||||
|
||||
// Calculate recommendations
|
||||
$suggestedLevel = 3; // Standard baseline
|
||||
if ($tagsChecked['form'] > 5 || $tagsChecked['table'] > 3) {
|
||||
$suggestedLevel = 4; // Higher complexity requires more strict tests
|
||||
}
|
||||
if ($tagsChecked['video'] > 0) {
|
||||
$suggestedLevel = 5; // Video elements need caption checks, standard level 5
|
||||
}
|
||||
|
||||
$exclusions = [];
|
||||
if ($tagsChecked['svg'] === 0) {
|
||||
$exclusions[] = 'WCAG_1_1_1_SVG';
|
||||
}
|
||||
if ($tagsChecked['video'] === 0) {
|
||||
$exclusions[] = 'WCAG_1_2_2_VIDEO_TRACK';
|
||||
}
|
||||
|
||||
return [
|
||||
'suggestedLevel' => $suggestedLevel,
|
||||
'tagsCheckedCount' => $tagsChecked,
|
||||
'recommendedExclusions' => $exclusions,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core\Container;
|
||||
|
||||
use DI\ContainerBuilder;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use YakNet\AccessibilityConsole\Core\Config;
|
||||
use YakNet\AccessibilityConsole\Core\Analyser;
|
||||
use YakNet\AccessibilityConsole\Core\Scanner;
|
||||
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
|
||||
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
|
||||
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
|
||||
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
|
||||
|
||||
class ContainerFactory
|
||||
{
|
||||
/**
|
||||
* Create and configure the PHP-DI container.
|
||||
*
|
||||
* @return ContainerInterface
|
||||
*/
|
||||
public static function create(): ContainerInterface
|
||||
{
|
||||
$builder = new ContainerBuilder();
|
||||
|
||||
$builder->addDefinitions([
|
||||
Config::class => function () {
|
||||
$configPath = getcwd() . '/a11y.yaml';
|
||||
return Config::fromYaml($configPath);
|
||||
},
|
||||
Scanner::class => function () {
|
||||
return new Scanner();
|
||||
},
|
||||
Analyser::class => function (ContainerInterface $c) {
|
||||
$config = $c->get(Config::class);
|
||||
$scanner = $c->get(Scanner::class);
|
||||
return new Analyser(
|
||||
$config instanceof Config ? $config : null,
|
||||
$scanner instanceof Scanner ? $scanner : null
|
||||
);
|
||||
},
|
||||
ScanCommand::class => function () {
|
||||
return new ScanCommand();
|
||||
},
|
||||
FixCommand::class => function () {
|
||||
return new FixCommand();
|
||||
},
|
||||
InitCommand::class => function () {
|
||||
return new InitCommand();
|
||||
},
|
||||
RulesCommand::class => function () {
|
||||
return new RulesCommand();
|
||||
},
|
||||
]);
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core\Diff;
|
||||
|
||||
use SebastianBergmann\Diff\Differ;
|
||||
use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
|
||||
|
||||
class VisualDiffGenerator
|
||||
{
|
||||
private Differ $differ;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->differ = new Differ(new UnifiedDiffOutputBuilder("--- Original\n+++ Healed\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a colorized git-style unified diff string.
|
||||
*
|
||||
* @param string $original
|
||||
* @param string $fixed
|
||||
* @return string
|
||||
*/
|
||||
public function generate(string $original, string $fixed): string
|
||||
{
|
||||
$diff = $this->differ->diff($original, $fixed);
|
||||
$lines = explode("\n", $diff);
|
||||
|
||||
$colorized = [];
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with($line, '+') && !str_starts_with($line, '+++')) {
|
||||
$colorized[] = "<fg=green>" . htmlspecialchars($line) . "</>";
|
||||
} elseif (str_starts_with($line, '-') && !str_starts_with($line, '---')) {
|
||||
$colorized[] = "<fg=red>" . htmlspecialchars($line) . "</>";
|
||||
} elseif (str_starts_with($line, '@@')) {
|
||||
$colorized[] = "<fg=cyan>" . htmlspecialchars($line) . "</>";
|
||||
} elseif (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
|
||||
$colorized[] = "<fg=gray>" . htmlspecialchars($line) . "</>";
|
||||
} else {
|
||||
$colorized[] = htmlspecialchars($line);
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $colorized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core\Locator;
|
||||
|
||||
class PreciseElementLocator
|
||||
{
|
||||
/**
|
||||
* Locate the exact line and column of an HTML snippet within file content.
|
||||
*
|
||||
* @param string $content Full file content
|
||||
* @param string $snippet HTML snippet of the violating element
|
||||
* @return array{line: int, column: int}
|
||||
*/
|
||||
public function locate(string $content, string $snippet): array
|
||||
{
|
||||
$cleanSnippet = trim(preg_replace('/\s+/', ' ', $snippet) ?? '');
|
||||
if ($cleanSnippet === '') {
|
||||
return ['line' => 1, 'column' => 1];
|
||||
}
|
||||
|
||||
// 1. Exact substring match pass
|
||||
$exactPos = strpos($content, $snippet);
|
||||
if ($exactPos !== false) {
|
||||
return $this->getLineAndColumn($content, $exactPos);
|
||||
}
|
||||
|
||||
// 2. Normalized whitespace search pass
|
||||
$lines = explode("\n", $content);
|
||||
$searchSnippet = mb_strlen($cleanSnippet) > 120 ? mb_substr($cleanSnippet, 0, 120) : $cleanSnippet;
|
||||
|
||||
foreach ($lines as $index => $line) {
|
||||
$cleanLine = trim(preg_replace('/\s+/', ' ', $line) ?? '');
|
||||
if ($cleanLine !== '' && str_contains($cleanLine, $searchSnippet)) {
|
||||
$col = strpos($line, mb_substr($searchSnippet, 0, 20));
|
||||
return [
|
||||
'line' => $index + 1,
|
||||
'column' => ($col !== false) ? $col + 1 : 1
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Tag & Attribute Token Match Pass
|
||||
preg_match('/<([a-zA-Z0-9:-]+)/', $snippet, $tagMatch);
|
||||
$tagName = strtolower($tagMatch[1] ?? '');
|
||||
|
||||
if ($tagName !== '') {
|
||||
preg_match_all('/([a-zA-Z0-9_:-]+)=["\']([^"\']*)["\']/', $snippet, $attrMatches, PREG_SET_ORDER);
|
||||
$attributes = [];
|
||||
foreach ($attrMatches as $m) {
|
||||
$attributes[$m[1]] = $m[2];
|
||||
}
|
||||
|
||||
$bestLine = 1;
|
||||
$bestCol = 1;
|
||||
$maxScore = 0.0;
|
||||
|
||||
foreach ($lines as $index => $line) {
|
||||
$lineLower = strtolower($line);
|
||||
$tagStr = '<' . $tagName;
|
||||
|
||||
if (!str_contains($lineLower, $tagStr)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$score = 1.0;
|
||||
|
||||
foreach ($attributes as $attrName => $attrVal) {
|
||||
if (str_contains($line, "$attrName=\"$attrVal\"") || str_contains($line, "$attrName='$attrVal'")) {
|
||||
$score += 3.0;
|
||||
} elseif (str_contains($line, "$attrName=")) {
|
||||
$score += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
if ($score > $maxScore) {
|
||||
$maxScore = $score;
|
||||
$bestLine = $index + 1;
|
||||
$col = strpos($lineLower, $tagStr);
|
||||
$bestCol = ($col !== false) ? $col + 1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($maxScore > 0) {
|
||||
return ['line' => $bestLine, 'column' => $bestCol];
|
||||
}
|
||||
}
|
||||
|
||||
return ['line' => 1, 'column' => 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{line: int, column: int}
|
||||
*/
|
||||
private function getLineAndColumn(string $content, int $offset): array
|
||||
{
|
||||
if ($offset <= 0) {
|
||||
return ['line' => 1, 'column' => 1];
|
||||
}
|
||||
|
||||
$before = substr($content, 0, $offset);
|
||||
$line = substr_count($before, "\n") + 1;
|
||||
$lastNewline = strrpos($before, "\n");
|
||||
$column = ($lastNewline === false) ? $offset + 1 : $offset - $lastNewline;
|
||||
|
||||
return ['line' => $line, 'column' => $column];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core\Metrics;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
|
||||
class AccessibilityScoreCalculator
|
||||
{
|
||||
/**
|
||||
* Calculate accessibility score (0-100) and user impact summaries.
|
||||
*
|
||||
* @param Violation[] $violations
|
||||
* @return array{score: int, visualImpact: int, motorImpact: int, cognitiveImpact: int}
|
||||
*/
|
||||
public static function calculate(array $violations): array
|
||||
{
|
||||
$score = 100;
|
||||
|
||||
$visualCount = 0;
|
||||
$motorCount = 0;
|
||||
$cognitiveCount = 0;
|
||||
|
||||
foreach ($violations as $v) {
|
||||
// Deduct points based on severity
|
||||
$deduction = 3; // Warning default
|
||||
if ($v->severity === Severity::CRITICAL) {
|
||||
$deduction = 15;
|
||||
} elseif ($v->severity === Severity::ERROR) {
|
||||
$deduction = 8;
|
||||
}
|
||||
$score -= $deduction;
|
||||
|
||||
// Map rule ID to disabled user groups impact
|
||||
$id = $v->ruleId;
|
||||
if (self::isVisual($id)) {
|
||||
$visualCount++;
|
||||
}
|
||||
if (self::isMotor($id)) {
|
||||
$motorCount++;
|
||||
}
|
||||
if (self::isCognitive($id)) {
|
||||
$cognitiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$score = max(0, $score);
|
||||
|
||||
// Normalize counts to percentage indicators of severity
|
||||
$total = count($violations);
|
||||
$visualImpact = $total > 0 ? (int)(($visualCount / $total) * 100) : 0;
|
||||
$motorImpact = $total > 0 ? (int)(($motorCount / $total) * 100) : 0;
|
||||
$cognitiveImpact = $total > 0 ? (int)(($cognitiveCount / $total) * 100) : 0;
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'visualImpact' => $visualImpact,
|
||||
'motorImpact' => $motorImpact,
|
||||
'cognitiveImpact' => $cognitiveImpact,
|
||||
];
|
||||
}
|
||||
|
||||
private static function isVisual(string $ruleId): bool
|
||||
{
|
||||
$visualKeywords = ['ALT', 'LABEL', 'TITLE', 'ARIA', 'LANG', 'CONTRAST', 'COLOR', 'TEXT', 'HEADING'];
|
||||
foreach ($visualKeywords as $key) {
|
||||
if (str_contains($ruleId, $key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function isMotor(string $ruleId): bool
|
||||
{
|
||||
$motorKeywords = ['ACCESSKEY', 'FOCUS', 'TABINDEX', 'KEYBOARD', 'LINK_JAVASCRIPT', 'AUTOFOCUS', 'SCROLLABLE'];
|
||||
foreach ($motorKeywords as $key) {
|
||||
if (str_contains($ruleId, $key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function isCognitive(string $ruleId): bool
|
||||
{
|
||||
$cognitiveKeywords = ['MARQUEE', 'BLINK', 'REFRESH', 'AUTOPLAY', 'AUTOCOMPLETE'];
|
||||
foreach ($cognitiveKeywords as $key) {
|
||||
if (str_contains($ruleId, $key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+41
-8
@@ -2,6 +2,11 @@
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMXPath;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleInterface;
|
||||
|
||||
class RuleEngine
|
||||
{
|
||||
/** @var array<int, mixed> */
|
||||
@@ -13,32 +18,60 @@ class RuleEngine
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public function getRules(): array
|
||||
{
|
||||
return $this->rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all registered rules against the DOMDocument using single-pass DOM traversal.
|
||||
*
|
||||
* @param DOMDocument $doc
|
||||
* @return Violation[]
|
||||
*/
|
||||
public function run(\DOMDocument $doc): array
|
||||
public function run(DOMDocument $doc): array
|
||||
{
|
||||
$allViolations = [];
|
||||
$docRules = [];
|
||||
$elementRules = [];
|
||||
|
||||
foreach ($this->rules as $rule) {
|
||||
if ($rule instanceof AbstractRule) {
|
||||
// Document-level rule (Core\AbstractRule style)
|
||||
$docRules[] = $rule;
|
||||
} elseif ($rule instanceof RuleInterface) {
|
||||
$elementRules[] = $rule;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Run Document-level rules
|
||||
foreach ($docRules as $rule) {
|
||||
$violations = $rule->check($doc);
|
||||
$allViolations = array_merge($allViolations, $violations);
|
||||
} elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) {
|
||||
// Element-level rule (Rules\RuleInterface style)
|
||||
$xpath = new \DOMXPath($doc);
|
||||
}
|
||||
|
||||
// 2. Run Element-level rules in a single DOM pass
|
||||
if (!empty($elementRules)) {
|
||||
$xpath = new DOMXPath($doc);
|
||||
$elements = $xpath->query('//*');
|
||||
|
||||
if ($elements !== false) {
|
||||
foreach ($elements as $element) {
|
||||
if ($element instanceof \DOMElement) {
|
||||
if (!$element instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($elementRules as $rule) {
|
||||
$violation = $rule->check($element);
|
||||
if ($violation) {
|
||||
if ($violation !== null) {
|
||||
$allViolations[] = $violation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $allViolations;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core;
|
||||
|
||||
use ReflectionClass;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleInterface;
|
||||
use YakNet\AccessibilityConsole\Rules\RuleLevels;
|
||||
|
||||
class RuleRegistry
|
||||
{
|
||||
/**
|
||||
* Validate an array of rule instances for ID uniqueness, interfaces, and validity.
|
||||
*
|
||||
* @param array<int, mixed> $rules
|
||||
* @return array{valid: bool, errors: array<int, string>, count: int}
|
||||
*/
|
||||
public static function validate(array $rules): array
|
||||
{
|
||||
$errors = [];
|
||||
$ids = [];
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if (!is_object($rule)) {
|
||||
$errors[] = "Non-object rule encountered in rule set.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$className = get_class($rule);
|
||||
$reflection = new ReflectionClass($rule);
|
||||
|
||||
$isDocRule = $rule instanceof AbstractRule;
|
||||
$isElementRule = $rule instanceof RuleInterface;
|
||||
|
||||
if (!$isDocRule && !$isElementRule) {
|
||||
$errors[] = "Class {$className} does not implement RuleInterface or extend AbstractRule.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$ruleId = method_exists($rule, 'getId') ? $rule->getId() : $reflection->getShortName();
|
||||
|
||||
if (isset($ids[$ruleId])) {
|
||||
$errors[] = "Duplicate Rule ID '{$ruleId}' detected across classes: {$ids[$ruleId]} and {$className}.";
|
||||
} else {
|
||||
$ids[$ruleId] = $className;
|
||||
}
|
||||
|
||||
if (!method_exists($rule, 'getStandard')) {
|
||||
$errors[] = "Rule {$className} is missing getStandard() method.";
|
||||
}
|
||||
|
||||
if (!method_exists($rule, 'getSeverity')) {
|
||||
$errors[] = "Rule {$className} is missing getSeverity() method.";
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => empty($errors),
|
||||
'errors' => $errors,
|
||||
'count' => count($rules),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a specific rule instance.
|
||||
*
|
||||
* @param object $rule
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function getMetadata(object $rule): array
|
||||
{
|
||||
$reflection = new ReflectionClass($rule);
|
||||
$id = method_exists($rule, 'getId') ? $rule->getId() : $reflection->getShortName();
|
||||
$desc = method_exists($rule, 'getDescription') ? $rule->getDescription() : '';
|
||||
$standard = method_exists($rule, 'getStandard') ? $rule->getStandard()->value : 'A';
|
||||
$severity = method_exists($rule, 'getSeverity') ? $rule->getSeverity()->value : 'error';
|
||||
$level = method_exists($rule, 'getLevel') ? $rule->getLevel() : 4;
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'class' => $reflection->getName(),
|
||||
'shortName' => $reflection->getShortName(),
|
||||
'description' => $desc,
|
||||
'standard' => $standard,
|
||||
'severity' => $severity,
|
||||
'level' => $level,
|
||||
'type' => ($rule instanceof AbstractRule) ? 'document' : 'element',
|
||||
];
|
||||
}
|
||||
}
|
||||
+13
-3
@@ -16,6 +16,11 @@ class Scanner
|
||||
$this->engine->addRule($rule);
|
||||
}
|
||||
|
||||
public function getEngine(): RuleEngine
|
||||
{
|
||||
return $this->engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan HTML content for accessibility violations.
|
||||
*
|
||||
@@ -28,9 +33,14 @@ 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);
|
||||
|
||||
// Attach raw HTML property for syntax/token level analysis
|
||||
/** @phpstan-ignore-next-line */
|
||||
$doc->rawHtml = $html;
|
||||
|
||||
return $this->engine->run($doc);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Core\SelfHealing;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
|
||||
class LocalHealer
|
||||
{
|
||||
/**
|
||||
* Attempt to deterministically fix simple violations locally.
|
||||
*
|
||||
* @param Violation $violation
|
||||
* @return string|null
|
||||
*/
|
||||
public function heal(Violation $violation): ?string
|
||||
{
|
||||
$snippet = $violation->htmlSnippet;
|
||||
$ruleId = $violation->ruleId;
|
||||
|
||||
switch ($ruleId) {
|
||||
case 'WCAG_2_2_2_BLINK':
|
||||
$fixed = str_replace(
|
||||
['<blink>', '</blink>', '<BLINK>', '</BLINK>'],
|
||||
['<span style="text-decoration: blink;">', '</span>', '<span style="text-decoration: blink;">', '</span>'],
|
||||
$snippet
|
||||
);
|
||||
return "EXPLANATION: Banned <blink> tag replaced with a span and text-decoration style.\nFIX: " . $fixed;
|
||||
|
||||
case 'WCAG_2_4_3_AUTOFOCUS':
|
||||
$fixed = preg_replace('/\s+autofocus(=["\'](.*?)["\'])?/i', '', $snippet) ?: $snippet;
|
||||
return "EXPLANATION: Autofocus attribute removed to prevent screen reader interruption on load.\nFIX: " . $fixed;
|
||||
|
||||
case 'WCAG_1_3_1_TABLE_SUMMARY':
|
||||
$fixed = preg_replace('/\s+summary=["\'](.*?)["\']/i', '', $snippet) ?: $snippet;
|
||||
return "EXPLANATION: Deprecated table summary attribute removed. Use caption elements instead.\nFIX: " . $fixed;
|
||||
|
||||
case 'WCAG_2_1_1_SCROLLABLE_FOCUS':
|
||||
if (!str_contains($snippet, 'tabindex')) {
|
||||
$fixed = preg_replace('/(style=["\'](.*?)["\'])/i', '$1 tabindex="0"', $snippet) ?: $snippet;
|
||||
return "EXPLANATION: Added tabindex=\"0\" to scrollable region to allow keyboard scrolling.\nFIX: " . $fixed;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'WCAG_1_3_1_PRESENTATION_TAGS':
|
||||
if (preg_match('/<center>(.*?)<\/center>/is', $snippet, $matches)) {
|
||||
$fixed = '<div style="text-align: center;">' . $matches[1] . '</div>';
|
||||
return "EXPLANATION: Center element replaced with div and text-align CSS.\nFIX: " . $fixed;
|
||||
}
|
||||
if (preg_match('/<strike>(.*?)<\/strike>/is', $snippet, $matches)) {
|
||||
$fixed = '<span style="text-decoration: line-through;">' . $matches[1] . '</span>';
|
||||
return "EXPLANATION: Strike element replaced with span and line-through decoration CSS.\nFIX: " . $fixed;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'WCAG_3_1_1_LANG':
|
||||
if (preg_match('/<html(\s+[^>]*>|>)/i', $snippet)) {
|
||||
$fixed = preg_replace('/<html/i', '<html lang="en"', $snippet) ?: $snippet;
|
||||
return "EXPLANATION: Injected default lang=\"en\" to HTML element.\nFIX: " . $fixed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,20 @@ namespace YakNet\AccessibilityConsole\Core;
|
||||
class Violation
|
||||
{
|
||||
/**
|
||||
* @param array{file: string, line: int}|null $location
|
||||
* @param array{file?: string, line: int, column?: int}|null $location
|
||||
*/
|
||||
public function __construct(
|
||||
public string $ruleId,
|
||||
public string $message,
|
||||
public Severity $severity,
|
||||
public WCAGStandard $standard,
|
||||
public string $htmlSnippet,
|
||||
public string $htmlSnippet = '',
|
||||
public ?array $location = null, // ['file' => '...', 'line' => 0]
|
||||
public ?string $fixSuggestion = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{ruleId: string, message: string, severity: string, standard: string, htmlSnippet: string, location: array{file: string, line: int}|null, fixSuggestion: string|null}
|
||||
* @return array{ruleId: string, message: string, severity: string, standard: string, htmlSnippet: string, location: array{file?: string, line: int, column?: int}|null, fixSuggestion: string|null}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Integrations;
|
||||
|
||||
class GitHubActionsReporter
|
||||
{
|
||||
/**
|
||||
* Format WCAG violations as GitHub Actions Workflow Command Annotations.
|
||||
*
|
||||
* @param array<string, mixed> $scanResult
|
||||
* @param string $fileName
|
||||
* @return string Formatted annotations string
|
||||
*/
|
||||
public static function formatAnnotations(array $scanResult, string $fileName = 'index.html'): string
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> $violations */
|
||||
$violations = is_array($scanResult['violations'] ?? null) ? $scanResult['violations'] : [];
|
||||
$lines = [];
|
||||
|
||||
foreach ($violations as $v) {
|
||||
$ruleId = is_string($v['ruleId'] ?? null) ? $v['ruleId'] : 'WCAG';
|
||||
$desc = is_string($v['description'] ?? null) ? $v['description'] : '';
|
||||
$line = is_numeric($v['line'] ?? null) ? intval($v['line']) : 1;
|
||||
$severity = is_string($v['severity'] ?? null) ? strtolower($v['severity']) : 'error';
|
||||
$level = $severity === 'error' ? 'error' : 'warning';
|
||||
|
||||
$lines[] = sprintf('::%s file=%s,line=%d::[%s] %s', $level, $fileName, $line, $ruleId, $desc);
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Parser;
|
||||
|
||||
class BladeParser implements ParserInterface
|
||||
{
|
||||
public function parse(string $content): string
|
||||
{
|
||||
// 1. Strip Blade comments {{-- ... --}} while preserving line count
|
||||
$content = preg_replace_callback('/\{\{--(.*?)--\}\}/s', function ($m) {
|
||||
return str_repeat("\n", substr_count($m[0], "\n"));
|
||||
}, $content) ?? $content;
|
||||
|
||||
// 2. Replace raw echo {!! ... !!} while preserving line count
|
||||
$content = preg_replace_callback('/\{\!\!(.*?)\!\!\}/s', function ($m) {
|
||||
$newlines = str_repeat("\n", substr_count($m[0], "\n"));
|
||||
return 'StaticRawBladeContent' . $newlines;
|
||||
}, $content) ?? $content;
|
||||
|
||||
// 3. Replace safe echo {{ ... }} while preserving line count
|
||||
$content = preg_replace_callback('/\{\{\s*(.*?)\s*\}\}/s', function ($m) {
|
||||
$newlines = str_repeat("\n", substr_count($m[0], "\n"));
|
||||
return 'StaticBladeContent' . $newlines;
|
||||
}, $content) ?? $content;
|
||||
|
||||
// 4. Strip Blade directives like @if, @else, @endif, @foreach, @component, etc.
|
||||
$content = preg_replace_callback('/@[a-zA-Z0-9_]+\s*(\([^\)]*\))?/s', function ($m) {
|
||||
return str_repeat("\n", substr_count($m[0], "\n"));
|
||||
}, $content) ?? $content;
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Parser;
|
||||
|
||||
class HtmlParser implements ParserInterface
|
||||
{
|
||||
public function parse(string $content): string
|
||||
{
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Parser;
|
||||
|
||||
interface ParserInterface
|
||||
{
|
||||
/**
|
||||
* Parse template file contents and strip out dynamic content placeholders
|
||||
* to return clean, standard HTML for scanning.
|
||||
*
|
||||
* @param string $content
|
||||
* @return string
|
||||
*/
|
||||
public function parse(string $content): string;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Parser;
|
||||
|
||||
class TemplatePreprocessor
|
||||
{
|
||||
private BladeParser $bladeParser;
|
||||
private TwigParser $twigParser;
|
||||
private HtmlParser $htmlParser;
|
||||
|
||||
public function __construct(
|
||||
?BladeParser $bladeParser = null,
|
||||
?TwigParser $twigParser = null,
|
||||
?HtmlParser $htmlParser = null
|
||||
) {
|
||||
$this->bladeParser = $bladeParser ?? new BladeParser();
|
||||
$this->twigParser = $twigParser ?? new TwigParser();
|
||||
$this->htmlParser = $htmlParser ?? new HtmlParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess file content according to its file extension or template dialect.
|
||||
*
|
||||
* @param string $content
|
||||
* @param string|null $filePath
|
||||
* @return string
|
||||
*/
|
||||
public function preprocess(string $content, ?string $filePath = null): string
|
||||
{
|
||||
if ($filePath !== null) {
|
||||
$lower = strtolower($filePath);
|
||||
if (str_ends_with($lower, '.blade.php')) {
|
||||
return $this->bladeParser->parse($content);
|
||||
}
|
||||
if (str_ends_with($lower, '.twig')) {
|
||||
return $this->twigParser->parse($content);
|
||||
}
|
||||
if (str_ends_with($lower, '.php')) {
|
||||
return $this->preprocessPhp($content);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect Blade or Twig by content heuristic if no path is provided
|
||||
if (str_contains($content, '{{') || str_contains($content, '@if') || str_contains($content, '@foreach')) {
|
||||
return $this->bladeParser->parse($content);
|
||||
}
|
||||
|
||||
if (str_contains($content, '{%') || str_contains($content, '{#')) {
|
||||
return $this->twigParser->parse($content);
|
||||
}
|
||||
|
||||
if (str_contains($content, '<' . '?php') || str_contains($content, '<' . '?=')) {
|
||||
return $this->preprocessPhp($content);
|
||||
}
|
||||
|
||||
return $this->htmlParser->parse($content);
|
||||
}
|
||||
|
||||
private function preprocessPhp(string $content): string
|
||||
{
|
||||
// Replace short echo tags with placeholder text preserving newlines
|
||||
$content = preg_replace_callback('/<\?=(.*?)\?>/s', function ($m) {
|
||||
$newlines = str_repeat("\n", substr_count($m[0], "\n"));
|
||||
return 'StaticPhpEcho' . $newlines;
|
||||
}, $content) ?? $content;
|
||||
|
||||
// Replace PHP execution blocks with placeholder preserving newlines
|
||||
$content = preg_replace_callback('/<\?php(.*?)\?>/s', function ($m) {
|
||||
return str_repeat("\n", substr_count($m[0], "\n"));
|
||||
}, $content) ?? $content;
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Parser;
|
||||
|
||||
class TwigParser implements ParserInterface
|
||||
{
|
||||
public function parse(string $content): string
|
||||
{
|
||||
// 1. Strip Twig comments {# ... #} while preserving line count
|
||||
$content = preg_replace_callback('/\{\#.*?\#\}/s', function ($m) {
|
||||
return str_repeat("\n", substr_count($m[0], "\n"));
|
||||
}, $content) ?? $content;
|
||||
|
||||
// 2. Replace Twig output {{ ... }} while preserving line count
|
||||
$content = preg_replace_callback('/\{\{\s*(.*?)\s*\}\}/s', function ($m) {
|
||||
$newlines = str_repeat("\n", substr_count($m[0], "\n"));
|
||||
return 'StaticTwigOutput' . $newlines;
|
||||
}, $content) ?? $content;
|
||||
|
||||
// 3. Strip Twig statements {% ... %} while preserving line count
|
||||
$content = preg_replace_callback('/\{\%.*?\%\}/s', function ($m) {
|
||||
return str_repeat("\n", substr_count($m[0], "\n"));
|
||||
}, $content) ?? $content;
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reference;
|
||||
|
||||
class WcagCriterion
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $title,
|
||||
public string $level,
|
||||
public string $url,
|
||||
public string $description,
|
||||
public string $badExample,
|
||||
public string $goodExample
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{id: string, title: string, level: string, url: string, description: string, badExample: string, goodExample: string}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'level' => $this->level,
|
||||
'url' => $this->url,
|
||||
'description' => $this->description,
|
||||
'badExample' => $this->badExample,
|
||||
'goodExample' => $this->goodExample,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reference;
|
||||
|
||||
class WcagDatabase
|
||||
{
|
||||
/** @var array<string, WcagCriterion> */
|
||||
private static array $criteria = [];
|
||||
|
||||
public static function load(): void
|
||||
{
|
||||
if (!empty(self::$criteria)) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_2_2_2_BLINK',
|
||||
title: '2.2.2 Pause, Stop, Hide',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html',
|
||||
description: 'The <blink> tag causes flashing text, presenting significant cognitive and visual accessibility barriers.',
|
||||
badExample: '<blink>Blinking Text</blink>',
|
||||
goodExample: '<span class="attention-style">Styled Text</span>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_3_1_DUPLICATE_H1',
|
||||
title: '1.3.1 Info and Relationships',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
|
||||
description: 'Pages should contain exactly one h1 element to present a clear hierarchy and entry point for screen readers.',
|
||||
badExample: '<h1>Title</h1><h1>Another Title</h1>',
|
||||
goodExample: '<h1>Title</h1><h2>Subtitle</h2>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_3_5_AUTOCOMPLETE',
|
||||
title: '1.3.5 Identify Input Purpose',
|
||||
level: 'AA',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html',
|
||||
description: 'Inputs collecting user personal info must have autocomplete attributes to assist users with cognitive impairments.',
|
||||
badExample: '<input type="email" name="email">',
|
||||
goodExample: '<input type="email" name="email" autocomplete="email">'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_2_1_1_SCROLLABLE_FOCUS',
|
||||
title: '2.1.1 Keyboard',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html',
|
||||
description: 'Scrollable regions must be focusable using keyboard navigation (tabindex="0") so keyboard-only users can scroll them.',
|
||||
badExample: '<div style="overflow: scroll; height: 100px;">Scrollable content</div>',
|
||||
goodExample: '<div style="overflow: scroll; height: 100px;" tabindex="0">Scrollable content</div>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_4_1_2_ARIA_LABELLEDBY',
|
||||
title: '4.1.2 Name, Role, Value',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
|
||||
description: 'aria-labelledby and aria-describedby attributes must refer to existing element IDs to prevent screen readers from reading empty contexts.',
|
||||
badExample: '<button aria-labelledby="missing_id">Submit</button>',
|
||||
goodExample: '<div id="btn_lbl">Submit Form</div><button aria-labelledby="btn_lbl">Submit</button>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_1_1_ALT_PLACEHOLDER',
|
||||
title: '1.1.1 Non-text Content',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
|
||||
description: 'Alt attributes must not contain placeholder words or file names. They should provide a meaningful description of the image content.',
|
||||
badExample: '<img src="logo.png" alt="logo">',
|
||||
goodExample: '<img src="logo.png" alt="YakNet Company Logo">'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_1_1_SVG',
|
||||
title: '1.1.1 Non-text Content',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
|
||||
description: 'Inline SVGs must either contain alternative descriptions or be hidden from assistive technologies using aria-hidden="true".',
|
||||
badExample: '<svg><circle cx="5" cy="5" r="5"></svg>',
|
||||
goodExample: '<svg aria-hidden="true"><circle cx="5" cy="5" r="5"></svg>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_3_1_TABLE_SUMMARY',
|
||||
title: '1.3.1 Info and Relationships',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
|
||||
description: 'The summary attribute on <table> is deprecated in HTML5. Use <caption> elements for tables description instead.',
|
||||
badExample: '<table summary="Table Summary">',
|
||||
goodExample: '<table><caption>Table Description</caption>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_3_1_PRESENTATION_TAGS',
|
||||
title: '1.3.1 Info and Relationships',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
|
||||
description: 'Obsolete presentation tags like <center> and <strike> are banned. Use standard CSS classes or styling.',
|
||||
badExample: '<center>Centered Text</center>',
|
||||
goodExample: '<span style="text-align: center; display: block;">Centered Text</span>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_2_4_3_AUTOFOCUS',
|
||||
title: '2.4.3 Focus Order',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html',
|
||||
description: 'autofocus attribute can cause keyboard traps and disorient blind/low-vision users on page load. Focus should be managed contextually.',
|
||||
badExample: '<input name="search" autofocus>',
|
||||
goodExample: '<input name="search">'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_2_5_8_TARGET_SIZE',
|
||||
title: '2.5.8 Target Size (Minimum)',
|
||||
level: 'AA',
|
||||
url: 'https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html',
|
||||
description: 'The size of the target for pointer inputs is at least 24 by 24 CSS pixels.',
|
||||
badExample: '<button style="width: 16px; height: 16px;">X</button>',
|
||||
goodExample: '<button style="width: 24px; height: 24px;">X</button>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_3_1_DETAILS_SUMMARY',
|
||||
title: '1.3.1 Info and Relationships',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
|
||||
description: '<details> elements must have a <summary> element to serve as an accessible disclosure trigger.',
|
||||
badExample: '<details><p>Details content</p></details>',
|
||||
goodExample: '<details><summary>More info</summary><p>Details content</p></details>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_4_1_2_ARIA_CURRENT',
|
||||
title: '4.1.2 Name, Role, Value',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
|
||||
description: 'The aria-current attribute must have a valid value indicating the current item in a set.',
|
||||
badExample: '<a href="/home" aria-current="active">Home</a>',
|
||||
goodExample: '<a href="/home" aria-current="page">Home</a>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_3_3_1_ARIA_ERRORMESSAGE',
|
||||
title: '3.3.1 Error Identification',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/error-identification.html',
|
||||
description: 'aria-errormessage must reference a valid error element and control must have aria-invalid="true".',
|
||||
badExample: '<input name="email" aria-errormessage="email_error">',
|
||||
goodExample: '<input name="email" aria-invalid="true" aria-errormessage="email_err"><span id="email_err">Invalid email</span>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_4_1_2_ARIA_ROLEDESCRIPTION',
|
||||
title: '4.1.2 Name, Role, Value',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
|
||||
description: 'aria-roledescription must only be used on elements with a non-generic role.',
|
||||
badExample: '<div aria-roledescription="slide">Slide 1</div>',
|
||||
goodExample: '<div role="region" aria-roledescription="slide" aria-label="Slide 1">Slide 1</div>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_4_1_2_ARIA_PROHIBITED_ATTR',
|
||||
title: '4.1.2 Name, Role, Value',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
|
||||
description: 'Prohibited ARIA attributes must not be used on presentation or generic roles.',
|
||||
badExample: '<div role="presentation" aria-label="decorative"></div>',
|
||||
goodExample: '<div role="presentation"></div>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_3_2_HTML_DIR',
|
||||
title: '1.3.2 Meaningful Sequence',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/meaningful-sequence.html',
|
||||
description: 'The dir attribute must only use valid values: ltr, rtl, or auto.',
|
||||
badExample: '<p dir="left">Text</p>',
|
||||
goodExample: '<p dir="ltr">Text</p>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_3_1_SELECT_CHILDREN',
|
||||
title: '1.3.1 Info and Relationships',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
|
||||
description: '<select> elements must only contain valid child elements (<option>, <optgroup>).',
|
||||
badExample: '<select><div>Option 1</div></select>',
|
||||
goodExample: '<select><option>Option 1</option></select>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_1_1_ROLE_IMG',
|
||||
title: '1.1.1 Non-text Content',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
|
||||
description: 'Elements with role="img" must have an accessible name.',
|
||||
badExample: '<span role="img" class="icon-star"></span>',
|
||||
goodExample: '<span role="img" class="icon-star" aria-label="5 stars rating"></span>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_4_2_MEDIA_CONTROLS',
|
||||
title: '1.4.2 Audio Control',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/audio-control.html',
|
||||
description: 'Audio and video media elements should provide user controls for playback and volume.',
|
||||
badExample: '<audio src="podcast.mp3"></audio>',
|
||||
goodExample: '<audio src="podcast.mp3" controls></audio>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_2_2_TRACK_VALIDATION',
|
||||
title: '1.2.2 Captions (Prerecorded)',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/captions-prerecorded.html',
|
||||
description: '<track> elements must have valid kind, srclang, and label attributes.',
|
||||
badExample: '<track src="sub.vtt" kind="captions">',
|
||||
goodExample: '<track src="sub.vtt" kind="captions" srclang="en" label="English">'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_2_1_1_ACCESSKEY_DUPLICATE',
|
||||
title: '2.1.1 Keyboard',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html',
|
||||
description: 'accesskey attribute values must be unique across the document.',
|
||||
badExample: '<a href="/" accesskey="h">Home</a><a href="/help" accesskey="h">Help</a>',
|
||||
goodExample: '<a href="/" accesskey="h">Home</a><a href="/help" accesskey="p">Help</a>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_4_1_2_BUTTON_NESTED_INTERACTIVE',
|
||||
title: '4.1.2 Name, Role, Value',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
|
||||
description: '<button> elements must not contain nested interactive elements.',
|
||||
badExample: '<button><a href="/cart">Checkout</a></button>',
|
||||
goodExample: '<a href="/cart" class="btn">Checkout</a>'
|
||||
));
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_2_4_11_FOCUS_NOT_OBSCURED',
|
||||
title: '2.4.11 Focus Not Obscured (Minimum)',
|
||||
level: 'AA',
|
||||
url: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html',
|
||||
description: 'Fixed or sticky headers and overlays must not completely obscure keyboard-focused elements.',
|
||||
badExample: '<header style="position: fixed; top: 0; width: 100%; height: 80px;"></header>',
|
||||
goodExample: '<html style="scroll-padding-top: 80px;"><header style="position: fixed; top: 0; width: 100%; height: 80px;"></header></html>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_2_5_7_DRAGGING_MOVEMENTS',
|
||||
title: '2.5.7 Dragging Movements',
|
||||
level: 'AA',
|
||||
url: 'https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html',
|
||||
description: 'Elements supporting drag-and-drop operations must provide a single-pointer alternative.',
|
||||
badExample: '<div draggable="true" class="list-item">Item 1</div>',
|
||||
goodExample: '<div draggable="true" class="list-item">Item 1 <button>Move Up</button> <button>Move Down</button></div>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_4_1_2_ARIA_LABEL_GENERIC',
|
||||
title: '4.1.2 Name, Role, Value',
|
||||
level: 'A',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
|
||||
description: 'Generic <div> or <span> elements should not use aria-label without an explicit ARIA role.',
|
||||
badExample: '<div aria-label="Important Notice">Text</div>',
|
||||
goodExample: '<div role="region" aria-label="Important Notice">Text</div>'
|
||||
));
|
||||
|
||||
self::add(new WcagCriterion(
|
||||
id: 'WCAG_1_3_5_AUTOCOMPLETE_APPROPRIATE',
|
||||
title: '1.3.5 Identify Input Purpose',
|
||||
level: 'AA',
|
||||
url: 'https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html',
|
||||
description: 'Input autocomplete value must be semantically appropriate for the input type.',
|
||||
badExample: '<input type="number" autocomplete="email">',
|
||||
goodExample: '<input type="email" autocomplete="email">'
|
||||
));
|
||||
}
|
||||
|
||||
public static function add(WcagCriterion $criterion): void
|
||||
{
|
||||
self::$criteria[$criterion->id] = $criterion;
|
||||
}
|
||||
|
||||
public static function get(string $ruleId): ?WcagCriterion
|
||||
{
|
||||
self::load();
|
||||
return self::$criteria[$ruleId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, WcagCriterion>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
self::load();
|
||||
return self::$criteria;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reference;
|
||||
|
||||
class WcagReference
|
||||
{
|
||||
/**
|
||||
* Look up the WCAG Success Criterion for a given rule ID.
|
||||
*
|
||||
* @param string $ruleId
|
||||
* @return WcagCriterion|null
|
||||
*/
|
||||
public static function lookup(string $ruleId): ?WcagCriterion
|
||||
{
|
||||
return WcagDatabase::get($ruleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all WCAG criteria mapped in the console.
|
||||
*
|
||||
* @return array<string, WcagCriterion>
|
||||
*/
|
||||
public static function getCatalog(): array
|
||||
{
|
||||
return WcagDatabase::all();
|
||||
}
|
||||
}
|
||||
@@ -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,44 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reporting;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
class CsvFormatter implements FormatterInterface
|
||||
{
|
||||
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
|
||||
{
|
||||
$out = fopen('php://temp', 'w+');
|
||||
if ($out === false) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Write header
|
||||
fputcsv($out, ['Rule ID', 'Severity', 'Standard', 'File', 'Line', 'Message', 'HTML Snippet', 'Fix Suggestion'], ',', '"', '\\');
|
||||
|
||||
foreach ($violations as $v) {
|
||||
$file = $v->location['file'] ?? 'unknown';
|
||||
$line = $v->location['line'] ?? 0;
|
||||
fputcsv($out, [
|
||||
$v->ruleId,
|
||||
$v->severity->value,
|
||||
$v->standard->value,
|
||||
$file,
|
||||
$line,
|
||||
$v->message,
|
||||
$v->htmlSnippet,
|
||||
$v->fixSuggestion
|
||||
], ',', '"', '\\');
|
||||
}
|
||||
|
||||
rewind($out);
|
||||
$csv = stream_get_contents($out);
|
||||
fclose($out);
|
||||
|
||||
$io->write($csv ?: '');
|
||||
|
||||
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reporting;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
|
||||
class ExcelReportExporter
|
||||
{
|
||||
/**
|
||||
* Export WCAG audit results as an Excel (.xlsx) workbook.
|
||||
*
|
||||
* @param array<string, mixed> $scanResult
|
||||
* @param string $targetUrl
|
||||
* @return string Binary Excel content
|
||||
*/
|
||||
public static function exportExcel(array $scanResult, string $targetUrl): string
|
||||
{
|
||||
if (!class_exists(Spreadsheet::class)) {
|
||||
throw new \RuntimeException('To export Excel audit reports, please install phpspreadsheet via: composer require phpoffice/phpspreadsheet');
|
||||
}
|
||||
|
||||
/** @var int $score */
|
||||
$score = is_numeric($scanResult['score'] ?? null) ? intval($scanResult['score']) : 0;
|
||||
|
||||
/** @var array<int, array<string, mixed>> $violations */
|
||||
$violations = is_array($scanResult['violations'] ?? null) ? $scanResult['violations'] : [];
|
||||
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Erişilebilirlik Raporu');
|
||||
|
||||
// Headers
|
||||
$sheet->setCellValue('A1', 'YakNet Accessibility Console - WCAG 2.1 Audit Export');
|
||||
$sheet->mergeCells('A1:F1');
|
||||
$sheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
|
||||
$sheet->getStyle('A1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
||||
|
||||
$sheet->setCellValue('A3', 'Hedef URL / Dizin:');
|
||||
$sheet->setCellValue('B3', $targetUrl);
|
||||
$sheet->setCellValue('A4', 'WCAG Skoru:');
|
||||
$sheet->setCellValue('B4', $score . ' / 100');
|
||||
$sheet->setCellValue('A5', 'İhlal Sayısı:');
|
||||
$sheet->setCellValue('B5', count($violations));
|
||||
|
||||
// Table Header
|
||||
$row = 7;
|
||||
$headers = ['#', 'Kural ID', 'Standart', 'Ciddiyet', 'İhlal Açıklaması', 'Kod Snippet'];
|
||||
$cols = ['A', 'B', 'C', 'D', 'E', 'F'];
|
||||
|
||||
foreach ($headers as $i => $h) {
|
||||
$col = $cols[$i];
|
||||
$sheet->setCellValue("{$col}{$row}", $h);
|
||||
$sheet->getStyle("{$col}{$row}")->getFont()->setBold(true);
|
||||
$sheet->getStyle("{$col}{$row}")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB('1E293B');
|
||||
$sheet->getStyle("{$col}{$row}")->getFont()->getColor()->setRGB('FFFFFF');
|
||||
}
|
||||
|
||||
// Table Rows
|
||||
foreach ($violations as $idx => $v) {
|
||||
/** @var array<string, mixed> $v */
|
||||
$r = $row + 1 + intval($idx);
|
||||
$ruleId = is_string($v['ruleId'] ?? null) ? $v['ruleId'] : '';
|
||||
$standard = is_string($v['standard'] ?? null) ? $v['standard'] : 'WCAG 2.1';
|
||||
$severity = is_string($v['severity'] ?? null) ? $v['severity'] : 'WARNING';
|
||||
$desc = is_string($v['description'] ?? null) ? $v['description'] : '';
|
||||
$snippet = is_string($v['htmlSnippet'] ?? null) ? $v['htmlSnippet'] : '';
|
||||
|
||||
$sheet->setCellValue("A{$r}", intval($idx) + 1);
|
||||
$sheet->setCellValue("B{$r}", $ruleId);
|
||||
$sheet->setCellValue("C{$r}", $standard);
|
||||
$sheet->setCellValue("D{$r}", $severity);
|
||||
$sheet->setCellValue("E{$r}", $desc);
|
||||
$sheet->setCellValue("F{$r}", $snippet);
|
||||
}
|
||||
|
||||
// Auto-fit columns
|
||||
foreach ($cols as $col) {
|
||||
$sheet->getColumnDimension($col)->setAutoSize(true);
|
||||
}
|
||||
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
ob_start();
|
||||
$writer->save('php://output');
|
||||
return strval(ob_get_clean());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reporting;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
interface FormatterInterface
|
||||
{
|
||||
/**
|
||||
* Format and output the analysis violations.
|
||||
*
|
||||
* @param Violation[] $violations
|
||||
* @param int $baselinedCount
|
||||
* @param SymfonyStyle $io
|
||||
* @return int Exit code (Command::SUCCESS or Command::FAILURE)
|
||||
*/
|
||||
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reporting;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
class GitLabCodeQualityFormatter implements FormatterInterface
|
||||
{
|
||||
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
|
||||
{
|
||||
$quality = [];
|
||||
|
||||
foreach ($violations as $v) {
|
||||
$file = $v->location['file'] ?? 'unknown';
|
||||
$line = $v->location['line'] ?? 1;
|
||||
|
||||
// Map our severity to GitLab code quality severities:
|
||||
// info, minor, major, critical, blocker
|
||||
$gitLabSeverity = 'major';
|
||||
if ($v->severity === Severity::CRITICAL) {
|
||||
$gitLabSeverity = 'critical';
|
||||
} elseif ($v->severity === Severity::WARNING) {
|
||||
$gitLabSeverity = 'minor';
|
||||
}
|
||||
|
||||
$quality[] = [
|
||||
'description' => sprintf('[%s] %s', $v->ruleId, $v->message),
|
||||
'fingerprint' => md5($v->ruleId . ':' . $file . ':' . $line . ':' . $v->htmlSnippet),
|
||||
'severity' => $gitLabSeverity,
|
||||
'location' => [
|
||||
'path' => $file,
|
||||
'lines' => [
|
||||
'begin' => $line
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$io->write((string)json_encode($quality, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reporting;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
class GithubFormatter implements FormatterInterface
|
||||
{
|
||||
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
|
||||
{
|
||||
if (empty($violations)) {
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($violations as $v) {
|
||||
$file = $v->location['file'] ?? '';
|
||||
$line = $v->location['line'] ?? 1;
|
||||
|
||||
// Normalize path separators to forward slashes for Github Actions compatibility
|
||||
$file = str_replace('\\', '/', $file);
|
||||
|
||||
$type = ($v->severity->value === 'error') ? 'error' : 'warning';
|
||||
|
||||
$io->writeln(sprintf(
|
||||
"::%s file=%s,line=%d::[%s] %s",
|
||||
$type,
|
||||
$file,
|
||||
$line,
|
||||
$v->ruleId,
|
||||
$v->message
|
||||
));
|
||||
}
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reporting;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
class MarkdownFormatter implements FormatterInterface
|
||||
{
|
||||
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
|
||||
{
|
||||
$md = [];
|
||||
$md[] = "# Accessibility Scan Report";
|
||||
$md[] = "";
|
||||
$md[] = sprintf("- **Total Violations:** %d", count($violations));
|
||||
$md[] = sprintf("- **Baselined Violations:** %d", $baselinedCount);
|
||||
$md[] = "";
|
||||
$md[] = "## Detailed Violations";
|
||||
$md[] = "";
|
||||
|
||||
if (empty($violations)) {
|
||||
$md[] = "*No violations found!* 🎉";
|
||||
} else {
|
||||
foreach ($violations as $index => $v) {
|
||||
$file = $v->location['file'] ?? 'unknown';
|
||||
$line = $v->location['line'] ?? 0;
|
||||
$md[] = sprintf("### %d. %s [%s - %s]", $index + 1, $v->ruleId, strtoupper($v->severity->value), $v->standard->value);
|
||||
$md[] = sprintf("- **File:** `%s` on line %d", $file, $line);
|
||||
$md[] = sprintf("- **Message:** %s", $v->message);
|
||||
if ($v->fixSuggestion) {
|
||||
$md[] = sprintf("- **Fix Suggestion:** %s", $v->fixSuggestion);
|
||||
}
|
||||
$md[] = "- **Snippet:**";
|
||||
$md[] = " ```html";
|
||||
$md[] = " " . trim($v->htmlSnippet);
|
||||
$md[] = " ```";
|
||||
$md[] = "";
|
||||
}
|
||||
}
|
||||
|
||||
$io->write(implode("\n", $md));
|
||||
|
||||
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reporting;
|
||||
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
|
||||
class PdfReportGenerator
|
||||
{
|
||||
/**
|
||||
* Generate a PDF Accessibility Certificate & Audit Report.
|
||||
*
|
||||
* @param array<string, mixed> $scanResult Scan result metrics and violations array
|
||||
* @param string $targetUrl Target URL or file name audited
|
||||
* @return string Raw PDF binary data
|
||||
*/
|
||||
public static function generatePdf(array $scanResult, string $targetUrl): string
|
||||
{
|
||||
if (!class_exists(Dompdf::class)) {
|
||||
throw new \RuntimeException('To generate PDF certificates, please install dompdf via: composer require dompdf/dompdf');
|
||||
}
|
||||
|
||||
date_default_timezone_set('Europe/Istanbul');
|
||||
|
||||
/** @var int $score */
|
||||
$score = is_numeric($scanResult['score'] ?? null) ? intval($scanResult['score']) : 0;
|
||||
|
||||
/** @var array<int, array<string, mixed>> $violations */
|
||||
$violations = is_array($scanResult['violations'] ?? null) ? $scanResult['violations'] : [];
|
||||
$violationsCount = count($violations);
|
||||
$dateStr = date('d.m.Y H:i');
|
||||
|
||||
$scoreColor = $score >= 80 ? '#10b981' : ($score >= 50 ? '#f59e0b' : '#ef4444');
|
||||
$statusText = $score >= 80 ? 'WCAG 2.1 AA Uyumlu (Pass)' : ($score >= 50 ? 'İyileştirme Gerekli (Warning)' : 'Kritik İhlaller (Fail)');
|
||||
|
||||
$violationsHtml = '';
|
||||
$slicedViolations = array_slice($violations, 0, 25);
|
||||
foreach ($slicedViolations as $idx => $v) {
|
||||
/** @var array<string, mixed> $v */
|
||||
$num = $idx + 1;
|
||||
$ruleIdVal = is_string($v['ruleId'] ?? null) ? $v['ruleId'] : '';
|
||||
$descVal = is_string($v['description'] ?? null) ? $v['description'] : '';
|
||||
$snippetVal = is_string($v['htmlSnippet'] ?? null) ? $v['htmlSnippet'] : '';
|
||||
$suggestionVal = is_string($v['suggestion'] ?? null) ? $v['suggestion'] : '';
|
||||
|
||||
$ruleId = htmlspecialchars($ruleIdVal);
|
||||
$desc = htmlspecialchars($descVal);
|
||||
$snippet = htmlspecialchars($snippetVal);
|
||||
$suggestion = htmlspecialchars($suggestionVal);
|
||||
|
||||
$violationsHtml .= "
|
||||
<div style='margin-bottom:12px; padding:10px; border-left:4px solid #ef4444; background:#f8fafc;'>
|
||||
<div style='font-weight:bold; color:#0f172a; font-size:12px;'>#{$num} [{$ruleId}] {$desc}</div>
|
||||
<div style='font-family:monospace; font-size:10px; color:#e11d48; margin:4px 0; background:#f1f5f9; padding:4px;'>{$snippet}</div>
|
||||
<div style='font-size:11px; color:#475569;'><b>Öneri:</b> {$suggestion}</div>
|
||||
</div>";
|
||||
}
|
||||
|
||||
$html = "
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv='Content-Type' content='text/html; charset=utf-8'/>
|
||||
<title>WCAG 2.1 Erişilebilirlik Sertifikası ve Raporu</title>
|
||||
<style>
|
||||
* { font-family: 'DejaVu Sans', sans-serif; }
|
||||
body { font-family: 'DejaVu Sans', sans-serif; color: #1e293b; line-height: 1.5; font-size: 11px; margin: 0; padding: 20px; }
|
||||
.header { text-align: center; border-bottom: 2px solid #38bdf8; padding-bottom: 15px; margin-bottom: 20px; }
|
||||
.title { font-size: 18px; font-weight: bold; color: #0f172a; }
|
||||
.subtitle { font-size: 12px; color: #64748b; margin-top: 4px; }
|
||||
.badge { display: inline-block; padding: 6px 16px; border-radius: 12px; color: #fff; font-weight: bold; font-size: 13px; background: {$scoreColor}; }
|
||||
.grid { margin-bottom: 20px; }
|
||||
.card { background: #f8fafc; border: 1px solid #e2e8f0; padding: 12px; border-radius: 6px; margin-bottom: 10px; }
|
||||
.footer { text-align: center; border-top: 1px solid #e2e8f0; padding-top: 15px; font-size: 10px; color: #94a3b8; margin-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class='header'>
|
||||
<div class='title'>♿ YakNet Accessibility Console</div>
|
||||
<div class='subtitle'>WCAG 2.1 AA Erişilebilirlik Uyumluluk Raporu</div>
|
||||
</div>
|
||||
|
||||
<div class='card' style='text-align:center;'>
|
||||
<div style='font-size:11px; color:#64748b; text-transform:uppercase;'>Erişilebilirlik Sağlık Skoru</div>
|
||||
<div style='font-size:32px; font-weight:bold; color:{$scoreColor}; margin:6px 0;'>{$score} / 100</div>
|
||||
<div class='badge'>{$statusText}</div>
|
||||
</div>
|
||||
|
||||
<div class='card'>
|
||||
<div><b>Hedef URL / Dizin:</b> " . htmlspecialchars($targetUrl) . "</div>
|
||||
<div><b>Tarama Tarihi:</b> {$dateStr}</div>
|
||||
<div><b>Tespit Edilen İhlal Sayısı:</b> {$violationsCount} Hata</div>
|
||||
<div><b>Raporu Üreten Motor:</b> YakNet AI & Accessibility Engine v3.2</div>
|
||||
</div>
|
||||
|
||||
<h3 style='color:#0f172a; border-bottom:1px solid #cbd5e1; padding-bottom:6px;'>📋 Erişilebilirlik İhlalleri ve Çözüm Detayları</h3>
|
||||
{$violationsHtml}
|
||||
|
||||
<div class='footer'>
|
||||
© {$dateStr} YakNet Bilişim — Türkiye'nin Yerli & Milli Erişilebilirlik Platformu (yak.net.tr)
|
||||
</div>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
$options = new Options();
|
||||
$options->set('isHtml5ParserEnabled', true);
|
||||
$options->set('isRemoteEnabled', true);
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->render();
|
||||
|
||||
return strval($dompdf->output());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Reporting;
|
||||
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
class XmlFormatter implements FormatterInterface
|
||||
{
|
||||
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
|
||||
{
|
||||
$dom = new \DOMDocument('1.0', 'utf-8');
|
||||
$dom->formatOutput = true;
|
||||
|
||||
$root = $dom->createElement('report');
|
||||
$root->setAttribute('totalViolations', (string)count($violations));
|
||||
$root->setAttribute('baselinedViolations', (string)$baselinedCount);
|
||||
$dom->appendChild($root);
|
||||
|
||||
$violationsNode = $dom->createElement('violations');
|
||||
$root->appendChild($violationsNode);
|
||||
|
||||
foreach ($violations as $v) {
|
||||
$node = $dom->createElement('violation');
|
||||
$node->setAttribute('ruleId', $v->ruleId);
|
||||
$node->setAttribute('severity', $v->severity->value);
|
||||
$node->setAttribute('standard', $v->standard->value);
|
||||
|
||||
$msg = $dom->createElement('message', htmlspecialchars($v->message));
|
||||
$node->appendChild($msg);
|
||||
|
||||
$file = $v->location['file'] ?? 'unknown';
|
||||
$line = $v->location['line'] ?? 0;
|
||||
|
||||
$loc = $dom->createElement('location');
|
||||
$loc->setAttribute('file', $file);
|
||||
$loc->setAttribute('line', (string)$line);
|
||||
$node->appendChild($loc);
|
||||
|
||||
$snippet = $dom->createElement('htmlSnippet');
|
||||
$snippet->appendChild($dom->createCDATASection($v->htmlSnippet));
|
||||
$node->appendChild($snippet);
|
||||
|
||||
if ($v->fixSuggestion) {
|
||||
$sug = $dom->createElement('suggestion', htmlspecialchars($v->fixSuggestion));
|
||||
$node->appendChild($sug);
|
||||
}
|
||||
|
||||
$violationsNode->appendChild($node);
|
||||
}
|
||||
|
||||
$io->write($dom->saveXML() ?: '');
|
||||
|
||||
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -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,76 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\AbstractRule;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AccesskeyDuplicate extends AbstractRule
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'WCAG_2_1_1_ACCESSKEY_DUPLICATE';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'accesskey attribute values must be unique across the document.';
|
||||
}
|
||||
|
||||
public function getStandard(): WCAGStandard
|
||||
{
|
||||
return WCAGStandard::A;
|
||||
}
|
||||
|
||||
public function getSeverity(): Severity
|
||||
{
|
||||
return Severity::ERROR;
|
||||
}
|
||||
|
||||
public function getLevel(): int
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
public function check(\DOMDocument $doc): array
|
||||
{
|
||||
$violations = [];
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$elements = $xpath->query('//*[@accesskey]');
|
||||
|
||||
if ($elements === false || $elements->length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
foreach ($elements as $el) {
|
||||
if (!$el instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
$val = trim($el->getAttribute('accesskey'));
|
||||
if ($val === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($keys[$val])) {
|
||||
$keys[$val] = [];
|
||||
}
|
||||
$keys[$val][] = $el;
|
||||
}
|
||||
|
||||
foreach ($keys as $key => $elementsWithKey) {
|
||||
if (count($elementsWithKey) > 1) {
|
||||
foreach ($elementsWithKey as $el) {
|
||||
$violations[] = $this->createViolation(
|
||||
"Duplicate accesskey=\"{$key}\" found. Access keys must be unique across the document.",
|
||||
$el,
|
||||
"Assign a unique accesskey to each interactive element, or avoid using accesskey entirely."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
}
|
||||
@@ -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 AnchorMissingHref extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_4_1_2_ANCHOR_HREF'; }
|
||||
public function getDescription(): string { return 'Anchor elements (<a>) acting as links must have a valid href attribute.'; }
|
||||
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) !== 'a') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($element->hasAttribute('name') || $element->hasAttribute('role')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$element->hasAttribute('href') || trim($element->getAttribute('href')) === '') {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Add a valid href attribute to the <a> element, or use a <button> element if it triggers an action.'
|
||||
);
|
||||
}
|
||||
|
||||
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 AppletBanned extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_4_1_2_APPLET_BANNED'; }
|
||||
public function getDescription(): string { return 'The <applet> element is obsolete and non-accessible. Use modern HTML5 elements instead.'; }
|
||||
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) === 'applet') {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Remove the <applet> element and replace it with modern HTML5 or accessible media embeddings.'
|
||||
);
|
||||
}
|
||||
|
||||
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 AreaAlt extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_1_1_1_AreaAlt'; }
|
||||
public function getDescription(): string { return 'Checks that <area> elements in image maps have non-empty alt attributes.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 2; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (strtolower($element->tagName) !== 'area') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$alt = $element->getAttribute('alt');
|
||||
$ariaLabel = $element->getAttribute('aria-label');
|
||||
|
||||
if (empty(trim($alt)) && empty(trim($ariaLabel))) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
'<area> öğesinde alt veya aria-label eksik.',
|
||||
'Provide a non-empty alt or aria-label attribute for the <area> element.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaBrailleEquivalent extends AbstractRule
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'WCAG_4_1_2_ARIA_BRAILLE';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Elements with aria-braillelabel or aria-brailleroledescription must provide a standard accessible equivalent.';
|
||||
}
|
||||
|
||||
public function getStandard(): WCAGStandard
|
||||
{
|
||||
return WCAGStandard::A;
|
||||
}
|
||||
|
||||
public function getSeverity(): Severity
|
||||
{
|
||||
return Severity::ERROR;
|
||||
}
|
||||
|
||||
public function getLevel(): int
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if ($element->hasAttribute('aria-braillelabel')) {
|
||||
$hasName = false;
|
||||
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
|
||||
$hasName = true;
|
||||
} elseif ($element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '') {
|
||||
$hasName = true;
|
||||
} elseif ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
|
||||
$hasName = true;
|
||||
} elseif ($element->hasAttribute('alt') && trim($element->getAttribute('alt')) !== '') {
|
||||
$hasName = true;
|
||||
} elseif (trim($element->textContent) !== '') {
|
||||
$hasName = true;
|
||||
}
|
||||
|
||||
if (!$hasName) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Element has aria-braillelabel but is missing a standard accessible name (aria-label, aria-labelledby, or text content).",
|
||||
"Add a standard aria-label or aria-labelledby in addition to the aria-braillelabel."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($element->hasAttribute('aria-brailleroledescription')) {
|
||||
if (!$element->hasAttribute('aria-roledescription') || trim($element->getAttribute('aria-roledescription')) === '') {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Element has aria-brailleroledescription but is missing the standard aria-roledescription attribute.",
|
||||
"Add an aria-roledescription attribute to accompany aria-brailleroledescription."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaCurrentValid extends AbstractRule
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private static array $validValues = [
|
||||
'page', 'step', 'location', 'date', 'time', 'true', 'false'
|
||||
];
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'WCAG_4_1_2_ARIA_CURRENT';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'The aria-current attribute must have a valid token value.';
|
||||
}
|
||||
|
||||
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('aria-current')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = strtolower(trim($element->getAttribute('aria-current')));
|
||||
|
||||
if (!in_array($value, self::$validValues, true)) {
|
||||
$validList = implode(', ', self::$validValues);
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Invalid aria-current value \"{$value}\". Valid values are: {$validList}.",
|
||||
"Set aria-current to one of the valid token values: page, step, location, date, time, true, or false."
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaDisabledFocusable extends AbstractRule
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'WCAG_4_1_2_ARIA_DISABLED';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Disabled elements should not have conflicting focusable tabindex settings.';
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
// 1. Native disabled element with explicit positive/zero tabindex
|
||||
if ($element->hasAttribute('disabled')) {
|
||||
if ($element->hasAttribute('tabindex')) {
|
||||
$tabindex = (int)$element->getAttribute('tabindex');
|
||||
if ($tabindex >= 0) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Element has both the 'disabled' attribute and a focusable tabindex=\"{$tabindex}\".",
|
||||
"Remove the tabindex or set tabindex=\"-1\" to avoid keyboard navigation confusion on disabled elements."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Elements with aria-disabled="true" on non-native elements
|
||||
if (strtolower(trim($element->getAttribute('aria-disabled'))) === 'true') {
|
||||
$tagName = strtolower($element->tagName);
|
||||
if (in_array($tagName, ['a', 'button'], true) && $element->hasAttribute('tabindex')) {
|
||||
$tabindex = (int)$element->getAttribute('tabindex');
|
||||
if ($tabindex > 0) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Element has aria-disabled=\"true\" with positive tabindex=\"{$tabindex}\".",
|
||||
"Avoid positive tabindex on disabled interactive elements."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaErrorMessage extends AbstractRule
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'WCAG_3_3_1_ARIA_ERRORMESSAGE';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'aria-errormessage must reference an existing element ID and the element must have aria-invalid set.';
|
||||
}
|
||||
|
||||
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('aria-errormessage')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$targetId = trim($element->getAttribute('aria-errormessage'));
|
||||
if ($targetId === '') {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Attribute aria-errormessage is empty.",
|
||||
"Provide the ID of an element containing the error message text."
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Verify that target element exists in document
|
||||
$doc = $element->ownerDocument;
|
||||
if ($doc !== null) {
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$targets = $xpath->query('//*[@id="' . $targetId . '"]');
|
||||
if ($targets === false || $targets->length === 0) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Attribute aria-errormessage=\"{$targetId}\" references an ID that does not exist in the document.",
|
||||
"Ensure the referenced error message element ID exists in the HTML document."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if element has aria-invalid set to a truthy value (true, grammar, spelling)
|
||||
$ariaInvalid = strtolower(trim($element->getAttribute('aria-invalid')));
|
||||
if ($ariaInvalid === '' || $ariaInvalid === 'false') {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Element uses aria-errormessage=\"{$targetId}\" but does not have aria-invalid set to \"true\".",
|
||||
"Add aria-invalid=\"true\" to the control when an error message is active."
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaExpandedState extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_4_1_2_ARIA_EXPANDED'; }
|
||||
public function getDescription(): string { return 'Collapsible toggle buttons (accordion, dropdown, collapse) must have an aria-expanded attribute.'; }
|
||||
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
|
||||
{
|
||||
$tagName = strtolower($element->tagName);
|
||||
$role = strtolower($element->getAttribute('role'));
|
||||
|
||||
if ($tagName !== 'button' && $tagName !== 'a' && $role !== 'button') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$class = strtolower($element->getAttribute('class'));
|
||||
$dataBsToggle = strtolower($element->getAttribute('data-bs-toggle'));
|
||||
$dataToggle = strtolower($element->getAttribute('data-toggle'));
|
||||
|
||||
$isCollapsible = str_contains($class, 'dropdown-toggle') ||
|
||||
str_contains($class, 'accordion-button') ||
|
||||
str_contains($class, 'collapse-toggle') ||
|
||||
in_array($dataBsToggle, ['collapse', 'dropdown', 'offcanvas'], true) ||
|
||||
in_array($dataToggle, ['collapse', 'dropdown'], true);
|
||||
|
||||
if (!$isCollapsible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($element->hasAttribute('aria-expanded')) {
|
||||
$val = strtolower($element->getAttribute('aria-expanded'));
|
||||
if (in_array($val, ['true', 'false'], true)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Add aria-expanded="false" (or "true") to the toggle element so screen readers announce expanded/collapsed status.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 AriaHiddenBody extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_4_1_2_ARIA_HIDDEN_BODY'; }
|
||||
public function getDescription(): string { return 'The <body> or <html> element must not have aria-hidden="true".'; }
|
||||
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
|
||||
{
|
||||
$tagName = strtolower($element->tagName);
|
||||
if ($tagName !== 'body' && $tagName !== 'html') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strtolower($element->getAttribute('aria-hidden')) === 'true') {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Remove aria-hidden="true" from the <body>/<html> tag to prevent hiding the entire document from screen readers.'
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
@@ -56,6 +57,18 @@ class AriaHiddenFocusable extends AbstractRule
|
||||
return $type !== 'hidden' && !$element->hasAttribute('disabled');
|
||||
}
|
||||
|
||||
if ($element->hasAttribute('contenteditable') && strtolower($element->getAttribute('contenteditable')) === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($tagName, ['details', 'summary'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (in_array($tagName, ['audio', 'video'], true) && $element->hasAttribute('controls')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($tagName === 'iframe') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaLabelMisuseOnGeneric extends AbstractRule
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'WCAG_4_1_2_ARIA_LABEL_GENERIC';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'aria-label or aria-labelledby should not be used on generic <div> or <span> elements without an ARIA role.';
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$tag = strtolower($element->tagName);
|
||||
if ($tag !== 'div' && $tag !== 'span') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hasRole = $element->hasAttribute('role') && trim($element->getAttribute('role')) !== '';
|
||||
if ($hasRole) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hasAriaLabel = $element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '';
|
||||
$hasAriaLabelledby = $element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '';
|
||||
|
||||
if ($hasAriaLabel || $hasAriaLabelledby) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Generic <{$tag}> uses aria-label/aria-labelledby without a semantic ARIA role (assistive technologies ignore names on generic elements).",
|
||||
"Add an appropriate ARIA role (e.g. role=\"region\", role=\"group\", role=\"navigation\") or use a semantic HTML5 element."
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaLabelledbyExists extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_4_1_2_ARIA_LABELLEDBY'; }
|
||||
public function getDescription(): string { return 'aria-labelledby and aria-describedby attributes must refer to existing element IDs.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 3; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
$attrs = ['aria-labelledby', 'aria-describedby'];
|
||||
foreach ($attrs as $attr) {
|
||||
if ($element->hasAttribute($attr)) {
|
||||
$ids = preg_split('/\s+/', trim($element->getAttribute($attr)));
|
||||
if ($ids === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$doc = $element->ownerDocument;
|
||||
if (!$doc) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $doc->getElementById($id);
|
||||
if (!$target) {
|
||||
// XPath fallback in case DOMDocument getElementById fails (which is common without DTD/XML schemas)
|
||||
$xpath = new \DOMXPath($doc);
|
||||
$query = $xpath->query("//*[@id='" . addslashes($id) . "']");
|
||||
if ($query === false || $query->length === 0) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"The attribute $attr refers to a non-existent ID: \"$id\".",
|
||||
"Ensure the element referenced by ID \"$id\" exists in the document, or correct the ID spelling."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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 AriaLiveStatus extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_4_1_3_STATUS_MESSAGES'; }
|
||||
public function getDescription(): string { return 'Dynamic status containers and toast messages must use aria-live or implicit live roles.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
|
||||
public function getSeverity(): Severity { return Severity::WARNING; }
|
||||
public function getLevel(): int { return 2; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
$class = strtolower($element->getAttribute('class'));
|
||||
$role = strtolower($element->getAttribute('role'));
|
||||
|
||||
$statusClasses = ['toast', 'status-message', 'loading-message', 'alert-message', 'live-region'];
|
||||
$isStatusContainer = false;
|
||||
|
||||
foreach ($statusClasses as $statusClass) {
|
||||
if (str_contains($class, $statusClass)) {
|
||||
$isStatusContainer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isStatusContainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Implicit live roles in ARIA include status, alert, log
|
||||
if (in_array($role, ['status', 'alert', 'log'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($element->hasAttribute('aria-live')) {
|
||||
$liveVal = strtolower($element->getAttribute('aria-live'));
|
||||
if (in_array($liveVal, ['polite', 'assertive'], true)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Add aria-live="polite" (or role="status"/role="alert") so screen readers dynamically announce updates to users.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaPressedToggle extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_4_1_2_TOGGLE_BUTTON_STATE'; }
|
||||
public function getDescription(): string { return 'Toggle buttons must use aria-pressed="true" or "false" to communicate state changes.'; }
|
||||
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
|
||||
{
|
||||
$tagName = strtolower($element->tagName);
|
||||
$role = strtolower($element->getAttribute('role'));
|
||||
|
||||
if ($tagName !== 'button' && $role !== 'button') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$class = strtolower($element->getAttribute('class'));
|
||||
$dataToggle = strtolower($element->getAttribute('data-toggle'));
|
||||
|
||||
$isToggleButton = str_contains($class, 'btn-toggle') || str_contains($class, 'toggle-btn') || $dataToggle === 'button';
|
||||
|
||||
if (!$isToggleButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($element->hasAttribute('aria-pressed')) {
|
||||
$val = strtolower($element->getAttribute('aria-pressed'));
|
||||
if (in_array($val, ['true', 'false', 'mixed'], true)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
$this->getDescription(),
|
||||
'Add aria-pressed="true" or aria-pressed="false" to indicate the toggle state to assistive technology.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaProhibitedAttr extends AbstractRule
|
||||
{
|
||||
/** @var array<string, array<int, string>> */
|
||||
private static array $prohibitedAttributesByRole = [
|
||||
'presentation' => ['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-details'],
|
||||
'none' => ['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-details'],
|
||||
'generic' => ['aria-label', 'aria-labelledby', 'aria-roledescription'],
|
||||
'caption' => ['aria-label', 'aria-labelledby']
|
||||
];
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'WCAG_4_1_2_ARIA_PROHIBITED_ATTR';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Prohibited ARIA attributes must not be used on roles that do not support naming (e.g. presentation, none).';
|
||||
}
|
||||
|
||||
public function getStandard(): WCAGStandard
|
||||
{
|
||||
return WCAGStandard::A;
|
||||
}
|
||||
|
||||
public function getSeverity(): Severity
|
||||
{
|
||||
return Severity::ERROR;
|
||||
}
|
||||
|
||||
public function getLevel(): int
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (!$element->hasAttribute('role')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
|
||||
|
||||
foreach ($roles as $role) {
|
||||
if (isset(self::$prohibitedAttributesByRole[$role])) {
|
||||
$prohibited = self::$prohibitedAttributesByRole[$role];
|
||||
$foundProhibited = [];
|
||||
|
||||
foreach ($prohibited as $attr) {
|
||||
if ($element->hasAttribute($attr)) {
|
||||
$foundProhibited[] = $attr;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($foundProhibited)) {
|
||||
$attrList = implode(', ', $foundProhibited);
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Element with role=\"{$role}\" contains prohibited ARIA attribute(s): {$attrList}.",
|
||||
"Remove the prohibited attribute(s) or change the element's ARIA role to one that supports naming."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaRedundantRole extends AbstractRule
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private static array $redundantMap = [
|
||||
'nav' => 'navigation',
|
||||
'main' => 'main',
|
||||
'button' => 'button',
|
||||
'article' => 'article',
|
||||
'aside' => 'complementary',
|
||||
'dialog' => 'dialog',
|
||||
'figure' => 'figure',
|
||||
];
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'WCAG_4_1_2_ARIA_REDUNDANT_ROLE';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Avoid specifying redundant ARIA roles on HTML elements with identical implicit semantics.';
|
||||
}
|
||||
|
||||
public function getStandard(): WCAGStandard
|
||||
{
|
||||
return WCAGStandard::A;
|
||||
}
|
||||
|
||||
public function getSeverity(): Severity
|
||||
{
|
||||
return Severity::WARNING;
|
||||
}
|
||||
|
||||
public function getLevel(): int
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (!$element->hasAttribute('role')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tagName = strtolower($element->tagName);
|
||||
$role = strtolower(trim($element->getAttribute('role')));
|
||||
|
||||
if (isset(self::$redundantMap[$tagName]) && self::$redundantMap[$tagName] === $role) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Redundant ARIA role=\"{$role}\" on <{$tagName}>. The element natively possesses this implicit semantic role.",
|
||||
"Remove the redundant role=\"{$role}\" attribute (First Rule of ARIA)."
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaRequiredAttr extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_4_1_2_ARIA_REQUIRED_ATTR'; }
|
||||
public function getDescription(): string { return 'Elements with ARIA roles must have all required ARIA attributes for that role.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 3; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (!$element->hasAttribute('role')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
|
||||
$requiredMap = [
|
||||
'slider' => ['aria-valuenow', 'aria-valuemin', 'aria-valuemax'],
|
||||
'checkbox' => ['aria-checked'],
|
||||
'combobox' => ['aria-expanded'],
|
||||
'scrollbar' => ['aria-controls', 'aria-valuenow', 'aria-valuemax', 'aria-valuemin', 'aria-orientation'],
|
||||
'spinbutton' => ['aria-valuenow', 'aria-valuemax', 'aria-valuemin'],
|
||||
'switch' => ['aria-checked'],
|
||||
'meter' => ['aria-valuenow'],
|
||||
'separator' => ['aria-valuenow'] // strictly when focusable, but we check if the role is present
|
||||
];
|
||||
|
||||
foreach ($roles as $role) {
|
||||
if (isset($requiredMap[$role])) {
|
||||
$missingAttrs = [];
|
||||
foreach ($requiredMap[$role] as $reqAttr) {
|
||||
if (!$element->hasAttribute($reqAttr)) {
|
||||
$missingAttrs[] = $reqAttr;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missingAttrs)) {
|
||||
// Specific case: separator without tabindex is not focusable and doesn't require aria-valuenow in some contexts
|
||||
if ($role === 'separator' && !$element->hasAttribute('tabindex')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missingStr = implode(', ', $missingAttrs);
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"ARIA role '{$role}' eksik zorunlu niteliklere sahip: {$missingStr}.",
|
||||
"Belirtilen ARIA rolü için zorunlu olan {$missingStr} niteliklerini ekleyin."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaRequiredChildren extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_CHILDREN'; }
|
||||
public function getDescription(): string { return 'Elements with certain ARIA roles must contain children with specific roles.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 4; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (!$element->hasAttribute('role')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
|
||||
$requiredChildrenMap = [
|
||||
'list' => ['listitem'],
|
||||
'menu' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
|
||||
'menubar' => ['menuitem', 'menuitemcheckbox', 'menuitemradio'],
|
||||
'tablist' => ['tab'],
|
||||
'tree' => ['treeitem'],
|
||||
'grid' => ['row', 'rowgroup'],
|
||||
'table' => ['row', 'rowgroup'],
|
||||
'radiogroup' => ['radio'],
|
||||
'listbox' => ['option'],
|
||||
'rowgroup' => ['row'],
|
||||
'row' => ['cell', 'gridcell', 'columnheader', 'rowheader']
|
||||
];
|
||||
|
||||
foreach ($roles as $role) {
|
||||
if (isset($requiredChildrenMap[$role])) {
|
||||
$hasRequiredChild = $this->hasChildWithRole($element, $requiredChildrenMap[$role]);
|
||||
|
||||
if (!$hasRequiredChild) {
|
||||
// Check if children are dynamically generated with templates
|
||||
$doc = $element->ownerDocument;
|
||||
if ($doc !== null) {
|
||||
$html = $doc->saveHTML($element);
|
||||
if ($html !== false && preg_match('/(\{\{|\{%|<\?)/', $html)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$expectedStr = implode(' veya ', $requiredChildrenMap[$role]);
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"ARIA rolü '{$role}' olan eleman gerekli alt rollere sahip değil.",
|
||||
"Bu elemanın içine rolü {$expectedStr} olan çocuk elemanlar ekleyin."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $requiredRoles
|
||||
*/
|
||||
private function hasChildWithRole(DOMElement $element, array $requiredRoles): bool
|
||||
{
|
||||
$doc = $element->ownerDocument;
|
||||
if ($doc === null) {
|
||||
return false;
|
||||
}
|
||||
$xpath = new \DOMXPath($doc);
|
||||
foreach ($requiredRoles as $reqRole) {
|
||||
$query = ".//*[@role='{$reqRole}' or contains(@role, ' {$reqRole}') or contains(@role, '{$reqRole} ')]";
|
||||
$nodes = $xpath->query($query, $element);
|
||||
if ($nodes !== false && $nodes->length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaRequiredParent extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_1_3_1_ARIA_REQUIRED_PARENT'; }
|
||||
public function getDescription(): string { return 'Elements with certain ARIA roles must be contained by parents with specific roles.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 4; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
if (!$element->hasAttribute('role')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
|
||||
$requiredParentMap = [
|
||||
'listitem' => ['list', 'group'],
|
||||
'menuitem' => ['menu', 'menubar', 'group'],
|
||||
'menuitemcheckbox' => ['menu', 'menubar'],
|
||||
'menuitemradio' => ['menu', 'menubar'],
|
||||
'tab' => ['tablist'],
|
||||
'treeitem' => ['tree', 'group'],
|
||||
'cell' => ['row'],
|
||||
'gridcell' => ['row'],
|
||||
'columnheader' => ['row'],
|
||||
'rowheader' => ['row'],
|
||||
'option' => ['listbox', 'group'],
|
||||
'row' => ['grid', 'table', 'treegrid', 'rowgroup']
|
||||
];
|
||||
|
||||
foreach ($roles as $role) {
|
||||
if (isset($requiredParentMap[$role])) {
|
||||
$hasRequiredParent = $this->hasParentWithRole($element, $requiredParentMap[$role]);
|
||||
|
||||
if (!$hasRequiredParent) {
|
||||
$expectedStr = implode(' veya ', $requiredParentMap[$role]);
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"ARIA rolü '{$role}' olan eleman gerekli üst eleman rollerinden hiçbirine sahip değil.",
|
||||
"Bu elemanı rolü {$expectedStr} olan bir üst elemanın içine yerleştirin."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $requiredRoles
|
||||
*/
|
||||
private function hasParentWithRole(DOMElement $element, array $requiredRoles): bool
|
||||
{
|
||||
$parent = $element->parentNode;
|
||||
while ($parent !== null && $parent instanceof DOMElement) {
|
||||
if ($parent->hasAttribute('role')) {
|
||||
$parentRoles = explode(' ', strtolower(trim($parent->getAttribute('role'))));
|
||||
foreach ($parentRoles as $pRole) {
|
||||
if (in_array($pRole, $requiredRoles)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// For native HTML semantics fallback (e.g. <ul> is a list)
|
||||
$tagName = strtolower($parent->tagName);
|
||||
$nativeRole = $this->getNativeRole($tagName);
|
||||
if ($nativeRole && in_array($nativeRole, $requiredRoles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$parent = $parent->parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getNativeRole(string $tagName): ?string
|
||||
{
|
||||
$map = [
|
||||
'ul' => 'list',
|
||||
'ol' => 'list',
|
||||
'dl' => 'list',
|
||||
'table' => 'table',
|
||||
'tr' => 'row',
|
||||
'tbody' => 'rowgroup',
|
||||
'thead' => 'rowgroup',
|
||||
'tfoot' => 'rowgroup',
|
||||
'select' => 'listbox',
|
||||
'optgroup' => 'group'
|
||||
];
|
||||
return $map[$tagName] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaRoledescription extends AbstractRule
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'WCAG_4_1_2_ARIA_ROLEDESCRIPTION';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'aria-roledescription must only be used on elements with a non-generic role and must not be empty.';
|
||||
}
|
||||
|
||||
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('aria-roledescription')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$desc = trim($element->getAttribute('aria-roledescription'));
|
||||
if ($desc === '') {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Attribute aria-roledescription is empty or contains only whitespace.",
|
||||
"Provide a meaningful description or remove the aria-roledescription attribute."
|
||||
);
|
||||
}
|
||||
|
||||
$role = strtolower(trim($element->getAttribute('role')));
|
||||
if ($role === 'presentation' || $role === 'none' || $role === 'generic') {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"aria-roledescription must not be used on elements with role=\"{$role}\".",
|
||||
"Remove aria-roledescription from presentation/generic elements, or provide an appropriate semantic ARIA role."
|
||||
);
|
||||
}
|
||||
|
||||
$tagName = strtolower($element->tagName);
|
||||
if ($role === '' && in_array($tagName, ['div', 'span'], true)) {
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"aria-roledescription is used on a generic <{$tagName}> element without an explicit ARIA role.",
|
||||
"Add an explicit ARIA role (e.g. role=\"region\", role=\"button\") or use a semantic HTML element."
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Rules;
|
||||
|
||||
use DOMElement;
|
||||
use YakNet\AccessibilityConsole\Core\Severity;
|
||||
use YakNet\AccessibilityConsole\Core\Violation;
|
||||
use YakNet\AccessibilityConsole\Core\WCAGStandard;
|
||||
|
||||
class AriaValidAttr extends AbstractRule
|
||||
{
|
||||
public function getId(): string { return 'WCAG_4_1_2_ARIA_VALID_ATTR'; }
|
||||
public function getDescription(): string { return 'ARIA attributes must be valid names.'; }
|
||||
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
|
||||
public function getSeverity(): Severity { return Severity::ERROR; }
|
||||
public function getLevel(): int { return 3; }
|
||||
|
||||
public function check(DOMElement $element): ?Violation
|
||||
{
|
||||
$validAttributes = [
|
||||
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel',
|
||||
'aria-brailleroledescription', 'aria-busy', 'aria-checked', 'aria-colcount',
|
||||
'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-controls',
|
||||
'aria-current', 'aria-describedby', 'aria-description', 'aria-details',
|
||||
'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-expanded',
|
||||
'aria-flowto', 'aria-grabbed', 'aria-haspopup', 'aria-hidden', 'aria-invalid',
|
||||
'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live',
|
||||
'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation',
|
||||
'aria-owns', 'aria-placeholder', 'aria-posinset', 'aria-pressed', 'aria-readonly',
|
||||
'aria-relevant', 'aria-required', 'aria-roledescription', 'aria-rowcount',
|
||||
'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected',
|
||||
'aria-setsize', 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow',
|
||||
'aria-valuetext'
|
||||
];
|
||||
|
||||
$invalidAttrs = [];
|
||||
|
||||
foreach ($element->attributes as $attr) {
|
||||
/** @var \DOMAttr $attr */
|
||||
$name = strtolower($attr->nodeName);
|
||||
if (strpos($name, 'aria-') === 0) {
|
||||
if (!in_array($name, $validAttributes)) {
|
||||
$invalidAttrs[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($invalidAttrs)) {
|
||||
$invalidStr = implode(', ', $invalidAttrs);
|
||||
return $this->createViolation(
|
||||
$element,
|
||||
"Geçersiz ARIA nitelikleri bulundu: {$invalidStr}.",
|
||||
"Sadece geçerli ARIA niteliklerini kullanın. Geçersiz olanları kaldırın veya düzeltin."
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user