From c7c01777c03fe486d311974cd3afbae1d76fef58 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:29:46 -0400 Subject: [PATCH] Extract rule: template-no-index-component-invocation --- README.md | 1 + .../template-no-index-component-invocation.md | 61 +++++++ .../template-no-index-component-invocation.js | 104 +++++++++++ .../template-no-index-component-invocation.js | 168 ++++++++++++++++++ 4 files changed, 334 insertions(+) create mode 100644 docs/rules/template-no-index-component-invocation.md create mode 100644 lib/rules/template-no-index-component-invocation.js create mode 100644 tests/lib/rules/template-no-index-component-invocation.js diff --git a/README.md b/README.md index 5de51a02ff..de30c75deb 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [template-no-dynamic-subexpression-invocations](docs/rules/template-no-dynamic-subexpression-invocations.md) | disallow dynamic subexpression invocations | | | | | [template-no-element-event-actions](docs/rules/template-no-element-event-actions.md) | disallow element event actions (use {{on}} modifier instead) | | | | | [template-no-implicit-this](docs/rules/template-no-implicit-this.md) | require explicit `this` in property access | | | | +| [template-no-index-component-invocation](docs/rules/template-no-index-component-invocation.md) | disallow index component invocations | | | | | [template-no-inline-event-handlers](docs/rules/template-no-inline-event-handlers.md) | disallow DOM event handler attributes | | | | | [template-no-inline-styles](docs/rules/template-no-inline-styles.md) | disallow inline styles | | | | | [template-no-input-block](docs/rules/template-no-input-block.md) | disallow block usage of {{input}} helper | | | | diff --git a/docs/rules/template-no-index-component-invocation.md b/docs/rules/template-no-index-component-invocation.md new file mode 100644 index 0000000000..88558f8139 --- /dev/null +++ b/docs/rules/template-no-index-component-invocation.md @@ -0,0 +1,61 @@ +# ember/template-no-index-component-invocation + + + +Disallows invoking components using an explicit `/index` or `::Index` suffix. + +Components and Component Templates can be structured as `app/components/foo-bar/index.js` and +`app/components/foo-bar/index.hbs`. This allows additional files related to the +component (such as a `README.md` file) to be co-located on the filesystem. + +For template-only components, they can be either `app/components/foo-bar.hbs` +or `app/components/foo-bar/index.hbs` without a corresponding JavaScript file. + +Similarly, for addons, templates can be placed inside `addon/components` with +the same rules laid out above. + +In all of these case, if a template file is present in `app/components` or +`addon/components`, it will take precedence over any corresponding template +files in `app/templates`, the `layout` property on classic components, or a +template with the same name that is made available with the resolver API. +Instead of being resolved at runtime, a template in `app/components` will be +associated with the component's JavaScript class at build time. + +## Examples + +This rule **forbids** the following: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +This rule **allows** the following: + +```gjs + +``` + +```gjs + +``` + +```gjs + +``` + +## Migration + +- replace all `::Index>` to `>` +- replace all `/index}}` to `}}` + +## References + +- [RFC #481](https://github.com/emberjs/rfcs/blob/master/text/0481-component-templates-co-location.md#high-level-design) diff --git a/lib/rules/template-no-index-component-invocation.js b/lib/rules/template-no-index-component-invocation.js new file mode 100644 index 0000000000..a3f71e98ec --- /dev/null +++ b/lib/rules/template-no-index-component-invocation.js @@ -0,0 +1,104 @@ +/* eslint-disable complexity, eslint-plugin/prefer-placeholders, unicorn/explicit-length-check */ +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'disallow index component invocations', + category: 'Best Practices', + recommended: false, + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-index-component-invocation.md', + templateMode: 'both', + }, + fixable: null, + schema: [], + messages: {}, + originallyFrom: { + name: 'ember-template-lint', + rule: 'lib/rules/no-index-component-invocation.js', + docs: 'docs/rule/no-index-component-invocation.md', + tests: 'test/unit/rules/no-index-component-invocation-test.js', + }, + }, + + create(context) { + function lintIndexUsage(node) { + // Handle angle bracket components: + if (node.type === 'GlimmerElementNode') { + if (node.tag && node.tag.endsWith('::Index')) { + const invocation = `<${node.tag}`; + const replacement = `<${node.tag.replace('::Index', '')}`; + + context.report({ + node, + message: `Replace \`${invocation} ...\` to \`${replacement} ...\``, + }); + } + return; + } + + // Handle mustache and block statements: {{foo/index}} or {{#foo/index}} + if (node.type === 'GlimmerMustacheStatement' || node.type === 'GlimmerBlockStatement') { + if ( + node.path && + node.path.type === 'GlimmerPathExpression' && + node.path.original && + node.path.original.endsWith('/index') + ) { + const invocationPrefix = node.type === 'GlimmerBlockStatement' ? '{{#' : '{{'; + const invocation = `${invocationPrefix}${node.path.original}`; + const replacement = `${invocationPrefix}${node.path.original.replace('/index', '')}`; + + context.report({ + node: node.path, + message: `Replace \`${invocation} ...\` to \`${replacement} ...\``, + }); + return; + } + } + + // Handle component helper: {{component "foo/index"}} or (component "foo/index") + if ( + node.type === 'GlimmerMustacheStatement' || + node.type === 'GlimmerBlockStatement' || + node.type === 'GlimmerSubExpression' + ) { + const prefix = + node.type === 'GlimmerMustacheStatement' + ? '{{' + : node.type === 'GlimmerBlockStatement' + ? '{{#' + : '('; + + if ( + node.path && + node.path.type === 'GlimmerPathExpression' && + node.path.original === 'component' && + node.params && + node.params.length > 0 && + node.params[0].type === 'GlimmerStringLiteral' + ) { + const componentName = node.params[0].value; + + if (componentName.endsWith('/index')) { + const invocation = `${prefix}component "${componentName}"`; + const replacement = `${prefix}component "${componentName.replace('/index', '')}"`; + + context.report({ + node: node.params[0], + message: `Replace \`${invocation} ...\` to \`${replacement} ...\``, + }); + } + } + } + } + + return { + GlimmerElementNode: lintIndexUsage, + GlimmerMustacheStatement: lintIndexUsage, + GlimmerBlockStatement: lintIndexUsage, + GlimmerSubExpression: lintIndexUsage, + }; + }, +}; +/* eslint-enable complexity, eslint-plugin/prefer-placeholders, unicorn/explicit-length-check */ diff --git a/tests/lib/rules/template-no-index-component-invocation.js b/tests/lib/rules/template-no-index-component-invocation.js new file mode 100644 index 0000000000..f658d41093 --- /dev/null +++ b/tests/lib/rules/template-no-index-component-invocation.js @@ -0,0 +1,168 @@ +const rule = require('../../../lib/rules/template-no-index-component-invocation'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-no-index-component-invocation', rule, { + valid: [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ], + invalid: [ + { + code: '', + output: null, + errors: [ + { + message: 'Replace `{{foo/index ...` to `{{foo ...`', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: 'Replace `{{component "foo/index" ...` to `{{component "foo" ...`', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: 'Replace `{{#foo/index ...` to `{{#foo ...`', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: 'Replace `{{#component "foo/index" ...` to `{{#component "foo" ...`', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: 'Replace `', + output: null, + errors: [ + { + message: 'Replace `{{foo/bar (component "foo/index")}}', + output: null, + errors: [{ message: 'Replace `(component "foo/index" ...` to `(component "foo" ...`' }], + }, + { + code: '', + output: null, + errors: [{ message: 'Replace `(component "foo/index" ...` to `(component "foo" ...`' }], + }, + { + code: '', + output: null, + errors: [{ message: 'Replace `', + '', + '', + '', + '{{foo/index-item}}', + '{{foo/my-index}}', + '{{foo/bar}}', + '{{#foo/bar}}{{/foo/bar}}', + '{{component "foo/bar"}}', + '{{component "foo/my-index"}}', + '{{component "foo/index-item"}}', + '{{#component "foo/index-item"}}{{/component}}', + ], + invalid: [ + { + code: '{{foo/index}}', + output: null, + errors: [{ message: 'Replace `{{foo/index ...` to `{{foo ...`' }], + }, + { + code: '{{component "foo/index"}}', + output: null, + errors: [{ message: 'Replace `{{component "foo/index" ...` to `{{component "foo" ...`' }], + }, + { + code: '{{#foo/index}}{{/foo/index}}', + output: null, + errors: [{ message: 'Replace `{{#foo/index ...` to `{{#foo ...`' }], + }, + { + code: '{{#component "foo/index"}}{{/component}}', + output: null, + errors: [{ message: 'Replace `{{#component "foo/index" ...` to `{{#component "foo" ...`' }], + }, + { + code: '{{foo/bar (component "foo/index")}}', + output: null, + errors: [{ message: 'Replace `(component "foo/index" ...` to `(component "foo" ...`' }], + }, + { + code: '{{foo/bar name=(component "foo/index")}}', + output: null, + errors: [{ message: 'Replace `(component "foo/index" ...` to `(component "foo" ...`' }], + }, + { + code: '', + output: null, + errors: [{ message: 'Replace `', + output: null, + errors: [{ message: 'Replace `', + output: null, + errors: [{ message: 'Replace `