a11y-lint 0.14.0 → 0.14.2
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/task.md +37 -0
- data/.rubocop.yml +1 -2
- data/CHANGELOG.md +15 -0
- data/docs/CONTRIBUTING.md +35 -0
- data/docs/src/_components/shared/code_tabs.css +57 -0
- data/docs/src/_components/shared/code_tabs.erb +30 -0
- data/docs/src/_components/shared/code_tabs.js +54 -0
- data/docs/src/_components/shared/code_tabs.rb +36 -0
- data/docs/src/rules/images-need-alt-text.md +22 -6
- data/lib/a11y/lint/cli.rb +1 -1
- data/lib/a11y/lint/phlex_node.rb +32 -3
- data/lib/a11y/lint/phlex_runner/block_text_scanner.rb +180 -0
- data/lib/a11y/lint/phlex_runner/phlex_call.rb +93 -0
- data/lib/a11y/lint/phlex_runner.rb +58 -72
- data/lib/a11y/lint/version.rb +1 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27003e9edd9b1055dd6c2ddd37ac6da8958bb3a608f5bb8fbfa103eb167ddb0e
|
|
4
|
+
data.tar.gz: 5af458356a27d126373c8780ca2cec362d7d2e05b70edfdb45236a90f70b82a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a03ec8dba3eea02eaf7195e6b06f7ef60e628fbe4fd0de1a36c646123e5a335f8244fb10d6fe1a10f3570d95d133e9e32ccdd3ab0619401b93ccf82c6040c4d7
|
|
7
|
+
data.tar.gz: 632ec73a902867c26723f05814717ec53f8f32df73d3d9db7f99afc5cfd1530899cb418e1172b4076de7b0b2f72a48f2ce179f08c506ec7a013cf4ebcae6b625
|
|
@@ -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,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Capture a free-form idea as a GitHub issue in the current repo
|
|
3
|
+
argument-hint: <idea>
|
|
4
|
+
allowed-tools: Bash(gh issue create:*), Bash(gh repo view:*), Bash(grep:*), Bash(rg:*), Bash(ls:*), Bash(find:*), Read, Glob, Grep
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Take the free-form idea below and turn it into a well-formed GitHub issue in the current repo.
|
|
8
|
+
|
|
9
|
+
Idea: $ARGUMENTS
|
|
10
|
+
|
|
11
|
+
## What to do
|
|
12
|
+
|
|
13
|
+
1. **Refine** the idea into:
|
|
14
|
+
- A clear **title** (≤ ~70 chars, sentence case, no trailing period)
|
|
15
|
+
- A **body** with these sections (omit any that don't apply):
|
|
16
|
+
- **Problem / idea** — restate the idea clearly in 1–3 sentences
|
|
17
|
+
- **Proposed approach** — only include if the approach is obvious from the idea
|
|
18
|
+
- **Files likely involved** — only include if you can quickly identify them from the repo (e.g. a new rule idea should point at the right WCAG principle directory under `lib/a11y/lint/rules/`)
|
|
19
|
+
|
|
20
|
+
2. **Clarify only if necessary** — one round, max. Voice input drops context, but too many questions defeat frictionless capture. Skip clarification when the idea is unambiguous. Typical clarifications: bug vs. feature, which rule/file, which template pipeline (Slim/ERB/Phlex).
|
|
21
|
+
|
|
22
|
+
3. **Create the issue** with `gh issue create --title "..." --body "$(cat <<'EOF' ... EOF)"` against the current repo. Do **not** pass `--repo`. Do **not** add labels.
|
|
23
|
+
|
|
24
|
+
4. **Return the issue URL** that `gh issue create` prints. Keep your final reply to one line — just the URL (and a one-line summary if helpful).
|
|
25
|
+
|
|
26
|
+
## Out of scope
|
|
27
|
+
|
|
28
|
+
- Auto-suggesting labels
|
|
29
|
+
- Cross-repo support
|
|
30
|
+
- Duplicate detection / linking related issues
|
|
31
|
+
- Editing or closing existing issues
|
|
32
|
+
|
|
33
|
+
## Style notes
|
|
34
|
+
|
|
35
|
+
- Bias toward **fast capture** over thoroughness — the issue can always be edited on GitHub.
|
|
36
|
+
- Don't pad the body with boilerplate. If "Proposed approach" or "Files likely involved" isn't obvious, leave it out.
|
|
37
|
+
- Match the tone of recent issues in this repo if you can quickly check one.
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.2] - 2026-05-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- `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
|
|
15
|
+
- `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
|
|
16
|
+
- `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
|
|
17
|
+
- `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
|
|
18
|
+
|
|
19
|
+
## [0.14.1] - 2026-05-03
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- `AnchorMissingAccessibleName` and `ButtonMissingAccessibleName` no longer report Phlex `a`/`button` tags whose accessible text comes from a phlex-rails value helper (`t`, `translate`, `l`, `localize`, `pluralize`, `truncate`, the `number_to_*` / `number_with_*` helpers, `highlight`, `excerpt`) or Phlex's built-in `text`. Previously only `plain` was recognized
|
|
24
|
+
|
|
10
25
|
## [0.14.0] - 2026-04-28
|
|
11
26
|
|
|
12
27
|
### Changed
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Contributing to the a11y-lint docs site
|
|
2
|
+
|
|
3
|
+
## Authoring rule reference pages
|
|
4
|
+
|
|
5
|
+
Rule pages live in `src/rules/`, one Markdown file per WCAG concept. Each rule class is an anchor section within that page.
|
|
6
|
+
|
|
7
|
+
### Showing example code in multiple template languages
|
|
8
|
+
|
|
9
|
+
a11y-lint supports ERB, Slim, and Phlex. When a rule page shows example code, render it through the `Shared::CodeTabs` component so all three pipelines collapse into one tabbed block instead of three stacked code blocks.
|
|
10
|
+
|
|
11
|
+
ERB is required and is the default tab. Slim and Phlex are optional — omit either kwarg and the tab won't render.
|
|
12
|
+
|
|
13
|
+
```erb
|
|
14
|
+
<%= render Shared::CodeTabs.new(
|
|
15
|
+
erb: <<~ERB,
|
|
16
|
+
<img src="hero.jpg" alt="Team celebrating after a product launch">
|
|
17
|
+
ERB
|
|
18
|
+
slim: <<~SLIM,
|
|
19
|
+
img src="hero.jpg" alt="Team celebrating after a product launch"
|
|
20
|
+
SLIM
|
|
21
|
+
phlex: <<~PHLEX,
|
|
22
|
+
img(src: "hero.jpg", alt: "Team celebrating after a product launch")
|
|
23
|
+
PHLEX
|
|
24
|
+
) %>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The component takes care of:
|
|
28
|
+
|
|
29
|
+
- Rouge syntax highlighting per tab (using the `erb`, `slim`, and `ruby` lexers).
|
|
30
|
+
- WAI-ARIA tab pattern: `role="tablist"` / `role="tab"` / `role="tabpanel"` with full `aria-*` wiring.
|
|
31
|
+
- Keyboard navigation: Left/Right arrows move between tabs, Home/End jump to the first/last tab, Enter/Space activate, Tab moves into the panel.
|
|
32
|
+
- A no-JavaScript fallback that shows the ERB panel only.
|
|
33
|
+
- `prefers-reduced-motion`.
|
|
34
|
+
|
|
35
|
+
Multiple instances on the same page get unique IDs automatically.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
.code-tabs {
|
|
2
|
+
margin: 1.5rem 0;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.code-tabs__tablist {
|
|
6
|
+
display: none;
|
|
7
|
+
gap: 0.25rem;
|
|
8
|
+
border-bottom: 1px solid #ddd;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.code-tabs.is-enhanced .code-tabs__tablist {
|
|
12
|
+
display: flex;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.code-tabs__tab {
|
|
16
|
+
background: transparent;
|
|
17
|
+
border: 1px solid transparent;
|
|
18
|
+
border-bottom: none;
|
|
19
|
+
border-radius: 4px 4px 0 0;
|
|
20
|
+
padding: 0.5rem 1rem;
|
|
21
|
+
font: inherit;
|
|
22
|
+
font-weight: 600;
|
|
23
|
+
color: var(--body-color);
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
margin-bottom: -1px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.code-tabs__tab[aria-selected="true"] {
|
|
29
|
+
background: white;
|
|
30
|
+
border-color: #ddd;
|
|
31
|
+
color: var(--heading-color);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.code-tabs__tab:focus-visible,
|
|
35
|
+
.code-tabs__panel:focus-visible {
|
|
36
|
+
outline: 2px solid var(--action-color);
|
|
37
|
+
outline-offset: 2px;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.code-tabs__panel {
|
|
41
|
+
border: 1px solid #ddd;
|
|
42
|
+
border-radius: 0 4px 4px 4px;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.code-tabs.is-enhanced .code-tabs__panel {
|
|
46
|
+
border-top-left-radius: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.code-tabs__panel pre {
|
|
50
|
+
margin: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@media (prefers-reduced-motion: reduce) {
|
|
54
|
+
.code-tabs__tab {
|
|
55
|
+
transition: none;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<div class="code-tabs">
|
|
2
|
+
<div class="code-tabs__tablist" role="tablist" aria-label="Template language">
|
|
3
|
+
<% tabs.each_with_index do |tab, i| %>
|
|
4
|
+
<button
|
|
5
|
+
type="button"
|
|
6
|
+
role="tab"
|
|
7
|
+
id="<%= tab_id(tab[:key]) %>"
|
|
8
|
+
aria-controls="<%= panel_id(tab[:key]) %>"
|
|
9
|
+
aria-selected="<%= i.zero? %>"
|
|
10
|
+
tabindex="<%= i.zero? ? 0 : -1 %>"
|
|
11
|
+
class="code-tabs__tab"
|
|
12
|
+
>
|
|
13
|
+
<%= tab[:label] %>
|
|
14
|
+
</button>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<% tabs.each_with_index do |tab, i| %>
|
|
19
|
+
<div
|
|
20
|
+
role="tabpanel"
|
|
21
|
+
id="<%= panel_id(tab[:key]) %>"
|
|
22
|
+
aria-labelledby="<%= tab_id(tab[:key]) %>"
|
|
23
|
+
class="code-tabs__panel"
|
|
24
|
+
tabindex="0"
|
|
25
|
+
<%= "hidden" unless i.zero? %>
|
|
26
|
+
>
|
|
27
|
+
<%= raw highlighted(tab[:key], tab[:lexer]) %>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function initCodeTabs(root) {
|
|
2
|
+
if (root.classList.contains("is-enhanced")) return
|
|
3
|
+
|
|
4
|
+
const tabs = Array.from(root.querySelectorAll('[role="tab"]'))
|
|
5
|
+
const panels = Array.from(root.querySelectorAll('[role="tabpanel"]'))
|
|
6
|
+
if (tabs.length === 0) return
|
|
7
|
+
|
|
8
|
+
root.classList.add("is-enhanced")
|
|
9
|
+
|
|
10
|
+
function activate(index, { focus = true } = {}) {
|
|
11
|
+
tabs.forEach((tab, i) => {
|
|
12
|
+
const selected = i === index
|
|
13
|
+
tab.setAttribute("aria-selected", String(selected))
|
|
14
|
+
tab.setAttribute("tabindex", selected ? "0" : "-1")
|
|
15
|
+
panels[i].hidden = !selected
|
|
16
|
+
})
|
|
17
|
+
if (focus) tabs[index].focus()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
tabs.forEach((tab, i) => {
|
|
21
|
+
tab.addEventListener("click", () => activate(i))
|
|
22
|
+
tab.addEventListener("keydown", (event) => {
|
|
23
|
+
let next = null
|
|
24
|
+
switch (event.key) {
|
|
25
|
+
case "ArrowRight":
|
|
26
|
+
next = (i + 1) % tabs.length
|
|
27
|
+
break
|
|
28
|
+
case "ArrowLeft":
|
|
29
|
+
next = (i - 1 + tabs.length) % tabs.length
|
|
30
|
+
break
|
|
31
|
+
case "Home":
|
|
32
|
+
next = 0
|
|
33
|
+
break
|
|
34
|
+
case "End":
|
|
35
|
+
next = tabs.length - 1
|
|
36
|
+
break
|
|
37
|
+
default:
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
event.preventDefault()
|
|
41
|
+
activate(next)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function initAllCodeTabs() {
|
|
47
|
+
document.querySelectorAll(".code-tabs").forEach(initCodeTabs)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (document.readyState === "loading") {
|
|
51
|
+
document.addEventListener("DOMContentLoaded", initAllCodeTabs)
|
|
52
|
+
} else {
|
|
53
|
+
initAllCodeTabs()
|
|
54
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "rouge"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
class Shared::CodeTabs < Bridgetown::Component
|
|
5
|
+
TABS = [
|
|
6
|
+
{ key: :erb, label: "ERB", lexer: "erb" },
|
|
7
|
+
{ key: :slim, label: "Slim", lexer: "slim" },
|
|
8
|
+
{ key: :phlex, label: "Phlex", lexer: "ruby" }
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :id
|
|
12
|
+
|
|
13
|
+
def initialize(erb:, slim: nil, phlex: nil)
|
|
14
|
+
@samples = { erb: erb, slim: slim, phlex: phlex }
|
|
15
|
+
@id = "code-tabs-#{SecureRandom.hex(4)}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def tabs
|
|
19
|
+
TABS.select { |tab| @samples[tab[:key]] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tab_id(key)
|
|
23
|
+
"#{id}-tab-#{key}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def panel_id(key)
|
|
27
|
+
"#{id}-panel-#{key}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def highlighted(key, lexer_name)
|
|
31
|
+
formatter = Rouge::Formatters::HTML.new
|
|
32
|
+
lexer = Rouge::Lexer.find(lexer_name) || Rouge::Lexers::PlainText.new
|
|
33
|
+
inner = formatter.format(lexer.lex(@samples[key].to_s))
|
|
34
|
+
%(<pre class="highlight"><code>#{inner}</code></pre>)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -67,15 +67,31 @@ Applies to: HTML `<img>` elements.
|
|
|
67
67
|
|
|
68
68
|
### Bad
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
<%= render Shared::CodeTabs.new(
|
|
71
|
+
erb: <<~ERB,
|
|
72
|
+
<img src="hero.jpg">
|
|
73
|
+
ERB
|
|
74
|
+
slim: <<~SLIM,
|
|
75
|
+
img src="hero.jpg"
|
|
76
|
+
SLIM
|
|
77
|
+
phlex: <<~PHLEX,
|
|
78
|
+
img(src: "hero.jpg")
|
|
79
|
+
PHLEX
|
|
80
|
+
) %>
|
|
73
81
|
|
|
74
82
|
### Good
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
<%= render Shared::CodeTabs.new(
|
|
85
|
+
erb: <<~ERB,
|
|
86
|
+
<img src="hero.jpg" alt="Team celebrating after a product launch">
|
|
87
|
+
ERB
|
|
88
|
+
slim: <<~SLIM,
|
|
89
|
+
img src="hero.jpg" alt="Team celebrating after a product launch"
|
|
90
|
+
SLIM
|
|
91
|
+
phlex: <<~PHLEX,
|
|
92
|
+
img(src: "hero.jpg", alt: "Team celebrating after a product launch")
|
|
93
|
+
PHLEX
|
|
94
|
+
) %>
|
|
79
95
|
|
|
80
96
|
For a decorative image, use an empty `alt` so assistive tech skips it:
|
|
81
97
|
|
data/lib/a11y/lint/cli.rb
CHANGED
data/lib/a11y/lint/phlex_node.rb
CHANGED
|
@@ -100,7 +100,31 @@ module A11y
|
|
|
100
100
|
|
|
101
101
|
kwarg_nodes(call_node).each_with_object({}) do |elem, h|
|
|
102
102
|
key = kwarg_key(elem.key)
|
|
103
|
-
|
|
103
|
+
next unless key
|
|
104
|
+
|
|
105
|
+
if elem.value.is_a?(Prism::HashNode)
|
|
106
|
+
flatten_nested_hash(key, elem.value, h)
|
|
107
|
+
else
|
|
108
|
+
h[key] = kwarg_value(elem.value)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Mirrors Phlex's render-time flattening of nested kwarg hashes
|
|
114
|
+
# (e.g. `aria: { label: "x" }` -> `aria-label="x"`). Inner key
|
|
115
|
+
# underscores become dashes the same way Phlex/Rails do.
|
|
116
|
+
def self.flatten_nested_hash(prefix, hash_node, attrs)
|
|
117
|
+
hash_node.elements.each do |inner|
|
|
118
|
+
next unless inner.is_a?(Prism::AssocNode)
|
|
119
|
+
next unless (inner_key = kwarg_key(inner.key))
|
|
120
|
+
|
|
121
|
+
full_key = "#{prefix}-#{inner_key.tr("_", "-")}"
|
|
122
|
+
|
|
123
|
+
if inner.value.is_a?(Prism::HashNode)
|
|
124
|
+
flatten_nested_hash(full_key, inner.value, attrs)
|
|
125
|
+
else
|
|
126
|
+
attrs[full_key] = kwarg_value(inner.value)
|
|
127
|
+
end
|
|
104
128
|
end
|
|
105
129
|
end
|
|
106
130
|
|
|
@@ -125,8 +149,13 @@ module A11y
|
|
|
125
149
|
end
|
|
126
150
|
end
|
|
127
151
|
|
|
128
|
-
private_class_method
|
|
129
|
-
|
|
152
|
+
private_class_method(
|
|
153
|
+
:extract_attributes,
|
|
154
|
+
:flatten_nested_hash,
|
|
155
|
+
:kwarg_key,
|
|
156
|
+
:kwarg_nodes,
|
|
157
|
+
:kwarg_value
|
|
158
|
+
)
|
|
130
159
|
end
|
|
131
160
|
end
|
|
132
161
|
end
|
|
@@ -0,0 +1,180 @@
|
|
|
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:)
|
|
26
|
+
new(block, children).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, []).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)
|
|
54
|
+
@block = block
|
|
55
|
+
@children = children
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def scan
|
|
59
|
+
return false unless @block.is_a?(Prism::BlockNode)
|
|
60
|
+
|
|
61
|
+
scan_for_text(@block) || @children.any?(&:text_content?)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def text_emitting?(node)
|
|
65
|
+
auto_emitted_text?(node)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# A Phlex tag block produces accessible text if any statement
|
|
71
|
+
# contains an explicit text marker (yield, plain/text/t/..., a
|
|
72
|
+
# non-blank string literal), or if the block's last expression
|
|
73
|
+
# auto-emits a value Phlex renders as text.
|
|
74
|
+
def scan_for_text(block)
|
|
75
|
+
statements = block_statements(block)
|
|
76
|
+
return false if statements.empty?
|
|
77
|
+
|
|
78
|
+
return true if statements.any? { |stmt| explicit_text?(stmt) }
|
|
79
|
+
|
|
80
|
+
auto_emitted_text?(statements.last)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def block_statements(block)
|
|
84
|
+
return [] unless block.is_a?(Prism::BlockNode)
|
|
85
|
+
|
|
86
|
+
body = block.body
|
|
87
|
+
body.is_a?(Prism::StatementsNode) ? body.body : []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def explicit_text?(node)
|
|
91
|
+
return false if node.nil?
|
|
92
|
+
return true if node.is_a?(Prism::YieldNode)
|
|
93
|
+
return true if self.class.non_blank_string_literal?(node)
|
|
94
|
+
return text_call?(node) if node.is_a?(Prism::CallNode)
|
|
95
|
+
|
|
96
|
+
# Recurse only into conditional/grouping nodes — descending into
|
|
97
|
+
# call args would treat string literals like `foo("bar")`'s "bar"
|
|
98
|
+
# as block text, which it isn't.
|
|
99
|
+
return false unless conditional_container?(node)
|
|
100
|
+
|
|
101
|
+
node.child_nodes.compact.any? { |c| explicit_text?(c) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def auto_emitted_text?(node)
|
|
105
|
+
return false if node.nil?
|
|
106
|
+
return true if explicit_text?(node)
|
|
107
|
+
|
|
108
|
+
case node
|
|
109
|
+
when Prism::LocalVariableReadNode
|
|
110
|
+
true
|
|
111
|
+
when Prism::CallNode
|
|
112
|
+
auto_emitted_call?(node)
|
|
113
|
+
when Prism::IfNode, Prism::UnlessNode
|
|
114
|
+
if_branch_lasts(node).any? { |last| auto_emitted_text?(last) }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def auto_emitted_call?(call)
|
|
119
|
+
return true if call.receiver
|
|
120
|
+
|
|
121
|
+
name = call.name.to_s
|
|
122
|
+
return false if PhlexNode.html_tag?(name)
|
|
123
|
+
return true if TEXT_CALLS.include?(name)
|
|
124
|
+
|
|
125
|
+
# Lowercase receiverless calls (locals, helper methods) auto-emit
|
|
126
|
+
# their return value. Capitalized names are Phlex components,
|
|
127
|
+
# which emit their own HTML, not text.
|
|
128
|
+
lowercase_name?(name)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def conditional_container?(node)
|
|
132
|
+
node.is_a?(Prism::IfNode) ||
|
|
133
|
+
node.is_a?(Prism::UnlessNode) ||
|
|
134
|
+
node.is_a?(Prism::BeginNode) ||
|
|
135
|
+
node.is_a?(Prism::StatementsNode) ||
|
|
136
|
+
node.is_a?(Prism::ElseNode) ||
|
|
137
|
+
node.is_a?(Prism::ParenthesesNode)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def lowercase_name?(name)
|
|
141
|
+
first = name[0]
|
|
142
|
+
first && first == first.downcase && first != first.upcase
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def if_branch_lasts(node)
|
|
146
|
+
if_branch_statements(node).flat_map do |stmts|
|
|
147
|
+
stmts.is_a?(Prism::StatementsNode) ? [stmts.body.last].compact : []
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def if_branch_statements(node)
|
|
152
|
+
branches = [node.statements]
|
|
153
|
+
collect_else_branches(if_else_clause(node), branches)
|
|
154
|
+
branches.compact
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def collect_else_branches(current, branches)
|
|
158
|
+
while current.is_a?(Prism::IfNode) || current.is_a?(Prism::UnlessNode)
|
|
159
|
+
branches << current.statements
|
|
160
|
+
current = if_else_clause(current)
|
|
161
|
+
end
|
|
162
|
+
branches << current.statements if current.is_a?(Prism::ElseNode)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def if_else_clause(node)
|
|
166
|
+
case node
|
|
167
|
+
when Prism::IfNode then node.subsequent
|
|
168
|
+
when Prism::UnlessNode then node.else_clause
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def text_call?(node)
|
|
173
|
+
node.is_a?(Prism::CallNode) &&
|
|
174
|
+
node.receiver.nil? &&
|
|
175
|
+
TEXT_CALLS.include?(node.name.to_s)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
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_children
|
|
61
|
+
block_node ? block_node.child_nodes.compact : []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def block_has_text?(children = [])
|
|
65
|
+
BlockTextScanner.scan(block_node, children: children)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Phlex's tag API emits the first positional argument as text
|
|
69
|
+
# content (`a("Click me", href: "/x")` is equivalent to
|
|
70
|
+
# `a(href: "/x") { "Click me" }`).
|
|
71
|
+
def arg_has_text?
|
|
72
|
+
arg = first_positional_arg
|
|
73
|
+
return false if arg.nil?
|
|
74
|
+
|
|
75
|
+
BlockTextScanner.text_emitting?(arg)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def first_positional_arg
|
|
79
|
+
return nil unless call_node.arguments
|
|
80
|
+
|
|
81
|
+
call_node.arguments.arguments.find do |arg|
|
|
82
|
+
!arg.is_a?(Prism::KeywordHashNode) &&
|
|
83
|
+
!arg.is_a?(Prism::BlockArgumentNode)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def class_values
|
|
88
|
+
PhlexNode.kwarg_class_values(call_node)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
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
|
|
@@ -30,27 +32,25 @@ module A11y
|
|
|
30
32
|
attr_reader :rules, :configuration
|
|
31
33
|
|
|
32
34
|
def walk(node)
|
|
33
|
-
if
|
|
34
|
-
process_call(
|
|
35
|
+
if (call = PhlexCall.wrap(node))
|
|
36
|
+
process_call(call)
|
|
35
37
|
else
|
|
36
38
|
node.child_nodes.compact.each { |c| walk(c) }
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
41
|
|
|
40
|
-
def process_call(
|
|
41
|
-
|
|
42
|
-
check_tag(node)
|
|
43
|
-
else
|
|
44
|
-
check_helper(node)
|
|
45
|
-
end
|
|
42
|
+
def process_call(call)
|
|
43
|
+
call.tag? ? check_tag(call) : check_helper(call)
|
|
46
44
|
end
|
|
47
45
|
|
|
48
|
-
def check_tag(
|
|
49
|
-
children = collect_block_children(
|
|
50
|
-
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)
|
|
51
51
|
check_node(
|
|
52
52
|
PhlexNode.build_tag(
|
|
53
|
-
|
|
53
|
+
call.call_node,
|
|
54
54
|
children: children,
|
|
55
55
|
text_content: has_text,
|
|
56
56
|
configuration: configuration
|
|
@@ -58,114 +58,100 @@ module A11y
|
|
|
58
58
|
)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
def check_helper(
|
|
62
|
-
codes, has_text = analyze_helper_block(
|
|
61
|
+
def check_helper(call)
|
|
62
|
+
codes, has_text = analyze_helper_block(call)
|
|
63
63
|
helper = PhlexNode.build_helper(
|
|
64
|
-
|
|
64
|
+
call.call_node,
|
|
65
65
|
block_body_codes: codes,
|
|
66
66
|
block_has_text_children: has_text,
|
|
67
67
|
configuration: configuration
|
|
68
68
|
)
|
|
69
69
|
check_node(helper)
|
|
70
|
-
walk_block(
|
|
70
|
+
walk_block(call.block_node)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def collect_block_children(
|
|
74
|
-
return [] unless
|
|
73
|
+
def collect_block_children(block_node)
|
|
74
|
+
return [] unless block_node
|
|
75
75
|
|
|
76
|
-
[].tap { |c| gather_children(
|
|
76
|
+
[].tap { |c| gather_children(block_node, c) }
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def gather_children(parent, result)
|
|
80
80
|
parent.child_nodes.compact.each do |child|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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)
|
|
85
86
|
else
|
|
86
87
|
gather_children(child, result)
|
|
87
88
|
end
|
|
88
89
|
end
|
|
89
90
|
end
|
|
90
91
|
|
|
91
|
-
def gather_tag_child(
|
|
92
|
-
kids = collect_block_children(
|
|
93
|
-
has_text =
|
|
92
|
+
def gather_tag_child(call, result)
|
|
93
|
+
kids = collect_block_children(call.block_node)
|
|
94
|
+
has_text = call.arg_has_text? || call.block_has_text?(kids)
|
|
94
95
|
tag = PhlexNode.build_tag(
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
call.call_node, children: kids, text_content: has_text,
|
|
97
|
+
configuration: configuration
|
|
97
98
|
)
|
|
98
99
|
check_node(tag)
|
|
99
|
-
result << tag unless hidden_wrapper_tag?(
|
|
100
|
+
result << tag unless hidden_wrapper_tag?(call)
|
|
100
101
|
end
|
|
101
102
|
|
|
102
|
-
def hidden_wrapper_tag?(
|
|
103
|
+
def hidden_wrapper_tag?(call)
|
|
103
104
|
classes = configuration.hidden_wrapper_classes
|
|
104
105
|
return false if classes.empty?
|
|
105
106
|
|
|
106
|
-
|
|
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?)
|
|
107
|
+
call.class_values.any? { |klass| classes.include?(klass) }
|
|
119
108
|
end
|
|
120
109
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
(!receiverless_call?(child) && scan_for_text(child))
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def analyze_helper_block(call_node)
|
|
129
|
-
block = call_node.block
|
|
130
|
-
return [nil, false] unless block.is_a?(Prism::BlockNode)
|
|
110
|
+
def analyze_helper_block(call)
|
|
111
|
+
block = call.block_node
|
|
112
|
+
return [nil, false] unless block
|
|
131
113
|
|
|
132
114
|
codes = []
|
|
133
115
|
has_text = scan_block_content(block, codes)
|
|
134
116
|
[codes.empty? ? nil : codes, has_text]
|
|
135
117
|
end
|
|
136
118
|
|
|
137
|
-
# rubocop:disable Metrics/CyclomaticComplexity
|
|
138
|
-
# rubocop:disable Metrics/PerceivedComplexity
|
|
139
119
|
def scan_block_content(node, codes)
|
|
140
120
|
node.child_nodes.compact.each do |child|
|
|
141
|
-
|
|
142
|
-
return true if child.is_a?(Prism::YieldNode)
|
|
143
|
-
return true if tag_call?(child) && child.block
|
|
144
|
-
next if tag_call?(child)
|
|
145
|
-
next codes << child.slice if receiverless_call?(child)
|
|
146
|
-
return true if scan_block_content(child, codes)
|
|
121
|
+
return true if visit_helper_block_child(child, codes)
|
|
147
122
|
end
|
|
148
123
|
false
|
|
149
124
|
end
|
|
150
|
-
# rubocop:enable Metrics/CyclomaticComplexity
|
|
151
|
-
# rubocop:enable Metrics/PerceivedComplexity
|
|
152
125
|
|
|
153
|
-
def
|
|
154
|
-
|
|
126
|
+
def visit_helper_block_child(child, codes)
|
|
127
|
+
child_call = PhlexCall.wrap(child)
|
|
128
|
+
return handle_call_block_child(child_call, codes) if child_call
|
|
129
|
+
|
|
130
|
+
block_child_text?(child) || scan_block_content(child, codes)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle_call_block_child(child_call, codes)
|
|
134
|
+
return false if child_call.tag? && hidden_wrapper_tag?(child_call)
|
|
135
|
+
return true if tag_call_has_text_block?(child_call)
|
|
136
|
+
return false if child_call.tag?
|
|
155
137
|
|
|
156
|
-
|
|
138
|
+
codes << child_call.call_node.slice
|
|
139
|
+
false
|
|
157
140
|
end
|
|
158
141
|
|
|
159
|
-
def
|
|
160
|
-
|
|
142
|
+
def block_child_text?(child)
|
|
143
|
+
child.is_a?(Prism::YieldNode) ||
|
|
144
|
+
BlockTextScanner.non_blank_string_literal?(child)
|
|
161
145
|
end
|
|
162
146
|
|
|
163
|
-
def
|
|
164
|
-
|
|
147
|
+
def tag_call_has_text_block?(child_call)
|
|
148
|
+
child_call.tag? && !child_call.block.nil?
|
|
165
149
|
end
|
|
166
150
|
|
|
167
|
-
def
|
|
168
|
-
|
|
151
|
+
def walk_block(block_node)
|
|
152
|
+
return unless block_node
|
|
153
|
+
|
|
154
|
+
block_node.child_nodes.compact.each { |c| walk(c) }
|
|
169
155
|
end
|
|
170
156
|
|
|
171
157
|
def check_node(node)
|
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.14.
|
|
4
|
+
version: 0.14.2
|
|
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/task.md"
|
|
48
50
|
- ".claude/rules/testing.md"
|
|
49
51
|
- ".rubocop.yml"
|
|
50
52
|
- CHANGELOG.md
|
|
@@ -55,6 +57,7 @@ files:
|
|
|
55
57
|
- Rakefile
|
|
56
58
|
- docs/.gitignore
|
|
57
59
|
- docs/.ruby-version
|
|
60
|
+
- docs/CONTRIBUTING.md
|
|
58
61
|
- docs/Gemfile
|
|
59
62
|
- docs/Gemfile.lock
|
|
60
63
|
- docs/README.md
|
|
@@ -80,6 +83,10 @@ files:
|
|
|
80
83
|
- docs/src/404.html
|
|
81
84
|
- docs/src/500.html
|
|
82
85
|
- docs/src/CNAME
|
|
86
|
+
- docs/src/_components/shared/code_tabs.css
|
|
87
|
+
- docs/src/_components/shared/code_tabs.erb
|
|
88
|
+
- docs/src/_components/shared/code_tabs.js
|
|
89
|
+
- docs/src/_components/shared/code_tabs.rb
|
|
83
90
|
- docs/src/_components/shared/navbar.erb
|
|
84
91
|
- docs/src/_components/shared/navbar.rb
|
|
85
92
|
- docs/src/_data/site_metadata.yml
|
|
@@ -111,6 +118,8 @@ files:
|
|
|
111
118
|
- lib/a11y/lint/offense.rb
|
|
112
119
|
- lib/a11y/lint/phlex_node.rb
|
|
113
120
|
- lib/a11y/lint/phlex_runner.rb
|
|
121
|
+
- lib/a11y/lint/phlex_runner/block_text_scanner.rb
|
|
122
|
+
- lib/a11y/lint/phlex_runner/phlex_call.rb
|
|
114
123
|
- lib/a11y/lint/phlex_tags.rb
|
|
115
124
|
- lib/a11y/lint/ruby_code.rb
|
|
116
125
|
- lib/a11y/lint/rules/perceivable/area_missing_alt.rb
|