Skip to content

Commit 0d45a3e

Browse files
committed
Extract rule: template-no-curly-component-invocation
1 parent ffc4ad8 commit 0d45a3e

4 files changed

Lines changed: 337 additions & 5 deletions

File tree

README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,11 +182,12 @@ rules in templates can be disabled with eslint directives with mustache or html
182182

183183
### Best Practices
184184

185-
| Name | Description | 💼 | 🔧 | 💡 |
186-
| :----------------------------------------------------------------------------------------- | :-------------------------------------------------------- | :- | :- | :- |
187-
| [template-builtin-component-arguments](docs/rules/template-builtin-component-arguments.md) | disallow setting certain attributes on builtin components | | | |
188-
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
189-
| [template-no-log](docs/rules/template-no-log.md) | disallow {{log}} in templates | | | |
185+
| Name                                   | Description | 💼 | 🔧 | 💡 |
186+
| :--------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------- | :- | :- | :- |
187+
| [template-builtin-component-arguments](docs/rules/template-builtin-component-arguments.md) | disallow setting certain attributes on builtin components | | | |
188+
| [template-no-curly-component-invocation](docs/rules/template-no-curly-component-invocation.md) | disallow curly component invocation, use angle bracket syntax instead | | | |
189+
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
190+
| [template-no-log](docs/rules/template-no-log.md) | disallow {{log}} in templates | | | |
190191

191192
### Components
192193

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# ember/template-no-curly-component-invocation
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallows curly component invocation syntax. Use angle bracket syntax instead.
6+
7+
There are two ways to invoke a component in a template: curly component syntax
8+
(`{{my-component}}`), and angle bracket syntax (`<MyComponent />`). The
9+
difference between them is syntactical. You should favour angle bracket syntax
10+
as it improves readability of templates, i.e. disambiguates components from
11+
helpers, and is also the future direction Ember is going with the Octane
12+
Edition.
13+
14+
This rule checks all the curly braces in your app and warns about those that
15+
look like they could be component invocations.
16+
17+
## Examples
18+
19+
This rule **forbids** the following:
20+
21+
```hbs
22+
{{foo-bar}}
23+
```
24+
25+
```hbs
26+
{{nested/component}}
27+
```
28+
29+
```hbs
30+
{{#foo-bar}}content{{/foo-bar}}
31+
```
32+
33+
This rule **allows** the following:
34+
35+
```hbs
36+
{{foo bar}}
37+
```
38+
39+
```hbs
40+
<FooBar />
41+
```
42+
43+
```hbs
44+
<Nested::Component />
45+
```
46+
47+
## Configuration
48+
49+
This rule accepts an options object with the following properties:
50+
51+
- `allow` (default: `[]`) - Array of component names to allow in curly syntax
52+
- `disallow` (default: `[]`) - Array of component names to disallow in curly syntax
53+
- `requireDash` (default: `false`) - Require dashes in component names
54+
- `noImplicitThis` (default: `true`) - Don't allow implicit `this` references
55+
56+
```js
57+
// .eslintrc.js
58+
module.exports = {
59+
rules: {
60+
'ember/template-no-curly-component-invocation': [
61+
'error',
62+
{
63+
allow: ['some-helper'],
64+
disallow: [],
65+
},
66+
],
67+
},
68+
};
69+
```
70+
71+
## References
72+
73+
- [Ember Guides - Angle Bracket Syntax](https://guides.emberjs.com/release/components/template-syntax/#toc_angle-bracket-syntax)
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/* eslint-disable eslint-plugin/prefer-placeholders */
2+
const BUILT_INS = new Set([
3+
'action',
4+
'array',
5+
'component',
6+
'concat',
7+
'debugger',
8+
'each',
9+
'each-in',
10+
'fn',
11+
'get',
12+
'hasBlock',
13+
'has-block',
14+
'has-block-params',
15+
'hash',
16+
'if',
17+
'input',
18+
'let',
19+
'link-to',
20+
'loc',
21+
'log',
22+
'mount',
23+
'mut',
24+
'on',
25+
'outlet',
26+
'partial',
27+
'query-params',
28+
'textarea',
29+
'unbound',
30+
'unique-id',
31+
'unless',
32+
'with',
33+
'-in-element',
34+
'in-element',
35+
'app-version',
36+
'rootURL',
37+
]);
38+
39+
const ALWAYS_CURLY = new Set(['yield']);
40+
41+
function transformTagName(name) {
42+
// Convert kebab-case to PascalCase for angle bracket syntax
43+
const parts = name.split('/');
44+
return parts
45+
.map((part) => {
46+
return part
47+
.split('-')
48+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
49+
.join('');
50+
})
51+
.join('::');
52+
}
53+
54+
function parseConfig(config) {
55+
const defaults = {
56+
allow: [],
57+
disallow: [],
58+
requireDash: false,
59+
noImplicitThis: true,
60+
};
61+
62+
if (config === true) {
63+
return defaults;
64+
}
65+
66+
return { ...defaults, ...config };
67+
}
68+
69+
/** @type {import('eslint').Rule.RuleModule} */
70+
module.exports = {
71+
meta: {
72+
type: 'suggestion',
73+
docs: {
74+
description: 'disallow curly component invocation, use angle bracket syntax instead',
75+
category: 'Best Practices',
76+
recommended: true,
77+
strictGjs: true,
78+
strictGts: true,
79+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-curly-component-invocation.md',
80+
},
81+
fixable: null,
82+
schema: [
83+
{
84+
type: 'object',
85+
properties: {
86+
allow: {
87+
type: 'array',
88+
items: { type: 'string' },
89+
},
90+
disallow: {
91+
type: 'array',
92+
items: { type: 'string' },
93+
},
94+
requireDash: {
95+
type: 'boolean',
96+
},
97+
noImplicitThis: {
98+
type: 'boolean',
99+
},
100+
},
101+
additionalProperties: false,
102+
},
103+
],
104+
messages: {},
105+
},
106+
107+
create(context) {
108+
const config = parseConfig(context.options[0]);
109+
110+
function shouldCheckComponent(pathOriginal) {
111+
// Check if in allow list
112+
if (config.allow.includes(pathOriginal)) {
113+
return false;
114+
}
115+
116+
// Check if in disallow list - always report these
117+
if (config.disallow.includes(pathOriginal)) {
118+
return true;
119+
}
120+
121+
// Always curly - don't report
122+
if (ALWAYS_CURLY.has(pathOriginal)) {
123+
return false;
124+
}
125+
126+
// Built-in helpers - don't report
127+
if (BUILT_INS.has(pathOriginal)) {
128+
return false;
129+
}
130+
131+
// If it looks like a component (has dash or slash), flag it
132+
if (pathOriginal.includes('-') || pathOriginal.includes('/')) {
133+
return true;
134+
}
135+
136+
return false;
137+
}
138+
139+
return {
140+
GlimmerMustacheStatement(node) {
141+
if (!node.path || node.path.type !== 'GlimmerPathExpression') {
142+
return;
143+
}
144+
145+
const pathOriginal = node.path.original;
146+
147+
// Skip if has positional params or hash arguments (can't be converted to angle brackets)
148+
if (
149+
(node.params && node.params.length > 0) ||
150+
(node.hash && node.hash.pairs && node.hash.pairs.length > 0)
151+
) {
152+
return;
153+
}
154+
155+
if (shouldCheckComponent(pathOriginal)) {
156+
const angleBracketName = transformTagName(pathOriginal);
157+
context.report({
158+
node,
159+
message: `You are using the component {{${pathOriginal}}} with curly component syntax. You should use <${angleBracketName}> instead. If it is actually a helper you must manually add it to the 'no-curly-component-invocation' rule configuration, e.g. \`'no-curly-component-invocation': { allow: ['${pathOriginal}'] }\`.`,
160+
});
161+
}
162+
},
163+
164+
GlimmerBlockStatement(node) {
165+
if (!node.path || node.path.type !== 'GlimmerPathExpression') {
166+
return;
167+
}
168+
169+
const pathOriginal = node.path.original;
170+
171+
// Skip if has positional params or hash arguments
172+
if (
173+
(node.params && node.params.length > 0) ||
174+
(node.hash && node.hash.pairs && node.hash.pairs.length > 0)
175+
) {
176+
return;
177+
}
178+
179+
if (shouldCheckComponent(pathOriginal)) {
180+
const angleBracketName = transformTagName(pathOriginal);
181+
context.report({
182+
node,
183+
message: `You are using the component {{#${pathOriginal}}} with curly component syntax. You should use <${angleBracketName}> instead. If it is actually a helper you must manually add it to the 'no-curly-component-invocation' rule configuration, e.g. \`'no-curly-component-invocation': { allow: ['${pathOriginal}'] }\`.`,
184+
});
185+
}
186+
},
187+
};
188+
},
189+
};
190+
/* eslint-enable eslint-plugin/prefer-placeholders */
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
const rule = require('../../../lib/rules/template-no-curly-component-invocation');
2+
const RuleTester = require('eslint').RuleTester;
3+
4+
const ruleTester = new RuleTester({
5+
parser: require.resolve('ember-eslint-parser'),
6+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
7+
});
8+
9+
function generateError(name) {
10+
const parts = name.split('/');
11+
const angleBracketName = parts
12+
.map((part) => {
13+
return part
14+
.split('-')
15+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
16+
.join('');
17+
})
18+
.join('::');
19+
return `You are using the component {{${name}}} with curly component syntax. You should use <${angleBracketName}> instead. If it is actually a helper you must manually add it to the 'no-curly-component-invocation' rule configuration, e.g. \`'no-curly-component-invocation': { allow: ['${name}'] }\`.`;
20+
}
21+
22+
ruleTester.run('template-no-curly-component-invocation', rule, {
23+
valid: [
24+
'<template>{{foo}}</template>',
25+
'<template>{{foo.bar}}</template>',
26+
'<template>{{42}}</template>',
27+
'<template>{{true}}</template>',
28+
'<template>{{foo bar}}</template>',
29+
'<template>{{#each items as |item|}}{{item}}{{/each}}</template>',
30+
'<template>{{#if someProperty}}yay{{/if}}</template>',
31+
'<template><FooBar /></template>',
32+
'<template>{{#some-component foo="bar"}}foo{{/some-component}}</template>',
33+
{
34+
code: '<template>{{foo-bar}}</template>',
35+
options: [{ allow: ['foo-bar'] }],
36+
},
37+
],
38+
invalid: [
39+
{
40+
code: '<template>{{foo-bar}}</template>',
41+
output: null,
42+
errors: [
43+
{
44+
message: generateError('foo-bar'),
45+
},
46+
],
47+
},
48+
{
49+
code: '<template>{{nested/component}}</template>',
50+
output: null,
51+
errors: [
52+
{
53+
message: generateError('nested/component'),
54+
},
55+
],
56+
},
57+
{
58+
code: '<template>{{#foo-bar}}content{{/foo-bar}}</template>',
59+
output: null,
60+
errors: [
61+
{
62+
message:
63+
"You are using the component {{#foo-bar}} with curly component syntax. You should use <FooBar> instead. If it is actually a helper you must manually add it to the 'no-curly-component-invocation' rule configuration, e.g. `'no-curly-component-invocation': { allow: ['foo-bar'] }`.",
64+
},
65+
],
66+
},
67+
],
68+
});

0 commit comments

Comments
 (0)