Repository: ProjectEvergreen/greenwood (js)

Issue:
introduce Single Page Application (SPA) configuration mode 
## Type of Change
- [x] New Feature Request
- [ ] Documentation / Website
- [ ] Improvement / Suggestion
- [ ] Bug
- [ ] Other (please clarify below)

## Summary
Greenwood should be able to support building and deploying a single page application.  This would also allow for making serialization / pre-rendering optional since you don't need to SSR a single page application (or if you are making an entirely HTML content based site).

## Details
Effectively I think it should just be making sure that this kind of directory layout works
```sh
src/
  index.html
  index.js
```

Can use [**contributary.community**](https://github.com/ContributaryCommunity/www.contributary.community) project as the pilot.

> _With this enabled, we should probably deprecate [**create-evergreen-app**](https://github.com/ProjectEvergreen/create-evergreen-app) then?_

Pull request description:
enable a Single Page Applictation (spa) mode
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #518 

## Summary of Changes
1. Added an additional `mode` called _spa_ and adjusted scaffolding as needed
1. Added test cases
1. Added **babel-eslint** for dynamic `import()` linting support

> _Intentionally committed with a _spa/_ directory for easy testing / validation.  Will be removed before merging._

## TODO
1. [x] SPA Example with 
    - ~~client side router~~
    - ~~route based code splitting~~
1. [x] Tests
1. [x] Documentation
1. [x] Needs #580 to be merged so "spa" mode can turn off pre-rendering (maybe something to consider as part of  https://github.com/ProjectEvergreen/greenwood/discussions/545 ?)
1. [x] Support fallback _index.html_ handling for serve tasks
1. [x] Remove demo code

Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index e8fb2385..f8b28f22 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,4 +1,5 @@
 module.exports = {
+  parser: 'babel-eslint',
   parserOptions: {
     ecmaVersion: 2018,
     sourceType: 'module'
diff --git a/nyc.config.js b/nyc.config.js
index c9fe4657..467df268 100644
--- a/nyc.config.js
+++ b/nyc.config.js
@@ -20,7 +20,7 @@ module.exports = {
   checkCoverage: true,
 
   statements: 80,
-  branches: 60,
+  branches: 65,
   functions: 80,
   lines: 80,
 
diff --git a/package.json b/package.json
index c035fe9b..f08598cc 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
   },
   "devDependencies": {
     "@ls-lint/ls-lint": "^1.9.2",
+    "babel-eslint": "^10.1.0",
     "chai": "^4.2.0",
     "eslint": "^6.8.0",
     "gallinago": "^0.3.0",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a7c3d337..842698da 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -53,10 +53,12 @@
   "devDependencies": {
     "@mapbox/rehype-prism": "^0.5.0",
     "lit-element": "^2.4.0",
+    "lit-redux-router": "^0.17.1",
     "lodash-es": "^4.17.20",
     "postcss-nested": "^4.1.2",
     "pwa-helpers": "^0.9.1",
     "redux": "^4.0.5",
+    "redux-thunk": "^2.3.0",
     "rehype-autolink-headings": "^4.0.0",
     "rehype-slug": "^3.0.0",
     "simpledotcss": "^1.0.0"
diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js
index ff211ee7..1047aaac 100644
--- a/packages/cli/src/commands/build.js
+++ b/packages/cli/src/commands/build.js
@@ -12,6 +12,7 @@ module.exports = runProductionBuild = async () => {
 
     try {
       const compilation = await generateCompilation();
+      const { prerender } = compilation.config;
       const port = compilation.config.devServer.port;
       const outputDir = compilation.context.outputDir;
 
@@ -19,7 +20,7 @@ module.exports = runProductionBuild = async () => {
         fs.mkdirSync(outputDir);
       }
       
-      if (compilation.config.prerender) {
+      if (prerender) {
         await new Promise((resolve) => {
           devServer(compilation).listen(port, async () => {
             console.info(`Started local development server at localhost:${port}`);
diff --git a/packages/cli/src/lifecycles/config.js b/packages/cli/src/lifecycles/config.js
index 1483e112..f99269e0 100644
--- a/packages/cli/src/lifecycles/config.js
+++ b/packages/cli/src/lifecycles/config.js
@@ -1,7 +1,7 @@
 const fs = require('fs');
 const path = require('path');
 
-const modes = ['ssg', 'mpa'];
+const modes = ['ssg', 'mpa', 'spa'];
 const optimizations = ['default', 'none', 'static', 'inline'];
 
 const defaultConfig = {
@@ -129,6 +129,11 @@ module.exports = readAndMergeConfig = async() => {
             reject(`Error: greenwood.config.js prerender must be a boolean; true or false.  Passed value was typeof: ${typeof prerender}`);
           }
         }
+        
+        // SPA should _not_ prerender if user has specified prerender should be true
+        if (prerender === undefined && mode === 'spa') {
+          customConfig.prerender = false;
+        }
       }
 
       resolve({ ...defaultConfig, ...customConfig });
diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js
index 75eac9de..9b0586aa 100644
--- a/packages/cli/src/lifecycles/graph.js
+++ b/packages/cli/src/lifecycles/graph.js
@@ -7,8 +7,13 @@ module.exports = generateGraph = async (compilation) => {
 
   return new Promise(async (resolve, reject) => {
     try {
-      const { context } = compilation;
-      const { pagesDir } = context;
+      const { context, config } = compilation;
+      const { pagesDir, userWorkspace } = context;
+      let graph = [{
+        path: '/',
+        route: '/',
+        data: {}
+      }];
 
       const walkDirectoryForPages = function(directory, pages = []) {
         
@@ -123,12 +128,16 @@ module.exports = generateGraph = async (compilation) => {
         return pages;
       };
 
-      const graph = fs.existsSync(pagesDir)
-        ? walkDirectoryForPages(pagesDir)
-        : [{
-          path: '/',
-          route: '/' 
+      if (config.mode === 'spa') {
+        graph = [{
+          ...graph[0],
+          path: `${userWorkspace}/index.html`
         }];
+      } else {
+        graph = fs.existsSync(pagesDir)
+          ? walkDirectoryForPages(pagesDir)
+          : graph;
+      }
 
       compilation.graph = graph;
 
diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js
index 5a431442..967ccfdf 100644
--- a/packages/cli/src/lifecycles/serve.js
+++ b/packages/cli/src/lifecycles/serve.js
@@ -146,10 +146,15 @@ function getProdServer(compilation) {
 
   app.use(async ctx => {
     const { outputDir } = compilation.context;
+    const { mode } = compilation.config;
     const url = ctx.request.url.replace(/\?(.*)/, ''); // get rid of things like query string parameters
 
     if (url.endsWith('/') || url.endsWith('.html')) {
-      const barePath = url.endsWith('/') ? path.join(url, 'index.html') : url;
+      const barePath = mode === 'spa'
+        ? 'index.html'
+        : url.endsWith('/')
+          ? path.join(url, 'index.html')
+          : url;
       const contents = await fs.promises.readFile(path.join(outputDir, barePath), 'utf-8');
       
       ctx.set('Content-Type', 'text/html');
diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js
index 6a45d85a..0ffe32f4 100644
--- a/packages/cli/src/plugins/resource/plugin-standard-html.js
+++ b/packages/cli/src/plugins/resource/plugin-standard-html.js
@@ -240,6 +240,7 @@ class StandardHtmlResource extends ResourceInterface {
       try {
         const config = Object.assign({}, this.compilation.config);
         const { userWorkspace, projectDirectory } = this.compilation.context;
+        const { mode } = this.compilation.config;
         const normalizedUrl = this.getRelativeUserworkspaceUrl(url);
         let customImports;
 
@@ -252,7 +253,7 @@ class StandardHtmlResource extends ResourceInterface {
         const isMarkdownContent = fs.existsSync(`${barePath}.md`)
           || fs.existsSync(`${barePath.substring(0, barePath.lastIndexOf('/index'))}.md`)
           || fs.existsSync(`${barePath.replace('/index', '.md')}`);
-
+        
         if (isMarkdownContent) {
           const markdownPath = fs.existsSync(`${barePath}.md`)
             ? `${barePath}.md`
@@ -302,9 +303,14 @@ class StandardHtmlResource extends ResourceInterface {
             }
           }
         }
-        
-        body = getPageTemplate(barePath, userWorkspace, template);
-        body = getAppTemplate(body, userWorkspace, customImports);
+      
+        if (mode === 'spa') {
+          body = fs.readFileSync(this.compilation.graph[0].path, 'utf-8');
+        } else {
+          body = getPageTemplate(barePath, userWorkspace, template);
+        }
+
+        body = getAppTemplate(body, userWorkspace, customImports);  
         body = getUserScripts(body, projectDirectory);
         body = getMetaContent(normalizedUrl, config, body);
         
diff --git a/www/pages/docs/configuration.md b/www/pages/docs/configuration.md
index d1a3e1ee..274c4c89 100644
--- a/www/pages/docs/configuration.md
+++ b/www/pages/docs/configuration.md
@@ -109,8 +109,9 @@ Greenwood provides a couple different "modes" by which you can indicate the type
 
 | Option | Description | Use Cases |
 | ------ | ----------- | --------- |
-|`mpa` | Assumes an `ssg` based site, but additionally adds a client side router to create a _Multi Page Application_. | Any `ssg` based site where content lines up well with templates to help with transition between similar pages, like blogs and documentation sites. |
 |`ssg` | (_Default_) Generates a pre-rendered statically generated website from [pages and templates](/docs/layouts/)at build time. | Blog, portfolio, anything really! |
+|`mpa` | Assumes an `ssg` based site, but additionally adds a client side router to create a _Multi Page Application_. | Any `ssg` based site where content lines up well with templates to help with transition between similar pages, like blogs and documentation sites. |
+|`spa` | For building and bundling a _Single Page Application (SPA)_ with client side routing and a [single _index.html_ file](/docs/layouts/#single-page-applications). | Any type of client side only rendered application. |
 
 #### Example
 ```js
diff --git a/www/pages/docs/layouts.md b/www/pages/docs/layouts.md
index a3682bed..c60ab6e2 100644
--- a/www/pages/docs/layouts.md
+++ b/www/pages/docs/layouts.md
@@ -174,8 +174,25 @@ And the directory structure for it:
       │   ├── index.md
       ├── styles
       │   └── theme.css
-      ├── templates/
+      └── templates/
           └── app.html
 ```
 
-> _It is recommended to use the "file" based approaches for loading JavaScript and CSS; `<script src="...">` and `<link rel="stylesheet" href="...">` respectively.  This will allow Greenwood to optimize these assets during both development and build workflows.  However, inline `<script>` and `<style>` can both be super helpful for one-off cases, so in those cases we recommend only relying on "vanilla" JS / CSS syntax. For more context, examples, and background information, you can [review this PR](https://github.com/ProjectEvergreen/greenwood/pull/472)._
\ No newline at end of file
+> _It is recommended to use the "file" based approaches for loading JavaScript and CSS; `<script src="...">` and `<link rel="stylesheet" href="...">` respectively.  This will allow Greenwood to optimize these assets during both development and build workflows.  However, inline `<script>` and `<style>` can both be super helpful for one-off cases, so in those cases we recommend only relying on "vanilla" JS / CSS syntax. For more context, examples, and background information, you can [review this PR](https://github.com/ProjectEvergreen/greenwood/pull/472)._
+
+### Single Page Applications
+
+If you would like to build a SPA and only deal with client side rendering, Greenwood can [support that](/docs/configuration#mode)!  As the name implies, your layout will be slightly different in this case.  Below is an example layout of a SPA, and you can see a working example in our [test suite](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/cli/test/cases/build.config.mode-spa) where we validate using [**lit-redux-router**](https://github.com/fernandopasik/lit-redux-router) with route based code splitting.
+
+```shell
+
+└── src
+      ├── components
+      │   └── app-footer.js
+      ├── routes
+      │   ├── about.js
+      │   └── home.js
+      ├── styles.css
+      ├── index.js
+      └── index.html
+```
\ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index 29a86bdd..9c8b5c37 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -166,6 +166,15 @@
     jsesc "^2.5.1"
     source-map "^0.5.0"
 
+"@babel/generator@^7.14.0":
+  version "7.14.1"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.14.1.tgz#1f99331babd65700183628da186f36f63d615c93"
+  integrity sha512-TMGhsXMXCP/O1WtQmZjpEYDhCYC9vFhayWZPJSZCGkPJgUqX0rF0wwtrYvnzVxIjcF80tkUertXVk5cwqi5cAQ==
+  dependencies:
+    "@babel/types" "^7.14.1"
+    jsesc "^2.5.1"
+    source-map "^0.5.0"
+
 "@babel/helper-annotate-as-pure@^7.12.13":
   version "7.12.13"
   resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.13.tgz#0f58e86dfc4bb3b1fcd7db806570e177d439b6ab"
@@ -444,6 +453,11 @@
   resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed"
   integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==
 
+"@babel/helper-validator-identifier@^7.14.0":
+  version "7.14.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.0.tgz#d26cad8a47c65286b15df1547319a5d0bcf27288"
+  integrity sha512-V3ts7zMSu5lfiwWDVWzRDGIN+lnCEUdaXgtVHJgLb1rGaA6jMrtB9EmE7L18foXJIE8Un/A/h6NJfGQp/e1J4A==
+
 "@babel/helper-validator-option@^7.12.17":
   version "7.12.17"
   resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.17.tgz#d1fbf012e1a79b7eebbfdc6d270baaf8d9eb9831"
@@ -515,6 +529,11 @@
   resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.4.tgz#340211b0da94a351a6f10e63671fa727333d13ab"
   integrity sha512-uvoOulWHhI+0+1f9L4BoozY7U5cIkZ9PgJqvb041d6vypgUmtVPG4vmGm4pSggjl8BELzvHyUeJSUyEMY6b+qA==
 
+"@babel/parser@^7.14.0", "@babel/parser@^7.7.0":
+  version "7.14.1"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.14.1.tgz#1bd644b5db3f5797c4479d89ec1817fe02b84c47"
+  integrity sha512-muUGEKu8E/ftMTPlNp+mc6zL3E9zKWmF5sDHZ5MSsoTP9Wyz64AhEf9kD08xYJ7w6Hdcu8H550ircnPyWSIF0Q==
+
 "@babel/plugin-proposal-async-generator-functions@^7.13.5":
   version "7.13.5"
   resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.13.5.tgz#69e3fbb9958949b09036e27b26eba1aafa1ba3db"
@@ -1152,6 +1171,20 @@
     globals "^11.1.0"
     lodash "^4.17.19"
 
+"@babel/traverse@^7.7.0":
+  version "7.14.0"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.14.0.tgz#cea0dc8ae7e2b1dec65f512f39f3483e8cc95aef"
+  integrity sha512-dZ/a371EE5XNhTHomvtuLTUyx6UEoJmYX+DT5zBCQN3McHemsuIaKKYqsc/fs26BEkHs/lBZy0J571LP5z9kQA==
+  dependencies:
+    "@babel/code-frame" "^7.12.13"
+    "@babel/generator" "^7.14.0"
+    "@babel/helper-function-name" "^7.12.13"
+    "@babel/helper-split-export-declaration" "^7.12.13"
+    "@babel/parser" "^7.14.0"
+    "@babel/types" "^7.14.0"
+    debug "^4.1.0"
+    globals "^11.1.0"
+
 "@babel/types@^7.10.4", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.4.0":
   version "7.11.5"
   resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d"
@@ -1188,6 +1221,14 @@
     lodash "^4.17.19"
     to-fast-properties "^2.0.0"
 
+"@babel/types@^7.14.0", "@babel/types@^7.14.1", "@babel/types@^7.7.0":
+  version "7.14.1"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.14.1.tgz#095bd12f1c08ab63eff6e8f7745fa7c9cc15a9db"
+  integrity sha512-S13Qe85fzLs3gYRUnrpyeIrBJIMYv33qSTg1qoBwiG6nPKwUWAD9odSzWhEedpwOIzSEI6gbdQIWEMiCI42iBA==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.14.0"
+    to-fast-properties "^2.0.0"
+
 "@csstools/convert-colors@^1.4.0":
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/@csstools/convert-colors/-/convert-colors-1.4.0.tgz#ad495dc41b12e75d588c6db8b9834f08fa131eb7"
@@ -3150,6 +3191,18 @@ aws4@^1.8.0:
   resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.10.1.tgz#e1e82e4f3e999e2cfd61b161280d16a111f86428"
   integrity sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA==
 
+babel-eslint@^10.1.0:
+  version "10.1.0"
+  resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232"
+  integrity sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    "@babel/parser" "^7.7.0"
+    "@babel/traverse" "^7.7.0"
+    "@babel/types" "^7.7.0"
+    eslint-visitor-keys "^1.0.0"
+    resolve "^1.12.0"
+
 babel-plugin-dynamic-import-node@^2.3.3:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"
@@ -4887,7 +4940,7 @@ eslint-utils@^1.4.3:
   dependencies:
     eslint-visitor-keys "^1.1.0"
 
-eslint-visitor-keys@^1.1.0:
+eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e"
   integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==
@@ -7264,6 +7317,13 @@ lit-html@^1.1.1:
   resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-1.3.0.tgz#c80f3cc5793a6dea6c07172be90a70ab20e56034"
   integrity sha512-0Q1bwmaFH9O14vycPHw8C/IeHMk/uSDldVLIefu/kfbTBGIc44KGH6A8p1bDfxUfHdc8q6Ct7kQklWoHgr4t1Q==
 
+lit-redux-router@^0.17.1:
+  version "0.17.1"
+  resolved "https://registry.yarnpkg.com/lit-redux-router/-/lit-redux-router-0.17.1.tgz#ed0874c06b29c5b2ec7cfda37141adde34f8ce25"
+  integrity sha512-6Dgq087OF5DggJkEJcyOK9LjVDD4hZWgtwkQ0itdoGs4Hg7hVclIzcxJk/UgAZpR84mFkXVEubEtIAuXXaZJ0w==
+  dependencies:
+    regexparam "^1.3.0"
+
 livereload-js@^3.1.0:
   version "3.3.1"
   resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-3.3.1.tgz#61f887468086762e61fb2987412cf9d1dda99202"
@@ -9968,6 +10028,11 @@ redent@^3.0.0:
     indent-string "^4.0.0"
     strip-indent "^3.0.0"
 
+redux-thunk@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622"
+  integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==
+
 redux@^4.0.5:
   version "4.0.5"
   resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f"
@@ -10017,6 +10082,11 @@ regex-not@^1.0.0, regex-not@^1.0.2:
     extend-shallow "^3.0.2"
     safe-regex "^1.1.0"
 
+regexparam@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/regexparam/-/regexparam-1.3.0.tgz#2fe42c93e32a40eff6235d635e0ffa344b92965f"
+  integrity sha512-6IQpFBv6e5vz1QAqI+V4k8P2e/3gRrqfCJ9FI+O1FLQTO+Uz6RXZEZOPmTJ6hlGj7gkERzY5BRCv09whKP96/g==
+
 regexpp@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
@@ -10280,7 +10350,7 @@ resolve@^1.10.0, resolve@^1.16.1:
   dependencies:
     path-parse "^1.0.6"
 
-resolve@^1.14.2, resolve@^1.3.2:
+resolve@^1.12.0, resolve@^1.14.2, resolve@^1.3.2:
   version "1.20.0"
   resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
   integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==


Test patch:
diff --git a/packages/cli/test/cases/build.config.error-mode/build.config.error-mode.spec.js b/packages/cli/test/cases/build.config.error-mode/build.config.error-mode.spec.js
index b81a05ab..97ff2d10 100644
--- a/packages/cli/test/cases/build.config.error-mode/build.config.error-mode.spec.js
+++ b/packages/cli/test/cases/build.config.error-mode/build.config.error-mode.spec.js
@@ -38,7 +38,7 @@ describe('Build Greenwood With: ', function() {
         await runner.setup(outputPath);
         await runner.runCommand(cliPath, 'build');
       } catch (err) {
-        expect(err).to.contain('Error: provided mode "loremipsum" is not supported.  Please use one of: ssg, mpa.');
+        expect(err).to.contain('Error: provided mode "loremipsum" is not supported.  Please use one of: ssg, mpa, spa.');
       }
     });
   });
diff --git a/packages/cli/test/cases/build.config.mode-mpa/build.config.mode-mpa.spec.js b/packages/cli/test/cases/build.config.mode-mpa/build.config.mode-mpa.spec.js
index 5b711b4d..c05b514c 100644
--- a/packages/cli/test/cases/build.config.mode-mpa/build.config.mode-mpa.spec.js
+++ b/packages/cli/test/cases/build.config.mode-mpa/build.config.mode-mpa.spec.js
@@ -56,6 +56,8 @@ describe('Build Greenwood With: ', function() {
       await runner.runCommand(cliPath, 'build');
     });
 
+    runSmokeTest(['public', 'index'], LABEL);
+
     describe('MPA (Multi Page Application)', function() {
       let dom;
       let aboutDom;
diff --git a/packages/cli/test/cases/build.config.mode-spa/build.config.mode-spa.spec.js b/packages/cli/test/cases/build.config.mode-spa/build.config.mode-spa.spec.js
new file mode 100644
index 00000000..2ebbfe9c
--- /dev/null
+++ b/packages/cli/test/cases/build.config.mode-spa/build.config.mode-spa.spec.js
@@ -0,0 +1,244 @@
+/*
+ * Use Case
+ * Run Greenwood with mode setting in Greenwood config set to spa.
+ *
+ * User Result
+ * Should generate a bare bones Greenwood build for hosting a Single Page Application.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * {
+ *   mode: 'spa'
+ * }
+ *
+ * User Workspace
+ * Greenwood default w/ single index.html file
+ *  src/
+ *   components/
+ *     footer.js
+ *   routes/
+ *     about.js
+ *     home.js
+ *   index.js
+ *   index.html
+ */
+const expect = require('chai').expect;
+const glob = require('glob-promise');
+const { JSDOM } = require('jsdom');
+const path = require('path');
+const { getSetupFiles, getDependencyFiles, getOutputTeardownFiles } = require('../../../../../test/utils');
+const runSmokeTest = require('../../../../../test/smoke-test');
+const Runner = require('gallinago').Runner;
+
+describe('Build Greenwood With: ', function() {
+  const LABEL = 'Custom Mode';
+  const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js');
+  const outputPath = __dirname;
+  let runner;
+
+  before(async function() {
+    this.context = { 
+      publicDir: path.join(outputPath, 'public') 
+    };
+    runner = new Runner();
+  });
+
+  describe(LABEL, function() {
+
+    before(async function() {
+      const litElementLibs = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-element/lib/*.js`, 
+        `${outputPath}/node_modules/lit-element/lib/`
+      );
+      const litElement = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-element/lit-element.js`, 
+        `${outputPath}/node_modules/lit-element/`
+      );
+      const litElementPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-element/package.json`, 
+        `${outputPath}/node_modules/lit-element/`
+      );
+      const litHtml = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-html/lit-html.js`, 
+        `${outputPath}/node_modules/lit-html/`
+      );
+      const litHtmlPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-html/package.json`, 
+        `${outputPath}/node_modules/lit-html/`
+      );
+      const litHtmlLibs = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-html/lib/*.js`, 
+        `${outputPath}/node_modules/lit-html/lib/`
+      );
+      const litHtmlDirectives = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-html/directives/*.js`,
+        `${outputPath}/node_modules/lit-html/directives/`
+      );
+      const litReduxRouterPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-redux-router/package.json`,
+        `${outputPath}/node_modules/lit-redux-router/`
+      );
+      const litReduxRouter = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-redux-router/*.js`,
+        `${outputPath}/node_modules/lit-redux-router/`
+      );
+      const litReduxRouterLibs = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-redux-router/lib/*.js`,
+        `${outputPath}/node_modules/lit-redux-router/lib/`
+      );
+      const pwaHelpersLibs = await getDependencyFiles(
+        `${process.cwd()}/node_modules/pwa-helpers/*.js`,
+        `${outputPath}/node_modules/pwa-helpers/`
+      );
+      const pwaHelpersPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/pwa-helpers/package.json`,
+        `${outputPath}/node_modules/pwa-helpers/`
+      );
+      const reduxLibs = await getDependencyFiles(
+        `${process.cwd()}/node_modules/redux/es/redux.mjs`,
+        `${outputPath}/node_modules/redux/es`
+      );
+      const reduxPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/redux/package.json`,
+        `${outputPath}/node_modules/redux/`
+      );
+      const regexParam = await getDependencyFiles(
+        `${process.cwd()}/node_modules/regexparam/dist/*`,
+        `${outputPath}/node_modules/regexparam/dist/`
+      );
+      const regexParamPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/regexparam/package.json`,
+        `${outputPath}/node_modules/regexparam/`
+      );
+      const looseLibs = await getDependencyFiles(
+        `${process.cwd()}/node_modules/loose-envify/index.js`,
+        `${outputPath}/node_modules/loose-envify`
+      );
+      const looseLibsPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/loose-envify/package.json`,
+        `${outputPath}/node_modules/loose-envify/`
+      );
+      const tokensLibs = await getDependencyFiles(
+        `${process.cwd()}/node_modules/js-tokens/index.js`,
+        `${outputPath}/node_modules/js-tokens`
+      );
+      const tokensLibsPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/js-tokens/package.json`,
+        `${outputPath}/node_modules/js-tokens/`
+      );
+      const symbolLibs = await getDependencyFiles(
+        `${process.cwd()}/node_modules/symbol-observable/es/*.js`,
+        `${outputPath}/node_modules/symbol-observable/es`
+      );
+      const symobolLibsPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/symbol-observable/package.json`,
+        `${outputPath}/node_modules/symbol-observable/`
+      );
+      const reduxThunk = await getDependencyFiles(
+        `${process.cwd()}/node_modules/redux-thunk/es/*.js`,
+        `${outputPath}/node_modules/redux-thunk/es`
+      );
+      const reduxThunkPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/redux-thunk/package.json`,
+        `${outputPath}/node_modules/redux-thunk/`
+      );
+
+      await runner.setup(outputPath, [
+        ...getSetupFiles(outputPath),
+        ...litElementLibs,
+        ...litElement,
+        ...litElementPackageJson,
+        ...litHtmlPackageJson,
+        ...litHtml,
+        ...litHtmlLibs,
+        ...litHtmlDirectives,
+        ...litReduxRouterPackageJson,
+        ...litReduxRouter,
+        ...litReduxRouterLibs,
+        ...pwaHelpersLibs,
+        ...pwaHelpersPackageJson,
+        ...reduxLibs,
+        ...reduxPackageJson,
+        ...regexParam,
+        ...regexParamPackageJson,
+        ...looseLibs,
+        ...looseLibsPackageJson,
+        ...tokensLibs,
+        ...tokensLibsPackageJson,
+        ...symbolLibs,
+        ...symobolLibsPackageJson,
+        ...reduxThunkPackageJson,
+        ...reduxThunk
+      ]);
+      await runner.runCommand(cliPath, 'build');
+    });
+
+    runSmokeTest(['public', 'index'], LABEL);
+
+    describe('SPA (Single Page Application)', function() {
+      let dom;
+      let htmlFiles;
+      let jsFiles;
+
+      before(async function() {
+        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, 'index.html'));
+        htmlFiles = await glob(`${this.context.publicDir}/**/*.html`);
+        jsFiles = await glob(`${this.context.publicDir}/**/*.js`);
+      });
+      
+      it('should only have one HTML file in the output directory', function() {
+        expect(htmlFiles.length).to.be.equal(1);
+      });
+
+      it('should output five script files in the output directory', function() {
+        // one for each route (home, about)
+        // one for the footer.js
+        // one for index.js
+        // one for lit element bundle
+        expect(jsFiles.length).to.be.equal(5);
+      });
+
+      it('should only have two script tags in the <head>', function() {
+        expect(htmlFiles.length).to.be.equal(1);
+      });
+
+      it('should have one <script> tag in the <head> for index.js', function() {
+        const indexScript = Array.from(dom.window.document.querySelectorAll('head > script[type]'))
+          .filter(script => (/index.*.js/).test(script.src));
+
+        expect(indexScript.length).to.be.equal(1);
+        expect(indexScript[0].type).to.be.equal('module');
+      });
+
+      it('should have one <script> tag in the <head> for the footer.js', function() {
+        const footerScript = Array.from(dom.window.document.querySelectorAll('head > script[type]'))
+          .filter(script => (/footer.*.js/).test(script.src));
+
+        expect(footerScript.length).to.be.equal(1);
+        expect(footerScript[0].type).to.be.equal('module');
+      });
+
+      it('should have two code split route javascript files emitted based code splitting', function() {
+        const aboutBundle = jsFiles.filter(file => (/about.*.js/).test(path.basename(file)));
+        const homeBundle = jsFiles.filter(file => (/home.*.js/).test(path.basename(file)));
+        
+        expect(aboutBundle.length).to.equal(1);
+        expect(homeBundle.length).to.equal(1);
+      });
+
+      it('should not have a pre-rendered custom footer', function() {
+        const footer = dom.window.document.querySelector('app-footer');
+
+        expect(footer.textContent).to.be.equal('');
+      });
+    });
+
+  });
+
+  after(function() {
+    runner.teardown(getOutputTeardownFiles(outputPath));
+  });
+
+});
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.mode-spa/greenwood.config.js b/packages/cli/test/cases/build.config.mode-spa/greenwood.config.js
new file mode 100644
index 00000000..d57e5520
--- /dev/null
+++ b/packages/cli/test/cases/build.config.mode-spa/greenwood.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+  mode: 'spa'
+};
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.mode-spa/package.json b/packages/cli/test/cases/build.config.mode-spa/package.json
new file mode 100644
index 00000000..d820e6f6
--- /dev/null
+++ b/packages/cli/test/cases/build.config.mode-spa/package.json
@@ -0,0 +1,9 @@
+{
+  "dependencies": {
+    "lit-element": "^2.4.0",
+    "lit-redux-router": "^0.17.1",
+    "pwa-helpers": "^0.9.1",
+    "redux": "^4.0.5",
+    "redux-thunk": "^2.3.0"
+  }
+}
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.mode-spa/src/components/footer.js b/packages/cli/test/cases/build.config.mode-spa/src/components/footer.js
new file mode 100644
index 00000000..e0a5f2ac
--- /dev/null
+++ b/packages/cli/test/cases/build.config.mode-spa/src/components/footer.js
@@ -0,0 +1,49 @@
+import { css, html, LitElement } from 'lit-element';
+
+class FooterComponent extends LitElement {
+
+  constructor() {
+    super();
+    this.version = '0.11.1';
+  }
+
+  static get styles() {
+    return css`
+      .footer {
+        background-color: #192a27;
+        min-height: 30px;
+        padding-top: 10px;
+      }
+
+      h4 {
+        width: 90%;
+        margin: 0 auto;
+        padding: 0;
+        text-align: center;
+      }
+
+      a {
+        color: white;
+        text-decoration: none;
+      }
+
+      span.separator {
+        color: white;
+      }
+    `;
+  }
+
+  render() {
+    const { version } = this;
+    
+    return html`
+      <footer class="footer">
+        <h4>
+          <a href="/">Greenwood v${version}</a> <span class="separator">&#9672</span> <a href="https://www.netlify.com/">This site is powered by Netlify</a>
+        </h4>
+      </footer>
+    `;
+  }
+}
+
+customElements.define('app-footer', FooterComponent);
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.mode-spa/src/index.html b/packages/cli/test/cases/build.config.mode-spa/src/index.html
new file mode 100644
index 00000000..aca04a64
--- /dev/null
+++ b/packages/cli/test/cases/build.config.mode-spa/src/index.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en" prefix="og:http://ogp.me/ns#">
+
+  <head>
+    <meta-outlet></meta-outlet>
+    <script type="module" src="/components/footer.js"></script>
+    <script type="module" src="/index.js"></script>
+  </head>
+
+  <body>
+    <h1>SPA mode activated!</h1>
+    <nav>
+      <ul>
+        <li>
+          <a href="/">Home</a>
+        </li>
+        <li>
+          <a href="/about">About</a>
+        </li>
+      </ul>
+    </nav>
+
+    <my-app></my-app>
+
+    <app-footer></app-footer>
+    
+  </body>
+
+</html>
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.mode-spa/src/index.js b/packages/cli/test/cases/build.config.mode-spa/src/index.js
new file mode 100644
index 00000000..e4f7baf4
--- /dev/null
+++ b/packages/cli/test/cases/build.config.mode-spa/src/index.js
@@ -0,0 +1,32 @@
+import { html, LitElement } from 'lit-element';
+import { connectRouter } from 'lit-redux-router';
+import { applyMiddleware, createStore, compose, combineReducers } from 'redux';
+import { lazyReducerEnhancer } from 'pwa-helpers';
+import thunk from 'redux-thunk';
+
+const store = createStore((state) => state,
+  compose(lazyReducerEnhancer(combineReducers), applyMiddleware(thunk))
+);
+
+connectRouter(store);
+
+class MyApp extends LitElement {
+  render() {
+    return html`
+      <div class="app-content">
+        <lit-route 
+          path="/" 
+          component="app-home"
+          .resolve="${() => import('./routes/home.js')}"
+        ></lit-route>
+        <lit-route 
+          path="/about" 
+          component="app-about"
+          .resolve="${() => import('./routes/about.js')}"
+        ></lit-route>
+      </div>
+    `;
+  }
+}
+
+customElements.define('my-app', MyApp);
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.mode-spa/src/routes/about.js b/packages/cli/test/cases/build.config.mode-spa/src/routes/about.js
new file mode 100644
index 00000000..5e80bcc2
--- /dev/null
+++ b/packages/cli/test/cases/build.config.mode-spa/src/routes/about.js
@@ -0,0 +1,9 @@
+import { html, LitElement } from 'lit-element';
+
+class AppAbout extends LitElement {
+  render() {
+    return html`<h1>About</h1>`;
+  }
+}
+
+customElements.define('app-about', AppAbout);
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.mode-spa/src/routes/home.js b/packages/cli/test/cases/build.config.mode-spa/src/routes/home.js
new file mode 100644
index 00000000..c0a0f73e
--- /dev/null
+++ b/packages/cli/test/cases/build.config.mode-spa/src/routes/home.js
@@ -0,0 +1,9 @@
+import { html, LitElement } from 'lit-element';
+
+class AppHome extends LitElement {
+  render() {
+    return html`<h1>Home</h1>`;
+  }
+}
+
+customElements.define('app-home', AppHome);
\ No newline at end of file