Compare commits

..
4 Commits
19 changed files with 457 additions and 40 deletions
+16 -1
View File
@@ -4,7 +4,7 @@
<title>Broken Page</title>
</head>
<body>
<h1>Welcome to a broken page</h1>
<h1 id="main-content">Welcome to a broken page</h1>
<!-- Missing alt attribute -->
<img src="logo.png">
@@ -15,6 +15,21 @@
<!-- Empty button -->
<button type="submit"></button>
<!-- Positive tabindex -->
<a href="/keyboard" tabindex="5">Keyboard test</a>
<!-- Duplicate ID -->
<div id="main-content">This is a duplicate main-content ID div!</div>
<!-- Low Contrast -->
<p style="color: #888888; background-color: #999999;">This text has extremely low contrast ratio!</p>
<!-- Invalid ARIA role and missing controls target -->
<div role="cool-widget" aria-controls="non-existent-popup">WAI-ARIA elements</div>
<!-- Missing Iframe Title -->
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>
<p>This page has multiple accessibility issues.</p>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
parameters:
level: 9
paths:
- src
+11 -3
View File
@@ -30,8 +30,16 @@ class FixCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
$projectPath = $input->getOption('project-path') ?? getcwd();
$dryRun = $input->getOption('dry-run');
if (!is_string($target)) {
$io->error("Target must be a string URL or file path.");
return Command::FAILURE;
}
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$dryRun = (bool)$input->getOption('dry-run');
$config = new Config();
$apiKey = $config->getGeminiApiKey();
@@ -45,7 +53,7 @@ class FixCommand extends Command
// 1. Fetch HTML
$html = @file_get_contents($target);
if (!$html) {
if ($html === false || $html === '') {
$io->error("Could not read target: $target");
return Command::FAILURE;
}
+13 -5
View File
@@ -16,7 +16,7 @@ use YakNet\AccessibilityConsole\Source\SourceLocator;
class ScanCommand extends Command
{
protected static $defaultName = 'scan';
protected static ?string $defaultName = 'scan';
protected function configure(): void
{
@@ -33,14 +33,22 @@ class ScanCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$target = $input->getArgument('target');
$enableAi = $input->getOption('ai');
$projectPath = $input->getOption('project-path') ?? getcwd();
if (!is_string($target)) {
$io->error("Target must be a string URL or file path.");
return Command::FAILURE;
}
$enableAi = (bool)$input->getOption('ai');
$projectPathOption = $input->getOption('project-path');
$projectPath = is_string($projectPathOption) ? $projectPathOption : getcwd();
if ($projectPath === false) {
$projectPath = '.';
}
$io->title("Accessibility Console - Scanning: $target");
// 1. Fetch HTML
$html = @file_get_contents($target);
if (!$html) {
if ($html === false || $html === '') {
$io->error("Could not read target: $target");
return Command::FAILURE;
}
@@ -97,7 +105,7 @@ class ScanCommand extends Command
// Export Report
$reportPath = $input->getOption('report');
if ($reportPath) {
if (is_string($reportPath) && $reportPath !== '') {
$extension = pathinfo($reportPath, PATHINFO_EXTENSION);
if ($extension === 'json') {
$data = array_map(fn($v) => $v->toArray(), $violations);
+10 -1
View File
@@ -21,12 +21,21 @@ abstract class AbstractRule
protected function createViolation(string $message, \DOMElement $element): Violation
{
$doc = $element->ownerDocument;
$html = '';
if ($doc !== null) {
$htmlVal = $doc->saveHTML($element);
if (is_string($htmlVal)) {
$html = $htmlVal;
}
}
return new Violation(
ruleId: (new \ReflectionClass($this))->getShortName(),
message: $message,
severity: $this->getSeverity(),
standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element)
htmlSnippet: $html
);
}
}
+4
View File
@@ -4,8 +4,12 @@ namespace YakNet\AccessibilityConsole\Core;
class Config
{
/** @var array<string, mixed> */
private array $settings = [];
/**
* @param array<string, mixed> $settings
*/
public function __construct(array $settings = [])
{
$this->settings = $settings;
+15 -18
View File
@@ -4,7 +4,7 @@ namespace YakNet\AccessibilityConsole\Core;
class RuleEngine
{
/** @var array */
/** @var array<int, mixed> */
private array $rules = [];
public function addRule(mixed $rule): void
@@ -19,24 +19,21 @@ class RuleEngine
{
$allViolations = [];
foreach ($this->rules as $rule) {
if (method_exists($rule, 'check')) {
// Determine if it's a document-level rule or element-level rule
$reflection = new \ReflectionMethod($rule, 'check');
$params = $reflection->getParameters();
if (isset($params[0]) && $params[0]->getType() && $params[0]->getType()->getName() === 'DOMDocument') {
// Document-level rule (Core\AbstractRule style)
$violations = $rule->check($doc);
$allViolations = array_merge($allViolations, $violations);
} else {
// Element-level rule (Rules\RuleInterface style)
// We need to iterate over all elements
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*');
if ($rule instanceof AbstractRule) {
// Document-level rule (Core\AbstractRule style)
$violations = $rule->check($doc);
$allViolations = array_merge($allViolations, $violations);
} elseif ($rule instanceof \YakNet\AccessibilityConsole\Rules\RuleInterface) {
// Element-level rule (Rules\RuleInterface style)
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*');
if ($elements !== false) {
foreach ($elements as $element) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
if ($element instanceof \DOMElement) {
$violation = $rule->check($element);
if ($violation) {
$allViolations[] = $violation;
}
}
}
}
+4 -4
View File
@@ -4,11 +4,11 @@ namespace YakNet\AccessibilityConsole\Core;
class Scanner
{
public function __construct(private ?RuleEngine $engine = null)
private RuleEngine $engine;
public function __construct(?RuleEngine $engine = null)
{
if ($this->engine === null) {
$this->engine = new RuleEngine();
}
$this->engine = $engine ?? new RuleEngine();
}
public function addRule(mixed $rule): void
+6
View File
@@ -4,6 +4,9 @@ namespace YakNet\AccessibilityConsole\Core;
class Violation
{
/**
* @param array{file: string, line: int}|null $location
*/
public function __construct(
public string $ruleId,
public string $message,
@@ -14,6 +17,9 @@ class Violation
public ?string $fixSuggestion = null
) {}
/**
* @return array{ruleId: string, message: string, severity: string, standard: string, htmlSnippet: string, location: array{file: string, line: int}|null, fixSuggestion: string|null}
*/
public function toArray(): array
{
return [
+10 -1
View File
@@ -16,12 +16,21 @@ abstract class AbstractRule implements RuleInterface
protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation
{
$doc = $element->ownerDocument;
$html = '';
if ($doc !== null) {
$htmlVal = $doc->saveHTML($element);
if (is_string($htmlVal)) {
$html = $htmlVal;
}
}
return new Violation(
ruleId: $this->getId(),
message: $message,
severity: $this->getSeverity(),
standard: $this->getStandard(),
htmlSnippet: $element->ownerDocument->saveHTML($element),
htmlSnippet: $html,
fixSuggestion: $suggestion
);
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class AriaRole extends AbstractRule
{
/** @var array<int, string> */
private static array $validRoles = [
'alert', 'alertdialog', 'application', 'article', 'banner', 'button', 'cell', 'checkbox',
'columnheader', 'combobox', 'complementary', 'contentinfo', 'definition', 'dialog', 'directory',
'document', 'feed', 'figure', 'form', 'grid', 'gridcell', 'group', 'heading', 'img', 'link',
'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'menu', 'menubar', 'menuitem',
'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'presentation',
'progressbar', 'radio', 'radiogroup', 'region', 'row', 'rowgroup', 'rowheader', 'scrollbar',
'search', 'searchbox', 'separator', 'slider', 'spinbutton', 'status', 'switch', 'tab', 'table',
'tablist', 'tabpanel', 'term', 'textbox', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid',
'treeitem'
];
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
// 1. Check WAI-ARIA Role Validity
$roleElements = $xpath->query('//*[@role]');
if ($roleElements !== false) {
foreach ($roleElements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$role = trim($el->getAttribute('role'));
if ($role === '') {
continue;
}
$roles = array_filter(explode(' ', $role));
foreach ($roles as $r) {
if (!in_array(strtolower($r), self::$validRoles, true)) {
$violations[] = $this->createViolation(
"Invalid ARIA role \"{$r}\" declared on element. Must use standard WAI-ARIA roles.",
$el
);
}
}
}
}
// 2. Check aria-controls Targets Existence
$controlsElements = $xpath->query('//*[@aria-controls]');
if ($controlsElements !== false) {
foreach ($controlsElements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$targetsStr = trim($el->getAttribute('aria-controls'));
if ($targetsStr === '') {
continue;
}
$targets = array_filter(explode(' ', $targetsStr));
foreach ($targets as $targetId) {
$targetQuery = $xpath->query('//*[@id="' . $targetId . '"]');
if ($targetQuery !== false && $targetQuery->length === 0) {
$violations[] = $this->createViolation(
"Attribute aria-controls=\"{$targetId}\" targets an element that does not exist in the document.",
$el
);
}
}
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class ColorContrast extends AbstractRule
{
public function getId(): string { return 'WCAG_1_4_3_CONTRAST'; }
public function getDescription(): string { return 'Text must have sufficient contrast against the background (4.5:1 minimum).'; }
public function getStandard(): WCAGStandard { return WCAGStandard::AA; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function check(DOMElement $element): ?Violation
{
if (!$element->hasAttribute('style')) {
return null;
}
$style = $element->getAttribute('style');
$color = $this->parseColorValue($style, 'color');
$bg = $this->parseColorValue($style, 'background-color') ?: $this->parseColorValue($style, 'background');
if ($color === null || $bg === null) {
return null;
}
$cRGB = $this->hexToRgb($color);
$bgRGB = $this->hexToRgb($bg);
if ($cRGB === null || $bgRGB === null) {
return null;
}
$cLum = $this->getLuminance($cRGB[0], $cRGB[1], $cRGB[2]);
$bgLum = $this->getLuminance($bgRGB[0], $bgRGB[1], $bgRGB[2]);
$ratio = ($cLum > $bgLum) ? ($cLum + 0.05) / ($bgLum + 0.05) : ($bgLum + 0.05) / ($cLum + 0.05);
$isLarge = false;
if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) {
$val = (float)$matches[1];
$unit = strtolower($matches[2]);
if (($unit === 'px' && $val >= 24) || ($unit === 'pt' && $val >= 18) || (($unit === 'em' || $unit === 'rem') && $val >= 1.5)) {
$isLarge = true;
}
}
if (preg_match('/font-weight\s*:\s*(bold|[7-9]00)/i', $style)) {
if (preg_match('/font-size\s*:\s*([0-9.]+)(px|em|rem|pt)/i', $style, $matches)) {
$val = (float)$matches[1];
$unit = strtolower($matches[2]);
if (($unit === 'px' && $val >= 18) || ($unit === 'pt' && $val >= 14) || (($unit === 'em' || $unit === 'rem') && $val >= 1.2)) {
$isLarge = true;
}
}
}
$minRatio = $isLarge ? 3.0 : 4.5;
if ($ratio < $minRatio) {
$formattedRatio = round($ratio, 2);
return $this->createViolation(
$element,
"Insufficient contrast ratio of {$formattedRatio}:1. Minimum required is {$minRatio}:1.",
"Adjust colors to increase contrast. For example, text: {$color}, background: {$bg}."
);
}
return null;
}
private function parseColorValue(string $style, string $property): ?string
{
if (preg_match('/(?:^|;)\s*' . preg_quote($property, '/') . '\s*:\s*([^;]+)/i', $style, $matches)) {
$value = trim($matches[1]);
if (preg_match('/^#([0-9a-f]{3,6})$/i', $value)) {
return $value;
}
$colors = [
'black' => '#000000', 'white' => '#ffffff', 'red' => '#ff0000',
'green' => '#008000', 'blue' => '#0000ff', 'yellow' => '#ffff00',
'gray' => '#808080', 'grey' => '#808080', 'silver' => '#c0c0c0',
];
if (isset($colors[strtolower($value)])) {
return $colors[strtolower($value)];
}
}
return null;
}
/**
* @return array{0: int, 1: int, 2: int}|null
*/
private function hexToRgb(string $hex): ?array
{
$hex = ltrim($hex, '#');
if (strlen($hex) === 3) {
$r = (int)hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
$g = (int)hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
$b = (int)hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
return [$r, $g, $b];
} elseif (strlen($hex) === 6) {
$r = (int)hexdec(substr($hex, 0, 2));
$g = (int)hexdec(substr($hex, 2, 2));
$b = (int)hexdec(substr($hex, 4, 2));
return [$r, $g, $b];
}
return null;
}
private function getLuminance(float $r, float $g, float $b): float
{
$rs = $r / 255.0;
$gs = $g / 255.0;
$bs = $b / 255.0;
$rVal = ($rs <= 0.03928) ? $rs / 12.92 : pow(($rs + 0.055) / 1.055, 2.4);
$gVal = ($gs <= 0.03928) ? $gs / 12.92 : pow(($gs + 0.055) / 1.055, 2.4);
$bVal = ($bs <= 0.03928) ? $bs / 12.92 : pow(($bs + 0.055) / 1.055, 2.4);
return 0.2126 * $rVal + 0.7152 * $gVal + 0.0722 * $bVal;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use YakNet\AccessibilityConsole\Core\AbstractRule;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class DuplicateId extends AbstractRule
{
public function check(\DOMDocument $doc): array
{
$violations = [];
$xpath = new \DOMXPath($doc);
$elements = $xpath->query('//*[@id]');
if ($elements === false) {
return [];
}
$ids = [];
foreach ($elements as $el) {
if (!$el instanceof \DOMElement) {
continue;
}
$id = $el->getAttribute('id');
if ($id === '') {
continue;
}
if (!isset($ids[$id])) {
$ids[$id] = [];
}
$ids[$id][] = $el;
}
foreach ($ids as $id => $elementsWithId) {
if (count($elementsWithId) > 1) {
foreach ($elementsWithId as $el) {
$violations[] = $this->createViolation(
"Duplicate ID \"{$id}\" found. Element ID attribute must be unique across the document.",
$el
);
}
}
}
return $violations;
}
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
}
+7 -4
View File
@@ -29,10 +29,13 @@ class FormLabel extends AbstractRule
if ($element->hasAttribute('id')) {
$id = $element->getAttribute('id');
$xpath = new \DOMXPath($element->ownerDocument);
$labels = $xpath->query("//label[@for='$id']");
if ($labels->length > 0) {
return null;
$doc = $element->ownerDocument;
if ($doc !== null) {
$xpath = new \DOMXPath($doc);
$labels = $xpath->query("//label[@for='$id']");
if ($labels !== false && $labels->length > 0) {
return null;
}
}
}
+6
View File
@@ -13,9 +13,15 @@ class HeadingOrderRule extends AbstractRule
$violations = [];
$xpath = new \DOMXPath($doc);
$headings = $xpath->query('//h1|//h2|//h3|//h4|//h5|//h6');
if ($headings === false) {
return [];
}
$lastLevel = 0;
foreach ($headings as $h) {
if (!$h instanceof \DOMElement) {
continue;
}
$level = (int)substr($h->tagName, 1);
if ($lastLevel > 0 && $level > $lastLevel + 1) {
$violations[] = $this->createViolation(
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class IframeTitle extends AbstractRule
{
public function getId(): string { return 'WCAG_4_1_2_IFRAME_TITLE'; }
public function getDescription(): string { return 'Iframe elements must have a non-empty title attribute to describe their content.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::ERROR; }
public function check(DOMElement $element): ?Violation
{
if (strtolower($element->tagName) !== 'iframe') {
return null;
}
if (!$element->hasAttribute('title') || trim($element->getAttribute('title')) === '') {
return $this->createViolation(
$element,
"Iframe element is missing a descriptive title attribute.",
"Add a descriptive title attribute, e.g., title=\"Embedded Map\" or title=\"Video Player\"."
);
}
return null;
}
}
+8 -1
View File
@@ -9,7 +9,8 @@ class StandardRuleSet
*/
public static function all(): array
{
return [
/** @var RuleInterface[] $rules */
$rules = [
new HtmlHasLang(),
new ImgAltText(),
new EmptyLink(),
@@ -18,6 +19,12 @@ class StandardRuleSet
new FieldsetLegend(),
new HeadingOrder(),
new MetaViewport(),
new TabindexOrder(),
new ColorContrast(),
new DuplicateId(),
new AriaRole(),
new IframeTitle(),
];
return $rules;
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace YakNet\AccessibilityConsole\Rules;
use DOMElement;
use YakNet\AccessibilityConsole\Core\Severity;
use YakNet\AccessibilityConsole\Core\Violation;
use YakNet\AccessibilityConsole\Core\WCAGStandard;
class TabindexOrder extends AbstractRule
{
public function getId(): string { return 'WCAG_2_4_3_TABINDEX'; }
public function getDescription(): string { return 'Avoid positive tabindex values to maintain natural keyboard focus order.'; }
public function getStandard(): WCAGStandard { return WCAGStandard::A; }
public function getSeverity(): Severity { return Severity::WARNING; }
public function check(DOMElement $element): ?Violation
{
if ($element->hasAttribute('tabindex')) {
$tabindex = $element->getAttribute('tabindex');
if (is_numeric($tabindex)) {
$val = (int)$tabindex;
if ($val > 0) {
return $this->createViolation(
$element,
"Avoid positive tabindex values (tabindex=\"{$val}\") to preserve the natural tab order.",
"Remove the positive tabindex or change it to \"0\" or \"-1\"."
);
}
}
}
return null;
}
}
+17 -2
View File
@@ -8,19 +8,24 @@ use SplFileInfo;
class SourceLocator
{
/** @var array<int, string> */
private array $extensions = ['php', 'html', 'twig', 'blade.php'];
public function __construct(
private readonly string $searchPath
) {}
/**
* @return array{file: string, line: int}|null
*/
public function locate(string $snippet): ?array
{
if (!is_dir($this->searchPath) || !is_readable($this->searchPath)) {
return null;
}
$cleanSnippet = trim(preg_replace('/\s+/', ' ', $snippet));
$replaced = preg_replace('/\s+/', ' ', $snippet);
$cleanSnippet = is_string($replaced) ? trim($replaced) : '';
if ($cleanSnippet === '') {
return null;
}
@@ -76,6 +81,9 @@ class SourceLocator
return false;
}
/**
* @return array{type: 'exact', location: array{file: string, line: int}}|array{type: 'fuzzy', score: float, location: array{file: string, line: int}}|array{type: 'none'}
*/
private function searchInFile(string $filepath, string $searchSnippet, string $fullSnippet): array
{
$content = file_get_contents($filepath);
@@ -91,7 +99,8 @@ class SourceLocator
foreach ($lines as $index => $line) {
$lineNumber = $index + 1;
$cleanLine = trim(preg_replace('/\s+/', ' ', $line));
$replacedLine = preg_replace('/\s+/', ' ', $line);
$cleanLine = is_string($replacedLine) ? trim($replacedLine) : '';
if (str_contains($cleanLine, $searchSnippet)) {
return [
@@ -118,6 +127,9 @@ class SourceLocator
return ['type' => 'none'];
}
/**
* @return array{tag: string|null, attributes: array<string, string>}
*/
private function parseSnippet(string $snippet): array
{
preg_match('/<([a-zA-Z0-9]+)/', $snippet, $matches);
@@ -133,6 +145,9 @@ class SourceLocator
return ['tag' => $tagName, 'attributes' => $attributes];
}
/**
* @param array{tag: string|null, attributes: array<string, string>} $parsed
*/
private function calculateFuzzyScore(string $line, array $parsed): float
{
if (!$parsed['tag'] || !str_contains(strtolower($line), '<' . strtolower($parsed['tag']))) {