Skip to content

Commit a48260c

Browse files
committed
feat: add template-valid-input-attributes — flag input attributes incompatible with declared type
1 parent 414d6d5 commit a48260c

4 files changed

Lines changed: 327 additions & 8 deletions

File tree

README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -491,14 +491,15 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
491491

492492
### Possible Errors
493493

494-
| Name                                                 | Description | 💼 | 🔧 | 💡 |
495-
| :------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------- | :- | :- | :- |
496-
| [template-no-extra-mut-helper-argument](docs/rules/template-no-extra-mut-helper-argument.md) | disallow passing more than one argument to the mut helper | 📋 | | |
497-
| [template-no-jsx-attributes](docs/rules/template-no-jsx-attributes.md) | disallow JSX-style camelCase attributes | | 🔧 | |
498-
| [template-no-scope-outside-table-headings](docs/rules/template-no-scope-outside-table-headings.md) | disallow scope attribute outside th elements | 📋 | | |
499-
| [template-no-shadowed-elements](docs/rules/template-no-shadowed-elements.md) | disallow ambiguity with block param names shadowing HTML elements | 📋 | | |
500-
| [template-no-unbalanced-curlies](docs/rules/template-no-unbalanced-curlies.md) | disallow unbalanced mustache curlies | 📋 | | |
501-
| [template-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | 📋 | 🔧 | |
494+
| Name                                                 | Description | 💼 | 🔧 | 💡 |
495+
| :------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------- | :- | :- | :- |
496+
| [template-no-extra-mut-helper-argument](docs/rules/template-no-extra-mut-helper-argument.md) | disallow passing more than one argument to the mut helper | 📋 | | |
497+
| [template-no-jsx-attributes](docs/rules/template-no-jsx-attributes.md) | disallow JSX-style camelCase attributes | | 🔧 | |
498+
| [template-no-scope-outside-table-headings](docs/rules/template-no-scope-outside-table-headings.md) | disallow scope attribute outside th elements | 📋 | | |
499+
| [template-no-shadowed-elements](docs/rules/template-no-shadowed-elements.md) | disallow ambiguity with block param names shadowing HTML elements | 📋 | | |
500+
| [template-no-unbalanced-curlies](docs/rules/template-no-unbalanced-curlies.md) | disallow unbalanced mustache curlies | 📋 | | |
501+
| [template-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | 📋 | 🔧 | |
502+
| [template-valid-input-attributes](docs/rules/template-valid-input-attributes.md) | disallow input attributes that are incompatible with the declared type | | | |
502503

503504
### Routes
504505

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# ember/template-valid-input-attributes
2+
3+
<!-- end auto-generated rule header -->
4+
5+
This rule flags `<input>` attributes that are incompatible with the
6+
declared `type`. For example, `pattern` only applies to the text-like
7+
input types; on `type="number"` it is silently ignored by the browser.
8+
9+
The attribute/type compatibility table matches the HTML living standard.
10+
Dynamic type values (e.g. `type={{this.inputType}}`) are skipped. Inputs
11+
with a missing, valueless, empty, or unknown `type` are treated as being
12+
in the Text state and are validated accordingly.
13+
14+
## Examples
15+
16+
This rule **forbids** the following:
17+
18+
```hbs
19+
<input type='number' pattern='\d+' />
20+
<input type='text' accept='image/*' />
21+
<input type='radio' maxlength='10' />
22+
<input type='checkbox' placeholder='label' />
23+
```
24+
25+
This rule **allows** the following:
26+
27+
```hbs
28+
<input type='text' pattern='\d+' />
29+
<input type='file' accept='image/*' />
30+
<input type='number' min='0' max='100' step='1' />
31+
<input type='image' alt='submit' src='/submit.png' />
32+
```
33+
34+
## References
35+
36+
- [HTML spec: input element content attributes](https://html.spec.whatwg.org/multipage/input.html#the-input-element)
37+
- Adapted from [`html-validate`'s `input-attributes`](https://html-validate.org/rules/input-attributes.html) (MIT).
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
'use strict';
2+
3+
// Logic adapted from html-validate (MIT), Copyright 2017 David Sveningsson.
4+
5+
const RESTRICTED = new Map([
6+
['accept', new Set(['file'])],
7+
['alt', new Set(['image'])],
8+
['capture', new Set(['file'])],
9+
['checked', new Set(['checkbox', 'radio'])],
10+
['dirname', new Set(['text', 'search'])],
11+
['height', new Set(['image'])],
12+
[
13+
'list',
14+
new Set([
15+
'text',
16+
'search',
17+
'url',
18+
'tel',
19+
'email',
20+
'date',
21+
'month',
22+
'week',
23+
'time',
24+
'datetime-local',
25+
'number',
26+
'range',
27+
'color',
28+
]),
29+
],
30+
['max', new Set(['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range'])],
31+
['maxlength', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
32+
['min', new Set(['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range'])],
33+
['minlength', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
34+
['multiple', new Set(['email', 'file'])],
35+
['pattern', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
36+
['placeholder', new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number'])],
37+
[
38+
'readonly',
39+
new Set([
40+
'text',
41+
'search',
42+
'url',
43+
'tel',
44+
'email',
45+
'password',
46+
'date',
47+
'month',
48+
'week',
49+
'time',
50+
'datetime-local',
51+
'number',
52+
]),
53+
],
54+
[
55+
'required',
56+
new Set([
57+
'text',
58+
'search',
59+
'url',
60+
'tel',
61+
'email',
62+
'password',
63+
'date',
64+
'month',
65+
'week',
66+
'time',
67+
'datetime-local',
68+
'number',
69+
'checkbox',
70+
'radio',
71+
'file',
72+
]),
73+
],
74+
['size', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
75+
['src', new Set(['image'])],
76+
['step', new Set(['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range'])],
77+
['width', new Set(['image'])],
78+
]);
79+
80+
// Input types defined by the HTML spec. Per the spec, an <input> element with a
81+
// missing, empty, or unknown `type` attribute falls back to the Text state, so
82+
// we normalize to 'text' before validating attribute compatibility.
83+
// https://html.spec.whatwg.org/multipage/input.html#attr-input-type
84+
const KNOWN_INPUT_TYPES = new Set([
85+
'hidden',
86+
'text',
87+
'search',
88+
'tel',
89+
'url',
90+
'email',
91+
'password',
92+
'date',
93+
'month',
94+
'week',
95+
'time',
96+
'datetime-local',
97+
'number',
98+
'range',
99+
'color',
100+
'checkbox',
101+
'radio',
102+
'file',
103+
'submit',
104+
'image',
105+
'reset',
106+
'button',
107+
]);
108+
109+
/** @type {import('eslint').Rule.RuleModule} */
110+
module.exports = {
111+
meta: {
112+
type: 'problem',
113+
docs: {
114+
description: 'disallow input attributes that are incompatible with the declared type',
115+
category: 'Possible Errors',
116+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-valid-input-attributes.md',
117+
templateMode: 'both',
118+
},
119+
fixable: null,
120+
schema: [],
121+
messages: {
122+
incompatible: 'Attribute `{{attr}}` is not allowed on `<input type="{{type}}">`',
123+
},
124+
},
125+
126+
create(context) {
127+
return {
128+
GlimmerElementNode(node) {
129+
if (node.tag !== 'input') {
130+
return;
131+
}
132+
// Per HTML §4.10.5, an <input> with missing, valueless, empty, or
133+
// unknown `type` falls back to the Text state. Only a DYNAMIC type
134+
// (mustache/concat) is opaque to static analysis — skip those.
135+
const typeAttr = node.attributes?.find((a) => a.name === 'type');
136+
let normalized;
137+
if (!typeAttr || !typeAttr.value) {
138+
// Missing attribute OR valueless `<input type />` — Text state.
139+
normalized = '';
140+
} else if (typeAttr.value.type === 'GlimmerTextNode') {
141+
normalized = typeAttr.value.chars.trim().toLowerCase();
142+
} else if (
143+
typeAttr.value.type === 'GlimmerMustacheStatement' &&
144+
typeAttr.value.path?.type === 'GlimmerStringLiteral'
145+
) {
146+
normalized = typeAttr.value.path.value.trim().toLowerCase();
147+
} else {
148+
// Dynamic value — can't statically determine; skip.
149+
return;
150+
}
151+
const type = KNOWN_INPUT_TYPES.has(normalized) ? normalized : 'text';
152+
153+
for (const attr of node.attributes || []) {
154+
const validTypes = RESTRICTED.get(attr.name);
155+
if (!validTypes) {
156+
continue;
157+
}
158+
if (validTypes.has(type)) {
159+
continue;
160+
}
161+
context.report({
162+
node: attr,
163+
messageId: 'incompatible',
164+
data: { attr: attr.name, type },
165+
});
166+
}
167+
},
168+
};
169+
},
170+
};
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
const rule = require('../../../lib/rules/template-valid-input-attributes');
2+
const RuleTester = require('eslint').RuleTester;
3+
4+
const err = (attr, type) => `Attribute \`${attr}\` is not allowed on \`<input type="${type}">\``;
5+
6+
const validHbs = [
7+
// Attribute matches type.
8+
'<input type="text" pattern="\\d+" />',
9+
'<input type="file" accept="image/*" />',
10+
'<input type="number" min="0" max="100" step="1" />',
11+
'<input type="image" alt="submit" src="/x.png" />',
12+
'<input type="email" placeholder="[email protected]" />',
13+
'<input type="checkbox" checked required />',
14+
'<input type="radio" checked required name="r" />',
15+
'<input type="text" size="20" maxlength="100" readonly />',
16+
'<input type="email" multiple />',
17+
'<input type="file" multiple capture="user" />',
18+
// Dynamic type — skip.
19+
'<input type={{this.t}} pattern="\\d+" />',
20+
// Mustache string literal — statically resolvable, attribute is compatible.
21+
'<input type={{"text"}} pattern="\\d+" />',
22+
// Missing / valueless / empty / unknown type fall back to the Text state
23+
// per HTML §4.10.5. Attributes valid for text still pass.
24+
'<input pattern="\\d+" />',
25+
'<input type />',
26+
'<input maxlength="100" size="20" readonly />',
27+
// Not an input — rule doesn't apply.
28+
'<textarea maxlength="10"></textarea>',
29+
// Empty/whitespace/unknown type values fall back to the Text state per HTML
30+
// spec, so `pattern` (allowed on text) is valid.
31+
'<input type="" pattern="x" />',
32+
'<input type=" TEXT " pattern="x" />',
33+
'<input type="UNKNOWN" pattern="x" />',
34+
];
35+
36+
const invalidHbs = [
37+
{
38+
code: '<input type="number" pattern="\\d+" />',
39+
errors: [{ message: err('pattern', 'number') }],
40+
},
41+
// Mustache string literal — statically resolvable, attribute is incompatible.
42+
{
43+
code: '<input type={{"number"}} pattern="\\d+" />',
44+
errors: [{ message: err('pattern', 'number') }],
45+
},
46+
{
47+
code: '<input type="text" accept="image/*" />',
48+
errors: [{ message: err('accept', 'text') }],
49+
},
50+
{
51+
code: '<input type="radio" maxlength="10" />',
52+
errors: [{ message: err('maxlength', 'radio') }],
53+
},
54+
{
55+
code: '<input type="checkbox" placeholder="x" />',
56+
errors: [{ message: err('placeholder', 'checkbox') }],
57+
},
58+
{
59+
code: '<input type="submit" pattern="x" size="5" />',
60+
errors: [{ message: err('pattern', 'submit') }, { message: err('size', 'submit') }],
61+
},
62+
{
63+
code: '<input type="TEXT" accept="image/*" />',
64+
errors: [{ message: err('accept', 'text') }],
65+
},
66+
// Text-state fallback — <input> with missing/valueless/empty/unknown type
67+
// is the Text state per HTML spec. Attributes incompatible with text are
68+
// flagged as `type="text"` in the error message.
69+
{
70+
code: '<input name="x" multiple />',
71+
errors: [{ message: err('multiple', 'text') }],
72+
},
73+
{
74+
code: '<input alt="foo" />',
75+
errors: [{ message: err('alt', 'text') }],
76+
},
77+
{
78+
code: '<input type accept="image/*" />',
79+
errors: [{ message: err('accept', 'text') }],
80+
},
81+
{
82+
code: '<input type="unknown" src="/x.png" />',
83+
errors: [{ message: err('src', 'text') }],
84+
},
85+
];
86+
87+
const gjsValid = validHbs.map((code) => `<template>${code}</template>`);
88+
const gjsInvalid = invalidHbs.map(({ code, errors }) => ({
89+
code: `<template>${code}</template>`,
90+
errors,
91+
}));
92+
93+
const gjsRuleTester = new RuleTester({
94+
parser: require.resolve('ember-eslint-parser'),
95+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
96+
});
97+
98+
gjsRuleTester.run('template-valid-input-attributes', rule, {
99+
valid: gjsValid,
100+
invalid: gjsInvalid,
101+
});
102+
103+
const hbsRuleTester = new RuleTester({
104+
parser: require.resolve('ember-eslint-parser/hbs'),
105+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
106+
});
107+
108+
hbsRuleTester.run('template-valid-input-attributes', rule, {
109+
valid: validHbs,
110+
invalid: invalidHbs,
111+
});

0 commit comments

Comments
 (0)