Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [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-no-unknown-arguments-for-builtin-components](docs/rules/template-no-unknown-arguments-for-builtin-components.md) | disallow unknown arguments for built-in components | | 🔧 | |

### Routes

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# ember/template-no-unknown-arguments-for-builtin-components

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->

The builtin components `LinkTo`, `Input`, `Textarea` has list of allowed arguments, and some argument names may be mistyped, this rule trying to highlight possible typos, checking for unknown arguments, also, some components has conflicted and required arguments, rule addressing this behavior.
Expand Down
94 changes: 92 additions & 2 deletions lib/rules/template-no-unknown-arguments-for-builtin-components.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,17 @@ function checkRequired(nodeMeta, node, seen, context) {
}
}

// Rename `@argName=value` to `newName=value` — strips the `@` and swaps
// the identifier. Used when a deprecated argument has a direct HTML
// attribute replacement (e.g. `@elementId` -> `id`).
function buildRenameFix(attr, newName) {
return (fixer) => {
const nameStart = attr.range[0];
const nameEnd = nameStart + attr.name.length;
return fixer.replaceTextRange([nameStart, nameEnd], newName);
};
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
Expand All @@ -392,7 +403,7 @@ module.exports = {
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unknown-arguments-for-builtin-components.md',
templateMode: 'both',
},
fixable: null,
fixable: 'code',
schema: [],
messages: {
unknownArgument: '{{message}}',
Expand All @@ -410,6 +421,83 @@ module.exports = {
create(context) {
const sourceCode = context.sourceCode;

// Remove the attribute entirely (including any preceding whitespace that
// separates it from the previous token).
function buildRemovalFix(attr) {
return (fixer) => {
const text = sourceCode.getText();
const attrStart = attr.range[0];
const attrEnd = attr.range[1];

let removeStart = attrStart;
while (removeStart > 0 && /\s/.test(text[removeStart - 1])) {
removeStart--;
}

return fixer.removeRange([removeStart, attrEnd]);
};
}

// Migrate `@eventName={{expr}}` to `{{on "htmlEvent" expr}}` modifier
// (or `{{on "htmlEvent" (helper ...params)}}` when the value is a call).
// Only safe when the attribute value is a mustache expression.
function buildEventMigrationFix(attr, htmlEventName) {
return (fixer) => {
const valueText = sourceCode.getText(attr.value);
// Strip outer `{{` and `}}` to get the expression text.
let inner = valueText;
if (inner.startsWith('{{') && inner.endsWith('}}')) {
inner = inner.slice(2, -2).trim();
}
// If the value has parameters (e.g. `action this.click`), wrap as
// a sub-expression so the modifier receives a single callable.
const hasParams =
attr.value &&
attr.value.type === 'GlimmerMustacheStatement' &&
Array.isArray(attr.value.params) &&
attr.value.params.length > 0;
const expr = hasParams ? `(${inner})` : inner;
const modifier = `{{on "${htmlEventName}" ${expr}}}`;
return fixer.replaceTextRange([attr.range[0], attr.range[1]], modifier);
};
}

function buildFix(node, attr) {
const tagMeta = KnownArguments[node.tag];
if (!tagMeta) {
return null;
}
const deprecatedArgs = tagMeta.deprecatedArguments || {};
const deprecatedEvents = tagMeta.deprecatedEvents || {};

if (attr.name in deprecatedArgs) {
const replacement = deprecatedArgs[attr.name];
if (replacement) {
// Rename to the equivalent HTML attribute.
return buildRenameFix(attr, replacement);
}
// No replacement attribute — just remove the deprecated arg.
return buildRemovalFix(attr);
}

if (attr.name in deprecatedEvents) {
const replacement = deprecatedEvents[attr.name];
if (!replacement) {
// No replacement event (e.g. `@bubbles`) — just remove.
return buildRemovalFix(attr);
}
// Only migrate to `{{on}}` when the value is a mustache expression.
// Otherwise (string literal, valueless), leave unfixed.
if (attr.value && attr.value.type === 'GlimmerMustacheStatement') {
return buildEventMigrationFix(attr, replacement);
}
return null;
}

// Truly unknown argument (typo) — no autofix.
return null;
}

return {
GlimmerElementNode(node) {
if (!node.tag || !node.attributes) {
Expand Down Expand Up @@ -451,12 +539,14 @@ module.exports = {
}
}

// Report unknown/deprecated arguments
// Report unknown/deprecated arguments.
for (const attr of warns) {
const fix = buildFix(node, attr);
context.report({
node: attr,
messageId: 'unknownArgument',
data: { message: getErrorMessage(node.tag, attr.name) },
fix: fix || null,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,55 +135,72 @@ ruleTester.run('template-no-unknown-arguments-for-builtin-components', rule, {
errors: [{ messageId: 'conflictArgument' }, { messageId: 'conflictArgument' }],
},
{
// Deprecated argument without a replacement attribute — autofixed by removal.
code: '<template><LinkTo @route="info" @model={{this.model}} @tagName="button" /></template>',
output: null,
output: '<template><LinkTo @route="info" @model={{this.model}} /></template>',
errors: [{ messageId: 'unknownArgument' }],
},
{
// Deprecated argument with replacement — autofixed by renaming to the HTML attribute.
code: '<template><LinkTo @route="info" @model={{this.model}} @elementId="superstar" /></template>',
output: null,
output: '<template><LinkTo @route="info" @model={{this.model}} id="superstar" /></template>',
errors: [{ messageId: 'unknownArgument' }],
},
{
// Deprecated event with a helper invocation value — migrated to an `{{on}}` modifier with the helper as a sub-expression.
code: '<template><LinkTo @route="info" @model={{this.model}} @doubleClick={{action this.click}} /></template>',
output: null,
output:
'<template><LinkTo @route="info" @model={{this.model}} {{on "dblclick" (action this.click)}} /></template>',
errors: [{ messageId: 'unknownArgument' }],
},
{
// Deprecated argument without a replacement attribute — autofixed by removal.
code: '<template><Input @value="1" @bubbles={{false}} /></template>',
output: null,
output: '<template><Input @value="1" /></template>',
errors: [{ messageId: 'unknownArgument' }],
},
{
// Two deprecated arguments on Input — both renamed to HTML attributes.
code: '<template><Input @value="1" @elementId="42" @disabled="disabled" /></template>',
output: null,
output: '<template><Input @value="1" id="42" disabled="disabled" /></template>',
errors: [{ messageId: 'unknownArgument' }, { messageId: 'unknownArgument' }],
},
{
// Deprecated event with a simple path value — migrated to an `{{on}}` modifier.
code: '<template><Input @value="1" @key-up={{ths.onKeyUp}} /></template>',
output: null,
output: '<template><Input @value="1" {{on "keyup" ths.onKeyUp}} /></template>',
errors: [{ messageId: 'unknownArgument' }],
},
{
// Deprecated argument without a replacement attribute — autofixed by removal.
code: '<template><Textarea @value="1" @bubbles={{false}} /></template>',
output: null,
output: '<template><Textarea @value="1" /></template>',
errors: [{ messageId: 'unknownArgument' }],
},
{
// Deprecated argument with replacement — autofixed by renaming to the HTML attribute.
code: '<template><Textarea @value="1" @elementId="42" /></template>',
output: null,
output: '<template><Textarea @value="1" id="42" /></template>',
errors: [{ messageId: 'unknownArgument' }],
},
{
// Deprecated event with a simple path value — migrated to an `{{on}}` modifier.
code: '<template><Textarea @value="1" @key-up={{ths.onKeyUp}} /></template>',
output: null,
output: '<template><Textarea @value="1" {{on "keyup" ths.onKeyUp}} /></template>',
errors: [{ messageId: 'unknownArgument' }],
},
{
// Truly unknown/typo argument — not autofixed.
code: '<template> <LinkTo class="auk-search-results-list__item" @route={{@route}} @models={{this.models}} @random="test" @query={{@query}} ...attributes >Hello</LinkTo></template>',
output: null,
errors: [{ messageId: 'unknownArgument' }],
},
{
// Deprecated event with a string-literal value — cannot migrate to `{{on}}`, so no autofix.
code: '<template><Input @value="1" @click="noop" /></template>',
output: null,
errors: [{ messageId: 'unknownArgument' }],
},

// Missing required arguments
{
Expand Down Expand Up @@ -271,12 +288,12 @@ hbsRuleTester.run('template-no-unknown-arguments-for-builtin-components', rule,
},
{
code: '<LinkTo @route="info" @model={{this.model}} @tagName="button" />',
output: null,
output: '<LinkTo @route="info" @model={{this.model}} />',
errors: [{ message: 'Passing the "@tagName" argument to <LinkTo /> is deprecated.' }],
},
{
code: '<LinkTo @route="info" @model={{this.model}} @elementId="superstar" />',
output: null,
output: '<LinkTo @route="info" @model={{this.model}} id="superstar" />',
errors: [
{
message: `Passing the "@elementId" argument to <LinkTo /> is deprecated.
Expand All @@ -286,7 +303,8 @@ Instead, please pass the attribute directly, i.e. "<LinkTo id={{...}} />" instea
},
{
code: '<LinkTo @route="info" @model={{this.model}} @doubleClick={{action this.click}} />',
output: null,
output:
'<LinkTo @route="info" @model={{this.model}} {{on "dblclick" (action this.click)}} />',
errors: [
{
message: `Passing the "@doubleClick" argument to <LinkTo /> is deprecated.
Expand All @@ -296,12 +314,12 @@ Instead, please use the {{on}} modifier, i.e. "<LinkTo {{on "dblclick" ...}} />"
},
{
code: '<Input @value="1" @bubbles={{false}} />',
output: null,
output: '<Input @value="1" />',
errors: [{ message: 'Passing the "@bubbles" argument to <Input /> is deprecated.' }],
},
{
code: '<Input @value="1" @elementId="42" @disabled="disabled" />',
output: null,
output: '<Input @value="1" id="42" disabled="disabled" />',
errors: [
{
message: `Passing the "@elementId" argument to <Input /> is deprecated.
Expand All @@ -315,7 +333,7 @@ Instead, please pass the attribute directly, i.e. "<Input disabled={{...}} />" i
},
{
code: '<Input @value="1" @key-up={{ths.onKeyUp}} />',
output: null,
output: '<Input @value="1" {{on "keyup" ths.onKeyUp}} />',
errors: [
{
message: `Passing the "@key-up" argument to <Input /> is deprecated.
Expand All @@ -325,12 +343,12 @@ Instead, please use the {{on}} modifier, i.e. "<Input {{on "keyup" ...}} />" ins
},
{
code: '<Textarea @value="1" @bubbles={{false}} />',
output: null,
output: '<Textarea @value="1" />',
errors: [{ message: 'Passing the "@bubbles" argument to <Textarea /> is deprecated.' }],
},
{
code: '<Textarea @value="1" @elementId="42" />',
output: null,
output: '<Textarea @value="1" id="42" />',
errors: [
{
message: `Passing the "@elementId" argument to <Textarea /> is deprecated.
Expand All @@ -340,7 +358,7 @@ Instead, please pass the attribute directly, i.e. "<Textarea id={{...}} />" inst
},
{
code: '<Textarea @value="1" @key-up={{ths.onKeyUp}} />',
output: null,
output: '<Textarea @value="1" {{on "keyup" ths.onKeyUp}} />',
errors: [
{
message: `Passing the "@key-up" argument to <Textarea /> is deprecated.
Expand Down
Loading