Files
tech-log-frontend/src/features/tech-log/contracts/tech-log-route-contract.ts
T
DongHyeonka 3754269118 feat: add the Studio release editor and point the footer at the changelog
The public site shipped a Releases page and a footer link to a release, and
neither could ever have content: the read path existed, the write path did not.
This adds the seven release operations to the contract contribution and the
gateway, and a Studio screen that can actually write one.

The editor is six markdown fields rather than one, because that is what the
contract models and what a release note is — why, what, what a reader notices,
what it leaves in the code, how it was verified, what is still open. Publishing
is separate from saving: a draft saves in any state, but the public query keys
on PUBLISHED alone, so publish is where completeness is demanded.

No new CSS. The screen reuses the document editor's field classes and the
working-copy list's row classes, so it inherits Studio's spacing and type
instead of introducing a second look.

The footer previously linked `/releases/0.1.0` — a version that did not exist,
so the link 404'd, and one that would have gone stale at 0.2.0 anyway. It now
points at the changelog index, which is the only place that knows what the
latest release is and which reads correctly when there are none.

Route inventory, navigation order, message catalog, manual accessibility
evidence, artifact baseline, and the pinned gate-shape digest all move with the
new route. The digest was recomputed by first reproducing the previous constant
from the previous gates.json, so the computation is known to be the one it was
pinned under.
2026-08-21 03:08:41 +09:00

180 lines
11 KiB
TypeScript

import type {
RouteCodecId,
RouteRuntimeDefinition,
} from "../../../contracts/route-runtime-contract.ts";
import type {
RouteDefinition,
RouteLayoutGroup,
} from "../../../contracts/routes.ts";
import type { SchemaDefinition } from "../../../contracts/schema-registry.ts";
type RouteSpec = Readonly<{
routeId: string;
path: string;
layoutGroup: RouteLayoutGroup;
paramsSchema: RouteCodecId | null;
searchSchema: RouteCodecId | null;
title: string;
navigationLabel: string | null;
navigationOrder: number | null;
}>;
const defineSpec = <const Spec extends RouteSpec>(spec: Spec): Spec => spec;
const TECH_LOG_ROUTE_SPECS = [
defineSpec({ routeId: "TECH_LOG_HOME", path: "/", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: "TechLogHomeSearch", title: "TechLog", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_EXPLORE", path: "/explore", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: "TechLogExploreSearch", title: "탐색", navigationLabel: "탐색", navigationOrder: 10 }),
defineSpec({ routeId: "TECH_LOG_EXPLORE_KIND", path: "/explore/:kind", layoutGroup: "PUBLIC", paramsSchema: "TechLogExploreKindParams", searchSchema: "TechLogExploreKindSearch", title: "유형별 탐색", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_CASE", path: "/cases/:slug", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: "TechLogCaseStateSearch", title: "Case", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_REFERENCE", path: "/references/:slug", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "Reference", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_QUESTION", path: "/questions/:slug", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "Open Question", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_TOPIC", path: "/topics/:slug", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "Topic", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_PROJECTS", path: "/projects", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, title: "프로젝트", navigationLabel: "프로젝트", navigationOrder: 20 }),
defineSpec({ routeId: "TECH_LOG_PROJECT", path: "/projects/:slug", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "프로젝트", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_PROJECT_RECORDS", path: "/projects/:slug/records", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "프로젝트 기록", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_PROJECT_DECISIONS", path: "/projects/:slug/decisions", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "프로젝트 결정", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_PROJECT_ACTIVITY", path: "/projects/:slug/activity", layoutGroup: "PUBLIC", paramsSchema: "TechLogSlugParams", searchSchema: null, title: "프로젝트 활동", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_RELEASES", path: "/releases", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, title: "변경 기록", navigationLabel: "변경 기록", navigationOrder: 30 }),
defineSpec({ routeId: "TECH_LOG_RELEASE", path: "/releases/:version", layoutGroup: "PUBLIC", paramsSchema: "TechLogVersionParams", searchSchema: null, title: "변경 기록", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_PROFILE", path: "/profile", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: null, title: "프로필", navigationLabel: "프로필", navigationOrder: 40 }),
defineSpec({ routeId: "TECH_LOG_SEARCH", path: "/search", layoutGroup: "PUBLIC", paramsSchema: null, searchSchema: "TechLogSearchQuery", title: "검색", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_HOME", path: "/studio", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "TechLog Studio", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENTS", path: "/studio/documents", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "작업본", navigationLabel: "작업본", navigationOrder: 10 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_NEW", path: "/studio/documents/new", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "새 문서", navigationLabel: "새 문서", navigationOrder: 30 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_EDIT", path: "/studio/documents/:id/edit", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "문서 편집", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_VALIDATION", path: "/studio/documents/:id/validation", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "문서 검증", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_PREVIEW", path: "/studio/documents/:id/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "Public Preview", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_DOCUMENT_PUBLISH", path: "/studio/documents/:id/publish", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "게시", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATIONS", path: "/studio/publications", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "게시 기록", navigationLabel: "게시 기록", navigationOrder: 20 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
] as const;
export type TechLogRouteId = (typeof TECH_LOG_ROUTE_SPECS)[number]["routeId"];
const routeSchema = (
schemaId: string,
boundary: "route-params" | "route-search",
unknownFieldPolicy: "REJECT_UNKNOWN" | "STRIP_UNKNOWN",
): SchemaDefinition =>
Object.freeze({
schemaId,
boundary,
owner: "feature-tech-log",
runtime: "zod",
schemaVersion: 1,
direction: "REQUEST",
unknownFieldPolicy,
});
export const TECH_LOG_ROUTE_SCHEMA_REGISTRY = Object.freeze({
TechLogExploreKindParams: routeSchema(
"TechLogExploreKindParams",
"route-params",
"REJECT_UNKNOWN",
),
TechLogSlugParams: routeSchema(
"TechLogSlugParams",
"route-params",
"REJECT_UNKNOWN",
),
TechLogVersionParams: routeSchema(
"TechLogVersionParams",
"route-params",
"REJECT_UNKNOWN",
),
TechLogDocumentIdParams: routeSchema(
"TechLogDocumentIdParams",
"route-params",
"REJECT_UNKNOWN",
),
TechLogPublicationEventIdParams: routeSchema(
"TechLogPublicationEventIdParams",
"route-params",
"REJECT_UNKNOWN",
),
TechLogStudioSplat: routeSchema(
"TechLogStudioSplat",
"route-params",
"REJECT_UNKNOWN",
),
TechLogHomeSearch: routeSchema(
"TechLogHomeSearch",
"route-search",
"STRIP_UNKNOWN",
),
TechLogExploreSearch: routeSchema(
"TechLogExploreSearch",
"route-search",
"STRIP_UNKNOWN",
),
TechLogExploreKindSearch: routeSchema(
"TechLogExploreKindSearch",
"route-search",
"STRIP_UNKNOWN",
),
TechLogSearchQuery: routeSchema(
"TechLogSearchQuery",
"route-search",
"STRIP_UNKNOWN",
),
TechLogCaseStateSearch: routeSchema(
"TechLogCaseStateSearch",
"route-search",
"STRIP_UNKNOWN",
),
});
function chunkId(routeId: TechLogRouteId): string {
return routeId === "NOT_FOUND"
? "route-not-found"
: `route-${routeId.toLowerCase().replaceAll("_", "-")}`;
}
export const TECH_LOG_ROUTE_REGISTRY = Object.freeze(
Object.fromEntries(
TECH_LOG_ROUTE_SPECS.map((spec) => [
spec.routeId,
Object.freeze({
...spec,
// Studio routes are the authenticated surface. Deriving this from the
// spec's own `layoutGroup` -- rather than restating it per route --
// keeps a newly added Studio route gated by construction. Registering
// every TechLog route as "public" made `decideRouteAccessForDefinition`
// a no-op for Studio: a signed-out visitor who typed /studio got the
// Studio shell rendered instead of the sign-in surface.
access: spec.layoutGroup === "STUDIO" ? "session-required" : "public",
loadingSurface: spec.path.endsWith("*") ? "none" : "app-shell",
errorSurface: spec.path.endsWith("*")
? "not-found"
: "feature-boundary",
chunkId: chunkId(spec.routeId),
} satisfies RouteDefinition),
]),
),
) as Readonly<Record<TechLogRouteId, RouteDefinition>>;
export const TECH_LOG_ROUTE_RUNTIME_CONTRACT = Object.freeze(
Object.fromEntries(
TECH_LOG_ROUTE_SPECS.map((spec) => [
spec.routeId,
Object.freeze({
routeId: spec.routeId,
moduleId: chunkId(spec.routeId),
paramsCodec: spec.paramsSchema ?? "none",
searchCodec: spec.searchSchema ?? "none",
} satisfies RouteRuntimeDefinition),
]),
),
) as Readonly<Record<TechLogRouteId, RouteRuntimeDefinition>>;
export const TECH_LOG_ROUTE_CONTRACT = Object.freeze({
routes: TECH_LOG_ROUTE_REGISTRY,
routeRuntimeContracts: TECH_LOG_ROUTE_RUNTIME_CONTRACT,
schemas: TECH_LOG_ROUTE_SCHEMA_REGISTRY,
});