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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c80dbcc5dec24d2850b42b84b2bbf512dc792f9c77e41ba4fd778d792c0d750a
4
- data.tar.gz: aff2c4e3002a785f2f8fbfc5cfe9b115466cc3850299114b8724a82b5c71e52a
3
+ metadata.gz: 27003e9edd9b1055dd6c2ddd37ac6da8958bb3a608f5bb8fbfa103eb167ddb0e
4
+ data.tar.gz: 5af458356a27d126373c8780ca2cec362d7d2e05b70edfdb45236a90f70b82a9
5
5
  SHA512:
6
- metadata.gz: c6431784a0c4704f7040953c2ef881678b13e61fee513f639c1ff4de019a4bb1599633638902764dc0cf9f9b25fe553349262639e0f30029033e5498f252f23a
7
- data.tar.gz: 5b140955d0e2eadc1fefe2314bf85a16f0129d0c2cf569deb507c829ca0cf1aeb633ed601d670e4eef814d1b825a0576a5ad5d41e733bf4c8fceae7b6040f4fe
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
@@ -29,5 +29,4 @@ Metrics/AbcSize:
29
29
  - "test/**/*"
30
30
 
31
31
  Metrics/MethodLength:
32
- Exclude:
33
- - "test/**/*"
32
+ Enabled: false
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
- ```erb
71
- <img src="hero.jpg">
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
- ```erb
77
- <img src="hero.jpg" alt="Team celebrating after a product launch">
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
@@ -34,7 +34,7 @@ module A11y
34
34
  option_parser.parse!(@argv)
35
35
  end
36
36
 
37
- def option_parser # rubocop:disable Metrics/MethodLength
37
+ def option_parser
38
38
  OptionParser.new do |opts|
39
39
  opts.banner = "Usage: a11y-lint [options] [file_or_directory ...]"
40
40
 
@@ -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
- h[key] = kwarg_value(elem.value) if key
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 :kwarg_key, :kwarg_nodes, :kwarg_value,
129
- :extract_attributes
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 receiverless_call?(node)
34
- process_call(node)
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(node)
41
- if PhlexNode.html_tag?(node.name.to_s)
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(node)
49
- children = collect_block_children(node.block)
50
- has_text = tag_block_has_text?(node.block, children)
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
- node,
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(node)
62
- codes, has_text = analyze_helper_block(node)
61
+ def check_helper(call)
62
+ codes, has_text = analyze_helper_block(call)
63
63
  helper = PhlexNode.build_helper(
64
- node,
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(node.block)
70
+ walk_block(call.block_node)
71
71
  end
72
72
 
73
- def collect_block_children(block)
74
- return [] unless block.is_a?(Prism::BlockNode)
73
+ def collect_block_children(block_node)
74
+ return [] unless block_node
75
75
 
76
- [].tap { |c| gather_children(block, c) }
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
- if tag_call?(child)
82
- gather_tag_child(child, result)
83
- elsif receiverless_call?(child)
84
- check_helper(child)
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(child, result)
92
- kids = collect_block_children(child.block)
93
- has_text = tag_block_has_text?(child.block, kids)
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
- child, children: kids, text_content: has_text,
96
- configuration: configuration
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?(child)
100
+ result << tag unless hidden_wrapper_tag?(call)
100
101
  end
101
102
 
102
- def hidden_wrapper_tag?(call_node)
103
+ def hidden_wrapper_tag?(call)
103
104
  classes = configuration.hidden_wrapper_classes
104
105
  return false if classes.empty?
105
106
 
106
- tag_class_values(call_node).any? { |klass| classes.include?(klass) }
107
- end
108
-
109
- def tag_class_values(call_node)
110
- return [] unless call_node.arguments
111
-
112
- PhlexNode.kwarg_class_values(call_node)
113
- end
114
-
115
- def tag_block_has_text?(block, children)
116
- return false unless block.is_a?(Prism::BlockNode)
117
-
118
- scan_for_text(block) || children.any?(&:text_content?)
107
+ call.class_values.any? { |klass| classes.include?(klass) }
119
108
  end
120
109
 
121
- def scan_for_text(node)
122
- node.child_nodes.compact.any? do |child|
123
- text_call?(child) || child.is_a?(Prism::YieldNode) ||
124
- (!receiverless_call?(child) && scan_for_text(child))
125
- end
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
- next if tag_call?(child) && hidden_wrapper_tag?(child)
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 walk_block(block)
154
- return unless block.is_a?(Prism::BlockNode)
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
- block.child_nodes.compact.each { |c| walk(c) }
138
+ codes << child_call.call_node.slice
139
+ false
157
140
  end
158
141
 
159
- def tag_call?(node)
160
- receiverless_call?(node) && PhlexNode.html_tag?(node.name.to_s)
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 text_call?(node)
164
- receiverless_call?(node) && node.name.to_s == "plain"
147
+ def tag_call_has_text_block?(child_call)
148
+ child_call.tag? && !child_call.block.nil?
165
149
  end
166
150
 
167
- def receiverless_call?(node)
168
- node.is_a?(Prism::CallNode) && node.receiver.nil?
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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A11y
4
4
  module Lint
5
- VERSION = "0.14.0"
5
+ VERSION = "0.14.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: a11y-lint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
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