fix: derive the Studio serving patterns from the route contract
`/studio/releases` answered a plain-text 404 from nginx. The route existed, the chunk was built, and the SPA could reach the screen by client-side navigation — but a hard load or a reload never got that far, because the web server had never been told the path exists. The public half of the serving contract derives its patterns from the route registry. The Studio half was a hand-maintained array, and it failed the way hand-maintained arrays fail: the comment above `^/studio/assets$` records that exact bug being fixed once already, and adding a route repeated it immediately. Both halves now come from the same source, so a Studio route that exists is served without anyone having to remember. Deriving them yields one pattern per route rather than the old alternation that folded the four document sub-screens together. Same matched set, and it no longer needs a human to keep the grouping honest.
This commit is contained in:
@@ -9,6 +9,10 @@ const publicRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY)
|
|||||||
.filter((route) => route.layoutGroup === "PUBLIC")
|
.filter((route) => route.layoutGroup === "PUBLIC")
|
||||||
.map((route) => route.path);
|
.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 });
|
await writeTechLogServingArtifact({ distRoot: "dist", contract });
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type ServingContractInput = Readonly<{
|
|||||||
* pure transform the tests can drive directly.
|
* pure transform the tests can drive directly.
|
||||||
*/
|
*/
|
||||||
publicRoutePaths: readonly string[];
|
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}$`;
|
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 {
|
function asciiCompare(left: string, right: string): number {
|
||||||
return left < right ? -1 : left > right ? 1 : 0;
|
return left < right ? -1 : left > right ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTechLogServingContract({
|
/**
|
||||||
publicRoutePaths,
|
* The catch-all is the SPA's own not-found screen; serving index.html for every
|
||||||
}: ServingContractInput): TechLogServingContract {
|
* 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<string>();
|
const patterns = new Set<string>();
|
||||||
for (const routePath of publicRoutePaths) {
|
for (const routePath of routePaths) {
|
||||||
// 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;
|
if (routePath === "*" || routePath.includes("*")) continue;
|
||||||
patterns.add(patternOf(routePath));
|
patterns.add(patternOf(routePath));
|
||||||
}
|
}
|
||||||
|
return Object.freeze([...patterns].sort(asciiCompare));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTechLogServingContract({
|
||||||
|
publicRoutePaths,
|
||||||
|
studioRoutePaths,
|
||||||
|
}: ServingContractInput): TechLogServingContract {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
publicSpaPathPatterns: Object.freeze([...patterns].sort(asciiCompare)),
|
publicSpaPathPatterns: patternsFor(publicRoutePaths),
|
||||||
studioPathPrefix: "/studio",
|
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({
|
notFound: Object.freeze({
|
||||||
status: 404,
|
status: 404,
|
||||||
contentType: "text/plain;charset=UTF-8",
|
contentType: "text/plain;charset=UTF-8",
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ const publicRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY)
|
|||||||
.filter((route) => route.layoutGroup === "PUBLIC")
|
.filter((route) => route.layoutGroup === "PUBLIC")
|
||||||
.map((route) => route.path);
|
.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", () => {
|
describe("TechLog production serving contract", () => {
|
||||||
it("derives one pattern per registered Public route", () => {
|
it("derives one pattern per registered Public route", () => {
|
||||||
const contract = createTechLogServingContract({ publicRoutePaths });
|
const contract = createTechLogServingContract({ publicRoutePaths, studioRoutePaths });
|
||||||
|
|
||||||
expect(contract.schemaVersion).toBe(2);
|
expect(contract.schemaVersion).toBe(2);
|
||||||
expect(contract.publicSpaPathPatterns).toEqual([
|
expect(contract.publicSpaPathPatterns).toEqual([
|
||||||
@@ -31,15 +35,23 @@ describe("TechLog production serving contract", () => {
|
|||||||
"^/topics/[^/]+$",
|
"^/topics/[^/]+$",
|
||||||
]);
|
]);
|
||||||
expect(contract.studioPathPrefix).toBe("/studio");
|
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([
|
expect(contract.studioSpaPathPatterns).toEqual([
|
||||||
"^/studio$",
|
"^/studio$",
|
||||||
"^/studio/assets$",
|
"^/studio/assets$",
|
||||||
"^/studio/taxonomy$",
|
|
||||||
"^/studio/documents$",
|
"^/studio/documents$",
|
||||||
|
"^/studio/documents/[^/]+/edit$",
|
||||||
|
"^/studio/documents/[^/]+/preview$",
|
||||||
|
"^/studio/documents/[^/]+/publish$",
|
||||||
|
"^/studio/documents/[^/]+/validation$",
|
||||||
"^/studio/documents/new$",
|
"^/studio/documents/new$",
|
||||||
"^/studio/documents/[^/]+/(edit|validation|preview|publish)$",
|
|
||||||
"^/studio/publications$",
|
"^/studio/publications$",
|
||||||
"^/studio/publications/[^/]+/preview$",
|
"^/studio/publications/[^/]+/preview$",
|
||||||
|
"^/studio/releases$",
|
||||||
|
"^/studio/taxonomy$",
|
||||||
]);
|
]);
|
||||||
expect(contract.notFound).toEqual({
|
expect(contract.notFound).toEqual({
|
||||||
status: 404,
|
status: 404,
|
||||||
@@ -54,7 +66,7 @@ describe("TechLog production serving contract", () => {
|
|||||||
* record must be served whatever its slug.
|
* record must be served whatever its slug.
|
||||||
*/
|
*/
|
||||||
it("serves a slug the build never saw", () => {
|
it("serves a slug the build never saw", () => {
|
||||||
const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths });
|
const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths, studioRoutePaths });
|
||||||
const matches = (pathname: string) =>
|
const matches = (pathname: string) =>
|
||||||
publicSpaPathPatterns.some((pattern) => new RegExp(pattern).test(pathname));
|
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. */
|
/** A parameter is one segment. Extra depth is a 404, not a soft 200. */
|
||||||
it("does not let a parameter swallow a slash", () => {
|
it("does not let a parameter swallow a slash", () => {
|
||||||
const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths });
|
const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths, studioRoutePaths });
|
||||||
const matches = (pathname: string) =>
|
const matches = (pathname: string) =>
|
||||||
publicSpaPathPatterns.some((pattern) => new RegExp(pattern).test(pathname));
|
publicSpaPathPatterns.some((pattern) => new RegExp(pattern).test(pathname));
|
||||||
|
|
||||||
@@ -81,6 +93,7 @@ describe("TechLog production serving contract", () => {
|
|||||||
it("drops the catch-all route", () => {
|
it("drops the catch-all route", () => {
|
||||||
const { publicSpaPathPatterns } = createTechLogServingContract({
|
const { publicSpaPathPatterns } = createTechLogServingContract({
|
||||||
publicRoutePaths: ["/", "*"],
|
publicRoutePaths: ["/", "*"],
|
||||||
|
studioRoutePaths,
|
||||||
});
|
});
|
||||||
expect(publicSpaPathPatterns).toEqual(["^/$"]);
|
expect(publicSpaPathPatterns).toEqual(["^/$"]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user