diff --git a/demo/broken.html b/demo/broken.html index c2d60e3..ffe096a 100644 --- a/demo/broken.html +++ b/demo/broken.html @@ -28,7 +28,7 @@
WAI-ARIA elements
- + @@ -49,6 +49,18 @@ + +

+ + + + + + + + +
Clickable div button without tabindex
+

This page has multiple accessibility issues.

diff --git a/src/Console/Command/ScanCommand.php b/src/Console/Command/ScanCommand.php index 8008803..67dc64f 100644 --- a/src/Console/Command/ScanCommand.php +++ b/src/Console/Command/ScanCommand.php @@ -12,6 +12,7 @@ use YakNet\AccessibilityConsole\AI\GeminiFixer; use YakNet\AccessibilityConsole\Core\Config; use YakNet\AccessibilityConsole\Core\Scanner; use YakNet\AccessibilityConsole\Rules\StandardRuleSet; +use YakNet\AccessibilityConsole\Rules\RuleLevels; use YakNet\AccessibilityConsole\Source\SourceLocator; class ScanCommand extends Command @@ -29,7 +30,8 @@ class ScanCommand extends Command ->addOption('project-path', null, InputOption::VALUE_REQUIRED, 'Base path for source mapping') ->addOption('crawl', null, InputOption::VALUE_NONE, 'Crawl internal links recursively') ->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Maximum crawling depth', '3') - ->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum number of pages to crawl', '20'); + ->addOption('max-pages', null, InputOption::VALUE_REQUIRED, 'Maximum number of pages to crawl', '20') + ->addOption('level', 'l', InputOption::VALUE_REQUIRED, 'Accessibility scan level (1-5)', '4'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -47,11 +49,19 @@ class ScanCommand extends Command $projectPath = '.'; } - $io->title("Accessibility Console - Scanning: $target"); + $levelOption = $input->getOption('level'); + $level = is_numeric($levelOption) ? (int)$levelOption : 4; + if ($level < 1 || $level > 5) { + $io->error("Scan level must be between 1 and 5."); + return Command::FAILURE; + } + + $io->title("Accessibility Console - Scanning: $target (Level $level)"); // 1. Setup Scanner $scanner = new Scanner(); - foreach (StandardRuleSet::all() as $rule) { + $rules = RuleLevels::getRulesForLevel($level); + foreach ($rules as $rule) { $scanner->addRule($rule); } diff --git a/src/Core/AbstractRule.php b/src/Core/AbstractRule.php index 33f9b13..8ebda4f 100644 --- a/src/Core/AbstractRule.php +++ b/src/Core/AbstractRule.php @@ -19,6 +19,11 @@ abstract class AbstractRule */ abstract public function getSeverity(): Severity; + public function getLevel(): int + { + return 4; + } + protected function createViolation(string $message, \DOMElement $element): Violation { $doc = $element->ownerDocument; diff --git a/src/Rules/AbstractRule.php b/src/Rules/AbstractRule.php index 552b44a..24ec3e0 100644 --- a/src/Rules/AbstractRule.php +++ b/src/Rules/AbstractRule.php @@ -14,6 +14,11 @@ abstract class AbstractRule implements RuleInterface abstract public function getStandard(): WCAGStandard; abstract public function getSeverity(): Severity; + public function getLevel(): int + { + return 4; + } + protected function createViolation(DOMElement $element, string $message, ?string $suggestion = null): Violation { $doc = $element->ownerDocument; diff --git a/src/Rules/AriaFocusable.php b/src/Rules/AriaFocusable.php new file mode 100644 index 0000000..49aac84 --- /dev/null +++ b/src/Rules/AriaFocusable.php @@ -0,0 +1,55 @@ + */ + private static array $interactiveRoles = [ + 'button', 'link', 'checkbox', 'menuitem', 'radio', 'textbox', + 'searchbox', 'slider', 'spinbutton', 'switch', 'tab' + ]; + + /** @var array */ + private static array $nativeFocusable = [ + 'a', 'button', 'input', 'select', 'textarea', 'iframe' + ]; + + public function getId(): string { return 'WCAG_2_1_1_ARIA_FOCUSABLE'; } + public function getDescription(): string { return 'Elements with interactive ARIA roles must be focusable (e.g. have a tabindex attribute).'; } + 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 (!$element->hasAttribute('role')) { + return null; + } + + $role = strtolower(trim($element->getAttribute('role'))); + if (!in_array($role, self::$interactiveRoles, true)) { + return null; + } + + $tagName = strtolower($element->tagName); + if (in_array($tagName, self::$nativeFocusable, true)) { + return null; + } + + if (!$element->hasAttribute('tabindex')) { + return $this->createViolation( + $element, + $this->getDescription(), + 'Add tabindex="0" to make the element focusable via keyboard navigation.' + ); + } + + return null; + } +} diff --git a/src/Rules/AriaHiddenFocusable.php b/src/Rules/AriaHiddenFocusable.php index 086843b..f5c3451 100644 --- a/src/Rules/AriaHiddenFocusable.php +++ b/src/Rules/AriaHiddenFocusable.php @@ -13,6 +13,7 @@ class AriaHiddenFocusable extends AbstractRule public function getDescription(): string { return 'Focusable elements must not be hidden with aria-hidden="true".'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 5; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/AriaRole.php b/src/Rules/AriaRole.php index 0b5d799..aaee7b8 100644 --- a/src/Rules/AriaRole.php +++ b/src/Rules/AriaRole.php @@ -78,4 +78,5 @@ class AriaRole extends AbstractRule public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 3; } } diff --git a/src/Rules/ButtonName.php b/src/Rules/ButtonName.php index 86180de..22e5bbd 100644 --- a/src/Rules/ButtonName.php +++ b/src/Rules/ButtonName.php @@ -13,6 +13,7 @@ class ButtonName extends AbstractRule public function getDescription(): string { return 'Buttons must have discernible text.'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 1; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/ColorContrast.php b/src/Rules/ColorContrast.php index 1ef844b..0ccbe40 100644 --- a/src/Rules/ColorContrast.php +++ b/src/Rules/ColorContrast.php @@ -13,6 +13,7 @@ class ColorContrast extends AbstractRule 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 getLevel(): int { return 5; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/DuplicateId.php b/src/Rules/DuplicateId.php index 5e5bbb2..a7faf03 100644 --- a/src/Rules/DuplicateId.php +++ b/src/Rules/DuplicateId.php @@ -48,4 +48,5 @@ class DuplicateId extends AbstractRule public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 3; } } diff --git a/src/Rules/EmptyHeading.php b/src/Rules/EmptyHeading.php new file mode 100644 index 0000000..cd048b5 --- /dev/null +++ b/src/Rules/EmptyHeading.php @@ -0,0 +1,35 @@ +tagName)) { + return null; + } + + $text = trim($element->textContent); + if ($text === '' && !$element->hasAttribute('aria-label') && !$element->hasAttribute('title')) { + return $this->createViolation( + $element, + $this->getDescription(), + 'Add descriptive text or an aria-label to the heading element.' + ); + } + + return null; + } +} diff --git a/src/Rules/EmptyLabel.php b/src/Rules/EmptyLabel.php new file mode 100644 index 0000000..e4ebf8e --- /dev/null +++ b/src/Rules/EmptyLabel.php @@ -0,0 +1,35 @@ +tagName) !== 'label') { + return null; + } + + $text = trim($element->textContent); + if ($text === '') { + return $this->createViolation( + $element, + $this->getDescription(), + 'Add text content to the label element to describe its associated input.' + ); + } + + return null; + } +} diff --git a/src/Rules/EmptyLink.php b/src/Rules/EmptyLink.php index 2120313..2dd49ed 100644 --- a/src/Rules/EmptyLink.php +++ b/src/Rules/EmptyLink.php @@ -13,6 +13,7 @@ class EmptyLink extends AbstractRule public function getDescription(): string { return 'Links must have discernible text.'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 1; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/FieldsetLegend.php b/src/Rules/FieldsetLegend.php index 86f7510..14e7f29 100644 --- a/src/Rules/FieldsetLegend.php +++ b/src/Rules/FieldsetLegend.php @@ -13,6 +13,7 @@ class FieldsetLegend extends AbstractRule public function getDescription(): string { return 'Fieldsets should have a legend.'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::WARNING; } + public function getLevel(): int { return 2; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/FormLabel.php b/src/Rules/FormLabel.php index 53968d2..4fbda08 100644 --- a/src/Rules/FormLabel.php +++ b/src/Rules/FormLabel.php @@ -13,6 +13,7 @@ class FormLabel extends AbstractRule public function getDescription(): string { return 'Form inputs must have an associated label.'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 2; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/HeadingOrder.php b/src/Rules/HeadingOrder.php index 104877f..77802ca 100644 --- a/src/Rules/HeadingOrder.php +++ b/src/Rules/HeadingOrder.php @@ -15,6 +15,7 @@ class HeadingOrder extends AbstractRule public function getDescription(): string { return 'Headings should follow a logical nesting order.'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::WARNING; } + public function getLevel(): int { return 2; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/HtmlHasLang.php b/src/Rules/HtmlHasLang.php index f344b88..42942af 100644 --- a/src/Rules/HtmlHasLang.php +++ b/src/Rules/HtmlHasLang.php @@ -13,6 +13,7 @@ class HtmlHasLang extends AbstractRule public function getDescription(): string { return 'The html element must have a lang attribute.'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 1; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/IframeTitle.php b/src/Rules/IframeTitle.php index 548d24c..13f1285 100644 --- a/src/Rules/IframeTitle.php +++ b/src/Rules/IframeTitle.php @@ -13,6 +13,7 @@ class IframeTitle extends AbstractRule public function getDescription(): string { return 'Iframe elements must have a non-empty title attribute to describe their content.'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 2; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/ImgAltText.php b/src/Rules/ImgAltText.php index d302e5b..b01ad0f 100644 --- a/src/Rules/ImgAltText.php +++ b/src/Rules/ImgAltText.php @@ -13,6 +13,7 @@ class ImgAltText extends AbstractRule public function getDescription(): string { return 'Images must have an alt attribute.'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 1; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/PageTitle.php b/src/Rules/PageTitle.php index b9910ef..f71970c 100644 --- a/src/Rules/PageTitle.php +++ b/src/Rules/PageTitle.php @@ -13,6 +13,7 @@ class PageTitle extends AbstractRule public function getDescription(): string { return 'Web pages must have a non-empty title element in the head.'; } public function getStandard(): WCAGStandard { return WCAGStandard::A; } public function getSeverity(): Severity { return Severity::ERROR; } + public function getLevel(): int { return 1; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/RuleLevels.php b/src/Rules/RuleLevels.php new file mode 100644 index 0000000..69d8e4c --- /dev/null +++ b/src/Rules/RuleLevels.php @@ -0,0 +1,70 @@ + + */ + public static function getRulesForLevel(int $level): array + { + $rules = []; + $files = glob(__DIR__ . '/*.php'); + if ($files === false) { + return []; + } + + foreach ($files as $file) { + $className = pathinfo($file, PATHINFO_FILENAME); + $fqcn = 'YakNet\AccessibilityConsole\Rules\\' . $className; + + if (class_exists($fqcn)) { + $reflection = new \ReflectionClass($fqcn); + + if ($reflection->isAbstract() || $reflection->isInterface() || $reflection->isTrait()) { + continue; + } + + // Check if it is a concrete rule + $isRule = $reflection->isSubclassOf(\YakNet\AccessibilityConsole\Core\AbstractRule::class) || + $reflection->implementsInterface(\YakNet\AccessibilityConsole\Rules\RuleInterface::class); + + if ($isRule) { + // Exclude Turkish custom rules to match standard behavior + if (in_array($className, ['EmptyLinkRule', 'HeadingOrderRule', 'ImageAltRule'], true)) { + continue; + } + + $instance = $reflection->newInstance(); + + // Check level + $ruleLevel = 4; // default + if (method_exists($instance, 'getLevel')) { + /** @var mixed $levelVal */ + $levelVal = $instance->getLevel(); + if (is_int($levelVal)) { + $ruleLevel = $levelVal; + } + } + + if ($ruleLevel <= $level) { + /** @var \YakNet\AccessibilityConsole\Rules\RuleInterface|\YakNet\AccessibilityConsole\Core\AbstractRule $instance */ + $rules[] = $instance; + } + } + } + } + + // Sort rules by class name to ensure deterministic order + usort($rules, function ($a, $b) { + return strcmp(get_class($a), get_class($b)); + }); + + return $rules; + } +} diff --git a/src/Rules/StandardRuleSet.php b/src/Rules/StandardRuleSet.php index c4b1638..6b95c08 100644 --- a/src/Rules/StandardRuleSet.php +++ b/src/Rules/StandardRuleSet.php @@ -5,31 +5,10 @@ namespace YakNet\AccessibilityConsole\Rules; class StandardRuleSet { /** - * @return RuleInterface[] + * @return array */ public static function all(): array { - /** @var RuleInterface[] $rules */ - $rules = [ - new HtmlHasLang(), - new ImgAltText(), - new EmptyLink(), - new ButtonName(), - new FormLabel(), - new FieldsetLegend(), - new HeadingOrder(), - new MetaViewport(), - new TabindexOrder(), - new ColorContrast(), - new DuplicateId(), - new AriaRole(), - new IframeTitle(), - new LinkTextGeneric(), - new PlaceholderAsLabel(), - new ImageAltRedundant(), - new AriaHiddenFocusable(), - new PageTitle(), - ]; - return $rules; + return RuleLevels::getRulesForLevel(5); } } diff --git a/src/Rules/TabindexOrder.php b/src/Rules/TabindexOrder.php index ff1142c..1b0111a 100644 --- a/src/Rules/TabindexOrder.php +++ b/src/Rules/TabindexOrder.php @@ -13,6 +13,7 @@ class TabindexOrder extends AbstractRule 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 getLevel(): int { return 3; } public function check(DOMElement $element): ?Violation { diff --git a/src/Rules/VideoTrack.php b/src/Rules/VideoTrack.php new file mode 100644 index 0000000..4675f46 --- /dev/null +++ b/src/Rules/VideoTrack.php @@ -0,0 +1,36 @@ +tagName) !== 'video') { + return null; + } + + // Check if there is a track element inside + $tracks = $element->getElementsByTagName('track'); + if ($tracks->length === 0) { + return $this->createViolation( + $element, + $this->getDescription(), + 'Add a or element inside the video element.' + ); + } + + return null; + } +}