Repository: single-spa/single-spa-layout
Pull request #5 (2020-04-28 23:28:17)

Finishing constructRoutes, and implementing matchRoute.
`matchRoute` will be used internally by single-spa-layout, but I'm exposing it because I think it'll be useful for server rendering code to call it directly


Patch:
diff --git a/src/constructRoutes.js b/src/constructRoutes.js
index 0a0a982..e3daed6 100644
--- a/src/constructRoutes.js
+++ b/src/constructRoutes.js
@@ -4,42 +4,50 @@ import {
   validateKeys,
   validateString,
   validateObject,
+  validateContainerEl,
 } from "./validation-helpers.js";
 
 /**
  * @typedef {{
  * mode?: string;
  * base?: string;
- * containerEl?: string | HTMLElement;
+ * containerEl?: ContainerEl;
  * disableWarnings?: boolean;
  * routes: Array<Route>;
  * }} RoutesConfig
  *
+ * @typedef {{
+ * mode: string;
+ * base: string;
+ * containerEl: ContainerEl;
+ * routes: Array<Route>;
+ * }} ResolvedRoutesConfig
+ *
  * @typedef {UrlRoute | Application} Route
  *
+ * @typedef {string | HTMLElement} ContainerEl
+ *
  * @typedef {{
- * type: 'route';
+ * type: string;
  * path: string;
- * routes?: Array<Route>;
+ * routes: Array<Route>;
  * }} UrlRoute
  *
  * @typedef {{
- * type: 'application';
+ * type: string;
  * name: string;
- * props: object;
+ * props?: object;
  * }} Application
  *
  * @param {RoutesConfig} routesConfig
- * @returns {any}
+ * @returns {ResolvedRoutesConfig}
  */
 export function constructRoutes(routesConfig) {
-  validate(routesConfig);
-
-  // TODO - construct a "resolved routes" return value
+  validateAndSanitize(routesConfig);
   return routesConfig;
 }
 
-function validate(routesConfig) {
+function validateAndSanitize(routesConfig) {
   validateObject("routesConfig", routesConfig);
 
   const disableWarnings = routesConfig.disableWarnings;
@@ -51,15 +59,23 @@ function validate(routesConfig) {
     disableWarnings
   );
 
+  if (routesConfig.hasOwnProperty("containerEl")) {
+    validateContainerEl("routesConfig.containerEl", routesConfig.containerEl);
+  } else {
+    routesConfig.containerEl = "body";
+  }
+
   if (!routesConfig.hasOwnProperty("mode")) {
     routesConfig.mode = "history";
   }
   validateEnum("routesConfig.mode", routesConfig.mode, ["history", "hash"]);
 
-  if (!routesConfig.hasOwnProperty("base")) {
-    routesConfig.base = getBaseURI();
+  if (routesConfig.hasOwnProperty("base")) {
+    validateString("routesConfig.base", routesConfig.base);
+    routesConfig.base = sanitizeBase(routesConfig.base);
+  } else {
+    routesConfig.base = "/";
   }
-  validateString("routesConfig.base", routesConfig.base);
 
   validateArray("routesConfig.routes", routesConfig.routes, validateRoute);
 
@@ -78,38 +94,21 @@ function validate(routesConfig) {
         disableWarnings
       );
       validateString(`${propertyName}.path`, route.path);
-      if (route.hasOwnProperty("routes")) {
-        validateArray(`${propertyName}.routes`, route.routes, validateRoute);
-      }
+      validateArray(`${propertyName}.routes`, route.routes, validateRoute);
     }
   }
-}
 
-function getBaseURI() {
-  if (typeof document !== "undefined") {
-    if (document.baseURI) {
-      return document.baseURI;
-    } else {
-      // Our old friend, Internet Explorer 11, does not support document.baseURI
-      // This code was taken from
-      // https://github.com/systemjs/systemjs/blob/109af162d97b54a7857e693f33d47a9058f4f18d/src/common.js#L15-L26
-
-      let baseUrl;
-      const baseEl = document.querySelector("base[href]");
-      if (baseEl) baseUrl = baseEl.href;
+  delete routesConfig.disableWarnings;
+}
 
-      if (!baseUrl) {
-        baseUrl = location.href.split("#")[0].split("?")[0];
-        const lastSepIndex = baseUrl.lastIndexOf("/");
-        if (lastSepIndex !== -1) {
-          baseUrl = baseUrl.slice(0, lastSepIndex + 1);
-        }
-      }
+function sanitizeBase(base) {
+  if (!base.startsWith("/")) {
+    base = "/" + base;
+  }
 
-      return baseUrl;
-    }
-  } else {
-    // NodeJS
-    return "/";
+  if (!base.endsWith("/")) {
+    base = base + "/";
   }
+
+  return base;
 }
diff --git a/src/matchRoute.js b/src/matchRoute.js
new file mode 100644
index 0000000..db99a08
--- /dev/null
+++ b/src/matchRoute.js
@@ -0,0 +1,69 @@
+/**
+ *
+ * @param {import('./constructRoutes').ResolvedRoutesConfig} resolvedRoutesConfig
+ * @param {string} path
+ * @returns {import('./constructRoutes').ResolvedRoutesConfig}
+ */
+export function matchRoute(resolvedRoutesConfig, pathMatch) {
+  const result = { ...resolvedRoutesConfig };
+
+  const baseWithoutSlash = resolvedRoutesConfig.base.slice(
+    0,
+    resolvedRoutesConfig.base.length - 1
+  );
+
+  if (pathMatch.startsWith(baseWithoutSlash)) {
+    result.routes = recurseRoutes(
+      pathMatch,
+      resolvedRoutesConfig.base,
+      resolvedRoutesConfig.routes
+    );
+  } else {
+    result.routes = [];
+  }
+
+  return result;
+}
+
+/**
+ *
+ * @param {string} pathMatch
+ * @param {string} startPath
+ * @param {Array<import('./constructRoutes').Route>} routes
+ */
+function recurseRoutes(pathMatch, startPath, routes) {
+  const result = [];
+
+  routes.forEach((route) => {
+    if (route.type === "application") {
+      result.push(route);
+    } else {
+      const resolvedPath = resolvePath(startPath, route.path);
+
+      if (pathMatch.startsWith(resolvedPath)) {
+        result.push({
+          ...route,
+          routes: recurseRoutes(pathMatch, resolvedPath, route.routes),
+        });
+      }
+    }
+  });
+
+  return result;
+}
+
+function resolvePath(prefix, path) {
+  if (prefix.endsWith("/")) {
+    if (path.startsWith("/")) {
+      return prefix + path.slice(1);
+    } else {
+      return prefix + path;
+    }
+  } else {
+    if (path.startsWith("/")) {
+      return prefix + path;
+    } else {
+      return prefix + "/" + path;
+    }
+  }
+}
diff --git a/src/single-spa-layout.js b/src/single-spa-layout.js
index 5130431..462c572 100644
--- a/src/single-spa-layout.js
+++ b/src/single-spa-layout.js
@@ -1,1 +1,2 @@
 export { constructRoutes } from "./constructRoutes.js";
+export { matchRoute } from "./matchRoute.js";
diff --git a/src/validation-helpers.js b/src/validation-helpers.js
index 88052a8..3007310 100644
--- a/src/validation-helpers.js
+++ b/src/validation-helpers.js
@@ -61,3 +61,20 @@ export function validateArray(propertyName, arr, cbk) {
     cbk(val, `${propertyName}[${index}]`);
   });
 }
+
+export function validateContainerEl(propertyName, containerEl) {
+  let err;
+  if (typeof containerEl === "string") {
+    err = containerEl.trim() === "";
+  } else if (typeof HTMLElement !== "undefined") {
+    err = !(containerEl instanceof HTMLElement);
+  } else {
+    err = true;
+  }
+
+  if (err) {
+    throw Error(
+      `Invalid ${propertyName}: received ${containerEl} but expected either non-blank string or HTMLElement`
+    );
+  }
+}


Test patch:
diff --git a/test/constructRoutes.test.js b/test/constructRoutes.test.js
index 81af432..599caa8 100644
--- a/test/constructRoutes.test.js
+++ b/test/constructRoutes.test.js
@@ -10,7 +10,7 @@ describe("constructRoutes", () => {
     console.warn.mockReset();
   });
 
-  describe(`input validation`, () => {
+  describe(`validates top level properties`, () => {
     it("accepts a valid routesConfig", () => {
       constructRoutes({
         mode: "history",
@@ -43,90 +43,90 @@ describe("constructRoutes", () => {
         ],
       });
     });
-  });
-
-  it(`throws an error if the first argument is not an object`, () => {
-    expect(() => {
-      constructRoutes();
-    }).toThrowError(/expected a plain object/);
 
-    expect(() => {
-      constructRoutes(null);
-    }).toThrowError(/expected a plain object/);
+    it(`throws an error if the first argument is not an object`, () => {
+      expect(() => {
+        constructRoutes();
+      }).toThrowError(/expected a plain object/);
 
-    expect(() => {
-      constructRoutes("");
-    }).toThrowError(/expected a plain object/);
+      expect(() => {
+        constructRoutes(null);
+      }).toThrowError(/expected a plain object/);
 
-    expect(() => {
-      constructRoutes(undefined);
-    }).toThrowError(/expected a plain object/);
+      expect(() => {
+        constructRoutes("");
+      }).toThrowError(/expected a plain object/);
 
-    expect(() => {
-      constructRoutes([]);
-    }).toThrowError(/expected a plain object/);
-  });
+      expect(() => {
+        constructRoutes(undefined);
+      }).toThrowError(/expected a plain object/);
 
-  it(`console.warns if extra properties are provided`, () => {
-    constructRoutes({
-      routes: [],
-      irrelevantProperty: "thing",
+      expect(() => {
+        constructRoutes([]);
+      }).toThrowError(/expected a plain object/);
     });
-    expect(console.warn).toHaveBeenCalled();
-    expect(console.warn.mock.calls[0][0].message).toEqual(
-      `Invalid routesConfig: received invalid properties 'irrelevantProperty', but valid properties are mode, base, containerEl, routes, disableWarnings`
-    );
-  });
 
-  it(`validates the mode correctly`, () => {
-    expect(() => {
+    it(`console.warns if extra properties are provided`, () => {
       constructRoutes({
-        mode: "wrong",
         routes: [],
+        irrelevantProperty: "thing",
       });
-    }).toThrowError("mode");
-
-    constructRoutes({
-      mode: "hash",
-      routes: [],
-    });
-
-    constructRoutes({
-      mode: "history",
-      routes: [],
+      expect(console.warn).toHaveBeenCalled();
+      expect(console.warn.mock.calls[0][0].message).toEqual(
+        `Invalid routesConfig: received invalid properties 'irrelevantProperty', but valid properties are mode, base, containerEl, routes, disableWarnings`
+      );
     });
-  });
 
-  it(`validates the base correctly`, () => {
-    constructRoutes({
-      base: "/",
-      mode: "history",
-      routes: [],
-    });
+    it(`validates the mode correctly`, () => {
+      expect(() => {
+        constructRoutes({
+          mode: "wrong",
+          routes: [],
+        });
+      }).toThrowError("mode");
 
-    expect(() => {
       constructRoutes({
-        base: "",
-        mode: "history",
+        mode: "hash",
         routes: [],
       });
-    }).toThrowError("non-blank string");
 
-    expect(() => {
       constructRoutes({
-        base: "  ",
         mode: "history",
         routes: [],
       });
-    }).toThrowError("non-blank string");
+    });
 
-    expect(() => {
+    it(`validates the base correctly`, () => {
       constructRoutes({
-        base: null,
+        base: "/",
         mode: "history",
         routes: [],
       });
-    }).toThrowError("non-blank string");
+
+      expect(() => {
+        constructRoutes({
+          base: "",
+          mode: "history",
+          routes: [],
+        });
+      }).toThrowError("non-blank string");
+
+      expect(() => {
+        constructRoutes({
+          base: "  ",
+          mode: "history",
+          routes: [],
+        });
+      }).toThrowError("non-blank string");
+
+      expect(() => {
+        constructRoutes({
+          base: null,
+          mode: "history",
+          routes: [],
+        });
+      }).toThrowError("non-blank string");
+    });
   });
 
   describe("validates routes", () => {
@@ -146,6 +146,22 @@ describe("constructRoutes", () => {
       }).toThrowError("array");
     });
 
+    it(`checks for presence of route array`, () => {
+      expect(() => {
+        constructRoutes({
+          mode: "history",
+          routes: [
+            {
+              type: "route",
+              path: "/",
+            },
+          ],
+        });
+      }).toThrowError(
+        `Invalid routesConfig.routes[0].routes: received 'undefined', but expected an array`
+      );
+    });
+
     it(`checks for valid route objects`, () => {
       constructRoutes({
         mode: "history",
@@ -153,6 +169,7 @@ describe("constructRoutes", () => {
           {
             type: "route",
             path: "/",
+            routes: [],
           },
         ],
       });
@@ -164,6 +181,7 @@ describe("constructRoutes", () => {
           {
             type: "route",
             path: "/",
+            routes: [],
             somethingElse: "value",
           },
         ],
@@ -265,10 +283,12 @@ describe("constructRoutes", () => {
           {
             type: "route",
             path: "/",
+            routes: [],
           },
           {
             type: "route",
             path: "/app1",
+            routes: [],
           },
         ],
       });
@@ -280,10 +300,12 @@ describe("constructRoutes", () => {
           {
             type: "route",
             path: "/",
+            routes: [],
           },
           {
             type: "route",
             path: "/app1",
+            routes: [],
             irrelevantProperty: "thing",
           },
         ],
@@ -293,5 +315,97 @@ describe("constructRoutes", () => {
         "Invalid routesConfig.routes[1]: received invalid properties 'irrelevantProperty', but valid properties are type, path, routes"
       );
     });
+
+    it(`throws when containerEl is invalid`, () => {
+      expect(() => {
+        constructRoutes({
+          containerEl: null,
+          routes: [],
+        });
+      }).toThrowError(
+        "Invalid routesConfig.containerEl: received null but expected either non-blank string or HTMLElement"
+      );
+
+      expect(() => {
+        constructRoutes({
+          containerEl: [],
+          routes: [],
+        });
+      }).toThrowError(
+        "Invalid routesConfig.containerEl: received  but expected either non-blank string or HTMLElement"
+      );
+
+      expect(() => {
+        constructRoutes({
+          containerEl: 2342,
+          routes: [],
+        });
+      }).toThrowError(
+        "Invalid routesConfig.containerEl: received 2342 but expected either non-blank string or HTMLElement"
+      );
+    });
+
+    it(`allows a string containerEl`, () => {
+      constructRoutes({
+        containerEl: "asdf",
+        routes: [],
+      });
+    });
+
+    if (typeof HTMLElement !== "undefined") {
+      it("allows an HTMLElement containerEl", () => {
+        constructRoutes({
+          containerEl: document.createElement("div"),
+          routes: [],
+        });
+      });
+    }
+  });
+
+  describe(`return value`, () => {
+    it(`adds a default base if one is not provided`, () => {
+      expect(
+        constructRoutes({
+          containerEl: "body",
+          mode: "history",
+          routes: [{ type: "application", name: "nav" }],
+        })
+      ).toEqual({
+        base: "/",
+        containerEl: "body",
+        mode: "history",
+        routes: [{ type: "application", name: "nav" }],
+      });
+    });
+
+    it(`adds a default mode if one is not provided`, () => {
+      expect(
+        constructRoutes({
+          containerEl: "body",
+          base: "/",
+          routes: [{ type: "application", name: "nav" }],
+        })
+      ).toEqual({
+        base: "/",
+        containerEl: "body",
+        mode: "history",
+        routes: [{ type: "application", name: "nav" }],
+      });
+    });
+
+    it(`adds a default containerEl if one is not provided`, () => {
+      expect(
+        constructRoutes({
+          base: "/",
+          mode: "history",
+          routes: [{ type: "application", name: "nav" }],
+        })
+      ).toEqual({
+        base: "/",
+        containerEl: "body",
+        mode: "history",
+        routes: [{ type: "application", name: "nav" }],
+      });
+    });
   });
 });
diff --git a/test/matchRoute.test.js b/test/matchRoute.test.js
new file mode 100644
index 0000000..8bf86ed
--- /dev/null
+++ b/test/matchRoute.test.js
@@ -0,0 +1,126 @@
+import { matchRoute } from "../src/single-spa-layout.js";
+
+describe(`matchRoute`, () => {
+  let routesConfig;
+
+  beforeEach(() => {
+    routesConfig = {
+      mode: "history",
+      base: "/",
+      containerEl: "body",
+      routes: [
+        { type: "application", name: "nav" },
+        {
+          type: "route",
+          path: "app1",
+          routes: [
+            { type: "application", name: "app1" },
+            {
+              type: "route",
+              path: "subroute",
+              routes: [{ type: "application", name: "subroute" }],
+            },
+          ],
+        },
+        {
+          type: "route",
+          path: "app2",
+          routes: [
+            { type: "application", name: "app2" },
+            {
+              type: "route",
+              path: "subroute",
+              routes: [{ type: "application", name: "subroute" }],
+            },
+          ],
+        },
+        { type: "application", name: "footer" },
+      ],
+    };
+  });
+
+  it(`returns a filtered routes array`, () => {
+    expect(matchRoute(routesConfig, "/")).toEqual({
+      ...routesConfig,
+      routes: [
+        { type: "application", name: "nav" },
+        { type: "application", name: "footer" },
+      ],
+    });
+  });
+
+  it(`matches nested routes`, () => {
+    expect(matchRoute(routesConfig, "/app1")).toEqual({
+      ...routesConfig,
+      routes: [
+        { type: "application", name: "nav" },
+        {
+          type: "route",
+          path: "app1",
+          routes: [{ type: "application", name: "app1" }],
+        },
+        { type: "application", name: "footer" },
+      ],
+    });
+
+    expect(matchRoute(routesConfig, "/app2")).toEqual({
+      ...routesConfig,
+      routes: [
+        { type: "application", name: "nav" },
+        {
+          type: "route",
+          path: "app2",
+          routes: [{ type: "application", name: "app2" }],
+        },
+        { type: "application", name: "footer" },
+      ],
+    });
+  });
+
+  it(`matches deeply nested routes`, () => {
+    expect(matchRoute(routesConfig, "/app1/subroute")).toEqual({
+      ...routesConfig,
+      routes: [
+        { type: "application", name: "nav" },
+        {
+          type: "route",
+          path: "app1",
+          routes: [
+            { type: "application", name: "app1" },
+            {
+              type: "route",
+              path: "subroute",
+              routes: [{ type: "application", name: "subroute" }],
+            },
+          ],
+        },
+        { type: "application", name: "footer" },
+      ],
+    });
+  });
+
+  it(`matches using base name`, () => {
+    routesConfig.base = "/base/";
+
+    expect(matchRoute(routesConfig, "/")).toEqual({
+      ...routesConfig,
+      routes: [],
+    });
+
+    expect(matchRoute(routesConfig, "/base/")).toEqual({
+      ...routesConfig,
+      routes: [
+        { type: "application", name: "nav" },
+        { type: "application", name: "footer" },
+      ],
+    });
+
+    expect(matchRoute(routesConfig, "/base")).toEqual({
+      ...routesConfig,
+      routes: [
+        { type: "application", name: "nav" },
+        { type: "application", name: "footer" },
+      ],
+    });
+  });
+});
diff --git a/test/single-spa-layout.test-d.ts b/test/single-spa-layout.test-d.ts
index 7f83a4e..eda7194 100644
--- a/test/single-spa-layout.test-d.ts
+++ b/test/single-spa-layout.test-d.ts
@@ -1,12 +1,26 @@
 import { expectError, expectType } from "tsd";
-import { constructRoutes } from "../src/single-spa-layout";
-
-expectType<any>(
-  constructRoutes({
-    routes: [],
-  })
-);
+import { constructRoutes, matchRoute } from "../src/single-spa-layout";
 
+// test constructRoutes
 expectError(constructRoutes());
 
 expectError(constructRoutes({}));
+
+const routes = constructRoutes({
+  routes: [
+    {
+      type: "route",
+      path: "/app1",
+      routes: [{ type: "application", name: "app1" }],
+    },
+  ],
+});
+
+// test matchRoute
+const matchedRoutes = matchRoute(routes, "/");
+expectType<string>(matchedRoutes.base);
+expectType<import("../src/constructRoutes").ContainerEl>(
+  matchedRoutes.containerEl
+);
+expectType<string>(matchedRoutes.mode);
+expectType<Array<import("../src/constructRoutes").Route>>(matchedRoutes.routes);