From a48260c9197a60a5ba833623783301a7880a146d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 27 Apr 2026 21:32:32 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20add=20template-valid-input-attribut?= =?UTF-8?q?es=20=E2=80=94=20flag=20input=20attributes=20incompatible=20wit?= =?UTF-8?q?h=20declared=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +- docs/rules/template-valid-input-attributes.md | 37 ++++ lib/rules/template-valid-input-attributes.js | 170 ++++++++++++++++++ .../rules/template-valid-input-attributes.js | 111 ++++++++++++ 4 files changed, 327 insertions(+), 8 deletions(-) create mode 100644 docs/rules/template-valid-input-attributes.md create mode 100644 lib/rules/template-valid-input-attributes.js create mode 100644 tests/lib/rules/template-valid-input-attributes.js 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..93a2cc73ba --- /dev/null +++ b/lib/rules/template-valid-input-attributes.js @@ -0,0 +1,170 @@ +'use strict'; + +// Logic adapted from html-validate (MIT), Copyright 2017 David Sveningsson. + +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) { + return { + GlimmerElementNode(node) { + if (node.tag !== 'input') { + 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) 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 `` — 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 }, + }); + } + }, + }; + }, +}; 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..6880616a2d --- /dev/null +++ b/tests/lib/rules/template-valid-input-attributes.js @@ -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 \`\``; + +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. + '', + '', + '', + // 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') }], + }, + // 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, +}); From fa51c92e7c7b4008d033f97ad19cae22edc81785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 27 Apr 2026 22:42:36 +0200 Subject: [PATCH 2/5] fix: normalize attr names to lowercase; add isNativeElement scope check; tests for UPPERCASE attrs --- lib/rules/template-valid-input-attributes.js | 8 +++++++- tests/lib/rules/template-valid-input-attributes.js | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/rules/template-valid-input-attributes.js b/lib/rules/template-valid-input-attributes.js index 93a2cc73ba..16bf0905e9 100644 --- a/lib/rules/template-valid-input-attributes.js +++ b/lib/rules/template-valid-input-attributes.js @@ -2,6 +2,8 @@ // Logic adapted from html-validate (MIT), Copyright 2017 David Sveningsson. +const { isNativeElement } = require('../utils/is-native-element'); + const RESTRICTED = new Map([ ['accept', new Set(['file'])], ['alt', new Set(['image'])], @@ -124,11 +126,15 @@ module.exports = { }, 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) is opaque to static analysis — skip those. @@ -151,7 +157,7 @@ module.exports = { const type = KNOWN_INPUT_TYPES.has(normalized) ? normalized : 'text'; for (const attr of node.attributes || []) { - const validTypes = RESTRICTED.get(attr.name); + const validTypes = RESTRICTED.get(attr.name.toLowerCase()); if (!validTypes) { continue; } diff --git a/tests/lib/rules/template-valid-input-attributes.js b/tests/lib/rules/template-valid-input-attributes.js index 6880616a2d..a484692d23 100644 --- a/tests/lib/rules/template-valid-input-attributes.js +++ b/tests/lib/rules/template-valid-input-attributes.js @@ -24,6 +24,9 @@ const validHbs = [ '', '', '', + // 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 @@ -63,6 +66,11 @@ const invalidHbs = [ 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. From bb75ce96d3433e00f3cab3f6be42e65abd00b0d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 28 Apr 2026 08:16:03 +0200 Subject: [PATCH 3/5] fix: move data table attribution local to RESTRICTED --- lib/rules/template-valid-input-attributes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/rules/template-valid-input-attributes.js b/lib/rules/template-valid-input-attributes.js index 16bf0905e9..d21625e347 100644 --- a/lib/rules/template-valid-input-attributes.js +++ b/lib/rules/template-valid-input-attributes.js @@ -1,9 +1,9 @@ 'use strict'; -// Logic adapted from html-validate (MIT), Copyright 2017 David Sveningsson. - 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'])], From 675e3781645cf909a34fc222826289e9dc0e34e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 28 Apr 2026 10:09:23 +0200 Subject: [PATCH 4/5] ci: re-trigger after windows runner flake From 53995edbf5f2e089ed80a7b9559e9cf42c5f000a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 28 Apr 2026 12:02:50 +0200 Subject: [PATCH 5/5] refactor(template-valid-input-attributes): drop normalized='' sentinel; resolve type per-branch --- lib/rules/template-valid-input-attributes.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/rules/template-valid-input-attributes.js b/lib/rules/template-valid-input-attributes.js index d21625e347..a9e60df1c8 100644 --- a/lib/rules/template-valid-input-attributes.js +++ b/lib/rules/template-valid-input-attributes.js @@ -137,24 +137,28 @@ module.exports = { } // 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) is opaque to static analysis — skip those. + // (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 normalized; + let type; if (!typeAttr || !typeAttr.value) { // Missing attribute OR valueless `` — Text state. - normalized = ''; + type = 'text'; } else if (typeAttr.value.type === 'GlimmerTextNode') { - normalized = typeAttr.value.chars.trim().toLowerCase(); + 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' ) { - normalized = typeAttr.value.path.value.trim().toLowerCase(); + 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; } - const type = KNOWN_INPUT_TYPES.has(normalized) ? normalized : 'text'; for (const attr of node.attributes || []) { const validTypes = RESTRICTED.get(attr.name.toLowerCase());