Skip to content

Commit ad734a3

Browse files
Merge pull request #2473 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-invalid-link-title
Extract rule: template-no-invalid-link-title
2 parents 7cebf8f + 91b7ef9 commit ad734a3

4 files changed

Lines changed: 361 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ rules in templates can be disabled with eslint directives with mustache or html
189189
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
190190
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
191191
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
192+
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
192193
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
193194
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
194195
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# ember/template-no-invalid-link-title
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallow invalid `title` attributes on link elements. The title should not be empty or the same as the link text.
6+
7+
## Rule Details
8+
9+
This rule ensures that link titles provide additional context and are not redundant with the link text.
10+
11+
## Examples
12+
13+
Examples of **incorrect** code for this rule:
14+
15+
```gjs
16+
<template>
17+
<a href="/page" title="">Page</a>
18+
</template>
19+
20+
<template>
21+
<a href="/page" title="Page">Page</a>
22+
</template>
23+
```
24+
25+
Examples of **correct** code for this rule:
26+
27+
```gjs
28+
<template>
29+
<a href="/page" title="More information about page">Page</a>
30+
</template>
31+
32+
<template>
33+
<a href="/page">Page</a>
34+
</template>
35+
```
36+
37+
## Migration
38+
39+
- If the `title` attribute value is the same as or part of the link text, it's better to leave it out.
40+
41+
## References
42+
43+
- [Supplementing link text with the title attribute](https://www.w3.org/TR/WCAG20-TECHS/H33.html)
44+
- [Understanding Link Purpose](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-refs.html)
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* Get all static text content from children of a node, lowercased and trimmed.
3+
*/
4+
function getChildTextContents(children) {
5+
const texts = [];
6+
for (const child of children || []) {
7+
if (child.type === 'GlimmerTextNode') {
8+
const trimmed = child.chars.toLowerCase().trim();
9+
if (trimmed.length > 0) {
10+
texts.push(trimmed);
11+
}
12+
}
13+
}
14+
return texts;
15+
}
16+
17+
/**
18+
* Check if any link text contains any of the title values.
19+
*/
20+
function hasInvalidLinkTitle(children, titleValues) {
21+
const linkTexts = getChildTextContents(children);
22+
return linkTexts.some((linkText) => titleValues.some((title) => linkText.includes(title)));
23+
}
24+
25+
/** @type {import('eslint').Rule.RuleModule} */
26+
module.exports = {
27+
meta: {
28+
type: 'problem',
29+
docs: {
30+
description: 'disallow invalid title attributes on link elements',
31+
category: 'Accessibility',
32+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-invalid-link-title.md',
33+
templateMode: 'both',
34+
},
35+
schema: [],
36+
messages: {
37+
noInvalidLinkTitle: 'Link title attribute should not be the same as link text or empty.',
38+
},
39+
originallyFrom: {
40+
name: 'ember-template-lint',
41+
rule: 'lib/rules/no-invalid-link-title.js',
42+
docs: 'docs/rule/no-invalid-link-title.md',
43+
tests: 'test/unit/rules/no-invalid-link-title-test.js',
44+
},
45+
},
46+
47+
create(context) {
48+
// eslint-disable-next-line complexity
49+
function checkElementNode(node) {
50+
if (node.tag !== 'a' && node.tag !== 'LinkTo') {
51+
return;
52+
}
53+
54+
const titleAttr = node.attributes.find(
55+
(attr) => attr.type === 'GlimmerAttrNode' && attr.name === 'title'
56+
);
57+
const titleArgAttr =
58+
node.tag === 'LinkTo'
59+
? node.attributes.find(
60+
(attr) => attr.type === 'GlimmerAttrNode' && attr.name === '@title'
61+
)
62+
: null;
63+
64+
// Get title attribute text value
65+
let titleAttrValue;
66+
if (titleAttr && titleAttr.value && titleAttr.value.type === 'GlimmerTextNode') {
67+
titleAttrValue = titleAttr.value.chars;
68+
}
69+
70+
// Get @title argument text value (LinkTo only)
71+
let titleArgValue;
72+
if (titleArgAttr && titleArgAttr.value && titleArgAttr.value.type === 'GlimmerTextNode') {
73+
titleArgValue = titleArgAttr.value.chars;
74+
}
75+
76+
// Collect all title values (lowercased, trimmed)
77+
const titleValues = [titleAttrValue, node.tag === 'LinkTo' ? titleArgValue : null]
78+
.filter((v) => typeof v === 'string')
79+
.map((v) => v.toLowerCase().trim());
80+
81+
// Error if both title and @title are specified on LinkTo
82+
if (node.tag === 'LinkTo' && titleAttrValue !== undefined && titleArgValue !== undefined) {
83+
context.report({
84+
node: titleAttr || node,
85+
messageId: 'noInvalidLinkTitle',
86+
});
87+
return;
88+
}
89+
90+
// Check empty title
91+
if (titleValues.includes('')) {
92+
context.report({
93+
node: titleAttr || node,
94+
messageId: 'noInvalidLinkTitle',
95+
});
96+
return;
97+
}
98+
99+
// Check if title is included in link text
100+
if (titleValues.length > 0 && hasInvalidLinkTitle(node.children, titleValues)) {
101+
context.report({
102+
node: titleAttr || titleArgAttr || node,
103+
messageId: 'noInvalidLinkTitle',
104+
});
105+
}
106+
}
107+
108+
function checkBlockStatement(node) {
109+
if (
110+
!node.path ||
111+
node.path.type !== 'GlimmerPathExpression' ||
112+
node.path.original !== 'link-to'
113+
) {
114+
return;
115+
}
116+
117+
// Find title in hash pairs
118+
let titleValue;
119+
if (node.hash && node.hash.pairs) {
120+
const titlePair = node.hash.pairs.find((pair) => pair.key === 'title');
121+
if (titlePair && titlePair.value) {
122+
if (titlePair.value.type === 'GlimmerStringLiteral') {
123+
titleValue = titlePair.value.value;
124+
} else if (titlePair.value.type === 'GlimmerTextNode') {
125+
titleValue = titlePair.value.chars;
126+
}
127+
}
128+
}
129+
130+
if (typeof titleValue !== 'string') {
131+
return;
132+
}
133+
134+
const normalizedTitle = titleValue.toLowerCase().trim();
135+
136+
if (!normalizedTitle) {
137+
context.report({
138+
node,
139+
messageId: 'noInvalidLinkTitle',
140+
});
141+
return;
142+
}
143+
144+
if (hasInvalidLinkTitle(node.program && node.program.body, [normalizedTitle])) {
145+
context.report({
146+
node,
147+
messageId: 'noInvalidLinkTitle',
148+
});
149+
}
150+
}
151+
152+
return {
153+
GlimmerElementNode: checkElementNode,
154+
GlimmerBlockStatement: checkBlockStatement,
155+
};
156+
},
157+
};
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
//------------------------------------------------------------------------------
2+
// Requirements
3+
//------------------------------------------------------------------------------
4+
5+
const rule = require('../../../lib/rules/template-no-invalid-link-title');
6+
const RuleTester = require('eslint').RuleTester;
7+
8+
const ruleTester = new RuleTester({
9+
parser: require.resolve('ember-eslint-parser'),
10+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
11+
});
12+
13+
ruleTester.run('template-no-invalid-link-title', rule, {
14+
valid: [
15+
'<template><a href="/page" title="More information about page">Page</a></template>',
16+
'<template><a href="/page">Page</a></template>',
17+
'<template><a href="/page" title={{dynamic}}>Page</a></template>',
18+
19+
'<template><a href="https://myurl.com">Click here to read more about this amazing adventure</a></template>',
20+
'<template>{{#link-to}} click here to read more about our company{{/link-to}}</template>',
21+
'<template><LinkTo>Read more about ways semantic HTML can make your code more accessible.</LinkTo></template>',
22+
'<template><LinkTo>{{foo}} more</LinkTo></template>',
23+
'<template><LinkTo @title="nice title">Something else</LinkTo></template>',
24+
'<template><LinkTo title="great titles!">Whatever, don\'t judge me</LinkTo></template>',
25+
'<template><LinkTo title="Download the video">Download</LinkTo></template>',
26+
'<template><a href="https://myurl.com" title="New to Ember? Read the full tutorial for the best experience">Read the Tutorial</a></template>',
27+
'<template><a href="./whatever" title={{foo}}>Hello!</a></template>',
28+
'<template>{{#link-to "blah.route.here" title="awesome title"}}Some thing else here{{/link-to}}</template>',
29+
`<template>
30+
<LinkTo @query={{hash page=@pagination.prevPage}} local-class="prev" @rel="prev" @title="previous page" data-test-pagination-prev>
31+
{{svg-jar "left-pag"}}
32+
</LinkTo>
33+
</template>`,
34+
],
35+
invalid: [
36+
{
37+
code: '<template><a href="/page" title="">Page</a></template>',
38+
output: null,
39+
errors: [{ messageId: 'noInvalidLinkTitle' }],
40+
},
41+
{
42+
code: '<template><a href="/page" title="Page">Page</a></template>',
43+
output: null,
44+
errors: [{ messageId: 'noInvalidLinkTitle' }],
45+
},
46+
47+
{
48+
code: '<template><a href="https://myurl.com" title="read the tutorial">Read the Tutorial</a></template>',
49+
output: null,
50+
errors: [{ messageId: 'noInvalidLinkTitle' }],
51+
},
52+
{
53+
code: '<template><LinkTo title="quickstart">Quickstart</LinkTo></template>',
54+
output: null,
55+
errors: [{ messageId: 'noInvalidLinkTitle' }],
56+
},
57+
{
58+
code: '<template><LinkTo @title="foo" title="blah">derp</LinkTo></template>',
59+
output: null,
60+
errors: [{ messageId: 'noInvalidLinkTitle' }],
61+
},
62+
{
63+
code: '<template>{{#link-to title="Do the things"}}Do the things{{/link-to}}</template>',
64+
output: null,
65+
errors: [{ messageId: 'noInvalidLinkTitle' }],
66+
},
67+
{
68+
code: '<template><LinkTo @route="some.route" @title="Do the things">Do the things</LinkTo></template>',
69+
output: null,
70+
errors: [{ messageId: 'noInvalidLinkTitle' }],
71+
},
72+
{
73+
code: '<template><a href="https://myurl.com" title="Tutorial">Read the Tutorial</a></template>',
74+
output: null,
75+
errors: [{ messageId: 'noInvalidLinkTitle' }],
76+
},
77+
{
78+
code: '<template><LinkTo title="Tutorial">Read the Tutorial</LinkTo></template>',
79+
output: null,
80+
errors: [{ messageId: 'noInvalidLinkTitle' }],
81+
},
82+
{
83+
code: '<template>{{#link-to title="Tutorial"}}Read the Tutorial{{/link-to}}</template>',
84+
output: null,
85+
errors: [{ messageId: 'noInvalidLinkTitle' }],
86+
},
87+
],
88+
});
89+
90+
const hbsRuleTester = new RuleTester({
91+
parser: require.resolve('ember-eslint-parser/hbs'),
92+
parserOptions: {
93+
ecmaVersion: 2022,
94+
sourceType: 'module',
95+
},
96+
});
97+
98+
hbsRuleTester.run('template-no-invalid-link-title', rule, {
99+
valid: [
100+
'<a href="https://myurl.com">Click here to read more about this amazing adventure</a>',
101+
'{{#link-to}} click here to read more about our company{{/link-to}}',
102+
'<LinkTo>Read more about ways semantic HTML can make your code more accessible.</LinkTo>',
103+
'<LinkTo>{{foo}} more</LinkTo>',
104+
'<LinkTo @title="nice title">Something else</LinkTo>',
105+
'<LinkTo title="great titles!">Whatever, don\'t judge me</LinkTo>',
106+
'<LinkTo title="Download the video">Download</LinkTo>',
107+
'<a href="https://myurl.com" title="New to Ember? Read the full tutorial for the best experience">Read the Tutorial</a>',
108+
'<a href="./whatever" title={{foo}}>Hello!</a>',
109+
'{{#link-to "blah.route.here" title="awesome title"}}Some thing else here{{/link-to}}',
110+
`
111+
<LinkTo @query={{hash page=@pagination.prevPage}} local-class="prev" @rel="prev" @title="previous page" data-test-pagination-prev>
112+
{{svg-jar "left-pag"}}
113+
</LinkTo>
114+
`,
115+
'<template><LinkTo>Quickstart</LinkTo></template>',
116+
],
117+
invalid: [
118+
{
119+
code: '<a href="https://myurl.com" title="read the tutorial">Read the Tutorial</a>',
120+
output: null,
121+
errors: [{ message: 'Link title attribute should not be the same as link text or empty.' }],
122+
},
123+
{
124+
code: '<LinkTo title="quickstart">Quickstart</LinkTo>',
125+
output: null,
126+
errors: [{ message: 'Link title attribute should not be the same as link text or empty.' }],
127+
},
128+
{
129+
code: '<LinkTo @title="foo" title="blah">derp</LinkTo>',
130+
output: null,
131+
errors: [{ message: 'Link title attribute should not be the same as link text or empty.' }],
132+
},
133+
{
134+
code: '{{#link-to title="Do the things"}}Do the things{{/link-to}}',
135+
output: null,
136+
errors: [{ message: 'Link title attribute should not be the same as link text or empty.' }],
137+
},
138+
{
139+
code: '<LinkTo @route="some.route" @title="Do the things">Do the things</LinkTo>',
140+
output: null,
141+
errors: [{ message: 'Link title attribute should not be the same as link text or empty.' }],
142+
},
143+
{
144+
code: '<a href="https://myurl.com" title="Tutorial">Read the Tutorial</a>',
145+
output: null,
146+
errors: [{ message: 'Link title attribute should not be the same as link text or empty.' }],
147+
},
148+
{
149+
code: '<LinkTo title="Tutorial">Read the Tutorial</LinkTo>',
150+
output: null,
151+
errors: [{ message: 'Link title attribute should not be the same as link text or empty.' }],
152+
},
153+
{
154+
code: '{{#link-to title="Tutorial"}}Read the Tutorial{{/link-to}}',
155+
output: null,
156+
errors: [{ message: 'Link title attribute should not be the same as link text or empty.' }],
157+
},
158+
],
159+
});

0 commit comments

Comments
 (0)