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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-obsolete-elements](docs/rules/template-no-obsolete-elements.md) | disallow obsolete HTML elements | | | |
| [template-no-outlet-outside-routes](docs/rules/template-no-outlet-outside-routes.md) | disallow {{outlet}} outside of route templates | | | |
| [template-no-page-title-component](docs/rules/template-no-page-title-component.md) | disallow usage of ember-page-title component | | | |
| [template-no-unnecessary-component-helper](docs/rules/template-no-unnecessary-component-helper.md) | disallow unnecessary component helper | | 🔧 | |
| [template-no-unnecessary-concat](docs/rules/template-no-unnecessary-concat.md) | disallow unnecessary string concatenation | | 🔧 | |
| [template-no-unnecessary-curly-parens](docs/rules/template-no-unnecessary-curly-parens.md) | disallow unnecessary parentheses enclosing statements in curlies | | 🔧 | |
| [template-no-unused-block-params](docs/rules/template-no-unused-block-params.md) | disallow unused block parameters in templates | | | |
Expand Down
52 changes: 52 additions & 0 deletions docs/rules/template-no-unnecessary-component-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ember/template-no-unnecessary-component-helper

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

> **HBS Only**: This rule applies to classic `.hbs` template files only (loose mode). It is not relevant for `gjs`/`gts` files (strict mode), where these patterns cannot occur.

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

The `component` template helper can be used to dynamically pick the component being rendered based on the provided property. But if the component name is passed as a string because it's already known, then the component should be invoked directly, instead of using the `component` helper.

## Examples

This rule **forbids** the following:

```gjs
<template>
{{component "my-component"}}
</template>
```

This rule **allows** the following:

```gjs
<template>
{{component SOME_COMPONENT_NAME}}
</template>
```

```gjs
<template>
{{!-- the `component` helper is needed to invoke this --}}
{{component "addon-name@component-name"}}
</template>
```

```gjs
<template>
{{my-component}}
</template>
```

```gjs
<template>
{{my-component close=(component "link-to" "index")}}
<MyComponent @close={{component "link-to" "index"}} />
</template>
```

## References

- [component helper guide](https://guides.emberjs.com/release/components/defining-a-component/#toc_dynamically-rendering-a-component)
- [component helper spec](https://www.emberjs.com/api/ember/release/classes/Ember.Templates.helpers/methods/component?anchor=component)
119 changes: 119 additions & 0 deletions lib/rules/template-no-unnecessary-component-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
function isComponentWithStringLiteral(node) {
return (
node.path &&
node.path.type === 'GlimmerPathExpression' &&
node.path.original === 'component' &&
node.params &&
node.params.length > 0 &&
node.params[0].type === 'GlimmerStringLiteral' &&
!(node.params[0].value || '').includes('@')
);
}

function getComponentInvocationText(sourceCode, node, componentName) {
const parts = [];

for (const param of node.params.slice(1)) {
parts.push(sourceCode.getText(param));
}

for (const pair of node.hash?.pairs || []) {
parts.push(sourceCode.getText(pair));
}

return [componentName, ...parts].join(' ');
}

function getOpenInvocationEnd(node) {
if (node.hash?.pairs?.length) {
return node.hash.range[1];
}

const lastParam = node.params.at(-1);

return lastParam ? lastParam.range[1] : node.path.range[1];
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow unnecessary component helper',
category: 'Best Practices',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unnecessary-component-helper.md',
templateMode: 'loose',
},
fixable: 'code',
schema: [],
messages: {
noUnnecessaryComponent: 'Invoke component directly instead of using `component` helper',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-unnecessary-component-helper.js',
docs: 'docs/rule/no-unnecessary-component-helper.md',
tests: 'test/unit/rules/no-unnecessary-component-helper-test.js',
},
},

create(context) {
const sourceCode = context.sourceCode || context.getSourceCode();
let inAttribute = 0;

return {
GlimmerAttrNode() {
inAttribute++;
},
'GlimmerAttrNode:exit'() {
inAttribute--;
},

GlimmerMustacheStatement(node) {
if (inAttribute > 0) {
return;
}
if (!isComponentWithStringLiteral(node)) {
return;
}

const componentName = node.params[0].value || node.params[0].original;
const invocation = getComponentInvocationText(sourceCode, node, componentName);
context.report({
node,
messageId: 'noUnnecessaryComponent',
fix(fixer) {
return fixer.replaceText(node, `{{${invocation}}}`);
},
});
},

GlimmerBlockStatement(node) {
if (inAttribute > 0) {
return;
}
if (!isComponentWithStringLiteral(node)) {
return;
}

const componentName = node.params[0].value || node.params[0].original;
const invocation = getComponentInvocationText(sourceCode, node, componentName);

context.report({
node,
messageId: 'noUnnecessaryComponent',
fix(fixer) {
const openInvocationEnd = getOpenInvocationEnd(node);
const closingPathEnd = node.range[1] - 2;
const closingPathStart = closingPathEnd - node.path.original.length;

return [
fixer.replaceTextRange([node.path.range[0], openInvocationEnd], invocation),
fixer.replaceTextRange([closingPathStart, closingPathEnd], componentName),
];
},
});
},
};
},
};
108 changes: 108 additions & 0 deletions tests/lib/rules/template-no-unnecessary-component-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
const eslint = require('eslint');
const rule = require('../../../lib/rules/template-no-unnecessary-component-helper');

const { RuleTester } = eslint;

const validHbs = [
'{{component SOME_COMPONENT_NAME}}',
'{{component SOME_COMPONENT_NAME SOME_ARG}}',
'{{component SOME_COMPONENT_NAME "Hello World"}}',
'{{my-component}}',
'{{my-component "Hello world"}}',
'{{my-component "Hello world" 123}}',
'{{#component SOME_COMPONENT_NAME}}{{/component}}',
'{{#component SOME_COMPONENT_NAME SOME_ARG}}{{/component}}',
'{{#component SOME_COMPONENT_NAME "Hello World"}}{{/component}}',
'{{#my-component}}{{/my-component}}',
'{{#my-component "Hello world"}}{{/my-component}}',
'{{#my-component "Hello world" 123}}{{/my-component}}',
'(component SOME_COMPONENT_NAME)',
'(component "my-component")',
'<Foo @bar={{component SOME_COMPONENT_NAME}} />',
'<Foo @bar={{component "my-component"}} />',
'<Foo @bar={{component SOME_COMPONENT_NAME}}></Foo>',
'<Foo @bar={{component "my-component"}}></Foo>',
'<Foo @arg="foo" />',
'<Foo class="foo" />',
'<Foo data-test-bar="foo" />',
'<Foo @arg={{if this.user.isAdmin "admin"}} />',
'<Foo @arg={{if this.user.isAdmin (component "my-component")}} />',
"{{component 'addon-name@component-name'}}",
"{{#component 'addon-name@component-name'}}{{/component}}",
];

const invalidHbs = [
{
code: '{{component "my-component-name" foo=123 bar=456}}',
output: '{{my-component-name foo=123 bar=456}}',
errors: [{ message: 'Invoke component directly instead of using `component` helper' }],
},
{
code: '{{#component "my-component-name" foo=123 bar=456}}{{/component}}',
output: '{{#my-component-name foo=123 bar=456}}{{/my-component-name}}',
errors: [{ message: 'Invoke component directly instead of using `component` helper' }],
},
{
code: '<Foo @arg={{component "allowed-component"}}>{{component "forbidden-component"}}</Foo>',
output: '<Foo @arg={{component "allowed-component"}}>{{forbidden-component}}</Foo>',
errors: [{ message: 'Invoke component directly instead of using `component` helper' }],
},
];

const validGjs = [
'<template>{{component SOME_COMPONENT_NAME}}</template>',
'<template>{{component SOME_COMPONENT_NAME SOME_ARG}}</template>',
'<template>{{component SOME_COMPONENT_NAME "Hello World"}}</template>',
'<template>{{my-component}}</template>',
'<template>{{my-component "Hello world"}}</template>',
'<template>{{my-component "Hello world" 123}}</template>',
'<template>{{#component SOME_COMPONENT_NAME}}{{/component}}</template>',
'<template>{{#component SOME_COMPONENT_NAME SOME_ARG}}{{/component}}</template>',
'<template>{{#component SOME_COMPONENT_NAME "Hello World"}}{{/component}}</template>',
'<template>{{#my-component}}{{/my-component}}</template>',
'<template>{{#my-component "Hello world"}}{{/my-component}}</template>',
'<template>{{#my-component "Hello world" 123}}{{/my-component}}</template>',
'<template><Foo @bar={{component SOME_COMPONENT_NAME}} /></template>',
'<template><Foo @bar={{component "my-component"}} /></template>',
'<template><Foo @bar={{component SOME_COMPONENT_NAME}}></Foo></template>',
'<template><Foo @bar={{component "my-component"}}></Foo></template>',
'<template><Foo @arg="foo" /></template>',
'<template><Foo class="foo" /></template>',
'<template><Foo data-test-bar="foo" /></template>',
'<template><Foo @arg={{if this.user.isAdmin "admin"}} /></template>',
'<template><Foo @arg={{if this.user.isAdmin (component "my-component")}} /></template>',
"<template>{{component 'addon-name@component-name'}}</template>",
"<template>{{#component 'addon-name@component-name'}}{{/component}}</template>",
];

function wrapTemplate(entry) {
return {
...entry,
code: `<template>${entry.code}</template>`,
output: entry.output ? `<template>${entry.output}</template>` : entry.output,
errors: entry.errors.map(() => ({ messageId: 'noUnnecessaryComponent' })),
};
}

const gjsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

gjsRuleTester.run('template-no-unnecessary-component-helper', rule, {
valid: validGjs,
invalid: invalidHbs.map(wrapTemplate),
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});

hbsRuleTester.run('template-no-unnecessary-component-helper', rule, {
valid: validHbs,
invalid: invalidHbs,
});
Loading