Repository: ProjectEvergreen/greenwood (js)

Issue:
Should have the ability to statically export server rendered routes as just HTML
## Type of Change
- [x] New Feature Request
- [ ] Documentation / Website
- [ ] Improvement / Suggestion
- [ ] Bug
- [ ] Other (please clarify below)

## Summary
As part of supporting SSR, Greenwood should allow users the ability to statically pre-render a page _with_ SSR strategies, even if just to output the contents of the page for _static_ hosting.

> [notes from Sveltekit team](https://twitter.com/lihautan/status/1480945282577670149) on this from their own implementation analysis

## Details
1. Provide an API in `getFrontmatter` just for SSR routes called `staticExport` - or just use `prerender` API convention instead of something new?  (or does pre-render imply hydration)
1. Ensure this works for all compatibly renderers and plugin
1. If that is enabled for a route, save the generated HTML to the appropriate file based routing location in the output directory
1. If _all_ routes enable this feature, then running `yarn serve` should effectively only need to start `staticServer`

Pull request description:
Feature/issue 879 static export ssr routes
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #879 

## Summary of Changes
1. Add feature for being able to static export a SSR route
1. Added documentation and specs

## TODO
1. [x] Wait for [`prerender`](https://github.com/ProjectEvergreen/greenwood/pull/912) work to get merged and rebase
1. [ ] Add support for _greenwood.config.js_ `prerender: false`  with per page opt-in for static exporting for mix and matching, as opposed to all or nothing
1. [ ] SSR still outputs HTML, due to #883 , may not be able to clear up no static writing `TODO`

> Deferring the above two to #951

Patch:
diff --git a/packages/cli/src/commands/serve.js b/packages/cli/src/commands/serve.js
index 9252b837..2852060e 100644
--- a/packages/cli/src/commands/serve.js
+++ b/packages/cli/src/commands/serve.js
@@ -6,8 +6,8 @@ const runProdServer = async (compilation) => {
 
     try {
       const port = compilation.config.port;
-      const hasRoutes = compilation.graph.find(page => page.isSSR);
-      const server = hasRoutes ? getHybridServer : getStaticServer;
+      const hasDynamicRoutes = compilation.graph.filter(page => page.isSSR && !page.data.static);
+      const server = hasDynamicRoutes.length > 0 ? getHybridServer : getStaticServer;
 
       (await server(compilation)).listen(port, () => {
         console.info(`Started server at localhost:${port}`);
diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js
index 4b4229b8..e04817c2 100644
--- a/packages/cli/src/lifecycles/bundle.js
+++ b/packages/cli/src/lifecycles/bundle.js
@@ -5,11 +5,12 @@ const bundleCompilation = async (compilation) => {
 
   return new Promise(async (resolve, reject) => {
     try {
+      compilation.graph = compilation.graph.filter(page => !page.isSSR || (page.isSSR && page.data.static));
+
       // https://rollupjs.org/guide/en/#differences-to-the-javascript-api
       if (compilation.graph.length > 0) {
         const rollupConfigs = await getRollupConfig({
-          ...compilation,
-          graph: compilation.graph.filter(page => !page.isSSR)
+          ...compilation
         });
         const bundle = await rollup(rollupConfigs[0]);
 
diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js
index ea19dbbd..0d605a56 100644
--- a/packages/cli/src/lifecycles/prerender.js
+++ b/packages/cli/src/lifecycles/prerender.js
@@ -192,7 +192,7 @@ async function preRenderCompilationDefault(compilation) {
 }
 
 async function staticRenderCompilation(compilation) {
-  const pages = compilation.graph.filter(page => !page.isSSR);
+  const pages = compilation.graph.filter(page => !page.isSSR || page.isSSR && page.data.static);
   const scratchDir = compilation.context.scratchDir;
   const htmlResource = compilation.config.plugins.filter((plugin) => {
     return plugin.name === 'plugin-standard-html';
diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js
index 9964c0a3..1ff0b940 100644
--- a/packages/cli/src/lifecycles/serve.js
+++ b/packages/cli/src/lifecycles/serve.js
@@ -262,7 +262,7 @@ async function getHybridServer(compilation) {
       return node.route === url;
     })[0] || { data: {} };
 
-    if (matchingRoute.isSSR) {
+    if (matchingRoute.isSSR && !matchingRoute.data.static) {
       const headers = {
         request: { 'accept': 'text/html', 'content-type': 'text/html' },
         response: { 'content-type': 'text/html' }
diff --git a/www/pages/docs/server-rendering.md b/www/pages/docs/server-rendering.md
index d76948fb..cb509410 100644
--- a/www/pages/docs/server-rendering.md
+++ b/www/pages/docs/server-rendering.md
@@ -113,6 +113,25 @@ async function getFrontmatter(compilation, route) {
 
 > _For defining custom dynamic based metadata, like for `<meta>` tags, use `getTemplate` and define those tags right in your HTML._
 
+##### Static Export
+
+To export server routes as just static HTML, you can set the `static` property within the `data` object of your frontmatter.
+
+```js
+// example
+async function getFrontmatter(compilation, route) {
+  return {
+    /* ... */
+
+    data: {
+      static: true
+    }
+  };
+}
+```
+
+So for example, `/pages/artist.js` would render out as `/artists/index.html` and would not require the serve task.  So if you need more flexibility in how you create your pages, but still want to just serve it statically, you can!
+
 #### Body
 
 For just returning content, you can use `getBody`.  For example, return a list of users from an API as the HTML you need.


Test patch:
diff --git a/packages/cli/test/cases/build.default.ssr-static-export/build.default.ssr-static-export.spec.js b/packages/cli/test/cases/build.default.ssr-static-export/build.default.ssr-static-export.spec.js
new file mode 100644
index 00000000..1a8b3ddd
--- /dev/null
+++ b/packages/cli/test/cases/build.default.ssr-static-export/build.default.ssr-static-export.spec.js
@@ -0,0 +1,230 @@
+/*
+ * Use Case
+ * Run Greenwood with an SSR route that exports static HTML.
+ *
+ * User Result
+ * Should generate a bare bones Greenwood build with a statically rendered from server routes.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * None
+ *
+ * User Workspace
+ *  src/
+ *   components/
+ *     counter.js
+ *     footer.js
+ *   pages/
+ *     artists.js
+ *   templates/
+ *     app.html
+ */
+import chai from 'chai';
+import fs from 'fs';
+import { JSDOM } from 'jsdom';
+import path from 'path';
+import { getSetupFiles, getDependencyFiles, getOutputTeardownFiles } from '../../../../../test/utils.js';
+import { runSmokeTest } from '../../../../../test/smoke-test.js';
+import { Runner } from 'gallinago';
+import { fileURLToPath, URL } from 'url';
+
+const expect = chai.expect;
+
+describe('Build Greenwood With: ', function() {
+  const LABEL = 'A Server Rendered Application (SSR) that is statically exported';
+  const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js');
+  const outputPath = fileURLToPath(new URL('.', import.meta.url));
+  let runner;
+
+  before(async function() {
+    this.context = { 
+      publicDir: path.join(outputPath, 'public') 
+    };
+    runner = new Runner();
+  });
+
+  describe(LABEL, function() {
+
+    before(async function() {
+      const lit = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit/*.js`, 
+        `${outputPath}/node_modules/lit/`
+      );
+      const litDecorators = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit/decorators/*.js`, 
+        `${outputPath}/node_modules/lit/decorators/`
+      );
+      const litDirectives = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit/directives/*.js`, 
+        `${outputPath}/node_modules/lit/directives/`
+      );
+      const litPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit/package.json`, 
+        `${outputPath}/node_modules/lit/`
+      );
+      const litElement = await getDependencyFiles(
+        `${process.cwd()}/node_modules/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 litElementDecorators = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-element/decorators/*.js`, 
+        `${outputPath}/node_modules/lit-element/decorators/`
+      );
+      const litHtml = await getDependencyFiles(
+        `${process.cwd()}/node_modules/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 litHtmlDirectives = await getDependencyFiles(
+        `${process.cwd()}/node_modules/lit-html/directives/*.js`, 
+        `${outputPath}/node_modules/lit-html/directives/`
+      );
+      // lit-html has a dependency on this
+      // https://github.com/lit/lit/blob/main/packages/lit-html/package.json#L82
+      const trustedTypes = await getDependencyFiles(
+        `${process.cwd()}/node_modules/@types/trusted-types/package.json`,
+        `${outputPath}/node_modules/@types/trusted-types/`
+      );
+      const litReactiveElement = await getDependencyFiles(
+        `${process.cwd()}/node_modules/@lit/reactive-element/*.js`, 
+        `${outputPath}/node_modules/@lit/reactive-element/`
+      );
+      const litReactiveElementDecorators = await getDependencyFiles(
+        `${process.cwd()}/node_modules/@lit/reactive-element/decorators/*.js`, 
+        `${outputPath}/node_modules/@lit/reactive-element/decorators/`
+      );
+      const litReactiveElementPackageJson = await getDependencyFiles(
+        `${process.cwd()}/node_modules/@lit/reactive-element/package.json`, 
+        `${outputPath}/node_modules/@lit/reactive-element/`
+      );
+
+      await runner.setup(outputPath, [
+        ...getSetupFiles(outputPath),
+        ...lit,
+        ...litPackageJson,
+        ...litDirectives,
+        ...litDecorators,
+        ...litElementPackageJson,
+        ...litElement,
+        ...litElementDecorators,
+        ...litHtmlPackageJson,
+        ...litHtml,
+        ...litHtmlDirectives,
+        ...trustedTypes,
+        ...litReactiveElement,
+        ...litReactiveElementDecorators,
+        ...litReactiveElementPackageJson
+      ]);
+
+      return new Promise(async (resolve) => {
+        setTimeout(() => {
+          resolve();
+        }, 10000);
+
+        await runner.runCommand(cliPath, 'build');
+      });
+    });
+
+    runSmokeTest(['public', 'index'], LABEL);
+
+    let dom;
+    let artistsPageGraphData;
+
+    before(async function() {
+      const graph = JSON.parse(await fs.promises.readFile(path.join(outputPath, 'public/graph.json'), 'utf-8'));
+      const artistsHtml = await fs.promises.readFile(path.join(outputPath, 'public/artists/index.html'), 'utf-8');
+      
+      artistsPageGraphData = graph.filter(page => page.route === '/artists/')[0];
+      dom = new JSDOM(artistsHtml);
+    });
+
+    describe('Build command that tests for static HTML export from SSR route', function() {
+
+      it('should have one style tags', function() {
+        const styles = dom.window.document.querySelectorAll('head > style');
+
+        expect(styles.length).to.equal(1);
+      });
+
+      it('should have three script tags', function() {
+        const scripts = dom.window.document.querySelectorAll('head > script');
+
+        expect(scripts.length).to.equal(3);
+      });
+
+      it('should have expected SSR content from the non module script tag', function() {
+        const scripts = Array.from(dom.window.document.querySelectorAll('head > script'))
+          .filter(tag => !tag.getAttribute('type'));
+
+        expect(scripts.length).to.equal(1);
+        expect(scripts[0].textContent).to.contain('console.log');
+      });
+
+      it('should have a bundled script for the footer component', function() {
+        const footerScript = Array.from(dom.window.document.querySelectorAll('head > script[type]'))
+          .filter(script => (/footer.*[a-z0-9].js/).test(script.src));
+
+        expect(footerScript.length).to.be.equal(1);
+        expect(footerScript[0].type).to.be.equal('module');
+      });
+
+      it('should have the expected number of table rows of content', function() {
+        const rows = dom.window.document.querySelectorAll('body > table tr');
+
+        expect(rows.length).to.equal(11);
+      });
+
+      it('should have the expected <title> content in the <head>', function() {
+        const title = dom.window.document.querySelectorAll('head > title');
+
+        expect(title.length).to.equal(1);
+        expect(title[0].textContent).to.equal('/artists/');
+      });
+
+      it('should have custom metadata in the <head>', function() {
+        const metaDescription = Array.from(dom.window.document.querySelectorAll('head > meta'))
+          .filter((tag) => tag.getAttribute('name') === 'description');
+
+        expect(metaDescription.length).to.equal(1);
+        expect(metaDescription[0].getAttribute('content')).to.equal('/artists/ (this was generated server side!!!)');
+      });
+
+      it('should be a part of graph.json', function() {
+        expect(artistsPageGraphData).to.not.be.undefined;
+      });
+
+      it('should have the expected menu and index values in the graph', function() {
+        expect(artistsPageGraphData.data.menu).to.equal('navigation');
+        expect(artistsPageGraphData.data.index).to.equal(7);
+      });
+
+      it('should have expected custom data values in its graph data', function() {
+        expect(artistsPageGraphData.data.author).to.equal('Project Evergreen');
+        expect(artistsPageGraphData.data.date).to.equal('01-01-2021');
+      });
+
+      it('should append the expected <script> tag for a frontmatter import <x-counter> component', function() {
+        const componentName = 'counter';
+        const counterScript = Array.from(dom.window.document.querySelectorAll('head > script[src]'))
+          .filter((tag) => tag.getAttribute('src').indexOf(`/${componentName}.`) === 0);
+
+        expect(artistsPageGraphData.imports[0]).to.equal(`/components/${componentName}.js`);
+        expect(counterScript.length).to.equal(1);
+      });
+    });
+  });
+
+  after(function() {
+    runner.teardown(getOutputTeardownFiles(outputPath));
+  });
+
+});
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.default.ssr-static-export/greenwood.config.js b/packages/cli/test/cases/build.default.ssr-static-export/greenwood.config.js
new file mode 100644
index 00000000..b31c8b19
--- /dev/null
+++ b/packages/cli/test/cases/build.default.ssr-static-export/greenwood.config.js
@@ -0,0 +1,3 @@
+export default {
+  prerender: false // TODO should allow this to be false and mix and match rendering options
+};
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.default.ssr-static-export/package.json b/packages/cli/test/cases/build.default.ssr-static-export/package.json
new file mode 100644
index 00000000..2dee3e76
--- /dev/null
+++ b/packages/cli/test/cases/build.default.ssr-static-export/package.json
@@ -0,0 +1,6 @@
+{
+  "type": "module",
+  "dependencies": {
+    "lit": "^2.0.0"
+  }
+}
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.default.ssr-static-export/src/components/counter.js b/packages/cli/test/cases/build.default.ssr-static-export/src/components/counter.js
new file mode 100644
index 00000000..b102faa2
--- /dev/null
+++ b/packages/cli/test/cases/build.default.ssr-static-export/src/components/counter.js
@@ -0,0 +1,42 @@
+const template = document.createElement('template');
+      
+template.innerHTML = `
+  <style>
+    :host {
+      color: blue;
+    }
+  </style>
+  <h3>My Counter</h3>
+  <button id="dec">-</button>
+  <span id="count"></span>
+  <button id="inc">+</button>
+`;
+
+class MyCounter extends HTMLElement {
+  constructor() {
+    super();
+    this.count = 0;
+    this.attachShadow({ mode: 'open' });
+  }
+
+  async connectedCallback() {    
+    this.shadowRoot.appendChild(template.content.cloneNode(true));
+    this.shadowRoot.getElementById('inc').onclick = () => this.inc();
+    this.shadowRoot.getElementById('dec').onclick = () => this.dec();
+    this.update();
+  }
+
+  inc() {
+    this.update(++this.count); // eslint-disable-line
+  }
+
+  dec() {
+    this.update(--this.count); // eslint-disable-line
+  }
+
+  update(count) {
+    this.shadowRoot.getElementById('count').innerHTML = count || this.count;
+  }
+}
+
+customElements.define('x-counter', MyCounter);
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.default.ssr-static-export/src/components/footer.js b/packages/cli/test/cases/build.default.ssr-static-export/src/components/footer.js
new file mode 100644
index 00000000..cad38627
--- /dev/null
+++ b/packages/cli/test/cases/build.default.ssr-static-export/src/components/footer.js
@@ -0,0 +1,49 @@
+import { css, html, LitElement } from 'lit';
+
+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.default.ssr-static-export/src/pages/artists.js b/packages/cli/test/cases/build.default.ssr-static-export/src/pages/artists.js
new file mode 100644
index 00000000..28b8aad9
--- /dev/null
+++ b/packages/cli/test/cases/build.default.ssr-static-export/src/pages/artists.js
@@ -0,0 +1,92 @@
+import fetch from 'node-fetch';
+
+async function getTemplate(compilation, route) {
+  return `
+    <html>
+      <head>
+        <title>${route}</title>
+        <meta name="description" content="${route} (this was generated server side!!!)">
+
+        <script>
+          console.log(${JSON.stringify(compilation.graph.map(page => page.title).join(''))});
+        </script>
+
+        <style>
+          * {
+            color: blue;
+          }
+
+          h1 {
+            width: 50%;
+            margin: 0 auto;
+            text-align: center;
+            color: red;
+          }
+        </style>
+      </head>
+      <body>
+        <h1>This heading was rendered server side!</h1>
+        <content-outlet></content-outlet>
+      </body>
+    </html>
+  `;
+}
+
+async function getBody(compilation) {
+  const artists = await fetch('http://www.analogstudios.net/api/artists').then(resp => resp.json());
+  const timestamp = new Date().getTime();
+  const artistsListItems = artists
+    .filter(artist => artist.isActive === '1')
+    .map((artist) => {
+      const { id, name, bio, imageUrl } = artist;
+
+      return `
+        <tr>
+          <td>${id}</td>
+          <td>${name}</td>
+          <td>${bio}</td>
+          <td><img src="${imageUrl}"/></td>
+        </tr>
+      `;
+    });
+
+  return `
+    <body>
+      <h1>Hello from the server rendered artists page! 👋</h1>
+      <table>
+        <tr>
+          <th>ID</th>
+          <th>Name</th>
+          <th>Description</th>
+          <th>Genre</th>
+        </tr>
+        ${artistsListItems.join('')}
+      </table>
+      <h6>Fetched at: ${timestamp}</h6>
+      <pre>
+        ${JSON.stringify(compilation.graph.map(page => page.title).join(''))}
+      </pre>
+    </body>
+  `;
+}
+
+async function getFrontmatter() {
+  return {
+    menu: 'navigation',
+    index: 7,
+    imports: [
+      '/components/counter.js'
+    ],
+    data: {
+      author: 'Project Evergreen',
+      date: '01-01-2021',
+      static: true
+    }
+  };
+}
+
+export {
+  getTemplate,
+  getBody,
+  getFrontmatter
+}; 
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.default.ssr-static-export/src/pages/index.md b/packages/cli/test/cases/build.default.ssr-static-export/src/pages/index.md
new file mode 100644
index 00000000..5fa8f5c5
--- /dev/null
+++ b/packages/cli/test/cases/build.default.ssr-static-export/src/pages/index.md
@@ -0,0 +1,3 @@
+## Hello SSR
+
+Lorum Ipsum.
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.default.ssr-static-export/src/templates/app.html b/packages/cli/test/cases/build.default.ssr-static-export/src/templates/app.html
new file mode 100644
index 00000000..39d0ebbb
--- /dev/null
+++ b/packages/cli/test/cases/build.default.ssr-static-export/src/templates/app.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en" prefix="og:http://ogp.me/ns#">
+  <head>
+    <title>Default Title</title>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1"/>
+    <script type="module" src="../components/footer.js"></script>
+  </head>
+  <body>
+    <page-outlet></page-outlet>
+    <app-footer></app-footer>
+  </body>
+</html>
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.default.ssr/build.default.ssr.spec.js b/packages/cli/test/cases/build.default.ssr/build.default.ssr.spec.js
index f1d9f769..7ad5a520 100644
--- a/packages/cli/test/cases/build.default.ssr/build.default.ssr.spec.js
+++ b/packages/cli/test/cases/build.default.ssr/build.default.ssr.spec.js
@@ -120,6 +120,14 @@ describe('Build Greenwood With: ', function() {
         done();
       });
 
+      // this is limited by the fact SSR routes have to write to the fs in order to bundle a page on the fly
+      xit('should not emit a static file', function(done) {
+        const ssrPageOutput = fs.existsSync(path.join(outputPath, 'public/artists/index.html'));
+
+        expect(ssrPageOutput).to.be.false;
+        done();
+      });
+
       it('the response body should be valid HTML from JSDOM', function(done) {
         expect(artistsPageDom).to.not.be.undefined;
         done();