diff --git a/README.md b/README.md index 406ed89301..4c150c4cdd 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-autocomplete](docs/rules/template-valid-autocomplete.md) | require autocomplete attribute values to match the HTML autofill grammar | | | | ### Routes diff --git a/docs/rules/template-valid-autocomplete.md b/docs/rules/template-valid-autocomplete.md new file mode 100644 index 0000000000..7a170b2482 --- /dev/null +++ b/docs/rules/template-valid-autocomplete.md @@ -0,0 +1,71 @@ +# ember/template-valid-autocomplete + + + +This rule validates the `autocomplete` attribute against the HTML living +standard. Browsers ignore unknown tokens and mismatched combinations +silently, so authoring mistakes become invisible at runtime — the user +just doesn't get the suggestions they expect. + +The rule handles: + +- `
` must be `"on"` or `"off"`. +- `` cannot use the bare values `"on"` / `"off"`. +- `` + cannot use `autocomplete` at all. +- Token grammar: tokens must be a valid combination from the section / hint / + contact / field-name / webauthn set, in the right order. + +Dynamic values (`autocomplete={{this.acValue}}`) are skipped. Static empty +(`autocomplete=""`) and whitespace-only values are flagged: on `` as +an invalid non-on/off value, on controls as a missing field name. + +**Not checked**: whether a field name's control group matches the input type +(e.g. `"current-password"` on ``). The HTML spec describes +these field-name-to-control-group mappings descriptively — it does not +prohibit mismatched pairings with a MUST/MUST NOT. UA and password-manager +behavior varies, so such a pairing is a grammar-valid author choice whose +UA-visibility is a UX question, not a spec violation. `html-validate` flags +it; we don't. `eslint-plugin-jsx-a11y` and `eslint-plugin-lit-a11y` also +don't (they delegate to axe-core's `autocomplete-valid`, which omits the +check) — that corroborates but does not drive the decision. + +## Token order + +An autocomplete attribute value can contain these tokens, in this order: + +1. Optional section name (`section-*` prefix). +2. Optional `shipping` or `billing`. +3. Optional contact modifier: `home`, `work`, `mobile`, `fax`, `pager` + (only for `tel-*` / `email` / `impp` field names). +4. Exactly one field name. +5. Optional `webauthn`. + +## Examples + +This rule **forbids** the following: + +```hbs + + + + + +
+``` + +This rule **allows** the following: + +```hbs +
+ + + + +
+``` + +## References + +- [HTML spec: autofill](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill) +- Adapted from [`html-validate`'s `valid-autocomplete`](https://html-validate.org/rules/valid-autocomplete.html) (MIT). diff --git a/lib/rules/template-valid-autocomplete.js b/lib/rules/template-valid-autocomplete.js new file mode 100644 index 0000000000..5c731d588d --- /dev/null +++ b/lib/rules/template-valid-autocomplete.js @@ -0,0 +1,332 @@ +'use strict'; + +// Logic adapted from html-validate (MIT), Copyright 2017 David Sveningsson. +// +// Grammar-only validation. We check token identity, order, and combinations +// that the HTML spec MUSTs (single field name, token order, contact-tokens +// paired with contact-group field names, disallowed input types). We do NOT +// port html-validate's field-name-to-input-type control-group cross-check +// (e.g. flagging `current-password` on `type="text"`). Rationale: the HTML +// spec describes the autofill field name control groups descriptively — it +// does not prohibit mismatched pairings with a MUST/MUST NOT. UA and +// password-manager behavior varies, so the combination is a grammar-valid +// author choice whose UA-visibility is a UX question, not a spec violation. +// Two peers (jsx-a11y, lit-a11y) also omit the check by delegating to +// axe-core's `autocomplete-valid`, which corroborates the decision but is +// not its justification. + +const FIELD_NAMES_NO_CONTACT = new Set([ + 'name', + 'honorific-prefix', + 'given-name', + 'additional-name', + 'family-name', + 'honorific-suffix', + 'nickname', + 'username', + 'new-password', + 'current-password', + 'one-time-code', + 'organization-title', + 'organization', + 'street-address', + 'address-line1', + 'address-line2', + 'address-line3', + 'address-level4', + 'address-level3', + 'address-level2', + 'address-level1', + 'country', + 'country-name', + 'postal-code', + 'cc-name', + 'cc-given-name', + 'cc-additional-name', + 'cc-family-name', + 'cc-number', + 'cc-exp', + 'cc-exp-month', + 'cc-exp-year', + 'cc-csc', + 'cc-type', + 'transaction-currency', + 'transaction-amount', + 'language', + 'bday', + 'bday-day', + 'bday-month', + 'bday-year', + 'sex', + 'url', + 'photo', +]); + +const FIELD_NAMES_WITH_CONTACT = new Set([ + 'tel', + 'tel-country-code', + 'tel-national', + 'tel-area-code', + 'tel-local', + 'tel-local-prefix', + 'tel-local-suffix', + 'tel-extension', + 'email', + 'impp', +]); + +const DISALLOWED_INPUT_TYPES = new Set([ + 'checkbox', + 'radio', + 'file', + 'submit', + 'image', + 'reset', + 'button', +]); + +const EXPECTED_ORDER = ['section', 'hint', 'contact', 'field1', 'field2', 'webauthn']; +const CONTACT_TOKENS = new Set(['home', 'work', 'mobile', 'fax', 'pager']); + +function classifyToken(token) { + // Per the HTML autofill spec, `section-*` requires at least one character + // after the hyphen (the identifier). Bare `section-` is not a valid token. + if (/^section-.+/.test(token)) { + return 'section'; + } + if (token === 'shipping' || token === 'billing') { + return 'hint'; + } + if (FIELD_NAMES_NO_CONTACT.has(token)) { + return 'field1'; + } + if (FIELD_NAMES_WITH_CONTACT.has(token)) { + return 'field2'; + } + if (CONTACT_TOKENS.has(token)) { + return 'contact'; + } + if (token === 'webauthn') { + return 'webauthn'; + } + return null; +} + +function findAttr(node, name) { + return node.attributes?.find((a) => a.name === name); +} + +function getStaticAttrString(node, name) { + const attr = findAttr(node, name); + if (!attr || !attr.value || attr.value.type !== 'GlimmerTextNode') { + return null; + } + return attr.value.chars; +} + +function getInputType(node) { + const t = getStaticAttrString(node, 'type'); + if (t === null) { + return node.tag === 'input' ? 'text' : null; + } + return t.toLowerCase(); +} + +function tokenize(value) { + return value + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 0); +} + +// Enforce multiplicity/mutual-exclusion rules per HTML §4.10.19.9. The +// autofill grammar permits at most one token from each of: +// - `section-*` prefix +// - hint group (`shipping` XOR `billing` — both map to `kind: 'hint'`) +// - contact modifier (`home` | `work` | `mobile` | `fax` | `pager`) +// - `webauthn` +// Returns `{ messageId, data }` on the first violation found, or `null`. +function checkMultiplicity(order) { + const counts = { section: 0, hint: 0, contact: 0, webauthn: 0 }; + const contactTokens = []; + for (const { tok, kind } of order) { + if (kind === 'section' || kind === 'hint' || kind === 'webauthn') { + counts[kind]++; + } else if (kind === 'contact') { + counts.contact++; + contactTokens.push(tok); + } + } + if (counts.section > 1) { + return { messageId: 'duplicateSection', data: {} }; + } + if (counts.hint > 1) { + return { messageId: 'duplicateHint', data: {} }; + } + if (counts.contact > 1) { + // Report the second contact token (authors usually fix the one that came + // "extra"; the first is the intended one). + return { messageId: 'duplicateContact', data: { token: contactTokens[1] } }; + } + if (counts.webauthn > 1) { + return { messageId: 'duplicateWebauthn', data: {} }; + } + return null; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'require autocomplete attribute values to match the HTML autofill grammar', + category: 'Possible Errors', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-valid-autocomplete.md', + templateMode: 'both', + }, + fixable: null, + schema: [], + messages: { + formValue: '`
` can only be `"on"` or `"off"` (got `"{{value}}"`)', + hiddenOnOff: '`` cannot use the autocomplete value `"{{value}}"`', + disallowedType: '`autocomplete` cannot be used on ``', + onOffCombine: '`"{{value}}"` cannot be combined with other autocomplete tokens', + invalidToken: '`"{{token}}"` is not a valid autocomplete token or field name', + missingField: '`autocomplete` attribute is missing a field name', + multipleFields: 'autocomplete attribute must contain exactly one field name', + contactMismatch: '`"{{contact}}"` cannot be combined with field name `"{{field}}"`', + order: '`"{{second}}"` must appear before `"{{first}}"` in autocomplete', + duplicateSection: 'autocomplete can contain at most one `section-*` token', + duplicateHint: 'autocomplete can contain at most one hint token (`shipping` or `billing`)', + duplicateContact: + '`"{{token}}"` cannot be combined with another contact modifier — autocomplete allows at most one of `home`, `work`, `mobile`, `fax`, `pager`', + duplicateWebauthn: '`"webauthn"` may appear at most once in autocomplete', + }, + }, + + create(context) { + function report(attr, messageId, data) { + context.report({ node: attr, messageId, data }); + } + + function validateControl(node, attr, value) { + const type = getInputType(node) ?? 'text'; + + if (node.tag === 'input' && DISALLOWED_INPUT_TYPES.has(type)) { + report(attr, 'disallowedType', { type }); + return; + } + + const tokens = tokenize(value); + // Empty or whitespace-only autocomplete is an authoring mistake — the + // attribute communicates nothing and isn't a valid value per HTML + // §4.10.19. Flag via `missingField` since the remedy is to add a + // field name (or remove the attribute entirely). + if (tokens.length === 0) { + report(attr, 'missingField', {}); + return; + } + + const onOffIdx = tokens.findIndex((t) => t === 'on' || t === 'off'); + if (onOffIdx !== -1) { + const token = tokens[onOffIdx]; + if (node.tag === 'input' && type === 'hidden') { + report(attr, 'hiddenOnOff', { value: token }); + return; + } + if (tokens.length > 1) { + report(attr, 'onOffCombine', { value: token }); + } + return; + } + + const order = []; + for (const tok of tokens) { + const kind = classifyToken(tok); + if (!kind) { + report(attr, 'invalidToken', { token: tok }); + return; + } + order.push({ tok, kind }); + } + + // Multiplicity constraints — each non-field token class may appear at + // most once, and `shipping`/`billing` are mutually exclusive. + const multiplicityError = checkMultiplicity(order); + if (multiplicityError) { + report(attr, multiplicityError.messageId, multiplicityError.data); + return; + } + + // Field presence. + const fieldIndices = order + .map((o, i) => (o.kind === 'field1' || o.kind === 'field2' ? i : -1)) + .filter((i) => i !== -1); + + if (fieldIndices.length === 0) { + report(attr, 'missingField', {}); + return; + } + if (fieldIndices.length > 1) { + report(attr, 'multipleFields', {}); + return; + } + + // Contact can only pair with field2. + const fieldIdx = fieldIndices[0]; + const field = order[fieldIdx]; + const contactIdx = order.findIndex((o) => o.kind === 'contact'); + if (contactIdx !== -1 && field.kind === 'field1') { + report(attr, 'contactMismatch', { contact: order[contactIdx].tok, field: field.tok }); + return; + } + + // Order validation. + const expectedIdx = order.map((o) => EXPECTED_ORDER.indexOf(o.kind)); + for (let i = 0; i < expectedIdx.length - 1; i++) { + if (expectedIdx[i] > expectedIdx[i + 1]) { + report(attr, 'order', { first: order[i].tok, second: order[i + 1].tok }); + return; + } + } + + // Deliberately NOT validating field-name-vs-input-type compatibility + // (e.g. `current-password` on ``). axe-core doesn't + // check this either, and the HTML autofill mapping has enough + // real-world variance that flagging here would over-trigger. + } + + function validateForm(node, attr, value) { + const trimmed = value.trim().toLowerCase(); + if (trimmed === 'on' || trimmed === 'off') { + return; + } + report(attr, 'formValue', { value: trimmed }); + } + + return { + GlimmerElementNode(node) { + const attr = findAttr(node, 'autocomplete'); + if (!attr) { + return; + } + if (attr.value && attr.value.type !== 'GlimmerTextNode') { + return; + } + const value = attr.value ? attr.value.chars : ''; + // Empty/whitespace values drop through to validateForm/validateControl + // — `` flags as a non-on/off value, and + // `` flags as a missing field name. Both are + // authoring mistakes the rule should surface. + + if (node.tag === 'form') { + validateForm(node, attr, value); + return; + } + if (node.tag === 'input' || node.tag === 'textarea' || node.tag === 'select') { + validateControl(node, attr, value); + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-valid-autocomplete.js b/tests/lib/rules/template-valid-autocomplete.js new file mode 100644 index 0000000000..c894e1d588 --- /dev/null +++ b/tests/lib/rules/template-valid-autocomplete.js @@ -0,0 +1,210 @@ +const rule = require('../../../lib/rules/template-valid-autocomplete'); +const RuleTester = require('eslint').RuleTester; + +const validHbs = [ + // form on/off. + '
', + '
', + '
', + // control on/off. + '', + '', + // Basic field name. + '', + '', + '', + '', + // Full pattern: section + hint + field + webauthn. + '', + '', + '', + // No control-group check: field name paired with an unrelated input type + // is NOT flagged. We defer to axe-core's behavior (grammar only). + '', + '', + '', + // Contact with field2. + '', + '', + // textarea / select. + '', + '', + // Dynamic — skip. + '', + // Non-form-control — skip. + '
', + // hidden with valid field. + '', +]; + +const invalidHbs = [ + // form: invalid value. + { + code: '
', + errors: [{ message: '`
` can only be `"on"` or `"off"` (got `"yes"`)' }], + }, + // Empty autocomplete on form — invalid (not on/off). + { + code: '
', + errors: [{ message: '`
` can only be `"on"` or `"off"` (got `""`)' }], + }, + // Empty autocomplete on a control — no field name, nothing to match. + { + code: '', + errors: [{ message: '`autocomplete` attribute is missing a field name' }], + }, + // Whitespace-only counts the same. + { + code: '', + errors: [{ message: '`autocomplete` attribute is missing a field name' }], + }, + // Valueless attribute — treated as empty string, invalid. + { + code: '', + errors: [{ message: '`autocomplete` attribute is missing a field name' }], + }, + { + code: '
', + errors: [{ message: '`
` can only be `"on"` or `"off"` (got `""`)' }], + }, + // hidden: on/off forbidden. + { + code: '', + errors: [{ message: '`` cannot use the autocomplete value `"off"`' }], + }, + // Disallowed input types. + { + code: '', + errors: [{ message: '`autocomplete` cannot be used on ``' }], + }, + { + code: '', + errors: [{ message: '`autocomplete` cannot be used on ``' }], + }, + // Invalid token. + { + code: '', + errors: [ + { + message: '`"first-name"` is not a valid autocomplete token or field name', + }, + ], + }, + // on/off combined with other tokens. + { + code: '', + errors: [{ message: '`"off"` cannot be combined with other autocomplete tokens' }], + }, + // Missing field. + { + code: '', + errors: [{ message: '`autocomplete` attribute is missing a field name' }], + }, + { + code: '', + errors: [{ message: '`autocomplete` attribute is missing a field name' }], + }, + // Multiple fields. + { + code: '', + errors: [{ message: 'autocomplete attribute must contain exactly one field name' }], + }, + // Contact with wrong field group. + { + code: '', + errors: [ + { + message: '`"home"` cannot be combined with field name `"family-name"`', + }, + ], + }, + // Multiple contact modifiers — HTML §4.10.19.9 allows at most one. + { + code: '', + errors: [ + { + message: + '`"work"` cannot be combined with another contact modifier — autocomplete allows at most one of `home`, `work`, `mobile`, `fax`, `pager`', + }, + ], + }, + { + code: '', + errors: [ + { + message: + '`"pager"` cannot be combined with another contact modifier — autocomplete allows at most one of `home`, `work`, `mobile`, `fax`, `pager`', + }, + ], + }, + // Same contact token repeated — still flagged (count > 1 regardless of + // whether it's the same or different token). + { + code: '', + errors: [ + { + message: + '`"home"` cannot be combined with another contact modifier — autocomplete allows at most one of `home`, `work`, `mobile`, `fax`, `pager`', + }, + ], + }, + // Order violation (webauthn before field). + { + code: '', + errors: [ + { + message: '`"current-password"` must appear before `"webauthn"` in autocomplete', + }, + ], + }, + // `section-` with empty identifier is not a valid token. + { + code: '', + errors: [{ message: '`"section-"` is not a valid autocomplete token or field name' }], + }, + // Multiplicity: at most one section-*. + { + code: '', + errors: [{ message: 'autocomplete can contain at most one `section-*` token' }], + }, + // Multiplicity: shipping and billing are mutually exclusive. + { + code: '', + errors: [ + { + message: 'autocomplete can contain at most one hint token (`shipping` or `billing`)', + }, + ], + }, + // Multiplicity: webauthn at most once. + { + code: '', + errors: [{ message: '`"webauthn"` may appear at most once in autocomplete' }], + }, +]; + +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-autocomplete', 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-autocomplete', rule, { + valid: validHbs, + invalid: invalidHbs, +});