Repository: ember-cli/eslint-plugin-ember
Pull request #369 (2019-01-06 02:15:52)

Add new 'route-path-style' rule
New rule:
* Title: `route-path-style`
* Description: Enforces usage of kebab-case (instead of snake_case or camelCase) in route paths.

Kebab-case looks like this URL: https://guides.emberjs.com/release/getting-started/core-concepts/

I'm assuming that kebab-case is the only route path format that we want to promote/enforce in Ember. This format seems to be a best practice / preference across the web, as even Google [recommends](https://support.google.com/webmasters/answer/76329?hl=en) it:

> Consider using punctuation in your URLs. The URL http://www.example.com/green-dress.html is much more useful to us than http://www.example.com/greendress.html. We recommend that you use hyphens (-) instead of underscores (_) in your URLs.

bmish: Question for reviewers. Is kebab-case (a URL that looks like: `https://guides.emberjs.com/release/getting-started/core-concepts/`) the only route path format we want to promote/enforce in Ember? If so, I would likely rename this rule to `require-kebab-case-in-route-path` to be more explicit.
bmish: Anyone want to have a look? This one has been ready.
bmish: Hey @rwjblue can you take a look? This has been waiting for nearing two months. I'm hoping to get this in to unblock other work I'd like to do. (also just rebased on the latest master).
Turbo87: @bmish could you extract the changes to the existing rules to separate PRs? those changes make the review of the new rule quite a bit more complicated
bmish: @Turbo87 good point, I removed the cleanup of other rules, which should dramatically simplify the PR diff.
Turbo87: @bmish I'm wondering if we should make this rule a little more generic. this is definitely a styling rule where different developers may have different opinions and it would be bad to have a separate rule for every style.

I was thinking that maybe we should rename this to `route-path-casing` (or even `route-path-naming`) with an optional `style` option that defaults to `kebab` case. We might even be able to pass a custom validator function to it to support any custom naming schemes 🤔 

I think @rwjblue was a bit skeptical about suggesting that everything should be kebab case but if we make it configurable we might persuade him that this can be a useful rule 😉 

Patch:
diff --git a/README.md b/README.md
index 076b2514..0bc38a01 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,7 @@ The `--fix` option on the command line automatically fixes problems reported by
 |  | [no-unnecessary-index-route](./docs/rules/no-unnecessary-index-route.md) | Disallow unnecessary `index` route definition |
 | :wrench: | [no-unnecessary-route-path-option](./docs/rules/no-unnecessary-route-path-option.md) | Disallow unnecessary route `path` option |
 | :wrench: | [no-unnecessary-service-injection-argument](./docs/rules/no-unnecessary-service-injection-argument.md) | Disallow unnecessary argument when injecting service |
+|  | [route-path-style](./docs/rules/route-path-style.md) | Enforces usage of kebab-case (instead of snake_case or camelCase) in route paths |
 | :wrench: | [use-ember-get-and-set](./docs/rules/use-ember-get-and-set.md) | Enforces usage of Ember.get and Ember.set |
 
 
diff --git a/docs/rules/no-capital-letters-in-routes.md b/docs/rules/no-capital-letters-in-routes.md
index 5df64f21..e5af9ad2 100644
--- a/docs/rules/no-capital-letters-in-routes.md
+++ b/docs/rules/no-capital-letters-in-routes.md
@@ -22,3 +22,13 @@ this.route('home');
 this.route('sign-up');
 
 ```
+
+### References
+
+* [Ember Routing Guide](https://guides.emberjs.com/release/routing/)
+
+### Related Rules
+
+* [no-unnecessary-route-path-option](no-unnecessary-route-path-option.md)
+* [route-path-style](route-path-style.md)
+* [routes-segments-snake-case](routes-segments-snake-case.md)
diff --git a/docs/rules/no-unnecessary-route-path-option.md b/docs/rules/no-unnecessary-route-path-option.md
index 068d0a57..57d8cdf8 100644
--- a/docs/rules/no-unnecessary-route-path-option.md
+++ b/docs/rules/no-unnecessary-route-path-option.md
@@ -30,4 +30,5 @@ this.route('blog-posts', { path: '/blog' });
 
 * [no-capital-letters-in-routes](no-capital-letters-in-routes.md)
 * [no-unnecessary-index-route](no-unnecessary-index-route.md)
+* [route-path-style](route-path-style.md)
 * [routes-segments-snake-case](routes-segments-snake-case.md)
diff --git a/docs/rules/route-path-style.md b/docs/rules/route-path-style.md
new file mode 100644
index 00000000..1aaa607c
--- /dev/null
+++ b/docs/rules/route-path-style.md
@@ -0,0 +1,48 @@
+## Enforces usage of kebab-case (instead of snake_case or camelCase) in route paths
+
+### Rule name: `route-path-style`
+
+A best practice on the web is to use kebab-case (hyphens) for separating words in URLs. This style is good for readability, clarity, SEO, etc.
+
+Example kebab-case URL: `https://guides.emberjs.com/release/getting-started/core-concepts/`
+
+### Rule Details
+
+Examples of **incorrect** code for this rule:
+
+```js
+this.route('blog_posts');
+```
+
+```js
+this.route('blogPosts');
+```
+
+```js
+this.route('blog-posts', { path: '/blog_posts' });
+```
+
+```js
+this.route('blog-posts', { path: '/blogPosts' });
+```
+
+Examples of **correct** code for this rule:
+
+```js
+this.route('blog-posts');
+```
+
+```js
+this.route('blog_posts', { path: '/blog-posts' });
+```
+
+### References
+
+* [Ember Routing Guide](https://guides.emberjs.com/release/routing/)
+* [Keep a simple URL structure](https://support.google.com/webmasters/answer/76329) article by Google
+
+### Related Rules
+
+* [no-capital-letters-in-routes](no-capital-letters-in-routes.md)
+* [no-unnecessary-route-path-option](no-unnecessary-route-path-option.md)
+* [routes-segments-snake-case](routes-segments-snake-case.md)
diff --git a/docs/rules/routes-segments-snake-case.md b/docs/rules/routes-segments-snake-case.md
index 5dd9bc2d..a412aed5 100644
--- a/docs/rules/routes-segments-snake-case.md
+++ b/docs/rules/routes-segments-snake-case.md
@@ -11,3 +11,13 @@ this.route('tree', { path: ':tree_id'});
 // BAD
 this.route('tree', { path: ':treeId'});
 ```
+
+### References
+
+* [Ember Routing Guide](https://guides.emberjs.com/release/routing/)
+
+### Related Rules
+
+* [no-capital-letters-in-routes](no-capital-letters-in-routes.md)
+* [no-unnecessary-route-path-option](no-unnecessary-route-path-option.md)
+* [route-path-style](route-path-style.md)
diff --git a/lib/index.js b/lib/index.js
index 9337dce1..c119c01c 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -39,6 +39,7 @@ module.exports = {
     'order-in-controllers': require('./rules/order-in-controllers'),
     'order-in-models': require('./rules/order-in-models'),
     'order-in-routes': require('./rules/order-in-routes'),
+    'route-path-style': require('./rules/route-path-style'),
     'require-return-from-computed': require('./rules/require-return-from-computed'),
     'require-super-in-init': require('./rules/require-super-in-init'),
     'routes-segments-snake-case': require('./rules/routes-segments-snake-case'),
diff --git a/lib/recommended-rules.js b/lib/recommended-rules.js
index b80d614a..666ffdf5 100644
--- a/lib/recommended-rules.js
+++ b/lib/recommended-rules.js
@@ -43,7 +43,8 @@ module.exports = {
   "ember/order-in-routes": "off",
   "ember/require-return-from-computed": "off",
   "ember/require-super-in-init": "error",
+  "ember/route-path-style": "off",
   "ember/routes-segments-snake-case": "error",
   "ember/use-brace-expansion": "error",
   "ember/use-ember-get-and-set": "off"
-}
\ No newline at end of file
+}
diff --git a/lib/rules/route-path-style.js b/lib/rules/route-path-style.js
new file mode 100644
index 00000000..671fab2e
--- /dev/null
+++ b/lib/rules/route-path-style.js
@@ -0,0 +1,73 @@
+'use strict';
+
+const utils = require('../utils/utils');
+const ember = require('../utils/ember');
+
+//------------------------------------------------------------------------------
+// Rule Definition
+//------------------------------------------------------------------------------
+
+const ERROR_MESSAGE = 'Use kebab-case in route path.';
+
+module.exports = {
+  meta: {
+    docs: {
+      description: 'Enforces usage of kebab-case (instead of snake_case or camelCase) in route paths',
+      category: 'Best Practices',
+      recommended: false
+    },
+    fixable: null,
+    ERROR_MESSAGE
+  },
+
+  create(context) {
+    return {
+      CallExpression(node) {
+        if (!ember.isRoute(node)) {
+          return;
+        }
+
+        // Retrieve the path based on the call format used:
+        // 1. this.route('route-and-path');
+        // 2. this.route('route', { path: 'path' });
+
+        const hasExplicitPathOption = utils.isObjectExpression(node.arguments[1]) && hasPropertyWithKeyName(node.arguments[1], 'path');
+        const pathValueNode = hasExplicitPathOption ? getPropertyByKeyName(node.arguments[1], 'path').value : node.arguments[0];
+        const pathValue = pathValueNode.value;
+
+        const urlSegments = getStaticURLSegments(pathValue);
+
+        if (urlSegments.some(urlPart => !isKebabCase(urlPart))) {
+          context.report(pathValueNode, ERROR_MESSAGE);
+        }
+      },
+    };
+  }
+};
+
+function hasPropertyWithKeyName(objectExpression, keyName) {
+  return getPropertyByKeyName(objectExpression, keyName) !== undefined;
+}
+
+function getPropertyByKeyName(objectExpression, keyName) {
+  return objectExpression.properties.find(property => property.key.name === keyName);
+}
+
+function getStaticURLSegments(path) {
+  return path
+    .split('/')
+    .filter(segment => !isDynamicSegment(segment) && !isWildcardSegment(segment) && segment !== '');
+}
+
+function isDynamicSegment(segment) {
+  return segment.includes(':');
+}
+
+function isWildcardSegment(segment) {
+  return segment.includes('*');
+}
+
+const KEBAB_CASE_REGEXP = /^[0-9a-z-]+$/;
+function isKebabCase(str) {
+  return str.match(KEBAB_CASE_REGEXP);
+}


Test patch:
diff --git a/tests/lib/rules/route-path-style.js b/tests/lib/rules/route-path-style.js
new file mode 100644
index 00000000..37b86e57
--- /dev/null
+++ b/tests/lib/rules/route-path-style.js
@@ -0,0 +1,117 @@
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const rule = require('../../../lib/rules/route-path-style');
+const RuleTester = require('eslint').RuleTester;
+
+const { ERROR_MESSAGE } = rule;
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+const ruleTester = new RuleTester();
+ruleTester.run('route-path-style', rule, {
+  valid: [
+    // Implicit path:
+    'this.route("blog");',
+    'this.route("blog-posts");',
+
+    // Explicit path:
+    'this.route("blog", { path: "" });',
+    'this.route("blog", { path: "/" });',
+    'this.route("blog", { path: "*:" });',
+    'this.route("blog", { path: "/blog" });',
+    'this.route("blog", { path: "/blog/blog" });',
+    'this.route("blog", { path: "/blog/blog-posts" });',
+    'this.route("blog-posts", { path: "/blog-posts" });',
+    'this.route("blog_posts", { path: "/blog-posts" });',
+    'this.route("blogPosts", { path: "/blog-posts" });',
+
+    // With dynamic segments:
+    'this.route("blog", { path: "/blog/blog-posts/:blog_id" });',
+    'this.route("blog", { path: ":blog_id" });',
+    'this.route("blog", { path: "blog/:blog_id" });',
+    'this.route("blog", { path: "/blog/:blog_id" });',
+    'this.route("blog", { path: ":blog_id/:other_id" });',
+    'this.route("blog", { path: "/blog/:blog_id/test/:other_id" });',
+
+    // With wildcard segment:
+    'this.route("blog", { path: "*path" });',
+    'this.route("blog", { path: "*path_name" });',
+    'this.route("blog", { path: "*pathName" });',
+    'this.route("blog", { path: "/*path" });',
+    'this.route("blog", { path: "/*path_name" });',
+    'this.route("blog", { path: "/*pathName" });',
+    'this.route("blog", { path: "/blog/*path" });',
+    'this.route("blog", { path: "/blog/*path_name" });',
+    'this.route("blog", { path: "/blog/*pathName" });',
+
+    // With function:
+    'this.route("blog", function() { this.route("update"); });',
+    'this.route("blog", { path: "/blog-posts" }, function() { this.route("update"); });',
+
+    // With other field in object:
+    'this.route("blog", { otherField: "/blog_posts" });',
+    'this.route("blog", { otherField: "/blog_posts", path: "/blog" });',
+    'this.route("blog-posts", { otherField: "/blog_posts" });',
+
+    // Not Ember's route function:
+    'test();',
+    "test('blog');",
+    "test('blog_posts');",
+    "test('blogPosts');",
+    "test('blog-posts', { path: '/blog_posts' });",
+    'this.test();',
+    "this.test('blog');",
+    "this.test('blog_posts');",
+    "this.test('blogPosts');",
+    "this.test('blog-posts', { path: '/blog_posts' });",
+    'MyClass.route();',
+    "MyClass.route('blog');",
+    "MyClass.route('blog_posts');",
+    "MyClass.route('blogPosts');",
+    "MyClass.route('blog-posts', { path: '/blog_posts' });",
+    "route.unrelatedFunction('blog_posts');",
+    "this.route.unrelatedFunction('blog_posts');"
+  ],
+  invalid: [
+    {
+      code: 'this.route("blog_posts");',
+      errors: [{ message: ERROR_MESSAGE, type: 'Literal' }]
+    },
+    {
+      code: 'this.route("blogPosts");',
+      errors: [{ message: ERROR_MESSAGE, type: 'Literal' }]
+    },
+    {
+      code: 'this.route("blogPosts", function() {});',
+      errors: [{ message: ERROR_MESSAGE, type: 'Literal' }]
+    },
+    {
+      code: 'this.route("blog-posts", { path: "/blog_posts" });',
+      errors: [{ message: ERROR_MESSAGE, type: 'Literal' }]
+    },
+    {
+      code: 'this.route("blog-posts", { path: "/blogPosts" });',
+      errors: [{ message: ERROR_MESSAGE, type: 'Literal' }]
+    },
+    {
+      code: 'this.route("blog-posts", { path: "/blogPosts/:blog_id" });',
+      errors: [{ message: ERROR_MESSAGE, type: 'Literal' }]
+    },
+    {
+      code: 'this.route("blog-posts", { path: "/blog-posts/:blog_id/otherPart" });',
+      errors: [{ message: ERROR_MESSAGE, type: 'Literal' }]
+    },
+    {
+      code: 'this.route("blog-posts", { path: "/blogPosts/*path" });',
+      errors: [{ message: ERROR_MESSAGE, type: 'Literal' }]
+    },
+    {
+      code: 'this.route("blog-posts", { path: "/blogPosts" }, function() {});',
+      errors: [{ message: ERROR_MESSAGE, type: 'Literal' }]
+    }
+  ]
+});