diff --git a/README.md b/README.md index 406ed89301..55d2f7e1db 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/rules/template-valid-input-attributes.md b/docs/rules/template-valid-input-attributes.md new file mode 100644 index 0000000000..395fb3e415 --- /dev/null +++ b/docs/rules/template-valid-input-attributes.md @@ -0,0 +1,37 @@ +# ember/template-valid-input-attributes + + + +This rule flags `` 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 + + + + +``` + +This rule **allows** the following: + +```hbs + + + + +``` + +## 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). diff --git a/lib/rules/template-valid-input-attributes.js b/lib/rules/template-valid-input-attributes.js new file mode 100644 index 0000000000..a9e60df1c8 --- /dev/null +++ b/lib/rules/template-valid-input-attributes.js @@ -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 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 ``', + }, + }, + + 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 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 `` — 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 }, + }); + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-valid-input-attributes.js b/tests/lib/rules/template-valid-input-attributes.js new file mode 100644 index 0000000000..a484692d23 --- /dev/null +++ b/tests/lib/rules/template-valid-input-attributes.js @@ -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 \`\``; + +const validHbs = [ + // Attribute matches type. + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + // Dynamic type — skip. + '', + // Mustache string literal — statically resolvable, attribute is compatible. + '', + // Missing / valueless / empty / unknown type fall back to the Text state + // per HTML §4.10.5. Attributes valid for text still pass. + '', + '', + '', + // Attribute name case — HTML attributes are case-insensitive; rule normalizes. + '', + '', + // Not an input — rule doesn't apply. + '', + // Empty/whitespace/unknown type values fall back to the Text state per HTML + // spec, so `pattern` (allowed on text) is valid. + '', + '', + '', +]; + +const invalidHbs = [ + { + code: '', + errors: [{ message: err('pattern', 'number') }], + }, + // Mustache string literal — statically resolvable, attribute is incompatible. + { + code: '', + errors: [{ message: err('pattern', 'number') }], + }, + { + code: '', + errors: [{ message: err('accept', 'text') }], + }, + { + code: '', + errors: [{ message: err('maxlength', 'radio') }], + }, + { + code: '', + errors: [{ message: err('placeholder', 'checkbox') }], + }, + { + code: '', + errors: [{ message: err('pattern', 'submit') }, { message: err('size', 'submit') }], + }, + { + code: '', + errors: [{ message: err('accept', 'text') }], + }, + // Uppercase attribute name — normalized before restriction lookup. + { + code: '', + errors: [{ message: err('PATTERN', 'number') }], + }, + // Text-state fallback — 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: '', + errors: [{ message: err('multiple', 'text') }], + }, + { + code: '', + errors: [{ message: err('alt', 'text') }], + }, + { + code: '', + errors: [{ message: err('accept', 'text') }], + }, + { + code: '', + errors: [{ message: err('src', 'text') }], + }, +]; + +const gjsValid = validHbs.map((code) => ``); +const gjsInvalid = invalidHbs.map(({ code, errors }) => ({ + code: ``, + 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, +});