diff --git a/scripts/generate-tech-log-serving-artifact.ts b/scripts/generate-tech-log-serving-artifact.ts index 8618ca0..76280f5 100644 --- a/scripts/generate-tech-log-serving-artifact.ts +++ b/scripts/generate-tech-log-serving-artifact.ts @@ -9,6 +9,10 @@ const publicRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY) .filter((route) => route.layoutGroup === "PUBLIC") .map((route) => route.path); -const contract = createTechLogServingContract({ publicRoutePaths }); +const studioRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY) + .filter((route) => route.layoutGroup === "STUDIO") + .map((route) => route.path); + +const contract = createTechLogServingContract({ publicRoutePaths, studioRoutePaths }); await writeTechLogServingArtifact({ distRoot: "dist", contract }); diff --git a/scripts/lib/tech-log-serving-contract.ts b/scripts/lib/tech-log-serving-contract.ts index caa7bf8..32380ec 100644 --- a/scripts/lib/tech-log-serving-contract.ts +++ b/scripts/lib/tech-log-serving-contract.ts @@ -31,6 +31,8 @@ type ServingContractInput = Readonly<{ * pure transform the tests can drive directly. */ publicRoutePaths: readonly string[]; + /** The Studio route templates, same form and same reason. */ + studioRoutePaths: readonly string[]; }>; /** @@ -49,42 +51,41 @@ function patternOf(routePath: string): string { return `^${escaped === "" ? "/" : escaped}$`; } -const studioSpaPathPatterns = Object.freeze([ - "^/studio$", - // The Asset Library is a first-class Studio route (TECH_LOG_STUDIO_ASSETS in - // the route contract) but was never listed here, so a hard navigation or a - // reload of /studio/assets was served the in-shell Studio 404 -- the screen - // was only reachable by client-side navigation from another Studio page. - "^/studio/assets$", - "^/studio/taxonomy$", - "^/studio/documents$", - "^/studio/documents/new$", - "^/studio/documents/[^/]+/(edit|validation|preview|publish)$", - "^/studio/publications$", - "^/studio/publications/[^/]+/preview$", -]); + function asciiCompare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } -export function createTechLogServingContract({ - publicRoutePaths, -}: ServingContractInput): TechLogServingContract { +/** + * The catch-all is the SPA's own not-found screen; serving index.html for every + * unmatched URL would turn the edge 404 into a soft 200 and hide broken links + * from crawlers and from us. + */ +function patternsFor(routePaths: readonly string[]): readonly string[] { const patterns = new Set(); - for (const routePath of publicRoutePaths) { - // The catch-all is the SPA's own not-found screen; serving index.html for - // every unmatched URL would turn the edge 404 into a soft 200 and hide - // broken links from crawlers and from us. + for (const routePath of routePaths) { if (routePath === "*" || routePath.includes("*")) continue; patterns.add(patternOf(routePath)); } + return Object.freeze([...patterns].sort(asciiCompare)); +} +export function createTechLogServingContract({ + publicRoutePaths, + studioRoutePaths, +}: ServingContractInput): TechLogServingContract { return Object.freeze({ schemaVersion: 2, - publicSpaPathPatterns: Object.freeze([...patterns].sort(asciiCompare)), + publicSpaPathPatterns: patternsFor(publicRoutePaths), studioPathPrefix: "/studio", - studioSpaPathPatterns, + // Derived, not listed. This was a hand-maintained array, and it went stale + // exactly the way a hand-maintained array does: /studio/assets was missing + // for its whole life, and /studio/releases repeated the mistake the moment + // it was added — the route worked by client-side navigation and 404'd on + // reload, because nginx had never heard of it. The route contract already + // knows which Studio paths exist, so ask it. + studioSpaPathPatterns: patternsFor(studioRoutePaths), notFound: Object.freeze({ status: 404, contentType: "text/plain;charset=UTF-8", diff --git a/tests/unit/tech-log-serving-contract.test.ts b/tests/unit/tech-log-serving-contract.test.ts index 216de55..4c07b5f 100644 --- a/tests/unit/tech-log-serving-contract.test.ts +++ b/tests/unit/tech-log-serving-contract.test.ts @@ -7,9 +7,13 @@ const publicRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY) .filter((route) => route.layoutGroup === "PUBLIC") .map((route) => route.path); +const studioRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY) + .filter((route) => route.layoutGroup === "STUDIO") + .map((route) => route.path); + describe("TechLog production serving contract", () => { it("derives one pattern per registered Public route", () => { - const contract = createTechLogServingContract({ publicRoutePaths }); + const contract = createTechLogServingContract({ publicRoutePaths, studioRoutePaths }); expect(contract.schemaVersion).toBe(2); expect(contract.publicSpaPathPatterns).toEqual([ @@ -31,15 +35,23 @@ describe("TechLog production serving contract", () => { "^/topics/[^/]+$", ]); expect(contract.studioPathPrefix).toBe("/studio"); + // Derived from the route contract and sorted, exactly like the public half. + // The old hand-written array folded the four document sub-screens into one + // alternation; deriving them yields one pattern per route, which is the + // point — a route that exists is served, without anyone remembering to add it. expect(contract.studioSpaPathPatterns).toEqual([ "^/studio$", "^/studio/assets$", - "^/studio/taxonomy$", "^/studio/documents$", + "^/studio/documents/[^/]+/edit$", + "^/studio/documents/[^/]+/preview$", + "^/studio/documents/[^/]+/publish$", + "^/studio/documents/[^/]+/validation$", "^/studio/documents/new$", - "^/studio/documents/[^/]+/(edit|validation|preview|publish)$", "^/studio/publications$", "^/studio/publications/[^/]+/preview$", + "^/studio/releases$", + "^/studio/taxonomy$", ]); expect(contract.notFound).toEqual({ status: 404, @@ -54,7 +66,7 @@ describe("TechLog production serving contract", () => { * record must be served whatever its slug. */ it("serves a slug the build never saw", () => { - const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths }); + const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths, studioRoutePaths }); const matches = (pathname: string) => publicSpaPathPatterns.some((pattern) => new RegExp(pattern).test(pathname)); @@ -65,7 +77,7 @@ describe("TechLog production serving contract", () => { /** A parameter is one segment. Extra depth is a 404, not a soft 200. */ it("does not let a parameter swallow a slash", () => { - const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths }); + const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths, studioRoutePaths }); const matches = (pathname: string) => publicSpaPathPatterns.some((pattern) => new RegExp(pattern).test(pathname)); @@ -81,6 +93,7 @@ describe("TechLog production serving contract", () => { it("drops the catch-all route", () => { const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths: ["/", "*"], + studioRoutePaths, }); expect(publicSpaPathPatterns).toEqual(["^/$"]); });