feat(v3.3.0): enterprise presets, badges, notifications, rule controller, watch mode, offline healer, and WCAG 2.2 ruleset

This commit is contained in:
Enes Yakıştır
2026-09-12 10:12:05 +03:00
parent 2de1f34878
commit ef8ccb9f75
43 changed files with 2598 additions and 81 deletions
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace YakNet\AccessibilityConsole\Badges;
class BadgeGenerator
{
/**
* Generate an SVG badge representing the Accessibility Health Score.
*
* @param int $score 0-100
* @param BadgeOptions|null $options
* @return string Raw SVG markup
*/
public static function generateScoreBadge(int $score, ?BadgeOptions $options = null): string
{
$opts = $options ?? new BadgeOptions();
$color = $opts->rightColor ?? ($score >= 90 ? '#10b981' : ($score >= 70 ? '#f59e0b' : '#ef4444'));
$label = $opts->label;
$value = "{$score}/100";
return self::renderBadge($label, $value, $color, $opts);
}
/**
* Generate an SVG badge representing WCAG compliance status.
*
* @param string $standard E.g. 'WCAG 2.1 AA'
* @param bool $compliant
* @param BadgeOptions|null $options
* @return string Raw SVG markup
*/
public static function generateComplianceBadge(string $standard = 'WCAG 2.1 AA', bool $compliant = true, ?BadgeOptions $options = null): string
{
$opts = $options ?? new BadgeOptions();
$color = $opts->rightColor ?? ($compliant ? '#10b981' : '#ef4444');
$value = $compliant ? 'passed' : 'failed';
return self::renderBadge($standard, $value, $color, $opts);
}
/**
* Generate an SVG badge for total violations count.
*
* @param int $violationsCount
* @param BadgeOptions|null $options
* @return string Raw SVG markup
*/
public static function generateViolationsBadge(int $violationsCount, ?BadgeOptions $options = null): string
{
$opts = $options ?? new BadgeOptions();
$color = $opts->rightColor ?? ($violationsCount === 0 ? '#10b981' : ($violationsCount < 5 ? '#f59e0b' : '#ef4444'));
$value = $violationsCount === 0 ? '0 issues' : "{$violationsCount} issues";
$label = $opts->label !== 'accessibility' ? $opts->label : 'a11y issues';
return self::renderBadge($label, $value, $color, $opts);
}
/**
* Render a standard flat shield SVG badge.
*/
private static function renderBadge(string $label, string $value, string $rightColor, ?BadgeOptions $options = null): string
{
$opts = $options ?? new BadgeOptions();
$charWidth = 7;
$padding = 12;
$leftWidth = (int)(mb_strlen($label) * $charWidth + $padding);
$rightWidth = (int)(mb_strlen($value) * $charWidth + $padding);
$totalWidth = $leftWidth + $rightWidth;
$height = 20;
$leftCenter = (int)($leftWidth / 2);
$rightCenter = (int)($leftWidth + ($rightWidth / 2));
$escLabel = htmlspecialchars($label);
$escValue = htmlspecialchars($value);
$borderRadius = $opts->borderRadius;
$leftColor = $opts->leftColor;
$fontFamily = htmlspecialchars($opts->fontFamily);
$fontSize = $opts->fontSize;
return <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="{$totalWidth}" height="{$height}" role="img" aria-label="{$escLabel}: {$escValue}">
<title>{$escLabel}: {$escValue}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r">
<rect width="{$totalWidth}" height="{$height}" rx="{$borderRadius}" fill="#fff"/>
</clipPath>
<g clip-path="url(#r)">
<rect width="{$leftWidth}" height="{$height}" fill="{$leftColor}"/>
<rect x="{$leftWidth}" width="{$rightWidth}" height="{$height}" fill="{$rightColor}"/>
<rect width="{$totalWidth}" height="{$height}" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="{$fontFamily}" text-rendering="geometricPrecision" font-size="{$fontSize}">
<text aria-hidden="true" x="{$leftCenter}" y="15" fill="#010101" fill-opacity=".3">{$escLabel}</text>
<text x="{$leftCenter}" y="14">{$escLabel}</text>
<text aria-hidden="true" x="{$rightCenter}" y="15" fill="#010101" fill-opacity=".3">{$escValue}</text>
<text x="{$rightCenter}" y="14">{$escValue}</text>
</g>
</svg>
SVG;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace YakNet\AccessibilityConsole\Badges;
/**
* Options DTO for customizing SVG badge generation.
*/
class BadgeOptions
{
/**
* @param string $label Default left container text label
* @param string $leftColor Hex color code for left container background
* @param string|null $rightColor Optional hex color override for right container background
* @param int $borderRadius Corner radius for SVG clipPath
* @param string $fontFamily Font family CSS rule for badge typography
* @param int $fontSize Font size in pixels
*/
public function __construct(
public readonly string $label = 'accessibility',
public readonly string $leftColor = '#1f2937',
public readonly ?string $rightColor = null,
public readonly int $borderRadius = 3,
public readonly string $fontFamily = 'Verdana,Geneva,DejaVu Sans,sans-serif',
public readonly int $fontSize = 11,
) {
}
}
+21 -13
View File
@@ -26,6 +26,7 @@ class FixCommand extends Command
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be fixed without modifying files')
->addOption('offline', null, InputOption::VALUE_NONE, 'Perform deterministic offline rule-based fixes without calling AI')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
->addOption('js', null, InputOption::VALUE_NONE, 'Enable JavaScript rendering using Headless Chrome (URL scans only)');
}
@@ -39,10 +40,11 @@ class FixCommand extends Command
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
$config = Config::fromYaml($configPath);
$offline = (bool)$input->getOption('offline');
$apiKey = $config->getGeminiApiKey();
if (!$apiKey) {
$io->error("GEMINI_API_KEY not found in .env or environment variables. AI fixing requires an API key.");
return Command::FAILURE;
if (!$offline && !$apiKey) {
$io->note("GEMINI_API_KEY not found in .env or environment variables. Running in offline rule-based healing mode (--offline).");
$offline = true;
}
$levelOption = $input->getOption('level');
@@ -53,11 +55,11 @@ class FixCommand extends Command
// If target is a URL, run the legacy URL fixing logic
if ($isUrl) {
return $this->executeUrlFix($target, $level, $apiKey, $input, $output, $io);
return $this->executeUrlFix($target, $level, $apiKey, $input, $output, $io, $offline);
}
// --- LOCAL FILE / DIRECTORY SELF-HEALING ENGINE ---
$io->title("AI Self-Healing Mode - Directory Static Analysis");
$io->title($offline ? "Offline Rule-Based Self-Healing Mode" : "AI Self-Healing Mode - Directory Static Analysis");
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
$dryRun = (bool)$input->getOption('dry-run');
@@ -87,9 +89,9 @@ class FixCommand extends Command
return Command::SUCCESS;
}
$io->note(sprintf("Found %d violations. Starting AI analysis and healing...", count($violations)));
$io->note(sprintf("Found %d violations. Starting %s analysis and healing...", count($violations), $offline ? 'offline rule-based' : 'AI'));
$fixer = new AiFixerManager($apiKey);
$fixer = !$offline ? new AiFixerManager($apiKey) : null;
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
$fixedCount = 0;
@@ -129,9 +131,12 @@ class FixCommand extends Command
if ($rawSuggestion !== null) {
$isLocalFix = true;
} else {
} elseif ($fixer !== null) {
// Fallback to AI Suggestion
$rawSuggestion = $fixer->suggestFix($violation);
} else {
$io->writeln("<fg=yellow>Skipped (Rule requires AI or manual intervention)</>");
continue;
}
$suggestion = $rawSuggestion;
@@ -189,7 +194,7 @@ class FixCommand extends Command
/**
* Legacy URL scan and fix logic
*/
private function executeUrlFix(string $target, int $level, string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io): int
private function executeUrlFix(string $target, int $level, ?string $apiKey, InputInterface $input, OutputInterface $output, SymfonyStyle $io, bool $offline = false): int
{
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
@@ -199,7 +204,7 @@ class FixCommand extends Command
$dryRun = (bool)$input->getOption('dry-run');
$useJs = (bool)$input->getOption('js');
$io->title("AI Self-Healing Mode - Fixing URL: $target");
$io->title($offline ? "Offline Rule-Based Mode - Fixing URL: $target" : "AI Self-Healing Mode - Fixing URL: $target");
// Fetch HTML
if ($useJs) {
@@ -231,9 +236,9 @@ class FixCommand extends Command
return Command::SUCCESS;
}
$io->note("Found " . count($violations) . " violations. Starting AI analysis...");
$io->note(sprintf("Found %d violations. Starting %s analysis...", count($violations), $offline ? 'offline' : 'AI'));
$fixer = new AiFixerManager($apiKey);
$fixer = (!$offline && is_string($apiKey) && $apiKey !== '') ? new AiFixerManager($apiKey) : null;
$diffGenerator = new \YakNet\AccessibilityConsole\Core\Diff\VisualDiffGenerator();
$locator = new SourceLocator($projectPath);
$fixedCount = 0;
@@ -254,8 +259,11 @@ class FixCommand extends Command
if ($rawSuggestion !== null) {
$isLocalFix = true;
} else {
} elseif ($fixer !== null) {
$rawSuggestion = $fixer->suggestFix($violation);
} else {
$io->writeln("<fg=yellow>Skipped (Rule requires AI or manual intervention)</>");
continue;
}
$suggestion = $rawSuggestion;
+45 -3
View File
@@ -9,13 +9,19 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
use Symfony\Component\Console\Input\InputOption;
class RulesCommand extends Command
{
protected function configure(): void
{
$this
->setName('rules')
->setDescription('List all available accessibility scanning rules');
->setDescription('List all available accessibility scanning rules')
->addOption('search', 's', InputOption::VALUE_REQUIRED, 'Search rules by ID or description')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Filter rules by maximum level (1-5)')
->addOption('standard', null, InputOption::VALUE_REQUIRED, 'Filter rules by standard (A, AA, AAA)')
->addOption('severity', null, InputOption::VALUE_REQUIRED, 'Filter rules by severity (error, warning, info)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -23,10 +29,18 @@ class RulesCommand extends Command
$io = new SymfonyStyle($input, $output);
$io->title("Accessibility Console - Available Rules");
$searchQuery = $input->getOption('search');
$levelFilter = $input->getOption('level');
$standardFilter = $input->getOption('standard');
$severityFilter = $input->getOption('severity');
$maxLevel = is_numeric($levelFilter) ? (int)$levelFilter : 9;
$rules = RuleLevels::getRulesForLevel($maxLevel);
$table = new Table($output);
$table->setHeaders(['Rule ID', 'Standard', 'Severity', 'Level', 'Description']);
$rules = RuleLevels::getRulesForLevel(9);
$matchedCount = 0;
foreach ($rules as $rule) {
$ruleId = ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface)
? $rule->getId()
@@ -36,6 +50,28 @@ class RulesCommand extends Command
$standard = method_exists($rule, 'getStandard') ? $rule->getStandard()->value : '';
$severity = method_exists($rule, 'getSeverity') ? $rule->getSeverity()->value : '';
$level = method_exists($rule, 'getLevel') ? $rule->getLevel() : 4;
// Search filter
if (is_string($searchQuery) && $searchQuery !== '') {
$query = strtolower($searchQuery);
if (!str_contains(strtolower($ruleId), $query) && !str_contains(strtolower($description), $query)) {
continue;
}
}
// Standard filter
if (is_string($standardFilter) && $standardFilter !== '') {
if (strcasecmp($standard, $standardFilter) !== 0) {
continue;
}
}
// Severity filter
if (is_string($severityFilter) && $severityFilter !== '') {
if (strcasecmp($severity, $severityFilter) !== 0) {
continue;
}
}
$table->addRow([
"<fg=cyan;options=bold>$ruleId</>",
@@ -44,11 +80,17 @@ class RulesCommand extends Command
$level,
$description
]);
$matchedCount++;
}
$table->render();
$io->newLine();
$io->note("Total rules available: " . count($rules));
if (is_string($searchQuery) || $levelFilter !== null || is_string($standardFilter) || is_string($severityFilter)) {
$io->note(sprintf("Showing %d matching rules (out of %d total rules).", $matchedCount, count(RuleLevels::getRulesForLevel(9))));
} else {
$io->note("Total rules available: " . count($rules));
}
return Command::SUCCESS;
}
+174 -33
View File
@@ -14,6 +14,7 @@ use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\BaselineManager;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
use YakNet\AccessibilityConsole\Reporting\GithubFormatter;
@@ -22,6 +23,12 @@ use YakNet\AccessibilityConsole\Reporting\XmlFormatter;
use YakNet\AccessibilityConsole\Reporting\MarkdownFormatter;
use YakNet\AccessibilityConsole\Reporting\CsvFormatter;
use YakNet\AccessibilityConsole\Reporting\GitLabCodeQualityFormatter;
use YakNet\AccessibilityConsole\Reporting\SarifFormatter;
use YakNet\AccessibilityConsole\Reporting\JunitFormatter;
use YakNet\AccessibilityConsole\Reporting\PdfReportGenerator;
use YakNet\AccessibilityConsole\Reporting\ExcelReportExporter;
use YakNet\AccessibilityConsole\Badges\BadgeGenerator;
use YakNet\AccessibilityConsole\Presets\PresetRegistry;
use YakNet\AccessibilityConsole\Source\SourceLocator;
class ScanCommand extends Command
@@ -35,12 +42,17 @@ class ScanCommand extends Command
->addArgument('target', InputArgument::OPTIONAL, 'The URL, file, or directory path to scan')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to the configuration file (default: a11y.yaml)')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-9)')
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (console, json, github)')
->addOption('preset', 'p', InputOption::VALUE_REQUIRED, 'Use a pre-configured audit profile (section508, en301549, ecommerce)')
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Output format (console, json, github, xml, markdown, csv, gitlab, sarif, junit)')
->addOption('min-score', null, InputOption::VALUE_REQUIRED, 'Minimum required accessibility health score (0-100) to pass the quality gate')
->addOption('max-warnings', null, InputOption::VALUE_REQUIRED, 'Maximum allowed warnings before failing the quality gate')
->addOption('fail-on', null, InputOption::VALUE_REQUIRED, 'Minimum severity to fail on (error, warning, info)')
->addOption('generate-baseline', null, InputOption::VALUE_NONE, 'Generate a baseline file containing all current violations')
->addOption('baseline', null, InputOption::VALUE_REQUIRED, 'Path to the baseline file')
->addOption('no-progress', null, InputOption::VALUE_NONE, 'Disable progress bar')
->addOption('ai', null, InputOption::VALUE_NONE, 'Enable AI-powered fix suggestions')
->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html or json)')
->addOption('report', null, InputOption::VALUE_REQUIRED, 'Save report to file (html, json, sarif, xml, junit, pdf, xlsx)')
->addOption('badge', null, InputOption::VALUE_REQUIRED, 'Save SVG accessibility health badge to file (e.g. a11y-badge.svg)')
->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping (URL scans only)')
->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively (URL scans only)')
->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth (URL scans only)', '3')
@@ -61,6 +73,18 @@ class ScanCommand extends Command
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
$presetOption = $input->getOption('preset');
$activePreset = null;
if (is_string($presetOption) && $presetOption !== '') {
$presetRegistry = new PresetRegistry();
$activePreset = $presetRegistry->get($presetOption);
if ($activePreset !== null) {
$level = $activePreset->getLevel();
} else {
$io->warning("Preset '{$presetOption}' not found. Using default rules.");
}
}
$formatOption = $input->getOption('format');
$format = is_string($formatOption) ? $formatOption : $config->getFormat();
@@ -79,9 +103,14 @@ class ScanCommand extends Command
}
// --- LOCAL DIRECTORY / FILE STATIC ANALYSIS ENGINE ---
$io->title("Accessibility Console - Static Analysis");
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
if ($format === 'console') {
$io->title("Accessibility Console - Static Analysis");
$io->writeln("<fg=gray>Configuration loaded from:</> <fg=cyan>$configPath</>");
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
if ($activePreset !== null) {
$io->writeln("<fg=gray>Compliance Preset:</> <fg=magenta>" . $activePreset->getName() . " (" . $activePreset->getDescription() . ")</>");
}
}
// Resolve paths to scan
$paths = $config->getPaths();
@@ -214,38 +243,88 @@ class ScanCommand extends Command
$formatter = new CsvFormatter();
} elseif ($format === 'gitlab') {
$formatter = new GitLabCodeQualityFormatter();
} elseif ($format === 'sarif') {
$formatter = new SarifFormatter();
} elseif ($format === 'junit') {
$formatter = new JunitFormatter();
}
$exitCode = $formatter->format($violations, $baselinedCount, $io);
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
$score = $metrics['score'];
if ($format === 'console') {
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
$io->section("Accessibility Ratings");
$color = $metrics['score'] >= 90 ? 'green' : ($metrics['score'] >= 70 ? 'yellow' : 'red');
$io->writeln(sprintf("Health Score: <fg=%s>%d/100</>", $color, $metrics['score']));
$color = $score >= 90 ? 'green' : ($score >= 70 ? 'yellow' : 'red');
$io->writeln(sprintf("Health Score: <fg=%s>%d/100</>", $color, $score));
$io->writeln(sprintf(" - Screen Reader / Visual Impact: <fg=cyan>%d%%</>", $metrics['visualImpact']));
$io->writeln(sprintf(" - Keyboard / Motor Impact: <fg=cyan>%d%%</>", $metrics['motorImpact']));
$io->writeln(sprintf(" - Cognitive / Distraction Impact: <fg=cyan>%d%%</>", $metrics['cognitiveImpact']));
$io->newLine();
}
// Quality Gate evaluation
$minScoreOption = $input->getOption('min-score');
$maxWarningsOption = $input->getOption('max-warnings');
$failOnOption = $input->getOption('fail-on');
$errorCount = 0;
$warningCount = 0;
foreach ($violations as $v) {
if ($v->severity === Severity::CRITICAL || $v->severity === Severity::ERROR) {
$errorCount++;
} elseif ($v->severity === Severity::WARNING) {
$warningCount++;
}
}
$qualityGateFailed = false;
/** @var string[] $gateFailures */
$gateFailures = [];
if ($minScoreOption !== null && is_numeric($minScoreOption)) {
$minScore = (int)$minScoreOption;
if ($score < $minScore) {
$qualityGateFailed = true;
$gateFailures[] = sprintf("Accessibility score (%d/100) is lower than the minimum required threshold of %d/100.", $score, $minScore);
}
}
if ($maxWarningsOption !== null && is_numeric($maxWarningsOption)) {
$maxWarnings = (int)$maxWarningsOption;
if ($warningCount > $maxWarnings) {
$qualityGateFailed = true;
$gateFailures[] = sprintf("Warning count (%d) exceeds the maximum allowed limit of %d.", $warningCount, $maxWarnings);
}
}
if (is_string($failOnOption)) {
$failOn = strtolower($failOnOption);
if ($failOn === 'error') {
$exitCode = ($errorCount > 0) ? Command::FAILURE : Command::SUCCESS;
} elseif ($failOn === 'warning') {
$exitCode = ($errorCount > 0 || $warningCount > 0) ? Command::FAILURE : Command::SUCCESS;
}
}
if ($qualityGateFailed) {
$io->newLine();
$io->error("CI/CD Quality Gate Failed:\n- " . implode("\n- ", $gateFailures));
$exitCode = Command::FAILURE;
}
// Export report if --report is specified
$reportPath = $input->getOption('report');
if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
if ($extension === 'json') {
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
} else {
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
// Map violations into page format
$mappedViolations = ['Static Analysis' => $violations];
file_put_contents($reportPath, $reporter->render($mappedViolations, 'Static Analysis'));
}
$io->success("Report saved to: $reportPath");
$this->exportReport($reportPath, $violations, 'Static Analysis', $io);
}
// Export SVG badge if --badge is specified
$badgePath = $input->getOption('badge');
if (is_string($badgePath) && $badgePath !== '') {
file_put_contents($badgePath, BadgeGenerator::generateScoreBadge($score));
$io->success("SVG Badge saved to: $badgePath");
}
return $exitCode;
@@ -438,20 +517,82 @@ class ScanCommand extends Command
$reportPath = $input->getOption('report');
if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
if ($extension === 'json') {
$serialized = [];
foreach ($results as $url => $violations) {
$serialized[$url] = array_map(fn($v) => $v->toArray(), $violations);
}
file_put_contents($reportPath, json_encode($serialized, JSON_PRETTY_PRINT));
} else {
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
file_put_contents($reportPath, $reporter->render($results, $target));
$allViolations = [];
foreach ($results as $urlViolations) {
$allViolations = array_merge($allViolations, $urlViolations);
}
$io->success("Report saved to: $reportPath");
$this->exportReport($reportPath, $allViolations, $target, $io, $results);
}
return Command::FAILURE;
}
/**
* Export violations to various file formats (HTML, JSON, SARIF, XML/JUnit, PDF, Excel).
*
* @param string $reportPath
* @param \YakNet\AccessibilityConsole\Core\Violation[] $violations
* @param string $targetName
* @param SymfonyStyle $io
* @param array<string, \YakNet\AccessibilityConsole\Core\Violation[]>|null $pageResults
*/
private function exportReport(
string $reportPath,
array $violations,
string $targetName,
SymfonyStyle $io,
?array $pageResults = null
): void {
$extension = strtolower(pathinfo($reportPath, PATHINFO_EXTENSION));
if ($extension === 'json') {
if ($pageResults !== null) {
$serialized = [];
foreach ($pageResults as $url => $pageViolations) {
$serialized[$url] = array_map(fn($v) => $v->toArray(), $pageViolations);
}
} else {
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
}
file_put_contents($reportPath, (string)json_encode($serialized, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
} elseif ($extension === 'sarif') {
$sarifFormatter = new SarifFormatter();
file_put_contents($reportPath, $sarifFormatter->render($violations));
} elseif ($extension === 'xml' || $extension === 'junit') {
$junitFormatter = new JunitFormatter();
file_put_contents($reportPath, $junitFormatter->render($violations));
} elseif ($extension === 'pdf') {
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
$scanResult = [
'score' => $metrics['score'],
'violations' => $serialized,
];
file_put_contents($reportPath, PdfReportGenerator::generatePdf($scanResult, $targetName));
} elseif ($extension === 'xlsx' || $extension === 'xls') {
$metrics = \YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator::calculate($violations);
$serialized = [];
foreach ($violations as $v) {
$serialized[] = $v->toArray();
}
$scanResult = [
'score' => $metrics['score'],
'violations' => $serialized,
];
file_put_contents($reportPath, ExcelReportExporter::exportExcel($scanResult, $targetName));
} else {
$reporter = new \YakNet\AccessibilityConsole\Reporting\HtmlDashboardReporter();
$mapped = $pageResults ?? [$targetName => $violations];
file_put_contents($reportPath, $reporter->render($mapped, $targetName));
}
$io->success("Report saved to: $reportPath");
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace YakNet\AccessibilityConsole\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Core\Analyser;
use YakNet\AccessibilityConsole\Core\BaselineManager;
use YakNet\AccessibilityConsole\Core\Config;
use YakNet\AccessibilityConsole\Core\Metrics\AccessibilityScoreCalculator;
use YakNet\AccessibilityConsole\Core\Scanner;
use YakNet\AccessibilityConsole\Reporting\ConsoleFormatter;
use YakNet\AccessibilityConsole\Rules\RuleLevels;
class WatchCommand extends Command
{
protected function configure(): void
{
$this
->setName('watch')
->setDescription('Continuously watch templates and files for changes and automatically re-scan')
->addArgument('target', InputArgument::OPTIONAL, 'The file or directory path to watch (defaults to config paths)')
->addOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to configuration file (default: a11y.yaml)')
->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-5)')
->addOption('interval', 'i', InputOption::VALUE_REQUIRED, 'Watch poll interval in seconds', '1')
->addOption('max-iterations', null, InputOption::VALUE_REQUIRED, 'Max loop iterations (used for testing/debugging)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$configPathOption = $input->getOption('config');
$configPath = is_string($configPathOption) ? $configPathOption : getcwd() . '/a11y.yaml';
$config = Config::fromYaml($configPath);
$levelOption = $input->getOption('level');
$level = is_numeric($levelOption) ? (int)$levelOption : $config->getLevel();
$target = $input->getArgument('target');
$paths = $config->getPaths();
if (is_string($target) && $target !== '') {
$paths = [$target];
}
$intervalOption = $input->getOption('interval');
$interval = is_numeric($intervalOption) ? (int)$intervalOption : 1;
if ($interval < 1) {
$interval = 1;
}
$maxIterationsOption = $input->getOption('max-iterations');
$maxIterations = is_numeric($maxIterationsOption) ? (int)$maxIterationsOption : null;
$io->title("Accessibility Console - Real-Time Watch Mode");
$io->writeln("<fg=gray>Watching paths:</> <fg=cyan>" . implode(', ', $paths) . "</>");
$io->writeln("<fg=gray>Rule Level:</> <fg=yellow>$level</>");
$io->writeln("<fg=gray>Poll Interval:</> <fg=yellow>{$interval}s</>");
$io->note("Press Ctrl+C to stop watching.");
// Build Scanner
$scanner = new Scanner();
$rules = RuleLevels::getRulesForLevel($level);
foreach ($rules as $rule) {
$scanner->addRule($rule);
}
$analyser = new Analyser($config, $scanner, new BaselineManager());
// Perform initial scan
$io->section("Initial Scan");
$this->runScanAndPrint($analyser, $paths, $io);
// Map existing files to their last modified timestamp
$fileMtimes = $this->collectFileMtimes($paths, $config->getExcludePaths());
$iteration = 0;
while (true) {
if ($maxIterations !== null && $iteration >= $maxIterations) {
break;
}
$iteration++;
sleep($interval);
$currentMtimes = $this->collectFileMtimes($paths, $config->getExcludePaths());
$changedFiles = $this->detectChangedFiles($fileMtimes, $currentMtimes);
if (!empty($changedFiles)) {
$fileMtimes = $currentMtimes;
$timestamp = date('H:i:s');
$io->newLine();
$io->writeln(sprintf(
"<fg=yellow>[%s]</> Change detected in <fg=cyan>%s</>. Re-scanning...",
$timestamp,
basename($changedFiles[0]) . (count($changedFiles) > 1 ? ' (+' . (count($changedFiles) - 1) . ' more)' : '')
));
// Re-scan changed files or paths
$this->runScanAndPrint($analyser, $changedFiles, $io);
}
}
return Command::SUCCESS;
}
/**
* @param Analyser $analyser
* @param string[] $paths
* @param SymfonyStyle $io
*/
private function runScanAndPrint(Analyser $analyser, array $paths, SymfonyStyle $io): void
{
$start = microtime(true);
$result = $analyser->analyse($paths);
$elapsed = round(microtime(true) - $start, 3);
$violations = $result['violations'];
$formatter = new ConsoleFormatter();
$formatter->format($violations, $result['baselinedCount'], $io);
$metrics = AccessibilityScoreCalculator::calculate($violations);
$score = $metrics['score'];
$color = $score >= 90 ? 'green' : ($score >= 70 ? 'yellow' : 'red');
$io->writeln(sprintf(
"Health Score: <fg=%s;options=bold>%d/100</> | Scan time: <fg=gray>%ss</>",
$color,
$score,
$elapsed
));
}
/**
* @param string[] $paths
* @param string[] $excludePaths
* @return array<string, int>
*/
public function collectFileMtimes(array $paths, array $excludePaths): array
{
$mtimes = [];
$extensions = ['php', 'html', 'twig', 'blade.php'];
foreach ($paths as $path) {
if (is_file($path)) {
$mtimes[$path] = (int)filemtime($path);
continue;
}
if (!is_dir($path)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \RecursiveDirectoryIterator::SKIP_DOTS)
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (!$file->isFile()) {
continue;
}
$filename = $file->getFilename();
$ext = $file->getExtension();
$isMatching = in_array($ext, ['php', 'html', 'twig'], true) || str_ends_with($filename, '.blade.php');
if (!$isMatching) {
continue;
}
$filePath = (string)$file->getRealPath();
$excluded = false;
foreach ($excludePaths as $ex) {
if (str_contains(str_replace('\\', '/', $filePath), '/' . trim($ex, '/') . '/')) {
$excluded = true;
break;
}
}
if (!$excluded) {
$mtimes[$filePath] = (int)$file->getMTime();
}
}
}
return $mtimes;
}
/**
* @param array<string, int> $oldMtimes
* @param array<string, int> $newMtimes
* @return string[]
*/
public function detectChangedFiles(array $oldMtimes, array $newMtimes): array
{
$changed = [];
foreach ($newMtimes as $file => $mtime) {
if (!isset($oldMtimes[$file]) || $oldMtimes[$file] !== $mtime) {
$changed[] = $file;
}
}
foreach ($oldMtimes as $file => $mtime) {
if (!isset($newMtimes[$file])) {
$changed[] = $file;
}
}
return array_values(array_unique($changed));
}
}
+4
View File
@@ -11,6 +11,7 @@ use YakNet\AccessibilityConsole\Console\Command\ScanCommand;
use YakNet\AccessibilityConsole\Console\Command\FixCommand;
use YakNet\AccessibilityConsole\Console\Command\InitCommand;
use YakNet\AccessibilityConsole\Console\Command\RulesCommand;
use YakNet\AccessibilityConsole\Console\Command\WatchCommand;
class ContainerFactory
{
@@ -51,6 +52,9 @@ class ContainerFactory
RulesCommand::class => function () {
return new RulesCommand();
},
WatchCommand::class => function () {
return new WatchCommand();
},
]);
return $builder->build();
+1 -1
View File
@@ -40,7 +40,7 @@ class Scanner
// Attach raw HTML property for syntax/token level analysis
/** @phpstan-ignore-next-line */
$doc->rawHtml = $html;
@$doc->rawHtml = $html;
return $this->engine->run($doc);
}
+80 -2
View File
@@ -18,7 +18,80 @@ class LocalHealer
$ruleId = $violation->ruleId;
switch ($ruleId) {
case 'WCAG_1_1_1_IMG_ALT':
case 'WCAG_1_1_1_ImageAlt':
case 'ImageAlt':
case 'ImgAltText':
if (preg_match('/<img\b(?![^>]*\balt=)/i', $snippet)) {
$fixed = preg_replace('/<img\b/i', '<img alt=""', $snippet) ?: $snippet;
return "EXPLANATION: Injected missing alt=\"\" attribute for decorative image.\nFIX: " . $fixed;
}
break;
case 'WCAG_1_1_1_SVG':
case 'SvgAltText':
if (preg_match('/<svg\b(?![^>]*\baria-hidden=)(?![^>]*\baria-label=)/i', $snippet) && !str_contains($snippet, '<title>')) {
$fixed = preg_replace('/<svg\b/i', '<svg aria-hidden="true"', $snippet) ?: $snippet;
return "EXPLANATION: Injected aria-hidden=\"true\" to decorative SVG element.\nFIX: " . $fixed;
}
break;
case 'WCAG_LINK_TARGET_BLANK':
case 'LinkTargetBlank':
if (preg_match('/<a\b[^>]*\btarget=["\']_blank["\']/i', $snippet)) {
if (preg_match('/rel=["\']([^"\']*)["\']/i', $snippet, $matches)) {
$existingRel = $matches[1];
$newRel = trim($existingRel . ' noopener noreferrer');
$fixed = str_replace($matches[0], 'rel="' . $newRel . '"', $snippet);
} else {
$fixed = preg_replace('/<a\b/i', '<a rel="noopener noreferrer"', $snippet) ?: $snippet;
}
return "EXPLANATION: Added rel=\"noopener noreferrer\" to target=\"_blank\" link.\nFIX: " . $fixed;
}
break;
case 'WCAG_2_4_3_TABINDEX':
case 'TabindexOrder':
if (preg_match('/\btabindex=["\'][1-9]\d*["\']/i', $snippet)) {
$fixed = preg_replace('/\btabindex=["\'][1-9]\d*["\']/i', 'tabindex="0"', $snippet) ?: $snippet;
return "EXPLANATION: Normalized positive tabindex to tabindex=\"0\" to maintain natural tab order.\nFIX: " . $fixed;
}
break;
case 'WCAG_1_3_1_TABLE_SCOPE':
case 'ScopeOnlyOnHeaders':
if (preg_match('/<td\b[^>]*\bscope=["\'][^"\']*["\']/i', $snippet)) {
$fixed = preg_replace('/\s+scope=["\'][^"\']*["\']/i', '', $snippet) ?: $snippet;
return "EXPLANATION: Removed invalid scope attribute from <td> element.\nFIX: " . $fixed;
}
break;
case 'WCAG_2_2_1_META_REFRESH':
case 'MetaRefreshBanned':
if (preg_match('/<meta\b[^>]*\bhttp-equiv=["\']refresh["\'][^>]*>/i', $snippet)) {
$fixed = '<!-- ' . trim($snippet) . ' (Removed: Meta refresh disorients users) -->';
return "EXPLANATION: Commented out disorienting meta refresh tag.\nFIX: " . $fixed;
}
break;
case 'WCAG_1_3_1_OPTGROUP_LABEL':
case 'OptgroupLabel':
if (preg_match('/<optgroup\b(?![^>]*\blabel=)/i', $snippet)) {
$fixed = preg_replace('/<optgroup\b/i', '<optgroup label="Options"', $snippet) ?: $snippet;
return "EXPLANATION: Added fallback label=\"Options\" attribute to <optgroup>.\nFIX: " . $fixed;
}
break;
case 'WCAG_2_4_7_FOCUS_OUTLINE':
case 'NoFocusOutline':
if (preg_match('/style=["\'][^"\']*outline\s*:\s*(?:none|0)[^"\']*["\']/i', $snippet)) {
$fixed = preg_replace('/outline\s*:\s*(?:none|0)/i', 'outline: 2px solid transparent; outline-offset: 2px', $snippet) ?: $snippet;
return "EXPLANATION: Replaced outline removal with accessible transparent outline.\nFIX: " . $fixed;
}
break;
case 'WCAG_2_2_2_BLINK':
case 'BlinkTagBanned':
$fixed = str_replace(
['<blink>', '</blink>', '<BLINK>', '</BLINK>'],
['<span style="text-decoration: blink;">', '</span>', '<span style="text-decoration: blink;">', '</span>'],
@@ -27,14 +100,17 @@ class LocalHealer
return "EXPLANATION: Banned <blink> tag replaced with a span and text-decoration style.\nFIX: " . $fixed;
case 'WCAG_2_4_3_AUTOFOCUS':
case 'AutofocusBanned':
$fixed = preg_replace('/\s+autofocus(=["\'](.*?)["\'])?/i', '', $snippet) ?: $snippet;
return "EXPLANATION: Autofocus attribute removed to prevent screen reader interruption on load.\nFIX: " . $fixed;
case 'WCAG_1_3_1_TABLE_SUMMARY':
case 'TableSummaryBanned':
$fixed = preg_replace('/\s+summary=["\'](.*?)["\']/i', '', $snippet) ?: $snippet;
return "EXPLANATION: Deprecated table summary attribute removed. Use caption elements instead.\nFIX: " . $fixed;
case 'WCAG_2_1_1_SCROLLABLE_FOCUS':
case 'ScrollableRegionFocusable':
if (!str_contains($snippet, 'tabindex')) {
$fixed = preg_replace('/(style=["\'](.*?)["\'])/i', '$1 tabindex="0"', $snippet) ?: $snippet;
return "EXPLANATION: Added tabindex=\"0\" to scrollable region to allow keyboard scrolling.\nFIX: " . $fixed;
@@ -42,6 +118,7 @@ class LocalHealer
break;
case 'WCAG_1_3_1_PRESENTATION_TAGS':
case 'PresentationTagsBanned':
if (preg_match('/<center>(.*?)<\/center>/is', $snippet, $matches)) {
$fixed = '<div style="text-align: center;">' . $matches[1] . '</div>';
return "EXPLANATION: Center element replaced with div and text-align CSS.\nFIX: " . $fixed;
@@ -53,9 +130,10 @@ class LocalHealer
break;
case 'WCAG_3_1_1_LANG':
case 'HtmlHasLang':
if (preg_match('/<html(\s+[^>]*>|>)/i', $snippet)) {
$fixed = preg_replace('/<html/i', '<html lang="en"', $snippet) ?: $snippet;
return "EXPLANATION: Injected default lang=\"en\" to HTML element.\nFIX: " . $fixed;
$fixed = preg_replace('/<html/i', '<html lang="tr"', $snippet) ?: $snippet;
return "EXPLANATION: Injected default lang=\"tr\" to HTML element.\nFIX: " . $fixed;
}
break;
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
use GuzzleHttp\Client;
class DiscordNotifier implements NotifierInterface
{
private Client $httpClient;
public function __construct(
private string $webhookUrl,
?Client $httpClient = null
) {
$this->httpClient = $httpClient ?? new Client(['timeout' => 5.0]);
}
public function send(NotificationPayload $payload): bool
{
if ($this->webhookUrl === '') {
return false;
}
// Color in decimal for Discord embed
$color = $payload->healthScore >= 90 ? 0x10b981 : ($payload->healthScore >= 70 ? 0xf59e0b : 0xef4444);
$body = [
'embeds' => [
[
'title' => "♿ Accessibility Audit: {$payload->target}",
'color' => $color,
'fields' => [
[
'name' => 'Health Score',
'value' => sprintf('%d / 100', $payload->healthScore),
'inline' => true,
],
[
'name' => 'Total Violations',
'value' => (string)$payload->totalViolations,
'inline' => true,
],
[
'name' => 'Breakdown',
'value' => sprintf('Errors: %d | Warnings: %d', $payload->errorCount, $payload->warningCount),
'inline' => false,
],
],
'footer' => [
'text' => 'YakNet Accessibility Console',
],
'timestamp' => date('c'),
],
],
];
try {
$response = $this->httpClient->post($this->webhookUrl, [
'json' => $body,
]);
return $response->getStatusCode() < 300;
} catch (\Throwable) {
return false;
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
class NotificationDispatcher
{
/**
* @var array<string, NotifierInterface>
*/
private array $notifiers = [];
public function addNotifier(string $channel, NotifierInterface $notifier): void
{
$this->notifiers[$channel] = $notifier;
}
/**
* Dispatch notification payload across all registered notifiers.
*
* @return array<string, bool> Channel delivery results
*/
public function dispatch(NotificationPayload $payload): array
{
$results = [];
foreach ($this->notifiers as $channel => $notifier) {
$results[$channel] = $notifier->send($payload);
}
return $results;
}
public function hasNotifiers(): bool
{
return !empty($this->notifiers);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
class NotificationPayload
{
/**
* @param array<int, array<string, mixed>> $topViolations
*/
public function __construct(
public string $target,
public int $healthScore,
public int $totalViolations,
public int $errorCount,
public int $warningCount,
public array $topViolations = []
) {}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
interface NotifierInterface
{
public function send(NotificationPayload $payload): bool;
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace YakNet\AccessibilityConsole\Notifications;
use GuzzleHttp\Client;
class SlackNotifier implements NotifierInterface
{
private Client $httpClient;
public function __construct(
private string $webhookUrl,
?Client $httpClient = null
) {
$this->httpClient = $httpClient ?? new Client(['timeout' => 5.0]);
}
public function send(NotificationPayload $payload): bool
{
if ($this->webhookUrl === '') {
return false;
}
$color = $payload->healthScore >= 90 ? '#10b981' : ($payload->healthScore >= 70 ? '#f59e0b' : '#ef4444');
$status = $payload->healthScore >= 90 ? 'PASSED' : 'FAILED';
$body = [
'attachments' => [
[
'color' => $color,
'title' => "♿ Accessibility Audit: {$payload->target}",
'text' => sprintf(
"*Health Score:* %d/100 (%s)\n*Total Violations:* %d (Errors: %d, Warnings: %d)",
$payload->healthScore,
$status,
$payload->totalViolations,
$payload->errorCount,
$payload->warningCount
),
'footer' => 'YakNet Accessibility Console',
'ts' => time(),
],
],
];
try {
$response = $this->httpClient->post($this->webhookUrl, [
'json' => $body,
]);
return $response->getStatusCode() < 300;
} catch (\Throwable) {
return false;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Presets;
class ECommercePreset implements PresetInterface
{
public function getName(): string
{
return 'ecommerce';
}
public function getDescription(): string
{
return 'E-Commerce storefront and checkout flow accessibility profile.';
}
public function getLevel(): int
{
return 4;
}
public function getIncludedRules(): array
{
return [
'WCAG_1_1_1_IMG_ALT',
'WCAG_4_1_2_BUTTON',
'WCAG_1_3_1_FORM_LABEL',
'WCAG_3_3_7_REDUNDANT_ENTRY',
'WCAG_2_5_8_TARGET_SIZE',
'WCAG_1_4_3_CONTRAST',
'WCAG_4_1_2_DIALOG',
];
}
public function getExcludedRules(): array
{
return [];
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace YakNet\AccessibilityConsole\Presets;
class En301549Preset implements PresetInterface
{
public function getName(): string
{
return 'en301549';
}
public function getDescription(): string
{
return 'European Union EN 301 549 public sector digital accessibility profile.';
}
public function getLevel(): int
{
return 4;
}
public function getIncludedRules(): array
{
return [
'WCAG_1_4_3_CONTRAST',
'WCAG_2_4_7_FOCUS_OUTLINE',
'WCAG_2_5_8_TARGET_SIZE',
'WCAG_1_4_12_TEXT_SPACING',
'WCAG_2_5_3_LABEL_IN_NAME',
];
}
public function getExcludedRules(): array
{
return [];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace YakNet\AccessibilityConsole\Presets;
interface PresetInterface
{
public function getName(): string;
public function getDescription(): string;
public function getLevel(): int;
/**
* @return string[]
*/
public function getIncludedRules(): array;
/**
* @return string[]
*/
public function getExcludedRules(): array;
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace YakNet\AccessibilityConsole\Presets;
class PresetRegistry
{
/**
* @var array<string, PresetInterface>
*/
private array $presets = [];
public function __construct()
{
$this->register(new Section508Preset());
$this->register(new En301549Preset());
$this->register(new ECommercePreset());
}
public function register(PresetInterface $preset): void
{
$this->presets[strtolower($preset->getName())] = $preset;
}
public function get(string $name): ?PresetInterface
{
return $this->presets[strtolower($name)] ?? null;
}
/**
* @return array<string, PresetInterface>
*/
public function all(): array
{
return $this->presets;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace YakNet\AccessibilityConsole\Presets;
class Section508Preset implements PresetInterface
{
public function getName(): string
{
return 'section508';
}
public function getDescription(): string
{
return 'US Federal Government Section 508 accessibility compliance profile.';
}
public function getLevel(): int
{
return 3;
}
public function getIncludedRules(): array
{
return [
'WCAG_1_1_1_IMG_ALT',
'WCAG_1_3_1_TABLE',
'WCAG_1_3_1_TABLE_HEADERS_ATTR',
'WCAG_1_3_1_FORM_LABEL',
'WCAG_2_1_1_KEYBOARD_CLICK',
'WCAG_2_4_2_PAGE_TITLE',
'WCAG_3_1_1_LANG',
];
}
public function getExcludedRules(): array
{
return [];
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use SimpleXMLElement;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Core\Violation;
class JunitFormatter implements FormatterInterface
{
/**
* @param Violation[] $violations
* @param int $baselinedCount
* @param SymfonyStyle $io
* @return int
*/
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$xml = $this->render($violations, $baselinedCount);
$io->write($xml);
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
/**
* Render the violations in JUnit XML format.
*
* @param Violation[] $violations
* @param int $baselinedCount
* @return string
*/
public function render(array $violations, int $baselinedCount = 0): string
{
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$testsuites = $dom->createElement('testsuites');
$testsuites->setAttribute('name', 'YakNet Accessibility Console');
$testsuites->setAttribute('tests', (string)max(1, count($violations)));
$testsuites->setAttribute('failures', (string)count($violations));
$testsuites->setAttribute('errors', '0');
$testsuites->setAttribute('time', '0.000');
$dom->appendChild($testsuites);
if (empty($violations)) {
$testsuite = $dom->createElement('testsuite');
$testsuite->setAttribute('name', 'AccessibilityScan');
$testsuite->setAttribute('tests', '1');
$testsuite->setAttribute('failures', '0');
$testsuite->setAttribute('errors', '0');
$testsuite->setAttribute('time', '0.000');
$testcase = $dom->createElement('testcase');
$testcase->setAttribute('name', 'AccessibilityCompliant');
$testcase->setAttribute('classname', 'YakNet.AccessibilityConsole');
$testcase->setAttribute('time', '0.000');
$testsuite->appendChild($testcase);
$testsuites->appendChild($testsuite);
return (string)$dom->saveXML();
}
// Group violations by file
/** @var array<string, Violation[]> $grouped */
$grouped = [];
foreach ($violations as $v) {
$file = $v->location['file'] ?? 'unknown';
$grouped[$file][] = $v;
}
foreach ($grouped as $file => $fileViolations) {
$testsuite = $dom->createElement('testsuite');
$testsuite->setAttribute('name', str_replace('\\', '/', $file));
$testsuite->setAttribute('tests', (string)count($fileViolations));
$testsuite->setAttribute('failures', (string)count($fileViolations));
$testsuite->setAttribute('errors', '0');
$testsuite->setAttribute('time', '0.000');
foreach ($fileViolations as $v) {
$line = $v->location['line'] ?? 1;
$col = $v->location['column'] ?? 1;
$testcase = $dom->createElement('testcase');
$testcase->setAttribute('name', sprintf('[%s] %s', $v->ruleId, $v->message));
$testcase->setAttribute('classname', str_replace(['/', '\\'], '.', $file));
$testcase->setAttribute('time', '0.000');
$failure = $dom->createElement('failure');
$failure->setAttribute('type', $v->severity->value);
$failure->setAttribute('message', $v->message);
$details = sprintf(
"Rule: %s (%s)\nStandard: %s\nSeverity: %s\nLocation: %s:%d:%d\n\nSnippet:\n%s",
$v->ruleId,
$v->message,
$v->standard->value,
$v->severity->value,
$file,
$line,
$col,
$v->htmlSnippet
);
if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') {
$details .= "\n\nSuggested Fix:\n" . $v->fixSuggestion;
}
$cdata = $dom->createCDATASection($details);
$failure->appendChild($cdata);
$testcase->appendChild($failure);
$testsuite->appendChild($testcase);
}
$testsuites->appendChild($testsuite);
}
return (string)$dom->saveXML();
}
}
+1 -1
View File
@@ -90,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.2</div>
<div><b>Raporu Üreten Motor:</b> YakNet AI & Accessibility Engine v3.3</div>
</div>
<h3 style='color:#0f172a; border-bottom:1px solid #cbd5e1; padding-bottom:6px;'>📋 Erişilebilirlik İhlalleri ve Çözüm Detayları</h3>
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace YakNet\AccessibilityConsole\Reporting;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
class SarifFormatter implements FormatterInterface
{
/**
* @param Violation[] $violations
* @param int $baselinedCount
* @param SymfonyStyle $io
* @return int
*/
public function format(array $violations, int $baselinedCount, SymfonyStyle $io): int
{
$sarifJson = $this->render($violations, $baselinedCount);
$io->write($sarifJson);
return empty($violations) ? Command::SUCCESS : Command::FAILURE;
}
/**
* Render the violations in SARIF 2.1.0 JSON format.
*
* @param Violation[] $violations
* @param int $baselinedCount
* @return string
*/
public function render(array $violations, int $baselinedCount = 0): string
{
$rulesMap = [];
$results = [];
foreach ($violations as $v) {
$ruleId = $v->ruleId;
// Register rule metadata if not already present
if (!isset($rulesMap[$ruleId])) {
$rulesMap[$ruleId] = [
'id' => $ruleId,
'name' => $this->sanitizeRuleName($ruleId),
'shortDescription' => [
'text' => $v->message,
],
'fullDescription' => [
'text' => $v->message,
],
'defaultConfiguration' => [
'level' => $this->mapSeverityToSarifLevel($v->severity),
],
'properties' => [
'tags' => [
'accessibility',
'a11y',
'wcag',
'wcag-' . strtolower($v->standard->value),
],
],
];
}
// Location details
$file = $v->location['file'] ?? 'unknown';
$line = isset($v->location['line']) ? max(1, (int)$v->location['line']) : 1;
$column = isset($v->location['column']) ? max(1, (int)$v->location['column']) : 1;
/** @var array<string, mixed> $region */
$region = [
'startLine' => $line,
'startColumn' => $column,
];
if ($v->htmlSnippet !== '') {
$region['snippet'] = [
'text' => $v->htmlSnippet,
];
}
/** @var array<string, mixed> $locationEntry */
$locationEntry = [
'physicalLocation' => [
'artifactLocation' => [
'uri' => str_replace('\\', '/', $file),
],
'region' => $region,
],
];
/** @var array<string, mixed> $resultItem */
$resultItem = [
'ruleId' => $ruleId,
'level' => $this->mapSeverityToSarifLevel($v->severity),
'message' => [
'text' => $v->message,
],
'locations' => [
$locationEntry,
],
];
if ($v->fixSuggestion !== null && $v->fixSuggestion !== '') {
$resultItem['fixes'] = [
[
'description' => [
'text' => 'Suggested accessibility fix',
],
],
];
}
$results[] = $resultItem;
}
$sarif = [
'$schema' => 'https://json.schemastore.org/sarif-2.1.0.json',
'version' => '2.1.0',
'runs' => [
[
'tool' => [
'driver' => [
'name' => 'YakNet Accessibility Console',
'informationUri' => 'https://forum.yak.net.tr',
'semanticVersion' => '3.3.0',
'rules' => array_values($rulesMap),
],
],
'results' => $results,
],
],
];
return (string)json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
private function mapSeverityToSarifLevel(Severity $severity): string
{
return match ($severity) {
Severity::CRITICAL, Severity::ERROR => 'error',
Severity::WARNING => 'warning',
Severity::INFO => 'note',
};
}
private function sanitizeRuleName(string $ruleId): string
{
$cleaned = preg_replace('/^WCAG_\d+_\d+_\d+_?/', '', $ruleId);
$cleaned = is_string($cleaned) ? $cleaned : $ruleId;
$words = explode('_', strtolower($cleaned));
return implode('', array_map('ucfirst', $words));
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use DOMNode;
use DOMXPath;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class LabelInName extends AbstractRule
{
public function getId(): string
{
return 'WCAG_2_5_3_LABEL_IN_NAME';
}
public function getDescription(): string
{
return 'Accessible name must contain the visible text label of interactive controls (WCAG 2.5.3).';
}
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);
$role = strtolower($element->getAttribute('role'));
$isInteractive = in_array($tag, ['button', 'a'], true)
|| ($tag === 'input' && in_array(strtolower($element->getAttribute('type')), ['button', 'submit', 'reset'], true))
|| in_array($role, ['button', 'link'], true);
if (!$isInteractive) {
return null;
}
// Avoid template false positives
$html = $element->ownerDocument ? $element->ownerDocument->saveHTML($element) : '';
if ($html !== false && preg_match('/<\?php|\{\{|\{%/i', $html)) {
return null;
}
// Determine visible text
$visibleText = '';
if ($tag === 'input') {
$visibleText = trim($element->getAttribute('value'));
} else {
$visibleText = trim($element->textContent);
}
// If there is no visible text (e.g. icon-only button), Label in Name criterion does not apply
if ($visibleText === '' || mb_strlen($visibleText) < 2) {
return null;
}
// Get explicit accessible name
$accessibleName = '';
if ($element->hasAttribute('aria-label')) {
$accessibleName = trim($element->getAttribute('aria-label'));
} elseif ($element->hasAttribute('aria-labelledby')) {
$id = trim($element->getAttribute('aria-labelledby'));
$doc = $element->ownerDocument;
if ($doc !== null && $id !== '') {
$xpath = new DOMXPath($doc);
$nodes = $xpath->query('//*[@id="' . $id . '"]');
if ($nodes !== false && $nodes->length > 0) {
$node = $nodes->item(0);
if ($node instanceof DOMNode) {
$accessibleName = trim($node->textContent);
}
}
}
}
// If no explicit ARIA name is set, the visible text itself is the accessible name
if ($accessibleName === '') {
return null;
}
// Normalization: clean extra whitespace and lower case
$normVisible = mb_strtolower((string)preg_replace('/\s+/', ' ', $visibleText));
$normAcc = mb_strtolower((string)preg_replace('/\s+/', ' ', $accessibleName));
// WCAG 2.5.3: Accessible name must contain the visible text
if (!str_contains($normAcc, $normVisible)) {
return $this->createViolation(
$element,
sprintf(
"Accessible name \"%s\" does not contain the visible text \"%s\".",
$accessibleName,
$visibleText
),
sprintf(
"Update aria-label to include \"%s\" (e.g. aria-label=\"%s %s\") so speech input users can activate the control.",
$visibleText,
$visibleText,
$accessibleName
)
);
}
return null;
}
}
+113
View File
@@ -0,0 +1,113 @@
<?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 RedundantEntry extends AbstractRule
{
public function getId(): string
{
return 'WCAG_3_3_7_REDUNDANT_ENTRY';
}
public function getDescription(): string
{
return 'Forms with repeated information (such as billing and shipping) must provide auto-population or a selection mechanism (WCAG 2.2 SC 3.3.7).';
}
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
{
if (strtolower($element->tagName) !== 'form') {
return null;
}
$doc = $element->ownerDocument;
if ($doc === null) {
return null;
}
$xpath = new DOMXPath($doc);
// Find all input and select elements in the form
$inputs = $xpath->query('.//input | .//select | .//textarea', $element);
if ($inputs === false || $inputs->length < 4) {
return null;
}
$hasShipping = false;
$hasBilling = false;
$hasSameCheckbox = false;
foreach ($inputs as $input) {
if (!$input instanceof DOMElement) {
continue;
}
$name = strtolower($input->getAttribute('name'));
$id = strtolower($input->getAttribute('id'));
$autocomplete = strtolower($input->getAttribute('autocomplete'));
$type = strtolower($input->getAttribute('type'));
if (str_contains($name, 'shipping') || str_contains($id, 'shipping') || str_contains($autocomplete, 'shipping')) {
$hasShipping = true;
}
if (str_contains($name, 'billing') || str_contains($id, 'billing') || str_contains($autocomplete, 'billing')) {
$hasBilling = true;
}
if ($type === 'checkbox') {
if (str_contains($name, 'same') || str_contains($id, 'same') || str_contains($name, 'copy') || str_contains($id, 'copy')) {
$hasSameCheckbox = true;
}
}
}
// Also check labels for "same as"
if (!$hasSameCheckbox) {
$labels = $xpath->query('.//label', $element);
if ($labels !== false) {
foreach ($labels as $label) {
if ($label instanceof DOMElement) {
$labelText = strtolower(trim($label->textContent));
if (str_contains($labelText, 'same as') || str_contains($labelText, 'aynı') || str_contains($labelText, 'kopyala')) {
$hasSameCheckbox = true;
break;
}
}
}
}
}
// If form has both shipping and billing fields but no "same as" checkbox
if ($hasShipping && $hasBilling && !$hasSameCheckbox) {
return $this->createViolation(
$element,
"Form requires re-entering shipping and billing information without providing a 'Same as shipping' checkbox or auto-fill option (WCAG 2.2 SC 3.3.7 Redundant Entry).",
"Add a checkbox (e.g. 'Billing address is the same as shipping address') or populate with previously entered user data to reduce cognitive burden."
);
}
return null;
}
}
+94
View File
@@ -0,0 +1,94 @@
<?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;
class RuleController
{
/**
* @var array<string, RuleInterface|AbstractRule>
*/
private array $rulesRegistry = [];
/**
* @param array<int, RuleInterface|AbstractRule>|null $rules
*/
public function __construct(?array $rules = null)
{
$allRules = $rules ?? RuleLevels::getRulesForLevel(9);
foreach ($allRules as $rule) {
$id = ($rule instanceof RuleInterface) ? $rule->getId() : (new \ReflectionClass($rule))->getShortName();
$this->rulesRegistry[$id] = $rule;
}
}
/**
* Get rule instance by ID.
*/
public function getRule(string $ruleId): RuleInterface|AbstractRule|null
{
return $this->rulesRegistry[$ruleId] ?? null;
}
/**
* Execute a specific rule directly on a DOM element.
*/
public function executeRule(string $ruleId, DOMElement $element): ?Violation
{
$rule = $this->getRule($ruleId);
if ($rule === null) {
return null;
}
if ($rule instanceof RuleInterface) {
return $rule->check($element);
}
return null;
}
/**
* Filter active rules by WCAG standard (A, AA, AAA).
*
* @return array<string, RuleInterface|AbstractRule>
*/
public function filterByStandard(WCAGStandard $standard): array
{
$filtered = [];
foreach ($this->rulesRegistry as $id => $rule) {
if (method_exists($rule, 'getStandard') && $rule->getStandard() === $standard) {
$filtered[$id] = $rule;
}
}
return $filtered;
}
/**
* Filter active rules by severity.
*
* @return array<string, RuleInterface|AbstractRule>
*/
public function filterBySeverity(Severity $severity): array
{
$filtered = [];
foreach ($this->rulesRegistry as $id => $rule) {
if (method_exists($rule, 'getSeverity') && $rule->getSeverity() === $severity) {
$filtered[$id] = $rule;
}
}
return $filtered;
}
/**
* Total number of registered rules in this controller.
*/
public function count(): int
{
return count($this->rulesRegistry);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
class RuleExecutionStats
{
/**
* @var array<string, array{invocations: int, total_time_ms: float, violations: int}>
*/
private array $stats = [];
/**
* Record a rule execution metric.
*/
public function record(string $ruleId, float $durationMs, bool $hasViolation = false): void
{
if (!isset($this->stats[$ruleId])) {
$this->stats[$ruleId] = [
'invocations' => 0,
'total_time_ms' => 0.0,
'violations' => 0,
];
}
$this->stats[$ruleId]['invocations']++;
$this->stats[$ruleId]['total_time_ms'] += $durationMs;
if ($hasViolation) {
$this->stats[$ruleId]['violations']++;
}
}
/**
* Get top slowest rules by total execution time.
*
* @return array<string, array{invocations: int, total_time_ms: float, avg_time_ms: float, violations: int}>
*/
public function getSlowestRules(int $limit = 10): array
{
$calculated = [];
foreach ($this->stats as $id => $data) {
$avg = $data['invocations'] > 0 ? ($data['total_time_ms'] / $data['invocations']) : 0.0;
$calculated[$id] = [
'invocations' => $data['invocations'],
'total_time_ms' => round($data['total_time_ms'], 3),
'avg_time_ms' => round($avg, 4),
'violations' => $data['violations'],
];
}
uasort($calculated, function ($a, $b) {
return $b['total_time_ms'] <=> $a['total_time_ms'];
});
return array_slice($calculated, 0, $limit, true);
}
/**
* Get all recorded statistics.
*
* @return array<string, array{invocations: int, total_time_ms: float, violations: int}>
*/
public function getAllStats(): array
{
return $this->stats;
}
/**
* Reset all stats counters.
*/
public function reset(): void
{
$this->stats = [];
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TextSpacing extends AbstractRule
{
public function getId(): string
{
return 'WCAG_1_4_12_TEXT_SPACING';
}
public function getDescription(): string
{
return 'Ensure text containers do not restrict line-height or clip content when text spacing is adjusted (WCAG 1.4.12).';
}
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 = strtolower($element->getAttribute('style'));
// Check line-height < 1.2 or <= 12px
if (preg_match('/line-height\s*:\s*([^;]+)/i', $style, $matches)) {
$lh = trim($matches[1]);
// Unitless number check
if (is_numeric($lh) && (float)$lh < 1.2 && (float)$lh > 0) {
return $this->createViolation(
$element,
"Inline line-height is set to {$lh}, which is below the recommended minimum of 1.2 to 1.5.",
"Increase line-height to at least 1.5 to satisfy WCAG 1.4.12 Text Spacing."
);
}
// Pixel check (e.g. 10px, 12px)
if (preg_match('/^(\d+(?:\.\d+)?)px$/i', $lh, $pxMatches)) {
$px = (float)$pxMatches[1];
if ($px < 14) {
return $this->createViolation(
$element,
"Inline line-height is fixed to {$px}px, risking text overlapping when resized.",
"Use relative line-height units (e.g. 1.5) instead of fixed small pixel values."
);
}
}
}
// Check fixed height combined with overflow: hidden on text containers
$textTags = ['p', 'div', 'span', 'article', 'section', 'blockquote', 'li'];
if (in_array(strtolower($element->tagName), $textTags, true)) {
if (str_contains($style, 'overflow:hidden') || str_contains($style, 'overflow: hidden')) {
if (preg_match('/(?:^|;)\s*height\s*:\s*(\d+)px/i', $style, $heightMatches)) {
$height = (int)$heightMatches[1];
$textLength = mb_strlen(trim($element->textContent));
if ($height <= 60 && $textLength > 60) {
return $this->createViolation(
$element,
"Fixed height ({$height}px) with overflow:hidden risks clipping text when user overrides text spacing.",
"Use min-height instead of fixed height or avoid overflow:hidden on text containers."
);
}
}
}
}
return null;
}
}