Publishing needs a topic and nothing could create one. The backend now owns that surface; this is its consumer — the management contract vendored, a gateway over its nine operations, and one Studio screen that lists, creates, and deletes topics and projects. The screen adds no CSS. It reuses the classes the working-copy list already uses, so it inherits Studio's spacing, type, and colour rather than growing a second visual vocabulary beside them. Scope stops at list/create/delete: renaming, phase changes, and visibility are implemented in the backend and declared in the contract, but their screens are a separate design. Two real defects surfaced while making the public port async, and both would have shipped: The search page and the header search dialog shared a query key. With an empty query, `["tech-log","search",""]` was identical for both, so react-query handed one surface the other's cache — different shapes — and the page died reading a field that was not there. Keys now name the surface. The explore filter's selects are uncontrolled and read `defaultValue`, which React applies once. Their options arrive later now, so the first render had nothing to match and the value stayed empty: a topic in the URL no longer showed as selected. The form key includes whether the catalog has arrived, so it remounts with the options present. Controlled inputs would be the other answer, but this form submits to build a URL — the URL owns the value. The route brought its own bookkeeping: a build chunk, a manual accessibility evidence file, and the CI artifact baseline that counts them. The gate pins a digest of its own shape precisely so a new route cannot slip in without that count being reviewed. Test harnesses that render public screens now assemble the query providers and await the settled paint, because the screens they render became async.
95 lines
3.2 KiB
TypeScript
95 lines
3.2 KiB
TypeScript
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/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 {
|
|
const patterns = new Set<string>();
|
|
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",
|
|
}),
|
|
});
|
|
}
|