refactor: derive TechLog navigation from the route contract
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
93ce86eef4
commit
9e5fbd1384
@@ -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();
|
||||
|
||||
@@ -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<Record<string, (path: string) => 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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user