export type TechLogServingContract = Readonly<{ schemaVersion: 2; /** * Patterns, not an enumeration. * * This used to list every public path the bundled fixture happened to * contain, and the generated nginx served exactly those. A record published * after the build — which is the entire point of a backend — answered 404 at * the edge before the SPA was ever asked, and no amount of correct routing * inside the bundle could recover it. * * The route contract already declares which paths exist; the catalog only * decides which of them currently resolve, and that is the SPA's call, not * the web server's. Studio has been pattern-based all along — this brings the * public half to the same footing. */ publicSpaPathPatterns: readonly string[]; studioPathPrefix: "/studio"; studioSpaPathPatterns: readonly string[]; notFound: Readonly<{ status: 404; contentType: "text/plain;charset=UTF-8"; body: "Not Found"; }>; }>; type ServingContractInput = Readonly<{ /** * The public route templates the router registers, in route-contract form * (`/cases/:slug`). Passed in rather than imported so this module stays a * pure transform the tests can drive directly. */ publicRoutePaths: readonly string[]; }>; /** * `/cases/:slug` -> `^/cases/[^/]+$`. A parameter matches one segment and never * a slash, which is what keeps `/cases/a/b` a 404 instead of a case page. */ function patternOf(routePath: string): string { const escaped = routePath .split("/") .map((segment) => segment.startsWith(":") ? "[^/]+" : segment.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), ) .join("/"); 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/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 { 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. if (routePath === "*" || routePath.includes("*")) continue; patterns.add(patternOf(routePath)); } return Object.freeze({ schemaVersion: 2, publicSpaPathPatterns: Object.freeze([...patterns].sort(asciiCompare)), studioPathPrefix: "/studio", studioSpaPathPatterns, notFound: Object.freeze({ status: 404, contentType: "text/plain;charset=UTF-8", body: "Not Found", }), }); }