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 @@ -208,6 +208,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-capital-arguments](docs/rules/template-no-capital-arguments.md) | disallow capital arguments (use lowercase @arg instead of @Arg) | | | |
| [template-no-chained-this](docs/rules/template-no-chained-this.md) | disallow redundant `this.this` in templates | | 🔧 | |
| [template-no-class-bindings](docs/rules/template-no-class-bindings.md) | disallow passing classBinding or classNameBindings as arguments in templates | | | |
| [template-no-curly-component-invocation](docs/rules/template-no-curly-component-invocation.md) | disallow curly component invocation, use angle bracket syntax instead | | | |
| [template-no-debugger](docs/rules/template-no-debugger.md) | disallow {{debugger}} in templates | | | |
| [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) | | | |
Expand Down
79 changes: 79 additions & 0 deletions docs/rules/template-no-curly-component-invocation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# ember/template-no-curly-component-invocation

> **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.

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

Disallows curly component invocation syntax. Use angle bracket syntax instead.

There are two ways to invoke a component in a template: curly component syntax
(`{{my-component}}`), and angle bracket syntax (`<MyComponent />`). The
difference between them is syntactical. You should favour angle bracket syntax
as it improves readability of templates, i.e. disambiguates components from
helpers, and is also the future direction Ember is going with the Octane
Edition.

This rule checks all the curly braces in your app and warns about those that
look like they could be component invocations.

## Examples

This rule **forbids** the following:

```hbs
{{foo-bar}}
```

```hbs
{{nested/component}}
```

```hbs
{{#foo-bar}}content{{/foo-bar}}
```

This rule **allows** the following:

```hbs
{{foo bar}}
```

```hbs
<FooBar />
```

```hbs
<Nested::Component />
```

## Migration

- use <https://github.com/ember-codemods/ember-angle-brackets-codemod>

## Configuration

This rule accepts an options object with the following properties:

- `allow` (default: `[]`) - Array of component names to allow in curly syntax
- `disallow` (default: `[]`) - Array of component names to disallow in curly syntax
- `requireDash` (default: `false`) - Require dashes in component names
- `noImplicitThis` (default: `true`) - Don't allow implicit `this` references

```js
// .eslintrc.js
module.exports = {
rules: {
'ember/template-no-curly-component-invocation': [
'error',
{
allow: ['some-helper'],
disallow: [],
},
],
},
};
```

## References

- [Ember Guides - Angle Bracket Syntax](https://guides.emberjs.com/release/components/template-syntax/#toc_angle-bracket-syntax)
302 changes: 302 additions & 0 deletions lib/rules/template-no-curly-component-invocation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
/* eslint-disable eslint-plugin/prefer-placeholders */
const BUILT_INS = new Set([
'action',
'array',
'component',
'concat',
'debugger',
'each',
'each-in',
'fn',
'get',
'hasBlock',
'has-block',
'has-block-params',
'hash',
'if',
'input',
'let',
'link-to',
'loc',
'log',
'mount',
'mut',
'on',
'outlet',
'partial',
'query-params',
'textarea',
'unbound',
'unique-id',
'unless',
'with',
'-in-element',
'in-element',
'app-version',
'rootURL',
]);

const ALWAYS_CURLY = new Set(['yield']);

function transformTagName(name) {
// Convert kebab-case to PascalCase for angle bracket syntax
const parts = name.split('/');
return parts
.map((part) => {
return part
.split('-')
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
.join('');
})
.join('::');
}

function parseConfig(config) {
const defaults = {
allow: [],
disallow: [],
requireDash: false,
noImplicitThis: true,
};

if (config === true) {
return defaults;
}

return { ...defaults, ...config };
}

function isExplicitThisPath(pathOriginal) {
return (
pathOriginal === 'this' || pathOriginal.startsWith('this.') || pathOriginal.startsWith('@')
);
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow curly component invocation, use angle bracket syntax instead',
category: 'Best Practices',
recommended: false,
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-curly-component-invocation.md',
templateMode: 'loose',
},
fixable: null,
schema: [
{
type: 'object',
properties: {
allow: {
type: 'array',
items: { type: 'string' },
},
disallow: {
type: 'array',
items: { type: 'string' },
},
requireDash: {
type: 'boolean',
},
noImplicitThis: {
type: 'boolean',
},
},
additionalProperties: false,
},
],
messages: {},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-curly-component-invocation.js',
docs: 'docs/rule/no-curly-component-invocation.md',
tests: 'test/unit/rules/no-curly-component-invocation-test.js',
},
},

create(context) {
const config = parseConfig(context.options[0]);

// Stack of block-param name arrays, one entry per active GlimmerBlockStatement.
const blockParamStack = [];
let insideAttrNode = false;

function isLocalVar(name) {
return blockParamStack.some((params) => params.includes(name));
}

function reportMustache(node, pathOriginal) {
const angleBracketName = transformTagName(pathOriginal);
context.report({
node,
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}'] }\`.`,
});
}

function checkMustacheWithNamedArgs(node, pathOriginal, explicitThis) {
// {{foo.bar bar=baz}} - multi-part path (not this./@ prefix) with named args
if (!explicitThis && pathOriginal.includes('.')) {
reportMustache(node, pathOriginal);
return;
}

if (config.allow.includes(pathOriginal)) {
return;
}

// input/textarea with hash pairs are always reported
if (['input', 'textarea'].includes(pathOriginal)) {
reportMustache(node, pathOriginal);
return;
}

// requireDash: skip single-word names without a dash
if (config.requireDash && !pathOriginal.includes('-')) {
return;
}

// Built-in helpers with hash pairs are not reported
if (BUILT_INS.has(pathOriginal)) {
return;
}

reportMustache(node, pathOriginal);
}

function checkMustacheWithoutNamedArgs(node, pathOriginal, explicitThis, local) {
// {{foo.bar}} - multi-part path (not this./@ prefix), no named args
if (!explicitThis && pathOriginal.includes('.')) {
if (config.noImplicitThis && !local) {
reportMustache(node, pathOriginal);
}
return;
}

// Explicit this.foo or @foo paths are never flagged as component invocations
if (explicitThis) {
return;
}

if (config.allow.includes(pathOriginal)) {
return;
}

if (config.disallow.includes(pathOriginal) && !local) {
reportMustache(node, pathOriginal);
return;
}

if (BUILT_INS.has(pathOriginal)) {
return;
}

// {{foo-bar}} or {{nested/component}}
if (pathOriginal.includes('-') || pathOriginal.includes('/')) {
reportMustache(node, pathOriginal);
return;
}

// {{foo}} - plain single-word name, flag when noImplicitThis is enabled
if (config.noImplicitThis && !local) {
reportMustache(node, pathOriginal);
}
}

return {
GlimmerMustacheStatement(node) {
// <Foo @bar={{baz}} /> — mustache as an attribute value; not a component invocation
if (insideAttrNode) {
return;
}

if (!node.path || node.path.type !== 'GlimmerPathExpression') {
return;
}

const pathOriginal = node.path.original;

// Special case: link-to is always reported regardless of params
if (pathOriginal === 'link-to') {
reportMustache(node, pathOriginal);
return;
}

// Skip if has positional params (angle bracket syntax doesn't support positional params)
if (node.params && node.params.length > 0) {
return;
}

if (ALWAYS_CURLY.has(pathOriginal)) {
return;
}

const explicitThis = isExplicitThisPath(pathOriginal);
const firstPart = pathOriginal.split('.')[0];
const local = isLocalVar(firstPart);

const hasNamedArguments = node.hash && node.hash.pairs && node.hash.pairs.length > 0;

if (hasNamedArguments) {
checkMustacheWithNamedArgs(node, pathOriginal, explicitThis);
} else {
checkMustacheWithoutNamedArgs(node, pathOriginal, explicitThis, local);
}
},

GlimmerBlockStatement(node) {
// Always push block params so nested mustaches can check scope.
blockParamStack.push(node.program?.blockParams ?? []);

if (node.inverse) {
// {{#foo}}bar{{else}}baz{{/foo}}
return;
}

if (!node.path || node.path.type !== 'GlimmerPathExpression') {
return;
}

const pathOriginal = node.path.original;

// Special case: link-to is always reported regardless of params
if (pathOriginal === 'link-to') {
const angleBracketName = transformTagName(pathOriginal);
context.report({
node,
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}'] }\`.`,
});
return;
}

// Skip if has positional params
if (node.params && node.params.length > 0) {
return;
}

if (config.allow.includes(pathOriginal)) {
return;
}

const angleBracketName = transformTagName(pathOriginal);
context.report({
node,
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}'] }\`.`,
});
},

'GlimmerBlockStatement:exit'() {
blockParamStack.pop();
},

GlimmerAttrNode() {
insideAttrNode = true;
},

'GlimmerAttrNode:exit'() {
insideAttrNode = false;
},
};
},
};
/* eslint-enable eslint-plugin/prefer-placeholders */
Loading
Loading