Skip to content

Commit 24ae541

Browse files
Merge pull request #2460 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-curly-component-invocation
Extract rule: template-no-curly-component-invocation
2 parents 9df0806 + 8acbf77 commit 24ae541

4 files changed

Lines changed: 795 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ rules in templates can be disabled with eslint directives with mustache or html
208208
| [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | |
209209
| [template-no-chained-this](docs/rules/template-no-chained-this.md) | disallow redundant `this.this` in templates | | 🔧 | |
210210
| [template-no-class-bindings](docs/rules/template-no-class-bindings.md) | disallow passing classBinding or classNameBindings as arguments in templates | | | |
211+
| [template-no-curly-component-invocation](docs/rules/template-no-curly-component-invocation.md) | disallow curly component invocation, use angle bracket syntax instead | | | |
211212
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
212213
| [template-no-dynamic-subexpression-invocations](docs/rules/template-no-dynamic-subexpression-invocations.md) | disallow dynamic subexpression invocations | | | |
213214
| [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | |
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# ember/template-no-curly-component-invocation
2+
3+
> **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.
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Disallows curly component invocation syntax. Use angle bracket syntax instead.
8+
9+
There are two ways to invoke a component in a template: curly component syntax
10+
(`{{my-component}}`), and angle bracket syntax (`<MyComponent />`). The
11+
difference between them is syntactical. You should favour angle bracket syntax
12+
as it improves readability of templates, i.e. disambiguates components from
13+
helpers, and is also the future direction Ember is going with the Octane
14+
Edition.
15+
16+
This rule checks all the curly braces in your app and warns about those that
17+
look like they could be component invocations.
18+
19+
## Examples
20+
21+
This rule **forbids** the following:
22+
23+
```hbs
24+
{{foo-bar}}
25+
```
26+
27+
```hbs
28+
{{nested/component}}
29+
```
30+
31+
```hbs
32+
{{#foo-bar}}content{{/foo-bar}}
33+
```
34+
35+
This rule **allows** the following:
36+
37+
```hbs
38+
{{foo bar}}
39+
```
40+
41+
```hbs
42+
<FooBar />
43+
```
44+
45+
```hbs
46+
<Nested::Component />
47+
```
48+
49+
## Migration
50+
51+
- use <https://github.com/ember-codemods/ember-angle-brackets-codemod>
52+
53+
## Configuration
54+
55+
This rule accepts an options object with the following properties:
56+
57+
- `allow` (default: `[]`) - Array of component names to allow in curly syntax
58+
- `disallow` (default: `[]`) - Array of component names to disallow in curly syntax
59+
- `requireDash` (default: `false`) - Require dashes in component names
60+
- `noImplicitThis` (default: `true`) - Don't allow implicit `this` references
61+
62+
```js
63+
// .eslintrc.js
64+
module.exports = {
65+
rules: {
66+
'ember/template-no-curly-component-invocation': [
67+
'error',
68+
{
69+
allow: ['some-helper'],
70+
disallow: [],
71+
},
72+
],
73+
},
74+
};
75+
```
76+
77+
## References
78+
79+
- [Ember Guides - Angle Bracket Syntax](https://guides.emberjs.com/release/components/template-syntax/#toc_angle-bracket-syntax)
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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+
function isExplicitThisPath(pathOriginal) {
70+
return (
71+
pathOriginal === 'this' || pathOriginal.startsWith('this.') || pathOriginal.startsWith('@')
72+
);
73+
}
74+
75+
/** @type {import('eslint').Rule.RuleModule} */
76+
module.exports = {
77+
meta: {
78+
type: 'suggestion',
79+
docs: {
80+
description: 'disallow curly component invocation, use angle bracket syntax instead',
81+
category: 'Best Practices',
82+
recommended: false,
83+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-curly-component-invocation.md',
84+
templateMode: 'loose',
85+
},
86+
fixable: null,
87+
schema: [
88+
{
89+
type: 'object',
90+
properties: {
91+
allow: {
92+
type: 'array',
93+
items: { type: 'string' },
94+
},
95+
disallow: {
96+
type: 'array',
97+
items: { type: 'string' },
98+
},
99+
requireDash: {
100+
type: 'boolean',
101+
},
102+
noImplicitThis: {
103+
type: 'boolean',
104+
},
105+
},
106+
additionalProperties: false,
107+
},
108+
],
109+
messages: {},
110+
originallyFrom: {
111+
name: 'ember-template-lint',
112+
rule: 'lib/rules/no-curly-component-invocation.js',
113+
docs: 'docs/rule/no-curly-component-invocation.md',
114+
tests: 'test/unit/rules/no-curly-component-invocation-test.js',
115+
},
116+
},
117+
118+
create(context) {
119+
const config = parseConfig(context.options[0]);
120+
121+
// Stack of block-param name arrays, one entry per active GlimmerBlockStatement.
122+
const blockParamStack = [];
123+
let insideAttrNode = false;
124+
125+
function isLocalVar(name) {
126+
return blockParamStack.some((params) => params.includes(name));
127+
}
128+
129+
function reportMustache(node, pathOriginal) {
130+
const angleBracketName = transformTagName(pathOriginal);
131+
context.report({
132+
node,
133+
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}'] }\`.`,
134+
});
135+
}
136+
137+
function checkMustacheWithNamedArgs(node, pathOriginal, explicitThis) {
138+
// {{foo.bar bar=baz}} - multi-part path (not this./@ prefix) with named args
139+
if (!explicitThis && pathOriginal.includes('.')) {
140+
reportMustache(node, pathOriginal);
141+
return;
142+
}
143+
144+
if (config.allow.includes(pathOriginal)) {
145+
return;
146+
}
147+
148+
// input/textarea with hash pairs are always reported
149+
if (['input', 'textarea'].includes(pathOriginal)) {
150+
reportMustache(node, pathOriginal);
151+
return;
152+
}
153+
154+
// requireDash: skip single-word names without a dash
155+
if (config.requireDash && !pathOriginal.includes('-')) {
156+
return;
157+
}
158+
159+
// Built-in helpers with hash pairs are not reported
160+
if (BUILT_INS.has(pathOriginal)) {
161+
return;
162+
}
163+
164+
reportMustache(node, pathOriginal);
165+
}
166+
167+
function checkMustacheWithoutNamedArgs(node, pathOriginal, explicitThis, local) {
168+
// {{foo.bar}} - multi-part path (not this./@ prefix), no named args
169+
if (!explicitThis && pathOriginal.includes('.')) {
170+
if (config.noImplicitThis && !local) {
171+
reportMustache(node, pathOriginal);
172+
}
173+
return;
174+
}
175+
176+
// Explicit this.foo or @foo paths are never flagged as component invocations
177+
if (explicitThis) {
178+
return;
179+
}
180+
181+
if (config.allow.includes(pathOriginal)) {
182+
return;
183+
}
184+
185+
if (config.disallow.includes(pathOriginal) && !local) {
186+
reportMustache(node, pathOriginal);
187+
return;
188+
}
189+
190+
if (BUILT_INS.has(pathOriginal)) {
191+
return;
192+
}
193+
194+
// {{foo-bar}} or {{nested/component}}
195+
if (pathOriginal.includes('-') || pathOriginal.includes('/')) {
196+
reportMustache(node, pathOriginal);
197+
return;
198+
}
199+
200+
// {{foo}} - plain single-word name, flag when noImplicitThis is enabled
201+
if (config.noImplicitThis && !local) {
202+
reportMustache(node, pathOriginal);
203+
}
204+
}
205+
206+
return {
207+
GlimmerMustacheStatement(node) {
208+
// <Foo @bar={{baz}} /> — mustache as an attribute value; not a component invocation
209+
if (insideAttrNode) {
210+
return;
211+
}
212+
213+
if (!node.path || node.path.type !== 'GlimmerPathExpression') {
214+
return;
215+
}
216+
217+
const pathOriginal = node.path.original;
218+
219+
// Special case: link-to is always reported regardless of params
220+
if (pathOriginal === 'link-to') {
221+
reportMustache(node, pathOriginal);
222+
return;
223+
}
224+
225+
// Skip if has positional params (angle bracket syntax doesn't support positional params)
226+
if (node.params && node.params.length > 0) {
227+
return;
228+
}
229+
230+
if (ALWAYS_CURLY.has(pathOriginal)) {
231+
return;
232+
}
233+
234+
const explicitThis = isExplicitThisPath(pathOriginal);
235+
const firstPart = pathOriginal.split('.')[0];
236+
const local = isLocalVar(firstPart);
237+
238+
const hasNamedArguments = node.hash && node.hash.pairs && node.hash.pairs.length > 0;
239+
240+
if (hasNamedArguments) {
241+
checkMustacheWithNamedArgs(node, pathOriginal, explicitThis);
242+
} else {
243+
checkMustacheWithoutNamedArgs(node, pathOriginal, explicitThis, local);
244+
}
245+
},
246+
247+
GlimmerBlockStatement(node) {
248+
// Always push block params so nested mustaches can check scope.
249+
blockParamStack.push(node.program?.blockParams ?? []);
250+
251+
if (node.inverse) {
252+
// {{#foo}}bar{{else}}baz{{/foo}}
253+
return;
254+
}
255+
256+
if (!node.path || node.path.type !== 'GlimmerPathExpression') {
257+
return;
258+
}
259+
260+
const pathOriginal = node.path.original;
261+
262+
// Special case: link-to is always reported regardless of params
263+
if (pathOriginal === 'link-to') {
264+
const angleBracketName = transformTagName(pathOriginal);
265+
context.report({
266+
node,
267+
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}'] }\`.`,
268+
});
269+
return;
270+
}
271+
272+
// Skip if has positional params
273+
if (node.params && node.params.length > 0) {
274+
return;
275+
}
276+
277+
if (config.allow.includes(pathOriginal)) {
278+
return;
279+
}
280+
281+
const angleBracketName = transformTagName(pathOriginal);
282+
context.report({
283+
node,
284+
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}'] }\`.`,
285+
});
286+
},
287+
288+
'GlimmerBlockStatement:exit'() {
289+
blockParamStack.pop();
290+
},
291+
292+
GlimmerAttrNode() {
293+
insideAttrNode = true;
294+
},
295+
296+
'GlimmerAttrNode:exit'() {
297+
insideAttrNode = false;
298+
},
299+
};
300+
},
301+
};
302+
/* eslint-enable eslint-plugin/prefer-placeholders */

0 commit comments

Comments
 (0)