Repository: ProjectEvergreen/greenwood (js)

Issue:
add configuration to allow pre-rendering to be optional
## Type of Change
- [x] New Feature Request
- [ ] Documentation / Website
- [ ] Improvement / Suggestion
- [ ] Bug
- [ ] Other (please clarify below)

## Summary
Currently, Greenwood pre-renders all HTML / CSS / JS by running all the user's code in a browser (puppeteer) to get the initial generated static markup even from Web Components.  However, not every application would require this feature since all content is generated as HTML without the need for a browser, so if a user never needs to pre-render their Web Components / JavaScript, then they could get a lot of build time back by opting-out of having to spin up a browser.

## Details
This would be most beneficial to supporting a SPA mode ( #518 ) since those apps generally do not need any form of pre-rendering at all typically, being almost exclusively client side rendered by design.

Additionally, this would open the door for additional pre-rendering tools like the new experimental [**lit-labs/ssr**](https://github.com/lit/lit/tree/main/packages/labs) package, which has tools not only for Lit projects, but also vanilla web components.  In this way, projects could choose any pre-render, for SSG or [SSR](https://github.com/ProjectEvergreen/greenwood/discussions/576).

Pull request description:
Enhancement/issue 575 optional prerendering
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #575 

## Summary of Changes
1. Renamed serialize lifecycle -> prerender
1. Added a `prerender` configuration option for bypassing puppeteer
1. Added test cases
1. Add documentation

At present and maybe not immediately obvious, but this feature is incompatible with using GraphQL from the client side since without running the web components, the _*-cache.json_ files will never get created.  As per https://github.com/ProjectEvergreen/greenwood/discussions/576 if we could do this entirely with HTML somehow or some sort of `<query-outlet></query-outlet>` that would be great?  There are some options to explore for sure.  

It should be noted _graph.json_ will still exist, so anyone can still use `fetch` to get the same information.

That said, the site builds _suuuuuuper_ fast without prerendering, in like 5-6s.

## Thoughts / Issues To Make
1. Some possible future `prerender` options?
    - `browser` (default) -  what we are doing currently
    - `RenderElement` - Lit's native solution
    - `LitElement` - as a plugin, specifically for Lit?
    - could then add an SSR `"mode"`
1.  In this model, it would be nice to make the puppeteer dependency optional, and only install if needed.  Should make an issue for that.

> Added notes in https://github.com/ProjectEvergreen/greenwood/discussions/576

## TODOs
1. [x] Documentation + disclaimer and some use cases
1. [ ] ~~Upgrade config schema ?~~ - not needed
1. [x] Some general refactoring to try and share logic in the `build` command
1. [x] Track a discussion around "static" templating with web components - added notes in https://github.com/ProjectEvergreen/greenwood/discussions/576

Patch:
diff --git a/nyc.config.js b/nyc.config.js
index b56f00d5..467df268 100644
--- a/nyc.config.js
+++ b/nyc.config.js
@@ -21,7 +21,7 @@ module.exports = {
 
   statements: 80,
   branches: 65,
-  functions: 85,
+  functions: 80,
   lines: 80,
 
   watermarks: {
diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js
index 069b88fb..ff211ee7 100644
--- a/packages/cli/src/commands/build.js
+++ b/packages/cli/src/commands/build.js
@@ -3,7 +3,7 @@ const copyAssets = require('../lifecycles/copy');
 const { devServer } = require('../lifecycles/serve');
 const fs = require('fs');
 const generateCompilation = require('../lifecycles/compile');
-const serializeCompilation = require('../lifecycles/serialize');
+const { preRenderCompilation, staticRenderCompilation } = require('../lifecycles/prerender');
 const { ServerInterface } = require('../lib/server-interface');
 
 module.exports = runProductionBuild = async () => {
@@ -15,38 +15,47 @@ module.exports = runProductionBuild = async () => {
       const port = compilation.config.devServer.port;
       const outputDir = compilation.context.outputDir;
 
-      devServer(compilation).listen(port, async () => {
-        console.info(`Started local development server at localhost:${port}`);
+      if (!fs.existsSync(outputDir)) {
+        fs.mkdirSync(outputDir);
+      }
+      
+      if (compilation.config.prerender) {
+        await new Promise((resolve) => {
+          devServer(compilation).listen(port, async () => {
+            console.info(`Started local development server at localhost:${port}`);
+            
+            // custom user server plugins
+            const servers = [...compilation.config.plugins.filter((plugin) => {
+              return plugin.type === 'server';
+            }).map((plugin) => {
+              const provider = plugin.provider(compilation);
+    
+              if (!(provider instanceof ServerInterface)) {
+                console.warn(`WARNING: ${plugin.name}'s provider is not an instance of ServerInterface.`);
+              }
+    
+              return provider;
+            })];
+    
+            await Promise.all(servers.map(async (server) => {
+              server.start();
+    
+              return Promise.resolve(server);
+            }));
         
-        // custom user server plugins
-        const servers = [...compilation.config.plugins.filter((plugin) => {
-          return plugin.type === 'server';
-        }).map((plugin) => {
-          const provider = plugin.provider(compilation);
+            await preRenderCompilation(compilation);
 
-          if (!(provider instanceof ServerInterface)) {
-            console.warn(`WARNING: ${plugin.name}'s provider is not an instance of ServerInterface.`);
-          }
+            resolve();
+          });
+        });
+      } else {
+        await staticRenderCompilation(compilation);
+      }
 
-          return provider;
-        })];
-
-        await Promise.all(servers.map(async (server) => {
-          server.start();
-
-          return Promise.resolve(server);
-        }));
-
-        if (!fs.existsSync(outputDir)) {
-          fs.mkdirSync(outputDir);
-        }
-    
-        await serializeCompilation(compilation);
-        await bundleCompilation(compilation);
-        await copyAssets(compilation);
+      await bundleCompilation(compilation);
+      await copyAssets(compilation);
 
-        resolve();
-      });
+      resolve();
     } catch (err) {
       reject(err);
     }
diff --git a/packages/cli/src/lifecycles/config.js b/packages/cli/src/lifecycles/config.js
index 956dedb8..9a3603e9 100644
--- a/packages/cli/src/lifecycles/config.js
+++ b/packages/cli/src/lifecycles/config.js
@@ -14,7 +14,8 @@ const defaultConfig = {
   title: 'My App',
   meta: [],
   plugins: [],
-  markdown: { plugins: [], settings: {} }
+  markdown: { plugins: [], settings: {} },
+  prerender: true
 };
 
 module.exports = readAndMergeConfig = async() => {
@@ -26,7 +27,7 @@ module.exports = readAndMergeConfig = async() => {
       
       if (fs.existsSync(path.join(process.cwd(), 'greenwood.config.js'))) {
         const userCfgFile = require(path.join(process.cwd(), 'greenwood.config.js'));
-        const { workspace, devServer, title, markdown, meta, mode, optimization, plugins } = userCfgFile;
+        const { workspace, devServer, title, markdown, meta, mode, optimization, plugins, prerender } = userCfgFile;
 
         // workspace validation
         if (workspace) {
@@ -116,7 +117,16 @@ module.exports = readAndMergeConfig = async() => {
           customConfig.markdown.plugins = markdown.plugins && markdown.plugins.length > 0 ? markdown.plugins : [];
           customConfig.markdown.settings = markdown.settings ? markdown.settings : {};
         }
+
+        if (prerender !== undefined) {
+          if (typeof prerender === 'boolean') {
+            customConfig.prerender = prerender;
+          } else {
+            reject(`Error: greenwood.config.js prerender must be a boolean; true or false.  Passed value was typeof: ${typeof prerender}`);
+          }
+        }
       }
+
       resolve({ ...defaultConfig, ...customConfig });
     } catch (err) {
       reject(err);
diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js
new file mode 100644
index 00000000..7ddb0f8b
--- /dev/null
+++ b/packages/cli/src/lifecycles/prerender.js
@@ -0,0 +1,111 @@
+const BrowserRunner = require('../lib/browser');
+const fs = require('fs');
+const path = require('path');
+const pluginResourceStandardHtml = require('../plugins/resource/plugin-standard-html');
+const pluginOptimizationMpa = require('../plugins/resource/plugin-optimization-mpa');
+
+async function optimizePage(compilation, contents, route, outputDir) {
+  const outputPath = `${outputDir}${route}index.html`;
+  const optimizeResources = [
+    pluginResourceStandardHtml.provider(compilation),
+    pluginOptimizationMpa().provider(compilation),
+    ...compilation.config.plugins.filter((plugin) => {
+      const provider = plugin.provider(compilation);
+
+      return plugin.type === 'resource' 
+        && provider.shouldOptimize 
+        && provider.optimize;
+    }).map((plugin) => {
+      return plugin.provider(compilation);
+    })
+  ];
+
+  const htmlOptimized = await optimizeResources.reduce(async (htmlPromise, resource) => {
+    const html = await htmlPromise;
+    const shouldOptimize = await resource.shouldOptimize(outputPath, html);
+    
+    return shouldOptimize
+      ? resource.optimize(outputPath, html)
+      : Promise.resolve(html);
+  }, Promise.resolve(contents));
+
+  if (!fs.existsSync(path.join(outputDir, route))) {
+    fs.mkdirSync(path.join(outputDir, route), {
+      recursive: true
+    });
+  }
+  
+  await fs.promises.writeFile(outputPath, htmlOptimized);
+}
+
+async function preRenderCompilation(compilation) {
+  const browserRunner = new BrowserRunner();
+
+  await browserRunner.init();
+
+  const runBrowser = async (serverUrl, pages, outputDir) => {
+    try {
+      return Promise.all(pages.map(async(page) => {
+        const { route } = page;
+        console.info('prerendering page...', route);
+        
+        return await browserRunner
+          .serialize(`${serverUrl}${route}`)
+          .then(async (indexHtml) => {
+            console.info(`prerendering complete for page ${route}.`);
+            
+            await optimizePage(compilation, indexHtml, route, outputDir);
+          });
+      }));
+    } catch (e) {
+      // eslint-disable-next-line no-console
+      console.error(err);
+      return false;
+    }
+  };
+
+  return new Promise(async (resolve, reject) => {
+    try {
+      const pages = compilation.graph;
+      const port = compilation.config.devServer.port;
+      const outputDir = compilation.context.scratchDir;
+      const serverAddress = `http://127.0.0.1:${port}`;
+
+      console.info(`Prerendering pages at ${serverAddress}`);
+      console.debug('pages to render', `\n ${pages.map(page => page.path).join('\n ')}`);
+  
+      await runBrowser(serverAddress, pages, outputDir);
+      
+      console.info('done prerendering all pages');
+      browserRunner.close();
+
+      resolve();
+    } catch (err) {
+      reject(err);
+    }
+  });
+}
+
+async function staticRenderCompilation(compilation) {
+  const pages = compilation.graph;
+  const scratchDir = compilation.context.scratchDir;
+  const htmlResource = pluginResourceStandardHtml.provider(compilation);
+
+  console.info('pages to generate', `\n ${pages.map(page => page.path).join('\n ')}`);
+  
+  await Promise.all(pages.map(async (page) => {
+    const route = page.route;
+    const response = await htmlResource.serve(route);
+
+    await optimizePage(compilation, response.body, route, scratchDir);
+
+    return Promise.resolve();
+  }));
+  
+  console.info('success, done generating all pages!');
+}
+
+module.exports = {
+  preRenderCompilation,
+  staticRenderCompilation
+};
\ No newline at end of file
diff --git a/packages/cli/src/lifecycles/serialize.js b/packages/cli/src/lifecycles/serialize.js
deleted file mode 100644
index a11019da..00000000
--- a/packages/cli/src/lifecycles/serialize.js
+++ /dev/null
@@ -1,83 +0,0 @@
-const BrowserRunner = require('../lib/browser');
-const fs = require('fs');
-const path = require('path');
-const pluginResourceStandardHtml = require('../plugins/resource/plugin-standard-html');
-const pluginOptimizationMpa = require('../plugins/resource/plugin-optimization-mpa');
-
-module.exports = serializeCompilation = async (compilation) => {
-  const compilationCopy = Object.assign({}, compilation);
-  const browserRunner = new BrowserRunner();
-  const optimizeResources = [
-    pluginResourceStandardHtml.provider(compilationCopy),
-    pluginOptimizationMpa().provider(compilationCopy),
-    ...compilation.config.plugins.filter((plugin) => {
-      const provider = plugin.provider(compilationCopy);
-
-      return plugin.type === 'resource' 
-        && provider.shouldOptimize 
-        && provider.optimize;
-    }).map((plugin) => {
-      return plugin.provider(compilationCopy);
-    })
-  ];
-
-  await browserRunner.init();
-
-  const runBrowser = async (serverUrl, pages, outputDir) => {
-    try {
-      return Promise.all(pages.map(async(page) => {
-        const { route } = page;
-        console.info('serializing page...', route);
-        
-        return await browserRunner
-          .serialize(`${serverUrl}${route}`)
-          .then(async (indexHtml) => {
-            const outputPath = `${outputDir}${route}index.html`;
-            console.info(`Serializing complete for page ${route}.`);
-            
-            const htmlOptimized = await optimizeResources.reduce(async (htmlPromise, resource) => {
-              const html = await htmlPromise;
-              const shouldOptimize = await resource.shouldOptimize(outputPath, html);
-              
-              return shouldOptimize
-                ? resource.optimize(outputPath, html)
-                : Promise.resolve(html);
-            }, Promise.resolve(indexHtml));
-
-            if (!fs.existsSync(path.join(outputDir, route))) {
-              fs.mkdirSync(path.join(outputDir, route), {
-                recursive: true
-              });
-            }
-            
-            await fs.promises.writeFile(outputPath, htmlOptimized);
-          });
-      }));
-    } catch (e) {
-      // eslint-disable-next-line no-console
-      console.error(err);
-      return false;
-    }
-  };
-
-  return new Promise(async (resolve, reject) => {
-    try {
-      const pages = compilation.graph;
-      const port = compilation.config.devServer.port;
-      const outputDir = compilation.context.scratchDir;
-      const serverAddress = `http://127.0.0.1:${port}`;
-
-      console.info(`Serializing pages at ${serverAddress}`);
-      console.debug('pages to generate', `\n ${pages.map(page => page.path).join('\n ')}`);
-  
-      await runBrowser(serverAddress, pages, outputDir);
-      
-      console.info('done serializing all pages');
-      browserRunner.close();
-
-      resolve();
-    } catch (err) {
-      reject(err);
-    }
-  });
-};
\ No newline at end of file
diff --git a/packages/plugin-graphql/src/queries/config.gql b/packages/plugin-graphql/src/queries/config.gql
index 11d11939..92937bb3 100644
--- a/packages/plugin-graphql/src/queries/config.gql
+++ b/packages/plugin-graphql/src/queries/config.gql
@@ -13,6 +13,7 @@ query {
     },
     mode,
     optimization,
+    prerender,
     title,
     workspace
   }
diff --git a/packages/plugin-graphql/src/schema/config.js b/packages/plugin-graphql/src/schema/config.js
index 65cfaa2e..37b12487 100644
--- a/packages/plugin-graphql/src/schema/config.js
+++ b/packages/plugin-graphql/src/schema/config.js
@@ -24,6 +24,7 @@ const configTypeDefs = gql`
     meta: [Meta],
     mode: String,
     optimization: String,
+    prerender: Boolean,
     title: String,
     workspace: String
   }
diff --git a/www/pages/docs/configuration.md b/www/pages/docs/configuration.md
index cb300cca..527bae9f 100644
--- a/www/pages/docs/configuration.md
+++ b/www/pages/docs/configuration.md
@@ -137,6 +137,21 @@ module.exports = {
 
 > _These settings are currently expiremental, and more fine grained control and intelligent based defaults will be coming soon!_
 
+### Prerender
+
+By default, [Greenwood pre-renders](/about/how-it-works/) all your _runtime_ JavaScript (Web Components, GraphQL calls, etc) across all your pages and captures the output as part of the final built HTML output.  This means you can have ["static" components](/docs/configuration/#optimization) that can just render once and generate all their initial HTML at build time.  This aims to provide a fully complete HTML document to the user, so even if JavaScript is disabled or something breaks in their browser, the user gets all the initial content.  And from there, progessive enhancement can take over.
+
+_However_, you may not need that, like for a [SPA (Single Page Application)](/docs/configuration#mode).  If you _don't_ want any sort of pre-rendering and just want to render out your markdown / HTML, add this setting to your _greenwood.config.js_ and set it to `false`.
+
+#### Example
+```js
+module.exports = {
+  prerender: false 
+}
+```
+
+> _**As of now, if you are using [plugin-graphql](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-graphql) you cannot change this setting.**  We are working on improving support for server [side rendering and templating](https://github.com/ProjectEvergreen/greenwood/discussions/576) (with Web Components) as part of our [1.0 release](https://github.com/ProjectEvergreen/greenwood/milestone/3)._
+
 ### Title
 A default `<title>` element for all pages can be configured with the `title` option.
 


Test patch:
diff --git a/packages/cli/test/cases/build.config.error-prerender/build.config.error-prerender.spec.js b/packages/cli/test/cases/build.config.error-prerender/build.config.error-prerender.spec.js
new file mode 100644
index 00000000..18b05b91
--- /dev/null
+++ b/packages/cli/test/cases/build.config.error-prerender/build.config.error-prerender.spec.js
@@ -0,0 +1,46 @@
+/*
+ * Use Case
+ * Run Greenwood build command with a bad value for title in a custom config.
+ *
+ * User Result
+ * Should throw an error.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * {
+ *   title: {}
+ * }
+ *
+ * User Workspace
+ * Greenwood default
+ */
+const expect = require('chai').expect;
+const path = require('path');
+const Runner = require('gallinago').Runner;
+
+describe('Build Greenwood With: ', function() {
+  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('Custom Configuration with a bad value for Prerender', function() {
+    it('should throw an error that prerender must be a boolean', async function() {
+      try {
+        await runner.setup(outputPath);
+        await runner.runCommand(cliPath, 'build');
+      } catch (err) {
+        expect(err).to.contain('Error: greenwood.config.js prerender must be a boolean; true or false.  Passed value was typeof: object');
+      }
+    });
+  });
+
+});
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.error-prerender/greenwood.config.js b/packages/cli/test/cases/build.config.error-prerender/greenwood.config.js
new file mode 100644
index 00000000..e658b0c4
--- /dev/null
+++ b/packages/cli/test/cases/build.config.error-prerender/greenwood.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+  prerender: {}
+};
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.prerender/build.config.prerender.spec.js b/packages/cli/test/cases/build.config.prerender/build.config.prerender.spec.js
new file mode 100644
index 00000000..dd4ba664
--- /dev/null
+++ b/packages/cli/test/cases/build.config.prerender/build.config.prerender.spec.js
@@ -0,0 +1,117 @@
+/*
+ * Use Case
+ * Run Greenwood build command with prerender config set to false.
+ *
+ * User Result
+ * Should generate a Greenwood build with no puppeteer generated output.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * {
+ *   prerender: false
+ * }
+ *
+ * User Workspace
+ *  src/
+ *   components/
+ *     header.js
+ *   pages/
+ *     index.html
+ */
+const expect = require('chai').expect;
+const { JSDOM } = require('jsdom');
+const path = require('path');
+const runSmokeTest = require('../../../../../test/smoke-test');
+const { getSetupFiles, getOutputTeardownFiles } = require('../../../../../test/utils');
+const Runner = require('gallinago').Runner;
+
+describe('Build Greenwood With: ', function() {
+  const LABEL = 'Prerender Configuration turned off';
+  const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js');
+  const outputPath = __dirname;
+  let runner;
+
+  before(function() {
+    this.context = {
+      publicDir: path.join(outputPath, 'public')
+    };
+    runner = new Runner();
+  });
+
+  describe(LABEL, function() {
+
+    before(async function() {
+      await runner.setup(outputPath, getSetupFiles(outputPath));
+      await runner.runCommand(cliPath, 'build');
+    });
+    
+    runSmokeTest(['public', 'index'], LABEL);
+  
+    describe('Default output for index.html', function() {
+      let dom;
+
+      before(async function() {
+        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html'));
+      });
+
+      describe('head section tags', function() {
+        let metaTags;
+
+        before(function() {
+          metaTags = dom.window.document.querySelectorAll('head > meta');
+        });
+
+        it('should have a <title> tag in the <head>', function() {
+          const title = dom.window.document.querySelector('head title').textContent;
+    
+          expect(title).to.be.equal('My App');
+        });
+
+        it('should have five default <meta> tags in the <head>', function() {
+          expect(metaTags.length).to.be.equal(5);
+        });
+
+        it('should have default mobile-web-app-capable <meta> tag', function() {
+          const mwacMeta = metaTags[2];
+
+          expect(mwacMeta.getAttribute('name')).to.be.equal('mobile-web-app-capable');
+          expect(mwacMeta.getAttribute('content')).to.be.equal('yes');
+        });
+
+        it('should have default apple-mobile-web-app-capable <meta> tag', function() {
+          const amwacMeta = metaTags[3];
+
+          expect(amwacMeta.getAttribute('name')).to.be.equal('apple-mobile-web-app-capable');
+          expect(amwacMeta.getAttribute('content')).to.be.equal('yes');
+        });
+
+        it('should have default apple-mobile-web-app-status-bar-style <meta> tag', function() {
+          const amwasbsMeta = metaTags[4];
+
+          expect(amwasbsMeta.getAttribute('name')).to.be.equal('apple-mobile-web-app-status-bar-style');
+          expect(amwasbsMeta.getAttribute('content')).to.be.equal('black');
+        });
+      });
+
+      it('should have the expected heading text within the index page in the public directory', function() {
+        const heading = dom.window.document.querySelector('body h1').textContent;
+
+        expect(heading).to.equal('Welcome to Greenwood!');
+      });
+
+      it('should not have any prerendered content from <app-header> component', function() {
+        const appHeader = dom.window.document.querySelectorAll('body app-header');
+        const header = dom.window.document.querySelectorAll('body header');
+
+        expect(appHeader.length).to.equal(1);
+        expect(header.length).to.equal(0);
+      });
+    });
+  });
+
+  after(function() {
+    runner.teardown(getOutputTeardownFiles(outputPath));
+  });
+});
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.prerender/greenwood.config.js b/packages/cli/test/cases/build.config.prerender/greenwood.config.js
new file mode 100644
index 00000000..0d76d506
--- /dev/null
+++ b/packages/cli/test/cases/build.config.prerender/greenwood.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+  prerender: false
+};
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.prerender/src/components/header.js b/packages/cli/test/cases/build.config.prerender/src/components/header.js
new file mode 100644
index 00000000..4fb4641e
--- /dev/null
+++ b/packages/cli/test/cases/build.config.prerender/src/components/header.js
@@ -0,0 +1,22 @@
+class HeaderComponent extends HTMLElement {
+  constructor() {
+    super();
+
+    // create a Shadow DOM
+    this.root = this.attachShadow({ mode: 'open' });
+  }
+
+  // run some code when the component is ready
+  connectedCallback() {
+    this.root.innerHTML = this.getTemplate();
+  }
+
+  // create templates that interpolate variables and HTML!
+  getTemplate() {
+    return `
+      <header>This is the header component.</header>
+    `;
+  }
+}
+
+customElements.define('app-header', HeaderComponent);
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.config.prerender/src/pages/index.html b/packages/cli/test/cases/build.config.prerender/src/pages/index.html
new file mode 100644
index 00000000..0f238084
--- /dev/null
+++ b/packages/cli/test/cases/build.config.prerender/src/pages/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta-outlet></meta-outlet>
+    <script type="module" src="/components/header.js"></script>
+  </head>
+
+  <body>
+    <app-header></app-header>
+    <content-outlet></content-outlet>
+  </body>
+
+</html>
\ No newline at end of file
diff --git a/packages/plugin-graphql/test/unit/schema/config.spec.js b/packages/plugin-graphql/test/unit/schema/config.spec.js
index 811c6776..fc75a341 100644
--- a/packages/plugin-graphql/test/unit/schema/config.spec.js
+++ b/packages/plugin-graphql/test/unit/schema/config.spec.js
@@ -53,6 +53,14 @@ describe('Unit Test: Data', function() {
 
       });
 
+      describe('Prerender', function() {
+
+        it('should have a default prerender setting of false', function() {
+          expect(config.optimization).to.equal(MOCK_CONFIG.config.prerender);
+        });
+
+      });
+
       describe('Title', function() {
         const { title } = MOCK_CONFIG.config;