a11y-lint 0.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1c92e880f51310dfe123ffdbc872bc661a781371365c53dd1a1972e302dcc1d
4
- data.tar.gz: dfb7186f222b4c87ffdc818e843907435eebda8e99cccdc945cf1f4d8b77708f
3
+ metadata.gz: 7f62cdc346f6aa1f5c2cae674c365678a333479966aabd1b144e7901f90e7cdf
4
+ data.tar.gz: 874c7cb5bac0680cb58fb2f73e89304c21862686010f5b0e721aad19861c0bbb
5
5
  SHA512:
6
- metadata.gz: 0fde7a03bb0e20294c451a57da6aa487fd7aa6cf6b3bde0d41e6b7a323ededf15c626e6b06e7dd55f37b109a0371cc24b5708fd5528f959bb96a6269642152f8
7
- data.tar.gz: b43f87086fdabf16d139639d169be80cadea3da8c61b09e7a92cc66f4c5d59011dce8b7433307877b652b4b85c2d4cb923c6ed9a95695094b035cbcdbe2d6d0a
6
+ metadata.gz: 5757d27c00656b68b5b4209a153da09e720d190bacb5124e1860a03a53d3e0df4d02909a2c1d667c5be477394228274a6e64c95b4a032d64cb14a65ba72f5bd7
7
+ data.tar.gz: bf502097c82848041313039b29271808133c1ff3be50b1d8e4b31b5d7df7bb1c35bb1ae643b869143355bbcd3dd2cfcddf98acd44ac858f4edb436e5699b3b92
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ 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
+
10
16
  ## [0.11.0] - 2026-04-21
11
17
 
12
18
  ### Added
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 < NodeRule
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
@@ -5,12 +5,14 @@ module A11y
5
5
  # Wraps a Nokogiri HTML element from an ERB template
6
6
  # as a queryable node for lint rules.
7
7
  class ErbElementNode
8
- attr_reader :line
8
+ attr_reader :line, :configuration
9
9
 
10
- def initialize(nokogiri_node:, line:, has_erb_output: false)
10
+ def initialize(
11
+ nokogiri_node:, line:, configuration: Configuration.new
12
+ )
11
13
  @nokogiri_node = nokogiri_node
12
14
  @line = line
13
- @has_erb_output = has_erb_output
15
+ @configuration = configuration
14
16
  end
15
17
 
16
18
  def tag_name
@@ -45,15 +47,58 @@ module A11y
45
47
  end
46
48
 
47
49
  def text_content?
48
- @has_erb_output || !@nokogiri_node.text.strip.empty?
50
+ return false if hidden_wrapper?(@nokogiri_node)
51
+
52
+ visible_text_or_output?(@nokogiri_node)
49
53
  end
50
54
 
51
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.
52
59
  def children
53
- @nokogiri_node.element_children.map do |child|
54
- ErbElementNode.new(nokogiri_node: child, line: child.line)
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?
55
86
  end
56
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
57
102
  end
58
103
  end
59
104
  end
@@ -14,8 +14,9 @@ module A11y
14
14
  link meta param source track wbr
15
15
  ].freeze
16
16
 
17
- def initialize(rules)
18
- @rules = rules
17
+ def initialize(rules = nil, configuration: Configuration.new)
18
+ @rules = rules || configuration.enabled_rules
19
+ @configuration = configuration
19
20
  end
20
21
 
21
22
  def run(source, filename:)
@@ -30,7 +31,7 @@ module A11y
30
31
 
31
32
  private
32
33
 
33
- attr_reader :rules
34
+ attr_reader :rules, :configuration
34
35
 
35
36
  def check_html_nodes(source)
36
37
  html = source.gsub(ERB_OUTPUT_TAG, ERB_OUTPUT_MARKER)
@@ -50,7 +51,7 @@ module A11y
50
51
  ErbElementNode.new(
51
52
  nokogiri_node: nokogiri_node,
52
53
  line: nokogiri_node.line,
53
- has_erb_output: nokogiri_node.text.include?(ERB_OUTPUT_MARKER)
54
+ configuration: configuration
54
55
  )
55
56
  end
56
57
 
@@ -88,10 +89,53 @@ module A11y
88
89
  return [nil, false] unless end_match
89
90
 
90
91
  block_content = rest[0...end_match.begin(0)]
91
- codes = block_content.scan(ERB_OUTPUT_TAG).map { |m| m[0].strip }
92
- 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)
93
136
 
94
- [codes, !text_only.empty?]
137
+ classes = configuration.hidden_wrapper_classes
138
+ value.split.any? { |klass| classes.include?(klass) }
95
139
  end
96
140
 
97
141
  def check_node(node)
@@ -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
  )
@@ -23,7 +24,8 @@ module A11y
23
24
  call_node: nil, children: [],
24
25
  block_body_codes: nil,
25
26
  block_has_text_children: false,
26
- text_content: false
27
+ text_content: false,
28
+ configuration: Configuration.new
27
29
  )
28
30
  @tag_name = tag_name
29
31
  @attributes = attributes
@@ -33,6 +35,7 @@ module A11y
33
35
  @block_body_codes = block_body_codes
34
36
  @block_has_text_children = block_has_text_children
35
37
  @text_content = text_content
38
+ @configuration = configuration
36
39
  end
37
40
  # rubocop:enable Metrics/ParameterLists
38
41
 
@@ -52,30 +55,46 @@ module A11y
52
55
  @text_content
53
56
  end
54
57
 
55
- def self.build_tag(call_node, children: [], text_content: false)
58
+ def self.build_tag(
59
+ call_node, children: [], text_content: false,
60
+ configuration: Configuration.new
61
+ )
56
62
  name = call_node.name.to_s
57
63
  new(
58
64
  tag_name: html_tag_name(name),
59
65
  attributes: extract_attributes(call_node),
60
66
  line: call_node.location.start_line,
61
67
  children: children,
62
- text_content: text_content
68
+ text_content: text_content,
69
+ configuration: configuration
63
70
  )
64
71
  end
65
72
 
66
73
  def self.build_helper(
67
74
  call_node,
68
75
  block_body_codes: nil,
69
- block_has_text_children: false
76
+ block_has_text_children: false,
77
+ configuration: Configuration.new
70
78
  )
71
79
  new(
72
80
  call_node: CallNode.new(call_node),
73
81
  line: call_node.location.start_line,
74
82
  block_body_codes: block_body_codes,
75
- block_has_text_children: block_has_text_children
83
+ block_has_text_children: block_has_text_children,
84
+ configuration: configuration
76
85
  )
77
86
  end
78
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
+
79
98
  def self.extract_attributes(call_node)
80
99
  return {} unless call_node.arguments
81
100
 
@@ -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)
@@ -48,7 +49,12 @@ module A11y
48
49
  children = collect_block_children(node.block)
49
50
  has_text = tag_block_has_text?(node.block, children)
50
51
  check_node(
51
- PhlexNode.build_tag(node, children:, text_content: has_text)
52
+ PhlexNode.build_tag(
53
+ node,
54
+ children: children,
55
+ text_content: has_text,
56
+ configuration: configuration
57
+ )
52
58
  )
53
59
  end
54
60
 
@@ -57,7 +63,8 @@ module A11y
57
63
  helper = PhlexNode.build_helper(
58
64
  node,
59
65
  block_body_codes: codes,
60
- block_has_text_children: has_text
66
+ block_has_text_children: has_text,
67
+ configuration: configuration
61
68
  )
62
69
  check_node(helper)
63
70
  walk_block(node.block)
@@ -85,10 +92,24 @@ module A11y
85
92
  kids = collect_block_children(child.block)
86
93
  has_text = tag_block_has_text?(child.block, kids)
87
94
  tag = PhlexNode.build_tag(
88
- child, children: kids, text_content: has_text
95
+ child, children: kids, text_content: has_text,
96
+ configuration: configuration
89
97
  )
90
- result << tag
91
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)
92
113
  end
93
114
 
94
115
  def tag_block_has_text?(block, children)
@@ -114,8 +135,10 @@ module A11y
114
135
  end
115
136
 
116
137
  # rubocop:disable Metrics/CyclomaticComplexity
138
+ # rubocop:disable Metrics/PerceivedComplexity
117
139
  def scan_block_content(node, codes)
118
140
  node.child_nodes.compact.each do |child|
141
+ next if tag_call?(child) && hidden_wrapper_tag?(child)
119
142
  return true if child.is_a?(Prism::YieldNode)
120
143
  return true if tag_call?(child) && child.block
121
144
  next if tag_call?(child)
@@ -125,6 +148,7 @@ module A11y
125
148
  false
126
149
  end
127
150
  # rubocop:enable Metrics/CyclomaticComplexity
151
+ # rubocop:enable Metrics/PerceivedComplexity
128
152
 
129
153
  def walk_block(block)
130
154
  return unless block.is_a?(Prism::BlockNode)
@@ -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
@@ -90,6 +91,7 @@ module A11y
90
91
 
91
92
  def collect_output_codes(sexp)
92
93
  return [] unless sexp.is_a?(Array)
94
+ return [] if hidden_wrapper_sexp?(sexp)
93
95
  return [sexp[3]] if slim_output_sexp?(sexp)
94
96
 
95
97
  sexp.flat_map { |child| collect_output_codes(child) }
@@ -101,6 +103,7 @@ module A11y
101
103
 
102
104
  def block_text_content?(sexp)
103
105
  return false unless sexp.is_a?(Array)
106
+ return false if hidden_wrapper_sexp?(sexp)
104
107
  return true if slim_text_sexp?(sexp) || html_tag_sexp?(sexp)
105
108
 
106
109
  sexp.any? { |child| block_text_content?(child) }
@@ -108,18 +111,69 @@ module A11y
108
111
 
109
112
  def text_or_output?(sexp)
110
113
  return false unless sexp.is_a?(Array)
114
+ return false if hidden_wrapper_sexp?(sexp)
111
115
  return true if slim_text_sexp?(sexp) || slim_output_sexp?(sexp)
112
116
 
113
117
  sexp.any? { |child| text_or_output?(child) }
114
118
  end
115
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)
164
+ end
165
+
116
166
  def slim_text_sexp?(sexp)
117
167
  sexp[0] == :slim && sexp[1] == :text
118
168
  end
119
169
 
120
170
  def collect_children(sexp)
121
171
  return [] unless sexp.is_a?(Array)
122
- 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
+
123
177
  return collect_children(sexp[3]) if slim_control_sexp?(sexp)
124
178
  return [] unless sexp[0] == :multi
125
179
 
@@ -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.11.0"
5
+ VERSION = "0.12.0"
6
6
  end
7
7
  end
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.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdullah Hashim