release: v3.2.0 - Enterprise Edition with Live Web Dashboard, PDF/Excel Exporters, Framework Stubs & 110 WCAG Rules

This commit is contained in:
Enes Yakıştır
2026-07-31 00:13:31 +03:00
parent 58b1618d96
commit 73fbf485d2
36 changed files with 1420 additions and 11 deletions
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace YakNet\AccessibilityConsole\Audit;
use YakNet\AccessibilityConsole\Core\Scanner;
class SiteCrawler
{
private Scanner $scanner;
public function __construct(?Scanner $scanner = null)
{
$this->scanner = $scanner ?? new Scanner();
}
/**
* Crawl and scan multiple pages of a website up to a maximum page limit.
*
* @param string $baseUrl Base URL to start crawling
* @param int $maxPages Maximum number of pages to audit
* @return array<string, mixed> Aggregated site-wide audit results
*/
public function crawlAndAudit(string $baseUrl, int $maxPages = 5): array
{
/** @var array<string, bool> $visited */
$visited = [];
$queue = [$baseUrl];
/** @var array<string, mixed> $results */
$results = [];
while (!empty($queue) && count($visited) < $maxPages) {
$url = array_shift($queue);
if (empty($url) || isset($visited[$url])) {
continue;
}
$visited[$url] = true;
try {
$html = @file_get_contents($url);
$violations = $html !== false ? $this->scanner->scan($html) : [];
$score = max(0, 100 - count($violations) * 3);
$results[$url] = [
'score' => $score,
'violations_count' => count($violations),
'violations' => $violations,
];
} catch (\Throwable $e) {
$results[$url] = ['error' => $e->getMessage(), 'score' => 0];
}
}
$scores = [];
$totalViolations = 0;
foreach ($results as $res) {
if (is_array($res)) {
if (isset($res['score']) && is_numeric($res['score'])) {
$scores[] = intval($res['score']);
}
if (isset($res['violations_count']) && is_numeric($res['violations_count'])) {
$totalViolations += intval($res['violations_count']);
}
}
}
$avgScore = count($scores) > 0 ? (int)round(array_sum($scores) / count($scores)) : 0;
return [
'base_url' => $baseUrl,
'pages_audited' => count($visited),
'average_score' => $avgScore,
'total_violations' => $totalViolations,
'page_results' => $results,
];
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace YakNet\AccessibilityConsole\AutoFix;
class HtmlRepairEngine
{
/**
* Instantly auto-repair common WCAG accessibility issues in raw HTML strings without network calls.
*
* @param string $html
* @return array{repaired_html: string, fixes_applied: array<int, string>}
*/
public static function autoRepair(string $html): array
{
$fixes = [];
$repaired = $html;
// 1. Ensure <html lang="..."> attribute
if (preg_match('/<html\b(?![^>]*\blang=)[^>]*>/i', $repaired)) {
$repaired = preg_replace('/<html\b([^>]*)>/i', '<html lang="tr"$1>', $repaired) ?? $repaired;
$fixes[] = 'Added missing lang="tr" attribute to <html> tag.';
}
// 2. Add aria-hidden="true" to decorative <svg> tags missing aria-hidden
$svgBefore = $repaired;
$repaired = preg_replace_callback('/<svg\b(?![^>]*\baria-hidden=)[^>]*>/i', function($matches) {
return str_replace('<svg', '<svg aria-hidden="true"', $matches[0]);
}, $repaired) ?? $repaired;
if ($repaired !== $svgBefore) {
$fixes[] = 'Added aria-hidden="true" to decorative <svg> icons.';
}
// 3. Add alt="" to <img> tags missing alt attribute
$imgBefore = $repaired;
$repaired = preg_replace_callback('/<img\b(?![^>]*\balt=)[^>]*>/i', function($matches) {
return str_replace('<img', '<img alt=""', $matches[0]);
}, $repaired) ?? $repaired;
if ($repaired !== $imgBefore) {
$fixes[] = 'Added empty alt="" attribute to decorative <img> elements.';
}
return [
'repaired_html' => $repaired,
'fixes_applied' => $fixes,
];
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace YakNet\AccessibilityConsole\Benchmarks;
use YakNet\AccessibilityConsole\Core\Scanner;
class BenchmarkEngine
{
private Scanner $scanner;
public function __construct(?Scanner $scanner = null)
{
$this->scanner = $scanner ?? new Scanner();
}
/**
* Compare accessibility health scores across multiple target URLs.
*
* @param array<int, string> $urls List of URLs to benchmark
* @return array<string, mixed> Side-by-side benchmark comparison matrix
*/
public function compare(array $urls): array
{
$matrix = [];
foreach ($urls as $url) {
try {
$html = @file_get_contents($url);
$violations = $html !== false ? $this->scanner->scan($html) : [];
$score = max(0, 100 - count($violations) * 3);
$violationsCount = count($violations);
$matrix[$url] = [
'score' => $score,
'violations_count' => $violationsCount,
'status' => ($score >= 80) ? 'PASS' : 'FAIL',
];
} catch (\Throwable $e) {
$matrix[$url] = ['score' => 0, 'violations_count' => -1, 'status' => 'ERROR', 'error' => $e->getMessage()];
}
}
// Rank by score descending
uasort($matrix, function (array $a, array $b) {
return intval($b['score']) <=> intval($a['score']);
});
return [
'benchmark_date' => date('Y-m-d H:i:s'),
'targets_count' => count($urls),
'rankings' => $matrix,
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Color\Contrast;
use Spatie\Color\Rgb;
class ContrastRatioCalculator
{
/**
* Calculate WCAG 2.1 relative contrast ratio between foreground and background colors.
*/
public static function calculate(Rgb $fg, Rgb $bg): float
{
$l1 = self::getRelativeLuminance($fg);
$l2 = self::getRelativeLuminance($bg);
$lighter = max($l1, $l2);
$darker = min($l1, $l2);
return ($lighter + 0.05) / ($darker + 0.05);
}
public static function getRelativeLuminance(Rgb $color): float
{
$r = $color->red() / 255.0;
$g = $color->green() / 255.0;
$b = $color->blue() / 255.0;
$r = ($r <= 0.03928) ? $r / 12.92 : pow(($r + 0.055) / 1.055, 2.4);
$g = ($g <= 0.03928) ? $g / 12.92 : pow(($g + 0.055) / 1.055, 2.4);
$b = ($b <= 0.03928) ? $b / 12.92 : pow(($b + 0.055) / 1.055, 2.4);
return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b;
}
}
@@ -0,0 +1,28 @@
<?php
namespace YakNet\AccessibilityConsole\Color\Simulators;
use Spatie\Color\Rgb;
class DeuteranopiaSimulator
{
/**
* Simulate Deuteranopia (green-blindness) vision deficiency for a given RGB color.
*/
public static function simulate(Rgb $color): Rgb
{
$r = $color->red();
$g = $color->green();
$b = $color->blue();
$simR = (int)round(0.625 * $r + 0.375 * $g);
$simG = (int)round(0.700 * $r + 0.300 * $g);
$simB = (int)round(0.000 * $r + 0.300 * $g + 0.700 * $b);
return new Rgb(
min(255, max(0, $simR)),
min(255, max(0, $simG)),
min(255, max(0, $simB))
);
}
}
@@ -0,0 +1,28 @@
<?php
namespace YakNet\AccessibilityConsole\Color\Simulators;
use Spatie\Color\Rgb;
class ProtanopiaSimulator
{
/**
* Simulate Protanopia (red-blindness) vision deficiency for a given RGB color.
*/
public static function simulate(Rgb $color): Rgb
{
$r = $color->red();
$g = $color->green();
$b = $color->blue();
$simR = (int)round(0.56667 * $r + 0.43333 * $g);
$simG = (int)round(0.55833 * $r + 0.44167 * $g);
$simB = (int)round(0.00000 * $r + 0.24167 * $g + 0.75833 * $b);
return new Rgb(
min(255, max(0, $simR)),
min(255, max(0, $simG)),
min(255, max(0, $simB))
);
}
}
@@ -0,0 +1,28 @@
<?php
namespace YakNet\AccessibilityConsole\Color\Simulators;
use Spatie\Color\Rgb;
class TritanopiaSimulator
{
/**
* Simulate Tritanopia (blue-blindness) vision deficiency for a given RGB color.
*/
public static function simulate(Rgb $color): Rgb
{
$r = $color->red();
$g = $color->green();
$b = $color->blue();
$simR = (int)round(0.95000 * $r + 0.05000 * $g);
$simG = (int)round(0.00000 * $r + 0.43333 * $g + 0.56667 * $b);
$simB = (int)round(0.00000 * $r + 0.47500 * $g + 0.52500 * $b);
return new Rgb(
min(255, max(0, $simR)),
min(255, max(0, $simG)),
min(255, max(0, $simB))
);
}
}
@@ -0,0 +1,32 @@
<?php
namespace YakNet\AccessibilityConsole\Integrations;
class GitHubActionsReporter
{
/**
* Format WCAG violations as GitHub Actions Workflow Command Annotations.
*
* @param array<string, mixed> $scanResult
* @param string $fileName
* @return string Formatted annotations string
*/
public static function formatAnnotations(array $scanResult, string $fileName = 'index.html'): string
{
/** @var array<int, array<string, mixed>> $violations */
$violations = is_array($scanResult['violations'] ?? null) ? $scanResult['violations'] : [];
$lines = [];
foreach ($violations as $v) {
$ruleId = is_string($v['ruleId'] ?? null) ? $v['ruleId'] : 'WCAG';
$desc = is_string($v['description'] ?? null) ? $v['description'] : '';
$line = is_numeric($v['line'] ?? null) ? intval($v['line']) : 1;
$severity = is_string($v['severity'] ?? null) ? strtolower($v['severity']) : 'error';
$level = $severity === 'error' ? 'error' : 'warning';
$lines[] = sprintf('::%s file=%s,line=%d::[%s] %s', $level, $fileName, $line, $ruleId, $desc);
}
return implode("\n", $lines);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
class ExcelReportExporter
{
/**
* Export WCAG audit results as an Excel (.xlsx) workbook.
*
* @param array<string, mixed> $scanResult
* @param string $targetUrl
* @return string Binary Excel content
*/
public static function exportExcel(array $scanResult, string $targetUrl): string
{
/** @var int $score */
$score = is_numeric($scanResult['score'] ?? null) ? intval($scanResult['score']) : 0;
/** @var array<int, array<string, mixed>> $violations */
$violations = is_array($scanResult['violations'] ?? null) ? $scanResult['violations'] : [];
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Erişilebilirlik Raporu');
// Headers
$sheet->setCellValue('A1', 'YakNet Accessibility Console - WCAG 2.1 Audit Export');
$sheet->mergeCells('A1:F1');
$sheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
$sheet->getStyle('A1')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->setCellValue('A3', 'Hedef URL / Dizin:');
$sheet->setCellValue('B3', $targetUrl);
$sheet->setCellValue('A4', 'WCAG Skoru:');
$sheet->setCellValue('B4', $score . ' / 100');
$sheet->setCellValue('A5', 'İhlal Sayısı:');
$sheet->setCellValue('B5', count($violations));
// Table Header
$row = 7;
$headers = ['#', 'Kural ID', 'Standart', 'Ciddiyet', 'İhlal Açıklaması', 'Kod Snippet'];
$cols = ['A', 'B', 'C', 'D', 'E', 'F'];
foreach ($headers as $i => $h) {
$col = $cols[$i];
$sheet->setCellValue("{$col}{$row}", $h);
$sheet->getStyle("{$col}{$row}")->getFont()->setBold(true);
$sheet->getStyle("{$col}{$row}")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setRGB('1E293B');
$sheet->getStyle("{$col}{$row}")->getFont()->getColor()->setRGB('FFFFFF');
}
// Table Rows
foreach ($violations as $idx => $v) {
/** @var array<string, mixed> $v */
$r = $row + 1 + intval($idx);
$ruleId = is_string($v['ruleId'] ?? null) ? $v['ruleId'] : '';
$standard = is_string($v['standard'] ?? null) ? $v['standard'] : 'WCAG 2.1';
$severity = is_string($v['severity'] ?? null) ? $v['severity'] : 'WARNING';
$desc = is_string($v['description'] ?? null) ? $v['description'] : '';
$snippet = is_string($v['htmlSnippet'] ?? null) ? $v['htmlSnippet'] : '';
$sheet->setCellValue("A{$r}", intval($idx) + 1);
$sheet->setCellValue("B{$r}", $ruleId);
$sheet->setCellValue("C{$r}", $standard);
$sheet->setCellValue("D{$r}", $severity);
$sheet->setCellValue("E{$r}", $desc);
$sheet->setCellValue("F{$r}", $snippet);
}
// Auto-fit columns
foreach ($cols as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
$writer = new Xlsx($spreadsheet);
ob_start();
$writer->save('php://output');
return strval(ob_get_clean());
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use Dompdf\Dompdf;
use Dompdf\Options;
class PdfReportGenerator
{
/**
* Generate a PDF Accessibility Certificate & Audit Report.
*
* @param array<string, mixed> $scanResult Scan result metrics and violations array
* @param string $targetUrl Target URL or file name audited
* @return string Raw PDF binary data
*/
public static function generatePdf(array $scanResult, string $targetUrl): string
{
/** @var int $score */
$score = is_numeric($scanResult['score'] ?? null) ? intval($scanResult['score']) : 0;
/** @var array<int, array<string, mixed>> $violations */
$violations = is_array($scanResult['violations'] ?? null) ? $scanResult['violations'] : [];
$violationsCount = count($violations);
$dateStr = date('d.m.Y H:i');
$scoreColor = $score >= 80 ? '#10b981' : ($score >= 50 ? '#f59e0b' : '#ef4444');
$statusText = $score >= 80 ? 'WCAG 2.1 AA Uyumlu (Pass)' : ($score >= 50 ? 'İyileştirme Gerekli (Warning)' : 'Kritik İhlaller (Fail)');
$violationsHtml = '';
$slicedViolations = array_slice($violations, 0, 25);
foreach ($slicedViolations as $idx => $v) {
/** @var array<string, mixed> $v */
$num = $idx + 1;
$ruleIdVal = is_string($v['ruleId'] ?? null) ? $v['ruleId'] : '';
$descVal = is_string($v['description'] ?? null) ? $v['description'] : '';
$snippetVal = is_string($v['htmlSnippet'] ?? null) ? $v['htmlSnippet'] : '';
$suggestionVal = is_string($v['suggestion'] ?? null) ? $v['suggestion'] : '';
$ruleId = htmlspecialchars($ruleIdVal);
$desc = htmlspecialchars($descVal);
$snippet = htmlspecialchars($snippetVal);
$suggestion = htmlspecialchars($suggestionVal);
$violationsHtml .= "
<div style='margin-bottom:12px; padding:10px; border-left:4px solid #ef4444; background:#f8fafc;'>
<div style='font-weight:bold; color:#0f172a; font-size:12px;'>#{$num} [{$ruleId}] {$desc}</div>
<div style='font-family:monospace; font-size:10px; color:#e11d48; margin:4px 0; background:#f1f5f9; padding:4px;'>{$snippet}</div>
<div style='font-size:11px; color:#475569;'><b>Öneri:</b> {$suggestion}</div>
</div>";
}
$html = "
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>WCAG 2.1 Erişilebilirlik Sertifikası ve Raporu</title>
<style>
body { font-family: sans-serif; color: #1e293b; line-height: 1.5; font-size: 12px; margin: 0; padding: 20px; }
.header { text-align: center; border-bottom: 2px solid #38bdf8; padding-bottom: 15px; margin-bottom: 20px; }
.title { font-size: 20px; font-weight: bold; color: #0f172a; }
.subtitle { font-size: 13px; color: #64748b; margin-top: 4px; }
.badge { display: inline-block; padding: 6px 16px; border-radius: 12px; color: #fff; font-weight: bold; font-size: 14px; background: {$scoreColor}; }
.grid { margin-bottom: 20px; }
.card { background: #f8fafc; border: 1px solid #e2e8f0; padding: 12px; border-radius: 6px; margin-bottom: 10px; }
.footer { text-align: center; border-top: 1px solid #e2e8f0; padding-top: 15px; font-size: 10px; color: #94a3b8; margin-top: 30px; }
</style>
</head>
<body>
<div class='header'>
<div class='title'>♿ YakNet Accessibility Console</div>
<div class='subtitle'>WCAG 2.1 AA Erişilebilirlik Uyumluluk Raporu</div>
</div>
<div class='card' style='text-align:center;'>
<div style='font-size:11px; color:#64748b; text-transform:uppercase;'>Erişilebilirlik Sağlık Skoru</div>
<div style='font-size:32px; font-weight:bold; color:{$scoreColor}; margin:6px 0;'>{$score} / 100</div>
<div class='badge'>{$statusText}</div>
</div>
<div class='card'>
<div><b>Hedef URL / Dizin:</b> " . htmlspecialchars($targetUrl) . "</div>
<div><b>Tarama Tarihi:</b> {$dateStr}</div>
<div><b>Tespit Edilen İhlal Sayısı:</b> {$violationsCount} Hata</div>
<div><b>Raporu Üreten Motor:</b> YakNet AI & Accessibility Engine v3.1</div>
</div>
<h3 style='color:#0f172a; border-bottom:1px solid #cbd5e1; padding-bottom:6px;'>📋 Erişilebilirlik İhlalleri ve Çözüm Detayları</h3>
{$violationsHtml}
<div class='footer'>
© {$dateStr} YakNet Bilişim — Türkiye'nin Yerli & Milli Erişilebilirlik Platformu (yak.net.tr)
</div>
</body>
</html>";
$options = new Options();
$options->set('isHtml5ParserEnabled', true);
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
return strval($dompdf->output());
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use Spatie\Color\Hex;
use YakNet\AccessibilityConsole\Color\Contrast\ContrastRatioCalculator;
use YakNet\AccessibilityConsole\Color\Simulators\DeuteranopiaSimulator;
use YakNet\AccessibilityConsole\Color\Simulators\ProtanopiaSimulator;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ColorBlindnessContrast extends AbstractRule
{
public function getId(): string { return 'WCAG_1_4_1_COLOR_BLINDNESS_CONTRAST'; }
public function getDescription(): string { return 'Text and background color contrast must remain accessible (minimum 4.5:1) under color vision deficiency simulations (Deuteranopia, Protanopia).'; }
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
{
if (!$element->hasAttribute('style')) {
return null;
}
$style = $element->getAttribute('style');
$colorHex = $this->extractColorFromStyle($style, 'color');
$bgHex = $this->extractColorFromStyle($style, 'background-color') ?? $this->extractColorFromStyle($style, 'background');
if (!$colorHex || !$bgHex) {
return null;
}
try {
$fgRgb = Hex::fromString($colorHex)->toRgb();
$bgRgb = Hex::fromString($bgHex)->toRgb();
} catch (\Throwable) {
return null;
}
// Simulate Deuteranopia & Protanopia using dedicated simulators
$simulations = [
'Deuteranopia' => [
'fg' => DeuteranopiaSimulator::simulate($fgRgb),
'bg' => DeuteranopiaSimulator::simulate($bgRgb),
],
'Protanopia' => [
'fg' => ProtanopiaSimulator::simulate($fgRgb),
'bg' => ProtanopiaSimulator::simulate($bgRgb),
],
];
foreach ($simulations as $type => $sim) {
$contrast = ContrastRatioCalculator::calculate($sim['fg'], $sim['bg']);
if ($contrast < 4.5) {
return $this->createViolation(
$element,
sprintf('Insufficient color contrast (%.2f:1) under %s color blindness simulation (minimum 4.5:1 required).', $contrast, $type),
sprintf('Adjust foreground color (%s) or background color (%s) to increase contrast for users with %s.', $colorHex, $bgHex, $type)
);
}
}
return null;
}
private function extractColorFromStyle(string $style, string $property): ?string
{
if (preg_match('/' . preg_quote($property, '/') . '\s*:\s*(#[0-9a-fA-F]{3,6})/i', $style, $matches)) {
$hex = $matches[1];
if (strlen($hex) === 4) {
$hex = '#' . $hex[1] . $hex[1] . $hex[2] . $hex[2] . $hex[3] . $hex[3];
}
return $hex;
}
return null;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ContentInfoParent extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_CONTENTINFO_PARENT'; }
public function getDescription(): string { return 'Footer contentinfo landmark must be at top-level body or within valid sectioning root.'; }
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
{
if (strtolower($element->tagName) !== 'footer' && $element->getAttribute('role') !== 'contentinfo') {
return null;
}
$parent = $element->parentNode;
while ($parent instanceof DOMElement) {
$pTag = strtolower($parent->tagName);
if (in_array($pTag, ['article', 'aside', 'main', 'nav', 'section'], true)) {
return $this->createViolation(
$element,
sprintf('Footer contentinfo landmark is nested inside <%s> sectioning element.', $pTag),
'Place global footer contentinfo landmark at the top level of <body> rather than nested inside sectioning elements.'
);
}
$parent = $parent->parentNode;
}
return null;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ImageDimensionsAndScale extends AbstractRule
{
public function getId(): string { return 'WCAG_1_4_4_IMAGE_DIMENSIONS_SCALE'; }
public function getDescription(): string { return 'Images must specify explicit width and height attributes or CSS dimensions to prevent layout shifts and ensure accessible touch target sizes.'; }
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
{
if (strtolower($element->tagName) !== 'img') {
return null;
}
$hasWidthAttr = $element->hasAttribute('width') && !empty(trim($element->getAttribute('width')));
$hasHeightAttr = $element->hasAttribute('height') && !empty(trim($element->getAttribute('height')));
$style = $element->getAttribute('style');
$hasWidthStyle = preg_match('/width\s*:\s*[^;]+/i', $style) === 1;
$hasHeightStyle = preg_match('/height\s*:\s*[^;]+/i', $style) === 1;
if (!$hasWidthAttr && !$hasWidthStyle) {
return $this->createViolation(
$element,
'Image element is missing explicit width attributes or CSS width rules.',
'Specify explicit width and height attributes (e.g. width="300" height="200") on the <img> tag to prevent layout shifts (CLS).'
);
}
if (!$hasHeightAttr && !$hasHeightStyle) {
return $this->createViolation(
$element,
'Image element is missing explicit height attributes or CSS height rules.',
'Specify explicit height attributes on the <img> tag to ensure proper rendering aspect ratio.'
);
}
// Check if image target is too small when nested in a link or button
if ($hasWidthAttr && $hasHeightAttr) {
$w = (int)$element->getAttribute('width');
$h = (int)$element->getAttribute('height');
$parentTag = strtolower($element->parentNode ? $element->parentNode->nodeName : '');
if (($parentTag === 'a' || $parentTag === 'button') && ($w < 24 || $h < 24)) {
return $this->createViolation(
$element,
sprintf('Interactive image target dimensions (%dx%dpx) are smaller than the recommended 24x24px minimum touch target size.', $w, $h),
'Increase interactive image target size or add padding to meet WCAG 2.5.5 touch target size requirements.'
);
}
}
return null;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class InputTypePasswordVisibility extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_PASSWORD_VISIBILITY_TOGGLE'; }
public function getDescription(): string { return 'Password visibility toggle buttons must have accessible names and specify aria-pressed or aria-label attributes.'; }
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
{
if (strtolower($element->tagName) !== 'button') {
return null;
}
$class = strtolower($element->getAttribute('class'));
$id = strtolower($element->getAttribute('id'));
$ariaLabel = $element->getAttribute('aria-label');
$isPasswordToggle = str_contains($class, 'password') || str_contains($class, 'toggle-pass') || str_contains($id, 'password') || str_contains($id, 'toggle-pass');
if ($isPasswordToggle && empty($ariaLabel) && !$element->hasAttribute('aria-labelledby') && empty(trim($element->textContent))) {
return $this->createViolation(
$element,
'Password visibility toggle button is missing an accessible name.',
'Add an aria-label="Şifreyi Göster/Gizle" or aria-pressed="false" attribute to the password toggle button.'
);
}
return null;
}
}
+33 -8
View File
@@ -2,11 +2,17 @@
namespace YakNet\AccessibilityConsole\Rules;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use YakNet\AccessibilityConsole\Core\AbstractRule;
class RuleLevels
{
/**
* Get rule instances for the specified level.
* Levels are incremental: Level 5 includes all rules from Levels 1, 2, 3, and 4.
* Recursively scans src/Rules and all subdirectories for rule classes.
*
* @param int $level
* @return array<int, \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule>
@@ -14,27 +20,46 @@ class RuleLevels
public static function getRulesForLevel(int $level): array
{
$rules = [];
$files = glob(__DIR__ . '/*.php');
if ($files === false) {
$dir = __DIR__;
if (!is_dir($dir)) {
return [];
}
foreach ($files as $file) {
$className = pathinfo($file, PATHINFO_FILENAME);
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $className;
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$realPath = $file->getRealPath();
$relativePath = str_replace([$dir . DIRECTORY_SEPARATOR, '.php'], ['', ''], $realPath);
$subNamespace = str_replace(DIRECTORY_SEPARATOR, '\\', $relativePath);
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $subNamespace;
if (!class_exists($fqcn)) {
// Fallback to flat namespace if class is in root Rules namespace
$flatClassName = $file->getBasename('.php');
$fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $flatClassName;
}
if (class_exists($fqcn)) {
$reflection = new \ReflectionClass($fqcn);
$reflection = new ReflectionClass($fqcn);
if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) {
continue;
}
// Check if it is a concrete rule
$isRule = $reflection->isSubclassOf(\YakNet\AccessibilityConsole\Core\AbstractRule::class) ||
$reflection->implementsInterface(\YakNet\AccessibilityConsole\Rules\RuleInterface::class);
$isRule = $reflection->isSubclassOf(AbstractRule::class) ||
$reflection->implementsInterface(RuleInterface::class);
if ($isRule) {
$className = $file->getBasename('.php');
// Exclude Turkish custom rules to match standard behavior
if (in_array($className, ['EmptyLinkRule', 'HeadingOrderRule', 'ImageAltRule'], true)) {
continue;
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TableNestedBanned extends AbstractRule
{
public function getId(): string { return 'WCAG_1_3_1_TABLE_NESTED_BANNED'; }
public function getDescription(): string { return 'Tables should not be nested inside another table as it confuses screen reader data table navigation.'; }
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 (strtolower($element->tagName) !== 'table') {
return null;
}
$parent = $element->parentNode;
while ($parent instanceof DOMElement) {
if (strtolower($parent->tagName) === 'table') {
return $this->createViolation(
$element,
'Table element is nested inside another table.',
'Refactor layout to use modern CSS Flexbox/Grid instead of nested tables for tabular data.'
);
}
$parent = $parent->parentNode;
}
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace YakNet\AccessibilityConsole\Support\Helpers;
use DOMElement;
class DomHelper
{
/**
* Check if a DOMElement has a specific parent tag name in its ancestor hierarchy.
*/
public static function hasAncestorTag(DOMElement $element, string $tagName): bool
{
$target = strtolower($tagName);
$parent = $element->parentNode;
while ($parent instanceof DOMElement) {
if (strtolower($parent->tagName) === $target) {
return true;
}
$parent = $parent->parentNode;
}
return false;
}
/**
* Get accessible name / visible text from element including aria-label and title.
*/
public static function getAccessibleName(DOMElement $element): string
{
if ($element->hasAttribute('aria-label')) {
return trim($element->getAttribute('aria-label'));
}
if ($element->hasAttribute('title')) {
return trim($element->getAttribute('title'));
}
return trim($element->textContent);
}
}