@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.
Files changed (61) hide show
  1. package/package.json +3 -1
  2. package/src/brain/doctor/analyzer.ts +1 -0
  3. package/src/brain/doctor/patcher.ts +10 -6
  4. package/src/brain/doctor/reporter.ts +2 -2
  5. package/src/brain/types.ts +14 -10
  6. package/src/bundler/build.ts +17 -17
  7. package/src/bundler/dev.ts +1 -1
  8. package/src/client/island.ts +10 -9
  9. package/src/client/router.ts +1 -1
  10. package/src/config/mcp-ref.ts +6 -6
  11. package/src/config/metadata.test.ts +1 -1
  12. package/src/config/metadata.ts +36 -16
  13. package/src/config/symbols.ts +1 -1
  14. package/src/config/validate.ts +16 -0
  15. package/src/content/content.test.ts +3 -3
  16. package/src/content/loaders/file.ts +3 -0
  17. package/src/content/loaders/glob.ts +1 -0
  18. package/src/contract/client-safe.test.ts +1 -1
  19. package/src/contract/client.ts +18 -18
  20. package/src/contract/define.ts +29 -14
  21. package/src/contract/handler.ts +11 -11
  22. package/src/contract/normalize.test.ts +1 -1
  23. package/src/contract/normalize.ts +16 -10
  24. package/src/contract/registry.test.ts +1 -1
  25. package/src/contract/zod-utils.ts +155 -0
  26. package/src/devtools/client/catchers/network-proxy.ts +5 -1
  27. package/src/devtools/init.ts +2 -2
  28. package/src/devtools/server/source-context.ts +9 -3
  29. package/src/devtools/worker/redaction-worker.ts +12 -5
  30. package/src/error/index.ts +1 -1
  31. package/src/error/result.ts +14 -0
  32. package/src/filling/deps.ts +1 -1
  33. package/src/generator/templates.ts +2 -2
  34. package/src/guard/contract-guard.test.ts +1 -0
  35. package/src/guard/file-type.test.ts +1 -1
  36. package/src/guard/negotiation.ts +29 -1
  37. package/src/index.ts +1 -0
  38. package/src/intent/index.ts +28 -17
  39. package/src/island/index.ts +2 -2
  40. package/src/openapi/generator.ts +49 -31
  41. package/src/plugins/registry.ts +28 -18
  42. package/src/resource/__tests__/backward-compat.test.ts +2 -2
  43. package/src/resource/__tests__/edge-cases.test.ts +14 -13
  44. package/src/resource/__tests__/fixtures.ts +2 -2
  45. package/src/resource/__tests__/generator.test.ts +1 -1
  46. package/src/resource/__tests__/performance.test.ts +8 -6
  47. package/src/resource/schema.ts +1 -1
  48. package/src/router/fs-routes.ts +29 -35
  49. package/src/runtime/logger.test.ts +3 -3
  50. package/src/runtime/logger.ts +1 -1
  51. package/src/runtime/server.ts +8 -6
  52. package/src/runtime/stable-selector.ts +1 -2
  53. package/src/runtime/streaming-ssr.ts +11 -2
  54. package/src/seo/index.ts +5 -0
  55. package/src/seo/integration/ssr.ts +2 -2
  56. package/src/seo/resolve/url.ts +7 -0
  57. package/src/seo/types.ts +13 -0
  58. package/src/spec/schema.ts +82 -54
  59. package/src/types/branded.ts +56 -0
  60. package/src/types/index.ts +1 -0
  61. package/src/utils/hasher.test.ts +6 -6
@@ -51,49 +51,43 @@ export interface GenerateOptions {
51
51
  * FSRouteConfig를 RouteSpec으로 변환
52
52
  */
53
53
  export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
54
- const routeSpec: RouteSpec = {
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
- routeSpec.componentModule = fsRoute.componentModule;
64
-
65
- // Island (클라이언트 모듈)
66
- if (fsRoute.clientModule) {
67
- routeSpec.clientModule = fsRoute.clientModule;
68
- routeSpec.hydration = fsRoute.hydration ?? {
69
- strategy: "island",
70
- priority: "visible",
71
- preload: false,
72
- };
73
- }
74
-
75
- // Layout 체인
76
- if (fsRoute.layoutChain && fsRoute.layoutChain.length > 0) {
77
- routeSpec.layoutChain = fsRoute.layoutChain;
78
- }
79
-
80
- // Loading UI
81
- if (fsRoute.loadingModule) {
82
- routeSpec.loadingModule = fsRoute.loadingModule;
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
- if (fsRoute.kind === "api" && fsRoute.methods) {
93
- routeSpec.methods = fsRoute.methods;
94
- }
95
-
96
- return routeSpec;
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
 
@@ -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 any, scope: "global" });
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
  }
@@ -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
- : (exported as any)?.component ?? exported;
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 ? (exported as any)?.filling : 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: ${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: route.id, pattern: route.pattern },
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
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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 && !(globalThis as any).__MANDU_STREAMING_WARNED__) {
697
+ if (isDev && !streamingWarnings.hasWarned()) {
689
698
  warnStreamingCaveats(isDev);
690
- (globalThis as any).__MANDU_STREAMING_WARNED__ = true;
699
+ streamingWarnings.markWarned();
691
700
  }
692
701
 
693
702
  const encoder = new TextEncoder();
package/src/seo/index.ts CHANGED
@@ -102,6 +102,11 @@ export type {
102
102
  JsonLd,
103
103
  JsonLdType,
104
104
 
105
+ // Template Literal Types
106
+ OGProperty,
107
+ TwitterProperty,
108
+ MetaProperty,
109
+
105
110
  // Google SEO 최적화
106
111
  GoogleMeta,
107
112
  FormatDetection,
@@ -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
  }
@@ -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
  // ============================================================================
@@ -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
- export const RouteSpec = z
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
- id: z.string().min(1, "id는 필수입니다"),
65
- pattern: z.string().startsWith("/", "pattern은 /로 시작해야 합니다"),
66
- kind: RouteKind,
67
-
68
- // HTTP 메서드 (API용)
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";
@@ -146,25 +146,25 @@ describe("normalizeForHash", () => {
146
146
  ["a", 2],
147
147
  ["m", 3],
148
148
  ]);
149
- const normalized = normalizeForHash({ data: map }) as any;
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 any;
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: any = { a: 1 };
164
+ const obj: Record<string, unknown> = { a: 1 };
165
165
  obj.self = obj;
166
166
 
167
- const normalized = normalizeForHash(obj) as any;
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 any;
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");