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: + +- `
+``` + +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: '`', + '', + '', + // 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: '`', + errors: [{ message: '`', + errors: [{ message: '`