a11y-lint 0.8.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/testing.md +1 -1
  3. data/.rubocop.yml +7 -1
  4. data/CHANGELOG.md +48 -0
  5. data/CLAUDE.md +14 -3
  6. data/lib/a11y/lint/block_inspection.rb +41 -0
  7. data/lib/a11y/lint/call_node.rb +125 -0
  8. data/lib/a11y/lint/cli.rb +2 -3
  9. data/lib/a11y/lint/erb_element_node.rb +59 -0
  10. data/lib/a11y/lint/erb_output_node.rb +47 -0
  11. data/lib/a11y/lint/erb_runner.rb +42 -7
  12. data/lib/a11y/lint/errors.rb +17 -0
  13. data/lib/a11y/lint/{rule.rb → node_rule.rb} +6 -2
  14. data/lib/a11y/lint/phlex_node.rb +51 -60
  15. data/lib/a11y/lint/phlex_runner.rb +61 -16
  16. data/lib/a11y/lint/phlex_tags.rb +133 -0
  17. data/lib/a11y/lint/ruby_code.rb +67 -0
  18. data/lib/a11y/lint/rules/perceivable/area_missing_alt.rb +23 -0
  19. data/lib/a11y/lint/rules/perceivable/image_submit_tag_missing_alt.rb +27 -0
  20. data/lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb +6 -70
  21. data/lib/a11y/lint/rules/perceivable/img_missing_alt.rb +1 -1
  22. data/lib/a11y/lint/rules/perceivable/input_image_missing_alt.rb +26 -0
  23. data/lib/a11y/lint/rules/perceivable/input_missing_autocomplete.rb +37 -0
  24. data/lib/a11y/lint/rules/perceivable/list_invalid_children.rb +1 -1
  25. data/lib/a11y/lint/rules/robust/anchor_missing_accessible_name.rb +42 -0
  26. data/lib/a11y/lint/rules/robust/button_missing_accessible_name.rb +42 -0
  27. data/lib/a11y/lint/rules/robust/button_tag_missing_accessible_name.rb +45 -0
  28. data/lib/a11y/lint/rules/robust/link_to_missing_accessible_name.rb +47 -0
  29. data/lib/a11y/lint/slim_node.rb +85 -2
  30. data/lib/a11y/lint/slim_runner.rb +3 -0
  31. data/lib/a11y/lint/version.rb +1 -1
  32. data/lib/a11y/lint.rb +9 -14
  33. metadata +19 -20
  34. data/lib/a11y/lint/erb_node.rb +0 -54
  35. data/lib/a11y/lint/rules/robust/missing_accessible_name.rb +0 -132
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0d462b5e30cff7f4b1c5adaa3eeb9d2ac1a1aa9d3dad477b1841e38e271f22a
4
- data.tar.gz: 66d8731b7371b98e4fede0445536d994fcd3405721dfb0617f1c139bd5fce925
3
+ metadata.gz: e1c92e880f51310dfe123ffdbc872bc661a781371365c53dd1a1972e302dcc1d
4
+ data.tar.gz: dfb7186f222b4c87ffdc818e843907435eebda8e99cccdc945cf1f4d8b77708f
5
5
  SHA512:
6
- metadata.gz: 6459a600d8cbd2465d5a9096eb99e36f319d1576cc685273b78505c636c15f77590104eec89ff4142d6525f70d5fc861822837fdf55cca9ed1afbad9a20011b5
7
- data.tar.gz: 3c22904b9d168f559b9bc83522e1a5422fad6f2dd3d5beffd158ad06135ef6dacc85fdf3d832970b330ed1f4fd25c68a41b3bd8a34ef57ca6df8c2211a694dca
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/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 3.1
2
+ TargetRubyVersion: 3.3
3
3
 
4
4
  Style/StringLiterals:
5
5
  EnforcedStyle: double_quotes
@@ -13,6 +13,12 @@ Layout/LineLength:
13
13
  - "a11y-lint.gemspec"
14
14
 
15
15
  Metrics/ClassLength:
16
+ Enabled: false
17
+
18
+ Metrics/ModuleLength:
19
+ Enabled: false
20
+
21
+ Metrics/AbcSize:
16
22
  Exclude:
17
23
  - "test/**/*"
18
24
 
data/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
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
+
32
+ ## [0.10.0] - 2026-04-15
33
+
34
+ ### Changed
35
+
36
+ - Introduce `A11y::Lint::CallNode` wrapper so rules express accessibility logic, not AST traversal
37
+ - Expose Prism `CallNode` from `SlimNode` and `ErbNode` via the new `RubyCode` parser
38
+ - Expose Prism `CallNode` from `PhlexNode` instead of converting to string
39
+ - Extract Phlex HTML tag constants into `PhlexTags` module
40
+
41
+ ## [0.9.0] - 2026-04-15
42
+
43
+ ### Changed
44
+
45
+ - **Breaking:** Require Ruby >= 3.3.0 (was 3.1.0). Prism ships with Ruby 3.3+ as a bundled gem
46
+ - **Breaking:** `slim` is now an optional dependency. Add `gem "slim"` to your Gemfile if you lint `.slim` files. A `SlimLoadError` is raised with a helpful message if the gem is missing
47
+ - `prism` is no longer a declared dependency (bundled with Ruby 3.3+)
48
+ - Replace Ripper with Prism for Ruby code parsing in `ImageTagMissingAlt` and `MissingAccessibleName` rules
49
+ - Custom error classes are now defined in `lib/a11y/lint/errors.rb`
50
+
51
+ ### Fixed
52
+
53
+ - `MissingAccessibleName`: fix false positive when block contains visible text or HTML tags (e.g. `link_to do` with a `span` or plain text inside)
54
+ - Add `block_body_codes` and `block_has_text_children?` to all three node types (Slim, ERB, Phlex) so block content inspection works across all pipelines
55
+
8
56
  ## [0.8.0] - 2026-04-14
9
57
 
10
58
  ### Added
data/CLAUDE.md CHANGED
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
4
 
5
5
  ## Project Overview
6
6
 
7
- a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::Lint` module namespace. Requires Ruby >= 3.1.0.
7
+ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::Lint` module namespace. Requires Ruby >= 3.3.0.
8
8
 
9
9
  ## Commands
10
10
 
@@ -24,15 +24,26 @@ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::L
24
24
  - **Slim pipeline:** `SlimRunner` parses Slim templates via `Slim::Parser`; `SlimNode` wraps Slim S-expressions
25
25
  - **ERB pipeline:** `ErbRunner` parses ERB templates via Nokogiri; `ErbNode` wraps Nokogiri nodes and extracted `<%= %>` Ruby code
26
26
  - **Phlex pipeline:** `PhlexRunner` parses Phlex view classes via Prism; `PhlexNode` wraps Prism AST call nodes; detects Phlex files by the presence of `def view_template`
27
+ - **Ruby code parsing:** `RubyCode` (`lib/a11y/lint/ruby_code.rb`) — parses a Ruby code string once with Prism and exposes the resulting `CallNode`; used by `SlimNode` and `ErbNode` so rules work against a parsed AST instead of re-parsing strings
27
28
  - **Configuration:** `lib/a11y/lint/configuration.rb` — loads `.a11y-lint.yml` to enable/disable individual rules; searches upward from the target path
28
- - **Rules:** `lib/a11y/lint/rules/` — organized by WCAG principle (`perceivable/`, `operable/`, `understandable/`, `robust/`); rules implement `check(node)` against the shared node interface (`tag_name`, `attribute?`, `attributes`, `ruby_code`, `line`)
29
+ - **Rules:** `lib/a11y/lint/rules/` — organized by WCAG principle (`perceivable/`, `operable/`, `understandable/`, `robust/`); rules implement `check(node)` against the shared node interface (`tag_name`, `attribute?`, `attributes`, `call_node`, `line`)
29
30
  - **Version:** `lib/a11y/lint/version.rb`
30
31
  - **Type signatures (RBS):** `sig/a11y/lint.rbs`
31
32
  - **Tests:** `test/` directory using Minitest; test helper at `test/test_helper.rb`
32
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`)
33
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
+
34
45
  ## Code Style
35
46
 
36
47
  RuboCop is configured in `.rubocop.yml`:
37
- - Target Ruby version: 3.1
48
+ - Target Ruby version: 3.3
38
49
  - Enforces double quotes for strings
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11y
4
+ module Lint
5
+ # Shared block-content queries for node types.
6
+ # Depends on the host class implementing
7
+ # #block_has_text_children? and #block_body_codes.
8
+ module BlockInspection
9
+ ICON_HELPERS = %w[inline_svg icon svg_icon].freeze
10
+
11
+ def block_has_only_icon_helpers?
12
+ return false if block_has_text_children?
13
+
14
+ codes = block_body_codes
15
+ return true unless codes&.any?
16
+
17
+ codes.all? do |code|
18
+ icon_helper?(code) || decorative_image_tag?(code)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def icon_helper?(code)
25
+ call = RubyCode.new(code).call_node
26
+ call && ICON_HELPERS.include?(call.method_name)
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
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module A11y
6
+ module Lint
7
+ # Wraps a Prism::CallNode with a rule-friendly query API.
8
+ class CallNode
9
+ attr_reader :prism_node
10
+
11
+ def initialize(prism_node)
12
+ @prism_node = prism_node
13
+ end
14
+
15
+ def method_name
16
+ @prism_node.name.to_s
17
+ end
18
+
19
+ # Checks for a keyword argument by name.
20
+ # keyword?(:alt) => alt: or "alt" =>
21
+ # keyword?(:aria, :label) => aria: { label: ... }
22
+ # keyword?(:"aria-label") => "aria-label" =>
23
+ def keyword?(*keys)
24
+ return false unless (kw_hash = find_keyword_hash)
25
+
26
+ if keys.length == 1
27
+ flat_keyword?(kw_hash, keys[0])
28
+ else
29
+ nested_keyword?(kw_hash, keys[0], keys[1])
30
+ end
31
+ end
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
+
50
+ def positional_args
51
+ return [] unless @prism_node.arguments
52
+
53
+ @prism_node.arguments.arguments.reject do |a|
54
+ a.is_a?(Prism::KeywordHashNode)
55
+ end
56
+ end
57
+
58
+ def first_positional_arg_empty_string?
59
+ first = positional_args.first
60
+ first.is_a?(Prism::StringNode) && first.unescaped.empty?
61
+ end
62
+
63
+ def block?
64
+ !@prism_node.block.nil?
65
+ end
66
+
67
+ # Finds a receiverless call by method name in this node's
68
+ # subtree (including self). Returns a CallNode or nil.
69
+ def find(name)
70
+ found = search_for_call(@prism_node, name)
71
+ found ? self.class.new(found) : nil
72
+ end
73
+
74
+ private
75
+
76
+ def flat_keyword?(kw_hash, key)
77
+ kw_hash.elements.any? do |assoc|
78
+ key_name(assoc) == key.to_s
79
+ end
80
+ end
81
+
82
+ def nested_keyword?(kw_hash, outer_key, inner_key)
83
+ assoc = kw_hash.elements.find do |a|
84
+ key_name(a) == outer_key.to_s
85
+ end
86
+ return false unless assoc&.value.is_a?(Prism::HashNode)
87
+
88
+ assoc.value.elements.any? do |inner|
89
+ key_name(inner) == inner_key.to_s
90
+ end
91
+ end
92
+
93
+ def find_keyword_hash
94
+ return unless @prism_node.arguments
95
+
96
+ @prism_node.arguments.arguments.find do |arg|
97
+ arg.is_a?(Prism::KeywordHashNode)
98
+ end
99
+ end
100
+
101
+ def key_name(assoc)
102
+ return unless assoc.is_a?(Prism::AssocNode)
103
+
104
+ case assoc.key
105
+ when Prism::SymbolNode then assoc.key.unescaped
106
+ when Prism::StringNode then assoc.key.unescaped
107
+ end
108
+ end
109
+
110
+ def search_for_call(node, name)
111
+ if node.is_a?(Prism::CallNode) &&
112
+ node.receiver.nil? &&
113
+ node.name.to_s == name
114
+ return node
115
+ end
116
+
117
+ node.child_nodes.compact.each do |child|
118
+ found = search_for_call(child, name)
119
+ return found if found
120
+ end
121
+ nil
122
+ end
123
+ end
124
+ end
125
+ end
data/lib/a11y/lint/cli.rb CHANGED
@@ -4,8 +4,7 @@ require "optparse"
4
4
 
5
5
  module A11y
6
6
  module Lint
7
- # Command-line interface for running accessibility
8
- # linting on Slim templates.
7
+ # Command-line interface for running accessibility linting.
9
8
  class CLI
10
9
  def initialize(argv, stdout: $stdout, stderr: $stderr)
11
10
  @argv = argv
@@ -100,7 +99,7 @@ module A11y
100
99
 
101
100
  Rules.constants.filter_map do |name|
102
101
  klass = Rules.const_get(name)
103
- next unless klass.is_a?(Class) && klass < Rule
102
+ next unless klass.is_a?(Class) && klass < NodeRule
104
103
 
105
104
  klass if configuration.enabled?(klass.rule_name)
106
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
@@ -53,12 +62,38 @@ module A11y
53
62
  source.scan(ERB_OUTPUT_TAG) do
54
63
  match = Regexp.last_match
55
64
  code = match[1]
56
- line_number = source[0...match.begin(0)].count("\n") + 1
57
- node = ErbNode.new(ruby_code: code, line: line_number)
58
- check_node(node)
65
+ line = source[0...match.begin(0)].count("\n") + 1
66
+ check_node(
67
+ build_erb_output_node(source, code, line, match.end(0))
68
+ )
59
69
  end
60
70
  end
61
71
 
72
+ def build_erb_output_node(source, code, line, match_end)
73
+ block_body_codes, block_has_text =
74
+ extract_block_info(source, code, match_end)
75
+
76
+ ErbOutputNode.new(
77
+ ruby_code: code, line: line,
78
+ block_body_codes: block_body_codes,
79
+ block_has_text_children: block_has_text
80
+ )
81
+ end
82
+
83
+ def extract_block_info(source, code, match_end)
84
+ return [nil, false] unless code.match?(/\s+do\s*\z/)
85
+
86
+ rest = source[match_end..]
87
+ end_match = rest.match(/<%-?\s*end\s*-?%>/m)
88
+ return [nil, false] unless end_match
89
+
90
+ 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
93
+
94
+ [codes, !text_only.empty?]
95
+ end
96
+
62
97
  def check_node(node)
63
98
  rules.each do |rule_class|
64
99
  message = rule_class.check(node)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11y
4
+ module Lint
5
+ class Error < StandardError; end
6
+
7
+ # Raised when slim is not installed.
8
+ class SlimLoadError < Error
9
+ def initialize
10
+ super(
11
+ "a11y-lint needs the `slim` gem to lint .slim files. " \
12
+ "Add `gem \"slim\"` to your Gemfile."
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -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
@@ -19,6 +19,10 @@ module A11y
19
19
  def check
20
20
  raise NotImplementedError
21
21
  end
22
+
23
+ private
24
+
25
+ attr_reader(:node)
22
26
  end
23
27
  end
24
28
  end