feat: add removable reference feature vertical slice

This commit is contained in:
donghyeon-ka
2026-07-26 14:56:34 +09:00
parent 980981bc86
commit c11be43f20
87 changed files with 1881 additions and 1114 deletions
+4 -1
View File
@@ -1,7 +1,10 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NAVIGATION_ROUTES, routePath } from "../../contracts/routes.js";
import {
NAVIGATION_ROUTES,
routePath,
} from "../../features/installed-feature-contracts.js";
import { useSession } from "../providers/session-provider.jsx";
import { useTheme } from "../providers/theme-provider.jsx";
+1 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../components/page-header.jsx";
import { useApplication } from "../providers/application-provider.js";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Link } from "react-router-dom";
import { routePath } from "../../contracts/routes.js";
import { routePath } from "../../features/installed-feature-contracts.js";
import { PageHeader } from "../components/page-header.jsx";
export default function NotFoundPage() {
@@ -1,23 +0,0 @@
import { PageHeader } from "../components/page-header.jsx";
import { useSession } from "../providers/session-provider.jsx";
export default function SampleContractPage() {
const { sessionState } = useSession();
return (
<section className="ui-page">
<PageHeader
eyebrow="보호 라우트"
title="보호된 연동 지점"
description="실제 도메인 기능이 인증된 세션과 연결되는 위치를 보여주는 중립적인 계약 화면입니다."
/>
<section className="ui-panel" aria-labelledby="protected-state-title">
<h2 id="protected-state-title">라우트 접근 허용</h2>
<p>
현재 세션 상태는 <strong>{sessionState}</strong>입니다. 서버의
권한 검증은 클라이언트 라우트 정책과 별도로 유지해야 합니다.
</p>
</section>
</section>
);
}
+3 -3
View File
@@ -21,8 +21,8 @@ import {
import {
getRoute,
ROUTE_REGISTRY,
type RouteDefinition,
} from "../../contracts/routes.js";
} from "../../features/installed-feature-contracts.js";
import type { RouteDefinition } from "../../contracts/routes.js";
import {
FeatureBoundary,
RouteBoundary,
@@ -43,7 +43,7 @@ import {
type ParsedRouteInput,
type RouteId,
} from "./route-codecs.js";
import { ROUTE_RUNTIME } from "./route-runtime.js";
import { ROUTE_RUNTIME } from "../../features/installed-feature-runtimes.js";
const RouteInputContext = createContext<ParsedRouteInput | null>(null);
+1 -1
View File
@@ -1,4 +1,4 @@
import { getRoute } from "../../contracts/routes.js";
import { getRoute } from "../../features/installed-feature-contracts.js";
/**
* @param {string} routeId
@@ -0,0 +1,6 @@
import { z } from "zod";
export const PLATFORM_ROUTE_CODECS = {
none: z.object({}).strict(),
NotFoundSplat: z.object({ "*": z.string().optional() }).strict(),
} as const;
+17 -35
View File
@@ -1,36 +1,8 @@
import { z } from "zod";
import { getRoute } from "../../contracts/routes.js";
import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
import { getRoute, ROUTE_RUNTIME_CONTRACT } from "../../features/installed-feature-contracts.js";
import { ROUTE_CODECS } from "../../features/installed-feature-runtimes.js";
export type RouteId = keyof typeof ROUTE_RUNTIME_CONTRACT;
const emptyCodec = z.object({}).strict();
const notFoundSplatCodec = z.object({ "*": z.string().optional() }).strict();
const sampleResourceListQuery = z
.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tags: z
.preprocess(
(value) =>
value === undefined
? undefined
: Array.isArray(value)
? value
: [value],
z.array(z.string().trim().min(1)),
)
.optional(),
})
.strict();
const codecs = {
none: emptyCodec,
NotFoundSplat: notFoundSplatCodec,
SampleResourceListQuery: sampleResourceListQuery,
} as const;
export type ParsedRouteInput = Readonly<{
routeId: RouteId;
params: Readonly<Record<string, unknown>>;
@@ -44,17 +16,23 @@ export type RouteInputResult =
code: "ROUTE_PARAMS_INVALID" | "ROUTE_SEARCH_INVALID";
}>;
function codecById(codecId: string) {
const codec = ROUTE_CODECS[codecId as keyof typeof ROUTE_CODECS];
if (!codec) throw new TypeError(`Unregistered route codec: ${codecId}`);
return codec;
}
export function parseRouteInput(
routeId: RouteId,
rawParams: Readonly<Record<string, string | undefined>>,
rawSearch: URLSearchParams,
): RouteInputResult {
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
const params = codecs[runtime.paramsCodec].safeParse(rawParams);
const params = codecById(runtime.paramsCodec).safeParse(rawParams);
if (!params.success) {
return { success: false, code: "ROUTE_PARAMS_INVALID" };
}
const search = codecs[runtime.searchCodec].safeParse(
const search = codecById(runtime.searchCodec).safeParse(
searchRecord(rawSearch),
);
if (!search.success) {
@@ -84,14 +62,18 @@ export function buildRouteUrl(
throw new TypeError("The not-found route cannot build a canonical URL");
}
const runtime = ROUTE_RUNTIME_CONTRACT[routeId];
const params = codecs[runtime.paramsCodec].parse(input.params ?? {});
const search = codecs[runtime.searchCodec].parse(input.search ?? {});
const params = codecById(runtime.paramsCodec).parse(input.params ?? {});
const search = codecById(runtime.searchCodec).parse(input.search ?? {});
const parsedParams: Record<string, unknown> = { ...params };
const parsedSearch: Record<string, unknown> = { ...search };
let path = definition.path;
path = path.replace(
/:([A-Za-z][A-Za-z0-9_]*)|\{([A-Za-z][A-Za-z0-9_]*)\}/g,
(_token, colonName: string | undefined, braceName: string | undefined) => {
(
_token: string,
colonName: string | undefined,
braceName: string | undefined,
) => {
const name = colonName ?? braceName ?? "";
const value = parsedParams[name];
if (typeof value !== "string" && typeof value !== "number") {
+5 -10
View File
@@ -4,8 +4,7 @@ import {
type LazyExoticComponent,
} from "react";
import { ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
import type { RouteId } from "./route-codecs.js";
import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../../contracts/route-runtime-contract.js";
type RouteModule = Readonly<{ default: ComponentType }>;
type RouteRuntime = Readonly<{
@@ -14,16 +13,16 @@ type RouteRuntime = Readonly<{
}>;
function runtime(
routeId: RouteId,
routeId: keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT,
load: () => Promise<RouteModule>,
): RouteRuntime {
return Object.freeze({
moduleId: ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
moduleId: PLATFORM_ROUTE_RUNTIME_CONTRACT[routeId].moduleId,
Component: lazy(load),
});
}
export const ROUTE_RUNTIME = {
export const PLATFORM_ROUTE_RUNTIME = {
APP_HOME: runtime("APP_HOME", () => import("../pages/home-page.jsx")),
EXAMPLES_UI: runtime(
"EXAMPLES_UI",
@@ -37,12 +36,8 @@ export const ROUTE_RUNTIME = {
"EXAMPLES_AUTH",
() => import("../examples/auth-example-page.jsx"),
),
SAMPLE_RESOURCE_LIST: runtime(
"SAMPLE_RESOURCE_LIST",
() => import("../pages/sample-contract-page.jsx"),
),
NOT_FOUND: runtime(
"NOT_FOUND",
() => import("../pages/not-found-page.jsx"),
),
} satisfies Record<RouteId, RouteRuntime>;
} satisfies Record<keyof typeof PLATFORM_ROUTE_RUNTIME_CONTRACT, RouteRuntime>;