Repository: ProjectEvergreen/greenwood (js)

Issue:
data client not working on production builds (when not using `prerender` option)
<!--
# PLEASE READ FIRST
If asking a question, looking for help, or you have an idea for Greenwood, please consider opening a discussion first or chatting with us in our Discord.
-->

## Type of Change

Bug

## Summary

While working on [this project](https://github.com/thescientist13/HP_Stuff/pull/1) realized that there was a significant oversight in how the data client would work.  It technically won't work as is without `prerender` enabled for a production build, since without prerendering, the custom JSON files per query will not be generated, leading to 404s in a production build
![image](https://github.com/user-attachments/assets/fec2ee60-0c3e-4403-b79e-35aa5dd5c367)

- For now have captured this in the discussion here - https://github.com/ProjectEvergreen/greenwood/discussions/1286
- Also, realized the docs don't capture this nuance well either - https://github.com/ProjectEvergreen/www.greenwoodjs.dev/pull/154

## Details

It seems for now we can get away with making a non-prerender fallback for the data client, where if prerendering was _not_ used, we can just `fetch` _graph.json_ and do the filtering client side.

Pull request description:
bug/issue 1354 support isomorphic data client APIs without `prerender` configuration option
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue

resolves #1354

## Documentation 

https://github.com/ProjectEvergreen/www.greenwoodjs.dev/pull/154

## Summary of Changes

1. Add "fallback" support for using the isomorphic data client APIs without setting the `prerender` flag
1. Add some test cases for content as data `<script>` setup blocks

Patch:
diff --git a/packages/cli/src/data/client.js b/packages/cli/src/data/client.js
index 57ab05a4..50cc34dc 100644
--- a/packages/cli/src/data/client.js
+++ b/packages/cli/src/data/client.js
@@ -1,13 +1,32 @@
+import { filterContentByCollection, filterContentByRoute } from '@greenwood/cli/src/lib/content-utils.js';
+
 const CONTENT_STATE = globalThis.__CONTENT_AS_DATA_STATE__ ?? false; // eslint-disable-line no-underscore-dangle
-const PORT = globalThis?.__CONTENT_SERVER__?.PORT ?? 1984; // eslint-disable-line no-underscore-dangle
+const PRERENDER = globalThis.__CONTENT_OPTIONS__?.PRERENDER === 'true'; // eslint-disable-line no-underscore-dangle
+const PORT = globalThis?.__CONTENT_OPTIONS__?.PORT ?? 1984; // eslint-disable-line no-underscore-dangle
 const BASE_PATH = globalThis?.__GWD_BASE_PATH__ ?? ''; // eslint-disable-line no-underscore-dangle
 
 async function getContentAsData(key = '') {
-  return CONTENT_STATE
-    ? await fetch(`${window.location.origin}${BASE_PATH}/data-${key.replace(/\//g, '_')}.json`)
-      .then(resp => resp.json())
-    : await fetch(`http://localhost:${PORT}${BASE_PATH}/___graph.json`, { headers: { 'X-CONTENT-KEY': key } })
+  if (CONTENT_STATE && PRERENDER) {
+    // fetch customized query files when a user has opted-in for prerendering with active content
+    await fetch(`${window.location.origin}${BASE_PATH}/data-${key.replace(/\//g, '_')}.json`)
+      .then(resp => resp.json());
+  } else if (CONTENT_STATE && !PRERENDER) {
+    // if user is not prerendering, just fetch the entire graph but apply the same filtering
+    const graph = await fetch('/graph.json').then(resp => resp.json());
+    const value = key.split('-').pop();
+
+    if (key === 'graph') {
+      return graph;
+    } else if (key.startsWith('collection')) {
+      return filterContentByCollection(graph, value);
+    } else if (key.startsWith('route')) {
+      return filterContentByRoute(graph, value);
+    }
+  } else {
+    // otherwise users is developing locally, so hit the dev server
+    return await fetch(`http://localhost:${PORT}${BASE_PATH}/___graph.json`, { headers: { 'X-CONTENT-KEY': key } })
       .then(resp => resp.json());
+  }
 }
 
 async function getContent() {
diff --git a/packages/cli/src/lib/content-utils.js b/packages/cli/src/lib/content-utils.js
index 7418918b..fc5c41c2 100644
--- a/packages/cli/src/lib/content-utils.js
+++ b/packages/cli/src/lib/content-utils.js
@@ -31,8 +31,18 @@ function cleanContentCollection(collection = []) {
   });
 }
 
+function filterContentByCollection(graph, collection) {
+  return graph.filter(page => page?.data?.collection === collection);
+}
+
+function filterContentByRoute(graph, route) {
+  return graph.filter(page => page?.route.startsWith(route));
+}
+
 export {
   pruneGraph,
   activeFrontmatterKeys,
-  cleanContentCollection
+  cleanContentCollection,
+  filterContentByCollection,
+  filterContentByRoute
 };
\ No newline at end of file
diff --git a/packages/cli/src/plugins/resource/plugin-active-content.js b/packages/cli/src/plugins/resource/plugin-active-content.js
index 7c99e601..f1e8767d 100644
--- a/packages/cli/src/plugins/resource/plugin-active-content.js
+++ b/packages/cli/src/plugins/resource/plugin-active-content.js
@@ -1,11 +1,12 @@
 import { mergeImportMap } from '../../lib/node-modules-utils.js';
 import { ResourceInterface } from '../../lib/resource-interface.js';
 import { checkResourceExists } from '../../lib/resource-utils.js';
-import { activeFrontmatterKeys, cleanContentCollection, pruneGraph } from '../../lib/content-utils.js';
+import { activeFrontmatterKeys, cleanContentCollection, pruneGraph, filterContentByCollection, filterContentByRoute } from '../../lib/content-utils.js';
 import fs from 'fs/promises';
 
 const importMap = {
-  '@greenwood/cli/src/data/client.js': '/node_modules/@greenwood/cli/src/data/client.js'
+  '@greenwood/cli/src/data/client.js': '/node_modules/@greenwood/cli/src/data/client.js',
+  '@greenwood/cli/src/lib/content-utils.js': '/node_modules/@greenwood/cli/src/lib/content-utils.js'
 };
 
 class ContentAsDataResource extends ResourceInterface {
@@ -38,9 +39,9 @@ class ContentAsDataResource extends ResourceInterface {
       if (contentKey === 'graph') {
         body = graph;
       } else if (keyPieces[0] === 'collection') {
-        body = graph.filter(page => page?.data?.collection === keyPieces[1]);
+        body = filterContentByCollection(graph, keyPieces[1]);
       } else if (keyPieces[0] === 'route') {
-        body = graph.filter(page => page?.route.startsWith(keyPieces[1]));
+        body = filterContentByRoute(graph, keyPieces[1]);
       }
 
       if (process.env.__GWD_COMMAND__ === 'build') { // eslint-disable-line no-underscore-dangle
@@ -78,11 +79,12 @@ class ContentAsDataResource extends ResourceInterface {
 
     newBody = newBody.replace('<head>', `
       <head>
-        <script id="content-server">
-          globalThis.__CONTENT_SERVER__ = globalThis.__CONTENT_SERVER__
-            ? globalThis.__CONTENT_SERVER__
+        <script id="data-client-options">
+          globalThis.__CONTENT_OPTIONS__ = globalThis.__CONTENT_OPTIONS__
+            ? globalThis.__CONTENT_OPTIONS__
             : {
-                PORT: ${devServer.port}
+                PORT: ${devServer.port},
+                PRERENDER: "${this.compilation.config.prerender}",
               }
         </script>
     `);
@@ -127,7 +129,7 @@ class ContentAsDataResource extends ResourceInterface {
 
     body = body.replace('<head>', `
       <head>
-        <script id="content-state">
+        <script id="content-as-data-state">
           globalThis.__CONTENT_AS_DATA_STATE__ = true;
         </script>
     `);


Test patch:
diff --git a/packages/cli/test/cases/build.config.prerender-collections/build.config.prerender-collections.spec.js b/packages/cli/test/cases/build.config.prerender-collections/build.config.prerender-collections.spec.js
index b11f6fc8..49c459e0 100644
--- a/packages/cli/test/cases/build.config.prerender-collections/build.config.prerender-collections.spec.js
+++ b/packages/cli/test/cases/build.config.prerender-collections/build.config.prerender-collections.spec.js
@@ -80,6 +80,28 @@ describe('Build Greenwood With: ', function() {
         });
       });
 
+      describe('<script> tag setup for active content', function() {
+        let stateScripts;
+        let optionsScript;
+
+        before(function() {
+          stateScripts = dom.window.document.querySelectorAll('script#content-as-data-state');
+          optionsScript = dom.window.document.querySelectorAll('script#data-client-options');
+        });
+
+        it('should have a <script> tag that confirms content as data is set', function() {
+          expect(stateScripts.length).to.equal(1);
+          expect(stateScripts[0].textContent).to.contain('globalThis.__CONTENT_AS_DATA_STATE__ = true;');
+        });
+
+        it('should have a <script> tag that captures content as data related options', function() {
+          expect(optionsScript.length).to.equal(1);
+
+          expect(optionsScript[0].textContent).to.contain('PORT:1984');
+          expect(optionsScript[0].textContent).to.contain('PRERENDER:"true"');
+        });
+      });
+
       describe('navigation links from getContentByCollection', function() {
         let navLinks;
 
diff --git a/packages/cli/test/cases/serve.default/greenwood.config.js b/packages/cli/test/cases/serve.default/greenwood.config.js
index 42c9683c..925e806c 100644
--- a/packages/cli/test/cases/serve.default/greenwood.config.js
+++ b/packages/cli/test/cases/serve.default/greenwood.config.js
@@ -4,5 +4,6 @@ export default {
       '/posts': 'https://jsonplaceholder.typicode.com'
     }
   },
-  port: 8181
+  port: 8181,
+  activeContent: true // just here to test some of the setup <script> output
 };
\ No newline at end of file
diff --git a/packages/cli/test/cases/serve.default/serve.default.spec.js b/packages/cli/test/cases/serve.default/serve.default.spec.js
index 4a14a803..60424b39 100644
--- a/packages/cli/test/cases/serve.default/serve.default.spec.js
+++ b/packages/cli/test/cases/serve.default/serve.default.spec.js
@@ -15,7 +15,8 @@
  *      '/post': 'https://jsonplaceholder.typicode.com'
  *    }
  *   },
- *   port: 8181
+ *   port: 8181,
+ *   activeContent: true // just here to test some basic content as data defaults
  * }
  *
  * User Workspace
@@ -33,6 +34,7 @@
  *     webcomponents.svg
  */
 import chai from 'chai';
+import { JSDOM } from 'jsdom';
 import path from 'path';
 import { getOutputTeardownFiles } from '../../../../../test/utils.js';
 import { runSmokeTest } from '../../../../../test/smoke-test.js';
@@ -72,6 +74,30 @@ describe('Serve Greenwood With: ', function() {
 
     runSmokeTest(['serve'], LABEL);
 
+    describe('<script> tag setup for active content', function() {
+      let dom;
+
+      before(async function() {
+        const response = await fetch(`${hostname}`);
+        dom = new JSDOM(await response.text());
+      });
+
+      it('should have a <script> tag that confirms content as data is set', function() {
+        const stateScripts = dom.window.document.querySelectorAll('script#content-as-data-state');
+
+        expect(stateScripts.length).to.equal(1);
+        expect(stateScripts[0].textContent).to.contain('globalThis.__CONTENT_AS_DATA_STATE__ = true;');
+      });
+
+      it('should have a <script> tag that captures content as data related options', function() {
+        const optionsScript = dom.window.document.querySelectorAll('script#data-client-options');
+
+        expect(optionsScript.length).to.equal(1);
+        expect(optionsScript[0].textContent).to.contain('PORT:1984');
+        expect(optionsScript[0].textContent).to.contain('PRERENDER:"false"');
+      });
+    });
+
     // proxies to https://jsonplaceholder.typicode.com/posts via greenwood.config.js
     describe('Serve command with dev proxy', function() {
       let response = {};