Git Komut Rehberi & Terminal Asistanı
Sıfırdan Yeni Bir Depo Başlatın:
Mevcut Bir Git Deposu veya Klasörü Gönderin:
Hataları Geri Alma, İptal & Temizlik:
Son commit'i geri al (yapılan değişiklikleri korur):
git reset --soft HEAD~1
Yapılan yerel değişiklikleri iptal et (çalışma ağacını temizler):
git restore .
Takip edilmeyen gereksiz dosyaları sil:
git clean -fd
Değişiklikleri geçici sakla ve geri getir (stash):
git stash git stash pop
Dal (Branch) ve Sürüm Etiketleri:
Yeni dal aç ve geçiş yap:
git checkout -b yeni-ozellik git push -u origin yeni-ozellik
Sürüm etiketi (tag) oluştur ve gönder:
git tag -a v1.0.0 -m "Sürüm 1.0.0" git push origin --tags
feat(v3.3.0): enterprise presets, badges, notifications, rule controller, watch mode, offline healer, and WCAG 2.2 ruleset
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace YakNet\AccessibilityConsole\Notifications;
|
||||
|
||||
interface NotifierInterface
|
||||
{
|
||||
public function send(NotificationPayload $payload): bool;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user