Repository: ProjectEvergreen/greenwood (js)

Issue:
diacritics in routes generated by external source plugins cause crashes during build
### What happened?

when you have a diacritic in the route generated by an external source plugin, you will be fine during dev mode, but during build it will fail with an undefined route (even though the route is in fact defined!!) 

```
Running Greenwood with the build command.
pages to generate 
 /L%C3%BCgner/
 /
 /404/
 /foo/
 /Lügner2/
TypeError: Cannot read properties of undefined (reading 'route')
    at ContentAsDataResource.intercept (file:///Users/lschiere/src/schierer/greenwood-repoductions/node_modules/.pnpm/@greenwood+cli@0.32.0-alpha.6/node_modules/@greenwood/cli/src/plugins/resource/plugin-active-content.js:108:86)
    at async interceptPage (file:///Users/lschiere/src/schierer/greenwood-repoductions/node_modules/.pnpm/@greenwood+cli@0.32.0-alpha.6/node_modules/@greenwood/cli/src/lifecycles/prerender.js:45:42)
    at async file:///Users/lschiere/src/schierer/greenwood-repoductions/node_modules/.pnpm/@greenwood+cli@0.32.0-alpha.6/node_modules/@greenwood/cli/src/lifecycles/prerender.js:194:21
    at async Promise.all (index 4)
    at async staticRenderCompilation (file:///Users/lschiere/src/schierer/greenwood-repoductions/node_modules/.pnpm/@greenwood+cli@0.32.0-alpha.6/node_modules/@greenwood/cli/src/lifecycles/prerender.js:186:3)
    at async file:///Users/lschiere/src/schierer/greenwood-repoductions/node_modules/.pnpm/@greenwood+cli@0.32.0-alpha.6/node_modules/@greenwood/cli/src/commands/build.js:73:9
 ELIFECYCLE  Command failed with exit code 1.
```

if I go into the external source plugin and cause it to exclude the ```Lügner2``` route, it will work fine. 

### Steps to reproduce

I have a minimal reproduction at <https://github.com/lschierer/greenwood-repoductions/tree/crash_on_diacritics>
you should be able to just check that tag out then run 
```pnpm i```
```pnpm build```


### Environment

I have a mise.toml file specifying pnpm 10, I've tested with both node 22.11 and node 22.14. both on OSX. 

### Additional Context

_No response_

Pull request description:
bug/issue 1456 better handling and display for special characters in pages metadata
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue

resolves #1456 

## Documentation 

1. [x] ~~Need to callout on the sources plugin docs that `route` should already be URI encoded~~ - N / A

## Summary of Changes

1. Handle display for special characters in `title`, `label`, and `id`
1. Add test cases

## TODO
1. [ ] Should probably add test cases for `title` / `label`
1. [x] Strategy around [Greenwood auto-encoding `route`](https://github.com/ProjectEvergreen/greenwood/issues/1456#issuecomment-2745347569)?

Patch:
diff --git a/.ls-lint.yml b/.ls-lint.yml
index c5068dc5..c0907a6f 100644
--- a/.ls-lint.yml
+++ b/.ls-lint.yml
@@ -20,3 +20,4 @@ ignore:
   - packages/plugin-babel/node_modules
   - packages/init/node_modules
   - packages/plugin-typescript/node_modules
+  - packages/cli/test/cases/build.default.workspace-special-characters/src/pages
diff --git a/packages/cli/src/lifecycles/graph.js b/packages/cli/src/lifecycles/graph.js
index 82018af5..e58c558b 100644
--- a/packages/cli/src/lifecycles/graph.js
+++ b/packages/cli/src/lifecycles/graph.js
@@ -115,7 +115,9 @@ const generateGraph = async (compilation) => {
                * isolation: if this should be run in isolated mode
                */
               apiRoutes.set(`${basePath}${route}`, {
-                id: getIdFromRelativePathPath(relativePagePath, extension).replace("api-", ""),
+                id: decodeURIComponent(
+                  getIdFromRelativePathPath(relativePagePath, extension).replace("api-", ""),
+                ),
                 pageHref: new URL(relativePagePath, pagesDir).href,
                 outputHref: new URL(relativePagePath, outputDir).href.replace(extension, ".js"),
                 route: `${basePath}${route}`,
@@ -263,9 +265,9 @@ const generateGraph = async (compilation) => {
                * servePage: signal that this is a custom page file type (static | dynamic)
                */
               const page = {
-                id: getIdFromRelativePathPath(relativePagePath, extension),
-                label,
-                title,
+                id: decodeURIComponent(getIdFromRelativePathPath(relativePagePath, extension)),
+                label: decodeURIComponent(label),
+                title: title ? decodeURIComponent(title) : title,
                 route: `${basePath}${route}`,
                 layout,
                 data: customData || {},
@@ -367,8 +369,10 @@ const generateGraph = async (compilation) => {
           const data = await instance();
 
           for (const node of data) {
-            if (!node.body || !node.route) {
-              const missingKey = !node.body ? "body" : "route";
+            const { body, route } = node;
+
+            if (!body || !route) {
+              const missingKey = !body ? "body" : "route";
 
               reject(`ERROR: provided node does not provide a ${missingKey} property.`);
             }
@@ -378,8 +382,9 @@ const generateGraph = async (compilation) => {
               data: {},
               imports: [],
               resources: [],
-              outputHref: new URL(`.${node.route}index.html`, outputDir).href,
+              outputHref: new URL(`.${route}index.html`, outputDir).href,
               ...node,
+              route: encodeURIComponent(route).replace(/%2F/g, "/"),
               external: true,
             });
           }
diff --git "a/packages/cli/test/cases/build.default.workspace-special-characters/src/pages/L\303\274gner2.html" "b/packages/cli/test/cases/build.default.workspace-special-characters/src/pages/L\303\274gner2.html"
new file mode 100644
index 00000000..a0c6e6c7
--- /dev/null
+++ "b/packages/cli/test/cases/build.default.workspace-special-characters/src/pages/L\303\274gner2.html"
@@ -0,0 +1,6 @@
+<!doctype html>
+<html>
+  <body>
+    <h1>Lügner2 Page</h1>
+  </body>
+</html>


Test patch:
diff --git a/packages/cli/test/cases/build.default.workspace-special-characters/build.default.workspace-special-characters.spec.js b/packages/cli/test/cases/build.default.workspace-special-characters/build.default.workspace-special-characters.spec.js
new file mode 100644
index 00000000..faea0446
--- /dev/null
+++ b/packages/cli/test/cases/build.default.workspace-special-characters/build.default.workspace-special-characters.spec.js
@@ -0,0 +1,99 @@
+/*
+ * Use Case
+ * Run Greenwood build command with no config and special characters in workspace files.
+ *
+ * User Result
+ * Should successfully generate a Greenwood build.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * None (Greenwood Default)
+ *
+ * User Workspace
+ * src/
+ *   pages/
+ *     First Post.html
+ *     index.html
+ *     Lügner2.html
+ */
+import chai from "chai";
+import { JSDOM } from "jsdom";
+import path from "path";
+import { runSmokeTest } from "../../../../../test/smoke-test.js";
+import { getOutputTeardownFiles } from "../../../../../test/utils.js";
+import { Runner } from "gallinago";
+import { fileURLToPath, URL } from "url";
+
+const expect = chai.expect;
+
+describe("Build Greenwood With: ", function () {
+  const LABEL =
+    "Default Greenwood Configuration and Workspace with special characters in page names";
+  const cliPath = path.join(process.cwd(), "packages/cli/src/index.js");
+  const outputPath = fileURLToPath(new URL(".", import.meta.url));
+  let runner;
+
+  before(function () {
+    this.context = {
+      publicDir: path.join(outputPath, "public"),
+    };
+    runner = new Runner();
+  });
+
+  describe(LABEL, function () {
+    before(function () {
+      runner.setup(outputPath);
+      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"));
+      });
+
+      it("should have a the expected heading text", function () {
+        const heading = dom.window.document.querySelector("body h1").textContent;
+
+        expect(heading).to.be.equal("Home Page");
+      });
+    });
+
+    describe("Default output for Lügner2.html", function () {
+      let dom;
+
+      before(async function () {
+        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, "./Lügner2/index.html"));
+      });
+
+      it("should have a the expected heading text", function () {
+        const heading = dom.window.document.querySelector("body h1").textContent;
+
+        expect(heading).to.be.equal("Lügner2 Page");
+      });
+    });
+
+    describe("Default output for First Post.html", function () {
+      let dom;
+
+      before(async function () {
+        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, "./First Post/index.html"));
+      });
+
+      it("should have a the expected heading text", function () {
+        const heading = dom.window.document.querySelector("body h1").textContent;
+
+        expect(heading).to.be.equal("First Post Page");
+      });
+    });
+  });
+
+  after(function () {
+    runner.teardown(getOutputTeardownFiles(outputPath));
+  });
+});
diff --git a/packages/cli/test/cases/build.default.workspace-special-characters/src/pages/First Post.html b/packages/cli/test/cases/build.default.workspace-special-characters/src/pages/First Post.html
new file mode 100644
index 00000000..31e6f218
--- /dev/null
+++ b/packages/cli/test/cases/build.default.workspace-special-characters/src/pages/First Post.html
@@ -0,0 +1,6 @@
+<!doctype html>
+<html>
+  <body>
+    <h1>First Post Page</h1>
+  </body>
+</html>
diff --git a/packages/cli/test/cases/build.default.workspace-special-characters/src/pages/index.html b/packages/cli/test/cases/build.default.workspace-special-characters/src/pages/index.html
new file mode 100644
index 00000000..f706c551
--- /dev/null
+++ b/packages/cli/test/cases/build.default.workspace-special-characters/src/pages/index.html
@@ -0,0 +1,6 @@
+<!doctype html>
+<html>
+  <body>
+    <h1>Home Page</h1>
+  </body>
+</html>
diff --git a/packages/cli/test/cases/build.plugins.source/build.plugins-source.spec.js b/packages/cli/test/cases/build.plugins.source/build.plugins-source.spec.js
index 36b50a32..9ccb85d7 100644
--- a/packages/cli/test/cases/build.plugins.source/build.plugins-source.spec.js
+++ b/packages/cli/test/cases/build.plugins.source/build.plugins-source.spec.js
@@ -141,6 +141,34 @@ describe("Build Greenwood With: ", function () {
         });
       });
     });
+
+    describe("Default output for Lügner2.html", function () {
+      let dom;
+
+      before(async function () {
+        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, "./Lügner2/index.html"));
+      });
+
+      it("should have a the expected heading text", function () {
+        const heading = dom.window.document.querySelector("body h1").textContent;
+
+        expect(heading).to.be.equal("Lügner2 Page");
+      });
+    });
+
+    describe("Default output for First Post.html", function () {
+      let dom;
+
+      before(async function () {
+        dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, "./First Post/index.html"));
+      });
+
+      it("should have a the expected heading text", function () {
+        const heading = dom.window.document.querySelector("body h1").textContent;
+
+        expect(heading).to.be.equal("First Post Page");
+      });
+    });
   });
 
   after(function () {
diff --git a/packages/cli/test/cases/build.plugins.source/greenwood.config.js b/packages/cli/test/cases/build.plugins.source/greenwood.config.js
index c03d375e..2575766d 100644
--- a/packages/cli/test/cases/build.plugins.source/greenwood.config.js
+++ b/packages/cli/test/cases/build.plugins.source/greenwood.config.js
@@ -9,27 +9,44 @@ const customExternalSourcesPlugin = {
         await fs.readFile(new URL("./data.json", import.meta.url), "utf-8"),
       );
 
-      return artists.map((artist) => {
-        const { bio, id, imageUrl, name } = artist;
-        const route = `/artists/${name.toLowerCase().replace(/ /g, "-")}/`;
+      return [
+        ...artists.map((artist) => {
+          const { bio, id, imageUrl, name } = artist;
+          const route = `/artists/${name.toLowerCase().replace(/ /g, "-")}/`;
 
-        return {
-          title: name,
-          body: `
+          return {
+            title: name,
+            body: `
             <p>${bio}</p>
             <img src='${imageUrl}'/>
           `,
-          route,
-          layout: "artist",
-          label: name,
-          imageUrl,
-          id,
-        };
-      });
+            route,
+            layout: "artist",
+            label: name,
+            imageUrl,
+            id,
+          };
+        }),
+        {
+          title: "Lügner2",
+          id: "Lügner2",
+          label: "Lügner2",
+          route: "/Lügner2/",
+          body: `<h1>Lügner2 Page</h1>`,
+        },
+        {
+          title: "First Post",
+          id: "first-post",
+          label: "First Post",
+          route: "/First Post/",
+          body: `<h1>First Post Page</h1>`,
+        },
+      ];
     };
   },
 };
 
 export default {
+  activeContent: true,
   plugins: [customExternalSourcesPlugin],
 };