Repository: maizzle/framework
Pull request #291 (2020-09-01 12:03:01)

2.0
This PR started from the idea to add support for multiple build destination directories, as outlined in #207. However, this introduced a breaking change in the `toString()` method and in the `config.js` structure, so here we are, Maizzle 2.0...

### Highlights

- [Multiple destination directories](#user-content-multiple-destinations)
  - [New `build.templates.config`](#user-content-new-templates-config)
  - [Multiple Sources](#user-content-multiple-sources)
- [Exposed `html` in `beforeRender`](#user-content-html-in-beforerender)
- [`toString` now returns object](#user-content-tostring-object)

<h2 id="multiple-destinations">Multiple destination directories</h2>

You can now define multiple destination directories in your `config.js`.

The new `build.templates` structures supports multiple template configurations, so you can compile from multiple sources to multiple destinations, each with their own filetypes, extensions, and even assets.

<h3 id="new-templates-config">New `build.templates` config</h3>

The `build` config key now has a slightly different structure:

- `assets` and `destination` have been moved under the `templates` key
- `templates.root` is now `templates.source`

```diff
# config.js

module.exports = {
  build: {
-    assets: {
-      source: 'src/assets/images',
-      destination: 'images',
-    },
-    destination: {
-      path: 'build_local',
-    },
-    templates: {
-      root: 'src/templates',
-    },
+    templates: {
+      source: 'src/templates',
+      filetypes: 'html',
+      destination: {
+        path: 'build_local',
+        extension: 'html',
+      },
+      assets: {
+        source: 'src/assets/images',
+        destination: 'images',
+      },
+    },
  },
}
```

<h3 id="multiple-sources">Multiple Sources</h3>

You can define multiple Template sources by using an array of objects inside `build.templates`:

```diff
# config.js

module.exports = {
  build: {
-    assets: {
-      source: 'src/assets/images',
-      destination: 'images',
-    },
-    destination: {
-      path: 'build_local',
-    },
-    templates: {
-      root: 'src/templates',
-    },
+    templates: [
+      {
+        source: 'src/templates',
+        destination: {
+          path: 'build_production',
+        },
+        assets: {
+          source: 'src/assets/images',
+          destination: 'images',
+        },
+      },
+      {
+        source: 'src/client-name',
+        filetypes: 'html',
+        destination: {
+          path: 'build_client_name',
+        },
+        assets: {
+          source: ['src/assets/images', '../client/branding'],
+          destination: 'assets',
+        },
+      },
+    ],
  },
}
```

#### Browsersync and multiple sources

With multiple sources, Browsersync will serve files from the first `destination.path` directory it finds in your `build.templates` config:

https://github.com/maizzle/framework/blob/ae08459e4fd6188e6bf974cec7080c66a3a38f1a/src/index.js#L37

In the 'Multiple Sources' example above, that directory is `build_production`.

<h2 id="html-in-beforerender">Exposed `html` in `beforeRender`</h2>

You can now manipulate the HTML in the `beforeRender` event, too:

```js
module.exports = {
  events: {
    beforeRender(html, config) {
      config.test = 'example'

      html = config.test

      // must return `html`
      return html
    },
  }
}
```

<h2 id="tostring-object">`toString` now returns object</h2>

The `toString` method now returns an object instead of just the rendered HTML. This object now includes the `config` that was computed for this template:

https://github.com/maizzle/framework/blob/ff8de79d4c30bb47608191e472e60ccab7e3d98c/src/generators/output/to-string.js#L51-L54

This means that when using it to compile a string, you need to either access the `result.html` property:

```js
const Maizzle = require('@maizzle/framework')

const html = Maizzle.render(`your html`, {/* config... */}).then(result => result.html)
```

... or destructure it:

```js
const Maizzle = require('@maizzle/framework')

const html = Maizzle.render(`your html`, {/* config... */}).then(({html}) => html)
```



Patch:
diff --git a/package-lock.json b/package-lock.json
index 0f1ac1e..5c3699f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1081,14 +1081,14 @@
           }
         },
         "caniuse-lite": {
-          "version": "1.0.30001116",
-          "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001116.tgz",
-          "integrity": "sha512-f2lcYnmAI5Mst9+g0nkMIznFGsArRmZ0qU+dnq8l91hymdc2J3SFbiPhOJEeDqC1vtE8nc1qNQyklzB8veJefQ=="
+          "version": "1.0.30001120",
+          "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001120.tgz",
+          "integrity": "sha512-JBP68okZs1X8D7MQTY602jxMYBmXEKOFkzTBaNSkubooMPFOAv2TXWaKle7qgHpjLDhUzA/TMT0qsNleVyXGUQ=="
         },
         "electron-to-chromium": {
-          "version": "1.3.539",
-          "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.539.tgz",
-          "integrity": "sha512-rM0LWDIstdqfaRUADZetNrL6+zd/0NBmavbMEhBXgc2u/CC1d1GaDyN5hho29fFvBiOVFwrSWZkzmNcZnCEDog=="
+          "version": "1.3.555",
+          "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.555.tgz",
+          "integrity": "sha512-/55x3nF2feXFZ5tdGUOr00TxnUjUgdxhrn+eCJ1FAcoAt+cKQTjQkUC5XF4frMWE1R5sjHk+JueuBalimfe5Pg=="
         },
         "node-releases": {
           "version": "1.1.60",
@@ -8951,9 +8951,9 @@
       }
     },
     "posthtml-expressions": {
-      "version": "1.4.6",
-      "resolved": "https://registry.npmjs.org/posthtml-expressions/-/posthtml-expressions-1.4.6.tgz",
-      "integrity": "sha512-9reSvnKdlp8wbzGB7A/dmmCiJhhXwIZeLyXQuJNDbbfE2JHQxpnIbfXe2J6hu7411+SphKDRYDrparlkq4RkJA==",
+      "version": "1.4.7",
+      "resolved": "https://registry.npmjs.org/posthtml-expressions/-/posthtml-expressions-1.4.7.tgz",
+      "integrity": "sha512-zX6BBJUn/Wri33xPZiAQI+Bq5LeLnMadt6bWjXLPCieB3XC+BjH/pTKyd21pO47UF1u26avnxzfKXe06eU4JkA==",
       "requires": {
         "fclone": "^1.0.11",
         "posthtml-parser": "^0.5.0",
@@ -10971,9 +10971,9 @@
       }
     },
     "tailwindcss": {
-      "version": "1.7.3",
-      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-1.7.3.tgz",
-      "integrity": "sha512-e6o/qhn80hcJ+cB1jIK3C1xlDPkFHU98c2m4ONMfeIOf8jvKQ+bowD39QKsWN+JMOvfATtMjgScpjSaqO1hffQ==",
+      "version": "1.7.6",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-1.7.6.tgz",
+      "integrity": "sha512-focAhU3ciM1/UYBHQVKKzede4zC3y9+IHzU2N/ZF6mbZbhY8S96lOxrO2Y6LMU08+Dbh2xBLmO1bsioLk3Egig==",
       "requires": {
         "@fullhuman/postcss-purgecss": "^2.1.2",
         "autoprefixer": "^9.4.5",
@@ -10983,7 +10983,7 @@
         "color": "^3.1.2",
         "detective": "^5.2.0",
         "fs-extra": "^8.0.0",
-        "lodash": "^4.17.15",
+        "lodash": "^4.17.20",
         "node-emoji": "^1.8.1",
         "normalize.css": "^8.0.1",
         "object-hash": "^2.0.3",
@@ -11019,9 +11019,9 @@
           }
         },
         "caniuse-lite": {
-          "version": "1.0.30001116",
-          "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001116.tgz",
-          "integrity": "sha512-f2lcYnmAI5Mst9+g0nkMIznFGsArRmZ0qU+dnq8l91hymdc2J3SFbiPhOJEeDqC1vtE8nc1qNQyklzB8veJefQ=="
+          "version": "1.0.30001120",
+          "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001120.tgz",
+          "integrity": "sha512-JBP68okZs1X8D7MQTY602jxMYBmXEKOFkzTBaNSkubooMPFOAv2TXWaKle7qgHpjLDhUzA/TMT0qsNleVyXGUQ=="
         },
         "chalk": {
           "version": "4.1.0",
@@ -11046,9 +11046,9 @@
           "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
         },
         "electron-to-chromium": {
-          "version": "1.3.539",
-          "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.539.tgz",
-          "integrity": "sha512-rM0LWDIstdqfaRUADZetNrL6+zd/0NBmavbMEhBXgc2u/CC1d1GaDyN5hho29fFvBiOVFwrSWZkzmNcZnCEDog=="
+          "version": "1.3.555",
+          "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.555.tgz",
+          "integrity": "sha512-/55x3nF2feXFZ5tdGUOr00TxnUjUgdxhrn+eCJ1FAcoAt+cKQTjQkUC5XF4frMWE1R5sjHk+JueuBalimfe5Pg=="
         },
         "fs-extra": {
           "version": "8.1.0",
@@ -11078,6 +11078,11 @@
             "graceful-fs": "^4.1.6"
           }
         },
+        "lodash": {
+          "version": "4.17.20",
+          "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
+          "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
+        },
         "node-releases": {
           "version": "1.1.60",
           "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.60.tgz",
@@ -11097,9 +11102,9 @@
           }
         },
         "supports-color": {
-          "version": "7.1.0",
-          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
-          "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==",
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
           "requires": {
             "has-flag": "^4.0.0"
           }
diff --git a/package.json b/package.json
index 7ef3754..1829eef 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,7 @@
     "access": "public"
   },
   "scripts": {
-    "test": "xo && nyc ava -s",
+    "test": "xo && nyc ava",
     "style": "xo",
     "release": "np"
   },
@@ -49,7 +49,7 @@
     "posthtml": "^0.13.3",
     "posthtml-attrs-parser": "^0.1.1",
     "posthtml-content": "^0.0.3",
-    "posthtml-expressions": "^1.4.6",
+    "posthtml-expressions": "^1.4.7",
     "posthtml-extend": "^0.5.0",
     "posthtml-extra-attributes": "^1.0.0",
     "posthtml-fetch": "^1.2.0",
@@ -64,7 +64,7 @@
     "prevent-widows": "^1.0.2",
     "query-string": "^6.13.1",
     "string-strip-html": "^5.0.0",
-    "tailwindcss": "^1.7.3"
+    "tailwindcss": "^1.7.6"
   },
   "devDependencies": {
     "ava": "^3.12.1",
diff --git a/src/generators/output/to-disk.js b/src/generators/output/to-disk.js
index acb6535..3a21cf6 100644
--- a/src/generators/output/to-disk.js
+++ b/src/generators/output/to-disk.js
@@ -1,8 +1,6 @@
 const path = require('path')
 const fs = require('fs-extra')
-const fm = require('front-matter')
 const glob = require('glob-promise')
-const deepmerge = require('deepmerge')
 const removePlaintextTags = require('../../transformers/plaintext')
 const {asyncForEach, getPropValue, isEmptyObject} = require('../../utils/helpers')
 
@@ -20,117 +18,111 @@ module.exports = async (env, spinner, config) => {
     })
   }
 
-  const css = await Tailwind.compile('', '', {}, config)
-
-  const sourceDir = getPropValue(config, 'build.templates.root') || 'src/templates'
-  const outputDir = getPropValue(config, 'build.destination.path') || `build_${env}`
-  let filetypes = getPropValue(config, 'build.templates.extensions') || 'html'
-
-  await fs.remove(outputDir)
+  const buildTemplates = getPropValue(config, 'build.templates')
+  const templatesConfig = Array.isArray(buildTemplates) ? buildTemplates : [buildTemplates]
 
-  if (Array.isArray(filetypes)) {
-    filetypes = filetypes.join('|')
-  }
+  const parsed = []
+  let files = []
+  const css = await Tailwind.compile('', '', {}, config)
 
-  if (Array.isArray(sourceDir)) {
-    await asyncForEach(sourceDir, async source => {
-      await fs.copy(source, outputDir).catch(error => spinner.warn(error.message))
-    })
-  } else {
-    await fs.copy(sourceDir, outputDir).catch(error => spinner.warn(error.message))
-  }
+  await asyncForEach(templatesConfig, async templateConfig => {
+    const outputDir = getPropValue(templateConfig, 'destination.path') || `build_${env}`
 
-  const templates = await glob(`${outputDir}/**/*.+(${filetypes})`)
+    await fs.remove(outputDir)
 
-  if (templates.length === 0) {
-    spinner
-      .fail(`Error: no files with the .${filetypes} extension found in your \`templates.root\` path${Array.isArray(sourceDir) ? 's' : ''}`)
-      .fail('Build failed')
+    await fs
+      .copy(templateConfig.source, outputDir)
+      .then(async () => {
+        const filetypes = Array.isArray(templateConfig.filetypes) ? templateConfig.filetypes.join('|') : templateConfig.filetypes || 'html'
+        const templates = await glob(`${outputDir}/**/*.+(${filetypes})`)
 
-    throw new Error('no templates found')
-  }
+        if (templates.length === 0) {
+          spinner.warn(`Error: no files with the .${filetypes} extension found in ${templateConfig.source}`)
+          return
+        }
 
-  if (config.events && typeof config.events.beforeCreate === 'function') {
-    await config.events.beforeCreate(config)
-  }
+        if (config.events && typeof config.events.beforeCreate === 'function') {
+          await config.events.beforeCreate(config)
+        }
 
-  await asyncForEach(templates, async file => {
-    let html = await fs.readFile(file, 'utf8')
-    const frontMatter = fm(html)
-    const templateConfig = deepmerge(config, frontMatter.attributes)
-    const events = templateConfig.events || []
-
-    templateConfig.isMerged = true
-    templateConfig.env = env
-
-    try {
-      html = await render(html, {
-        tailwind: {
-          compiled: css
-        },
-        maizzle: {
-          config: templateConfig
-        },
-        ...events
-      })
-    } catch (error) {
-      switch (templateConfig.build.fail) {
-        case 'silent':
-          spinner.warn(`Failed to compile ${file}`)
-          break
-        case 'verbose':
-          spinner.warn(`Failed to compile ${file}`)
-          console.error(error)
-          break
-        default:
-          spinner.fail(`Failed to compile ${file}`)
-          throw error
-      }
-    }
-
-    if (templateConfig.plaintext) {
-      await Plaintext.prepare(html, file, templateConfig)
-        .then(async ({destination, plaintext}) => {
-          await fs.outputFile(destination, plaintext)
-          html = removePlaintextTags(html, config)
+        await asyncForEach(templates, async file => {
+          const html = await fs.readFile(file, 'utf8')
+
+          await render(html, {
+            maizzle: {
+              ...config,
+              env
+            },
+            tailwind: {
+              compiled: css
+            },
+            ...config.events
+          })
+            .then(async ({html, config}) => {
+              const destination = config.permalink || file
+
+              if (templateConfig.plaintext) {
+                await Plaintext.prepare(html, destination, config)
+                  .then(async ({target, plaintext}) => {
+                    await fs.outputFile(target, plaintext)
+                    html = removePlaintextTags(html, config)
+                  })
+              }
+
+              await fs.outputFile(destination, html)
+                .then(async () => {
+                  const extension = getPropValue(templateConfig, 'destination.extension') || 'html'
+
+                  if (extension !== 'html') {
+                    const parts = path.parse(destination)
+                    await fs.move(destination, `${parts.dir}/${parts.name}.${extension}`)
+                  }
+
+                  files.push(file)
+                  parsed.push(file)
+                })
+            })
+            .catch(error => {
+              switch (config.build.fail) {
+                case 'silent':
+                  spinner.warn(`Failed to compile template: ${path.basename(file)}`)
+                  break
+                case 'verbose':
+                  spinner.warn(`Failed to compile template: ${path.basename(file)}`)
+                  console.error(error)
+                  break
+                default:
+                  spinner.fail(`Failed to compile template: ${path.basename(file)}`)
+                  throw error
+              }
+            })
         })
-    }
-
-    const destination = templateConfig.permalink || file
-
-    fs.outputFile(destination, html)
-      .then(async () => {
-        const extension = getPropValue(templateConfig, 'build.destination.extension') || 'html'
 
-        if (extension !== 'html') {
-          const parts = path.parse(destination)
-          await fs.rename(destination, `${parts.dir}/${parts.name}.${extension}`)
+        const assets = {source: '', destination: 'assets', ...getPropValue(templateConfig, 'assets')}
+
+        if (Array.isArray(assets.source)) {
+          await asyncForEach(assets.source, async source => {
+            if (fs.existsSync(source)) {
+              await fs.copy(source, path.join(templateConfig.destination.path, assets.destination)).catch(error => spinner.warn(error.message))
+            }
+          })
+        } else {
+          if (fs.existsSync(assets.source)) {
+            await fs.copy(assets.source, path.join(templateConfig.destination.path, assets.destination)).catch(error => spinner.warn(error.message))
+          }
         }
+
+        await glob(path.join(templateConfig.destination.path, '/**/*.*'))
+          .then(contents => {
+            files = [...new Set([...files, ...contents])]
+          })
       })
+      .catch(error => spinner.warn(error.message))
   })
 
-  const assets = {source: '', destination: 'assets', ...getPropValue(config, 'build.assets')}
-
-  if (Array.isArray(assets.source)) {
-    await asyncForEach(assets.source, async source => {
-      if (fs.existsSync(source)) {
-        await fs.copy(source, `${outputDir}/${assets.destination}`).catch(error => spinner.warn(error.message))
-      }
-    })
-  } else {
-    if (fs.existsSync(assets.source)) {
-      await fs.copy(assets.source, `${outputDir}/${assets.destination}`).catch(error => spinner.warn(error.message))
-    }
-  }
-
-  const files = await glob(`${outputDir}/**/*.*`)
-
   if (config.events && typeof config.events.afterBuild === 'function') {
     await config.events.afterBuild(files)
   }
 
-  return {
-    files,
-    count: templates.length
-  }
+  return parsed
 }
diff --git a/src/generators/output/to-string.js b/src/generators/output/to-string.js
index 21406c9..db02bdd 100644
--- a/src/generators/output/to-string.js
+++ b/src/generators/output/to-string.js
@@ -14,15 +14,13 @@ module.exports = async (html, options) => {
     throw new RangeError('received empty string')
   }
 
-  let config = getPropValue(options, 'maizzle.config') || {}
+  let config = getPropValue(options, 'maizzle') || {}
   const tailwindConfig = getPropValue(options, 'tailwind.config') || {}
   const cssString = getPropValue(options, 'tailwind.css') || '@tailwind components; @tailwind utilities;'
 
-  if (!config.isMerged) {
-    const frontMatter = fm(html)
-    html = frontMatter.body
-    config = deepmerge(config, frontMatter.attributes)
-  }
+  const frontMatter = fm(html)
+  html = frontMatter.body
+  config = deepmerge(config, frontMatter.attributes)
 
   if (typeof getPropValue(options, 'tailwind.compiled') === 'string') {
     config.css = options.tailwind.compiled
@@ -31,7 +29,7 @@ module.exports = async (html, options) => {
   }
 
   if (options && typeof options.beforeRender === 'function') {
-    await options.beforeRender(config)
+    html = await options.beforeRender(html, config)
   }
 
   html = await posthtml(html, config)
@@ -50,5 +48,8 @@ module.exports = async (html, options) => {
     html = await options.afterTransformers(html, config)
   }
 
-  return html
+  return {
+    html,
+    config
+  }
 }
diff --git a/src/generators/plaintext.js b/src/generators/plaintext.js
index f5d8f6d..c594bb1 100644
--- a/src/generators/plaintext.js
+++ b/src/generators/plaintext.js
@@ -4,7 +4,7 @@ const {getPropValue} = require('../utils/helpers')
 
 module.exports.prepare = async (html, filePath, config) => {
   filePath = getPropValue(config, 'permalink') || filePath
-  const destination = path.join(path.dirname(filePath), path.basename(filePath, path.extname(filePath)) + '.txt')
+  const target = path.join(path.dirname(filePath), path.basename(filePath, path.extname(filePath)) + '.txt')
 
   const plaintext = stripHTML(html,
     {
@@ -16,5 +16,5 @@ module.exports.prepare = async (html, filePath, config) => {
       }
     }).result
 
-  return {destination, plaintext}
+  return {target, plaintext}
 }
diff --git a/src/generators/tailwind.js b/src/generators/tailwind.js
index 10ad019..22d473e 100644
--- a/src/generators/tailwind.js
+++ b/src/generators/tailwind.js
@@ -17,10 +17,10 @@ module.exports = {
 
     const purgeCSSOptions = getPropValue(maizzleConfig, 'purgeCSS') || {}
 
-    const templatesRoot = getPropValue(maizzleConfig, 'build.templates.root')
-
-    const templateSources = Array.isArray(templatesRoot) ? templatesRoot.map(item => `${item}/**/*.*`) : [`./${templatesRoot}/**/*.*`]
+    const buildTemplates = getPropValue(maizzleConfig, 'build.templates') || []
+    const templateSources = Array.isArray(buildTemplates) ? buildTemplates.map(({source}) => `${source}/**/*.*`) : [buildTemplates].map(({source}) => `${source}/**/*.*`)
     const tailwindSources = Array.isArray(tailwindConfigObject.purge) ? tailwindConfigObject.purge : (isObject(tailwindConfigObject.purge) ? tailwindConfigObject.purge.content || [] : [])
+
     const extraPurgeSources = purgeCSSOptions.content || []
 
     const purgeSources = [
diff --git a/src/index.js b/src/index.js
index 5c1110d..c9113cd 100644
--- a/src/index.js
+++ b/src/index.js
@@ -10,9 +10,9 @@ const self = module.exports = { // eslint-disable-line
     const spinner = ora('Building emails...').start()
 
     return Output.toDisk(env, spinner, config)
-      .then(response => {
-        spinner.succeed(`Built ${response.count} templates in ${new Date() - start} ms.`)
-        return response.files
+      .then(files => {
+        spinner.succeed(`Built ${files.length} templates in ${new Date() - start} ms.`)
+        return files
       })
       .catch(error => {
         throw error
@@ -26,27 +26,28 @@ const self = module.exports = { // eslint-disable-line
     require('./generators/config')
       .getMerged('local')
       .then(config => {
-        const baseDir = getPropValue(config, 'build.destination.path') || 'build_local'
-        const templatesRoot = getPropValue(config, 'build.templates.root')
+        let templates = getPropValue(config, 'build.templates')
+        templates = Array.isArray(templates) ? templates : [templates]
+
         const bsOptions = {
           notify: false,
           open: false,
           port: 3000,
           server: {
-            baseDir,
+            baseDir: getPropValue(templates[0], 'destination.path') || 'build_local',
             directory: true
           },
           tunnel: false,
           ui: {port: 3001},
           ...getPropValue(config, 'build.browsersync')
         }
-        const watchPaths = bsOptions.watch || ['src/**/*.*', 'tailwind.config.js']
 
-        if (Array.isArray(templatesRoot)) {
-          templatesRoot.forEach(root => watchPaths.push(root))
-        } else if (typeof templatesRoot === 'string') {
-          watchPaths.push(templatesRoot)
-        }
+        const watchPaths = [
+          'src/**/*.*',
+          'tailwind.config.js',
+          ...new Set(templates.map(config => `${getPropValue(config, 'source') || 'src'}/**/*.*`)),
+          ...new Set(bsOptions.watch)
+        ]
 
         bs.create()
 
diff --git a/xo.config.js b/xo.config.js
index 512a24a..98633a1 100644
--- a/xo.config.js
+++ b/xo.config.js
@@ -5,6 +5,7 @@ module.exports = {
     complexity: 0,
     'no-lonely-if': 0,
     'unicorn/no-reduce': 0,
+    'max-nested-callbacks': 0,
     'unicorn/string-content': 0,
     'unicorn/prefer-array-find': 0,
     'promise/prefer-await-to-then': 0,


Test patch:
diff --git a/test/stubs/breaking/bad.html b/test/stubs/breaking/bad.html
new file mode 100644
index 0000000..8ee73dc
--- /dev/null
+++ b/test/stubs/breaking/bad.html
@@ -0,0 +1,5 @@
+---
+title: [THIS] should break
+---
+
+<div>{{ page.title }}</div>
diff --git a/test/stubs/templates/3.mzl b/test/stubs/templates/3.mzl
new file mode 100644
index 0000000..8201b62
--- /dev/null
+++ b/test/stubs/templates/3.mzl
@@ -0,0 +1,1 @@
+<a href="https://example.com">Test</a>
diff --git a/test/test-todisk.js b/test/test-todisk.js
index 90e0869..d64fe9d 100644
--- a/test/test-todisk.js
+++ b/test/test-todisk.js
@@ -20,110 +20,118 @@ test('throws if config cannot be computed', async t => {
   }, {instanceOf: Error, message: `could not load 'config.production.js'`})
 })
 
-test('throws if no templates found', async t => {
-  await t.throwsAsync(async () => {
-    await Maizzle.build('local', {
-      build: {
-        fail: 'silent',
+test('skips if no templates found', async t => {
+  const files = await Maizzle.build('production', {
+    build: {
+      fail: 'silent',
+      templates: {
+        source: 'test/stubs/templates',
+        filetypes: 'fake',
         destination: {
           path: t.context.folder
-        },
-        templates: {
-          root: 'test/stubs/assets'
         }
       }
-    })
-  }, {instanceOf: Error, message: 'no templates found'})
+    }
+  })
+
+  t.is(files.length, 0)
 })
 
 test('outputs files at the correct location', async t => {
   const files = await Maizzle.build('production', {
     build: {
       fail: 'silent',
-      destination: {
-        path: t.context.folder
-      },
       templates: {
-        root: 'test/stubs/templates'
+        source: 'test/stubs/templates',
+        destination: {
+          path: t.context.folder
+        }
       }
     }
   })
 
-  t.is(files.length, 2)
   t.true(fs.pathExistsSync(t.context.folder))
+  t.is(files.length, 2)
 })
 
-test('outputs files at the correct location when multiple template sources are used', async t => {
-  const files = await Maizzle.build('production', {
+test('outputs files at the correct location if multiple template sources are used', async t => {
+  const files = await Maizzle.build('local', {
     build: {
       fail: 'silent',
-      destination: {
-        path: t.context.folder
-      },
-      templates: {
-        root: ['test/stubs/templates', 'test/stubs/empty']
-      }
+      templates: [
+        {
+          source: 'test/stubs/templates',
+          destination: {
+            path: t.context.folder
+          }
+        },
+        {
+          source: 'test/stubs/plaintext',
+          destination: {
+            path: t.context.folder
+          }
+        }
+      ]
     }
   })
 
-  t.is(files.length, 3)
   t.true(fs.pathExistsSync(t.context.folder))
+  t.is(files.length, 3)
 })
 
 test('processes all files in the `filetypes` option', async t => {
   const files = await Maizzle.build('production', {
     build: {
       fail: 'silent',
-      destination: {
-        path: t.context.folder
-      },
       templates: {
-        extensions: ['html', 'mzl'],
-        root: 'test/stubs/templates'
+        source: 'test/stubs/templates',
+        filetypes: ['html', 'mzl'],
+        destination: {
+          path: t.context.folder
+        }
       }
     }
   })
 
-  t.is(files.length, 2)
   t.true(fs.pathExistsSync(t.context.folder))
+  t.is(files.length, 3)
 })
 
 test('outputs files with the correct extension', async t => {
   await Maizzle.build('production', {
     build: {
       fail: 'silent',
-      destination: {
-        path: t.context.folder,
-        extension: 'blade.php'
-      },
       templates: {
-        root: 'test/stubs/empty'
+        source: 'test/stubs/templates',
+        destination: {
+          path: t.context.folder,
+          extension: 'blade.php'
+        }
       }
     }
   })
 
-  // This is currently faking it, need to fix
-  t.true(fs.readdirSync(t.context.folder).includes('empty.html'))
+  t.true(fs.readdirSync(t.context.folder).includes('1.blade.php'))
 })
 
 test('outputs plaintext files if option is enabled', async t => {
-  const files = await Maizzle.build('production', {
-    plaintext: true,
+  await Maizzle.build('production', {
     fail: 'silent',
     build: {
-      destination: {
-        path: t.context.folder
-      },
       templates: {
-        root: 'test/stubs/plaintext/'
+        source: 'test/stubs/plaintext',
+        destination: {
+          path: t.context.folder
+        },
+        plaintext: true
       }
     }
   })
 
-  const plaintext = files.filter(file => file.includes('.txt'))
-  const html = files.filter(file => file.includes('.html'))
-  const plaintextContent = await fs.readFile(plaintext[0], 'utf8')
-  const htmlContent = await fs.readFile(html[0], 'utf8')
+  const plaintext = fs.readdirSync(t.context.folder).filter(file => file.includes('.txt'))
+  const html = fs.readdirSync(t.context.folder).filter(file => file.includes('.html'))
+  const plaintextContent = await fs.readFile(`${t.context.folder}/${plaintext[0]}`, 'utf8')
+  const htmlContent = await fs.readFile(`${t.context.folder}/${html[0]}`, 'utf8')
 
   t.is(plaintext.length, 1)
   t.is(plaintextContent, 'Show in HTML\nShow in plaintext')
@@ -134,148 +142,157 @@ test('copies assets to destination', async t => {
   await Maizzle.build('production', {
     build: {
       fail: 'silent',
-      assets: {
-        source: 'test/stubs/assets',
-        destination: 'images'
-      },
-      destination: {
-        path: t.context.folder
-      },
       templates: {
-        root: 'test/stubs/templates'
+        source: 'test/stubs/templates',
+        destination: {
+          path: t.context.folder
+        },
+        assets: {
+          source: 'test/stubs/assets',
+          destination: 'images'
+        }
       }
     }
   })
 
   t.is(fs.pathExistsSync(`${t.context.folder}/images`), true)
+  t.is(fs.readdirSync(`${t.context.folder}/images`).length, 1)
 })
 
-test('throws and exits if a template cannot be rendered and `fail` option is undefined', async t => {
-  await t.throwsAsync(async () => {
-    await Maizzle.build('production', {
-      build: {
+test('runs the `beforeCreate` event', async t => {
+  await Maizzle.build('production', {
+    build: {
+      fail: 'silent',
+      templates: {
+        source: 'test/stubs/events',
         destination: {
           path: t.context.folder
-        },
-        templates: {
-          root: 'test/stubs/empty'
         }
       }
-    })
-  }, {instanceOf: RangeError, message: 'received empty string'})
+    },
+    events: {
+      beforeCreate(config) {
+        config.foo = 'bar'
+      }
+    }
+  })
+
+  const html = fs.readFileSync(`${t.context.folder}/${fs.readdirSync(t.context.folder)[0]}`, 'utf8').trim()
+
+  t.is(html, 'Foo is bar')
 })
 
-test('warns if a template cannot be rendered and `fail` option is `verbose`', async t => {
+test('runs the `afterBuild` event', async t => {
   const files = await Maizzle.build('production', {
     build: {
-      fail: 'verbose',
-      destination: {
-        path: t.context.folder
-      },
+      fail: 'silent',
       templates: {
-        root: 'test/stubs/empty'
+        source: 'test/stubs/templates',
+        destination: {
+          path: t.context.folder
+        }
+      }
+    },
+    events: {
+      afterBuild(files) {
+        t.context.afterBuild = files
       }
     }
   })
 
-  t.true(files[0].includes('empty.html'))
+  const getIntersection = (a, ...array) => [...new Set(a)].filter(v => array.every(b => b.includes(v)))
+
+  t.deepEqual(getIntersection(t.context.afterBuild, files), files)
 })
 
-test('warns if a template cannot be rendered and `fail` option is `silent`', async t => {
-  const files = await Maizzle.build('production', {
+test('supports multiple asset paths', async t => {
+  await Maizzle.build('production', {
     build: {
       fail: 'silent',
-      destination: {
-        path: t.context.folder
-      },
       templates: {
-        root: 'test/stubs/empty'
+        source: 'test/stubs/templates',
+        destination: {
+          path: t.context.folder
+        },
+        assets: {
+          source: ['test/stubs/assets', 'test/stubs/plaintext', 'test/stubs/invalid'],
+          destination: 'extras'
+        }
       }
     }
   })
 
-  t.true(files[0].includes('empty.html'))
+  t.true(fs.existsSync(`${t.context.folder}/extras/foo.bar`))
+  t.true(fs.existsSync(`${t.context.folder}/extras/plaintext.html`))
+  t.false(fs.existsSync(`${t.context.folder}/extras/invalid`))
 })
 
-test('runs the `beforeCreate` event', async t => {
+test('warns if a template cannot be rendered and `fail` option is undefined', async t => {
   const files = await Maizzle.build('production', {
     build: {
-      fail: 'silent',
-      destination: {
-        path: t.context.folder
-      },
       templates: {
-        root: 'test/stubs/events'
-      }
-    },
-    events: {
-      beforeCreate(config) {
-        config.foo = 'bar'
+        source: 'test/stubs/breaking',
+        destination: {
+          path: t.context.folder
+        }
       }
     }
   })
 
-  const html = fs.readFileSync(files[0], 'utf8').trim()
-
-  t.is(html, 'Foo is bar')
+  t.false(files.includes('empty.html'))
 })
 
-test('runs the `afterBuild` event', async t => {
+test('warns if a template cannot be rendered and `fail` option is `verbose`', async t => {
   const files = await Maizzle.build('production', {
     build: {
-      fail: 'silent',
-      destination: {
-        path: t.context.folder
-      },
+      fail: 'verbose',
       templates: {
-        root: 'test/stubs/templates'
-      }
-    },
-    events: {
-      afterBuild(files) {
-        t.context.afterBuild = files
+        source: 'test/stubs/breaking',
+        destination: {
+          path: t.context.folder
+        }
       }
     }
   })
 
-  t.deepEqual(t.context.afterBuild, files)
+  t.false(files.includes('empty.html'))
 })
 
-test('supports multiple asset paths', async t => {
-  await Maizzle.build('production', {
+test('warns if a template cannot be rendered and `fail` option is `silent`', async t => {
+  const files = await Maizzle.build('production', {
     build: {
       fail: 'silent',
-      destination: {
-        path: t.context.folder
-      },
       templates: {
-        root: 'test/stubs/templates'
-      },
-      assets: {
-        source: ['test/stubs/assets', 'test/stubs/plaintext', 'test/stubs/invalid'],
-        destination: 'extras'
+        source: 'test/stubs/breaking',
+        destination: {
+          path: t.context.folder
+        }
       }
     }
   })
 
-  t.true(fs.existsSync(`${t.context.folder}/extras/foo.bar`))
-  t.true(fs.existsSync(`${t.context.folder}/extras/plaintext.html`))
-  t.false(fs.existsSync(`${t.context.folder}/extras/invalid`))
+  t.false(files.includes('empty.html'))
 })
 
 test('spins up local development server', async t => {
-  await fs.copy('test/stubs/templates', 'src/templates')
-
   await Maizzle.serve({
     build: {
       templates: {
-        root: 'test/stubs/templates'
+        source: 'test/stubs/templates',
+        destination: {
+          path: t.context.folder
+        }
       }
     }
   })
 
-  t.true(fs.existsSync('build_local'))
+  t.true(fs.existsSync(t.context.folder))
 
-  await fs.remove('build_local')
-  await fs.remove('src/templates')
+  await fs.remove(t.context.folder)
+})
+
+test('throws if it cannot spin up local development server', async t => {
+  await t.throwsAsync(async () => {
+    await Maizzle.serve({})
+  }, {instanceOf: TypeError, message: `Cannot read property 'source' of undefined`})
 })
diff --git a/test/test-tostring.js b/test/test-tostring.js
index 2863875..f43a07f 100644
--- a/test/test-tostring.js
+++ b/test/test-tostring.js
@@ -7,7 +7,7 @@ const {readFileSync} = require('fs')
 const fixture = file => readFileSync(join(__dirname, 'fixtures', `${file}.html`), 'utf8')
 const expected = file => readFileSync(join(__dirname, 'expected', `${file}.html`), 'utf8')
 
-const renderString = (string, options = {}) => Maizzle.render(string, options).then(html => html)
+const renderString = (string, options = {}) => Maizzle.render(string, options).then(({html}) => html)
 
 test('compiles HTML string if no options are passed', async t => {
   let html = await renderString(fixture('basic'))
@@ -37,8 +37,10 @@ test('throws if first argument is an empty string', async t => {
 
 test('runs the `beforeRender` event', async t => {
   const html = await renderString(`<div>{{ page.foo }}</div>`, {
-    beforeRender(config) {
+    beforeRender(html, config) {
       config.foo = 'bar'
+
+      return html
     }
   })
 
@@ -62,9 +64,7 @@ test('runs the `afterRender` event', async t => {
 test('runs the `afterTransformers` event', async t => {
   const result = await renderString(`<div>foo</div>`, {
     maizzle: {
-      config: {
-        title: 'bar'
-      }
+      title: 'bar'
     },
     afterTransformers(html, config) {
       return html.replace('foo', config.title)
diff --git a/test/test-transformers.js b/test/test-transformers.js
index 95ac58b..46a8366 100644
--- a/test/test-transformers.js
+++ b/test/test-transformers.js
@@ -21,17 +21,15 @@ const maizzleConfig = (options = {}) => {
       }
     },
     maizzle: {
-      config: {
-        env: 'node',
-        ...options
-      }
+      env: 'node',
+      ...options
     }
   }
 }
 
 const processFile = (t, name, options = {}, log = false) => {
   return Maizzle.render(fixture(name), {...options})
-    .then(result => log ? console.log(result) : clean(result))
+    .then(({html}) => log ? console.log(html) : clean(html))
     .then(html => t.is(html, expected(name).trim()))
 }