Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-builtin-component-arguments](docs/rules/template-builtin-component-arguments.md) | disallow setting certain attributes on builtin components | | | |
| [template-no-action-modifiers](docs/rules/template-no-action-modifiers.md) | disallow usage of {{action}} modifiers | | | |
| [template-no-action-on-submit-button](docs/rules/template-no-action-on-submit-button.md) | disallow action attribute on submit buttons | | | |
| [template-no-args-paths](docs/rules/template-no-args-paths.md) | disallow args.foo paths in templates, use @foo instead | | 🔧 | |
| [template-no-arguments-for-html-elements](docs/rules/template-no-arguments-for-html-elements.md) | disallow @arguments on HTML elements | | | |
| [template-no-array-prototype-extensions](docs/rules/template-no-array-prototype-extensions.md) | disallow usage of Ember Array prototype extensions | | | |
| [template-no-at-ember-render-modifiers](docs/rules/template-no-at-ember-render-modifiers.md) | disallow usage of @ember/render-modifiers | | | |
Expand Down
80 changes: 80 additions & 0 deletions docs/rules/template-no-args-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# ember/template-no-args-paths

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->

Arguments that are passed to components are prefixed with the `@` symbol in Angle bracket syntax.
Ember Octane leverages this in the component's templates by allowing users to directly refer to an argument using the same prefix:

```gjs
<template>
<!-- todo-list.hbs -->
<ul>
{{#each @todos as |todo index|}}
<li>
{{yield (todo-item-component todo=todo) index}}
</li>
{{/each}}
</ul>
</template>
```

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.

## Examples

This rule **forbids** the following:

```gjs
<template>
{{this.args.foo}}
{{args.foo}}
</template>
```

```gjs
<template>
{{my-helper this.args.foo}}
{{my-helper (hash value=this.args.foo)}}
</template>
```

```gjs
<template>
<MyComponent @value={{this.args.foo}} />
<div {{my-modifier this.args.foo}}></div>
</template>
```

This rule **allows** the following:

```gjs
<template>
{{my-helper this.args}}
{{my-helper (hash value=this.args)}}
</template>
```

```gjs
<template>
{{@foo}}
<MyComponent @value={{@foo}} />
<div {{my-modifier @foo}}></div>
</template>
```

## Migration

- find in templates `this.args.` replace to `@`

## Related Rules

- [no-curly-component-invocation](no-curly-component-invocation.md)

## References

- [RFC #276](https://github.com/emberjs/rfcs/blob/master/text/0276-named-args.md)
- [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)
- [Named arguments in Ember.js](https://www.balinterdi.com/blog/named-arguments-in-ember-js/)
- [ember-named-arguments-polyfill](https://github.com/rwjblue/ember-named-arguments-polyfill)
97 changes: 97 additions & 0 deletions lib/rules/template-no-args-paths.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow args.foo paths in templates, use @foo instead',
category: 'Best Practices',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-args-paths.md',
templateMode: 'both',
},
fixable: 'code',
schema: [],
messages: {
argsPath:
'Component templates should avoid "{{path}}" usage, try "@{{replacement}}" instead.',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-args-paths.js',
docs: 'docs/rule/no-args-paths.md',
tests: 'test/unit/rules/no-args-paths-test.js',
},
},
create(context) {
const localScopes = [];

function pushLocals(params) {
localScopes.push(new Set(params || []));
}

function popLocals() {
localScopes.pop();
}

function isLocal(name) {
for (const scope of localScopes) {
if (scope.has(name)) {
return true;
}
}
return false;
}

return {
GlimmerBlockStatement(node) {
if (node.program && node.program.blockParams) {
pushLocals(node.program.blockParams);
}
},
'GlimmerBlockStatement:exit'(node) {
if (node.program && node.program.blockParams) {
popLocals();
}
},

GlimmerElementNode(node) {
if (node.blockParams && node.blockParams.length > 0) {
pushLocals(node.blockParams);
}
},
'GlimmerElementNode:exit'(node) {
if (node.blockParams && node.blockParams.length > 0) {
popLocals();
}
},

GlimmerPathExpression(node) {
const path = node.original;

// @args.foo is a valid named argument — skip paths starting with @
if (node.head?.type === 'AtHead') {
return;
}

if (!path?.startsWith('args.') && !path?.startsWith('this.args.')) {
return;
}

// Skip when 'args' is a block param in the current scope
if (isLocal('args')) {
return;
}

const replacement = path.replace(/^(this\.)?args\./, '');

context.report({
node,
messageId: 'argsPath',
data: { path, replacement },
fix(fixer) {
return fixer.replaceText(node, `@${replacement}`);
},
});
},
};
},
};
133 changes: 133 additions & 0 deletions tests/lib/rules/template-no-args-paths.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
const rule = require('../../../lib/rules/template-no-args-paths');
const RuleTester = require('eslint').RuleTester;

const ruleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});
ruleTester.run('template-no-args-paths', rule, {
valid: [
'<template>{{@foo}}</template>',
// @args.foo is a valid named argument, not a path violation
'<template>{{@args.foo}}</template>',
'<template><div @foo={{cleanup this.args}}></div></template>',
'<template>{{foo (name this.args)}}</template>',
'<template>{{foo name=this.args}}</template>',
'<template>{{foo name=(extract this.args)}}</template>',
'<template><Foo @params={{this.args}} /></template>',
'<template><Foo {{mod this.args}} /></template>',
'<template><Foo {{mod items=this.args}} /></template>',
'<template><Foo {{mod items=(extract this.args)}} /></template>',
// args as a block param is not flagged
'<template>{{#each items as |args|}}{{args.name}}{{/each}}</template>',
],
invalid: [
{
code: '<template>{{hello (format value=args.foo)}}</template>',
output: '<template>{{hello (format value=@foo)}}</template>',
errors: [{ messageId: 'argsPath' }],
},
{
code: '<template>{{hello value=args.foo}}</template>',
output: '<template>{{hello value=@foo}}</template>',
errors: [{ messageId: 'argsPath' }],
},
{
code: '<template>{{hello (format args.foo.bar)}}</template>',
output: '<template>{{hello (format @foo.bar)}}</template>',
errors: [{ messageId: 'argsPath' }],
},
{
code: '<template><br {{hello args.foo.bar}}></template>',
output: '<template><br {{hello @foo.bar}}></template>',
errors: [{ messageId: 'argsPath' }],
},
{
code: '<template>{{hello args.foo.bar}}</template>',
output: '<template>{{hello @foo.bar}}</template>',
errors: [{ messageId: 'argsPath' }],
},
{
code: '<template>{{args.foo.bar}}</template>',
output: '<template>{{@foo.bar}}</template>',
errors: [{ messageId: 'argsPath' }],
},
{
code: '<template>{{args.foo}}</template>',
output: '<template>{{@foo}}</template>',
errors: [{ messageId: 'argsPath' }],
},
{
code: '<template>{{this.args.foo}}</template>',
output: '<template>{{@foo}}</template>',
errors: [{ messageId: 'argsPath' }],
},
],
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});

hbsRuleTester.run('template-no-args-paths', rule, {
valid: [
// @args.foo is a valid named argument
'{{@args.foo}}',
'<div @foo={{cleanup this.args}}></div>',
'{{foo (name this.args)}}',
'{{foo name=this.args}}',
'{{foo name=(extract this.args)}}',
'<Foo @params={{this.args}} />',
'<Foo {{mod this.args}} />',
'<Foo {{mod items=this.args}} />',
'<Foo {{mod items=(extract this.args)}} />',
// args as a block param is not flagged
'{{#each items as |args|}}{{args.name}}{{/each}}',
],
invalid: [
{
code: '{{hello (format value=args.foo)}}',
output: '{{hello (format value=@foo)}}',
errors: [{ messageId: 'argsPath' }],
},
{
code: '{{hello value=args.foo}}',
output: '{{hello value=@foo}}',
errors: [{ messageId: 'argsPath' }],
},
{
code: '{{hello (format args.foo.bar)}}',
output: '{{hello (format @foo.bar)}}',
errors: [{ messageId: 'argsPath' }],
},
{
code: '<br {{hello args.foo.bar}}>',
output: '<br {{hello @foo.bar}}>',
errors: [{ messageId: 'argsPath' }],
},
{
code: '{{hello args.foo.bar}}',
output: '{{hello @foo.bar}}',
errors: [{ messageId: 'argsPath' }],
},
{
code: '{{args.foo.bar}}',
output: '{{@foo.bar}}',
errors: [{ messageId: 'argsPath' }],
},
{
code: '{{args.foo}}',
output: '{{@foo}}',
errors: [{ messageId: 'argsPath' }],
},
{
code: '{{this.args.foo}}',
output: '{{@foo}}',
errors: [{ messageId: 'argsPath' }],
},
],
});
Loading