Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions AGENTS.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions src/Rules/Forms/InputLabelRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
return;
}

// Ignore hidden inputs: they don't require accessible labels
if (preg_match('/\btype\s*=\s*["\']hidden["\']/i', $opening)) {
return;
}

// Id present?
$id = null;
if (preg_match('/\bid\s*=\s*(?:"|\')([^"\']+)(?:"|\')/i', $opening, $m)) {
Expand Down
31 changes: 20 additions & 11 deletions src/Rules/Structure/LandmarkRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,26 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
return;
}

// If we encounter the end of head/body start, and haven't seen
// a main landmark previously, emit a missing landmark warning.
if (str_contains($value, '<body') || str_contains($value, '</head>')) {
// Simple approach: scan a small window ahead to see if a main is present
$look = $this->collectUntil($tokenIndex, $tokens, '<main', 200);
if (!str_contains($look, '<main') && !preg_match('/role\s*=\s*["\']main["\']/i', $look)) {
// Emit at the start of the file for consistency with other
// page-level rules (tests expect line 1:1 identifiers).
$first = $tokens->get(0);
$emit('Page should include a main landmark', $first, 'Landmark.MissingMain');
}
// This rule is page-level. Only evaluate once per file (at tokenIndex 0)
// and only if the content looks like a full HTML page (contains
// a <body> or a <!DOCTYPE). This avoids flagging fragments/partials.
if (0 !== $tokenIndex) {
return;
}

$full = $this->getFullContent($tokens);

// If this looks like a fragment (no body/doctype), skip evaluation
if (!str_contains($full, '<body') && !str_contains(strtoupper($full), '<!DOCTYPE')) {
return;
}

// Scan the full content for a main landmark or role="main"
if (str_contains($full, '<main') || preg_match('/role\s*=\s*["\']main["\']/i', $full)) {
return;
}

$first = $tokens->get(0);
$emit('Page should include a main landmark', $first, 'Landmark.MissingMain');
}
}
14 changes: 9 additions & 5 deletions src/Rules/Structure/SkipLinkRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,24 @@ public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
return;
}

// Only perform a single page-level scan (when invoked on the first
// token) to detect absence of skip links. This avoids emitting the
// same violation multiple times.
// Page-level rule: only run once (tokenIndex 0) and only for full
// pages (containing <body> or <!DOCTYPE). This avoids reporting on
// partials/components.
if (0 !== $tokenIndex) {
return;
}

$content = $this->getFullContent($tokens);

if (preg_match('/href\s*=\s*["\"]#([^"\']+)["\"][^>]*>.*?skip/i', $content)) {
if (!str_contains($content, '<body') && !str_contains(strtoupper($content), '<!DOCTYPE')) {
return;
}

if (preg_match('/href\s*=\s*["\"]#(main|content)["\"][^>]*>/i', $content)) {
if (preg_match('/href\s*=\s*["\']#([^"\']+)["\'][^>]*>.*?skip/i', $content)) {
return;
}

if (preg_match('/href\s*=\s*["\']#(main|content)["\'][^>]*>/i', $content)) {
return;
}

Expand Down
5 changes: 5 additions & 0 deletions tests/Rules/Forms/Fixtures/valid/input_hidden.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<html>
<body>
html<input type="hidden" name="token" value="abc">
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<html>
<body>
<input type="text" aria-label="{{ dynamic_label }}">
</body>
</html>
6 changes: 6 additions & 0 deletions tests/Rules/Forms/InputLabelRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,11 @@ public static function provideFixtures(): iterable
__DIR__.'/Fixtures/invalid/input_no_label.html.twig',
['InputLabel.InputLabel.MissingLabel:3:1' => 'Input element must have an associated <label> or an aria-label.'],
];

// Hidden inputs should not trigger the rule
yield 'input hidden' => [__DIR__.'/Fixtures/valid/input_hidden.html.twig', []];

// Dynamic aria-label (Twig var) should be considered present
yield 'input with aria variable' => [__DIR__.'/Fixtures/valid/input_with_aria_variable.html.twig', []];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<header>Nav</header>
<div>Content without main</div>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{# partial component - should not be treated as a full page #}
<div class="card">{{ content }}</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{# partial fragment with no <html> tag - should be valid for page-level rules #}
<div class="snippet">{{ title }}</div>
8 changes: 8 additions & 0 deletions tests/Rules/Structure/LandmarkRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,13 @@ public static function provideFixtures(): iterable
yield 'missing main' => [__DIR__.'/Fixtures/invalid/no_main.html.twig', [
'Landmark.Landmark.MissingMain:1:1' => 'Page should include a main landmark',
]];

// Partials/fragments without <body> should NOT trigger the page-level rule
yield 'partial fragment' => [__DIR__.'/Fixtures/valid/partial_component_fragment.html.twig', []];

// Ensure only one error is emitted when the document has </head><body>
yield 'duplicate trigger check' => [__DIR__.'/Fixtures/invalid/duplicate_landmark_trigger.html.twig', [
'Landmark.Landmark.MissingMain:1:1' => 'Page should include a main landmark',
]];
}
}
3 changes: 3 additions & 0 deletions tests/Rules/Structure/LangAttributeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,8 @@ public static function provideFixtures(): iterable
__DIR__.'/Fixtures/invalid/html_no_lang.html.twig',
['LangAttribute.LangAttribute.MissingLang:2:1' => 'The <html> element should have a lang attribute.'],
];

// Partial fragments (no <html>) should not trigger the rule
yield 'partial fragment no html' => [__DIR__.'/Fixtures/valid/partial_no_html.html.twig', []];
}
}
6 changes: 3 additions & 3 deletions tests/Rules/Structure/SkipLinkRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ public static function provideFixtures(): iterable

yield 'valid main id' => [__DIR__.'/Fixtures/valid/main_id.html.twig', []];

yield 'missing skip link' => [__DIR__.'/Fixtures/invalid/no_skip_link.html.twig', [
'SkipLink.SkipLink.Missing:1:1' => 'Page should include a skip link to bypass navigation',
]];
// This fixture is a fragment (no <body> or <!DOCTYPE>); skip link
// rule is page-level and should NOT emit on partials.
yield 'missing skip link' => [__DIR__.'/Fixtures/invalid/no_skip_link.html.twig', []];
}
}
Loading