a11y-lint 0.5.1 → 0.7.0
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 +19 -1
- 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/robust/missing_accessible_name.rb +132 -0
- data/lib/a11y/lint/slim_runner.rb +3 -3
- data/lib/a11y/lint/version.rb +1 -1
- data/lib/a11y/lint.rb +5 -3
- metadata +6 -4
- data/lib/a11y/lint/rules/link_missing_accessible_name.rb +0 -106
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db05eea43ab0509e04c90f7e2942ddc272ad5d3e8894aec3ee9a3ddb477a70e0
|
|
4
|
+
data.tar.gz: 729f6b3a082105a85b8718293e7c9c9a49d8917ac02ffb554c190973f97ff173
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e55e705c0496c85e98f99e0ff680530a347db33aeb4b4351b318d8f9bfd9273908a8a92750606253556512c80c7ef02d41a55a8abd0c7857c4f21df70adc903a
|
|
7
|
+
data.tar.gz: dda39aeac2ca75ac9b6cf5c2afd5283f318a5b6c7de2e0470d65216cc5c7f0942d2e243e2703c58a8efd62fe685697252041a436d3e56a5af698a1849aabf1e1
|
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
|
@@ -5,7 +5,25 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.7.0] - 2026-04-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `ListInvalidChildren` rule: detects invalid direct children of `<ul>` and `<ol>` elements (WCAG 1.3.1)
|
|
13
|
+
- `MissingAccessibleName` rule: consolidates `LinkMissingAccessibleName` into a single rule covering `link_to`, `external_link_to`, and `button_tag`
|
|
14
|
+
- Per-rule configuration via `.a11y-lint.yml` file: enable or disable individual rules
|
|
15
|
+
- Place `.a11y-lint.yml` in the project root for automatic detection
|
|
16
|
+
- Use `--config PATH` to specify a custom configuration file path
|
|
17
|
+
|
|
18
|
+
### Removed
|
|
19
|
+
|
|
20
|
+
- `LinkMissingAccessibleName` rule: replaced by `MissingAccessibleName`
|
|
21
|
+
|
|
22
|
+
## [0.6.0] - 2026-03-31
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `LinkMissingAccessibleName` rule: detect block-style `link_to` calls missing an `aria-label`
|
|
9
27
|
|
|
10
28
|
## [0.5.1] - 2026-03-31
|
|
11
29
|
|
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
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module A11y
|
|
6
|
+
module Lint
|
|
7
|
+
module Rules
|
|
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
|
+
|
|
13
|
+
def check
|
|
14
|
+
return unless (code = @node.ruby_code)
|
|
15
|
+
|
|
16
|
+
clean_code = code.sub(/\s+do\s*\z/, "")
|
|
17
|
+
is_block = clean_code != code
|
|
18
|
+
call = parse_call(clean_code)
|
|
19
|
+
return unless call
|
|
20
|
+
return if aria_label_within?(call)
|
|
21
|
+
return unless first_arg_empty_string?(call) || is_block
|
|
22
|
+
|
|
23
|
+
offense_message(extract_method_name(call))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
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)
|
|
36
|
+
sexp = Ripper.sexp(code)
|
|
37
|
+
return unless sexp
|
|
38
|
+
|
|
39
|
+
extract_matching_call(sexp)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def extract_matching_call(sexp)
|
|
43
|
+
return unless sexp.is_a?(Array)
|
|
44
|
+
return sexp if matching_call?(sexp)
|
|
45
|
+
|
|
46
|
+
sexp.each do |child|
|
|
47
|
+
result = extract_matching_call(child)
|
|
48
|
+
return result if result
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
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)
|
|
60
|
+
case sexp
|
|
61
|
+
in [:command, [:@ident, name, *], *]
|
|
62
|
+
name
|
|
63
|
+
in [:method_add_arg,
|
|
64
|
+
[:fcall, [:@ident, name, *]], *]
|
|
65
|
+
name
|
|
66
|
+
else nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def extract_method_name(call)
|
|
71
|
+
call_method_name(call)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def first_arg_empty_string?(call)
|
|
75
|
+
args = extract_args(call)
|
|
76
|
+
return false unless args&.first
|
|
77
|
+
|
|
78
|
+
args.first in [:string_literal, [:string_content]]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extract_args(call)
|
|
82
|
+
case call
|
|
83
|
+
in [:command, _, [:args_add_block, args, *]] then args
|
|
84
|
+
in [:method_add_arg, _,
|
|
85
|
+
[:arg_paren, [:args_add_block, args, *]]]
|
|
86
|
+
then args
|
|
87
|
+
in [:method_add_arg, _, [:arg_paren, Array => args]] then args
|
|
88
|
+
else nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def aria_label_within?(sexp)
|
|
93
|
+
return true if aria_hash_with_label?(sexp)
|
|
94
|
+
return true if aria_label_string_key?(sexp)
|
|
95
|
+
return false unless sexp.is_a?(Array)
|
|
96
|
+
|
|
97
|
+
sexp.any? { |child| aria_label_within?(child) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def aria_hash_with_label?(sexp)
|
|
101
|
+
return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
|
|
102
|
+
|
|
103
|
+
key = sexp[1]
|
|
104
|
+
value = sexp[2]
|
|
105
|
+
|
|
106
|
+
(key in [:@label, "aria:", *]) && label_key_within?(value)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def label_key_within?(sexp)
|
|
110
|
+
return true if label_key?(sexp)
|
|
111
|
+
return false unless sexp.is_a?(Array)
|
|
112
|
+
|
|
113
|
+
sexp.any? { |child| label_key_within?(child) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def label_key?(sexp)
|
|
117
|
+
sexp.is_a?(Array) &&
|
|
118
|
+
sexp[0] == :assoc_new &&
|
|
119
|
+
(sexp[1] in [:@label, "label:", *])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def aria_label_string_key?(sexp)
|
|
123
|
+
return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
|
|
124
|
+
|
|
125
|
+
sexp[1] in [:string_literal,
|
|
126
|
+
[:string_content,
|
|
127
|
+
[:@tstring_content, "aria-label", *]]]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -43,12 +43,12 @@ module A11y
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def check_node(node)
|
|
46
|
-
rules.each do |
|
|
47
|
-
message =
|
|
46
|
+
rules.each do |rule_class|
|
|
47
|
+
message = rule_class.check(node)
|
|
48
48
|
next unless message
|
|
49
49
|
|
|
50
50
|
@offenses << Offense.new(
|
|
51
|
-
rule:
|
|
51
|
+
rule: rule_class.rule_name,
|
|
52
52
|
filename: @filename,
|
|
53
53
|
line: node.line,
|
|
54
54
|
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.0
|
|
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
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ripper"
|
|
4
|
-
|
|
5
|
-
module A11y
|
|
6
|
-
module Lint
|
|
7
|
-
module Rules
|
|
8
|
-
# Checks that link_to / external_link_to calls with empty text
|
|
9
|
-
# include an aria-label (WCAG 4.1.2).
|
|
10
|
-
class LinkMissingAccessibleName < Rule
|
|
11
|
-
LINK_METHODS = %w[link_to external_link_to].freeze
|
|
12
|
-
|
|
13
|
-
def check(node)
|
|
14
|
-
return unless link_with_empty_text_and_no_accessible_name?(node)
|
|
15
|
-
|
|
16
|
-
"link with empty text content requires an aria-label (WCAG 4.1.2)"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
private
|
|
20
|
-
|
|
21
|
-
def link_with_empty_text_and_no_accessible_name?(node)
|
|
22
|
-
code = node.ruby_code
|
|
23
|
-
return false unless code
|
|
24
|
-
|
|
25
|
-
sexp = Ripper.sexp(code)
|
|
26
|
-
return false unless sexp
|
|
27
|
-
|
|
28
|
-
call = extract_link_call(sexp)
|
|
29
|
-
return false unless call
|
|
30
|
-
|
|
31
|
-
first_arg_empty_string?(call) && !aria_label_within?(call)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def extract_link_call(sexp)
|
|
35
|
-
return unless sexp.is_a?(Array)
|
|
36
|
-
return sexp if link_call?(sexp)
|
|
37
|
-
|
|
38
|
-
sexp.each do |child|
|
|
39
|
-
result = extract_link_call(child)
|
|
40
|
-
return result if result
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
nil
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def link_call?(sexp)
|
|
47
|
-
case sexp
|
|
48
|
-
in [:command, [:@ident, name, *], *] if LINK_METHODS.include?(name) then true
|
|
49
|
-
in [:method_add_arg, [:fcall, [:@ident, name, *]], *] if LINK_METHODS.include?(name) then true
|
|
50
|
-
else false
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def first_arg_empty_string?(call)
|
|
55
|
-
args = extract_args(call)
|
|
56
|
-
return false unless args&.first
|
|
57
|
-
|
|
58
|
-
args.first in [:string_literal, [:string_content]]
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def extract_args(call)
|
|
62
|
-
case call
|
|
63
|
-
in [:command, _, [:args_add_block, args, *]] then args
|
|
64
|
-
in [:method_add_arg, _, [:arg_paren, [:args_add_block, args, *]]] then args
|
|
65
|
-
in [:method_add_arg, _, [:arg_paren, Array => args]] then args
|
|
66
|
-
else nil
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def aria_label_within?(sexp)
|
|
71
|
-
return true if aria_hash_with_label?(sexp)
|
|
72
|
-
return true if aria_label_string_key?(sexp)
|
|
73
|
-
return false unless sexp.is_a?(Array)
|
|
74
|
-
|
|
75
|
-
sexp.any? { |child| aria_label_within?(child) }
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def aria_hash_with_label?(sexp)
|
|
79
|
-
return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
|
|
80
|
-
|
|
81
|
-
key = sexp[1]
|
|
82
|
-
value = sexp[2]
|
|
83
|
-
|
|
84
|
-
(key in [:@label, "aria:", *]) && label_key_within?(value)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def label_key_within?(sexp)
|
|
88
|
-
return true if label_key?(sexp)
|
|
89
|
-
return false unless sexp.is_a?(Array)
|
|
90
|
-
|
|
91
|
-
sexp.any? { |child| label_key_within?(child) }
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def label_key?(sexp)
|
|
95
|
-
sexp.is_a?(Array) && sexp[0] == :assoc_new && (sexp[1] in [:@label, "label:", *])
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def aria_label_string_key?(sexp)
|
|
99
|
-
return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
|
|
100
|
-
|
|
101
|
-
sexp[1] in [:string_literal, [:string_content, [:@tstring_content, "aria-label", *]]]
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
end
|