a11y-lint 0.7.2 → 0.8.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: fcce561f67949c3ba5eb95447325e06ce67c2e759733c0623168ce5948183222
4
- data.tar.gz: 59a739dcb7b14d79d2b8c7a4bb135853c04f3f1e79558cf8ddc05c0435c52a18
3
+ metadata.gz: a0d462b5e30cff7f4b1c5adaa3eeb9d2ac1a1aa9d3dad477b1841e38e271f22a
4
+ data.tar.gz: 66d8731b7371b98e4fede0445536d994fcd3405721dfb0617f1c139bd5fce925
5
5
  SHA512:
6
- metadata.gz: d03e078e621d82b48f114f6f8a2ecfa7bf05f746892bf5640c6ee5e5817ac740ee086ddb6dab3a7c993436d653bbd38f15967696a58f41802d335a67a22ab2ea
7
- data.tar.gz: 1a07a2c565896cde540e09adc8352602166ce7da1ecdcb555c52220a8fc764504abab43bf17ebefb0fe436ca4cff3835d9bae355fd620d08b3f64c6dcf83d291
6
+ metadata.gz: 6459a600d8cbd2465d5a9096eb99e36f319d1576cc685273b78505c636c15f77590104eec89ff4142d6525f70d5fc861822837fdf55cca9ed1afbad9a20011b5
7
+ data.tar.gz: 3c22904b9d168f559b9bc83522e1a5422fad6f2dd3d5beffd158ad06135ef6dacc85fdf3d832970b330ed1f4fd25c68a41b3bd8a34ef57ca6df8c2211a694dca
data/CHANGELOG.md CHANGED
@@ -5,7 +5,22 @@ 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]
8
+ ## [0.8.0] - 2026-04-14
9
+
10
+ ### Added
11
+
12
+ - Phlex view support: scans `.rb` files containing Phlex components
13
+ - Detects Phlex files by the presence of a `def view_template` method
14
+ - All existing rules (`ImgMissingAlt`, `ImageTagMissingAlt`, `ListInvalidChildren`, `MissingAccessibleName`) work with Phlex views
15
+ - CLI automatically discovers `.rb` files when scanning directories
16
+
17
+ ### Changed
18
+
19
+ - **Breaking:** `Node` has been renamed to `SlimNode` for consistency with `ErbNode` and `PhlexNode`
20
+
21
+ ### Dependencies
22
+
23
+ - Added `prism` gem for Ruby AST parsing
9
24
 
10
25
  ## [0.7.2] - 2026-04-13
11
26
 
data/CLAUDE.md CHANGED
@@ -19,16 +19,17 @@ 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`; routes `.slim` files to `SlimRunner` and `.erb` files to `ErbRunner`
22
+ - **CLI:** `lib/a11y/lint/cli.rb` — command-line interface; executable at `exe/a11y-lint`; routes `.slim` files to `SlimRunner`, `.erb` files to `ErbRunner`, and `.rb` files to `PhlexRunner`
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
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
+ - **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`
26
27
  - **Configuration:** `lib/a11y/lint/configuration.rb` — loads `.a11y-lint.yml` to enable/disable individual rules; searches upward from the target path
27
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`)
28
29
  - **Version:** `lib/a11y/lint/version.rb`
29
30
  - **Type signatures (RBS):** `sig/a11y/lint.rbs`
30
31
  - **Tests:** `test/` directory using Minitest; test helper at `test/test_helper.rb`
31
- - **Dummy app:** `test/fixtures/dummy_app/` — a fixture app with Slim/ERB templates for end-to-end smoke testing before releases (`bundle exec a11y-lint test/fixtures/dummy_app`)
32
+ - **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`)
32
33
 
33
34
  ## Code Style
34
35
 
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` and `.erb` files:
36
+ With no arguments, it scans the current directory recursively for `.slim`, `.erb`, and `.rb` (Phlex) files:
37
37
 
38
38
  ```bash
39
39
  a11y-lint
@@ -64,10 +64,21 @@ a11y-lint --config path/to/.a11y-lint.yml app/views/
64
64
  ```ruby
65
65
  require "a11y/lint"
66
66
 
67
+ # Slim
67
68
  source = File.read("app/views/home.html.slim")
68
- runner = A11y::Lint::SlimRunner.new([A11y::Lint::Rules::ImgMissingAlt.new])
69
+ runner = A11y::Lint::SlimRunner.new([A11y::Lint::Rules::ImgMissingAlt])
69
70
  offenses = runner.run(source, filename: "app/views/home.html.slim")
70
71
 
72
+ # ERB
73
+ source = File.read("app/views/home.html.erb")
74
+ runner = A11y::Lint::ErbRunner.new([A11y::Lint::Rules::ImgMissingAlt])
75
+ offenses = runner.run(source, filename: "app/views/home.html.erb")
76
+
77
+ # Phlex
78
+ source = File.read("app/views/home_view.rb")
79
+ runner = A11y::Lint::PhlexRunner.new([A11y::Lint::Rules::ImgMissingAlt])
80
+ offenses = runner.run(source, filename: "app/views/home_view.rb")
81
+
71
82
  offenses.each do |offense|
72
83
  puts "#{offense.filename}:#{offense.line} [#{offense.rule}] #{offense.message}"
73
84
  end
data/lib/a11y/lint/cli.rb CHANGED
@@ -19,7 +19,7 @@ module A11y
19
19
  files = resolve_files(@argv)
20
20
 
21
21
  if files.empty?
22
- @stderr.puts("No .slim or .erb files found")
22
+ @stderr.puts("No .slim, .erb, or .rb files found")
23
23
  return 0
24
24
  end
25
25
 
@@ -62,7 +62,7 @@ module A11y
62
62
 
63
63
  def expand_path(path)
64
64
  if File.directory?(path)
65
- Dir.glob(File.join(path, "**", "*.{slim,erb}"))
65
+ Dir.glob(File.join(path, "**", "*.{slim,erb,rb}"))
66
66
  elsif File.file?(path)
67
67
  [path]
68
68
  else
@@ -75,14 +75,23 @@ module A11y
75
75
  rules = all_rules
76
76
  slim_runner = SlimRunner.new(rules)
77
77
  erb_runner = ErbRunner.new(rules)
78
+ phlex_runner = PhlexRunner.new(rules)
78
79
 
79
80
  files.flat_map do |file|
80
81
  source = File.read(file)
81
- runner = file.end_with?(".erb") ? erb_runner : slim_runner
82
+ runner = runner_for(file, slim_runner, erb_runner, phlex_runner)
82
83
  runner.run(source, filename: file)
83
84
  end
84
85
  end
85
86
 
87
+ def runner_for(file, slim_runner, erb_runner, phlex_runner)
88
+ case File.extname(file)
89
+ when ".erb" then erb_runner
90
+ when ".rb" then phlex_runner
91
+ else slim_runner
92
+ end
93
+ end
94
+
86
95
  def all_rules
87
96
  configuration = Configuration.load(
88
97
  @config_path,
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11y
4
+ module Lint
5
+ # Wraps a Phlex HTML tag call or helper method call
6
+ # as a queryable node for lint rules.
7
+ class PhlexNode
8
+ attr_reader :line, :children
9
+
10
+ # Phlex method names that map to a different HTML tag.
11
+ TAG_ALIASES = {
12
+ "ruby_element" => "ruby",
13
+ "template_tag" => "template"
14
+ }.freeze
15
+
16
+ HTML_TAGS = Set.new(
17
+ %w[
18
+ a abbr address article aside b bdi bdo
19
+ blockquote body br button caption cite code
20
+ col colgroup data datalist dd del details dfn
21
+ dialog div dl dt em embed fieldset figcaption
22
+ figure footer form h1 h2 h3 h4 h5 h6 head
23
+ header hgroup hr html i iframe img input ins
24
+ kbd label legend li link main map mark menu
25
+ meter nav noscript object ol optgroup option
26
+ output p picture pre progress q rp rt
27
+ ruby_element s samp script search section
28
+ select slot small span strong style sub
29
+ summary sup table tbody td template_tag
30
+ textarea tfoot th thead time title tr u ul
31
+ var video wbr
32
+ ]
33
+ ).freeze
34
+
35
+ def initialize(
36
+ line:, tag_name: nil,
37
+ attributes: {}, ruby_code: nil, children: []
38
+ )
39
+ @tag_name_string = tag_name
40
+ @attributes_hash = attributes
41
+ @ruby_code_string = ruby_code
42
+ @line = line
43
+ @children = children
44
+ end
45
+
46
+ def tag_name
47
+ @tag_name_string
48
+ end
49
+
50
+ def attribute?(name)
51
+ @attributes_hash.key?(name)
52
+ end
53
+
54
+ def attributes
55
+ @attributes_hash
56
+ end
57
+
58
+ def ruby_code
59
+ @ruby_code_string
60
+ end
61
+
62
+ def self.html_tag?(method_name)
63
+ HTML_TAGS.include?(method_name)
64
+ end
65
+
66
+ def self.html_tag_name(method_name)
67
+ TAG_ALIASES.fetch(method_name, method_name)
68
+ end
69
+
70
+ def self.build_tag(call_node, children: [])
71
+ name = call_node.name.to_s
72
+ new(
73
+ tag_name: html_tag_name(name),
74
+ attributes: extract_attributes(call_node),
75
+ line: call_node.location.start_line,
76
+ children: children
77
+ )
78
+ end
79
+
80
+ def self.build_helper(call_node, source)
81
+ new(
82
+ ruby_code: ruby_code_for(call_node, source),
83
+ line: call_node.location.start_line
84
+ )
85
+ end
86
+
87
+ def self.extract_attributes(call_node)
88
+ return {} unless call_node.arguments
89
+
90
+ kwarg_nodes(call_node).each_with_object({}) do |elem, h|
91
+ key = kwarg_key(elem.key)
92
+ h[key] = true if key
93
+ end
94
+ end
95
+
96
+ def self.kwarg_nodes(call_node)
97
+ args = call_node.arguments.arguments
98
+ args.select { |a| a.is_a?(Prism::KeywordHashNode) }
99
+ .flat_map { |a| a.elements.select { |e| e.is_a?(Prism::AssocNode) } }
100
+ end
101
+
102
+ def self.kwarg_key(key_node)
103
+ case key_node
104
+ when Prism::SymbolNode then key_node.value
105
+ when Prism::StringNode then key_node.unescaped
106
+ end
107
+ end
108
+
109
+ def self.ruby_code_for(call_node, source)
110
+ return call_node.slice unless call_node.block
111
+
112
+ stop = call_node.block.location.start_offset
113
+ start = call_node.location.start_offset
114
+ "#{source[start...stop].rstrip} do"
115
+ end
116
+
117
+ private_class_method :kwarg_key, :kwarg_nodes,
118
+ :ruby_code_for,
119
+ :extract_attributes
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module A11y
6
+ module Lint
7
+ # Parses Phlex view classes and checks them
8
+ # against accessibility rules.
9
+ class PhlexRunner
10
+ PHLEX_PATTERN = /\bdef\s+view_template\b/
11
+
12
+ def initialize(rules)
13
+ @rules = rules
14
+ end
15
+
16
+ def run(source, filename:)
17
+ return [] unless source.match?(PHLEX_PATTERN)
18
+
19
+ @source = source
20
+ @filename = filename
21
+ @offenses = []
22
+
23
+ walk(Prism.parse(source).value)
24
+ @offenses
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :rules
30
+
31
+ def walk(node)
32
+ if receiverless_call?(node)
33
+ process_call(node)
34
+ else
35
+ node.child_nodes.compact.each { |c| walk(c) }
36
+ end
37
+ end
38
+
39
+ def process_call(node)
40
+ name = node.name.to_s
41
+ if PhlexNode.html_tag?(name)
42
+ check_tag(node)
43
+ else
44
+ check_helper(node)
45
+ end
46
+ end
47
+
48
+ def check_tag(node)
49
+ children = collect_block_children(node.block)
50
+ check_node(PhlexNode.build_tag(node, children:))
51
+ end
52
+
53
+ def check_helper(node)
54
+ check_node(PhlexNode.build_helper(node, @source))
55
+ walk_block(node.block)
56
+ end
57
+
58
+ def collect_block_children(block)
59
+ return [] unless block.is_a?(Prism::BlockNode)
60
+
61
+ children = []
62
+ gather_children(block, children)
63
+ children
64
+ end
65
+
66
+ def gather_children(parent, result)
67
+ parent.child_nodes.compact.each do |child|
68
+ if tag_call?(child)
69
+ gather_tag_child(child, result)
70
+ elsif receiverless_call?(child)
71
+ check_helper(child)
72
+ else
73
+ gather_children(child, result)
74
+ end
75
+ end
76
+ end
77
+
78
+ def gather_tag_child(child, result)
79
+ kids = collect_block_children(child.block)
80
+ node = PhlexNode.build_tag(child, children: kids)
81
+ result << node
82
+ check_node(node)
83
+ end
84
+
85
+ def walk_block(block)
86
+ return unless block.is_a?(Prism::BlockNode)
87
+
88
+ block.child_nodes.compact.each { |c| walk(c) }
89
+ end
90
+
91
+ def tag_call?(node)
92
+ receiverless_call?(node) &&
93
+ PhlexNode.html_tag?(node.name.to_s)
94
+ end
95
+
96
+ def receiverless_call?(node)
97
+ node.is_a?(Prism::CallNode) && node.receiver.nil?
98
+ end
99
+
100
+ def check_node(node)
101
+ rules.each do |rule_class|
102
+ message = rule_class.check(node)
103
+ next unless message
104
+
105
+ @offenses << Offense.new(
106
+ rule: rule_class.rule_name,
107
+ filename: @filename,
108
+ line: node.line,
109
+ message: message
110
+ )
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -3,7 +3,7 @@
3
3
  module A11y
4
4
  module Lint
5
5
  # Wraps a Slim AST s-expression as a queryable node for lint rules.
6
- class Node
6
+ class SlimNode
7
7
  attr_reader :line
8
8
 
9
9
  def initialize(sexp, line:)
@@ -29,7 +29,7 @@ module A11y
29
29
  @attributes ||= extract_attributes
30
30
  end
31
31
 
32
- # Returns direct HTML element children as Node objects.
32
+ # Returns direct HTML element children as SlimNode objects.
33
33
  # Walks through [:multi] and [:slim, :control] wrappers so that tags
34
34
  # nested inside control flow are still treated as direct children.
35
35
  # Opaque [:slim, :output] blocks are skipped.
@@ -48,7 +48,7 @@ module A11y
48
48
 
49
49
  def collect_children(sexp)
50
50
  return [] unless sexp.is_a?(Array)
51
- return [Node.new(sexp, line: @line)] if html_tag_sexp?(sexp)
51
+ return [SlimNode.new(sexp, line: @line)] if html_tag_sexp?(sexp)
52
52
  return collect_children(sexp[3]) if slim_control_sexp?(sexp)
53
53
  return [] unless sexp[0] == :multi
54
54
 
@@ -25,7 +25,7 @@ module A11y
25
25
  return unless node?(sexp)
26
26
 
27
27
  @line += 1 if sexp[0] == :newline
28
- new_node = Node.new(sexp, line: @line)
28
+ new_node = SlimNode.new(sexp, line: @line)
29
29
  check_node(new_node) if html_tag?(sexp) || slim_output?(sexp)
30
30
  @line += continuation_newlines(sexp)
31
31
  sexp.each { |child| walk(child) }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A11y
4
4
  module Lint
5
- VERSION = "0.7.2"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
data/lib/a11y/lint.rb CHANGED
@@ -2,10 +2,12 @@
2
2
 
3
3
  require "slim"
4
4
  require "nokogiri"
5
+ require "prism"
5
6
  require_relative "lint/version"
6
7
  require_relative "lint/offense"
7
- require_relative "lint/node"
8
+ require_relative "lint/slim_node"
8
9
  require_relative "lint/erb_node"
10
+ require_relative "lint/phlex_node"
9
11
  require_relative "lint/configuration"
10
12
  require_relative "lint/rule"
11
13
  require_relative "lint/rules/perceivable/image_tag_missing_alt"
@@ -14,6 +16,7 @@ require_relative "lint/rules/perceivable/list_invalid_children"
14
16
  require_relative "lint/rules/robust/missing_accessible_name"
15
17
  require_relative "lint/slim_runner"
16
18
  require_relative "lint/erb_runner"
19
+ require_relative "lint/phlex_runner"
17
20
 
18
21
  module A11y
19
22
  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.7.2
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdullah Hashim
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.13'
26
+ - !ruby/object:Gem::Dependency
27
+ name: prism
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: slim
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -59,13 +73,15 @@ files:
59
73
  - lib/a11y/lint/configuration.rb
60
74
  - lib/a11y/lint/erb_node.rb
61
75
  - lib/a11y/lint/erb_runner.rb
62
- - lib/a11y/lint/node.rb
63
76
  - lib/a11y/lint/offense.rb
77
+ - lib/a11y/lint/phlex_node.rb
78
+ - lib/a11y/lint/phlex_runner.rb
64
79
  - lib/a11y/lint/rule.rb
65
80
  - lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb
66
81
  - lib/a11y/lint/rules/perceivable/img_missing_alt.rb
67
82
  - lib/a11y/lint/rules/perceivable/list_invalid_children.rb
68
83
  - lib/a11y/lint/rules/robust/missing_accessible_name.rb
84
+ - lib/a11y/lint/slim_node.rb
69
85
  - lib/a11y/lint/slim_runner.rb
70
86
  - lib/a11y/lint/version.rb
71
87
  - sig/a11y/lint.rbs