From 6cc1e5606cfdbe23308cf4aa9541d6075cc7c56c Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Wed, 22 Apr 2026 13:35:37 -0600 Subject: [PATCH 1/5] Implement `a11y-avoid-generic-link-text` linter rule Implements the `a11y-avoid-generic-link-text` rule from erblint-github's `AvoidGenericLinkText`. This rule flags `` elements with generic link text such as "Read more", "Learn more", "Click here", "More", "Link", or "Here" which do not make sense when read out of context. Screen reader users often navigate by links, so descriptive link text is important for accessibility. Links with `aria-label` or `aria-labelledby` attributes are not flagged, as those provide an accessible name. Links with dynamic ERB content are also skipped since the text cannot be statically analyzed. The rule is disabled by default with a severity of `warning`. Closes #1218 --- .../rules/a11y-avoid-generic-link-text.md | 71 ++++++++++++ javascript/packages/linter/src/rules.ts | 2 + .../src/rules/a11y-avoid-generic-link-text.ts | 107 ++++++++++++++++++ javascript/packages/linter/src/rules/index.ts | 1 + .../test/__snapshots__/cli.test.ts.snap | 44 +++---- .../a11y-avoid-generic-link-text.test.ts | 79 +++++++++++++ 6 files changed, 282 insertions(+), 22 deletions(-) create mode 100644 javascript/packages/linter/docs/rules/a11y-avoid-generic-link-text.md create mode 100644 javascript/packages/linter/src/rules/a11y-avoid-generic-link-text.ts create mode 100644 javascript/packages/linter/test/rules/a11y-avoid-generic-link-text.test.ts diff --git a/javascript/packages/linter/docs/rules/a11y-avoid-generic-link-text.md b/javascript/packages/linter/docs/rules/a11y-avoid-generic-link-text.md new file mode 100644 index 000000000..900bece2c --- /dev/null +++ b/javascript/packages/linter/docs/rules/a11y-avoid-generic-link-text.md @@ -0,0 +1,71 @@ +# Linter Rule: Avoid generic link text + +**Rule:** `a11y-avoid-generic-link-text` + +## Description + +Avoid setting generic link text like "Click here", "Read more", and "Learn more" which do not make sense when read out of context. + +## Rationale + +Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context. Links should clearly describe their destination. + +## Banned generic text + +- `Read more` +- `Learn more` +- `Click here` +- `More` +- `Link` +- `Here` + +## Examples + +### ✅ Good + +```erb +Learn more about GitHub +``` + +```erb +Create a new repository +``` + +```erb +Learn more +``` + +```erb +Learn more +``` + +### 🚫 Bad + +```erb +Learn more +``` + +```erb +Read more +``` + +```erb +Click here +``` + +```erb +More +``` + +```erb +Link +``` + +```erb +Here +``` + +## References + +- [erblint-github: AvoidGenericLinkText](https://github.com/github/erblint-github/blob/main/lib/erblint-github/linters/github/accessibility/avoid_generic_link_text.rb) +- [erblint-github docs](https://github.com/github/erblint-github/blob/main/docs/rules/accessibility/avoid-generic-link-text.md) diff --git a/javascript/packages/linter/src/rules.ts b/javascript/packages/linter/src/rules.ts index 7a4f935b3..16ebe77bb 100644 --- a/javascript/packages/linter/src/rules.ts +++ b/javascript/packages/linter/src/rules.ts @@ -1,5 +1,6 @@ import type { RuleClass } from "./types.js" +import { A11yAvoidGenericLinkTextRule } from "./rules/a11y-avoid-generic-link-text.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" @@ -103,6 +104,7 @@ import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalizatio import { TurboPermanentRequireIdRule } from "./rules/turbo-permanent-require-id.js" export const rules: RuleClass[] = [ + A11yAvoidGenericLinkTextRule, A11yNoAccesskeyAttributeRule, A11yNoAriaUnsupportedElementsRule, A11yNoAutofocusAttributeRule, diff --git a/javascript/packages/linter/src/rules/a11y-avoid-generic-link-text.ts b/javascript/packages/linter/src/rules/a11y-avoid-generic-link-text.ts new file mode 100644 index 000000000..4addaa963 --- /dev/null +++ b/javascript/packages/linter/src/rules/a11y-avoid-generic-link-text.ts @@ -0,0 +1,107 @@ +import { BaseRuleVisitor } from "./rule-utils.js" +import { getTagLocalName, hasAttribute, isHTMLTextNode, isLiteralNode, isERBOutputNode } from "@herb-tools/core" + +import { ParserRule } from "../types.js" +import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js" +import type { HTMLElementNode, ParseResult, ParserOptions, Node } from "@herb-tools/core" + +const BANNED_GENERIC_TEXT = [ + "read more", + "learn more", + "click here", + "more", + "link", + "here", +] + +function stripText(text: string): string { + return text.toLowerCase().replace(/\W+/g, " ").trim() +} + +function isBannedText(text: string): boolean { + return BANNED_GENERIC_TEXT.includes(stripText(text)) +} + +class AvoidGenericLinkTextVisitor extends BaseRuleVisitor { + visitHTMLElementNode(node: HTMLElementNode): void { + this.checkLinkElement(node) + super.visitHTMLElementNode(node) + } + + private checkLinkElement(node: HTMLElementNode): void { + const tagName = getTagLocalName(node) + + if (tagName !== "a") { + return + } + + if (hasAttribute(node, "aria-labelledby")) { + return + } + + if (hasAttribute(node, "aria-label")) { + return + } + + const textContent = this.getStaticTextContent(node) + + if (textContent === null) { + return + } + + if (isBannedText(textContent)) { + this.addOffense( + `Avoid using generic link text such as "${textContent.trim()}". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.`, + node.location, + ) + } + } + + private getStaticTextContent(node: HTMLElementNode): string | null { + if (!node.body || node.body.length === 0) { + return "" + } + + return this.collectStaticText(node.body) + } + + private collectStaticText(nodes: Node[]): string | null { + let text = "" + + for (const child of nodes) { + if (isHTMLTextNode(child) || isLiteralNode(child)) { + text += child.content + } else if (isERBOutputNode(child)) { + return null + } else { + return null + } + } + + return text + } +} + +export class A11yAvoidGenericLinkTextRule extends ParserRule { + static ruleName = "a11y-avoid-generic-link-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 AvoidGenericLinkTextVisitor(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..e1c7d6099 100644 --- a/javascript/packages/linter/src/rules/index.ts +++ b/javascript/packages/linter/src/rules/index.ts @@ -1,3 +1,4 @@ +export * from "./a11y-avoid-generic-link-text.js" export * from "./a11y-no-accesskey-attribute.js" export * from "./a11y-no-aria-unsupported-elements.js" export * from "./a11y-no-autofocus-attribute.js" 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" `; diff --git a/javascript/packages/linter/test/rules/a11y-avoid-generic-link-text.test.ts b/javascript/packages/linter/test/rules/a11y-avoid-generic-link-text.test.ts new file mode 100644 index 000000000..38e9bc2fc --- /dev/null +++ b/javascript/packages/linter/test/rules/a11y-avoid-generic-link-text.test.ts @@ -0,0 +1,79 @@ +import { describe, test } from "vitest" +import { A11yAvoidGenericLinkTextRule } from "../../src/rules/a11y-avoid-generic-link-text.js" +import { createLinterTest } from "../helpers/linter-test-helper.js" + +const { expectNoOffenses, expectWarning, assertOffenses } = createLinterTest(A11yAvoidGenericLinkTextRule) + +describe("a11y-avoid-generic-link-text", () => { + test("passes for link with descriptive text", () => { + expectNoOffenses('Learn more about GitHub') + }) + + test("passes for link with aria-label", () => { + expectNoOffenses('Learn more') + }) + + test("passes for link with aria-labelledby", () => { + expectNoOffenses('Learn more') + }) + + test("passes for link with ERB content", () => { + expectNoOffenses('<%= link_text %>') + }) + + test("passes for link with no text", () => { + expectNoOffenses('') + }) + + test("passes for non-link element with banned text", () => { + expectNoOffenses('Click here') + }) + + test("fails for link with 'Learn more'", () => { + expectWarning('Avoid using generic link text such as "Learn more". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.') + + assertOffenses('Learn more') + }) + + test("fails for link with 'Read more'", () => { + expectWarning('Avoid using generic link text such as "Read more". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.') + + assertOffenses('Read more') + }) + + test("fails for link with 'Click here'", () => { + expectWarning('Avoid using generic link text such as "Click here". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.') + + assertOffenses('Click here') + }) + + test("fails for link with 'More'", () => { + expectWarning('Avoid using generic link text such as "More". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.') + + assertOffenses('More') + }) + + test("fails for link with 'Link'", () => { + expectWarning('Avoid using generic link text such as "Link". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.') + + assertOffenses('Link') + }) + + test("fails for link with 'Here'", () => { + expectWarning('Avoid using generic link text such as "Here". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.') + + assertOffenses('Here') + }) + + test("fails for link with case-insensitive banned text", () => { + expectWarning('Avoid using generic link text such as "CLICK HERE". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.') + + assertOffenses('CLICK HERE') + }) + + test("fails for link with extra whitespace in banned text", () => { + expectWarning('Avoid using generic link text such as "Learn more". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.') + + assertOffenses(' Learn more ') + }) +}) From 31b21ef4174ee564e18b18aa0060fadd09875df4 Mon Sep 17 00:00:00 2001 From: Joel Hawksley Date: Fri, 8 May 2026 14:45:53 -0600 Subject: [PATCH 2/5] Add a11y-avoid-generic-link-text to rules README --- javascript/packages/linter/docs/rules/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index a506bccaf..d3ebaf107 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -6,6 +6,7 @@ This page contains documentation for all Herb Linter rules. #### Accessibility +- [`a11y-avoid-generic-link-text`](./a11y-avoid-generic-link-text.md) - Avoid generic link text like "Click here" or "Read more" - [`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 From b055f02e1facdae4ec57558f6c963ae4b4df86e0 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 6 Jun 2026 09:18:54 +0200 Subject: [PATCH 3/5] Update --- .../packages/linter/docs/rules/README.md | 1 + test/engine/action_view/link_to_test.rb | 10 +++ ...g_key_4cb1c219b60c5075805803030cc07700.txt | 6 ++ ...g_key_5daec1fb841a45823700bc029cc4c3bc.txt | 5 ++ ...a83f0-ef4af315cb33925c38d24ea3c2e8a1cd.txt | 67 +++++++++++++++++++ ...label_4d39d9fc22da3917c3a3f13bef420650.txt | 5 ++ ...ef62a-ef4af315cb33925c38d24ea3c2e8a1cd.txt | 67 +++++++++++++++++++ ...label_b7a0928a858e765acc5b414a15832442.txt | 6 ++ 8 files changed, 167 insertions(+) create mode 100644 test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_4cb1c219b60c5075805803030cc07700.txt create mode 100644 test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_5daec1fb841a45823700bc029cc4c3bc.txt create mode 100644 test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_b16128baa22222ede038ab6d582a83f0-ef4af315cb33925c38d24ea3c2e8a1cd.txt create mode 100644 test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_4d39d9fc22da3917c3a3f13bef420650.txt create mode 100644 test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_9f886823e619435b71a40e3a263ef62a-ef4af315cb33925c38d24ea3c2e8a1cd.txt create mode 100644 test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_b7a0928a858e765acc5b414a15832442.txt diff --git a/javascript/packages/linter/docs/rules/README.md b/javascript/packages/linter/docs/rules/README.md index 00ce7cb92..51261ae61 100644 --- a/javascript/packages/linter/docs/rules/README.md +++ b/javascript/packages/linter/docs/rules/README.md @@ -7,6 +7,7 @@ This page contains documentation for all Herb Linter rules. #### Accessibility - [`a11y-avoid-generic-link-text`](./a11y-avoid-generic-link-text.md) - Avoid generic link text like "Click here" or "Read more" +- [`a11y-disabled-attribute`](./a11y-disabled-attribute.md) - Prevent usage of the `disabled` attribute on unsupported elements - [`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 diff --git a/test/engine/action_view/link_to_test.rb b/test/engine/action_view/link_to_test.rb index c346f0b72..cb651ad00 100644 --- a/test/engine/action_view/link_to_test.rb +++ b/test/engine/action_view/link_to_test.rb @@ -68,6 +68,16 @@ def model_name = "Profile" <% end %> ERB end + + test "link_to with aria-label string key" do + assert_optimized_snapshot('<%= link_to "More", "#", "aria-label": "Learn more about GitHub" %>') + assert_parsed_snapshot('<%= link_to "More", "#", "aria-label": "Learn more about GitHub" %>', action_view_helpers: true) + end + + test "link_to with aria hash label" do + assert_optimized_snapshot('<%= link_to "More", "#", aria: { label: "Learn more about GitHub" } %>') + assert_parsed_snapshot('<%= link_to "More", "#", aria: { label: "Learn more about GitHub" } %>', action_view_helpers: true) + end end end end diff --git a/test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_4cb1c219b60c5075805803030cc07700.txt b/test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_4cb1c219b60c5075805803030cc07700.txt new file mode 100644 index 000000000..d83c326a8 --- /dev/null +++ b/test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_4cb1c219b60c5075805803030cc07700.txt @@ -0,0 +1,6 @@ +--- +source: "Engine::ActionView::LinkToTest#test_0012_link_to with aria-label string key" +input: "{source: \"<%= link_to \\\"More\\\", \\\"#\\\", \\\"aria-label\\\": \\\"Learn more about GitHub\\\" %>\", options: {optimize: true}}" +--- +_buf = ::String.new; _buf << 'More'.freeze; +_buf.to_s diff --git a/test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_5daec1fb841a45823700bc029cc4c3bc.txt b/test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_5daec1fb841a45823700bc029cc4c3bc.txt new file mode 100644 index 000000000..7b18c3474 --- /dev/null +++ b/test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_5daec1fb841a45823700bc029cc4c3bc.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::ActionView::LinkToTest#test_0012_link_to with aria-label string key" +input: "{source: \"<%= link_to \\\"More\\\", \\\"#\\\", \\\"aria-label\\\": \\\"Learn more about GitHub\\\" %>\", locals: {}, options: {action_view_helpers: true}}" +--- +More \ No newline at end of file diff --git a/test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_b16128baa22222ede038ab6d582a83f0-ef4af315cb33925c38d24ea3c2e8a1cd.txt b/test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_b16128baa22222ede038ab6d582a83f0-ef4af315cb33925c38d24ea3c2e8a1cd.txt new file mode 100644 index 000000000..03a3fce41 --- /dev/null +++ b/test/snapshots/engine/action_view/link_to_test/test_0012_link_to_with_aria-label_string_key_b16128baa22222ede038ab6d582a83f0-ef4af315cb33925c38d24ea3c2e8a1cd.txt @@ -0,0 +1,67 @@ +--- +source: "Engine::ActionView::LinkToTest#test_0012_link_to with aria-label string key" +input: "<%= link_to \"More\", \"#\", \"aria-label\": \"Learn more about GitHub\" %>" +options: {action_view_helpers: true} +--- +@ DocumentNode (location: (1:0)-(1:67)) +└── children: (1 item) + └── @ HTMLElementNode (location: (1:0)-(1:67)) + ├── open_tag: + │ └── @ ERBOpenTagNode (location: (1:0)-(1:67)) + │ ├── tag_opening: "<%=" (location: (1:0)-(1:3)) + │ ├── content: " link_to "More", "#", "aria-label": "Learn more about GitHub" " (location: (1:3)-(1:65)) + │ ├── tag_closing: "%>" (location: (1:65)-(1:67)) + │ ├── tag_name: "a" (location: (1:4)-(1:11)) + │ └── children: (2 items) + │ ├── @ HTMLAttributeNode (location: (1:26)-(1:64)) + │ │ ├── name: + │ │ │ └── @ HTMLAttributeNameNode (location: (1:26)-(1:36)) + │ │ │ └── children: (1 item) + │ │ │ └── @ LiteralNode (location: (1:26)-(1:36)) + │ │ │ └── content: "aria-label" + │ │ │ + │ │ │ + │ │ ├── equals: ": " (location: (1:37)-(1:39)) + │ │ └── value: + │ │ └── @ HTMLAttributeValueNode (location: (1:39)-(1:64)) + │ │ ├── open_quote: """ (location: (1:39)-(1:40)) + │ │ ├── children: (1 item) + │ │ │ └── @ LiteralNode (location: (1:40)-(1:63)) + │ │ │ └── content: "Learn more about GitHub" + │ │ │ + │ │ ├── close_quote: """ (location: (1:63)-(1:64)) + │ │ └── quoted: true + │ │ + │ │ + │ └── @ HTMLAttributeNode (location: (1:20)-(1:23)) + │ ├── name: + │ │ └── @ HTMLAttributeNameNode (location: (1:20)-(1:23)) + │ │ └── children: (1 item) + │ │ └── @ LiteralNode (location: (1:20)-(1:23)) + │ │ └── content: "href" + │ │ + │ │ + │ ├── equals: "=" (location: (1:20)-(1:23)) + │ └── value: + │ └── @ HTMLAttributeValueNode (location: (1:20)-(1:23)) + │ ├── open_quote: """ (location: (1:20)-(1:20)) + │ ├── children: (1 item) + │ │ └── @ LiteralNode (location: (1:20)-(1:23)) + │ │ └── content: "#" + │ │ + │ ├── close_quote: """ (location: (1:23)-(1:23)) + │ └── quoted: true + │ + │ + │ + ├── tag_name: "a" (location: (1:4)-(1:11)) + ├── body: (1 item) + │ └── @ HTMLTextNode (location: (1:0)-(1:67)) + │ └── content: "More" + │ + ├── close_tag: + │ └── @ HTMLVirtualCloseTagNode (location: (1:67)-(1:67)) + │ └── tag_name: "a" (location: (1:4)-(1:11)) + │ + ├── is_void: false + └── element_source: "ActionView::Helpers::UrlHelper#link_to" \ No newline at end of file diff --git a/test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_4d39d9fc22da3917c3a3f13bef420650.txt b/test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_4d39d9fc22da3917c3a3f13bef420650.txt new file mode 100644 index 000000000..1ab0f5e54 --- /dev/null +++ b/test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_4d39d9fc22da3917c3a3f13bef420650.txt @@ -0,0 +1,5 @@ +--- +source: "Engine::ActionView::LinkToTest#test_0013_link_to with aria hash label" +input: "{source: \"<%= link_to \\\"More\\\", \\\"#\\\", aria: { label: \\\"Learn more about GitHub\\\" } %>\", locals: {}, options: {action_view_helpers: true}}" +--- +More \ No newline at end of file diff --git a/test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_9f886823e619435b71a40e3a263ef62a-ef4af315cb33925c38d24ea3c2e8a1cd.txt b/test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_9f886823e619435b71a40e3a263ef62a-ef4af315cb33925c38d24ea3c2e8a1cd.txt new file mode 100644 index 000000000..4df819bd4 --- /dev/null +++ b/test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_9f886823e619435b71a40e3a263ef62a-ef4af315cb33925c38d24ea3c2e8a1cd.txt @@ -0,0 +1,67 @@ +--- +source: "Engine::ActionView::LinkToTest#test_0013_link_to with aria hash label" +input: "<%= link_to \"More\", \"#\", aria: { label: \"Learn more about GitHub\" } %>" +options: {action_view_helpers: true} +--- +@ DocumentNode (location: (1:0)-(1:70)) +└── children: (1 item) + └── @ HTMLElementNode (location: (1:0)-(1:70)) + ├── open_tag: + │ └── @ ERBOpenTagNode (location: (1:0)-(1:70)) + │ ├── tag_opening: "<%=" (location: (1:0)-(1:3)) + │ ├── content: " link_to "More", "#", aria: { label: "Learn more about GitHub" } " (location: (1:3)-(1:68)) + │ ├── tag_closing: "%>" (location: (1:68)-(1:70)) + │ ├── tag_name: "a" (location: (1:4)-(1:11)) + │ └── children: (2 items) + │ ├── @ HTMLAttributeNode (location: (1:33)-(1:65)) + │ │ ├── name: + │ │ │ └── @ HTMLAttributeNameNode (location: (1:33)-(1:38)) + │ │ │ └── children: (1 item) + │ │ │ └── @ LiteralNode (location: (1:33)-(1:38)) + │ │ │ └── content: "aria-label" + │ │ │ + │ │ │ + │ │ ├── equals: ": " (location: (1:38)-(1:40)) + │ │ └── value: + │ │ └── @ HTMLAttributeValueNode (location: (1:40)-(1:65)) + │ │ ├── open_quote: """ (location: (1:40)-(1:41)) + │ │ ├── children: (1 item) + │ │ │ └── @ LiteralNode (location: (1:41)-(1:64)) + │ │ │ └── content: "Learn more about GitHub" + │ │ │ + │ │ ├── close_quote: """ (location: (1:64)-(1:65)) + │ │ └── quoted: true + │ │ + │ │ + │ └── @ HTMLAttributeNode (location: (1:20)-(1:23)) + │ ├── name: + │ │ └── @ HTMLAttributeNameNode (location: (1:20)-(1:23)) + │ │ └── children: (1 item) + │ │ └── @ LiteralNode (location: (1:20)-(1:23)) + │ │ └── content: "href" + │ │ + │ │ + │ ├── equals: "=" (location: (1:20)-(1:23)) + │ └── value: + │ └── @ HTMLAttributeValueNode (location: (1:20)-(1:23)) + │ ├── open_quote: """ (location: (1:20)-(1:20)) + │ ├── children: (1 item) + │ │ └── @ LiteralNode (location: (1:20)-(1:23)) + │ │ └── content: "#" + │ │ + │ ├── close_quote: """ (location: (1:23)-(1:23)) + │ └── quoted: true + │ + │ + │ + ├── tag_name: "a" (location: (1:4)-(1:11)) + ├── body: (1 item) + │ └── @ HTMLTextNode (location: (1:0)-(1:70)) + │ └── content: "More" + │ + ├── close_tag: + │ └── @ HTMLVirtualCloseTagNode (location: (1:70)-(1:70)) + │ └── tag_name: "a" (location: (1:4)-(1:11)) + │ + ├── is_void: false + └── element_source: "ActionView::Helpers::UrlHelper#link_to" \ No newline at end of file diff --git a/test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_b7a0928a858e765acc5b414a15832442.txt b/test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_b7a0928a858e765acc5b414a15832442.txt new file mode 100644 index 000000000..7a2182164 --- /dev/null +++ b/test/snapshots/engine/action_view/link_to_test/test_0013_link_to_with_aria_hash_label_b7a0928a858e765acc5b414a15832442.txt @@ -0,0 +1,6 @@ +--- +source: "Engine::ActionView::LinkToTest#test_0013_link_to with aria hash label" +input: "{source: \"<%= link_to \\\"More\\\", \\\"#\\\", aria: { label: \\\"Learn more about GitHub\\\" } %>\", options: {optimize: true}}" +--- +_buf = ::String.new; _buf << 'More'.freeze; +_buf.to_s From 1ca43db9ae7882fb4ccb916a73a485249375f116 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 6 Jun 2026 09:26:08 +0200 Subject: [PATCH 4/5] Add getOpenTagChildren --- javascript/packages/core/src/ast-utils.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/javascript/packages/core/src/ast-utils.ts b/javascript/packages/core/src/ast-utils.ts index 8968127fa..1d49ea899 100644 --- a/javascript/packages/core/src/ast-utils.ts +++ b/javascript/packages/core/src/ast-utils.ts @@ -29,6 +29,7 @@ import { isHTMLCommentNode, isHTMLElementNode, isHTMLOpenTagNode, + isERBOpenTagNode, isHTMLTextNode, isWhitespaceNode, isHTMLAttributeNameNode, @@ -278,13 +279,27 @@ export function getOpenTag(node: HTMLElementNode | HTMLOpenTagNode | null | unde return null } +/** + * Gets the children array from an element's open tag, regardless of whether + * it's an HTMLOpenTagNode or ERBOpenTagNode (used by action view helpers). + */ +function getOpenTagChildren(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): Node[] { + if (!node) return [] + if (isHTMLOpenTagNode(node)) return node.children + + if (isHTMLElementNode(node) && node.open_tag) { + if (isHTMLOpenTagNode(node.open_tag)) return node.open_tag.children + if (isERBOpenTagNode(node.open_tag)) return node.open_tag.children + } + + return [] +} + /** * Gets attributes from an HTMLElementNode or HTMLOpenTagNode */ export function getAttributes(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): HTMLAttributeNode[] { - const openTag = getOpenTag(node) - - return openTag ? filterHTMLAttributeNodes(openTag.children) : [] + return filterHTMLAttributeNodes(getOpenTagChildren(node)) } /** From c56592cdba7abb3a26ea015bc7f7f6b364de4ad4 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 6 Jun 2026 09:37:50 +0200 Subject: [PATCH 5/5] Add tests with dynamic labels --- .../test/rules/a11y-avoid-generic-link-text.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/javascript/packages/linter/test/rules/a11y-avoid-generic-link-text.test.ts b/javascript/packages/linter/test/rules/a11y-avoid-generic-link-text.test.ts index 8018c422a..fa11196e7 100644 --- a/javascript/packages/linter/test/rules/a11y-avoid-generic-link-text.test.ts +++ b/javascript/packages/linter/test/rules/a11y-avoid-generic-link-text.test.ts @@ -13,6 +13,10 @@ describe("a11y-avoid-generic-link-text", () => { expectNoOffenses('Learn more') }) + test("passes for link with dynamic aria-label", () => { + expectNoOffenses('Learn more') + }) + test("passes for link with aria-labelledby", () => { expectNoOffenses('Learn more') }) @@ -101,6 +105,10 @@ describe("a11y-avoid-generic-link-text", () => { expectNoOffenses('<%= link_to "More", root_path, aria: { label: "Learn more about GitHub" } %>') }) + test("passes for link_to with dynamic aria-label", () => { + expectNoOffenses('<%= link_to "More", root_path, aria: { label: label_text } %>') + }) + test("fails for link_to block form with generic text", () => { expectWarning('Avoid using generic link text such as "Read more". Screen reader users often navigate by links, and generic text like "Read more", "Learn more", "Click here", "More", "Link", or "Here" is not meaningful out of context.')