Compare commits

..
37 Commits
Author SHA1 Message Date
Enes Yakıştır ef8ccb9f75 feat(v3.3.0): enterprise presets, badges, notifications, rule controller, watch mode, offline healer, and WCAG 2.2 ruleset 2026-09-12 10:12:05 +03:00
Enes Yakıştır 2de1f34878 feat(v3.2.7): expand WCAG ruleset to 127 rules, add HTML syntax linter, single-pass rule engine, template preprocessor, and accessibility analytics 2026-08-16 22:00:30 +03:00
Enes Yakıştır 9a6784ad19 release: v3.2.6 - restore dompdf and phpspreadsheet to require section 2026-08-01 08:12:06 +03:00
Enes Yakıştır 399ba86ca2 Update engine version string to v3.2 in PdfReportGenerator (v3.2.5) 2026-07-31 17:07:27 +03:00
Enes Yakıştır b634e97921 Set Europe/Istanbul timezone in PdfReportGenerator and ensure stable dates (v3.2.4) 2026-07-31 17:03:22 +03:00
Enes Yakıştır a0a0a369b8 Fix Turkish UTF-8 font encoding in PdfReportGenerator for Dompdf (v3.2.3) 2026-07-31 16:59:49 +03:00
Enes Yakıştır 3fb927e211 release: v3.2.2 - Move optional export packages to suggest section to prevent root package conflict errors 2026-07-31 02:11:45 +03:00
Enes Yakıştır c33eca5adf release: v3.2.1 - Widen dependency constraints for universal project compatibility (PhpSpreadsheet, Dompdf, Symfony, Spatie Color) 2026-07-31 02:09:04 +03:00
Enes Yakıştır 73fbf485d2 release: v3.2.0 - Enterprise Edition with Live Web Dashboard, PDF/Excel Exporters, Framework Stubs & 110 WCAG Rules 2026-07-31 00:13:31 +03:00
Enes Yakıştır 58b1618d96 feat: add 15 new WCAG rules, dynamic ARIA checks and bump version to v3.1.4 2026-07-30 20:34:41 +03:00
Enes Yakıştır 164828f9f7 fix: resolve PHPStan level 9 errors and bump version to v3.1.3 2026-07-29 06:31:35 +03:00
Enes Yakıştır 6f4799dc38 fix: bump version to 3.1.2 and resolve Symfony 8 compatibility issue 2026-07-14 00:28:58 +03:00
Enes Yakıştır c79ddfcc50 chore: broaden sebastian/diff requirement for phpunit 12 compatibility 2026-07-13 11:50:38 +03:00
Enes Yakıştır 5292c2c865 release: v3.1.0 premium smart core engines and 14 new accessibility rules 2026-07-13 10:54:25 +03:00
Enes Yakıştır 7f3bf3cb7d feat: add 5 new WCAG accessibility rules and bump version to 3.0.5 2026-07-08 12:38:47 +03:00
Enes Yakıştır 8e6ea62ba7 chore: bump version to v3.0.4 and fix Symfony 8 compatibility 2026-07-07 13:00:44 +03:00
Enes Yakıştır c138cbca65 chore: remove BOM from composer.json 2026-07-07 12:59:20 +03:00
Enes Yakıştır 18aa4641d2 chore: bump version to v3.0.3 and update configurations 2026-07-07 12:47:34 +03:00
Enes Yakıştır c4745d6cb3 fix: resolve all WCAG 2.1 violations in demo/broken.html and bump version to 3.0.2
- Added lang attribute to html element (WCAG 3.1.1)
- Added descriptive page title (WCAG 2.4.2)
- Added alt attributes to all images (WCAG 1.1.1)
- Removed redundant alt text words (WCAG 1.1.1)
- Fixed empty link and button with discernible text (WCAG 2.4.4, 4.1.2)
- Removed positive tabindex value (WCAG 2.4.3)
- Eliminated duplicate id=main-content (WCAG 4.1.1)
- Fixed insufficient color contrast ratio (WCAG 1.4.3)
- Replaced invalid ARIA role cool-widget with region (WCAG 4.1.2)
- Added title attribute to iframe (WCAG 4.1.2)
- Replaced generic link texts with descriptive ones (WCAG 2.4.4)
- Added associated label for placeholder-only input (WCAG 3.3.2)
- Removed aria-hidden from focusable elements (WCAG 4.1.2)
- Added non-empty heading text (WCAG 2.4.6)
- Added video captions track element (WCAG 1.2.2)
- Added non-empty label text (WCAG 1.3.1)
- Added tabindex=0 to role=button div (WCAG 2.1.1)
2026-06-23 01:25:08 +03:00
Enes Yakıştır a2b984df04 fix: resolve autoloading in vendor execution and eliminate template false positives 2026-06-23 01:15:35 +03:00
Enes Yakıştır d5165f0be4 feat: Release v3.0.0 - PHPStan-style analysis, configuration, baseline, and advanced WCAG rules 2026-06-23 01:02:15 +03:00
Enes Yakıştır 84c6521195 fix: update Gemini model to gemini-3.1-flash-lite in GeminiFixer
bump version to 2.9.2
2026-06-07 10:49:30 +03:00
Enes Yakıştır 0134482c54 fix: resolve string/int type mismatches in HtmlDashboardReporter
- preg_replace() return value guarded with '?? ''' to eliminate string|null type
- location line number explicitly cast to (string) before concatenation
- PHPStan level 9 confirms zero errors after fix
2026-06-07 10:20:55 +03:00
Enes Yakıştır c9ffbdba08 feat: extend scan level range 1-9 and confirm PHPStan level 9 zero errors 2026-06-07 10:18:15 +03:00
Enes Yakıştır 2a97138fec feat: implement dynamic rule discovery and incremental level filtering 2026-06-06 13:48:10 +03:00
Enes Yakıştır c42ab714ff feat: add guzzle-powered recursive website crawling and masterminds/html5 integration 2026-06-06 13:22:35 +03:00
Enes Yakıştır b805741ef2 feat: redesign HtmlDashboardReporter to premium responsive accessible dashboard 2026-06-06 10:20:50 +03:00
Enes Yakıştır 84f713e434 feat: add PageTitle accessibility rule (WCAG 2.4.2) 2026-06-06 10:16:39 +03:00
Enes Yakıştır c6df5e2bdc feat: add 4 new WCAG rules and optimize SourceLocator directory traversal 2026-06-06 10:15:24 +03:00
Enes Yakıştır 75171c6f96 build: upgrade PHPStan to level 9 and fix strict type errors 2026-06-06 09:36:46 +03:00
Enes Yakıştır 1ea2579d20 chore: configure PHPStan Level 6 and resolve strict-type warnings 2026-05-31 23:00:56 +03:00
Enes Yakıştır 873b4c4233 feat: implement 5 new advanced WCAG rules (ColorContrast, DuplicateId, TabindexOrder, AriaRole, IframeTitle) 2026-05-30 12:04:21 +03:00
Enes Yakıştır d219272487 fix: resolve strict types for ReflectionType and DOMNode 2026-05-30 11:13:17 +03:00
Enes Yakıştır 09f33650ec Fix AI suggestions, improve reporter UI, and fix RuleEngine compatibility issues 2026-05-13 13:09:01 +03:00
Enes Yakıştır 3a19c1c534 Fix model name to gemini-1.5-flash v2.2.3 2026-05-13 12:46:26 +03:00
Enes Yakıştır 40dc04d328 Add debug info to GeminiFixer v2.2.2 2026-05-13 12:21:24 +03:00
Enes Yakıştır 3f262b7b35 Fix Violation constructor parameters and Enum usage v2.2.1 2026-05-13 12:16:37 +03:00
331 changed files with 22195 additions and 357 deletions
+2
View File
@@ -4,3 +4,5 @@
/rapor.html
/.phpunit.result.cache
/phpstan-baseline.neon
/.phpunit.cache/
+213 -56
View File
@@ -1,96 +1,253 @@
# YakNet Accessibility Console 2.0 (AI-Powered)
# YakNet Accessibility Console 3.3 (AI-Powered Static Analysis & WCAG 2.2 Linter)
[![PHP Version](https://img.shields.io/badge/php-%5E8.2-blue.svg)](https://php.net)
[![PHPStan](https://img.shields.io/badge/PHPStan-Level%209-brightgreen.svg)](https://phpstan.org)
[![Tests](https://img.shields.io/badge/PHPUnit-230%20passed-success.svg)](https://phpunit.de)
[![Rules](https://img.shields.io/badge/WCAG%20Rules-130%20Active-purple.svg)](https://www.w3.org/WAI/standards-guidelines/wcag/)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![AI Powered](https://img.shields.io/badge/AI-Powered-purple.svg)](https://gemini.google.com)
[![AI Powered](https://img.shields.io/badge/AI%20Self--Healing-Google%20Gemini-orange.svg)](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, **130 benzersiz kural setiyle** denetleyen, kurumsal CI/CD kalite kapısı (Quality Gates) sunan ve **Yapay Zeka (Google Gemini AI) ile Çevrimdışı Deterministik Motor** 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.
- **🛡️ 130 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 kural havuzu. (Yeni: `LabelInName` WCAG 2.5.3, `RedundantEntry` WCAG 3.3.7, `TextSpacing` WCAG 1.4.12).
- **🏛️ Sektörel ve Mevzuat Ön Ayarları (Presets):** US Section 508, Avrupa Birliği EN 301 549 ve E-Ticaret (`--preset=section508|en301549|ecommerce`) profilleriyle hedeflenmiş standart denetimi.
- **🏷️ Dinamik SVG Rozet Üretimi (Badges):** Tarama tamamlandığında sağlık skoruna göre (yeşil/sarı/kırmızı) canlı SVG rozet (`--badge=public/a11y-badge.svg`) üretir.
- **📢 CI/CD Webhook Bildirimleri (Notifications):** Tarama sonuçlarını Slack ve Discord webhook kanallarına otomatik formatlanmış zengin mesajlarla iletir.
- **⚙️ Programatik Kural Denetleyicisi & İstatistikler:** `RuleController` ile DOM elementleri üzerinde doğrudan tekil kural yürütme ve `RuleExecutionStats` ile kural performans/yürütme süresi izleme.
- **⏱️ Gerçek Zamanlı İzleme Modu (`watch`):** Şablon dosyalarını (`.blade.php`, `.twig`, `.html`) arka planda izler, dosya kaydedildiğinde anında hızlı analiz yapıp konsolda canlı skor ve geri bildirim sunar.
- **🚦 CI/CD Kalite Kapısı Eşikleri (Quality Gates):** `--min-score=90`, `--max-warnings=5`, `--fail-on=error` seçenekleriyle pipeline süreçlerinde esnek kalite denetimi sağlar.
- **🔍 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 & Çevrimdışı Otomatik Onarım (Self-Healing):**
- **Çevrimdışı (Offline) Mod:** API anahtarı olmadan güvenli deterministik tamirler (`alt=""`, `aria-hidden="true"`, `rel="noopener noreferrer"`, `tabindex="0"`, `scope` temizliği vb.).
- **AI Destekli Mod:** Karmaşık şablon mantığı ve etiketler için Google Gemini AI ile akıllı tamir.
- **📊 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.
- **🔌 Zengin Çıktı & Rapor Formatları:**
- Konsol tablosu (`console`), JSON (`json`), GitHub Actions (`github`), GitLab SAST (`gitlab`)
- **OASIS SARIF 2.1.0 (`sarif`):** GitHub Code Scanning / Security Alerts ve VS Code SARIF uyumlu.
- **JUnit XML (`junit`):** Jenkins, GitLab CI, CircleCI, Bitbucket Pipelines uyumlu.
- **Çoklu Dışa Aktarım (`--report`):** HTML (`.html`), JSON (`.json`), SARIF (`.sarif`), JUnit (`.xml`), PDF (`.pdf`) ve Excel (`.xlsx`).
- **🏆 PHPStan Level 9 & %100 Tip Güvenliği:** Kod tabanı PHPStan en yüksek seviye (Level 9) ve 230 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
### 3. CI/CD Kalite Kapısı Eşikleri (Quality Gates)
Pipeline süreçlerinde derlemenin kırılma şartlarını belirleyin:
```bash
# Sağlık skoru 90'ın altındaysa derlemeyi başarısız yap:
bin/a11y scan --min-score=90
Kendi PHP uygulamalarınızın içine entegre edin:
# En fazla 3 uyarıya izin ver:
bin/a11y scan --max-warnings=3
```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";
}
# Yalnızca kritik ve hata seviyesindeki ihlallerde kırıl (uyarıları göz ardı et):
bin/a11y scan --fail-on=error
```
## ⚙️ Yapılandırma
### 4. Gerçek Zamanlı İzleme Modu (`watch`)
Geliştirme yaparken şablon dosyalarınızı izleyin ve dosya kaydedildiğinde anında otomatik re-scan çalıştırın:
```bash
bin/a11y watch resources/views
```
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 Code Scanning / SARIF 2.1.0 formatı:**
```bash
bin/a11y scan --format=sarif
```
- **JUnit XML formatı (Jenkins, GitLab CI):**
```bash
bin/a11y scan --format=junit
```
- **GitHub Actions bildirim formatı:**
```bash
bin/a11y scan --format=github
```
- **JSON formatında çıktı:**
```bash
bin/a11y scan --format=json
```
- **Çoklu Dışa Aktarım Formatları (`--report`):**
```bash
bin/a11y scan --report=rapor.html # İnteraktif HTML Dashboard
bin/a11y scan --report=rapor.pdf # PDF Sertifika ve Denetim Raporu
bin/a11y scan --report=rapor.xlsx # Microsoft Excel Çalışma Kitabı
bin/a11y scan --report=rapor.sarif # SARIF 2.1.0 Dosyası
bin/a11y scan --report=rapor.xml # JUnit XML Raporu
```
### 6. Mevzuat Ön Ayarları (`--preset`) ve Rozet Üretimi (`--badge`)
Mevzuat veya sektörel kurallara göre hedeflenmiş tarama yapın ve canlı SVG rozet oluşturun:
```bash
# US Section 508 federal uyumluluk profiliyle tara:
bin/a11y scan --preset=section508
# Avrupa Birliği EN 301 549 kamu web standardı profiliyle tara:
bin/a11y scan --preset=en301549
# E-Ticaret ödeme ve sepet akışları profiliyle tara:
bin/a11y scan --preset=ecommerce
# Tarama sonucunu SVG rozet olarak kaydet (README veya CI/CD artifact için):
bin/a11y scan --badge=public/badges/a11y.svg
```
### 7. Otomatik Düzeltme (`fix`)
Yapay zeka veya çevrimdışı yerel motor ile kaynak dosyalarınızı onarın:
```bash
# Gemini AI desteğiyle otomatik onarım:
bin/a11y fix
# Gemini API anahtarı olmadan tamamen çevrimdışı (offline) yerel motorla onarım:
bin/a11y fix --offline
# Dosyaları değiştirmeden yapılacak düzeltmeleri önizleme:
bin/a11y fix --offline --dry-run
```
### 8. Kuralları Listeleme ve Filtreleme (`rules`)
Kütüphanede tanımlı 130 kuralı listeleyin, arayın ve filtreleyin:
```bash
# Tüm kuralları listele:
bin/a11y rules
# Kural adına veya açıklamasına göre ara:
bin/a11y rules --search=contrast
# Seviye ve standarda göre filtrele:
bin/a11y rules --standard=AA --level=3
```
---
## ⚙️ 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.
+210
View File
@@ -0,0 +1,210 @@
{
"generator": "YakNet Accessibility Console",
"violations": [
{
"file": "demo/broken.html",
"ruleId": "AriaRole",
"htmlSnippet": "<div role=\"cool-widget\" aria-controls=\"non-existent-popup\">WAI-ARIA elements</div>"
},
{
"file": "demo/broken.html",
"ruleId": "AriaRole",
"htmlSnippet": "<div role=\"cool-widget\" aria-controls=\"non-existent-popup\">WAI-ARIA elements</div>"
},
{
"file": "demo/broken.html",
"ruleId": "DuplicateId",
"htmlSnippet": "<h1 id=\"main-content\">Welcome to a broken page</h1>"
},
{
"file": "demo/broken.html",
"ruleId": "DuplicateId",
"htmlSnippet": "<div id=\"main-content\">This is a duplicate main-content ID div!</div>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"logo.png\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_1_1_ALT_REDUNDANT",
"htmlSnippet": "<img src=\"logo2.png\" alt=\"logo of our company\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_1_1_ALT_REDUNDANT",
"htmlSnippet": "<img src=\"profile.jpg\" alt=\"profile photo of john doe\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_2_2_VIDEO_TRACK",
"htmlSnippet": "<video src=\"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4\" width=\"320\" height=\"240\" controls=\"\"></video>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_3_1_EMPTY_HEADING",
"htmlSnippet": "<h2></h2>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_3_1_EMPTY_LABEL",
"htmlSnippet": "<label for=\"search-input\"></label>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_1_3_1_LABEL",
"htmlSnippet": "<input type=\"text\" placeholder=\"Search website...\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_1_1_ARIA_FOCUSABLE",
"htmlSnippet": "<div role=\"button\">Clickable div button without tabindex</div>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_2_PAGE_TITLE",
"htmlSnippet": "<head>\n <title></title>\n</head>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_3_TABINDEX",
"htmlSnippet": "<a href=\"/keyboard\" tabindex=\"5\">Keyboard test</a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_4_LINK_GENERIC",
"htmlSnippet": "<a href=\"/read-more\">Click here</a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_4_LINK_GENERIC",
"htmlSnippet": "<a href=\"/devam-oku\">devamını oku</a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_2_4_4_LINK_TEXT",
"htmlSnippet": "<a href=\"https://google.com\"></a>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><head>\n <title></title>\n</head>\n<body>\n <h1 id=\"main-content\">Welcome to a broken page</h1>\n \n <!-- Missing alt attribute -->\n <img src=\"logo.png\">\n \n <!-- Empty link -->\n <a href=\"https://google.com\"></a>\n \n <!-- Empty button -->\n <button type=\"submit\"></button>\n \n <!-- Positive tabindex -->\n <a href=\"/keyboard\" tabindex=\"5\">Keyboard test</a>\n\n <!-- Duplicate ID -->\n <div id=\"main-content\">This is a duplicate main-content ID div!</div>\n\n <!-- Low Contrast -->\n <p style=\"color: #888888; background-color: #999999;\">This text has extremely low contrast ratio!</p>\n\n <!-- Invalid ARIA role and missing controls target -->\n <div role=\"cool-widget\" aria-controls=\"non-existent-popup\">WAI-ARIA elements</div>\n\n <!-- Missing Iframe Title -->\n <iframe src=\"https://www.youtube.com/embed/aqz-KE-bpKQ\" width=\"560\" height=\"315\"></iframe>\n\n <!-- New WCAG Rule Violations for testing -->\n \n <!-- LinkTextGeneric: Generic text without context -->\n <a href=\"/read-more\">Click here</a>\n <a href=\"/devam-oku\">devamını oku</a>\n \n <!-- PlaceholderAsLabel: Input relying only on placeholder -->\n <input type=\"text\" placeholder=\"Search website...\">\n\n <!-- ImageAltRedundant: Redundant words in alt text -->\n <img src=\"logo2.png\" alt=\"logo of our company\">\n <img src=\"profile.jpg\" alt=\"profile photo of john doe\">\n\n <!-- AriaHiddenFocusable: Focusable element hidden with aria-hidden=\"true\" -->\n <div aria-hidden=\"true\">\n <button type=\"button\">Hidden click</button>\n </div>\n <a href=\"#\" aria-hidden=\"true\">Hidden link</a>\n\n <!-- Empty heading violation -->\n <h2></h2>\n\n <!-- Video missing track violation -->\n <video src=\"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4\" width=\"320\" height=\"240\" controls=\"\"></video>\n\n <!-- Empty label violation -->\n <label for=\"search-input\"></label>\n\n <!-- AriaFocusable violation -->\n <div role=\"button\">Clickable div button without tabindex</div>\n\n <p>This page has multiple accessibility issues.</p>\n</body>\n\n</html>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_3_3_2_PLACEHOLDER_LABEL",
"htmlSnippet": "<input type=\"text\" placeholder=\"Search website...\">"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_4_1_2_BUTTON",
"htmlSnippet": "<button type=\"submit\"></button>"
},
{
"file": "demo/broken.html",
"ruleId": "WCAG_4_1_2_IFRAME_TITLE",
"htmlSnippet": "<iframe src=\"https://www.youtube.com/embed/aqz-KE-bpKQ\" width=\"560\" height=\"315\"></iframe>"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/gadget.jpg\" width=\"100\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/widget.png\" width=\"100\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/thing.jpg\" width=\"100\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/dynamic_preview.html",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/views-fixed/contact.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Contact Us</h2>\n<p>Get in touch with us.</p>\n\n<form action=\"\" method=\"post\">\n <div style=\"margin-bottom: 1rem;\">\n <!-- FIXED: Wrapped in label -->\n <label>\n Name:\n <input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">\n </label>\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <!-- FIXED: Added for/id association -->\n <label for=\"email\">Email Address</label>\n <br>\n <input type=\"email\" id=\"email\" name=\"email\" style=\"padding: 8px; width: 100%;\">\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <label for=\"message\">Message</label>\n <br>\n <textarea id=\"message\" name=\"message\" rows=\"5\" style=\"width: 100%;\"></textarea>\n </div>\n\n <!-- FIXED: Added inner text or aria-label -->\n <a href=\"#\" class=\"btn\" aria-label=\"Send via WhatsApp\">\n <i class=\"fa fa-send\"></i> Send\n </a>\n\n <button type=\"submit\" class=\"btn\">Send Message</button>\n</form></html>"
},
{
"file": "demo/views-fixed/home.php",
"ruleId": "WCAG_1_3_1_EMPTY_HEADING",
"htmlSnippet": "<h3>\n <?php echo $product['name']; >\n </h3>"
},
{
"file": "demo/views-fixed/home.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Latest Products</h2>\n<p>Welcome to our accessible product catalog.</p>\n\n<?php $products = [\n ['id' => 1, 'name' => 'Super Gadget', 'image' => 'gadget.jpg', 'price' => '$99'],\n ['id' => 2, 'name' => 'Mega Widget', 'image' => 'widget.png', 'price' => '$49'],\n ['id' => 3, 'name' => 'Ultra Thing', 'image' => 'thing.jpg', 'price' => '$199'],\n];\n>\n\n<div class=\"products\">\n <?php foreach ($products as $product): >\n <div class=\"card\">\n <!-- FIXED: Added alt attribute describing the product -->\n <img src=\"images/&lt;?php%20echo%20$product['image'];%20?&gt;\" width=\"100\" alt=\"&lt;?php echo htmlspecialchars($product['name']); ?&gt;\">\n <h3>\n <?php echo $product['name']; >\n </h3>\n <p>\n <?php echo $product['price']; >\n </p>\n <button class=\"btn\">Buy Now</button>\n </div>\n <?php endforeach; >\n</div>\n\n<hr>\n\n<h3>Sponsors</h3>\n<div>\n <!-- FIXED: Added empty alt=\"\" for decorative images -->\n <img src=\"https://via.placeholder.com/150\" alt=\"\">\n <img src=\"https://via.placeholder.com/150\" alt=\"\">\n</div></html>"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_1_3_1_LABEL",
"htmlSnippet": "<input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_1_3_1_LABEL",
"htmlSnippet": "<input type=\"email\" name=\"email\" style=\"padding: 8px; width: 100%;\">"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_2_4_4_LINK_TEXT",
"htmlSnippet": "<a href=\"#\" class=\"btn\">\n <i class=\"fa fa-send\"></i> <!-- Empty link text error (if we had that rule) -->\n </a>"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Contact Us</h2>\n<p>Get in touch with us.</p>\n\n<form action=\"\" method=\"post\">\n <div style=\"margin-bottom: 1rem;\">\n <!-- ERROR: No label for input -->\n <div>Name:</div>\n <input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <!-- ERROR: Label exists but not associated with For/ID -->\n <label>Email Address</label>\n <br>\n <input type=\"email\" name=\"email\" style=\"padding: 8px; width: 100%;\">\n </div>\n\n <div style=\"margin-bottom: 1rem;\">\n <label for=\"message\">Message</label>\n <br>\n <textarea id=\"message\" name=\"message\" rows=\"5\" style=\"width: 100%;\"></textarea>\n </div>\n\n <!-- ERROR: Submit button without clear text if it was an input type=image, but button is usually fine. \n Let's add a problematic icon link -->\n <a href=\"#\" class=\"btn\">\n <i class=\"fa fa-send\"></i> <!-- Empty link text error (if we had that rule) -->\n </a>\n\n <button type=\"submit\" class=\"btn\">Send Message</button>\n</form></html>"
},
{
"file": "demo/views/contact.php",
"ruleId": "WCAG_3_3_2_PLACEHOLDER_LABEL",
"htmlSnippet": "<input type=\"text\" name=\"fullname\" placeholder=\"John Doe\" style=\"padding: 8px; width: 100%;\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"images/&lt;?php%20echo%20$product['image'];%20?&gt;\" width=\"100\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_1_1_ALT",
"htmlSnippet": "<img src=\"https://via.placeholder.com/150\">"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_1_3_1_EMPTY_HEADING",
"htmlSnippet": "<h3>\n <?php echo $product['name']; >\n </h3>"
},
{
"file": "demo/views/home.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><h2>Latest Products</h2>\n<p>Welcome to our dynamic product catalog. (Intentionally filled with errors)</p>\n\n<?php $products = [\n ['id' => 1, 'name' => 'Super Gadget', 'image' => 'gadget.jpg', 'price' => '$99'],\n ['id' => 2, 'name' => 'Mega Widget', 'image' => 'widget.png', 'price' => '$49'],\n ['id' => 3, 'name' => 'Ultra Thing', 'image' => 'thing.jpg', 'price' => '$199'],\n];\n>\n\n<div class=\"products\">\n <?php foreach ($products as $product): >\n <div class=\"card\">\n <!-- ERROR: Dynamic image without alt text -->\n <img src=\"images/&lt;?php%20echo%20$product['image'];%20?&gt;\" width=\"100\">\n <h3>\n <?php echo $product['name']; >\n </h3>\n <p>\n <?php echo $product['price']; >\n </p>\n <button class=\"btn\">Buy Now</button>\n <!-- Error: Button might need specific accessible name if icon-only, but text is okay here. -->\n </div>\n <?php endforeach; >\n</div>\n\n<hr>\n\n<h3>Sponsors</h3>\n<div>\n <!-- ERROR: Decorative image causing noise, missing alt=\"\" -->\n <img src=\"https://via.placeholder.com/150\">\n <img src=\"https://via.placeholder.com/150\">\n</div></html>"
},
{
"file": "demo/views/layout.php",
"ruleId": "WCAG_2_4_4_LINK_TEXT",
"htmlSnippet": "<a href=\"#\" class=\"btn\"></a>"
},
{
"file": "demo/views/layout.php",
"ruleId": "WCAG_3_1_1_LANG",
"htmlSnippet": "<html><!-- Missing lang attribute error --><head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>YakNet Dynamic Demo</title>\n <style>\n body {\n font-family: sans-serif;\n margin: 0;\n padding: 0;\n line-height: 1.6;\n }\n\n header {\n background: #333;\n color: #fff;\n padding: 1rem;\n }\n\n nav a {\n color: #fff;\n margin-right: 1rem;\n text-decoration: none;\n }\n\n .container {\n padding: 2rem;\n max-width: 800px;\n margin: 0 auto;\n }\n\n .card {\n border: 1px solid #ddd;\n padding: 1rem;\n margin-bottom: 1rem;\n border-radius: 4px;\n }\n\n .btn {\n background: #007bff;\n color: white;\n padding: 0.5rem 1rem;\n border: none;\n cursor: pointer;\n }\n </style>\n</head>\n\n<body>\n <header>\n <div class=\"container\" style=\"padding: 0;\">\n <h1>My Dynamic Site</h1>\n <nav>\n <a href=\"?page=home\">Home</a>\n <a href=\"?page=contact\">Contact</a>\n <a href=\"?page=about\">About</a>\n <a href=\"#\" class=\"btn\"></a> <!-- Error: Empty Link -->\n </nav>\n </div>\n </header>\n\n <main class=\"container\">\n <?php include $viewPath; >\n </main>\n\n <footer style=\"text-align: center; padding: 2rem; background: #f4f4f4; margin-top: 2rem;\">\n ©\n <?php echo date('Y'); > YakNet Demo\n </footer>\n</body>\n\n</html>"
}
]
}
+32
View File
@@ -0,0 +1,32 @@
# YakNet Accessibility Console Configuration
# For detailed configuration, see: https://forum.yak.net.tr
# Paths to scan (can be directories or specific files)
paths:
- .
# Exclude directories or files
exclude_paths:
- vendor
- node_modules
- tests
- storage
- .git
# Accessibility scanning level (1-9, default 4)
level: 4
# Output format (console, json, html, github)
format: console
# Path to the baseline file for ignoring existing violations
baseline: a11y-baseline.json
# Rule adjustments
rules:
# Rules to exclude from analysis
exclude:
# - WCAG_1_3_1_FIELDSET
# Additional custom rules to run
include:
# - App\Rules\MyCustomRule
+20 -5
View File
@@ -1,16 +1,31 @@
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
require __DIR__ . '/../vendor/autoload.php';
} elseif (file_exists(__DIR__ . '/../../../autoload.php')) {
require __DIR__ . '/../../../autoload.php';
} else {
echo "Autoload file not found.\n";
exit(1);
}
use Symfony\Component\Console\Application;
use YakNet\AccessibilityConsole\Core\Container\ContainerFactory;
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
use YakNet\AccessibilityConsole\Console\Command\WatchCommand;
$application = new Application('YakNet Accessibility Console', '2.0.0');
$container = ContainerFactory::create();
$application = new Application('YakNet Accessibility Console', '3.3.0');
// 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->addCommand($container->get(WatchCommand::class));
$application->run();
+20 -4
View File
@@ -12,6 +12,7 @@
"yaknet"
],
"homepage": "https://forum.yak.net.tr",
"version": "3.3.0",
"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/"
}
+103
View File
@@ -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>
+23
View File
@@ -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);
}
}
+115 -14
View File
@@ -1,20 +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>Welcome to a broken page</h1>
<!-- Missing alt attribute -->
<img src="logo.png">
<!-- Empty link -->
<a href="https://google.com"></a>
<!-- Empty button -->
<button type="submit"></button>
<p>This page has multiple accessibility issues.</p>
<h1 id="main-content">Erişilebilir Demo Sayfa</h1>
<!-- FIXED: Removed duplicate id="main-content" (WCAG 4.1.1) -->
<!-- FIXED: Added alt attribute (WCAG 1.1.1) -->
<img src="logo.png" alt="YakNet Şirket">
<!-- FIXED: Added descriptive link text (WCAG 2.4.4) -->
<a href="https://google.com">Google Ana Sayfasına Git</a>
<!-- FIXED: Added discernible button text (WCAG 4.1.2) -->
<button type="submit">Formu Gönder</button>
<!-- FIXED: Removed positive tabindex (WCAG 2.4.3) — use natural DOM order -->
<a href="/keyboard">Klavye Testi</a>
<!-- FIXED: Removed duplicate id="main-content" — unique wrapper instead -->
<div id="duplicate-content-wrapper">Bu bölümün içeriği buradadır.</div>
<!-- FIXED: High contrast text (WCAG 1.4.3) -->
<p class="low-contrast-example">Bu metnin kontrast oranı WCAG AA gereksinimlerini karşılamaktadır.</p>
<!-- FIXED: Valid WAI-ARIA role (WCAG 4.1.2) and removed reference to non-existent element -->
<div role="region" aria-label="WAI-ARIA Örnek Bölüm">WAI-ARIA bölüm örneği</div>
<!-- FIXED: Added descriptive title to iframe (WCAG 4.1.2) -->
<iframe
src="https://www.youtube.com/embed/aqz-KE-bpKQ"
width="560"
height="315"
title="YouTube Video: Örnek Video İçeriği"
></iframe>
<!-- FIXED: Descriptive link text instead of generic "click here" / "devamını oku" (WCAG 2.4.4) -->
<a href="/read-more">Makaleyi Devamı: WCAG Erişilebilirlik Kılavuzu</a>
<a href="/devam-oku">Şiir Serisinin Devamını Oku: "Bahar Rüzgarı"</a>
<!-- FIXED: Added associated <label> element (WCAG 1.3.1 & 3.3.2) -->
<div>
<label for="search-input">Web sitesinde ara</label>
<input type="text" id="search-input" placeholder="Arama teriminizi girin...">
</div>
<!-- FIXED: Removed redundant words "logo" and "photo" from alt texts (WCAG 1.1.1) -->
<img src="logo2.png" alt="Şirketimizin simgesi">
<img src="profile.jpg" alt="John Doe vesikalık">
<!-- FIXED: Removed aria-hidden="true" from focusable elements (WCAG 4.1.2) -->
<div>
<button type="button">Gizli Olmayan Tıklanabilir Düğme</button>
</div>
<a href="#">Normal Bağlantı</a>
<!-- FIXED: Added non-empty heading text (WCAG 2.4.6) -->
<h2>Alt Bölüm Başlığı</h2>
<!-- FIXED: Added <track> element for video captions (WCAG 1.2.2) -->
<video
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4"
width="320"
height="240"
controls
>
<track kind="captions" src="captions.vtt" srclang="tr" label="Türkçe Altyazı" default>
</video>
<!-- FIXED: Added non-empty label text (WCAG 1.3.1) -->
<label for="name-input">Ad Soyad</label>
<input type="text" id="name-input" name="name">
<!-- FIXED: Added tabindex="0" to make role="button" keyboard focusable (WCAG 2.1.1) -->
<div role="button" tabindex="0">Tıklanabilir Div Düğme (Erişilebilir)</div>
<p>Bu sayfa WCAG 2.1 erişilebilirlik standartlarına uygun olarak düzenlenmiştir.</p>
</body>
</html>
+10 -19
View File
@@ -1,19 +1,11 @@
<?php
// 1. Setup Autoloading (Mocking Composer)
require_once __DIR__ . '/../src/Scanner.php';
require_once __DIR__ . '/../src/Violation.php';
require_once __DIR__ . '/../src/SourceLocator.php';
require_once __DIR__ . '/../src/Reporter/HtmlReporter.php';
require_once __DIR__ . '/../src/Rule/RuleInterface.php';
require_once __DIR__ . '/../src/Rules/ImgAltText.php';
require_once __DIR__ . '/../src/Rules/FormLabel.php';
require_once __DIR__ . '/../src/Rules/HtmlHasLang.php';
require_once __DIR__ . '/../src/Rules/EmptyLink.php';
// 1. Setup Autoloading
require_once __DIR__ . '/../vendor/autoload.php';
use YakNet\AccessibilityConsole\Scanner;
use YakNet\AccessibilityConsole\SourceLocator;
use YakNet\AccessibilityConsole\Reporter\HtmlReporter;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Source\SourceLocator;
use YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter;
use YakNet\AccessibilityConsole\Rules\ImgAltText;
use YakNet\AccessibilityConsole\Rules\FormLabel;
@@ -48,19 +40,18 @@ $violations = $scanner->scan($html);
if (!empty($violations)) {
// Locate Sources
// We point it to the 'views' directory so it knows where to look for the error source
$locator = new SourceLocator(__DIR__ . '/views');
foreach ($violations as $violation) {
$location = $locator->locate($violation->snippet);
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$violation->setSourceLocation($location['file'], $location['line']);
$violation->location = $location;
}
}
// Render Report
$reporter = new HtmlReporter();
$reportHtml = $reporter->render($violations);
// Render Report (Format as results mapped by page URL)
$reporter = new HtmlDashboardReporter();
$reportHtml = $reporter->render(['http://localhost:8080/index.php' => $violations], 'demo/index.php');
// Inject into body
$html = str_replace('</body>', $reportHtml . '</body>', $html);
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Local Fix</title>
</head>
<body>
<span style="text-decoration: blink;">Hello blink</span>
<input type="text" name="username" autofocus />
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<html>
<head>
<title>Test Page</title>
<meta http-equiv="refresh" content="30">
</head>
<body>
<img src="avatar.png" alt="Avatar">
<button>Submit</button>
<a href="#">Click here for nothing</a>
<a href="javascript:void(0)">Do something</a>
<label for="nonexistent-id">Broken Label</label>
<table>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
<tr>
<td scope="row">Data 1</td>
<td>Data 2</td>
</tr>
</table>
</body>
</html>
+31
View File
@@ -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.
+25
View File
@@ -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).
+7
View File
@@ -0,0 +1,7 @@
parameters:
level: 9
paths:
- src
bootstrapFiles:
- vendor/autoload.php
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="false"
beStrictAboutCoverageMetadata="false"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="YakNet Accessibility Console Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\AI\Providers\GeminiProvider;
use YakNet\AccessibilityConsole\AI\Providers\OpenAiProvider;
use YakNet\AccessibilityConsole\AI\Providers\ClaudeProvider;
use YakNet\AccessibilityConsole\AI\Providers\MockAiProvider;
use YakNet\AccessibilityConsole\Core\Violation;
class AiFixerManager
{
/** @var array<string, AiProviderInterface> */
private array $providers = [];
private ?string $activeProviderName = null;
public function __construct(?string $geminiApiKey = null, ?string $openAiApiKey = null, ?string $claudeApiKey = null)
{
// Register default providers
if ($geminiApiKey) {
$this->registerProvider('gemini', new GeminiProvider($geminiApiKey));
$this->activeProviderName = 'gemini';
}
if ($openAiApiKey) {
$this->registerProvider('openai', new OpenAiProvider($openAiApiKey));
if (!$this->activeProviderName) {
$this->activeProviderName = 'openai';
}
}
if ($claudeApiKey) {
$this->registerProvider('claude', new ClaudeProvider($claudeApiKey));
if (!$this->activeProviderName) {
$this->activeProviderName = 'claude';
}
}
// Always register mock provider as fallback/testing option
$this->registerProvider('mock', new MockAiProvider());
if (!$this->activeProviderName) {
$this->activeProviderName = 'mock';
}
}
public function registerProvider(string $name, AiProviderInterface $provider): void
{
$this->providers[strtolower($name)] = $provider;
}
public function setActiveProvider(string $name): void
{
$key = strtolower($name);
if (isset($this->providers[$key])) {
$this->activeProviderName = $key;
}
}
public function getActiveProvider(): ?AiProviderInterface
{
if ($this->activeProviderName && isset($this->providers[$this->activeProviderName])) {
return $this->providers[$this->activeProviderName];
}
return null;
}
public function suggestFix(Violation $violation): ?string
{
$provider = $this->getActiveProvider();
if ($provider) {
return $provider->suggestFix($violation);
}
return "No active AI provider configured.";
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\Core\Violation;
interface AiProviderInterface
{
/**
* Get the name of this AI provider.
*
* @return string
*/
public function getName(): string;
/**
* Suggest a fix for the given violation.
*
* @param Violation $violation
* @return string|null
*/
public function suggestFix(Violation $violation): ?string;
}
+18 -16
View File
@@ -11,15 +11,23 @@ class GeminiFixer
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey && class_exists(Gemini::class)) {
$this->client = Gemini::client($this->apiKey);
if ($this->apiKey) {
try {
$this->client = \Gemini::client($this->apiKey);
} catch (\Throwable) {
$this->client = null;
}
}
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->apiKey) {
return "Hata: GEMINI_API_KEY bulunamadı.";
}
if (!$this->client) {
return null;
return "Hata: Gemini istemcisi başlatılamadı. Kütüphane eksik olabilir.";
}
$prompt = <<<PROMPT
@@ -30,23 +38,17 @@ Error: {$violation->message}
Severity: {$violation->severity->value}
Standard: {$violation->standard->value}
Please provide ONLY the fixed version of the HTML snippet.
Do not explain, do not add markdown code blocks, just return the corrected HTML.
If the error is about missing 'alt' text, add a meaningful alt text based on context.
If it's about ARIA roles, add the correct ones.
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]
FIX: [Only the corrected HTML snippet]
PROMPT;
try {
$result = $this->client->generativeModel('gemini-2.5-flash')->generateContent($prompt);
$suggestion = trim($result->text());
// Clean up if AI included markdown blocks anyway
$suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion);
return $suggestion;
$result = $this->client->generativeModel('gemini-3.1-flash-lite')->generateContent($prompt);
return trim($result->text());
} catch (\Throwable $e) {
// Log error if needed
return null;
return "AI Düzeltme Hatası: " . $e->getMessage();
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use YakNet\AccessibilityConsole\Core\Violation;
class PromptBuilder
{
public static function build(Violation $violation): string
{
return <<<PROMPT
You are a WCAG 2.1 Accessibility expert.
A static analysis tool found an accessibility issue in this HTML snippet.
Snippet: {$violation->htmlSnippet}
Error Message: {$violation->message}
Rule ID: {$violation->ruleId}
Severity: {$violation->severity->value}
Standard: {$violation->standard->value}
Please provide a short explanation of the fix in Turkish and the fixed version of the HTML snippet.
Use the following EXACT format:
EXPLANATION: [A short explanation of what you fixed and why, in Turkish]
FIX: [Only the corrected HTML snippet, no surrounding explanation]
PROMPT;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use GuzzleHttp\Client as GuzzleClient;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\AI\PromptBuilder;
use YakNet\AccessibilityConsole\Core\Violation;
class ClaudeProvider implements AiProviderInterface
{
private ?GuzzleClient $client = null;
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey) {
$this->client = new GuzzleClient([
'base_uri' => 'https://api.anthropic.com/v1/',
'headers' => [
'x-api-key' => $this->apiKey,
'anthropic-version' => '2023-06-01',
'Content-Type' => 'application/json',
],
'timeout' => 10.0,
]);
}
}
public function getName(): string
{
return 'Anthropic Claude';
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->client) {
return null;
}
$prompt = PromptBuilder::build($violation);
try {
$response = $this->client->post('messages', [
'json' => [
'model' => 'claude-3-5-haiku-20241022',
'max_tokens' => 1000,
'messages' => [
['role' => 'user', 'content' => $prompt]
],
],
]);
$data = json_decode($response->getBody()->getContents(), true);
if (is_array($data)
&& isset($data['content']) && is_array($data['content'])
&& isset($data['content'][0]) && is_array($data['content'][0])
&& isset($data['content'][0]['text']) && is_string($data['content'][0]['text'])
) {
return $data['content'][0]['text'];
}
return null;
} catch (\Throwable $e) {
return "Claude Error: " . $e->getMessage();
}
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use Gemini;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\AI\PromptBuilder;
use YakNet\AccessibilityConsole\Core\Violation;
class GeminiProvider implements AiProviderInterface
{
private ?\Gemini\Client $client = null;
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey) {
try {
$this->client = \Gemini::client($this->apiKey);
} catch (\Throwable) {
$this->client = null;
}
}
}
public function getName(): string
{
return 'Google Gemini';
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->apiKey || !$this->client) {
return null;
}
$prompt = PromptBuilder::build($violation);
try {
$result = $this->client->generativeModel('gemini-3.1-flash-lite')->generateContent($prompt);
return trim($result->text());
} catch (\Throwable $e) {
return "Gemini Error: " . $e->getMessage();
}
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\Core\Violation;
class MockAiProvider implements AiProviderInterface
{
public function getName(): string
{
return 'Mock AI';
}
public function suggestFix(Violation $violation): ?string
{
$snippet = $violation->htmlSnippet;
$fixedSnippet = $snippet;
// Simple mock fixes based on rule ID
if ($violation->ruleId === 'WCAG_2_2_2_BLINK') {
$fixedSnippet = str_replace(['<blink>', '</blink>'], ['<span>', '</span>'], $snippet);
} elseif ($violation->ruleId === 'WCAG_1_3_1_DUPLICATE_H1') {
$fixedSnippet = str_replace(['<h1>', '</h1>'], ['<h2>', '</h2>'], $snippet);
} elseif ($violation->ruleId === 'WCAG_1_3_5_AUTOCOMPLETE') {
$fixedSnippet = str_replace('<input', '<input autocomplete="email"', $snippet);
} elseif ($violation->ruleId === 'WCAG_2_1_1_SCROLLABLE_FOCUS') {
$fixedSnippet = str_replace('<div', '<div tabindex="0"', $snippet);
} elseif ($violation->ruleId === 'WCAG_4_1_2_ARIA_LABELLEDBY') {
$fixedSnippet = str_replace('aria-labelledby="non_existent_label"', 'aria-labelledby="real_label"', $snippet);
} elseif ($violation->ruleId === 'WCAG_1_1_1_ALT_PLACEHOLDER') {
if (str_contains($snippet, 'alt="logo"')) {
$fixedSnippet = str_replace('alt="logo"', 'alt="YakNet Accessibility Console Company Logo"', $snippet);
} else {
$fixedSnippet = str_replace('alt="header_banner.jpg"', 'alt="YakNet Accessibility Console Marketing Banner"', $snippet);
}
} elseif ($violation->ruleId === 'WCAG_1_1_1_SVG') {
$fixedSnippet = str_replace('<svg', '<svg aria-hidden="true"', $snippet);
} elseif ($violation->ruleId === 'WCAG_1_3_1_TABLE_SUMMARY') {
$fixedSnippet = preg_replace('/summary=".*?"/i', '', $snippet) ?: $snippet;
} elseif ($violation->ruleId === 'WCAG_1_3_1_PRESENTATION_TAGS') {
$fixedSnippet = str_replace(['<center>', '</center>', '<strike>', '</strike>'], ['<span style="text-align: center; display: block;">', '</span>', '<span style="text-decoration: line-through;">', '</span>'], $snippet);
} elseif ($violation->ruleId === 'WCAG_2_4_3_AUTOFOCUS') {
$fixedSnippet = str_replace(' autofocus', '', $snippet);
}
return "EXPLANATION: Mocked correction for accessibility validation.\nFIX: " . $fixedSnippet;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\AI\Providers;
use GuzzleHttp\Client as GuzzleClient;
use YakNet\AccessibilityConsole\AI\AiProviderInterface;
use YakNet\AccessibilityConsole\AI\PromptBuilder;
use YakNet\AccessibilityConsole\Core\Violation;
class OpenAiProvider implements AiProviderInterface
{
private ?GuzzleClient $client = null;
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey) {
$this->client = new GuzzleClient([
'base_uri' => 'https://api.openai.com/v1/',
'headers' => [
'Authorization' => 'Bearer ' . $this->apiKey,
'Content-Type' => 'application/json',
],
'timeout' => 10.0,
]);
}
}
public function getName(): string
{
return 'OpenAI GPT';
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->client) {
return null;
}
$prompt = PromptBuilder::build($violation);
try {
$response = $this->client->post('chat/completions', [
'json' => [
'model' => 'gpt-4o-mini',
'messages' => [
['role' => 'user', 'content' => $prompt]
],
'temperature' => 0.2,
],
]);
$data = json_decode($response->getBody()->getContents(), true);
if (is_array($data)
&& isset($data['choices']) && is_array($data['choices'])
&& isset($data['choices'][0]) && is_array($data['choices'][0])
&& isset($data['choices'][0]['message']) && is_array($data['choices'][0]['message'])
&& isset($data['choices'][0]['message']['content']) && is_string($data['choices'][0]['message']['content'])
) {
return $data['choices'][0]['message']['content'];
}
return null;
} catch (\Throwable $e) {
return "OpenAI Error: " . $e->getMessage();
}
}
}
+49
View File
@@ -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,
];
}
}
+42
View File
@@ -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,
];
}
}
+76
View File
@@ -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,
];
}
}
+49
View File
@@ -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,
];
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace YakNet\AccessibilityConsole\Badges;
class BadgeGenerator
{
/**
* Generate an SVG badge representing the Accessibility Health Score.
*
* @param int $score 0-100
* @param BadgeOptions|null $options
* @return string Raw SVG markup
*/
public static function generateScoreBadge(int $score, ?BadgeOptions $options = null): string
{
$opts = $options ?? new BadgeOptions();
$color = $opts->rightColor ?? ($score >= 90 ? '#10b981' : ($score >= 70 ? '#f59e0b' : '#ef4444'));
$label = $opts->label;
$value = "{$score}/100";
return self::renderBadge($label, $value, $color, $opts);
}
/**
* Generate an SVG badge representing WCAG compliance status.
*
* @param string $standard E.g. 'WCAG 2.1 AA'
* @param bool $compliant
* @param BadgeOptions|null $options
* @return string Raw SVG markup
*/
public static function generateComplianceBadge(string $standard = 'WCAG 2.1 AA', bool $compliant = true, ?BadgeOptions $options = null): string
{
$opts = $options ?? new BadgeOptions();
$color = $opts->rightColor ?? ($compliant ? '#10b981' : '#ef4444');
$value = $compliant ? 'passed' : 'failed';
return self::renderBadge($standard, $value, $color, $opts);
}
/**
* Generate an SVG badge for total violations count.
*
* @param int $violationsCount
* @param BadgeOptions|null $options
* @return string Raw SVG markup
*/
public static function generateViolationsBadge(int $violationsCount, ?BadgeOptions $options = null): string
{
$opts = $options ?? new BadgeOptions();
$color = $opts->rightColor ?? ($violationsCount === 0 ? '#10b981' : ($violationsCount < 5 ? '#f59e0b' : '#ef4444'));
$value = $violationsCount === 0 ? '0 issues' : "{$violationsCount} issues";
$label = $opts->label !== 'accessibility' ? $opts->label : 'a11y issues';
return self::renderBadge($label, $value, $color, $opts);
}
/**
* Render a standard flat shield SVG badge.
*/
private static function renderBadge(string $label, string $value, string $rightColor, ?BadgeOptions $options = null): string
{
$opts = $options ?? new BadgeOptions();
$charWidth = 7;
$padding = 12;
$leftWidth = (int)(mb_strlen($label) * $charWidth + $padding);
$rightWidth = (int)(mb_strlen($value) * $charWidth + $padding);
$totalWidth = $leftWidth + $rightWidth;
$height = 20;
$leftCenter = (int)($leftWidth / 2);
$rightCenter = (int)($leftWidth + ($rightWidth / 2));
$escLabel = htmlspecialchars($label);
$escValue = htmlspecialchars($value);
$borderRadius = $opts->borderRadius;
$leftColor = $opts->leftColor;
$fontFamily = htmlspecialchars($opts->fontFamily);
$fontSize = $opts->fontSize;
return <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="{$totalWidth}" height="{$height}" role="img" aria-label="{$escLabel}: {$escValue}">
<title>{$escLabel}: {$escValue}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="{$totalWidth}" height="{$height}" rx="{$borderRadius}" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="{$leftWidth}" height="{$height}" fill="{$leftColor}"/>
<rect x="{$leftWidth}" width="{$rightWidth}" height="{$height}" fill="{$rightColor}"/>
<rect width="{$totalWidth}" height="{$height}" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="{$fontFamily}" text-rendering="geometricPrecision" font-size="{$fontSize}">
<text aria-hidden="true" x="{$leftCenter}" y="15" fill="#010101" fill-opacity=".3">{$escLabel}</text>
<text x="{$leftCenter}" y="14">{$escLabel}</text>
<text aria-hidden="true" x="{$rightCenter}" y="15" fill="#010101" fill-opacity=".3">{$escValue}</text>
<text x="{$rightCenter}" y="14">{$escValue}</text>
</g>
</svg>
SVG;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\Badges;
/**
* Options DTO for customizing SVG badge generation.
*/
class BadgeOptions
{
/**
* @param string $label Default left container text label
* @param string $leftColor Hex color code for left container background
* @param string|null $rightColor Optional hex color override for right container background
* @param int $borderRadius Corner radius for SVG clipPath
* @param string $fontFamily Font family CSS rule for badge typography
* @param int $fontSize Font size in pixels
*/
public function __construct(
public readonly string $label = 'accessibility',
public readonly string $leftColor = '#1f2937',
public readonly ?string $rightColor = null,
public readonly int $borderRadius = 3,
public readonly string $fontFamily = 'Verdana,Geneva,DejaVu Sans,sans-serif',
public readonly int $fontSize = 11,
) {
}
}
+53
View File
@@ -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))
);
}
}
+236 -33
View File
@@ -8,10 +8,11 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\AI\GeminiFixer;
use YakNet\AccessibilityConsole\AI\AiFixerManager;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
use YakNet\AccessibilityConsole\Source\SourceLocator;
class FixCommand extends Command
@@ -21,38 +22,211 @@ 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('offline', null, InputOption::VALUE_NONE, 'Perform deterministic offline rule-based fixes without calling AI')
->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');
$projectPath = $input->getOption('project-path') ?? getcwd();
$dryRun = $input->getOption('dry-run');
// 1. Resolve configuration
$configPathOption = $input->getOption('config');
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
$config = Config::fromYaml($configPath);
$config = new Config();
$offline = (bool)$input->getOption('offline');
$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;
if (!$offline && !$apiKey) {
$io->note("GEMINI_API_KEY not found in .env or environment variables. Running in offline rule-based healing mode (--offline).");
$offline = true;
}
$io->title("AI Self-Healing Mode - Fixing: $target");
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
// 1. Fetch HTML
$html = @file_get_contents($target);
if (!$html) {
$io->error("Could not read target: $target");
return Command::FAILURE;
$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, $offline);
}
// 2. Scan
// --- LOCAL FILE / DIRECTORY SELF-HEALING ENGINE ---
$io->title($offline ? "Offline Rule-Based Self-Healing Mode" : "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();
foreach (StandardRuleSet::all() as $rule) {
$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 %s analysis and healing...", count($violations), $offline ? 'offline rule-based' : 'AI'));
$fixer = !$offline ? new AiFixerManager($apiKey) : null;
$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;
} elseif ($fixer !== null) {
// Fallback to AI Suggestion
$rawSuggestion = $fixer->suggestFix($violation);
} else {
$io->writeln("<fg=yellow>Skipped (Rule requires AI or manual intervention)</>");
continue;
}
$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, bool $offline = false): int
{
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$dryRun = (bool)$input->getOption('dry-run');
$useJs = (bool)$input->getOption('js');
$io->title($offline ? "Offline Rule-Based Mode - Fixing URL: $target" : "AI Self-Healing Mode - Fixing URL: $target");
// Fetch HTML
if ($useJs) {
try {
$chromeRenderer = new \YakNet\AccessibilityConsole\Core\ChromeRenderer();
$html = $chromeRenderer->render($target);
} catch (\Throwable $e) {
$io->error("Failed JavaScript rendering: " . $e->getMessage());
return Command::FAILURE;
}
} else {
$html = @file_get_contents($target);
}
if ($html === false || $html === '') {
$io->error("Could not read target URL: $target");
return Command::FAILURE;
}
// Scan
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
$violations = $scanner->scan($html);
@@ -62,9 +236,10 @@ class FixCommand extends Command
return Command::SUCCESS;
}
$io->note("Found " . count($violations) . " violations. Starting AI analysis...");
$io->note(sprintf("Found %d violations. Starting %s analysis...", count($violations), $offline ? 'offline' : 'AI'));
$fixer = new GeminiFixer($apiKey);
$fixer = (!$offline && is_string($apiKey) && $apiKey !== '') ? new AiFixerManager($apiKey) : null;
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
$locator = new SourceLocator($projectPath);
$fixedCount = 0;
@@ -78,16 +253,45 @@ class FixCommand extends Command
continue;
}
// Get AI Suggestion
$suggestion = $fixer->suggestFix($violation);
if (!$suggestion) {
$io->writeln("<fg=red>Skipped (AI could not generate fix)</>");
$localHealer = new \YakNet\AccessibilityConsole\Core\SelfHealing\LocalHealer();
$rawSuggestion = $localHealer->heal($violation);
$isLocalFix = false;
if ($rawSuggestion !== null) {
$isLocalFix = true;
} elseif ($fixer !== null) {
$rawSuggestion = $fixer->suggestFix($violation);
} else {
$io->writeln("<fg=yellow>Skipped (Rule requires AI or manual intervention)</>");
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)) . "</>");
$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.</>");
@@ -107,7 +311,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;
@@ -122,8 +326,7 @@ class FixCommand extends Command
if (str_contains($content, $originalSnippet)) {
$newContent = str_replace($originalSnippet, $fixedSnippet, $content);
} else {
// Fallback: Fuzzy replacement (simplified for this demo)
// In a real professional lib, we'd use more advanced regex or DOM patching
// Fallback: Fuzzy replacement
$newContent = str_replace(trim($originalSnippet), trim($fixedSnippet), $content);
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class InitCommand extends Command
{
protected function configure(): void
{
$this
->setName('init')
->setDescription('Initialize a default a11y.yaml configuration file');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$configPath = getcwd() . '/a11y.yaml';
if (file_exists($configPath)) {
$io->warning("Configuration file 'a11y.yaml' already exists in the current directory.");
return Command::SUCCESS;
}
$defaultConfig = <<<YAML
# YakNet Accessibility Console Configuration
# For detailed configuration, see: https://forum.yak.net.tr
# Paths to scan (can be directories or specific files)
paths:
- .
# Exclude directories or files
exclude_paths:
- vendor
- node_modules
- tests
- storage
- .git
# Accessibility scanning level (1-9, default 4)
level: 4
# Output format (console, json, html, github)
format: console
# Path to the baseline file for ignoring existing violations
# baseline: a11y-baseline.json
# Rule adjustments
rules:
# Rules to exclude from analysis
exclude:
# - WCAG_1_3_1_FIELDSET
# Additional custom rules to run
include:
# - App\Rules\MyCustomRule
YAML;
if (file_put_contents($configPath, $defaultConfig) !== false) {
$io->success("Created configuration file: a11y.yaml");
return Command::SUCCESS;
}
$io->error("Failed to create configuration file 'a11y.yaml'.");
return Command::FAILURE;
}
}
+97
View File
@@ -0,0 +1,97 @@
<?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;
use Symfony\Component\Console\Input\InputOption;
class RulesCommand extends Command
{
protected function configure(): void
{
$this
->setName('rules')
->setDescription('List all available accessibility scanning rules')
->addOption('search', 's', InputOption::VALUE_REQUIRED, 'Search rules by ID or description')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Filter rules by maximum level (1-5)')
->addOption('standard', null, InputOption::VALUE_REQUIRED, 'Filter rules by standard (A, AA, AAA)')
->addOption('severity', null, InputOption::VALUE_REQUIRED, 'Filter rules by severity (error, warning, info)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title("Accessibility Console - Available Rules");
$searchQuery = $input->getOption('search');
$levelFilter = $input->getOption('level');
$standardFilter = $input->getOption('standard');
$severityFilter = $input->getOption('severity');
$maxLevel = is_numeric($levelFilter) ? (int)$levelFilter : 9;
$rules = RuleLevels::getRulesForLevel($maxLevel);
$table = new Table($output);
$table->setHeaders(['Rule ID', 'Standard', 'Severity', 'Level', 'Description']);
$matchedCount = 0;
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;
// Search filter
if (is_string($searchQuery) && $searchQuery !== '') {
$query = strtolower($searchQuery);
if (!str_contains(strtolower($ruleId), $query) && !str_contains(strtolower($description), $query)) {
continue;
}
}
// Standard filter
if (is_string($standardFilter) && $standardFilter !== '') {
if (strcasecmp($standard, $standardFilter) !== 0) {
continue;
}
}
// Severity filter
if (is_string($severityFilter) && $severityFilter !== '') {
if (strcasecmp($severity, $severityFilter) !== 0) {
continue;
}
}
$table->addRow([
"<fg=cyan;options=bold>$ruleId</>",
$standard,
$severity,
$level,
$description
]);
$matchedCount++;
}
$table->render();
$io->newLine();
if (is_string($searchQuery) || $levelFilter !== null || is_string($standardFilter) || is_string($severityFilter)) {
$io->note(sprintf("Showing %d matching rules (out of %d total rules).", $matchedCount, count(RuleLevels::getRulesForLevel(9))));
} else {
$io->note("Total rules available: " . count($rules));
}
return Command::SUCCESS;
}
}
+544 -59
View File
@@ -3,111 +3,596 @@
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\Core\Severity;
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\Reporting\SarifFormatter;
use YakNet\AccessibilityConsole\Reporting\JunitFormatter;
use YakNet\AccessibilityConsole\Reporting\PdfReportGenerator;
use YakNet\AccessibilityConsole\Reporting\ExcelReportExporter;
use YakNet\AccessibilityConsole\Badges\BadgeGenerator;
use YakNet\AccessibilityConsole\Presets\PresetRegistry;
use YakNet\AccessibilityConsole\Source\SourceLocator;
class ScanCommand extends Command
{
protected static $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('preset', 'p', InputOption::VALUE_REQUIRED, 'Use a pre-configured audit profile (section508, en301549, ecommerce)')
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (console, json, github, xml, markdown, csv, gitlab, sarif, junit)')
->addOption('min-score', null, InputOption::VALUE_REQUIRED, 'Minimum required accessibility health score (0-100) to pass the quality gate')
->addOption('max-warnings', null, InputOption::VALUE_REQUIRED, 'Maximum allowed warnings before failing the quality gate')
->addOption('fail-on', null, InputOption::VALUE_REQUIRED, 'Minimum severity to fail on (error, warning, info)')
->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('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html, json, sarif, xml, junit, pdf, xlsx)')
->addOption('badge', null, InputOption::VALUE_REQUIRED, 'Save SVG accessibility health badge to file (e.g. a11y-badge.svg)')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively (URL scans only)')
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth (URL scans only)', '3')
->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum pages to crawl (URL scans only)', '20')
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
$enableAi = $input->getOption('ai');
$projectPath = $input->getOption('project-path') ?? getcwd();
// 1. Resolve configuration
$configPathOption = $input->getOption('config');
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
$config = Config::fromYaml($configPath);
$io->title("Accessibility Console - Scanning: $target");
// 2. Resolve parameters (CLI options override config file)
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
// 1. Fetch HTML
$html = @file_get_contents($target);
if (!$html) {
$io->error("Could not read target: $target");
return Command::FAILURE;
$presetOption = $input->getOption('preset');
$activePreset = null;
if (is_string($presetOption) && $presetOption !== '') {
$presetRegistry = new PresetRegistry();
$activePreset = $presetRegistry->get($presetOption);
if ($activePreset !== null) {
$level = $activePreset->getLevel();
} else {
$io->warning("Preset '{$presetOption}' not found. Using default rules.");
}
}
// 2. Setup Scanner
$formatOption = $input->getOption('format');
$format = is_string($formatOption) ? $formatOption : $config->getFormat();
$baselinePathOption = $input->getOption('baseline');
$baselinePath = is_string($baselinePathOption) ? $baselinePathOption : $config->getBaselinePath();
if (!$baselinePath && $input->getOption('generate-baseline')) {
$baselinePath = 'a11y-baseline.json'; // default baseline filename
}
$target = $input->getArgument('target');
$isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://'));
// If target is a URL, run the legacy URL scanning / crawling logic
if ($isUrl) {
return $this->executeUrlScan($target, $level, $input, $output, $io);
}
// --- LOCAL DIRECTORY / FILE STATIC ANALYSIS ENGINE ---
if ($format === 'console') {
$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</>");
if ($activePreset !== null) {
$io->writeln("<fg=gray>Compliance Preset:</> <fg=magenta>" . $activePreset->getName() . " (" . $activePreset->getDescription() . ")</>");
}
}
// Resolve paths to scan
$paths = $config->getPaths();
if (is_string($target) && $target !== '') {
$paths = [$target];
}
// Setup Analyser
$scanner = new Scanner();
foreach (StandardRuleSet::all() as $rule) {
$rules = RuleLevels::getRulesForLevel($level);
// Filter rules based on config
$rulesConfig = $config->getRulesConfig();
$activeRules = [];
foreach ($rules as $rule) {
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: (new \ReflectionClass($rule))->getShortName();
if (in_array($ruleId, $rulesConfig['exclude'], true)) {
continue;
}
$activeRules[$ruleId] = $rule;
}
foreach ($rulesConfig['include'] as $customRuleClass) {
if (class_exists($customRuleClass)) {
try {
$reflection = new \ReflectionClass($customRuleClass);
if ($reflection->isInstantiable()) {
$rule = $reflection->newInstance();
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
: $reflection->getShortName();
$activeRules[$ruleId] = $rule;
}
} catch (\Throwable $e) {}
}
}
foreach ($activeRules as $rule) {
$scanner->addRule($rule);
}
// 3. Scan
$violations = $scanner->scan($html);
// Setup Baseline Manager
$baselineManager = new BaselineManager();
if ($baselinePath && !$input->getOption('generate-baseline')) {
$baselineManager->load(getcwd() . '/' . $baselinePath);
}
if (empty($violations)) {
$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();
} elseif ($format === 'sarif') {
$formatter = new SarifFormatter();
} elseif ($format === 'junit') {
$formatter = new JunitFormatter();
}
$exitCode = $formatter->format($violations, $baselinedCount, $io);
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
$score = $metrics['score'];
if ($format === 'console') {
$io->section("Accessibility Ratings");
$color = $score >= 90 ? 'green' : ($score >= 70 ? 'yellow' : 'red');
$io->writeln(sprintf("Health Score: <fg=%s>%d/100</>", $color, $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();
}
// Quality Gate evaluation
$minScoreOption = $input->getOption('min-score');
$maxWarningsOption = $input->getOption('max-warnings');
$failOnOption = $input->getOption('fail-on');
$errorCount = 0;
$warningCount = 0;
foreach ($violations as $v) {
if ($v->severity === Severity::CRITICAL || $v->severity === Severity::ERROR) {
$errorCount++;
} elseif ($v->severity === Severity::WARNING) {
$warningCount++;
}
}
$qualityGateFailed = false;
/** @var string[] $gateFailures */
$gateFailures = [];
if ($minScoreOption !== null && is_numeric($minScoreOption)) {
$minScore = (int)$minScoreOption;
if ($score < $minScore) {
$qualityGateFailed = true;
$gateFailures[] = sprintf("Accessibility score (%d/100) is lower than the minimum required threshold of %d/100.", $score, $minScore);
}
}
if ($maxWarningsOption !== null && is_numeric($maxWarningsOption)) {
$maxWarnings = (int)$maxWarningsOption;
if ($warningCount > $maxWarnings) {
$qualityGateFailed = true;
$gateFailures[] = sprintf("Warning count (%d) exceeds the maximum allowed limit of %d.", $warningCount, $maxWarnings);
}
}
if (is_string($failOnOption)) {
$failOn = strtolower($failOnOption);
if ($failOn === 'error') {
$exitCode = ($errorCount > 0) ? Command::FAILURE : Command::SUCCESS;
} elseif ($failOn === 'warning') {
$exitCode = ($errorCount > 0 || $warningCount > 0) ? Command::FAILURE : Command::SUCCESS;
}
}
if ($qualityGateFailed) {
$io->newLine();
$io->error("CI/CD Quality Gate Failed:\n- " . implode("\n- ", $gateFailures));
$exitCode = Command::FAILURE;
}
// Export report if --report is specified
$reportPath = $input->getOption('report');
if (is_string($reportPath) && $reportPath !== '') {
$this->exportReport($reportPath, $violations, 'Static Analysis', $io);
}
// Export SVG badge if --badge is specified
$badgePath = $input->getOption('badge');
if (is_string($badgePath) && $badgePath !== '') {
file_put_contents($badgePath, BadgeGenerator::generateScoreBadge($score));
$io->success("SVG Badge saved to: $badgePath");
}
return $exitCode;
}
/**
* Legacy URL scanning and crawling logic
*/
private function executeUrlScan(string $target, int $level, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
{
$enableAi = (bool)$input->getOption('ai');
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$io->title("Accessibility Console - Scanning URL: $target (Level $level)");
// 1. Setup Scanner
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
// 2. Fetch and Scan
$crawl = (bool)$input->getOption('crawl');
$useJs = (bool)$input->getOption('js');
$chromeRenderer = $useJs ? new \YakNet\AccessibilityConsole\Core\ChromeRenderer() : null;
$results = [];
if ($crawl) {
$depthOption = $input->getOption('depth');
$depthLimit = is_numeric($depthOption) ? (int)$depthOption : 3;
$maxPagesOption = $input->getOption('max-pages');
$maxPages = is_numeric($maxPagesOption) ? (int)$maxPagesOption : 20;
$io->comment("Starting recursive crawl from: $target (Max Depth: $depthLimit, Max Pages: $maxPages)");
$parsedBase = parse_url($target);
$baseHost = $parsedBase['host'] ?? '';
$queue = [['url' => $target, 'depth' => 1]];
$visited = [];
$client = new \GuzzleHttp\Client([
'timeout' => 10,
'allow_redirects' => true,
'headers' => [
'User-Agent' => 'YakNet Accessibility Console Crawler/1.0',
]
]);
while (!empty($queue) && count($visited) < $maxPages) {
$current = array_shift($queue);
$url = $current['url'];
$depth = (int)$current['depth'];
$cleanUrl = strtok($url, '#');
if ($cleanUrl === false || isset($visited[$cleanUrl])) {
continue;
}
$visited[$cleanUrl] = true;
$io->write(" <fg=gray>Crawling [Depth $depth]:</> <fg=cyan>$cleanUrl</>... ");
try {
if ($chromeRenderer) {
$html = $chromeRenderer->render($cleanUrl);
} else {
$response = $client->request('GET', $cleanUrl);
$html = (string)$response->getBody();
}
$io->writeln("<fg=green>OK</>");
$pageViolations = $scanner->scan($html);
$results[$cleanUrl] = $pageViolations;
if ($depth < $depthLimit) {
$html5 = new \Masterminds\HTML5(['disable_html_ns' => true]);
$doc = $html5->loadHTML($html);
$links = $doc->getElementsByTagName('a');
foreach ($links as $link) {
$href = $link->getAttribute('href');
if (empty($href) || str_starts_with($href, 'javascript:') || str_starts_with($href, 'mailto:') || str_starts_with($href, 'tel:')) {
continue;
}
try {
$baseUri = new \GuzzleHttp\Psr7\Uri($cleanUrl);
$relativeUri = new \GuzzleHttp\Psr7\Uri($href);
$absoluteUri = \GuzzleHttp\Psr7\UriResolver::resolve($baseUri, $relativeUri);
$absoluteUrl = (string)$absoluteUri;
} catch (\Throwable) {
continue;
}
$parsedAbsolute = parse_url($absoluteUrl);
$absoluteHost = $parsedAbsolute['host'] ?? '';
if ($absoluteHost === $baseHost) {
$cleanAbsolute = strtok($absoluteUrl, '#');
if ($cleanAbsolute !== false && !isset($visited[$cleanAbsolute])) {
$inQueue = false;
foreach ($queue as $q) {
if ($q['url'] === $cleanAbsolute) {
$inQueue = true;
break;
}
}
if (!$inQueue) {
$queue[] = ['url' => $cleanAbsolute, 'depth' => $depth + 1];
}
}
}
}
}
} catch (\Throwable $e) {
$io->writeln("<fg=red>FAIL (" . $e->getMessage() . ")</>");
}
}
} else {
if ($chromeRenderer) {
try {
$html = $chromeRenderer->render($target);
} catch (\Throwable $e) {
$io->error("Failed JavaScript rendering: " . $e->getMessage());
return Command::FAILURE;
}
} else {
$html = @file_get_contents($target);
}
if ($html === false || $html === '') {
$io->error("Could not read target URL: $target");
return Command::FAILURE;
}
$violations = $scanner->scan($html);
$results[$target] = $violations;
}
$totalViolations = 0;
foreach ($results as $url => $violations) {
$totalViolations += count($violations);
}
if ($totalViolations === 0) {
$io->success("No violations found!");
return Command::SUCCESS;
}
$io->section("Found " . count($violations) . " violations");
$io->section("Found a total of $totalViolations violations across " . count($results) . " pages");
// 4. Setup Helpers
$locator = new SourceLocator($projectPath);
$config = new Config();
$fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null;
foreach ($violations as $violation) {
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
// Show Snippet
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
// Source Mapping
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
foreach ($results as $url => $violations) {
if (empty($violations)) {
continue;
}
$io->section("Page: $url (" . count($violations) . " violations)");
// AI Fixer
if ($enableAi && $fixer) {
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation);
if ($suggestion) {
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
} else {
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
foreach ($violations as $violation) {
$io->writeln("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
$violation->location = $location;
}
if ($enableAi && $fixer) {
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation);
if ($suggestion) {
$violation->fixSuggestion = $suggestion;
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
} else {
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
}
}
$io->writeln("");
}
}
$reportPath = $input->getOption('report');
if (is_string($reportPath) && $reportPath !== '') {
$allViolations = [];
foreach ($results as $urlViolations) {
$allViolations = array_merge($allViolations, $urlViolations);
}
$this->exportReport($reportPath, $allViolations, $target, $io, $results);
}
return Command::FAILURE;
}
/**
* Export violations to various file formats (HTML, JSON, SARIF, XML/JUnit, PDF, Excel).
*
* @param string $reportPath
* @param \YakNet\AccessibilityConsole\Core\Violation[] $violations
* @param string $targetName
* @param SymfonyStyle $io
* @param array<string, \YakNet\AccessibilityConsole\Core\Violation[]>|null $pageResults
*/
private function exportReport(
string $reportPath,
array $violations,
string $targetName,
SymfonyStyle $io,
?array $pageResults = null
): void {
$extension = strtolower(pathinfo($reportPath, PATHINFO_EXTENSION));
if ($extension === 'json') {
if ($pageResults !== null) {
$serialized = [];
foreach ($pageResults as $url => $pageViolations) {
$serialized[$url] = array_map(fn($v) => $v->toArray(), $pageViolations);
}
} else {
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
}
$io->writeln("");
}
$io->warning("Scanned " . count($violations) . " errors. Use --ai to get fix suggestions.");
// Export Report
$reportPath = $input->getOption('report');
if ($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));
} else {
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
file_put_contents($reportPath, $reporter->render($violations, $target));
file_put_contents($reportPath, (string)json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
} elseif ($extension === 'sarif') {
$sarifFormatter = new SarifFormatter();
file_put_contents($reportPath, $sarifFormatter->render($violations));
} elseif ($extension === 'xml' || $extension === 'junit') {
$junitFormatter = new JunitFormatter();
file_put_contents($reportPath, $junitFormatter->render($violations));
} elseif ($extension === 'pdf') {
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
$io->success("Report saved to: $reportPath");
$scanResult = [
'score' => $metrics['score'],
'violations' => $serialized,
];
file_put_contents($reportPath, PdfReportGenerator::generatePdf($scanResult, $targetName));
} elseif ($extension === 'xlsx' || $extension === 'xls') {
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
$scanResult = [
'score' => $metrics['score'],
'violations' => $serialized,
];
file_put_contents($reportPath, ExcelReportExporter::exportExcel($scanResult, $targetName));
} else {
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
$mapped = $pageResults ?? [$targetName => $violations];
file_put_contents($reportPath, $reporter->render($mapped, $targetName));
}
return Command::SUCCESS;
$io->success("Report saved to: $reportPath");
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
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\Core\Analyser;
use YakNet\AccessibilityConsole\Core\BaselineManager;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class WatchCommand extends Command
{
protected function configure(): void
{
$this
->setName('watch')
->setDescription('Continuously watch templates and files for changes and automatically re-scan')
->addArgument('target', InputArgument::OPTIONAL, 'The file or directory path to watch (defaults to config paths)')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to configuration file (default: a11y.yaml)')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-5)')
->addOption('interval', 'i', InputOption::VALUE_REQUIRED, 'Watch poll interval in seconds', '1')
->addOption('max-iterations', null, InputOption::VALUE_REQUIRED, 'Max loop iterations (used for testing/debugging)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$configPathOption = $input->getOption('config');
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
$config = Config::fromYaml($configPath);
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
$target = $input->getArgument('target');
$paths = $config->getPaths();
if (is_string($target) && $target !== '') {
$paths = [$target];
}
$intervalOption = $input->getOption('interval');
$interval = is_numeric($intervalOption) ? (int)$intervalOption : 1;
if ($interval < 1) {
$interval = 1;
}
$maxIterationsOption = $input->getOption('max-iterations');
$maxIterations = is_numeric($maxIterationsOption) ? (int)$maxIterationsOption : null;
$io->title("Accessibility Console - Real-Time Watch Mode");
$io->writeln("<fg=gray>Watching paths:</> <fg=cyan>" . implode(', ', $paths) . "</>");
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
$io->writeln("<fg=gray>Poll Interval:</> <fg=yellow>{$interval}s</>");
$io->note("Press Ctrl+C to stop watching.");
// Build Scanner
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
$analyser = new Analyser($config, $scanner, new BaselineManager());
// Perform initial scan
$io->section("Initial Scan");
$this->runScanAndPrint($analyser, $paths, $io);
// Map existing files to their last modified timestamp
$fileMtimes = $this->collectFileMtimes($paths, $config->getExcludePaths());
$iteration = 0;
while (true) {
if ($maxIterations !== null && $iteration >= $maxIterations) {
break;
}
$iteration++;
sleep($interval);
$currentMtimes = $this->collectFileMtimes($paths, $config->getExcludePaths());
$changedFiles = $this->detectChangedFiles($fileMtimes, $currentMtimes);
if (!empty($changedFiles)) {
$fileMtimes = $currentMtimes;
$timestamp = date('H:i:s');
$io->newLine();
$io->writeln(sprintf(
"<fg=yellow>[%s]</> Change detected in <fg=cyan>%s</>. Re-scanning...",
$timestamp,
basename($changedFiles[0]) . (count($changedFiles) > 1 ? ' (+' . (count($changedFiles) - 1) . ' more)' : '')
));
// Re-scan changed files or paths
$this->runScanAndPrint($analyser, $changedFiles, $io);
}
}
return Command::SUCCESS;
}
/**
* @param Analyser $analyser
* @param string[] $paths
* @param SymfonyStyle $io
*/
private function runScanAndPrint(Analyser $analyser, array $paths, SymfonyStyle $io): void
{
$start = microtime(true);
$result = $analyser->analyse($paths);
$elapsed = round(microtime(true) - $start, 3);
$violations = $result['violations'];
$formatter = new ConsoleFormatter();
$formatter->format($violations, $result['baselinedCount'], $io);
$metrics = AccessibilityScoreCalculator::calculate($violations);
$score = $metrics['score'];
$color = $score >= 90 ? 'green' : ($score >= 70 ? 'yellow' : 'red');
$io->writeln(sprintf(
"Health Score: <fg=%s;options=bold>%d/100</> | Scan time: <fg=gray>%ss</>",
$color,
$score,
$elapsed
));
}
/**
* @param string[] $paths
* @param string[] $excludePaths
* @return array<string, int>
*/
public function collectFileMtimes(array $paths, array $excludePaths): array
{
$mtimes = [];
$extensions = ['php', 'html', 'twig', 'blade.php'];
foreach ($paths as $path) {
if (is_file($path)) {
$mtimes[$path] = (int)filemtime($path);
continue;
}
if (!is_dir($path)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS)
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (!$file->isFile()) {
continue;
}
$filename = $file->getFilename();
$ext = $file->getExtension();
$isMatching = in_array($ext, ['php', 'html', 'twig'], true) || str_ends_with($filename, '.blade.php');
if (!$isMatching) {
continue;
}
$filePath = (string)$file->getRealPath();
$excluded = false;
foreach ($excludePaths as $ex) {
if (str_contains(str_replace('\\', '/', $filePath), '/' . trim($ex, '/') . '/')) {
$excluded = true;
break;
}
}
if (!$excluded) {
$mtimes[$filePath] = (int)$file->getMTime();
}
}
}
return $mtimes;
}
/**
* @param array<string, int> $oldMtimes
* @param array<string, int> $newMtimes
* @return string[]
*/
public function detectChangedFiles(array $oldMtimes, array $newMtimes): array
{
$changed = [];
foreach ($newMtimes as $file => $mtime) {
if (!isset($oldMtimes[$file]) || $oldMtimes[$file] !== $mtime) {
$changed[] = $file;
}
}
foreach ($oldMtimes as $file => $mtime) {
if (!isset($newMtimes[$file])) {
$changed[] = $file;
}
}
return array_values(array_unique($changed));
}
}
+33 -7
View File
@@ -10,22 +10,48 @@ abstract class AbstractRule
abstract public function check(\DOMDocument $doc): array;
/**
* Get the WCAG standard ID (e.g., WCAG 2.1 1.1.1)
* Get the WCAG standard Enum
*/
abstract public function getStandardId(): string;
abstract public function getStandard(): WCAGStandard;
/**
* Get the severity level (Critical, Major, Minor)
* Get the severity level Enum
*/
abstract public function getSeverity(): string;
abstract public function getSeverity(): Severity;
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 = '';
if ($doc !== null) {
$htmlVal = $doc->saveHTML($element);
if (is_string($htmlVal)) {
$html = $htmlVal;
}
}
return new Violation(
ruleId: $this->getId(),
message: $message,
htmlSnippet: $element->ownerDocument->saveHTML($element),
severity: $this->getSeverity(),
standard: $this->getStandardId()
standard: $this->getStandard(),
htmlSnippet: $html,
fixSuggestion: $suggestion
);
}
}
+241
View File
@@ -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);
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
class BaselineManager
{
/** @var array<string, bool> */
private array $ignoredKeys = [];
public function load(string $baselinePath): bool
{
if (!file_exists($baselinePath) || !is_readable($baselinePath)) {
return false;
}
$content = file_get_contents($baselinePath);
if ($content === false) {
return false;
}
$data = json_decode($content, true);
if (!is_array($data) || !isset($data['violations']) || !is_array($data['violations'])) {
return false;
}
foreach ($data['violations'] as $item) {
if (!is_array($item)) {
continue;
}
if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])
&& is_string($item['file']) && is_string($item['ruleId']) && is_string($item['htmlSnippet'])
) {
$key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']);
$this->ignoredKeys[$key] = true;
}
}
return true;
}
/**
* @param Violation[] $violations
*/
public function generate(array $violations, string $baselinePath): bool
{
$serialized = [
'generator' => 'YakNet Accessibility Console',
'violations' => []
];
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
$serialized['violations'][] = [
'file' => $file,
'ruleId' => $v->ruleId,
'htmlSnippet' => $v->htmlSnippet
];
}
// Sort baseline violations by file and ruleId for cleaner git diffs
usort($serialized['violations'], function ($a, $b) {
$fileCmp = strcmp($a['file'], $b['file']);
if ($fileCmp !== 0) {
return $fileCmp;
}
return strcmp($a['ruleId'], $b['ruleId']);
});
$json = json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return false;
}
$dir = dirname($baselinePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return file_put_contents($baselinePath, $json) !== false;
}
public function isIgnored(string $file, string $ruleId, string $htmlSnippet): bool
{
$key = $this->calculateKey($file, $ruleId, $htmlSnippet);
return isset($this->ignoredKeys[$key]);
}
private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string
{
// Normalize snippet using fuzzy baseline matcher
$normalizedSnippet = \YakNet\AccessibilityConsole\Core\Baseline\FuzzyBaselineMatcher::normalize($htmlSnippet);
if ($normalizedSnippet === '') {
$normalizedSnippet = trim($htmlSnippet);
}
// Normalize file separator to forward slashes for cross-platform compatibility
$normalizedFile = str_replace('\\', '/', $file);
return md5($normalizedFile . '::' . $ruleId . '::' . $normalizedSnippet);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use HeadlessChromium\BrowserFactory;
use HeadlessChromium\Navigation;
class ChromeRenderer
{
/**
* Navigate to URL using headless Chrome and return fully rendered HTML.
*/
public function render(string $url): string
{
$browserFactory = new BrowserFactory();
$options = [
'windowSize' => [1280, 1024],
'noSandbox' => true,
];
// Windows Chrome binary path auto-detection override
$chromeBinary = $_ENV['CHROME_BINARY'] ?? getenv('CHROME_BINARY') ?: null;
if (!$chromeBinary && PHP_OS_FAMILY === 'Windows') {
$standardPaths = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files/Google/Chrome/Application/chrome.exe'
];
foreach ($standardPaths as $path) {
if (file_exists($path)) {
$chromeBinary = $path;
break;
}
}
}
if ($chromeBinary) {
$options['chromeBinary'] = $chromeBinary;
}
$browser = $browserFactory->createBrowser($options);
try {
$page = $browser->createPage();
// Navigate and wait for onload event
$navigation = $page->navigate($url);
$navigation->waitForNavigation(\HeadlessChromium\Page::LOAD, 10000); // 10 seconds timeout
// Extra wait for async JS rendering to complete
usleep(1500000); // 1.5s
$html = $page->getHtml();
} finally {
$browser->close();
}
return $html;
}
}
+103 -2
View File
@@ -4,16 +4,44 @@ namespace YakNet\AccessibilityConsole\Core;
class Config
{
/** @var array<string, mixed> */
private array $settings = [];
/**
* @param array<string, mixed> $settings
*/
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([]);
}
@@ -22,8 +50,81 @@ class Config
return $this->settings[$key] ?? $default;
}
/**
* @return array<int, string>
*/
public function getPaths(): array
{
$paths = $this->get('paths');
if (is_array($paths)) {
return array_values(array_filter($paths, 'is_string'));
}
if (is_string($paths)) {
return [$paths];
}
return ['.']; // default to current directory
}
/**
* @return array<int, string>
*/
public function getExcludePaths(): array
{
$exclude = $this->get('exclude_paths');
if (is_array($exclude)) {
return array_values(array_filter($exclude, 'is_string'));
}
if (is_string($exclude)) {
return [$exclude];
}
return ['vendor', 'node_modules', 'tests', 'storage', '.git']; // sensible defaults
}
public function getLevel(): int
{
$level = $this->get('level', 4);
return is_numeric($level) ? (int)$level : 4;
}
public function getBaselinePath(): ?string
{
$baseline = $this->get('baseline');
return is_string($baseline) ? $baseline : null;
}
public function getFormat(): string
{
$format = $this->get('format', 'console');
return is_string($format) ? $format : 'console';
}
/**
* @return array{include: array<int, string>, exclude: array<int, string>}
*/
public function getRulesConfig(): array
{
$rules = $this->get('rules', []);
$include = [];
$exclude = [];
if (is_array($rules)) {
if (isset($rules['include']) && is_array($rules['include'])) {
$include = array_values(array_filter($rules['include'], 'is_string'));
}
if (isset($rules['exclude']) && is_array($rules['exclude'])) {
$exclude = array_values(array_filter($rules['exclude'], 'is_string'));
}
}
return [
'include' => $include,
'exclude' => $exclude
];
}
public function getGeminiApiKey(): ?string
{
return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
$key = $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null;
return is_string($key) ? $key : null;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Config;
class SmartPresetBuilder
{
/**
* Analyze HTML content features to suggest rules presets.
*
* @param string $html
* @return array{suggestedLevel: int, tagsCheckedCount: array<string, int>, recommendedExclusions: string[]}
*/
public function analyze(string $html): array
{
$tagsChecked = ['img' => 0, 'form' => 0, 'table' => 0, 'svg' => 0, 'video' => 0];
foreach (array_keys($tagsChecked) as $tag) {
$tagsChecked[$tag] = substr_count(strtolower($html), '<' . $tag);
}
// Calculate recommendations
$suggestedLevel = 3; // Standard baseline
if ($tagsChecked['form'] > 5 || $tagsChecked['table'] > 3) {
$suggestedLevel = 4; // Higher complexity requires more strict tests
}
if ($tagsChecked['video'] > 0) {
$suggestedLevel = 5; // Video elements need caption checks, standard level 5
}
$exclusions = [];
if ($tagsChecked['svg'] === 0) {
$exclusions[] = 'WCAG_1_1_1_SVG';
}
if ($tagsChecked['video'] === 0) {
$exclusions[] = 'WCAG_1_2_2_VIDEO_TRACK';
}
return [
'suggestedLevel' => $suggestedLevel,
'tagsCheckedCount' => $tagsChecked,
'recommendedExclusions' => $exclusions,
];
}
}
+62
View File
@@ -0,0 +1,62 @@
<?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;
use YakNet\AccessibilityConsole\Console\Command\WatchCommand;
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();
},
WatchCommand::class => function () {
return new WatchCommand();
},
]);
return $builder->build();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Diff;
use SebastianBergmann\Diff\Differ;
use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
class VisualDiffGenerator
{
private Differ $differ;
public function __construct()
{
$this->differ = new Differ(new UnifiedDiffOutputBuilder("--- Original\n+++ Healed\n"));
}
/**
* Generate a colorized git-style unified diff string.
*
* @param string $original
* @param string $fixed
* @return string
*/
public function generate(string $original, string $fixed): string
{
$diff = $this->differ->diff($original, $fixed);
$lines = explode("\n", $diff);
$colorized = [];
foreach ($lines as $line) {
if (str_starts_with($line, '+') && !str_starts_with($line, '+++')) {
$colorized[] = "<fg=green>" . htmlspecialchars($line) . "</>";
} elseif (str_starts_with($line, '-') && !str_starts_with($line, '---')) {
$colorized[] = "<fg=red>" . htmlspecialchars($line) . "</>";
} elseif (str_starts_with($line, '@@')) {
$colorized[] = "<fg=cyan>" . htmlspecialchars($line) . "</>";
} elseif (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
$colorized[] = "<fg=gray>" . htmlspecialchars($line) . "</>";
} else {
$colorized[] = htmlspecialchars($line);
}
}
return implode("\n", $colorized);
}
}
+107
View File
@@ -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;
}
}
+53 -3
View File
@@ -2,26 +2,76 @@
namespace YakNet\AccessibilityConsole\Core;
use DOMDocument;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Rules\RuleInterface;
class RuleEngine
{
/** @var AbstractRule[] */
/** @var array<int, mixed> */
private array $rules = [];
public function addRule(AbstractRule $rule): void
public function addRule(mixed $rule): void
{
$this->rules[] = $rule;
}
/**
* @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) {
$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);
}
// 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) {
continue;
}
foreach ($elementRules as $rule) {
$violation = $rule->check($element);
if ($violation !== null) {
$allViolations[] = $violation;
}
}
}
}
}
return $allViolations;
}
}
+89
View File
@@ -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',
];
}
}
+22 -7
View File
@@ -4,11 +4,21 @@ namespace YakNet\AccessibilityConsole\Core;
class Scanner
{
public function __construct(private ?RuleEngine $engine = null)
private RuleEngine $engine;
public function __construct(?RuleEngine $engine = null)
{
if ($this->engine === null) {
$this->engine = new RuleEngine();
}
$this->engine = $engine ?? new RuleEngine();
}
public function addRule(mixed $rule): void
{
$this->engine->addRule($rule);
}
public function getEngine(): RuleEngine
{
return $this->engine;
}
/**
@@ -23,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);
}
+143
View File
@@ -0,0 +1,143 @@
<?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_1_1_1_IMG_ALT':
case 'WCAG_1_1_1_ImageAlt':
case 'ImageAlt':
case 'ImgAltText':
if (preg_match('/<img\b(?![^>]*\balt=)/i', $snippet)) {
$fixed = preg_replace('/<img\b/i', '<img alt=""', $snippet) ?: $snippet;
return "EXPLANATION: Injected missing alt=\"\" attribute for decorative image.\nFIX: " . $fixed;
}
break;
case 'WCAG_1_1_1_SVG':
case 'SvgAltText':
if (preg_match('/<svg\b(?![^>]*\baria-hidden=)(?![^>]*\baria-label=)/i', $snippet) && !str_contains($snippet, '<title>')) {
$fixed = preg_replace('/<svg\b/i', '<svg aria-hidden="true"', $snippet) ?: $snippet;
return "EXPLANATION: Injected aria-hidden=\"true\" to decorative SVG element.\nFIX: " . $fixed;
}
break;
case 'WCAG_LINK_TARGET_BLANK':
case 'LinkTargetBlank':
if (preg_match('/<a\b[^>]*\btarget=["\']_blank["\']/i', $snippet)) {
if (preg_match('/rel=["\']([^"\']*)["\']/i', $snippet, $matches)) {
$existingRel = $matches[1];
$newRel = trim($existingRel . ' noopener noreferrer');
$fixed = str_replace($matches[0], 'rel="' . $newRel . '"', $snippet);
} else {
$fixed = preg_replace('/<a\b/i', '<a rel="noopener noreferrer"', $snippet) ?: $snippet;
}
return "EXPLANATION: Added rel=\"noopener noreferrer\" to target=\"_blank\" link.\nFIX: " . $fixed;
}
break;
case 'WCAG_2_4_3_TABINDEX':
case 'TabindexOrder':
if (preg_match('/\btabindex=["\'][1-9]\d*["\']/i', $snippet)) {
$fixed = preg_replace('/\btabindex=["\'][1-9]\d*["\']/i', 'tabindex="0"', $snippet) ?: $snippet;
return "EXPLANATION: Normalized positive tabindex to tabindex=\"0\" to maintain natural tab order.\nFIX: " . $fixed;
}
break;
case 'WCAG_1_3_1_TABLE_SCOPE':
case 'ScopeOnlyOnHeaders':
if (preg_match('/<td\b[^>]*\bscope=["\'][^"\']*["\']/i', $snippet)) {
$fixed = preg_replace('/\s+scope=["\'][^"\']*["\']/i', '', $snippet) ?: $snippet;
return "EXPLANATION: Removed invalid scope attribute from <td> element.\nFIX: " . $fixed;
}
break;
case 'WCAG_2_2_1_META_REFRESH':
case 'MetaRefreshBanned':
if (preg_match('/<meta\b[^>]*\bhttp-equiv=["\']refresh["\'][^>]*>/i', $snippet)) {
$fixed = '<!-- ' . trim($snippet) . ' (Removed: Meta refresh disorients users) -->';
return "EXPLANATION: Commented out disorienting meta refresh tag.\nFIX: " . $fixed;
}
break;
case 'WCAG_1_3_1_OPTGROUP_LABEL':
case 'OptgroupLabel':
if (preg_match('/<optgroup\b(?![^>]*\blabel=)/i', $snippet)) {
$fixed = preg_replace('/<optgroup\b/i', '<optgroup label="Options"', $snippet) ?: $snippet;
return "EXPLANATION: Added fallback label=\"Options\" attribute to <optgroup>.\nFIX: " . $fixed;
}
break;
case 'WCAG_2_4_7_FOCUS_OUTLINE':
case 'NoFocusOutline':
if (preg_match('/style=["\'][^"\']*outline\s*:\s*(?:none|0)[^"\']*["\']/i', $snippet)) {
$fixed = preg_replace('/outline\s*:\s*(?:none|0)/i', 'outline: 2px solid transparent; outline-offset: 2px', $snippet) ?: $snippet;
return "EXPLANATION: Replaced outline removal with accessible transparent outline.\nFIX: " . $fixed;
}
break;
case 'WCAG_2_2_2_BLINK':
case 'BlinkTagBanned':
$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':
case 'AutofocusBanned':
$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':
case 'TableSummaryBanned':
$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':
case 'ScrollableRegionFocusable':
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':
case 'PresentationTagsBanned':
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':
case 'HtmlHasLang':
if (preg_match('/<html(\s+[^>]*>|>)/i', $snippet)) {
$fixed = preg_replace('/<html/i', '<html lang="tr"', $snippet) ?: $snippet;
return "EXPLANATION: Injected default lang=\"tr\" to HTML element.\nFIX: " . $fixed;
}
break;
}
return null;
}
}
+8 -2
View File
@@ -2,18 +2,24 @@
namespace YakNet\AccessibilityConsole\Core;
readonly class Violation
class Violation
{
/**
* @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, column?: int}|null, fixSuggestion: string|null}
*/
public function toArray(): array
{
return [
@@ -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);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
use GuzzleHttp\Client;
class DiscordNotifier implements NotifierInterface
{
private Client $httpClient;
public function __construct(
private string $webhookUrl,
?Client $httpClient = null
) {
$this->httpClient = $httpClient ?? new Client(['timeout' => 5.0]);
}
public function send(NotificationPayload $payload): bool
{
if ($this->webhookUrl === '') {
return false;
}
// Color in decimal for Discord embed
$color = $payload->healthScore >= 90 ? 0x10b981 : ($payload->healthScore >= 70 ? 0xf59e0b : 0xef4444);
$body = [
'embeds' => [
[
'title' => "♿ Accessibility Audit: {$payload->target}",
'color' => $color,
'fields' => [
[
'name' => 'Health Score',
'value' => sprintf('%d / 100', $payload->healthScore),
'inline' => true,
],
[
'name' => 'Total Violations',
'value' => (string)$payload->totalViolations,
'inline' => true,
],
[
'name' => 'Breakdown',
'value' => sprintf('Errors: %d | Warnings: %d', $payload->errorCount, $payload->warningCount),
'inline' => false,
],
],
'footer' => [
'text' => 'YakNet Accessibility Console',
],
'timestamp' => date('c'),
],
],
];
try {
$response = $this->httpClient->post($this->webhookUrl, [
'json' => $body,
]);
return $response->getStatusCode() < 300;
} catch (\Throwable) {
return false;
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
class NotificationDispatcher
{
/**
* @var array<string, NotifierInterface>
*/
private array $notifiers = [];
public function addNotifier(string $channel, NotifierInterface $notifier): void
{
$this->notifiers[$channel] = $notifier;
}
/**
* Dispatch notification payload across all registered notifiers.
*
* @return array<string, bool> Channel delivery results
*/
public function dispatch(NotificationPayload $payload): array
{
$results = [];
foreach ($this->notifiers as $channel => $notifier) {
$results[$channel] = $notifier->send($payload);
}
return $results;
}
public function hasNotifiers(): bool
{
return !empty($this->notifiers);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
class NotificationPayload
{
/**
* @param array<int, array<string, mixed>> $topViolations
*/
public function __construct(
public string $target,
public int $healthScore,
public int $totalViolations,
public int $errorCount,
public int $warningCount,
public array $topViolations = []
) {}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
interface NotifierInterface
{
public function send(NotificationPayload $payload): bool;
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
use GuzzleHttp\Client;
class SlackNotifier implements NotifierInterface
{
private Client $httpClient;
public function __construct(
private string $webhookUrl,
?Client $httpClient = null
) {
$this->httpClient = $httpClient ?? new Client(['timeout' => 5.0]);
}
public function send(NotificationPayload $payload): bool
{
if ($this->webhookUrl === '') {
return false;
}
$color = $payload->healthScore >= 90 ? '#10b981' : ($payload->healthScore >= 70 ? '#f59e0b' : '#ef4444');
$status = $payload->healthScore >= 90 ? 'PASSED' : 'FAILED';
$body = [
'attachments' => [
[
'color' => $color,
'title' => "♿ Accessibility Audit: {$payload->target}",
'text' => sprintf(
"*Health Score:* %d/100 (%s)\n*Total Violations:* %d (Errors: %d, Warnings: %d)",
$payload->healthScore,
$status,
$payload->totalViolations,
$payload->errorCount,
$payload->warningCount
),
'footer' => 'YakNet Accessibility Console',
'ts' => time(),
],
],
];
try {
$response = $this->httpClient->post($this->webhookUrl, [
'json' => $body,
]);
return $response->getStatusCode() < 300;
} catch (\Throwable) {
return false;
}
}
}
+33
View File
@@ -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;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class HtmlParser implements ParserInterface
{
public function parse(string $content): string
{
return $content;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
interface ParserInterface
{
/**
* Parse template file contents and strip out dynamic content placeholders
* to return clean, standard HTML for scanning.
*
* @param string $content
* @return string
*/
public function parse(string $content): string;
}
+74
View File
@@ -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;
}
}
+27
View File
@@ -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;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Presets;
class ECommercePreset implements PresetInterface
{
public function getName(): string
{
return 'ecommerce';
}
public function getDescription(): string
{
return 'E-Commerce storefront and checkout flow accessibility profile.';
}
public function getLevel(): int
{
return 4;
}
public function getIncludedRules(): array
{
return [
'WCAG_1_1_1_IMG_ALT',
'WCAG_4_1_2_BUTTON',
'WCAG_1_3_1_FORM_LABEL',
'WCAG_3_3_7_REDUNDANT_ENTRY',
'WCAG_2_5_8_TARGET_SIZE',
'WCAG_1_4_3_CONTRAST',
'WCAG_4_1_2_DIALOG',
];
}
public function getExcludedRules(): array
{
return [];
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Presets;
class En301549Preset implements PresetInterface
{
public function getName(): string
{
return 'en301549';
}
public function getDescription(): string
{
return 'European Union EN 301 549 public sector digital accessibility profile.';
}
public function getLevel(): int
{
return 4;
}
public function getIncludedRules(): array
{
return [
'WCAG_1_4_3_CONTRAST',
'WCAG_2_4_7_FOCUS_OUTLINE',
'WCAG_2_5_8_TARGET_SIZE',
'WCAG_1_4_12_TEXT_SPACING',
'WCAG_2_5_3_LABEL_IN_NAME',
];
}
public function getExcludedRules(): array
{
return [];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace YakNet\AccessibilityConsole\Presets;
interface PresetInterface
{
public function getName(): string;
public function getDescription(): string;
public function getLevel(): int;
/**
* @return string[]
*/
public function getIncludedRules(): array;
/**
* @return string[]
*/
public function getExcludedRules(): array;
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace YakNet\AccessibilityConsole\Presets;
class PresetRegistry
{
/**
* @var array<string, PresetInterface>
*/
private array $presets = [];
public function __construct()
{
$this->register(new Section508Preset());
$this->register(new En301549Preset());
$this->register(new ECommercePreset());
}
public function register(PresetInterface $preset): void
{
$this->presets[strtolower($preset->getName())] = $preset;
}
public function get(string $name): ?PresetInterface
{
return $this->presets[strtolower($name)] ?? null;
}
/**
* @return array<string, PresetInterface>
*/
public function all(): array
{
return $this->presets;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Presets;
class Section508Preset implements PresetInterface
{
public function getName(): string
{
return 'section508';
}
public function getDescription(): string
{
return 'US Federal Government Section 508 accessibility compliance profile.';
}
public function getLevel(): int
{
return 3;
}
public function getIncludedRules(): array
{
return [
'WCAG_1_1_1_IMG_ALT',
'WCAG_1_3_1_TABLE',
'WCAG_1_3_1_TABLE_HEADERS_ATTR',
'WCAG_1_3_1_FORM_LABEL',
'WCAG_2_1_1_KEYBOARD_CLICK',
'WCAG_2_4_2_PAGE_TITLE',
'WCAG_3_1_1_LANG',
];
}
public function getExcludedRules(): array
{
return [];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagCriterion
{
public function __construct(
public string $id,
public string $title,
public string $level,
public string $url,
public string $description,
public string $badExample,
public string $goodExample
) {}
/**
* @return array{id: string, title: string, level: string, url: string, description: string, badExample: string, goodExample: string}
*/
public function toArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'level' => $this->level,
'url' => $this->url,
'description' => $this->description,
'badExample' => $this->badExample,
'goodExample' => $this->goodExample,
];
}
}
+305
View File
@@ -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;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\Reference;
class WcagReference
{
/**
* Look up the WCAG Success Criterion for a given rule ID.
*
* @param string $ruleId
* @return WcagCriterion|null
*/
public static function lookup(string $ruleId): ?WcagCriterion
{
return WcagDatabase::get($ruleId);
}
/**
* Get a list of all WCAG criteria mapped in the console.
*
* @return array<string, WcagCriterion>
*/
public static function getCatalog(): array
{
return WcagDatabase::all();
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class ConsoleFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
if (empty($violations)) {
$io->success("No violations found!");
if ($baselinedCount > 0) {
$io->comment("($baselinedCount violations ignored by baseline)");
}
return Command::SUCCESS;
}
// Group by file
$grouped = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$grouped[$file][] = $v;
}
// Sort files alphabetically for stable order
ksort($grouped);
foreach ($grouped as $file => $fileViolations) {
$io->section("File: " . $file);
// Sort violations by line number
usort($fileViolations, function ($a, $b) {
return ($a->location['line'] ?? 0) <=> ($b->location['line'] ?? 0);
});
foreach ($fileViolations as $v) {
$line = $v->location['line'] ?? 0;
$severityColor = 'red';
if ($v->severity->value === 'warning') {
$severityColor = 'yellow';
} elseif ($v->severity->value === 'info') {
$severityColor = 'cyan';
}
// Format the output
$io->writeln(sprintf(
" <fg=gray>Line %d:</> <fg=%s;options=bold>[%s]</> %s",
$line,
$severityColor,
$v->ruleId,
$v->message
));
// Show snippet
$snippet = (string)preg_replace('/\s+/', ' ', trim($v->htmlSnippet));
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($snippet) . "</>");
if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') {
$io->writeln(" <fg=gray>AI Suggestion:</> <fg=green>" . htmlspecialchars(trim($v->fixSuggestion)) . "</>");
}
$io->writeln("");
}
}
$totalViolations = count($violations);
$io->error(sprintf(
"Found %d %s across %d %s.",
$totalViolations,
$totalViolations === 1 ? 'violation' : 'violations',
count($grouped),
count($grouped) === 1 ? 'file' : 'files'
));
if ($baselinedCount > 0) {
$io->comment("($baselinedCount violations ignored by baseline)");
}
return Command::FAILURE;
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class CsvFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$out = fopen('php://temp', 'w+');
if ($out === false) {
return Command::FAILURE;
}
// Write header
fputcsv($out, ['Rule ID', 'Severity', 'Standard', 'File', 'Line', 'Message', 'HTML Snippet', 'Fix Suggestion'], ',', '"', '\\');
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
fputcsv($out, [
$v->ruleId,
$v->severity->value,
$v->standard->value,
$file,
$line,
$v->message,
$v->htmlSnippet,
$v->fixSuggestion
], ',', '"', '\\');
}
rewind($out);
$csv = stream_get_contents($out);
fclose($out);
$io->write($csv ?: '');
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+89
View File
@@ -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());
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
interface FormatterInterface
{
/**
* Format and output the analysis violations.
*
* @param Violation[] $violations
* @param int $baselinedCount
* @param SymfonyStyle $io
* @return int Exit code (Command::SUCCESS or Command::FAILURE)
*/
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int;
}
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\Severity;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class GitLabCodeQualityFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$quality = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 1;
// Map our severity to GitLab code quality severities:
// info, minor, major, critical, blocker
$gitLabSeverity = 'major';
if ($v->severity === Severity::CRITICAL) {
$gitLabSeverity = 'critical';
} elseif ($v->severity === Severity::WARNING) {
$gitLabSeverity = 'minor';
}
$quality[] = [
'description' => sprintf('[%s] %s', $v->ruleId, $v->message),
'fingerprint' => md5($v->ruleId . ':' . $file . ':' . $line . ':' . $v->htmlSnippet),
'severity' => $gitLabSeverity,
'location' => [
'path' => $file,
'lines' => [
'begin' => $line
]
]
];
}
$io->write((string)json_encode($quality, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class GithubFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
if (empty($violations)) {
return Command::SUCCESS;
}
foreach ($violations as $v) {
$file = $v->location['file'] ?? '';
$line = $v->location['line'] ?? 1;
// Normalize path separators to forward slashes for Github Actions compatibility
$file = str_replace('\\', '/', $file);
$type = ($v->severity->value === 'error') ? 'error' : 'warning';
$io->writeln(sprintf(
"::%s file=%s,line=%d::[%s] %s",
$type,
$file,
$line,
$v->ruleId,
$v->message
));
}
return Command::FAILURE;
}
}
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class JsonFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
$result = [
'totals' => [
'violations' => count($violations),
'baselined' => $baselinedCount
],
'violations' => $serialized
];
$io->write((string)json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use SimpleXMLElement;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Core\Violation;
class JunitFormatter implements FormatterInterface
{
/**
* @param Violation[] $violations
* @param int $baselinedCount
* @param SymfonyStyle $io
* @return int
*/
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$xml = $this->render($violations, $baselinedCount);
$io->write($xml);
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
/**
* Render the violations in JUnit XML format.
*
* @param Violation[] $violations
* @param int $baselinedCount
* @return string
*/
public function render(array $violations, int $baselinedCount = 0): string
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$testsuites = $dom->createElement('testsuites');
$testsuites->setAttribute('name', 'YakNet Accessibility Console');
$testsuites->setAttribute('tests', (string)max(1, count($violations)));
$testsuites->setAttribute('failures', (string)count($violations));
$testsuites->setAttribute('errors', '0');
$testsuites->setAttribute('time', '0.000');
$dom->appendChild($testsuites);
if (empty($violations)) {
$testsuite = $dom->createElement('testsuite');
$testsuite->setAttribute('name', 'AccessibilityScan');
$testsuite->setAttribute('tests', '1');
$testsuite->setAttribute('failures', '0');
$testsuite->setAttribute('errors', '0');
$testsuite->setAttribute('time', '0.000');
$testcase = $dom->createElement('testcase');
$testcase->setAttribute('name', 'AccessibilityCompliant');
$testcase->setAttribute('classname', 'YakNet.AccessibilityConsole');
$testcase->setAttribute('time', '0.000');
$testsuite->appendChild($testcase);
$testsuites->appendChild($testsuite);
return (string)$dom->saveXML();
}
// Group violations by file
/** @var array<string, Violation[]> $grouped */
$grouped = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$grouped[$file][] = $v;
}
foreach ($grouped as $file => $fileViolations) {
$testsuite = $dom->createElement('testsuite');
$testsuite->setAttribute('name', str_replace('\\', '/', $file));
$testsuite->setAttribute('tests', (string)count($fileViolations));
$testsuite->setAttribute('failures', (string)count($fileViolations));
$testsuite->setAttribute('errors', '0');
$testsuite->setAttribute('time', '0.000');
foreach ($fileViolations as $v) {
$line = $v->location['line'] ?? 1;
$col = $v->location['column'] ?? 1;
$testcase = $dom->createElement('testcase');
$testcase->setAttribute('name', sprintf('[%s] %s', $v->ruleId, $v->message));
$testcase->setAttribute('classname', str_replace(['/', '\\'], '.', $file));
$testcase->setAttribute('time', '0.000');
$failure = $dom->createElement('failure');
$failure->setAttribute('type', $v->severity->value);
$failure->setAttribute('message', $v->message);
$details = sprintf(
"Rule: %s (%s)\nStandard: %s\nSeverity: %s\nLocation: %s:%d:%d\n\nSnippet:\n%s",
$v->ruleId,
$v->message,
$v->standard->value,
$v->severity->value,
$file,
$line,
$col,
$v->htmlSnippet
);
if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') {
$details .= "\n\nSuggested Fix:\n" . $v->fixSuggestion;
}
$cdata = $dom->createCDATASection($details);
$failure->appendChild($cdata);
$testcase->appendChild($failure);
$testsuite->appendChild($testcase);
}
$testsuites->appendChild($testsuite);
}
return (string)$dom->saveXML();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class MarkdownFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$md = [];
$md[] = "# Accessibility Scan Report";
$md[] = "";
$md[] = sprintf("- **Total Violations:** %d", count($violations));
$md[] = sprintf("- **Baselined Violations:** %d", $baselinedCount);
$md[] = "";
$md[] = "## Detailed Violations";
$md[] = "";
if (empty($violations)) {
$md[] = "*No violations found!* 🎉";
} else {
foreach ($violations as $index => $v) {
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
$md[] = sprintf("### %d. %s [%s - %s]", $index + 1, $v->ruleId, strtoupper($v->severity->value), $v->standard->value);
$md[] = sprintf("- **File:** `%s` on line %d", $file, $line);
$md[] = sprintf("- **Message:** %s", $v->message);
if ($v->fixSuggestion) {
$md[] = sprintf("- **Fix Suggestion:** %s", $v->fixSuggestion);
}
$md[] = "- **Snippet:**";
$md[] = " ```html";
$md[] = " " . trim($v->htmlSnippet);
$md[] = " ```";
$md[] = "";
}
}
$io->write(implode("\n", $md));
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+116
View File
@@ -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.3</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());
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
class SarifFormatter implements FormatterInterface
{
/**
* @param Violation[] $violations
* @param int $baselinedCount
* @param SymfonyStyle $io
* @return int
*/
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$sarifJson = $this->render($violations, $baselinedCount);
$io->write($sarifJson);
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
/**
* Render the violations in SARIF 2.1.0 JSON format.
*
* @param Violation[] $violations
* @param int $baselinedCount
* @return string
*/
public function render(array $violations, int $baselinedCount = 0): string
{
$rulesMap = [];
$results = [];
foreach ($violations as $v) {
$ruleId = $v->ruleId;
// Register rule metadata if not already present
if (!isset($rulesMap[$ruleId])) {
$rulesMap[$ruleId] = [
'id' => $ruleId,
'name' => $this->sanitizeRuleName($ruleId),
'shortDescription' => [
'text' => $v->message,
],
'fullDescription' => [
'text' => $v->message,
],
'defaultConfiguration' => [
'level' => $this->mapSeverityToSarifLevel($v->severity),
],
'properties' => [
'tags' => [
'accessibility',
'a11y',
'wcag',
'wcag-' . strtolower($v->standard->value),
],
],
];
}
// Location details
$file = $v->location['file'] ?? 'unknown';
$line = isset($v->location['line']) ? max(1, (int)$v->location['line']) : 1;
$column = isset($v->location['column']) ? max(1, (int)$v->location['column']) : 1;
/** @var array<string, mixed> $region */
$region = [
'startLine' => $line,
'startColumn' => $column,
];
if ($v->htmlSnippet !== '') {
$region['snippet'] = [
'text' => $v->htmlSnippet,
];
}
/** @var array<string, mixed> $locationEntry */
$locationEntry = [
'physicalLocation' => [
'artifactLocation' => [
'uri' => str_replace('\\', '/', $file),
],
'region' => $region,
],
];
/** @var array<string, mixed> $resultItem */
$resultItem = [
'ruleId' => $ruleId,
'level' => $this->mapSeverityToSarifLevel($v->severity),
'message' => [
'text' => $v->message,
],
'locations' => [
$locationEntry,
],
];
if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') {
$resultItem['fixes'] = [
[
'description' => [
'text' => 'Suggested accessibility fix',
],
],
];
}
$results[] = $resultItem;
}
$sarif = [
'$schema' => 'https://json.schemastore.org/sarif-2.1.0.json',
'version' => '2.1.0',
'runs' => [
[
'tool' => [
'driver' => [
'name' => 'YakNet Accessibility Console',
'informationUri' => 'https://forum.yak.net.tr',
'semanticVersion' => '3.3.0',
'rules' => array_values($rulesMap),
],
],
'results' => $results,
],
],
];
return (string)json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
private function mapSeverityToSarifLevel(Severity $severity): string
{
return match ($severity) {
Severity::CRITICAL, Severity::ERROR => 'error',
Severity::WARNING => 'warning',
Severity::INFO => 'note',
};
}
private function sanitizeRuleName(string $ruleId): string
{
$cleaned = preg_replace('/^WCAG_\d+_\d+_\d+_?/', '', $ruleId);
$cleaned = is_string($cleaned) ? $cleaned : $ruleId;
$words = explode('_', strtolower($cleaned));
return implode('', array_map('ucfirst', $words));
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Command\Command;
class XmlFormatter implements FormatterInterface
{
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$dom = new \DOMDocument('1.0', 'utf-8');
$dom->formatOutput = true;
$root = $dom->createElement('report');
$root->setAttribute('totalViolations', (string)count($violations));
$root->setAttribute('baselinedViolations', (string)$baselinedCount);
$dom->appendChild($root);
$violationsNode = $dom->createElement('violations');
$root->appendChild($violationsNode);
foreach ($violations as $v) {
$node = $dom->createElement('violation');
$node->setAttribute('ruleId', $v->ruleId);
$node->setAttribute('severity', $v->severity->value);
$node->setAttribute('standard', $v->standard->value);
$msg = $dom->createElement('message', htmlspecialchars($v->message));
$node->appendChild($msg);
$file = $v->location['file'] ?? 'unknown';
$line = $v->location['line'] ?? 0;
$loc = $dom->createElement('location');
$loc->setAttribute('file', $file);
$loc->setAttribute('line', (string)$line);
$node->appendChild($loc);
$snippet = $dom->createElement('htmlSnippet');
$snippet->appendChild($dom->createCDATASection($v->htmlSnippet));
$node->appendChild($snippet);
if ($v->fixSuggestion) {
$sug = $dom->createElement('suggestion', htmlspecialchars($v->fixSuggestion));
$node->appendChild($sug);
}
$violationsNode->appendChild($node);
}
$io->write($dom->saveXML() ?: '');
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
}
+15 -1
View File
@@ -14,14 +14,28 @@ abstract class AbstractRule implements RuleInterface
abstract public function getStandard(): WCAGStandard;
abstract public function getSeverity(): Severity;
public function getLevel(): int
{
return 4;
}
protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation
{
$doc = $element->ownerDocument;
$html = '';
if ($doc !== null) {
$htmlVal = $doc->saveHTML($element);
if (is_string($htmlVal)) {
$html = $htmlVal;
}
}
return new Violation(
ruleId: $this->getId(),
message: $message,
severity: $this->getSeverity(),
standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element),
htmlSnippet: $html,
fixSuggestion: $suggestion
);
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AccesskeyBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_2_1_1_ACCESSKEY'; }
public function getDescription(): string { return 'The accesskey attribute should not be used as it conflicts with browser and screen reader shortcuts.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function check(DOMElement $element): ?Violation
{
if ($element->hasAttribute('accesskey')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Remove the accesskey attribute. Use natural focus order and standard keyboard event listeners instead.'
);
}
return null;
}
}
+76
View File
@@ -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;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class 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;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class 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;
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AreaAlt extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_AreaAlt'; }
public function getDescription(): string { return 'Checks that <area> elements in image maps have non-empty alt attributes.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'area') {
return null;
}
$alt = $element->getAttribute('alt');
$ariaLabel = $element->getAttribute('aria-label');
if (empty(trim($alt)) && empty(trim($ariaLabel))) {
return $this->createViolation(
$element,
'<area> öğesinde alt veya aria-label eksik.',
'Provide a non-empty alt or aria-label attribute for the <area> element.'
);
}
return null;
}
}
+74
View File
@@ -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;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class 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;
}
}
+70
View File
@@ -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;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class 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;
}
}
+54
View File
@@ -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.'
);
}
}

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