From 9e5fbd1384d7bb32c277c5e54b58f0e2d1565706 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Mon, 17 Aug 2026 20:34:36 +0900 Subject: [PATCH] refactor: derive TechLog navigation from the route contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both headers carried their own literal list of {label, path}. That made the route contract and the header two sources for the same three facts — which routes are navigable, what they are called, and in what order — with nothing keeping them in step: a renamed route or a reordered menu could be right in one place and stale in the other. techLogNavigation(layoutGroup) derives the menu from TECH_LOG_ROUTE_REGISTRY, where navigationOrder is what makes a route navigable. Output is byte-identical to the previous literal lists, pinned by a new test. Derived from TechLog's own route contract rather than from the composed registries: .dependency-cruiser.json freezes an exact allowlist of files that may read src/features/installed-*, explicitly so that coupling cannot spread, and a feature header is not on it. The studio header keeps its active-state rules — "작업본" stays highlighted across /studio/documents/* except on the new-document screen — because that is presentation behaviour the contract has no opinion about. Co-Authored-By: Claude Opus 5 (1M context) --- .../public/components/site-header.tsx | 10 ++-- .../studio/components/studio-header.tsx | 37 ++++++------ .../presentation/tech-log-navigation.ts | 45 ++++++++++++++ .../tech-log/navigation-derivation.test.ts | 60 +++++++++++++++++++ 4 files changed, 129 insertions(+), 23 deletions(-) create mode 100644 src/features/tech-log/presentation/tech-log-navigation.ts create mode 100644 tests/features/tech-log/navigation-derivation.test.ts diff --git a/src/features/tech-log/presentation/public/components/site-header.tsx b/src/features/tech-log/presentation/public/components/site-header.tsx index 0ce018d..cb285fb 100644 --- a/src/features/tech-log/presentation/public/components/site-header.tsx +++ b/src/features/tech-log/presentation/public/components/site-header.tsx @@ -2,14 +2,12 @@ import { useRef } from "react"; import { Link, useLocation } from "react-router-dom"; import { publicSiteConfig } from "../../../contracts/public-site-config.ts"; +import { techLogNavigation } from "../../tech-log-navigation.ts"; import { SearchDialog } from "./search-dialog.tsx"; -const navigation = [ - { label: "탐색", path: "/explore" }, - { label: "프로젝트", path: "/projects" }, - { label: "변경 기록", path: "/releases" }, - { label: "프로필", path: "/profile" }, -] as const; +// Derived from the route contract rather than repeated here; see +// `tech-log-navigation.ts` for why. +const navigation = techLogNavigation("PUBLIC"); export function SiteHeader({ currentPath }: { currentPath?: string }) { const location = useLocation(); diff --git a/src/features/tech-log/presentation/studio/components/studio-header.tsx b/src/features/tech-log/presentation/studio/components/studio-header.tsx index 4282d6c..b99ef78 100644 --- a/src/features/tech-log/presentation/studio/components/studio-header.tsx +++ b/src/features/tech-log/presentation/studio/components/studio-header.tsx @@ -1,25 +1,28 @@ import { useId, useState } from "react"; +import { techLogNavigation } from "../../tech-log-navigation.ts"; import { GuardedStudioLink } from "./guarded-studio-link.tsx"; -const navigation = [ - { - href: "/studio/documents", - label: "작업본", - active: (path: string) => +/** + * Which entries exist, what they are called and in what order comes from the + * route contract. Only the active-state rule stays here: "작업본" stays + * highlighted across `/studio/documents/*` except on the new-document screen, + * which is presentation behaviour the contract has no opinion about. + */ +const activeRules: Readonly boolean>> = + Object.freeze({ + TECH_LOG_STUDIO_DOCUMENTS: (path) => path.startsWith("/studio/documents") && path !== "/studio/documents/new", - }, - { - href: "/studio/publications", - label: "게시 기록", - active: (path: string) => path.startsWith("/studio/publications"), - }, - { - href: "/studio/documents/new", - label: "새 문서", - active: (path: string) => path === "/studio/documents/new", - }, -] as const; + TECH_LOG_STUDIO_PUBLICATIONS: (path) => + path.startsWith("/studio/publications"), + TECH_LOG_STUDIO_DOCUMENT_NEW: (path) => path === "/studio/documents/new", + }); + +const navigation = techLogNavigation("STUDIO").map((entry) => ({ + href: entry.path, + label: entry.label, + active: activeRules[entry.routeId] ?? ((path: string) => path === entry.path), +})); function StudioNavigation({ currentPath, diff --git a/src/features/tech-log/presentation/tech-log-navigation.ts b/src/features/tech-log/presentation/tech-log-navigation.ts new file mode 100644 index 0000000..17570aa --- /dev/null +++ b/src/features/tech-log/presentation/tech-log-navigation.ts @@ -0,0 +1,45 @@ +import type { RouteLayoutGroup } from "../../../contracts/routes.ts"; +import { TECH_LOG_ROUTE_REGISTRY } from "../contracts/tech-log-route-contract.ts"; + +/** + * The navigation entries for one shell, derived from the route contract. + * + * Both headers used to carry their own literal list of `{ label, path }`. That + * made the route contract and the header two sources for the same three facts — + * which routes are navigable, what they are called, and in what order — and + * nothing kept them in step: a renamed route or a changed order stayed correct + * in one place and silently stale in the other. + * + * `navigationOrder` is what makes a route navigable; a route with `null` is + * reachable but never advertised. + */ +export type TechLogNavigationEntry = Readonly<{ + routeId: string; + path: string; + label: string; +}>; + +export function techLogNavigation( + layoutGroup: RouteLayoutGroup, +): readonly TechLogNavigationEntry[] { + return Object.freeze( + Object.values(TECH_LOG_ROUTE_REGISTRY) + .filter( + (definition) => + definition.layoutGroup === layoutGroup && + definition.navigationOrder !== null && + definition.navigationLabel !== null, + ) + .sort( + (left, right) => + (left.navigationOrder ?? 0) - (right.navigationOrder ?? 0), + ) + .map((definition) => + Object.freeze({ + routeId: definition.routeId, + path: definition.path, + label: definition.navigationLabel as string, + }), + ), + ); +} diff --git a/tests/features/tech-log/navigation-derivation.test.ts b/tests/features/tech-log/navigation-derivation.test.ts new file mode 100644 index 0000000..a1dab08 --- /dev/null +++ b/tests/features/tech-log/navigation-derivation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { techLogNavigation } from "../../../src/features/tech-log/presentation/tech-log-navigation.ts"; +import { TECH_LOG_ROUTE_REGISTRY } from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts"; + +/** + * The headers used to repeat the route contract's navigation facts in their own + * literal arrays, so a renamed route or a reordered menu could be right in one + * place and stale in the other. These pin the derived result against the shape + * the headers rendered before the change, and against the contract itself. + */ +describe("TechLog navigation derivation", () => { + it("derives the public menu in contract order", () => { + expect( + techLogNavigation("PUBLIC").map((entry) => [entry.label, entry.path]), + ).toEqual([ + ["탐색", "/explore"], + ["프로젝트", "/projects"], + ["변경 기록", "/releases"], + ["프로필", "/profile"], + ]); + }); + + it("derives the studio menu in contract order", () => { + expect( + techLogNavigation("STUDIO").map((entry) => [entry.label, entry.path]), + ).toEqual([ + ["작업본", "/studio/documents"], + ["게시 기록", "/studio/publications"], + ["새 문서", "/studio/documents/new"], + ]); + }); + + it("advertises exactly the routes the contract marks navigable", () => { + const navigable = Object.values(TECH_LOG_ROUTE_REGISTRY) + .filter((definition) => definition.navigationOrder !== null) + .map((definition) => definition.routeId) + .sort(); + const derived = [ + ...techLogNavigation("PUBLIC"), + ...techLogNavigation("STUDIO"), + ] + .map((entry) => entry.routeId) + .sort(); + expect(derived).toEqual(navigable); + }); + + it("keeps each shell's entries inside its own layout group", () => { + for (const group of ["PUBLIC", "STUDIO"] as const) { + for (const entry of techLogNavigation(group)) { + expect( + TECH_LOG_ROUTE_REGISTRY[ + entry.routeId as keyof typeof TECH_LOG_ROUTE_REGISTRY + ]?.layoutGroup, + entry.routeId, + ).toBe(group); + } + } + }); +});