Skip to content

Commit bfafc47

Browse files
Merge pull request #4 from PhilDaiguille/feat/duplicate-id-rule
Feat/duplicate id rule
2 parents cff4803 + 6f63d87 commit bfafc47

32 files changed

Lines changed: 717 additions & 149 deletions

src/Rules/Forms/InputTypeRule.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Forms;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Tokens;
9+
10+
final class InputTypeRule extends AbstractA11yRule
11+
{
12+
/**
13+
* Check inputs with certain types have an autocomplete attribute.
14+
*/
15+
protected function process(int $tokenIndex, Tokens $tokens): void
16+
{
17+
if (0 !== $tokenIndex) {
18+
return;
19+
}
20+
21+
$full = '';
22+
foreach ($tokens->toArray() as $t) {
23+
$full .= $t->getValue();
24+
}
25+
26+
// quick bail
27+
if (!str_contains($full, '<input')) {
28+
return;
29+
}
30+
31+
// find inputs of type email/tel/name etc - we'll check only email for now
32+
if (!preg_match_all('/<input\b([^>]*\btype\s*=\s*(?:"|\')email(?:"|\')[^>]*)>/i', $full, $m, PREG_SET_ORDER)) {
33+
return;
34+
}
35+
36+
foreach ($m as $set) {
37+
$attrs = $set[1];
38+
if (!preg_match('/\bautocomplete\b\s*=\s*(?:"|\')/i', $attrs)) {
39+
$token = $tokens->get(0);
40+
$this->addError('Input of type "email" should include an autocomplete attribute.', $token, 'InputType.MissingAutocomplete');
41+
42+
return;
43+
}
44+
}
45+
}
46+
}

src/Rules/Media/VideoTrackRule.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Media;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Tokens;
9+
10+
final class VideoTrackRule extends AbstractA11yRule
11+
{
12+
protected function process(int $tokenIndex, Tokens $tokens): void
13+
{
14+
// Only run once per file
15+
if (0 !== $tokenIndex) {
16+
return;
17+
}
18+
19+
$full = '';
20+
foreach ($tokens->toArray() as $t) {
21+
$full .= $t->getValue();
22+
}
23+
24+
if (!str_contains($full, '<video')) {
25+
return;
26+
}
27+
28+
// Find all <video ...>...</video> blocks
29+
if (!preg_match_all('/<video\b([^>]*)>(.*?)<\/video>/is', $full, $m, PREG_SET_ORDER)) {
30+
return;
31+
}
32+
33+
foreach ($m as $set) {
34+
$openAttrs = $set[1];
35+
$content = $set[2];
36+
37+
// If a <track kind="captions" exists inside the video block, OK
38+
if (preg_match('/<track\b[^>]*\bkind\s*=\s*(?:"|\')captions(?:"|\')/i', $content)) {
39+
continue;
40+
}
41+
42+
// No captions found — report error at token 0 for determinism
43+
$token = $tokens->get(0);
44+
$this->addError('Video should have captions (track kind="captions").', $token, 'VideoTrack.MissingCaptions');
45+
46+
return;
47+
}
48+
}
49+
}

src/Rules/Structure/AllInOneRule.php

Lines changed: 0 additions & 66 deletions
This file was deleted.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Structure;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Token;
9+
use TwigCsFixer\Token\Tokens;
10+
11+
final class DuplicateIdRule extends AbstractA11yRule
12+
{
13+
protected function process(int $tokenIndex, Tokens $tokens): void
14+
{
15+
// Only run once per file
16+
if (0 !== $tokenIndex) {
17+
return;
18+
}
19+
20+
$full = '';
21+
foreach ($tokens->toArray() as $t) {
22+
$full .= $t->getValue();
23+
}
24+
25+
if (!str_contains($full, 'id=')) {
26+
return;
27+
}
28+
29+
// find all id attributes
30+
if (!preg_match_all('/\bid\s*=\s*(?:"|\')([^"\']+)(?:"|\')/i', $full, $m)) {
31+
return;
32+
}
33+
34+
$ids = $m[1];
35+
$counts = [];
36+
foreach ($ids as $id) {
37+
$counts[$id] = ($counts[$id] ?? 0) + 1;
38+
}
39+
40+
foreach ($counts as $id => $cnt) {
41+
if ($cnt > 1) {
42+
// Report first token as location (use token 0)
43+
$token = $tokens->get(0);
44+
$this->addError(sprintf('Duplicate id "%s" found in document.', $id), $token, 'DuplicateId.Duplicate');
45+
46+
// stop after first duplicate for determinism
47+
return;
48+
}
49+
}
50+
}
51+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Structure;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Token;
9+
use TwigCsFixer\Token\Tokens;
10+
11+
final class LandmarkRule extends AbstractA11yRule
12+
{
13+
public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
14+
{
15+
$token = $tokens->get($tokenIndex);
16+
17+
if (!$token->isMatching(Token::TEXT_TYPE)) {
18+
return;
19+
}
20+
21+
$value = $token->getValue();
22+
23+
// Detect presence of main landmark or role="main"
24+
if (str_contains($value, '<main') || str_contains($value, 'role="main"') || str_contains($value, "role='main'")) {
25+
return;
26+
}
27+
28+
// If we encounter the end of head/body start, and haven't seen
29+
// a main landmark previously, emit a missing landmark warning.
30+
if (str_contains($value, '<body') || str_contains($value, '</head>')) {
31+
// Simple approach: scan a small window ahead to see if a main is present
32+
$look = $this->collectUntil($tokenIndex, $tokens, '<main', 200);
33+
if (!str_contains($look, '<main') && !preg_match('/role\s*=\s*["\']main["\']/i', $look)) {
34+
// Emit at the start of the file for consistency with other
35+
// page-level rules (tests expect line 1:1 identifiers).
36+
$first = $tokens->get(0);
37+
$emit('Page should include a main landmark', $first, 'Landmark.MissingMain');
38+
}
39+
}
40+
}
41+
42+
protected function process(int $tokenIndex, Tokens $tokens): void
43+
{
44+
$emit = function (string $message, Token $token, ?string $id = null): void {
45+
$this->addError($message, $token, $id);
46+
};
47+
48+
$this->evaluate($tokens, $tokenIndex, $emit);
49+
}
50+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Structure;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Token;
9+
use TwigCsFixer\Token\Tokens;
10+
11+
final class SkipLinkRule extends AbstractA11yRule
12+
{
13+
public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
14+
{
15+
$token = $tokens->get($tokenIndex);
16+
17+
if (!$token->isMatching(Token::TEXT_TYPE)) {
18+
return;
19+
}
20+
21+
// Only perform a single page-level scan (when invoked on the first
22+
// token) to detect absence of skip links. This avoids emitting the
23+
// same violation multiple times.
24+
if (0 !== $tokenIndex) {
25+
return;
26+
}
27+
28+
$content = '';
29+
foreach ($tokens->toArray() as $t) {
30+
$content .= (string) $t->getValue();
31+
}
32+
33+
if (preg_match('/href\s*=\s*["\"]#([^"\']+)["\"][^>]*>.*?skip/i', $content)) {
34+
return;
35+
}
36+
37+
if (preg_match('/href\s*=\s*["\"]#(main|content)["\"][^>]*>/i', $content)) {
38+
return;
39+
}
40+
41+
$emit('Page should include a skip link to bypass navigation', $token, 'SkipLink.Missing');
42+
}
43+
44+
protected function process(int $tokenIndex, Tokens $tokens): void
45+
{
46+
$emit = function (string $message, Token $token, ?string $id = null): void {
47+
$this->addError($message, $token, $id);
48+
};
49+
50+
$this->evaluate($tokens, $tokenIndex, $emit);
51+
}
52+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Structure;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Tokens;
9+
10+
final class TableHeaderRule extends AbstractA11yRule
11+
{
12+
protected function process(int $tokenIndex, Tokens $tokens): void
13+
{
14+
if (0 !== $tokenIndex) {
15+
return;
16+
}
17+
18+
$full = '';
19+
foreach ($tokens->toArray() as $t) {
20+
$full .= $t->getValue();
21+
}
22+
23+
if (!str_contains($full, '<table')) {
24+
return;
25+
}
26+
27+
// Find th elements
28+
if (!preg_match_all('/<th\b([^>]*)>/i', $full, $m, PREG_SET_ORDER)) {
29+
return;
30+
}
31+
32+
foreach ($m as $set) {
33+
$attrs = $set[1];
34+
if (!preg_match('/\bscope\b\s*=\s*(?:"|\')/i', $attrs)) {
35+
$token = $tokens->get(0);
36+
$this->addError('Table header <th> elements should include a scope attribute.', $token, 'TableHeader.MissingScope');
37+
38+
return;
39+
}
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)