commit e34802557966ffd4ade1775d4e849a1b23b133e7 Author: Enes Yakıştır Date: Wed May 13 09:10:43 2026 +0300 Major upgrade v2.0.0 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f0d664a --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +GEMINI_API_KEY=your_api_key_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9805767 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +/.env +/composer.lock +/rapor.html +/.phpunit.result.cache +/phpstan-baseline.neon diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..288f9dd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 YakNet Bilişim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..22ecfdb --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# YakNet Accessibility Console 2.0 (AI-Powered) + +[![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. + +## 🌟 Ö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. + +## 📦 Kurulum + +Composer ile projenize hemen 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: +```bash +bin/a11y scan https://test-siteniz.com +``` + +### 2. AI Destekli Tarama ve Raporlama +Yapay zeka önerileriyle birlikte tarama yapın ve sonucu HTML olarak kaydedin: +```bash +bin/a11y scan https://test-siteniz.com --ai --report=sonuc.html +``` + +### 3. Otomatik Düzeltme (Self-Healing) - **DEVRİMSEL** +Tespit edilen hataları yapay zekaya düzelttirin ve dosyalarınızı güncelleyin: +```bash +bin/a11y fix https://test-siteniz.com --project-path=/var/www/projeniz +``` + +## 💻 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"; +} +``` + +## ⚙️ Yapılandırma + +AI özelliklerini kullanabilmek için projenizin kök dizininde bir `.env` dosyası oluşturun ve **Google Gemini API** anahtarınızı ekleyin: + +```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 + +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. + +## 📜 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. \ No newline at end of file diff --git a/bin/a11y b/bin/a11y new file mode 100644 index 0000000..c447cb0 --- /dev/null +++ b/bin/a11y @@ -0,0 +1,16 @@ +#!/usr/bin/env php +add(new ScanCommand()); +$application->add(new FixCommand()); + +$application->run(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0db6a3e --- /dev/null +++ b/composer.json @@ -0,0 +1,49 @@ +{ + "name": "yaknet/accessibility-console", + "description": "A professional AI-powered WCAG 2.1 accessibility scanner with self-healing capabilities.", + "keywords": [ + "accessibility", + "wcag", + "a11y", + "scanner", + "ai", + "gemini", + "self-healing", + "yaknet" + ], + "homepage": "https://forum.yak.net.tr", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "YakNet Bilişim", + "email": "dev@yak.net.tr", + "role": "Developer" + } + ], + "require": { + "php": "^8.2", + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "symfony/console": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0", + "monolog/monolog": "^3.0", + "vlucas/phpdotenv": "^5.6", + "google-gemini-php/client": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "phpstan/phpstan": "^1.10" + }, + "autoload": { + "psr-4": { + "YakNet\\AccessibilityConsole\\": "src/" + } + }, + "bin": [ + "bin/a11y" + ], + "minimum-stability": "stable", + "prefer-stable": true +} \ No newline at end of file diff --git a/demo/broken.html b/demo/broken.html new file mode 100644 index 0000000..d39825b --- /dev/null +++ b/demo/broken.html @@ -0,0 +1,20 @@ + + + + Broken Page + + +

Welcome to a broken page

+ + + + + + + + + + +

This page has multiple accessibility issues.

+ + diff --git a/demo/dynamic_preview.html b/demo/dynamic_preview.html new file mode 100644 index 0000000..5a6eed5 --- /dev/null +++ b/demo/dynamic_preview.html @@ -0,0 +1,195 @@ + + + + + + + YakNet Dynamic Demo + + + + +
+
+

My Dynamic Site

+ +
+
+ +
+

Latest Products

+

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

+ + +
+
+ + +

Super Gadget

+

$99

+ + +
+
+ + +

Mega Widget

+

$49

+ + +
+
+ + +

Ultra Thing

+

$199

+ + +
+
+ +
+ +

Sponsors

+
+ + + +
+
+ +
+ © 2026 YakNet Demo +
+ +
+ ⚠️ Accessibility: 5 Errors +
+ + + + + \ No newline at end of file diff --git a/demo/fixed_preview.html b/demo/fixed_preview.html new file mode 100644 index 0000000..9c4c3bc --- /dev/null +++ b/demo/fixed_preview.html @@ -0,0 +1,112 @@ + + + + + + + YakNet Dynamic Demo (Fixed) + + + + +
+
+

My Dynamic Site (Accessible)

+ +
+
+ +
+

Latest Products

+

Welcome to our accessible product catalog.

+ + +
+
+ + Super Gadget +

Super Gadget

+

$99

+ +
+
+ + Mega Widget +

Mega Widget

+

$49

+ +
+
+ + Ultra Thing +

Ultra Thing

+

$199

+ +
+
+ +
+ +

Sponsors

+
+ + + +
+
+ +
+ © 2026 YakNet Demo +
+
+ ✅ Accessibility: 0 Errors
+ + + \ No newline at end of file diff --git a/demo/fixed_preview_with_widget.html b/demo/fixed_preview_with_widget.html new file mode 100644 index 0000000..7984a99 --- /dev/null +++ b/demo/fixed_preview_with_widget.html @@ -0,0 +1,23 @@ + + + + + + + YakNet Dynamic Demo (Fixed) + + + + + +

My Dynamic Site (Accessible)

+

Success widget demonstration.

+ + +
+ ✅ Accessibility: 0 Errors +
+ + + \ No newline at end of file diff --git a/demo/index.php b/demo/index.php new file mode 100644 index 0000000..51eace3 --- /dev/null +++ b/demo/index.php @@ -0,0 +1,70 @@ +addRule(new ImgAltText()); +$scanner->addRule(new FormLabel()); +$scanner->addRule(new \YakNet\AccessibilityConsole\Rules\HtmlHasLang()); +$scanner->addRule(new \YakNet\AccessibilityConsole\Rules\EmptyLink()); + +// Scan the captured HTML +$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); + if ($location) { + $violation->setSourceLocation($location['file'], $location['line']); + } + } + + // Render Report + $reporter = new HtmlReporter(); + $reportHtml = $reporter->render($violations); + + // Inject into body + $html = str_replace('', $reportHtml . '', $html); +} + +// 7. Output Final HTML +echo $html; diff --git a/demo/index_fixed.php b/demo/index_fixed.php new file mode 100644 index 0000000..e15d0db --- /dev/null +++ b/demo/index_fixed.php @@ -0,0 +1,75 @@ +addRule(new ImgAltText()); +$scanner->addRule(new FormLabel()); +$scanner->addRule(new HtmlHasLang()); +$scanner->addRule(new EmptyLink()); + +$violations = $scanner->scan($html); + +if (!empty($violations)) { + // Locate Sources + $locator = new SourceLocator(__DIR__ . '/views-fixed'); + + foreach ($violations as $violation) { + $location = $locator->locate($violation->snippet); + if ($location) { + $violation->setSourceLocation($location['file'], $location['line']); + } + } + + $reporter = new HtmlReporter(); + // Enable success widget for this demo + $reporter->setShowSuccess(true); + + $reportHtml = $reporter->render($violations); + $html = str_replace('', $reportHtml . '', $html); +} else { + // Violations is empty, but render() will return the success widget because we set it to true + $reporter = new HtmlReporter(); + $reporter->setShowSuccess(true); + $reportHtml = $reporter->render($violations); + + $html = str_replace('', $reportHtml . '', $html); +} + +echo $html; diff --git a/demo/static_preview.html b/demo/static_preview.html new file mode 100644 index 0000000..9c475e2 --- /dev/null +++ b/demo/static_preview.html @@ -0,0 +1,102 @@ + + + + + + Accessibility Console Preview + + + +

Accessibility Console Preview

+

This is a static preview of how the error console looks.

+

Click the "Accessibility: 2 Errors" button in the bottom right.

+ + +
+ ⚠️ Accessibility: 2 Errors +
+ + + + + \ No newline at end of file diff --git a/demo/views-fixed/contact.php b/demo/views-fixed/contact.php new file mode 100644 index 0000000..59eed19 --- /dev/null +++ b/demo/views-fixed/contact.php @@ -0,0 +1,32 @@ +

Contact Us

+

Get in touch with us.

+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + + + Send + + + +
\ No newline at end of file diff --git a/demo/views-fixed/home.php b/demo/views-fixed/home.php new file mode 100644 index 0000000..ee7b980 --- /dev/null +++ b/demo/views-fixed/home.php @@ -0,0 +1,36 @@ +

Latest Products

+

Welcome to our accessible product catalog.

+ + 1, 'name' => 'Super Gadget', 'image' => 'gadget.jpg', 'price' => '$99'], + ['id' => 2, 'name' => 'Mega Widget', 'image' => 'widget.png', 'price' => '$49'], + ['id' => 3, 'name' => 'Ultra Thing', 'image' => 'thing.jpg', 'price' => '$199'], +]; +?> + +
+ +
+ + <?php echo htmlspecialchars($product['name']); ?> +

+ +

+

+ +

+ +
+ +
+ +
+ +

Sponsors

+
+ + + +
\ No newline at end of file diff --git a/demo/views-fixed/layout.php b/demo/views-fixed/layout.php new file mode 100644 index 0000000..f09bf0d --- /dev/null +++ b/demo/views-fixed/layout.php @@ -0,0 +1,75 @@ + + + + + + + YakNet Dynamic Demo (Fixed) + + + + +
+
+

My Dynamic Site (Accessible)

+ +
+
+ +
+ +
+ +
+ © + YakNet Demo +
+ + + \ No newline at end of file diff --git a/demo/views/contact.php b/demo/views/contact.php new file mode 100644 index 0000000..4335ea9 --- /dev/null +++ b/demo/views/contact.php @@ -0,0 +1,31 @@ +

Contact Us

+

Get in touch with us.

+ +
+
+ +
Name:
+ +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + + + + + + +
\ No newline at end of file diff --git a/demo/views/home.php b/demo/views/home.php new file mode 100644 index 0000000..b63a191 --- /dev/null +++ b/demo/views/home.php @@ -0,0 +1,36 @@ +

Latest Products

+

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

+ + 1, 'name' => 'Super Gadget', 'image' => 'gadget.jpg', 'price' => '$99'], + ['id' => 2, 'name' => 'Mega Widget', 'image' => 'widget.png', 'price' => '$49'], + ['id' => 3, 'name' => 'Ultra Thing', 'image' => 'thing.jpg', 'price' => '$199'], +]; +?> + +
+ +
+ + +

+ +

+

+ +

+ + +
+ +
+ +
+ +

Sponsors

+
+ + + +
\ No newline at end of file diff --git a/demo/views/layout.php b/demo/views/layout.php new file mode 100644 index 0000000..7bdd26b --- /dev/null +++ b/demo/views/layout.php @@ -0,0 +1,74 @@ + + + + + + + YakNet Dynamic Demo + + + + +
+
+

My Dynamic Site

+ +
+
+ +
+ +
+ +
+ © + YakNet Demo +
+ + + \ No newline at end of file diff --git a/src/AI/GeminiFixer.php b/src/AI/GeminiFixer.php new file mode 100644 index 0000000..7e320a1 --- /dev/null +++ b/src/AI/GeminiFixer.php @@ -0,0 +1,52 @@ +apiKey && class_exists(Gemini::class)) { + $this->client = Gemini::client($this->apiKey); + } + } + + public function suggestFix(Violation $violation): ?string + { + if (!$this->client) { + return null; + } + + $prompt = <<htmlSnippet} +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. +PROMPT; + + try { + $result = $this->client->geminiPro()->generateContent($prompt); + $suggestion = trim($result->text()); + + // Clean up if AI included markdown blocks anyway + $suggestion = preg_replace('/^```html\s*|\s*```$/i', '', $suggestion); + + return $suggestion; + } catch (\Throwable $e) { + // Log error if needed + return null; + } + } +} diff --git a/src/Console/Command/FixCommand.php b/src/Console/Command/FixCommand.php new file mode 100644 index 0000000..4cf6ad1 --- /dev/null +++ b/src/Console/Command/FixCommand.php @@ -0,0 +1,136 @@ +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'); + } + + 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'); + + $config = new Config(); + $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; + } + + $io->title("AI Self-Healing Mode - Fixing: $target"); + + // 1. Fetch HTML + $html = @file_get_contents($target); + if (!$html) { + $io->error("Could not read target: $target"); + return Command::FAILURE; + } + + // 2. Scan + $scanner = new Scanner(); + foreach (StandardRuleSet::all() as $rule) { + $scanner->addRule($rule); + } + $violations = $scanner->scan($html); + + if (empty($violations)) { + $io->success("Everything looks good! No violations to fix."); + return Command::SUCCESS; + } + + $io->note("Found " . count($violations) . " violations. Starting AI analysis..."); + + $fixer = new GeminiFixer($apiKey); + $locator = new SourceLocator($projectPath); + $fixedCount = 0; + + foreach ($violations as $violation) { + $io->write("Analyzing {$violation->ruleId}... "); + + // Locate source + $location = $locator->locate($violation->htmlSnippet); + if (!$location) { + $io->writeln("Skipped (Source not found)"); + continue; + } + + // Get AI Suggestion + $suggestion = $fixer->suggestFix($violation); + if (!$suggestion) { + $io->writeln("Skipped (AI could not generate 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($location['file'], $violation->htmlSnippet, $suggestion)) { + $io->writeln(" ✔ Applied to {$location['file']}:{$location['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. Check error logs for details."); + } + + return Command::SUCCESS; + } + + private function applyFix(string $filePath, string $originalSnippet, string $fixedSnippet): bool + { + $content = file_get_contents($filePath); + if ($content === false) return false; + + // Try exact replacement first + 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 + $newContent = str_replace(trim($originalSnippet), trim($fixedSnippet), $content); + } + + if ($newContent === $content) { + return false; + } + + return file_put_contents($filePath, $newContent) !== false; + } +} diff --git a/src/Console/Command/ScanCommand.php b/src/Console/Command/ScanCommand.php new file mode 100644 index 0000000..fa4e9d1 --- /dev/null +++ b/src/Console/Command/ScanCommand.php @@ -0,0 +1,113 @@ +setName('scan') + ->setDescription('Scan a URL or file for accessibility violations') + ->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan') + ->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'); + } + + 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(); + + $io->title("Accessibility Console - Scanning: $target"); + + // 1. Fetch HTML + $html = @file_get_contents($target); + if (!$html) { + $io->error("Could not read target: $target"); + return Command::FAILURE; + } + + // 2. Setup Scanner + $scanner = new Scanner(); + foreach (StandardRuleSet::all() as $rule) { + $scanner->addRule($rule); + } + + // 3. Scan + $violations = $scanner->scan($html); + + if (empty($violations)) { + $io->success("No violations found!"); + return Command::SUCCESS; + } + + $io->section("Found " . count($violations) . " violations"); + + // 4. Setup Helpers + $locator = new SourceLocator($projectPath); + $config = new Config(); + $fixer = $enableAi ? new GeminiFixer($config->getGeminiApiKey()) : null; + + 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']}"); + } + + // AI Fixer + if ($enableAi && $fixer) { + $io->write(" AI Fix Suggestion: "); + $suggestion = $fixer->suggestFix($violation); + if ($suggestion) { + $io->writeln("" . htmlspecialchars($suggestion) . ""); + } else { + $io->writeln("Could not generate suggestion (check API key)"); + } + } + + $io->writeln(""); + } + + $io->warning("Scanned " . count($violations) . " errors. Use --ai to get fix suggestions."); + + // Export Report + $reportPath = $input->getOption('report'); + if ($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)); + } + $io->success("Report saved to: $reportPath"); + } + + return Command::SUCCESS; + } +} diff --git a/src/Core/Config.php b/src/Core/Config.php new file mode 100644 index 0000000..7d59000 --- /dev/null +++ b/src/Core/Config.php @@ -0,0 +1,29 @@ +settings = $settings; + } + + public static function fromYaml(string $path): self + { + // Placeholder for YAML loading logic + return new self([]); + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->settings[$key] ?? $default; + } + + public function getGeminiApiKey(): ?string + { + return $this->get('gemini_api_key') ?? $_ENV['GEMINI_API_KEY'] ?? getenv('GEMINI_API_KEY') ?: null; + } +} diff --git a/src/Core/RuleEngine.php b/src/Core/RuleEngine.php new file mode 100644 index 0000000..13b41d0 --- /dev/null +++ b/src/Core/RuleEngine.php @@ -0,0 +1,24 @@ +rules[] = $rule; + } + + /** + * @return RuleInterface[] + */ + public function getRules(): array + { + return $this->rules; + } +} diff --git a/src/Core/Scanner.php b/src/Core/Scanner.php new file mode 100644 index 0000000..f0d1eb3 --- /dev/null +++ b/src/Core/Scanner.php @@ -0,0 +1,63 @@ +engine) { + $this->rules = $this->engine->getRules(); + } + } + + public function addRule(RuleInterface $rule): void + { + $this->rules[] = $rule; + } + + /** + * @return Violation[] + */ + public function scan(string $html): array + { + if (trim($html) === '') { + return []; + } + + $dom = new DOMDocument(); + $previousState = libxml_use_internal_errors(true); + $loaded = $dom->loadHTML('' . $html, LIBXML_NOERROR | LIBXML_NOWARNING); + libxml_clear_errors(); + libxml_use_internal_errors($previousState); + + if (!$loaded) { + return []; + } + + $violations = []; + $elements = $dom->getElementsByTagName('*'); + + foreach ($elements as $element) { + if (!$element instanceof \DOMElement) continue; + foreach ($this->rules as $rule) { + try { + $violation = $rule->check($element); + if ($violation) { + $violations[] = $violation; + } + } catch (\Throwable) { + continue; + } + } + } + + return $violations; + } +} diff --git a/src/Core/Severity.php b/src/Core/Severity.php new file mode 100644 index 0000000..9f06dc1 --- /dev/null +++ b/src/Core/Severity.php @@ -0,0 +1,21 @@ + 'info', + self::WARNING => 'comment', + self::ERROR => 'error', + self::CRITICAL => 'error', + }; + } +} diff --git a/src/Core/Violation.php b/src/Core/Violation.php new file mode 100644 index 0000000..8036f3b --- /dev/null +++ b/src/Core/Violation.php @@ -0,0 +1,29 @@ + '...', 'line' => 0] + public ?string $fixSuggestion = null + ) {} + + public function toArray(): array + { + return [ + 'ruleId' => $this->ruleId, + 'message' => $this->message, + 'severity' => $this->severity->value, + 'standard' => $this->standard->value, + 'htmlSnippet' => $this->htmlSnippet, + 'location' => $this->location, + 'fixSuggestion' => $this->fixSuggestion, + ]; + } +} diff --git a/src/Core/WCAGStandard.php b/src/Core/WCAGStandard.php new file mode 100644 index 0000000..47cbe79 --- /dev/null +++ b/src/Core/WCAGStandard.php @@ -0,0 +1,10 @@ +severity->value); + $rows .= " + + {$v->ruleId} + {$v->message} +
" . htmlspecialchars($v->htmlSnippet) . "
+ " . ($v->location ? "{$v->location['file']}:{$v->location['line']}" : 'N/A') . " + "; + } + + return << + + + + A11y Report - {$target} + + + +
+

Accessibility Report

+

Target: {$target} | Generated: {$date}

+ +
+
+

{$count}

+

Violations Found

+
+
+

WCAG 2.1

+

Standard Level

+
+
+ + + + + + + + + + + + {$rows} + +
Rule IDIssueCode SnippetSource Location
+
+ + +HTML; + } +} diff --git a/src/Rules/AbstractRule.php b/src/Rules/AbstractRule.php new file mode 100644 index 0000000..571cf7b --- /dev/null +++ b/src/Rules/AbstractRule.php @@ -0,0 +1,28 @@ +getId(), + message: $message, + severity: $this->getSeverity(), + standard: $this->getStandard(), + htmlSnippet: $element->ownerDocument->saveHTML($element), + fixSuggestion: $suggestion + ); + } +} diff --git a/src/Rules/ButtonName.php b/src/Rules/ButtonName.php new file mode 100644 index 0000000..86180de --- /dev/null +++ b/src/Rules/ButtonName.php @@ -0,0 +1,30 @@ +tagName) !== 'button') { + return null; + } + + $text = trim($element->textContent); + if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) { + return $this->createViolation($element, $this->getDescription(), 'Add text or an aria-label to the button.'); + } + + return null; + } +} diff --git a/src/Rules/EmptyLink.php b/src/Rules/EmptyLink.php new file mode 100644 index 0000000..2120313 --- /dev/null +++ b/src/Rules/EmptyLink.php @@ -0,0 +1,38 @@ +tagName) !== 'a') { + return null; + } + + $text = trim($element->textContent); + if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) { + // Check for images inside + $imgs = $element->getElementsByTagName('img'); + foreach ($imgs as $img) { + if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') { + return null; + } + } + + return $this->createViolation($element, $this->getDescription(), 'Add text content or an aria-label to the link.'); + } + + return null; + } +} diff --git a/src/Rules/FieldsetLegend.php b/src/Rules/FieldsetLegend.php new file mode 100644 index 0000000..86f7510 --- /dev/null +++ b/src/Rules/FieldsetLegend.php @@ -0,0 +1,30 @@ +tagName) !== 'fieldset') { + return null; + } + + $legends = $element->getElementsByTagName('legend'); + if ($legends->length === 0) { + return $this->createViolation($element, $this->getDescription(), 'Add a inside the fieldset.'); + } + + return null; + } +} diff --git a/src/Rules/FormLabel.php b/src/Rules/FormLabel.php new file mode 100644 index 0000000..176967f --- /dev/null +++ b/src/Rules/FormLabel.php @@ -0,0 +1,55 @@ +tagName), $tags)) { + return null; + } + + // Skip hidden inputs and buttons + $type = strtolower($element->getAttribute('type')); + if ($type === 'hidden' || $type === 'submit' || $type === 'button' || $type === 'reset') { + return null; + } + + if ($element->hasAttribute('id')) { + $id = $element->getAttribute('id'); + $xpath = new \DOMXPath($element->ownerDocument); + $labels = $xpath->query("//label[@for='$id']"); + if ($labels->length > 0) { + return null; + } + } + + // Check for aria-label or aria-labelledby + if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby')) { + return null; + } + + // Check if wrapped in label + $parent = $element->parentNode; + while ($parent) { + if ($parent instanceof DOMElement && strtolower($parent->tagName) === 'label') { + return null; + } + $parent = $parent->parentNode; + } + + return $this->createViolation($element, $this->getDescription(), 'Add a