Skip to content

Commit ab1c01e

Browse files
Merge pull request #2472 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-invalid-link-text
Extract rule: template-no-invalid-link-text
2 parents 538ed00 + 659c116 commit ab1c01e

4 files changed

Lines changed: 545 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+
- [ember-template-lint: no-invalid-link-text](https://github.com/ember-template-lint/ember-template-lint/blob/main/docs/rule/no-invalid-link-text.md)
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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+
function isDynamicValue(value) {
26+
return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement';
27+
}
28+
29+
/**
30+
* Checks aria-labelledby and aria-label attributes.
31+
* Returns:
32+
* { skip: true } — has valid accessible name, skip element
33+
* { report: true, text: string } — aria-label is itself a disallowed text, report it
34+
* { skip: false } — no valid aria override, check text content
35+
*/
36+
function checkAriaAttributes(attrs) {
37+
const ariaLabelledby = attrs.find((a) => a.name === 'aria-labelledby');
38+
if (ariaLabelledby) {
39+
if (isDynamicValue(ariaLabelledby.value)) {
40+
return { skip: true };
41+
}
42+
if (ariaLabelledby.value?.type === 'GlimmerTextNode') {
43+
if (ariaLabelledby.value.chars.trim().length > 0) {
44+
return { skip: true }; // valid non-empty labelledby
45+
}
46+
}
47+
// empty aria-labelledby → fall through
48+
return { skip: false };
49+
}
50+
51+
const ariaLabel = attrs.find((a) => a.name === 'aria-label');
52+
if (ariaLabel) {
53+
if (isDynamicValue(ariaLabel.value)) {
54+
return { skip: true };
55+
}
56+
if (ariaLabel.value?.type === 'GlimmerTextNode') {
57+
const val = ariaLabel.value.chars.replaceAll('&nbsp;', ' ').toLowerCase().trim();
58+
if (val.length > 0 && !DISALLOWED_LINK_TEXTS.has(val)) {
59+
return { skip: true }; // valid aria-label
60+
}
61+
if (val.length > 0) {
62+
return { skip: true, report: true, text: val }; // aria-label itself is disallowed
63+
}
64+
}
65+
}
66+
67+
return { skip: false };
68+
}
69+
70+
/** @type {import('eslint').Rule.RuleModule} */
71+
module.exports = {
72+
meta: {
73+
type: 'problem',
74+
docs: {
75+
description: 'disallow invalid or uninformative link text content',
76+
category: 'Accessibility',
77+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-invalid-link-text.md',
78+
templateMode: 'both',
79+
},
80+
fixable: null,
81+
schema: [
82+
{
83+
type: 'object',
84+
properties: {
85+
allowEmptyLinks: { type: 'boolean' },
86+
linkComponents: { type: 'array', items: { type: 'string' } },
87+
},
88+
additionalProperties: false,
89+
},
90+
],
91+
messages: {
92+
invalidText:
93+
'Link text "{{text}}" is not descriptive. Use meaningful text that describes the link destination.',
94+
},
95+
originallyFrom: {
96+
name: 'ember-template-lint',
97+
rule: 'lib/rules/no-invalid-link-text.js',
98+
docs: 'docs/rule/no-invalid-link-text.md',
99+
tests: 'test/unit/rules/no-invalid-link-text-test.js',
100+
},
101+
},
102+
103+
create(context) {
104+
const options = context.options[0] || {};
105+
const allowEmptyLinks = options.allowEmptyLinks || false;
106+
const customLinkComponents = options.linkComponents || [];
107+
108+
const filename = context.filename ?? context.getFilename();
109+
const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts');
110+
111+
// In HBS, LinkTo always refers to Ember's router link component.
112+
// In GJS/GTS, LinkTo must be explicitly imported from '@ember/routing'.
113+
// local alias → true (any truthy value marks it as a tracked link component)
114+
const importedLinkComponents = new Map();
115+
116+
const linkTags = new Set(['a', ...customLinkComponents]);
117+
if (!isStrictMode) {
118+
linkTags.add('LinkTo');
119+
}
120+
121+
function checkLinkContent(node, children) {
122+
const attrs = node.attributes || [];
123+
124+
// Skip if aria-hidden="true"
125+
const ariaHidden = attrs.find((a) => a.name === 'aria-hidden');
126+
if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
127+
return;
128+
}
129+
130+
// Skip if hidden attribute present
131+
if (attrs.some((a) => a.name === 'hidden')) {
132+
return;
133+
}
134+
135+
const ariaResult = checkAriaAttributes(attrs);
136+
if (ariaResult.report) {
137+
context.report({ node, messageId: 'invalidText', data: { text: ariaResult.text } });
138+
return;
139+
}
140+
if (ariaResult.skip) {
141+
return;
142+
}
143+
144+
// Check text content
145+
let fullText = '';
146+
let hasDynamic = false;
147+
for (const child of children || []) {
148+
const result = getTextContentResult(child);
149+
fullText += result.text;
150+
if (result.hasDynamic) {
151+
hasDynamic = true;
152+
}
153+
}
154+
155+
if (hasDynamic) {
156+
return; // can't validate dynamic content
157+
}
158+
159+
const normalized = fullText.trim().toLowerCase().replaceAll(/\s+/g, ' ');
160+
161+
if (!normalized.replaceAll(' ', '')) {
162+
if (!allowEmptyLinks) {
163+
context.report({ node, messageId: 'invalidText', data: { text: '(empty)' } });
164+
}
165+
return;
166+
}
167+
168+
if (DISALLOWED_LINK_TEXTS.has(normalized)) {
169+
context.report({ node, messageId: 'invalidText', data: { text: normalized } });
170+
}
171+
}
172+
173+
return {
174+
ImportDeclaration(node) {
175+
if (node.source.value === '@ember/routing') {
176+
for (const specifier of node.specifiers) {
177+
if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'LinkTo') {
178+
importedLinkComponents.set(specifier.local.name, true);
179+
linkTags.add(specifier.local.name);
180+
}
181+
}
182+
}
183+
},
184+
185+
GlimmerElementNode(node) {
186+
if (!linkTags.has(node.tag)) {
187+
return;
188+
}
189+
checkLinkContent(node, node.children);
190+
},
191+
192+
GlimmerBlockStatement(node) {
193+
if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'link-to') {
194+
checkLinkContent(node, node.program?.body);
195+
}
196+
},
197+
};
198+
},
199+
};

0 commit comments

Comments
 (0)