Skip to content

Commit ad4c929

Browse files
committed
Extract rule: template-no-invalid-link-text
1 parent 072c781 commit ad4c929

4 files changed

Lines changed: 558 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ rules in templates can be disabled with eslint directives with mustache or html
191191
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
192192
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
193193
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
194+
| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | | | |
194195
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
195196
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
196197
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# ember/template-no-invalid-link-text
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallows invalid or uninformative link text content.
6+
7+
Link text should be descriptive and provide context about the destination. Generic phrases like "click here" or "read more" are not accessible because they don't convey meaningful information, especially for screen reader users who may navigate by links alone.
8+
9+
## Rule Details
10+
11+
This rule disallows the following link text values:
12+
13+
- "click here"
14+
- "more info"
15+
- "read more"
16+
- "more"
17+
18+
Comparison is case-insensitive and whitespace is normalized.
19+
20+
Links with a valid `aria-label` or `aria-labelledby` attribute are exempt. A valid `aria-label` must be non-empty and must not itself be a disallowed text value.
21+
22+
## Examples
23+
24+
Examples of **incorrect** code for this rule:
25+
26+
```gjs
27+
<template>
28+
<a href="/about">Click here</a>
29+
</template>
30+
```
31+
32+
```gjs
33+
<template>
34+
<a href="/docs">Read more</a>
35+
</template>
36+
```
37+
38+
```gjs
39+
<template>
40+
<LinkTo @route="info">More info</LinkTo>
41+
</template>
42+
```
43+
44+
Examples of **correct** code for this rule:
45+
46+
```gjs
47+
<template>
48+
<a href="/about">About Us</a>
49+
</template>
50+
```
51+
52+
```gjs
53+
<template>
54+
<a href="/docs">Documentation</a>
55+
</template>
56+
```
57+
58+
```gjs
59+
<template>
60+
<a href="/page" aria-label="View user profile">Click here</a>
61+
</template>
62+
```
63+
64+
## Options
65+
66+
| Name | Type | Default | Description |
67+
| ----------------- | ---------- | ------- | --------------------------------------------------------------------------- |
68+
| `allowEmptyLinks` | `boolean` | `false` | When `true`, allows links with no text content. |
69+
| `linkComponents` | `string[]` | `[]` | Additional component names treated as links (besides `<a>` and `<LinkTo>`). |
70+
71+
## References
72+
73+
- [WebAIM: Link Text and Appearance](https://webaim.org/techniques/hypertext/link_text)
74+
- [WCAG 2.4.4: Link Purpose (In Context)](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html)
75+
- [eslint-plugin-ember template-no-invalid-link-text](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-invalid-link-text.md)
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
const DISALLOWED_LINK_TEXTS = new Set(['click here', 'more info', 'read more', 'more']);
2+
3+
function getTextContentResult(node) {
4+
if (node.type === 'GlimmerTextNode') {
5+
return { text: node.chars.replaceAll('&nbsp;', ' '), hasDynamic: false };
6+
}
7+
if (node.type === 'GlimmerMustacheStatement' || node.type === 'GlimmerSubExpression') {
8+
return { text: '', hasDynamic: true };
9+
}
10+
if (node.type === 'GlimmerElementNode' && node.children) {
11+
let text = '';
12+
let hasDynamic = false;
13+
for (const child of node.children) {
14+
const result = getTextContentResult(child);
15+
text += result.text;
16+
if (result.hasDynamic) {
17+
hasDynamic = true;
18+
}
19+
}
20+
return { text, hasDynamic };
21+
}
22+
return { text: '', hasDynamic: false };
23+
}
24+
25+
/**
26+
* Check if the node has a valid aria-label or aria-labelledby that
27+
* exempts it from link text validation.
28+
*/
29+
function hasValidAriaLabelOrLabelledby(node) {
30+
const attrs = node.attributes || [];
31+
32+
// Check aria-labelledby
33+
const ariaLabelledby = attrs.find((a) => a.name === 'aria-labelledby');
34+
if (ariaLabelledby) {
35+
if (ariaLabelledby.value && ariaLabelledby.value.type === 'GlimmerTextNode') {
36+
const val = ariaLabelledby.value.chars.trim();
37+
// Only valid if non-empty
38+
return val.length > 0;
39+
}
40+
// Dynamic value — assume valid
41+
if (
42+
ariaLabelledby.value &&
43+
(ariaLabelledby.value.type === 'GlimmerMustacheStatement' ||
44+
ariaLabelledby.value.type === 'GlimmerConcatStatement')
45+
) {
46+
return true;
47+
}
48+
// No value or empty — not valid
49+
return false;
50+
}
51+
52+
// Check aria-label
53+
const ariaLabel = attrs.find((a) => a.name === 'aria-label');
54+
if (ariaLabel) {
55+
// Dynamic value — assume valid
56+
if (
57+
ariaLabel.value &&
58+
(ariaLabel.value.type === 'GlimmerMustacheStatement' ||
59+
ariaLabel.value.type === 'GlimmerConcatStatement')
60+
) {
61+
return true;
62+
}
63+
if (ariaLabel.value && ariaLabel.value.type === 'GlimmerTextNode') {
64+
const val = ariaLabel.value.chars.replaceAll('&nbsp;', ' ').toLowerCase().trim();
65+
// aria-label itself must not be disallowed text
66+
return val.length > 0 && !DISALLOWED_LINK_TEXTS.has(val);
67+
}
68+
return false;
69+
}
70+
71+
return false;
72+
}
73+
74+
/** @type {import('eslint').Rule.RuleModule} */
75+
module.exports = {
76+
meta: {
77+
type: 'problem',
78+
docs: {
79+
description: 'disallow invalid or uninformative link text content',
80+
category: 'Accessibility',
81+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-invalid-link-text.md',
82+
templateMode: 'both',
83+
},
84+
fixable: null,
85+
schema: [
86+
{
87+
type: 'object',
88+
properties: {
89+
allowEmptyLinks: { type: 'boolean' },
90+
linkComponents: { type: 'array', items: { type: 'string' } },
91+
},
92+
additionalProperties: false,
93+
},
94+
],
95+
messages: {
96+
invalidText:
97+
'Link text "{{text}}" is not descriptive. Use meaningful text that describes the link destination.',
98+
},
99+
originallyFrom: {
100+
name: 'ember-template-lint',
101+
rule: 'lib/rules/no-invalid-link-text.js',
102+
docs: 'docs/rule/no-invalid-link-text.md',
103+
tests: 'test/unit/rules/no-invalid-link-text-test.js',
104+
},
105+
},
106+
107+
create(context) {
108+
const options = context.options[0] || {};
109+
const allowEmptyLinks = options.allowEmptyLinks || false;
110+
const customLinkComponents = options.linkComponents || [];
111+
const linkTags = new Set(['a', 'LinkTo', ...customLinkComponents]);
112+
113+
function checkLinkContent(node, children) {
114+
// Skip if has aria-hidden
115+
const ariaHidden = (node.attributes || []).find((a) => a.name === 'aria-hidden');
116+
if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
117+
return;
118+
}
119+
120+
// Skip if has hidden attribute
121+
if ((node.attributes || []).some((a) => a.name === 'hidden')) {
122+
return;
123+
}
124+
125+
// Check aria-label / aria-labelledby
126+
if (hasValidAriaLabelOrLabelledby(node)) {
127+
return;
128+
}
129+
130+
let fullText = '';
131+
let hasDynamic = false;
132+
133+
for (const child of children || []) {
134+
const result = getTextContentResult(child);
135+
fullText += result.text;
136+
if (result.hasDynamic) {
137+
hasDynamic = true;
138+
}
139+
}
140+
141+
// If there's dynamic content, skip (can't validate)
142+
if (hasDynamic) {
143+
return;
144+
}
145+
146+
const normalized = fullText.trim().toLowerCase().replaceAll(/\s+/g, ' ');
147+
148+
// Empty link check
149+
if (!normalized.replaceAll(' ', '')) {
150+
if (!allowEmptyLinks) {
151+
context.report({
152+
node,
153+
messageId: 'invalidText',
154+
data: { text: '(empty)' },
155+
});
156+
}
157+
return;
158+
}
159+
160+
if (DISALLOWED_LINK_TEXTS.has(normalized)) {
161+
context.report({
162+
node,
163+
messageId: 'invalidText',
164+
data: { text: normalized },
165+
});
166+
}
167+
}
168+
169+
return {
170+
GlimmerElementNode(node) {
171+
if (!linkTags.has(node.tag)) {
172+
return;
173+
}
174+
175+
checkLinkContent(node, node.children);
176+
},
177+
178+
GlimmerBlockStatement(node) {
179+
if (
180+
node.path &&
181+
node.path.type === 'GlimmerPathExpression' &&
182+
node.path.original === 'link-to'
183+
) {
184+
checkLinkContent(node, node.program && node.program.body);
185+
}
186+
},
187+
};
188+
},
189+
};

0 commit comments

Comments
 (0)