Skip to content

Commit 707cf7b

Browse files
Merge pull request #2584 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-unnecessary-component-helper
Extract rule: template-no-unnecessary-component-helper
2 parents 4b103e6 + 983b592 commit 707cf7b

4 files changed

Lines changed: 280 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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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+
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.
10+
11+
## Examples
12+
13+
This rule **forbids** the following:
14+
15+
```gjs
16+
<template>
17+
{{component "my-component"}}
18+
</template>
19+
```
20+
21+
This rule **allows** the following:
22+
23+
```gjs
24+
<template>
25+
{{component SOME_COMPONENT_NAME}}
26+
</template>
27+
```
28+
29+
```gjs
30+
<template>
31+
{{!-- the `component` helper is needed to invoke this --}}
32+
{{component "addon-name@component-name"}}
33+
</template>
34+
```
35+
36+
```gjs
37+
<template>
38+
{{my-component}}
39+
</template>
40+
```
41+
42+
```gjs
43+
<template>
44+
{{my-component close=(component "link-to" "index")}}
45+
<MyComponent @close={{component "link-to" "index"}} />
46+
</template>
47+
```
48+
49+
## References
50+
51+
- [component helper guide](https://guides.emberjs.com/release/components/defining-a-component/#toc_dynamically-rendering-a-component)
52+
- [component helper spec](https://www.emberjs.com/api/ember/release/classes/Ember.Templates.helpers/methods/component?anchor=component)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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+
function getComponentInvocationText(sourceCode, node, componentName) {
14+
const parts = [];
15+
16+
for (const param of node.params.slice(1)) {
17+
parts.push(sourceCode.getText(param));
18+
}
19+
20+
for (const pair of node.hash?.pairs || []) {
21+
parts.push(sourceCode.getText(pair));
22+
}
23+
24+
return [componentName, ...parts].join(' ');
25+
}
26+
27+
function getOpenInvocationEnd(node) {
28+
if (node.hash?.pairs?.length) {
29+
return node.hash.range[1];
30+
}
31+
32+
const lastParam = node.params.at(-1);
33+
34+
return lastParam ? lastParam.range[1] : node.path.range[1];
35+
}
36+
37+
/** @type {import('eslint').Rule.RuleModule} */
38+
module.exports = {
39+
meta: {
40+
type: 'suggestion',
41+
docs: {
42+
description: 'disallow unnecessary component helper',
43+
category: 'Best Practices',
44+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-unnecessary-component-helper.md',
45+
templateMode: 'loose',
46+
},
47+
fixable: 'code',
48+
schema: [],
49+
messages: {
50+
noUnnecessaryComponent: 'Invoke component directly instead of using `component` helper',
51+
},
52+
originallyFrom: {
53+
name: 'ember-template-lint',
54+
rule: 'lib/rules/no-unnecessary-component-helper.js',
55+
docs: 'docs/rule/no-unnecessary-component-helper.md',
56+
tests: 'test/unit/rules/no-unnecessary-component-helper-test.js',
57+
},
58+
},
59+
60+
create(context) {
61+
const sourceCode = context.sourceCode || context.getSourceCode();
62+
let inAttribute = 0;
63+
64+
return {
65+
GlimmerAttrNode() {
66+
inAttribute++;
67+
},
68+
'GlimmerAttrNode:exit'() {
69+
inAttribute--;
70+
},
71+
72+
GlimmerMustacheStatement(node) {
73+
if (inAttribute > 0) {
74+
return;
75+
}
76+
if (!isComponentWithStringLiteral(node)) {
77+
return;
78+
}
79+
80+
const componentName = node.params[0].value || node.params[0].original;
81+
const invocation = getComponentInvocationText(sourceCode, node, componentName);
82+
context.report({
83+
node,
84+
messageId: 'noUnnecessaryComponent',
85+
fix(fixer) {
86+
return fixer.replaceText(node, `{{${invocation}}}`);
87+
},
88+
});
89+
},
90+
91+
GlimmerBlockStatement(node) {
92+
if (inAttribute > 0) {
93+
return;
94+
}
95+
if (!isComponentWithStringLiteral(node)) {
96+
return;
97+
}
98+
99+
const componentName = node.params[0].value || node.params[0].original;
100+
const invocation = getComponentInvocationText(sourceCode, node, componentName);
101+
102+
context.report({
103+
node,
104+
messageId: 'noUnnecessaryComponent',
105+
fix(fixer) {
106+
const openInvocationEnd = getOpenInvocationEnd(node);
107+
const closingPathEnd = node.range[1] - 2;
108+
const closingPathStart = closingPathEnd - node.path.original.length;
109+
110+
return [
111+
fixer.replaceTextRange([node.path.range[0], openInvocationEnd], invocation),
112+
fixer.replaceTextRange([closingPathStart, closingPathEnd], componentName),
113+
];
114+
},
115+
});
116+
},
117+
};
118+
},
119+
};
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
const eslint = require('eslint');
2+
const rule = require('../../../lib/rules/template-no-unnecessary-component-helper');
3+
4+
const { RuleTester } = eslint;
5+
6+
const validHbs = [
7+
'{{component SOME_COMPONENT_NAME}}',
8+
'{{component SOME_COMPONENT_NAME SOME_ARG}}',
9+
'{{component SOME_COMPONENT_NAME "Hello World"}}',
10+
'{{my-component}}',
11+
'{{my-component "Hello world"}}',
12+
'{{my-component "Hello world" 123}}',
13+
'{{#component SOME_COMPONENT_NAME}}{{/component}}',
14+
'{{#component SOME_COMPONENT_NAME SOME_ARG}}{{/component}}',
15+
'{{#component SOME_COMPONENT_NAME "Hello World"}}{{/component}}',
16+
'{{#my-component}}{{/my-component}}',
17+
'{{#my-component "Hello world"}}{{/my-component}}',
18+
'{{#my-component "Hello world" 123}}{{/my-component}}',
19+
'(component SOME_COMPONENT_NAME)',
20+
'(component "my-component")',
21+
'<Foo @bar={{component SOME_COMPONENT_NAME}} />',
22+
'<Foo @bar={{component "my-component"}} />',
23+
'<Foo @bar={{component SOME_COMPONENT_NAME}}></Foo>',
24+
'<Foo @bar={{component "my-component"}}></Foo>',
25+
'<Foo @arg="foo" />',
26+
'<Foo class="foo" />',
27+
'<Foo data-test-bar="foo" />',
28+
'<Foo @arg={{if this.user.isAdmin "admin"}} />',
29+
'<Foo @arg={{if this.user.isAdmin (component "my-component")}} />',
30+
"{{component 'addon-name@component-name'}}",
31+
"{{#component 'addon-name@component-name'}}{{/component}}",
32+
];
33+
34+
const invalidHbs = [
35+
{
36+
code: '{{component "my-component-name" foo=123 bar=456}}',
37+
output: '{{my-component-name foo=123 bar=456}}',
38+
errors: [{ message: 'Invoke component directly instead of using `component` helper' }],
39+
},
40+
{
41+
code: '{{#component "my-component-name" foo=123 bar=456}}{{/component}}',
42+
output: '{{#my-component-name foo=123 bar=456}}{{/my-component-name}}',
43+
errors: [{ message: 'Invoke component directly instead of using `component` helper' }],
44+
},
45+
{
46+
code: '<Foo @arg={{component "allowed-component"}}>{{component "forbidden-component"}}</Foo>',
47+
output: '<Foo @arg={{component "allowed-component"}}>{{forbidden-component}}</Foo>',
48+
errors: [{ message: 'Invoke component directly instead of using `component` helper' }],
49+
},
50+
];
51+
52+
const validGjs = [
53+
'<template>{{component SOME_COMPONENT_NAME}}</template>',
54+
'<template>{{component SOME_COMPONENT_NAME SOME_ARG}}</template>',
55+
'<template>{{component SOME_COMPONENT_NAME "Hello World"}}</template>',
56+
'<template>{{my-component}}</template>',
57+
'<template>{{my-component "Hello world"}}</template>',
58+
'<template>{{my-component "Hello world" 123}}</template>',
59+
'<template>{{#component SOME_COMPONENT_NAME}}{{/component}}</template>',
60+
'<template>{{#component SOME_COMPONENT_NAME SOME_ARG}}{{/component}}</template>',
61+
'<template>{{#component SOME_COMPONENT_NAME "Hello World"}}{{/component}}</template>',
62+
'<template>{{#my-component}}{{/my-component}}</template>',
63+
'<template>{{#my-component "Hello world"}}{{/my-component}}</template>',
64+
'<template>{{#my-component "Hello world" 123}}{{/my-component}}</template>',
65+
'<template><Foo @bar={{component SOME_COMPONENT_NAME}} /></template>',
66+
'<template><Foo @bar={{component "my-component"}} /></template>',
67+
'<template><Foo @bar={{component SOME_COMPONENT_NAME}}></Foo></template>',
68+
'<template><Foo @bar={{component "my-component"}}></Foo></template>',
69+
'<template><Foo @arg="foo" /></template>',
70+
'<template><Foo class="foo" /></template>',
71+
'<template><Foo data-test-bar="foo" /></template>',
72+
'<template><Foo @arg={{if this.user.isAdmin "admin"}} /></template>',
73+
'<template><Foo @arg={{if this.user.isAdmin (component "my-component")}} /></template>',
74+
"<template>{{component 'addon-name@component-name'}}</template>",
75+
"<template>{{#component 'addon-name@component-name'}}{{/component}}</template>",
76+
];
77+
78+
function wrapTemplate(entry) {
79+
return {
80+
...entry,
81+
code: `<template>${entry.code}</template>`,
82+
output: entry.output ? `<template>${entry.output}</template>` : entry.output,
83+
errors: entry.errors.map(() => ({ messageId: 'noUnnecessaryComponent' })),
84+
};
85+
}
86+
87+
const gjsRuleTester = new RuleTester({
88+
parser: require.resolve('ember-eslint-parser'),
89+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
90+
});
91+
92+
gjsRuleTester.run('template-no-unnecessary-component-helper', rule, {
93+
valid: validGjs,
94+
invalid: invalidHbs.map(wrapTemplate),
95+
});
96+
97+
const hbsRuleTester = new RuleTester({
98+
parser: require.resolve('ember-eslint-parser/hbs'),
99+
parserOptions: {
100+
ecmaVersion: 2022,
101+
sourceType: 'module',
102+
},
103+
});
104+
105+
hbsRuleTester.run('template-no-unnecessary-component-helper', rule, {
106+
valid: validHbs,
107+
invalid: invalidHbs,
108+
});

0 commit comments

Comments
 (0)