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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/testing.md +1 -1
  3. data/CHANGELOG.md +22 -0
  4. data/CLAUDE.md +10 -0
  5. data/lib/a11y/lint/block_inspection.rb +16 -3
  6. data/lib/a11y/lint/call_node.rb +17 -0
  7. data/lib/a11y/lint/cli.rb +1 -1
  8. data/lib/a11y/lint/erb_element_node.rb +59 -0
  9. data/lib/a11y/lint/erb_output_node.rb +47 -0
  10. data/lib/a11y/lint/erb_runner.rb +14 -5
  11. data/lib/a11y/lint/{rule.rb → node_rule.rb} +2 -2
  12. data/lib/a11y/lint/phlex_node.rb +20 -5
  13. data/lib/a11y/lint/phlex_runner.rb +27 -3
  14. data/lib/a11y/lint/phlex_tags.rb +1 -0
  15. data/lib/a11y/lint/rules/perceivable/area_missing_alt.rb +23 -0
  16. data/lib/a11y/lint/rules/perceivable/image_submit_tag_missing_alt.rb +27 -0
  17. data/lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb +1 -1
  18. data/lib/a11y/lint/rules/perceivable/img_missing_alt.rb +1 -1
  19. data/lib/a11y/lint/rules/perceivable/input_image_missing_alt.rb +26 -0
  20. data/lib/a11y/lint/rules/perceivable/input_missing_autocomplete.rb +37 -0
  21. data/lib/a11y/lint/rules/perceivable/list_invalid_children.rb +1 -1
  22. data/lib/a11y/lint/rules/robust/anchor_missing_accessible_name.rb +42 -0
  23. data/lib/a11y/lint/rules/robust/button_missing_accessible_name.rb +42 -0
  24. data/lib/a11y/lint/rules/robust/button_tag_missing_accessible_name.rb +45 -0
  25. data/lib/a11y/lint/rules/robust/{missing_accessible_name.rb → link_to_missing_accessible_name.rb} +4 -4
  26. data/lib/a11y/lint/slim_node.rb +32 -4
  27. data/lib/a11y/lint/version.rb +1 -1
  28. data/lib/a11y/lint.rb +4 -6
  29. metadata +12 -4
  30. data/lib/a11y/lint/erb_node.rb +0 -70
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ecfe715cda47636bb8dc87f9cf3655c641d33d2e10cfd8471be73b2dd7336a1f
4
- data.tar.gz: 818bb7d22993ac9036ac148d60adce32c2ef7ee342bc741bbd8e64d893414d2f
3
+ metadata.gz: e1c92e880f51310dfe123ffdbc872bc661a781371365c53dd1a1972e302dcc1d
4
+ data.tar.gz: dfb7186f222b4c87ffdc818e843907435eebda8e99cccdc945cf1f4d8b77708f
5
5
  SHA512:
6
- metadata.gz: 3a0d354908cfc0c670db3556f0854e1e91f0486ae502ff6f93395a95923d1a6b7b456bd625ffb476ba8f7e8d3f8bea5f859ed4875e0cf172770be5e87da8517f
7
- data.tar.gz: e0e5e582649edc60f0df35635af190beb49b2ab69c9da392e9ae31326918c6704962b5a5cd6d60f8e1bb8bf47474a9d4b2fdb18baf424f3013871a7d3cc17a10
6
+ metadata.gz: 0fde7a03bb0e20294c451a57da6aa487fd7aa6cf6b3bde0d41e6b7a323ededf15c626e6b06e7dd55f37b109a0371cc24b5708fd5528f959bb96a6269642152f8
7
+ data.tar.gz: b43f87086fdabf16d139639d169be80cadea3da8c61b09e7a92cc66f4c5d59011dce8b7433307877b652b4b85c2d4cb923c6ed9a95695094b035cbcdbe2d6d0a
@@ -49,4 +49,4 @@ globs: test/**/*.rb
49
49
 
50
50
  Each style must be tested for both the offense case and the "passes with fix" case.
51
51
 
52
- - Every rule must be tested against both the Slim and ERB pipelines.
52
+ - Every rule must be tested against the Slim, ERB, and Phlex pipelines.
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 image_tag svg_icon].freeze
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? { |code| icon_helper_call?(code) }
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 icon_helper_call?(code)
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
@@ -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 < Rule
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
@@ -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(ERB_TAG, "")
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
- check_node(
43
- ErbNode.new(nokogiri_node: nokogiri_node, line: nokogiri_node.line)
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
- ErbNode.new(
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
@@ -2,8 +2,8 @@
2
2
 
3
3
  module A11y
4
4
  module Lint
5
- # Base class for accessibility lint rules.
6
- class Rule
5
+ # Base class for per-node accessibility lint rules.
6
+ class NodeRule
7
7
  def self.check(node)
8
8
  new(node).check
9
9
  end
@@ -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 self.build_tag(call_node, children: [])
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] = true if 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
- private_class_method :kwarg_key, :kwarg_nodes,
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
- check_node(PhlexNode.build_tag(node, children:))
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
- result << PhlexNode.build_tag(child, children: kids)
84
- check_node(result.last)
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
@@ -14,6 +14,7 @@ module A11y
14
14
  %w[
15
15
  a
16
16
  abbr
17
+ area
17
18
  address
18
19
  article
19
20
  aside
@@ -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
@@ -4,7 +4,7 @@ module A11y
4
4
  module Lint
5
5
  module Rules
6
6
  # Checks that image_tag calls include an alt option (WCAG 1.1.1).
7
- class ImageTagMissingAlt < Rule
7
+ class ImageTagMissingAlt < NodeRule
8
8
  def check
9
9
  return if no_offense?
10
10
 
@@ -4,7 +4,7 @@ module A11y
4
4
  module Lint
5
5
  module Rules
6
6
  # Checks that img tags include an alt attribute (WCAG 1.1.1).
7
- class ImgMissingAlt < Rule
7
+ class ImgMissingAlt < NodeRule
8
8
  def check
9
9
  return unless an_image_without_an_alt_attribute?
10
10
 
@@ -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 < Rule
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
@@ -3,10 +3,10 @@
3
3
  module A11y
4
4
  module Lint
5
5
  module Rules
6
- # Checks that link_to, external_link_to, and button_tag calls with
7
- # empty text or block content include an aria-label (WCAG 4.1.2).
8
- class MissingAccessibleName < Rule
9
- METHODS = %w[link_to external_link_to button_tag].freeze
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?
@@ -62,7 +62,16 @@ module A11y
62
62
  def block_has_text_children?
63
63
  return false unless slim_output?
64
64
 
65
- text_content?(@sexp[4])
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 text_content?(sexp)
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| text_content?(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
- result[attr_sexp[2]] = true if html_attribute?(attr_sexp)
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A11y
4
4
  module Lint
5
- VERSION = "0.10.0"
5
+ VERSION = "0.11.0"
6
6
  end
7
7
  end
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/erb_node"
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/rule"
16
- require_relative "lint/rules/perceivable/image_tag_missing_alt"
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.10.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/erb_node.rb
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/rule.rb
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/missing_accessible_name.rb
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
@@ -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