From b603236082fd3c12ae6f319e56460e905e2b7272 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 22 Apr 2026 08:53:55 -0600 Subject: [PATCH 1/8] Implement `a11y-svg-has-accessible-text` linter rule Add a new linter rule that ensures `` elements have accessible text. The rule reports an offense when an `` is missing all of the following: - `aria-label` attribute - `aria-labelledby` attribute - A nested `` child element SVGs marked as decorative with `aria-hidden="true"` are exempt. Based on the erblint-github `GitHub::Accessibility::SvgHasAccessibleText` rule. Closes #1226 --- javascript/packages/linter/src/rules.ts | 2 + .../src/rules/a11y-svg-has-accessible-text.ts | 99 +++++++++++++++++++ javascript/packages/linter/src/rules/index.ts | 1 + .../a11y-svg-has-accessible-text.test.ts | 54 ++++++++++ 4 files changed, 156 insertions(+) create mode 100644 javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts create mode 100644 javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index 7a4f935b3..f29291322 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -3,6 +3,7 @@ import type { RuleClass } from "./types.js" import { A11yNoAccesskeyAttributeRule } from "./rules/a11y-no-accesskey-attribute.js" import { A11yNoAriaUnsupportedElementsRule } from "./rules/a11y-no-aria-unsupported-elements.js" import { A11yNoAutofocusAttributeRule } from "./rules/a11y-no-autofocus-attribute.js" +import { A11ySvgHasAccessibleTextRule } from "./rules/a11y-svg-has-accessible-text.js" import { ActionViewNoSilentHelperRule } from "./rules/actionview-no-silent-helper.js" import { ActionViewNoSilentRenderRule } from "./rules/actionview-no-silent-render.js" @@ -106,6 +107,7 @@ export const rules: RuleClass[] = [ A11yNoAccesskeyAttributeRule, A11yNoAriaUnsupportedElementsRule, A11yNoAutofocusAttributeRule, + A11ySvgHasAccessibleTextRule, ActionViewNoSilentHelperRule, ActionViewNoSilentRenderRule, diff --git a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts new file mode 100644 index 000000000..4bc13504f --- /dev/null +++ b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts @@ -0,0 +1,99 @@ +import { BaseRuleVisitor } from "./rule-utils.js" +import { hasAttribute, getAttributeValue, findAttributeByName, getAttributes, getTagLocalName, isHTMLElementNode } from "@herb-tools/core" + +import { ParserRule } from "../types.js" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" +import type { HTMLElementNode, ParseResult, ParserOptions } from "@herb-tools/core" + +class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { + visitHTMLElementNode(node: HTMLElementNode): void { + this.checkSvgElement(node) + super.visitHTMLElementNode(node) + } + + private checkSvgElement(node: HTMLElementNode): void { + const tagName = getTagLocalName(node) + + if (tagName !== "svg") { + return + } + + if (this.hasAriaHidden(node)) { + return + } + + if (this.hasAriaLabel(node) || this.hasAriaLabelledby(node) || this.hasDirectTitleChild(node)) { + return + } + + this.addOffense( + "`` must have accessible text. Set `aria-label`, or `aria-labelledby`, or nest a `` element. If the `` is decorative, hide it with `aria-hidden=\"true\"`.", + node.tag_name!.location, + ) + } + + private hasAriaLabel(node: HTMLElementNode): boolean { + return hasAttribute(node.open_tag!, "aria-label") + } + + private hasAriaLabelledby(node: HTMLElementNode): boolean { + return hasAttribute(node.open_tag!, "aria-labelledby") + } + + private hasAriaHidden(node: HTMLElementNode): boolean { + if (!hasAttribute(node.open_tag!, "aria-hidden")) { + return false + } + + const attributes = getAttributes(node.open_tag!) + const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden") + + if (!ariaHiddenAttr) { + return false + } + + const value = getAttributeValue(ariaHiddenAttr) + + return value === "true" + } + + private hasDirectTitleChild(node: HTMLElementNode): boolean { + if (!node.body || node.body.length === 0) { + return false + } + + for (const child of node.body) { + if (isHTMLElementNode(child)) { + const childTagName = getTagLocalName(child) + + if (childTagName === "title") { + return true + } + } + } + + return false + } +} + +export class A11ySvgHasAccessibleTextRule extends ParserRule { + static ruleName = "a11y-svg-has-accessible-text" + static introducedIn = this.version("0.9.6") + + get defaultConfig(): FullRuleConfig { + return { + enabled: false, + severity: "warning" + } + } + + get parserOptions(): Partial { + return { action_view_helpers: true } + } + + check(result: ParseResult, context?: Partial): UnboundLintOffense[] { + const visitor = new SvgHasAccessibleTextVisitor(this.ruleName, context) + visitor.visit(result.value) + return visitor.offenses + } +} diff --git a/javascript/packages/linter/src/rules/index.ts b/javascript/packages/linter/src/rules/index.ts index 815093506..ff8c429ee 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -1,6 +1,7 @@ export * from "./a11y-no-accesskey-attribute.js" export * from "./a11y-no-aria-unsupported-elements.js" export * from "./a11y-no-autofocus-attribute.js" +export * from "./a11y-svg-has-accessible-text.js" export * from "./rule-utils.js" export * from "./prism-rule-utils.js" diff --git a/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts b/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts new file mode 100644 index 000000000..eba2da63c --- /dev/null +++ b/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts @@ -0,0 +1,54 @@ +import { describe, test } from "vitest" +import { A11ySvgHasAccessibleTextRule } from "../../src/rules/a11y-svg-has-accessible-text.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(A11ySvgHasAccessibleTextRule) + +const offenseMessage = '`` must have accessible text. Set `aria-label`, or `aria-labelledby`, or nest a `` element. If the `` is decorative, hide it with `aria-hidden="true"`.' + +describe("a11y-svg-has-accessible-text", () => { + test("passes for svg with aria-label", () => { + expectNoOffenses('') + }) + + test("passes for svg with aria-labelledby", () => { + expectNoOffenses('') + }) + + test("passes for svg with nested title element", () => { + expectNoOffenses('A circle') + }) + + test("passes for svg with aria-hidden=true", () => { + expectNoOffenses('') + }) + + test("fails for svg without accessible text", () => { + expectWarning(offenseMessage) + assertOffenses('') + }) + + test("fails for empty svg without accessible text", () => { + expectWarning(offenseMessage) + assertOffenses('') + }) + + test("fails for svg with aria-hidden=false", () => { + expectWarning(offenseMessage) + assertOffenses('') + }) + + test("ignores non-svg elements", () => { + expectNoOffenses('
') + }) + + test("fails for multiple svg elements without accessible text", () => { + expectWarning(offenseMessage) + expectWarning(offenseMessage) + assertOffenses('') + }) + + test("passes for svg with both aria-label and title", () => { + expectNoOffenses('A circle') + }) +}) From 7588a5e5f735bc9e379aa333e8c07a2cfee7d4ba Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 22 Apr 2026 09:47:51 -0600 Subject: [PATCH 2/8] fix ts error --- .../src/rules/a11y-svg-has-accessible-text.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts index 4bc13504f..3c9990bc1 100644 --- a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts +++ b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts @@ -3,7 +3,7 @@ import { hasAttribute, getAttributeValue, findAttributeByName, getAttributes, ge import { ParserRule } from "../types.js" import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" -import type { HTMLElementNode, ParseResult, ParserOptions } from "@herb-tools/core" +import type { HTMLElementNode, HTMLOpenTagNode, ParseResult, ParserOptions } from "@herb-tools/core" class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { visitHTMLElementNode(node: HTMLElementNode): void { @@ -33,19 +33,22 @@ class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { } private hasAriaLabel(node: HTMLElementNode): boolean { - return hasAttribute(node.open_tag!, "aria-label") + const openTag = node.open_tag as HTMLOpenTagNode + return hasAttribute(openTag, "aria-label") } private hasAriaLabelledby(node: HTMLElementNode): boolean { - return hasAttribute(node.open_tag!, "aria-labelledby") + const openTag = node.open_tag as HTMLOpenTagNode + return hasAttribute(openTag, "aria-labelledby") } private hasAriaHidden(node: HTMLElementNode): boolean { - if (!hasAttribute(node.open_tag!, "aria-hidden")) { + const openTag = node.open_tag as HTMLOpenTagNode + if (!hasAttribute(openTag, "aria-hidden")) { return false } - const attributes = getAttributes(node.open_tag!) + const attributes = getAttributes(openTag) const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden") if (!ariaHiddenAttr) { From 11c6471792aa1c431d63c0dd2c3c2bbdcb33bb45 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 8 May 2026 14:48:28 -0600 Subject: [PATCH 3/8] Add a11y-svg-has-accessible-text docs page and README entry --- .../packages/linter/docs/rules/README.md | 1 + .../rules/a11y-svg-has-accessible-text.md | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 javascript/packages/linter/docs/rules/a11y-svg-has-accessible-text.md diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index a506bccaf..9a6f2e00f 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -9,6 +9,7 @@ This page contains documentation for all Herb Linter rules. - [`a11y-no-accesskey-attribute`](./a11y-no-accesskey-attribute.md) - Prevent usage of the `accesskey` attribute - [`a11y-no-aria-unsupported-elements`](./a11y-no-aria-unsupported-elements.md) - Prevent usage of ARIA on unsupported elements - [`a11y-no-autofocus-attribute`](./a11y-no-autofocus-attribute.md) - Prevent usage of the `autofocus` attribute +- [`a11y-svg-has-accessible-text`](./a11y-svg-has-accessible-text.md) - Require accessible text on `` elements #### Action View diff --git a/javascript/packages/linter/docs/rules/a11y-svg-has-accessible-text.md b/javascript/packages/linter/docs/rules/a11y-svg-has-accessible-text.md new file mode 100644 index 000000000..8c0155d45 --- /dev/null +++ b/javascript/packages/linter/docs/rules/a11y-svg-has-accessible-text.md @@ -0,0 +1,60 @@ +# Linter Rule: SVG must have accessible text + +**Rule:** `a11y-svg-has-accessible-text` + +## Description + +Enforce that `` elements have accessible text via `aria-label`, `aria-labelledby`, or a nested `` element. Decorative SVGs should be hidden with `aria-hidden="true"`. + +## Rationale + +SVG images without accessible text are invisible to screen readers and other assistive technologies. Every meaningful SVG should have a text alternative so that non-sighted users understand its purpose. Decorative SVGs that convey no meaning should be explicitly hidden with `aria-hidden="true"` to avoid confusing assistive technology users. + +## Examples + +### ✅ Good + +```erb + + + +``` + +```erb + + Monthly sales chart + + +``` + +```erb + + Search icon + + +``` + +```erb + +``` + +### 🚫 Bad + +```erb + + + +``` + +```erb + + + +``` + +## References + +- [WAI: SVG Accessibility](https://www.w3.org/wiki/SVG_Accessibility) +- [Deque: svg-img-alt](https://dequeuniversity.com/rules/axe/4.10/svg-img-alt) From 5dd63b6bb7df45eee83fe9019bada0687c14f39a Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 8 May 2026 15:41:41 -0600 Subject: [PATCH 4/8] Update CLI test snapshots for new rule count --- .../test/__snapshots__/cli.test.ts.snap | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap index 1b38e8234..04fb24b07 100644 --- a/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap +++ b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap @@ -111,7 +111,7 @@ test/fixtures/ignored.html.erb:8:8 Offenses 5 errors | 2 warnings (7 offenses across 1 file) Note 3 additional offenses reported (would have been ignored) Fixable 7 offenses | 4 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > Excluded Files > skips excluded file in subdirectory with README.md project indicator 1`] = ` @@ -177,7 +177,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 1`] = ` @@ -202,7 +202,7 @@ test/fixtures/no-trailing-newline.html.erb:1:29 Checked 1 file Offenses 1 error | 0 warnings (1 offense across 1 file) Fixable 1 offense | 1 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > GitHub Actions format includes rule codes 2`] = ` @@ -266,7 +266,7 @@ test/fixtures/erb-no-extra-whitespace-inside-tags.html.erb:1:4 Checked 1 file Offenses 4 errors | 0 warnings (4 offenses across 1 file) Fixable 4 offenses | 3 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > Ignores disabled rules 1`] = ` @@ -307,7 +307,7 @@ test/fixtures/ignored.html.erb:6:14 Checked 1 file Offenses 2 errors | 0 warnings | 3 ignored (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > allows tag.attributes in attribute position 1`] = ` @@ -346,7 +346,7 @@ test/fixtures/tag-attributes.html.erb:5:0 Checked 1 file Offenses 2 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > diplays only parsers errors if one is present 1`] = ` @@ -372,7 +372,7 @@ test/fixtures/parser-errors.html.erb:2:16 Checked 1 file Offenses 1 error | 0 warnings (1 offense across 1 file) Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > displays most violated rules with multiple offenses 1`] = ` @@ -587,7 +587,7 @@ test/fixtures/multiple-rule-offenses.html.erb:4:2 Checked 1 file Offenses 8 errors | 6 warnings (14 offenses across 1 file) Fixable 14 offenses | 4 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > displays rule offenses when showing all rules 1`] = ` @@ -684,7 +684,7 @@ test/fixtures/few-rule-offenses.html.erb:6:0 Checked 1 file Offenses 4 errors | 2 warnings (6 offenses across 1 file) Fixable 6 offenses | 3 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for bad file 1`] = ` @@ -723,7 +723,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for clean file 1`] = ` @@ -735,7 +735,7 @@ exports[`CLI Output Formatting > formats GitHub Actions output correctly for cle Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output correctly for file with errors 1`] = ` @@ -795,7 +795,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats GitHub Actions output with --format=github option 1`] = ` @@ -834,7 +834,7 @@ test/fixtures/test-file-simple.html.erb:2:22 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats JSON output correctly for bad file 1`] = ` @@ -1038,7 +1038,7 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats simple output correctly 1`] = ` @@ -1057,7 +1057,7 @@ test/fixtures/test-file-simple.html.erb: Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats simple output for bad-file correctly 1`] = ` @@ -1076,7 +1076,7 @@ test/fixtures/bad-file.html.erb: Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > formats success output correctly 1`] = ` @@ -1088,7 +1088,7 @@ exports[`CLI Output Formatting > formats success output correctly 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > handles boolean attributes 1`] = ` @@ -1100,7 +1100,7 @@ exports[`CLI Output Formatting > handles boolean attributes 1`] = ` Checked 1 file Offenses 0 offenses Fixable 0 offenses - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > handles multiple errors correctly 1`] = ` @@ -1135,7 +1135,7 @@ test/fixtures/bad-file.html.erb:1:16 Checked 1 file Offenses 2 errors | 0 warnings (2 offenses across 1 file) Fixable 2 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 1`] = ` @@ -1267,7 +1267,7 @@ test/fixtures/disabled-1.html.erb:14:19 Checked 1 file Offenses 2 errors | 6 warnings | 8 ignored (8 offenses across 1 file) Fixable 8 offenses | 1 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > herb:disable rules 2`] = ` @@ -1470,7 +1470,7 @@ test/fixtures/disabled-2.html.erb:2:44 Checked 1 file Offenses 6 errors | 7 warnings | 5 ignored (13 offenses across 1 file) Fixable 13 offenses | 6 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; exports[`CLI Output Formatting > uses GitHub Actions format by default when GITHUB_ACTIONS is true 1`] = ` @@ -1530,5 +1530,5 @@ test/fixtures/test-file-with-errors.html.erb:2:22 Checked 1 file Offenses 2 errors | 1 warning (3 offenses across 1 file) Fixable 3 offenses | 2 autocorrectable using \`--fix\` - Rules 83 enabled | 10 not enabled" + Rules 83 enabled | 11 not enabled" `; From 11729ab9dc9cf1a4282c0f09cdd1ac0a6b3b471e Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 5 Jun 2026 17:19:49 +0200 Subject: [PATCH 5/8] Apply suggestion Signed-off-by: Marco Roth --- .../packages/linter/src/rules/a11y-svg-has-accessible-text.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts index 3c9990bc1..21a4d54f4 100644 --- a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts +++ b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts @@ -81,7 +81,7 @@ class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { export class A11ySvgHasAccessibleTextRule extends ParserRule { static ruleName = "a11y-svg-has-accessible-text" - static introducedIn = this.version("0.9.6") + static introducedIn = this.version("unreleased") get defaultConfig(): FullRuleConfig { return { From 27167e909c77f82425dc11e69a887b38b474c4e4 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 6 Jun 2026 10:18:10 +0200 Subject: [PATCH 6/8] Cleanup --- .../src/rules/a11y-svg-has-accessible-text.ts | 50 ++++++++----------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts index 68a959075..e6c792b28 100644 --- a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts +++ b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts @@ -1,26 +1,22 @@ +import { ParserRule } from "../types.js" import { BaseRuleVisitor } from "./rule-utils.js" + import { hasAttribute, getAttributeValue, findAttributeByName, getAttributes, getTagLocalName, isHTMLElementNode } from "@herb-tools/core" -import { ParserRule } from "../types.js" import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" -import type { HTMLElementNode, HTMLOpenTagNode, ParseResult, ParserOptions } from "@herb-tools/core" +import type { HTMLElementNode, ParseResult, ParserOptions } from "@herb-tools/core" class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { visitHTMLElementNode(node: HTMLElementNode): void { - this.checkSvgElement(node) + this.checkSVGElement(node) super.visitHTMLElementNode(node) } - private checkSvgElement(node: HTMLElementNode): void { + private checkSVGElement(node: HTMLElementNode): void { const tagName = getTagLocalName(node) - if (tagName !== "svg") { - return - } - - if (this.hasAriaHidden(node)) { - return - } + if (tagName !== "svg") return + if (this.hasAriaHidden(node)) return if (this.hasAriaLabel(node) || this.hasAriaLabelledby(node) || this.hasDirectTitleChild(node)) { return @@ -33,37 +29,29 @@ class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { } private hasAriaLabel(node: HTMLElementNode): boolean { - const openTag = node.open_tag as HTMLOpenTagNode - return hasAttribute(openTag, "aria-label") + return hasAttribute(node, "aria-label") } private hasAriaLabelledby(node: HTMLElementNode): boolean { - const openTag = node.open_tag as HTMLOpenTagNode - return hasAttribute(openTag, "aria-labelledby") + return hasAttribute(node, "aria-labelledby") } private hasAriaHidden(node: HTMLElementNode): boolean { - const openTag = node.open_tag as HTMLOpenTagNode - if (!hasAttribute(openTag, "aria-hidden")) { - return false - } + if (!hasAttribute(node, "aria-hidden")) return false - const attributes = getAttributes(openTag) - const ariaHiddenAttr = findAttributeByName(attributes, "aria-hidden") + const attributes = getAttributes(node) + const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden") - if (!ariaHiddenAttr) { - return false - } + if (!ariaHiddenAttribute) return false - const value = getAttributeValue(ariaHiddenAttr) + const value = getAttributeValue(ariaHiddenAttribute) return value === "true" } private hasDirectTitleChild(node: HTMLElementNode): boolean { - if (!node.body || node.body.length === 0) { - return false - } + if (!node.body) return false + if (node.body.length === 0) return false for (const child of node.body) { if (isHTMLElementNode(child)) { @@ -91,12 +79,16 @@ export class A11ySVGHasAccessibleTextRule extends ParserRule { } get parserOptions(): Partial { - return { action_view_helpers: true } + return { + action_view_helpers: true + } } check(result: ParseResult, context?: Partial): UnboundLintOffense[] { const visitor = new SvgHasAccessibleTextVisitor(this.ruleName, context) + visitor.visit(result.value) + return visitor.offenses } } From ecef204e3b8402384fc698686f458b416d2c62d7 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 6 Jun 2026 10:26:42 +0200 Subject: [PATCH 7/8] Add ERB/dynamic tests --- .../src/rules/a11y-svg-has-accessible-text.ts | 10 +--- .../a11y-svg-has-accessible-text.test.ts | 56 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts index e6c792b28..4de80e8f2 100644 --- a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts +++ b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts @@ -1,7 +1,7 @@ import { ParserRule } from "../types.js" import { BaseRuleVisitor } from "./rule-utils.js" -import { hasAttribute, getAttributeValue, findAttributeByName, getAttributes, getTagLocalName, isHTMLElementNode } from "@herb-tools/core" +import { hasAttribute, getStaticAttributeValue, getTagLocalName, isHTMLElementNode } from "@herb-tools/core" import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" import type { HTMLElementNode, ParseResult, ParserOptions } from "@herb-tools/core" @@ -39,12 +39,8 @@ class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { private hasAriaHidden(node: HTMLElementNode): boolean { if (!hasAttribute(node, "aria-hidden")) return false - const attributes = getAttributes(node) - const ariaHiddenAttribute = findAttributeByName(attributes, "aria-hidden") - - if (!ariaHiddenAttribute) return false - - const value = getAttributeValue(ariaHiddenAttribute) + const value = getStaticAttributeValue(node, "aria-hidden") + if (value === null) return true return value === "true" } diff --git a/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts b/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts index dcaf9ca17..0ffc549a9 100644 --- a/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts +++ b/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts @@ -51,4 +51,60 @@ describe("a11y-svg-has-accessible-text", () => { test("passes for svg with both aria-label and title", () => { expectNoOffenses('A circle') }) + + test("passes for svg with dynamic aria-label", () => { + expectNoOffenses('') + }) + + test("passes for svg with dynamic aria-labelledby", () => { + expectNoOffenses('') + }) + + test("passes for svg with dynamic aria-hidden=true", () => { + expectNoOffenses('') + }) + + test("passes for svg with ERB title child", () => { + expectNoOffenses('<%= title_text %>') + }) + + test("fails for title nested inside a group", () => { + expectWarning(offenseMessage) + assertOffenses('Nested title') + }) + + test("passes for svg with aria-hidden boolean attribute", () => { + expectNoOffenses('') + }) + + test("fails for tag.svg without accessible text", () => { + expectWarning(offenseMessage) + assertOffenses('<%= tag.svg %>') + }) + + test("passes for tag.svg with aria-label", () => { + expectNoOffenses('<%= tag.svg aria_label: "Icon" %>') + }) + + test("passes for tag.svg with aria-hidden", () => { + expectNoOffenses('<%= tag.svg aria_hidden: true %>') + }) + + test("fails for content_tag :svg without accessible text", () => { + expectWarning(offenseMessage) + assertOffenses('<%= content_tag :svg %>') + }) + + test("passes for content_tag :svg with aria-label", () => { + expectNoOffenses('<%= content_tag :svg, nil, "aria-label": "Icon" %>') + }) + + test("passes for tag.svg with tag.title child", () => { + expectNoOffenses('<%= tag.svg do %><%= tag.title "Icon" %><% end %>') + }) + + test("fails for tag.svg with tag.title nested in tag.g", () => { + expectWarning(offenseMessage) + assertOffenses('<%= tag.svg do %><%= tag.g do %><%= tag.title "Icon" %><% end %><% end %>') + }) }) From 6e4f3b1c461340a29351fb59aa2bb36ad43a7bf6 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 6 Jun 2026 10:31:11 +0200 Subject: [PATCH 8/8] Cleanup --- .../src/rules/a11y-svg-has-accessible-text.ts | 29 ++++--------------- 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts index 4de80e8f2..03305d261 100644 --- a/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts +++ b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts @@ -18,9 +18,9 @@ class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { if (tagName !== "svg") return if (this.hasAriaHidden(node)) return - if (this.hasAriaLabel(node) || this.hasAriaLabelledby(node) || this.hasDirectTitleChild(node)) { - return - } + if (hasAttribute(node, "aria-label")) return + if (hasAttribute(node, "aria-labelledby")) return + if (this.hasDirectTitleChild(node)) return this.addOffense( "`` must have accessible text. Set `aria-label`, or `aria-labelledby`, or nest a `` element. If the `` is decorative, hide it with `aria-hidden=\"true\"`.", @@ -28,14 +28,6 @@ class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { ) } - private hasAriaLabel(node: HTMLElementNode): boolean { - return hasAttribute(node, "aria-label") - } - - private hasAriaLabelledby(node: HTMLElementNode): boolean { - return hasAttribute(node, "aria-labelledby") - } - private hasAriaHidden(node: HTMLElementNode): boolean { if (!hasAttribute(node, "aria-hidden")) return false @@ -46,20 +38,9 @@ class SvgHasAccessibleTextVisitor extends BaseRuleVisitor { } private hasDirectTitleChild(node: HTMLElementNode): boolean { - if (!node.body) return false - if (node.body.length === 0) return false - - for (const child of node.body) { - if (isHTMLElementNode(child)) { - const childTagName = getTagLocalName(child) - - if (childTagName === "title") { - return true - } - } - } + const children = node.body ?? [] - return false + return children.some(child => isHTMLElementNode(child) && getTagLocalName(child) === "title") } }