Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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).
170 changes: 170 additions & 0 deletions lib/rules/template-valid-input-attributes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
'use strict';

// Logic adapted from html-validate (MIT), Copyright 2017 David Sveningsson.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we copying someone else's work here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay, the note is slightly off, perhaps.

The logic part is not ported (the ESLint visitor and Glimmer AST traversal). What was ported was the restricted data table (attribute → allowed types). The table is an encoding of HTML spec §4.10.5.1 and others (e.g. HTML Media Capture for capture).

Did look for options to import instead of copy, but restricted isn't exported from html-validate's public API. The annotation in the source could be updated to say "Data table ported from" rather than "Logic adapted from" to reflect this more accurately?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved the attribution to be more local to the actual restricted data table

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another and more viable solution perhaps: https://github.com/johanrd/html-validate-ember


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) {
return {
GlimmerElementNode(node) {
if (node.tag !== 'input') {
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) is opaque to static analysis — skip those.
const typeAttr = node.attributes?.find((a) => a.name === 'type');
let normalized;
if (!typeAttr || !typeAttr.value) {
// Missing attribute OR valueless `<input type />` — Text state.
normalized = '';
} else if (typeAttr.value.type === 'GlimmerTextNode') {
normalized = typeAttr.value.chars.trim().toLowerCase();
} else if (
typeAttr.value.type === 'GlimmerMustacheStatement' &&
typeAttr.value.path?.type === 'GlimmerStringLiteral'
) {
normalized = typeAttr.value.path.value.trim().toLowerCase();
} else {
// Dynamic value — can't statically determine; skip.
return;
}
const type = KNOWN_INPUT_TYPES.has(normalized) ? normalized : 'text';

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