@mandujs/core 0.18.21 → 0.18.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/brain/doctor/analyzer.ts +1 -0
- package/src/brain/doctor/patcher.ts +10 -6
- package/src/brain/doctor/reporter.ts +2 -2
- package/src/brain/types.ts +14 -10
- package/src/bundler/build.ts +17 -17
- package/src/bundler/dev.ts +1 -1
- package/src/client/island.ts +10 -9
- package/src/client/router.ts +1 -1
- package/src/config/mcp-ref.ts +6 -6
- package/src/config/metadata.test.ts +1 -1
- package/src/config/metadata.ts +36 -16
- package/src/config/symbols.ts +1 -1
- package/src/config/validate.ts +16 -0
- package/src/content/content.test.ts +3 -3
- package/src/content/loaders/file.ts +3 -0
- package/src/content/loaders/glob.ts +1 -0
- package/src/contract/client-safe.test.ts +1 -1
- package/src/contract/client.ts +18 -18
- package/src/contract/define.ts +29 -14
- package/src/contract/handler.ts +11 -11
- package/src/contract/normalize.test.ts +1 -1
- package/src/contract/normalize.ts +16 -10
- package/src/contract/registry.test.ts +1 -1
- package/src/contract/zod-utils.ts +155 -0
- package/src/devtools/client/catchers/network-proxy.ts +5 -1
- package/src/devtools/init.ts +2 -2
- package/src/devtools/server/source-context.ts +9 -3
- package/src/devtools/worker/redaction-worker.ts +12 -5
- package/src/error/index.ts +1 -1
- package/src/error/result.ts +14 -0
- package/src/filling/deps.ts +1 -1
- package/src/generator/templates.ts +2 -2
- package/src/guard/contract-guard.test.ts +1 -0
- package/src/guard/file-type.test.ts +1 -1
- package/src/guard/negotiation.ts +29 -1
- package/src/index.ts +1 -0
- package/src/intent/index.ts +28 -17
- package/src/island/index.ts +2 -2
- package/src/openapi/generator.ts +49 -31
- package/src/plugins/registry.ts +28 -18
- package/src/resource/__tests__/backward-compat.test.ts +2 -2
- package/src/resource/__tests__/edge-cases.test.ts +14 -13
- package/src/resource/__tests__/fixtures.ts +2 -2
- package/src/resource/__tests__/generator.test.ts +1 -1
- package/src/resource/__tests__/performance.test.ts +8 -6
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-routes.ts +29 -35
- package/src/runtime/logger.test.ts +3 -3
- package/src/runtime/logger.ts +1 -1
- package/src/runtime/server.ts +8 -6
- package/src/runtime/stable-selector.ts +1 -2
- package/src/runtime/streaming-ssr.ts +11 -2
- package/src/seo/index.ts +5 -0
- package/src/seo/integration/ssr.ts +2 -2
- package/src/seo/resolve/url.ts +7 -0
- package/src/seo/types.ts +13 -0
- package/src/spec/schema.ts +82 -54
- package/src/types/branded.ts +56 -0
- package/src/types/index.ts +1 -0
- package/src/utils/hasher.test.ts +6 -6
package/src/router/fs-routes.ts
CHANGED
|
@@ -51,49 +51,43 @@ export interface GenerateOptions {
|
|
|
51
51
|
* FSRouteConfig를 RouteSpec으로 변환
|
|
52
52
|
*/
|
|
53
53
|
export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
|
|
54
|
-
const
|
|
54
|
+
const base = {
|
|
55
55
|
id: fsRoute.id,
|
|
56
56
|
pattern: fsRoute.pattern,
|
|
57
|
-
kind: fsRoute.kind,
|
|
58
57
|
module: fsRoute.module,
|
|
59
58
|
};
|
|
60
59
|
|
|
61
|
-
// 페이지 라우트의 경우
|
|
62
60
|
if (fsRoute.kind === "page") {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// Error UI
|
|
86
|
-
if (fsRoute.errorModule) {
|
|
87
|
-
routeSpec.errorModule = fsRoute.errorModule;
|
|
88
|
-
}
|
|
61
|
+
const pageRoute: RouteSpec = {
|
|
62
|
+
...base,
|
|
63
|
+
kind: "page" as const,
|
|
64
|
+
componentModule: fsRoute.componentModule ?? "",
|
|
65
|
+
...(fsRoute.clientModule
|
|
66
|
+
? {
|
|
67
|
+
clientModule: fsRoute.clientModule,
|
|
68
|
+
hydration: fsRoute.hydration ?? {
|
|
69
|
+
strategy: "island" as const,
|
|
70
|
+
priority: "visible" as const,
|
|
71
|
+
preload: false,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
: {}),
|
|
75
|
+
...(fsRoute.layoutChain && fsRoute.layoutChain.length > 0
|
|
76
|
+
? { layoutChain: fsRoute.layoutChain }
|
|
77
|
+
: {}),
|
|
78
|
+
...(fsRoute.loadingModule ? { loadingModule: fsRoute.loadingModule } : {}),
|
|
79
|
+
...(fsRoute.errorModule ? { errorModule: fsRoute.errorModule } : {}),
|
|
80
|
+
};
|
|
81
|
+
return pageRoute;
|
|
89
82
|
}
|
|
90
83
|
|
|
91
|
-
// API
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
84
|
+
// API 라우트
|
|
85
|
+
const apiRoute: RouteSpec = {
|
|
86
|
+
...base,
|
|
87
|
+
kind: "api" as const,
|
|
88
|
+
...(fsRoute.methods ? { methods: fsRoute.methods } : {}),
|
|
89
|
+
};
|
|
90
|
+
return apiRoute;
|
|
97
91
|
}
|
|
98
92
|
|
|
99
93
|
/**
|
|
@@ -62,7 +62,7 @@ describe("logger", () => {
|
|
|
62
62
|
|
|
63
63
|
log.onError(ctx, error);
|
|
64
64
|
|
|
65
|
-
expect(ctx.get("__mandu_logger_error")).toBe(error);
|
|
65
|
+
expect(ctx.get<Error>("__mandu_logger_error")).toBe(error);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
test("afterHandle은 응답을 저장하고 반환", () => {
|
|
@@ -72,7 +72,7 @@ describe("logger", () => {
|
|
|
72
72
|
|
|
73
73
|
const result = log.afterHandle(ctx, response);
|
|
74
74
|
|
|
75
|
-
expect(ctx.get("__mandu_logger_response")).toBe(response);
|
|
75
|
+
expect(ctx.get<Response>("__mandu_logger_response")).toBe(response);
|
|
76
76
|
expect(result).toBe(response);
|
|
77
77
|
});
|
|
78
78
|
});
|
|
@@ -147,7 +147,7 @@ describe("logger", () => {
|
|
|
147
147
|
|
|
148
148
|
log.onRequest(ctx);
|
|
149
149
|
|
|
150
|
-
expect(ctx.get("__mandu_logger_request_id")).toBe("custom-POST-123");
|
|
150
|
+
expect(ctx.get<string>("__mandu_logger_request_id")).toBe("custom-POST-123");
|
|
151
151
|
});
|
|
152
152
|
});
|
|
153
153
|
|
package/src/runtime/logger.ts
CHANGED
|
@@ -639,7 +639,7 @@ export function applyLogger(
|
|
|
639
639
|
loggerInstance: ReturnType<typeof logger>
|
|
640
640
|
): void {
|
|
641
641
|
lifecycle.onRequest.push({ fn: loggerInstance.onRequest, scope: "global" });
|
|
642
|
-
lifecycle.onError.push({ fn: loggerInstance.onError as
|
|
642
|
+
lifecycle.onError.push({ fn: loggerInstance.onError as (ctx: ManduContext, error: Error) => void, scope: "global" });
|
|
643
643
|
lifecycle.afterHandle.push({ fn: loggerInstance.afterHandle, scope: "global" });
|
|
644
644
|
lifecycle.afterResponse.push({ fn: loggerInstance.afterResponse, scope: "global" });
|
|
645
645
|
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
|
-
import type { RoutesManifest, HydrationConfig } from "../spec/schema";
|
|
2
|
+
import type { RoutesManifest, RouteSpec, HydrationConfig } from "../spec/schema";
|
|
3
3
|
import type { BundleManifest } from "../bundler/types";
|
|
4
4
|
import type { ManduFilling } from "../filling/filling";
|
|
5
5
|
import { ManduContext } from "../filling/context";
|
|
@@ -917,13 +917,14 @@ async function loadPageData(
|
|
|
917
917
|
try {
|
|
918
918
|
const module = await loader();
|
|
919
919
|
const exported: unknown = module.default;
|
|
920
|
+
const exportedObj = exported as Record<string, unknown> | null;
|
|
920
921
|
const component = typeof exported === "function"
|
|
921
922
|
? (exported as RouteComponent)
|
|
922
|
-
: (
|
|
923
|
+
: (exportedObj?.component ?? exported);
|
|
923
924
|
registry.registerRouteComponent(route.id, component as RouteComponent);
|
|
924
925
|
|
|
925
926
|
// filling이 있으면 loader 실행
|
|
926
|
-
const filling = typeof exported === "object" && exported !== null ? (
|
|
927
|
+
const filling = typeof exported === "object" && exported !== null ? (exportedObj as Record<string, unknown>)?.filling as ManduFilling | null : null;
|
|
927
928
|
if (filling?.hasLoader?.()) {
|
|
928
929
|
const ctx = new ManduContext(req, params);
|
|
929
930
|
loaderData = await filling.executeLoader(ctx);
|
|
@@ -1127,18 +1128,19 @@ async function handleRequestInternal(
|
|
|
1127
1128
|
return handlePageRoute(req, url, route, params, registry);
|
|
1128
1129
|
}
|
|
1129
1130
|
|
|
1130
|
-
// 4. 알 수 없는 라우트 종류
|
|
1131
|
+
// 4. 알 수 없는 라우트 종류 — exhaustiveness check
|
|
1132
|
+
const _exhaustive: never = route;
|
|
1131
1133
|
return err({
|
|
1132
1134
|
errorType: "FRAMEWORK_BUG",
|
|
1133
1135
|
code: "MANDU_F003",
|
|
1134
1136
|
httpStatus: 500,
|
|
1135
|
-
message: `Unknown route kind: ${
|
|
1137
|
+
message: `Unknown route kind: ${(_exhaustive as RouteSpec).kind}`,
|
|
1136
1138
|
summary: "알 수 없는 라우트 종류 - 프레임워크 버그",
|
|
1137
1139
|
fix: {
|
|
1138
1140
|
file: ".mandu/routes.manifest.json",
|
|
1139
1141
|
suggestion: "라우트의 kind는 'api' 또는 'page'여야 합니다",
|
|
1140
1142
|
},
|
|
1141
|
-
route: { id:
|
|
1143
|
+
route: { id: (_exhaustive as RouteSpec).id, pattern: (_exhaustive as RouteSpec).pattern },
|
|
1142
1144
|
timestamp: new Date().toISOString(),
|
|
1143
1145
|
});
|
|
1144
1146
|
}
|
|
@@ -11,8 +11,7 @@ function fnv1a64Hex(input: string): string {
|
|
|
11
11
|
|
|
12
12
|
function getBuildSaltFallback(): string {
|
|
13
13
|
try {
|
|
14
|
-
|
|
15
|
-
const salt = (typeof process !== "undefined" && (process as any)?.env?.MANDU_BUILD_SALT) as string | undefined;
|
|
14
|
+
const salt = (typeof process !== "undefined" && process.env?.MANDU_BUILD_SALT) as string | undefined;
|
|
16
15
|
return salt ?? "dev";
|
|
17
16
|
} catch {
|
|
18
17
|
return "dev";
|
|
@@ -244,6 +244,15 @@ function validateCriticalData(data: Record<string, unknown> | undefined, isDev:
|
|
|
244
244
|
|
|
245
245
|
// ========== Streaming Warnings ==========
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Streaming 경고 상태 (module-level, globalThis as any 제거)
|
|
249
|
+
*/
|
|
250
|
+
const streamingWarnings = {
|
|
251
|
+
_warned: false,
|
|
252
|
+
markWarned() { this._warned = true; },
|
|
253
|
+
hasWarned() { return this._warned; },
|
|
254
|
+
};
|
|
255
|
+
|
|
247
256
|
/**
|
|
248
257
|
* 프록시/버퍼링 관련 경고 (개발 모드)
|
|
249
258
|
*/
|
|
@@ -685,9 +694,9 @@ export async function renderToStream(
|
|
|
685
694
|
validateCriticalData(criticalData, isDev);
|
|
686
695
|
|
|
687
696
|
// 스트리밍 주의사항 경고 (첫 요청 시 1회만)
|
|
688
|
-
if (isDev && !(
|
|
697
|
+
if (isDev && !streamingWarnings.hasWarned()) {
|
|
689
698
|
warnStreamingCaveats(isDev);
|
|
690
|
-
(
|
|
699
|
+
streamingWarnings.markWarned();
|
|
691
700
|
}
|
|
692
701
|
|
|
693
702
|
const encoder = new TextEncoder();
|
package/src/seo/index.ts
CHANGED
|
@@ -118,12 +118,12 @@ export function resolveSEOSync(staticMetadata: Metadata): SEOResult {
|
|
|
118
118
|
resolvedTitle = { absolute: staticMetadata.title, template: null }
|
|
119
119
|
} else if ('absolute' in staticMetadata.title) {
|
|
120
120
|
resolvedTitle = {
|
|
121
|
-
absolute: staticMetadata.title.absolute,
|
|
121
|
+
absolute: staticMetadata.title.absolute ?? '',
|
|
122
122
|
template: staticMetadata.title.template ?? null,
|
|
123
123
|
}
|
|
124
124
|
} else if ('default' in staticMetadata.title) {
|
|
125
125
|
resolvedTitle = {
|
|
126
|
-
absolute: staticMetadata.title.default,
|
|
126
|
+
absolute: staticMetadata.title.default ?? '',
|
|
127
127
|
template: staticMetadata.title.template ?? null,
|
|
128
128
|
}
|
|
129
129
|
}
|
package/src/seo/resolve/url.ts
CHANGED
|
@@ -27,6 +27,9 @@ export function normalizeMetadataBase(
|
|
|
27
27
|
/**
|
|
28
28
|
* 상대 URL을 절대 URL로 변환
|
|
29
29
|
*/
|
|
30
|
+
export function resolveUrl(url: URL, metadataBase: URL | null): URL;
|
|
31
|
+
export function resolveUrl(url: string | null | undefined, metadataBase: URL | null): URL | null;
|
|
32
|
+
export function resolveUrl(url: string | URL | null | undefined, metadataBase: URL | null): URL | null;
|
|
30
33
|
export function resolveUrl(
|
|
31
34
|
url: string | URL | null | undefined,
|
|
32
35
|
metadataBase: URL | null
|
|
@@ -69,6 +72,10 @@ export function resolveUrl(
|
|
|
69
72
|
/**
|
|
70
73
|
* URL을 문자열로 변환 (렌더링용)
|
|
71
74
|
*/
|
|
75
|
+
export function urlToString(url: URL): string;
|
|
76
|
+
export function urlToString(url: string): string;
|
|
77
|
+
export function urlToString(url: null | undefined): null;
|
|
78
|
+
export function urlToString(url: URL | string | null | undefined): string | null;
|
|
72
79
|
export function urlToString(url: URL | string | null | undefined): string | null {
|
|
73
80
|
if (!url) return null
|
|
74
81
|
if (url instanceof URL) return url.href
|
package/src/seo/types.ts
CHANGED
|
@@ -223,6 +223,19 @@ export interface ResolvedVerification {
|
|
|
223
223
|
other: Record<string, string[]> | null
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Template Literal Types for Meta Properties
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
/** OpenGraph meta property name (og:title, og:description, etc.) */
|
|
231
|
+
export type OGProperty = `og:${string}`;
|
|
232
|
+
|
|
233
|
+
/** Twitter meta property name (twitter:card, twitter:site, etc.) */
|
|
234
|
+
export type TwitterProperty = `twitter:${string}`;
|
|
235
|
+
|
|
236
|
+
/** Union of well-known meta property prefixes plus arbitrary strings */
|
|
237
|
+
export type MetaProperty = OGProperty | TwitterProperty | string;
|
|
238
|
+
|
|
226
239
|
// ============================================================================
|
|
227
240
|
// Open Graph Types
|
|
228
241
|
// ============================================================================
|
package/src/spec/schema.ts
CHANGED
|
@@ -59,68 +59,34 @@ export type RouteKind = z.infer<typeof RouteKind>;
|
|
|
59
59
|
export const SpecHttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
|
|
60
60
|
export type SpecHttpMethod = z.infer<typeof SpecHttpMethod>;
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// ---- 공통 필드 ----
|
|
63
|
+
const RouteSpecBase = {
|
|
64
|
+
id: z.string().min(1, "id는 필수입니다"),
|
|
65
|
+
pattern: z.string().startsWith("/", "pattern은 /로 시작해야 합니다"),
|
|
66
|
+
module: z.string().min(1, "module 경로는 필수입니다"),
|
|
67
|
+
slotModule: z.string().optional(),
|
|
68
|
+
clientModule: z.string().optional(),
|
|
69
|
+
contractModule: z.string().optional(),
|
|
70
|
+
hydration: HydrationConfig.optional(),
|
|
71
|
+
loader: LoaderConfig.optional(),
|
|
72
|
+
streaming: z.boolean().optional(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ---- Page 라우트 ----
|
|
76
|
+
export const PageRouteSpec = z
|
|
63
77
|
.object({
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
78
|
+
...RouteSpecBase,
|
|
79
|
+
kind: z.literal("page"),
|
|
80
|
+
// page 필수
|
|
81
|
+
componentModule: z.string().min(1, "kind가 'page'인 경우 componentModule은 필수입니다"),
|
|
82
|
+
// page 전용 optional
|
|
69
83
|
methods: z.array(SpecHttpMethod).optional(),
|
|
70
|
-
|
|
71
|
-
// 서버 모듈 (generated route handler)
|
|
72
|
-
module: z.string().min(1, "module 경로는 필수입니다"),
|
|
73
|
-
|
|
74
|
-
// 페이지 컴포넌트 모듈 (generated)
|
|
75
|
-
componentModule: z.string().optional(),
|
|
76
|
-
|
|
77
|
-
// 서버 슬롯 (비즈니스 로직)
|
|
78
|
-
slotModule: z.string().optional(),
|
|
79
|
-
|
|
80
|
-
// 클라이언트 슬롯 (interactive 로직) [NEW]
|
|
81
|
-
clientModule: z.string().optional(),
|
|
82
|
-
|
|
83
|
-
// Contract 모듈 (API 스키마 정의) [NEW]
|
|
84
|
-
contractModule: z.string().optional(),
|
|
85
|
-
|
|
86
|
-
// Hydration 설정 [NEW]
|
|
87
|
-
hydration: HydrationConfig.optional(),
|
|
88
|
-
|
|
89
|
-
// Loader 설정 [NEW]
|
|
90
|
-
loader: LoaderConfig.optional(),
|
|
91
|
-
|
|
92
|
-
// Streaming SSR 설정 [NEW v0.9.18]
|
|
93
|
-
// - true: 이 라우트에 Streaming SSR 적용
|
|
94
|
-
// - false: 이 라우트에 전통적 SSR 적용
|
|
95
|
-
// - undefined: 서버 설정 따름
|
|
96
|
-
streaming: z.boolean().optional(),
|
|
97
|
-
|
|
98
|
-
// Layout 체인 [NEW v0.9.33]
|
|
99
|
-
// - 페이지에 적용할 레이아웃 모듈 경로 배열
|
|
100
|
-
// - 배열 순서: 외부 → 내부 (Root → Parent → Child)
|
|
101
84
|
layoutChain: z.array(z.string()).optional(),
|
|
102
|
-
|
|
103
|
-
// Loading UI 모듈 경로 [NEW v0.9.33]
|
|
104
85
|
loadingModule: z.string().optional(),
|
|
105
|
-
|
|
106
|
-
// Error UI 모듈 경로 [NEW v0.9.33]
|
|
107
86
|
errorModule: z.string().optional(),
|
|
108
87
|
})
|
|
109
88
|
.refine(
|
|
110
89
|
(route) => {
|
|
111
|
-
if (route.kind === "page" && !route.componentModule) {
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
return true;
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
message: "kind가 'page'인 경우 componentModule은 필수입니다",
|
|
118
|
-
path: ["componentModule"],
|
|
119
|
-
}
|
|
120
|
-
)
|
|
121
|
-
.refine(
|
|
122
|
-
(route) => {
|
|
123
|
-
// clientModule이 있으면 hydration.strategy가 none이 아니어야 함
|
|
124
90
|
if (route.clientModule && route.hydration?.strategy === "none") {
|
|
125
91
|
return false;
|
|
126
92
|
}
|
|
@@ -132,6 +98,46 @@ export const RouteSpec = z
|
|
|
132
98
|
}
|
|
133
99
|
);
|
|
134
100
|
|
|
101
|
+
export type PageRouteSpec = z.infer<typeof PageRouteSpec>;
|
|
102
|
+
|
|
103
|
+
// ---- API 라우트 ----
|
|
104
|
+
export const ApiRouteSpec = z.object({
|
|
105
|
+
...RouteSpecBase,
|
|
106
|
+
kind: z.literal("api"),
|
|
107
|
+
// api 전용
|
|
108
|
+
methods: z.array(SpecHttpMethod).optional(),
|
|
109
|
+
// page 전용 필드도 optional로 허용 (호환성)
|
|
110
|
+
componentModule: z.string().optional(),
|
|
111
|
+
layoutChain: z.array(z.string()).optional(),
|
|
112
|
+
loadingModule: z.string().optional(),
|
|
113
|
+
errorModule: z.string().optional(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export type ApiRouteSpec = z.infer<typeof ApiRouteSpec>;
|
|
117
|
+
|
|
118
|
+
// ---- discriminatedUnion ----
|
|
119
|
+
export const RouteSpec = z.discriminatedUnion("kind", [
|
|
120
|
+
// PageRouteSpec에 .refine()이 적용되어 있으므로 내부 shape를 직접 사용
|
|
121
|
+
z.object({
|
|
122
|
+
...RouteSpecBase,
|
|
123
|
+
kind: z.literal("page"),
|
|
124
|
+
componentModule: z.string().min(1, "kind가 'page'인 경우 componentModule은 필수입니다"),
|
|
125
|
+
methods: z.array(SpecHttpMethod).optional(),
|
|
126
|
+
layoutChain: z.array(z.string()).optional(),
|
|
127
|
+
loadingModule: z.string().optional(),
|
|
128
|
+
errorModule: z.string().optional(),
|
|
129
|
+
}),
|
|
130
|
+
z.object({
|
|
131
|
+
...RouteSpecBase,
|
|
132
|
+
kind: z.literal("api"),
|
|
133
|
+
methods: z.array(SpecHttpMethod).optional(),
|
|
134
|
+
componentModule: z.string().optional(),
|
|
135
|
+
layoutChain: z.array(z.string()).optional(),
|
|
136
|
+
loadingModule: z.string().optional(),
|
|
137
|
+
errorModule: z.string().optional(),
|
|
138
|
+
}),
|
|
139
|
+
]);
|
|
140
|
+
|
|
135
141
|
export type RouteSpec = z.infer<typeof RouteSpec>;
|
|
136
142
|
|
|
137
143
|
// ========== Manifest ==========
|
|
@@ -166,6 +172,28 @@ export const RoutesManifest = z
|
|
|
166
172
|
|
|
167
173
|
export type RoutesManifest = z.infer<typeof RoutesManifest>;
|
|
168
174
|
|
|
175
|
+
// ========== Assertion Functions ==========
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Asserts that the given route is a page route.
|
|
179
|
+
* After this call, TypeScript narrows the type to PageRouteSpec.
|
|
180
|
+
*/
|
|
181
|
+
export function assertPageRoute(route: RouteSpec): asserts route is PageRouteSpec {
|
|
182
|
+
if (route.kind !== "page") {
|
|
183
|
+
throw new Error(`Expected page route, got "${route.kind}" (id: ${route.id})`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Asserts that the given route is an API route.
|
|
189
|
+
* After this call, TypeScript narrows the type to ApiRouteSpec.
|
|
190
|
+
*/
|
|
191
|
+
export function assertApiRoute(route: RouteSpec): asserts route is ApiRouteSpec {
|
|
192
|
+
if (route.kind !== "api") {
|
|
193
|
+
throw new Error(`Expected API route, got "${route.kind}" (id: ${route.id})`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
// ========== 유틸리티 함수 ==========
|
|
170
198
|
|
|
171
199
|
/**
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branded Types - Nominal typing for string identifiers
|
|
3
|
+
*
|
|
4
|
+
* Prevents accidental mixing of different ID types at compile time.
|
|
5
|
+
* All branded types are structurally compatible with plain strings at runtime.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const col = collectionId("blog");
|
|
10
|
+
* const entry = entryId("hello-world");
|
|
11
|
+
* // col and entry are both strings at runtime,
|
|
12
|
+
* // but TypeScript treats them as distinct types.
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
declare const __brand: unique symbol;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Brand utility type - creates nominal types from structural types.
|
|
20
|
+
* The brand exists only at the type level and has zero runtime cost.
|
|
21
|
+
*/
|
|
22
|
+
export type Brand<T, B extends string> = T & { readonly [__brand]: B };
|
|
23
|
+
|
|
24
|
+
/** Branded string identifying a content collection */
|
|
25
|
+
export type CollectionId = Brand<string, "CollectionId">;
|
|
26
|
+
|
|
27
|
+
/** Branded string identifying a content entry within a collection */
|
|
28
|
+
export type EntryId = Brand<string, "EntryId">;
|
|
29
|
+
|
|
30
|
+
/** Branded string containing sanitized HTML (XSS-safe) */
|
|
31
|
+
export type SafeHTML = Brand<string, "SafeHTML">;
|
|
32
|
+
|
|
33
|
+
/** Branded string identifying a route in the manifest */
|
|
34
|
+
export type RouteId = Brand<string, "RouteId">;
|
|
35
|
+
|
|
36
|
+
// -- Constructor functions --
|
|
37
|
+
|
|
38
|
+
/** Create a branded CollectionId from a plain string */
|
|
39
|
+
export function collectionId(id: string): CollectionId {
|
|
40
|
+
return id as CollectionId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Create a branded EntryId from a plain string */
|
|
44
|
+
export function entryId(id: string): EntryId {
|
|
45
|
+
return id as EntryId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Create a branded SafeHTML from a sanitized string */
|
|
49
|
+
export function safeHTML(html: string): SafeHTML {
|
|
50
|
+
return html as SafeHTML;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Create a branded RouteId from a plain string */
|
|
54
|
+
export function routeId(id: string): RouteId {
|
|
55
|
+
return id as RouteId;
|
|
56
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./branded";
|
package/src/utils/hasher.test.ts
CHANGED
|
@@ -146,25 +146,25 @@ describe("normalizeForHash", () => {
|
|
|
146
146
|
["a", 2],
|
|
147
147
|
["m", 3],
|
|
148
148
|
]);
|
|
149
|
-
const normalized = normalizeForHash({ data: map }) as
|
|
149
|
+
const normalized = normalizeForHash({ data: map }) as Record<string, Record<string, unknown>>;
|
|
150
150
|
|
|
151
151
|
expect(normalized.data.__type__).toBe("Map");
|
|
152
|
-
expect(normalized.data.entries[0][0]).toBe("a"); // 정렬됨
|
|
152
|
+
expect((normalized.data.entries as string[][])[0][0]).toBe("a"); // 정렬됨
|
|
153
153
|
});
|
|
154
154
|
|
|
155
155
|
it("should handle Set as sorted array", () => {
|
|
156
156
|
const set = new Set([3, 1, 2]);
|
|
157
|
-
const normalized = normalizeForHash({ data: set }) as
|
|
157
|
+
const normalized = normalizeForHash({ data: set }) as Record<string, Record<string, unknown>>;
|
|
158
158
|
|
|
159
159
|
expect(normalized.data.__type__).toBe("Set");
|
|
160
160
|
expect(normalized.data.items).toEqual([1, 2, 3]); // 정렬됨
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
it("should detect circular references", () => {
|
|
164
|
-
const obj:
|
|
164
|
+
const obj: Record<string, unknown> = { a: 1 };
|
|
165
165
|
obj.self = obj;
|
|
166
166
|
|
|
167
|
-
const normalized = normalizeForHash(obj) as
|
|
167
|
+
const normalized = normalizeForHash(obj) as Record<string, unknown>;
|
|
168
168
|
expect(normalized.self).toBe("__circular__");
|
|
169
169
|
});
|
|
170
170
|
|
|
@@ -184,7 +184,7 @@ describe("normalizeForHash", () => {
|
|
|
184
184
|
|
|
185
185
|
it("should normalize Error objects", () => {
|
|
186
186
|
const error = new TypeError("test error");
|
|
187
|
-
const normalized = normalizeForHash({ error }) as
|
|
187
|
+
const normalized = normalizeForHash({ error }) as Record<string, Record<string, unknown>>;
|
|
188
188
|
|
|
189
189
|
expect(normalized.error.__type__).toBe("Error");
|
|
190
190
|
expect(normalized.error.name).toBe("TypeError");
|