a11y-lint 0.14.1 → 0.15.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/commands/implement.md +46 -0
- data/.claude/commands/prepare-release.md +62 -0
- data/.rubocop.yml +1 -2
- data/CHANGELOG.md +19 -0
- data/lib/a11y/lint/cli.rb +1 -1
- data/lib/a11y/lint/configuration.rb +8 -3
- data/lib/a11y/lint/erb_element_node.rb +5 -5
- data/lib/a11y/lint/erb_runner.rb +7 -7
- data/lib/a11y/lint/phlex_node.rb +39 -3
- data/lib/a11y/lint/phlex_runner/block_text_scanner.rb +191 -0
- data/lib/a11y/lint/phlex_runner/phlex_call.rb +89 -0
- data/lib/a11y/lint/phlex_runner.rb +69 -83
- data/lib/a11y/lint/slim_node.rb +6 -6
- data/lib/a11y/lint/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8621f3bad2e339bfff4c19dda38dc6f9e46a1195dd832c1d41506435f7836701
|
|
4
|
+
data.tar.gz: e7522df50471a67ec8693b7f1a006190dff8d319628ed30ffd978a6540287692
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c18b6aea457244af85b7e21d749f32841c0cd45cb898eda52a0f8e271478ee0ad99da9f1f539c5c6ae97537edfe212b820b3139a6865dcc46a77396511c2d2f8
|
|
7
|
+
data.tar.gz: f9dc521203ff94d686ef5d4cb01cf0d382ff18d1be8e83807197a8de54e0dba441fbe0d23fd6301fac0523c13d582d70ab4c5763eaf002ecb9c6963e7579c78f
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Plan and implement a GitHub issue end-to-end on a new branch
|
|
3
|
+
argument-hint: <issue-number>
|
|
4
|
+
allowed-tools: Bash(gh issue view:*), Bash(gh repo view:*), Bash(git checkout:*), Bash(git switch:*), Bash(git branch:*), Bash(git status:*), Bash(git diff:*), Bash(bundle exec:*), Bash(bin/setup:*), Bash(bin/console:*), Bash(grep:*), Bash(rg:*), Bash(ls:*), Bash(find:*), Read, Edit, Write, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Plan and implement GitHub issue **#$ARGUMENTS** in this repo, end-to-end, on a fresh branch. Do **not** commit or push — leave the working tree dirty for the user to review.
|
|
8
|
+
|
|
9
|
+
## What to do
|
|
10
|
+
|
|
11
|
+
1. **Fetch the issue.** Run `gh issue view $ARGUMENTS` (and `gh issue view $ARGUMENTS --comments` if there are comments) to read the title, body, and any discussion. Do **not** pass `--repo`.
|
|
12
|
+
|
|
13
|
+
2. **Create a branch.** From the current branch, run `git checkout -b <slug>` where `<slug>` is `issue-$ARGUMENTS-<short-kebab-summary>` (e.g. `issue-87-anchor-button-phlex-helpers`). Keep it under ~50 chars. If the branch already exists, switch to it instead.
|
|
14
|
+
|
|
15
|
+
3. **Plan before coding.** Produce a short plan covering:
|
|
16
|
+
- Which files you'll touch or create
|
|
17
|
+
- For new rules: the WCAG principle directory under `lib/a11y/lint/rules/`, the rule class name (see CLAUDE.md "Rule Scoping Convention"), and whether it has an HTML/helper pair
|
|
18
|
+
- Tests to add (Slim + ERB + Phlex parity per `.claude/rules/testing.md`)
|
|
19
|
+
- Dummy app fixtures to add in `test/fixtures/dummy_app/app/views/home/home.html.{slim,erb}` and any Phlex view
|
|
20
|
+
- Open questions, if any — ask once, then proceed
|
|
21
|
+
|
|
22
|
+
4. **Implement the plan.**
|
|
23
|
+
- Follow `CLAUDE.md` (rule scoping, node interface, configuration loading) and `.claude/rules/testing.md` (no `setup`, all three calling styles for `ruby_code` rules, Slim/ERB/Phlex parity, inline HEREDOC fixtures).
|
|
24
|
+
- For new rules: register the rule, add good/bad examples to dummy app fixtures, and add a docs page under `docs/src/rules/` if the issue is adding a user-visible rule.
|
|
25
|
+
- Add an entry under `[Unreleased]` in `CHANGELOG.md` for any user-visible change (bug fixes, new rules, behavior changes). Skip for internal refactors and test-only changes.
|
|
26
|
+
|
|
27
|
+
5. **Run the suite until green.** `bundle exec rake` runs tests + RuboCop. Iterate on failures. If a fix is non-obvious, surface it before grinding.
|
|
28
|
+
|
|
29
|
+
6. **Stop without committing.** Leave changes staged-or-unstaged in the working tree. In your final reply:
|
|
30
|
+
- List the branch name
|
|
31
|
+
- Summarize what changed (files added/modified, tests added)
|
|
32
|
+
- Confirm `bundle exec rake` is green (or call out what's still failing and why)
|
|
33
|
+
- Suggest the next command (`git add -A && git commit -m "..."` or a `gh pr create` once the user is ready) — but do **not** run it
|
|
34
|
+
|
|
35
|
+
## Out of scope
|
|
36
|
+
|
|
37
|
+
- Committing, pushing, or opening a PR
|
|
38
|
+
- Cross-repo support
|
|
39
|
+
- Auto-closing the issue
|
|
40
|
+
- Picking the issue automatically (always passed explicitly)
|
|
41
|
+
|
|
42
|
+
## Style notes
|
|
43
|
+
|
|
44
|
+
- Plan first, code second. A 5-line plan beats a 50-line one.
|
|
45
|
+
- Match the conventions of recent rules and recent commits — skim `git log --oneline -20` and a nearby rule file before inventing a new pattern.
|
|
46
|
+
- If the issue is ambiguous, ask **one** round of clarifying questions before implementing — voice input drops context, but too many questions defeat the point.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Scaffold gem release prep — preflight, bump, smoke, then push a release branch and open a PR
|
|
3
|
+
argument-hint: [patch|minor|major|X.Y.Z]
|
|
4
|
+
allowed-tools: Bash(git status:*), Bash(git fetch:*), Bash(git log:*), Bash(git diff:*), Bash(git tag:*), Bash(git rev-parse:*), Bash(git rev-list:*), Bash(git branch:*), Bash(git checkout:*), Bash(git switch:*), Bash(git restore:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(gh pr create:*), Bash(gh pr view:*), Bash(bundle install:*), Bash(bundle exec:*), Bash(grep:*), Bash(rg:*), Bash(ls:*), Bash(date:*), Read, Edit, Write, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Prepare a gem release. Bump kind: **$ARGUMENTS** (empty = infer from commits since last tag).
|
|
8
|
+
|
|
9
|
+
End state: a `release-X.Y.Z` branch pushed to origin with an open PR titled `Release X.Y.Z` and body `🎉`. The PR diff is the review surface — the CHANGELOG is the release notes.
|
|
10
|
+
|
|
11
|
+
The user runs the irreversible steps after the PR merges. **Never** run `bundle exec rake release`, `gem push pkg/a11y-lint-X.Y.Z.gem`, or `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
|
12
|
+
|
|
13
|
+
## Preflight
|
|
14
|
+
|
|
15
|
+
Bail loudly on any failure — don't try to fix state silently.
|
|
16
|
+
|
|
17
|
+
- On `main`, working tree clean, not behind `origin/main` (`git fetch origin main` then `git rev-list --count HEAD..origin/main` → `0`).
|
|
18
|
+
- `lib/a11y/lint/version.rb` `VERSION`, `Gemfile.lock`'s `a11y-lint (X.Y.Z)` line under `PATH → specs:`, and the latest `git tag --sort=-v:refname | head -1` (stripped of leading `v`) all agree. A mismatch usually means a prior release was half-finished — surface it and stop.
|
|
19
|
+
|
|
20
|
+
## Bump
|
|
21
|
+
|
|
22
|
+
If `$ARGUMENTS` is empty, infer from `git log <latest_tag>..HEAD`:
|
|
23
|
+
|
|
24
|
+
- Any commit subject containing `Breaking`, or starting with `Add ` or `Change ` → **minor**
|
|
25
|
+
- Otherwise → **patch**
|
|
26
|
+
|
|
27
|
+
This gem is pre-1.0; breaking changes still bump the minor (no major path). Print the inferred bump and the resulting `X.Y.Z`, then proceed without confirming.
|
|
28
|
+
|
|
29
|
+
If `$ARGUMENTS` is `patch`/`minor`/`major`, bump that segment. If it's `X.Y.Z`, use literally. Anything else → bail.
|
|
30
|
+
|
|
31
|
+
## Apply
|
|
32
|
+
|
|
33
|
+
1. Edit `lib/a11y/lint/version.rb` — replace the `VERSION = "..."` literal.
|
|
34
|
+
2. Edit `CHANGELOG.md` — insert `## [X.Y.Z] - YYYY-MM-DD` (today, absolute via `date +%Y-%m-%d`) directly below `## [Unreleased]` with blank lines around it. The Unreleased entries shift down to attribute the new release; the new Unreleased block stays empty.
|
|
35
|
+
3. `bundle install` — should change only `Gemfile.lock`'s `a11y-lint (X.Y.Z)` line. Surface anything else.
|
|
36
|
+
|
|
37
|
+
## Verify
|
|
38
|
+
|
|
39
|
+
- `bundle exec rake` — tests + RuboCop. Bail on red.
|
|
40
|
+
- `bundle exec a11y-lint test/fixtures/dummy_app` — capture offense count. Then run the same command against the prior release's fixtures with the *current* linter to isolate any linter regression on stable input:
|
|
41
|
+
```
|
|
42
|
+
git checkout <latest_tag> -- test/fixtures/dummy_app
|
|
43
|
+
bundle exec a11y-lint test/fixtures/dummy_app
|
|
44
|
+
git checkout HEAD -- test/fixtures/dummy_app
|
|
45
|
+
```
|
|
46
|
+
Report `prev → current`. A non-trivial change is a linter regression — surface it.
|
|
47
|
+
|
|
48
|
+
## Shape check
|
|
49
|
+
|
|
50
|
+
`git diff --stat` should touch only `lib/a11y/lint/version.rb`, `CHANGELOG.md`, `Gemfile.lock`. Anything else is a red flag — stop and ask before pushing.
|
|
51
|
+
|
|
52
|
+
## Branch + PR
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
git checkout -b release-X.Y.Z
|
|
56
|
+
git add lib/a11y/lint/version.rb CHANGELOG.md Gemfile.lock
|
|
57
|
+
git commit -m "Release X.Y.Z"
|
|
58
|
+
git push -u origin release-X.Y.Z
|
|
59
|
+
gh pr create --title "Release X.Y.Z" --body "🎉"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Print the PR URL.
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.0] - 2026-05-06
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Breaking:** `.a11y-lint.yml`'s `hidden_wrapper_classes` key is now `inaccessible_wrapper_classes`. Behavior is unchanged. The rename brings the key into line with the new `accessible_wrapper_classes` (below). Projects with `hidden_wrapper_classes` in their config need to rename it
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `.a11y-lint.yml` now supports a top-level `accessible_wrapper_classes` list. Inside Phlex `a`/`button` tags whose direct child has a matching class, `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` treat ambiguous bare calls — receiverless calls whose name matches an HTML tag, with no arguments and no block — as text-emitting method calls rather than empty HTML elements. Fixes false positives on the `span(class: "sr-only") { label }` pattern, where `label` is an instance method that parses as a `Prism::CallNode` indistinguishable from the `<label>` HTML tag. Opt-in; default is no special handling. Distinct from `inaccessible_wrapper_classes` (which excludes the wrapper from the parent's accessible name); a wrapper-class list member here stays in the parent's children with `text_content?` set to true. No-op in the Slim and ERB pipelines, which have no analogous ambiguity
|
|
19
|
+
|
|
20
|
+
## [0.14.2] - 2026-05-05
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` no longer report Phlex `a`/`button` tags that use the nested-hash shorthand for `aria-label` (e.g. `aria: { label: "..." }`). `PhlexNode` now flattens nested kwarg hashes the way Phlex does at render time (`aria: { label: "x" }` → `aria-label="x"`, `data: { controller: "y" }` → `data-controller="y"`), so every Phlex tag rule benefits
|
|
25
|
+
- `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` no longer report Phlex `a`/`button` tags whose accessible name comes from the block's auto-emitted return value. Phlex renders the block's last expression when it's a string-like value, and `PhlexRunner` now recognizes string literals (`{ "Clear all" }`, `{ "Hi #{name}" }`), method calls with a receiver (`{ account.email }`, `{ tab[:text] }`), lowercase receiverless calls (`{ external_url }`, `{ add_label }`), method-parameter / local-variable reads, and ternary / `if` / `unless` expressions whose branches resolve to text. Capitalized component calls (`{ Pencil(...) }`) and blank string literals are still treated as having no accessible name
|
|
26
|
+
- `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` no longer report Phlex `a`/`button` tags that forward a block argument (`a(href:, &block)`, `button(type: "submit", &block)`) inside a wrapper method. The accessible name is supplied by the caller's block, which can't be resolved from the tag call site, so forwarded blocks are now trusted by default
|
|
27
|
+
- `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` no longer report Phlex `a`/`button` tags whose accessible name is passed as a positional argument (`a("Click me", href: "/x")`, `button("Submit", type: :submit)`). Phlex's tag API renders the first positional argument as text content, and `PhlexRunner` now applies the same recognizer used for block content to the positional arg — string literals, interpolated strings, method calls (with or without a receiver), `t`/`translate` and other phlex-rails value helpers, and local variable / method-parameter reads. Empty/whitespace strings and capitalized component calls (`a(Pencil(...), href: ...)`) are still reported
|
|
28
|
+
|
|
10
29
|
## [0.14.1] - 2026-05-03
|
|
11
30
|
|
|
12
31
|
### Fixed
|
data/lib/a11y/lint/cli.rb
CHANGED
|
@@ -42,9 +42,14 @@ module A11y
|
|
|
42
42
|
@config.dig(rule_name, "Enabled") != false
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
def
|
|
46
|
-
@
|
|
47
|
-
Array(@config["
|
|
45
|
+
def inaccessible_wrapper_classes
|
|
46
|
+
@inaccessible_wrapper_classes ||=
|
|
47
|
+
Array(@config["inaccessible_wrapper_classes"]).map(&:to_s).freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def accessible_wrapper_classes
|
|
51
|
+
@accessible_wrapper_classes ||=
|
|
52
|
+
Array(@config["accessible_wrapper_classes"]).map(&:to_s).freeze
|
|
48
53
|
end
|
|
49
54
|
|
|
50
55
|
def enabled_rules
|
|
@@ -47,7 +47,7 @@ module A11y
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def text_content?
|
|
50
|
-
return false if
|
|
50
|
+
return false if inaccessible_wrapper?(@nokogiri_node)
|
|
51
51
|
|
|
52
52
|
visible_text_or_output?(@nokogiri_node)
|
|
53
53
|
end
|
|
@@ -58,7 +58,7 @@ module A11y
|
|
|
58
58
|
# to the accessible name.
|
|
59
59
|
def children
|
|
60
60
|
@nokogiri_node.element_children.filter_map do |child|
|
|
61
|
-
next if
|
|
61
|
+
next if inaccessible_wrapper?(child)
|
|
62
62
|
|
|
63
63
|
ErbElementNode.new(
|
|
64
64
|
nokogiri_node: child, line: child.line,
|
|
@@ -70,7 +70,7 @@ module A11y
|
|
|
70
70
|
private
|
|
71
71
|
|
|
72
72
|
def visible_text_or_output?(node)
|
|
73
|
-
return false if
|
|
73
|
+
return false if inaccessible_wrapper?(node)
|
|
74
74
|
return true if own_text_or_marker?(node)
|
|
75
75
|
|
|
76
76
|
node.element_children.any? { |c| visible_text_or_output?(c) }
|
|
@@ -86,8 +86,8 @@ module A11y
|
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
def
|
|
90
|
-
classes = configuration.
|
|
89
|
+
def inaccessible_wrapper?(node)
|
|
90
|
+
classes = configuration.inaccessible_wrapper_classes
|
|
91
91
|
return false if classes.empty?
|
|
92
92
|
|
|
93
93
|
node_classes(node).any? { |klass| classes.include?(klass) }
|
data/lib/a11y/lint/erb_runner.rb
CHANGED
|
@@ -98,7 +98,7 @@ module A11y
|
|
|
98
98
|
indexed_codes = []
|
|
99
99
|
html = indexed_marker_html(block_content, indexed_codes)
|
|
100
100
|
fragment = Nokogiri::HTML4::DocumentFragment.parse(html)
|
|
101
|
-
|
|
101
|
+
strip_inaccessible_wrappers!(fragment)
|
|
102
102
|
|
|
103
103
|
remaining = fragment.to_html
|
|
104
104
|
visible_codes = indexed_codes.each_with_index.filter_map do |code, i|
|
|
@@ -118,23 +118,23 @@ module A11y
|
|
|
118
118
|
!html.gsub(/#{ERB_OUTPUT_MARKER}\d+_/, "").strip.empty?
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
def
|
|
122
|
-
return if configuration.
|
|
121
|
+
def strip_inaccessible_wrappers!(node)
|
|
122
|
+
return if configuration.inaccessible_wrapper_classes.empty?
|
|
123
123
|
|
|
124
124
|
node.element_children.each do |child|
|
|
125
|
-
if
|
|
125
|
+
if inaccessible_wrapper_element?(child)
|
|
126
126
|
child.remove
|
|
127
127
|
else
|
|
128
|
-
|
|
128
|
+
strip_inaccessible_wrappers!(child)
|
|
129
129
|
end
|
|
130
130
|
end
|
|
131
131
|
end
|
|
132
132
|
|
|
133
|
-
def
|
|
133
|
+
def inaccessible_wrapper_element?(node)
|
|
134
134
|
value = node.attributes["class"]&.value
|
|
135
135
|
return false unless value.is_a?(String)
|
|
136
136
|
|
|
137
|
-
classes = configuration.
|
|
137
|
+
classes = configuration.inaccessible_wrapper_classes
|
|
138
138
|
value.split.any? { |klass| classes.include?(klass) }
|
|
139
139
|
end
|
|
140
140
|
|
data/lib/a11y/lint/phlex_node.rb
CHANGED
|
@@ -85,6 +85,13 @@ module A11y
|
|
|
85
85
|
)
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# Resolves a Phlex tag call's `class:` kwarg to a list of class names.
|
|
89
|
+
# Intentionally narrow: only matches a literal `Prism::StringNode`.
|
|
90
|
+
# Misses array form (`class: ["sr-only", other]`), interpolation
|
|
91
|
+
# (`class: "sr-only #{foo}"`), helper-wrapped (`class: cn("sr-only")`),
|
|
92
|
+
# and computed values (`class: some_method`). Wrapper-class config
|
|
93
|
+
# (`inaccessible_wrapper_classes`, `accessible_wrapper_classes`)
|
|
94
|
+
# therefore only matches authors who write a single static literal.
|
|
88
95
|
def self.kwarg_class_values(call_node)
|
|
89
96
|
return [] unless call_node.arguments
|
|
90
97
|
|
|
@@ -100,7 +107,31 @@ module A11y
|
|
|
100
107
|
|
|
101
108
|
kwarg_nodes(call_node).each_with_object({}) do |elem, h|
|
|
102
109
|
key = kwarg_key(elem.key)
|
|
103
|
-
|
|
110
|
+
next unless key
|
|
111
|
+
|
|
112
|
+
if elem.value.is_a?(Prism::HashNode)
|
|
113
|
+
flatten_nested_hash(key, elem.value, h)
|
|
114
|
+
else
|
|
115
|
+
h[key] = kwarg_value(elem.value)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Mirrors Phlex's render-time flattening of nested kwarg hashes
|
|
121
|
+
# (e.g. `aria: { label: "x" }` -> `aria-label="x"`). Inner key
|
|
122
|
+
# underscores become dashes the same way Phlex/Rails do.
|
|
123
|
+
def self.flatten_nested_hash(prefix, hash_node, attrs)
|
|
124
|
+
hash_node.elements.each do |inner|
|
|
125
|
+
next unless inner.is_a?(Prism::AssocNode)
|
|
126
|
+
next unless (inner_key = kwarg_key(inner.key))
|
|
127
|
+
|
|
128
|
+
full_key = "#{prefix}-#{inner_key.tr("_", "-")}"
|
|
129
|
+
|
|
130
|
+
if inner.value.is_a?(Prism::HashNode)
|
|
131
|
+
flatten_nested_hash(full_key, inner.value, attrs)
|
|
132
|
+
else
|
|
133
|
+
attrs[full_key] = kwarg_value(inner.value)
|
|
134
|
+
end
|
|
104
135
|
end
|
|
105
136
|
end
|
|
106
137
|
|
|
@@ -125,8 +156,13 @@ module A11y
|
|
|
125
156
|
end
|
|
126
157
|
end
|
|
127
158
|
|
|
128
|
-
private_class_method
|
|
129
|
-
|
|
159
|
+
private_class_method(
|
|
160
|
+
:extract_attributes,
|
|
161
|
+
:flatten_nested_hash,
|
|
162
|
+
:kwarg_key,
|
|
163
|
+
:kwarg_nodes,
|
|
164
|
+
:kwarg_value
|
|
165
|
+
)
|
|
130
166
|
end
|
|
131
167
|
end
|
|
132
168
|
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module A11y
|
|
6
|
+
module Lint
|
|
7
|
+
class PhlexRunner
|
|
8
|
+
# Decides whether a Phlex tag block produces accessible text.
|
|
9
|
+
# Stateless: only reads the given Prism block node and pre-collected
|
|
10
|
+
# child tags.
|
|
11
|
+
class BlockTextScanner
|
|
12
|
+
# Phlex auto-emits the return value of these calls into the document:
|
|
13
|
+
# `plain` / `text` are built-in; the rest are registered as value
|
|
14
|
+
# helpers by phlex-rails via `register_value_helper`.
|
|
15
|
+
TEXT_CALLS = %w[
|
|
16
|
+
plain text
|
|
17
|
+
t translate l localize
|
|
18
|
+
pluralize truncate
|
|
19
|
+
number_to_currency number_to_human number_to_human_size
|
|
20
|
+
number_to_percentage number_to_phone
|
|
21
|
+
number_with_delimiter number_with_precision
|
|
22
|
+
highlight excerpt
|
|
23
|
+
].to_set.freeze
|
|
24
|
+
|
|
25
|
+
def self.scan(block, children:, accessible_wrapper: false)
|
|
26
|
+
new(block, children:, accessible_wrapper:).scan
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Public recognizer for a single Prism node — used by PhlexRunner
|
|
30
|
+
# to inspect a tag's first positional argument, where Phlex emits
|
|
31
|
+
# the value as text content (`a("Click", href: "/x")`).
|
|
32
|
+
def self.text_emitting?(node)
|
|
33
|
+
new(nil, children: []).text_emitting?(node)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.non_blank_string_literal?(node)
|
|
37
|
+
case node
|
|
38
|
+
when Prism::StringNode
|
|
39
|
+
!node.unescaped.strip.empty?
|
|
40
|
+
when Prism::InterpolatedStringNode
|
|
41
|
+
node.parts.any? do |part|
|
|
42
|
+
if part.is_a?(Prism::StringNode)
|
|
43
|
+
!part.unescaped.strip.empty?
|
|
44
|
+
else
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
else
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(block, children:, accessible_wrapper: false)
|
|
54
|
+
@block = block
|
|
55
|
+
@children = children
|
|
56
|
+
@accessible_wrapper = accessible_wrapper
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def scan
|
|
60
|
+
return false unless @block.is_a?(Prism::BlockNode)
|
|
61
|
+
|
|
62
|
+
scan_for_text(@block) || @children.any?(&:text_content?)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def text_emitting?(node)
|
|
66
|
+
auto_emitted_text?(node)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# A Phlex tag block produces accessible text if any statement
|
|
72
|
+
# contains an explicit text marker (yield, plain/text/t/..., a
|
|
73
|
+
# non-blank string literal), or if the block's last expression
|
|
74
|
+
# auto-emits a value Phlex renders as text.
|
|
75
|
+
def scan_for_text(block)
|
|
76
|
+
statements = block_statements(block)
|
|
77
|
+
return false if statements.empty?
|
|
78
|
+
|
|
79
|
+
return true if statements.any? { |stmt| explicit_text?(stmt) }
|
|
80
|
+
|
|
81
|
+
auto_emitted_text?(statements.last)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def block_statements(block)
|
|
85
|
+
return [] unless block.is_a?(Prism::BlockNode)
|
|
86
|
+
|
|
87
|
+
body = block.body
|
|
88
|
+
body.is_a?(Prism::StatementsNode) ? body.body : []
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def explicit_text?(node)
|
|
92
|
+
return false if node.nil?
|
|
93
|
+
return true if node.is_a?(Prism::YieldNode)
|
|
94
|
+
return true if self.class.non_blank_string_literal?(node)
|
|
95
|
+
return text_call?(node) if node.is_a?(Prism::CallNode)
|
|
96
|
+
|
|
97
|
+
# Recurse only into conditional/grouping nodes — descending into
|
|
98
|
+
# call args would treat string literals like `foo("bar")`'s "bar"
|
|
99
|
+
# as block text, which it isn't.
|
|
100
|
+
return false unless conditional_container?(node)
|
|
101
|
+
|
|
102
|
+
node.child_nodes.compact.any? { |c| explicit_text?(c) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def auto_emitted_text?(node)
|
|
106
|
+
return false if node.nil?
|
|
107
|
+
return true if explicit_text?(node)
|
|
108
|
+
|
|
109
|
+
case node
|
|
110
|
+
when Prism::LocalVariableReadNode
|
|
111
|
+
true
|
|
112
|
+
when Prism::CallNode
|
|
113
|
+
auto_emitted_call?(node)
|
|
114
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
115
|
+
if_branch_lasts(node).any? { |last| auto_emitted_text?(last) }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def auto_emitted_call?(call)
|
|
120
|
+
return true if call.receiver
|
|
121
|
+
|
|
122
|
+
name = call.name.to_s
|
|
123
|
+
if PhlexNode.html_tag?(name)
|
|
124
|
+
return accessible_wrapper? && bare_call?(call)
|
|
125
|
+
end
|
|
126
|
+
return true if TEXT_CALLS.include?(name)
|
|
127
|
+
|
|
128
|
+
# Lowercase receiverless calls (locals, helper methods) auto-emit
|
|
129
|
+
# their return value. Capitalized names are Phlex components,
|
|
130
|
+
# which emit their own HTML, not text.
|
|
131
|
+
lowercase_name?(name)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def bare_call?(call)
|
|
135
|
+
call.arguments.nil? && call.block.nil?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def accessible_wrapper?
|
|
139
|
+
@accessible_wrapper
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def conditional_container?(node)
|
|
143
|
+
node.is_a?(Prism::IfNode) ||
|
|
144
|
+
node.is_a?(Prism::UnlessNode) ||
|
|
145
|
+
node.is_a?(Prism::BeginNode) ||
|
|
146
|
+
node.is_a?(Prism::StatementsNode) ||
|
|
147
|
+
node.is_a?(Prism::ElseNode) ||
|
|
148
|
+
node.is_a?(Prism::ParenthesesNode)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def lowercase_name?(name)
|
|
152
|
+
first = name[0]
|
|
153
|
+
first && first == first.downcase && first != first.upcase
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def if_branch_lasts(node)
|
|
157
|
+
if_branch_statements(node).flat_map do |stmts|
|
|
158
|
+
stmts.is_a?(Prism::StatementsNode) ? [stmts.body.last].compact : []
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def if_branch_statements(node)
|
|
163
|
+
branches = [node.statements]
|
|
164
|
+
collect_else_branches(if_else_clause(node), branches)
|
|
165
|
+
branches.compact
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def collect_else_branches(current, branches)
|
|
169
|
+
while current.is_a?(Prism::IfNode) || current.is_a?(Prism::UnlessNode)
|
|
170
|
+
branches << current.statements
|
|
171
|
+
current = if_else_clause(current)
|
|
172
|
+
end
|
|
173
|
+
branches << current.statements if current.is_a?(Prism::ElseNode)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def if_else_clause(node)
|
|
177
|
+
case node
|
|
178
|
+
when Prism::IfNode then node.subsequent
|
|
179
|
+
when Prism::UnlessNode then node.else_clause
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def text_call?(node)
|
|
184
|
+
node.is_a?(Prism::CallNode) &&
|
|
185
|
+
node.receiver.nil? &&
|
|
186
|
+
TEXT_CALLS.include?(node.name.to_s)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module A11y
|
|
6
|
+
module Lint
|
|
7
|
+
class PhlexRunner
|
|
8
|
+
# Wraps a `Prism::CallNode` with the predicates and block accessors
|
|
9
|
+
# PhlexRunner uses to classify and traverse Phlex view code:
|
|
10
|
+
# tag vs helper, block form (`do...end`, `&block`, none),
|
|
11
|
+
# first positional arg, class values.
|
|
12
|
+
class PhlexCall
|
|
13
|
+
# Phlex tag/helper calls are always receiverless; a call like
|
|
14
|
+
# `helpers.image_tag(...)` has a receiver and is skipped.
|
|
15
|
+
def self.wrap(node)
|
|
16
|
+
return unless node.is_a?(Prism::CallNode) && node.receiver.nil?
|
|
17
|
+
|
|
18
|
+
new(node)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(call_node)
|
|
22
|
+
@call_node = call_node
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader(:call_node)
|
|
26
|
+
|
|
27
|
+
def name
|
|
28
|
+
call_node.name.to_s
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def line
|
|
32
|
+
call_node.location.start_line
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def tag?
|
|
36
|
+
PhlexNode.html_tag?(name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def helper?
|
|
40
|
+
!tag?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def block
|
|
44
|
+
call_node.block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The block as a Prism::BlockNode, or nil for `&block` forwards
|
|
48
|
+
# and calls without a block.
|
|
49
|
+
def block_node
|
|
50
|
+
block.is_a?(Prism::BlockNode) ? block : nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# `tag(&block)` defers content to the caller; we can't see what
|
|
54
|
+
# they emit, so trust the wrapper rather than report a false
|
|
55
|
+
# positive.
|
|
56
|
+
def forwarded_block?
|
|
57
|
+
block.is_a?(Prism::BlockArgumentNode)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def block_has_text?(children = [], accessible_wrapper: false)
|
|
61
|
+
BlockTextScanner.scan(block_node, children:, accessible_wrapper:)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Phlex's tag API emits the first positional argument as text
|
|
65
|
+
# content (`a("Click me", href: "/x")` is equivalent to
|
|
66
|
+
# `a(href: "/x") { "Click me" }`).
|
|
67
|
+
def arg_has_text?
|
|
68
|
+
arg = first_positional_arg
|
|
69
|
+
return false if arg.nil?
|
|
70
|
+
|
|
71
|
+
BlockTextScanner.text_emitting?(arg)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def first_positional_arg
|
|
75
|
+
return nil unless call_node.arguments
|
|
76
|
+
|
|
77
|
+
call_node.arguments.arguments.find do |arg|
|
|
78
|
+
!arg.is_a?(Prism::KeywordHashNode) &&
|
|
79
|
+
!arg.is_a?(Prism::BlockArgumentNode)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def class_values
|
|
84
|
+
PhlexNode.kwarg_class_values(call_node)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
|
+
require_relative "phlex_runner/block_text_scanner"
|
|
5
|
+
require_relative "phlex_runner/phlex_call"
|
|
4
6
|
|
|
5
7
|
module A11y
|
|
6
8
|
module Lint
|
|
@@ -9,19 +11,6 @@ module A11y
|
|
|
9
11
|
class PhlexRunner
|
|
10
12
|
PHLEX_PATTERN = /\bdef\s+view_template\b/
|
|
11
13
|
|
|
12
|
-
# Phlex auto-emits the return value of these calls into the document:
|
|
13
|
-
# `plain` / `text` are built-in; the rest are registered as value
|
|
14
|
-
# helpers by phlex-rails via `register_value_helper`.
|
|
15
|
-
TEXT_CALLS = %w[
|
|
16
|
-
plain text
|
|
17
|
-
t translate l localize
|
|
18
|
-
pluralize truncate
|
|
19
|
-
number_to_currency number_to_human number_to_human_size
|
|
20
|
-
number_to_percentage number_to_phone
|
|
21
|
-
number_with_delimiter number_with_precision
|
|
22
|
-
highlight excerpt
|
|
23
|
-
].to_set.freeze
|
|
24
|
-
|
|
25
14
|
def initialize(rules = nil, configuration: Configuration.new)
|
|
26
15
|
@rules = rules || configuration.enabled_rules
|
|
27
16
|
@configuration = configuration
|
|
@@ -43,27 +32,25 @@ module A11y
|
|
|
43
32
|
attr_reader :rules, :configuration
|
|
44
33
|
|
|
45
34
|
def walk(node)
|
|
46
|
-
if
|
|
47
|
-
process_call(
|
|
35
|
+
if (call = PhlexCall.wrap(node))
|
|
36
|
+
process_call(call)
|
|
48
37
|
else
|
|
49
38
|
node.child_nodes.compact.each { |c| walk(c) }
|
|
50
39
|
end
|
|
51
40
|
end
|
|
52
41
|
|
|
53
|
-
def process_call(
|
|
54
|
-
|
|
55
|
-
check_tag(node)
|
|
56
|
-
else
|
|
57
|
-
check_helper(node)
|
|
58
|
-
end
|
|
42
|
+
def process_call(call)
|
|
43
|
+
call.tag? ? check_tag(call) : check_helper(call)
|
|
59
44
|
end
|
|
60
45
|
|
|
61
|
-
def check_tag(
|
|
62
|
-
children = collect_block_children(
|
|
63
|
-
has_text =
|
|
46
|
+
def check_tag(call)
|
|
47
|
+
children = collect_block_children(call.block_node)
|
|
48
|
+
has_text = call.forwarded_block? ||
|
|
49
|
+
call.arg_has_text? ||
|
|
50
|
+
call.block_has_text?(children)
|
|
64
51
|
check_node(
|
|
65
52
|
PhlexNode.build_tag(
|
|
66
|
-
|
|
53
|
+
call.call_node,
|
|
67
54
|
children: children,
|
|
68
55
|
text_content: has_text,
|
|
69
56
|
configuration: configuration
|
|
@@ -71,114 +58,113 @@ module A11y
|
|
|
71
58
|
)
|
|
72
59
|
end
|
|
73
60
|
|
|
74
|
-
def check_helper(
|
|
75
|
-
codes, has_text = analyze_helper_block(
|
|
61
|
+
def check_helper(call)
|
|
62
|
+
codes, has_text = analyze_helper_block(call)
|
|
76
63
|
helper = PhlexNode.build_helper(
|
|
77
|
-
|
|
64
|
+
call.call_node,
|
|
78
65
|
block_body_codes: codes,
|
|
79
66
|
block_has_text_children: has_text,
|
|
80
67
|
configuration: configuration
|
|
81
68
|
)
|
|
82
69
|
check_node(helper)
|
|
83
|
-
walk_block(
|
|
70
|
+
walk_block(call.block_node)
|
|
84
71
|
end
|
|
85
72
|
|
|
86
|
-
def collect_block_children(
|
|
87
|
-
return [] unless
|
|
73
|
+
def collect_block_children(block_node)
|
|
74
|
+
return [] unless block_node
|
|
88
75
|
|
|
89
|
-
[].tap { |c| gather_children(
|
|
76
|
+
[].tap { |c| gather_children(block_node, c) }
|
|
90
77
|
end
|
|
91
78
|
|
|
92
79
|
def gather_children(parent, result)
|
|
93
80
|
parent.child_nodes.compact.each do |child|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
81
|
+
child_call = PhlexCall.wrap(child)
|
|
82
|
+
if child_call&.tag?
|
|
83
|
+
gather_tag_child(child_call, result)
|
|
84
|
+
elsif child_call
|
|
85
|
+
check_helper(child_call)
|
|
98
86
|
else
|
|
99
87
|
gather_children(child, result)
|
|
100
88
|
end
|
|
101
89
|
end
|
|
102
90
|
end
|
|
103
91
|
|
|
104
|
-
def gather_tag_child(
|
|
105
|
-
kids = collect_block_children(
|
|
106
|
-
has_text =
|
|
92
|
+
def gather_tag_child(call, result)
|
|
93
|
+
kids = collect_block_children(call.block_node)
|
|
94
|
+
has_text = call.arg_has_text? ||
|
|
95
|
+
call.block_has_text?(
|
|
96
|
+
kids,
|
|
97
|
+
accessible_wrapper: accessible_wrapper_tag?(call)
|
|
98
|
+
)
|
|
107
99
|
tag = PhlexNode.build_tag(
|
|
108
|
-
|
|
109
|
-
|
|
100
|
+
call.call_node, children: kids, text_content: has_text,
|
|
101
|
+
configuration: configuration
|
|
110
102
|
)
|
|
111
103
|
check_node(tag)
|
|
112
|
-
result << tag unless
|
|
104
|
+
result << tag unless inaccessible_wrapper_tag?(call)
|
|
113
105
|
end
|
|
114
106
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
return false if classes.empty?
|
|
118
|
-
|
|
119
|
-
tag_class_values(call_node).any? { |klass| classes.include?(klass) }
|
|
107
|
+
def inaccessible_wrapper_tag?(call)
|
|
108
|
+
wrapper_class_match?(call, configuration.inaccessible_wrapper_classes)
|
|
120
109
|
end
|
|
121
110
|
|
|
122
|
-
def
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
111
|
+
def accessible_wrapper_tag?(call)
|
|
112
|
+
wrapper_class_match?(
|
|
113
|
+
call, configuration.accessible_wrapper_classes
|
|
114
|
+
)
|
|
126
115
|
end
|
|
127
116
|
|
|
128
|
-
def
|
|
129
|
-
return false
|
|
130
|
-
|
|
131
|
-
scan_for_text(block) || children.any?(&:text_content?)
|
|
132
|
-
end
|
|
117
|
+
def wrapper_class_match?(call, classes)
|
|
118
|
+
return false if classes.empty?
|
|
133
119
|
|
|
134
|
-
|
|
135
|
-
node.child_nodes.compact.any? do |child|
|
|
136
|
-
text_call?(child) || child.is_a?(Prism::YieldNode) ||
|
|
137
|
-
(!receiverless_call?(child) && scan_for_text(child))
|
|
138
|
-
end
|
|
120
|
+
call.class_values.any? { |klass| classes.include?(klass) }
|
|
139
121
|
end
|
|
140
122
|
|
|
141
|
-
def analyze_helper_block(
|
|
142
|
-
block =
|
|
143
|
-
return [nil, false] unless block
|
|
123
|
+
def analyze_helper_block(call)
|
|
124
|
+
block = call.block_node
|
|
125
|
+
return [nil, false] unless block
|
|
144
126
|
|
|
145
127
|
codes = []
|
|
146
128
|
has_text = scan_block_content(block, codes)
|
|
147
129
|
[codes.empty? ? nil : codes, has_text]
|
|
148
130
|
end
|
|
149
131
|
|
|
150
|
-
# rubocop:disable Metrics/CyclomaticComplexity
|
|
151
|
-
# rubocop:disable Metrics/PerceivedComplexity
|
|
152
132
|
def scan_block_content(node, codes)
|
|
153
133
|
node.child_nodes.compact.each do |child|
|
|
154
|
-
|
|
155
|
-
return true if child.is_a?(Prism::YieldNode)
|
|
156
|
-
return true if tag_call?(child) && child.block
|
|
157
|
-
next if tag_call?(child)
|
|
158
|
-
next codes << child.slice if receiverless_call?(child)
|
|
159
|
-
return true if scan_block_content(child, codes)
|
|
134
|
+
return true if visit_helper_block_child(child, codes)
|
|
160
135
|
end
|
|
161
136
|
false
|
|
162
137
|
end
|
|
163
|
-
# rubocop:enable Metrics/CyclomaticComplexity
|
|
164
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
165
138
|
|
|
166
|
-
def
|
|
167
|
-
|
|
139
|
+
def visit_helper_block_child(child, codes)
|
|
140
|
+
child_call = PhlexCall.wrap(child)
|
|
141
|
+
return handle_call_block_child(child_call, codes) if child_call
|
|
142
|
+
|
|
143
|
+
block_child_text?(child) || scan_block_content(child, codes)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def handle_call_block_child(child_call, codes)
|
|
147
|
+
return false if child_call.tag? && inaccessible_wrapper_tag?(child_call)
|
|
148
|
+
return true if tag_call_has_text_block?(child_call)
|
|
149
|
+
return false if child_call.tag?
|
|
168
150
|
|
|
169
|
-
|
|
151
|
+
codes << child_call.call_node.slice
|
|
152
|
+
false
|
|
170
153
|
end
|
|
171
154
|
|
|
172
|
-
def
|
|
173
|
-
|
|
155
|
+
def block_child_text?(child)
|
|
156
|
+
child.is_a?(Prism::YieldNode) ||
|
|
157
|
+
BlockTextScanner.non_blank_string_literal?(child)
|
|
174
158
|
end
|
|
175
159
|
|
|
176
|
-
def
|
|
177
|
-
|
|
160
|
+
def tag_call_has_text_block?(child_call)
|
|
161
|
+
child_call.tag? && !child_call.block.nil?
|
|
178
162
|
end
|
|
179
163
|
|
|
180
|
-
def
|
|
181
|
-
|
|
164
|
+
def walk_block(block_node)
|
|
165
|
+
return unless block_node
|
|
166
|
+
|
|
167
|
+
block_node.child_nodes.compact.each { |c| walk(c) }
|
|
182
168
|
end
|
|
183
169
|
|
|
184
170
|
def check_node(node)
|
data/lib/a11y/lint/slim_node.rb
CHANGED
|
@@ -91,7 +91,7 @@ module A11y
|
|
|
91
91
|
|
|
92
92
|
def collect_output_codes(sexp)
|
|
93
93
|
return [] unless sexp.is_a?(Array)
|
|
94
|
-
return [] if
|
|
94
|
+
return [] if inaccessible_wrapper_sexp?(sexp)
|
|
95
95
|
return [sexp[3]] if slim_output_sexp?(sexp)
|
|
96
96
|
|
|
97
97
|
sexp.flat_map { |child| collect_output_codes(child) }
|
|
@@ -103,7 +103,7 @@ module A11y
|
|
|
103
103
|
|
|
104
104
|
def block_text_content?(sexp)
|
|
105
105
|
return false unless sexp.is_a?(Array)
|
|
106
|
-
return false if
|
|
106
|
+
return false if inaccessible_wrapper_sexp?(sexp)
|
|
107
107
|
return true if slim_text_sexp?(sexp) || html_tag_sexp?(sexp)
|
|
108
108
|
|
|
109
109
|
sexp.any? { |child| block_text_content?(child) }
|
|
@@ -111,18 +111,18 @@ module A11y
|
|
|
111
111
|
|
|
112
112
|
def text_or_output?(sexp)
|
|
113
113
|
return false unless sexp.is_a?(Array)
|
|
114
|
-
return false if
|
|
114
|
+
return false if inaccessible_wrapper_sexp?(sexp)
|
|
115
115
|
return true if slim_text_sexp?(sexp) || slim_output_sexp?(sexp)
|
|
116
116
|
|
|
117
117
|
sexp.any? { |child| text_or_output?(child) }
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
def
|
|
120
|
+
def inaccessible_wrapper_sexp?(sexp)
|
|
121
121
|
return false unless html_tag_sexp?(sexp)
|
|
122
|
-
return false if configuration.
|
|
122
|
+
return false if configuration.inaccessible_wrapper_classes.empty?
|
|
123
123
|
|
|
124
124
|
class_values(sexp[3]).any? do |klass|
|
|
125
|
-
configuration.
|
|
125
|
+
configuration.inaccessible_wrapper_classes.include?(klass)
|
|
126
126
|
end
|
|
127
127
|
end
|
|
128
128
|
|
data/lib/a11y/lint/version.rb
CHANGED
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.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdullah Hashim
|
|
@@ -45,6 +45,8 @@ executables:
|
|
|
45
45
|
extensions: []
|
|
46
46
|
extra_rdoc_files: []
|
|
47
47
|
files:
|
|
48
|
+
- ".claude/commands/implement.md"
|
|
49
|
+
- ".claude/commands/prepare-release.md"
|
|
48
50
|
- ".claude/commands/task.md"
|
|
49
51
|
- ".claude/rules/testing.md"
|
|
50
52
|
- ".rubocop.yml"
|
|
@@ -117,6 +119,8 @@ files:
|
|
|
117
119
|
- lib/a11y/lint/offense.rb
|
|
118
120
|
- lib/a11y/lint/phlex_node.rb
|
|
119
121
|
- lib/a11y/lint/phlex_runner.rb
|
|
122
|
+
- lib/a11y/lint/phlex_runner/block_text_scanner.rb
|
|
123
|
+
- lib/a11y/lint/phlex_runner/phlex_call.rb
|
|
120
124
|
- lib/a11y/lint/phlex_tags.rb
|
|
121
125
|
- lib/a11y/lint/ruby_code.rb
|
|
122
126
|
- lib/a11y/lint/rules/perceivable/area_missing_alt.rb
|