Compare commits

...
4 Commits
94 changed files with 4885 additions and 311 deletions
+103 -51
View File
@@ -1,40 +1,49 @@
# YakNet Accessibility Console 2.9 (AI-Powered Static Analysis)
# YakNet Accessibility Console 3.0 (AI-Powered Static Analysis & WCAG 2.2 Linter)
[![PHP Version](https://img.shields.io/badge/php-%5E8.2-blue.svg)](https://php.net)
[![PHPStan](https://img.shields.io/badge/PHPStan-Level%209-brightgreen.svg)](https://phpstan.org)
[![Tests](https://img.shields.io/badge/PHPUnit-191%20passed-success.svg)](https://phpunit.de)
[![Rules](https://img.shields.io/badge/WCAG%20Rules-127%20Active-purple.svg)](https://www.w3.org/WAI/standards-guidelines/wcag/)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![AI Powered](https://img.shields.io/badge/AI-Powered-purple.svg)](https://gemini.google.com)
[![AI Powered](https://img.shields.io/badge/AI%20Self--Healing-Google%20Gemini-orange.svg)](https://ai.google.dev)
**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1) hatalarını sadece tespit etmekle kalmayan, aynı zamanda **Yapay Zeka (AI)** desteğiyle bu hataları otomatik olarak düzeltebilen (Self-Healing) profesyonel bir PHP static analysis kütüphanesidir.
**YakNet Accessibility Console**, web projelerinizdeki erişilebilirlik (WCAG 2.1, 2.2 ve WAI-ARIA) ve HTML söz dizimi hatalarını statik olarak analiz eden, **127 benzersiz kural setiyle** denetleyen ve **Yapay Zeka (Google Gemini AI)** desteğiyle kaynak kodunuzu otomatik tamir edebilen (Self-Healing) kurumsal düzeyde bir PHP statik analiz ve linter kütüphanesidir.
PHPStan ve PHP-CS-Fixer esintili 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.
PHPStan ve PHP-CS-Fixer esintili modern mimarisiyle; Laravel Blade (`.blade.php`), Twig (`.twig`), PHP (`.php`) ve standart HTML şablonlarınızı doğrudan tarar, baseline desteğiyle mevcut teknik borcu yönetir ve CI/CD pipeline süreçlerinize sıfır yapılandırmayla entegre olur.
---
## 🌟 Öne Çıkan Özellikler
- **🤖 AI Self-Healing:** Tespit edilen WCAG hataları için Google Gemini AI üzerinden akıllı çözüm önerileri alır ve 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.
- **🛡️ 127 Benzersiz WCAG 2.1 / 2.2 & WAI-ARIA Kuralı:** Algılanabilir (Perceivable), Çalıştırılabilir (Operable), Anlaşılabilir (Understandable) ve Sağlam (Robust) prensiplerine göre yapılandırılmış 5 kademeli (Level 1-5) kural havuzu.
- **🔍 HTML & Şablon Söz Dizimi (Syntax/AST) Linter'ı:**
- Aynı etiket üzerinde yinelenen nitelikler (`Duplicate Attributes` - HTML5 3.2.4 ihlali)
- Kapanmamış veya çapraz kapanmış yapısal etiketler (`Mismatched / Unclosed Tags`)
- Void olmayan etiketlerde geçersiz self-closing kullanımı (`<div />`, `<span />`, `<p />`)
- Boşluksuz bitişik nitelik söz dizimi (`<a href="..."class="...">`)
- Kapanmamış HTML yorum blokları (`<!--`)
- **⚡ Tek Geçişli (Single-Pass) Yüksek Hızlı Kural Motoru:** DOM ağacını tek bir geçişte tarayarak büyük şablonlarda 5-10 kat daha hızlı analiz gerçekleştirir.
- **📝 1:1 Satır Eşlemeli Şablon Ön İşleyici (Template Preprocessor):** Blade direktiflerini (`@if`, `@foreach`, `@component`), Twig bloklarını (`{% %}`, `{{ }}`) ve PHP etiketlerini temizlerken satır sayılarını birebir korur.
- **🎯 Yüksek Hassasiyetli Satır & Sütun Bulucu (`PreciseElementLocator`):** Çok satırlı ve dinamik şablon bileşenlerinde ihlalin kaynak koddaki tam satır ve sütun numarasını saptar.
- **🤖 Yapay Zeka ile Otomatik Onarım (AI Self-Healing):** Tespit edilen WCAG ve söz dizimi hataları için Google Gemini AI üzerinden akıllı çözüm ve tamir kodları üretir; kaynak dosyalarınızı (`--dry-run` desteğiyle) anında onarır.
- **📊 Erişilebilirlik Analitiği & Uyumluluk Matrisi:** POUR prensipleri, WCAG A/AA/AAA başarı oranları ve KLOC başına hata yoğunluğu (Defect Density) istatistikleri.
- **🛡️ Baseline Desteği (`a11y-baseline.json`):** Mevcut hataları dondurarak CI/CD süreçlerinin yalnızca **yeni eklenen** hatalarda kırılmasını sağlar.
- **🔌 Çoklu Çıktı Formatları:** Renkli konsol tablosu (`console`), makine tarafından okunabilir JSON (`json`), GitHub Actions PR bildirimleri (`github`) ve interaktif HTML Dashboard raporu (`--report`).
- **🏆 PHPStan Level 9 & %100 Tip Güvenliği:** Kod tabanı PHPStan en yüksek seviye (Level 9) ve 191+ birim testi ile tam doğrulanmıştır.
---
## 📦 Kurulum
Composer ile projenize dahil edin:
Composer ile projenize geliştirme bağımlılığı olarak ekleyin:
```bash
composer require yaknet/accessibility-console
composer require --dev yaknet/accessibility-console
```
---
## 🚀 CLI Kullanımı
## 🚀 Komut Satırı (CLI) Kullanımı
### 1. Yapılandırma Dosyası Oluşturma (`init`)
Projenizin kök dizininde varsayılan bir `a11y.yaml` konfigürasyon dosyası oluşturur:
@@ -42,71 +51,84 @@ Projenizin kök dizininde varsayılan bir `a11y.yaml` konfigürasyon dosyası ol
bin/a11y init
```
### 2. Projeyi Tarama (`scan` / `analyse`)
`a11y.yaml` dosyasındaki kurallara ve dizinlere göre tüm projeyi tarar:
### 2. Projeyi veya Dizinleri Tarama (`scan`)
Yapılandırma dosyasındaki yollara göre tüm projeyi tarar:
```bash
bin/a11y scan
```
Belirli bir dizini veya dosyayı hedef göstermek için:
Belirli bir dizini veya şablon klasörünü hedef göstererek taramak için:
```bash
bin/a11y scan resources/views
bin/a11y scan templates/
```
URL veya tek bir yerel HTML dosyasını taramak için (crawler desteğiyle):
Canlı bir web sitesini crawler desteğiyle taramak için:
```bash
bin/a11y scan https://test-siteniz.com --crawl
bin/a11y scan https://siteniz.com --crawl
```
### 3. Baseline Yönetimi
Projeye ait mevcut tüm hataları `a11y-baseline.json` dosyasına kaydederek sonraki taramalarda yok sayılmasını sağlar:
### 3. Kural Seviyesi Belirleme (`--level`)
1 (Temel) ile 5 (Çok Katı / Strict) arasında bir seviye belirleyin:
```bash
bin/a11y scan --level=5
```
### 4. Baseline (Teknik Borç Dondurma) Yönetimi
Mevcut tüm ihlalleri `a11y-baseline.json` dosyasına kaydederek sonraki taramalarda yok sayılmasını sağlar:
```bash
bin/a11y scan --generate-baseline
```
### 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
```
### 5. Format ve Raporlama Seçenekleri
- **GitHub Actions formatında çıktı:**
```bash
bin/a11y scan --format=github
```
- **JSON formatında çıktı:**
```bash
bin/a11y scan --format=json
```
- **İnteraktif HTML Dashboard Raporu:**
```bash
bin/a11y scan --report=a11y-report.html
```
### 5. AI Destekli Otomatik Düzeltme (`fix`)
Tespit edilen tüm hataları yapay zekaya analiz ettirip dosyalarınızı otomatik olarak tamir ettirin:
### 6. Yapay Zeka Destekli Otomatik Düzeltme (`fix`)
Tespit edilen erişilebilirlik sorunlarını yapay zeka ile kaynak dosyalarınızda otomatik olarak onarın:
```bash
bin/a11y fix
```
Sadece neyin değişeceğini görmek için (`--dry-run`):
Dosyaları değiştirmeden yalnızca yapılacak düzeltme önerilerini görmek için:
```bash
bin/a11y fix --dry-run
```
### 6. Kuralları Listeleme (`rules`)
Mevcut tüm WCAG kurallarını seviyeleri ve açıklamalarıyla birlikte listeler:
### 7. Kuralları Listeleme (`rules`)
Kütüphanede tanımlı 127 kuralı seviyeleri, WCAG standartları ve açıklamalarıyla listeler:
```bash
bin/a11y rules
```
---
## ⚙️ Yapılandırma (`a11y.yaml`)
## ⚙️ Yapılandırma Dosyası (`a11y.yaml`)
Projenizi özelleştirmek için `a11y.yaml` dosyasını kullanabilirsiniz:
Projenizin ihtiyaçlarına göre `a11y.yaml` dosyasını özelleştirebilirsiniz:
```yaml
# Tarama yapılacak dizin veya dosyalar
# Tarama yapılacak dizin veya şablon yolları
paths:
- resources/views
- public/templates
- templates
- public/partials
# Hariç tutulacak dizinler
# Hariç tutulacak klasörler
exclude_paths:
- vendor
- node_modules
- storage
- tests/fixtures
# WCAG seviyesi (1-9, varsayılan 4)
# Kural Seviyesi (1: Kritik A -> 5: Kapsamlı AA/AAA)
level: 4
# Varsayılan çıktı formatı (console, json, github)
@@ -115,30 +137,60 @@ format: console
# Baseline dosyası yolu
baseline: a11y-baseline.json
# Kural özelleştirmeleri
# Kural istisnaları ve özel kurallar
rules:
exclude:
- WCAG_1_3_1_FIELDSET # Bu kuralı çalıştırma
- WCAG_1_3_1_FIELDSET # Belirli bir kuralı devre dışı bırak
include:
- App\Rules\CustomAccessibilityRule # Özel kural ekle
- App\Rules\CustomBrandAccessibilityRule # Özel proje kuralı ekle
```
---
## 🔐 Yapay Zeka API Anahtarı
## 📐 Kural Seviyeleri (Rule Levels)
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:
| Seviye | Adı | Kapsam | Örnek Kurallar |
| :--- | :--- | :--- | :--- |
| **Level 1** | Essential | Temel WCAG 2.1 A | `ImageAlt`, `ButtonName`, `HtmlHasLang`, `PageTitle` |
| **Level 2** | Standard | Standart WCAG 2.1/2.2 A & Söz Dizimi | `HtmlSyntaxValid`, `FormLabel`, `HeadingOrder`, `TrackValidation` |
| **Level 3** | Enhanced | Gelişmiş WAI-ARIA & Formlar | `DuplicateId`, `NestedInteractive`, `AriaCurrentValid`, `AccessibleAuth` |
| **Level 4** | Advanced | Kapsamlı WCAG 2.2 AA & UX | `TargetSizeMinimum`, `DraggingMovements`, `FocusNotObscured`, `ColorContrast` |
| **Level 5** | Strict | Çok Katı AAA & Best Practices | `FocusTrapping`, `ColorBlindnessContrast`, `NoFocusOutline` |
---
## 🔐 Yapay Zeka (AI) API Yapılandırması
AI Self-Healing ve otomatik kod onarım özelliklerini kullanabilmek için projenizin kök dizinindeki `.env` dosyasına **Google Gemini API** anahtarınızı ekleyin:
```env
GEMINI_API_KEY=AIzaSyA...your_key_here
GEMINI_API_KEY=AIzaSyA...your_api_key_here
```
---
## 🧪 Geliştirme ve Test
```bash
# Tüm PHPUnit testlerini çalıştır
vendor/bin/phpunit
# PHPStan Level 9 statik tip analizini çalıştır
vendor/bin/phpstan analyse -l 9 src/
```
---
## 🤝 Katkıda Bulunma
Bu proje açık kaynaklıdır ve katkılarınıza açıktır. Lütfen hata bildirimleri için `Issue` açmaktan veya yeni özellikler için `Pull Request` göndermekten çekinmeyin.
1. Bu depoyu forklayın (`fork`).
2. Yeni bir özellik dalı açın (`git checkout -b feature/harika-kural`).
3. Değişikliklerinizi commit edin (`git commit -m 'feat: Yeni WCAG kuralı eklendi'`).
4. Dalınıza push yapın (`git push origin feature/harika-kural`).
5. Bir **Pull Request** açın.
---
## 📜 Lisans
Bu yazılım **YakNet Bilişim** tarafından geliştirilmiştir ve **MIT Lisansı** altında lisanslanmıştır. Detaylar için [LICENSE](LICENSE) dosyasına göz atabilirsiniz.
Bu proje **YakNet Bilişim** tarafından geliştirilmiştir ve **MIT Lisansı** altında lisanslanmıştır. Detaylar için [LICENSE](LICENSE) dosyasına göz atabilirsiniz.
+1 -1
View File
@@ -18,7 +18,7 @@ use YakNet\AccessibilityConsole\Console\Command\InitCommand;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
$container = ContainerFactory::create();
$application = new Application('YakNet Accessibility Console', '3.2.2');
$application = new Application('YakNet Accessibility Console', '3.2.7');
// Add commands resolved via DI Container
$application->addCommand($container->get(ScanCommand::class));
+4 -6
View File
@@ -12,7 +12,7 @@
"yaknet"
],
"homepage": "https://forum.yak.net.tr",
"version": "3.2.3",
"version": "3.2.7",
"type": "library",
"license": "MIT",
"authors": [
@@ -41,11 +41,9 @@
"symfony/css-selector": "^6.4 || ^7.0 || ^8.0",
"symfony/dom-crawler": "^6.4 || ^7.0 || ^8.0",
"spatie/color": "^1.5 || ^2.0",
"intervention/image": "^2.7 || ^3.0"
},
"suggest": {
"dompdf/dompdf": "Required for generating PDF Accessibility Certificates (^2.0 || ^3.0).",
"phpoffice/phpspreadsheet": "Required for exporting Excel Accessibility Audit Reports (^1.28 || ^2.0 || ^3.0)."
"intervention/image": "^2.7 || ^3.0",
"dompdf/dompdf": "^2.0 || ^3.0",
"phpoffice/phpspreadsheet": "^1.28 || ^2.0 || ^3.0"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\Analytics;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
class AccessibilityTelemetry
{
/**
* Compute violation density, error distribution, and priority impact metrics.
*
* @param Violation[] $violations
* @param int $totalLinesOfCode
* @return array{
* total_violations: int,
* error_count: int,
* warning_count: int,
* defect_density_per_kloc: float,
* critical_blockers_ratio: float
* }
*/
public function computeMetrics(array $violations, int $totalLinesOfCode = 1000): array
{
$errorCount = 0;
$warningCount = 0;
foreach ($violations as $v) {
if ($v->severity === Severity::ERROR) {
$errorCount++;
} else {
$warningCount++;
}
}
$totalViolations = count($violations);
$kloc = max(1.0, $totalLinesOfCode / 1000.0);
$defectDensity = round($totalViolations / $kloc, 2);
$criticalRatio = ($totalViolations > 0) ? round(($errorCount / $totalViolations) * 100, 1) : 0.0;
return [
'total_violations' => $totalViolations,
'error_count' => $errorCount,
'warning_count' => $warningCount,
'defect_density_per_kloc' => $defectDensity,
'critical_blockers_ratio' => $criticalRatio,
];
}
}
@@ -0,0 +1,77 @@
<?php
namespace YakNet\AccessibilityConsole\Analytics;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ComplianceMatrixGenerator
{
/** @var array<int|string, string> */
private static array $principleMap = [
1 => 'Perceivable',
2 => 'Operable',
3 => 'Understandable',
4 => 'Robust',
];
/**
* Generate WCAG 2.1 & 2.2 Compliance Matrix from a list of violations and total rules tested.
*
* @param Violation[] $violations
* @param int $totalActiveRules
* @return array{
* overall_compliance_percentage: float,
* principles: array<string, array{violations: int, status: string}>,
* levels: array<string, array{violations: int, percentage: float}>
* }
*/
public function generate(array $violations, int $totalActiveRules = 127): array
{
$principles = [
'Perceivable' => ['violations' => 0, 'status' => 'Pass'],
'Operable' => ['violations' => 0, 'status' => 'Pass'],
'Understandable' => ['violations' => 0, 'status' => 'Pass'],
'Robust' => ['violations' => 0, 'status' => 'Pass'],
];
/** @var array<string, array{violations: int, percentage: float}> $levelCounts */
$levelCounts = [
'A' => ['violations' => 0, 'percentage' => 100.0],
'AA' => ['violations' => 0, 'percentage' => 100.0],
'AAA' => ['violations' => 0, 'percentage' => 100.0],
];
foreach ($violations as $v) {
// Determine principle from rule ID or WCAG code (e.g. WCAG_1_... => Perceivable)
$principleNumber = 4;
if (preg_match('/WCAG_([1-4])_/', $v->ruleId, $m)) {
$principleNumber = (int)$m[1];
}
$pName = self::$principleMap[$principleNumber] ?? 'Robust';
$principles[$pName]['violations']++;
$principles[$pName]['status'] = 'Needs Review';
// Level breakdown
$stdVal = $v->standard->value;
if (array_key_exists($stdVal, $levelCounts)) {
$levelCounts[$stdVal]['violations']++;
}
}
$totalViolations = count($violations);
$overallScore = ($totalActiveRules > 0)
? max(0.0, min(100.0, round((1.0 - ($totalViolations / ($totalActiveRules * 2))) * 100, 1)))
: 100.0;
foreach ($levelCounts as $lvl => &$data) {
$data['percentage'] = max(0.0, min(100.0, round((1.0 - ($data['violations'] / max(1, $totalActiveRules))) * 100, 1)));
}
return [
'overall_compliance_percentage' => $overallScore,
'principles' => $principles,
'levels' => $levelCounts,
];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Analytics;
class ReportSummaryModel
{
/**
* @param array<string, int> $violationCountsBySeverity
* @param array<string, int> $violationCountsByStandard
* @param array<string, int> $violationCountsByPrinciple
* @param array<string, mixed> $complianceScores
*/
public function __construct(
public readonly int $totalViolations,
public readonly int $totalFilesScanned,
public readonly int $baselinedCount,
public readonly int $healthScore,
public readonly array $violationCountsBySeverity,
public readonly array $violationCountsByStandard,
public readonly array $violationCountsByPrinciple,
public readonly array $complianceScores,
public readonly float $scanDurationSeconds = 0.0
) {}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'totalViolations' => $this->totalViolations,
'totalFilesScanned' => $this->totalFilesScanned,
'baselinedCount' => $this->baselinedCount,
'healthScore' => $this->healthScore,
'bySeverity' => $this->violationCountsBySeverity,
'byStandard' => $this->violationCountsByStandard,
'byPrinciple' => $this->violationCountsByPrinciple,
'complianceScores' => $this->complianceScores,
'duration' => $this->scanDurationSeconds,
];
}
}
+14 -3
View File
@@ -24,7 +24,17 @@ abstract class AbstractRule
return 4;
}
protected function createViolation(string $message, \DOMElement $element): Violation
public function getId(): string
{
return (new \ReflectionClass($this))->getShortName();
}
public function getDescription(): string
{
return '';
}
protected function createViolation(string $message, \DOMElement $element, ?string $suggestion = null): Violation
{
$doc = $element->ownerDocument;
$html = '';
@@ -36,11 +46,12 @@ abstract class AbstractRule
}
return new Violation(
ruleId: (new \ReflectionClass($this))->getShortName(),
ruleId: $this->getId(),
message: $message,
severity: $this->getSeverity(),
standard: $this->getStandard(),
htmlSnippet: $html
htmlSnippet: $html,
fixSuggestion: $suggestion
);
}
}
+30 -79
View File
@@ -5,6 +5,8 @@ namespace YakNet\AccessibilityConsole\Core;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use YakNet\AccessibilityConsole\Core\Locator\PreciseElementLocator;
use YakNet\AccessibilityConsole\Parser\TemplatePreprocessor;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class Analyser
@@ -12,13 +14,22 @@ class Analyser
private Config $config;
private Scanner $scanner;
private BaselineManager $baselineManager;
private TemplatePreprocessor $templatePreprocessor;
private PreciseElementLocator $elementLocator;
private string $projectRoot;
public function __construct(?Config $config = null, ?Scanner $scanner = null, ?BaselineManager $baselineManager = null)
{
public function __construct(
?Config $config = null,
?Scanner $scanner = null,
?BaselineManager $baselineManager = null,
?TemplatePreprocessor $templatePreprocessor = null,
?PreciseElementLocator $elementLocator = null
) {
$this->config = $config ?? new Config();
$this->scanner = $scanner ?? $this->setupScanner();
$this->baselineManager = $baselineManager ?? new BaselineManager();
$this->templatePreprocessor = $templatePreprocessor ?? new TemplatePreprocessor();
$this->elementLocator = $elementLocator ?? new PreciseElementLocator();
$cwd = getcwd();
$this->projectRoot = $cwd ? str_replace('\\', '/', realpath($cwd) ?: $cwd) : '.';
@@ -108,14 +119,17 @@ class Analyser
$onProgress($file, $index + 1, $totalFiles);
}
$content = @file_get_contents($file);
if ($content === false || trim($content) === '') {
$rawContent = @file_get_contents($file);
if ($rawContent === false || trim($rawContent) === '') {
continue;
}
// Preprocess templates (Blade, Twig, PHP) into scan-ready HTML preserving line numbers
$scanContent = $this->templatePreprocessor->preprocess($rawContent, $file);
// Scan file content
try {
$fileViolations = $this->scanner->scan($content);
$fileViolations = $this->scanner->scan($scanContent);
} catch (\Throwable $e) {
// Skip if parsing failed dramatically
continue;
@@ -124,14 +138,17 @@ class Analyser
$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
];
// Find line and column number using high-precision locator
if (!isset($violation->location['line']) || $violation->location['line'] <= 1) {
$location = $this->elementLocator->locate($rawContent, $violation->htmlSnippet);
$violation->location = [
'file' => $relativeFile,
'line' => $location['line'],
'column' => $location['column'],
];
} else {
$violation->location['file'] = $relativeFile;
}
// Check if ignored in baseline
if ($this->baselineManager->isIgnored($relativeFile, $violation->ruleId, $violation->htmlSnippet)) {
@@ -221,70 +238,4 @@ class Analyser
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;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace YakNet\AccessibilityConsole\Core\Locator;
class PreciseElementLocator
{
/**
* Locate the exact line and column of an HTML snippet within file content.
*
* @param string $content Full file content
* @param string $snippet HTML snippet of the violating element
* @return array{line: int, column: int}
*/
public function locate(string $content, string $snippet): array
{
$cleanSnippet = trim(preg_replace('/\s+/', ' ', $snippet) ?? '');
if ($cleanSnippet === '') {
return ['line' => 1, 'column' => 1];
}
// 1. Exact substring match pass
$exactPos = strpos($content, $snippet);
if ($exactPos !== false) {
return $this->getLineAndColumn($content, $exactPos);
}
// 2. Normalized whitespace search pass
$lines = explode("\n", $content);
$searchSnippet = mb_strlen($cleanSnippet) > 120 ? mb_substr($cleanSnippet, 0, 120) : $cleanSnippet;
foreach ($lines as $index => $line) {
$cleanLine = trim(preg_replace('/\s+/', ' ', $line) ?? '');
if ($cleanLine !== '' && str_contains($cleanLine, $searchSnippet)) {
$col = strpos($line, mb_substr($searchSnippet, 0, 20));
return [
'line' => $index + 1,
'column' => ($col !== false) ? $col + 1 : 1
];
}
}
// 3. Tag & Attribute Token Match Pass
preg_match('/<([a-zA-Z0-9:-]+)/', $snippet, $tagMatch);
$tagName = strtolower($tagMatch[1] ?? '');
if ($tagName !== '') {
preg_match_all('/([a-zA-Z0-9_:-]+)=["\']([^"\']*)["\']/', $snippet, $attrMatches, PREG_SET_ORDER);
$attributes = [];
foreach ($attrMatches as $m) {
$attributes[$m[1]] = $m[2];
}
$bestLine = 1;
$bestCol = 1;
$maxScore = 0.0;
foreach ($lines as $index => $line) {
$lineLower = strtolower($line);
$tagStr = '<' . $tagName;
if (!str_contains($lineLower, $tagStr)) {
continue;
}
$score = 1.0;
foreach ($attributes as $attrName => $attrVal) {
if (str_contains($line, "$attrName=\"$attrVal\"") || str_contains($line, "$attrName='$attrVal'")) {
$score += 3.0;
} elseif (str_contains($line, "$attrName=")) {
$score += 1.0;
}
}
if ($score > $maxScore) {
$maxScore = $score;
$bestLine = $index + 1;
$col = strpos($lineLower, $tagStr);
$bestCol = ($col !== false) ? $col + 1 : 1;
}
}
if ($maxScore > 0) {
return ['line' => $bestLine, 'column' => $bestCol];
}
}
return ['line' => 1, 'column' => 1];
}
/**
* @return array{line: int, column: int}
*/
private function getLineAndColumn(string $content, int $offset): array
{
if ($offset <= 0) {
return ['line' => 1, 'column' => 1];
}
$before = substr($content, 0, $offset);
$line = substr_count($before, "\n") + 1;
$lastNewline = strrpos($before, "\n");
$column = ($lastNewline === false) ? $offset + 1 : $offset - $lastNewline;
return ['line' => $line, 'column' => $column];
}
}
+48 -15
View File
@@ -2,6 +2,11 @@
namespace YakNet\AccessibilityConsole\Core;
use DOMDocument;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Rules\RuleInterface;
class RuleEngine
{
/** @var array<int, mixed> */
@@ -13,32 +18,60 @@ class RuleEngine
}
/**
* @return array<int, mixed>
*/
public function getRules(): array
{
return $this->rules;
}
/**
* Run all registered rules against the DOMDocument using single-pass DOM traversal.
*
* @param DOMDocument $doc
* @return Violation[]
*/
public function run(\DOMDocument $doc): array
public function run(DOMDocument $doc): array
{
$allViolations = [];
$docRules = [];
$elementRules = [];
foreach ($this->rules as $rule) {
if ($rule instanceof AbstractRule) {
// Document-level rule (Core\AbstractRule style)
$violations = $rule->check($doc);
$allViolations = array_merge($allViolations, $violations);
} elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) {
// Element-level rule (Rules\RuleInterface style)
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*');
if ($elements !== false) {
foreach ($elements as $element) {
if ($element instanceof \DOMElement) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
}
$docRules[] = $rule;
} elseif ($rule instanceof RuleInterface) {
$elementRules[] = $rule;
}
}
// 1. Run Document-level rules
foreach ($docRules as $rule) {
$violations = $rule->check($doc);
$allViolations = array_merge($allViolations, $violations);
}
// 2. Run Element-level rules in a single DOM pass
if (!empty($elementRules)) {
$xpath = new DOMXPath($doc);
$elements = $xpath->query('//*');
if ($elements !== false) {
foreach ($elements as $element) {
if (!$element instanceof DOMElement) {
continue;
}
foreach ($elementRules as $rule) {
$violation = $rule->check($element);
if ($violation !== null) {
$allViolations[] = $violation;
}
}
}
}
}
return $allViolations;
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace YakNet\AccessibilityConsole\Core;
use ReflectionClass;
use YakNet\AccessibilityConsole\Rules\RuleInterface;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class RuleRegistry
{
/**
* Validate an array of rule instances for ID uniqueness, interfaces, and validity.
*
* @param array<int, mixed> $rules
* @return array{valid: bool, errors: array<int, string>, count: int}
*/
public static function validate(array $rules): array
{
$errors = [];
$ids = [];
foreach ($rules as $rule) {
if (!is_object($rule)) {
$errors[] = "Non-object rule encountered in rule set.";
continue;
}
$className = get_class($rule);
$reflection = new ReflectionClass($rule);
$isDocRule = $rule instanceof AbstractRule;
$isElementRule = $rule instanceof RuleInterface;
if (!$isDocRule && !$isElementRule) {
$errors[] = "Class {$className} does not implement RuleInterface or extend AbstractRule.";
continue;
}
$ruleId = method_exists($rule, 'getId') ? $rule->getId() : $reflection->getShortName();
if (isset($ids[$ruleId])) {
$errors[] = "Duplicate Rule ID '{$ruleId}' detected across classes: {$ids[$ruleId]} and {$className}.";
} else {
$ids[$ruleId] = $className;
}
if (!method_exists($rule, 'getStandard')) {
$errors[] = "Rule {$className} is missing getStandard() method.";
}
if (!method_exists($rule, 'getSeverity')) {
$errors[] = "Rule {$className} is missing getSeverity() method.";
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
'count' => count($rules),
];
}
/**
* Get metadata for a specific rule instance.
*
* @param object $rule
* @return array<string, mixed>
*/
public static function getMetadata(object $rule): array
{
$reflection = new ReflectionClass($rule);
$id = method_exists($rule, 'getId') ? $rule->getId() : $reflection->getShortName();
$desc = method_exists($rule, 'getDescription') ? $rule->getDescription() : '';
$standard = method_exists($rule, 'getStandard') ? $rule->getStandard()->value : 'A';
$severity = method_exists($rule, 'getSeverity') ? $rule->getSeverity()->value : 'error';
$level = method_exists($rule, 'getLevel') ? $rule->getLevel() : 4;
return [
'id' => $id,
'class' => $reflection->getName(),
'shortName' => $reflection->getShortName(),
'description' => $desc,
'standard' => $standard,
'severity' => $severity,
'level' => $level,
'type' => ($rule instanceof AbstractRule) ? 'document' : 'element',
];
}
}
+9
View File
@@ -16,6 +16,11 @@ class Scanner
$this->engine->addRule($rule);
}
public function getEngine(): RuleEngine
{
return $this->engine;
}
/**
* Scan HTML content for accessibility violations.
*
@@ -33,6 +38,10 @@ class Scanner
]);
$doc = $html5->loadHTML($html);
// Attach raw HTML property for syntax/token level analysis
/** @phpstan-ignore-next-line */
$doc->rawHtml = $html;
return $this->engine->run($doc);
}
}
+3 -3
View File
@@ -5,20 +5,20 @@ namespace YakNet\AccessibilityConsole\Core;
class Violation
{
/**
* @param array{file: string, line: int}|null $location
* @param array{file?: string, line: int, column?: int}|null $location
*/
public function __construct(
public string $ruleId,
public string $message,
public Severity $severity,
public WCAGStandard $standard,
public string $htmlSnippet,
public string $htmlSnippet = '',
public ?array $location = null, // ['file' => '...', 'line' => 0]
public ?string $fixSuggestion = null
) {}
/**
* @return array{ruleId: string, message: string, severity: string, standard: string, htmlSnippet: string, location: array{file: string, line: int}|null, fixSuggestion: string|null}
* @return array{ruleId: string, message: string, severity: string, standard: string, htmlSnippet: string, location: array{file?: string, line: int, column?: int}|null, fixSuggestion: string|null}
*/
public function toArray(): array
{
+22 -9
View File
@@ -6,15 +6,28 @@ class BladeParser implements ParserInterface
{
public function parse(string $content): string
{
// Replace {!! ... !!} with static text
$content = preg_replace('/\{\!\!(.*?)\!\!\}/s', 'StaticRawBladeContent', $content) ?? $content;
// Replace {{ ... }} with static text
$content = preg_replace('/\{\{\s*(.*?)\s*\}\}/s', 'StaticBladeContent', $content) ?? $content;
// Strip Blade directives like @if, @else, @endif, @foreach, @endforeach, etc.
$content = preg_replace('/@[a-zA-Z0-9_]+\s*(\(.*?\))?/i', '', $content) ?? $content;
// 1. Strip Blade comments {{-- ... --}} while preserving line count
$content = preg_replace_callback('/\{\{--(.*?)--\}\}/s', function ($m) {
return str_repeat("\n", substr_count($m[0], "\n"));
}, $content) ?? $content;
// 2. Replace raw echo {!! ... !!} while preserving line count
$content = preg_replace_callback('/\{\!\!(.*?)\!\!\}/s', function ($m) {
$newlines = str_repeat("\n", substr_count($m[0], "\n"));
return 'StaticRawBladeContent' . $newlines;
}, $content) ?? $content;
// 3. Replace safe echo {{ ... }} while preserving line count
$content = preg_replace_callback('/\{\{\s*(.*?)\s*\}\}/s', function ($m) {
$newlines = str_repeat("\n", substr_count($m[0], "\n"));
return 'StaticBladeContent' . $newlines;
}, $content) ?? $content;
// 4. Strip Blade directives like @if, @else, @endif, @foreach, @component, etc.
$content = preg_replace_callback('/@[a-zA-Z0-9_]+\s*(\([^\)]*\))?/s', function ($m) {
return str_repeat("\n", substr_count($m[0], "\n"));
}, $content) ?? $content;
return $content;
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace YakNet\AccessibilityConsole\Parser;
class TemplatePreprocessor
{
private BladeParser $bladeParser;
private TwigParser $twigParser;
private HtmlParser $htmlParser;
public function __construct(
?BladeParser $bladeParser = null,
?TwigParser $twigParser = null,
?HtmlParser $htmlParser = null
) {
$this->bladeParser = $bladeParser ?? new BladeParser();
$this->twigParser = $twigParser ?? new TwigParser();
$this->htmlParser = $htmlParser ?? new HtmlParser();
}
/**
* Preprocess file content according to its file extension or template dialect.
*
* @param string $content
* @param string|null $filePath
* @return string
*/
public function preprocess(string $content, ?string $filePath = null): string
{
if ($filePath !== null) {
$lower = strtolower($filePath);
if (str_ends_with($lower, '.blade.php')) {
return $this->bladeParser->parse($content);
}
if (str_ends_with($lower, '.twig')) {
return $this->twigParser->parse($content);
}
if (str_ends_with($lower, '.php')) {
return $this->preprocessPhp($content);
}
}
// Detect Blade or Twig by content heuristic if no path is provided
if (str_contains($content, '{{') || str_contains($content, '@if') || str_contains($content, '@foreach')) {
return $this->bladeParser->parse($content);
}
if (str_contains($content, '{%') || str_contains($content, '{#')) {
return $this->twigParser->parse($content);
}
if (str_contains($content, '<' . '?php') || str_contains($content, '<' . '?=')) {
return $this->preprocessPhp($content);
}
return $this->htmlParser->parse($content);
}
private function preprocessPhp(string $content): string
{
// Replace short echo tags with placeholder text preserving newlines
$content = preg_replace_callback('/<\?=(.*?)\?>/s', function ($m) {
$newlines = str_repeat("\n", substr_count($m[0], "\n"));
return 'StaticPhpEcho' . $newlines;
}, $content) ?? $content;
// Replace PHP execution blocks with placeholder preserving newlines
$content = preg_replace_callback('/<\?php(.*?)\?>/s', function ($m) {
return str_repeat("\n", substr_count($m[0], "\n"));
}, $content) ?? $content;
return $content;
}
}
+16 -9
View File
@@ -6,15 +6,22 @@ class TwigParser implements ParserInterface
{
public function parse(string $content): string
{
// Strip Twig comments {# ... #}
$content = preg_replace('/\{\#.*?\#\}/s', '', $content) ?? $content;
// Replace Twig output {{ ... }} with static text
$content = preg_replace('/\{\{\s*(.*?)\s*\}\}/s', 'StaticTwigOutput', $content) ?? $content;
// Strip Twig statements {% ... %}
$content = preg_replace('/\{\%.*?\%\}/s', '', $content) ?? $content;
// 1. Strip Twig comments {# ... #} while preserving line count
$content = preg_replace_callback('/\{\#.*?\#\}/s', function ($m) {
return str_repeat("\n", substr_count($m[0], "\n"));
}, $content) ?? $content;
// 2. Replace Twig output {{ ... }} while preserving line count
$content = preg_replace_callback('/\{\{\s*(.*?)\s*\}\}/s', function ($m) {
$newlines = str_repeat("\n", substr_count($m[0], "\n"));
return 'StaticTwigOutput' . $newlines;
}, $content) ?? $content;
// 3. Strip Twig statements {% ... %} while preserving line count
$content = preg_replace_callback('/\{\%.*?\%\}/s', function ($m) {
return str_repeat("\n", substr_count($m[0], "\n"));
}, $content) ?? $content;
return $content;
}
}
+169
View File
@@ -112,6 +112,175 @@ class WcagDatabase
badExample: '<input name="search" autofocus>',
goodExample: '<input name="search">'
));
self::add(new WcagCriterion(
id: 'WCAG_2_5_8_TARGET_SIZE',
title: '2.5.8 Target Size (Minimum)',
level: 'AA',
url: 'https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html',
description: 'The size of the target for pointer inputs is at least 24 by 24 CSS pixels.',
badExample: '<button style="width: 16px; height: 16px;">X</button>',
goodExample: '<button style="width: 24px; height: 24px;">X</button>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_DETAILS_SUMMARY',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: '<details> elements must have a <summary> element to serve as an accessible disclosure trigger.',
badExample: '<details><p>Details content</p></details>',
goodExample: '<details><summary>More info</summary><p>Details content</p></details>'
));
self::add(new WcagCriterion(
id: 'WCAG_4_1_2_ARIA_CURRENT',
title: '4.1.2 Name, Role, Value',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
description: 'The aria-current attribute must have a valid value indicating the current item in a set.',
badExample: '<a href="/home" aria-current="active">Home</a>',
goodExample: '<a href="/home" aria-current="page">Home</a>'
));
self::add(new WcagCriterion(
id: 'WCAG_3_3_1_ARIA_ERRORMESSAGE',
title: '3.3.1 Error Identification',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/error-identification.html',
description: 'aria-errormessage must reference a valid error element and control must have aria-invalid="true".',
badExample: '<input name="email" aria-errormessage="email_error">',
goodExample: '<input name="email" aria-invalid="true" aria-errormessage="email_err"><span id="email_err">Invalid email</span>'
));
self::add(new WcagCriterion(
id: 'WCAG_4_1_2_ARIA_ROLEDESCRIPTION',
title: '4.1.2 Name, Role, Value',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
description: 'aria-roledescription must only be used on elements with a non-generic role.',
badExample: '<div aria-roledescription="slide">Slide 1</div>',
goodExample: '<div role="region" aria-roledescription="slide" aria-label="Slide 1">Slide 1</div>'
));
self::add(new WcagCriterion(
id: 'WCAG_4_1_2_ARIA_PROHIBITED_ATTR',
title: '4.1.2 Name, Role, Value',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
description: 'Prohibited ARIA attributes must not be used on presentation or generic roles.',
badExample: '<div role="presentation" aria-label="decorative"></div>',
goodExample: '<div role="presentation"></div>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_2_HTML_DIR',
title: '1.3.2 Meaningful Sequence',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/meaningful-sequence.html',
description: 'The dir attribute must only use valid values: ltr, rtl, or auto.',
badExample: '<p dir="left">Text</p>',
goodExample: '<p dir="ltr">Text</p>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_1_SELECT_CHILDREN',
title: '1.3.1 Info and Relationships',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html',
description: '<select> elements must only contain valid child elements (<option>, <optgroup>).',
badExample: '<select><div>Option 1</div></select>',
goodExample: '<select><option>Option 1</option></select>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_1_1_ROLE_IMG',
title: '1.1.1 Non-text Content',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html',
description: 'Elements with role="img" must have an accessible name.',
badExample: '<span role="img" class="icon-star"></span>',
goodExample: '<span role="img" class="icon-star" aria-label="5 stars rating"></span>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_4_2_MEDIA_CONTROLS',
title: '1.4.2 Audio Control',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/audio-control.html',
description: 'Audio and video media elements should provide user controls for playback and volume.',
badExample: '<audio src="podcast.mp3"></audio>',
goodExample: '<audio src="podcast.mp3" controls></audio>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_2_2_TRACK_VALIDATION',
title: '1.2.2 Captions (Prerecorded)',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/captions-prerecorded.html',
description: '<track> elements must have valid kind, srclang, and label attributes.',
badExample: '<track src="sub.vtt" kind="captions">',
goodExample: '<track src="sub.vtt" kind="captions" srclang="en" label="English">'
));
self::add(new WcagCriterion(
id: 'WCAG_2_1_1_ACCESSKEY_DUPLICATE',
title: '2.1.1 Keyboard',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html',
description: 'accesskey attribute values must be unique across the document.',
badExample: '<a href="/" accesskey="h">Home</a><a href="/help" accesskey="h">Help</a>',
goodExample: '<a href="/" accesskey="h">Home</a><a href="/help" accesskey="p">Help</a>'
));
self::add(new WcagCriterion(
id: 'WCAG_4_1_2_BUTTON_NESTED_INTERACTIVE',
title: '4.1.2 Name, Role, Value',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
description: '<button> elements must not contain nested interactive elements.',
badExample: '<button><a href="/cart">Checkout</a></button>',
goodExample: '<a href="/cart" class="btn">Checkout</a>'
));
self::add(new WcagCriterion(
id: 'WCAG_2_4_11_FOCUS_NOT_OBSCURED',
title: '2.4.11 Focus Not Obscured (Minimum)',
level: 'AA',
url: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html',
description: 'Fixed or sticky headers and overlays must not completely obscure keyboard-focused elements.',
badExample: '<header style="position: fixed; top: 0; width: 100%; height: 80px;"></header>',
goodExample: '<html style="scroll-padding-top: 80px;"><header style="position: fixed; top: 0; width: 100%; height: 80px;"></header></html>'
));
self::add(new WcagCriterion(
id: 'WCAG_2_5_7_DRAGGING_MOVEMENTS',
title: '2.5.7 Dragging Movements',
level: 'AA',
url: 'https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html',
description: 'Elements supporting drag-and-drop operations must provide a single-pointer alternative.',
badExample: '<div draggable="true" class="list-item">Item 1</div>',
goodExample: '<div draggable="true" class="list-item">Item 1 <button>Move Up</button> <button>Move Down</button></div>'
));
self::add(new WcagCriterion(
id: 'WCAG_4_1_2_ARIA_LABEL_GENERIC',
title: '4.1.2 Name, Role, Value',
level: 'A',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html',
description: 'Generic <div> or <span> elements should not use aria-label without an explicit ARIA role.',
badExample: '<div aria-label="Important Notice">Text</div>',
goodExample: '<div role="region" aria-label="Important Notice">Text</div>'
));
self::add(new WcagCriterion(
id: 'WCAG_1_3_5_AUTOCOMPLETE_APPROPRIATE',
title: '1.3.5 Identify Input Purpose',
level: 'AA',
url: 'https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html',
description: 'Input autocomplete value must be semantically appropriate for the input type.',
badExample: '<input type="number" autocomplete="email">',
goodExample: '<input type="email" autocomplete="email">'
));
}
public static function add(WcagCriterion $criterion): void
+10 -2
View File
@@ -92,6 +92,7 @@ class HtmlDashboardReporter
$explanation = '';
$fixedCode = '';
$explanation = '';
if ($aiSuggestion && str_contains($aiSuggestion, 'FIX:')) {
preg_match('/EXPLANATION:(.*)FIX:(.*)/s', $aiSuggestion, $matches);
$explanation = trim($matches[1] ?? '');
@@ -103,8 +104,15 @@ class HtmlDashboardReporter
$escapedSnippet = htmlspecialchars($v->htmlSnippet, ENT_QUOTES, 'UTF-8');
$escapedFixed = $fixedCode ? htmlspecialchars($fixedCode, ENT_QUOTES, 'UTF-8') : '';
$escapedExplanation = $explanation ? htmlspecialchars($explanation, ENT_QUOTES, 'UTF-8') : '';
$locationStr = $v->location ? htmlspecialchars($v->location['file'] . ':' . (string)$v->location['line'], ENT_QUOTES, 'UTF-8') : 'N/A';
$escapedExplanation = $explanation !== '' ? htmlspecialchars($explanation, ENT_QUOTES, 'UTF-8') : '';
$locationStr = 'N/A';
if ($v->location !== null) {
$filePart = $v->location['file'] ?? '';
$lineNum = (string)$v->location['line'];
$locText = $filePart !== '' ? $filePart . ':' . $lineNum : 'Line ' . $lineNum;
$locationStr = htmlspecialchars($locText, ENT_QUOTES, 'UTF-8');
}
$escapedRuleId = htmlspecialchars($v->ruleId, ENT_QUOTES, 'UTF-8');
$escapedMessage = htmlspecialchars($v->message, ENT_QUOTES, 'UTF-8');
+3 -1
View File
@@ -20,6 +20,8 @@ class PdfReportGenerator
throw new \RuntimeException('To generate PDF certificates, please install dompdf via: composer require dompdf/dompdf');
}
date_default_timezone_set('Europe/Istanbul');
/** @var int $score */
$score = is_numeric($scanResult['score'] ?? null) ? intval($scanResult['score']) : 0;
@@ -88,7 +90,7 @@ class PdfReportGenerator
<div><b>Hedef URL / Dizin:</b> " . htmlspecialchars($targetUrl) . "</div>
<div><b>Tarama Tarihi:</b> {$dateStr}</div>
<div><b>Tespit Edilen İhlal Sayısı:</b> {$violationsCount} Hata</div>
<div><b>Raporu Üreten Motor:</b> YakNet AI & Accessibility Engine v3.1</div>
<div><b>Raporu Üreten Motor:</b> YakNet AI & Accessibility Engine v3.2</div>
</div>
<h3 style='color:#0f172a; border-bottom:1px solid #cbd5e1; padding-bottom:6px;'>📋 Erişilebilirlik İhlalleri ve Çözüm Detayları</h3>
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AccesskeyDuplicate extends AbstractRule
{
public function getId(): string
{
return 'WCAG_2_1_1_ACCESSKEY_DUPLICATE';
}
public function getDescription(): string
{
return 'accesskey attribute values must be unique across the document.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*[@accesskey]');
if ($elements === false || $elements->length === 0) {
return [];
}
$keys = [];
foreach ($elements as $el) {
if (!$el instanceof DOMElement) {
continue;
}
$val = trim($el->getAttribute('accesskey'));
if ($val === '') {
continue;
}
if (!isset($keys[$val])) {
$keys[$val] = [];
}
$keys[$val][] = $el;
}
foreach ($keys as $key => $elementsWithKey) {
if (count($elementsWithKey) > 1) {
foreach ($elementsWithKey as $el) {
$violations[] = $this->createViolation(
"Duplicate accesskey=\"{$key}\" found. Access keys must be unique across the document.",
$el,
"Assign a unique accesskey to each interactive element, or avoid using accesskey entirely."
);
}
}
}
return $violations;
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaBrailleEquivalent extends AbstractRule
{
public function getId(): string
{
return 'WCAG_4_1_2_ARIA_BRAILLE';
}
public function getDescription(): string
{
return 'Elements with aria-braillelabel or aria-brailleroledescription must provide a standard accessible equivalent.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 4;
}
public function check(DOMElement $element): ?Violation
{
if ($element->hasAttribute('aria-braillelabel')) {
$hasName = false;
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
$hasName = true;
} elseif ($element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '') {
$hasName = true;
} elseif ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
$hasName = true;
} elseif ($element->hasAttribute('alt') && trim($element->getAttribute('alt')) !== '') {
$hasName = true;
} elseif (trim($element->textContent) !== '') {
$hasName = true;
}
if (!$hasName) {
return $this->createViolation(
$element,
"Element has aria-braillelabel but is missing a standard accessible name (aria-label, aria-labelledby, or text content).",
"Add a standard aria-label or aria-labelledby in addition to the aria-braillelabel."
);
}
}
if ($element->hasAttribute('aria-brailleroledescription')) {
if (!$element->hasAttribute('aria-roledescription') || trim($element->getAttribute('aria-roledescription')) === '') {
return $this->createViolation(
$element,
"Element has aria-brailleroledescription but is missing the standard aria-roledescription attribute.",
"Add an aria-roledescription attribute to accompany aria-brailleroledescription."
);
}
}
return null;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaCurrentValid extends AbstractRule
{
/** @var array<int, string> */
private static array $validValues = [
'page', 'step', 'location', 'date', 'time', 'true', 'false'
];
public function getId(): string
{
return 'WCAG_4_1_2_ARIA_CURRENT';
}
public function getDescription(): string
{
return 'The aria-current attribute must have a valid token value.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('aria-current')) {
return null;
}
$value = strtolower(trim($element->getAttribute('aria-current')));
if (!in_array($value, self::$validValues, true)) {
$validList = implode(', ', self::$validValues);
return $this->createViolation(
$element,
"Invalid aria-current value \"{$value}\". Valid values are: {$validList}.",
"Set aria-current to one of the valid token values: page, step, location, date, time, true, or false."
);
}
return null;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaDisabledFocusable extends AbstractRule
{
public function getId(): string
{
return 'WCAG_4_1_2_ARIA_DISABLED';
}
public function getDescription(): string
{
return 'Disabled elements should not have conflicting focusable tabindex settings.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
// 1. Native disabled element with explicit positive/zero tabindex
if ($element->hasAttribute('disabled')) {
if ($element->hasAttribute('tabindex')) {
$tabindex = (int)$element->getAttribute('tabindex');
if ($tabindex >= 0) {
return $this->createViolation(
$element,
"Element has both the 'disabled' attribute and a focusable tabindex=\"{$tabindex}\".",
"Remove the tabindex or set tabindex=\"-1\" to avoid keyboard navigation confusion on disabled elements."
);
}
}
}
// 2. Elements with aria-disabled="true" on non-native elements
if (strtolower(trim($element->getAttribute('aria-disabled'))) === 'true') {
$tagName = strtolower($element->tagName);
if (in_array($tagName, ['a', 'button'], true) && $element->hasAttribute('tabindex')) {
$tabindex = (int)$element->getAttribute('tabindex');
if ($tabindex > 0) {
return $this->createViolation(
$element,
"Element has aria-disabled=\"true\" with positive tabindex=\"{$tabindex}\".",
"Avoid positive tabindex on disabled interactive elements."
);
}
}
}
return null;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaErrorMessage extends AbstractRule
{
public function getId(): string
{
return 'WCAG_3_3_1_ARIA_ERRORMESSAGE';
}
public function getDescription(): string
{
return 'aria-errormessage must reference an existing element ID and the element must have aria-invalid set.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('aria-errormessage')) {
return null;
}
$targetId = trim($element->getAttribute('aria-errormessage'));
if ($targetId === '') {
return $this->createViolation(
$element,
"Attribute aria-errormessage is empty.",
"Provide the ID of an element containing the error message text."
);
}
// 1. Verify that target element exists in document
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$targets = $xpath->query('//*[@id="' . $targetId . '"]');
if ($targets === false || $targets->length === 0) {
return $this->createViolation(
$element,
"Attribute aria-errormessage=\"{$targetId}\" references an ID that does not exist in the document.",
"Ensure the referenced error message element ID exists in the HTML document."
);
}
}
// 2. Check if element has aria-invalid set to a truthy value (true, grammar, spelling)
$ariaInvalid = strtolower(trim($element->getAttribute('aria-invalid')));
if ($ariaInvalid === '' || $ariaInvalid === 'false') {
return $this->createViolation(
$element,
"Element uses aria-errormessage=\"{$targetId}\" but does not have aria-invalid set to \"true\".",
"Add aria-invalid=\"true\" to the control when an error message is active."
);
}
return null;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaLabelMisuseOnGeneric extends AbstractRule
{
public function getId(): string
{
return 'WCAG_4_1_2_ARIA_LABEL_GENERIC';
}
public function getDescription(): string
{
return 'aria-label or aria-labelledby should not be used on generic <div> or <span> elements without an ARIA role.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if ($tag !== 'div' && $tag !== 'span') {
return null;
}
$hasRole = $element->hasAttribute('role') && trim($element->getAttribute('role')) !== '';
if ($hasRole) {
return null;
}
$hasAriaLabel = $element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '';
$hasAriaLabelledby = $element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '';
if ($hasAriaLabel || $hasAriaLabelledby) {
return $this->createViolation(
$element,
"Generic <{$tag}> uses aria-label/aria-labelledby without a semantic ARIA role (assistive technologies ignore names on generic elements).",
"Add an appropriate ARIA role (e.g. role=\"region\", role=\"group\", role=\"navigation\") or use a semantic HTML5 element."
);
}
return null;
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaProhibitedAttr extends AbstractRule
{
/** @var array<string, array<int, string>> */
private static array $prohibitedAttributesByRole = [
'presentation' => ['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-details'],
'none' => ['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-details'],
'generic' => ['aria-label', 'aria-labelledby', 'aria-roledescription'],
'caption' => ['aria-label', 'aria-labelledby']
];
public function getId(): string
{
return 'WCAG_4_1_2_ARIA_PROHIBITED_ATTR';
}
public function getDescription(): string
{
return 'Prohibited ARIA attributes must not be used on roles that do not support naming (e.g. presentation, none).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 4;
}
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
foreach ($roles as $role) {
if (isset(self::$prohibitedAttributesByRole[$role])) {
$prohibited = self::$prohibitedAttributesByRole[$role];
$foundProhibited = [];
foreach ($prohibited as $attr) {
if ($element->hasAttribute($attr)) {
$foundProhibited[] = $attr;
}
}
if (!empty($foundProhibited)) {
$attrList = implode(', ', $foundProhibited);
return $this->createViolation(
$element,
"Element with role=\"{$role}\" contains prohibited ARIA attribute(s): {$attrList}.",
"Remove the prohibited attribute(s) or change the element's ARIA role to one that supports naming."
);
}
}
}
return null;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRedundantRole extends AbstractRule
{
/** @var array<string, string> */
private static array $redundantMap = [
'nav' => 'navigation',
'main' => 'main',
'button' => 'button',
'article' => 'article',
'aside' => 'complementary',
'dialog' => 'dialog',
'figure' => 'figure',
];
public function getId(): string
{
return 'WCAG_4_1_2_ARIA_REDUNDANT_ROLE';
}
public function getDescription(): string
{
return 'Avoid specifying redundant ARIA roles on HTML elements with identical implicit semantics.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 4;
}
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('role')) {
return null;
}
$tagName = strtolower($element->tagName);
$role = strtolower(trim($element->getAttribute('role')));
if (isset(self::$redundantMap[$tagName]) && self::$redundantMap[$tagName] === $role) {
return $this->createViolation(
$element,
"Redundant ARIA role=\"{$role}\" on <{$tagName}>. The element natively possesses this implicit semantic role.",
"Remove the redundant role=\"{$role}\" attribute (First Rule of ARIA)."
);
}
return null;
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRoledescription extends AbstractRule
{
public function getId(): string
{
return 'WCAG_4_1_2_ARIA_ROLEDESCRIPTION';
}
public function getDescription(): string
{
return 'aria-roledescription must only be used on elements with a non-generic role and must not be empty.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('aria-roledescription')) {
return null;
}
$desc = trim($element->getAttribute('aria-roledescription'));
if ($desc === '') {
return $this->createViolation(
$element,
"Attribute aria-roledescription is empty or contains only whitespace.",
"Provide a meaningful description or remove the aria-roledescription attribute."
);
}
$role = strtolower(trim($element->getAttribute('role')));
if ($role === 'presentation' || $role === 'none' || $role === 'generic') {
return $this->createViolation(
$element,
"aria-roledescription must not be used on elements with role=\"{$role}\".",
"Remove aria-roledescription from presentation/generic elements, or provide an appropriate semantic ARIA role."
);
}
$tagName = strtolower($element->tagName);
if ($role === '' && in_array($tagName, ['div', 'span'], true)) {
return $this->createViolation(
$element,
"aria-roledescription is used on a generic <{$tagName}> element without an explicit ARIA role.",
"Add an explicit ARIA role (e.g. role=\"region\", role=\"button\") or use a semantic HTML element."
);
}
return null;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ButtonNestedInteractive extends AbstractRule
{
public function getId(): string
{
return 'WCAG_4_1_2_BUTTON_NESTED_INTERACTIVE';
}
public function getDescription(): string
{
return '<button> elements must not contain nested interactive controls.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'button') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$nestedInteractives = $xpath->query('.//a[@href] | .//button | .//select | .//textarea | .//input[not(@type="hidden")]', $element);
if ($nestedInteractives !== false && $nestedInteractives->length > 0) {
$first = $nestedInteractives->item(0);
$nestedTag = $first instanceof DOMElement ? strtolower($first->tagName) : 'interactive element';
return $this->createViolation(
$element,
"<button> contains a nested interactive <{$nestedTag}> element.",
"Remove nested interactive elements from inside the <button> or restructure as sibling elements."
);
}
return null;
}
}
+9 -1
View File
@@ -23,7 +23,7 @@ class ColorContrast extends AbstractRule
// 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');
$bg = $this->parseColorValue($inlineStyle, 'background-color') ?: $this->parseBackgroundValue($inlineStyle);
// 2. Fallback: Parse stylesheet styles
if (($color === null || $bg === null) && $element->ownerDocument !== null) {
@@ -327,6 +327,14 @@ class ColorContrast extends AbstractRule
return null;
}
private function parseBackgroundValue(string $style): ?string
{
if (preg_match('/(?:^|;)\s*background\s*:\s*([^;]+)/i', $style, $matches)) {
return $this->extractColorFromBackground(trim($matches[1]));
}
return null;
}
/**
* @return array{0: int, 1: int, 2: int}|null
*/
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMNode;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DetailsMissingSummary extends AbstractRule
{
public function getId(): string
{
return 'WCAG_1_3_1_DETAILS_SUMMARY';
}
public function getDescription(): string
{
return '<details> elements must contain a <summary> element to provide an accessible disclosure label.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'details') {
return null;
}
$hasSummary = false;
$summaryEmpty = false;
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement && strtolower($child->tagName) === 'summary') {
$hasSummary = true;
$text = trim($child->textContent);
$ariaLabel = trim($child->getAttribute('aria-label'));
if ($text === '' && $ariaLabel === '') {
$summaryEmpty = true;
}
break;
}
}
if (!$hasSummary) {
return $this->createViolation(
$element,
"<details> element is missing a <summary> element as its label.",
"Add a <summary> element with descriptive text as the first child of the <details> tag."
);
}
if ($summaryEmpty) {
return $this->createViolation(
$element,
"<summary> element inside <details> is empty.",
"Provide meaningful text inside the <summary> element to describe the expandable content."
);
}
return null;
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DraggingMovements extends AbstractRule
{
public function getId(): string
{
return 'WCAG_2_5_7_DRAGGING_MOVEMENTS';
}
public function getDescription(): string
{
return 'Elements supporting drag-and-drop operations must provide a single-pointer alternative (WCAG 2.2 SC 2.5.7).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::AA;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 4;
}
public function check(DOMElement $element): ?Violation
{
$isDraggable = strtolower(trim($element->getAttribute('draggable'))) === 'true';
$hasDragEvent = $element->hasAttribute('ondragstart') || $element->hasAttribute('ondrop');
if (!$isDraggable && !$hasDragEvent) {
return null;
}
// Check if element or child has alternative buttons/actions or aria-grabbed/keyshortcuts
$hasKeyboardShortcut = $element->hasAttribute('aria-keyshortcuts');
$hasGrabbed = $element->hasAttribute('aria-grabbed');
$hasRole = $element->hasAttribute('role');
$doc = $element->ownerDocument;
$hasButtonsInside = false;
if ($doc !== null) {
$buttons = $element->getElementsByTagName('button');
if ($buttons->length > 0) {
$hasButtonsInside = true;
}
}
if (!$hasKeyboardShortcut && !$hasGrabbed && !$hasButtonsInside) {
return $this->createViolation(
$element,
"Draggable element lacks a single-pointer or keyboard-accessible alternative (WCAG 2.2 SC 2.5.7).",
"Provide alternative click buttons (e.g. Move Up / Move Down) or keyboard commands for users unable to perform dragging movements."
);
}
return null;
}
}
+26 -4
View File
@@ -8,6 +8,16 @@ use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DuplicateId extends AbstractRule
{
public function getId(): string
{
return 'WCAG_4_1_2_DUPLICATE_ID';
}
public function getDescription(): string
{
return 'Element IDs must be unique across the document.';
}
public function check(\DOMDocument $doc): array
{
$violations = [];
@@ -37,7 +47,8 @@ class DuplicateId extends AbstractRule
foreach ($elementsWithId as $el) {
$violations[] = $this->createViolation(
"Duplicate ID \"{$id}\" found. Element ID attribute must be unique across the document.",
$el
$el,
"Change duplicate id=\"{$id}\" to a unique identifier."
);
}
}
@@ -46,7 +57,18 @@ class DuplicateId extends AbstractRule
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 3;
}
}
+95 -12
View File
@@ -3,17 +3,37 @@
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMNode;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class EmptyHeading extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_EMPTY_HEADING'; }
public function getDescription(): string { return 'Heading elements (h1-h6) must have discernible text content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function getId(): string
{
return 'WCAG_1_3_1_EMPTY_HEADING';
}
public function getDescription(): string
{
return 'Heading elements (h1-h6) must have discernible text content.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
@@ -26,15 +46,78 @@ class EmptyHeading extends AbstractRule
return null;
}
// 1. Check direct text content
$text = trim($element->textContent);
if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add descriptive text or an aria-label to the heading element.'
);
if ($text !== '') {
return null;
}
return null;
// 2. Check aria-label
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
// 3. Check title attribute
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// 4. Check aria-labelledby
if ($element->hasAttribute('aria-labelledby')) {
$id = trim($element->getAttribute('aria-labelledby'));
$doc = $element->ownerDocument;
if ($id !== '' && $doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//*[@id='$id']");
if ($labels !== false && $labels->length > 0) {
$labelNode = $labels->item(0);
if ($labelNode instanceof DOMNode && trim($labelNode->textContent) !== '') {
return null;
}
}
}
}
// 5. Check img with alt attribute
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
// 6. Check svg with title or aria-label
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
if ($svg->hasAttribute('aria-label') && trim($svg->getAttribute('aria-label')) !== '') {
return null;
}
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
}
// 7. Check element with role="img" and aria-label
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$roleImgs = $xpath->query('.//*[@role="img"]', $element);
if ($roleImgs !== false) {
foreach ($roleImgs as $roleImg) {
if ($roleImg instanceof DOMElement && $roleImg->hasAttribute('aria-label') && trim($roleImg->getAttribute('aria-label')) !== '') {
return null;
}
}
}
}
return $this->createViolation(
$element,
$this->getDescription(),
'Add descriptive text, an <img> with alt text, or an aria-label attribute to the heading.'
);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class FocusNotObscured extends AbstractRule
{
public function getId(): string
{
return 'WCAG_2_4_11_FOCUS_NOT_OBSCURED';
}
public function getDescription(): string
{
return 'Fixed, sticky, or overlay elements must not completely obscure keyboard-focused controls (WCAG 2.2 SC 2.4.11).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::AA;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 4;
}
public function check(DOMElement $element): ?Violation
{
$style = strtolower($element->getAttribute('style'));
if (trim($style) === '') {
return null;
}
$isFixedOrSticky = str_contains($style, 'position:fixed') || str_contains($style, 'position: fixed') ||
str_contains($style, 'position:sticky') || str_contains($style, 'position: sticky');
if (!$isFixedOrSticky) {
return null;
}
// Check if element is a top/bottom bar with high z-index without scroll-padding considerations
$hasTopOrBottom = str_contains($style, 'top:0') || str_contains($style, 'top: 0') ||
str_contains($style, 'bottom:0') || str_contains($style, 'bottom: 0');
$hasFullWidth = str_contains($style, 'width:100%') || str_contains($style, 'width: 100%') ||
str_contains($style, 'left:0') || str_contains($style, 'left: 0');
if ($hasTopOrBottom && $hasFullWidth) {
// Check if height or z-index is large
return $this->createViolation(
$element,
"Fixed/sticky header or footer overlay may obscure focused items during keyboard navigation (WCAG 2.2 SC 2.4.11).",
"Ensure html/body has adequate scroll-padding-top or scroll-padding-bottom to prevent focused elements from being hidden under sticky bars."
);
}
return null;
}
}
+61 -13
View File
@@ -3,35 +3,56 @@
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMNode;
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 getLevel(): int { return 2; }
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 getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
$tags = ['input', 'select', 'textarea'];
if (!in_array(strtolower($element->tagName), $tags)) {
if (!in_array(strtolower($element->tagName), $tags, true)) {
return null;
}
// Skip hidden inputs and buttons
$type = strtolower($element->getAttribute('type'));
if ($type === 'hidden' || $type === 'submit' || $type === 'button' || $type === 'reset') {
if ($type === 'hidden' || $type === 'submit' || $type === 'button' || $type === 'reset' || $type === 'image') {
return null;
}
// 1. Check label for="id"
if ($element->hasAttribute('id')) {
$id = $element->getAttribute('id');
$id = trim($element->getAttribute('id'));
$doc = $element->ownerDocument;
if ($doc !== null) {
if ($id !== '' && $doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//label[@for='$id']");
if ($labels !== false && $labels->length > 0) {
@@ -40,12 +61,35 @@ class FormLabel extends AbstractRule
}
}
// Check for aria-label, aria-labelledby, or title
if ($element->hasAttribute('aria-label') || $element->hasAttribute('aria-labelledby') || $element->hasAttribute('title')) {
// 2. Check aria-label
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
// Check if wrapped in label
// 3. Check aria-labelledby
if ($element->hasAttribute('aria-labelledby')) {
$ariaLabelledby = trim($element->getAttribute('aria-labelledby'));
if ($ariaLabelledby !== '') {
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$ids = array_filter(explode(' ', $ariaLabelledby));
foreach ($ids as $targetId) {
$targets = $xpath->query("//*[@id='$targetId']");
if ($targets !== false && $targets->length > 0) {
return null;
}
}
}
}
}
// 4. Check title attribute
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// 5. Check if wrapped in label
$parent = $element->parentNode;
while ($parent) {
if ($parent instanceof DOMElement && strtolower($parent->tagName) === 'label') {
@@ -54,6 +98,10 @@ class FormLabel extends AbstractRule
$parent = $parent->parentNode;
}
return $this->createViolation($element, $this->getDescription(), 'Add a <label for="..."> or an aria-label attribute.');
return $this->createViolation(
$element,
$this->getDescription(),
'Add a <label for="..."> or an aria-label attribute to associate a label with this input.'
);
}
}
+57 -24
View File
@@ -3,39 +3,72 @@
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\AbstractRule;
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 getLevel(): int { return 2; }
public function check(DOMElement $element): ?Violation
public function getId(): string
{
if (!preg_match('/^h([1-6])$/i', $element->tagName, $matches)) {
return null;
return 'WCAG_1_3_1_HEADING';
}
public function getDescription(): string
{
return 'Headings should follow a logical nesting order without skipping levels.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 2;
}
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
$headings = $xpath->query('//h1 | //h2 | //h3 | //h4 | //h5 | //h6');
if ($headings === false || $headings->length === 0) {
return [];
}
$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;
$lastLevel = 0;
foreach ($headings as $heading) {
if (!$heading instanceof DOMElement) {
continue;
}
if (!preg_match('/^h([1-6])$/i', $heading->tagName, $matches)) {
continue;
}
$currentLevel = (int)$matches[1];
// If heading jumps more than 1 level down (e.g. h1 to h3, h2 to h5)
if ($lastLevel > 0 && $currentLevel > $lastLevel + 1) {
$violations[] = $this->createViolation(
"Heading level <h{$currentLevel}> jumps from <h{$lastLevel}>, skipping intermediate heading levels.",
$heading,
"Ensure headings follow a logical sequence (e.g., <h1> followed by <h2>, not skipping to <h{$currentLevel}>)."
);
}
$lastLevel = $currentLevel;
}
self::$lastLevel = $currentLevel;
return null;
return $violations;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class HtmlDirValid extends AbstractRule
{
/** @var array<int, string> */
private static array $validDirValues = ['ltr', 'rtl', 'auto'];
public function getId(): string
{
return 'WCAG_1_3_2_HTML_DIR';
}
public function getDescription(): string
{
return 'The dir attribute must have a valid value (ltr, rtl, or auto).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('dir')) {
return null;
}
$dir = strtolower(trim($element->getAttribute('dir')));
if (!in_array($dir, self::$validDirValues, true)) {
return $this->createViolation(
$element,
"Invalid dir attribute value \"{$dir}\". Valid values are 'ltr', 'rtl', or 'auto'.",
"Set dir to \"ltr\", \"rtl\", or \"auto\" for proper bidirectional text handling."
);
}
return null;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
use YakNet\AccessibilityConsole\Syntax\HtmlSyntaxLinter;
class HtmlSyntaxValid extends AbstractRule
{
private HtmlSyntaxLinter $linter;
public function __construct(?HtmlSyntaxLinter $linter = null)
{
$this->linter = $linter ?? new HtmlSyntaxLinter();
}
public function getId(): string
{
return 'WCAG_4_1_1_HTML_PARSING_SYNTAX';
}
public function getDescription(): string
{
return 'HTML documents must be well-formed without duplicate attributes, mismatched tags, or malformed syntax (WCAG 4.1.1).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(\DOMDocument $doc): array
{
$rawHtml = property_exists($doc, 'rawHtml') && is_string($doc->rawHtml)
? $doc->rawHtml
: $doc->saveHTML();
if (empty($rawHtml) || trim($rawHtml) === '') {
return [];
}
$issues = $this->linter->lint($rawHtml);
$violations = [];
foreach ($issues as $issue) {
$severity = ($issue->severity === 'warning') ? Severity::WARNING : Severity::ERROR;
$violations[] = new Violation(
ruleId: $this->getId(),
message: "[{$issue->code}] {$issue->message}",
severity: $severity,
standard: $this->getStandard(),
htmlSnippet: $issue->snippet,
location: [
'line' => $issue->line,
'column' => $issue->column,
],
fixSuggestion: $issue->fixSuggestion
);
}
return $violations;
}
}
+40 -12
View File
@@ -9,11 +9,30 @@ use YakNet\AccessibilityConsole\Core\WCAGStandard;
class IframeTitle extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_IFRAME_TITLE'; }
public function getDescription(): string { return 'Iframe elements must have a non-empty title attribute to describe their content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 2; }
public function getId(): string
{
return 'WCAG_4_1_2_IFRAME_TITLE';
}
public function getDescription(): string
{
return 'Iframe elements must have an accessible name (title or aria-label) to describe their content.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
@@ -21,14 +40,23 @@ class IframeTitle extends AbstractRule
return null;
}
if (!$element->hasAttribute('title') || trim($element->getAttribute('title')) === '') {
return $this->createViolation(
$element,
"Iframe element is missing a descriptive title attribute.",
"Add a descriptive title attribute, e.g., title=\"Embedded Map\" or title=\"Video Player\"."
);
// Check if decorative / hidden
if ($element->getAttribute('aria-hidden') === 'true') {
return null;
}
return null;
$title = trim($element->getAttribute('title'));
$ariaLabel = trim($element->getAttribute('aria-label'));
$ariaLabelledby = trim($element->getAttribute('aria-labelledby'));
if ($title !== '' || $ariaLabel !== '' || $ariaLabelledby !== '') {
return null;
}
return $this->createViolation(
$element,
"Iframe element is missing an accessible title or aria-label attribute.",
"Add a descriptive title attribute, e.g., title=\"Embedded Map\" or an aria-label."
);
}
}
@@ -0,0 +1,83 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputAutocompleteAppropriate extends AbstractRule
{
public function getId(): string
{
return 'WCAG_1_3_5_AUTOCOMPLETE_APPROPRIATE';
}
public function getDescription(): string
{
return 'Input autocomplete value must be semantically appropriate for the input type (WCAG 1.3.5).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::AA;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'input') {
return null;
}
if (!$element->hasAttribute('autocomplete')) {
return null;
}
$type = strtolower($element->getAttribute('type') ?: 'text');
$autocomplete = strtolower(trim($element->getAttribute('autocomplete')));
if ($autocomplete === '' || in_array($autocomplete, ['on', 'off'], true)) {
return null;
}
// Email type should not have telephone or postal autocomplete
if ($type === 'email' && (str_contains($autocomplete, 'tel') || str_contains($autocomplete, 'postal') || str_contains($autocomplete, 'bday'))) {
return $this->createViolation(
$element,
"Inappropriate autocomplete=\"{$autocomplete}\" on <input type=\"email\">.",
"Use autocomplete=\"email\" or \"username\" for email inputs."
);
}
// Number type should not have name, street-address, or email
if ($type === 'number' && (str_contains($autocomplete, 'name') || str_contains($autocomplete, 'email') || str_contains($autocomplete, 'address') || str_contains($autocomplete, 'country'))) {
return $this->createViolation(
$element,
"Inappropriate autocomplete=\"{$autocomplete}\" on <input type=\"number\">.",
"Number inputs should use numeric autocomplete tokens like \"bday-year\", \"cc-exp-year\", or \"tel\"."
);
}
// Checkbox or Radio should not have email or password autocompletes
if (in_array($type, ['checkbox', 'radio'], true) && (str_contains($autocomplete, 'password') || str_contains($autocomplete, 'email'))) {
return $this->createViolation(
$element,
"Inappropriate autocomplete=\"{$autocomplete}\" on <input type=\"{$type}\">.",
"Do not apply credential or text autocompletes to checkbox/radio inputs."
);
}
return null;
}
}
+51 -27
View File
@@ -9,11 +9,30 @@ use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputMissingAutocomplete extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_5_INPUT_AUTOCOMPLETE'; }
public function getDescription(): string { return 'Input fields collecting personal data should have an autocomplete attribute.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function getLevel(): int { return 3; }
public function getId(): string
{
return 'WCAG_3_3_8_ACCESSIBLE_AUTH';
}
public function getDescription(): string
{
return 'Authentication inputs must support password managers and must not disable autocomplete or paste (WCAG 2.2 SC 3.3.8).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::AA;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
@@ -22,32 +41,37 @@ class InputMissingAutocomplete extends AbstractRule
}
$type = strtolower($element->getAttribute('type'));
if (in_array($type, ['hidden', 'submit', 'button', 'reset', 'checkbox', 'radio', 'image', 'file'], true)) {
return null;
}
$name = strtolower($element->getAttribute('name'));
$id = strtolower($element->getAttribute('id'));
$targetKeywords = ['email', 'phone', 'tel', 'username', 'user', 'password', 'pass', 'address', 'zip', 'city', 'country', 'name', 'fname', 'lname'];
$isPersonalField = false;
if (in_array($type, ['email', 'tel'], true)) {
$isPersonalField = true;
} else {
foreach ($targetKeywords as $keyword) {
if (str_contains($name, $keyword) || str_contains($id, $keyword)) {
$isPersonalField = true;
break;
}
// 1. Check for paste blocking on input fields (which harms accessible authentication)
if ($element->hasAttribute('onpaste')) {
$onpaste = strtolower(trim($element->getAttribute('onpaste')));
if (str_contains($onpaste, 'return false') || str_contains($onpaste, 'preventdefault')) {
return $this->createViolation(
$element,
"Input field blocks pasting (onpaste handler). This violates accessible authentication (WCAG 2.2 SC 3.3.8).",
"Remove paste restrictions so users can paste credentials from password managers."
);
}
}
if ($isPersonalField && (!$element->hasAttribute('autocomplete') || trim($element->getAttribute('autocomplete')) === '')) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add an autocomplete attribute (e.g., autocomplete="email", autocomplete="username") to improve accessibility and user experience.'
);
// 2. Password fields should not have autocomplete="off"
if ($type === 'password') {
$autocomplete = strtolower(trim($element->getAttribute('autocomplete')));
if ($autocomplete === 'off') {
return $this->createViolation(
$element,
"Password input has autocomplete=\"off\", preventing password managers from assisting users.",
"Change autocomplete to \"current-password\" or \"new-password\" instead of \"off\"."
);
}
if ($autocomplete === '' && !$element->hasAttribute('autocomplete')) {
return $this->createViolation(
$element,
"Password input should specify autocomplete=\"current-password\" or autocomplete=\"new-password\".",
"Add autocomplete=\"current-password\" (for login) or autocomplete=\"new-password\" (for registration/reset)."
);
}
}
return null;
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class MediaControls extends AbstractRule
{
public function getId(): string
{
return 'WCAG_1_4_2_MEDIA_CONTROLS';
}
public function getDescription(): string
{
return 'Audio and video elements should provide controls for user interaction.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if ($tag !== 'audio' && $tag !== 'video') {
return null;
}
// Check if controls attribute is present
if ($element->hasAttribute('controls')) {
return null;
}
// Allow background video if explicitly muted and looping
$isMuted = $element->hasAttribute('muted');
$isAutoplay = $element->hasAttribute('autoplay');
$isLoop = $element->hasAttribute('loop');
if ($tag === 'video' && $isMuted && $isAutoplay && $isLoop) {
return null;
}
return $this->createViolation(
$element,
"<{$tag}> element is missing the 'controls' attribute.",
"Add the 'controls' attribute to allow users to play, pause, and adjust media volume."
);
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class MetaCharsetPresent extends AbstractRule
{
public function getId(): string
{
return 'WCAG_3_1_1_META_CHARSET';
}
public function getDescription(): string
{
return 'HTML documents should specify character encoding (meta charset) inside the <head> element.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 2;
}
public function check(\DOMDocument $doc): array
{
$heads = $doc->getElementsByTagName('head');
if ($heads->length === 0) {
return [];
}
$head = $heads->item(0);
if (!$head instanceof DOMElement) {
return [];
}
$hasCharset = false;
$metas = $head->getElementsByTagName('meta');
foreach ($metas as $meta) {
if ($meta->hasAttribute('charset') && trim($meta->getAttribute('charset')) !== '') {
$hasCharset = true;
break;
}
if (strtolower($meta->getAttribute('http-equiv')) === 'content-type') {
$content = strtolower($meta->getAttribute('content'));
if (str_contains($content, 'charset=')) {
$hasCharset = true;
break;
}
}
}
if (!$hasCharset) {
return [
$this->createViolation(
"The document <head> is missing a character encoding declaration (<meta charset=\"utf-8\">).",
$head,
"Add <meta charset=\"utf-8\"> as the first child of the <head> tag to prevent screen reader character misinterpretations."
)
];
}
return [];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class MeterAccessibility extends AbstractRule
{
public function getId(): string
{
return 'WCAG_1_3_1_METER_ACCESSIBLE';
}
public function getDescription(): string
{
return '<meter> elements must have an accessible name and valid values.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'meter') {
return null;
}
// 1. Check for accessible name
$hasName = false;
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
$hasName = true;
} elseif ($element->hasAttribute('aria-labelledby') && trim($element->getAttribute('aria-labelledby')) !== '') {
$hasName = true;
} elseif ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
$hasName = true;
} elseif ($element->hasAttribute('id')) {
$id = trim($element->getAttribute('id'));
$doc = $element->ownerDocument;
if ($id !== '' && $doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//label[@for='{$id}']");
if ($labels !== false && $labels->length > 0) {
$hasName = true;
}
}
}
if (!$hasName) {
// Check if wrapped in <label>
$parent = $element->parentNode;
while ($parent) {
if ($parent instanceof DOMElement && strtolower($parent->tagName) === 'label') {
$hasName = true;
break;
}
$parent = $parent->parentNode;
}
}
if (!$hasName) {
return $this->createViolation(
$element,
"<meter> element is missing an accessible name.",
"Add an aria-label, title, or associated <label> element to describe the gauge value."
);
}
return null;
}
}
+31 -9
View File
@@ -10,16 +10,35 @@ use YakNet\AccessibilityConsole\Core\WCAGStandard;
class NestedInteractive extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_NestedInteractive'; }
public function getDescription(): string { return 'Checks for nested interactive elements.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 3; }
public function getId(): string
{
return 'WCAG_4_1_2_NESTED_INTERACTIVE';
}
public function getDescription(): string
{
return 'Interactive elements (<a>, <button>, etc.) must not contain nested interactive controls.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if (!in_array($tag, ['a', 'button'])) {
if (!in_array($tag, ['a', 'button'], true)) {
return null;
}
@@ -27,14 +46,17 @@ class NestedInteractive extends AbstractRule
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
$interactives = $xpath->query('.//a | .//button | .//input | .//select | .//textarea', $element);
$interactives = $xpath->query('.//a[@href] | .//button | .//select | .//textarea | .//input[not(@type="hidden")]', $element);
if ($interactives !== false && $interactives->length > 0) {
$first = $interactives->item(0);
$nestedTag = $first instanceof DOMElement ? strtolower($first->tagName) : 'interactive';
return $this->createViolation(
$element,
"İç içe geçmiş etkileşimli öğeler bulundu (<{$tag}> içerisinde).",
'Ensure interactive controls are not nested.'
"Nested interactive control <{$nestedTag}> found inside <{$tag}>.",
"Ensure interactive controls are not nested inside each other."
);
}
+26 -21
View File
@@ -9,11 +9,30 @@ use YakNet\AccessibilityConsole\Core\WCAGStandard;
class PageTitle extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_2_PAGE_TITLE'; }
public function getDescription(): string { return 'Web pages must have a non-empty title element in the head.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function getLevel(): int { return 1; }
public function getId(): string
{
return 'WCAG_2_4_2_PAGE_TITLE';
}
public function getDescription(): string
{
return 'Web pages must have a <title> element inside the <head>.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 1;
}
public function check(DOMElement $element): ?Violation
{
@@ -26,22 +45,8 @@ class PageTitle extends AbstractRule
if ($titles->length === 0) {
return $this->createViolation(
$element,
$this->getDescription(),
'Add a <title> element inside the <head>.'
);
}
$titleText = '';
$firstTitle = $titles->item(0);
if ($firstTitle !== null) {
$titleText = trim($firstTitle->textContent);
}
if ($titleText === '') {
return $this->createViolation(
$element,
$this->getDescription(),
'Provide descriptive text inside the <title> element.'
"The <head> element is missing a <title> element.",
'Add a <title> element inside the <head> to provide a page title.'
);
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class RoleImgAlt extends AbstractRule
{
public function getId(): string
{
return 'WCAG_1_1_1_ROLE_IMG';
}
public function getDescription(): string
{
return 'Elements with role="img" must have an accessible text alternative.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) === 'img') {
return null; // Handled by ImgAltText
}
$roles = explode(' ', strtolower(trim($element->getAttribute('role'))));
if (!in_array('img', $roles, true)) {
return null;
}
if ($element->getAttribute('aria-hidden') === 'true') {
return null;
}
$ariaLabel = trim($element->getAttribute('aria-label'));
$ariaLabelledby = trim($element->getAttribute('aria-labelledby'));
$title = trim($element->getAttribute('title'));
$text = trim($element->textContent);
if ($ariaLabel !== '' || $ariaLabelledby !== '' || $title !== '' || $text !== '') {
return null;
}
return $this->createViolation(
$element,
"Element with role=\"img\" (<{$element->tagName}>) does not have an accessible name.",
"Provide an accessible description using aria-label, aria-labelledby, or title, or hide it if decorative using aria-hidden=\"true\"."
);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class SelectValidChildren extends AbstractRule
{
/** @var array<int, string> */
private static array $validChildTags = ['option', 'optgroup', 'script', 'template', 'hr'];
public function getId(): string
{
return 'WCAG_1_3_1_SELECT_CHILDREN';
}
public function getDescription(): string
{
return '<select> elements must only contain valid child elements (<option>, <optgroup>, <script>, <template>).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'select') {
return null;
}
$invalidTags = [];
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
$childTag = strtolower($child->tagName);
if (!in_array($childTag, self::$validChildTags, true)) {
$invalidTags[] = "<{$childTag}>";
}
}
}
if (!empty($invalidTags)) {
$invalidStr = implode(', ', array_unique($invalidTags));
return $this->createViolation(
$element,
"<select> contains invalid direct child elements: {$invalidStr}.",
"Remove non-option elements from the <select> or restructure using <optgroup> and <option> tags."
);
}
return null;
}
}
+4 -4
View File
@@ -8,9 +8,9 @@ class StrictRuleSet extends RuleEngine
{
public function __construct()
{
$this->addRule(new ImageAltRule());
$this->addRule(new HeadingOrderRule());
$this->addRule(new EmptyLinkRule());
// Daha fazla kural buraya eklenebilir
$rules = RuleLevels::getRulesForLevel(5);
foreach ($rules as $rule) {
$this->addRule($rule);
}
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TableHeaderNotEmpty extends AbstractRule
{
public function getId(): string
{
return 'WCAG_1_3_1_TH_NOT_EMPTY';
}
public function getDescription(): string
{
return '<th> table header cells must have discernible text or an accessible name.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'th') {
return null;
}
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
// 1. Text content
if (trim($element->textContent) !== '') {
return null;
}
// 2. aria-label or title
if ($element->hasAttribute('aria-label') && trim($element->getAttribute('aria-label')) !== '') {
return null;
}
if ($element->hasAttribute('title') && trim($element->getAttribute('title')) !== '') {
return null;
}
// 3. Img with alt
$imgs = $element->getElementsByTagName('img');
foreach ($imgs as $img) {
if ($img->hasAttribute('alt') && trim($img->getAttribute('alt')) !== '') {
return null;
}
}
// 4. Svg with title or aria-label
$svgs = $element->getElementsByTagName('svg');
foreach ($svgs as $svg) {
if ($svg->hasAttribute('aria-label') && trim($svg->getAttribute('aria-label')) !== '') {
return null;
}
$titles = $svg->getElementsByTagName('title');
foreach ($titles as $title) {
if (trim($title->textContent) !== '') {
return null;
}
}
}
return $this->createViolation(
$element,
"Table header cell <th> is empty and has no accessible name.",
"Add meaningful text content or an aria-label to describe the column or row represented by this <th>."
);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TableHeadersAttributeValid extends AbstractRule
{
public function getId(): string
{
return 'WCAG_1_3_1_TABLE_HEADERS_ATTR';
}
public function getDescription(): string
{
return 'The headers attribute on table cells must refer to valid <th> elements within the same table.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 3;
}
public function check(DOMElement $element): ?Violation
{
$tag = strtolower($element->tagName);
if ($tag !== 'td' && $tag !== 'th') {
return null;
}
if (!$element->hasAttribute('headers')) {
return null;
}
$headersStr = trim($element->getAttribute('headers'));
if ($headersStr === '') {
return $this->createViolation(
$element,
"Empty 'headers' attribute found on <{$tag}>.",
"Provide space-separated IDs of the corresponding <th> header cells or remove the empty attribute."
);
}
// Find enclosing table
$table = $element->parentNode;
while ($table !== null && (!($table instanceof DOMElement) || strtolower($table->tagName) !== 'table')) {
$table = $table->parentNode;
}
if (!$table instanceof DOMElement) {
return null;
}
$headerIds = array_filter(explode(' ', $headersStr));
$invalidIds = [];
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new \DOMXPath($doc);
foreach ($headerIds as $hid) {
$matchingTh = $xpath->query(".//th[@id='{$hid}']", $table);
if ($matchingTh === false || $matchingTh->length === 0) {
$invalidIds[] = $hid;
}
}
if (!empty($invalidIds)) {
$invalidStr = implode(', ', $invalidIds);
return $this->createViolation(
$element,
"The headers attribute refers to ID(s) not found as <th> elements within the table: {$invalidStr}.",
"Ensure each ID specified in the 'headers' attribute matches the 'id' of a <th> element in this table."
);
}
return null;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TargetSizeMinimum extends AbstractRule
{
public function getId(): string
{
return 'WCAG_2_5_8_TARGET_SIZE';
}
public function getDescription(): string
{
return 'Interactive elements must have a minimum target size of at least 24x24 CSS pixels (WCAG 2.2).';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::AA;
}
public function getSeverity(): Severity
{
return Severity::WARNING;
}
public function getLevel(): int
{
return 4;
}
public function check(DOMElement $element): ?Violation
{
$tagName = strtolower($element->tagName);
$isInteractive = false;
if ($tagName === 'a' && $element->hasAttribute('href')) {
$isInteractive = true;
} elseif (in_array($tagName, ['button', 'select', 'textarea'], true)) {
$isInteractive = true;
} elseif ($tagName === 'input') {
$type = strtolower($element->getAttribute('type'));
if ($type !== 'hidden') {
$isInteractive = true;
}
} elseif ($element->hasAttribute('role')) {
$role = strtolower(trim($element->getAttribute('role')));
if (in_array($role, ['button', 'link', 'checkbox', 'radio', 'tab', 'menuitem'], true)) {
$isInteractive = true;
}
}
if (!$isInteractive) {
return null;
}
$style = $element->getAttribute('style');
if (trim($style) === '') {
return null;
}
$width = $this->parsePixelDimension($style, 'width');
$height = $this->parsePixelDimension($style, 'height');
$tooSmallWidth = ($width !== null && $width < 24.0);
$tooSmallHeight = ($height !== null && $height < 24.0);
if ($tooSmallWidth || $tooSmallHeight) {
$wStr = $width !== null ? "{$width}px" : "auto";
$hStr = $height !== null ? "{$height}px" : "auto";
return $this->createViolation(
$element,
"Interactive element target size ({$wStr} x {$hStr}) is smaller than the required 24x24px minimum (WCAG 2.2 SC 2.5.8).",
"Ensure interactive targets have a minimum width and height of at least 24x24px, or add adequate spacing/padding around them."
);
}
return null;
}
private function parsePixelDimension(string $style, string $prop): ?float
{
if (preg_match('/(?:^|;)\s*' . preg_quote($prop, '/') . '\s*:\s*([0-9.]+)\s*px/i', $style, $matches)) {
return (float)$matches[1];
}
return null;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TrackValidation extends AbstractRule
{
/** @var array<int, string> */
private static array $validKinds = ['subtitles', 'captions', 'descriptions', 'chapters', 'metadata'];
public function getId(): string
{
return 'WCAG_1_2_2_TRACK_VALIDATION';
}
public function getDescription(): string
{
return '<track> elements must specify a valid kind, and captions/subtitles must include srclang and label.';
}
public function getStandard(): WCAGStandard
{
return WCAGStandard::A;
}
public function getSeverity(): Severity
{
return Severity::ERROR;
}
public function getLevel(): int
{
return 2;
}
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'track') {
return null;
}
// 1. Kind attribute validity
$kind = strtolower(trim($element->getAttribute('kind')));
if ($kind !== '' && !in_array($kind, self::$validKinds, true)) {
$validList = implode(', ', self::$validKinds);
return $this->createViolation(
$element,
"Invalid track kind \"{$kind}\". Valid values are: {$validList}.",
"Set kind to one of: subtitles, captions, descriptions, chapters, metadata."
);
}
// Default kind if omitted is 'subtitles'
$effectiveKind = $kind !== '' ? $kind : 'subtitles';
// 2. If captions or subtitles, check srclang and label
if (in_array($effectiveKind, ['captions', 'subtitles'], true)) {
$srclang = trim($element->getAttribute('srclang'));
$label = trim($element->getAttribute('label'));
if ($srclang === '') {
return $this->createViolation(
$element,
"<track kind=\"{$effectiveKind}\"> element is missing the required 'srclang' attribute.",
"Add a valid BCP 47 language code (e.g. srclang=\"en\" or srclang=\"tr\")."
);
}
if ($label === '') {
return $this->createViolation(
$element,
"<track kind=\"{$effectiveKind}\"> element is missing the required 'label' attribute.",
"Add a human-readable title (e.g. label=\"English Captions\" or label=\"Türkçe Altyazı\")."
);
}
}
return null;
}
}
+337
View File
@@ -0,0 +1,337 @@
<?php
namespace YakNet\AccessibilityConsole\Syntax;
class HtmlSyntaxLinter
{
/** @var array<int, string> */
private static array $voidElements = [
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr'
];
/**
* Lint raw HTML/template content for markup syntax errors and structural defects.
*
* @param string $html
* @return SyntaxIssue[]
*/
public function lint(string $html): array
{
if (trim($html) === '') {
return [];
}
$issues = [];
// 1. Check for unclosed comments
$this->checkUnclosedComments($html, $issues);
// 2. Check for duplicate attributes on same tag & malformed attribute syntax
$this->checkAttributes($html, $issues);
// 3. Check for invalid self-closing on non-void elements
$this->checkInvalidSelfClosing($html, $issues);
// 4. Check for unclosed / mismatched tags (structural validation)
$this->checkTagStructure($html, $issues);
// 5. Check multiple DOCTYPE / HEAD / BODY declarations
$this->checkStructureTagMultiplicity($html, $issues);
return $issues;
}
/**
* @param SyntaxIssue[] $issues
*/
private function checkUnclosedComments(string $html, array &$issues): void
{
$offset = 0;
while (($pos = strpos($html, '<!--', $offset)) !== false) {
$endPos = strpos($html, '-->', $pos + 4);
if ($endPos === false) {
$line = $this->getLineFromOffset($html, $pos);
$issues[] = new SyntaxIssue(
code: 'SYNTAX_UNCLOSED_COMMENT',
message: 'HTML comment is opened with <!-- but never closed with -->.',
line: $line,
snippet: mb_substr($html, $pos, 80),
fixSuggestion: 'Close the comment with -->.',
severity: 'error'
);
break;
}
$offset = $endPos + 3;
}
}
/**
* @param SyntaxIssue[] $issues
*/
private function checkAttributes(string $html, array &$issues): void
{
// Match start tags <tagname attributes...>
$pattern = '/<([a-zA-Z0-9:-]+)(\s+[^>]*)?>/s';
if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $index => $match) {
$fullTag = $match[0];
$offset = $match[1];
$tagName = strtolower($matches[1][$index][0]);
if (str_starts_with($fullTag, '<?') || str_starts_with($fullTag, '<!')) {
continue;
}
$attrString = $matches[2][$index][0] ?? '';
if (trim($attrString) === '') {
continue;
}
$line = $this->getLineFromOffset($html, $offset);
// A. Check for missing space between attributes (e.g. href="x"class="y")
if (preg_match('/["\']([a-zA-Z0-9_:-]+)=["\']/', $attrString, $missingSpaceMatch, PREG_OFFSET_CAPTURE)) {
$mOffset = $offset + (int)$missingSpaceMatch[0][1];
$mLine = $this->getLineFromOffset($html, $mOffset);
$issues[] = new SyntaxIssue(
code: 'SYNTAX_MISSING_ATTR_SPACE',
message: "Missing whitespace separator between attributes on <{$tagName}>.",
line: $mLine,
snippet: $fullTag,
fixSuggestion: 'Insert a space between adjacent attributes.',
severity: 'warning'
);
}
// B. Parse individual attributes to check for duplicates
$attrNames = [];
$attrPattern = '/([a-zA-Z0-9_:-]+)(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+)))?/s';
if (preg_match_all($attrPattern, $attrString, $attrMatches)) {
foreach ($attrMatches[1] as $aName) {
$normalizedName = strtolower(trim($aName));
if ($normalizedName === '' || $normalizedName === '/') {
continue;
}
if (isset($attrNames[$normalizedName])) {
$issues[] = new SyntaxIssue(
code: 'SYNTAX_DUPLICATE_ATTR',
message: "Duplicate attribute '{$normalizedName}' declared multiple times on <{$tagName}> element (HTML5 3.2.4 violation).",
line: $line,
snippet: $fullTag,
fixSuggestion: "Remove the duplicate '{$normalizedName}' attribute.",
severity: 'error'
);
} else {
$attrNames[$normalizedName] = true;
}
}
}
}
}
}
/**
* @param SyntaxIssue[] $issues
*/
private function checkInvalidSelfClosing(string $html, array &$issues): void
{
// Match <tagname ... /> where tagname is NOT in void elements list and not SVG/MathML
$pattern = '/<([a-zA-Z0-9-]+)(\s+[^>]*)?\/>/s';
if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) {
foreach ($matches[0] as $index => $match) {
$fullTag = $match[0];
$offset = $match[1];
$tagName = strtolower($matches[1][$index][0]);
if (in_array($tagName, self::$voidElements, true)) {
continue; // Allowed to self-close in HTML5/XHTML
}
// Skip SVG and MathML subtrees/elements
if (in_array($tagName, ['path', 'circle', 'rect', 'line', 'polygon', 'polyline', 'ellipse', 'use', 'stop'], true)) {
continue;
}
$line = $this->getLineFromOffset($html, $offset);
$issues[] = new SyntaxIssue(
code: 'SYNTAX_INVALID_SELF_CLOSING',
message: "Self-closing syntax (<{$tagName} />) is invalid on non-void HTML element <{$tagName}>. It causes DOM tree distortion.",
line: $line,
snippet: $fullTag,
fixSuggestion: "Replace with explicit opening and closing tags: <{$tagName}></{$tagName}>.",
severity: 'error'
);
}
}
}
/**
* @param SyntaxIssue[] $issues
*/
private function checkTagStructure(string $html, array &$issues): void
{
// Strip comments and script/style contents for structure check
$clean = preg_replace('/<!--.*?-->/s', '', $html) ?? $html;
$clean = preg_replace('/<script\b[^>]*>.*?<\/script>/si', '', $clean) ?? $clean;
$clean = preg_replace('/<style\b[^>]*>.*?<\/style>/si', '', $clean) ?? $clean;
$clean = preg_replace('/<\?php.*?\?>/si', '', $clean) ?? $clean;
// Find all tags in order: <tag> or </tag>
$tagPattern = '/<(\/)?([a-zA-Z0-9:-]+)(?:\s+[^>]*)?>/s';
if (preg_match_all($tagPattern, $clean, $matches, PREG_OFFSET_CAPTURE)) {
$stack = [];
$trackedTags = [
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'details', 'summary', 'dialog', 'main', 'nav', 'header', 'footer',
'section', 'article', 'aside', 'form', 'fieldset', 'div', 'span', 'p', 'a', 'button'
];
foreach ($matches[0] as $index => $match) {
$fullMatch = $match[0];
$offset = $match[1];
$isClosing = ($matches[1][$index][0] === '/');
$tagName = strtolower($matches[2][$index][0]);
if (in_array($tagName, self::$voidElements, true)) {
continue;
}
if (str_starts_with($fullMatch, '<?') || str_starts_with($fullMatch, '<!')) {
continue;
}
// Check self-closing syntax
if (str_ends_with(trim($fullMatch), '/>')) {
continue;
}
if (!in_array($tagName, $trackedTags, true)) {
continue;
}
if (!$isClosing) {
$stack[] = ['tag' => $tagName, 'offset' => $offset, 'line' => $this->getLineFromOffset($clean, $offset)];
} else {
if (empty($stack)) {
$line = $this->getLineFromOffset($clean, $offset);
$issues[] = new SyntaxIssue(
code: 'SYNTAX_UNOPENED_TAG',
message: "Closing tag </{$tagName}> has no matching opening tag.",
line: $line,
snippet: $fullMatch,
fixSuggestion: "Remove the stray </{$tagName}> closing tag.",
severity: 'error'
);
} else {
$last = end($stack);
if ($last['tag'] === $tagName) {
array_pop($stack);
} else {
// Find if $tagName exists deeper in the stack
$foundIndex = null;
for ($i = count($stack) - 1; $i >= 0; $i--) {
if ($stack[$i]['tag'] === $tagName) {
$foundIndex = $i;
break;
}
}
if ($foundIndex !== null) {
$unclosed = array_slice($stack, $foundIndex + 1);
$stack = array_slice($stack, 0, $foundIndex);
$line = $this->getLineFromOffset($clean, $offset);
foreach ($unclosed as $u) {
$issues[] = new SyntaxIssue(
code: 'SYNTAX_MISMATCHED_TAG',
message: "Mismatched closing tag </{$tagName}>; <{$u['tag']}> opened at line {$u['line']} was not closed properly.",
line: $line,
snippet: $fullMatch,
fixSuggestion: "Close <{$u['tag']}> before </{$tagName}>.",
severity: 'error'
);
}
} else {
$line = $this->getLineFromOffset($clean, $offset);
$issues[] = new SyntaxIssue(
code: 'SYNTAX_UNOPENED_TAG',
message: "Closing tag </{$tagName}> has no matching opening tag.",
line: $line,
snippet: $fullMatch,
fixSuggestion: "Remove the stray </{$tagName}> closing tag.",
severity: 'error'
);
}
}
}
}
}
// Check any unclosed structural tags left on stack
foreach ($stack as $unclosed) {
// Ignore optional closing tags like p or li if unclosed at document end
if (in_array($unclosed['tag'], ['p', 'li', 'dt', 'dd', 'span'], true)) {
continue;
}
$issues[] = new SyntaxIssue(
code: 'SYNTAX_UNCLOSED_TAG',
message: "Unclosed tag <{$unclosed['tag']}> opened at line {$unclosed['line']} was never closed.",
line: $unclosed['line'],
snippet: "<{$unclosed['tag']}>",
fixSuggestion: "Add matching closing tag </{$unclosed['tag']}>.",
severity: 'error'
);
}
}
}
/**
* @param SyntaxIssue[] $issues
*/
private function checkStructureTagMultiplicity(string $html, array &$issues): void
{
// 1. Multiple <body> tags
if (preg_match_all('/<body\b[^>]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) {
if (count($matches[0]) > 1) {
foreach (array_slice($matches[0], 1) as $match) {
$line = $this->getLineFromOffset($html, $match[1]);
$issues[] = new SyntaxIssue(
code: 'SYNTAX_MULTIPLE_BODY',
message: 'Document contains multiple <body> elements. Only one <body> is permitted per HTML document.',
line: $line,
snippet: $match[0],
fixSuggestion: 'Merge contents into a single <body> element.',
severity: 'error'
);
}
}
}
// 2. Multiple <head> tags
if (preg_match_all('/<head\b[^>]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) {
if (count($matches[0]) > 1) {
foreach (array_slice($matches[0], 1) as $match) {
$line = $this->getLineFromOffset($html, $match[1]);
$issues[] = new SyntaxIssue(
code: 'SYNTAX_MULTIPLE_HEAD',
message: 'Document contains multiple <head> elements. Only one <head> is permitted per HTML document.',
line: $line,
snippet: $match[0],
fixSuggestion: 'Merge head tags into a single <head> element.',
severity: 'error'
);
}
}
}
}
private function getLineFromOffset(string $content, int $offset): int
{
if ($offset <= 0) {
return 1;
}
$sub = substr($content, 0, $offset);
return substr_count($sub, "\n") + 1;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace YakNet\AccessibilityConsole\Syntax;
class SyntaxIssue
{
public function __construct(
public readonly string $code,
public readonly string $message,
public readonly int $line,
public readonly int $column = 1,
public readonly string $snippet = '',
public readonly ?string $fixSuggestion = null,
public readonly string $severity = 'error'
) {}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'code' => $this->code,
'message' => $this->message,
'line' => $this->line,
'column' => $this->column,
'snippet' => $this->snippet,
'fixSuggestion' => $this->fixSuggestion,
'severity' => $this->severity,
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Analytics;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Analytics\AccessibilityTelemetry;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AccessibilityTelemetryTest extends TestCase
{
private AccessibilityTelemetry $telemetry;
protected function setUp(): void
{
$this->telemetry = new AccessibilityTelemetry();
}
public function testComputesDefectDensityAndRatios(): void
{
$violations = [
new Violation(ruleId: 'R1', message: 'M1', severity: Severity::ERROR, standard: WCAGStandard::A),
new Violation(ruleId: 'R2', message: 'M2', severity: Severity::WARNING, standard: WCAGStandard::AA),
];
$metrics = $this->telemetry->computeMetrics($violations, 2000);
$this->assertSame(2, $metrics['total_violations']);
$this->assertSame(1, $metrics['error_count']);
$this->assertSame(1, $metrics['warning_count']);
$this->assertSame(1.0, $metrics['defect_density_per_kloc']); // 2 violations / 2 kloc = 1.0
$this->assertSame(50.0, $metrics['critical_blockers_ratio']); // 1 / 2 = 50%
}
}
@@ -0,0 +1,48 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Analytics;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Analytics\ComplianceMatrixGenerator;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ComplianceMatrixGeneratorTest extends TestCase
{
private ComplianceMatrixGenerator $generator;
protected function setUp(): void
{
$this->generator = new ComplianceMatrixGenerator();
}
public function testGeneratesMatrixSuccessfully(): void
{
$violations = [
new Violation(
ruleId: 'WCAG_1_1_1_ALT',
message: 'Image alt missing',
severity: Severity::ERROR,
standard: WCAGStandard::A
),
new Violation(
ruleId: 'WCAG_2_4_4_LINK_TEXT',
message: 'Link text empty',
severity: Severity::ERROR,
standard: WCAGStandard::A
),
];
$matrix = $this->generator->generate($violations, 127);
$this->assertArrayHasKey('overall_compliance_percentage', $matrix);
$this->assertArrayHasKey('principles', $matrix);
$this->assertArrayHasKey('levels', $matrix);
$this->assertSame(1, $matrix['principles']['Perceivable']['violations']);
$this->assertSame(1, $matrix['principles']['Operable']['violations']);
$this->assertSame(0, $matrix['principles']['Understandable']['violations']);
$this->assertSame('Pass', $matrix['principles']['Understandable']['status']);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Core;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Locator\PreciseElementLocator;
class PreciseElementLocatorTest extends TestCase
{
private PreciseElementLocator $locator;
protected function setUp(): void
{
$this->locator = new PreciseElementLocator();
}
public function testLocatesExactMatch(): void
{
$content = "<html>\n<body>\n<div class=\"container\">\n<img src=\"logo.png\">\n</div>\n</body>\n</html>";
$snippet = '<img src="logo.png">';
$loc = $this->locator->locate($content, $snippet);
$this->assertSame(4, $loc['line']);
}
public function testLocatesAttributeTokenMatch(): void
{
$content = "<div>\n<button id=\"save-btn\" class=\"btn btn-primary\">\nSave\n</button>\n</div>";
// Snippet with different whitespace/attr arrangement
$snippet = '<button class="btn btn-primary" id="save-btn">';
$loc = $this->locator->locate($content, $snippet);
$this->assertSame(2, $loc['line']);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Core;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\RuleRegistry;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class RuleRegistryTest extends TestCase
{
public function testValidateRuleSetHasZeroDuplicates(): void
{
$rules = RuleLevels::getRulesForLevel(9);
$result = RuleRegistry::validate($rules);
$this->assertTrue($result['valid'], 'Rule validation failed: ' . implode("\n", $result['errors']));
$this->assertEmpty($result['errors']);
$this->assertGreaterThanOrEqual(120, $result['count']);
}
public function testGetMetadataReturnsAccurateData(): void
{
$rules = RuleLevels::getRulesForLevel(1);
$firstRule = $rules[0];
$meta = RuleRegistry::getMetadata($firstRule);
$this->assertArrayHasKey('id', $meta);
$this->assertArrayHasKey('class', $meta);
$this->assertArrayHasKey('severity', $meta);
$this->assertArrayHasKey('standard', $meta);
$this->assertArrayHasKey('level', $meta);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Parser;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Parser\TemplatePreprocessor;
class TemplatePreprocessorTest extends TestCase
{
private TemplatePreprocessor $preprocessor;
protected function setUp(): void
{
$this->preprocessor = new TemplatePreprocessor();
}
public function testPreprocessesBladeTemplate(): void
{
$blade = "<div>\n@if(true)\n<h1>{{ title }}</h1>\n@endif\n</div>";
$processed = $this->preprocessor->preprocess($blade, 'views/user.blade.php');
$this->assertStringNotContainsString('@if', $processed);
$this->assertStringNotContainsString('{{', $processed);
$this->assertStringContainsString('<h1>', $processed);
// Verify line count is preserved
$this->assertSame(substr_count($blade, "\n"), substr_count($processed, "\n"));
}
public function testPreprocessesTwigTemplate(): void
{
$twig = "<div>\n{% if logged_in %}\n<p>{{ user.email }}</p>\n{% endif %}\n</div>";
$processed = $this->preprocessor->preprocess($twig, 'templates/user.twig');
$this->assertStringNotContainsString('{%', $processed);
$this->assertStringNotContainsString('{{', $processed);
$this->assertStringContainsString('<p>', $processed);
// Verify line count is preserved
$this->assertSame(substr_count($twig, "\n"), substr_count($processed, "\n"));
}
public function testPreprocessesPhpTemplate(): void
{
$php = "<div>\n<?php if (true): ?>\n<button><?= \$label ?></button>\n<?php endif; ?>\n</div>";
$processed = $this->preprocessor->preprocess($php, 'views/button.php');
$this->assertStringNotContainsString('<?php', $processed);
$this->assertStringNotContainsString('<?=', $processed);
$this->assertStringContainsString('<button>', $processed);
// Verify line count is preserved
$this->assertSame(substr_count($php, "\n"), substr_count($processed, "\n"));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AccesskeyDuplicate;
class AccesskeyDuplicateTest extends TestCase
{
public function testFlagsDuplicateAccesskeys(): void
{
$scanner = new Scanner();
$scanner->addRule(new AccesskeyDuplicate());
$violations = $scanner->scan('<div><a href="/" accesskey="h">Home</a><a href="/help" accesskey="h">Help</a></div>');
$this->assertCount(2, $violations);
$this->assertSame('WCAG_2_1_1_ACCESSKEY_DUPLICATE', $violations[0]->ruleId);
}
public function testPassesUniqueAccesskeys(): void
{
$scanner = new Scanner();
$scanner->addRule(new AccesskeyDuplicate());
$violations = $scanner->scan('<div><a href="/" accesskey="h">Home</a><a href="/help" accesskey="p">Help</a></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaBrailleEquivalent;
class AriaBrailleEquivalentTest extends TestCase
{
public function testFlagsBrailleLabelWithoutStandardName(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaBrailleEquivalent());
$violations = $scanner->scan('<div><button aria-braillelabel="btn"></button></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_BRAILLE', $violations[0]->ruleId);
}
public function testPassesBrailleLabelWithAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaBrailleEquivalent());
$violations = $scanner->scan('<div><button aria-braillelabel="btn" aria-label="Submit Form">Submit</button></div>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaCurrentValid;
class AriaCurrentValidTest extends TestCase
{
public function testFlagsInvalidAriaCurrent(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaCurrentValid());
$violations = $scanner->scan('<div><a href="/home" aria-current="active">Home</a></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_CURRENT', $violations[0]->ruleId);
}
public function testPassesValidAriaCurrent(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaCurrentValid());
$violations = $scanner->scan('<div><a href="/home" aria-current="page">Home</a></div>');
$this->assertCount(0, $violations);
}
public function testPassesBooleanAriaCurrent(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaCurrentValid());
$violations = $scanner->scan('<div><a href="/step2" aria-current="true">Step 2</a></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaDisabledFocusable;
class AriaDisabledFocusableTest extends TestCase
{
public function testFlagsDisabledWithPositiveTabindex(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaDisabledFocusable());
$violations = $scanner->scan('<div><button disabled tabindex="0">Disabled Button</button></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_DISABLED', $violations[0]->ruleId);
}
public function testPassesStandardDisabled(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaDisabledFocusable());
$violations = $scanner->scan('<div><button disabled>Disabled Button</button></div>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaErrorMessage;
class AriaErrorMessageTest extends TestCase
{
public function testFlagsMissingTargetElement(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaErrorMessage());
$violations = $scanner->scan('<div><input id="email" aria-invalid="true" aria-errormessage="nonexistent_err"></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_3_3_1_ARIA_ERRORMESSAGE', $violations[0]->ruleId);
}
public function testFlagsMissingAriaInvalid(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaErrorMessage());
$violations = $scanner->scan('<div><input id="email" aria-errormessage="email_err"><span id="email_err">Error</span></div>');
$this->assertCount(1, $violations);
}
public function testPassesValidErrorMessageSetup(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaErrorMessage());
$violations = $scanner->scan('<div><input id="email" aria-invalid="true" aria-errormessage="email_err"><span id="email_err">Invalid Email Address</span></div>');
$this->assertCount(0, $violations);
}
}
@@ -0,0 +1,33 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaLabelMisuseOnGeneric;
class AriaLabelMisuseOnGenericTest extends TestCase
{
public function testFlagsGenericDivWithAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaLabelMisuseOnGeneric());
$html = '<div><div aria-label="Card container">Content</div></div>';
$violations = $scanner->scan($html);
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_LABEL_GENERIC', $violations[0]->ruleId);
}
public function testPassesDivWithRoleAndAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaLabelMisuseOnGeneric());
$html = '<div><div role="region" aria-label="Card container">Content</div></div>';
$violations = $scanner->scan($html);
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaProhibitedAttr;
class AriaProhibitedAttrTest extends TestCase
{
public function testFlagsAriaLabelOnPresentationRole(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaProhibitedAttr());
$violations = $scanner->scan('<div><div role="presentation" aria-label="Decorative block">Content</div></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_PROHIBITED_ATTR', $violations[0]->ruleId);
}
public function testFlagsAriaLabelledbyOnNoneRole(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaProhibitedAttr());
$violations = $scanner->scan('<div><div role="none" aria-labelledby="some_id">Content</div></div>');
$this->assertCount(1, $violations);
}
public function testPassesValidRolesWithAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaProhibitedAttr());
$violations = $scanner->scan('<div><div role="region" aria-label="Main Section">Content</div></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaRedundantRole;
class AriaRedundantRoleTest extends TestCase
{
public function testFlagsRedundantRoleOnNav(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaRedundantRole());
$violations = $scanner->scan('<div><nav role="navigation"><a href="/">Home</a></nav></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_REDUNDANT_ROLE', $violations[0]->ruleId);
}
public function testPassesNavWithoutRedundantRole(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaRedundantRole());
$violations = $scanner->scan('<div><nav><a href="/">Home</a></nav></div>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\AriaRoledescription;
class AriaRoledescriptionTest extends TestCase
{
public function testFlagsEmptyRoledescription(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaRoledescription());
$violations = $scanner->scan('<div><div role="region" aria-roledescription="">Content</div></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_ARIA_ROLEDESCRIPTION', $violations[0]->ruleId);
}
public function testFlagsRoledescriptionOnPresentation(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaRoledescription());
$violations = $scanner->scan('<div><div role="presentation" aria-roledescription="slide">Content</div></div>');
$this->assertCount(1, $violations);
}
public function testPassesValidRoledescription(): void
{
$scanner = new Scanner();
$scanner->addRule(new AriaRoledescription());
$violations = $scanner->scan('<div><div role="region" aria-roledescription="slide" aria-label="Slide 1">Slide Content</div></div>');
$this->assertCount(0, $violations);
}
}
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\ButtonNestedInteractive;
class ButtonNestedInteractiveTest extends TestCase
{
public function testFlagsNestedAnchorInsideButton(): void
{
$scanner = new Scanner();
$scanner->addRule(new ButtonNestedInteractive());
$violations = $scanner->scan('<div><button><a href="/cart">Cart</a></button></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_BUTTON_NESTED_INTERACTIVE', $violations[0]->ruleId);
}
public function testFlagsNestedButtonInsideButton(): void
{
$scanner = new Scanner();
$scanner->addRule(new ButtonNestedInteractive());
$violations = $scanner->scan('<div><button><button>Sub action</button></button></div>');
$this->assertCount(1, $violations);
}
public function testPassesStandardButton(): void
{
$scanner = new Scanner();
$scanner->addRule(new ButtonNestedInteractive());
$violations = $scanner->scan('<div><button><span>Standard Button</span></button></div>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\DetailsMissingSummary;
class DetailsMissingSummaryTest extends TestCase
{
public function testFlagsDetailsWithoutSummary(): void
{
$scanner = new Scanner();
$scanner->addRule(new DetailsMissingSummary());
$violations = $scanner->scan('<details><p>Some hidden details here</p></details>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_DETAILS_SUMMARY', $violations[0]->ruleId);
}
public function testFlagsDetailsWithEmptySummary(): void
{
$scanner = new Scanner();
$scanner->addRule(new DetailsMissingSummary());
$violations = $scanner->scan('<details><summary></summary><p>Details</p></details>');
$this->assertCount(1, $violations);
}
public function testPassesDetailsWithValidSummary(): void
{
$scanner = new Scanner();
$scanner->addRule(new DetailsMissingSummary());
$violations = $scanner->scan('<details><summary>More info</summary><p>Details content</p></details>');
$this->assertCount(0, $violations);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\DraggingMovements;
class DraggingMovementsTest extends TestCase
{
public function testFlagsDraggableWithoutAlternativeControls(): void
{
$scanner = new Scanner();
$scanner->addRule(new DraggingMovements());
$html = '<div><div draggable="true" class="task-card">Task 1</div></div>';
$violations = $scanner->scan($html);
$this->assertCount(1, $violations);
$this->assertSame('WCAG_2_5_7_DRAGGING_MOVEMENTS', $violations[0]->ruleId);
}
public function testPassesDraggableWithKeyboardShortcutsOrButtons(): void
{
$scanner = new Scanner();
$scanner->addRule(new DraggingMovements());
$html = '<div><div draggable="true" aria-keyshortcuts="Control+Up Control+Down" class="task-card">Task 1</div></div>';
$violations = $scanner->scan($html);
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\DuplicateId;
class DuplicateIdTest extends TestCase
{
public function testFlagsDuplicateIds(): void
{
$scanner = new Scanner();
$scanner->addRule(new DuplicateId());
$violations = $scanner->scan('<div><div id="card">A</div><div id="card">B</div></div>');
$this->assertCount(2, $violations);
$this->assertSame('WCAG_4_1_2_DUPLICATE_ID', $violations[0]->ruleId);
}
public function testPassesUniqueIds(): void
{
$scanner = new Scanner();
$scanner->addRule(new DuplicateId());
$violations = $scanner->scan('<div><div id="card1">A</div><div id="card2">B</div></div>');
$this->assertCount(0, $violations);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\EmptyHeading;
class EmptyHeadingTest extends TestCase
{
public function testFlagsEmptyHeading(): void
{
$scanner = new Scanner();
$scanner->addRule(new EmptyHeading());
$violations = $scanner->scan('<div><h1></h1></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_EMPTY_HEADING', $violations[0]->ruleId);
}
public function testFlagsEmptyAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new EmptyHeading());
$violations = $scanner->scan('<div><h2 aria-label=" "></h2></div>');
$this->assertCount(1, $violations);
}
public function testPassesHeadingWithImageAlt(): void
{
$scanner = new Scanner();
$scanner->addRule(new EmptyHeading());
$violations = $scanner->scan('<div><h1><img src="logo.png" alt="Company Name"></h1></div>');
$this->assertCount(0, $violations);
}
public function testPassesHeadingWithSvgTitle(): void
{
$scanner = new Scanner();
$scanner->addRule(new EmptyHeading());
$violations = $scanner->scan('<div><h1><svg><title>Dashboard</title></svg></h1></div>');
$this->assertCount(0, $violations);
}
public function testPassesHeadingWithAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new EmptyHeading());
$violations = $scanner->scan('<div><h1 aria-label="Accessible Title"></h1></div>');
$this->assertCount(0, $violations);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\FocusNotObscured;
class FocusNotObscuredTest extends TestCase
{
public function testFlagsFixedFullWidthTopBarWithoutScrollPadding(): void
{
$scanner = new Scanner();
$scanner->addRule(new FocusNotObscured());
$html = '<div><header style="position: fixed; top: 0; left: 0; width: 100%; height: 60px;">Navbar</header></div>';
$violations = $scanner->scan($html);
$this->assertCount(1, $violations);
$this->assertSame('WCAG_2_4_11_FOCUS_NOT_OBSCURED', $violations[0]->ruleId);
}
public function testPassesStaticElements(): void
{
$scanner = new Scanner();
$scanner->addRule(new FocusNotObscured());
$html = '<div><header style="width: 100%; height: 60px;">Navbar</header></div>';
$violations = $scanner->scan($html);
$this->assertCount(0, $violations);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\FormLabel;
class FormLabelTest extends TestCase
{
public function testFlagsInputWithoutLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new FormLabel());
$violations = $scanner->scan('<div><input type="text" name="username"></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_LABEL', $violations[0]->ruleId);
}
public function testFlagsEmptyAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new FormLabel());
$violations = $scanner->scan('<div><input type="text" name="username" aria-label=" "></div>');
$this->assertCount(1, $violations);
}
public function testPassesInputWithAssociatedLabelFor(): void
{
$scanner = new Scanner();
$scanner->addRule(new FormLabel());
$violations = $scanner->scan('<div><label for="usr">Username</label><input type="text" id="usr" name="username"></div>');
$this->assertCount(0, $violations);
}
public function testPassesInputWithAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new FormLabel());
$violations = $scanner->scan('<div><input type="text" name="username" aria-label="Username"></div>');
$this->assertCount(0, $violations);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\HeadingOrder;
class HeadingOrderTest extends TestCase
{
public function testFlagsSkippedHeadingLevel(): void
{
$scanner = new Scanner();
$scanner->addRule(new HeadingOrder());
$violations = $scanner->scan('<div><h1>Main Heading</h1><h3>Skipped Subheading</h3></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_HEADING', $violations[0]->ruleId);
}
public function testPassesSequentialHeadings(): void
{
$scanner = new Scanner();
$scanner->addRule(new HeadingOrder());
$violations = $scanner->scan('<div><h1>Main Heading</h1><h2>Section</h2><h3>Sub-section</h3></div>');
$this->assertCount(0, $violations);
}
public function testIsStatelessAcrossMultipleScans(): void
{
$scanner = new Scanner();
$scanner->addRule(new HeadingOrder());
// First scan ends with H3
$scanner->scan('<div><h1>H1</h1><h2>H2</h2><h3>H3</h3></div>');
// Second scan starts with H1, should not flag jump from previous scan's H3
$violations = $scanner->scan('<div><h1>Fresh Document H1</h1><h2>H2</h2></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\HtmlDirValid;
class HtmlDirValidTest extends TestCase
{
public function testFlagsInvalidDir(): void
{
$scanner = new Scanner();
$scanner->addRule(new HtmlDirValid());
$violations = $scanner->scan('<div><p dir="left">Paragraph</p></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_2_HTML_DIR', $violations[0]->ruleId);
}
public function testPassesValidDir(): void
{
$scanner = new Scanner();
$scanner->addRule(new HtmlDirValid());
$violations = $scanner->scan('<div><p dir="rtl">Arabic text</p><p dir="ltr">English</p><p dir="auto">Auto</p></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\HtmlSyntaxValid;
class HtmlSyntaxValidTest extends TestCase
{
public function testFlagsDuplicateAttributesInHtml(): void
{
$scanner = new Scanner();
$scanner->addRule(new HtmlSyntaxValid());
$violations = $scanner->scan('<div><input id="email" type="email" id="email2"></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_1_HTML_PARSING_SYNTAX', $violations[0]->ruleId);
}
public function testPassesValidHtml(): void
{
$scanner = new Scanner();
$scanner->addRule(new HtmlSyntaxValid());
$violations = $scanner->scan('<div><input id="email" type="email" name="user_email"></div>');
$this->assertCount(0, $violations);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\IframeTitle;
class IframeTitleTest extends TestCase
{
public function testFlagsIframeWithoutTitle(): void
{
$scanner = new Scanner();
$scanner->addRule(new IframeTitle());
$violations = $scanner->scan('<div><iframe src="map.html"></iframe></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_IFRAME_TITLE', $violations[0]->ruleId);
}
public function testPassesIframeWithTitle(): void
{
$scanner = new Scanner();
$scanner->addRule(new IframeTitle());
$violations = $scanner->scan('<div><iframe src="map.html" title="Office Location Map"></iframe></div>');
$this->assertCount(0, $violations);
}
public function testPassesIframeWithAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new IframeTitle());
$violations = $scanner->scan('<div><iframe src="map.html" aria-label="Interactive Map"></iframe></div>');
$this->assertCount(0, $violations);
}
public function testPassesAriaHiddenIframe(): void
{
$scanner = new Scanner();
$scanner->addRule(new IframeTitle());
$violations = $scanner->scan('<div><iframe src="tracker.html" aria-hidden="true"></iframe></div>');
$this->assertCount(0, $violations);
}
}
@@ -0,0 +1,33 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\InputAutocompleteAppropriate;
class InputAutocompleteAppropriateTest extends TestCase
{
public function testFlagsInappropriateAutocompleteType(): void
{
$scanner = new Scanner();
$scanner->addRule(new InputAutocompleteAppropriate());
$html = '<form><input type="number" autocomplete="email"></form>';
$violations = $scanner->scan($html);
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_5_AUTOCOMPLETE_APPROPRIATE', $violations[0]->ruleId);
}
public function testPassesAppropriateAutocompleteType(): void
{
$scanner = new Scanner();
$scanner->addRule(new InputAutocompleteAppropriate());
$html = '<form><input type="email" autocomplete="email"></form>';
$violations = $scanner->scan($html);
$this->assertCount(0, $violations);
}
}
+15 -5
View File
@@ -8,22 +8,32 @@ use YakNet\AccessibilityConsole\Rules\InputMissingAutocomplete;
class InputMissingAutocompleteTest extends TestCase
{
public function testFlagsPersonalDataInputWithoutAutocomplete(): void
public function testFlagsPasswordWithAutocompleteOff(): void
{
$scanner = new Scanner();
$scanner->addRule(new InputMissingAutocomplete());
$violations = $scanner->scan('<form><input type="email" name="user_email"></form>');
$violations = $scanner->scan('<form><input type="password" name="pwd" autocomplete="off"></form>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_5_INPUT_AUTOCOMPLETE', $violations[0]->ruleId);
$this->assertSame('WCAG_3_3_8_ACCESSIBLE_AUTH', $violations[0]->ruleId);
}
public function testPassesPersonalDataInputWithAutocomplete(): void
public function testFlagsPasteBlockingOnInput(): void
{
$scanner = new Scanner();
$scanner->addRule(new InputMissingAutocomplete());
$violations = $scanner->scan('<form><input type="email" name="user_email" autocomplete="email"></form>');
$violations = $scanner->scan('<form><input type="text" name="pin" onpaste="return false;"></form>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_3_3_8_ACCESSIBLE_AUTH', $violations[0]->ruleId);
}
public function testPassesAccessiblePasswordInput(): void
{
$scanner = new Scanner();
$scanner->addRule(new InputMissingAutocomplete());
$violations = $scanner->scan('<form><input type="password" name="pwd" autocomplete="current-password"></form>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\MediaControls;
class MediaControlsTest extends TestCase
{
public function testFlagsAudioWithoutControls(): void
{
$scanner = new Scanner();
$scanner->addRule(new MediaControls());
$violations = $scanner->scan('<div><audio src="audio.mp3"></audio></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_4_2_MEDIA_CONTROLS', $violations[0]->ruleId);
}
public function testPassesAudioWithControls(): void
{
$scanner = new Scanner();
$scanner->addRule(new MediaControls());
$violations = $scanner->scan('<div><audio src="audio.mp3" controls></audio></div>');
$this->assertCount(0, $violations);
}
public function testPassesBackgroundMutedAutoplayVideo(): void
{
$scanner = new Scanner();
$scanner->addRule(new MediaControls());
$violations = $scanner->scan('<div><video src="bg.mp4" muted autoplay loop></video></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\MetaCharsetPresent;
class MetaCharsetPresentTest extends TestCase
{
public function testFlagsMissingCharsetInHead(): void
{
$scanner = new Scanner();
$scanner->addRule(new MetaCharsetPresent());
$violations = $scanner->scan('<!DOCTYPE html><html><head><title>Test Page</title></head><body><h1>Hello</h1></body></html>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_3_1_1_META_CHARSET', $violations[0]->ruleId);
}
public function testPassesWhenMetaCharsetPresent(): void
{
$scanner = new Scanner();
$scanner->addRule(new MetaCharsetPresent());
$violations = $scanner->scan('<!DOCTYPE html><html><head><meta charset="utf-8"><title>Test Page</title></head><body><h1>Hello</h1></body></html>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\MeterAccessibility;
class MeterAccessibilityTest extends TestCase
{
public function testFlagsMeterWithoutAccessibleName(): void
{
$scanner = new Scanner();
$scanner->addRule(new MeterAccessibility());
$violations = $scanner->scan('<div><meter value="0.6"></meter></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_METER_ACCESSIBLE', $violations[0]->ruleId);
}
public function testPassesMeterWithAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new MeterAccessibility());
$violations = $scanner->scan('<div><meter value="0.6" aria-label="Disk Usage"></meter></div>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\NestedInteractive;
class NestedInteractiveTest extends TestCase
{
public function testFlagsNestedLinkInsideLink(): void
{
$scanner = new Scanner();
$scanner->addRule(new NestedInteractive());
$violations = $scanner->scan('<div><a href="/outer">Outer <a href="/inner">Inner</a></a></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_4_1_2_NESTED_INTERACTIVE', $violations[0]->ruleId);
}
public function testFlagsButtonInsideLink(): void
{
$scanner = new Scanner();
$scanner->addRule(new NestedInteractive());
$violations = $scanner->scan('<div><a href="/outer"><button>Button</button></a></div>');
$this->assertCount(1, $violations);
}
public function testPassesNonNestedInteractives(): void
{
$scanner = new Scanner();
$scanner->addRule(new NestedInteractive());
$violations = $scanner->scan('<div><a href="/link">Link</a><button>Button</button></div>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\RoleImgAlt;
class RoleImgAltTest extends TestCase
{
public function testFlagsRoleImgWithoutAccessibleName(): void
{
$scanner = new Scanner();
$scanner->addRule(new RoleImgAlt());
$violations = $scanner->scan('<div><span role="img" class="icon-star"></span></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_1_1_ROLE_IMG', $violations[0]->ruleId);
}
public function testPassesRoleImgWithAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new RoleImgAlt());
$violations = $scanner->scan('<div><span role="img" class="icon-star" aria-label="Five Star Rating"></span></div>');
$this->assertCount(0, $violations);
}
public function testPassesRoleImgWithAriaHidden(): void
{
$scanner = new Scanner();
$scanner->addRule(new RoleImgAlt());
$violations = $scanner->scan('<div><span role="img" class="icon-star" aria-hidden="true"></span></div>');
$this->assertCount(0, $violations);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\SelectValidChildren;
class SelectValidChildrenTest extends TestCase
{
public function testFlagsInvalidDivInSelect(): void
{
$scanner = new Scanner();
$scanner->addRule(new SelectValidChildren());
$violations = $scanner->scan('<select><div>Option 1</div><option value="1">Option 1</option></select>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_SELECT_CHILDREN', $violations[0]->ruleId);
}
public function testPassesValidSelectOptionsAndOptgroups(): void
{
$scanner = new Scanner();
$scanner->addRule(new SelectValidChildren());
$violations = $scanner->scan('<select><optgroup label="Group"><option value="1">Opt 1</option></optgroup></select>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\TableHeaderNotEmpty;
class TableHeaderNotEmptyTest extends TestCase
{
public function testFlagsEmptyTableHeader(): void
{
$scanner = new Scanner();
$scanner->addRule(new TableHeaderNotEmpty());
$violations = $scanner->scan('<table><tr><th></th><td>Value</td></tr></table>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_TH_NOT_EMPTY', $violations[0]->ruleId);
}
public function testPassesTableHeaderWithText(): void
{
$scanner = new Scanner();
$scanner->addRule(new TableHeaderNotEmpty());
$violations = $scanner->scan('<table><tr><th>Name</th><td>John</td></tr></table>');
$this->assertCount(0, $violations);
}
public function testPassesTableHeaderWithAriaLabel(): void
{
$scanner = new Scanner();
$scanner->addRule(new TableHeaderNotEmpty());
$violations = $scanner->scan('<table><tr><th aria-label="Action buttons"></th><td>Edit</td></tr></table>');
$this->assertCount(0, $violations);
}
}
@@ -0,0 +1,29 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\TableHeadersAttributeValid;
class TableHeadersAttributeValidTest extends TestCase
{
public function testFlagsInvalidHeaderId(): void
{
$scanner = new Scanner();
$scanner->addRule(new TableHeadersAttributeValid());
$violations = $scanner->scan('<table><tr><th id="h1">Header 1</th></tr><tr><td headers="nonexistent_h">Cell</td></tr></table>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_3_1_TABLE_HEADERS_ATTR', $violations[0]->ruleId);
}
public function testPassesValidHeaderId(): void
{
$scanner = new Scanner();
$scanner->addRule(new TableHeadersAttributeValid());
$violations = $scanner->scan('<table><tr><th id="h1">Header 1</th></tr><tr><td headers="h1">Cell</td></tr></table>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\TargetSizeMinimum;
class TargetSizeMinimumTest extends TestCase
{
public function testFlagsTooSmallTarget(): void
{
$scanner = new Scanner();
$scanner->addRule(new TargetSizeMinimum());
$violations = $scanner->scan('<div><button style="width: 16px; height: 16px;">X</button></div>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_2_5_8_TARGET_SIZE', $violations[0]->ruleId);
}
public function testPassesAdequateTargetSize(): void
{
$scanner = new Scanner();
$scanner->addRule(new TargetSizeMinimum());
$violations = $scanner->scan('<div><button style="width: 24px; height: 24px;">X</button></div>');
$this->assertCount(0, $violations);
}
public function testPassesElementsWithoutInlineSize(): void
{
$scanner = new Scanner();
$scanner->addRule(new TargetSizeMinimum());
$violations = $scanner->scan('<div><a href="/link">Normal Link</a></div>');
$this->assertCount(0, $violations);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Rules;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Rules\TrackValidation;
class TrackValidationTest extends TestCase
{
public function testFlagsInvalidTrackKind(): void
{
$scanner = new Scanner();
$scanner->addRule(new TrackValidation());
$violations = $scanner->scan('<video src="v.mp4"><track kind="invalid_kind" src="track.vtt"></video>');
$this->assertCount(1, $violations);
$this->assertSame('WCAG_1_2_2_TRACK_VALIDATION', $violations[0]->ruleId);
}
public function testFlagsMissingSrclangOnCaptions(): void
{
$scanner = new Scanner();
$scanner->addRule(new TrackValidation());
$violations = $scanner->scan('<video src="v.mp4"><track kind="captions" src="track.vtt" label="English"></video>');
$this->assertCount(1, $violations);
}
public function testPassesValidTrack(): void
{
$scanner = new Scanner();
$scanner->addRule(new TrackValidation());
$violations = $scanner->scan('<video src="v.mp4"><track kind="captions" src="track.vtt" srclang="en" label="English Captions"></video>');
$this->assertCount(0, $violations);
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace YakNet\AccessibilityConsole\Tests\Syntax;
use PHPUnit\Framework\TestCase;
use YakNet\AccessibilityConsole\Syntax\HtmlSyntaxLinter;
class HtmlSyntaxLinterTest extends TestCase
{
private HtmlSyntaxLinter $linter;
protected function setUp(): void
{
$this->linter = new HtmlSyntaxLinter();
}
public function testDetectsDuplicateAttributes(): void
{
$html = '<div><input type="text" id="username" class="form-control" id="duplicate_user"></div>';
$issues = $this->linter->lint($html);
$this->assertNotEmpty($issues);
$duplicateIssues = array_filter($issues, fn($i) => $i->code === 'SYNTAX_DUPLICATE_ATTR');
$this->assertCount(1, $duplicateIssues);
}
public function testDetectsInvalidSelfClosingOnNonVoidElement(): void
{
$html = '<div><div class="card" /><p>Text</p></div>';
$issues = $this->linter->lint($html);
$this->assertNotEmpty($issues);
$selfCloseIssues = array_filter($issues, fn($i) => $i->code === 'SYNTAX_INVALID_SELF_CLOSING');
$this->assertCount(1, $selfCloseIssues);
}
public function testDetectsUnclosedComments(): void
{
$html = '<div><!-- unclosed comment <p>Text</p></div>';
$issues = $this->linter->lint($html);
$this->assertNotEmpty($issues);
$commentIssues = array_filter($issues, fn($i) => $i->code === 'SYNTAX_UNCLOSED_COMMENT');
$this->assertCount(1, $commentIssues);
}
public function testDetectsMismatchedTags(): void
{
$html = '<main><section><p>Text</section></main>';
$issues = $this->linter->lint($html);
$this->assertNotEmpty($issues);
$mismatchIssues = array_filter($issues, fn($i) => in_array($i->code, ['SYNTAX_MISMATCHED_TAG', 'SYNTAX_UNCLOSED_TAG']));
$this->assertNotEmpty($mismatchIssues);
}
public function testPassesValidHtml5(): void
{
$html = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><main><h1>Hello</h1><p>World</p></main></body></html>';
$issues = $this->linter->lint($html);
$this->assertEmpty($issues);
}
}