a11y-lint 0.3.1 → 0.5.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: 5c000b195d8308dc83a3480df00e9a469e064535305ece969f779ee05b93fc87
4
- data.tar.gz: cf3ec59befbf3057ff5f1a9d971925656fbf0f0c001e40780202abd862bd67a2
3
+ metadata.gz: 7cb772a75e7dc2bc7f36f7869efac31d0f9281a196ef237f57e2e2db4f3116c1
4
+ data.tar.gz: ddb88dd42e274631a6d289ab56b2c0353e305b37816b9232bd7689578d19ed87
5
5
  SHA512:
6
- metadata.gz: ad3e23e799fce7418f9e28224a9ec63120f42d5a04bd27710637df12e278118728af0a3baf47c5fe8b3cfa2a12e6bfbc366d8f334a453b385111b1befc2828df
7
- data.tar.gz: 8be4c61819523edcbb0a11bb4959ab5834b0552d0fe27be2fc71aa30362dc18eb5ca8f9d25bffa5578f7f77d0af11b5e1716e8429b4c2a865fcd3a243031ede0
6
+ metadata.gz: 98b46acd6a78f33f546b6c2aa88e6ff40f7ffe89aaf196d3df0b355a7e2cd2a96d0fdc74c89796764953d47e40b3e89a26dc0008ae55528c3585f48a4d31493d
7
+ data.tar.gz: 2571c7dbaef8b3c37cea2a6426b29586c1ad5d3e5d120504ff20c3d4b70cabe8578cbcd50d2f8c677056aabee4520f264eae642be07e610290a4303317031c41
data/.rubocop.yml CHANGED
@@ -6,3 +6,7 @@ Style/StringLiterals:
6
6
 
7
7
  Style/StringLiteralsInInterpolation:
8
8
  EnforcedStyle: double_quotes
9
+
10
+ Metrics/ClassLength:
11
+ Exclude:
12
+ - "test/**/*"
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-03-31
11
+
12
+ ### Added
13
+
14
+ - `LinkMissingAccessibleName` rule: detects `<a>` tags without accessible text content
15
+
16
+ ## [0.4.0] - 2026-03-27
17
+
18
+ ### Added
19
+
20
+ - ERB template support: scans `.erb` files using Nokogiri for HTML parsing
21
+ - Both `ImgMissingAlt` and `ImageTagMissingAlt` rules now work on ERB templates
22
+ - CLI automatically detects and routes `.slim` and `.erb` files to the appropriate runner
23
+
24
+ ### Changed
25
+
26
+ - **Breaking:** `Runner` has been renamed to `SlimRunner`
27
+
10
28
  ## [0.3.1] - 2026-03-27
11
29
 
12
30
  ### Fixed
data/CLAUDE.md CHANGED
@@ -19,8 +19,11 @@ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::L
19
19
 
20
20
  ## Architecture
21
21
 
22
- - **CLI:** `lib/a11y/lint/cli.rb` — command-line interface; executable at `exe/a11y-lint`
22
+ - **CLI:** `lib/a11y/lint/cli.rb` — command-line interface; executable at `exe/a11y-lint`; routes `.slim` files to `SlimRunner` and `.erb` files to `ErbRunner`
23
23
  - **Entry point:** `lib/a11y/lint.rb` — defines the `A11y::Lint` module and `A11y::Lint::Error` exception
24
+ - **Slim pipeline:** `SlimRunner` parses Slim templates via `Slim::Parser`; `Node` wraps Slim S-expressions
25
+ - **ERB pipeline:** `ErbRunner` parses ERB templates via Nokogiri; `ErbNode` wraps Nokogiri nodes and extracted `<%= %>` Ruby code
26
+ - **Rules:** `lib/a11y/lint/rules/` — rules implement `check(node)` against the shared node interface (`tag_name`, `attribute?`, `attributes`, `ruby_code`, `line`)
24
27
  - **Version:** `lib/a11y/lint/version.rb`
25
28
  - **Type signatures (RBS):** `sig/a11y/lint.rbs`
26
29
  - **Tests:** `test/` directory using Minitest; test helper at `test/test_helper.rb`
data/README.md CHANGED
@@ -33,7 +33,7 @@ a11y-lint app/views/
33
33
  a11y-lint app/views/home.html.slim app/views/about.html.slim
34
34
  ```
35
35
 
36
- With no arguments, it scans the current directory recursively for `.slim` files:
36
+ With no arguments, it scans the current directory recursively for `.slim` and `.erb` files:
37
37
 
38
38
  ```bash
39
39
  a11y-lint
@@ -45,7 +45,7 @@ a11y-lint
45
45
  require "a11y/lint"
46
46
 
47
47
  source = File.read("app/views/home.html.slim")
48
- runner = A11y::Lint::Runner.new([A11y::Lint::Rules::ImgMissingAlt.new])
48
+ runner = A11y::Lint::SlimRunner.new([A11y::Lint::Rules::ImgMissingAlt.new])
49
49
  offenses = runner.run(source, filename: "app/views/home.html.slim")
50
50
 
51
51
  offenses.each do |offense|
data/lib/a11y/lint/cli.rb CHANGED
@@ -17,7 +17,7 @@ module A11y
17
17
  files = resolve_files(@argv)
18
18
 
19
19
  if files.empty?
20
- @stderr.puts("No .slim files found")
20
+ @stderr.puts("No .slim or .erb files found")
21
21
  return 0
22
22
  end
23
23
 
@@ -56,7 +56,7 @@ module A11y
56
56
 
57
57
  def expand_path(path)
58
58
  if File.directory?(path)
59
- Dir.glob(File.join(path, "**", "*.slim"))
59
+ Dir.glob(File.join(path, "**", "*.{slim,erb}"))
60
60
  elsif File.file?(path)
61
61
  [path]
62
62
  else
@@ -66,10 +66,13 @@ module A11y
66
66
  end
67
67
 
68
68
  def lint_files(files)
69
- runner = Runner.new(all_rules)
69
+ rules = all_rules
70
+ slim_runner = SlimRunner.new(rules)
71
+ erb_runner = ErbRunner.new(rules)
70
72
 
71
73
  files.flat_map do |file|
72
74
  source = File.read(file)
75
+ runner = file.end_with?(".erb") ? erb_runner : slim_runner
73
76
  runner.run(source, filename: file)
74
77
  end
75
78
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11y
4
+ module Lint
5
+ # Wraps a Nokogiri node or extracted ERB output tag as a queryable node for lint rules.
6
+ class ErbNode
7
+ attr_reader :line
8
+
9
+ def initialize(line:, nokogiri_node: nil, ruby_code: nil)
10
+ @nokogiri_node = nokogiri_node
11
+ @ruby_code_string = ruby_code
12
+ @line = line
13
+ end
14
+
15
+ def tag_name
16
+ @nokogiri_node&.name
17
+ end
18
+
19
+ def attribute?(name)
20
+ attributes.key?(name)
21
+ end
22
+
23
+ def attributes
24
+ @attributes ||= extract_attributes
25
+ end
26
+
27
+ def ruby_code
28
+ @ruby_code_string
29
+ end
30
+
31
+ private
32
+
33
+ def extract_attributes
34
+ return {} unless @nokogiri_node
35
+
36
+ @nokogiri_node.attributes.each_with_object({}) do |(name, _attr), result|
37
+ result[name] = true
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module A11y
6
+ module Lint
7
+ # Parses ERB templates and checks them against accessibility rules.
8
+ class ErbRunner
9
+ ERB_TAG = /<%.*?%>/m
10
+ ERB_OUTPUT_TAG = /<%=\s*(.*?)\s*-?%>/m
11
+
12
+ def initialize(rules)
13
+ @rules = rules
14
+ end
15
+
16
+ def run(source, filename:)
17
+ @filename = filename
18
+ @offenses = []
19
+
20
+ check_html_nodes(source)
21
+ check_erb_output_tags(source)
22
+
23
+ @offenses
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :rules
29
+
30
+ def check_html_nodes(source)
31
+ html = source.gsub(ERB_TAG, "")
32
+ doc = Nokogiri::HTML4::DocumentFragment.parse(html)
33
+
34
+ doc.traverse do |nokogiri_node|
35
+ next unless nokogiri_node.element?
36
+
37
+ node = ErbNode.new(nokogiri_node: nokogiri_node, line: nokogiri_node.line)
38
+ check_node(node)
39
+ end
40
+ end
41
+
42
+ def check_erb_output_tags(source)
43
+ source.scan(ERB_OUTPUT_TAG) do
44
+ match = Regexp.last_match
45
+ code = match[1]
46
+ line_number = source[0...match.begin(0)].count("\n") + 1
47
+ node = ErbNode.new(ruby_code: code, line: line_number)
48
+ check_node(node)
49
+ end
50
+ end
51
+
52
+ def check_node(node)
53
+ rules.each do |rule|
54
+ message = rule.check(node)
55
+ next unless message
56
+
57
+ @offenses << Offense.new(
58
+ rule: rule.name,
59
+ filename: @filename,
60
+ line: node.line,
61
+ message: message
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module A11y
6
+ module Lint
7
+ module Rules
8
+ # Checks that link_to / external_link_to calls with empty text
9
+ # include an aria-label (WCAG 4.1.2).
10
+ class LinkMissingAccessibleName < Rule
11
+ LINK_METHODS = %w[link_to external_link_to].freeze
12
+
13
+ def check(node)
14
+ return unless link_with_empty_text_and_no_accessible_name?(node)
15
+
16
+ "link with empty text content requires an aria-label (WCAG 4.1.2)"
17
+ end
18
+
19
+ private
20
+
21
+ def link_with_empty_text_and_no_accessible_name?(node)
22
+ code = node.ruby_code
23
+ return false unless code
24
+
25
+ sexp = Ripper.sexp(code)
26
+ return false unless sexp
27
+
28
+ call = extract_link_call(sexp)
29
+ return false unless call
30
+
31
+ first_arg_empty_string?(call) && !aria_label_within?(call)
32
+ end
33
+
34
+ def extract_link_call(sexp)
35
+ return unless sexp.is_a?(Array)
36
+ return sexp if link_call?(sexp)
37
+
38
+ sexp.each do |child|
39
+ result = extract_link_call(child)
40
+ return result if result
41
+ end
42
+
43
+ nil
44
+ end
45
+
46
+ def link_call?(sexp)
47
+ case sexp
48
+ in [:command, [:@ident, name, *], *] if LINK_METHODS.include?(name) then true
49
+ in [:method_add_arg, [:fcall, [:@ident, name, *]], *] if LINK_METHODS.include?(name) then true
50
+ else false
51
+ end
52
+ end
53
+
54
+ def first_arg_empty_string?(call)
55
+ args = extract_args(call)
56
+ return false unless args&.first
57
+
58
+ args.first in [:string_literal, [:string_content]]
59
+ end
60
+
61
+ def extract_args(call)
62
+ case call
63
+ in [:command, _, [:args_add_block, args, *]] then args
64
+ in [:method_add_arg, _, [:arg_paren, [:args_add_block, args, *]]] then args
65
+ else nil
66
+ end
67
+ end
68
+
69
+ def aria_label_within?(sexp)
70
+ return true if aria_hash_with_label?(sexp)
71
+ return true if aria_label_string_key?(sexp)
72
+ return false unless sexp.is_a?(Array)
73
+
74
+ sexp.any? { |child| aria_label_within?(child) }
75
+ end
76
+
77
+ def aria_hash_with_label?(sexp)
78
+ return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
79
+
80
+ key = sexp[1]
81
+ value = sexp[2]
82
+
83
+ (key in [:@label, "aria:", *]) && label_key_within?(value)
84
+ end
85
+
86
+ def label_key_within?(sexp)
87
+ return true if label_key?(sexp)
88
+ return false unless sexp.is_a?(Array)
89
+
90
+ sexp.any? { |child| label_key_within?(child) }
91
+ end
92
+
93
+ def label_key?(sexp)
94
+ sexp.is_a?(Array) && sexp[0] == :assoc_new && (sexp[1] in [:@label, "label:", *])
95
+ end
96
+
97
+ def aria_label_string_key?(sexp)
98
+ return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
99
+
100
+ sexp[1] in [:string_literal, [:string_content, [:@tstring_content, "aria-label", *]]]
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -3,7 +3,7 @@
3
3
  module A11y
4
4
  module Lint
5
5
  # Parses Slim templates and checks them against accessibility rules.
6
- class Runner
6
+ class SlimRunner
7
7
  def initialize(rules)
8
8
  @rules = rules
9
9
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A11y
4
4
  module Lint
5
- VERSION = "0.3.1"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
data/lib/a11y/lint.rb CHANGED
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "slim"
4
+ require "nokogiri"
4
5
  require_relative "lint/version"
5
6
  require_relative "lint/offense"
6
7
  require_relative "lint/node"
8
+ require_relative "lint/erb_node"
7
9
  require_relative "lint/rule"
8
10
  require_relative "lint/rules/image_tag_missing_alt"
9
11
  require_relative "lint/rules/img_missing_alt"
10
- require_relative "lint/runner"
12
+ require_relative "lint/rules/link_missing_accessible_name"
13
+ require_relative "lint/slim_runner"
14
+ require_relative "lint/erb_runner"
11
15
 
12
16
  module A11y
13
17
  module Lint
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.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdullah Hashim
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: nokogiri
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.13'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.13'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: slim
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -42,12 +56,15 @@ files:
42
56
  - exe/a11y-lint
43
57
  - lib/a11y/lint.rb
44
58
  - lib/a11y/lint/cli.rb
59
+ - lib/a11y/lint/erb_node.rb
60
+ - lib/a11y/lint/erb_runner.rb
45
61
  - lib/a11y/lint/node.rb
46
62
  - lib/a11y/lint/offense.rb
47
63
  - lib/a11y/lint/rule.rb
48
64
  - lib/a11y/lint/rules/image_tag_missing_alt.rb
49
65
  - lib/a11y/lint/rules/img_missing_alt.rb
50
- - lib/a11y/lint/runner.rb
66
+ - lib/a11y/lint/rules/link_missing_accessible_name.rb
67
+ - lib/a11y/lint/slim_runner.rb
51
68
  - lib/a11y/lint/version.rb
52
69
  - sig/a11y/lint.rbs
53
70
  homepage: https://github.com/Guided-Rails/a11y-lint