Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,14 +491,15 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le

### Possible Errors

| Name                                                 | Description | 💼 | 🔧 | 💡 |
| :------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------- | :- | :- | :- |
| [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 | 📋 | | |
| [template-no-jsx-attributes](docs/rules/template-no-jsx-attributes.md) | disallow JSX-style camelCase attributes | | 🔧 | |
| [template-no-scope-outside-table-headings](docs/rules/template-no-scope-outside-table-headings.md) | disallow scope attribute outside th elements | 📋 | | |
| [template-no-shadowed-elements](docs/rules/template-no-shadowed-elements.md) | disallow ambiguity with block param names shadowing HTML elements | 📋 | | |
| [template-no-unbalanced-curlies](docs/rules/template-no-unbalanced-curlies.md) | disallow unbalanced mustache curlies | 📋 | | |
| [template-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | 📋 | 🔧 | |
| Name                                                 | Description | 💼 | 🔧 | 💡 |
| :------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------- | :- | :- | :- |
| [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 | 📋 | | |
| [template-no-jsx-attributes](docs/rules/template-no-jsx-attributes.md) | disallow JSX-style camelCase attributes | | 🔧 | |
| [template-no-scope-outside-table-headings](docs/rules/template-no-scope-outside-table-headings.md) | disallow scope attribute outside th elements | 📋 | | |
| [template-no-shadowed-elements](docs/rules/template-no-shadowed-elements.md) | disallow ambiguity with block param names shadowing HTML elements | 📋 | | |
| [template-no-unbalanced-curlies](docs/rules/template-no-unbalanced-curlies.md) | disallow unbalanced mustache curlies | 📋 | | |
| [template-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | 📋 | 🔧 | |
| [template-valid-input-attributes](docs/rules/template-valid-input-attributes.md) | disallow input attributes that are incompatible with the declared type | | | |

### Routes

Expand Down
37 changes: 37 additions & 0 deletions docs/rules/template-valid-input-attributes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ember/template-valid-input-attributes

<!-- end auto-generated rule header -->

This rule flags `<input>` attributes that are incompatible with the
declared `type`. For example, `pattern` only applies to the text-like
input types; on `type="number"` it is silently ignored by the browser.

The attribute/type compatibility table matches the HTML living standard.
Dynamic type values (e.g. `type={{this.inputType}}`) are skipped. Inputs
with a missing, valueless, empty, or unknown `type` are treated as being
in the Text state and are validated accordingly.

## Examples

This rule **forbids** the following:

```hbs
<input type='number' pattern='\d+' />
<input type='text' accept='image/*' />
<input type='radio' maxlength='10' />
<input type='checkbox' placeholder='label' />
```

This rule **allows** the following:

```hbs
<input type='text' pattern='\d+' />
<input type='file' accept='image/*' />
<input type='number' min='0' max='100' step='1' />
<input type='image' alt='submit' src='/submit.png' />
```

## References

- [HTML spec: input element content attributes](https://html.spec.whatwg.org/multipage/input.html#the-input-element)
- Adapted from [`html-validate`'s `input-attributes`](https://html-validate.org/rules/input-attributes.html) (MIT).
180 changes: 180 additions & 0 deletions lib/rules/template-valid-input-attributes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
'use strict';

const { isNativeElement } = require('../utils/is-native-element');

// Data table ported from html-validate input-attributes (MIT), Copyright 2017 David Sveningsson.
// https://html-validate.org/rules/input-attributes.html
const RESTRICTED = new Map([
['accept', new Set(['file'])],
['alt', new Set(['image'])],
['capture', new Set(['file'])],
['checked', new Set(['checkbox', 'radio'])],
['dirname', new Set(['text', 'search'])],
['height', new Set(['image'])],
[
'list',
new Set([
'text',
'search',
'url',
'tel',
'email',
'date',
'month',
'week',
'time',
'datetime-local',
'number',
'range',
'color',
]),
],
['max', new Set(['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range'])],
['maxlength', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
['min', new Set(['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range'])],
['minlength', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
['multiple', new Set(['email', 'file'])],
['pattern', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
['placeholder', new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number'])],
[
'readonly',
new Set([
'text',
'search',
'url',
'tel',
'email',
'password',
'date',
'month',
'week',
'time',
'datetime-local',
'number',
]),
],
[
'required',
new Set([
'text',
'search',
'url',
'tel',
'email',
'password',
'date',
'month',
'week',
'time',
'datetime-local',
'number',
'checkbox',
'radio',
'file',
]),
],
['size', new Set(['text', 'search', 'url', 'tel', 'email', 'password'])],
['src', new Set(['image'])],
['step', new Set(['date', 'month', 'week', 'time', 'datetime-local', 'number', 'range'])],
['width', new Set(['image'])],
]);

// Input types defined by the HTML spec. Per the spec, an <input> element with a
// missing, empty, or unknown `type` attribute falls back to the Text state, so
// we normalize to 'text' before validating attribute compatibility.
// https://html.spec.whatwg.org/multipage/input.html#attr-input-type
const KNOWN_INPUT_TYPES = new Set([
'hidden',
'text',
'search',
'tel',
'url',
'email',
'password',
'date',
'month',
'week',
'time',
'datetime-local',
'number',
'range',
'color',
'checkbox',
'radio',
'file',
'submit',
'image',
'reset',
'button',
]);

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow input attributes that are incompatible with the declared type',
category: 'Possible Errors',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-valid-input-attributes.md',
templateMode: 'both',
},
fixable: null,
schema: [],
messages: {
incompatible: 'Attribute `{{attr}}` is not allowed on `<input type="{{type}}">`',
},
},

create(context) {
const sourceCode = context.sourceCode || context.getSourceCode();
return {
GlimmerElementNode(node) {
if (node.tag !== 'input') {
return;
}
if (!isNativeElement(node, sourceCode)) {
return;
}
// Per HTML §4.10.5, an <input> with missing, valueless, empty, or
// unknown `type` falls back to the Text state. Only a DYNAMIC type
// (mustache/concat with a non-literal path) is opaque to static
// analysis — skip those. Each branch resolves directly to a known
// type or to the spec-default 'text', avoiding an intermediate
// sentinel.
const typeAttr = node.attributes?.find((a) => a.name === 'type');
let type;
if (!typeAttr || !typeAttr.value) {
// Missing attribute OR valueless `<input type />` — Text state.
type = 'text';
} else if (typeAttr.value.type === 'GlimmerTextNode') {
const raw = typeAttr.value.chars.trim().toLowerCase();
type = KNOWN_INPUT_TYPES.has(raw) ? raw : 'text';
} else if (
typeAttr.value.type === 'GlimmerMustacheStatement' &&
typeAttr.value.path?.type === 'GlimmerStringLiteral'
) {
const raw = typeAttr.value.path.value.trim().toLowerCase();
type = KNOWN_INPUT_TYPES.has(raw) ? raw : 'text';
} else {
// Dynamic value — can't statically determine; skip.
return;
}

for (const attr of node.attributes || []) {
const validTypes = RESTRICTED.get(attr.name.toLowerCase());
if (!validTypes) {
continue;
}
if (validTypes.has(type)) {
continue;
}
context.report({
node: attr,
messageId: 'incompatible',
data: { attr: attr.name, type },
});
}
},
};
},
};
119 changes: 119 additions & 0 deletions tests/lib/rules/template-valid-input-attributes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
const rule = require('../../../lib/rules/template-valid-input-attributes');
const RuleTester = require('eslint').RuleTester;

const err = (attr, type) => `Attribute \`${attr}\` is not allowed on \`<input type="${type}">\``;

const validHbs = [
// Attribute matches type.
'<input type="text" pattern="\\d+" />',
'<input type="file" accept="image/*" />',
'<input type="number" min="0" max="100" step="1" />',
'<input type="image" alt="submit" src="/x.png" />',
'<input type="email" placeholder="[email protected]" />',
'<input type="checkbox" checked required />',
'<input type="radio" checked required name="r" />',
'<input type="text" size="20" maxlength="100" readonly />',
'<input type="email" multiple />',
'<input type="file" multiple capture="user" />',
// Dynamic type — skip.
'<input type={{this.t}} pattern="\\d+" />',
// Mustache string literal — statically resolvable, attribute is compatible.
'<input type={{"text"}} pattern="\\d+" />',
// Missing / valueless / empty / unknown type fall back to the Text state
// per HTML §4.10.5. Attributes valid for text still pass.
'<input pattern="\\d+" />',
'<input type />',
'<input maxlength="100" size="20" readonly />',
// Attribute name case — HTML attributes are case-insensitive; rule normalizes.
'<input type="text" MAXLENGTH="5" />',
'<input type="text" Pattern="\\d+" />',
// Not an input — rule doesn't apply.
'<textarea maxlength="10"></textarea>',
// Empty/whitespace/unknown type values fall back to the Text state per HTML
// spec, so `pattern` (allowed on text) is valid.
'<input type="" pattern="x" />',
'<input type=" TEXT " pattern="x" />',
'<input type="UNKNOWN" pattern="x" />',
];

const invalidHbs = [
{
code: '<input type="number" pattern="\\d+" />',
errors: [{ message: err('pattern', 'number') }],
},
// Mustache string literal — statically resolvable, attribute is incompatible.
{
code: '<input type={{"number"}} pattern="\\d+" />',
errors: [{ message: err('pattern', 'number') }],
},
{
code: '<input type="text" accept="image/*" />',
errors: [{ message: err('accept', 'text') }],
},
{
code: '<input type="radio" maxlength="10" />',
errors: [{ message: err('maxlength', 'radio') }],
},
{
code: '<input type="checkbox" placeholder="x" />',
errors: [{ message: err('placeholder', 'checkbox') }],
},
{
code: '<input type="submit" pattern="x" size="5" />',
errors: [{ message: err('pattern', 'submit') }, { message: err('size', 'submit') }],
},
{
code: '<input type="TEXT" accept="image/*" />',
errors: [{ message: err('accept', 'text') }],
},
// Uppercase attribute name — normalized before restriction lookup.
{
code: '<input type="number" PATTERN="x" />',
errors: [{ message: err('PATTERN', 'number') }],
},
// Text-state fallback — <input> with missing/valueless/empty/unknown type
// is the Text state per HTML spec. Attributes incompatible with text are
// flagged as `type="text"` in the error message.
{
code: '<input name="x" multiple />',
errors: [{ message: err('multiple', 'text') }],
},
{
code: '<input alt="foo" />',
errors: [{ message: err('alt', 'text') }],
},
{
code: '<input type accept="image/*" />',
errors: [{ message: err('accept', 'text') }],
},
{
code: '<input type="unknown" src="/x.png" />',
errors: [{ message: err('src', 'text') }],
},
];

const gjsValid = validHbs.map((code) => `<template>${code}</template>`);
const gjsInvalid = invalidHbs.map(({ code, errors }) => ({
code: `<template>${code}</template>`,
errors,
}));

const gjsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

gjsRuleTester.run('template-valid-input-attributes', rule, {
valid: gjsValid,
invalid: gjsInvalid,
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

hbsRuleTester.run('template-valid-input-attributes', rule, {
valid: validHbs,
invalid: invalidHbs,
});
Loading