Repository: buxlabs/boxwood (js)

Issue:
script[routes]
setup:

```js
// ...
const { template } = await compile(source, {
  routes: {
    Users: {
      browse: '/rest/users'
    }
  }
})
// ...
```

input:

```html
<script routes>
const url = route('Users.browse')
</script>
```

output:

```html
<script routes>
const url = '/rest/users'
</script>
```


Pull request description:
Routes
Closes #622 

TODO:

- [ ] handling multiple params, e.g. for `/foo/:id`
- [ ] error handling
- [ ] move part of the code into a utility function and add unit specs (input -> output, blackbox)

Patch:
diff --git a/package-lock.json b/package-lock.json
index 44bc6aa..292cf70 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2510,6 +2510,12 @@
           "requires": {
             "ms": "2.0.0"
           }
+        },
+        "path-to-regexp": {
+          "version": "0.1.7",
+          "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+          "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=",
+          "dev": true
         }
       }
     },
@@ -4734,10 +4740,9 @@
       "dev": true
     },
     "path-to-regexp": {
-      "version": "0.1.7",
-      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
-      "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=",
-      "dev": true
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz",
+      "integrity": "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw=="
     },
     "path-type": {
       "version": "4.0.0",
diff --git a/package.json b/package.json
index f6b9962..f9d8220 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,7 @@
     "memoizee": "^0.4.14",
     "negate-sentence": "^0.2.0",
     "normalize-newline": "^3.0.0",
+    "path-to-regexp": "^6.1.0",
     "pure-conditions": "^1.0.5",
     "pure-utilities": "^1.2.0",
     "rollup": "^2.6.1",
diff --git a/src/Renderer.js b/src/Renderer.js
index 47c78ab..9bb921f 100644
--- a/src/Renderer.js
+++ b/src/Renderer.js
@@ -8,6 +8,7 @@ const collect = require('./utilities/collect')
 const { getFilter } = require('./utilities/filters')
 const { unique } = require('pure-utilities/array')
 const Statistics = require('./Statistics')
+const RoutesPlugin = require('./plugins/RoutesPlugin')
 const DataPlugin = require('./plugins/DataPlugin')
 const CurlyStylesPlugin = require('./plugins/CurlyStylesPlugin')
 const ScopedStylesPlugin = require('./plugins/ScopedStylesPlugin')
@@ -57,6 +58,7 @@ class Renderer {
     const promises = []
     const errors = []
     const plugins = [
+      new RoutesPlugin(options, errors),
       new DataPlugin(),
       new InlinePlugin(),
       new BoxModelPlugin(options),
diff --git a/src/plugins/RoutesPlugin.js b/src/plugins/RoutesPlugin.js
new file mode 100644
index 0000000..3749df5
--- /dev/null
+++ b/src/plugins/RoutesPlugin.js
@@ -0,0 +1,27 @@
+const Plugin = require('./Plugin')
+const { isEmptyObject } = require('pure-conditions')
+const { transform } = require('../utilities/routes')
+
+class RoutesPlugin extends Plugin {
+  constructor (options, errors) {
+    super()
+    const { routes } = options
+    this.routes = routes
+    this.errors = errors
+    this.disabled = isEmptyObject(routes)
+  }
+
+  prerun ({ tag, keys, fragment }) {
+    if (this.disabled) { return }
+    if (tag === 'script' && keys.includes('routes')) {
+      const { content } = fragment.children[0]
+      // TODO implement routes.has(, routes.each(
+      if (content.includes('routes.get(')) {
+        fragment.children[0].content = transform(content, this.routes, this.errors)
+      }
+      fragment.attributes = fragment.attributes.filter(attribute => attribute.key !== 'routes')
+    }
+  }
+}
+
+module.exports = RoutesPlugin
diff --git a/src/utilities/options.js b/src/utilities/options.js
index 5928649..3c78f5b 100644
--- a/src/utilities/options.js
+++ b/src/utilities/options.js
@@ -28,7 +28,8 @@ module.exports = {
       },
       script: {
         paths: []
-      }
+      },
+      routes: {}
     }, options)
     newOptions.hooks = Object.assign({
       onBeforeFile () {},
diff --git a/src/utilities/routes.js b/src/utilities/routes.js
new file mode 100644
index 0000000..4e9634c
--- /dev/null
+++ b/src/utilities/routes.js
@@ -0,0 +1,74 @@
+const AbstractSyntaxTree = require('abstract-syntax-tree')
+const { dig } = require('pure-utilities/object')
+const { parse } = require('path-to-regexp')
+
+function transform (source, routes, errors) {
+  const tree = new AbstractSyntaxTree(source)
+  tree.replace(node => {
+    if (node.type === 'CallExpression' &&
+      node.callee &&
+      node.callee.type === 'MemberExpression' &&
+      node.callee.object.type === 'Identifier' &&
+      node.callee.object.name === 'routes' &&
+      node.callee.property.type === 'Identifier' &&
+      node.callee.property.name === 'get') {
+      const params = node.arguments
+      if (params.length === 0) {
+        errors.push({ type: 'ROUTE_INVALID', message: 'routes.get requires a string literal as the first parameter.' })
+      } else {
+        const param = params[0]
+        if (param.type !== 'Literal' || typeof param.value !== 'string') {
+          errors.push({ type: 'ROUTE_INVALID', message: 'routes.get requires a string literal as the first parameter.' })
+          return node
+        }
+        const url = dig(routes, param.value)
+        if (!url) {
+          errors.push({ type: 'ROUTE_MISSING', message: `routes.get could not find the '${param.value}' route.` })
+          return node
+        }
+        if (params.length === 1) {
+          return {
+            type: 'Literal',
+            value: url
+          }
+        }
+        const quasis = []
+        const parts = parse(url)
+        // TODO it doesn't handle more complex cases yet
+        parts.forEach((part, index) => {
+          const last = index === parts.length - 1
+          if (typeof part === 'string') {
+            const value = part + (last ? '' : '/')
+            quasis.push({
+              type: 'TemplateElement',
+              value: {
+                cooked: value,
+                raw: value
+              },
+              tail: last
+            })
+          }
+        })
+        if (quasis[quasis.length - 1].tail === false) {
+          quasis.push({
+            type: 'TemplateElement',
+            value: {
+              cooked: '',
+              raw: ''
+            },
+            tail: true
+          })
+        }
+        // TODO could be optimized to inline literals
+        return {
+          type: 'TemplateLiteral',
+          quasis,
+          expressions: params.slice(1)
+        }
+      }
+    }
+  })
+  return tree.source
+}
+
+module.exports = { transform }
diff --git a/src/utilities/routes.spec.js b/src/utilities/routes.spec.js
new file mode 100644
index 0000000..63043a8
--- /dev/null
+++ b/src/utilities/routes.spec.js
@@ -0,0 +1,44 @@
+'use strict'
+
+const test = require('ava')
+const { transform } = require('./routes')
+
+test('it replaces routes.get with url', assert => {
+  const input = 'routes.get("User.browse")'
+  const routes = { User: { browse: '/user' } }
+  const errors = []
+  const output = transform(input, routes, errors)
+  assert.deepEqual(output.trim(), '"/user";')
+})
+
+test('it works for urls with params', assert => {
+  const input = 'routes.get("User.browse", id)'
+  const routes = { User: { browse: '/user/:id' } }
+  const errors = []
+  const output = transform(input, routes, errors)
+  assert.deepEqual(output.trim(), '`/user/${id}`;')
+})
+
+test('it adds an error if given url does not exist', assert => {
+  const input = 'routes.get("User.browse")'
+  const routes = {}
+  const errors = []
+  transform(input, routes, errors)
+  assert.deepEqual(errors[0], { type: 'ROUTE_MISSING', message: 'routes.get could not find the \'User.browse\' route.' })
+})
+
+test('it adds an error if routes.get has no params', assert => {
+  const input = 'routes.get()'
+  const routes = {}
+  const errors = []
+  transform(input, routes, errors)
+  assert.deepEqual(errors[0], { type: 'ROUTE_INVALID', message: 'routes.get requires a string literal as the first parameter.' })
+})
+
+test('it adds an error if routes.get does not receive a string', assert => {
+  const input = 'routes.get(5)'
+  const routes = {}
+  const errors = []
+  transform(input, routes, errors)
+  assert.deepEqual(errors[0], { type: 'ROUTE_INVALID', message: 'routes.get requires a string literal as the first parameter.' })
+})


Test patch:
diff --git a/test/fixtures/acceptance/routes/actual.html b/test/fixtures/acceptance/routes/actual.html
new file mode 100644
index 0000000..5623141
--- /dev/null
+++ b/test/fixtures/acceptance/routes/actual.html
@@ -0,0 +1,3 @@
+<script routes>
+fetch(routes.get('Users.browse')).then(response => response.json()).then(console.log)
+</script>
diff --git a/test/fixtures/acceptance/routes/data.json b/test/fixtures/acceptance/routes/data.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/test/fixtures/acceptance/routes/data.json
@@ -0,0 +1,1 @@
+{}
diff --git a/test/fixtures/acceptance/routes/expected.html b/test/fixtures/acceptance/routes/expected.html
new file mode 100644
index 0000000..7f14117
--- /dev/null
+++ b/test/fixtures/acceptance/routes/expected.html
@@ -0,0 +1,3 @@
+<script>
+fetch("/rest/users").then(response => response.json()).then(console.log);
+</script>
diff --git a/test/spec/acceptance.spec.js b/test/spec/acceptance.spec.js
index a93a7ad..50ad61d 100644
--- a/test/spec/acceptance.spec.js
+++ b/test/spec/acceptance.spec.js
@@ -188,6 +188,17 @@ test('acceptance: translate-modifier', async assert => {
   assert.deepEqual(actual, expected)
 })
 
+test('acceptance: routes', async assert => {
+  const { actual, expected } = await suite('routes', {
+    routes: {
+      Users: {
+        browse: '/rest/users'
+      }
+    }
+  })
+  assert.deepEqual(actual, expected)
+})
+
 test.skip('acceptance: slot as a variable', async assert => {
   const { actual, expected } = await suite('slot-as-variable')
   assert.deepEqual(actual, expected)