From d5165f0be4f36642a7d524c62b4d8a943cdc6f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enes=20Yak=C4=B1=C5=9Ft=C4=B1r?= Date: Tue, 23 Jun 2026 01:02:15 +0300 Subject: [PATCH] feat: Release v3.0.0 - PHPStan-style analysis, configuration, baseline, and advanced WCAG rules --- README.md | 156 +++++++----- a11y-baseline.json | 210 +++++++++++++++++ a11y.yaml | 32 +++ bin/a11y | 6 +- composer.json | 6 +- demo/test_new_error.html | 9 + src/Console/Command/FixCommand.php | 184 +++++++++++++-- src/Console/Command/InitCommand.php | 72 ++++++ src/Console/Command/RulesCommand.php | 55 +++++ src/Console/Command/ScanCommand.php | 282 ++++++++++++++++++---- src/Core/Analyser.php | 288 +++++++++++++++++++++++ src/Core/BaselineManager.php | 96 ++++++++ src/Core/ChromeRenderer.php | 61 +++++ src/Core/Config.php | 97 +++++++- src/Reporting/ConsoleFormatter.php | 84 +++++++ src/Reporting/FormatterInterface.php | 19 ++ src/Reporting/GithubFormatter.php | 38 +++ src/Reporting/JsonFormatter.php | 30 +++ src/Rules/AccesskeyBanned.php | 30 +++ src/Rules/ColorContrast.php | 255 +++++++++++++++++--- src/Rules/FontTagBanned.php | 30 +++ src/Rules/InputTypeButtonDiscernible.php | 41 ++++ src/Rules/LanguageSubtag.php | 39 +++ src/Rules/LinkTargetBlank.php | 37 +++ src/Rules/MarqueeBanned.php | 30 +++ src/Rules/MediaAutoplay.php | 38 +++ src/Rules/NoFocusOutline.php | 37 +++ src/Rules/ObjectAlt.php | 53 +++++ src/Rules/TableHeaders.php | 44 ++++ 29 files changed, 2192 insertions(+), 167 deletions(-) create mode 100644 a11y-baseline.json create mode 100644 a11y.yaml create mode 100644 demo/test_new_error.html create mode 100644 src/Console/Command/InitCommand.php create mode 100644 src/Console/Command/RulesCommand.php create mode 100644 src/Core/Analyser.php create mode 100644 src/Core/BaselineManager.php create mode 100644 src/Core/ChromeRenderer.php create mode 100644 src/Reporting/ConsoleFormatter.php create mode 100644 src/Reporting/FormatterInterface.php create mode 100644 src/Reporting/GithubFormatter.php create mode 100644 src/Reporting/JsonFormatter.php create mode 100644 src/Rules/AccesskeyBanned.php create mode 100644 src/Rules/FontTagBanned.php create mode 100644 src/Rules/InputTypeButtonDiscernible.php create mode 100644 src/Rules/LanguageSubtag.php create mode 100644 src/Rules/LinkTargetBlank.php create mode 100644 src/Rules/MarqueeBanned.php create mode 100644 src/Rules/MediaAutoplay.php create mode 100644 src/Rules/NoFocusOutline.php create mode 100644 src/Rules/ObjectAlt.php create mode 100644 src/Rules/TableHeaders.php diff --git a/README.md b/README.md index 22ecfdb..815282b 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,139 @@ -# YakNet Accessibility Console 2.0 (AI-Powered) +# YakNet Accessibility Console 2.9 (AI-Powered Static Analysis) [![PHP Version](https://img.shields.io/badge/php-%5E8.2-blue.svg)](https://php.net) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![AI Powered](https://img.shields.io/badge/AI-Powered-purple.svg)](https://gemini.google.com) -**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1) hatalarını sadece tespit etmekle kalmayan, aynı zamanda **Yapay Zeka (AI)** desteğiyle bu hataları otomatik olarak düzeltebilen (Self-Healing) profesyonel bir PHP kütüphanesidir. +**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1) hatalarını sadece tespit etmekle kalmayan, aynı zamanda **Yapay Zeka (AI)** desteğiyle bu hataları otomatik olarak düzeltebilen (Self-Healing) profesyonel bir PHP static analysis kütüphanesidir. + +PHPStan ve PHP-CS-Fixer esintili yapısıyla, yerel görünüm şablonlarınızı (`.blade.php`, `.twig`, `.html`, `.php`) doğrudan tarayabilir, baseline desteğiyle mevcut hataları yok sayabilir ve CI/CD süreçlerinize tam entegre çalışabilir. + +--- ## 🌟 Öne Çıkan Özellikler -- **🤖 AI Self-Healing:** Tespit edilen WCAG hataları için Google Gemini AI üzerinden akıllı çözüm önerileri alır ve isterseniz kaynak kodunuzu (Blade, Twig, PHP) otomatik olarak tamir eder. -- **⚡ Modern CLI:** Rust/Vite tarzı şık bir komut satırı arayüzü ile saniyeler içinde tarama ve düzeltme yapın. -- **📊 Görsel Dashboard:** Tarama sonuçlarını modern ve şık bir HTML raporu olarak dışarı aktarın. -- **🔍 Akıllı Kaynak Tespiti:** Hataları projenizdeki tam dosya yolu ve satır numarası ile eşleştirir. -- **🛠️ Geliştirici Dostu:** PSR-4 uyumlu, modern PHP 8.2+ mimarisi ve genişletilebilir kural motoru. +- **🤖 AI Self-Healing:** Tespit edilen WCAG hataları için Google Gemini AI üzerinden akıllı çözüm önerileri alır ve kaynak kodunuzu (Blade, Twig, PHP, HTML) otomatik olarak tamir eder. +- **⚡ PHPStan Tarzı Analiz:** Tüm projeyi veya belirli dizinleri komut satırından tarayarak hataları tam dosya yolu ve satır numarası ile listeler. +- **🛡️ Baseline Desteği:** `a11y-baseline.json` oluşturarak mevcut erişilebilirlik hatalarını yok sayabilir, CI/CD pipeline'ınızın sadece yeni hatalarda kırılmasını sağlayabilirsiniz. +- **⚙️ YAML Konfigürasyonu:** `a11y.yaml` dosyası üzerinden tarama yollarını, hariç tutulacak klasörleri, kural seviyelerini ve özel kural setlerini yapılandırabilirsiniz. +- **🔌 Çoklu Çıktı Formatları:** + - `console`: Renkli ve düzenli terminal tablosu. + - `json`: Makine tarafından okunabilir JSON çıktısı. + - `github`: GitHub Actions PR süreçlerinde doğrudan inline hata göstermek için workflow formatı. +- **📊 Görsel Raporlama:** Sonuçları modern bir HTML Dashboard raporu olarak dışarı aktarın. + +--- ## 📦 Kurulum -Composer ile projenize hemen dahil edin: +Composer ile projenize dahil edin: ```bash composer require yaknet/accessibility-console ``` +--- + ## 🚀 CLI Kullanımı -Kütüphane, `vendor/bin/a11y` (veya kütüphane dizininde `bin/a11y`) üzerinden kullanılabilir. - -### 1. Standart Tarama -Bir URL'yi veya yerel bir HTML dosyasını tarayın: +### 1. Yapılandırma Dosyası Oluşturma (`init`) +Projenizin kök dizininde varsayılan bir `a11y.yaml` konfigürasyon dosyası oluşturur: ```bash -bin/a11y scan https://test-siteniz.com +bin/a11y init ``` -### 2. AI Destekli Tarama ve Raporlama -Yapay zeka önerileriyle birlikte tarama yapın ve sonucu HTML olarak kaydedin: +### 2. Projeyi Tarama (`scan` / `analyse`) +`a11y.yaml` dosyasındaki kurallara ve dizinlere göre tüm projeyi tarar: ```bash -bin/a11y scan https://test-siteniz.com --ai --report=sonuc.html +bin/a11y scan ``` - -### 3. Otomatik Düzeltme (Self-Healing) - **DEVRİMSEL** -Tespit edilen hataları yapay zekaya düzelttirin ve dosyalarınızı güncelleyin: +Belirli bir dizini veya dosyayı hedef göstermek için: ```bash -bin/a11y fix https://test-siteniz.com --project-path=/var/www/projeniz +bin/a11y scan resources/views +``` +URL veya tek bir yerel HTML dosyasını taramak için (crawler desteğiyle): +```bash +bin/a11y scan https://test-siteniz.com --crawl ``` -## 💻 Kütüphane Olarak Kullanım - -Kendi PHP uygulamalarınızın içine entegre edin: - -```php -use YakNet\AccessibilityConsole\Core\Scanner; -use YakNet\AccessibilityConsole\Rules\StandardRuleSet; - -// 1. Scanner'ı ve Kuralları Hazırla -$scanner = new Scanner(); -foreach (StandardRuleSet::all() as $rule) { - $scanner->addRule($rule); -} - -// 2. HTML İçeriğini Tara -$html = file_get_contents('view.html'); -$violations = $scanner->scan($html); - -// 3. Sonuçları İşle -foreach ($violations as $v) { - echo "Hata: {$v->message} (Kural: {$v->ruleId})\n"; -} +### 3. Baseline Yönetimi +Projeye ait mevcut tüm hataları `a11y-baseline.json` dosyasına kaydederek sonraki taramalarda yok sayılmasını sağlar: +```bash +bin/a11y scan --generate-baseline ``` -## ⚙️ Yapılandırma +### 4. Format ve Raporlama Seçenekleri +Hataları GitHub Actions formatında yazdırmak için: +```bash +bin/a11y scan --format=github +``` +HTML Raporu kaydetmek için: +```bash +bin/a11y scan --report=report.html +``` -AI özelliklerini kullanabilmek için projenizin kök dizininde bir `.env` dosyası oluşturun ve **Google Gemini API** anahtarınızı ekleyin: +### 5. AI Destekli Otomatik Düzeltme (`fix`) +Tespit edilen tüm hataları yapay zekaya analiz ettirip dosyalarınızı otomatik olarak tamir ettirin: +```bash +bin/a11y fix +``` +Sadece neyin değişeceğini görmek için (`--dry-run`): +```bash +bin/a11y fix --dry-run +``` + +### 6. Kuralları Listeleme (`rules`) +Mevcut tüm WCAG kurallarını seviyeleri ve açıklamalarıyla birlikte listeler: +```bash +bin/a11y rules +``` + +--- + +## ⚙️ Yapılandırma (`a11y.yaml`) + +Projenizi özelleştirmek için `a11y.yaml` dosyasını kullanabilirsiniz: + +```yaml +# Tarama yapılacak dizin veya dosyalar +paths: + - resources/views + - public/templates + +# Hariç tutulacak dizinler +exclude_paths: + - vendor + - node_modules + - storage + +# WCAG seviyesi (1-9, varsayılan 4) +level: 4 + +# Varsayılan çıktı formatı (console, json, github) +format: console + +# Baseline dosyası yolu +baseline: a11y-baseline.json + +# Kural özelleştirmeleri +rules: + exclude: + - WCAG_1_3_1_FIELDSET # Bu kuralı çalıştırma + include: + - App\Rules\CustomAccessibilityRule # Özel kural ekle +``` + +--- + +## 🔐 Yapay Zeka API Anahtarı + +AI düzeltme ve öneri özelliklerini kullanabilmek için projenizin kök dizininde bir `.env` dosyası oluşturup **Google Gemini API** anahtarınızı eklemeniz gerekir: ```env GEMINI_API_KEY=AIzaSyA...your_key_here ``` -## 🛠️ Mevcut Kurallar (WCAG 2.1) - -- **A11Y_VIEWPORT:** Zoom engelleme kontrolü. -- **WCAG_1_1_1_ALT:** Görsel alt metin kontrolü. -- **WCAG_1_3_1_LABEL:** Form etiketi kontrolü. -- **WCAG_1_3_1_FIELDSET:** Fieldset/Legend kontrolü. -- **WCAG_1_3_1_HEADING:** Başlık hiyerarşisi kontrolü. -- **WCAG_2_4_4_LINK_TEXT:** Boş link kontrolü. -- **WCAG_3_1_1_LANG:** HTML dil etiketi kontrolü. -- **WCAG_4_1_2_BUTTON:** Boş buton kontrolü. +--- ## 🤝 Katkıda Bulunma diff --git a/a11y-baseline.json b/a11y-baseline.json new file mode 100644 index 0000000..a3482d8 --- /dev/null +++ b/a11y-baseline.json @@ -0,0 +1,210 @@ +{ + "generator": "YakNet Accessibility Console", + "violations": [ + { + "file": "demo/broken.html", + "ruleId": "AriaRole", + "htmlSnippet": "
WAI-ARIA elements
" + }, + { + "file": "demo/broken.html", + "ruleId": "AriaRole", + "htmlSnippet": "
WAI-ARIA elements
" + }, + { + "file": "demo/broken.html", + "ruleId": "DuplicateId", + "htmlSnippet": "

Welcome to a broken page

" + }, + { + "file": "demo/broken.html", + "ruleId": "DuplicateId", + "htmlSnippet": "
This is a duplicate main-content ID div!
" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_1_1_1_ALT", + "htmlSnippet": "" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_1_1_1_ALT_REDUNDANT", + "htmlSnippet": "\"logo" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_1_1_1_ALT_REDUNDANT", + "htmlSnippet": "\"profile" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_1_2_2_VIDEO_TRACK", + "htmlSnippet": "" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_1_3_1_EMPTY_HEADING", + "htmlSnippet": "

" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_1_3_1_EMPTY_LABEL", + "htmlSnippet": "" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_1_3_1_LABEL", + "htmlSnippet": "" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_2_1_1_ARIA_FOCUSABLE", + "htmlSnippet": "
Clickable div button without tabindex
" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_2_4_2_PAGE_TITLE", + "htmlSnippet": "\n \n" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_2_4_3_TABINDEX", + "htmlSnippet": "Keyboard test" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_2_4_4_LINK_GENERIC", + "htmlSnippet": "Click here" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_2_4_4_LINK_GENERIC", + "htmlSnippet": "devamını oku" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_2_4_4_LINK_TEXT", + "htmlSnippet": "" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_3_1_1_LANG", + "htmlSnippet": "\n \n\n\n

Welcome to a broken page

\n \n \n \n \n \n \n \n \n \n \n \n Keyboard test\n\n \n
This is a duplicate main-content ID div!
\n\n \n

This text has extremely low contrast ratio!

\n\n \n
WAI-ARIA elements
\n\n \n \n\n \n \n \n Click here\n devamını oku\n \n \n \n\n \n \"logo\n \"profile\n\n \n
\n \n
\n Hidden link\n\n \n

\n\n \n \n\n \n \n\n \n
Clickable div button without tabindex
\n\n

This page has multiple accessibility issues.

\n\n\n" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_3_3_2_PLACEHOLDER_LABEL", + "htmlSnippet": "" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_4_1_2_BUTTON", + "htmlSnippet": "" + }, + { + "file": "demo/broken.html", + "ruleId": "WCAG_4_1_2_IFRAME_TITLE", + "htmlSnippet": "" + }, + { + "file": "demo/dynamic_preview.html", + "ruleId": "WCAG_1_1_1_ALT", + "htmlSnippet": "" + }, + { + "file": "demo/dynamic_preview.html", + "ruleId": "WCAG_1_1_1_ALT", + "htmlSnippet": "" + }, + { + "file": "demo/dynamic_preview.html", + "ruleId": "WCAG_1_1_1_ALT", + "htmlSnippet": "" + }, + { + "file": "demo/dynamic_preview.html", + "ruleId": "WCAG_1_1_1_ALT", + "htmlSnippet": "" + }, + { + "file": "demo/dynamic_preview.html", + "ruleId": "WCAG_1_1_1_ALT", + "htmlSnippet": "" + }, + { + "file": "demo/views-fixed/contact.php", + "ruleId": "WCAG_3_1_1_LANG", + "htmlSnippet": "

Contact Us

\n

Get in touch with us.

\n\n
\n
\n \n \n
\n\n
\n \n \n
\n \n
\n\n
\n \n
\n \n
\n\n \n \n Send\n \n\n \n
" + }, + { + "file": "demo/views-fixed/home.php", + "ruleId": "WCAG_1_3_1_EMPTY_HEADING", + "htmlSnippet": "

\n \n

" + }, + { + "file": "demo/views-fixed/home.php", + "ruleId": "WCAG_3_1_1_LANG", + "htmlSnippet": "

Latest Products

\n

Welcome to our accessible product catalog.

\n\n 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
\n \n
\n \n \"<?php\n

\n \n

\n

\n \n

\n \n
\n \n
\n\n
\n\n

Sponsors

\n
\n \n \"\"\n \"\"\n
" + }, + { + "file": "demo/views/contact.php", + "ruleId": "WCAG_1_3_1_LABEL", + "htmlSnippet": "" + }, + { + "file": "demo/views/contact.php", + "ruleId": "WCAG_1_3_1_LABEL", + "htmlSnippet": "" + }, + { + "file": "demo/views/contact.php", + "ruleId": "WCAG_2_4_4_LINK_TEXT", + "htmlSnippet": "\n \n " + }, + { + "file": "demo/views/contact.php", + "ruleId": "WCAG_3_1_1_LANG", + "htmlSnippet": "

Contact Us

\n

Get in touch with us.

\n\n
\n
\n \n
Name:
\n \n
\n\n
\n \n \n
\n \n
\n\n
\n \n
\n \n
\n\n \n \n \n \n\n \n
" + }, + { + "file": "demo/views/contact.php", + "ruleId": "WCAG_3_3_2_PLACEHOLDER_LABEL", + "htmlSnippet": "" + }, + { + "file": "demo/views/home.php", + "ruleId": "WCAG_1_1_1_ALT", + "htmlSnippet": "" + }, + { + "file": "demo/views/home.php", + "ruleId": "WCAG_1_1_1_ALT", + "htmlSnippet": "" + }, + { + "file": "demo/views/home.php", + "ruleId": "WCAG_1_1_1_ALT", + "htmlSnippet": "" + }, + { + "file": "demo/views/home.php", + "ruleId": "WCAG_1_3_1_EMPTY_HEADING", + "htmlSnippet": "

\n \n

" + }, + { + "file": "demo/views/home.php", + "ruleId": "WCAG_3_1_1_LANG", + "htmlSnippet": "

Latest Products

\n

Welcome to our dynamic product catalog. (Intentionally filled with errors)

\n\n 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
\n \n
\n \n \n

\n \n

\n

\n \n

\n \n \n
\n \n
\n\n
\n\n

Sponsors

\n
\n \n \n \n
" + }, + { + "file": "demo/views/layout.php", + "ruleId": "WCAG_2_4_4_LINK_TEXT", + "htmlSnippet": "" + }, + { + "file": "demo/views/layout.php", + "ruleId": "WCAG_3_1_1_LANG", + "htmlSnippet": "\n \n \n YakNet Dynamic Demo\n \n\n\n\n
\n
\n

My Dynamic Site

\n \n
\n
\n\n
\n \n
\n\n \n\n\n" + } + ] +} \ No newline at end of file diff --git a/a11y.yaml b/a11y.yaml new file mode 100644 index 0000000..d67b9fc --- /dev/null +++ b/a11y.yaml @@ -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 \ No newline at end of file diff --git a/bin/a11y b/bin/a11y index c447cb0..ab5f0a0 100644 --- a/bin/a11y +++ b/bin/a11y @@ -6,11 +6,15 @@ require __DIR__ . '/../vendor/autoload.php'; use Symfony\Component\Console\Application; use YakNet\AccessibilityConsole\Console\Command\ScanCommand; use YakNet\AccessibilityConsole\Console\Command\FixCommand; +use YakNet\AccessibilityConsole\Console\Command\InitCommand; +use YakNet\AccessibilityConsole\Console\Command\RulesCommand; -$application = new Application('YakNet Accessibility Console', '2.0.0'); +$application = new Application('YakNet Accessibility Console', '2.9.2'); // Add commands $application->add(new ScanCommand()); $application->add(new FixCommand()); +$application->add(new InitCommand()); +$application->add(new RulesCommand()); $application->run(); diff --git a/composer.json b/composer.json index 17d3f89..74867e8 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "yaknet" ], "homepage": "https://forum.yak.net.tr", - "version": "2.9.2", + "version": "3.0.0", "type": "library", "license": "MIT", "authors": [ @@ -33,7 +33,9 @@ "vlucas/phpdotenv": "^5.6", "google-gemini-php/client": "^1.0", "guzzlehttp/guzzle": "^7.8", - "masterminds/html5": "^2.8" + "masterminds/html5": "^2.8", + "sabberworm/php-css-parser": "^9.4", + "chrome-php/chrome": "^1.15" }, "require-dev": { "phpunit/phpunit": "^10.5", diff --git a/demo/test_new_error.html b/demo/test_new_error.html new file mode 100644 index 0000000..e86f914 --- /dev/null +++ b/demo/test_new_error.html @@ -0,0 +1,9 @@ + + + Test Page + + + + + + diff --git a/src/Console/Command/FixCommand.php b/src/Console/Command/FixCommand.php index 52307e7..7303713 100644 --- a/src/Console/Command/FixCommand.php +++ b/src/Console/Command/FixCommand.php @@ -9,9 +9,10 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use YakNet\AccessibilityConsole\AI\GeminiFixer; +use YakNet\AccessibilityConsole\Core\Analyser; use YakNet\AccessibilityConsole\Core\Config; use YakNet\AccessibilityConsole\Core\Scanner; -use YakNet\AccessibilityConsole\Rules\StandardRuleSet; +use YakNet\AccessibilityConsole\Rules\RuleLevels; use YakNet\AccessibilityConsole\Source\SourceLocator; class FixCommand extends Command @@ -21,46 +22,183 @@ class FixCommand extends Command $this ->setName('fix') ->setDescription('Automatically fix accessibility violations using AI') - ->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan') - ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping') - ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files'); + ->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan and fix') + ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)') + ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files') + ->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)') + ->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $target = $input->getArgument('target'); - if (!is_string($target)) { - $io->error("Target must be a string URL or file path."); + + // 1. Resolve configuration + $configPathOption = $input->getOption('config'); + $configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml'; + $config = Config::fromYaml($configPath); + + $apiKey = $config->getGeminiApiKey(); + if (!$apiKey) { + $io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key."); return Command::FAILURE; } + + $levelOption = $input->getOption('level'); + $level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel(); + + $target = $input->getArgument('target'); + $isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://')); + + // If target is a URL, run the legacy URL fixing logic + if ($isUrl) { + return $this->executeUrlFix($target, $level, $apiKey, $input, $output, $io); + } + + // --- LOCAL FILE / DIRECTORY SELF-HEALING ENGINE --- + $io->title("AI Self-Healing Mode - Directory Static Analysis"); + $io->writeln("Configuration loaded from: $configPath"); + $dryRun = (bool)$input->getOption('dry-run'); + + // Resolve paths to scan + $paths = $config->getPaths(); + if (is_string($target) && $target !== '') { + $paths = [$target]; + } + + // Setup Analyser + $scanner = new Scanner(); + $rules = RuleLevels::getRulesForLevel($level); + foreach ($rules as $rule) { + $scanner->addRule($rule); + } + + // We run without baseline filtering so we can fix ALL errors in the project, + // or we can optionally load baseline. Fixing everything is better for a general "fix" run. + $analyser = new Analyser($config, $scanner, new \YakNet\AccessibilityConsole\Core\BaselineManager()); + + $io->note("Scanning targets for violations..."); + $result = $analyser->analyse($paths); + $violations = $result['violations']; + + if (empty($violations)) { + $io->success("Everything looks good! No violations to fix."); + return Command::SUCCESS; + } + + $io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations))); + + $fixer = new GeminiFixer($apiKey); + $fixedCount = 0; + + // Group violations by file path + $groupedViolations = []; + foreach ($violations as $v) { + $file = $v->location['file'] ?? ''; + if ($file !== '') { + $groupedViolations[$file][] = $v; + } + } + + foreach ($groupedViolations as $file => $fileViolations) { + $io->section("Fixing file: $file"); + + // Sort violations by line number DESCENDING (bottom to top) + // This prevents edits at the bottom of the file from invalidating line numbers above them! + usort($fileViolations, function ($a, $b) { + return ($b->location['line'] ?? 0) <=> ($a->location['line'] ?? 0); + }); + + $absoluteFile = getcwd() . '/' . $file; + if (!file_exists($absoluteFile) || !is_writable($absoluteFile)) { + $io->warning("File not found or not writable: $file. Skipping."); + continue; + } + + foreach ($fileViolations as $violation) { + $line = $violation->location['line'] ?? 0; + $io->write("Analyzing {$violation->ruleId} on line $line... "); + + // Get AI Suggestion + $rawSuggestion = $fixer->suggestFix($violation); + $suggestion = $rawSuggestion; + if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) { + preg_match('/FIX:(.*)/s', $rawSuggestion, $matches); + $suggestion = trim($matches[1] ?? $rawSuggestion); + $suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion); + } + + if (!$suggestion || ($suggestion === $rawSuggestion && str_contains($suggestion, 'EXPLANATION:'))) { + $io->writeln("Skipped (AI could not generate a clean fix)"); + continue; + } + + $io->writeln("Fix Found!"); + $io->writeln(" Original: " . htmlspecialchars(trim($violation->htmlSnippet)) . ""); + $io->writeln(" AI Fix: " . htmlspecialchars(trim($suggestion)) . ""); + + if ($dryRun) { + $io->writeln(" [DRY RUN] File modification skipped."); + continue; + } + + // Apply Fix + if ($this->applyFix($absoluteFile, $violation->htmlSnippet, $suggestion)) { + $io->writeln(" ✔ Applied to line $line"); + $fixedCount++; + } else { + $io->writeln(" ✘ Failed to apply fix to file."); + } + $io->writeln(""); + } + } + + if ($fixedCount > 0) { + $io->success("Self-healing complete! $fixedCount violations were automatically fixed."); + } else { + $io->warning("No files were modified."); + } + + return Command::SUCCESS; + } + + /** + * Legacy URL scan and fix logic + */ + private function executeUrlFix(string $target, int $level, string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int + { $projectPathOption = $input->getOption('project-path'); $projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd(); if ($projectPath === false) { $projectPath = '.'; } $dryRun = (bool)$input->getOption('dry-run'); + $useJs = (bool)$input->getOption('js'); - $config = new Config(); - $apiKey = $config->getGeminiApiKey(); + $io->title("AI Self-Healing Mode - Fixing URL: $target"); - if (!$apiKey) { - $io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key."); - return Command::FAILURE; + // Fetch HTML + if ($useJs) { + try { + $chromeRenderer = new \YakNet\AccessibilityConsole\Core\ChromeRenderer(); + $html = $chromeRenderer->render($target); + } catch (\Throwable $e) { + $io->error("Failed JavaScript rendering: " . $e->getMessage()); + return Command::FAILURE; + } + } else { + $html = @file_get_contents($target); } - - $io->title("AI Self-Healing Mode - Fixing: $target"); - - // 1. Fetch HTML - $html = @file_get_contents($target); if ($html === false || $html === '') { - $io->error("Could not read target: $target"); + $io->error("Could not read target URL: $target"); return Command::FAILURE; } - // 2. Scan + // Scan $scanner = new Scanner(); - foreach (StandardRuleSet::all() as $rule) { + $rules = RuleLevels::getRulesForLevel($level); + foreach ($rules as $rule) { $scanner->addRule($rule); } $violations = $scanner->scan($html); @@ -92,7 +230,6 @@ class FixCommand extends Command if ($rawSuggestion && str_contains($rawSuggestion, 'FIX:')) { preg_match('/FIX:(.*)/s', $rawSuggestion, $matches); $suggestion = trim($matches[1] ?? $rawSuggestion); - // Clean up potential markdown $suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion); } @@ -123,7 +260,7 @@ class FixCommand extends Command if ($fixedCount > 0) { $io->success("Self-healing complete! $fixedCount violations were automatically fixed."); } else { - $io->warning("No files were modified. Check error logs for details."); + $io->warning("No files were modified."); } return Command::SUCCESS; @@ -138,8 +275,7 @@ class FixCommand extends Command if (str_contains($content, $originalSnippet)) { $newContent = str_replace($originalSnippet, $fixedSnippet, $content); } else { - // Fallback: Fuzzy replacement (simplified for this demo) - // In a real professional lib, we'd use more advanced regex or DOM patching + // Fallback: Fuzzy replacement $newContent = str_replace(trim($originalSnippet), trim($fixedSnippet), $content); } diff --git a/src/Console/Command/InitCommand.php b/src/Console/Command/InitCommand.php new file mode 100644 index 0000000..d2a2d35 --- /dev/null +++ b/src/Console/Command/InitCommand.php @@ -0,0 +1,72 @@ +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 = <<success("Created configuration file: a11y.yaml"); + return Command::SUCCESS; + } + + $io->error("Failed to create configuration file 'a11y.yaml'."); + return Command::FAILURE; + } +} diff --git a/src/Console/Command/RulesCommand.php b/src/Console/Command/RulesCommand.php new file mode 100644 index 0000000..e21b385 --- /dev/null +++ b/src/Console/Command/RulesCommand.php @@ -0,0 +1,55 @@ +setName('rules') + ->setDescription('List all available accessibility scanning rules'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $io->title("Accessibility Console - Available Rules"); + + $table = new Table($output); + $table->setHeaders(['Rule ID', 'Standard', 'Severity', 'Level', 'Description']); + + $rules = RuleLevels::getRulesForLevel(9); + foreach ($rules as $rule) { + $ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) + ? $rule->getId() + : (new \ReflectionClass($rule))->getShortName(); + + $description = method_exists($rule, 'getDescription') ? $rule->getDescription() : ''; + $standard = method_exists($rule, 'getStandard') ? $rule->getStandard()->value : ''; + $severity = method_exists($rule, 'getSeverity') ? $rule->getSeverity()->value : ''; + $level = method_exists($rule, 'getLevel') ? $rule->getLevel() : 4; + + $table->addRow([ + "$ruleId", + $standard, + $severity, + $level, + $description + ]); + } + + $table->render(); + $io->newLine(); + $io->note("Total rules available: " . count($rules)); + + return Command::SUCCESS; + } +} diff --git a/src/Console/Command/ScanCommand.php b/src/Console/Command/ScanCommand.php index abda066..51656ba 100644 --- a/src/Console/Command/ScanCommand.php +++ b/src/Console/Command/ScanCommand.php @@ -3,45 +3,236 @@ namespace YakNet\AccessibilityConsole\Console\Command; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use YakNet\AccessibilityConsole\AI\GeminiFixer; +use YakNet\AccessibilityConsole\Core\Analyser; +use YakNet\AccessibilityConsole\Core\BaselineManager; use YakNet\AccessibilityConsole\Core\Config; use YakNet\AccessibilityConsole\Core\Scanner; -use YakNet\AccessibilityConsole\Rules\StandardRuleSet; use YakNet\AccessibilityConsole\Rules\RuleLevels; +use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter; +use YakNet\AccessibilityConsole\Reporting\GithubFormatter; +use YakNet\AccessibilityConsole\Reporting\JsonFormatter; use YakNet\AccessibilityConsole\Source\SourceLocator; class ScanCommand extends Command { - protected static ?string $defaultName = 'scan'; - protected function configure(): void { $this ->setName('scan') - ->setDescription('Scan a URL or file for accessibility violations') - ->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan') + ->setAliases(['analyse']) + ->setDescription('Scan local files, directories, or a URL for accessibility violations') + ->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan') + ->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)') + ->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)') + ->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (console, json, github)') + ->addOption('generate-baseline', null, InputOption::VALUE_NONE, 'Generate a baseline file containing all current violations') + ->addOption('baseline', null, InputOption::VALUE_REQUIRED, 'Path to the baseline file') + ->addOption('no-progress', null, InputOption::VALUE_NONE, 'Disable progress bar') ->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions') ->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)') - ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping') - ->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively') - ->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth', '3') - ->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum number of pages to crawl', '20') - ->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)', '4'); + ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)') + ->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively (URL scans only)') + ->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth (URL scans only)', '3') + ->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum pages to crawl (URL scans only)', '20') + ->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $target = $input->getArgument('target'); - if (!is_string($target)) { - $io->error("Target must be a string URL or file path."); - return Command::FAILURE; + + // 1. Resolve configuration + $configPathOption = $input->getOption('config'); + $configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml'; + $config = Config::fromYaml($configPath); + + // 2. Resolve parameters (CLI options override config file) + $levelOption = $input->getOption('level'); + $level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel(); + + $formatOption = $input->getOption('format'); + $format = is_string($formatOption) ? $formatOption : $config->getFormat(); + + $baselinePathOption = $input->getOption('baseline'); + $baselinePath = is_string($baselinePathOption) ? $baselinePathOption : $config->getBaselinePath(); + if (!$baselinePath && $input->getOption('generate-baseline')) { + $baselinePath = 'a11y-baseline.json'; // default baseline filename } + + $target = $input->getArgument('target'); + $isUrl = is_string($target) && (str_starts_with($target, 'http://') || str_starts_with($target, 'https://')); + + // If target is a URL, run the legacy URL scanning / crawling logic + if ($isUrl) { + return $this->executeUrlScan($target, $level, $input, $output, $io); + } + + // --- LOCAL DIRECTORY / FILE STATIC ANALYSIS ENGINE --- + $io->title("Accessibility Console - Static Analysis"); + $io->writeln("Configuration loaded from: $configPath"); + $io->writeln("Rule Level: $level"); + + // Resolve paths to scan + $paths = $config->getPaths(); + if (is_string($target) && $target !== '') { + $paths = [$target]; + } + + // Setup Analyser + $scanner = new Scanner(); + $rules = RuleLevels::getRulesForLevel($level); + + // Filter rules based on config + $rulesConfig = $config->getRulesConfig(); + $activeRules = []; + foreach ($rules as $rule) { + $ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) + ? $rule->getId() + : (new \ReflectionClass($rule))->getShortName(); + + if (in_array($ruleId, $rulesConfig['exclude'], true)) { + continue; + } + $activeRules[$ruleId] = $rule; + } + + foreach ($rulesConfig['include'] as $customRuleClass) { + if (class_exists($customRuleClass)) { + try { + $reflection = new \ReflectionClass($customRuleClass); + if ($reflection->isInstantiable()) { + $rule = $reflection->newInstance(); + $ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) + ? $rule->getId() + : $reflection->getShortName(); + $activeRules[$ruleId] = $rule; + } + } catch (\Throwable $e) {} + } + } + + foreach ($activeRules as $rule) { + $scanner->addRule($rule); + } + + // Setup Baseline Manager + $baselineManager = new BaselineManager(); + if ($baselinePath && !$input->getOption('generate-baseline')) { + $baselineManager->load(getcwd() . '/' . $baselinePath); + } + + $analyser = new Analyser($config, $scanner, $baselineManager); + + // Progress bar setup + $progressBar = null; + $showProgress = !$input->getOption('no-progress') && $format === 'console'; + $onProgress = null; + + if ($showProgress) { + $onProgress = function (string $file, int $index, int $total) use ($io, &$progressBar) { + if ($progressBar === null) { + $progressBar = new ProgressBar($io, $total); + $progressBar->start(); + } + $progressBar->setProgress($index); + $progressBar->setMessage(basename($file), 'filename'); + }; + } + + // Run analysis + $result = $analyser->analyse($paths, $onProgress); + + if ($progressBar) { + $progressBar->finish(); + $io->newLine(2); + } + + $violations = $result['violations']; + $baselinedCount = $result['baselinedCount']; + + // AI Fix generation (if requested) + if ($input->getOption('ai') && !empty($violations)) { + $apiKey = $config->getGeminiApiKey(); + if ($apiKey) { + $fixer = new GeminiFixer($apiKey); + $io->comment("Requesting AI fix suggestions for violations..."); + foreach ($violations as $v) { + $suggestion = $fixer->suggestFix($v); + if ($suggestion) { + $v->fixSuggestion = $suggestion; + } + } + } else { + $io->warning("GEMINI_API_KEY not found. AI suggestions skipped."); + } + } + + // Generate baseline if option is present + if ($input->getOption('generate-baseline')) { + if (!$baselinePath) { + $io->error("No baseline path specified."); + return Command::FAILURE; + } + $baselineFile = getcwd() . '/' . $baselinePath; + + // Re-run analysis without baseline filtering to catch all violations + $unfilteredAnalyser = new Analyser($config, $scanner, new BaselineManager()); + $allResult = $unfilteredAnalyser->analyse($paths); + $allViolations = $allResult['violations']; + + if ($baselineManager->generate($allViolations, $baselineFile)) { + $io->success(sprintf("Baseline generated successfully with %d violations at: %s", count($allViolations), $baselinePath)); + return Command::SUCCESS; + } else { + $io->error("Failed to generate baseline file."); + return Command::FAILURE; + } + } + + // Select formatter + $formatter = new ConsoleFormatter(); + if ($format === 'json') { + $formatter = new JsonFormatter(); + } elseif ($format === 'github') { + $formatter = new GithubFormatter(); + } + + $exitCode = $formatter->format($violations, $baselinedCount, $io); + + // Export report if --report is specified + $reportPath = $input->getOption('report'); + if (is_string($reportPath) && $reportPath !== '') { + $extension = pathinfo($reportPath, PATHINFO_EXTENSION); + if ($extension === 'json') { + $serialized = []; + foreach ($violations as $v) { + $serialized[] = $v->toArray(); + } + file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT)); + } else { + $reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter(); + // Map violations into page format + $mappedViolations = ['Static Analysis' => $violations]; + file_put_contents($reportPath, $reporter->render($mappedViolations, 'Static Analysis')); + } + $io->success("Report saved to: $reportPath"); + } + + return $exitCode; + } + + /** + * Legacy URL scanning and crawling logic + */ + private function executeUrlScan(string $target, int $level, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int + { $enableAi = (bool)$input->getOption('ai'); $projectPathOption = $input->getOption('project-path'); $projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd(); @@ -49,14 +240,7 @@ class ScanCommand extends Command $projectPath = '.'; } - $levelOption = $input->getOption('level'); - $level = is_numeric($levelOption) ? (int)$levelOption : 4; - if ($level < 1 || $level > 9) { - $io->error("Scan level must be between 1 and 9."); - return Command::FAILURE; - } - - $io->title("Accessibility Console - Scanning: $target (Level $level)"); + $io->title("Accessibility Console - Scanning URL: $target (Level $level)"); // 1. Setup Scanner $scanner = new Scanner(); @@ -66,10 +250,9 @@ class ScanCommand extends Command } // 2. Fetch and Scan - $isUrl = str_starts_with($target, 'http://') || str_starts_with($target, 'https://'); - $crawl = (bool)$input->getOption('crawl') && $isUrl; - - /** @var array $results */ + $crawl = (bool)$input->getOption('crawl'); + $useJs = (bool)$input->getOption('js'); + $chromeRenderer = $useJs ? new \YakNet\AccessibilityConsole\Core\ChromeRenderer() : null; $results = []; if ($crawl) { @@ -83,9 +266,7 @@ class ScanCommand extends Command $parsedBase = parse_url($target); $baseHost = $parsedBase['host'] ?? ''; - $queue = [ - ['url' => $target, 'depth' => 1] - ]; + $queue = [['url' => $target, 'depth' => 1]]; $visited = []; $client = new \GuzzleHttp\Client([ @@ -105,7 +286,6 @@ class ScanCommand extends Command $url = $current['url']; $depth = (int)$current['depth']; - // Remove fragment $cleanUrl = strtok($url, '#'); if ($cleanUrl === false || isset($visited[$cleanUrl])) { continue; @@ -115,15 +295,17 @@ class ScanCommand extends Command $io->write(" Crawling [Depth $depth]: $cleanUrl... "); try { - $response = $client->request('GET', $cleanUrl); - $html = (string)$response->getBody(); + if ($chromeRenderer) { + $html = $chromeRenderer->render($cleanUrl); + } else { + $response = $client->request('GET', $cleanUrl); + $html = (string)$response->getBody(); + } $io->writeln("OK"); - // Scan the page HTML $pageViolations = $scanner->scan($html); $results[$cleanUrl] = $pageViolations; - // Extract links if depth is less than limit if ($depth < $depthLimit) { $html5 = new \Masterminds\HTML5(['disable_html_ns' => true]); $doc = $html5->loadHTML($html); @@ -135,7 +317,6 @@ class ScanCommand extends Command continue; } - // Resolve relative URI try { $baseUri = new \GuzzleHttp\Psr7\Uri($cleanUrl); $relativeUri = new \GuzzleHttp\Psr7\Uri($href); @@ -145,12 +326,10 @@ class ScanCommand extends Command continue; } - // Check host compatibility $parsedAbsolute = parse_url($absoluteUrl); $absoluteHost = $parsedAbsolute['host'] ?? ''; - + if ($absoluteHost === $baseHost) { - // Add to queue if not already visited or in queue $cleanAbsolute = strtok($absoluteUrl, '#'); if ($cleanAbsolute !== false && !isset($visited[$cleanAbsolute])) { $inQueue = false; @@ -172,10 +351,19 @@ class ScanCommand extends Command } } } else { - // Standard single page/file scan - $html = @file_get_contents($target); + if ($chromeRenderer) { + try { + $html = $chromeRenderer->render($target); + } catch (\Throwable $e) { + $io->error("Failed JavaScript rendering: " . $e->getMessage()); + return Command::FAILURE; + } + } else { + $html = @file_get_contents($target); + } + if ($html === false || $html === '') { - $io->error("Could not read target: $target"); + $io->error("Could not read target URL: $target"); return Command::FAILURE; } $violations = $scanner->scan($html); @@ -194,7 +382,6 @@ class ScanCommand extends Command $io->section("Found a total of $totalViolations violations across " . count($results) . " pages"); - // 3. Setup Helpers $locator = new SourceLocator($projectPath); $config = new Config(); $fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null; @@ -207,17 +394,14 @@ class ScanCommand extends Command foreach ($violations as $violation) { $io->writeln(" {$violation->ruleId} \t{$violation->message}"); - - // Show Snippet $io->writeln(" Snippet: " . htmlspecialchars($violation->htmlSnippet) . ""); - // Source Mapping $location = $locator->locate($violation->htmlSnippet); if ($location) { $io->writeln(" Source: {$location['file']}:{$location['line']}"); + $violation->location = $location; } - // AI Fixer if ($enableAi && $fixer) { $io->write(" AI Fix Suggestion: "); $suggestion = $fixer->suggestFix($violation); @@ -228,14 +412,10 @@ class ScanCommand extends Command $io->writeln("Could not generate suggestion (check API key)"); } } - $io->writeln(""); } } - $io->warning("Scanned " . $totalViolations . " errors. Use --ai to get fix suggestions."); - - // 4. Export Report $reportPath = $input->getOption('report'); if (is_string($reportPath) && $reportPath !== '') { $extension = pathinfo($reportPath, PATHINFO_EXTENSION); @@ -252,6 +432,6 @@ class ScanCommand extends Command $io->success("Report saved to: $reportPath"); } - return Command::SUCCESS; + return Command::FAILURE; } } diff --git a/src/Core/Analyser.php b/src/Core/Analyser.php new file mode 100644 index 0000000..7d56f29 --- /dev/null +++ b/src/Core/Analyser.php @@ -0,0 +1,288 @@ +config = $config ?? new Config(); + $this->scanner = $scanner ?? $this->setupScanner(); + $this->baselineManager = $baselineManager ?? new BaselineManager(); + + $cwd = getcwd(); + $this->projectRoot = $cwd ? str_replace('\\', '/', realpath($cwd) ?: $cwd) : '.'; + + $baselinePath = $this->config->getBaselinePath(); + if ($baselinePath) { + $this->baselineManager->load($this->projectRoot . '/' . $baselinePath); + } + } + + private function setupScanner(): Scanner + { + $scanner = new Scanner(); + $rules = RuleLevels::getRulesForLevel($this->config->getLevel()); + $rulesConfig = $this->config->getRulesConfig(); + + $activeRules = []; + foreach ($rules as $rule) { + $ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) + ? $rule->getId() + : (new \ReflectionClass($rule))->getShortName(); + + if (in_array($ruleId, $rulesConfig['exclude'], true)) { + continue; + } + $activeRules[$ruleId] = $rule; + } + + // Include custom rules + foreach ($rulesConfig['include'] as $customRuleClass) { + if (class_exists($customRuleClass)) { + try { + $reflection = new \ReflectionClass($customRuleClass); + if ($reflection->isInstantiable()) { + $rule = $reflection->newInstance(); + $ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) + ? $rule->getId() + : $reflection->getShortName(); + $activeRules[$ruleId] = $rule; + } + } catch (\Throwable $e) { + // Ignore instantiation failures + } + } + } + + foreach ($activeRules as $rule) { + $scanner->addRule($rule); + } + + return $scanner; + } + + /** + * Analyse files and return a list of violations. + * + * @param array|null $pathsOverride + * @param callable|null $onProgress Function called for progress tracking: fn(string $file, int $index, int $total) + * @return array{violations: Violation[], baselinedCount: int} + */ + public function analyse(?array $pathsOverride = null, ?callable $onProgress = null): array + { + $paths = $pathsOverride ?? $this->config->getPaths(); + $excludePaths = $this->config->getExcludePaths(); + + // Resolve all files to scan + $filesToScan = []; + foreach ($paths as $path) { + if (is_file($path)) { + $filesToScan[] = str_replace('\\', '/', realpath($path) ?: $path); + } elseif (is_dir($path)) { + $files = $this->findFiles($path, $excludePaths); + $filesToScan = array_merge($filesToScan, $files); + } + } + + $filesToScan = array_unique($filesToScan); + $totalFiles = count($filesToScan); + + $violations = []; + $baselinedCount = 0; + + foreach ($filesToScan as $index => $file) { + if ($onProgress) { + $onProgress($file, $index + 1, $totalFiles); + } + + $content = @file_get_contents($file); + if ($content === false || trim($content) === '') { + continue; + } + + // Scan file content + try { + $fileViolations = $this->scanner->scan($content); + } catch (\Throwable $e) { + // Skip if parsing failed dramatically + continue; + } + + $relativeFile = $this->makePathRelative($file); + + foreach ($fileViolations as $violation) { + // Find line number using line-by-line helper + $line = $this->locateLineInContent($content, $violation->htmlSnippet); + + // Store location in violation + $violation->location = [ + 'file' => $relativeFile, + 'line' => $line + ]; + + // Check if ignored in baseline + if ($this->baselineManager->isIgnored($relativeFile, $violation->ruleId, $violation->htmlSnippet)) { + $baselinedCount++; + } else { + $violations[] = $violation; + } + } + } + + return [ + 'violations' => $violations, + 'baselinedCount' => $baselinedCount + ]; + } + + public function getBaselineManager(): BaselineManager + { + return $this->baselineManager; + } + + public function makePathRelative(string $absolutePath): string + { + $absolutePath = str_replace('\\', '/', realpath($absolutePath) ?: $absolutePath); + if ($this->projectRoot !== '.' && str_starts_with($absolutePath, $this->projectRoot)) { + $relative = ltrim(substr($absolutePath, strlen($this->projectRoot)), '/'); + return $relative === '' ? '.' : $relative; + } + return $absolutePath; + } + + /** + * @param array $excludePaths + * @return array + */ + private function findFiles(string $dir, array $excludePaths): array + { + $realDir = realpath($dir); + if ($realDir === false) { + return []; + } + + $files = []; + try { + $directory = new RecursiveDirectoryIterator($realDir, \FilesystemIterator::SKIP_DOTS); + $filter = new \RecursiveCallbackFilterIterator($directory, function ($current) use ($excludePaths) { + /** @var SplFileInfo $current */ + $filename = $current->getFilename(); + $pathname = str_replace('\\', '/', $current->getPathname()); + + // Exclude common directories/files + foreach ($excludePaths as $exclude) { + $normalizedExclude = str_replace('\\', '/', $exclude); + if ($filename === $normalizedExclude || str_contains($pathname, '/' . $normalizedExclude . '/')) { + return false; + } + } + return true; + }); + + $iterator = new RecursiveIteratorIterator($filter, RecursiveIteratorIterator::SELF_FIRST); + + $extensions = ['php', 'html', 'twig', 'blade.php']; + + foreach ($iterator as $file) { + /** @var SplFileInfo $file */ + if ($file->isDir()) { + continue; + } + + $filename = $file->getFilename(); + $isValidExt = false; + foreach ($extensions as $ext) { + if (str_ends_with($filename, '.' . $ext) || ($ext === 'blade.php' && str_ends_with($filename, '.blade.php'))) { + $isValidExt = true; + break; + } + } + + if ($isValidExt) { + $files[] = str_replace('\\', '/', $file->getPathname()); + } + } + } catch (\Throwable $e) { + // Ignore directory iteration errors + } + + return $files; + } + + private function locateLineInContent(string $content, string $snippet): int + { + $replaced = preg_replace('/\s+/', ' ', $snippet); + $cleanSnippet = is_string($replaced) ? trim($replaced) : ''; + if ($cleanSnippet === '') { + return 1; + } + + $searchSnippet = mb_strlen($cleanSnippet) > 150 ? mb_substr($cleanSnippet, 0, 150) : $cleanSnippet; + $lines = explode("\n", $content); + + // 1. Exact match pass + foreach ($lines as $index => $line) { + $replacedLine = preg_replace('/\s+/', ' ', $line); + $cleanLine = is_string($replacedLine) ? trim($replacedLine) : ''; + if (str_contains($cleanLine, $searchSnippet)) { + return $index + 1; + } + } + + // 2. Parse snippet to find tag and attributes for fuzzy match pass + preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches); + $tagName = $matches[1] ?? null; + if (!$tagName) { + return 1; + } + + preg_match_all('/([a-zA-Z0-9-]+)=["\']([^"\']*)["\']/', $snippet, $attrMatches, PREG_SET_ORDER); + $attributes = []; + foreach ($attrMatches as $match) { + $attributes[$match[1]] = $match[2]; + } + + $bestLine = 1; + $maxScore = 0; + + foreach ($lines as $index => $line) { + $replacedLine = preg_replace('/\s+/', ' ', $line); + $cleanLine = is_string($replacedLine) ? trim($replacedLine) : ''; + + if (!str_contains(strtolower($cleanLine), '<' . strtolower($tagName))) { + continue; + } + + $score = 1.0; + if (in_array(strtolower($tagName), ['html', 'head', 'body', 'title'], true)) { + $score += 1.0; + } + + foreach ($attributes as $name => $value) { + if (str_contains($cleanLine, "$name=\"$value\"") || str_contains($cleanLine, "$name='$value'")) { + $score += 2.0; + } elseif (str_contains($cleanLine, $name . '=')) { + $score += 0.5; + } + } + + if ($score > $maxScore) { + $maxScore = $score; + $bestLine = $index + 1; + } + } + + return $bestLine; + } +} diff --git a/src/Core/BaselineManager.php b/src/Core/BaselineManager.php new file mode 100644 index 0000000..2bd9590 --- /dev/null +++ b/src/Core/BaselineManager.php @@ -0,0 +1,96 @@ + */ + private array $ignoredKeys = []; + + public function load(string $baselinePath): bool + { + if (!file_exists($baselinePath) || !is_readable($baselinePath)) { + return false; + } + + $content = file_get_contents($baselinePath); + if ($content === false) { + return false; + } + + $data = json_decode($content, true); + if (!is_array($data) || !isset($data['violations']) || !is_array($data['violations'])) { + return false; + } + + foreach ($data['violations'] as $item) { + if (isset($item['file'], $item['ruleId'], $item['htmlSnippet'])) { + $key = $this->calculateKey($item['file'], $item['ruleId'], $item['htmlSnippet']); + $this->ignoredKeys[$key] = true; + } + } + + return true; + } + + /** + * @param Violation[] $violations + */ + public function generate(array $violations, string $baselinePath): bool + { + $serialized = [ + 'generator' => 'YakNet Accessibility Console', + 'violations' => [] + ]; + + foreach ($violations as $v) { + $file = $v->location['file'] ?? ''; + $serialized['violations'][] = [ + 'file' => $file, + 'ruleId' => $v->ruleId, + 'htmlSnippet' => $v->htmlSnippet + ]; + } + + // Sort baseline violations by file and ruleId for cleaner git diffs + usort($serialized['violations'], function ($a, $b) { + $fileCmp = strcmp($a['file'], $b['file']); + if ($fileCmp !== 0) { + return $fileCmp; + } + return strcmp($a['ruleId'], $b['ruleId']); + }); + + $json = json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + return false; + } + + $dir = dirname($baselinePath); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + return file_put_contents($baselinePath, $json) !== false; + } + + public function isIgnored(string $file, string $ruleId, string $htmlSnippet): bool + { + $key = $this->calculateKey($file, $ruleId, $htmlSnippet); + return isset($this->ignoredKeys[$key]); + } + + private function calculateKey(string $file, string $ruleId, string $htmlSnippet): string + { + // Normalize whitespaces and trim to prevent minor formatting updates from breaking baseline + $normalizedSnippet = preg_replace('/\s+/', ' ', trim($htmlSnippet)); + if ($normalizedSnippet === null) { + $normalizedSnippet = trim($htmlSnippet); + } + + // Normalize file separator to forward slashes for cross-platform compatibility + $normalizedFile = str_replace('\\', '/', $file); + + return md5($normalizedFile . '::' . $ruleId . '::' . $normalizedSnippet); + } +} diff --git a/src/Core/ChromeRenderer.php b/src/Core/ChromeRenderer.php new file mode 100644 index 0000000..0892569 --- /dev/null +++ b/src/Core/ChromeRenderer.php @@ -0,0 +1,61 @@ + [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; + } +} diff --git a/src/Core/Config.php b/src/Core/Config.php index a115afe..d3081c0 100644 --- a/src/Core/Config.php +++ b/src/Core/Config.php @@ -13,11 +13,34 @@ class Config public function __construct(array $settings = []) { $this->settings = $settings; + $this->loadDotenv(); + } + + private function loadDotenv(): void + { + $cwd = getcwd(); + if ($cwd && file_exists($cwd . '/.env')) { + try { + $dotenv = \Dotenv\Dotenv::createImmutable($cwd); + $dotenv->safeLoad(); + } catch (\Throwable $e) { + // Ignore Dotenv errors silently + } + } } public static function fromYaml(string $path): self { - // Placeholder for YAML loading logic + if (file_exists($path)) { + try { + $content = \Symfony\Component\Yaml\Yaml::parseFile($path); + if (is_array($content)) { + return new self($content); + } + } catch (\Throwable $e) { + // Ignore parsing errors + } + } return new self([]); } @@ -26,6 +49,78 @@ class Config return $this->settings[$key] ?? $default; } + /** + * @return array + */ + public function getPaths(): array + { + $paths = $this->get('paths'); + if (is_array($paths)) { + return $paths; + } + if (is_string($paths)) { + return [$paths]; + } + return ['.']; // default to current directory + } + + /** + * @return array + */ + public function getExcludePaths(): array + { + $exclude = $this->get('exclude_paths'); + if (is_array($exclude)) { + return $exclude; + } + if (is_string($exclude)) { + return [$exclude]; + } + return ['vendor', 'node_modules', 'tests', 'storage', '.git']; // sensible defaults + } + + public function getLevel(): int + { + $level = $this->get('level', 4); + return is_numeric($level) ? (int)$level : 4; + } + + public function getBaselinePath(): ?string + { + $baseline = $this->get('baseline'); + return is_string($baseline) ? $baseline : null; + } + + public function getFormat(): string + { + $format = $this->get('format', 'console'); + return is_string($format) ? $format : 'console'; + } + + /** + * @return array{include: array, exclude: array} + */ + public function getRulesConfig(): array + { + $rules = $this->get('rules', []); + $include = []; + $exclude = []; + + if (is_array($rules)) { + if (isset($rules['include']) && is_array($rules['include'])) { + $include = $rules['include']; + } + if (isset($rules['exclude']) && is_array($rules['exclude'])) { + $exclude = $rules['exclude']; + } + } + + return [ + 'include' => $include, + 'exclude' => $exclude + ]; + } + public function getGeminiApiKey(): ?string { return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null; diff --git a/src/Reporting/ConsoleFormatter.php b/src/Reporting/ConsoleFormatter.php new file mode 100644 index 0000000..5edf674 --- /dev/null +++ b/src/Reporting/ConsoleFormatter.php @@ -0,0 +1,84 @@ +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( + " Line %d: [%s] %s", + $line, + $severityColor, + $v->ruleId, + $v->message + )); + + // Show snippet + $snippet = (string)preg_replace('/\s+/', ' ', trim($v->htmlSnippet)); + $io->writeln(" Snippet: " . htmlspecialchars($snippet) . ""); + + if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') { + $io->writeln(" AI Suggestion: " . 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; + } +} diff --git a/src/Reporting/FormatterInterface.php b/src/Reporting/FormatterInterface.php new file mode 100644 index 0000000..fdb0106 --- /dev/null +++ b/src/Reporting/FormatterInterface.php @@ -0,0 +1,19 @@ +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; + } +} diff --git a/src/Reporting/JsonFormatter.php b/src/Reporting/JsonFormatter.php new file mode 100644 index 0000000..458c7cd --- /dev/null +++ b/src/Reporting/JsonFormatter.php @@ -0,0 +1,30 @@ +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; + } +} diff --git a/src/Rules/AccesskeyBanned.php b/src/Rules/AccesskeyBanned.php new file mode 100644 index 0000000..1508836 --- /dev/null +++ b/src/Rules/AccesskeyBanned.php @@ -0,0 +1,30 @@ +hasAttribute('accesskey')) { + return $this->createViolation( + $element, + $this->getDescription(), + 'Remove the accesskey attribute. Use natural focus order and standard keyboard event listeners instead.' + ); + } + + return null; + } +} diff --git a/src/Rules/ColorContrast.php b/src/Rules/ColorContrast.php index 0ccbe40..e9199c3 100644 --- a/src/Rules/ColorContrast.php +++ b/src/Rules/ColorContrast.php @@ -9,6 +9,9 @@ use YakNet\AccessibilityConsole\Core\WCAGStandard; class ColorContrast extends AbstractRule { + /** @var array> */ + private array $documentStylesCache = []; + public function getId(): string { return 'WCAG_1_4_3_CONTRAST'; } public function getDescription(): string { return 'Text must have sufficient contrast against the background (4.5:1 minimum).'; } public function getStandard(): WCAGStandard { return WCAGStandard::AA; } @@ -17,14 +20,27 @@ class ColorContrast extends AbstractRule public function check(DOMElement $element): ?Violation { - if (!$element->hasAttribute('style')) { - return null; - } + // 1. Parse inline styles (highest priority) + $inlineStyle = $element->hasAttribute('style') ? $element->getAttribute('style') : ''; + $color = $this->parseColorValue($inlineStyle, 'color'); + $bg = $this->parseColorValue($inlineStyle, 'background-color') ?: $this->parseColorValue($inlineStyle, 'background'); - $style = $element->getAttribute('style'); - - $color = $this->parseColorValue($style, 'color'); - $bg = $this->parseColorValue($style, 'background-color') ?: $this->parseColorValue($style, 'background'); + // 2. Fallback: Parse stylesheet styles + if (($color === null || $bg === null) && $element->ownerDocument !== null) { + $docStyles = $this->parseDocumentStyles($element->ownerDocument); + + // Loop through all parsed styles. Later matching rules override earlier ones + foreach ($docStyles as $selector => $styles) { + if ($this->elementMatchesSelector($element, $selector)) { + if ($color === null && isset($styles['color'])) { + $color = $styles['color']; + } + if ($bg === null && isset($styles['background-color'])) { + $bg = $styles['background-color']; + } + } + } + } if ($color === null || $bg === null) { return null; @@ -42,22 +58,11 @@ class ColorContrast extends AbstractRule $ratio = ($cLum > $bgLum) ? ($cLum + 0.05) / ($bgLum + 0.05) : ($bgLum + 0.05) / ($cLum + 0.05); + // Check font size & weight for large text rules $isLarge = false; - if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) { - $val = (float)$matches[1]; - $unit = strtolower($matches[2]); - if (($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5)) { - $isLarge = true; - } - } - if (preg_match('/font-weight\s*:\s*(bold|[7-9]00)/i', $style)) { - if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) { - $val = (float)$matches[1]; - $unit = strtolower($matches[2]); - if (($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2)) { - $isLarge = true; - } - } + $fontSizeStyle = $this->parseFontSize($inlineStyle, $element); + if ($fontSizeStyle !== null) { + $isLarge = $this->isLargeText($fontSizeStyle, $this->parseFontWeight($inlineStyle, $element)); } $minRatio = $isLarge ? 3.0 : 4.5; @@ -74,21 +79,203 @@ class ColorContrast extends AbstractRule return null; } + private function parseFontSize(string $style, DOMElement $element): ?string + { + if (preg_match('/font-size\s*:\s*([^;]+)/i', $style, $matches)) { + return trim($matches[1]); + } + return null; + } + + private function parseFontWeight(string $style, DOMElement $element): ?string + { + if (preg_match('/font-weight\s*:\s*([^;]+)/i', $style, $matches)) { + return trim($matches[1]); + } + return null; + } + + private function isLargeText(string $size, ?string $weight): bool + { + if (preg_match('/([0-9.]+)(px|em|rem|pt)/i', $size, $matches)) { + $val = (float)$matches[1]; + $unit = strtolower($matches[2]); + $isBold = $weight !== null && preg_match('/^(bold|[7-9]00)$/i', $weight); + + if ($isBold) { + return ($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2); + } else { + return ($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5); + } + } + return false; + } + + /** + * @return array + */ + private function parseDocumentStyles(\DOMDocument $doc): array + { + $hash = spl_object_hash($doc); + if (isset($this->documentStylesCache[$hash])) { + return $this->documentStylesCache[$hash]; + } + + $mergedStyles = []; + $styleTags = $doc->getElementsByTagName('style'); + + foreach ($styleTags as $tag) { + $cssText = $tag->textContent; + if (trim($cssText) === '') { + continue; + } + + try { + $parser = new \Sabberworm\CSS\Parser($cssText); + $cssDoc = $parser->parse(); + + foreach ($cssDoc->getAllDeclarationBlocks() as $block) { + $rules = []; + foreach ($block->getDeclarations() as $decl) { + $prop = strtolower($decl->getPropertyName()); + if ($prop === 'color' || $prop === 'background-color' || $prop === 'background') { + // Extract string value + $valObj = $decl->getValue(); + $val = is_object($valObj) && method_exists($valObj, '__toString') + ? (string)$valObj + : (is_string($valObj) ? $valObj : ''); + + if ($prop === 'color') { + $normalized = $this->normalizeColorValue($val); + if ($normalized !== null) { + $rules['color'] = $normalized; + } + } elseif ($prop === 'background-color') { + $normalized = $this->normalizeColorValue($val); + if ($normalized !== null) { + $rules['background-color'] = $normalized; + } + } elseif ($prop === 'background') { + $normalized = $this->extractColorFromBackground($val); + if ($normalized !== null) { + $rules['background-color'] = $normalized; + } + } + } + } + + if (empty($rules)) { + continue; + } + + foreach ($block->getSelectors() as $selector) { + $selectorString = trim($selector->getSelector()); + if ($selectorString !== '') { + if (!isset($mergedStyles[$selectorString])) { + $mergedStyles[$selectorString] = []; + } + $mergedStyles[$selectorString] = array_merge($mergedStyles[$selectorString], $rules); + } + } + } + } catch (\Throwable $e) { + // Ignore CSS parser failures + } + } + + $this->documentStylesCache[$hash] = $mergedStyles; + return $mergedStyles; + } + + private function elementMatchesSelector(DOMElement $element, string $selector): bool + { + $parts = array_map('trim', explode(',', $selector)); + foreach ($parts as $part) { + if ($part === '') { + continue; + } + + if (str_starts_with($part, '#')) { + $id = substr($part, 1); + if ($element->getAttribute('id') === $id) { + return true; + } + continue; + } + + if (str_starts_with($part, '.')) { + $class = substr($part, 1); + $classes = array_filter(array_map('trim', explode(' ', $element->getAttribute('class')))); + if (in_array($class, $classes, true)) { + return true; + } + continue; + } + + if (strtolower($element->tagName) === strtolower($part)) { + return true; + } + + if (preg_match('/^([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)$/', $part, $matches)) { + $tag = $matches[1]; + $class = $matches[2]; + $classes = array_filter(array_map('trim', explode(' ', $element->getAttribute('class')))); + if (strtolower($element->tagName) === strtolower($tag) && in_array($class, $classes, true)) { + return true; + } + } + + if (preg_match('/^([a-zA-Z0-9_-]+)#([a-zA-Z0-9_-]+)$/', $part, $matches)) { + $tag = $matches[1]; + $id = $matches[2]; + if (strtolower($element->tagName) === strtolower($tag) && $element->getAttribute('id') === $id) { + return true; + } + } + } + + return false; + } + + private function normalizeColorValue(string $val): ?string + { + $val = trim($val); + if (preg_match('/^#([0-9a-fA-F]{3,6})$/', $val)) { + return $val; + } + + if (preg_match('/rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)/i', $val, $matches)) { + return sprintf("#%02x%02x%02x", (int)$matches[1], (int)$matches[2], (int)$matches[3]); + } + + $colors = [ + 'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000', + 'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00', + 'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0', + ]; + if (isset($colors[strtolower($val)])) { + return $colors[strtolower($val)]; + } + + return null; + } + + private function extractColorFromBackground(string $val): ?string + { + $parts = explode(' ', $val); + foreach ($parts as $part) { + $color = $this->normalizeColorValue($part); + if ($color !== null) { + return $color; + } + } + return null; + } + private function parseColorValue(string $style, string $property): ?string { if (preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:\s*([^;]+)/i', $style, $matches)) { - $value = trim($matches[1]); - if (preg_match('/^#([0-9a-f]{3,6})$/i', $value)) { - return $value; - } - $colors = [ - 'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000', - 'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00', - 'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0', - ]; - if (isset($colors[strtolower($value)])) { - return $colors[strtolower($value)]; - } + return $this->normalizeColorValue(trim($matches[1])); } return null; } diff --git a/src/Rules/FontTagBanned.php b/src/Rules/FontTagBanned.php new file mode 100644 index 0000000..5cbf53c --- /dev/null +++ b/src/Rules/FontTagBanned.php @@ -0,0 +1,30 @@ +tagName) === 'font') { + return $this->createViolation( + $element, + $this->getDescription(), + 'Remove the tag and style the text using CSS instead.' + ); + } + + return null; + } +} diff --git a/src/Rules/InputTypeButtonDiscernible.php b/src/Rules/InputTypeButtonDiscernible.php new file mode 100644 index 0000000..f976bfd --- /dev/null +++ b/src/Rules/InputTypeButtonDiscernible.php @@ -0,0 +1,41 @@ +tagName) !== 'input') { + return null; + } + + $type = strtolower($element->getAttribute('type')); + if (in_array($type, ['button', 'submit', 'reset'], true)) { + $value = trim($element->getAttribute('value')); + $ariaLabel = trim($element->getAttribute('aria-label')); + $title = trim($element->getAttribute('title')); + + if ($value === '' && $ariaLabel === '' && $title === '') { + return $this->createViolation( + $element, + $this->getDescription(), + 'Add a non-empty value, aria-label, or title attribute to the input button.' + ); + } + } + + return null; + } +} diff --git a/src/Rules/LanguageSubtag.php b/src/Rules/LanguageSubtag.php new file mode 100644 index 0000000..27078c5 --- /dev/null +++ b/src/Rules/LanguageSubtag.php @@ -0,0 +1,39 @@ +hasAttribute('lang')) { + return null; + } + + $lang = trim($element->getAttribute('lang')); + + // Simple IETF language tag format check (e.g. "en", "tr", "en-US", "zh-Hant") + $isValid = preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/', $lang); + + if (!$isValid || $lang === '') { + return $this->createViolation( + $element, + $this->getDescription(), + 'Use a valid IETF language code format for the lang attribute (e.g., "tr", "en", "en-US").' + ); + } + + return null; + } +} diff --git a/src/Rules/LinkTargetBlank.php b/src/Rules/LinkTargetBlank.php new file mode 100644 index 0000000..d77abd0 --- /dev/null +++ b/src/Rules/LinkTargetBlank.php @@ -0,0 +1,37 @@ +tagName) !== 'a') { + return null; + } + + if (strtolower($element->getAttribute('target')) === '_blank') { + $rel = strtolower($element->getAttribute('rel')); + if (!str_contains($rel, 'noopener') && !str_contains($rel, 'noreferrer')) { + return $this->createViolation( + $element, + $this->getDescription(), + 'Add rel="noopener" or rel="noreferrer" to the link to improve security, performance, and screen reader notification.' + ); + } + } + + return null; + } +} diff --git a/src/Rules/MarqueeBanned.php b/src/Rules/MarqueeBanned.php new file mode 100644 index 0000000..62e5766 --- /dev/null +++ b/src/Rules/MarqueeBanned.php @@ -0,0 +1,30 @@ +tagName) === 'marquee') { + return $this->createViolation( + $element, + $this->getDescription(), + 'Remove the marquee tag and use CSS animations or transitions instead.' + ); + } + + return null; + } +} diff --git a/src/Rules/MediaAutoplay.php b/src/Rules/MediaAutoplay.php new file mode 100644 index 0000000..e6abc86 --- /dev/null +++ b/src/Rules/MediaAutoplay.php @@ -0,0 +1,38 @@ +tagName); + if ($tagName !== 'video' && $tagName !== 'audio') { + return null; + } + + if ($element->hasAttribute('autoplay')) { + // If autoplay is active, it must be muted to be acceptable + if (!$element->hasAttribute('muted')) { + return $this->createViolation( + $element, + $this->getDescription(), + 'Remove the autoplay attribute, or add the muted attribute to prevent the media from playing sound automatically.' + ); + } + } + + return null; + } +} diff --git a/src/Rules/NoFocusOutline.php b/src/Rules/NoFocusOutline.php new file mode 100644 index 0000000..32ed563 --- /dev/null +++ b/src/Rules/NoFocusOutline.php @@ -0,0 +1,37 @@ +hasAttribute('style')) { + return null; + } + + $style = strtolower($element->getAttribute('style')); + + // Match outline: none or outline: 0 + if (preg_match('/\boutline\s*:\s*(none|0)\b/', $style)) { + return $this->createViolation( + $element, + $this->getDescription(), + 'Avoid disabling focus outlines using outline: none or outline: 0 in inline styles. Ensure keyboard users can see where focus is.' + ); + } + + return null; + } +} diff --git a/src/Rules/ObjectAlt.php b/src/Rules/ObjectAlt.php new file mode 100644 index 0000000..08f813b --- /dev/null +++ b/src/Rules/ObjectAlt.php @@ -0,0 +1,53 @@ +tagName) !== 'object') { + return null; + } + + // Object can have title attribute + if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') { + return null; + } + + // Or object can contain fallback text content. + // We clean up child tags like or to find actual descriptive text. + $text = ''; + foreach ($element->childNodes as $child) { + if ($child instanceof \DOMText) { + $text .= $child->textContent; + } elseif ($child instanceof DOMElement) { + $tagName = strtolower($child->tagName); + if ($tagName !== 'param' && $tagName !== 'embed') { + $text .= $child->textContent; + } + } + } + + if (trim($text) === '') { + return $this->createViolation( + $element, + $this->getDescription(), + 'Add fallback text inside the element or provide a descriptive title attribute.' + ); + } + + return null; + } +} diff --git a/src/Rules/TableHeaders.php b/src/Rules/TableHeaders.php new file mode 100644 index 0000000..a75f8e1 --- /dev/null +++ b/src/Rules/TableHeaders.php @@ -0,0 +1,44 @@ +tagName) !== 'table') { + return null; + } + + // Skip layout tables explicitly marked as presentation + $role = strtolower($element->getAttribute('role')); + if ($role === 'presentation' || $role === 'none') { + return null; + } + + // Check if there is at least one or + $thCount = $element->getElementsByTagName('th')->length; + $captionCount = $element->getElementsByTagName('caption')->length; + + if ($thCount === 0 && $captionCount === 0) { + return $this->createViolation( + $element, + $this->getDescription(), + 'Add header elements or a to the table, or set role="presentation" if it is only used for layout.' + ); + } + + return null; + } +}