a11y-lint 0.8.0 → 0.10.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 +4 -4
- data/.rubocop.yml +7 -1
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +4 -3
- data/lib/a11y/lint/block_inspection.rb +28 -0
- data/lib/a11y/lint/call_node.rb +108 -0
- data/lib/a11y/lint/cli.rb +1 -2
- data/lib/a11y/lint/erb_node.rb +27 -11
- data/lib/a11y/lint/erb_runner.rb +29 -3
- data/lib/a11y/lint/errors.rb +17 -0
- data/lib/a11y/lint/phlex_node.rb +36 -60
- data/lib/a11y/lint/phlex_runner.rb +36 -15
- data/lib/a11y/lint/phlex_tags.rb +132 -0
- data/lib/a11y/lint/ruby_code.rb +67 -0
- data/lib/a11y/lint/rule.rb +4 -0
- data/lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb +5 -69
- data/lib/a11y/lint/rules/robust/missing_accessible_name.rb +19 -104
- data/lib/a11y/lint/slim_node.rb +56 -1
- data/lib/a11y/lint/slim_runner.rb +3 -0
- data/lib/a11y/lint/version.rb +1 -1
- data/lib/a11y/lint.rb +5 -8
- metadata +8 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecfe715cda47636bb8dc87f9cf3655c641d33d2e10cfd8471be73b2dd7336a1f
|
|
4
|
+
data.tar.gz: 818bb7d22993ac9036ac148d60adce32c2ef7ee342bc741bbd8e64d893414d2f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3a0d354908cfc0c670db3556f0854e1e91f0486ae502ff6f93395a95923d1a6b7b456bd625ffb476ba8f7e8d3f8bea5f859ed4875e0cf172770be5e87da8517f
|
|
7
|
+
data.tar.gz: e0e5e582649edc60f0df35635af190beb49b2ab69c9da392e9ae31326918c6704962b5a5cd6d60f8e1bb8bf47474a9d4b2fdb18baf424f3013871a7d3cc17a10
|
data/.rubocop.yml
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
AllCops:
|
|
2
|
-
TargetRubyVersion: 3.
|
|
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,32 @@ 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.10.0] - 2026-04-15
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Introduce `A11y::Lint::CallNode` wrapper so rules express accessibility logic, not AST traversal
|
|
15
|
+
- Expose Prism `CallNode` from `SlimNode` and `ErbNode` via the new `RubyCode` parser
|
|
16
|
+
- Expose Prism `CallNode` from `PhlexNode` instead of converting to string
|
|
17
|
+
- Extract Phlex HTML tag constants into `PhlexTags` module
|
|
18
|
+
|
|
19
|
+
## [0.9.0] - 2026-04-15
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **Breaking:** Require Ruby >= 3.3.0 (was 3.1.0). Prism ships with Ruby 3.3+ as a bundled gem
|
|
24
|
+
- **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
|
|
25
|
+
- `prism` is no longer a declared dependency (bundled with Ruby 3.3+)
|
|
26
|
+
- Replace Ripper with Prism for Ruby code parsing in `ImageTagMissingAlt` and `MissingAccessibleName` rules
|
|
27
|
+
- Custom error classes are now defined in `lib/a11y/lint/errors.rb`
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- `MissingAccessibleName`: fix false positive when block contains visible text or HTML tags (e.g. `link_to do` with a `span` or plain text inside)
|
|
32
|
+
- 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
|
|
33
|
+
|
|
8
34
|
## [0.8.0] - 2026-04-14
|
|
9
35
|
|
|
10
36
|
### 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.
|
|
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,8 +24,9 @@ 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`, `
|
|
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`
|
|
@@ -34,5 +35,5 @@ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::L
|
|
|
34
35
|
## Code Style
|
|
35
36
|
|
|
36
37
|
RuboCop is configured in `.rubocop.yml`:
|
|
37
|
-
- Target Ruby version: 3.
|
|
38
|
+
- Target Ruby version: 3.3
|
|
38
39
|
- Enforces double quotes for strings
|
|
@@ -0,0 +1,28 @@
|
|
|
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 image_tag 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? { |code| icon_helper_call?(code) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def icon_helper_call?(code)
|
|
23
|
+
call = RubyCode.new(code).call_node
|
|
24
|
+
call && ICON_HELPERS.include?(call.method_name)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
+
def positional_args
|
|
34
|
+
return [] unless @prism_node.arguments
|
|
35
|
+
|
|
36
|
+
@prism_node.arguments.arguments.reject do |a|
|
|
37
|
+
a.is_a?(Prism::KeywordHashNode)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def first_positional_arg_empty_string?
|
|
42
|
+
first = positional_args.first
|
|
43
|
+
first.is_a?(Prism::StringNode) && first.unescaped.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def block?
|
|
47
|
+
!@prism_node.block.nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Finds a receiverless call by method name in this node's
|
|
51
|
+
# subtree (including self). Returns a CallNode or nil.
|
|
52
|
+
def find(name)
|
|
53
|
+
found = search_for_call(@prism_node, name)
|
|
54
|
+
found ? self.class.new(found) : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def flat_keyword?(kw_hash, key)
|
|
60
|
+
kw_hash.elements.any? do |assoc|
|
|
61
|
+
key_name(assoc) == key.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def nested_keyword?(kw_hash, outer_key, inner_key)
|
|
66
|
+
assoc = kw_hash.elements.find do |a|
|
|
67
|
+
key_name(a) == outer_key.to_s
|
|
68
|
+
end
|
|
69
|
+
return false unless assoc&.value.is_a?(Prism::HashNode)
|
|
70
|
+
|
|
71
|
+
assoc.value.elements.any? do |inner|
|
|
72
|
+
key_name(inner) == inner_key.to_s
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def find_keyword_hash
|
|
77
|
+
return unless @prism_node.arguments
|
|
78
|
+
|
|
79
|
+
@prism_node.arguments.arguments.find do |arg|
|
|
80
|
+
arg.is_a?(Prism::KeywordHashNode)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def key_name(assoc)
|
|
85
|
+
return unless assoc.is_a?(Prism::AssocNode)
|
|
86
|
+
|
|
87
|
+
case assoc.key
|
|
88
|
+
when Prism::SymbolNode then assoc.key.unescaped
|
|
89
|
+
when Prism::StringNode then assoc.key.unescaped
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def search_for_call(node, name)
|
|
94
|
+
if node.is_a?(Prism::CallNode) &&
|
|
95
|
+
node.receiver.nil? &&
|
|
96
|
+
node.name.to_s == name
|
|
97
|
+
return node
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
node.child_nodes.compact.each do |child|
|
|
101
|
+
found = search_for_call(child, name)
|
|
102
|
+
return found if found
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
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
|
data/lib/a11y/lint/erb_node.rb
CHANGED
|
@@ -5,16 +5,26 @@ module A11y
|
|
|
5
5
|
# Wraps a Nokogiri node or extracted ERB output tag
|
|
6
6
|
# as a queryable node for lint rules.
|
|
7
7
|
class ErbNode
|
|
8
|
-
|
|
8
|
+
include BlockInspection
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
)
|
|
13
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
|
|
14
24
|
end
|
|
15
25
|
|
|
16
26
|
def tag_name
|
|
17
|
-
|
|
27
|
+
nokogiri_node&.name
|
|
18
28
|
end
|
|
19
29
|
|
|
20
30
|
def attribute?(name)
|
|
@@ -25,25 +35,31 @@ module A11y
|
|
|
25
35
|
@attributes ||= extract_attributes
|
|
26
36
|
end
|
|
27
37
|
|
|
28
|
-
def
|
|
29
|
-
@
|
|
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
|
|
30
44
|
end
|
|
31
45
|
|
|
32
46
|
# Returns direct element children wrapped as ErbNode objects.
|
|
33
47
|
def children
|
|
34
|
-
return [] unless
|
|
48
|
+
return [] unless nokogiri_node
|
|
35
49
|
|
|
36
|
-
|
|
50
|
+
nokogiri_node.element_children.map do |child|
|
|
37
51
|
ErbNode.new(nokogiri_node: child, line: child.line)
|
|
38
52
|
end
|
|
39
53
|
end
|
|
40
54
|
|
|
41
55
|
private
|
|
42
56
|
|
|
57
|
+
attr_reader(:nokogiri_node)
|
|
58
|
+
|
|
43
59
|
def extract_attributes
|
|
44
|
-
return {} unless
|
|
60
|
+
return {} unless nokogiri_node
|
|
45
61
|
|
|
46
|
-
|
|
62
|
+
nokogiri_node
|
|
47
63
|
.attributes
|
|
48
64
|
.each_with_object({}) do |(name, _attr), result|
|
|
49
65
|
result[name] = true
|
data/lib/a11y/lint/erb_runner.rb
CHANGED
|
@@ -53,12 +53,38 @@ module A11y
|
|
|
53
53
|
source.scan(ERB_OUTPUT_TAG) do
|
|
54
54
|
match = Regexp.last_match
|
|
55
55
|
code = match[1]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
line = source[0...match.begin(0)].count("\n") + 1
|
|
57
|
+
check_node(
|
|
58
|
+
build_erb_output_node(source, code, line, match.end(0))
|
|
59
|
+
)
|
|
59
60
|
end
|
|
60
61
|
end
|
|
61
62
|
|
|
63
|
+
def build_erb_output_node(source, code, line, match_end)
|
|
64
|
+
block_body_codes, block_has_text =
|
|
65
|
+
extract_block_info(source, code, match_end)
|
|
66
|
+
|
|
67
|
+
ErbNode.new(
|
|
68
|
+
ruby_code: code, line: line,
|
|
69
|
+
block_body_codes: block_body_codes,
|
|
70
|
+
block_has_text_children: block_has_text
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def extract_block_info(source, code, match_end)
|
|
75
|
+
return [nil, false] unless code.match?(/\s+do\s*\z/)
|
|
76
|
+
|
|
77
|
+
rest = source[match_end..]
|
|
78
|
+
end_match = rest.match(/<%-?\s*end\s*-?%>/m)
|
|
79
|
+
return [nil, false] unless end_match
|
|
80
|
+
|
|
81
|
+
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
|
|
84
|
+
|
|
85
|
+
[codes, !text_only.empty?]
|
|
86
|
+
end
|
|
87
|
+
|
|
62
88
|
def check_node(node)
|
|
63
89
|
rules.each do |rule_class|
|
|
64
90
|
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
|
data/lib/a11y/lint/phlex_node.rb
CHANGED
|
@@ -5,66 +5,45 @@ module A11y
|
|
|
5
5
|
# Wraps a Phlex HTML tag call or helper method call
|
|
6
6
|
# as a queryable node for lint rules.
|
|
7
7
|
class PhlexNode
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
8
|
+
include BlockInspection
|
|
9
|
+
extend PhlexTags
|
|
10
|
+
|
|
11
|
+
attr_reader(
|
|
12
|
+
:attributes,
|
|
13
|
+
:block_body_codes,
|
|
14
|
+
:call_node,
|
|
15
|
+
:children,
|
|
16
|
+
:line,
|
|
17
|
+
:tag_name
|
|
18
|
+
)
|
|
34
19
|
|
|
20
|
+
# rubocop:disable Metrics/ParameterLists
|
|
35
21
|
def initialize(
|
|
36
|
-
line:, tag_name: nil,
|
|
37
|
-
|
|
22
|
+
line:, tag_name: nil, attributes: {},
|
|
23
|
+
call_node: nil, children: [],
|
|
24
|
+
block_body_codes: nil,
|
|
25
|
+
block_has_text_children: false
|
|
38
26
|
)
|
|
39
|
-
@
|
|
40
|
-
@
|
|
41
|
-
@
|
|
27
|
+
@tag_name = tag_name
|
|
28
|
+
@attributes = attributes
|
|
29
|
+
@call_node = call_node
|
|
42
30
|
@line = line
|
|
43
31
|
@children = children
|
|
32
|
+
@block_body_codes = block_body_codes
|
|
33
|
+
@block_has_text_children = block_has_text_children
|
|
44
34
|
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
|
|
35
|
+
# rubocop:enable Metrics/ParameterLists
|
|
57
36
|
|
|
58
37
|
def ruby_code
|
|
59
|
-
|
|
38
|
+
nil
|
|
60
39
|
end
|
|
61
40
|
|
|
62
|
-
def
|
|
63
|
-
|
|
41
|
+
def attribute?(name)
|
|
42
|
+
attributes.key?(name)
|
|
64
43
|
end
|
|
65
44
|
|
|
66
|
-
def
|
|
67
|
-
|
|
45
|
+
def block_has_text_children?
|
|
46
|
+
@block_has_text_children
|
|
68
47
|
end
|
|
69
48
|
|
|
70
49
|
def self.build_tag(call_node, children: [])
|
|
@@ -77,10 +56,16 @@ module A11y
|
|
|
77
56
|
)
|
|
78
57
|
end
|
|
79
58
|
|
|
80
|
-
def self.build_helper(
|
|
59
|
+
def self.build_helper(
|
|
60
|
+
call_node,
|
|
61
|
+
block_body_codes: nil,
|
|
62
|
+
block_has_text_children: false
|
|
63
|
+
)
|
|
81
64
|
new(
|
|
82
|
-
|
|
83
|
-
line: call_node.location.start_line
|
|
65
|
+
call_node: CallNode.new(call_node),
|
|
66
|
+
line: call_node.location.start_line,
|
|
67
|
+
block_body_codes: block_body_codes,
|
|
68
|
+
block_has_text_children: block_has_text_children
|
|
84
69
|
)
|
|
85
70
|
end
|
|
86
71
|
|
|
@@ -106,16 +91,7 @@ module A11y
|
|
|
106
91
|
end
|
|
107
92
|
end
|
|
108
93
|
|
|
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
94
|
private_class_method :kwarg_key, :kwarg_nodes,
|
|
118
|
-
:ruby_code_for,
|
|
119
95
|
:extract_attributes
|
|
120
96
|
end
|
|
121
97
|
end
|
|
@@ -37,8 +37,7 @@ module A11y
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def process_call(node)
|
|
40
|
-
|
|
41
|
-
if PhlexNode.html_tag?(name)
|
|
40
|
+
if PhlexNode.html_tag?(node.name.to_s)
|
|
42
41
|
check_tag(node)
|
|
43
42
|
else
|
|
44
43
|
check_helper(node)
|
|
@@ -51,16 +50,20 @@ module A11y
|
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
def check_helper(node)
|
|
54
|
-
|
|
53
|
+
codes, has_text = analyze_helper_block(node)
|
|
54
|
+
helper = PhlexNode.build_helper(
|
|
55
|
+
node,
|
|
56
|
+
block_body_codes: codes,
|
|
57
|
+
block_has_text_children: has_text
|
|
58
|
+
)
|
|
59
|
+
check_node(helper)
|
|
55
60
|
walk_block(node.block)
|
|
56
61
|
end
|
|
57
62
|
|
|
58
63
|
def collect_block_children(block)
|
|
59
64
|
return [] unless block.is_a?(Prism::BlockNode)
|
|
60
65
|
|
|
61
|
-
|
|
62
|
-
gather_children(block, children)
|
|
63
|
-
children
|
|
66
|
+
[].tap { |c| gather_children(block, c) }
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
def gather_children(parent, result)
|
|
@@ -77,11 +80,32 @@ module A11y
|
|
|
77
80
|
|
|
78
81
|
def gather_tag_child(child, result)
|
|
79
82
|
kids = collect_block_children(child.block)
|
|
80
|
-
|
|
81
|
-
result
|
|
82
|
-
check_node(node)
|
|
83
|
+
result << PhlexNode.build_tag(child, children: kids)
|
|
84
|
+
check_node(result.last)
|
|
83
85
|
end
|
|
84
86
|
|
|
87
|
+
def analyze_helper_block(call_node)
|
|
88
|
+
block = call_node.block
|
|
89
|
+
return [nil, false] unless block.is_a?(Prism::BlockNode)
|
|
90
|
+
|
|
91
|
+
codes = []
|
|
92
|
+
has_text = scan_block_content(block, codes)
|
|
93
|
+
[codes.empty? ? nil : codes, has_text]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
97
|
+
def scan_block_content(node, codes)
|
|
98
|
+
node.child_nodes.compact.each do |child|
|
|
99
|
+
return true if child.is_a?(Prism::YieldNode)
|
|
100
|
+
return true if tag_call?(child) && child.block
|
|
101
|
+
next if tag_call?(child)
|
|
102
|
+
next codes << child.slice if receiverless_call?(child)
|
|
103
|
+
return true if scan_block_content(child, codes)
|
|
104
|
+
end
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
108
|
+
|
|
85
109
|
def walk_block(block)
|
|
86
110
|
return unless block.is_a?(Prism::BlockNode)
|
|
87
111
|
|
|
@@ -89,8 +113,7 @@ module A11y
|
|
|
89
113
|
end
|
|
90
114
|
|
|
91
115
|
def tag_call?(node)
|
|
92
|
-
receiverless_call?(node) &&
|
|
93
|
-
PhlexNode.html_tag?(node.name.to_s)
|
|
116
|
+
receiverless_call?(node) && PhlexNode.html_tag?(node.name.to_s)
|
|
94
117
|
end
|
|
95
118
|
|
|
96
119
|
def receiverless_call?(node)
|
|
@@ -103,10 +126,8 @@ module A11y
|
|
|
103
126
|
next unless message
|
|
104
127
|
|
|
105
128
|
@offenses << Offense.new(
|
|
106
|
-
rule: rule_class.rule_name,
|
|
107
|
-
|
|
108
|
-
line: node.line,
|
|
109
|
-
message: message
|
|
129
|
+
rule: rule_class.rule_name, filename: @filename,
|
|
130
|
+
line: node.line, message: message
|
|
110
131
|
)
|
|
111
132
|
end
|
|
112
133
|
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
# Maps Phlex method names to their HTML tag equivalents.
|
|
6
|
+
module PhlexTags
|
|
7
|
+
# Phlex method names that map to a different HTML tag.
|
|
8
|
+
TAG_ALIASES = {
|
|
9
|
+
"ruby_element" => "ruby",
|
|
10
|
+
"template_tag" => "template"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
HTML_TAGS = Set.new(
|
|
14
|
+
%w[
|
|
15
|
+
a
|
|
16
|
+
abbr
|
|
17
|
+
address
|
|
18
|
+
article
|
|
19
|
+
aside
|
|
20
|
+
b
|
|
21
|
+
bdi
|
|
22
|
+
bdo
|
|
23
|
+
blockquote
|
|
24
|
+
body
|
|
25
|
+
br
|
|
26
|
+
button
|
|
27
|
+
caption
|
|
28
|
+
cite
|
|
29
|
+
code
|
|
30
|
+
col
|
|
31
|
+
colgroup
|
|
32
|
+
data
|
|
33
|
+
datalist
|
|
34
|
+
dd
|
|
35
|
+
del
|
|
36
|
+
details
|
|
37
|
+
dfn
|
|
38
|
+
dialog
|
|
39
|
+
div
|
|
40
|
+
dl
|
|
41
|
+
dt
|
|
42
|
+
em
|
|
43
|
+
embed
|
|
44
|
+
fieldset
|
|
45
|
+
figcaption
|
|
46
|
+
figure
|
|
47
|
+
footer
|
|
48
|
+
form
|
|
49
|
+
h1
|
|
50
|
+
h2
|
|
51
|
+
h3
|
|
52
|
+
h4
|
|
53
|
+
h5
|
|
54
|
+
h6
|
|
55
|
+
head
|
|
56
|
+
header
|
|
57
|
+
hgroup
|
|
58
|
+
hr
|
|
59
|
+
html
|
|
60
|
+
i
|
|
61
|
+
iframe
|
|
62
|
+
img
|
|
63
|
+
input
|
|
64
|
+
ins
|
|
65
|
+
kbd
|
|
66
|
+
label
|
|
67
|
+
legend
|
|
68
|
+
li
|
|
69
|
+
link
|
|
70
|
+
main
|
|
71
|
+
map
|
|
72
|
+
mark
|
|
73
|
+
menu
|
|
74
|
+
meter
|
|
75
|
+
nav
|
|
76
|
+
noscript
|
|
77
|
+
object
|
|
78
|
+
ol
|
|
79
|
+
optgroup
|
|
80
|
+
option
|
|
81
|
+
output
|
|
82
|
+
p
|
|
83
|
+
picture
|
|
84
|
+
pre
|
|
85
|
+
progress
|
|
86
|
+
q
|
|
87
|
+
rp
|
|
88
|
+
rt
|
|
89
|
+
ruby_element
|
|
90
|
+
s
|
|
91
|
+
samp
|
|
92
|
+
script
|
|
93
|
+
search
|
|
94
|
+
section
|
|
95
|
+
select
|
|
96
|
+
slot
|
|
97
|
+
small
|
|
98
|
+
span
|
|
99
|
+
strong
|
|
100
|
+
style
|
|
101
|
+
sub
|
|
102
|
+
summary
|
|
103
|
+
sup
|
|
104
|
+
table
|
|
105
|
+
tbody
|
|
106
|
+
td
|
|
107
|
+
template_tag
|
|
108
|
+
textarea
|
|
109
|
+
tfoot
|
|
110
|
+
th
|
|
111
|
+
thead
|
|
112
|
+
time
|
|
113
|
+
title
|
|
114
|
+
tr
|
|
115
|
+
u
|
|
116
|
+
ul
|
|
117
|
+
var
|
|
118
|
+
video
|
|
119
|
+
wbr
|
|
120
|
+
]
|
|
121
|
+
).freeze
|
|
122
|
+
|
|
123
|
+
def html_tag?(method_name)
|
|
124
|
+
HTML_TAGS.include?(method_name)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def html_tag_name(method_name)
|
|
128
|
+
TAG_ALIASES.fetch(method_name, method_name)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module A11y
|
|
6
|
+
module Lint
|
|
7
|
+
# Represents a Ruby code string extracted from a template.
|
|
8
|
+
# Parses the code with Prism and exposes the resulting CallNode.
|
|
9
|
+
class RubyCode
|
|
10
|
+
def initialize(code)
|
|
11
|
+
@code = code
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Returns the Prism::CallNode for the outermost receiverless
|
|
15
|
+
# method call in the code, or nil if none exists.
|
|
16
|
+
def call_node
|
|
17
|
+
@call_node ||= parse
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader(:code)
|
|
23
|
+
|
|
24
|
+
# Parses the code string into a Prism AST and returns the first
|
|
25
|
+
# receiverless CallNode, or nil if the code is empty, invalid,
|
|
26
|
+
# or contains no method calls (e.g. a plain variable or literal).
|
|
27
|
+
def parse
|
|
28
|
+
return if code.nil? || code.empty?
|
|
29
|
+
return unless prism_parse_result.success?
|
|
30
|
+
|
|
31
|
+
prism_node = find_receiverless_call(prism_parse_result.value)
|
|
32
|
+
prism_node ? CallNode.new(prism_node) : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Walks the Prism AST to find the first method call without a
|
|
36
|
+
# receiver (e.g. `link_to(...)` rather than `obj.link_to(...)`).
|
|
37
|
+
# Rails helper calls in templates are always receiverless.
|
|
38
|
+
def find_receiverless_call(node)
|
|
39
|
+
return node if node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
40
|
+
|
|
41
|
+
node.child_nodes.compact.each do |child|
|
|
42
|
+
found = find_receiverless_call(child)
|
|
43
|
+
return found if found
|
|
44
|
+
end
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Prism.parse returns a Prism::ParseResult which contains the
|
|
49
|
+
# AST (via .value) and whether the parse succeeded (via .success?).
|
|
50
|
+
# The AST is always present since Prism does error-tolerant parsing.
|
|
51
|
+
def prism_parse_result
|
|
52
|
+
@prism_parse_result ||= Prism.parse(source)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Slim/ERB block forms end with ` do` (e.g. `link_to("#") do`)
|
|
56
|
+
# which isn't valid Ruby on its own. Appending `\nend` makes it
|
|
57
|
+
# parseable and gives the resulting CallNode a `.block` attribute.
|
|
58
|
+
def source
|
|
59
|
+
if code.match?(/\s+do\s*\z/)
|
|
60
|
+
"#{code}\nend"
|
|
61
|
+
else
|
|
62
|
+
code
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/a11y/lint/rule.rb
CHANGED
|
@@ -1,88 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "ripper"
|
|
4
|
-
|
|
5
3
|
module A11y
|
|
6
4
|
module Lint
|
|
7
5
|
module Rules
|
|
8
6
|
# Checks that image_tag calls include an alt option (WCAG 1.1.1).
|
|
9
7
|
class ImageTagMissingAlt < Rule
|
|
10
8
|
def check
|
|
11
|
-
return
|
|
9
|
+
return if no_offense?
|
|
12
10
|
|
|
13
11
|
"image_tag is missing an alt option (WCAG 1.1.1)"
|
|
14
12
|
end
|
|
15
13
|
|
|
16
14
|
private
|
|
17
15
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
return false unless code
|
|
21
|
-
|
|
22
|
-
sexp = Ripper.sexp(code)
|
|
23
|
-
return false unless sexp
|
|
24
|
-
|
|
25
|
-
call = extract_image_tag_call(sexp)
|
|
26
|
-
call && !alt_key_within?(call)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Walks the Ripper S-expression tree to find
|
|
30
|
-
# the image_tag call node, if present.
|
|
31
|
-
def extract_image_tag_call(sexp)
|
|
32
|
-
return unless sexp.is_a?(Array)
|
|
33
|
-
return sexp if image_tag_call?(sexp)
|
|
34
|
-
|
|
35
|
-
sexp.each do |child|
|
|
36
|
-
result = extract_image_tag_call(child)
|
|
37
|
-
return result if result
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
nil
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Matches both calling styles:
|
|
44
|
-
# image_tag "photo.jpg" => :command
|
|
45
|
-
# image_tag("photo.jpg") => :method_add_arg
|
|
46
|
-
def image_tag_call?(sexp)
|
|
47
|
-
case sexp
|
|
48
|
-
in [:command, [:@ident, "image_tag", *], *] then true
|
|
49
|
-
in [:method_add_arg, [:fcall, [:@ident, "image_tag", *]], *] then true
|
|
50
|
-
else false
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Recursively searches the sexp for an
|
|
55
|
-
# :assoc_new node whose key is "alt".
|
|
56
|
-
def alt_key_within?(sexp)
|
|
57
|
-
return true if alt_key?(sexp)
|
|
58
|
-
return false unless sexp.is_a?(Array)
|
|
59
|
-
|
|
60
|
-
sexp.any? { |child| alt_key_within?(child) }
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Checks if a sexp is a hash pair (assoc_new)
|
|
64
|
-
# with "alt" as the key.
|
|
65
|
-
def alt_key?(sexp)
|
|
66
|
-
return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
|
|
67
|
-
|
|
68
|
-
alt_key_value?(sexp[1])
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def alt_key_value?(key)
|
|
72
|
-
alt_symbol_key?(key) || alt_string_key?(key)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Matches symbol-style key: `alt: "..."`
|
|
76
|
-
def alt_symbol_key?(key)
|
|
77
|
-
key in [:@label, "alt:", *]
|
|
16
|
+
def no_offense?
|
|
17
|
+
!image_tag || image_tag.keyword?(:alt)
|
|
78
18
|
end
|
|
79
19
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
key in [
|
|
83
|
-
:string_literal,
|
|
84
|
-
[:string_content, [:@tstring_content, "alt", *]]
|
|
85
|
-
]
|
|
20
|
+
def image_tag
|
|
21
|
+
@image_tag ||= node.call_node&.find("image_tag")
|
|
86
22
|
end
|
|
87
23
|
end
|
|
88
24
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "ripper"
|
|
4
|
-
|
|
5
3
|
module A11y
|
|
6
4
|
module Lint
|
|
7
5
|
module Rules
|
|
@@ -11,120 +9,37 @@ module A11y
|
|
|
11
9
|
METHODS = %w[link_to external_link_to button_tag].freeze
|
|
12
10
|
|
|
13
11
|
def check
|
|
14
|
-
return
|
|
15
|
-
|
|
16
|
-
clean_code = code.sub(/\s+do\s*\z/, "")
|
|
17
|
-
is_block = clean_code != code
|
|
18
|
-
call = parse_call(clean_code)
|
|
19
|
-
return unless call
|
|
20
|
-
return if aria_label_within?(call)
|
|
21
|
-
return unless first_arg_empty_string?(call) || is_block
|
|
12
|
+
return if no_offense?
|
|
22
13
|
|
|
23
|
-
offense_message(
|
|
14
|
+
offense_message(helper_call.method_name)
|
|
24
15
|
end
|
|
25
16
|
|
|
26
17
|
private
|
|
27
18
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
19
|
+
def no_offense?
|
|
20
|
+
!helper_call ||
|
|
21
|
+
aria_label? ||
|
|
22
|
+
!(helper_call.first_positional_arg_empty_string? ||
|
|
23
|
+
(helper_call.block? && node.block_has_only_icon_helpers?))
|
|
33
24
|
end
|
|
34
25
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
extract_matching_call(sexp)
|
|
26
|
+
def aria_label?
|
|
27
|
+
helper_call.keyword?(:aria, :label) ||
|
|
28
|
+
helper_call.keyword?(:"aria-label")
|
|
40
29
|
end
|
|
41
30
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
sexp.each do |child|
|
|
47
|
-
result = extract_matching_call(child)
|
|
48
|
-
return result if result
|
|
31
|
+
def helper_call
|
|
32
|
+
@helper_call ||= begin
|
|
33
|
+
call = node.call_node
|
|
34
|
+
call if call && METHODS.include?(call.method_name)
|
|
49
35
|
end
|
|
50
|
-
|
|
51
|
-
nil
|
|
52
36
|
end
|
|
53
37
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def call_method_name(sexp)
|
|
60
|
-
case sexp
|
|
61
|
-
in [:command, [:@ident, name, *], *]
|
|
62
|
-
name
|
|
63
|
-
in [:method_add_arg,
|
|
64
|
-
[:fcall, [:@ident, name, *]], *]
|
|
65
|
-
name
|
|
66
|
-
else nil
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def extract_method_name(call)
|
|
71
|
-
call_method_name(call)
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def first_arg_empty_string?(call)
|
|
75
|
-
args = extract_args(call)
|
|
76
|
-
return false unless args&.first
|
|
77
|
-
|
|
78
|
-
args.first in [:string_literal, [:string_content]]
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def extract_args(call)
|
|
82
|
-
case call
|
|
83
|
-
in [:command, _, [:args_add_block, args, *]] then args
|
|
84
|
-
in [:method_add_arg, _,
|
|
85
|
-
[:arg_paren, [:args_add_block, args, *]]]
|
|
86
|
-
then args
|
|
87
|
-
in [:method_add_arg, _, [:arg_paren, Array => args]] then args
|
|
88
|
-
else nil
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def aria_label_within?(sexp)
|
|
93
|
-
return true if aria_hash_with_label?(sexp)
|
|
94
|
-
return true if aria_label_string_key?(sexp)
|
|
95
|
-
return false unless sexp.is_a?(Array)
|
|
96
|
-
|
|
97
|
-
sexp.any? { |child| aria_label_within?(child) }
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def aria_hash_with_label?(sexp)
|
|
101
|
-
return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
|
|
102
|
-
|
|
103
|
-
key = sexp[1]
|
|
104
|
-
value = sexp[2]
|
|
105
|
-
|
|
106
|
-
(key in [:@label, "aria:", *]) && label_key_within?(value)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def label_key_within?(sexp)
|
|
110
|
-
return true if label_key?(sexp)
|
|
111
|
-
return false unless sexp.is_a?(Array)
|
|
112
|
-
|
|
113
|
-
sexp.any? { |child| label_key_within?(child) }
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def label_key?(sexp)
|
|
117
|
-
sexp.is_a?(Array) &&
|
|
118
|
-
sexp[0] == :assoc_new &&
|
|
119
|
-
(sexp[1] in [:@label, "label:", *])
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def aria_label_string_key?(sexp)
|
|
123
|
-
return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
|
|
124
|
-
|
|
125
|
-
sexp[1] in [:string_literal,
|
|
126
|
-
[:string_content,
|
|
127
|
-
[:@tstring_content, "aria-label", *]]]
|
|
38
|
+
def offense_message(method_name)
|
|
39
|
+
<<~MSG.strip
|
|
40
|
+
#{method_name} missing an accessible name \
|
|
41
|
+
requires an aria-label (WCAG 4.1.2)
|
|
42
|
+
MSG
|
|
128
43
|
end
|
|
129
44
|
end
|
|
130
45
|
end
|
data/lib/a11y/lint/slim_node.rb
CHANGED
|
@@ -4,6 +4,8 @@ module A11y
|
|
|
4
4
|
module Lint
|
|
5
5
|
# Wraps a Slim AST s-expression as a queryable node for lint rules.
|
|
6
6
|
class SlimNode
|
|
7
|
+
include BlockInspection
|
|
8
|
+
|
|
7
9
|
attr_reader :line
|
|
8
10
|
|
|
9
11
|
def initialize(sexp, line:)
|
|
@@ -16,11 +18,17 @@ module A11y
|
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def ruby_code
|
|
19
|
-
return unless
|
|
21
|
+
return unless slim_output?
|
|
20
22
|
|
|
21
23
|
@sexp[3]
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
def call_node
|
|
27
|
+
return unless slim_output?
|
|
28
|
+
|
|
29
|
+
@call_node ||= parse_call_node
|
|
30
|
+
end
|
|
31
|
+
|
|
24
32
|
def attribute?(name)
|
|
25
33
|
attributes.key?(name)
|
|
26
34
|
end
|
|
@@ -40,12 +48,59 @@ module A11y
|
|
|
40
48
|
collect_children(body)
|
|
41
49
|
end
|
|
42
50
|
|
|
51
|
+
# Returns ruby_code strings from child :slim :output nodes
|
|
52
|
+
# inside a block body. Only meaningful for output nodes
|
|
53
|
+
# (e.g. `= button_tag(...) do`).
|
|
54
|
+
def block_body_codes
|
|
55
|
+
return unless slim_output?
|
|
56
|
+
|
|
57
|
+
collect_output_codes(@sexp[4])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns true when the block body contains visible text or
|
|
61
|
+
# HTML tag children (i.e. content that provides an accessible name).
|
|
62
|
+
def block_has_text_children?
|
|
63
|
+
return false unless slim_output?
|
|
64
|
+
|
|
65
|
+
text_content?(@sexp[4])
|
|
66
|
+
end
|
|
67
|
+
|
|
43
68
|
private
|
|
44
69
|
|
|
45
70
|
def html_tag?
|
|
46
71
|
@sexp[0] == :html && @sexp[1] == :tag
|
|
47
72
|
end
|
|
48
73
|
|
|
74
|
+
def slim_output?
|
|
75
|
+
@sexp[0] == :slim && @sexp[1] == :output
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def parse_call_node
|
|
79
|
+
RubyCode.new(@sexp[3]).call_node
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def collect_output_codes(sexp)
|
|
83
|
+
return [] unless sexp.is_a?(Array)
|
|
84
|
+
return [sexp[3]] if slim_output_sexp?(sexp)
|
|
85
|
+
|
|
86
|
+
sexp.flat_map { |child| collect_output_codes(child) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def slim_output_sexp?(sexp)
|
|
90
|
+
sexp.is_a?(Array) && sexp[0] == :slim && sexp[1] == :output
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def text_content?(sexp)
|
|
94
|
+
return false unless sexp.is_a?(Array)
|
|
95
|
+
return true if slim_text_sexp?(sexp) || html_tag_sexp?(sexp)
|
|
96
|
+
|
|
97
|
+
sexp.any? { |child| text_content?(child) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def slim_text_sexp?(sexp)
|
|
101
|
+
sexp[0] == :slim && sexp[1] == :text
|
|
102
|
+
end
|
|
103
|
+
|
|
49
104
|
def collect_children(sexp)
|
|
50
105
|
return [] unless sexp.is_a?(Array)
|
|
51
106
|
return [SlimNode.new(sexp, line: @line)] if html_tag_sexp?(sexp)
|
|
@@ -9,12 +9,15 @@ module A11y
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def run(source, filename:)
|
|
12
|
+
require "slim"
|
|
12
13
|
sexp = Slim::Parser.new.call(source)
|
|
13
14
|
@line = 1
|
|
14
15
|
@filename = filename
|
|
15
16
|
@offenses = []
|
|
16
17
|
walk(sexp)
|
|
17
18
|
@offenses
|
|
19
|
+
rescue LoadError
|
|
20
|
+
raise SlimLoadError
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
private
|
data/lib/a11y/lint/version.rb
CHANGED
data/lib/a11y/lint.rb
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "slim"
|
|
4
3
|
require "nokogiri"
|
|
5
|
-
|
|
4
|
+
require_relative "lint/errors"
|
|
6
5
|
require_relative "lint/version"
|
|
7
6
|
require_relative "lint/offense"
|
|
7
|
+
require_relative "lint/call_node"
|
|
8
|
+
require_relative "lint/ruby_code"
|
|
9
|
+
require_relative "lint/block_inspection"
|
|
8
10
|
require_relative "lint/slim_node"
|
|
9
11
|
require_relative "lint/erb_node"
|
|
12
|
+
require_relative "lint/phlex_tags"
|
|
10
13
|
require_relative "lint/phlex_node"
|
|
11
14
|
require_relative "lint/configuration"
|
|
12
15
|
require_relative "lint/rule"
|
|
@@ -17,9 +20,3 @@ require_relative "lint/rules/robust/missing_accessible_name"
|
|
|
17
20
|
require_relative "lint/slim_runner"
|
|
18
21
|
require_relative "lint/erb_runner"
|
|
19
22
|
require_relative "lint/phlex_runner"
|
|
20
|
-
|
|
21
|
-
module A11y
|
|
22
|
-
module Lint
|
|
23
|
-
class Error < StandardError; end
|
|
24
|
-
end
|
|
25
|
-
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.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdullah Hashim
|
|
@@ -23,20 +23,6 @@ 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'
|
|
40
26
|
- !ruby/object:Gem::Dependency
|
|
41
27
|
name: slim
|
|
42
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -44,7 +30,7 @@ dependencies:
|
|
|
44
30
|
- - ">="
|
|
45
31
|
- !ruby/object:Gem::Version
|
|
46
32
|
version: '4.0'
|
|
47
|
-
type: :
|
|
33
|
+
type: :development
|
|
48
34
|
prerelease: false
|
|
49
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
36
|
requirements:
|
|
@@ -69,13 +55,18 @@ files:
|
|
|
69
55
|
- Rakefile
|
|
70
56
|
- exe/a11y-lint
|
|
71
57
|
- lib/a11y/lint.rb
|
|
58
|
+
- lib/a11y/lint/block_inspection.rb
|
|
59
|
+
- lib/a11y/lint/call_node.rb
|
|
72
60
|
- lib/a11y/lint/cli.rb
|
|
73
61
|
- lib/a11y/lint/configuration.rb
|
|
74
62
|
- lib/a11y/lint/erb_node.rb
|
|
75
63
|
- lib/a11y/lint/erb_runner.rb
|
|
64
|
+
- lib/a11y/lint/errors.rb
|
|
76
65
|
- lib/a11y/lint/offense.rb
|
|
77
66
|
- lib/a11y/lint/phlex_node.rb
|
|
78
67
|
- lib/a11y/lint/phlex_runner.rb
|
|
68
|
+
- lib/a11y/lint/phlex_tags.rb
|
|
69
|
+
- lib/a11y/lint/ruby_code.rb
|
|
79
70
|
- lib/a11y/lint/rule.rb
|
|
80
71
|
- lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb
|
|
81
72
|
- lib/a11y/lint/rules/perceivable/img_missing_alt.rb
|
|
@@ -100,7 +91,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
100
91
|
requirements:
|
|
101
92
|
- - ">="
|
|
102
93
|
- !ruby/object:Gem::Version
|
|
103
|
-
version: 3.
|
|
94
|
+
version: 3.3.0
|
|
104
95
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
105
96
|
requirements:
|
|
106
97
|
- - ">="
|