Repository: ember-template-lint/ember-template-lint
Pull request #768 (2019-07-09 09:17:25)

Add no-curly-component-invocation lint rule
Fixes https://github.com/ember-template-lint/ember-template-lint/issues/765

So in our app we started the process of migrating 1000s of templates over to use angle bracket syntax. As part of that process we created our own template-lint rule to prevent instances of classic invocation component syntax sneaking back in. As we found it useful I thought I'd see if you'd like to have it contributed back to ember-template-lint? I'm not tied to any of the solution here so please let me know what you think.

* It detects if block or mustache statement name has a `/` or `-` in it. If so this is possibly a component.
* Checks against a whitelist of helpers (see config section in the rule README) to differentiate between helpers and components
* If the name isn't in the whitelist it will log a template lint error like:
> You are using the component `{{awesome-code}}` with classic invocation syntax. You should use `<AwesomeCode>` instead. If it is actually a helper you must manually add it to the 'no-classic-invocation-component' rule configuration, e.g. `'no-classic-invocation-component': { allow: ['awesome-code'] }`.
* It currently doesn't support the case where you have a component which doesn't have a `-` or a `/` in it (which _I think_ is legal) as we had none of those in our codebase. If we need to support it we could always add a `disallow` config option so these could be added there.

#### Bad

```hbs
{{bad-code}}
{{#bad-code}}{{/bad-code}}
{{nested/bad-code}}
{{#nested/bad-code}}{{/nested/bad-code}}
```

#### Good

```hbs
<GoodCode />
<GoodCode></GoodCode>
<Nested::GoodCode />
<Nested::GoodCode></Nested::GoodCode>
{{! whitelisted helpers}}
{{some-valid-helper param}}
{{some/some-valid-helper param}}
{{! in-built helpers}}
{{if someProperty "yay"}}
{{#each items as |item|}}
  {{item}}
{{/each}}
```

rwjblue: Thanks for working on this! I absolutely think its a great rule to add...

Patch:
diff --git a/docs/rule/no-curly-component-invocation.md b/docs/rule/no-curly-component-invocation.md
new file mode 100644
index 00000000..8a581a97
--- /dev/null
+++ b/docs/rule/no-curly-component-invocation.md
@@ -0,0 +1,111 @@
+## Use angle bracket syntax for components
+
+### Rule name: `no-curly-component-invocation`
+
+There are two ways to invoke a component in a template: curly compoment 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.
+
+#### Bad
+
+```hbs
+{{bad-code}}
+{{#bad-code}}{{/bad-code}}
+{{nested/bad-code}}
+{{#nested/bad-code}}{{/nested/bad-code}}
+```
+
+#### Good
+
+```hbs
+<GoodCode />
+<GoodCode></GoodCode>
+<Nested::GoodCode />
+<Nested::GoodCode></Nested::GoodCode>
+
+{{! whitelisted helpers}}
+{{some-valid-helper param}}
+{{some/some-valid-helper param}}
+
+{{! in-built helpers}}
+{{if someProperty "yay"}}
+{{#each items as |item|}}
+  {{item}}
+{{/each}}
+```
+
+## Configuration
+### Whitelisting helpers
+To be able to differentiate between components and helpers used within curlies, e.g. `{{my-helper}}`, you can add a whitelist of all your known helpers to this rule's configuration. To do this add the following to your `.template-lintrc.js` which enables your rule.
+
+```js
+module.exports = {
+  rules: {
+    'no-curly-component-invocation': {
+        allow: [
+        'some-random-helper',
+        'another-helper',
+      ],
+    },
+  },
+};
+```
+
+To get a list of of all the helpers in your app run the following code in your browser's Developer Tools Console when your app has loaded:
+
+``` js
+var componentLikeHelpers = Object.keys(require.entries)
+  .filter(name => name.includes('/helpers/'))
+  .map(name => {
+    let path = name.split('/helpers/');
+    return path.pop();
+  })
+  .filter(name => !name.includes('/'))
+  .uniq();
+
+copy(JSON.stringify(componentLikeHelpers));
+```
+
+Hat tip to @lifeart for [this code](https://github.com/lifeart/ember-ast-hot-load#how-to-use-this-addon).
+
+### Blacklisting components without dashes
+Since Ember 3.10 components have not required dashes in their name, e.g. `{{datepicker}}`. To help the linter throw an error on these curly component invocations, which it would otherwise have thought to be a helper or property, you can explicitly add it to the `disallow` section of the rule's config. Any curly statements matching an entry in `disallow` will throw a lint error.
+
+```js
+module.exports = {
+  rules: {
+    'no-curly-component-invocation': {
+        disallow: [
+        'heading',
+        'datepicker',
+      ],
+    },
+  },
+};
+```
+
+To get a list of of all the components in your app which don't have dashes in their name run the following code in your browser's Developer Tools Console when your app has loaded:
+
+``` js
+var componentsWithoutDashes = Object.keys(require.entries)
+  .filter(name => name.includes('/components/'))
+  .filter(name => !name.includes('-'))
+  .map(name => {
+    let path = name.split('/components/');
+    return path.pop();
+  })
+  .uniq();
+
+copy(JSON.stringify(componentsWithoutDashes));
+```
+
+### Matching on components without dashes in their name
+Before Ember 3.10 components were required to have at least one dash, `-`, in their name. By default this rule assumes that all components have `-` in their name. If you'd like to change this behaviour to match all curly invocations, even those without dashes then set the `requireDash` option to `false`.
+
+```js
+module.exports = {
+  rules: {
+    'no-curly-component-invocation': {
+        requireDash: false,
+    },
+  },
+};
+```
\ No newline at end of file
diff --git a/lib/helpers/curly-component-invocation.js b/lib/helpers/curly-component-invocation.js
new file mode 100644
index 00000000..43869e2b
--- /dev/null
+++ b/lib/helpers/curly-component-invocation.js
@@ -0,0 +1,40 @@
+function transformTagName(tagName) {
+  if (isNestedComponentTagName(tagName)) {
+    return transformNestedTagName(tagName);
+  }
+
+  return capitalizedTagName(tagName);
+}
+
+function isNestedComponentTagName(tagName) {
+  let nestedComponentName = /\//g;
+  return nestedComponentName.test(tagName);
+}
+
+function transformNestedTagName(tagName) {
+  let paths = tagName.split('/');
+  return paths.map(name => capitalizedTagName(name)).join('::');
+}
+
+function capitalizedTagName(tagname) {
+  if (tagname.includes('@')) {
+    tagname = tagname
+      .split('@')
+      .map(upperCase)
+      .join('@');
+  }
+
+  return tagname
+    .split('-')
+    .map(upperCase)
+    .join('');
+}
+
+function upperCase(text) {
+  return text[0].toUpperCase() + text.slice(1);
+}
+
+module.exports = {
+  transformTagName,
+  isNestedComponentTagName,
+};
diff --git a/lib/rules/index.js b/lib/rules/index.js
index 6e0e1f2a..0b57a772 100644
--- a/lib/rules/index.js
+++ b/lib/rules/index.js
@@ -17,6 +17,7 @@ module.exports = {
   'no-action-modifiers': require('./lint-no-action-modifiers'),
   'no-attrs-in-components': require('./lint-no-attrs-in-components'),
   'no-bare-strings': require('./lint-no-bare-strings'),
+  'no-curly-component-invocation': require('./lint-no-curly-component-invocation'),
   'no-debugger': require('./lint-no-debugger'),
   'no-duplicate-attributes': require('./lint-duplicate-attributes'),
   'no-element-event-actions': require('./lint-no-element-event-actions'),
diff --git a/lib/rules/lint-no-curly-component-invocation.js b/lib/rules/lint-no-curly-component-invocation.js
new file mode 100644
index 00000000..ea20374c
--- /dev/null
+++ b/lib/rules/lint-no-curly-component-invocation.js
@@ -0,0 +1,131 @@
+const Rule = require('./base');
+const createErrorMessage = require('../helpers/create-error-message');
+const { transformTagName } = require('../helpers/curly-component-invocation');
+const BUILT_IN_HELPERS = [
+  'action',
+  'array',
+  'component',
+  'concat',
+  'debugger',
+  'each',
+  'each-in',
+  'fn',
+  'get',
+  'hasBlock',
+  'hasBlockParams',
+  'hash',
+  'if',
+  'input',
+  'let',
+  'link-to',
+  'loc',
+  'log',
+  'mount',
+  'mut',
+  'on',
+  'outlet',
+  'partial',
+  'query-params',
+  'textarea',
+  'unbound',
+  'unless',
+  'with',
+  'yield',
+];
+const DEFAULT_CONFIG = {
+  allow: [],
+  disallow: [],
+  requireDash: true,
+};
+
+module.exports = class NoCurlyComponentInvocation extends Rule {
+  parseConfig(config) {
+    let configType = typeof config;
+
+    switch (configType) {
+      case 'boolean':
+        // if `true` use `DEFAULT_CONFIG`
+        return config ? DEFAULT_CONFIG : false;
+      case 'object':
+        return Object.assign({}, DEFAULT_CONFIG, config);
+      case 'undefined':
+        return false;
+    }
+
+    let errorMessage = createErrorMessage(
+      this.ruleName,
+      [
+        '  * boolean - `true` to enable / `false` to disable',
+        '  * object -- An object with the following keys:',
+        '    * `allow` -- array: A list of whitelisted helpers to be allowed used with curly component syntax',
+        '    * `disallow` -- array: A list of component names to not allow use with curly component syntax',
+        '    * `requireDash` -- boolean: The codemod assumes that all components have a dash in their name. `true` to enable (default) / `false` to disable',
+      ],
+      config
+    );
+
+    throw new Error(errorMessage);
+  }
+  logNode({ message, node }) {
+    this.log({
+      message,
+      line: node.loc && node.loc.start.line,
+      column: node.loc && node.loc.start.column,
+      source: this.sourceForNode(node),
+    });
+  }
+
+  getCurlyInvocationSyntaxError(node) {
+    let name = node.path.original;
+    if (node.path.type !== 'PathExpression') {
+      return '';
+    }
+    if (
+      node.type === 'BlockStatement' &&
+      !BUILT_IN_HELPERS.includes(name) &&
+      !this.isAllowed(name)
+    ) {
+      return this.generateError(name);
+    }
+    if (name.startsWith('this.') || name.startsWith('@') || this.isAllowed(name)) {
+      return '';
+    }
+    if (this.isDisallowed(name) || !this.config.requireDash) {
+      return this.generateError(name);
+    }
+    return /\w+-\w+/g.test(name) ? this.generateError(name) : '';
+  }
+
+  isDisallowed(name) {
+    return this.config.disallow && this.config.disallow.includes(name);
+  }
+
+  isAllowed(name) {
+    return (
+      BUILT_IN_HELPERS.includes(name) || (this.config.allow && this.config.allow.includes(name))
+    );
+  }
+
+  generateError(name) {
+    let angleBracketName = transformTagName(name);
+    return `You are using the component {{${name}}} 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: ['${name}'] }\`.`;
+  }
+
+  visitor() {
+    return {
+      MustacheStatement(node) {
+        let message = this.getCurlyInvocationSyntaxError(node);
+        if (message) {
+          this.logNode({ message, node });
+        }
+      },
+
+      BlockStatement(node) {
+        let message = this.getCurlyInvocationSyntaxError(node);
+        if (message) {
+          this.logNode({ message, node });
+        }
+      },
+    };
+  }
+};


Test patch:
diff --git a/test/unit/helpers/curly-invocation-component-test.js b/test/unit/helpers/curly-invocation-component-test.js
new file mode 100644
index 00000000..62b924aa
--- /dev/null
+++ b/test/unit/helpers/curly-invocation-component-test.js
@@ -0,0 +1,29 @@
+'use strict';
+
+const expect = require('chai').expect;
+const {
+  transformTagName,
+  isNestedComponentTagName,
+} = require('../../../lib/helpers/curly-component-invocation');
+
+describe('#transformTagName', function() {
+  it(`it works as expected`, function() {
+    expect(transformTagName('foo')).to.equal('Foo');
+    expect(transformTagName('foo-bar')).to.equal('FooBar');
+    expect(transformTagName('f3-bar')).to.equal('F3Bar');
+    expect(transformTagName('foo3-bar')).to.equal('Foo3Bar');
+    expect(transformTagName('foo3bar-baz')).to.equal('Foo3barBaz');
+    expect(transformTagName('foo-b3ar')).to.equal('FooB3ar');
+    expect(transformTagName('x-blah')).to.equal('XBlah');
+    expect(transformTagName('foo@bar-baz')).to.equal('Foo@BarBaz');
+    expect(transformTagName('foo/bar-baz')).to.equal('Foo::BarBaz');
+    expect(transformTagName('foo/bar-baz/bang')).to.equal('Foo::BarBaz::Bang');
+  });
+});
+
+describe('#isNestedComponentTagName', function() {
+  it(`it works as expected`, function() {
+    expect(isNestedComponentTagName('nested/my-component')).to.be.true;
+    expect(isNestedComponentTagName('my-component')).to.be.false;
+  });
+});
diff --git a/test/unit/rules/lint-no-curly-component-invocation-test.js b/test/unit/rules/lint-no-curly-component-invocation-test.js
new file mode 100644
index 00000000..57a0e664
--- /dev/null
+++ b/test/unit/rules/lint-no-curly-component-invocation-test.js
@@ -0,0 +1,125 @@
+const generateRuleTests = require('../../helpers/rule-test-harness');
+const { transformTagName } = require('../../../lib/helpers/curly-component-invocation');
+
+function generateError(name) {
+  let angleBracketName = transformTagName(name);
+  return `You are using the component {{${name}}} 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: ['${name}'] }\`.`;
+}
+
+generateRuleTests({
+  name: 'no-curly-component-invocation',
+
+  config: {
+    allow: ['some-valid-helper', 'some/valid-nested-helper'],
+    disallow: ['heading'],
+  },
+
+  good: [
+    '<GoodCode />',
+    '<GoodCode></GoodCode>',
+    '{{if someProperty "yay"}}',
+    '<Nested::GoodCode />',
+    '<Nested::GoodCode @someProperty={{-50}} @someProperty={{"-50"}} @someProperty={{true}} />',
+    '{{some-valid-helper param}}',
+    '{{some/valid-nested-helper param}}',
+    `{{@someArg}}`,
+    `{{this.someProperty}}`,
+    `{{#each items as |item|}}
+        {{item}}
+     {{/each}}`,
+  ],
+
+  bad: [
+    {
+      template: '{{bad-code}}',
+      results: [getErrorResult(generateError('bad-code'), '{{bad-code}}')],
+    },
+    {
+      template: '{{nested/bad-code}}',
+      results: [getErrorResult(generateError('nested/bad-code'), '{{nested/bad-code}}')],
+    },
+    {
+      template: '{{heading size="1" text="Disallowed heading component"}}',
+      results: [
+        getErrorResult(
+          generateError('heading'),
+          '{{heading size="1" text="Disallowed heading component"}}'
+        ),
+      ],
+    },
+    {
+      template: '{{#heading size="1"}}Disallowed heading component{{/heading}}',
+      results: [
+        getErrorResult(
+          generateError('heading'),
+          '{{#heading size="1"}}Disallowed heading component{{/heading}}'
+        ),
+      ],
+    },
+  ],
+});
+
+generateRuleTests({
+  name: 'no-curly-component-invocation',
+
+  config: {
+    allow: ['some-valid-helper', 'some/valid-nested-helper'],
+    disallow: ['heading'],
+    requireDash: false,
+  },
+
+  good: [
+    '<GoodCode />',
+    '<GoodCode></GoodCode>',
+    '{{if someProperty "yay"}}',
+    '<Nested::GoodCode />',
+    '<Nested::GoodCode @someProperty={{-50}} @someProperty={{"-50"}}/ />',
+    '{{some-valid-helper param}}',
+    '{{some/valid-nested-helper param}}',
+    `{{@someArg}}`,
+    `{{this.someProperty}}`,
+    `{{#each items as |item|}}
+     {{/each}}`,
+  ],
+
+  bad: [
+    {
+      template: '{{box}}',
+      results: [getErrorResult(generateError('box'), '{{box}}')],
+    },
+    {
+      template: '{{nested/bad-code}}',
+      results: [getErrorResult(generateError('nested/bad-code'), '{{nested/bad-code}}')],
+    },
+    {
+      template: '{{heading size="1" text="Disallowed heading component"}}',
+      results: [
+        getErrorResult(
+          generateError('heading'),
+          '{{heading size="1" text="Disallowed heading component"}}'
+        ),
+      ],
+    },
+    {
+      template: '{{#heading size="1"}}Disallowed heading component{{/heading}}',
+      results: [
+        getErrorResult(
+          generateError('heading'),
+          '{{#heading size="1"}}Disallowed heading component{{/heading}}'
+        ),
+      ],
+    },
+  ],
+});
+
+function getErrorResult(message, source) {
+  return {
+    rule: 'no-curly-component-invocation',
+    severity: 2,
+    moduleId: 'layout.hbs',
+    message,
+    line: 1,
+    column: 0,
+    source,
+  };
+}