a11y-lint 0.10.0 → 0.12.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/testing.md +1 -1
  3. data/CHANGELOG.md +28 -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 +6 -16
  8. data/lib/a11y/lint/configuration.rb +14 -0
  9. data/lib/a11y/lint/erb_element_node.rb +104 -0
  10. data/lib/a11y/lint/erb_output_node.rb +47 -0
  11. data/lib/a11y/lint/erb_runner.rb +64 -11
  12. data/lib/a11y/lint/{rule.rb → node_rule.rb} +2 -2
  13. data/lib/a11y/lint/phlex_node.rb +41 -7
  14. data/lib/a11y/lint/phlex_runner.rb +55 -7
  15. data/lib/a11y/lint/phlex_tags.rb +1 -0
  16. data/lib/a11y/lint/rules/perceivable/area_missing_alt.rb +23 -0
  17. data/lib/a11y/lint/rules/perceivable/image_submit_tag_missing_alt.rb +27 -0
  18. data/lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb +1 -1
  19. data/lib/a11y/lint/rules/perceivable/img_missing_alt.rb +1 -1
  20. data/lib/a11y/lint/rules/perceivable/input_image_missing_alt.rb +26 -0
  21. data/lib/a11y/lint/rules/perceivable/input_missing_autocomplete.rb +37 -0
  22. data/lib/a11y/lint/rules/perceivable/list_invalid_children.rb +1 -1
  23. data/lib/a11y/lint/rules/robust/anchor_missing_accessible_name.rb +42 -0
  24. data/lib/a11y/lint/rules/robust/button_missing_accessible_name.rb +42 -0
  25. data/lib/a11y/lint/rules/robust/button_tag_missing_accessible_name.rb +45 -0
  26. data/lib/a11y/lint/rules/robust/{missing_accessible_name.rb → link_to_missing_accessible_name.rb} +4 -4
  27. data/lib/a11y/lint/slim_node.rb +89 -7
  28. data/lib/a11y/lint/slim_runner.rb +5 -4
  29. data/lib/a11y/lint/version.rb +1 -1
  30. data/lib/a11y/lint.rb +4 -6
  31. metadata +12 -4
  32. 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: 7f62cdc346f6aa1f5c2cae674c365678a333479966aabd1b144e7901f90e7cdf
4
+ data.tar.gz: 874c7cb5bac0680cb58fb2f73e89304c21862686010f5b0e721aad19861c0bbb
5
5
  SHA512:
6
- metadata.gz: 3a0d354908cfc0c670db3556f0854e1e91f0486ae502ff6f93395a95923d1a6b7b456bd625ffb476ba8f7e8d3f8bea5f859ed4875e0cf172770be5e87da8517f
7
- data.tar.gz: e0e5e582649edc60f0df35635af190beb49b2ab69c9da392e9ae31326918c6704962b5a5cd6d60f8e1bb8bf47474a9d4b2fdb18baf424f3013871a7d3cc17a10
6
+ metadata.gz: 5757d27c00656b68b5b4209a153da09e720d190bacb5124e1860a03a53d3e0df4d02909a2c1d667c5be477394228274a6e64c95b4a032d64cb14a65ba72f5bd7
7
+ data.tar.gz: bf502097c82848041313039b29271808133c1ff3be50b1d8e4b31b5d7df7bb1c35bb1ae643b869143355bbcd3dd2cfcddf98acd44ac858f4edb436e5699b3b92
@@ -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,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.0] - 2026-04-27
11
+
12
+ ### Added
13
+
14
+ - `.a11y-lint.yml` now supports a top-level `hidden_wrapper_classes` list; content inside elements whose class matches is treated as hidden from assistive technology when the four accessible-name rules (`AnchorMissingAccessibleName`, `ButtonMissingAccessibleName`, `LinkToMissingAccessibleName`, `ButtonTagMissingAccessibleName`) determine whether a button/link has an accessible name. Opt-in; default is no filtering
15
+
16
+ ## [0.11.0] - 2026-04-21
17
+
18
+ ### Added
19
+
20
+ - `AreaMissingAlt` rule: detects `<area>` tags without `alt` attributes (WCAG 1.1.1)
21
+ - `InputImageMissingAlt` rule: detects `<input type="image">` tags without `alt` attributes (WCAG 1.1.1)
22
+ - `ImageSubmitTagMissingAlt` rule: detects `image_submit_tag` calls without an `alt` option (WCAG 1.1.1)
23
+ - `InputMissingAutocomplete` rule: detects `<input>` elements without an `autocomplete` attribute (WCAG 1.3.5)
24
+ - `AnchorMissingAccessibleName` rule: detects `<a>` elements without text content, `aria-label`, or a child image with `alt` (WCAG 4.1.2)
25
+ - `ButtonMissingAccessibleName` rule: detects `<button>` elements without text content, `aria-label`, or a child image with `alt` (WCAG 4.1.2)
26
+
27
+ ### Changed
28
+
29
+ - **Breaking:** Split `MissingAccessibleName` into `LinkToMissingAccessibleName` (for `link_to`/`external_link_to`) and `ButtonTagMissingAccessibleName` (for `button_tag`) to follow one-rule-per-helper convention
30
+ - **Breaking:** Rename `A11y::Lint::Rule` to `A11y::Lint::NodeRule`. Custom rules that subclass the base class must update the parent class name
31
+ - Auto-require all rule files instead of listing them individually
32
+ - Node attribute storage now preserves string values, enabling rules to check attribute values (not just existence)
33
+
34
+ ### Fixed
35
+
36
+ - `LinkToMissingAccessibleName` / `ButtonTagMissingAccessibleName`: no longer flag `link_to`/`button_tag` blocks containing an `image_tag` with a non-empty `alt` option
37
+
10
38
  ## [0.10.0] - 2026-04-15
11
39
 
12
40
  ### 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
@@ -71,10 +71,10 @@ module A11y
71
71
  end
72
72
 
73
73
  def lint_files(files)
74
- rules = all_rules
75
- slim_runner = SlimRunner.new(rules)
76
- erb_runner = ErbRunner.new(rules)
77
- phlex_runner = PhlexRunner.new(rules)
74
+ configuration = load_configuration
75
+ slim_runner = SlimRunner.new(configuration:)
76
+ erb_runner = ErbRunner.new(configuration:)
77
+ phlex_runner = PhlexRunner.new(configuration:)
78
78
 
79
79
  files.flat_map do |file|
80
80
  source = File.read(file)
@@ -91,18 +91,8 @@ module A11y
91
91
  end
92
92
  end
93
93
 
94
- def all_rules
95
- configuration = Configuration.load(
96
- @config_path,
97
- search_path: @argv.first || "."
98
- )
99
-
100
- Rules.constants.filter_map do |name|
101
- klass = Rules.const_get(name)
102
- next unless klass.is_a?(Class) && klass < Rule
103
-
104
- klass if configuration.enabled?(klass.rule_name)
105
- end
94
+ def load_configuration
95
+ Configuration.load(@config_path, search_path: @argv.first || ".")
106
96
  end
107
97
 
108
98
  def print_results(offenses)
@@ -41,6 +41,20 @@ module A11y
41
41
 
42
42
  @config.dig(rule_name, "Enabled") != false
43
43
  end
44
+
45
+ def hidden_wrapper_classes
46
+ @hidden_wrapper_classes ||=
47
+ Array(@config["hidden_wrapper_classes"]).map(&:to_s).freeze
48
+ end
49
+
50
+ def enabled_rules
51
+ Rules.constants.filter_map do |name|
52
+ klass = Rules.const_get(name)
53
+ next unless klass.is_a?(Class) && klass < NodeRule
54
+
55
+ klass if enabled?(klass.rule_name)
56
+ end
57
+ end
44
58
  end
45
59
  end
46
60
  end
@@ -0,0 +1,104 @@
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, :configuration
9
+
10
+ def initialize(
11
+ nokogiri_node:, line:, configuration: Configuration.new
12
+ )
13
+ @nokogiri_node = nokogiri_node
14
+ @line = line
15
+ @configuration = configuration
16
+ end
17
+
18
+ def tag_name
19
+ @nokogiri_node.name
20
+ end
21
+
22
+ def attribute?(name)
23
+ attributes.key?(name)
24
+ end
25
+
26
+ def attributes
27
+ @attributes ||=
28
+ @nokogiri_node
29
+ .attributes
30
+ .transform_values(&:value)
31
+ end
32
+
33
+ def call_node
34
+ nil
35
+ end
36
+
37
+ def ruby_code
38
+ nil
39
+ end
40
+
41
+ def block_body_codes
42
+ nil
43
+ end
44
+
45
+ def block_has_text_children?
46
+ false
47
+ end
48
+
49
+ def text_content?
50
+ return false if hidden_wrapper?(@nokogiri_node)
51
+
52
+ visible_text_or_output?(@nokogiri_node)
53
+ end
54
+
55
+ # Returns direct element children wrapped as ErbElementNode objects.
56
+ # Excludes elements whose class attribute matches a configured
57
+ # hidden-wrapper class, since CSS-hidden subtrees do not contribute
58
+ # to the accessible name.
59
+ def children
60
+ @nokogiri_node.element_children.filter_map do |child|
61
+ next if hidden_wrapper?(child)
62
+
63
+ ErbElementNode.new(
64
+ nokogiri_node: child, line: child.line,
65
+ configuration: configuration
66
+ )
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def visible_text_or_output?(node)
73
+ return false if hidden_wrapper?(node)
74
+ return true if own_text_or_marker?(node)
75
+
76
+ node.element_children.any? { |c| visible_text_or_output?(c) }
77
+ end
78
+
79
+ def own_text_or_marker?(node)
80
+ node.children.any? do |c|
81
+ next false unless c.text?
82
+
83
+ content = c.content
84
+ content.include?(ErbRunner::ERB_OUTPUT_MARKER) ||
85
+ !content.strip.empty?
86
+ end
87
+ end
88
+
89
+ def hidden_wrapper?(node)
90
+ classes = configuration.hidden_wrapper_classes
91
+ return false if classes.empty?
92
+
93
+ node_classes(node).any? { |klass| classes.include?(klass) }
94
+ end
95
+
96
+ def node_classes(node)
97
+ return [] unless node.respond_to?(:attributes)
98
+
99
+ value = node.attributes["class"]&.value
100
+ value.is_a?(String) ? value.split : []
101
+ end
102
+ end
103
+ end
104
+ 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,13 +8,15 @@ 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
14
15
  ].freeze
15
16
 
16
- def initialize(rules)
17
- @rules = rules
17
+ def initialize(rules = nil, configuration: Configuration.new)
18
+ @rules = rules || configuration.enabled_rules
19
+ @configuration = configuration
18
20
  end
19
21
 
20
22
  def run(source, filename:)
@@ -29,22 +31,30 @@ module A11y
29
31
 
30
32
  private
31
33
 
32
- attr_reader :rules
34
+ attr_reader :rules, :configuration
33
35
 
34
36
  def check_html_nodes(source)
35
- html = source.gsub(ERB_TAG, "")
37
+ html = source.gsub(ERB_OUTPUT_TAG, ERB_OUTPUT_MARKER)
38
+ html = html.gsub(ERB_TAG, "")
36
39
  doc = Nokogiri::HTML4::DocumentFragment.parse(html)
37
40
 
38
41
  doc.traverse do |nokogiri_node|
39
42
  next unless nokogiri_node.element?
40
43
  next unless source_confirmed_element?(html, nokogiri_node.name)
41
44
 
42
- check_node(
43
- ErbNode.new(nokogiri_node: nokogiri_node, line: nokogiri_node.line)
44
- )
45
+ node = build_erb_element_node(nokogiri_node)
46
+ check_node(node)
45
47
  end
46
48
  end
47
49
 
50
+ def build_erb_element_node(nokogiri_node)
51
+ ErbElementNode.new(
52
+ nokogiri_node: nokogiri_node,
53
+ line: nokogiri_node.line,
54
+ configuration: configuration
55
+ )
56
+ end
57
+
48
58
  def source_confirmed_element?(html, tag_name)
49
59
  VOID_ELEMENTS.include?(tag_name) || html.include?("</#{tag_name}>")
50
60
  end
@@ -64,7 +74,7 @@ module A11y
64
74
  block_body_codes, block_has_text =
65
75
  extract_block_info(source, code, match_end)
66
76
 
67
- ErbNode.new(
77
+ ErbOutputNode.new(
68
78
  ruby_code: code, line: line,
69
79
  block_body_codes: block_body_codes,
70
80
  block_has_text_children: block_has_text
@@ -79,10 +89,53 @@ module A11y
79
89
  return [nil, false] unless end_match
80
90
 
81
91
  block_content = rest[0...end_match.begin(0)]
82
- codes = block_content.scan(ERB_OUTPUT_TAG).map { |m| m[0].strip }
83
- text_only = block_content.gsub(ERB_TAG, "").strip
92
+ visible_codes_and_text(block_content)
93
+ end
94
+
95
+ # Returns [visible_codes, visible_non_output_text?] where "visible"
96
+ # means not inside a hidden-wrapper element (per configuration).
97
+ def visible_codes_and_text(block_content)
98
+ indexed_codes = []
99
+ html = indexed_marker_html(block_content, indexed_codes)
100
+ fragment = Nokogiri::HTML4::DocumentFragment.parse(html)
101
+ strip_hidden_wrappers!(fragment)
102
+
103
+ remaining = fragment.to_html
104
+ visible_codes = indexed_codes.each_with_index.filter_map do |code, i|
105
+ code if remaining.include?("#{ERB_OUTPUT_MARKER}#{i}_")
106
+ end
107
+ [visible_codes, non_marker_text?(remaining)]
108
+ end
109
+
110
+ def indexed_marker_html(block_content, codes)
111
+ block_content.gsub(ERB_OUTPUT_TAG) do
112
+ codes << Regexp.last_match(1).strip
113
+ "#{ERB_OUTPUT_MARKER}#{codes.length - 1}_"
114
+ end.gsub(ERB_TAG, "")
115
+ end
116
+
117
+ def non_marker_text?(html)
118
+ !html.gsub(/#{ERB_OUTPUT_MARKER}\d+_/, "").strip.empty?
119
+ end
120
+
121
+ def strip_hidden_wrappers!(node)
122
+ return if configuration.hidden_wrapper_classes.empty?
123
+
124
+ node.element_children.each do |child|
125
+ if hidden_wrapper_element?(child)
126
+ child.remove
127
+ else
128
+ strip_hidden_wrappers!(child)
129
+ end
130
+ end
131
+ end
132
+
133
+ def hidden_wrapper_element?(node)
134
+ value = node.attributes["class"]&.value
135
+ return false unless value.is_a?(String)
84
136
 
85
- [codes, !text_only.empty?]
137
+ classes = configuration.hidden_wrapper_classes
138
+ value.split.any? { |klass| classes.include?(klass) }
86
139
  end
87
140
 
88
141
  def check_node(node)
@@ -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
@@ -13,6 +13,7 @@ module A11y
13
13
  :block_body_codes,
14
14
  :call_node,
15
15
  :children,
16
+ :configuration,
16
17
  :line,
17
18
  :tag_name
18
19
  )
@@ -22,7 +23,9 @@ module A11y
22
23
  line:, tag_name: nil, attributes: {},
23
24
  call_node: nil, children: [],
24
25
  block_body_codes: nil,
25
- block_has_text_children: false
26
+ block_has_text_children: false,
27
+ text_content: false,
28
+ configuration: Configuration.new
26
29
  )
27
30
  @tag_name = tag_name
28
31
  @attributes = attributes
@@ -31,6 +34,8 @@ module A11y
31
34
  @children = children
32
35
  @block_body_codes = block_body_codes
33
36
  @block_has_text_children = block_has_text_children
37
+ @text_content = text_content
38
+ @configuration = configuration
34
39
  end
35
40
  # rubocop:enable Metrics/ParameterLists
36
41
 
@@ -46,35 +51,56 @@ module A11y
46
51
  @block_has_text_children
47
52
  end
48
53
 
49
- def self.build_tag(call_node, children: [])
54
+ def text_content?
55
+ @text_content
56
+ end
57
+
58
+ def self.build_tag(
59
+ call_node, children: [], text_content: false,
60
+ configuration: Configuration.new
61
+ )
50
62
  name = call_node.name.to_s
51
63
  new(
52
64
  tag_name: html_tag_name(name),
53
65
  attributes: extract_attributes(call_node),
54
66
  line: call_node.location.start_line,
55
- children: children
67
+ children: children,
68
+ text_content: text_content,
69
+ configuration: configuration
56
70
  )
57
71
  end
58
72
 
59
73
  def self.build_helper(
60
74
  call_node,
61
75
  block_body_codes: nil,
62
- block_has_text_children: false
76
+ block_has_text_children: false,
77
+ configuration: Configuration.new
63
78
  )
64
79
  new(
65
80
  call_node: CallNode.new(call_node),
66
81
  line: call_node.location.start_line,
67
82
  block_body_codes: block_body_codes,
68
- block_has_text_children: block_has_text_children
83
+ block_has_text_children: block_has_text_children,
84
+ configuration: configuration
69
85
  )
70
86
  end
71
87
 
88
+ def self.kwarg_class_values(call_node)
89
+ return [] unless call_node.arguments
90
+
91
+ value = kwarg_nodes(call_node).find do |elem|
92
+ kwarg_key(elem.key) == "class"
93
+ end&.value
94
+
95
+ value.is_a?(Prism::StringNode) ? value.unescaped.split : []
96
+ end
97
+
72
98
  def self.extract_attributes(call_node)
73
99
  return {} unless call_node.arguments
74
100
 
75
101
  kwarg_nodes(call_node).each_with_object({}) do |elem, h|
76
102
  key = kwarg_key(elem.key)
77
- h[key] = true if key
103
+ h[key] = kwarg_value(elem.value) if key
78
104
  end
79
105
  end
80
106
 
@@ -91,7 +117,15 @@ module A11y
91
117
  end
92
118
  end
93
119
 
94
- private_class_method :kwarg_key, :kwarg_nodes,
120
+ def self.kwarg_value(value_node)
121
+ case value_node
122
+ when Prism::StringNode then value_node.unescaped
123
+ when Prism::SymbolNode then value_node.value
124
+ else true
125
+ end
126
+ end
127
+
128
+ private_class_method :kwarg_key, :kwarg_nodes, :kwarg_value,
95
129
  :extract_attributes
96
130
  end
97
131
  end
@@ -9,8 +9,9 @@ module A11y
9
9
  class PhlexRunner
10
10
  PHLEX_PATTERN = /\bdef\s+view_template\b/
11
11
 
12
- def initialize(rules)
13
- @rules = rules
12
+ def initialize(rules = nil, configuration: Configuration.new)
13
+ @rules = rules || configuration.enabled_rules
14
+ @configuration = configuration
14
15
  end
15
16
 
16
17
  def run(source, filename:)
@@ -26,7 +27,7 @@ module A11y
26
27
 
27
28
  private
28
29
 
29
- attr_reader :rules
30
+ attr_reader :rules, :configuration
30
31
 
31
32
  def walk(node)
32
33
  if receiverless_call?(node)
@@ -46,7 +47,15 @@ module A11y
46
47
 
47
48
  def check_tag(node)
48
49
  children = collect_block_children(node.block)
49
- check_node(PhlexNode.build_tag(node, children:))
50
+ has_text = tag_block_has_text?(node.block, children)
51
+ check_node(
52
+ PhlexNode.build_tag(
53
+ node,
54
+ children: children,
55
+ text_content: has_text,
56
+ configuration: configuration
57
+ )
58
+ )
50
59
  end
51
60
 
52
61
  def check_helper(node)
@@ -54,7 +63,8 @@ module A11y
54
63
  helper = PhlexNode.build_helper(
55
64
  node,
56
65
  block_body_codes: codes,
57
- block_has_text_children: has_text
66
+ block_has_text_children: has_text,
67
+ configuration: configuration
58
68
  )
59
69
  check_node(helper)
60
70
  walk_block(node.block)
@@ -80,8 +90,39 @@ module A11y
80
90
 
81
91
  def gather_tag_child(child, result)
82
92
  kids = collect_block_children(child.block)
83
- result << PhlexNode.build_tag(child, children: kids)
84
- check_node(result.last)
93
+ has_text = tag_block_has_text?(child.block, kids)
94
+ tag = PhlexNode.build_tag(
95
+ child, children: kids, text_content: has_text,
96
+ configuration: configuration
97
+ )
98
+ check_node(tag)
99
+ result << tag unless hidden_wrapper_tag?(child)
100
+ end
101
+
102
+ def hidden_wrapper_tag?(call_node)
103
+ classes = configuration.hidden_wrapper_classes
104
+ return false if classes.empty?
105
+
106
+ tag_class_values(call_node).any? { |klass| classes.include?(klass) }
107
+ end
108
+
109
+ def tag_class_values(call_node)
110
+ return [] unless call_node.arguments
111
+
112
+ PhlexNode.kwarg_class_values(call_node)
113
+ end
114
+
115
+ def tag_block_has_text?(block, children)
116
+ return false unless block.is_a?(Prism::BlockNode)
117
+
118
+ scan_for_text(block) || children.any?(&:text_content?)
119
+ end
120
+
121
+ def scan_for_text(node)
122
+ node.child_nodes.compact.any? do |child|
123
+ text_call?(child) || child.is_a?(Prism::YieldNode) ||
124
+ (!receiverless_call?(child) && scan_for_text(child))
125
+ end
85
126
  end
86
127
 
87
128
  def analyze_helper_block(call_node)
@@ -94,8 +135,10 @@ module A11y
94
135
  end
95
136
 
96
137
  # rubocop:disable Metrics/CyclomaticComplexity
138
+ # rubocop:disable Metrics/PerceivedComplexity
97
139
  def scan_block_content(node, codes)
98
140
  node.child_nodes.compact.each do |child|
141
+ next if tag_call?(child) && hidden_wrapper_tag?(child)
99
142
  return true if child.is_a?(Prism::YieldNode)
100
143
  return true if tag_call?(child) && child.block
101
144
  next if tag_call?(child)
@@ -105,6 +148,7 @@ module A11y
105
148
  false
106
149
  end
107
150
  # rubocop:enable Metrics/CyclomaticComplexity
151
+ # rubocop:enable Metrics/PerceivedComplexity
108
152
 
109
153
  def walk_block(block)
110
154
  return unless block.is_a?(Prism::BlockNode)
@@ -116,6 +160,10 @@ module A11y
116
160
  receiverless_call?(node) && PhlexNode.html_tag?(node.name.to_s)
117
161
  end
118
162
 
163
+ def text_call?(node)
164
+ receiverless_call?(node) && node.name.to_s == "plain"
165
+ end
166
+
119
167
  def receiverless_call?(node)
120
168
  node.is_a?(Prism::CallNode) && node.receiver.nil?
121
169
  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?
@@ -6,11 +6,12 @@ module A11y
6
6
  class SlimNode
7
7
  include BlockInspection
8
8
 
9
- attr_reader :line
9
+ attr_reader :line, :configuration
10
10
 
11
- def initialize(sexp, line:)
11
+ def initialize(sexp, line:, configuration: Configuration.new)
12
12
  @sexp = sexp
13
13
  @line = line
14
+ @configuration = configuration
14
15
  end
15
16
 
16
17
  def tag_name
@@ -62,7 +63,16 @@ module A11y
62
63
  def block_has_text_children?
63
64
  return false unless slim_output?
64
65
 
65
- text_content?(@sexp[4])
66
+ block_text_content?(@sexp[4])
67
+ end
68
+
69
+ # Returns true when the HTML element body contains visible text
70
+ # or dynamic output (i.e. content that could provide an accessible
71
+ # name). Only meaningful for HTML tag nodes.
72
+ def text_content?
73
+ return false unless html_tag?
74
+
75
+ text_or_output?(@sexp[4])
66
76
  end
67
77
 
68
78
  private
@@ -81,6 +91,7 @@ module A11y
81
91
 
82
92
  def collect_output_codes(sexp)
83
93
  return [] unless sexp.is_a?(Array)
94
+ return [] if hidden_wrapper_sexp?(sexp)
84
95
  return [sexp[3]] if slim_output_sexp?(sexp)
85
96
 
86
97
  sexp.flat_map { |child| collect_output_codes(child) }
@@ -90,11 +101,66 @@ module A11y
90
101
  sexp.is_a?(Array) && sexp[0] == :slim && sexp[1] == :output
91
102
  end
92
103
 
93
- def text_content?(sexp)
104
+ def block_text_content?(sexp)
94
105
  return false unless sexp.is_a?(Array)
106
+ return false if hidden_wrapper_sexp?(sexp)
95
107
  return true if slim_text_sexp?(sexp) || html_tag_sexp?(sexp)
96
108
 
97
- sexp.any? { |child| text_content?(child) }
109
+ sexp.any? { |child| block_text_content?(child) }
110
+ end
111
+
112
+ def text_or_output?(sexp)
113
+ return false unless sexp.is_a?(Array)
114
+ return false if hidden_wrapper_sexp?(sexp)
115
+ return true if slim_text_sexp?(sexp) || slim_output_sexp?(sexp)
116
+
117
+ sexp.any? { |child| text_or_output?(child) }
118
+ end
119
+
120
+ def hidden_wrapper_sexp?(sexp)
121
+ return false unless html_tag_sexp?(sexp)
122
+ return false if configuration.hidden_wrapper_classes.empty?
123
+
124
+ class_values(sexp[3]).any? do |klass|
125
+ configuration.hidden_wrapper_classes.include?(klass)
126
+ end
127
+ end
128
+
129
+ def class_values(attrs_sexp)
130
+ return [] unless attrs_sexp.is_a?(Array) &&
131
+ attrs_sexp[0] == :html && attrs_sexp[1] == :attrs
132
+
133
+ attrs_sexp[2..].flat_map { |attr| class_values_for_attr(attr) }
134
+ end
135
+
136
+ def class_values_for_attr(attr)
137
+ return [] unless attr.is_a?(Array) &&
138
+ attr[0] == :html && attr[1] == :attr &&
139
+ attr[2] == "class"
140
+
141
+ value = static_class_string(attr[3])
142
+ value ? value.split : []
143
+ end
144
+
145
+ # Extracts a static class string from the two forms Slim emits:
146
+ # `[:static, "name"]` for class shortcuts (`.popover`) and
147
+ # `[:escape, true, [:slim, :interpolate, "name"]]` for `class="..."`.
148
+ def static_class_string(value_sexp)
149
+ return unless value_sexp.is_a?(Array)
150
+ return static_sexp_value(value_sexp) if value_sexp[0] == :static
151
+
152
+ interpolate_sexp_value(value_sexp)
153
+ end
154
+
155
+ def static_sexp_value(sexp)
156
+ sexp[1] if sexp[1].is_a?(String)
157
+ end
158
+
159
+ def interpolate_sexp_value(sexp)
160
+ return unless sexp[0] == :escape && sexp[2].is_a?(Array)
161
+ return unless sexp[2][0] == :slim && sexp[2][1] == :interpolate
162
+
163
+ sexp[2][2] if sexp[2][2].is_a?(String)
98
164
  end
99
165
 
100
166
  def slim_text_sexp?(sexp)
@@ -103,7 +169,11 @@ module A11y
103
169
 
104
170
  def collect_children(sexp)
105
171
  return [] unless sexp.is_a?(Array)
106
- return [SlimNode.new(sexp, line: @line)] if html_tag_sexp?(sexp)
172
+
173
+ if html_tag_sexp?(sexp)
174
+ return [SlimNode.new(sexp, line: @line, configuration: configuration)]
175
+ end
176
+
107
177
  return collect_children(sexp[3]) if slim_control_sexp?(sexp)
108
178
  return [] unless sexp[0] == :multi
109
179
 
@@ -122,7 +192,19 @@ module A11y
122
192
  return {} unless html_attributes?
123
193
 
124
194
  sexp_attributes[2..].each_with_object({}) do |attr_sexp, result|
125
- result[attr_sexp[2]] = true if html_attribute?(attr_sexp)
195
+ next unless html_attribute?(attr_sexp)
196
+
197
+ result[attr_sexp[2]] = static_value(attr_sexp[3])
198
+ end
199
+ end
200
+
201
+ def static_value(value_sexp)
202
+ if value_sexp.is_a?(Array) && value_sexp[0] == :escape &&
203
+ value_sexp[2].is_a?(Array) &&
204
+ value_sexp[2][0] == :slim && value_sexp[2][1] == :interpolate
205
+ value_sexp[2][2]
206
+ else
207
+ true
126
208
  end
127
209
  end
128
210
 
@@ -4,8 +4,9 @@ module A11y
4
4
  module Lint
5
5
  # Parses Slim templates and checks them against accessibility rules.
6
6
  class SlimRunner
7
- def initialize(rules)
8
- @rules = rules
7
+ def initialize(rules = nil, configuration: Configuration.new)
8
+ @rules = rules || configuration.enabled_rules
9
+ @configuration = configuration
9
10
  end
10
11
 
11
12
  def run(source, filename:)
@@ -22,13 +23,13 @@ module A11y
22
23
 
23
24
  private
24
25
 
25
- attr_reader(:rules)
26
+ attr_reader(:rules, :configuration)
26
27
 
27
28
  def walk(sexp)
28
29
  return unless node?(sexp)
29
30
 
30
31
  @line += 1 if sexp[0] == :newline
31
- new_node = SlimNode.new(sexp, line: @line)
32
+ new_node = SlimNode.new(sexp, line: @line, configuration:)
32
33
  check_node(new_node) if html_tag?(sexp) || slim_output?(sexp)
33
34
  @line += continuation_newlines(sexp)
34
35
  sexp.each { |child| walk(child) }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A11y
4
4
  module Lint
5
- VERSION = "0.10.0"
5
+ VERSION = "0.12.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.12.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