Skip to content

Commit e8cf7f7

Browse files
authored
Merge pull request #32 from github/kh-avoid-generic-link
Add linter against generic link text
2 parents 8e14106 + c077f6d commit e8cf7f7

File tree

5 files changed

+255
-0
lines changed

5 files changed

+255
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ linters:
4242
## Rules
4343
4444
- [GitHub::Accessibility::AvoidBothDisabledAndAriaDisabled](./docs/rules/accessibility/avoid-both-disabled-and-aria-disabled.md)
45+
- [GitHub::Accessibility::AvoidGenericLinkTextCounter](./docs/rules/accessibility/avoid-generic-link-text-counter.md)
4546
- [GitHub::Accessibility::IframeHasTitle](./docs/rules/accessibility/iframe-has-title.md)
4647
- [GitHub::Accessibility::ImageHasAlt](./docs/rules/accessibility/image-has-alt.md)
4748
- [GitHub::Accessibility::NoAriaLabelMisuseCounter](./docs/rules/accessibility/no-aria-label-misuse-counter.md)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Avoid generic link text
2+
3+
## Rule Details
4+
5+
Avoid setting generic link text like, "Click here", "Read more", and "Learn more" which do not make sense when read out of context.
6+
7+
Screen reader users often tab through links on a page to quickly find content without needing to listen to the full page. When link text is too generic, it becomes difficult to quickly identify the destination of the link. While it is possible to provide a more specific link text by setting the `aria-label`, this results in divergence between the label and the text and is not an ideal, future-proof solution.
8+
9+
Additionally, generic link text can also problematic for heavy zoom users where the link context is out of view.
10+
11+
Ensure that your link text is descriptive and the purpose of the link is clear even when read out of context of surrounding text.
12+
Learn more about how to write descriptive link text at [Access Guide: Write descriptive link text](https://www.accessguide.io/guide/descriptive-link-text)
13+
14+
## Resources
15+
16+
- [Primer: Links](https://primer.style/design/accessibility/links)
17+
- [Understanding Success Criterion 2.4.4: Link Purpose (In Context)](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html)
18+
- [WebAim: Links and Hypertext](https://webaim.org/techniques/hypertext/)
19+
- [Deque: Use link text that make sense when read out of context](https://dequeuniversity.com/tips/link-text)
20+
21+
## Examples
22+
23+
### **Incorrect** code for this rule 👎
24+
25+
```erb
26+
<a href="github.com/about">Learn more</a>
27+
```
28+
29+
```erb
30+
<!-- also bad -->
31+
<a href="github.com/about">Read more</a>
32+
```
33+
34+
```erb
35+
<!-- also bad -->
36+
<span>
37+
<a href="github.com/new">Click here</a> to create a new repository.
38+
</span>
39+
```
40+
41+
```erb
42+
<!-- also bad -->
43+
<%= link_to "Learn more", "#" %>
44+
```
45+
46+
### **Correct** code for this rule 👍
47+
48+
```erb
49+
<a href="github.com/about">Learn more about GitHub</a>
50+
```
51+
52+
```erb
53+
<a href="github.com/new">Create a new repository</a>
54+
```

lib/erblint-github/linters/custom_helpers.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ def generate_offense(klass, processed_source, tag, message = nil, replacement =
6464
add_offense(processed_source.to_source_range(tag.loc), offense, replacement)
6565
end
6666

67+
def generate_offense_from_source_range(klass, source_range, message = nil, replacement = nil)
68+
message ||= klass::MESSAGE
69+
message += "\nLearn more at https://github.com/github/erblint-github#rules.\n"
70+
offense = ["#{simple_class_name}:#{message}", source_range.source].join("\n")
71+
add_offense(source_range, offense, replacement)
72+
end
73+
6774
def possible_attribute_values(tag, attr_name)
6875
value = tag.attributes[attr_name]&.value || nil
6976
basic_conditional_code_check(value || "") || [value].compact
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../../custom_helpers"
4+
5+
module ERBLint
6+
module Linters
7+
module GitHub
8+
module Accessibility
9+
class AvoidGenericLinkTextCounter < Linter
10+
include ERBLint::Linters::CustomHelpers
11+
include LinterRegistry
12+
13+
BANNED_GENERIC_TEXT = [
14+
"Read more",
15+
"Learn more",
16+
"Click here",
17+
"More",
18+
"Link",
19+
"Here"
20+
].freeze
21+
MESSAGE = "Avoid using generic link text such as #{BANNED_GENERIC_TEXT.join(', ')} which do not make sense in isolation."
22+
23+
def run(processed_source)
24+
processed_source.ast.children.each_with_index do |node, index|
25+
next unless node.methods.include?(:type) && node.type == :text
26+
27+
text = node.children.join.strip
28+
# Checks HTML tags
29+
if banned_text?(text)
30+
prev_node = processed_source.ast.children[index - 1]
31+
next_node = processed_source.ast.children[index + 1]
32+
33+
next unless tag_type?(prev_node) && tag_type?(next_node)
34+
35+
text_node_tag = BetterHtml::Tree::Tag.from_node(node)
36+
prev_node_tag = BetterHtml::Tree::Tag.from_node(prev_node)
37+
next_node_tag = BetterHtml::Tree::Tag.from_node(next_node)
38+
39+
# We only report if the text is nested between two link tags.
40+
if link_tag?(prev_node_tag) && link_tag?(next_node_tag) && next_node_tag.closing?
41+
range = prev_node_tag.loc.begin_pos...text_node_tag.loc.end_pos
42+
source_range = processed_source.to_source_range(range)
43+
generate_offense_from_source_range(self.class, source_range)
44+
end
45+
end
46+
47+
# Checks Rails link helpers like `link_to`
48+
erb_node = node.type == :erb ? node : node.descendants(:erb).first
49+
next unless erb_node
50+
51+
_, _, code_node = *erb_node
52+
source = code_node.loc.source
53+
ruby_node = extract_ruby_node(source)
54+
send_node = ruby_node&.descendants(:send)&.first
55+
next unless send_node.methods.include?(:method_name) && send_node.method_name == :link_to
56+
57+
send_node.child_nodes.each do |child_node|
58+
if child_node.methods.include?(:type) && child_node.type == :str && banned_text?(child_node.children.join)
59+
tag = BetterHtml::Tree::Tag.from_node(code_node)
60+
generate_offense(self.class, processed_source, tag)
61+
end
62+
end
63+
end
64+
counter_correct?(processed_source)
65+
end
66+
67+
def autocorrect(processed_source, offense)
68+
return unless offense.context
69+
70+
lambda do |corrector|
71+
if processed_source.file_content.include?("erblint:counter #{simple_class_name}")
72+
# update the counter if exists
73+
corrector.replace(offense.source_range, offense.context)
74+
else
75+
# add comment with counter if none
76+
corrector.insert_before(processed_source.source_buffer.source_range, "#{offense.context}\n")
77+
end
78+
end
79+
end
80+
81+
private
82+
83+
def banned_text?(text)
84+
BANNED_GENERIC_TEXT.map(&:downcase).include?(text.downcase)
85+
end
86+
87+
def extract_ruby_node(source)
88+
BetterHtml::TestHelper::RubyNode.parse(source)
89+
rescue ::Parser::SyntaxError
90+
nil
91+
end
92+
93+
def link_tag?(tag_node)
94+
tag_node.name == "a"
95+
end
96+
97+
def tag_type?(node)
98+
node.methods.include?(:type) && node.type == :tag
99+
end
100+
end
101+
end
102+
end
103+
end
104+
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
class AvoidGenericLinkTextCounterTest < LinterTestCase
6+
def linter_class
7+
ERBLint::Linters::GitHub::Accessibility::AvoidGenericLinkTextCounter
8+
end
9+
10+
def test_warns_when_link_text_is_click_here
11+
@file = "<a>Click here</a>"
12+
@linter.run(processed_source)
13+
14+
refute_empty @linter.offenses
15+
end
16+
17+
def test_warns_when_link_text_is_learn_more
18+
@file = "<a>Learn more</a>"
19+
@linter.run(processed_source)
20+
21+
refute_empty @linter.offenses
22+
end
23+
24+
def test_warns_when_link_text_is_read_more
25+
@file = "<a>Read more</a>"
26+
@linter.run(processed_source)
27+
28+
refute_empty @linter.offenses
29+
end
30+
31+
def test_warns_when_link_text_is_more
32+
@file = "<a>More</a>"
33+
@linter.run(processed_source)
34+
35+
refute_empty @linter.offenses
36+
end
37+
38+
def test_warns_when_link_text_is_link
39+
@file = "<a>Link</a>"
40+
@linter.run(processed_source)
41+
42+
refute_empty @linter.offenses
43+
end
44+
45+
def test_does_not_warn_when_banned_text_is_part_of_more_text
46+
@file = "<a>Learn more about GitHub Stars</a>"
47+
@linter.run(processed_source)
48+
49+
assert_empty @linter.offenses
50+
end
51+
52+
def test_warns_when_link_rails_helper_text_is_banned_text
53+
@file = "<%= link_to('click here', redirect_url, id: 'redirect') %>"
54+
@linter.run(processed_source)
55+
56+
refute_empty @linter.offenses
57+
end
58+
59+
def test_does_not_warn_when_generic_text_is_link_rails_helper_sub_text
60+
@file = "<%= link_to('click here to learn about github', redirect_url, id: 'redirect') %>"
61+
@linter.run(processed_source)
62+
63+
assert_empty @linter.offenses
64+
end
65+
66+
def test_does_not_warns_if_element_has_correct_counter_comment
67+
@file = <<~ERB
68+
<%# erblint:counter GitHub::Accessibility::AvoidGenericLinkTextCounter 1 %>
69+
<a>Link</a>
70+
ERB
71+
@linter.run(processed_source)
72+
73+
assert_equal 0, @linter.offenses.count
74+
end
75+
76+
def test_autocorrects_when_ignores_are_not_correct
77+
@file = <<~ERB
78+
<%# erblint:counter GitHub::Accessibility::AvoidGenericLinkTextCounter 2 %>
79+
<a>Link</a>
80+
ERB
81+
refute_equal @file, corrected_content
82+
83+
expected_content = <<~ERB
84+
<%# erblint:counter GitHub::Accessibility::AvoidGenericLinkTextCounter 1 %>
85+
<a>Link</a>
86+
ERB
87+
assert_equal expected_content, corrected_content
88+
end
89+
end

0 commit comments

Comments
 (0)