Major upgrade v2.0.0

This commit is contained in:
Enes Yakıştır
2026-05-13 09:10:43 +03:00
commit e348025579
41 changed files with 2139 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
GEMINI_API_KEY=your_api_key_here
+6
View File
@@ -0,0 +1,6 @@
/vendor/
/.env
/composer.lock
/rapor.html
/.phpunit.result.cache
/phpstan-baseline.neon
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 YakNet Bilişim <[email protected]>
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.
+96
View File
@@ -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.
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
use Symfony\Component\Console\Application;
use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
$application = new Application('YakNet Accessibility Console', '2.0.0');
// Add commands
$application->add(new ScanCommand());
$application->add(new FixCommand());
$application->run();
+49
View File
@@ -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": "[email protected]",
"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
}
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<title>Broken Page</title>
</head>
<body>
<h1>Welcome to a broken page</h1>
<!-- Missing alt attribute -->
<img src="logo.png">
<!-- Empty link -->
<a href="https://google.com"></a>
<!-- Empty button -->
<button type="submit"></button>
<p>This page has multiple accessibility issues.</p>
</body>
</html>
+195
View File
@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YakNet Dynamic Demo</title>
<style>
body {
font-family: sans-serif;
margin: 0;
padding: 0;
line-height: 1.6;
}
header {
background: #333;
color: #fff;
padding: 1rem;
}
nav a {
color: #fff;
margin-right: 1rem;
text-decoration: none;
}
.container {
padding: 2rem;
max-width: 800px;
margin: 0 auto;
}
.card {
border: 1px solid #ddd;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.btn {
background: #007bff;
color: white;
padding: 0.5rem 1rem;
border: none;
cursor: pointer;
}
</style>
</head>
<body>
<header>
<div class="container" style="padding: 0;">
<h1>My Dynamic Site</h1>
<nav>
<a href="?page=home">Home</a>
<a href="?page=contact">Contact</a>
<a href="?page=about">About</a>
</nav>
</div>
</header>
<main class="container">
<h2>Latest Products</h2>
<p>Welcome to our dynamic product catalog. (Intentionally filled with errors)</p>
<div class="products">
<div class="card">
<!-- ERROR: Dynamic image without alt text -->
<img src="images/gadget.jpg" width="100">
<h3>Super Gadget</h3>
<p>$99</p>
<button class="btn">Buy Now</button>
<!-- Error: Button might need specific accessible name if icon-only, but text is okay here. -->
</div>
<div class="card">
<!-- ERROR: Dynamic image without alt text -->
<img src="images/widget.png" width="100">
<h3>Mega Widget</h3>
<p>$49</p>
<button class="btn">Buy Now</button>
<!-- Error: Button might need specific accessible name if icon-only, but text is okay here. -->
</div>
<div class="card">
<!-- ERROR: Dynamic image without alt text -->
<img src="images/thing.jpg" width="100">
<h3>Ultra Thing</h3>
<p>$199</p>
<button class="btn">Buy Now</button>
<!-- Error: Button might need specific accessible name if icon-only, but text is okay here. -->
</div>
</div>
<hr>
<h3>Sponsors</h3>
<div>
<!-- ERROR: Decorative image causing noise, missing alt="" -->
<img src="https://via.placeholder.com/150">
<img src="https://via.placeholder.com/150">
</div>
</main>
<footer style="text-align: center; padding: 2rem; background: #f4f4f4; margin-top: 2rem;">
&copy; 2026 YakNet Demo
</footer>
<div id='yaknet-a11y-console-toggle' onclick='document.getElementById("yaknet-a11y-console").style.display="block"'
style='position:fixed; bottom:10px; right:10px; background:#ff9900; color:white; padding:8px 15px; border-radius:4px; cursor:pointer; font-family:sans-serif; z-index:99999; font-weight:bold; font-size:14px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); border: 1px solid #cc7a00;'>
⚠️ Accessibility: 5 Errors
</div>
<div id='yaknet-a11y-console'
style='display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:100000; font-family:Verdana, sans-serif;'>
<div
style='width:90%; max-width:1200px; margin:50px auto; background:#fff; box-shadow:0 0 20px rgba(0,0,0,0.5); overflow:hidden; border-radius:4px;'>
<!-- Header imitating PHP Error -->
<div
style='background:#f7f7f7; color:#333; padding:15px 20px; border-bottom:1px solid #ccc; font-size:18px; font-weight:bold; display:flex; justify-content:space-between; align-items:center;'>
<span>
<span
style='background:#d9534f; color:white; padding:2px 6px; border-radius:3px; font-size:14px; vertical-align:middle; margin-right:10px;'>FATAL
ERROR</span>
Accessibility Violations Detected
</span>
<button onclick='document.getElementById("yaknet-a11y-console").style.display="none"'
style='border:none; background:none; font-size:24px; cursor:pointer; color:#999; line-height:1;'>&times;</button>
</div>
<div style='max-height:80vh; overflow-y:auto; padding:0;'>
<table style='width:100%; border-collapse:collapse; font-size:14px;'>
<thead style='background:#eeeff0; color:#333; text-align:left;'>
<tr>
<th style='padding:12px 15px; border-bottom:2px solid #ddd; width: 50px;'>#</th>
<th style='padding:12px 15px; border-bottom:2px solid #ddd;'>Violation / Message</th>
<th style='padding:12px 15px; border-bottom:2px solid #ddd;'>Source File</th>
</tr>
</thead>
<tbody>
<tr style='border-bottom:1px solid #eee; background:#fff;'>
<td style='padding:12px 15px; vertical-align:top; color:#999;'>1</td>
<td style='padding:12px 15px; vertical-align:top;'>
<div style='color:#cc0000; font-weight:bold; margin-bottom:5px; font-size:15px;'>Image
is missing alt attribute.</div>
<div style='font-size:12px; color:#555; margin-bottom:8px;'>Rule:
<code>img-alt-text</code></div>
<div
style='background:#f1f1f1; padding:8px; border-left:3px solid #ccc; font-family:monospace; font-size:12px; margin-bottom:8px; color:#333; overflow-x:auto;'>
&lt;img src="images/gadget.jpg" width="100"&gt;
</div>
<div style='font-size:13px; color:#222;'>
<span style='color:#28a745; font-weight:bold;'>Fix:</span> Add an alt="" attribute
describing the image, or alt="" if decorative.
</div>
</td>
<td
style='padding:12px 15px; vertical-align:top; font-family:monospace; font-size:13px; color:#333;'>
<div style='margin-bottom:5px;'>
home.php<strong>:14</strong>
</div>
</td>
</tr>
<tr style='border-bottom:1px solid #eee; background:#f9f9f9;'>
<td style='padding:12px 15px; vertical-align:top; color:#999;'>2</td>
<td style='padding:12px 15px; vertical-align:top;'>
<div style='color:#cc0000; font-weight:bold; margin-bottom:5px; font-size:15px;'>Image
is missing alt attribute.</div>
<div style='font-size:12px; color:#555; margin-bottom:8px;'>Rule:
<code>img-alt-text</code></div>
<div
style='background:#f1f1f1; padding:8px; border-left:3px solid #ccc; font-family:monospace; font-size:12px; margin-bottom:8px; color:#333; overflow-x:auto;'>
&lt;img src="images/widget.png" width="100"&gt;
</div>
<div style='font-size:13px; color:#222;'>
<span style='color:#28a745; font-weight:bold;'>Fix:</span> Add an alt="" attribute
describing the image, or alt="" if decorative.
</div>
</td>
<td
style='padding:12px 15px; vertical-align:top; font-family:monospace; font-size:13px; color:#333;'>
<div style='margin-bottom:5px;'>
home.php<strong>:14</strong>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="tr"> <!-- FIXED: Added lang attribute -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YakNet Dynamic Demo (Fixed)</title>
<style>
body {
font-family: sans-serif;
margin: 0;
padding: 0;
line-height: 1.6;
}
header {
background: #333;
color: #fff;
padding: 1rem;
}
nav a {
color: #fff;
margin-right: 1rem;
text-decoration: none;
}
.container {
padding: 2rem;
max-width: 800px;
margin: 0 auto;
}
.card {
border: 1px solid #ddd;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.btn {
background: #28a745;
color: white;
padding: 0.5rem 1rem;
border: none;
cursor: pointer;
}
</style>
</head>
<body>
<header>
<div class="container" style="padding: 0;">
<h1>My Dynamic Site (Accessible)</h1>
<nav>
<a href="?page=home">Home</a>
<a href="?page=contact">Contact</a>
<a href="?page=about">About</a>
<!-- FIXED: Added aria-label to empty link, or removed it/added text -->
<a href="#" class="btn" aria-label="Profile">👤</a>
</nav>
</div>
</header>
<main class="container">
<h2>Latest Products</h2>
<p>Welcome to our accessible product catalog.</p>
<div class="products">
<div class="card">
<!-- FIXED: Added alt attribute describing the product -->
<img src="images/gadget.jpg" width="100" alt="Super Gadget">
<h3>Super Gadget</h3>
<p>$99</p>
<button class="btn">Buy Now</button>
</div>
<div class="card">
<!-- FIXED: Added alt attribute describing the product -->
<img src="images/widget.png" width="100" alt="Mega Widget">
<h3>Mega Widget</h3>
<p>$49</p>
<button class="btn">Buy Now</button>
</div>
<div class="card">
<!-- FIXED: Added alt attribute describing the product -->
<img src="images/thing.jpg" width="100" alt="Ultra Thing">
<h3>Ultra Thing</h3>
<p>$199</p>
<button class="btn">Buy Now</button>
</div>
</div>
<hr>
<h3>Sponsors</h3>
<div>
<!-- FIXED: Added empty alt="" for decorative images -->
<img src="https://via.placeholder.com/150" alt="">
<img src="https://via.placeholder.com/150" alt="">
</div>
</main>
<footer style="text-align: center; padding: 2rem; background: #f4f4f4; margin-top: 2rem;">
&copy; 2026 YakNet Demo
</footer>
<div
style="position:fixed; bottom:10px; right:10px; background:#28a745; color:white; padding:8px 15px; border-radius:4px; font-family:sans-serif; font-weight:bold; box-shadow: 0 2px 5px rgba(0,0,0,0.2);">
✅ Accessibility: 0 Errors</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YakNet Dynamic Demo (Fixed)</title>
<!-- Styles omitted for brevity -->
</head>
<body>
<!-- Content omitted for brevity -->
<h1>My Dynamic Site (Accessible)</h1>
<p>Success widget demonstration.</p>
<!-- The widget injected by HtmlReporter when setShowSuccess(true) is called -->
<div id='yaknet-a11y-success' onclick='this.style.display="none"'
style='position:fixed; bottom:10px; right:10px; background:#28a745; color:white; padding:8px 15px; border-radius:4px; font-family:sans-serif; font-weight:bold; box-shadow: 0 2px 5px rgba(0,0,0,0.2); cursor:pointer; z-index:9999;'>
✅ Accessibility: 0 Errors
</div>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
<?php
// 1. Setup Autoloading (Mocking Composer)
require_once __DIR__ . '/../src/Scanner.php';
require_once __DIR__ . '/../src/Violation.php';
require_once __DIR__ . '/../src/SourceLocator.php';
require_once __DIR__ . '/../src/Reporter/HtmlReporter.php';
require_once __DIR__ . '/../src/Rule/RuleInterface.php';
require_once __DIR__ . '/../src/Rules/ImgAltText.php';
require_once __DIR__ . '/../src/Rules/FormLabel.php';
require_once __DIR__ . '/../src/Rules/HtmlHasLang.php';
require_once __DIR__ . '/../src/Rules/EmptyLink.php';
use YakNet\AccessibilityConsole\Scanner;
use YakNet\AccessibilityConsole\SourceLocator;
use YakNet\AccessibilityConsole\Reporter\HtmlReporter;
use YakNet\AccessibilityConsole\Rules\ImgAltText;
use YakNet\AccessibilityConsole\Rules\FormLabel;
// 2. Start Buffering to capture output
ob_start();
// 3. Simple Router
$page = $_GET['page'] ?? 'home';
$viewFile = __DIR__ . "/views/{$page}.php";
if (!file_exists($viewFile)) {
$viewFile = __DIR__ . '/views/home.php';
}
// 4. Render Layout (which includes the view)
$viewPath = $viewFile;
include __DIR__ . '/views/layout.php';
// 5. Capture Output
$html = ob_get_clean();
// 6. RUN ACCESSIBILITY SCANNER
// ==========================================
$scanner = new Scanner();
$scanner->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('</body>', $reportHtml . '</body>', $html);
}
// 7. Output Final HTML
echo $html;
+75
View File
@@ -0,0 +1,75 @@
<?php
// 1. Setup Autoloading
require_once __DIR__ . '/../src/Scanner.php';
require_once __DIR__ . '/../src/Violation.php';
require_once __DIR__ . '/../src/SourceLocator.php';
require_once __DIR__ . '/../src/Reporter/HtmlReporter.php';
require_once __DIR__ . '/../src/Rule/RuleInterface.php';
require_once __DIR__ . '/../src/Rules/ImgAltText.php';
require_once __DIR__ . '/../src/Rules/FormLabel.php';
require_once __DIR__ . '/../src/Rules/HtmlHasLang.php';
require_once __DIR__ . '/../src/Rules/EmptyLink.php';
use YakNet\AccessibilityConsole\Scanner;
use YakNet\AccessibilityConsole\SourceLocator;
use YakNet\AccessibilityConsole\Reporter\HtmlReporter;
use YakNet\AccessibilityConsole\Rules\ImgAltText;
use YakNet\AccessibilityConsole\Rules\FormLabel;
use YakNet\AccessibilityConsole\Rules\HtmlHasLang;
use YakNet\AccessibilityConsole\Rules\EmptyLink;
// 2. Start Buffering
ob_start();
// 3. Simple Router for Fixed Views
$page = $_GET['page'] ?? 'home';
$viewFile = __DIR__ . "/views-fixed/{$page}.php";
if (!file_exists($viewFile)) {
$viewFile = __DIR__ . '/views-fixed/home.php';
}
// 4. Render Layout
$viewPath = $viewFile;
include __DIR__ . '/views-fixed/layout.php';
// 5. Capture Output
$html = ob_get_clean();
// 6. RUN ACCESSIBILITY SCANNER
$scanner = new Scanner();
$scanner->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('</body>', $reportHtml . '</body>', $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('</body>', $reportHtml . '</body>', $html);
}
echo $html;
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Accessibility Console Preview</title>
</head>
<body>
<h1>Accessibility Console Preview</h1>
<p>This is a static preview of how the error console looks.</p>
<p>Click the "Accessibility: 2 Errors" button in the bottom right.</p>
<!-- Hardcoded output from HtmlReporter for demonstration -->
<div id='yaknet-a11y-console-toggle' onclick='document.getElementById("yaknet-a11y-console").style.display="block"'
style='position:fixed; bottom:10px; right:10px; background:#ff9900; color:white; padding:8px 15px; border-radius:4px; cursor:pointer; font-family:sans-serif; z-index:99999; font-weight:bold; font-size:14px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); border: 1px solid #cc7a00;'>
⚠️ Accessibility: 2 Errors
</div>
<div id='yaknet-a11y-console'
style='display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7); z-index:100000; font-family:Verdana, sans-serif;'>
<div
style='width:90%; max-width:1200px; margin:50px auto; background:#fff; box-shadow:0 0 20px rgba(0,0,0,0.5); overflow:hidden; border-radius:4px;'>
<div
style='background:#f7f7f7; color:#333; padding:15px 20px; border-bottom:1px solid #ccc; font-size:18px; font-weight:bold; display:flex; justify-content:space-between; align-items:center;'>
<span>
<span
style='background:#d9534f; color:white; padding:2px 6px; border-radius:3px; font-size:14px; vertical-align:middle; margin-right:10px;'>FATAL
ERROR</span>
Accessibility Violations Detected
</span>
<button onclick='document.getElementById("yaknet-a11y-console").style.display="none"'
style='border:none; background:none; font-size:24px; cursor:pointer; color:#999; line-height:1;'>&times;</button>
</div>
<div style='max-height:80vh; overflow-y:auto; padding:0;'>
<table style='width:100%; border-collapse:collapse; font-size:14px;'>
<thead style='background:#eeeff0; color:#333; text-align:left;'>
<tr>
<th style='padding:12px 15px; border-bottom:2px solid #ddd; width: 50px;'>#</th>
<th style='padding:12px 15px; border-bottom:2px solid #ddd;'>Violation / Message</th>
<th style='padding:12px 15px; border-bottom:2px solid #ddd;'>Source File</th>
</tr>
</thead>
<tbody>
<tr style='border-bottom:1px solid #eee; background:#fff;'>
<td style='padding:12px 15px; vertical-align:top; color:#999;'>1</td>
<td style='padding:12px 15px; vertical-align:top;'>
<div style='color:#cc0000; font-weight:bold; margin-bottom:5px; font-size:15px;'>Image
is missing alt attribute.</div>
<div style='font-size:12px; color:#555; margin-bottom:8px;'>Rule:
<code>img-alt-text</code></div>
<div
style='background:#f1f1f1; padding:8px; border-left:3px solid #ccc; font-family:monospace; font-size:12px; margin-bottom:8px; color:#333; overflow-x:auto;'>
&lt;img src="logo.png" /&gt;
</div>
<div style='font-size:13px; color:#222;'>
<span style='color:#28a745; font-weight:bold;'>Fix:</span> Add an alt="" attribute
describing the image, or alt="" if decorative.
</div>
</td>
<td
style='padding:12px 15px; vertical-align:top; font-family:monospace; font-size:13px; color:#333;'>
<div style='margin-bottom:5px;'>
C:\Users\User\Project\views\header.php<strong>:42</strong>
</div>
</td>
</tr>
<tr style='border-bottom:1px solid #eee; background:#f9f9f9;'>
<td style='padding:12px 15px; vertical-align:top; color:#999;'>2</td>
<td style='padding:12px 15px; vertical-align:top;'>
<div style='color:#cc0000; font-weight:bold; margin-bottom:5px; font-size:15px;'>Form
field &lt;input&gt; missing label.</div>
<div style='font-size:12px; color:#555; margin-bottom:8px;'>Rule:
<code>form-label</code></div>
<div
style='background:#f1f1f1; padding:8px; border-left:3px solid #ccc; font-family:monospace; font-size:12px; margin-bottom:8px; color:#333; overflow-x:auto;'>
&lt;input type="text" name="username" /&gt;
</div>
<div style='font-size:13px; color:#222;'>
<span style='color:#28a745; font-weight:bold;'>Fix:</span> Associate a &lt;label&gt;
via "for" attribute, wrap in &lt;label&gt;, or use "aria-label".
</div>
</td>
<td
style='padding:12px 15px; vertical-align:top; font-family:monospace; font-size:13px; color:#333;'>
<div style='margin-bottom:5px;'>
C:\Users\User\Project\views\login.php<strong>:15</strong>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<h2>Contact Us</h2>
<p>Get in touch with us.</p>
<form action="" method="post">
<div style="margin-bottom: 1rem;">
<!-- FIXED: Wrapped in label -->
<label>
Name:
<input type="text" name="fullname" placeholder="John Doe" style="padding: 8px; width: 100%;">
</label>
</div>
<div style="margin-bottom: 1rem;">
<!-- FIXED: Added for/id association -->
<label for="email">Email Address</label>
<br>
<input type="email" id="email" name="email" style="padding: 8px; width: 100%;">
</div>
<div style="margin-bottom: 1rem;">
<label for="message">Message</label>
<br>
<textarea id="message" name="message" rows="5" style="width: 100%;"></textarea>
</div>
<!-- FIXED: Added inner text or aria-label -->
<a href="#" class="btn" aria-label="Send via WhatsApp">
<i class="fa fa-send"></i> Send
</a>
<button type="submit" class="btn">Send Message</button>
</form>
+36
View File
@@ -0,0 +1,36 @@
<h2>Latest Products</h2>
<p>Welcome to our accessible product catalog.</p>
<?php
$products = [
['id' => 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'],
];
?>
<div class="products">
<?php foreach ($products as $product): ?>
<div class="card">
<!-- FIXED: Added alt attribute describing the product -->
<img src="images/<?php echo $product['image']; ?>" width="100"
alt="<?php echo htmlspecialchars($product['name']); ?>">
<h3>
<?php echo $product['name']; ?>
</h3>
<p>
<?php echo $product['price']; ?>
</p>
<button class="btn">Buy Now</button>
</div>
<?php endforeach; ?>
</div>
<hr>
<h3>Sponsors</h3>
<div>
<!-- FIXED: Added empty alt="" for decorative images -->
<img src="https://via.placeholder.com/150" alt="">
<img src="https://via.placeholder.com/150" alt="">
</div>
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="tr"> <!-- FIXED: Added lang attribute -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YakNet Dynamic Demo (Fixed)</title>
<style>
body {
font-family: sans-serif;
margin: 0;
padding: 0;
line-height: 1.6;
}
header {
background: #333;
color: #fff;
padding: 1rem;
}
nav a {
color: #fff;
margin-right: 1rem;
text-decoration: none;
}
.container {
padding: 2rem;
max-width: 800px;
margin: 0 auto;
}
.card {
border: 1px solid #ddd;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.btn {
background: #28a745;
color: white;
padding: 0.5rem 1rem;
border: none;
cursor: pointer;
}
</style>
</head>
<body>
<header>
<div class="container" style="padding: 0;">
<h1>My Dynamic Site (Accessible)</h1>
<nav>
<a href="?page=home">Home</a>
<a href="?page=contact">Contact</a>
<a href="?page=about">About</a>
<!-- FIXED: Added aria-label to empty link, or removed it/added text -->
<a href="#" class="btn" aria-label="Profile">👤</a>
</nav>
</div>
</header>
<main class="container">
<?php include $viewPath; ?>
</main>
<footer style="text-align: center; padding: 2rem; background: #f4f4f4; margin-top: 2rem;">
&copy;
<?php echo date('Y'); ?> YakNet Demo
</footer>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<h2>Contact Us</h2>
<p>Get in touch with us.</p>
<form action="" method="post">
<div style="margin-bottom: 1rem;">
<!-- ERROR: No label for input -->
<div>Name:</div>
<input type="text" name="fullname" placeholder="John Doe" style="padding: 8px; width: 100%;">
</div>
<div style="margin-bottom: 1rem;">
<!-- ERROR: Label exists but not associated with For/ID -->
<label>Email Address</label>
<br>
<input type="email" name="email" style="padding: 8px; width: 100%;">
</div>
<div style="margin-bottom: 1rem;">
<label for="message">Message</label>
<br>
<textarea id="message" name="message" rows="5" style="width: 100%;"></textarea>
</div>
<!-- ERROR: Submit button without clear text if it was an input type=image, but button is usually fine.
Let's add a problematic icon link -->
<a href="#" class="btn">
<i class="fa fa-send"></i> <!-- Empty link text error (if we had that rule) -->
</a>
<button type="submit" class="btn">Send Message</button>
</form>
+36
View File
@@ -0,0 +1,36 @@
<h2>Latest Products</h2>
<p>Welcome to our dynamic product catalog. (Intentionally filled with errors)</p>
<?php
$products = [
['id' => 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'],
];
?>
<div class="products">
<?php foreach ($products as $product): ?>
<div class="card">
<!-- ERROR: Dynamic image without alt text -->
<img src="images/<?php echo $product['image']; ?>" width="100">
<h3>
<?php echo $product['name']; ?>
</h3>
<p>
<?php echo $product['price']; ?>
</p>
<button class="btn">Buy Now</button>
<!-- Error: Button might need specific accessible name if icon-only, but text is okay here. -->
</div>
<?php endforeach; ?>
</div>
<hr>
<h3>Sponsors</h3>
<div>
<!-- ERROR: Decorative image causing noise, missing alt="" -->
<img src="https://via.placeholder.com/150">
<img src="https://via.placeholder.com/150">
</div>
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html> <!-- Missing lang attribute error -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YakNet Dynamic Demo</title>
<style>
body {
font-family: sans-serif;
margin: 0;
padding: 0;
line-height: 1.6;
}
header {
background: #333;
color: #fff;
padding: 1rem;
}
nav a {
color: #fff;
margin-right: 1rem;
text-decoration: none;
}
.container {
padding: 2rem;
max-width: 800px;
margin: 0 auto;
}
.card {
border: 1px solid #ddd;
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.btn {
background: #007bff;
color: white;
padding: 0.5rem 1rem;
border: none;
cursor: pointer;
}
</style>
</head>
<body>
<header>
<div class="container" style="padding: 0;">
<h1>My Dynamic Site</h1>
<nav>
<a href="?page=home">Home</a>
<a href="?page=contact">Contact</a>
<a href="?page=about">About</a>
<a href="#" class="btn"></a> <!-- Error: Empty Link -->
</nav>
</div>
</header>
<main class="container">
<?php include $viewPath; ?>
</main>
<footer style="text-align: center; padding: 2rem; background: #f4f4f4; margin-top: 2rem;">
&copy;
<?php echo date('Y'); ?> YakNet Demo
</footer>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace YakNet\AccessibilityConsole\AI;
use Gemini;
use YakNet\AccessibilityConsole\Core\Violation;
class GeminiFixer
{
private ?\Gemini\Client $client = null;
public function __construct(private readonly ?string $apiKey)
{
if ($this->apiKey && class_exists(Gemini::class)) {
$this->client = Gemini::client($this->apiKey);
}
}
public function suggestFix(Violation $violation): ?string
{
if (!$this->client) {
return null;
}
$prompt = <<<PROMPT
You are a WCAG 2.1 Accessibility expert.
A scanner found an error in this HTML snippet:
Snippet: {$violation->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;
}
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\AI\GeminiFixer;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
use YakNet\AccessibilityConsole\Source\SourceLocator;
class FixCommand extends Command
{
protected function configure(): void
{
$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');
}
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 <fg=yellow>{$violation->ruleId}</>... ");
// Locate source
$location = $locator->locate($violation->htmlSnippet);
if (!$location) {
$io->writeln("<fg=red>Skipped (Source not found)</>");
continue;
}
// Get AI Suggestion
$suggestion = $fixer->suggestFix($violation);
if (!$suggestion) {
$io->writeln("<fg=red>Skipped (AI could not generate fix)</>");
continue;
}
$io->writeln("<fg=green>Fix Found!</>");
$io->writeln(" <fg=gray>Original:</> <fg=red>" . htmlspecialchars(trim($violation->htmlSnippet)) . "</>");
$io->writeln(" <fg=gray>AI Fix: </> <fg=green>" . htmlspecialchars(trim($suggestion)) . "</>");
if ($dryRun) {
$io->writeln(" <fg=blue>[DRY RUN] File modification skipped.</>");
continue;
}
// Apply Fix
if ($this->applyFix($location['file'], $violation->htmlSnippet, $suggestion)) {
$io->writeln(" <fg=bright-green>✔ Applied to {$location['file']}:{$location['line']}</>");
$fixedCount++;
} else {
$io->writeln(" <fg=red>✘ Failed to apply fix to file.</>");
}
$io->writeln("");
}
if ($fixedCount > 0) {
$io->success("Self-healing complete! $fixedCount violations were automatically fixed.");
} else {
$io->warning("No files were modified. 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;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\AI\GeminiFixer;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\StandardRuleSet;
use YakNet\AccessibilityConsole\Source\SourceLocator;
class ScanCommand extends Command
{
protected static $defaultName = 'scan';
protected function configure(): void
{
$this
->setName('scan')
->setDescription('Scan a URL or file for accessibility violations')
->addArgument('target', InputArgument::REQUIRED, 'The URL or file path to scan')
->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("<fg=white;bg=red;options=bold> {$violation->ruleId} </>\t<fg=red>{$violation->message}</>");
// Show Snippet
$io->writeln(" <fg=gray>Snippet:</> <fg=yellow>" . htmlspecialchars($violation->htmlSnippet) . "</>");
// Source Mapping
$location = $locator->locate($violation->htmlSnippet);
if ($location) {
$io->writeln(" <fg=gray>Source:</> <fg=cyan>{$location['file']}:{$location['line']}</>");
}
// AI Fixer
if ($enableAi && $fixer) {
$io->write(" <fg=gray>AI Fix Suggestion:</> ");
$suggestion = $fixer->suggestFix($violation);
if ($suggestion) {
$io->writeln("<fg=green>" . htmlspecialchars($suggestion) . "</>");
} else {
$io->writeln("<fg=red>Could not generate suggestion (check API key)</>");
}
}
$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;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
class Config
{
private array $settings = [];
public function __construct(array $settings = [])
{
$this->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;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use YakNet\AccessibilityConsole\Rules\RuleInterface;
class RuleEngine
{
/** @var RuleInterface[] */
private array $rules = [];
public function addRule(RuleInterface $rule): void
{
$this->rules[] = $rule;
}
/**
* @return RuleInterface[]
*/
public function getRules(): array
{
return $this->rules;
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use DOMDocument;
use YakNet\AccessibilityConsole\Rules\RuleInterface;
class Scanner
{
private array $rules = [];
public function __construct(
private readonly ?RuleEngine $engine = null
) {
if ($this->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('<?xml encoding="utf-8" ?>' . $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;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
enum Severity: string
{
case INFO = 'info';
case WARNING = 'warning';
case ERROR = 'error';
case CRITICAL = 'critical';
public function color(): string
{
return match($this) {
self::INFO => 'info',
self::WARNING => 'comment',
self::ERROR => 'error',
self::CRITICAL => 'error',
};
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
readonly class Violation
{
public function __construct(
public string $ruleId,
public string $message,
public Severity $severity,
public WCAGStandard $standard,
public string $htmlSnippet,
public ?array $location = null, // ['file' => '...', '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,
];
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
enum WCAGStandard: string
{
case A = 'A';
case AA = 'AA';
case AAA = 'AAA';
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use YakNet\AccessibilityConsole\Core\Violation;
class HtmlDashboardReporter
{
/**
* @param Violation[] $violations
*/
public function render(array $violations, string $target): string
{
$count = count($violations);
$date = date('Y-m-d H:i:s');
$rows = '';
foreach ($violations as $v) {
$severityClass = strtolower($v->severity->value);
$rows .= "
<tr class='severity-{$severityClass}'>
<td><span class='badge'>{$v->ruleId}</span></td>
<td>{$v->message}</td>
<td><pre><code>" . htmlspecialchars($v->htmlSnippet) . "</code></pre></td>
<td>" . ($v->location ? "{$v->location['file']}:{$v->location['line']}" : 'N/A') . "</td>
</tr>";
}
return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>A11y Report - {$target}</title>
<style>
body { font-family: 'Inter', sans-serif; background: #f4f7f6; color: #333; margin: 0; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }
h1 { color: #2c3e50; margin-top: 0; }
.summary { display: flex; gap: 20px; margin-bottom: 30px; }
.card { background: #3498db; color: white; padding: 20px; border-radius: 8px; flex: 1; text-align: center; }
.card.error { background: #e74c3c; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; }
pre { background: #272822; color: #f8f8f2; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 12px; }
.badge { background: #34495e; color: white; padding: 4px 8px; border-radius: 4px; font-size: 11px; }
.severity-error { background: #fff5f5; }
</style>
</head>
<body>
<div class="container">
<h1>Accessibility Report</h1>
<p>Target: <strong>{$target}</strong> | Generated: {$date}</p>
<div class="summary">
<div class="card error">
<h2>{$count}</h2>
<p>Violations Found</p>
</div>
<div class="card">
<h2>WCAG 2.1</h2>
<p>Standard Level</p>
</div>
</div>
<table>
<thead>
<tr>
<th>Rule ID</th>
<th>Issue</th>
<th>Code Snippet</th>
<th>Source Location</th>
</tr>
</thead>
<tbody>
{$rows}
</tbody>
</table>
</div>
</body>
</html>
HTML;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
abstract class AbstractRule implements RuleInterface
{
abstract public function getId(): string;
abstract public function getDescription(): string;
abstract public function getStandard(): WCAGStandard;
abstract public function getSeverity(): Severity;
protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation
{
return new Violation(
ruleId: $this->getId(),
message: $message,
severity: $this->getSeverity(),
standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element),
fixSuggestion: $suggestion
);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ButtonName extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_BUTTON'; }
public function getDescription(): string { return 'Buttons must have discernible text.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->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;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyLink extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_4_LINK_TEXT'; }
public function getDescription(): string { return 'Links must have discernible text.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->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;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FieldsetLegend extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_FIELDSET'; }
public function getDescription(): string { return 'Fieldsets should have a legend.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'fieldset') {
return null;
}
$legends = $element->getElementsByTagName('legend');
if ($legends->length === 0) {
return $this->createViolation($element, $this->getDescription(), 'Add a <legend> inside the fieldset.');
}
return null;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FormLabel extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_LABEL'; }
public function getDescription(): string { return 'Form inputs must have an associated label.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function check(DOMElement $element): ?Violation
{
$tags = ['input', 'select', 'textarea'];
if (!in_array(strtolower($element->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 <label for="..."> or an aria-label attribute.');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class HeadingOrder extends AbstractRule
{
private static int $lastLevel = 0;
public function getId(): string { return 'WCAG_1_3_1_HEADING'; }
public function getDescription(): string { return 'Headings should follow a logical nesting order.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function check(DOMElement $element): ?Violation
{
if (!preg_match('/^h([1-6])$/i', $element->tagName, $matches)) {
return null;
}
$currentLevel = (int)$matches[1];
// This rule is a bit complex in a per-element check without global state,
// but we can at least check if an h2 comes before an h1 in some cases or jumps.
// For simplicity in this engine, we warn if it's not starting with H1 or jumps too many levels.
if ($currentLevel > self::$lastLevel + 1 && self::$lastLevel !== 0) {
$violation = $this->createViolation($element, "Heading level h$currentLevel jumps from h" . self::$lastLevel, "Ensure headings follow a logical order (h1, then h2, etc.)");
self::$lastLevel = $currentLevel;
return $violation;
}
self::$lastLevel = $currentLevel;
return null;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class HtmlHasLang extends AbstractRule
{
public function getId(): string { return 'WCAG_3_1_1_LANG'; }
public function getDescription(): string { return 'The html element must have a lang attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'html') {
return null;
}
if (!$element->hasAttribute('lang') || trim($element->getAttribute('lang')) === '') {
return $this->createViolation($element, $this->getDescription(), 'Add lang="en" (or your language) to the html tag.');
}
return null;
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImgAltText extends AbstractRule
{
public function getId(): string { return 'WCAG_1_1_1_ALT'; }
public function getDescription(): string { return 'Images must have an alt attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
if (!$element->hasAttribute('alt')) {
// Check for decorative roles
$role = $element->getAttribute('role');
if ($role === 'presentation' || $role === 'none') {
return null;
}
return $this->createViolation($element, $this->getDescription(), 'Add an alt attribute. Use alt="" for decorative images.');
}
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class MetaViewport extends AbstractRule
{
public function getId(): string { return 'A11Y_VIEWPORT'; }
public function getDescription(): string { return 'Viewport meta tag should not disable zooming.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'meta' || $element->getAttribute('name') !== 'viewport') {
return null;
}
$content = $element->getAttribute('content');
if (str_contains($content, 'user-scalable=no') || str_contains($content, 'maximum-scale=1')) {
return $this->createViolation($element, $this->getDescription(), 'Remove user-scalable=no or maximum-scale=1 to allow users to zoom.');
}
return null;
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Violation;
interface RuleInterface
{
public function getId(): string;
public function check(DOMElement $element): ?Violation;
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
class StandardRuleSet
{
/**
* @return RuleInterface[]
*/
public static function all(): array
{
return [
new HtmlHasLang(),
new ImgAltText(),
new EmptyLink(),
new ButtonName(),
new FormLabel(),
new FieldsetLegend(),
new HeadingOrder(),
new MetaViewport(),
];
}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
namespace YakNet\AccessibilityConsole\Source;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
class SourceLocator
{
private array $extensions = ['php', 'html', 'twig', 'blade.php'];
public function __construct(
private readonly string $searchPath
) {}
public function locate(string $snippet): ?array
{
if (!is_dir($this->searchPath) || !is_readable($this->searchPath)) {
return null;
}
$cleanSnippet = trim(preg_replace('/\s+/', ' ', $snippet));
if ($cleanSnippet === '') {
return null;
}
$searchSnippet = mb_strlen($cleanSnippet) > 150 ? mb_substr($cleanSnippet, 0, 150) : $cleanSnippet;
try {
$directory = new RecursiveDirectoryIterator($this->searchPath, \FilesystemIterator::SKIP_DOTS);
$iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST);
} catch (\UnexpectedValueException) {
return null;
}
$bestFuzzyMatch = null;
$highestScore = 0;
foreach ($iterator as $file) {
/** @var SplFileInfo $file */
if ($file->isDir()) {
continue;
}
if (!$this->isValidExtension($file->getFilename())) {
continue;
}
try {
$result = $this->searchInFile($file->getPathname(), $searchSnippet, $cleanSnippet);
if ($result['type'] === 'exact') {
return $result['location'];
}
if ($result['type'] === 'fuzzy' && $result['score'] > $highestScore) {
$highestScore = $result['score'];
$bestFuzzyMatch = $result['location'];
}
} catch (\Throwable) {
continue;
}
}
return ($bestFuzzyMatch && $highestScore >= 2) ? $bestFuzzyMatch : null;
}
private function isValidExtension(string $filename): bool
{
foreach ($this->extensions as $ext) {
if (str_ends_with($filename, $ext)) {
return true;
}
}
return false;
}
private function searchInFile(string $filepath, string $searchSnippet, string $fullSnippet): array
{
$content = file_get_contents($filepath);
if ($content === false) {
return ['type' => 'none'];
}
$lines = explode("\n", $content);
$parsedSnippet = $this->parseSnippet($fullSnippet);
$bestLine = 0;
$maxScore = 0;
foreach ($lines as $index => $line) {
$lineNumber = $index + 1;
$cleanLine = trim(preg_replace('/\s+/', ' ', $line));
if (str_contains($cleanLine, $searchSnippet)) {
return [
'type' => 'exact',
'location' => ['file' => $filepath, 'line' => $lineNumber]
];
}
$score = $this->calculateFuzzyScore($cleanLine, $parsedSnippet);
if ($score > $maxScore) {
$maxScore = $score;
$bestLine = $lineNumber;
}
}
if ($maxScore > 0) {
return [
'type' => 'fuzzy',
'score' => $maxScore,
'location' => ['file' => $filepath, 'line' => $bestLine]
];
}
return ['type' => 'none'];
}
private function parseSnippet(string $snippet): array
{
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
$tagName = $matches[1] ?? null;
preg_match_all('/([a-zA-Z0-9-]+)=["\']([^"\']*)["\']/', $snippet, $matches, PREG_SET_ORDER);
$attributes = [];
foreach ($matches as $match) {
$attributes[$match[1]] = $match[2];
}
return ['tag' => $tagName, 'attributes' => $attributes];
}
private function calculateFuzzyScore(string $line, array $parsed): float
{
if (!$parsed['tag'] || !str_contains(strtolower($line), '<' . strtolower($parsed['tag']))) {
return 0;
}
$score = 1.0;
if (in_array(strtolower($parsed['tag']), ['html', 'head', 'body', 'title'])) {
$score += 1.0;
}
foreach ($parsed['attributes'] as $name => $value) {
if (str_contains($line, "$name=\"$value\"") || str_contains($line, "$name='$value'")) {
$score += 2.0;
} elseif (str_contains($line, $name . '=')) {
$score += 0.5;
}
}
return $score;
}
}