a11y-lint 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +9 -0
- data/CHANGELOG.md +20 -0
- data/CLAUDE.md +3 -1
- data/README.md +20 -0
- data/lib/a11y/lint/cli.rb +15 -2
- data/lib/a11y/lint/configuration.rb +46 -0
- data/lib/a11y/lint/erb_node.rb +15 -3
- data/lib/a11y/lint/erb_runner.rb +7 -4
- data/lib/a11y/lint/node.rb +32 -0
- data/lib/a11y/lint/rule.rb +11 -3
- data/lib/a11y/lint/rules/{image_tag_missing_alt.rb → perceivable/image_tag_missing_alt.rb} +31 -7
- data/lib/a11y/lint/rules/{img_missing_alt.rb → perceivable/img_missing_alt.rb} +4 -4
- data/lib/a11y/lint/rules/perceivable/list_invalid_children.rb +37 -0
- data/lib/a11y/lint/rules/{link_missing_accessible_name.rb → robust/missing_accessible_name.rb} +45 -21
- data/lib/a11y/lint/slim_runner.rb +17 -3
- data/lib/a11y/lint/version.rb +1 -1
- data/lib/a11y/lint.rb +5 -3
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6fe6daf54c27923c7bb8d7c200baab018d5643ae6d17f1da3116b90b8b9a9b1a
|
|
4
|
+
data.tar.gz: 866c2a9cdbeddc1d8b4c726e89a104b4b193070fb52ec1ae45c6552fee41afe1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d07bed9bdc71ef0d08f6aa097c5c2550988c11899106e0ceeaaaf54a2c5243449842dd20390b321fdbbcf54811bc53a1487cbb502ac75698224dec99e13599c
|
|
7
|
+
data.tar.gz: a7ef305af11a87604e4f95ca3cfd62afa7c142601d9c081ec2448747414fef7ae32a11020134cebaddb4954c788eec5688251d18332d7079d8b531f98015ab6f
|
data/.rubocop.yml
CHANGED
|
@@ -7,6 +7,15 @@ Style/StringLiterals:
|
|
|
7
7
|
Style/StringLiteralsInInterpolation:
|
|
8
8
|
EnforcedStyle: double_quotes
|
|
9
9
|
|
|
10
|
+
Layout/LineLength:
|
|
11
|
+
Max: 80
|
|
12
|
+
Exclude:
|
|
13
|
+
- "a11y-lint.gemspec"
|
|
14
|
+
|
|
10
15
|
Metrics/ClassLength:
|
|
11
16
|
Exclude:
|
|
12
17
|
- "test/**/*"
|
|
18
|
+
|
|
19
|
+
Metrics/MethodLength:
|
|
20
|
+
Exclude:
|
|
21
|
+
- "test/**/*"
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.1] - 2026-04-13
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Slim runner: correct line numbers in templates with multiline backslash continuations
|
|
15
|
+
|
|
16
|
+
## [0.7.0] - 2026-04-13
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `ListInvalidChildren` rule: detects invalid direct children of `<ul>` and `<ol>` elements (WCAG 1.3.1)
|
|
21
|
+
- `MissingAccessibleName` rule: consolidates `LinkMissingAccessibleName` into a single rule covering `link_to`, `external_link_to`, and `button_tag`
|
|
22
|
+
- Per-rule configuration via `.a11y-lint.yml` file: enable or disable individual rules
|
|
23
|
+
- Place `.a11y-lint.yml` in the project root for automatic detection
|
|
24
|
+
- Use `--config PATH` to specify a custom configuration file path
|
|
25
|
+
|
|
26
|
+
### Removed
|
|
27
|
+
|
|
28
|
+
- `LinkMissingAccessibleName` rule: replaced by `MissingAccessibleName`
|
|
29
|
+
|
|
10
30
|
## [0.6.0] - 2026-03-31
|
|
11
31
|
|
|
12
32
|
### Added
|
data/CLAUDE.md
CHANGED
|
@@ -23,10 +23,12 @@ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::L
|
|
|
23
23
|
- **Entry point:** `lib/a11y/lint.rb` — defines the `A11y::Lint` module and `A11y::Lint::Error` exception
|
|
24
24
|
- **Slim pipeline:** `SlimRunner` parses Slim templates via `Slim::Parser`; `Node` wraps Slim S-expressions
|
|
25
25
|
- **ERB pipeline:** `ErbRunner` parses ERB templates via Nokogiri; `ErbNode` wraps Nokogiri nodes and extracted `<%= %>` Ruby code
|
|
26
|
-
- **
|
|
26
|
+
- **Configuration:** `lib/a11y/lint/configuration.rb` — loads `.a11y-lint.yml` to enable/disable individual rules; searches upward from the target path
|
|
27
|
+
- **Rules:** `lib/a11y/lint/rules/` — organized by WCAG principle (`perceivable/`, `operable/`, `understandable/`, `robust/`); rules implement `check(node)` against the shared node interface (`tag_name`, `attribute?`, `attributes`, `ruby_code`, `line`)
|
|
27
28
|
- **Version:** `lib/a11y/lint/version.rb`
|
|
28
29
|
- **Type signatures (RBS):** `sig/a11y/lint.rbs`
|
|
29
30
|
- **Tests:** `test/` directory using Minitest; test helper at `test/test_helper.rb`
|
|
31
|
+
- **Dummy app:** `test/fixtures/dummy_app/` — a fixture app with Slim/ERB templates for end-to-end smoke testing before releases (`bundle exec a11y-lint test/fixtures/dummy_app`)
|
|
30
32
|
|
|
31
33
|
## Code Style
|
|
32
34
|
|
data/README.md
CHANGED
|
@@ -39,6 +39,26 @@ With no arguments, it scans the current directory recursively for `.slim` and `.
|
|
|
39
39
|
a11y-lint
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
### Configuration
|
|
43
|
+
|
|
44
|
+
Create a `.a11y-lint.yml` file in your project root to enable or disable individual rules:
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
ImgMissingAlt:
|
|
48
|
+
Enabled: false
|
|
49
|
+
|
|
50
|
+
ImageTagMissingAlt:
|
|
51
|
+
Enabled: true
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Rules not listed in the file are enabled by default. The linter searches for `.a11y-lint.yml` starting from the target directory and walking up to the filesystem root.
|
|
55
|
+
|
|
56
|
+
To specify a config file explicitly:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
a11y-lint --config path/to/.a11y-lint.yml app/views/
|
|
60
|
+
```
|
|
61
|
+
|
|
42
62
|
### Ruby API
|
|
43
63
|
|
|
44
64
|
```ruby
|
data/lib/a11y/lint/cli.rb
CHANGED
|
@@ -4,12 +4,14 @@ require "optparse"
|
|
|
4
4
|
|
|
5
5
|
module A11y
|
|
6
6
|
module Lint
|
|
7
|
-
# Command-line interface for running accessibility
|
|
7
|
+
# Command-line interface for running accessibility
|
|
8
|
+
# linting on Slim templates.
|
|
8
9
|
class CLI
|
|
9
10
|
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
10
11
|
@argv = argv
|
|
11
12
|
@stdout = stdout
|
|
12
13
|
@stderr = stderr
|
|
14
|
+
@config_path = nil
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def run
|
|
@@ -42,6 +44,10 @@ module A11y
|
|
|
42
44
|
exit 0
|
|
43
45
|
end
|
|
44
46
|
|
|
47
|
+
opts.on("-c", "--config PATH", "Path to configuration file") do |path|
|
|
48
|
+
@config_path = path
|
|
49
|
+
end
|
|
50
|
+
|
|
45
51
|
opts.on("-h", "--help", "Show help") do
|
|
46
52
|
@stdout.puts(opts)
|
|
47
53
|
exit 0
|
|
@@ -78,9 +84,16 @@ module A11y
|
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
def all_rules
|
|
87
|
+
configuration = Configuration.load(
|
|
88
|
+
@config_path,
|
|
89
|
+
search_path: @argv.first || "."
|
|
90
|
+
)
|
|
91
|
+
|
|
81
92
|
Rules.constants.filter_map do |name|
|
|
82
93
|
klass = Rules.const_get(name)
|
|
83
|
-
|
|
94
|
+
next unless klass.is_a?(Class) && klass < Rule
|
|
95
|
+
|
|
96
|
+
klass if configuration.enabled?(klass.rule_name)
|
|
84
97
|
end
|
|
85
98
|
end
|
|
86
99
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module A11y
|
|
6
|
+
module Lint
|
|
7
|
+
# Loads and stores rule configuration from a YAML file.
|
|
8
|
+
class Configuration
|
|
9
|
+
DEFAULT_FILE = ".a11y-lint.yml"
|
|
10
|
+
|
|
11
|
+
def initialize(config_hash = {})
|
|
12
|
+
@config = config_hash
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.load(path = nil, search_path: Dir.pwd)
|
|
16
|
+
path ||= find_config_file(search_path)
|
|
17
|
+
return new unless path && File.exist?(path)
|
|
18
|
+
|
|
19
|
+
config_hash = YAML.safe_load_file(path) || {}
|
|
20
|
+
new(config_hash)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.find_config_file(start_dir)
|
|
24
|
+
dir = File.expand_path(start_dir)
|
|
25
|
+
dir = File.dirname(dir) unless File.directory?(dir)
|
|
26
|
+
|
|
27
|
+
loop do
|
|
28
|
+
candidate = File.join(dir, DEFAULT_FILE)
|
|
29
|
+
return candidate if File.exist?(candidate)
|
|
30
|
+
|
|
31
|
+
parent = File.dirname(dir)
|
|
32
|
+
return nil if parent == dir
|
|
33
|
+
|
|
34
|
+
dir = parent
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
private_class_method :find_config_file
|
|
38
|
+
|
|
39
|
+
def enabled?(rule_name)
|
|
40
|
+
return true unless @config.key?(rule_name)
|
|
41
|
+
|
|
42
|
+
@config.dig(rule_name, "Enabled") != false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/a11y/lint/erb_node.rb
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module A11y
|
|
4
4
|
module Lint
|
|
5
|
-
# Wraps a Nokogiri node or extracted ERB output tag
|
|
5
|
+
# Wraps a Nokogiri node or extracted ERB output tag
|
|
6
|
+
# as a queryable node for lint rules.
|
|
6
7
|
class ErbNode
|
|
7
8
|
attr_reader :line
|
|
8
9
|
|
|
@@ -28,13 +29,24 @@ module A11y
|
|
|
28
29
|
@ruby_code_string
|
|
29
30
|
end
|
|
30
31
|
|
|
32
|
+
# Returns direct element children wrapped as ErbNode objects.
|
|
33
|
+
def children
|
|
34
|
+
return [] unless @nokogiri_node
|
|
35
|
+
|
|
36
|
+
@nokogiri_node.element_children.map do |child|
|
|
37
|
+
ErbNode.new(nokogiri_node: child, line: child.line)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
31
41
|
private
|
|
32
42
|
|
|
33
43
|
def extract_attributes
|
|
34
44
|
return {} unless @nokogiri_node
|
|
35
45
|
|
|
36
|
-
@nokogiri_node
|
|
37
|
-
|
|
46
|
+
@nokogiri_node
|
|
47
|
+
.attributes
|
|
48
|
+
.each_with_object({}) do |(name, _attr), result|
|
|
49
|
+
result[name] = true
|
|
38
50
|
end
|
|
39
51
|
end
|
|
40
52
|
end
|
data/lib/a11y/lint/erb_runner.rb
CHANGED
|
@@ -34,7 +34,10 @@ module A11y
|
|
|
34
34
|
doc.traverse do |nokogiri_node|
|
|
35
35
|
next unless nokogiri_node.element?
|
|
36
36
|
|
|
37
|
-
node = ErbNode.new(
|
|
37
|
+
node = ErbNode.new(
|
|
38
|
+
nokogiri_node: nokogiri_node,
|
|
39
|
+
line: nokogiri_node.line
|
|
40
|
+
)
|
|
38
41
|
check_node(node)
|
|
39
42
|
end
|
|
40
43
|
end
|
|
@@ -50,12 +53,12 @@ module A11y
|
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def check_node(node)
|
|
53
|
-
rules.each do |
|
|
54
|
-
message =
|
|
56
|
+
rules.each do |rule_class|
|
|
57
|
+
message = rule_class.check(node)
|
|
55
58
|
next unless message
|
|
56
59
|
|
|
57
60
|
@offenses << Offense.new(
|
|
58
|
-
rule:
|
|
61
|
+
rule: rule_class.rule_name,
|
|
59
62
|
filename: @filename,
|
|
60
63
|
line: node.line,
|
|
61
64
|
message: message
|
data/lib/a11y/lint/node.rb
CHANGED
|
@@ -29,8 +29,40 @@ module A11y
|
|
|
29
29
|
@attributes ||= extract_attributes
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Returns direct HTML element children as Node objects.
|
|
33
|
+
# Walks through [:multi] and [:slim, :control] wrappers so that tags
|
|
34
|
+
# nested inside control flow are still treated as direct children.
|
|
35
|
+
# Opaque [:slim, :output] blocks are skipped.
|
|
36
|
+
def children
|
|
37
|
+
return [] unless html_tag?
|
|
38
|
+
|
|
39
|
+
body = @sexp[4]
|
|
40
|
+
collect_children(body)
|
|
41
|
+
end
|
|
42
|
+
|
|
32
43
|
private
|
|
33
44
|
|
|
45
|
+
def html_tag?
|
|
46
|
+
@sexp[0] == :html && @sexp[1] == :tag
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def collect_children(sexp)
|
|
50
|
+
return [] unless sexp.is_a?(Array)
|
|
51
|
+
return [Node.new(sexp, line: @line)] if html_tag_sexp?(sexp)
|
|
52
|
+
return collect_children(sexp[3]) if slim_control_sexp?(sexp)
|
|
53
|
+
return [] unless sexp[0] == :multi
|
|
54
|
+
|
|
55
|
+
sexp[1..].flat_map { |c| collect_children(c) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def html_tag_sexp?(sexp)
|
|
59
|
+
sexp[0] == :html && sexp[1] == :tag
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def slim_control_sexp?(sexp)
|
|
63
|
+
sexp[0] == :slim && sexp[1] == :control
|
|
64
|
+
end
|
|
65
|
+
|
|
34
66
|
def extract_attributes
|
|
35
67
|
return {} unless html_attributes?
|
|
36
68
|
|
data/lib/a11y/lint/rule.rb
CHANGED
|
@@ -4,11 +4,19 @@ module A11y
|
|
|
4
4
|
module Lint
|
|
5
5
|
# Base class for accessibility lint rules.
|
|
6
6
|
class Rule
|
|
7
|
-
def
|
|
8
|
-
|
|
7
|
+
def self.check(node)
|
|
8
|
+
new(node).check
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
def
|
|
11
|
+
def self.rule_name
|
|
12
|
+
name.split("::").last
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(node)
|
|
16
|
+
@node = node
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def check
|
|
12
20
|
raise NotImplementedError
|
|
13
21
|
end
|
|
14
22
|
end
|
|
@@ -7,16 +7,16 @@ module A11y
|
|
|
7
7
|
module Rules
|
|
8
8
|
# Checks that image_tag calls include an alt option (WCAG 1.1.1).
|
|
9
9
|
class ImageTagMissingAlt < Rule
|
|
10
|
-
def check
|
|
11
|
-
return unless an_image_tag_without_an_alt_attribute?
|
|
10
|
+
def check
|
|
11
|
+
return unless an_image_tag_without_an_alt_attribute?
|
|
12
12
|
|
|
13
13
|
"image_tag is missing an alt option (WCAG 1.1.1)"
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
private
|
|
17
17
|
|
|
18
|
-
def an_image_tag_without_an_alt_attribute?
|
|
19
|
-
code = node.ruby_code
|
|
18
|
+
def an_image_tag_without_an_alt_attribute?
|
|
19
|
+
code = @node.ruby_code
|
|
20
20
|
return false unless code
|
|
21
21
|
|
|
22
22
|
sexp = Ripper.sexp(code)
|
|
@@ -26,6 +26,8 @@ module A11y
|
|
|
26
26
|
call && !alt_key_within?(call)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Walks the Ripper S-expression tree to find
|
|
30
|
+
# the image_tag call node, if present.
|
|
29
31
|
def extract_image_tag_call(sexp)
|
|
30
32
|
return unless sexp.is_a?(Array)
|
|
31
33
|
return sexp if image_tag_call?(sexp)
|
|
@@ -38,6 +40,9 @@ module A11y
|
|
|
38
40
|
nil
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# Matches both calling styles:
|
|
44
|
+
# image_tag "photo.jpg" => :command
|
|
45
|
+
# image_tag("photo.jpg") => :method_add_arg
|
|
41
46
|
def image_tag_call?(sexp)
|
|
42
47
|
case sexp
|
|
43
48
|
in [:command, [:@ident, "image_tag", *], *] then true
|
|
@@ -46,6 +51,8 @@ module A11y
|
|
|
46
51
|
end
|
|
47
52
|
end
|
|
48
53
|
|
|
54
|
+
# Recursively searches the sexp for an
|
|
55
|
+
# :assoc_new node whose key is "alt".
|
|
49
56
|
def alt_key_within?(sexp)
|
|
50
57
|
return true if alt_key?(sexp)
|
|
51
58
|
return false unless sexp.is_a?(Array)
|
|
@@ -53,12 +60,29 @@ module A11y
|
|
|
53
60
|
sexp.any? { |child| alt_key_within?(child) }
|
|
54
61
|
end
|
|
55
62
|
|
|
63
|
+
# Checks if a sexp is a hash pair (assoc_new)
|
|
64
|
+
# with "alt" as the key.
|
|
56
65
|
def alt_key?(sexp)
|
|
57
66
|
return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
|
|
58
67
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
alt_key_value?(sexp[1])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def alt_key_value?(key)
|
|
72
|
+
alt_symbol_key?(key) || alt_string_key?(key)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Matches symbol-style key: `alt: "..."`
|
|
76
|
+
def alt_symbol_key?(key)
|
|
77
|
+
key in [:@label, "alt:", *]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Matches string-style key: `"alt" => "..."`
|
|
81
|
+
def alt_string_key?(key)
|
|
82
|
+
key in [
|
|
83
|
+
:string_literal,
|
|
84
|
+
[:string_content, [:@tstring_content, "alt", *]]
|
|
85
|
+
]
|
|
62
86
|
end
|
|
63
87
|
end
|
|
64
88
|
end
|
|
@@ -5,16 +5,16 @@ module A11y
|
|
|
5
5
|
module Rules
|
|
6
6
|
# Checks that img tags include an alt attribute (WCAG 1.1.1).
|
|
7
7
|
class ImgMissingAlt < Rule
|
|
8
|
-
def check
|
|
9
|
-
return unless an_image_without_an_alt_attribute?
|
|
8
|
+
def check
|
|
9
|
+
return unless an_image_without_an_alt_attribute?
|
|
10
10
|
|
|
11
11
|
"img tag is missing an alt attribute (WCAG 1.1.1)"
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
|
-
def an_image_without_an_alt_attribute?
|
|
17
|
-
node.tag_name == "img" &&
|
|
16
|
+
def an_image_without_an_alt_attribute?
|
|
17
|
+
@node.tag_name == "img" && !@node.attribute?("alt")
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that <ul> and <ol> only directly contain <li>, <script>,
|
|
7
|
+
# or <template> elements (WCAG 1.3.1).
|
|
8
|
+
class ListInvalidChildren < Rule
|
|
9
|
+
LIST_TAGS = %w[ul ol].freeze
|
|
10
|
+
ALLOWED_CHILDREN = %w[li script template].freeze
|
|
11
|
+
|
|
12
|
+
def check
|
|
13
|
+
return unless list_with_invalid_children?
|
|
14
|
+
|
|
15
|
+
offense_message(@node.tag_name, invalid_children.first.tag_name)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def list_with_invalid_children?
|
|
21
|
+
LIST_TAGS.include?(@node.tag_name) && invalid_children.any?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def invalid_children
|
|
25
|
+
@invalid_children ||= @node.children.reject do |child|
|
|
26
|
+
ALLOWED_CHILDREN.include?(child.tag_name)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def offense_message(parent, child)
|
|
31
|
+
"<#{parent}> must only directly contain <li>, <script>, " \
|
|
32
|
+
"or <template> elements, found <#{child}> (WCAG 1.3.1)"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/a11y/lint/rules/{link_missing_accessible_name.rb → robust/missing_accessible_name.rb}
RENAMED
|
@@ -5,54 +5,72 @@ require "ripper"
|
|
|
5
5
|
module A11y
|
|
6
6
|
module Lint
|
|
7
7
|
module Rules
|
|
8
|
-
# Checks that link_to
|
|
9
|
-
# or block content include an aria-label (WCAG 4.1.2).
|
|
10
|
-
class
|
|
11
|
-
|
|
8
|
+
# Checks that link_to, external_link_to, and button_tag calls with
|
|
9
|
+
# empty text or block content include an aria-label (WCAG 4.1.2).
|
|
10
|
+
class MissingAccessibleName < Rule
|
|
11
|
+
METHODS = %w[link_to external_link_to button_tag].freeze
|
|
12
12
|
|
|
13
|
-
def check
|
|
14
|
-
code = node.ruby_code
|
|
15
|
-
return unless code
|
|
13
|
+
def check
|
|
14
|
+
return unless (code = @node.ruby_code)
|
|
16
15
|
|
|
17
16
|
clean_code = code.sub(/\s+do\s*\z/, "")
|
|
18
17
|
is_block = clean_code != code
|
|
19
|
-
call =
|
|
18
|
+
call = parse_call(clean_code)
|
|
20
19
|
return unless call
|
|
21
20
|
return if aria_label_within?(call)
|
|
22
21
|
return unless first_arg_empty_string?(call) || is_block
|
|
23
22
|
|
|
24
|
-
|
|
23
|
+
offense_message(extract_method_name(call))
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
private
|
|
28
27
|
|
|
29
|
-
def
|
|
28
|
+
def offense_message(method_name)
|
|
29
|
+
<<~MSG.strip
|
|
30
|
+
#{method_name} missing an accessible name \
|
|
31
|
+
requires an aria-label (WCAG 4.1.2)
|
|
32
|
+
MSG
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parse_call(code)
|
|
30
36
|
sexp = Ripper.sexp(code)
|
|
31
37
|
return unless sexp
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
extract_matching_call(sexp)
|
|
34
40
|
end
|
|
35
41
|
|
|
36
|
-
def
|
|
42
|
+
def extract_matching_call(sexp)
|
|
37
43
|
return unless sexp.is_a?(Array)
|
|
38
|
-
return sexp if
|
|
44
|
+
return sexp if matching_call?(sexp)
|
|
39
45
|
|
|
40
46
|
sexp.each do |child|
|
|
41
|
-
result =
|
|
47
|
+
result = extract_matching_call(child)
|
|
42
48
|
return result if result
|
|
43
49
|
end
|
|
44
50
|
|
|
45
51
|
nil
|
|
46
52
|
end
|
|
47
53
|
|
|
48
|
-
def
|
|
54
|
+
def matching_call?(sexp)
|
|
55
|
+
name = call_method_name(sexp)
|
|
56
|
+
name ? METHODS.include?(name) : false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def call_method_name(sexp)
|
|
49
60
|
case sexp
|
|
50
|
-
in [:command, [:@ident, name, *], *]
|
|
51
|
-
|
|
52
|
-
|
|
61
|
+
in [:command, [:@ident, name, *], *]
|
|
62
|
+
name
|
|
63
|
+
in [:method_add_arg,
|
|
64
|
+
[:fcall, [:@ident, name, *]], *]
|
|
65
|
+
name
|
|
66
|
+
else nil
|
|
53
67
|
end
|
|
54
68
|
end
|
|
55
69
|
|
|
70
|
+
def extract_method_name(call)
|
|
71
|
+
call_method_name(call)
|
|
72
|
+
end
|
|
73
|
+
|
|
56
74
|
def first_arg_empty_string?(call)
|
|
57
75
|
args = extract_args(call)
|
|
58
76
|
return false unless args&.first
|
|
@@ -63,7 +81,9 @@ module A11y
|
|
|
63
81
|
def extract_args(call)
|
|
64
82
|
case call
|
|
65
83
|
in [:command, _, [:args_add_block, args, *]] then args
|
|
66
|
-
in [:method_add_arg, _,
|
|
84
|
+
in [:method_add_arg, _,
|
|
85
|
+
[:arg_paren, [:args_add_block, args, *]]]
|
|
86
|
+
then args
|
|
67
87
|
in [:method_add_arg, _, [:arg_paren, Array => args]] then args
|
|
68
88
|
else nil
|
|
69
89
|
end
|
|
@@ -94,13 +114,17 @@ module A11y
|
|
|
94
114
|
end
|
|
95
115
|
|
|
96
116
|
def label_key?(sexp)
|
|
97
|
-
sexp.is_a?(Array) &&
|
|
117
|
+
sexp.is_a?(Array) &&
|
|
118
|
+
sexp[0] == :assoc_new &&
|
|
119
|
+
(sexp[1] in [:@label, "label:", *])
|
|
98
120
|
end
|
|
99
121
|
|
|
100
122
|
def aria_label_string_key?(sexp)
|
|
101
123
|
return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
|
|
102
124
|
|
|
103
|
-
sexp[1] in [:string_literal,
|
|
125
|
+
sexp[1] in [:string_literal,
|
|
126
|
+
[:string_content,
|
|
127
|
+
[:@tstring_content, "aria-label", *]]]
|
|
104
128
|
end
|
|
105
129
|
end
|
|
106
130
|
end
|
|
@@ -27,6 +27,7 @@ module A11y
|
|
|
27
27
|
@line += 1 if sexp[0] == :newline
|
|
28
28
|
new_node = Node.new(sexp, line: @line)
|
|
29
29
|
check_node(new_node) if html_tag?(sexp) || slim_output?(sexp)
|
|
30
|
+
@line += continuation_newlines(sexp)
|
|
30
31
|
sexp.each { |child| walk(child) }
|
|
31
32
|
end
|
|
32
33
|
|
|
@@ -38,17 +39,30 @@ module A11y
|
|
|
38
39
|
sexp[0] == :slim && sexp[1] == :output
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# Counts extra source lines consumed by backslash-continued
|
|
43
|
+
# multiline expressions in :output and :control nodes.
|
|
44
|
+
# Slim collapses these into one AST node whose ruby_code
|
|
45
|
+
# string retains the original newlines.
|
|
46
|
+
def continuation_newlines(sexp)
|
|
47
|
+
code = case sexp
|
|
48
|
+
in [:slim, :output, _, String => c, *] then c
|
|
49
|
+
in [:slim, :control, String => c, *] then c
|
|
50
|
+
else return 0
|
|
51
|
+
end
|
|
52
|
+
code.count("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
41
55
|
def node?(sexp)
|
|
42
56
|
sexp.is_a?(Array) && !sexp.empty? && sexp[0].is_a?(Symbol)
|
|
43
57
|
end
|
|
44
58
|
|
|
45
59
|
def check_node(node)
|
|
46
|
-
rules.each do |
|
|
47
|
-
message =
|
|
60
|
+
rules.each do |rule_class|
|
|
61
|
+
message = rule_class.check(node)
|
|
48
62
|
next unless message
|
|
49
63
|
|
|
50
64
|
@offenses << Offense.new(
|
|
51
|
-
rule:
|
|
65
|
+
rule: rule_class.rule_name,
|
|
52
66
|
filename: @filename,
|
|
53
67
|
line: node.line,
|
|
54
68
|
message: message
|
data/lib/a11y/lint/version.rb
CHANGED
data/lib/a11y/lint.rb
CHANGED
|
@@ -6,10 +6,12 @@ require_relative "lint/version"
|
|
|
6
6
|
require_relative "lint/offense"
|
|
7
7
|
require_relative "lint/node"
|
|
8
8
|
require_relative "lint/erb_node"
|
|
9
|
+
require_relative "lint/configuration"
|
|
9
10
|
require_relative "lint/rule"
|
|
10
|
-
require_relative "lint/rules/image_tag_missing_alt"
|
|
11
|
-
require_relative "lint/rules/img_missing_alt"
|
|
12
|
-
require_relative "lint/rules/
|
|
11
|
+
require_relative "lint/rules/perceivable/image_tag_missing_alt"
|
|
12
|
+
require_relative "lint/rules/perceivable/img_missing_alt"
|
|
13
|
+
require_relative "lint/rules/perceivable/list_invalid_children"
|
|
14
|
+
require_relative "lint/rules/robust/missing_accessible_name"
|
|
13
15
|
require_relative "lint/slim_runner"
|
|
14
16
|
require_relative "lint/erb_runner"
|
|
15
17
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: a11y-lint
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdullah Hashim
|
|
@@ -56,14 +56,16 @@ files:
|
|
|
56
56
|
- exe/a11y-lint
|
|
57
57
|
- lib/a11y/lint.rb
|
|
58
58
|
- lib/a11y/lint/cli.rb
|
|
59
|
+
- lib/a11y/lint/configuration.rb
|
|
59
60
|
- lib/a11y/lint/erb_node.rb
|
|
60
61
|
- lib/a11y/lint/erb_runner.rb
|
|
61
62
|
- lib/a11y/lint/node.rb
|
|
62
63
|
- lib/a11y/lint/offense.rb
|
|
63
64
|
- lib/a11y/lint/rule.rb
|
|
64
|
-
- lib/a11y/lint/rules/image_tag_missing_alt.rb
|
|
65
|
-
- lib/a11y/lint/rules/img_missing_alt.rb
|
|
66
|
-
- lib/a11y/lint/rules/
|
|
65
|
+
- lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb
|
|
66
|
+
- lib/a11y/lint/rules/perceivable/img_missing_alt.rb
|
|
67
|
+
- lib/a11y/lint/rules/perceivable/list_invalid_children.rb
|
|
68
|
+
- lib/a11y/lint/rules/robust/missing_accessible_name.rb
|
|
67
69
|
- lib/a11y/lint/slim_runner.rb
|
|
68
70
|
- lib/a11y/lint/version.rb
|
|
69
71
|
- sig/a11y/lint.rbs
|