Repository: ProjectEvergreen/greenwood (js)

Issue:
SSR pages are not getting their resources (`<link>` / `<style>` / `<script>` tags) bundled and optimized with the serve command
## Type of Change
Bug

## Summary
After [upgrading to v0.28.0](https://github.com/AnalogStudiosRI/www.tuesdaystunes.tv), noticed that when [introduced API routes](https://github.com/AnalogStudiosRI/www.tuesdaystunes.tv/pull/114) into a "static" project, this combination triggers the hybrid server, but the HTML coming back is showing a bunch of 404s because none of the assets were loading correctly in the browser.

![Screen Shot 2023-04-15 at 2 25 03 PM](https://user-images.githubusercontent.com/895923/232247215-38dbcd0c-3e68-4dcf-9dd7-f22eaf5ebeaf.png)

```html
<link rel="stylesheet" href="../styles/main.css" data-gwd-opt="inline">
          
<script type="module" data-gwd-opt="static" src="../components/footer/footer.js"></script>
<script type="module" data-gwd-opt="static" src="../components/header/header.js"></script>
<script type="module" src="../components/nav/nav.js"></script>
<script type="module" src="../components/video-card/video-card.js"></script>
```

The reason for this is it looks like all the paths (from a template) were pointing to the development paths and so of course everything is broken as a result.

> As a note, seems like these shouldn't be returning a `500` though, should be a `404`.

## Details
The issue after some debugging appears to be that when an SSR page goes into its optimization phase, none of the `imports` are there because _graph.json_ is written **_before_** all the files are bundled.  The simple solution is to just re-serialize _graph.json_ during the bundling phase to get that latest state.

My concern is that these imports are built up from `modelResource`, which will also have a ton of raw contents, which would significantly bloat _graph.json_, so should keep that in mind.

Should also make sure this can be tested.

Pull request:
bug/include resource tracking and serialization for all pages
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #1098 

## Summary of Changes
1. Ended up being a bit of a refactor to make sure all pages had their resources tracked, not just the prerendered one
1. Re-serialize _graph.json_ to include page `resources` and frontmatter `imports` and delete graph copy plugin
1. Add specs to cover SSR pages get properly bundled assets


## TODO
1. [x] Add test case
1. [x] Try and keep _graph.json_ light weight (maybe try and re-hydrate _graph.json_ from _resources.json_)
1. [ ] track refactoring opportunities into a new or existing issue
1. [x] Handle those 500s as 404s instead (nice to have, otherwise make a ticket) - https://github.com/ProjectEvergreen/greenwood/issues/1102

Patch:
diff --git a/packages/cli/src/commands/build.js b/packages/cli/src/commands/build.js
index b8b9e142..1dbd30a6 100644
--- a/packages/cli/src/commands/build.js
+++ b/packages/cli/src/commands/build.js
@@ -1,10 +1,66 @@
 import { bundleCompilation } from '../lifecycles/bundle.js';
-import { checkResourceExists } from '../lib/resource-utils.js';
+import { checkResourceExists, trackResourcesForRoute } from '../lib/resource-utils.js';
 import { copyAssets } from '../lifecycles/copy.js';
 import fs from 'fs/promises';
 import { preRenderCompilationWorker, preRenderCompilationCustom, staticRenderCompilation } from '../lifecycles/prerender.js';
 import { ServerInterface } from '../lib/server-interface.js';
 
+// TODO a lot of these are duplicated in the prerender lifecycle too
+// would be good to refactor
+async function servePage(url, request, plugins) {
+  let response = new Response('');
+
+  for (const plugin of plugins) {
+    if (plugin.shouldServe && await plugin.shouldServe(url, request)) {
+      response = await plugin.serve(url, request);
+      break;
+    }
+  }
+
+  return response;
+}
+
+async function interceptPage(url, request, plugins, body) {
+  let response = new Response(body, {
+    headers: new Headers({ 'Content-Type': 'text/html' })
+  });
+
+  for (const plugin of plugins) {
+    if (plugin.shouldIntercept && await plugin.shouldIntercept(url, request, response)) {
+      response = await plugin.intercept(url, request, response);
+    }
+  }
+
+  return response;
+}
+
+function getPluginInstances (compilation) {
+  return [...compilation.config.plugins]
+    .filter(plugin => plugin.type === 'resource' && plugin.name !== 'plugin-node-modules:resource')
+    .map((plugin) => {
+      return plugin.provider(compilation);
+    });
+}
+
+// TODO does this make more sense in bundle lifecycle?
+// https://github.com/ProjectEvergreen/greenwood/issues/970
+// or could this be done sooner (like in appTemplate building in html resource plugin)?
+// Or do we need to ensure userland code / plugins have gone first
+async function trackResourcesForRoutes(compilation) {
+  const plugins = getPluginInstances(compilation);
+
+  for (const page of compilation.graph) {
+    const { route } = page;
+    const url = new URL(`http://localhost:${compilation.config.port}${route}`);
+    const request = new Request(url);
+
+    let body = await (await servePage(url, request, plugins)).text();
+    body = await (await interceptPage(url, request, plugins, body)).text();
+
+    await trackResourcesForRoute(body, compilation, route);
+  }
+}
+
 const runProductionBuild = async (compilation) => {
 
   return new Promise(async (resolve, reject) => {
@@ -43,11 +99,13 @@ const runProductionBuild = async (compilation) => {
         }));
 
         if (prerenderPlugin.workerUrl) {
+          await trackResourcesForRoutes(compilation);
           await preRenderCompilationWorker(compilation, prerenderPlugin);
         } else {
           await preRenderCompilationCustom(compilation, prerenderPlugin);
         }
       } else {
+        await trackResourcesForRoutes(compilation);
         await staticRenderCompilation(compilation);
       }
 
diff --git a/packages/cli/src/lib/resource-utils.js b/packages/cli/src/lib/resource-utils.js
index 6d0e3c2c..6f84e415 100644
--- a/packages/cli/src/lib/resource-utils.js
+++ b/packages/cli/src/lib/resource-utils.js
@@ -1,5 +1,6 @@
 import fs from 'fs/promises';
 import { hashString } from './hashing-utils.js';
+import htmlparser from 'node-html-parser';
 
 async function modelResource(context, type, src = undefined, contents = undefined, optimizationAttr = undefined, rawAttributes = undefined) {
   const { projectDirectory, scratchDir, userWorkspace } = context;
@@ -105,10 +106,74 @@ async function resolveForRelativeUrl(url, rootUrl) {
   return reducedUrl;
 }
 
+// TODO does this make more sense in bundle lifecycle?
+// https://github.com/ProjectEvergreen/greenwood/issues/970
+// or could this be done sooner (like in appTemplate building in html resource plugin)?
+// Or do we need to ensure userland code / plugins have gone first
+// before we can curate the final list of <script> / <style> / <link> tags to bundle
+async function trackResourcesForRoute(html, compilation, route) {
+  const { context } = compilation;
+  const root = htmlparser.parse(html, {
+    script: true,
+    style: true
+  });
+
+  // intentionally support <script> tags from the <head> or <body>
+  const scripts = await Promise.all(root.querySelectorAll('script')
+    .filter(script => (
+      isLocalLink(script.getAttribute('src')) || script.rawText)
+      && script.rawAttrs.indexOf('importmap') < 0)
+    .map(async(script) => {
+      const src = script.getAttribute('src');
+      const optimizationAttr = script.getAttribute('data-gwd-opt');
+      const { rawAttrs } = script;
+
+      if (src) {
+        // <script src="...."></script>
+        return await modelResource(context, 'script', src, null, optimizationAttr, rawAttrs);
+      } else if (script.rawText) {
+        // <script>...</script>
+        return await modelResource(context, 'script', null, script.rawText, optimizationAttr, rawAttrs);
+      }
+    }));
+
+  const styles = await Promise.all(root.querySelectorAll('style')
+    .filter(style => !(/\$/).test(style.rawText) && !(/<!-- Shady DOM styles for -->/).test(style.rawText)) // filter out Shady DOM <style> tags that happen when using puppeteer
+    .map(async(style) => await modelResource(context, 'style', null, style.rawText, null, style.getAttribute('data-gwd-opt'))));
+
+  const links = await Promise.all(root.querySelectorAll('head link')
+    .filter(link => {
+      // <link rel="stylesheet" href="..."></link>
+      return link.getAttribute('rel') === 'stylesheet'
+        && link.getAttribute('href') && isLocalLink(link.getAttribute('href'));
+    }).map(async(link) => {
+      return modelResource(context, 'link', link.getAttribute('href'), null, link.getAttribute('data-gwd-opt'), link.rawAttrs);
+    }));
+
+  const resources = [
+    ...scripts,
+    ...styles,
+    ...links
+  ];
+
+  resources.forEach(resource => {
+    compilation.resources.set(resource.sourcePathURL.pathname, resource);
+  });
+
+  compilation.graph.find(page => page.route === route).resources = resources.map(resource => resource.sourcePathURL.pathname);
+
+  return resources;
+}
+
+function isLocalLink(url = '') {
+  return url !== '' && (url.indexOf('http') !== 0 && url.indexOf('//') !== 0);
+}
+
 export {
   checkResourceExists,
   mergeResponse,
   modelResource,
   normalizePathnameForWindows,
-  resolveForRelativeUrl
+  resolveForRelativeUrl,
+  trackResourcesForRoute
 };
\ No newline at end of file
diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js
index db288148..3ad6bf00 100644
--- a/packages/cli/src/lifecycles/bundle.js
+++ b/packages/cli/src/lifecycles/bundle.js
@@ -8,7 +8,7 @@ import { rollup } from 'rollup';
 
 async function emitResources(compilation) {
   const { outputDir } = compilation.context;
-  const { resources } = compilation;
+  const { resources, graph } = compilation;
 
   // https://stackoverflow.com/a/56150320/417806
   // TODO put into a util
@@ -23,6 +23,8 @@ async function emitResources(compilation) {
       return value;
     }
   }));
+
+  await fs.writeFile(new URL('./graph.json', outputDir), JSON.stringify(graph));
 }
 
 async function cleanUpResources(compilation) {
@@ -182,15 +184,6 @@ async function bundleSsrPages(compilation) {
   // });
 
   const input = [];
-  // TODO ideally be able to serialize entire graph (or only an explicit subset?)
-  // right now page.imports is breaking JSON.stringify
-  // https://github.com/ProjectEvergreen/greenwood/issues/1008
-  const intermediateGraph = compilation.graph.map(page => {
-    const p = { ...page };
-    delete p.imports;
-
-    return p;
-  });
 
   if (!compilation.config.prerender) {
     for (const page of compilation.graph) {
@@ -207,7 +200,7 @@ async function bundleSsrPages(compilation) {
             const routeModuleLocationUrl = new URL('./_${filename}', '${outputDir}');
             const routeWorkerUrl = '${compilation.config.plugins.find(plugin => plugin.type === 'renderer').provider().workerUrl}';
             const htmlOptimizer = compilation.config.plugins.find(plugin => plugin.name === 'plugin-standard-html').provider(compilation);
-            let body = 'Hello from the ${page.id} page!';
+            let body = '';
             let html = '';
             let frontmatter;
             let template;
@@ -255,7 +248,7 @@ async function bundleSsrPages(compilation) {
 
               worker.postMessage({
                 moduleUrl: routeModuleLocationUrl.href,
-                compilation: \`${JSON.stringify({ graph: intermediateGraph })}\`,
+                compilation: \`${JSON.stringify(compilation)}\`,
                 route: '${pagePath}'
               });
             });
@@ -311,12 +304,6 @@ const bundleCompilation = async (compilation) => {
         return provider.shouldIntercept && provider.intercept
           || provider.shouldOptimize && provider.optimize;
       });
-      // centrally register all static resources
-      compilation.graph.map((page) => {
-        return page.imports;
-      }).flat().forEach(resource => {
-        compilation.resources.set(resource.sourcePathURL.pathname, resource);
-      });
 
       console.info('bundling static assets...');
 
diff --git a/packages/cli/src/lifecycles/compile.js b/packages/cli/src/lifecycles/compile.js
index 60b1a07c..5e0d2c97 100644
--- a/packages/cli/src/lifecycles/compile.js
+++ b/packages/cli/src/lifecycles/compile.js
@@ -42,15 +42,6 @@ const generateCompilation = () => {
 
         compilation.graph = JSON.parse(await fs.readFile(new URL('./graph.json', outputDir), 'utf-8'));
 
-        // hydrate URLs
-        compilation.graph.forEach((page, idx) => {
-          if (page.imports.length > 0) {
-            page.imports.forEach((imp, jdx) => {
-              compilation.graph[idx].imports[jdx].sourcePathURL = new URL(imp.sourcePathURL);
-            });
-          }
-        });
-
         if (await checkResourceExists(new URL('./manifest.json', outputDir))) {
           console.info('Loading manifest from build output...');
           // TODO put reviver into a utility?
diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js
index d937e6bf..e70c46e6 100644
--- a/packages/cli/src/lifecycles/graph.js
+++ b/packages/cli/src/lifecycles/graph.js
@@ -1,7 +1,7 @@
 /* eslint-disable complexity, max-depth */
 import fs from 'fs/promises';
 import fm from 'front-matter';
-import { checkResourceExists, modelResource } from '../lib/resource-utils.js';
+import { checkResourceExists } from '../lib/resource-utils.js';
 import toc from 'markdown-toc';
 import { Worker } from 'worker_threads';
 
@@ -19,7 +19,8 @@ const generateGraph = async (compilation) => {
         id: 'index',
         label: 'Index',
         data: {},
-        imports: []
+        imports: [],
+        resources: []
       }];
 
       const walkDirectoryForPages = async function(directory, pages = []) {
@@ -125,13 +126,7 @@ const generateGraph = async (compilation) => {
 
                 worker.on('message', async (result) => {
                   if (result.frontmatter) {
-                    const resources = await Promise.all((result.frontmatter.imports || []).map(async (resource) => {
-                      const type = resource.split('.').pop() === 'js' ? 'script' : 'link';
-
-                      return await modelResource(compilation.context, type, resource);
-                    }));
-
-                    result.frontmatter.imports = resources;
+                    result.frontmatter.imports = result.frontmatter.imports || [];
                     ssrFrontmatter = result.frontmatter;
                   }
                   resolve();
@@ -178,7 +173,8 @@ const generateGraph = async (compilation) => {
              * filename: base filename of the page
              * id: filename without the extension
              * label: "pretty" text representation of the filename
-             * imports: per page JS or CSS file imports to be included in HTML output
+             * imports: per page JS or CSS file imports to be included in HTML output from frontmatter
+             * resources: sum of all resources for the entire page
              * outputPath: the filename to write to when generating static HTML
              * path: path to the file relative to the workspace
              * route: URL route for a given page on outputFilePath
@@ -194,6 +190,7 @@ const generateGraph = async (compilation) => {
                   return `${idPart.charAt(0).toUpperCase()}${idPart.substring(1)}`;
                 }).join(' '),
               imports,
+              resources: [],
               outputPath: route === '/404/'
                 ? '404.html'
                 : `${route}index.html`,
@@ -306,6 +303,7 @@ const generateGraph = async (compilation) => {
               path: null,
               data: {},
               imports: [],
+              resources: [],
               outputPath: `${node.route}index.html`,
               ...node,
               external: true
diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js
index 0fa273b2..6e13b95b 100644
--- a/packages/cli/src/lifecycles/prerender.js
+++ b/packages/cli/src/lifecycles/prerender.js
@@ -1,13 +1,10 @@
 import fs from 'fs/promises';
-import htmlparser from 'node-html-parser';
-import { checkResourceExists, modelResource } from '../lib/resource-utils.js';
+import { checkResourceExists, trackResourcesForRoute } from '../lib/resource-utils.js';
 import os from 'os';
 import { WorkerPool } from '../lib/threadpool.js';
 
-function isLocalLink(url = '') {
-  return url !== '' && (url.indexOf('http') !== 0 && url.indexOf('//') !== 0);
-}
-
+// TODO a lot of these are duplicated in the build lifecycle too
+// would be good to refactor
 async function createOutputDirectory(route, outputDir) {
   if (route !== '/404/' && !await checkResourceExists(outputDir)) {
     await fs.mkdir(outputDir, {
@@ -16,61 +13,6 @@ async function createOutputDirectory(route, outputDir) {
   }
 }
 
-// TODO does this make more sense in bundle lifecycle?
-// https://github.com/ProjectEvergreen/greenwood/issues/970
-// or could this be done sooner (like in appTemplate building in html resource plugin)?
-// Or do we need to ensure userland code / plugins have gone first
-// before we can curate the final list of <script> / <style> / <link> tags to bundle
-async function trackResourcesForRoute(html, compilation, route) {
-  const { context } = compilation;
-  const root = htmlparser.parse(html, {
-    script: true,
-    style: true
-  });
-
-  // intentionally support <script> tags from the <head> or <body>
-  const scripts = await Promise.all(root.querySelectorAll('script')
-    .filter(script => (
-      isLocalLink(script.getAttribute('src')) || script.rawText)
-      && script.rawAttrs.indexOf('importmap') < 0)
-    .map(async(script) => {
-      const src = script.getAttribute('src');
-      const optimizationAttr = script.getAttribute('data-gwd-opt');
-      const { rawAttrs } = script;
-
-      if (src) {
-        // <script src="...."></script>
-        return await modelResource(context, 'script', src, null, optimizationAttr, rawAttrs);
-      } else if (script.rawText) {
-        // <script>...</script>
-        return await modelResource(context, 'script', null, script.rawText, optimizationAttr, rawAttrs);
-      }
-    }));
-
-  const styles = await Promise.all(root.querySelectorAll('style')
-    .filter(style => !(/\$/).test(style.rawText) && !(/<!-- Shady DOM styles for -->/).test(style.rawText)) // filter out Shady DOM <style> tags that happen when using puppeteer
-    .map(async(style) => await modelResource(context, 'style', null, style.rawText, null, style.getAttribute('data-gwd-opt'))));
-
-  const links = await Promise.all(root.querySelectorAll('head link')
-    .filter(link => {
-      // <link rel="stylesheet" href="..."></link>
-      return link.getAttribute('rel') === 'stylesheet'
-        && link.getAttribute('href') && isLocalLink(link.getAttribute('href'));
-    }).map(async(link) => {
-      return modelResource(context, 'link', link.getAttribute('href'), null, link.getAttribute('data-gwd-opt'), link.rawAttrs);
-    }));
-
-  const resources = [
-    ...scripts,
-    ...styles,
-    ...links
-  ];
-
-  compilation.graph.find(page => page.route === route).imports = resources;
-
-  return resources;
-}
-
 async function servePage(url, request, plugins) {
   let response = new Response('');
 
@@ -116,7 +58,7 @@ async function preRenderCompilationWorker(compilation, workerPrerender) {
   const pool = new WorkerPool(os.cpus().length, workerPrerender.workerUrl);
 
   for (const page of pages) {
-    const { route, outputPath } = page;
+    const { route, outputPath, resources } = page;
     const outputDirUrl = new URL(`./${route}/`, scratchDir);
     const outputPathUrl = new URL(`./${outputPath}`, scratchDir);
     const url = new URL(`http://localhost:${compilation.config.port}${route}`);
@@ -127,8 +69,8 @@ async function preRenderCompilationWorker(compilation, workerPrerender) {
 
     await createOutputDirectory(route, outputDirUrl);
 
-    const resources = await trackResourcesForRoute(body, compilation, route);
     const scripts = resources
+      .map(resource => compilation.resources.get(resource))
       .filter(resource => resource.type === 'script')
       .map(resource => resource.sourcePathURL.href);
 
@@ -200,7 +142,6 @@ async function staticRenderCompilation(compilation) {
     let body = await (await servePage(url, request, plugins)).text();
     body = await (await interceptPage(url, request, plugins, body)).text();
 
-    await trackResourcesForRoute(body, compilation, route);
     await createOutputDirectory(route, outputDirUrl);
     await fs.writeFile(outputPathUrl, body);
 
diff --git a/packages/cli/src/plugins/copy/plugin-copy-graph-json.js b/packages/cli/src/plugins/copy/plugin-copy-graph-json.js
deleted file mode 100644
index 2753c4a8..00000000
--- a/packages/cli/src/plugins/copy/plugin-copy-graph-json.js
+++ /dev/null
@@ -1,14 +0,0 @@
-const greenwoodPluginCopyGraphJson = [{
-  type: 'copy',
-  name: 'plugin-copy-graph-json',
-  provider: (compilation) => {
-    const { scratchDir, outputDir } = compilation.context;
-
-    return [{
-      from: new URL('./graph.json', scratchDir),
-      to: new URL('./graph.json', outputDir)
-    }];
-  }
-}];
-
-export { greenwoodPluginCopyGraphJson };
\ No newline at end of file
diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js
index bb592d3b..52fbd8e4 100644
--- a/packages/cli/src/plugins/resource/plugin-standard-html.js
+++ b/packages/cli/src/plugins/resource/plugin-standard-html.js
@@ -218,11 +218,11 @@ class StandardHtmlResource extends ResourceInterface {
   async optimize(url, response) {
     const { optimization } = this.compilation.config;
     const { pathname } = url;
-    const pageResources = this.compilation.graph.find(page => page.outputPath === pathname || page.route === pathname).imports;
+    const pageResources = this.compilation.graph.find(page => page.outputPath === pathname || page.route === pathname).resources;
     let body = await response.text();
 
     for (const pageResource of pageResources) {
-      const keyedResource = this.compilation.resources.get(pageResource.sourcePathURL.pathname);
+      const keyedResource = this.compilation.resources.get(pageResource);
       const { contents, src, type, optimizationAttr, optimizedFileContents, optimizedFileName, rawAttributes } = keyedResource;
 
       if (src) {


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
index 0dd5f526..dd25603c 100644
--- 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
@@ -209,8 +209,11 @@ describe('Build Greenwood With: ', function() {
         const componentName = 'counter';
         const counterScript = Array.from(dom.window.document.querySelectorAll('head > script[src]'))
           .filter((tag) => tag.getAttribute('src').indexOf(`/${componentName}.`) === 0);
+        const counterImport = artistsPageGraphData.imports
+          .filter(script => script.indexOf(`${componentName}.`) >= 0);
 
-        expect(artistsPageGraphData.imports[0].src).to.equal(`/components/${componentName}.js`);
+        expect(artistsPageGraphData.imports.length).to.equal(1);
+        expect(counterImport.length).to.equal(1);
         expect(counterScript.length).to.equal(1);
       });
     });
diff --git a/packages/cli/test/cases/develop.ssr/develop.ssr.spec.js b/packages/cli/test/cases/develop.ssr/develop.ssr.spec.js
index 6aa36c17..4dbfddc1 100644
--- a/packages/cli/test/cases/develop.ssr/develop.ssr.spec.js
+++ b/packages/cli/test/cases/develop.ssr/develop.ssr.spec.js
@@ -232,7 +232,7 @@ describe('Develop Greenwood With: ', function() {
         const counterScript = Array.from(dom.window.document.querySelectorAll('head > script[src]'))
           .filter((tag) => tag.getAttribute('src').indexOf(`${componentName}.js`) >= 0);
 
-        expect(artistsPageGraphData.imports[0].src).to.equal(`/components/${componentName}.js`);
+        expect(artistsPageGraphData.imports[0]).to.equal(`/components/${componentName}.js`);
         expect(counterScript.length).to.equal(1);
       });
     });
diff --git a/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js b/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js
index d119d357..596967e9 100644
--- a/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js
+++ b/packages/cli/test/cases/serve.default.ssr/serve.default.ssr.spec.js
@@ -186,7 +186,14 @@ describe('Serve Greenwood With: ', function() {
       it('should have the expected number of <script> tags in the <head>', function() {
         const scripts = artistsPageDom.window.document.querySelectorAll('head > script');
 
-        expect(scripts.length).to.equal(2);
+        expect(scripts.length).to.equal(3);
+      });
+
+      it('should have the expected <app-header> tag from the app template in the <head>', function() {
+        const scripts = Array.from(artistsPageDom.window.document.querySelectorAll('head > script'))
+          .filter(script => script.src && script.src.startsWith('/header.'));
+
+        expect(scripts.length).to.equal(1);
       });
 
       it('should have expected SSR content from the non module script tag', function() {
@@ -234,14 +241,26 @@ describe('Serve Greenwood With: ', function() {
       });
 
       it('should append the expected <script> tag for a frontmatter import <x-counter> component', function() {
-        const componentName = 'counter';
         const counterScript = Array.from(artistsPageDom.window.document.querySelectorAll('head > script[src]'))
-          .filter((tag) => tag.getAttribute('src').indexOf(`/${componentName}.`) === 0);
+          .filter((tag) => tag.getAttribute('src').startsWith('/counter.'));
 
-        expect(artistsPageGraphData.imports[0].src).to.equal(`/components/${componentName}.js`);
         expect(counterScript.length).to.equal(1);
       });
 
+      it('should append the expected graph import scripts for the page', function() {
+        const { imports } = artistsPageGraphData;
+
+        expect(imports.length).to.equal(1);
+        expect(imports[0]).to.equal('/components/counter.js');
+      });
+
+      it('should append the expected graph resource scripts for the page from a template', function() {
+        const { resources } = artistsPageGraphData;
+
+        expect(resources.length).to.equal(4);
+        expect(resources.find(resource => resource.endsWith('/header.js'))).to.not.be.undefined;
+      });
+
       it('should have the expected bundled SSR output for the page', async function() {
         const scriptFiles = (await glob.promise(path.join(this.context.publicDir, '*.js')))
           .filter(file => file.indexOf('artists.js') >= 0);
diff --git a/packages/cli/test/cases/serve.default.ssr/src/components/header.js b/packages/cli/test/cases/serve.default.ssr/src/components/header.js
new file mode 100644
index 00000000..61ee4639
--- /dev/null
+++ b/packages/cli/test/cases/serve.default.ssr/src/components/header.js
@@ -0,0 +1,19 @@
+class HeaderComponent extends HTMLElement {
+  constructor() {
+    super();
+
+    this.root = this.attachShadow({ mode: 'open' });
+  }
+
+  connectedCallback() {
+    this.root.innerHTML = this.getTemplate();
+  }
+
+  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/serve.default.ssr/src/templates/app.html b/packages/cli/test/cases/serve.default.ssr/src/templates/app.html
index e2bf8602..2d1227c9 100644
--- a/packages/cli/test/cases/serve.default.ssr/src/templates/app.html
+++ b/packages/cli/test/cases/serve.default.ssr/src/templates/app.html
@@ -4,8 +4,10 @@
     <title>Default Title</title>
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1"/>
+    <script type="module" src="../components/header.js"></script>
   </head>
   <body>
+    <app-header></app-header>
     <page-outlet></page-outlet>
   </body>
 </html>
\ No newline at end of file