Skip to content

Commit d25c2d3

Browse files
committed
Extract rule: template-no-unnecessary-component-helper
1 parent 4b103e6 commit d25c2d3

4 files changed

Lines changed: 304 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ rules in templates can be disabled with eslint directives with mustache or html
256256
| [template-no-obsolete-elements](docs/rules/template-no-obsolete-elements.md) | disallow obsolete HTML elements | | | |
257257
| [template-no-outlet-outside-routes](docs/rules/template-no-outlet-outside-routes.md) | disallow {{outlet}} outside of route templates | | | |
258258
| [template-no-page-title-component](docs/rules/template-no-page-title-component.md) | disallow usage of ember-page-title component | | | |
259+
| [template-no-unnecessary-component-helper](docs/rules/template-no-unnecessary-component-helper.md) | disallow unnecessary component helper | | 🔧 | |
259260
| [template-no-unnecessary-concat](docs/rules/template-no-unnecessary-concat.md) | disallow unnecessary string concatenation | | 🔧 | |
260261
| [template-no-unnecessary-curly-parens](docs/rules/template-no-unnecessary-curly-parens.md) | disallow unnecessary parentheses enclosing statements in curlies | | 🔧 | |
261262
| [template-no-unused-block-params](docs/rules/template-no-unused-block-params.md) | disallow unused block parameters in templates | | | |
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# ember/template-no-unnecessary-component-helper
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
> **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.
6+
7+
<!-- end auto-generated rule header -->
8+
9+
Disallow unnecessary usage of the `{{component}}` helper with static component names.
10+
11+
## Rule Details
12+
13+
This rule disallows using `{{component "component-name"}}` when you could use angle bracket invocation instead.
14+
15+
## Examples
16+
17+
Examples of **incorrect** code for this rule:
18+
19+
```hbs
20+
{{component 'my-component'}}
21+
{{component 'MyComponent' arg='value'}}
22+
```
23+
24+
Examples of **correct** code for this rule:
25+
26+
```hbs
27+
<MyComponent />
28+
{{component this.dynamicComponentName}}
29+
{{component @componentName}}
30+
```
31+
32+
## References
33+
34+
- [Ember Guides - Components](https://guides.emberjs.com/release/components/)
35+
- [RFC #311 - Angle Bracket Invocation](https://github.com/emberjs/rfcs/blob/master/text/0311-angle-bracket-invocation.md)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
function isComponentWithStringLiteral(node) {
2+
return (
3+
node.path &&
4+
node.path.type === 'GlimmerPathExpression' &&
5+
node.path.original === 'component' &&
6+
node.params &&
7+
node.params.length > 0 &&
8+
node.params[0].type === 'GlimmerStringLiteral' &&
9+
!(node.params[0].value || '').includes('@')
10+
);
11+
}
12+
13+
/** @type {import('eslint').Rule.RuleModule} */
14+
module.exports = {
15+
meta: {
16+
type: 'suggestion',
17+
docs: {
18+
description: 'disallow unnecessary component helper',
19+
category: 'Best Practices',
20+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unnecessary-component-helper.md',
21+
templateMode: 'loose',
22+
},
23+
fixable: 'code',
24+
schema: [],
25+
messages: {
26+
noUnnecessaryComponent:
27+
'Unnecessary use of (component) helper. Use the component name directly.',
28+
},
29+
originallyFrom: {
30+
name: 'ember-template-lint',
31+
rule: 'lib/rules/no-unnecessary-component-helper.js',
32+
docs: 'docs/rule/no-unnecessary-component-helper.md',
33+
tests: 'test/unit/rules/no-unnecessary-component-helper-test.js',
34+
},
35+
},
36+
37+
create(context) {
38+
const sourceCode = context.sourceCode || context.getSourceCode();
39+
let inAttribute = 0;
40+
41+
return {
42+
GlimmerAttrNode() {
43+
inAttribute++;
44+
},
45+
'GlimmerAttrNode:exit'() {
46+
inAttribute--;
47+
},
48+
49+
GlimmerMustacheStatement(node) {
50+
if (inAttribute > 0) {
51+
return;
52+
}
53+
if (!isComponentWithStringLiteral(node)) {
54+
return;
55+
}
56+
57+
const componentName = node.params[0].value || node.params[0].original;
58+
context.report({
59+
node,
60+
messageId: 'noUnnecessaryComponent',
61+
fix(fixer) {
62+
const restParams = node.params.slice(1);
63+
const hashPairs = node.hash?.pairs || [];
64+
65+
let replacement = `{{${componentName}`;
66+
for (const param of restParams) {
67+
replacement += ` ${sourceCode.getText(param)}`;
68+
}
69+
for (const pair of hashPairs) {
70+
replacement += ` ${sourceCode.getText(pair)}`;
71+
}
72+
replacement += '}}';
73+
74+
return fixer.replaceText(node, replacement);
75+
},
76+
});
77+
},
78+
79+
GlimmerBlockStatement(node) {
80+
if (inAttribute > 0) {
81+
return;
82+
}
83+
if (!isComponentWithStringLiteral(node)) {
84+
return;
85+
}
86+
87+
context.report({
88+
node,
89+
messageId: 'noUnnecessaryComponent',
90+
});
91+
},
92+
93+
GlimmerSubExpression(node) {
94+
if (inAttribute > 0) {
95+
return;
96+
}
97+
if (!isComponentWithStringLiteral(node)) {
98+
return;
99+
}
100+
101+
const componentName = node.params[0].value || node.params[0].original;
102+
context.report({
103+
node,
104+
messageId: 'noUnnecessaryComponent',
105+
fix(fixer) {
106+
const restParams = node.params.slice(1);
107+
const hashPairs = node.hash?.pairs || [];
108+
109+
let replacement = `(${componentName}`;
110+
for (const param of restParams) {
111+
replacement += ` ${sourceCode.getText(param)}`;
112+
}
113+
for (const pair of hashPairs) {
114+
replacement += ` ${sourceCode.getText(pair)}`;
115+
}
116+
replacement += ')';
117+
118+
return fixer.replaceText(node, replacement);
119+
},
120+
});
121+
},
122+
};
123+
},
124+
};
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
const eslint = require('eslint');
2+
const rule = require('../../../lib/rules/template-no-unnecessary-component-helper');
3+
4+
const { RuleTester } = eslint;
5+
6+
const ruleTester = new RuleTester({
7+
parser: require.resolve('ember-eslint-parser'),
8+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
9+
});
10+
11+
ruleTester.run('template-no-unnecessary-component-helper', rule, {
12+
valid: [
13+
// Angle bracket invocation
14+
'<template><MyComponent /></template>',
15+
16+
// Dynamic component names (necessary use)
17+
'<template>{{component this.componentName}}</template>',
18+
'<template>{{component @componentName}}</template>',
19+
20+
// No component helper
21+
'<template>{{my-helper}}</template>',
22+
23+
// Dynamic component with extra args (MustacheStatement)
24+
'<template>{{component SOME_COMPONENT_NAME}}</template>',
25+
'<template>{{component SOME_COMPONENT_NAME SOME_ARG}}</template>',
26+
'<template>{{component SOME_COMPONENT_NAME "Hello World"}}</template>',
27+
28+
// Regular component invocations (not using component helper)
29+
'<template>{{my-component "Hello world"}}</template>',
30+
'<template>{{my-component "Hello world" 123}}</template>',
31+
32+
// Block statements with dynamic component name
33+
'<template>{{#component SOME_COMPONENT_NAME}}{{/component}}</template>',
34+
'<template>{{#component SOME_COMPONENT_NAME SOME_ARG}}{{/component}}</template>',
35+
'<template>{{#component SOME_COMPONENT_NAME "Hello World"}}{{/component}}</template>',
36+
37+
// Regular block components (no component helper)
38+
'<template>{{#my-component}}{{/my-component}}</template>',
39+
'<template>{{#my-component "Hello world"}}{{/my-component}}</template>',
40+
'<template>{{#my-component "Hello world" 123}}{{/my-component}}</template>',
41+
42+
// Dynamic in angle bracket attribute (valid - first param not string)
43+
'<template><Foo @bar={{component SOME_COMPONENT_NAME}} /></template>',
44+
'<template><Foo @bar={{component SOME_COMPONENT_NAME}}></Foo></template>',
45+
46+
// Static args on angle bracket (no component helper)
47+
'<template><Foo @arg="foo" /></template>',
48+
49+
// If expressions without (component) - should not trigger
50+
'<template><Foo @arg={{if this.user.isAdmin "admin"}} /></template>',
51+
],
52+
invalid: [
53+
{
54+
code: '<template>{{component "my-component"}}</template>',
55+
output: '<template>{{my-component}}</template>',
56+
errors: [{ messageId: 'noUnnecessaryComponent' }],
57+
},
58+
{
59+
code: '<template>{{component "MyComponent"}}</template>',
60+
output: '<template>{{MyComponent}}</template>',
61+
errors: [{ messageId: 'noUnnecessaryComponent' }],
62+
},
63+
64+
{
65+
code: '<template>{{component "my-component-name" foo=123 bar=456}}</template>',
66+
output: '<template>{{my-component-name foo=123 bar=456}}</template>',
67+
errors: [{ messageId: 'noUnnecessaryComponent' }],
68+
},
69+
{
70+
code: '<template>{{#component "my-component-name" foo=123 bar=456}}{{/component}}</template>',
71+
output: null,
72+
errors: [{ messageId: 'noUnnecessaryComponent' }],
73+
},
74+
{
75+
code: '<template><Foo @arg={{component "allowed-component"}}>{{component "forbidden-component"}}</Foo></template>',
76+
output:
77+
'<template><Foo @arg={{component "allowed-component"}}>{{forbidden-component}}</Foo></template>',
78+
errors: [{ messageId: 'noUnnecessaryComponent' }],
79+
},
80+
],
81+
});
82+
83+
const hbsRuleTester = new RuleTester({
84+
parser: require.resolve('ember-eslint-parser/hbs'),
85+
parserOptions: {
86+
ecmaVersion: 2022,
87+
sourceType: 'module',
88+
},
89+
});
90+
91+
hbsRuleTester.run('template-no-unnecessary-component-helper', rule, {
92+
valid: [
93+
'{{component SOME_COMPONENT_NAME}}',
94+
'{{component SOME_COMPONENT_NAME SOME_ARG}}',
95+
'{{component SOME_COMPONENT_NAME "Hello World"}}',
96+
'{{my-component}}',
97+
'{{my-component "Hello world"}}',
98+
'{{my-component "Hello world" 123}}',
99+
'{{#component SOME_COMPONENT_NAME}}{{/component}}',
100+
'{{#component SOME_COMPONENT_NAME SOME_ARG}}{{/component}}',
101+
'{{#component SOME_COMPONENT_NAME "Hello World"}}{{/component}}',
102+
'{{#my-component}}{{/my-component}}',
103+
'{{#my-component "Hello world"}}{{/my-component}}',
104+
'{{#my-component "Hello world" 123}}{{/my-component}}',
105+
'(component SOME_COMPONENT_NAME)',
106+
'(component "my-component")',
107+
'<Foo @bar={{component SOME_COMPONENT_NAME}} />',
108+
'<Foo @bar={{component SOME_COMPONENT_NAME}}></Foo>',
109+
'<Foo @arg="foo" />',
110+
'<Foo class="foo" />',
111+
'<Foo data-test-bar="foo" />',
112+
'<Foo @arg={{if this.user.isAdmin "admin"}} />',
113+
// component helper in attribute values is safe (passing components as args)
114+
'<Foo @bar={{component "my-component"}} />',
115+
'<Foo @bar={{component "my-component"}}></Foo>',
116+
'<Foo @arg={{if this.user.isAdmin (component "my-component")}} />',
117+
// addon-scoped component names with @ are allowed
118+
'{{component "addon-name@component-name"}}',
119+
'{{#component "addon-name@component-name"}}{{/component}}',
120+
],
121+
invalid: [
122+
{
123+
code: '{{component "my-component-name" foo=123 bar=456}}',
124+
output: '{{my-component-name foo=123 bar=456}}',
125+
errors: [
126+
{ message: 'Unnecessary use of (component) helper. Use the component name directly.' },
127+
],
128+
},
129+
{
130+
code: '{{#component "my-component-name" foo=123 bar=456}}{{/component}}',
131+
output: null,
132+
errors: [
133+
{ message: 'Unnecessary use of (component) helper. Use the component name directly.' },
134+
],
135+
},
136+
{
137+
code: '<Foo @arg={{component "allowed-component"}}>{{component "forbidden-component"}}</Foo>',
138+
output: '<Foo @arg={{component "allowed-component"}}>{{forbidden-component}}</Foo>',
139+
errors: [
140+
{ message: 'Unnecessary use of (component) helper. Use the component name directly.' },
141+
],
142+
},
143+
],
144+
});

0 commit comments

Comments
 (0)