a11y-lint 0.10.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 +4 -4
- data/.claude/rules/testing.md +1 -1
- data/CHANGELOG.md +28 -0
- data/CLAUDE.md +10 -0
- data/lib/a11y/lint/block_inspection.rb +16 -3
- data/lib/a11y/lint/call_node.rb +17 -0
- data/lib/a11y/lint/cli.rb +6 -16
- data/lib/a11y/lint/configuration.rb +14 -0
- data/lib/a11y/lint/erb_element_node.rb +104 -0
- data/lib/a11y/lint/erb_output_node.rb +47 -0
- data/lib/a11y/lint/erb_runner.rb +64 -11
- data/lib/a11y/lint/{rule.rb → node_rule.rb} +2 -2
- data/lib/a11y/lint/phlex_node.rb +41 -7
- data/lib/a11y/lint/phlex_runner.rb +55 -7
- data/lib/a11y/lint/phlex_tags.rb +1 -0
- data/lib/a11y/lint/rules/perceivable/area_missing_alt.rb +23 -0
- data/lib/a11y/lint/rules/perceivable/image_submit_tag_missing_alt.rb +27 -0
- data/lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb +1 -1
- data/lib/a11y/lint/rules/perceivable/img_missing_alt.rb +1 -1
- data/lib/a11y/lint/rules/perceivable/input_image_missing_alt.rb +26 -0
- data/lib/a11y/lint/rules/perceivable/input_missing_autocomplete.rb +37 -0
- data/lib/a11y/lint/rules/perceivable/list_invalid_children.rb +1 -1
- data/lib/a11y/lint/rules/robust/anchor_missing_accessible_name.rb +42 -0
- data/lib/a11y/lint/rules/robust/button_missing_accessible_name.rb +42 -0
- data/lib/a11y/lint/rules/robust/button_tag_missing_accessible_name.rb +45 -0
- data/lib/a11y/lint/rules/robust/{missing_accessible_name.rb → link_to_missing_accessible_name.rb} +4 -4
- data/lib/a11y/lint/slim_node.rb +89 -7
- data/lib/a11y/lint/slim_runner.rb +5 -4
- data/lib/a11y/lint/version.rb +1 -1
- data/lib/a11y/lint.rb +4 -6
- metadata +12 -4
- data/lib/a11y/lint/erb_node.rb +0 -70
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f62cdc346f6aa1f5c2cae674c365678a333479966aabd1b144e7901f90e7cdf
|
|
4
|
+
data.tar.gz: 874c7cb5bac0680cb58fb2f73e89304c21862686010f5b0e721aad19861c0bbb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5757d27c00656b68b5b4209a153da09e720d190bacb5124e1860a03a53d3e0df4d02909a2c1d667c5be477394228274a6e64c95b4a032d64cb14a65ba72f5bd7
|
|
7
|
+
data.tar.gz: bf502097c82848041313039b29271808133c1ff3be50b1d8e4b31b5d7df7bb1c35bb1ae643b869143355bbcd3dd2cfcddf98acd44ac858f4edb436e5699b3b92
|
data/.claude/rules/testing.md
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ 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
|
+
|
|
16
|
+
## [0.11.0] - 2026-04-21
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `AreaMissingAlt` rule: detects `<area>` tags without `alt` attributes (WCAG 1.1.1)
|
|
21
|
+
- `InputImageMissingAlt` rule: detects `<input type="image">` tags without `alt` attributes (WCAG 1.1.1)
|
|
22
|
+
- `ImageSubmitTagMissingAlt` rule: detects `image_submit_tag` calls without an `alt` option (WCAG 1.1.1)
|
|
23
|
+
- `InputMissingAutocomplete` rule: detects `<input>` elements without an `autocomplete` attribute (WCAG 1.3.5)
|
|
24
|
+
- `AnchorMissingAccessibleName` rule: detects `<a>` elements without text content, `aria-label`, or a child image with `alt` (WCAG 4.1.2)
|
|
25
|
+
- `ButtonMissingAccessibleName` rule: detects `<button>` elements without text content, `aria-label`, or a child image with `alt` (WCAG 4.1.2)
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **Breaking:** Split `MissingAccessibleName` into `LinkToMissingAccessibleName` (for `link_to`/`external_link_to`) and `ButtonTagMissingAccessibleName` (for `button_tag`) to follow one-rule-per-helper convention
|
|
30
|
+
- **Breaking:** Rename `A11y::Lint::Rule` to `A11y::Lint::NodeRule`. Custom rules that subclass the base class must update the parent class name
|
|
31
|
+
- Auto-require all rule files instead of listing them individually
|
|
32
|
+
- Node attribute storage now preserves string values, enabling rules to check attribute values (not just existence)
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- `LinkToMissingAccessibleName` / `ButtonTagMissingAccessibleName`: no longer flag `link_to`/`button_tag` blocks containing an `image_tag` with a non-empty `alt` option
|
|
37
|
+
|
|
10
38
|
## [0.10.0] - 2026-04-15
|
|
11
39
|
|
|
12
40
|
### Changed
|
data/CLAUDE.md
CHANGED
|
@@ -32,6 +32,16 @@ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::L
|
|
|
32
32
|
- **Tests:** `test/` directory using Minitest; test helper at `test/test_helper.rb`
|
|
33
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`)
|
|
34
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
|
+
|
|
35
45
|
## Code Style
|
|
36
46
|
|
|
37
47
|
RuboCop is configured in `.rubocop.yml`:
|
|
@@ -6,7 +6,7 @@ module A11y
|
|
|
6
6
|
# Depends on the host class implementing
|
|
7
7
|
# #block_has_text_children? and #block_body_codes.
|
|
8
8
|
module BlockInspection
|
|
9
|
-
ICON_HELPERS = %w[inline_svg icon
|
|
9
|
+
ICON_HELPERS = %w[inline_svg icon svg_icon].freeze
|
|
10
10
|
|
|
11
11
|
def block_has_only_icon_helpers?
|
|
12
12
|
return false if block_has_text_children?
|
|
@@ -14,15 +14,28 @@ module A11y
|
|
|
14
14
|
codes = block_body_codes
|
|
15
15
|
return true unless codes&.any?
|
|
16
16
|
|
|
17
|
-
codes.all?
|
|
17
|
+
codes.all? do |code|
|
|
18
|
+
icon_helper?(code) || decorative_image_tag?(code)
|
|
19
|
+
end
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
private
|
|
21
23
|
|
|
22
|
-
def
|
|
24
|
+
def icon_helper?(code)
|
|
23
25
|
call = RubyCode.new(code).call_node
|
|
24
26
|
call && ICON_HELPERS.include?(call.method_name)
|
|
25
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
|
|
26
39
|
end
|
|
27
40
|
end
|
|
28
41
|
end
|
data/lib/a11y/lint/call_node.rb
CHANGED
|
@@ -30,6 +30,23 @@ module A11y
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
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
|
+
|
|
33
50
|
def positional_args
|
|
34
51
|
return [] unless @prism_node.arguments
|
|
35
52
|
|
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
|
-
|
|
75
|
-
slim_runner = SlimRunner.new(
|
|
76
|
-
erb_runner = ErbRunner.new(
|
|
77
|
-
phlex_runner = PhlexRunner.new(
|
|
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
|
|
95
|
-
|
|
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 < Rule
|
|
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
|
|
@@ -0,0 +1,104 @@
|
|
|
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, :configuration
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
nokogiri_node:, line:, configuration: Configuration.new
|
|
12
|
+
)
|
|
13
|
+
@nokogiri_node = nokogiri_node
|
|
14
|
+
@line = line
|
|
15
|
+
@configuration = configuration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def tag_name
|
|
19
|
+
@nokogiri_node.name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def attribute?(name)
|
|
23
|
+
attributes.key?(name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def attributes
|
|
27
|
+
@attributes ||=
|
|
28
|
+
@nokogiri_node
|
|
29
|
+
.attributes
|
|
30
|
+
.transform_values(&:value)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call_node
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ruby_code
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def block_body_codes
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def block_has_text_children?
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def text_content?
|
|
50
|
+
return false if hidden_wrapper?(@nokogiri_node)
|
|
51
|
+
|
|
52
|
+
visible_text_or_output?(@nokogiri_node)
|
|
53
|
+
end
|
|
54
|
+
|
|
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.
|
|
59
|
+
def children
|
|
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?
|
|
86
|
+
end
|
|
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
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
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
|
data/lib/a11y/lint/erb_runner.rb
CHANGED
|
@@ -8,13 +8,15 @@ 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
|
|
14
15
|
].freeze
|
|
15
16
|
|
|
16
|
-
def initialize(rules)
|
|
17
|
-
@rules = rules
|
|
17
|
+
def initialize(rules = nil, configuration: Configuration.new)
|
|
18
|
+
@rules = rules || configuration.enabled_rules
|
|
19
|
+
@configuration = configuration
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def run(source, filename:)
|
|
@@ -29,22 +31,30 @@ module A11y
|
|
|
29
31
|
|
|
30
32
|
private
|
|
31
33
|
|
|
32
|
-
attr_reader :rules
|
|
34
|
+
attr_reader :rules, :configuration
|
|
33
35
|
|
|
34
36
|
def check_html_nodes(source)
|
|
35
|
-
html = source.gsub(
|
|
37
|
+
html = source.gsub(ERB_OUTPUT_TAG, ERB_OUTPUT_MARKER)
|
|
38
|
+
html = html.gsub(ERB_TAG, "")
|
|
36
39
|
doc = Nokogiri::HTML4::DocumentFragment.parse(html)
|
|
37
40
|
|
|
38
41
|
doc.traverse do |nokogiri_node|
|
|
39
42
|
next unless nokogiri_node.element?
|
|
40
43
|
next unless source_confirmed_element?(html, nokogiri_node.name)
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
+
node = build_erb_element_node(nokogiri_node)
|
|
46
|
+
check_node(node)
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
def build_erb_element_node(nokogiri_node)
|
|
51
|
+
ErbElementNode.new(
|
|
52
|
+
nokogiri_node: nokogiri_node,
|
|
53
|
+
line: nokogiri_node.line,
|
|
54
|
+
configuration: configuration
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
48
58
|
def source_confirmed_element?(html, tag_name)
|
|
49
59
|
VOID_ELEMENTS.include?(tag_name) || html.include?("</#{tag_name}>")
|
|
50
60
|
end
|
|
@@ -64,7 +74,7 @@ module A11y
|
|
|
64
74
|
block_body_codes, block_has_text =
|
|
65
75
|
extract_block_info(source, code, match_end)
|
|
66
76
|
|
|
67
|
-
|
|
77
|
+
ErbOutputNode.new(
|
|
68
78
|
ruby_code: code, line: line,
|
|
69
79
|
block_body_codes: block_body_codes,
|
|
70
80
|
block_has_text_children: block_has_text
|
|
@@ -79,10 +89,53 @@ module A11y
|
|
|
79
89
|
return [nil, false] unless end_match
|
|
80
90
|
|
|
81
91
|
block_content = rest[0...end_match.begin(0)]
|
|
82
|
-
|
|
83
|
-
|
|
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)
|
|
84
136
|
|
|
85
|
-
|
|
137
|
+
classes = configuration.hidden_wrapper_classes
|
|
138
|
+
value.split.any? { |klass| classes.include?(klass) }
|
|
86
139
|
end
|
|
87
140
|
|
|
88
141
|
def check_node(node)
|
data/lib/a11y/lint/phlex_node.rb
CHANGED
|
@@ -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
|
)
|
|
@@ -22,7 +23,9 @@ module A11y
|
|
|
22
23
|
line:, tag_name: nil, attributes: {},
|
|
23
24
|
call_node: nil, children: [],
|
|
24
25
|
block_body_codes: nil,
|
|
25
|
-
block_has_text_children: false
|
|
26
|
+
block_has_text_children: false,
|
|
27
|
+
text_content: false,
|
|
28
|
+
configuration: Configuration.new
|
|
26
29
|
)
|
|
27
30
|
@tag_name = tag_name
|
|
28
31
|
@attributes = attributes
|
|
@@ -31,6 +34,8 @@ module A11y
|
|
|
31
34
|
@children = children
|
|
32
35
|
@block_body_codes = block_body_codes
|
|
33
36
|
@block_has_text_children = block_has_text_children
|
|
37
|
+
@text_content = text_content
|
|
38
|
+
@configuration = configuration
|
|
34
39
|
end
|
|
35
40
|
# rubocop:enable Metrics/ParameterLists
|
|
36
41
|
|
|
@@ -46,35 +51,56 @@ module A11y
|
|
|
46
51
|
@block_has_text_children
|
|
47
52
|
end
|
|
48
53
|
|
|
49
|
-
def
|
|
54
|
+
def text_content?
|
|
55
|
+
@text_content
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.build_tag(
|
|
59
|
+
call_node, children: [], text_content: false,
|
|
60
|
+
configuration: Configuration.new
|
|
61
|
+
)
|
|
50
62
|
name = call_node.name.to_s
|
|
51
63
|
new(
|
|
52
64
|
tag_name: html_tag_name(name),
|
|
53
65
|
attributes: extract_attributes(call_node),
|
|
54
66
|
line: call_node.location.start_line,
|
|
55
|
-
children: children
|
|
67
|
+
children: children,
|
|
68
|
+
text_content: text_content,
|
|
69
|
+
configuration: configuration
|
|
56
70
|
)
|
|
57
71
|
end
|
|
58
72
|
|
|
59
73
|
def self.build_helper(
|
|
60
74
|
call_node,
|
|
61
75
|
block_body_codes: nil,
|
|
62
|
-
block_has_text_children: false
|
|
76
|
+
block_has_text_children: false,
|
|
77
|
+
configuration: Configuration.new
|
|
63
78
|
)
|
|
64
79
|
new(
|
|
65
80
|
call_node: CallNode.new(call_node),
|
|
66
81
|
line: call_node.location.start_line,
|
|
67
82
|
block_body_codes: block_body_codes,
|
|
68
|
-
block_has_text_children: block_has_text_children
|
|
83
|
+
block_has_text_children: block_has_text_children,
|
|
84
|
+
configuration: configuration
|
|
69
85
|
)
|
|
70
86
|
end
|
|
71
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
|
+
|
|
72
98
|
def self.extract_attributes(call_node)
|
|
73
99
|
return {} unless call_node.arguments
|
|
74
100
|
|
|
75
101
|
kwarg_nodes(call_node).each_with_object({}) do |elem, h|
|
|
76
102
|
key = kwarg_key(elem.key)
|
|
77
|
-
h[key] =
|
|
103
|
+
h[key] = kwarg_value(elem.value) if key
|
|
78
104
|
end
|
|
79
105
|
end
|
|
80
106
|
|
|
@@ -91,7 +117,15 @@ module A11y
|
|
|
91
117
|
end
|
|
92
118
|
end
|
|
93
119
|
|
|
94
|
-
|
|
120
|
+
def self.kwarg_value(value_node)
|
|
121
|
+
case value_node
|
|
122
|
+
when Prism::StringNode then value_node.unescaped
|
|
123
|
+
when Prism::SymbolNode then value_node.value
|
|
124
|
+
else true
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private_class_method :kwarg_key, :kwarg_nodes, :kwarg_value,
|
|
95
129
|
:extract_attributes
|
|
96
130
|
end
|
|
97
131
|
end
|
|
@@ -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)
|
|
@@ -46,7 +47,15 @@ module A11y
|
|
|
46
47
|
|
|
47
48
|
def check_tag(node)
|
|
48
49
|
children = collect_block_children(node.block)
|
|
49
|
-
|
|
50
|
+
has_text = tag_block_has_text?(node.block, children)
|
|
51
|
+
check_node(
|
|
52
|
+
PhlexNode.build_tag(
|
|
53
|
+
node,
|
|
54
|
+
children: children,
|
|
55
|
+
text_content: has_text,
|
|
56
|
+
configuration: configuration
|
|
57
|
+
)
|
|
58
|
+
)
|
|
50
59
|
end
|
|
51
60
|
|
|
52
61
|
def check_helper(node)
|
|
@@ -54,7 +63,8 @@ module A11y
|
|
|
54
63
|
helper = PhlexNode.build_helper(
|
|
55
64
|
node,
|
|
56
65
|
block_body_codes: codes,
|
|
57
|
-
block_has_text_children: has_text
|
|
66
|
+
block_has_text_children: has_text,
|
|
67
|
+
configuration: configuration
|
|
58
68
|
)
|
|
59
69
|
check_node(helper)
|
|
60
70
|
walk_block(node.block)
|
|
@@ -80,8 +90,39 @@ module A11y
|
|
|
80
90
|
|
|
81
91
|
def gather_tag_child(child, result)
|
|
82
92
|
kids = collect_block_children(child.block)
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
has_text = tag_block_has_text?(child.block, kids)
|
|
94
|
+
tag = PhlexNode.build_tag(
|
|
95
|
+
child, children: kids, text_content: has_text,
|
|
96
|
+
configuration: configuration
|
|
97
|
+
)
|
|
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)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def tag_block_has_text?(block, children)
|
|
116
|
+
return false unless block.is_a?(Prism::BlockNode)
|
|
117
|
+
|
|
118
|
+
scan_for_text(block) || children.any?(&:text_content?)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def scan_for_text(node)
|
|
122
|
+
node.child_nodes.compact.any? do |child|
|
|
123
|
+
text_call?(child) || child.is_a?(Prism::YieldNode) ||
|
|
124
|
+
(!receiverless_call?(child) && scan_for_text(child))
|
|
125
|
+
end
|
|
85
126
|
end
|
|
86
127
|
|
|
87
128
|
def analyze_helper_block(call_node)
|
|
@@ -94,8 +135,10 @@ module A11y
|
|
|
94
135
|
end
|
|
95
136
|
|
|
96
137
|
# rubocop:disable Metrics/CyclomaticComplexity
|
|
138
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
|
97
139
|
def scan_block_content(node, codes)
|
|
98
140
|
node.child_nodes.compact.each do |child|
|
|
141
|
+
next if tag_call?(child) && hidden_wrapper_tag?(child)
|
|
99
142
|
return true if child.is_a?(Prism::YieldNode)
|
|
100
143
|
return true if tag_call?(child) && child.block
|
|
101
144
|
next if tag_call?(child)
|
|
@@ -105,6 +148,7 @@ module A11y
|
|
|
105
148
|
false
|
|
106
149
|
end
|
|
107
150
|
# rubocop:enable Metrics/CyclomaticComplexity
|
|
151
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
|
108
152
|
|
|
109
153
|
def walk_block(block)
|
|
110
154
|
return unless block.is_a?(Prism::BlockNode)
|
|
@@ -116,6 +160,10 @@ module A11y
|
|
|
116
160
|
receiverless_call?(node) && PhlexNode.html_tag?(node.name.to_s)
|
|
117
161
|
end
|
|
118
162
|
|
|
163
|
+
def text_call?(node)
|
|
164
|
+
receiverless_call?(node) && node.name.to_s == "plain"
|
|
165
|
+
end
|
|
166
|
+
|
|
119
167
|
def receiverless_call?(node)
|
|
120
168
|
node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
121
169
|
end
|
data/lib/a11y/lint/phlex_tags.rb
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that area tags include an alt attribute (WCAG 1.1.1).
|
|
7
|
+
# https://www.w3.org/WAI/WCAG21/Techniques/html/H24
|
|
8
|
+
class AreaMissingAlt < NodeRule
|
|
9
|
+
def check
|
|
10
|
+
return unless an_area_without_an_alt_attribute?
|
|
11
|
+
|
|
12
|
+
"area tag is missing an alt attribute (WCAG 1.1.1)"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def an_area_without_an_alt_attribute?
|
|
18
|
+
node.tag_name == "area" && !node.attribute?("alt")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that image_submit_tag calls include an alt option (WCAG 1.1.1).
|
|
7
|
+
# https://www.w3.org/WAI/WCAG21/Techniques/html/H36
|
|
8
|
+
class ImageSubmitTagMissingAlt < NodeRule
|
|
9
|
+
def check
|
|
10
|
+
return if no_offense?
|
|
11
|
+
|
|
12
|
+
"image_submit_tag is missing an alt option (WCAG 1.1.1)"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def no_offense?
|
|
18
|
+
!image_submit_tag || image_submit_tag.keyword?(:alt)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def image_submit_tag
|
|
22
|
+
@image_submit_tag ||= node.call_node&.find("image_submit_tag")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that <input type="image"> tags include an alt
|
|
7
|
+
# attribute (WCAG 1.1.1).
|
|
8
|
+
# https://www.w3.org/WAI/WCAG21/Techniques/html/H36
|
|
9
|
+
class InputImageMissingAlt < NodeRule
|
|
10
|
+
def check
|
|
11
|
+
return unless an_input_image_without_an_alt_attribute?
|
|
12
|
+
|
|
13
|
+
"input type=\"image\" is missing an alt attribute (WCAG 1.1.1)"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def an_input_image_without_an_alt_attribute?
|
|
19
|
+
node.tag_name == "input" &&
|
|
20
|
+
node.attributes["type"] == "image" &&
|
|
21
|
+
!node.attribute?("alt")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that <input> elements include an autocomplete
|
|
7
|
+
# attribute (WCAG 1.3.5).
|
|
8
|
+
# https://www.w3.org/WAI/WCAG21/Understanding/identify-input-purpose.html
|
|
9
|
+
class InputMissingAutocomplete < NodeRule
|
|
10
|
+
EXCLUDED_TYPES = %w[
|
|
11
|
+
button
|
|
12
|
+
checkbox
|
|
13
|
+
file
|
|
14
|
+
hidden
|
|
15
|
+
image
|
|
16
|
+
radio
|
|
17
|
+
reset
|
|
18
|
+
submit
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def check
|
|
22
|
+
return unless an_input_missing_autocomplete?
|
|
23
|
+
|
|
24
|
+
"input is missing an autocomplete attribute (WCAG 1.3.5)"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def an_input_missing_autocomplete?
|
|
30
|
+
node.tag_name == "input" &&
|
|
31
|
+
!EXCLUDED_TYPES.include?(node.attributes["type"]) &&
|
|
32
|
+
!node.attribute?("autocomplete")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -5,7 +5,7 @@ module A11y
|
|
|
5
5
|
module Rules
|
|
6
6
|
# Checks that <ul> and <ol> only directly contain <li>, <script>,
|
|
7
7
|
# or <template> elements (WCAG 1.3.1).
|
|
8
|
-
class ListInvalidChildren <
|
|
8
|
+
class ListInvalidChildren < NodeRule
|
|
9
9
|
LIST_TAGS = %w[ul ol].freeze
|
|
10
10
|
ALLOWED_CHILDREN = %w[li script template].freeze
|
|
11
11
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that <a> elements with no text content or meaningful
|
|
7
|
+
# child elements include an aria-label (WCAG 4.1.2).
|
|
8
|
+
class AnchorMissingAccessibleName < NodeRule
|
|
9
|
+
def check
|
|
10
|
+
return if no_offense?
|
|
11
|
+
|
|
12
|
+
"a tag is missing an accessible name " \
|
|
13
|
+
"requires an aria-label (WCAG 4.1.2)"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def no_offense?
|
|
19
|
+
node.tag_name != "a" ||
|
|
20
|
+
aria_label? ||
|
|
21
|
+
node.text_content? ||
|
|
22
|
+
child_image_has_alt?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def aria_label?
|
|
26
|
+
node.attribute?("aria-label") || node.attribute?("aria_label")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def child_image_has_alt?
|
|
30
|
+
node.children.any? do |child|
|
|
31
|
+
child.tag_name == "img" && non_empty_alt?(child)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def non_empty_alt?(child)
|
|
36
|
+
alt = child.attributes["alt"]
|
|
37
|
+
alt.is_a?(String) && !alt.strip.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that <button> elements with no text content or meaningful
|
|
7
|
+
# child elements include an aria-label (WCAG 4.1.2).
|
|
8
|
+
class ButtonMissingAccessibleName < NodeRule
|
|
9
|
+
def check
|
|
10
|
+
return if no_offense?
|
|
11
|
+
|
|
12
|
+
"button tag is missing an accessible name " \
|
|
13
|
+
"requires an aria-label (WCAG 4.1.2)"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def no_offense?
|
|
19
|
+
node.tag_name != "button" ||
|
|
20
|
+
aria_label? ||
|
|
21
|
+
node.text_content? ||
|
|
22
|
+
child_image_has_alt?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def aria_label?
|
|
26
|
+
node.attribute?("aria-label") || node.attribute?("aria_label")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def child_image_has_alt?
|
|
30
|
+
node.children.any? do |child|
|
|
31
|
+
child.tag_name == "img" && non_empty_alt?(child)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def non_empty_alt?(child)
|
|
36
|
+
alt = child.attributes["alt"]
|
|
37
|
+
alt.is_a?(String) && !alt.strip.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
module Rules
|
|
6
|
+
# Checks that button_tag calls with empty text or block content
|
|
7
|
+
# include an aria-label (WCAG 4.1.2).
|
|
8
|
+
class ButtonTagMissingAccessibleName < NodeRule
|
|
9
|
+
def check
|
|
10
|
+
return if no_offense?
|
|
11
|
+
|
|
12
|
+
offense_message
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def no_offense?
|
|
18
|
+
!helper_call ||
|
|
19
|
+
aria_label? ||
|
|
20
|
+
!(helper_call.first_positional_arg_empty_string? ||
|
|
21
|
+
(helper_call.block? && node.block_has_only_icon_helpers?))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def aria_label?
|
|
25
|
+
helper_call.keyword?(:aria, :label) ||
|
|
26
|
+
helper_call.keyword?(:"aria-label")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def helper_call
|
|
30
|
+
@helper_call ||= begin
|
|
31
|
+
call = node.call_node
|
|
32
|
+
call if call && call.method_name == "button_tag"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def offense_message
|
|
37
|
+
<<~MSG.strip
|
|
38
|
+
button_tag missing an accessible name \
|
|
39
|
+
requires an aria-label (WCAG 4.1.2)
|
|
40
|
+
MSG
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/a11y/lint/rules/robust/{missing_accessible_name.rb → link_to_missing_accessible_name.rb}
RENAMED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module A11y
|
|
4
4
|
module Lint
|
|
5
5
|
module Rules
|
|
6
|
-
# Checks that link_to
|
|
7
|
-
#
|
|
8
|
-
class
|
|
9
|
-
METHODS = %w[link_to external_link_to
|
|
6
|
+
# Checks that link_to and external_link_to calls with empty text or
|
|
7
|
+
# block content include an aria-label (WCAG 4.1.2).
|
|
8
|
+
class LinkToMissingAccessibleName < NodeRule
|
|
9
|
+
METHODS = %w[link_to external_link_to].freeze
|
|
10
10
|
|
|
11
11
|
def check
|
|
12
12
|
return if no_offense?
|
data/lib/a11y/lint/slim_node.rb
CHANGED
|
@@ -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
|
|
@@ -62,7 +63,16 @@ module A11y
|
|
|
62
63
|
def block_has_text_children?
|
|
63
64
|
return false unless slim_output?
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
block_text_content?(@sexp[4])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns true when the HTML element body contains visible text
|
|
70
|
+
# or dynamic output (i.e. content that could provide an accessible
|
|
71
|
+
# name). Only meaningful for HTML tag nodes.
|
|
72
|
+
def text_content?
|
|
73
|
+
return false unless html_tag?
|
|
74
|
+
|
|
75
|
+
text_or_output?(@sexp[4])
|
|
66
76
|
end
|
|
67
77
|
|
|
68
78
|
private
|
|
@@ -81,6 +91,7 @@ module A11y
|
|
|
81
91
|
|
|
82
92
|
def collect_output_codes(sexp)
|
|
83
93
|
return [] unless sexp.is_a?(Array)
|
|
94
|
+
return [] if hidden_wrapper_sexp?(sexp)
|
|
84
95
|
return [sexp[3]] if slim_output_sexp?(sexp)
|
|
85
96
|
|
|
86
97
|
sexp.flat_map { |child| collect_output_codes(child) }
|
|
@@ -90,11 +101,66 @@ module A11y
|
|
|
90
101
|
sexp.is_a?(Array) && sexp[0] == :slim && sexp[1] == :output
|
|
91
102
|
end
|
|
92
103
|
|
|
93
|
-
def
|
|
104
|
+
def block_text_content?(sexp)
|
|
94
105
|
return false unless sexp.is_a?(Array)
|
|
106
|
+
return false if hidden_wrapper_sexp?(sexp)
|
|
95
107
|
return true if slim_text_sexp?(sexp) || html_tag_sexp?(sexp)
|
|
96
108
|
|
|
97
|
-
sexp.any? { |child|
|
|
109
|
+
sexp.any? { |child| block_text_content?(child) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def text_or_output?(sexp)
|
|
113
|
+
return false unless sexp.is_a?(Array)
|
|
114
|
+
return false if hidden_wrapper_sexp?(sexp)
|
|
115
|
+
return true if slim_text_sexp?(sexp) || slim_output_sexp?(sexp)
|
|
116
|
+
|
|
117
|
+
sexp.any? { |child| text_or_output?(child) }
|
|
118
|
+
end
|
|
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)
|
|
98
164
|
end
|
|
99
165
|
|
|
100
166
|
def slim_text_sexp?(sexp)
|
|
@@ -103,7 +169,11 @@ module A11y
|
|
|
103
169
|
|
|
104
170
|
def collect_children(sexp)
|
|
105
171
|
return [] unless sexp.is_a?(Array)
|
|
106
|
-
|
|
172
|
+
|
|
173
|
+
if html_tag_sexp?(sexp)
|
|
174
|
+
return [SlimNode.new(sexp, line: @line, configuration: configuration)]
|
|
175
|
+
end
|
|
176
|
+
|
|
107
177
|
return collect_children(sexp[3]) if slim_control_sexp?(sexp)
|
|
108
178
|
return [] unless sexp[0] == :multi
|
|
109
179
|
|
|
@@ -122,7 +192,19 @@ module A11y
|
|
|
122
192
|
return {} unless html_attributes?
|
|
123
193
|
|
|
124
194
|
sexp_attributes[2..].each_with_object({}) do |attr_sexp, result|
|
|
125
|
-
|
|
195
|
+
next unless html_attribute?(attr_sexp)
|
|
196
|
+
|
|
197
|
+
result[attr_sexp[2]] = static_value(attr_sexp[3])
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def static_value(value_sexp)
|
|
202
|
+
if value_sexp.is_a?(Array) && value_sexp[0] == :escape &&
|
|
203
|
+
value_sexp[2].is_a?(Array) &&
|
|
204
|
+
value_sexp[2][0] == :slim && value_sexp[2][1] == :interpolate
|
|
205
|
+
value_sexp[2][2]
|
|
206
|
+
else
|
|
207
|
+
true
|
|
126
208
|
end
|
|
127
209
|
end
|
|
128
210
|
|
|
@@ -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) }
|
data/lib/a11y/lint/version.rb
CHANGED
data/lib/a11y/lint.rb
CHANGED
|
@@ -8,15 +8,13 @@ require_relative "lint/call_node"
|
|
|
8
8
|
require_relative "lint/ruby_code"
|
|
9
9
|
require_relative "lint/block_inspection"
|
|
10
10
|
require_relative "lint/slim_node"
|
|
11
|
-
require_relative "lint/
|
|
11
|
+
require_relative "lint/erb_element_node"
|
|
12
|
+
require_relative "lint/erb_output_node"
|
|
12
13
|
require_relative "lint/phlex_tags"
|
|
13
14
|
require_relative "lint/phlex_node"
|
|
14
15
|
require_relative "lint/configuration"
|
|
15
|
-
require_relative "lint/
|
|
16
|
-
|
|
17
|
-
require_relative "lint/rules/perceivable/img_missing_alt"
|
|
18
|
-
require_relative "lint/rules/perceivable/list_invalid_children"
|
|
19
|
-
require_relative "lint/rules/robust/missing_accessible_name"
|
|
16
|
+
require_relative "lint/node_rule"
|
|
17
|
+
Dir[File.join(__dir__, "lint", "rules", "**", "*.rb")].each { |f| require f }
|
|
20
18
|
require_relative "lint/slim_runner"
|
|
21
19
|
require_relative "lint/erb_runner"
|
|
22
20
|
require_relative "lint/phlex_runner"
|
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.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdullah Hashim
|
|
@@ -59,19 +59,27 @@ files:
|
|
|
59
59
|
- lib/a11y/lint/call_node.rb
|
|
60
60
|
- lib/a11y/lint/cli.rb
|
|
61
61
|
- lib/a11y/lint/configuration.rb
|
|
62
|
-
- lib/a11y/lint/
|
|
62
|
+
- lib/a11y/lint/erb_element_node.rb
|
|
63
|
+
- lib/a11y/lint/erb_output_node.rb
|
|
63
64
|
- lib/a11y/lint/erb_runner.rb
|
|
64
65
|
- lib/a11y/lint/errors.rb
|
|
66
|
+
- lib/a11y/lint/node_rule.rb
|
|
65
67
|
- lib/a11y/lint/offense.rb
|
|
66
68
|
- lib/a11y/lint/phlex_node.rb
|
|
67
69
|
- lib/a11y/lint/phlex_runner.rb
|
|
68
70
|
- lib/a11y/lint/phlex_tags.rb
|
|
69
71
|
- lib/a11y/lint/ruby_code.rb
|
|
70
|
-
- lib/a11y/lint/
|
|
72
|
+
- lib/a11y/lint/rules/perceivable/area_missing_alt.rb
|
|
73
|
+
- lib/a11y/lint/rules/perceivable/image_submit_tag_missing_alt.rb
|
|
71
74
|
- lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb
|
|
72
75
|
- lib/a11y/lint/rules/perceivable/img_missing_alt.rb
|
|
76
|
+
- lib/a11y/lint/rules/perceivable/input_image_missing_alt.rb
|
|
77
|
+
- lib/a11y/lint/rules/perceivable/input_missing_autocomplete.rb
|
|
73
78
|
- lib/a11y/lint/rules/perceivable/list_invalid_children.rb
|
|
74
|
-
- lib/a11y/lint/rules/robust/
|
|
79
|
+
- lib/a11y/lint/rules/robust/anchor_missing_accessible_name.rb
|
|
80
|
+
- lib/a11y/lint/rules/robust/button_missing_accessible_name.rb
|
|
81
|
+
- lib/a11y/lint/rules/robust/button_tag_missing_accessible_name.rb
|
|
82
|
+
- lib/a11y/lint/rules/robust/link_to_missing_accessible_name.rb
|
|
75
83
|
- lib/a11y/lint/slim_node.rb
|
|
76
84
|
- lib/a11y/lint/slim_runner.rb
|
|
77
85
|
- lib/a11y/lint/version.rb
|
data/lib/a11y/lint/erb_node.rb
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module A11y
|
|
4
|
-
module Lint
|
|
5
|
-
# Wraps a Nokogiri node or extracted ERB output tag
|
|
6
|
-
# as a queryable node for lint rules.
|
|
7
|
-
class ErbNode
|
|
8
|
-
include BlockInspection
|
|
9
|
-
|
|
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
|
-
)
|
|
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
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def tag_name
|
|
27
|
-
nokogiri_node&.name
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def attribute?(name)
|
|
31
|
-
attributes.key?(name)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def attributes
|
|
35
|
-
@attributes ||= extract_attributes
|
|
36
|
-
end
|
|
37
|
-
|
|
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
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Returns direct element children wrapped as ErbNode objects.
|
|
47
|
-
def children
|
|
48
|
-
return [] unless nokogiri_node
|
|
49
|
-
|
|
50
|
-
nokogiri_node.element_children.map do |child|
|
|
51
|
-
ErbNode.new(nokogiri_node: child, line: child.line)
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
attr_reader(:nokogiri_node)
|
|
58
|
-
|
|
59
|
-
def extract_attributes
|
|
60
|
-
return {} unless nokogiri_node
|
|
61
|
-
|
|
62
|
-
nokogiri_node
|
|
63
|
-
.attributes
|
|
64
|
-
.each_with_object({}) do |(name, _attr), result|
|
|
65
|
-
result[name] = true
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|