diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md
index 51261ae61..ba1220b87 100644
--- a/javascript/packages/linter/docs/rules/README.md
+++ b/javascript/packages/linter/docs/rules/README.md
@@ -12,7 +12,7 @@ This page contains documentation for all Herb Linter rules.
- [`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-no-redundant-image-alt`](./a11y-no-redundant-image-alt.md) - Prevent redundant words in ` ` `alt` attributes
-
+- [`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)
diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts
index bfffe7d4e..672a06563 100644
--- a/javascript/packages/linter/src/rules.ts
+++ b/javascript/packages/linter/src/rules.ts
@@ -6,6 +6,7 @@ import { A11yNoAccesskeyAttributeRule } from "./rules/a11y-no-accesskey-attribut
import { A11yNoAriaUnsupportedElementsRule } from "./rules/a11y-no-aria-unsupported-elements.js"
import { A11yNoAutofocusAttributeRule } from "./rules/a11y-no-autofocus-attribute.js"
import { A11yNoRedundantImageAltRule } from "./rules/a11y-no-redundant-image-alt.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"
@@ -112,6 +113,7 @@ export const rules: RuleClass[] = [
A11yNoAriaUnsupportedElementsRule,
A11yNoAutofocusAttributeRule,
A11yNoRedundantImageAltRule,
+ 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..03305d261
--- /dev/null
+++ b/javascript/packages/linter/src/rules/a11y-svg-has-accessible-text.ts
@@ -0,0 +1,71 @@
+import { ParserRule } from "../types.js"
+import { BaseRuleVisitor } from "./rule-utils.js"
+
+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"
+
+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 (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\"`.",
+ node.tag_name!.location,
+ )
+ }
+
+ private hasAriaHidden(node: HTMLElementNode): boolean {
+ if (!hasAttribute(node, "aria-hidden")) return false
+
+ const value = getStaticAttributeValue(node, "aria-hidden")
+ if (value === null) return true
+
+ return value === "true"
+ }
+
+ private hasDirectTitleChild(node: HTMLElementNode): boolean {
+ const children = node.body ?? []
+
+ return children.some(child => isHTMLElementNode(child) && getTagLocalName(child) === "title")
+ }
+}
+
+export class A11ySVGHasAccessibleTextRule extends ParserRule {
+ static ruleName = "a11y-svg-has-accessible-text"
+ static introducedIn = this.version("unreleased")
+
+ 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 c9dba8b6d..0e641638c 100644
--- a/javascript/packages/linter/src/rules/index.ts
+++ b/javascript/packages/linter/src/rules/index.ts
@@ -4,6 +4,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-no-redundant-image-alt.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/__snapshots__/cli.test.ts.snap b/javascript/packages/linter/test/__snapshots__/cli.test.ts.snap
index 363f8c04b..119067d92 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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:7
Checked 1 file
Offenses 8 errors | 6 warnings (14 offenses across 1 file)
Fixable 14 offenses | 4 autocorrectable using \`--fix\`
- Rules 83 enabled | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 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 | 13 not enabled"
+ Rules 83 enabled | 14 not enabled"
`;
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..0ffc549a9
--- /dev/null
+++ b/javascript/packages/linter/test/rules/a11y-svg-has-accessible-text.test.ts
@@ -0,0 +1,110 @@
+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 ')
+ })
+
+ 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 %>')
+ })
+})