a11y-lint 0.10.0 → 0.11.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/.claude/rules/testing.md +1 -1
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +10 -0
- data/lib/a11y/lint/block_inspection.rb +16 -3
- data/lib/a11y/lint/call_node.rb +17 -0
- data/lib/a11y/lint/cli.rb +1 -1
- data/lib/a11y/lint/erb_element_node.rb +59 -0
- data/lib/a11y/lint/erb_output_node.rb +47 -0
- data/lib/a11y/lint/erb_runner.rb +14 -5
- data/lib/a11y/lint/{rule.rb → node_rule.rb} +2 -2
- data/lib/a11y/lint/phlex_node.rb +20 -5
- data/lib/a11y/lint/phlex_runner.rb +27 -3
- data/lib/a11y/lint/phlex_tags.rb +1 -0
- data/lib/a11y/lint/rules/perceivable/area_missing_alt.rb +23 -0
- data/lib/a11y/lint/rules/perceivable/image_submit_tag_missing_alt.rb +27 -0
- data/lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb +1 -1
- data/lib/a11y/lint/rules/perceivable/img_missing_alt.rb +1 -1
- data/lib/a11y/lint/rules/perceivable/input_image_missing_alt.rb +26 -0
- data/lib/a11y/lint/rules/perceivable/input_missing_autocomplete.rb +37 -0
- data/lib/a11y/lint/rules/perceivable/list_invalid_children.rb +1 -1
- data/lib/a11y/lint/rules/robust/anchor_missing_accessible_name.rb +42 -0
- data/lib/a11y/lint/rules/robust/button_missing_accessible_name.rb +42 -0
- data/lib/a11y/lint/rules/robust/button_tag_missing_accessible_name.rb +45 -0
- data/lib/a11y/lint/rules/robust/{missing_accessible_name.rb → link_to_missing_accessible_name.rb} +4 -4
- data/lib/a11y/lint/slim_node.rb +32 -4
- data/lib/a11y/lint/version.rb +1 -1
- data/lib/a11y/lint.rb +4 -6
- metadata +12 -4
- data/lib/a11y/lint/erb_node.rb +0 -70
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e1c92e880f51310dfe123ffdbc872bc661a781371365c53dd1a1972e302dcc1d
|
|
4
|
+
data.tar.gz: dfb7186f222b4c87ffdc818e843907435eebda8e99cccdc945cf1f4d8b77708f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0fde7a03bb0e20294c451a57da6aa487fd7aa6cf6b3bde0d41e6b7a323ededf15c626e6b06e7dd55f37b109a0371cc24b5708fd5528f959bb96a6269642152f8
|
|
7
|
+
data.tar.gz: b43f87086fdabf16d139639d169be80cadea3da8c61b09e7a92cc66f4c5d59011dce8b7433307877b652b4b85c2d4cb923c6ed9a95695094b035cbcdbe2d6d0a
|
data/.claude/rules/testing.md
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.0] - 2026-04-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `AreaMissingAlt` rule: detects `<area>` tags without `alt` attributes (WCAG 1.1.1)
|
|
15
|
+
- `InputImageMissingAlt` rule: detects `<input type="image">` tags without `alt` attributes (WCAG 1.1.1)
|
|
16
|
+
- `ImageSubmitTagMissingAlt` rule: detects `image_submit_tag` calls without an `alt` option (WCAG 1.1.1)
|
|
17
|
+
- `InputMissingAutocomplete` rule: detects `<input>` elements without an `autocomplete` attribute (WCAG 1.3.5)
|
|
18
|
+
- `AnchorMissingAccessibleName` rule: detects `<a>` elements without text content, `aria-label`, or a child image with `alt` (WCAG 4.1.2)
|
|
19
|
+
- `ButtonMissingAccessibleName` rule: detects `<button>` elements without text content, `aria-label`, or a child image with `alt` (WCAG 4.1.2)
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **Breaking:** Split `MissingAccessibleName` into `LinkToMissingAccessibleName` (for `link_to`/`external_link_to`) and `ButtonTagMissingAccessibleName` (for `button_tag`) to follow one-rule-per-helper convention
|
|
24
|
+
- **Breaking:** Rename `A11y::Lint::Rule` to `A11y::Lint::NodeRule`. Custom rules that subclass the base class must update the parent class name
|
|
25
|
+
- Auto-require all rule files instead of listing them individually
|
|
26
|
+
- Node attribute storage now preserves string values, enabling rules to check attribute values (not just existence)
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `LinkToMissingAccessibleName` / `ButtonTagMissingAccessibleName`: no longer flag `link_to`/`button_tag` blocks containing an `image_tag` with a non-empty `alt` option
|
|
31
|
+
|
|
10
32
|
## [0.10.0] - 2026-04-15
|
|
11
33
|
|
|
12
34
|
### Changed
|
data/CLAUDE.md
CHANGED
|
@@ -32,6 +32,16 @@ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::L
|
|
|
32
32
|
- **Tests:** `test/` directory using Minitest; test helper at `test/test_helper.rb`
|
|
33
33
|
- **Dummy app:** `test/fixtures/dummy_app/` — a fixture app with Slim/ERB/Phlex templates for end-to-end smoke testing before releases (`bundle exec a11y-lint test/fixtures/dummy_app`)
|
|
34
34
|
|
|
35
|
+
## Rule Scoping Convention
|
|
36
|
+
|
|
37
|
+
Each rule targets **one element or helper**, unless multiple elements share identical check logic (e.g., `<ul>` and `<ol>` in `ListInvalidChildren`).
|
|
38
|
+
|
|
39
|
+
- **HTML element rules** are named after the element: `ImgMissingAlt`, `AreaMissingAlt`, `AnchorMissingAccessibleName`
|
|
40
|
+
- **Rails helper rules** are named after the helper: `ImageTagMissingAlt`, `LinkToMissingAccessibleName`
|
|
41
|
+
- HTML/helper pairs mirror each other (e.g., `ImgMissingAlt` / `ImageTagMissingAlt`)
|
|
42
|
+
|
|
43
|
+
Do not bundle unrelated elements or helpers into a single rule just because they share a WCAG criterion.
|
|
44
|
+
|
|
35
45
|
## Code Style
|
|
36
46
|
|
|
37
47
|
RuboCop is configured in `.rubocop.yml`:
|
|
@@ -6,7 +6,7 @@ module A11y
|
|
|
6
6
|
# Depends on the host class implementing
|
|
7
7
|
# #block_has_text_children? and #block_body_codes.
|
|
8
8
|
module BlockInspection
|
|
9
|
-
ICON_HELPERS = %w[inline_svg icon
|
|
9
|
+
ICON_HELPERS = %w[inline_svg icon svg_icon].freeze
|
|
10
10
|
|
|
11
11
|
def block_has_only_icon_helpers?
|
|
12
12
|
return false if block_has_text_children?
|
|
@@ -14,15 +14,28 @@ module A11y
|
|
|
14
14
|
codes = block_body_codes
|
|
15
15
|
return true unless codes&.any?
|
|
16
16
|
|
|
17
|
-
codes.all?
|
|
17
|
+
codes.all? do |code|
|
|
18
|
+
icon_helper?(code) || decorative_image_tag?(code)
|
|
19
|
+
end
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
private
|
|
21
23
|
|
|
22
|
-
def
|
|
24
|
+
def icon_helper?(code)
|
|
23
25
|
call = RubyCode.new(code).call_node
|
|
24
26
|
call && ICON_HELPERS.include?(call.method_name)
|
|
25
27
|
end
|
|
28
|
+
|
|
29
|
+
# image_tag counts as decorative only when it lacks a non-empty
|
|
30
|
+
# alt. A non-empty alt provides the accessible name, matching how
|
|
31
|
+
# <a><img alt="Home"></a> is treated by
|
|
32
|
+
# AnchorMissingAccessibleName#child_image_has_alt?.
|
|
33
|
+
def decorative_image_tag?(code)
|
|
34
|
+
call = RubyCode.new(code).call_node
|
|
35
|
+
call &&
|
|
36
|
+
call.method_name == "image_tag" &&
|
|
37
|
+
!call.keyword_non_empty?(:alt)
|
|
38
|
+
end
|
|
26
39
|
end
|
|
27
40
|
end
|
|
28
41
|
end
|
data/lib/a11y/lint/call_node.rb
CHANGED
|
@@ -30,6 +30,23 @@ module A11y
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# True when the keyword is present AND its value is a non-empty
|
|
34
|
+
# string literal OR any non-string expression (dynamic — can't be
|
|
35
|
+
# statically proven empty, so treat as providing content).
|
|
36
|
+
# False when the key is absent or the value is an empty string
|
|
37
|
+
# literal.
|
|
38
|
+
def keyword_non_empty?(key)
|
|
39
|
+
return false unless (kw_hash = find_keyword_hash)
|
|
40
|
+
|
|
41
|
+
assoc = kw_hash.elements.find { |a| key_name(a) == key.to_s }
|
|
42
|
+
return false unless assoc
|
|
43
|
+
|
|
44
|
+
value = assoc.value
|
|
45
|
+
return !value.unescaped.empty? if value.is_a?(Prism::StringNode)
|
|
46
|
+
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
33
50
|
def positional_args
|
|
34
51
|
return [] unless @prism_node.arguments
|
|
35
52
|
|
data/lib/a11y/lint/cli.rb
CHANGED
|
@@ -99,7 +99,7 @@ module A11y
|
|
|
99
99
|
|
|
100
100
|
Rules.constants.filter_map do |name|
|
|
101
101
|
klass = Rules.const_get(name)
|
|
102
|
-
next unless klass.is_a?(Class) && klass <
|
|
102
|
+
next unless klass.is_a?(Class) && klass < NodeRule
|
|
103
103
|
|
|
104
104
|
klass if configuration.enabled?(klass.rule_name)
|
|
105
105
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
# Wraps a Nokogiri HTML element from an ERB template
|
|
6
|
+
# as a queryable node for lint rules.
|
|
7
|
+
class ErbElementNode
|
|
8
|
+
attr_reader :line
|
|
9
|
+
|
|
10
|
+
def initialize(nokogiri_node:, line:, has_erb_output: false)
|
|
11
|
+
@nokogiri_node = nokogiri_node
|
|
12
|
+
@line = line
|
|
13
|
+
@has_erb_output = has_erb_output
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def tag_name
|
|
17
|
+
@nokogiri_node.name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def attribute?(name)
|
|
21
|
+
attributes.key?(name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def attributes
|
|
25
|
+
@attributes ||=
|
|
26
|
+
@nokogiri_node
|
|
27
|
+
.attributes
|
|
28
|
+
.transform_values(&:value)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call_node
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ruby_code
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def block_body_codes
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def block_has_text_children?
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def text_content?
|
|
48
|
+
@has_erb_output || !@nokogiri_node.text.strip.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns direct element children wrapped as ErbElementNode objects.
|
|
52
|
+
def children
|
|
53
|
+
@nokogiri_node.element_children.map do |child|
|
|
54
|
+
ErbElementNode.new(nokogiri_node: child, line: child.line)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
# Wraps an extracted ERB output tag (<%= ... %>)
|
|
6
|
+
# as a queryable node for lint rules.
|
|
7
|
+
class ErbOutputNode
|
|
8
|
+
include BlockInspection
|
|
9
|
+
|
|
10
|
+
attr_reader(:block_body_codes, :line, :ruby_code)
|
|
11
|
+
|
|
12
|
+
def initialize(
|
|
13
|
+
ruby_code:, line:,
|
|
14
|
+
block_body_codes: nil, block_has_text_children: false
|
|
15
|
+
)
|
|
16
|
+
@ruby_code = ruby_code
|
|
17
|
+
@line = line
|
|
18
|
+
@block_body_codes = block_body_codes
|
|
19
|
+
@block_has_text_children = block_has_text_children
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tag_name
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def attribute?(name)
|
|
27
|
+
attributes.key?(name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def attributes
|
|
31
|
+
{}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call_node
|
|
35
|
+
@call_node ||= RubyCode.new(ruby_code).call_node
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def block_has_text_children?
|
|
39
|
+
@block_has_text_children
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def children
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/a11y/lint/erb_runner.rb
CHANGED
|
@@ -8,6 +8,7 @@ module A11y
|
|
|
8
8
|
class ErbRunner
|
|
9
9
|
ERB_TAG = /<%.*?%>/m
|
|
10
10
|
ERB_OUTPUT_TAG = /<%=\s*(.*?)\s*-?%>/m
|
|
11
|
+
ERB_OUTPUT_MARKER = "A11Y_LINT_ERB_OUTPUT"
|
|
11
12
|
VOID_ELEMENTS = %w[
|
|
12
13
|
area base br col embed hr img input
|
|
13
14
|
link meta param source track wbr
|
|
@@ -32,19 +33,27 @@ module A11y
|
|
|
32
33
|
attr_reader :rules
|
|
33
34
|
|
|
34
35
|
def check_html_nodes(source)
|
|
35
|
-
html = source.gsub(
|
|
36
|
+
html = source.gsub(ERB_OUTPUT_TAG, ERB_OUTPUT_MARKER)
|
|
37
|
+
html = html.gsub(ERB_TAG, "")
|
|
36
38
|
doc = Nokogiri::HTML4::DocumentFragment.parse(html)
|
|
37
39
|
|
|
38
40
|
doc.traverse do |nokogiri_node|
|
|
39
41
|
next unless nokogiri_node.element?
|
|
40
42
|
next unless source_confirmed_element?(html, nokogiri_node.name)
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
44
|
+
node = build_erb_element_node(nokogiri_node)
|
|
45
|
+
check_node(node)
|
|
45
46
|
end
|
|
46
47
|
end
|
|
47
48
|
|
|
49
|
+
def build_erb_element_node(nokogiri_node)
|
|
50
|
+
ErbElementNode.new(
|
|
51
|
+
nokogiri_node: nokogiri_node,
|
|
52
|
+
line: nokogiri_node.line,
|
|
53
|
+
has_erb_output: nokogiri_node.text.include?(ERB_OUTPUT_MARKER)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
48
57
|
def source_confirmed_element?(html, tag_name)
|
|
49
58
|
VOID_ELEMENTS.include?(tag_name) || html.include?("</#{tag_name}>")
|
|
50
59
|
end
|
|
@@ -64,7 +73,7 @@ module A11y
|
|
|
64
73
|
block_body_codes, block_has_text =
|
|
65
74
|
extract_block_info(source, code, match_end)
|
|
66
75
|
|
|
67
|
-
|
|
76
|
+
ErbOutputNode.new(
|
|
68
77
|
ruby_code: code, line: line,
|
|
69
78
|
block_body_codes: block_body_codes,
|
|
70
79
|
block_has_text_children: block_has_text
|
data/lib/a11y/lint/phlex_node.rb
CHANGED
|
@@ -22,7 +22,8 @@ module A11y
|
|
|
22
22
|
line:, tag_name: nil, attributes: {},
|
|
23
23
|
call_node: nil, children: [],
|
|
24
24
|
block_body_codes: nil,
|
|
25
|
-
block_has_text_children: false
|
|
25
|
+
block_has_text_children: false,
|
|
26
|
+
text_content: false
|
|
26
27
|
)
|
|
27
28
|
@tag_name = tag_name
|
|
28
29
|
@attributes = attributes
|
|
@@ -31,6 +32,7 @@ module A11y
|
|
|
31
32
|
@children = children
|
|
32
33
|
@block_body_codes = block_body_codes
|
|
33
34
|
@block_has_text_children = block_has_text_children
|
|
35
|
+
@text_content = text_content
|
|
34
36
|
end
|
|
35
37
|
# rubocop:enable Metrics/ParameterLists
|
|
36
38
|
|
|
@@ -46,13 +48,18 @@ module A11y
|
|
|
46
48
|
@block_has_text_children
|
|
47
49
|
end
|
|
48
50
|
|
|
49
|
-
def
|
|
51
|
+
def text_content?
|
|
52
|
+
@text_content
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.build_tag(call_node, children: [], text_content: false)
|
|
50
56
|
name = call_node.name.to_s
|
|
51
57
|
new(
|
|
52
58
|
tag_name: html_tag_name(name),
|
|
53
59
|
attributes: extract_attributes(call_node),
|
|
54
60
|
line: call_node.location.start_line,
|
|
55
|
-
children: children
|
|
61
|
+
children: children,
|
|
62
|
+
text_content: text_content
|
|
56
63
|
)
|
|
57
64
|
end
|
|
58
65
|
|
|
@@ -74,7 +81,7 @@ module A11y
|
|
|
74
81
|
|
|
75
82
|
kwarg_nodes(call_node).each_with_object({}) do |elem, h|
|
|
76
83
|
key = kwarg_key(elem.key)
|
|
77
|
-
h[key] =
|
|
84
|
+
h[key] = kwarg_value(elem.value) if key
|
|
78
85
|
end
|
|
79
86
|
end
|
|
80
87
|
|
|
@@ -91,7 +98,15 @@ module A11y
|
|
|
91
98
|
end
|
|
92
99
|
end
|
|
93
100
|
|
|
94
|
-
|
|
101
|
+
def self.kwarg_value(value_node)
|
|
102
|
+
case value_node
|
|
103
|
+
when Prism::StringNode then value_node.unescaped
|
|
104
|
+
when Prism::SymbolNode then value_node.value
|
|
105
|
+
else true
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private_class_method :kwarg_key, :kwarg_nodes, :kwarg_value,
|
|
95
110
|
:extract_attributes
|
|
96
111
|
end
|
|
97
112
|
end
|
|
@@ -46,7 +46,10 @@ module A11y
|
|
|
46
46
|
|
|
47
47
|
def check_tag(node)
|
|
48
48
|
children = collect_block_children(node.block)
|
|
49
|
-
|
|
49
|
+
has_text = tag_block_has_text?(node.block, children)
|
|
50
|
+
check_node(
|
|
51
|
+
PhlexNode.build_tag(node, children:, text_content: has_text)
|
|
52
|
+
)
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
def check_helper(node)
|
|
@@ -80,8 +83,25 @@ module A11y
|
|
|
80
83
|
|
|
81
84
|
def gather_tag_child(child, result)
|
|
82
85
|
kids = collect_block_children(child.block)
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
has_text = tag_block_has_text?(child.block, kids)
|
|
87
|
+
tag = PhlexNode.build_tag(
|
|
88
|
+
child, children: kids, text_content: has_text
|
|
89
|
+
)
|
|
90
|
+
result << tag
|
|
91
|
+
check_node(tag)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def tag_block_has_text?(block, children)
|
|
95
|
+
return false unless block.is_a?(Prism::BlockNode)
|
|
96
|
+
|
|
97
|
+
scan_for_text(block) || children.any?(&:text_content?)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def scan_for_text(node)
|
|
101
|
+
node.child_nodes.compact.any? do |child|
|
|
102
|
+
text_call?(child) || child.is_a?(Prism::YieldNode) ||
|
|
103
|
+
(!receiverless_call?(child) && scan_for_text(child))
|
|
104
|
+
end
|
|
85
105
|
end
|
|
86
106
|
|
|
87
107
|
def analyze_helper_block(call_node)
|
|
@@ -116,6 +136,10 @@ module A11y
|
|
|
116
136
|
receiverless_call?(node) && PhlexNode.html_tag?(node.name.to_s)
|
|
117
137
|
end
|
|
118
138
|
|
|
139
|
+
def text_call?(node)
|
|
140
|
+
receiverless_call?(node) && node.name.to_s == "plain"
|
|
141
|
+
end
|
|
142
|
+
|
|
119
143
|
def receiverless_call?(node)
|
|
120
144
|
node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
121
145
|
end
|
data/lib/a11y/lint/phlex_tags.rb
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that area tags include an alt attribute (WCAG 1.1.1).
|
|
7
|
+
# https://www.w3.org/WAI/WCAG21/Techniques/html/H24
|
|
8
|
+
class AreaMissingAlt < NodeRule
|
|
9
|
+
def check
|
|
10
|
+
return unless an_area_without_an_alt_attribute?
|
|
11
|
+
|
|
12
|
+
"area tag is missing an alt attribute (WCAG 1.1.1)"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def an_area_without_an_alt_attribute?
|
|
18
|
+
node.tag_name == "area" && !node.attribute?("alt")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that image_submit_tag calls include an alt option (WCAG 1.1.1).
|
|
7
|
+
# https://www.w3.org/WAI/WCAG21/Techniques/html/H36
|
|
8
|
+
class ImageSubmitTagMissingAlt < NodeRule
|
|
9
|
+
def check
|
|
10
|
+
return if no_offense?
|
|
11
|
+
|
|
12
|
+
"image_submit_tag is missing an alt option (WCAG 1.1.1)"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def no_offense?
|
|
18
|
+
!image_submit_tag || image_submit_tag.keyword?(:alt)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def image_submit_tag
|
|
22
|
+
@image_submit_tag ||= node.call_node&.find("image_submit_tag")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that <input type="image"> tags include an alt
|
|
7
|
+
# attribute (WCAG 1.1.1).
|
|
8
|
+
# https://www.w3.org/WAI/WCAG21/Techniques/html/H36
|
|
9
|
+
class InputImageMissingAlt < NodeRule
|
|
10
|
+
def check
|
|
11
|
+
return unless an_input_image_without_an_alt_attribute?
|
|
12
|
+
|
|
13
|
+
"input type=\"image\" is missing an alt attribute (WCAG 1.1.1)"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def an_input_image_without_an_alt_attribute?
|
|
19
|
+
node.tag_name == "input" &&
|
|
20
|
+
node.attributes["type"] == "image" &&
|
|
21
|
+
!node.attribute?("alt")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that <input> elements include an autocomplete
|
|
7
|
+
# attribute (WCAG 1.3.5).
|
|
8
|
+
# https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html
|
|
9
|
+
class InputMissingAutocomplete < NodeRule
|
|
10
|
+
EXCLUDED_TYPES = %w[
|
|
11
|
+
button
|
|
12
|
+
checkbox
|
|
13
|
+
file
|
|
14
|
+
hidden
|
|
15
|
+
image
|
|
16
|
+
radio
|
|
17
|
+
reset
|
|
18
|
+
submit
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def check
|
|
22
|
+
return unless an_input_missing_autocomplete?
|
|
23
|
+
|
|
24
|
+
"input is missing an autocomplete attribute (WCAG 1.3.5)"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def an_input_missing_autocomplete?
|
|
30
|
+
node.tag_name == "input" &&
|
|
31
|
+
!EXCLUDED_TYPES.include?(node.attributes["type"]) &&
|
|
32
|
+
!node.attribute?("autocomplete")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -5,7 +5,7 @@ module A11y
|
|
|
5
5
|
module Rules
|
|
6
6
|
# Checks that <ul> and <ol> only directly contain <li>, <script>,
|
|
7
7
|
# or <template> elements (WCAG 1.3.1).
|
|
8
|
-
class ListInvalidChildren <
|
|
8
|
+
class ListInvalidChildren < NodeRule
|
|
9
9
|
LIST_TAGS = %w[ul ol].freeze
|
|
10
10
|
ALLOWED_CHILDREN = %w[li script template].freeze
|
|
11
11
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that <a> elements with no text content or meaningful
|
|
7
|
+
# child elements include an aria-label (WCAG 4.1.2).
|
|
8
|
+
class AnchorMissingAccessibleName < NodeRule
|
|
9
|
+
def check
|
|
10
|
+
return if no_offense?
|
|
11
|
+
|
|
12
|
+
"a tag is missing an accessible name " \
|
|
13
|
+
"requires an aria-label (WCAG 4.1.2)"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def no_offense?
|
|
19
|
+
node.tag_name != "a" ||
|
|
20
|
+
aria_label? ||
|
|
21
|
+
node.text_content? ||
|
|
22
|
+
child_image_has_alt?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def aria_label?
|
|
26
|
+
node.attribute?("aria-label") || node.attribute?("aria_label")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def child_image_has_alt?
|
|
30
|
+
node.children.any? do |child|
|
|
31
|
+
child.tag_name == "img" && non_empty_alt?(child)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def non_empty_alt?(child)
|
|
36
|
+
alt = child.attributes["alt"]
|
|
37
|
+
alt.is_a?(String) && !alt.strip.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that <button> elements with no text content or meaningful
|
|
7
|
+
# child elements include an aria-label (WCAG 4.1.2).
|
|
8
|
+
class ButtonMissingAccessibleName < NodeRule
|
|
9
|
+
def check
|
|
10
|
+
return if no_offense?
|
|
11
|
+
|
|
12
|
+
"button tag is missing an accessible name " \
|
|
13
|
+
"requires an aria-label (WCAG 4.1.2)"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def no_offense?
|
|
19
|
+
node.tag_name != "button" ||
|
|
20
|
+
aria_label? ||
|
|
21
|
+
node.text_content? ||
|
|
22
|
+
child_image_has_alt?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def aria_label?
|
|
26
|
+
node.attribute?("aria-label") || node.attribute?("aria_label")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def child_image_has_alt?
|
|
30
|
+
node.children.any? do |child|
|
|
31
|
+
child.tag_name == "img" && non_empty_alt?(child)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def non_empty_alt?(child)
|
|
36
|
+
alt = child.attributes["alt"]
|
|
37
|
+
alt.is_a?(String) && !alt.strip.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that button_tag calls with empty text or block content
|
|
7
|
+
# include an aria-label (WCAG 4.1.2).
|
|
8
|
+
class ButtonTagMissingAccessibleName < NodeRule
|
|
9
|
+
def check
|
|
10
|
+
return if no_offense?
|
|
11
|
+
|
|
12
|
+
offense_message
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def no_offense?
|
|
18
|
+
!helper_call ||
|
|
19
|
+
aria_label? ||
|
|
20
|
+
!(helper_call.first_positional_arg_empty_string? ||
|
|
21
|
+
(helper_call.block? && node.block_has_only_icon_helpers?))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def aria_label?
|
|
25
|
+
helper_call.keyword?(:aria, :label) ||
|
|
26
|
+
helper_call.keyword?(:"aria-label")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def helper_call
|
|
30
|
+
@helper_call ||= begin
|
|
31
|
+
call = node.call_node
|
|
32
|
+
call if call && call.method_name == "button_tag"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def offense_message
|
|
37
|
+
<<~MSG.strip
|
|
38
|
+
button_tag missing an accessible name \
|
|
39
|
+
requires an aria-label (WCAG 4.1.2)
|
|
40
|
+
MSG
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/a11y/lint/rules/robust/{missing_accessible_name.rb → link_to_missing_accessible_name.rb}
RENAMED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module A11y
|
|
4
4
|
module Lint
|
|
5
5
|
module Rules
|
|
6
|
-
# Checks that link_to
|
|
7
|
-
#
|
|
8
|
-
class
|
|
9
|
-
METHODS = %w[link_to external_link_to
|
|
6
|
+
# Checks that link_to and external_link_to calls with empty text or
|
|
7
|
+
# block content include an aria-label (WCAG 4.1.2).
|
|
8
|
+
class LinkToMissingAccessibleName < NodeRule
|
|
9
|
+
METHODS = %w[link_to external_link_to].freeze
|
|
10
10
|
|
|
11
11
|
def check
|
|
12
12
|
return if no_offense?
|
data/lib/a11y/lint/slim_node.rb
CHANGED
|
@@ -62,7 +62,16 @@ module A11y
|
|
|
62
62
|
def block_has_text_children?
|
|
63
63
|
return false unless slim_output?
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
block_text_content?(@sexp[4])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns true when the HTML element body contains visible text
|
|
69
|
+
# or dynamic output (i.e. content that could provide an accessible
|
|
70
|
+
# name). Only meaningful for HTML tag nodes.
|
|
71
|
+
def text_content?
|
|
72
|
+
return false unless html_tag?
|
|
73
|
+
|
|
74
|
+
text_or_output?(@sexp[4])
|
|
66
75
|
end
|
|
67
76
|
|
|
68
77
|
private
|
|
@@ -90,11 +99,18 @@ module A11y
|
|
|
90
99
|
sexp.is_a?(Array) && sexp[0] == :slim && sexp[1] == :output
|
|
91
100
|
end
|
|
92
101
|
|
|
93
|
-
def
|
|
102
|
+
def block_text_content?(sexp)
|
|
94
103
|
return false unless sexp.is_a?(Array)
|
|
95
104
|
return true if slim_text_sexp?(sexp) || html_tag_sexp?(sexp)
|
|
96
105
|
|
|
97
|
-
sexp.any? { |child|
|
|
106
|
+
sexp.any? { |child| block_text_content?(child) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def text_or_output?(sexp)
|
|
110
|
+
return false unless sexp.is_a?(Array)
|
|
111
|
+
return true if slim_text_sexp?(sexp) || slim_output_sexp?(sexp)
|
|
112
|
+
|
|
113
|
+
sexp.any? { |child| text_or_output?(child) }
|
|
98
114
|
end
|
|
99
115
|
|
|
100
116
|
def slim_text_sexp?(sexp)
|
|
@@ -122,7 +138,19 @@ module A11y
|
|
|
122
138
|
return {} unless html_attributes?
|
|
123
139
|
|
|
124
140
|
sexp_attributes[2..].each_with_object({}) do |attr_sexp, result|
|
|
125
|
-
|
|
141
|
+
next unless html_attribute?(attr_sexp)
|
|
142
|
+
|
|
143
|
+
result[attr_sexp[2]] = static_value(attr_sexp[3])
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def static_value(value_sexp)
|
|
148
|
+
if value_sexp.is_a?(Array) && value_sexp[0] == :escape &&
|
|
149
|
+
value_sexp[2].is_a?(Array) &&
|
|
150
|
+
value_sexp[2][0] == :slim && value_sexp[2][1] == :interpolate
|
|
151
|
+
value_sexp[2][2]
|
|
152
|
+
else
|
|
153
|
+
true
|
|
126
154
|
end
|
|
127
155
|
end
|
|
128
156
|
|
data/lib/a11y/lint/version.rb
CHANGED
data/lib/a11y/lint.rb
CHANGED
|
@@ -8,15 +8,13 @@ require_relative "lint/call_node"
|
|
|
8
8
|
require_relative "lint/ruby_code"
|
|
9
9
|
require_relative "lint/block_inspection"
|
|
10
10
|
require_relative "lint/slim_node"
|
|
11
|
-
require_relative "lint/
|
|
11
|
+
require_relative "lint/erb_element_node"
|
|
12
|
+
require_relative "lint/erb_output_node"
|
|
12
13
|
require_relative "lint/phlex_tags"
|
|
13
14
|
require_relative "lint/phlex_node"
|
|
14
15
|
require_relative "lint/configuration"
|
|
15
|
-
require_relative "lint/
|
|
16
|
-
|
|
17
|
-
require_relative "lint/rules/perceivable/img_missing_alt"
|
|
18
|
-
require_relative "lint/rules/perceivable/list_invalid_children"
|
|
19
|
-
require_relative "lint/rules/robust/missing_accessible_name"
|
|
16
|
+
require_relative "lint/node_rule"
|
|
17
|
+
Dir[File.join(__dir__, "lint", "rules", "**", "*.rb")].each { |f| require f }
|
|
20
18
|
require_relative "lint/slim_runner"
|
|
21
19
|
require_relative "lint/erb_runner"
|
|
22
20
|
require_relative "lint/phlex_runner"
|
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.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdullah Hashim
|
|
@@ -59,19 +59,27 @@ files:
|
|
|
59
59
|
- lib/a11y/lint/call_node.rb
|
|
60
60
|
- lib/a11y/lint/cli.rb
|
|
61
61
|
- lib/a11y/lint/configuration.rb
|
|
62
|
-
- lib/a11y/lint/
|
|
62
|
+
- lib/a11y/lint/erb_element_node.rb
|
|
63
|
+
- lib/a11y/lint/erb_output_node.rb
|
|
63
64
|
- lib/a11y/lint/erb_runner.rb
|
|
64
65
|
- lib/a11y/lint/errors.rb
|
|
66
|
+
- lib/a11y/lint/node_rule.rb
|
|
65
67
|
- lib/a11y/lint/offense.rb
|
|
66
68
|
- lib/a11y/lint/phlex_node.rb
|
|
67
69
|
- lib/a11y/lint/phlex_runner.rb
|
|
68
70
|
- lib/a11y/lint/phlex_tags.rb
|
|
69
71
|
- lib/a11y/lint/ruby_code.rb
|
|
70
|
-
- lib/a11y/lint/
|
|
72
|
+
- lib/a11y/lint/rules/perceivable/area_missing_alt.rb
|
|
73
|
+
- lib/a11y/lint/rules/perceivable/image_submit_tag_missing_alt.rb
|
|
71
74
|
- lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb
|
|
72
75
|
- lib/a11y/lint/rules/perceivable/img_missing_alt.rb
|
|
76
|
+
- lib/a11y/lint/rules/perceivable/input_image_missing_alt.rb
|
|
77
|
+
- lib/a11y/lint/rules/perceivable/input_missing_autocomplete.rb
|
|
73
78
|
- lib/a11y/lint/rules/perceivable/list_invalid_children.rb
|
|
74
|
-
- lib/a11y/lint/rules/robust/
|
|
79
|
+
- lib/a11y/lint/rules/robust/anchor_missing_accessible_name.rb
|
|
80
|
+
- lib/a11y/lint/rules/robust/button_missing_accessible_name.rb
|
|
81
|
+
- lib/a11y/lint/rules/robust/button_tag_missing_accessible_name.rb
|
|
82
|
+
- lib/a11y/lint/rules/robust/link_to_missing_accessible_name.rb
|
|
75
83
|
- lib/a11y/lint/slim_node.rb
|
|
76
84
|
- lib/a11y/lint/slim_runner.rb
|
|
77
85
|
- lib/a11y/lint/version.rb
|
data/lib/a11y/lint/erb_node.rb
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module A11y
|
|
4
|
-
module Lint
|
|
5
|
-
# Wraps a Nokogiri node or extracted ERB output tag
|
|
6
|
-
# as a queryable node for lint rules.
|
|
7
|
-
class ErbNode
|
|
8
|
-
include BlockInspection
|
|
9
|
-
|
|
10
|
-
attr_reader(:block_body_codes, :line, :ruby_code)
|
|
11
|
-
|
|
12
|
-
def initialize(
|
|
13
|
-
line:,
|
|
14
|
-
block_body_codes: nil,
|
|
15
|
-
block_has_text_children: false,
|
|
16
|
-
nokogiri_node: nil,
|
|
17
|
-
ruby_code: nil
|
|
18
|
-
)
|
|
19
|
-
@line = line
|
|
20
|
-
@block_body_codes = block_body_codes
|
|
21
|
-
@block_has_text_children = block_has_text_children
|
|
22
|
-
@nokogiri_node = nokogiri_node
|
|
23
|
-
@ruby_code = ruby_code
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def tag_name
|
|
27
|
-
nokogiri_node&.name
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def attribute?(name)
|
|
31
|
-
attributes.key?(name)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def attributes
|
|
35
|
-
@attributes ||= extract_attributes
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def call_node
|
|
39
|
-
@call_node ||= RubyCode.new(ruby_code).call_node
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def block_has_text_children?
|
|
43
|
-
@block_has_text_children
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Returns direct element children wrapped as ErbNode objects.
|
|
47
|
-
def children
|
|
48
|
-
return [] unless nokogiri_node
|
|
49
|
-
|
|
50
|
-
nokogiri_node.element_children.map do |child|
|
|
51
|
-
ErbNode.new(nokogiri_node: child, line: child.line)
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
attr_reader(:nokogiri_node)
|
|
58
|
-
|
|
59
|
-
def extract_attributes
|
|
60
|
-
return {} unless nokogiri_node
|
|
61
|
-
|
|
62
|
-
nokogiri_node
|
|
63
|
-
.attributes
|
|
64
|
-
.each_with_object({}) do |(name, _attr), result|
|
|
65
|
-
result[name] = true
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|