Skip to content

Commit 589f47e

Browse files
Merge pull request #2455 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-args-paths
Extract rule: template-no-args-paths
2 parents cdb0f91 + d9befc8 commit 589f47e

4 files changed

Lines changed: 311 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ rules in templates can be disabled with eslint directives with mustache or html
202202
| [template-builtin-component-arguments](docs/rules/template-builtin-component-arguments.md) | disallow setting certain attributes on builtin components | | | |
203203
| [template-no-action-modifiers](docs/rules/template-no-action-modifiers.md) | disallow usage of {{action}} modifiers | | | |
204204
| [template-no-action-on-submit-button](docs/rules/template-no-action-on-submit-button.md) | disallow action attribute on submit buttons | | | |
205+
| [template-no-args-paths](docs/rules/template-no-args-paths.md) | disallow args.foo paths in templates, use @foo instead | | 🔧 | |
205206
| [template-no-arguments-for-html-elements](docs/rules/template-no-arguments-for-html-elements.md) | disallow @arguments on HTML elements | | | |
206207
| [template-no-array-prototype-extensions](docs/rules/template-no-array-prototype-extensions.md) | disallow usage of Ember Array prototype extensions | | | |
207208
| [template-no-at-ember-render-modifiers](docs/rules/template-no-at-ember-render-modifiers.md) | disallow usage of @ember/render-modifiers | | | |
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# ember/template-no-args-paths
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+
<!-- end auto-generated rule header -->
6+
7+
Arguments that are passed to components are prefixed with the `@` symbol in Angle bracket syntax.
8+
Ember Octane leverages this in the component's templates by allowing users to directly refer to an argument using the same prefix:
9+
10+
```gjs
11+
<template>
12+
<!-- todo-list.hbs -->
13+
<ul>
14+
{{#each @todos as |todo index|}}
15+
<li>
16+
{{yield (todo-item-component todo=todo) index}}
17+
</li>
18+
{{/each}}
19+
</ul>
20+
</template>
21+
```
22+
23+
We can immediately tell now by looking at this template that `@todos` is an argument that was passed to the component externally. This is in fact _always true_ - there is no way to modify the value referenced by `@todos` from the component class, it is the original, unmodified value.
24+
25+
## Examples
26+
27+
This rule **forbids** the following:
28+
29+
```gjs
30+
<template>
31+
{{this.args.foo}}
32+
{{args.foo}}
33+
</template>
34+
```
35+
36+
```gjs
37+
<template>
38+
{{my-helper this.args.foo}}
39+
{{my-helper (hash value=this.args.foo)}}
40+
</template>
41+
```
42+
43+
```gjs
44+
<template>
45+
<MyComponent @value={{this.args.foo}} />
46+
<div {{my-modifier this.args.foo}}></div>
47+
</template>
48+
```
49+
50+
This rule **allows** the following:
51+
52+
```gjs
53+
<template>
54+
{{my-helper this.args}}
55+
{{my-helper (hash value=this.args)}}
56+
</template>
57+
```
58+
59+
```gjs
60+
<template>
61+
{{@foo}}
62+
<MyComponent @value={{@foo}} />
63+
<div {{my-modifier @foo}}></div>
64+
</template>
65+
```
66+
67+
## Migration
68+
69+
- find in templates `this.args.` replace to `@`
70+
71+
## Related Rules
72+
73+
- [no-curly-component-invocation](no-curly-component-invocation.md)
74+
75+
## References
76+
77+
- [RFC #276](https://github.com/emberjs/rfcs/blob/master/text/0276-named-args.md)
78+
- [Coming Soon in Ember Octane - Part 2: Named Argument Syntax](https://www.pzuraq.com/blog/coming-soon-in-ember-octane-part-2-angle-brackets-and-named-arguments/#namedargumentsyntax)
79+
- [Named arguments in Ember.js](https://www.balinterdi.com/blog/named-arguments-in-ember-js/)
80+
- [ember-named-arguments-polyfill](https://github.com/rwjblue/ember-named-arguments-polyfill)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/** @type {import('eslint').Rule.RuleModule} */
2+
module.exports = {
3+
meta: {
4+
type: 'problem',
5+
docs: {
6+
description: 'disallow args.foo paths in templates, use @foo instead',
7+
category: 'Best Practices',
8+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-args-paths.md',
9+
templateMode: 'both',
10+
},
11+
fixable: 'code',
12+
schema: [],
13+
messages: {
14+
argsPath:
15+
'Component templates should avoid "{{path}}" usage, try "@{{replacement}}" instead.',
16+
},
17+
originallyFrom: {
18+
name: 'ember-template-lint',
19+
rule: 'lib/rules/no-args-paths.js',
20+
docs: 'docs/rule/no-args-paths.md',
21+
tests: 'test/unit/rules/no-args-paths-test.js',
22+
},
23+
},
24+
create(context) {
25+
const localScopes = [];
26+
27+
function pushLocals(params) {
28+
localScopes.push(new Set(params || []));
29+
}
30+
31+
function popLocals() {
32+
localScopes.pop();
33+
}
34+
35+
function isLocal(name) {
36+
for (const scope of localScopes) {
37+
if (scope.has(name)) {
38+
return true;
39+
}
40+
}
41+
return false;
42+
}
43+
44+
return {
45+
GlimmerBlockStatement(node) {
46+
if (node.program && node.program.blockParams) {
47+
pushLocals(node.program.blockParams);
48+
}
49+
},
50+
'GlimmerBlockStatement:exit'(node) {
51+
if (node.program && node.program.blockParams) {
52+
popLocals();
53+
}
54+
},
55+
56+
GlimmerElementNode(node) {
57+
if (node.blockParams && node.blockParams.length > 0) {
58+
pushLocals(node.blockParams);
59+
}
60+
},
61+
'GlimmerElementNode:exit'(node) {
62+
if (node.blockParams && node.blockParams.length > 0) {
63+
popLocals();
64+
}
65+
},
66+
67+
GlimmerPathExpression(node) {
68+
const path = node.original;
69+
70+
// @args.foo is a valid named argument — skip paths starting with @
71+
if (node.head?.type === 'AtHead') {
72+
return;
73+
}
74+
75+
if (!path?.startsWith('args.') && !path?.startsWith('this.args.')) {
76+
return;
77+
}
78+
79+
// Skip when 'args' is a block param in the current scope
80+
if (isLocal('args')) {
81+
return;
82+
}
83+
84+
const replacement = path.replace(/^(this\.)?args\./, '');
85+
86+
context.report({
87+
node,
88+
messageId: 'argsPath',
89+
data: { path, replacement },
90+
fix(fixer) {
91+
return fixer.replaceText(node, `@${replacement}`);
92+
},
93+
});
94+
},
95+
};
96+
},
97+
};
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
const rule = require('../../../lib/rules/template-no-args-paths');
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+
ruleTester.run('template-no-args-paths', rule, {
9+
valid: [
10+
'<template>{{@foo}}</template>',
11+
// @args.foo is a valid named argument, not a path violation
12+
'<template>{{@args.foo}}</template>',
13+
'<template><div @foo={{cleanup this.args}}></div></template>',
14+
'<template>{{foo (name this.args)}}</template>',
15+
'<template>{{foo name=this.args}}</template>',
16+
'<template>{{foo name=(extract this.args)}}</template>',
17+
'<template><Foo @params={{this.args}} /></template>',
18+
'<template><Foo {{mod this.args}} /></template>',
19+
'<template><Foo {{mod items=this.args}} /></template>',
20+
'<template><Foo {{mod items=(extract this.args)}} /></template>',
21+
// args as a block param is not flagged
22+
'<template>{{#each items as |args|}}{{args.name}}{{/each}}</template>',
23+
],
24+
invalid: [
25+
{
26+
code: '<template>{{hello (format value=args.foo)}}</template>',
27+
output: '<template>{{hello (format value=@foo)}}</template>',
28+
errors: [{ messageId: 'argsPath' }],
29+
},
30+
{
31+
code: '<template>{{hello value=args.foo}}</template>',
32+
output: '<template>{{hello value=@foo}}</template>',
33+
errors: [{ messageId: 'argsPath' }],
34+
},
35+
{
36+
code: '<template>{{hello (format args.foo.bar)}}</template>',
37+
output: '<template>{{hello (format @foo.bar)}}</template>',
38+
errors: [{ messageId: 'argsPath' }],
39+
},
40+
{
41+
code: '<template><br {{hello args.foo.bar}}></template>',
42+
output: '<template><br {{hello @foo.bar}}></template>',
43+
errors: [{ messageId: 'argsPath' }],
44+
},
45+
{
46+
code: '<template>{{hello args.foo.bar}}</template>',
47+
output: '<template>{{hello @foo.bar}}</template>',
48+
errors: [{ messageId: 'argsPath' }],
49+
},
50+
{
51+
code: '<template>{{args.foo.bar}}</template>',
52+
output: '<template>{{@foo.bar}}</template>',
53+
errors: [{ messageId: 'argsPath' }],
54+
},
55+
{
56+
code: '<template>{{args.foo}}</template>',
57+
output: '<template>{{@foo}}</template>',
58+
errors: [{ messageId: 'argsPath' }],
59+
},
60+
{
61+
code: '<template>{{this.args.foo}}</template>',
62+
output: '<template>{{@foo}}</template>',
63+
errors: [{ messageId: 'argsPath' }],
64+
},
65+
],
66+
});
67+
68+
const hbsRuleTester = new RuleTester({
69+
parser: require.resolve('ember-eslint-parser/hbs'),
70+
parserOptions: {
71+
ecmaVersion: 2022,
72+
sourceType: 'module',
73+
},
74+
});
75+
76+
hbsRuleTester.run('template-no-args-paths', rule, {
77+
valid: [
78+
// @args.foo is a valid named argument
79+
'{{@args.foo}}',
80+
'<div @foo={{cleanup this.args}}></div>',
81+
'{{foo (name this.args)}}',
82+
'{{foo name=this.args}}',
83+
'{{foo name=(extract this.args)}}',
84+
'<Foo @params={{this.args}} />',
85+
'<Foo {{mod this.args}} />',
86+
'<Foo {{mod items=this.args}} />',
87+
'<Foo {{mod items=(extract this.args)}} />',
88+
// args as a block param is not flagged
89+
'{{#each items as |args|}}{{args.name}}{{/each}}',
90+
],
91+
invalid: [
92+
{
93+
code: '{{hello (format value=args.foo)}}',
94+
output: '{{hello (format value=@foo)}}',
95+
errors: [{ messageId: 'argsPath' }],
96+
},
97+
{
98+
code: '{{hello value=args.foo}}',
99+
output: '{{hello value=@foo}}',
100+
errors: [{ messageId: 'argsPath' }],
101+
},
102+
{
103+
code: '{{hello (format args.foo.bar)}}',
104+
output: '{{hello (format @foo.bar)}}',
105+
errors: [{ messageId: 'argsPath' }],
106+
},
107+
{
108+
code: '<br {{hello args.foo.bar}}>',
109+
output: '<br {{hello @foo.bar}}>',
110+
errors: [{ messageId: 'argsPath' }],
111+
},
112+
{
113+
code: '{{hello args.foo.bar}}',
114+
output: '{{hello @foo.bar}}',
115+
errors: [{ messageId: 'argsPath' }],
116+
},
117+
{
118+
code: '{{args.foo.bar}}',
119+
output: '{{@foo.bar}}',
120+
errors: [{ messageId: 'argsPath' }],
121+
},
122+
{
123+
code: '{{args.foo}}',
124+
output: '{{@foo}}',
125+
errors: [{ messageId: 'argsPath' }],
126+
},
127+
{
128+
code: '{{this.args.foo}}',
129+
output: '{{@foo}}',
130+
errors: [{ messageId: 'argsPath' }],
131+
},
132+
],
133+
});

0 commit comments

Comments
 (0)