diff --git a/README.md b/README.md index b695f7a52e..c2924ba272 100644 --- a/README.md +++ b/README.md @@ -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 | | | | diff --git a/docs/rules/template-no-args-paths.md b/docs/rules/template-no-args-paths.md new file mode 100644 index 0000000000..c8753d8e4e --- /dev/null +++ b/docs/rules/template-no-args-paths.md @@ -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). + + + +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 + +``` + +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 + +``` + +```gjs + +``` + +```gjs + +``` + +This rule **allows** the following: + +```gjs + +``` + +```gjs + +``` + +## 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) diff --git a/lib/rules/template-no-args-paths.js b/lib/rules/template-no-args-paths.js new file mode 100644 index 0000000000..e687bcb112 --- /dev/null +++ b/lib/rules/template-no-args-paths.js @@ -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}`); + }, + }); + }, + }; + }, +}; diff --git a/tests/lib/rules/template-no-args-paths.js b/tests/lib/rules/template-no-args-paths.js new file mode 100644 index 0000000000..649468c2f9 --- /dev/null +++ b/tests/lib/rules/template-no-args-paths.js @@ -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: [ + '', + // @args.foo is a valid named argument, not a path violation + '', + '', + '', + '', + '', + '', + '', + '', + '', + // args as a block param is not flagged + '', + ], + invalid: [ + { + code: '', + output: '', + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: '', + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: '', + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: '', + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: '', + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: '', + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: '', + errors: [{ messageId: 'argsPath' }], + }, + { + code: '', + output: '', + 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}}', + '
', + '{{foo (name this.args)}}', + '{{foo name=this.args}}', + '{{foo name=(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: '
', + output: '
', + 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' }], + }, + ], +});