Skip to content

Commit 0279ad0

Browse files
committed
feat(rules): add new accessibility rule tests for various HTML elements and attributes
1 parent 3ed1939 commit 0279ad0

71 files changed

Lines changed: 1714 additions & 10 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/release-drafter.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name-template: 'v$RESOLVED_VERSION'
2+
tag-template: 'v$RESOLVED_VERSION'
3+
change-template: '- $TITLE (#$NUMBER) @$AUTHOR'
4+
template: |
5+
## Changes
6+
7+
$CHANGES
8+
9+
version-resolver:
10+
major:
11+
labels:
12+
- 'breaking'
13+
minor:
14+
labels:
15+
- 'feature'
16+
- 'enhancement'
17+
patch:
18+
labels:
19+
- 'bug'
20+
- 'fix'
21+
- 'dependencies'
22+
default: patch
23+
24+
categories:
25+
- title: 'Breaking Changes'
26+
labels:
27+
- 'breaking'
28+
- title: 'Features'
29+
labels:
30+
- 'feature'
31+
- 'enhancement'
32+
- title: 'Bug Fixes'
33+
labels:
34+
- 'bug'
35+
- 'fix'
36+
- title: 'Maintenance'
37+
labels:
38+
- 'chore'
39+
- 'dependencies'
40+
- 'documentation'
41+
42+
exclude-labels:
43+
- 'skip-changelog'
44+
45+
autolabeler:
46+
- label: 'documentation'
47+
files:
48+
- '*.md'
49+
- 'docs/**'
50+
- label: 'tests'
51+
files:
52+
- 'tests/**'
53+
- label: 'feature'
54+
files:
55+
- 'src/**'
56+
57+
sort-by: 'merged_at'
58+
sort-direction: 'descending'
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Release Drafter
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request_target:
8+
types:
9+
- opened
10+
- reopened
11+
- synchronize
12+
- labeled
13+
- unlabeled
14+
15+
permissions:
16+
contents: write
17+
pull-requests: write
18+
19+
jobs:
20+
update_release_draft:
21+
runs-on: ubuntu-latest
22+
23+
steps:
24+
- name: Draft next release
25+
uses: release-drafter/release-drafter@v6
26+
with:
27+
config-name: release-drafter.yml
28+
env:
29+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/release.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
publish:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Create GitHub release
17+
uses: softprops/action-gh-release@v2
18+
with:
19+
generate_release_notes: true

README.md

Lines changed: 11 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

phpunit.xml

Lines changed: 0 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

roadmap.md

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Anchor;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Token;
9+
use TwigCsFixer\Token\Tokens;
10+
11+
final class LinkHrefValidityRule extends AbstractA11yRule
12+
{
13+
public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
14+
{
15+
if ($this->shouldSkipByTokenIndex($tokenIndex)) {
16+
return;
17+
}
18+
19+
$token = $tokens->get($tokenIndex);
20+
if (!$token->isMatching(Token::TEXT_TYPE)) {
21+
return;
22+
}
23+
24+
$value = $token->getValue();
25+
if (!str_contains($value, '<a')) {
26+
return;
27+
}
28+
29+
$tag = $this->collectUntil($tokenIndex, $tokens, '>', 100);
30+
31+
if (!preg_match('/<a\b/i', $tag)) {
32+
return;
33+
}
34+
35+
if (preg_match('/\brole\s*=\s*["\']button["\']/i', $tag)) {
36+
return;
37+
}
38+
39+
if ($this->containsTwigExpressions($tag)) {
40+
return;
41+
}
42+
43+
if (!preg_match('/\bhref\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/i', $tag, $hrefMatch)) {
44+
$emit('Anchor elements should include a valid href attribute.', $token, 'LinkHref.MissingHref');
45+
46+
return;
47+
}
48+
49+
$href = $this->firstMatch($hrefMatch, 1, 2);
50+
$normalized = strtolower(trim($href));
51+
52+
if ('' === $normalized) {
53+
$emit('Anchor elements should not use an empty href attribute.', $token, 'LinkHref.EmptyHref');
54+
55+
return;
56+
}
57+
58+
if ('#' === $normalized || 'javascript:void(0)' === $normalized || 'javascript:void(0);' === $normalized) {
59+
$emit('Anchor elements should use a real destination href instead of placeholder links.', $token, 'LinkHref.PlaceholderHref');
60+
}
61+
}
62+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Aria;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Token;
9+
use TwigCsFixer\Token\Tokens;
10+
11+
final class AriaControlsIdExistsRule extends AbstractA11yRule
12+
{
13+
public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
14+
{
15+
if ($this->shouldSkipByTokenIndex($tokenIndex)) {
16+
return;
17+
}
18+
19+
$full = $this->getFullContent($tokens);
20+
21+
if (!str_contains($full, 'aria-controls')) {
22+
return;
23+
}
24+
25+
$idCount = preg_match_all('/\bid\s*=\s*(?:"|\')([^"\']+)(?:"|\')/i', $full, $idMatches);
26+
$ids = [];
27+
if ($idCount > 0) {
28+
$ids = array_flip($idMatches[1]);
29+
}
30+
31+
if (!preg_match_all('/\baria-controls\s*=\s*(?:"([^"]+)"|\'([^\']+)\')/i', $full, $refs, PREG_OFFSET_CAPTURE)) {
32+
return;
33+
}
34+
35+
foreach ($refs[0] as $index => $match) {
36+
$attr = $match[0];
37+
$offset = $match[1];
38+
$value = $refs[1][$index][0] ?: $refs[2][$index][0];
39+
$pieces = preg_split('/\s+/', trim($value));
40+
41+
if (false === $pieces) {
42+
$pieces = [];
43+
}
44+
45+
foreach ($pieces as $refId) {
46+
if ('' === $refId) {
47+
continue;
48+
}
49+
50+
if (isset($ids[$refId])) {
51+
continue;
52+
}
53+
54+
$line = 1 + substr_count(substr($full, 0, $offset), "\n");
55+
$fakeToken = $this->fakeTokenForLine($tokens, $line, $attr);
56+
57+
$emit(sprintf('Referenced id "%s" in aria-controls does not exist in template.', $refId), $fakeToken, 'AriaControls.MissingId');
58+
59+
return;
60+
}
61+
}
62+
}
63+
64+
protected function evaluateOncePerFile(): bool
65+
{
66+
return true;
67+
}
68+
69+
private function fakeTokenForLine(Tokens $tokens, int $line, string $value): Token
70+
{
71+
$token = $tokens->get(0);
72+
73+
return new Token(
74+
$token->getType(),
75+
$line,
76+
1,
77+
$token->getFilename(),
78+
$value
79+
);
80+
}
81+
}

src/Rules/Forms/ButtonTypeRule.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TwigA11y\Rules\Forms;
6+
7+
use TwigA11y\Rules\AbstractA11yRule;
8+
use TwigCsFixer\Token\Token;
9+
use TwigCsFixer\Token\Tokens;
10+
11+
final class ButtonTypeRule extends AbstractA11yRule
12+
{
13+
public function evaluate(Tokens $tokens, int $tokenIndex, callable $emit): void
14+
{
15+
$token = $tokens->get($tokenIndex);
16+
if (!$token->isMatching(Token::TEXT_TYPE)) {
17+
return;
18+
}
19+
20+
$value = $token->getValue();
21+
if (!str_contains($value, '<button')) {
22+
return;
23+
}
24+
25+
$tag = $this->collectUntil($tokenIndex, $tokens, '>', 100);
26+
27+
if (preg_match('/\btype\s*=\s*["\'](?:button|submit|reset)["\']/i', $tag)) {
28+
return;
29+
}
30+
31+
$emit('Button inside a form should declare an explicit type attribute.', $token, 'ButtonType.MissingType');
32+
}
33+
}

0 commit comments

Comments
 (0)