Skip to content

Commit d2c4dad

Browse files
committed
feat: add template-no-invalid-link-href
1 parent 24882a3 commit d2c4dad

6 files changed

Lines changed: 399 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
239239
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | 📋 | | |
240240
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | 📋 | | |
241241
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | 📋 | | |
242+
| [template-no-invalid-link-href](docs/rules/template-no-invalid-link-href.md) | disallow invalid href values on anchor elements | | | |
242243
| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | 📋 | | |
243244
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | 📋 | | |
244245
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | 📋 | | |
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# ember/template-no-invalid-link-href
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallow link elements — `<a>` and `<area>` — whose `href` value is a commonly-misused placeholder (e.g. `href="#"`, `href=""`, `href="javascript:..."`). Both carry URL semantics per HTML §4.5.1 / §4.8.14, so the same validity rules apply on each.
6+
7+
This rule is **pragmatic accessibility/UX guidance, not spec enforcement.** Values like `href="#"` and `href="javascript:void(0)"` are technically valid URLs per the [HTML spec](https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-a-element); the rule flags them because they are widely-recognized anti-patterns for faking a clickable anchor:
8+
9+
- Breaks expected keyboard behavior (anchors should navigate; buttons should act)
10+
- The `javascript:` pseudo-protocol is [called out as an anti-pattern by MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#javascript_pseudo-protocol)
11+
- Leaves assistive tech announcing a link that doesn't navigate
12+
13+
If a click handler is what you want, use a `<button>`. If you want a genuine fragment link, use `href="#section-id"`.
14+
15+
Complements [`template-link-href-attributes`](./template-link-href-attributes.md), which handles the **missing** href case. This rule validates the href **value**.
16+
17+
## Examples
18+
19+
This rule **forbids** the following:
20+
21+
```gjs
22+
<template>
23+
<a href="#">Click</a>
24+
<a href="#!">Click</a>
25+
<a href="">Click</a>
26+
<a href>Click</a>
27+
<a href="javascript:void(0)">Click</a>
28+
<a href="JavaScript:alert(1)">Execute</a>
29+
</template>
30+
```
31+
32+
This rule **allows** the following:
33+
34+
```gjs
35+
<template>
36+
<a href="/x">Link</a>
37+
<a href="https://example.com">Link</a>
38+
<a href="#section">Fragment link</a>
39+
<a href="mailto:[email protected]">Email</a>
40+
<a href={{this.url}}>Dynamic</a>
41+
</template>
42+
```
43+
44+
Mustache hrefs whose value is a **static literal** (string, number, or boolean) are validated — the rule unwraps them to their static value via `getStaticAttrValue`. Only **truly dynamic** mustaches (PathExpressions, helpers with arguments, or concat statements that include a dynamic part) are skipped, because we can't statically determine what they will resolve to at runtime.
45+
46+
## References
47+
48+
- [HTML Living Standard — the `<a>` element](https://html.spec.whatwg.org/multipage/text-level-semantics.html#the-a-element)
49+
- [MDN — `<a>` — javascript: pseudo-protocol](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/a#javascript_pseudo-protocol)
50+
- [`anchor-is-valid` — eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/anchor-is-valid.md)
51+
- [`anchor-is-valid` — eslint-plugin-lit-a11y](https://github.com/open-wc/open-wc/blob/main/packages/eslint-plugin-lit-a11y/docs/rules/anchor-is-valid.md)
3.54 KB
Binary file not shown.

lib/utils/static-attr-value.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict';
2+
3+
/**
4+
* Return the statically-known string value of a Glimmer attribute value node,
5+
* or `undefined` when the value is dynamic (cannot be resolved at lint time).
6+
*
7+
* Unwraps:
8+
* - GlimmerTextNode → chars
9+
* - GlimmerMustacheStatement with a literal path (boolean/string/number) → stringified value
10+
* - GlimmerConcatStatement whose parts are all statically resolvable → joined string
11+
*
12+
* A missing/undefined value (valueless attribute, e.g. `<input disabled>`)
13+
* returns the empty string. Pass `attr.value` — not the attribute itself.
14+
*/
15+
function getStaticAttrValue(value) {
16+
if (value === null || value === undefined) {
17+
return '';
18+
}
19+
if (value.type === 'GlimmerTextNode') {
20+
return value.chars;
21+
}
22+
if (value.type === 'GlimmerMustacheStatement') {
23+
return extractLiteral(value.path);
24+
}
25+
if (value.type === 'GlimmerConcatStatement') {
26+
const parts = value.parts || [];
27+
let out = '';
28+
for (const part of parts) {
29+
if (part.type === 'GlimmerTextNode') {
30+
out += part.chars;
31+
continue;
32+
}
33+
if (part.type === 'GlimmerMustacheStatement') {
34+
const literal = extractLiteral(part.path);
35+
if (literal === undefined) {
36+
return undefined;
37+
}
38+
out += literal;
39+
continue;
40+
}
41+
return undefined;
42+
}
43+
return out;
44+
}
45+
return undefined;
46+
}
47+
48+
function extractLiteral(path) {
49+
if (!path) {
50+
return undefined;
51+
}
52+
if (path.type === 'GlimmerBooleanLiteral') {
53+
return path.value ? 'true' : 'false';
54+
}
55+
if (path.type === 'GlimmerStringLiteral') {
56+
return path.value;
57+
}
58+
if (path.type === 'GlimmerNumberLiteral') {
59+
return String(path.value);
60+
}
61+
return undefined;
62+
}
63+
64+
module.exports = { getStaticAttrValue };
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
'use strict';
2+
3+
const rule = require('../../../lib/rules/template-no-invalid-link-href');
4+
const RuleTester = require('eslint').RuleTester;
5+
6+
const ruleTester = new RuleTester({
7+
parser: require.resolve('ember-eslint-parser'),
8+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
9+
});
10+
11+
ruleTester.run('template-no-invalid-link-href', rule, {
12+
valid: [
13+
// Valid navigable hrefs.
14+
'<template><a href="/x">Link</a></template>',
15+
'<template><a href="https://example.com">Link</a></template>',
16+
'<template><a href="#section">Link</a></template>',
17+
'<template><a href="mailto:[email protected]">Email</a></template>',
18+
'<template><a href="tel:+47123">Phone</a></template>',
19+
20+
// Dynamic href — rule can't statically validate, skips.
21+
'<template><a href={{this.url}}>Link</a></template>',
22+
'<template><a href="{{this.prefix}}/{{this.slug}}">Link</a></template>',
23+
24+
// No href at all — handled by template-link-href-attributes, not this rule.
25+
'<template><a>Not a link</a></template>',
26+
27+
// Non-anchor elements are not in scope.
28+
'<template><button>Click me</button></template>',
29+
'<template><div href="#">Not an anchor</div></template>',
30+
31+
// <area> is in scope — same href semantics as <a>. Valid values pass.
32+
'<template><map name="m"><area href="/region-a" shape="rect" coords="0,0,10,10" /></map></template>',
33+
'<template><area href="#section" shape="default" /></template>',
34+
// Dynamic area href — skip.
35+
'<template><area href={{this.url}} shape="rect" coords="0,0,1,1" /></template>',
36+
37+
// Non-scheme URLs that happen to contain `javascript:` are not javascript:
38+
// URLs — they are relative paths or fragments. The URL parser resolves
39+
// them against the base URL; no script runs.
40+
'<template><a href="./javascript:foo">Relative path</a></template>',
41+
'<template><a href="#javascript:foo">Fragment id</a></template>',
42+
'<template><a href="/javascript:foo">Absolute path</a></template>',
43+
'<template><a href="?q=javascript:foo">Query string</a></template>',
44+
// `javascript` as a bare word (no colon) — treated as a relative path.
45+
// Our regex only matches the `javascript:` scheme (with colon), so these pass.
46+
'<template><a href="javascript">Relative</a></template>',
47+
'<template><a href="javascriptFoo">Relative</a></template>',
48+
],
49+
invalid: [
50+
// Plain "#" placeholder.
51+
{
52+
code: '<template><a href="#">Click</a></template>',
53+
output: null,
54+
errors: [{ messageId: 'invalidHref' }],
55+
},
56+
{
57+
code: '<template><a href="#!">Click</a></template>',
58+
output: null,
59+
errors: [{ messageId: 'invalidHref' }],
60+
},
61+
// Empty / whitespace href.
62+
{
63+
code: '<template><a href="">Click</a></template>',
64+
output: null,
65+
errors: [{ messageId: 'invalidHref' }],
66+
},
67+
{
68+
code: '<template><a href=" ">Click</a></template>',
69+
output: null,
70+
errors: [{ messageId: 'invalidHref' }],
71+
},
72+
{
73+
code: '<template><a href>Click</a></template>',
74+
output: null,
75+
errors: [{ messageId: 'invalidHref' }],
76+
},
77+
// javascript: protocol.
78+
{
79+
code: '<template><a href="javascript:void(0)">Click</a></template>',
80+
output: null,
81+
errors: [{ messageId: 'invalidHref' }],
82+
},
83+
{
84+
code: '<template><a href="JavaScript:alert(1)">Click</a></template>',
85+
output: null,
86+
errors: [{ messageId: 'invalidHref' }],
87+
},
88+
// Leading whitespace — catches obfuscations.
89+
{
90+
code: '<template><a href=" javascript:void(0)">Click</a></template>',
91+
output: null,
92+
errors: [{ messageId: 'invalidHref' }],
93+
},
94+
// Mustache-string-literal hrefs resolve to their static value via the
95+
// shared `getStaticAttrValue` helper — the rule validates them the same
96+
// as text-node values. Covers the common bypass hole where authors
97+
// wrap a literal href in mustaches (`{{"#"}}`) to dodge a simple
98+
// "is this a text node" check.
99+
{
100+
code: '<template><a href={{"#"}}>Click</a></template>',
101+
output: null,
102+
errors: [{ messageId: 'invalidHref' }],
103+
},
104+
{
105+
code: '<template><a href={{"javascript:void(0)"}}>Click</a></template>',
106+
output: null,
107+
errors: [{ messageId: 'invalidHref' }],
108+
},
109+
// Single-part quoted-mustache (GlimmerConcatStatement wrapping a
110+
// literal) resolves the same way.
111+
{
112+
code: '<template><a href="{{\'#\'}}">Click</a></template>',
113+
output: null,
114+
errors: [{ messageId: 'invalidHref' }],
115+
},
116+
// <area> shares <a>'s href semantics — same invalid values flag.
117+
{
118+
code: '<template><area href="#" shape="rect" coords="0,0,10,10" /></template>',
119+
output: null,
120+
errors: [{ messageId: 'invalidHref' }],
121+
},
122+
{
123+
code: '<template><area href="" shape="rect" coords="0,0,10,10" /></template>',
124+
output: null,
125+
errors: [{ messageId: 'invalidHref' }],
126+
},
127+
{
128+
code: '<template><area href="javascript:alert(1)" shape="rect" coords="0,0,10,10" /></template>',
129+
output: null,
130+
errors: [{ messageId: 'invalidHref' }],
131+
},
132+
],
133+
});
134+
135+
const hbsRuleTester = new RuleTester({
136+
parser: require.resolve('ember-eslint-parser/hbs'),
137+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
138+
});
139+
140+
hbsRuleTester.run('template-no-invalid-link-href', rule, {
141+
valid: ['<a href="/x">Link</a>', '<a href={{this.url}}>Link</a>', '<a>Not a link</a>'],
142+
invalid: [
143+
{
144+
code: '<a href="#">Click</a>',
145+
output: null,
146+
errors: [{ messageId: 'invalidHref' }],
147+
},
148+
{
149+
code: '<a href="javascript:void(0)">Click</a>',
150+
output: null,
151+
errors: [{ messageId: 'invalidHref' }],
152+
},
153+
],
154+
});

0 commit comments

Comments
 (0)