a11y-lint 0.8.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/rules/testing.md +1 -1
- data/.rubocop.yml +7 -1
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +14 -3
- data/lib/a11y/lint/block_inspection.rb +41 -0
- data/lib/a11y/lint/call_node.rb +125 -0
- data/lib/a11y/lint/cli.rb +2 -3
- data/lib/a11y/lint/erb_element_node.rb +59 -0
- data/lib/a11y/lint/erb_output_node.rb +47 -0
- data/lib/a11y/lint/erb_runner.rb +42 -7
- data/lib/a11y/lint/errors.rb +17 -0
- data/lib/a11y/lint/{rule.rb → node_rule.rb} +6 -2
- data/lib/a11y/lint/phlex_node.rb +51 -60
- data/lib/a11y/lint/phlex_runner.rb +61 -16
- data/lib/a11y/lint/phlex_tags.rb +133 -0
- data/lib/a11y/lint/ruby_code.rb +67 -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 +6 -70
- 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/link_to_missing_accessible_name.rb +47 -0
- data/lib/a11y/lint/slim_node.rb +85 -2
- data/lib/a11y/lint/slim_runner.rb +3 -0
- data/lib/a11y/lint/version.rb +1 -1
- data/lib/a11y/lint.rb +9 -14
- metadata +19 -20
- data/lib/a11y/lint/erb_node.rb +0 -54
- data/lib/a11y/lint/rules/robust/missing_accessible_name.rb +0 -132
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e1c92e880f51310dfe123ffdbc872bc661a781371365c53dd1a1972e302dcc1d
|
|
4
|
+
data.tar.gz: dfb7186f222b4c87ffdc818e843907435eebda8e99cccdc945cf1f4d8b77708f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0fde7a03bb0e20294c451a57da6aa487fd7aa6cf6b3bde0d41e6b7a323ededf15c626e6b06e7dd55f37b109a0371cc24b5708fd5528f959bb96a6269642152f8
|
|
7
|
+
data.tar.gz: b43f87086fdabf16d139639d169be80cadea3da8c61b09e7a92cc66f4c5d59011dce8b7433307877b652b4b85c2d4cb923c6ed9a95695094b035cbcdbe2d6d0a
|
data/.claude/rules/testing.md
CHANGED
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,54 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.11.0] - 2026-04-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `AreaMissingAlt` rule: detects `<area>` tags without `alt` attributes (WCAG 1.1.1)
|
|
15
|
+
- `InputImageMissingAlt` rule: detects `<input type="image">` tags without `alt` attributes (WCAG 1.1.1)
|
|
16
|
+
- `ImageSubmitTagMissingAlt` rule: detects `image_submit_tag` calls without an `alt` option (WCAG 1.1.1)
|
|
17
|
+
- `InputMissingAutocomplete` rule: detects `<input>` elements without an `autocomplete` attribute (WCAG 1.3.5)
|
|
18
|
+
- `AnchorMissingAccessibleName` rule: detects `<a>` elements without text content, `aria-label`, or a child image with `alt` (WCAG 4.1.2)
|
|
19
|
+
- `ButtonMissingAccessibleName` rule: detects `<button>` elements without text content, `aria-label`, or a child image with `alt` (WCAG 4.1.2)
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **Breaking:** Split `MissingAccessibleName` into `LinkToMissingAccessibleName` (for `link_to`/`external_link_to`) and `ButtonTagMissingAccessibleName` (for `button_tag`) to follow one-rule-per-helper convention
|
|
24
|
+
- **Breaking:** Rename `A11y::Lint::Rule` to `A11y::Lint::NodeRule`. Custom rules that subclass the base class must update the parent class name
|
|
25
|
+
- Auto-require all rule files instead of listing them individually
|
|
26
|
+
- Node attribute storage now preserves string values, enabling rules to check attribute values (not just existence)
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `LinkToMissingAccessibleName` / `ButtonTagMissingAccessibleName`: no longer flag `link_to`/`button_tag` blocks containing an `image_tag` with a non-empty `alt` option
|
|
31
|
+
|
|
32
|
+
## [0.10.0] - 2026-04-15
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Introduce `A11y::Lint::CallNode` wrapper so rules express accessibility logic, not AST traversal
|
|
37
|
+
- Expose Prism `CallNode` from `SlimNode` and `ErbNode` via the new `RubyCode` parser
|
|
38
|
+
- Expose Prism `CallNode` from `PhlexNode` instead of converting to string
|
|
39
|
+
- Extract Phlex HTML tag constants into `PhlexTags` module
|
|
40
|
+
|
|
41
|
+
## [0.9.0] - 2026-04-15
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **Breaking:** Require Ruby >= 3.3.0 (was 3.1.0). Prism ships with Ruby 3.3+ as a bundled gem
|
|
46
|
+
- **Breaking:** `slim` is now an optional dependency. Add `gem "slim"` to your Gemfile if you lint `.slim` files. A `SlimLoadError` is raised with a helpful message if the gem is missing
|
|
47
|
+
- `prism` is no longer a declared dependency (bundled with Ruby 3.3+)
|
|
48
|
+
- Replace Ripper with Prism for Ruby code parsing in `ImageTagMissingAlt` and `MissingAccessibleName` rules
|
|
49
|
+
- Custom error classes are now defined in `lib/a11y/lint/errors.rb`
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- `MissingAccessibleName`: fix false positive when block contains visible text or HTML tags (e.g. `link_to do` with a `span` or plain text inside)
|
|
54
|
+
- Add `block_body_codes` and `block_has_text_children?` to all three node types (Slim, ERB, Phlex) so block content inspection works across all pipelines
|
|
55
|
+
|
|
8
56
|
## [0.8.0] - 2026-04-14
|
|
9
57
|
|
|
10
58
|
### Added
|
data/CLAUDE.md
CHANGED
|
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
4
4
|
|
|
5
5
|
## Project Overview
|
|
6
6
|
|
|
7
|
-
a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::Lint` module namespace. Requires Ruby >= 3.
|
|
7
|
+
a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::Lint` module namespace. Requires Ruby >= 3.3.0.
|
|
8
8
|
|
|
9
9
|
## Commands
|
|
10
10
|
|
|
@@ -24,15 +24,26 @@ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::L
|
|
|
24
24
|
- **Slim pipeline:** `SlimRunner` parses Slim templates via `Slim::Parser`; `SlimNode` wraps Slim S-expressions
|
|
25
25
|
- **ERB pipeline:** `ErbRunner` parses ERB templates via Nokogiri; `ErbNode` wraps Nokogiri nodes and extracted `<%= %>` Ruby code
|
|
26
26
|
- **Phlex pipeline:** `PhlexRunner` parses Phlex view classes via Prism; `PhlexNode` wraps Prism AST call nodes; detects Phlex files by the presence of `def view_template`
|
|
27
|
+
- **Ruby code parsing:** `RubyCode` (`lib/a11y/lint/ruby_code.rb`) — parses a Ruby code string once with Prism and exposes the resulting `CallNode`; used by `SlimNode` and `ErbNode` so rules work against a parsed AST instead of re-parsing strings
|
|
27
28
|
- **Configuration:** `lib/a11y/lint/configuration.rb` — loads `.a11y-lint.yml` to enable/disable individual rules; searches upward from the target path
|
|
28
|
-
- **Rules:** `lib/a11y/lint/rules/` — organized by WCAG principle (`perceivable/`, `operable/`, `understandable/`, `robust/`); rules implement `check(node)` against the shared node interface (`tag_name`, `attribute?`, `attributes`, `
|
|
29
|
+
- **Rules:** `lib/a11y/lint/rules/` — organized by WCAG principle (`perceivable/`, `operable/`, `understandable/`, `robust/`); rules implement `check(node)` against the shared node interface (`tag_name`, `attribute?`, `attributes`, `call_node`, `line`)
|
|
29
30
|
- **Version:** `lib/a11y/lint/version.rb`
|
|
30
31
|
- **Type signatures (RBS):** `sig/a11y/lint.rbs`
|
|
31
32
|
- **Tests:** `test/` directory using Minitest; test helper at `test/test_helper.rb`
|
|
32
33
|
- **Dummy app:** `test/fixtures/dummy_app/` — a fixture app with Slim/ERB/Phlex templates for end-to-end smoke testing before releases (`bundle exec a11y-lint test/fixtures/dummy_app`)
|
|
33
34
|
|
|
35
|
+
## Rule Scoping Convention
|
|
36
|
+
|
|
37
|
+
Each rule targets **one element or helper**, unless multiple elements share identical check logic (e.g., `<ul>` and `<ol>` in `ListInvalidChildren`).
|
|
38
|
+
|
|
39
|
+
- **HTML element rules** are named after the element: `ImgMissingAlt`, `AreaMissingAlt`, `AnchorMissingAccessibleName`
|
|
40
|
+
- **Rails helper rules** are named after the helper: `ImageTagMissingAlt`, `LinkToMissingAccessibleName`
|
|
41
|
+
- HTML/helper pairs mirror each other (e.g., `ImgMissingAlt` / `ImageTagMissingAlt`)
|
|
42
|
+
|
|
43
|
+
Do not bundle unrelated elements or helpers into a single rule just because they share a WCAG criterion.
|
|
44
|
+
|
|
34
45
|
## Code Style
|
|
35
46
|
|
|
36
47
|
RuboCop is configured in `.rubocop.yml`:
|
|
37
|
-
- Target Ruby version: 3.
|
|
48
|
+
- Target Ruby version: 3.3
|
|
38
49
|
- Enforces double quotes for strings
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
# Shared block-content queries for node types.
|
|
6
|
+
# Depends on the host class implementing
|
|
7
|
+
# #block_has_text_children? and #block_body_codes.
|
|
8
|
+
module BlockInspection
|
|
9
|
+
ICON_HELPERS = %w[inline_svg icon svg_icon].freeze
|
|
10
|
+
|
|
11
|
+
def block_has_only_icon_helpers?
|
|
12
|
+
return false if block_has_text_children?
|
|
13
|
+
|
|
14
|
+
codes = block_body_codes
|
|
15
|
+
return true unless codes&.any?
|
|
16
|
+
|
|
17
|
+
codes.all? do |code|
|
|
18
|
+
icon_helper?(code) || decorative_image_tag?(code)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def icon_helper?(code)
|
|
25
|
+
call = RubyCode.new(code).call_node
|
|
26
|
+
call && ICON_HELPERS.include?(call.method_name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# image_tag counts as decorative only when it lacks a non-empty
|
|
30
|
+
# alt. A non-empty alt provides the accessible name, matching how
|
|
31
|
+
# <a><img alt="Home"></a> is treated by
|
|
32
|
+
# AnchorMissingAccessibleName#child_image_has_alt?.
|
|
33
|
+
def decorative_image_tag?(code)
|
|
34
|
+
call = RubyCode.new(code).call_node
|
|
35
|
+
call &&
|
|
36
|
+
call.method_name == "image_tag" &&
|
|
37
|
+
!call.keyword_non_empty?(:alt)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module A11y
|
|
6
|
+
module Lint
|
|
7
|
+
# Wraps a Prism::CallNode with a rule-friendly query API.
|
|
8
|
+
class CallNode
|
|
9
|
+
attr_reader :prism_node
|
|
10
|
+
|
|
11
|
+
def initialize(prism_node)
|
|
12
|
+
@prism_node = prism_node
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def method_name
|
|
16
|
+
@prism_node.name.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Checks for a keyword argument by name.
|
|
20
|
+
# keyword?(:alt) => alt: or "alt" =>
|
|
21
|
+
# keyword?(:aria, :label) => aria: { label: ... }
|
|
22
|
+
# keyword?(:"aria-label") => "aria-label" =>
|
|
23
|
+
def keyword?(*keys)
|
|
24
|
+
return false unless (kw_hash = find_keyword_hash)
|
|
25
|
+
|
|
26
|
+
if keys.length == 1
|
|
27
|
+
flat_keyword?(kw_hash, keys[0])
|
|
28
|
+
else
|
|
29
|
+
nested_keyword?(kw_hash, keys[0], keys[1])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# True when the keyword is present AND its value is a non-empty
|
|
34
|
+
# string literal OR any non-string expression (dynamic — can't be
|
|
35
|
+
# statically proven empty, so treat as providing content).
|
|
36
|
+
# False when the key is absent or the value is an empty string
|
|
37
|
+
# literal.
|
|
38
|
+
def keyword_non_empty?(key)
|
|
39
|
+
return false unless (kw_hash = find_keyword_hash)
|
|
40
|
+
|
|
41
|
+
assoc = kw_hash.elements.find { |a| key_name(a) == key.to_s }
|
|
42
|
+
return false unless assoc
|
|
43
|
+
|
|
44
|
+
value = assoc.value
|
|
45
|
+
return !value.unescaped.empty? if value.is_a?(Prism::StringNode)
|
|
46
|
+
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def positional_args
|
|
51
|
+
return [] unless @prism_node.arguments
|
|
52
|
+
|
|
53
|
+
@prism_node.arguments.arguments.reject do |a|
|
|
54
|
+
a.is_a?(Prism::KeywordHashNode)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def first_positional_arg_empty_string?
|
|
59
|
+
first = positional_args.first
|
|
60
|
+
first.is_a?(Prism::StringNode) && first.unescaped.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def block?
|
|
64
|
+
!@prism_node.block.nil?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Finds a receiverless call by method name in this node's
|
|
68
|
+
# subtree (including self). Returns a CallNode or nil.
|
|
69
|
+
def find(name)
|
|
70
|
+
found = search_for_call(@prism_node, name)
|
|
71
|
+
found ? self.class.new(found) : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def flat_keyword?(kw_hash, key)
|
|
77
|
+
kw_hash.elements.any? do |assoc|
|
|
78
|
+
key_name(assoc) == key.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def nested_keyword?(kw_hash, outer_key, inner_key)
|
|
83
|
+
assoc = kw_hash.elements.find do |a|
|
|
84
|
+
key_name(a) == outer_key.to_s
|
|
85
|
+
end
|
|
86
|
+
return false unless assoc&.value.is_a?(Prism::HashNode)
|
|
87
|
+
|
|
88
|
+
assoc.value.elements.any? do |inner|
|
|
89
|
+
key_name(inner) == inner_key.to_s
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def find_keyword_hash
|
|
94
|
+
return unless @prism_node.arguments
|
|
95
|
+
|
|
96
|
+
@prism_node.arguments.arguments.find do |arg|
|
|
97
|
+
arg.is_a?(Prism::KeywordHashNode)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def key_name(assoc)
|
|
102
|
+
return unless assoc.is_a?(Prism::AssocNode)
|
|
103
|
+
|
|
104
|
+
case assoc.key
|
|
105
|
+
when Prism::SymbolNode then assoc.key.unescaped
|
|
106
|
+
when Prism::StringNode then assoc.key.unescaped
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def search_for_call(node, name)
|
|
111
|
+
if node.is_a?(Prism::CallNode) &&
|
|
112
|
+
node.receiver.nil? &&
|
|
113
|
+
node.name.to_s == name
|
|
114
|
+
return node
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
node.child_nodes.compact.each do |child|
|
|
118
|
+
found = search_for_call(child, name)
|
|
119
|
+
return found if found
|
|
120
|
+
end
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
data/lib/a11y/lint/cli.rb
CHANGED
|
@@ -4,8 +4,7 @@ require "optparse"
|
|
|
4
4
|
|
|
5
5
|
module A11y
|
|
6
6
|
module Lint
|
|
7
|
-
# Command-line interface for running accessibility
|
|
8
|
-
# linting on Slim templates.
|
|
7
|
+
# Command-line interface for running accessibility linting.
|
|
9
8
|
class CLI
|
|
10
9
|
def initialize(argv, stdout: $stdout, stderr: $stderr)
|
|
11
10
|
@argv = argv
|
|
@@ -100,7 +99,7 @@ module A11y
|
|
|
100
99
|
|
|
101
100
|
Rules.constants.filter_map do |name|
|
|
102
101
|
klass = Rules.const_get(name)
|
|
103
|
-
next unless klass.is_a?(Class) && klass <
|
|
102
|
+
next unless klass.is_a?(Class) && klass < NodeRule
|
|
104
103
|
|
|
105
104
|
klass if configuration.enabled?(klass.rule_name)
|
|
106
105
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
# Wraps a Nokogiri HTML element from an ERB template
|
|
6
|
+
# as a queryable node for lint rules.
|
|
7
|
+
class ErbElementNode
|
|
8
|
+
attr_reader :line
|
|
9
|
+
|
|
10
|
+
def initialize(nokogiri_node:, line:, has_erb_output: false)
|
|
11
|
+
@nokogiri_node = nokogiri_node
|
|
12
|
+
@line = line
|
|
13
|
+
@has_erb_output = has_erb_output
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def tag_name
|
|
17
|
+
@nokogiri_node.name
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def attribute?(name)
|
|
21
|
+
attributes.key?(name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def attributes
|
|
25
|
+
@attributes ||=
|
|
26
|
+
@nokogiri_node
|
|
27
|
+
.attributes
|
|
28
|
+
.transform_values(&:value)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call_node
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ruby_code
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def block_body_codes
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def block_has_text_children?
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def text_content?
|
|
48
|
+
@has_erb_output || !@nokogiri_node.text.strip.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns direct element children wrapped as ErbElementNode objects.
|
|
52
|
+
def children
|
|
53
|
+
@nokogiri_node.element_children.map do |child|
|
|
54
|
+
ErbElementNode.new(nokogiri_node: child, line: child.line)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
# Wraps an extracted ERB output tag (<%= ... %>)
|
|
6
|
+
# as a queryable node for lint rules.
|
|
7
|
+
class ErbOutputNode
|
|
8
|
+
include BlockInspection
|
|
9
|
+
|
|
10
|
+
attr_reader(:block_body_codes, :line, :ruby_code)
|
|
11
|
+
|
|
12
|
+
def initialize(
|
|
13
|
+
ruby_code:, line:,
|
|
14
|
+
block_body_codes: nil, block_has_text_children: false
|
|
15
|
+
)
|
|
16
|
+
@ruby_code = ruby_code
|
|
17
|
+
@line = line
|
|
18
|
+
@block_body_codes = block_body_codes
|
|
19
|
+
@block_has_text_children = block_has_text_children
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tag_name
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def attribute?(name)
|
|
27
|
+
attributes.key?(name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def attributes
|
|
31
|
+
{}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call_node
|
|
35
|
+
@call_node ||= RubyCode.new(ruby_code).call_node
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def block_has_text_children?
|
|
39
|
+
@block_has_text_children
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def children
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/a11y/lint/erb_runner.rb
CHANGED
|
@@ -8,6 +8,7 @@ module A11y
|
|
|
8
8
|
class ErbRunner
|
|
9
9
|
ERB_TAG = /<%.*?%>/m
|
|
10
10
|
ERB_OUTPUT_TAG = /<%=\s*(.*?)\s*-?%>/m
|
|
11
|
+
ERB_OUTPUT_MARKER = "A11Y_LINT_ERB_OUTPUT"
|
|
11
12
|
VOID_ELEMENTS = %w[
|
|
12
13
|
area base br col embed hr img input
|
|
13
14
|
link meta param source track wbr
|
|
@@ -32,19 +33,27 @@ module A11y
|
|
|
32
33
|
attr_reader :rules
|
|
33
34
|
|
|
34
35
|
def check_html_nodes(source)
|
|
35
|
-
html = source.gsub(
|
|
36
|
+
html = source.gsub(ERB_OUTPUT_TAG, ERB_OUTPUT_MARKER)
|
|
37
|
+
html = html.gsub(ERB_TAG, "")
|
|
36
38
|
doc = Nokogiri::HTML4::DocumentFragment.parse(html)
|
|
37
39
|
|
|
38
40
|
doc.traverse do |nokogiri_node|
|
|
39
41
|
next unless nokogiri_node.element?
|
|
40
42
|
next unless source_confirmed_element?(html, nokogiri_node.name)
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
44
|
+
node = build_erb_element_node(nokogiri_node)
|
|
45
|
+
check_node(node)
|
|
45
46
|
end
|
|
46
47
|
end
|
|
47
48
|
|
|
49
|
+
def build_erb_element_node(nokogiri_node)
|
|
50
|
+
ErbElementNode.new(
|
|
51
|
+
nokogiri_node: nokogiri_node,
|
|
52
|
+
line: nokogiri_node.line,
|
|
53
|
+
has_erb_output: nokogiri_node.text.include?(ERB_OUTPUT_MARKER)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
48
57
|
def source_confirmed_element?(html, tag_name)
|
|
49
58
|
VOID_ELEMENTS.include?(tag_name) || html.include?("</#{tag_name}>")
|
|
50
59
|
end
|
|
@@ -53,12 +62,38 @@ module A11y
|
|
|
53
62
|
source.scan(ERB_OUTPUT_TAG) do
|
|
54
63
|
match = Regexp.last_match
|
|
55
64
|
code = match[1]
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
line = source[0...match.begin(0)].count("\n") + 1
|
|
66
|
+
check_node(
|
|
67
|
+
build_erb_output_node(source, code, line, match.end(0))
|
|
68
|
+
)
|
|
59
69
|
end
|
|
60
70
|
end
|
|
61
71
|
|
|
72
|
+
def build_erb_output_node(source, code, line, match_end)
|
|
73
|
+
block_body_codes, block_has_text =
|
|
74
|
+
extract_block_info(source, code, match_end)
|
|
75
|
+
|
|
76
|
+
ErbOutputNode.new(
|
|
77
|
+
ruby_code: code, line: line,
|
|
78
|
+
block_body_codes: block_body_codes,
|
|
79
|
+
block_has_text_children: block_has_text
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def extract_block_info(source, code, match_end)
|
|
84
|
+
return [nil, false] unless code.match?(/\s+do\s*\z/)
|
|
85
|
+
|
|
86
|
+
rest = source[match_end..]
|
|
87
|
+
end_match = rest.match(/<%-?\s*end\s*-?%>/m)
|
|
88
|
+
return [nil, false] unless end_match
|
|
89
|
+
|
|
90
|
+
block_content = rest[0...end_match.begin(0)]
|
|
91
|
+
codes = block_content.scan(ERB_OUTPUT_TAG).map { |m| m[0].strip }
|
|
92
|
+
text_only = block_content.gsub(ERB_TAG, "").strip
|
|
93
|
+
|
|
94
|
+
[codes, !text_only.empty?]
|
|
95
|
+
end
|
|
96
|
+
|
|
62
97
|
def check_node(node)
|
|
63
98
|
rules.each do |rule_class|
|
|
64
99
|
message = rule_class.check(node)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module A11y
|
|
4
|
+
module Lint
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when slim is not installed.
|
|
8
|
+
class SlimLoadError < Error
|
|
9
|
+
def initialize
|
|
10
|
+
super(
|
|
11
|
+
"a11y-lint needs the `slim` gem to lint .slim files. " \
|
|
12
|
+
"Add `gem \"slim\"` to your Gemfile."
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module A11y
|
|
4
4
|
module Lint
|
|
5
|
-
# Base class for accessibility lint rules.
|
|
6
|
-
class
|
|
5
|
+
# Base class for per-node accessibility lint rules.
|
|
6
|
+
class NodeRule
|
|
7
7
|
def self.check(node)
|
|
8
8
|
new(node).check
|
|
9
9
|
end
|
|
@@ -19,6 +19,10 @@ module A11y
|
|
|
19
19
|
def check
|
|
20
20
|
raise NotImplementedError
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader(:node)
|
|
22
26
|
end
|
|
23
27
|
end
|
|
24
28
|
end
|