@mandujs/core 0.18.20 → 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 (91) hide show
  1. package/package.json +3 -1
  2. package/src/brain/architecture/analyzer.ts +3 -5
  3. package/src/brain/architecture/types.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +1 -0
  5. package/src/brain/doctor/index.ts +1 -1
  6. package/src/brain/doctor/patcher.ts +10 -6
  7. package/src/brain/doctor/reporter.ts +4 -4
  8. package/src/brain/types.ts +14 -10
  9. package/src/bundler/build.ts +17 -17
  10. package/src/bundler/css.ts +3 -2
  11. package/src/bundler/dev.ts +1 -1
  12. package/src/client/island.ts +10 -9
  13. package/src/client/router.ts +1 -1
  14. package/src/config/mcp-ref.ts +6 -6
  15. package/src/config/metadata.test.ts +1 -1
  16. package/src/config/metadata.ts +36 -16
  17. package/src/config/symbols.ts +1 -1
  18. package/src/config/validate.ts +17 -1
  19. package/src/content/content.test.ts +3 -3
  20. package/src/content/loaders/file.ts +3 -0
  21. package/src/content/loaders/glob.ts +1 -0
  22. package/src/contract/client-safe.test.ts +1 -1
  23. package/src/contract/client.test.ts +2 -1
  24. package/src/contract/client.ts +18 -18
  25. package/src/contract/define.ts +32 -17
  26. package/src/contract/handler.ts +11 -11
  27. package/src/contract/index.ts +2 -5
  28. package/src/contract/infer.test.ts +2 -1
  29. package/src/contract/normalize.test.ts +1 -1
  30. package/src/contract/normalize.ts +17 -11
  31. package/src/contract/registry.test.ts +1 -1
  32. package/src/contract/zod-utils.ts +155 -0
  33. package/src/devtools/client/catchers/error-catcher.ts +3 -3
  34. package/src/devtools/client/catchers/network-proxy.ts +5 -1
  35. package/src/devtools/client/components/kitchen-root.tsx +2 -2
  36. package/src/devtools/client/components/panel/guard-panel.tsx +3 -3
  37. package/src/devtools/client/state-manager.ts +9 -9
  38. package/src/devtools/index.ts +8 -8
  39. package/src/devtools/init.ts +2 -2
  40. package/src/devtools/protocol.ts +4 -4
  41. package/src/devtools/server/source-context.ts +9 -3
  42. package/src/devtools/types.ts +5 -5
  43. package/src/devtools/worker/redaction-worker.ts +12 -5
  44. package/src/error/index.ts +1 -1
  45. package/src/error/result.ts +14 -0
  46. package/src/filling/deps.ts +5 -2
  47. package/src/filling/filling.ts +1 -1
  48. package/src/generator/templates.ts +2 -2
  49. package/src/guard/contract-guard.test.ts +1 -0
  50. package/src/guard/file-type.test.ts +1 -1
  51. package/src/guard/index.ts +1 -1
  52. package/src/guard/negotiation.ts +29 -1
  53. package/src/guard/presets/index.ts +3 -0
  54. package/src/guard/semantic-slots.ts +4 -4
  55. package/src/index.ts +10 -1
  56. package/src/intent/index.ts +28 -17
  57. package/src/island/index.ts +8 -8
  58. package/src/openapi/generator.ts +49 -31
  59. package/src/plugins/index.ts +1 -1
  60. package/src/plugins/registry.ts +28 -18
  61. package/src/plugins/types.ts +2 -2
  62. package/src/resource/__tests__/backward-compat.test.ts +2 -2
  63. package/src/resource/__tests__/edge-cases.test.ts +14 -13
  64. package/src/resource/__tests__/fixtures.ts +2 -2
  65. package/src/resource/__tests__/generator.test.ts +1 -1
  66. package/src/resource/__tests__/performance.test.ts +8 -6
  67. package/src/resource/schema.ts +1 -1
  68. package/src/router/fs-routes.ts +34 -40
  69. package/src/router/fs-types.ts +2 -2
  70. package/src/router/index.ts +1 -1
  71. package/src/runtime/boundary.tsx +4 -4
  72. package/src/runtime/logger.test.ts +3 -3
  73. package/src/runtime/logger.ts +1 -1
  74. package/src/runtime/server.ts +18 -16
  75. package/src/runtime/ssr.ts +1 -1
  76. package/src/runtime/stable-selector.ts +1 -2
  77. package/src/runtime/streaming-ssr.ts +15 -6
  78. package/src/seo/index.ts +5 -0
  79. package/src/seo/integration/ssr.ts +4 -4
  80. package/src/seo/render/basic.ts +12 -4
  81. package/src/seo/render/opengraph.ts +12 -6
  82. package/src/seo/render/twitter.ts +3 -2
  83. package/src/seo/resolve/url.ts +7 -0
  84. package/src/seo/types.ts +13 -0
  85. package/src/spec/schema.ts +89 -61
  86. package/src/types/branded.ts +56 -0
  87. package/src/types/index.ts +1 -0
  88. package/src/utils/hasher.test.ts +6 -6
  89. package/src/utils/hasher.ts +2 -2
  90. package/src/utils/index.ts +1 -1
  91. package/src/watcher/watcher.ts +2 -2
@@ -57,7 +57,7 @@ interface ErrorBoundaryState {
57
57
  * </LoadingBoundary>
58
58
  * ```
59
59
  */
60
- export function LoadingBoundary({ fallback, children }: LoadingBoundaryProps): JSX.Element {
60
+ export function LoadingBoundary({ fallback, children }: LoadingBoundaryProps): React.ReactElement {
61
61
  return <Suspense fallback={fallback}>{children}</Suspense>;
62
62
  }
63
63
 
@@ -127,7 +127,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
127
127
  /**
128
128
  * 기본 로딩 컴포넌트
129
129
  */
130
- export function DefaultLoading(): JSX.Element {
130
+ export function DefaultLoading(): React.ReactElement {
131
131
  return (
132
132
  <div
133
133
  style={{
@@ -146,7 +146,7 @@ export function DefaultLoading(): JSX.Element {
146
146
  /**
147
147
  * 기본 에러 컴포넌트
148
148
  */
149
- export function DefaultError({ error, resetError }: ErrorFallbackProps): JSX.Element {
149
+ export function DefaultError({ error, resetError }: ErrorFallbackProps): React.ReactElement {
150
150
  return (
151
151
  <div
152
152
  style={{
@@ -220,7 +220,7 @@ export function PageBoundary({
220
220
  errorComponent,
221
221
  children,
222
222
  onError,
223
- }: PageBoundaryProps): JSX.Element {
223
+ }: PageBoundaryProps): React.ReactElement {
224
224
  const LoadingFallback = loadingComponent ?? <DefaultLoading />;
225
225
  const ErrorFallback = errorComponent ?? DefaultError;
226
226
 
@@ -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 } 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";
@@ -304,7 +304,7 @@ export interface ServerOptions {
304
304
  }
305
305
 
306
306
  export interface ManduServer {
307
- server: Server;
307
+ server: Server<undefined>;
308
308
  router: Router;
309
309
  /** 이 서버 인스턴스의 레지스트리 */
310
310
  registry: ServerRegistry;
@@ -659,7 +659,7 @@ async function wrapWithLayouts(
659
659
  for (let i = layouts.length - 1; i >= 0; i--) {
660
660
  const Layout = layouts[i];
661
661
  if (Layout) {
662
- wrapped = React.createElement(Layout, { params }, wrapped);
662
+ wrapped = React.createElement(Layout, { params, children: wrapped });
663
663
  }
664
664
  }
665
665
 
@@ -916,14 +916,15 @@ async function loadPageData(
916
916
  if (loader) {
917
917
  try {
918
918
  const module = await loader();
919
- const exported = module.default;
919
+ const exported: unknown = module.default;
920
+ const exportedObj = exported as Record<string, unknown> | null;
920
921
  const component = typeof exported === "function"
921
- ? exported
922
- : exported?.component ?? exported;
923
- registry.registerRouteComponent(route.id, component);
922
+ ? (exported as RouteComponent)
923
+ : (exportedObj?.component ?? exported);
924
+ registry.registerRouteComponent(route.id, component as RouteComponent);
924
925
 
925
926
  // filling이 있으면 loader 실행
926
- const filling = typeof exported === "object" ? exported?.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);
@@ -948,7 +949,7 @@ async function loadPageData(
948
949
  * SSR 렌더링 (Streaming/Non-streaming)
949
950
  */
950
951
  async function renderPageSSR(
951
- route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: unknown },
952
+ route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig },
952
953
  params: Record<string, string>,
953
954
  loaderData: unknown,
954
955
  url: string,
@@ -1041,7 +1042,7 @@ async function renderPageSSR(
1041
1042
  async function handlePageRoute(
1042
1043
  req: Request,
1043
1044
  url: URL,
1044
- route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: unknown },
1045
+ route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig },
1045
1046
  params: Record<string, string>,
1046
1047
  registry: ServerRegistry
1047
1048
  ): Promise<Result<Response>> {
@@ -1084,7 +1085,7 @@ async function handleRequestInternal(
1084
1085
 
1085
1086
  // 0. CORS Preflight 요청 처리
1086
1087
  if (settings.cors && isPreflightRequest(req)) {
1087
- const corsOptions = settings.cors === true ? {} : settings.cors;
1088
+ const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
1088
1089
  return ok(handlePreflightRequest(req, corsOptions));
1089
1090
  }
1090
1091
 
@@ -1092,7 +1093,7 @@ async function handleRequestInternal(
1092
1093
  const staticResponse = await serveStaticFile(pathname, settings);
1093
1094
  if (staticResponse) {
1094
1095
  if (settings.cors && isCorsRequest(req)) {
1095
- const corsOptions = settings.cors === true ? {} : settings.cors;
1096
+ const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
1096
1097
  return ok(applyCorsToResponse(staticResponse, req, corsOptions));
1097
1098
  }
1098
1099
  return ok(staticResponse);
@@ -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
  }
@@ -1158,7 +1160,7 @@ function startBunServerWithFallback(options: {
1158
1160
  port: number;
1159
1161
  hostname?: string;
1160
1162
  fetch: (req: Request) => Promise<Response>;
1161
- }): { server: Server; port: number; attempts: number } {
1163
+ }): { server: Server<undefined>; port: number; attempts: number } {
1162
1164
  const { port: startPort, hostname, fetch } = options;
1163
1165
  let lastError: unknown = null;
1164
1166
 
@@ -184,7 +184,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
184
184
  // CSS 링크 태그 생성
185
185
  // - cssPath가 string이면 해당 경로 사용
186
186
  // - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
187
- const cssLinkTag = cssPath && cssPath !== false
187
+ const cssLinkTag = cssPath
188
188
  ? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
189
189
  : "";
190
190
 
@@ -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
  */
@@ -378,7 +387,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
378
387
  // CSS 링크 태그 생성
379
388
  // - cssPath가 string이면 해당 경로 사용
380
389
  // - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
381
- const cssLinkTag = cssPath && cssPath !== false
390
+ const cssLinkTag = cssPath
382
391
  ? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
383
392
  : "";
384
393
 
@@ -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();
@@ -748,7 +757,7 @@ export async function renderToStream(
748
757
 
749
758
  async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array> | null> {
750
759
  if (!deadline) {
751
- return reader.read();
760
+ return reader.read() as Promise<ReadableStreamReadResult<Uint8Array>>;
752
761
  }
753
762
 
754
763
  const remaining = deadline - Date.now();
@@ -763,8 +772,8 @@ export async function renderToStream(
763
772
 
764
773
  const readPromise = reader
765
774
  .read()
766
- .then((result) => ({ kind: "read" as const, result }))
767
- .catch((error) => ({ kind: "error" as const, error }));
775
+ .then((result) => ({ kind: "read" as const, result: result as ReadableStreamReadResult<Uint8Array> }))
776
+ .catch((error: unknown) => ({ kind: "error" as const, error }));
768
777
 
769
778
  const result = await Promise.race([readPromise, timeoutPromise]);
770
779
 
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,13 +118,13 @@ 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,
122
- template: staticMetadata.title.template || null,
121
+ absolute: staticMetadata.title.absolute ?? '',
122
+ template: staticMetadata.title.template ?? null,
123
123
  }
124
124
  } else if ('default' in staticMetadata.title) {
125
125
  resolvedTitle = {
126
- absolute: staticMetadata.title.default,
127
- template: staticMetadata.title.template || null,
126
+ absolute: staticMetadata.title.default ?? '',
127
+ template: staticMetadata.title.template ?? null,
128
128
  }
129
129
  }
130
130
  }
@@ -212,7 +212,9 @@ export function renderIcons(metadata: ResolvedMetadata): string {
212
212
 
213
213
  // icon
214
214
  for (const icon of icons.icon) {
215
- const attrs = [`rel="icon"`, `href="${escapeHtml(urlToString(icon.url))}"`]
215
+ const iconUrl = urlToString(icon.url)
216
+ if (!iconUrl) continue
217
+ const attrs = [`rel="icon"`, `href="${escapeHtml(iconUrl)}"`]
216
218
  if (icon.type) attrs.push(`type="${escapeHtml(icon.type)}"`)
217
219
  if (icon.sizes) attrs.push(`sizes="${escapeHtml(icon.sizes)}"`)
218
220
  tags.push(`<link ${attrs.join(' ')} />`)
@@ -220,19 +222,25 @@ export function renderIcons(metadata: ResolvedMetadata): string {
220
222
 
221
223
  // apple-touch-icon
222
224
  for (const icon of icons.apple) {
223
- const attrs = [`rel="apple-touch-icon"`, `href="${escapeHtml(urlToString(icon.url))}"`]
225
+ const iconUrl = urlToString(icon.url)
226
+ if (!iconUrl) continue
227
+ const attrs = [`rel="apple-touch-icon"`, `href="${escapeHtml(iconUrl)}"`]
224
228
  if (icon.sizes) attrs.push(`sizes="${escapeHtml(icon.sizes)}"`)
225
229
  tags.push(`<link ${attrs.join(' ')} />`)
226
230
  }
227
231
 
228
232
  // shortcut icon
229
233
  for (const icon of icons.shortcut) {
230
- tags.push(`<link rel="shortcut icon" href="${escapeHtml(urlToString(icon.url))}" />`)
234
+ const iconUrl = urlToString(icon.url)
235
+ if (!iconUrl) continue
236
+ tags.push(`<link rel="shortcut icon" href="${escapeHtml(iconUrl)}" />`)
231
237
  }
232
238
 
233
239
  // other icons
234
240
  for (const icon of icons.other) {
235
- const attrs = [`href="${escapeHtml(urlToString(icon.url))}"`]
241
+ const iconUrl = urlToString(icon.url)
242
+ if (!iconUrl) continue
243
+ const attrs = [`href="${escapeHtml(iconUrl)}"`]
236
244
  if (icon.rel) attrs.push(`rel="${escapeHtml(icon.rel)}"`)
237
245
  if (icon.type) attrs.push(`type="${escapeHtml(icon.type)}"`)
238
246
  if (icon.sizes) attrs.push(`sizes="${escapeHtml(icon.sizes)}"`)
@@ -69,9 +69,11 @@ export function renderOpenGraph(metadata: ResolvedMetadata): string {
69
69
  // Images
70
70
  if (openGraph.images) {
71
71
  for (const image of openGraph.images) {
72
- tags.push(og('image', urlToString(image.url)))
72
+ const imageUrl = urlToString(image.url)
73
+ if (imageUrl) tags.push(og('image', imageUrl))
73
74
  if (image.secureUrl) {
74
- tags.push(og('image:secure_url', urlToString(image.secureUrl)))
75
+ const secureUrl = urlToString(image.secureUrl)
76
+ if (secureUrl) tags.push(og('image:secure_url', secureUrl))
75
77
  }
76
78
  if (image.type) {
77
79
  tags.push(og('image:type', image.type))
@@ -91,9 +93,11 @@ export function renderOpenGraph(metadata: ResolvedMetadata): string {
91
93
  // Videos
92
94
  if (openGraph.videos) {
93
95
  for (const video of openGraph.videos) {
94
- tags.push(og('video', urlToString(video.url)))
96
+ const videoUrl = urlToString(video.url)
97
+ if (videoUrl) tags.push(og('video', videoUrl))
95
98
  if (video.secureUrl) {
96
- tags.push(og('video:secure_url', urlToString(video.secureUrl)))
99
+ const secureUrl = urlToString(video.secureUrl)
100
+ if (secureUrl) tags.push(og('video:secure_url', secureUrl))
97
101
  }
98
102
  if (video.type) {
99
103
  tags.push(og('video:type', video.type))
@@ -110,9 +114,11 @@ export function renderOpenGraph(metadata: ResolvedMetadata): string {
110
114
  // Audio
111
115
  if (openGraph.audio) {
112
116
  for (const audio of openGraph.audio) {
113
- tags.push(og('audio', urlToString(audio.url)))
117
+ const audioUrl = urlToString(audio.url)
118
+ if (audioUrl) tags.push(og('audio', audioUrl))
114
119
  if (audio.secureUrl) {
115
- tags.push(og('audio:secure_url', urlToString(audio.secureUrl)))
120
+ const secureUrl = urlToString(audio.secureUrl)
121
+ if (secureUrl) tags.push(og('audio:secure_url', secureUrl))
116
122
  }
117
123
  if (audio.type) {
118
124
  tags.push(og('audio:type', audio.type))
@@ -68,14 +68,15 @@ export function renderTwitter(metadata: ResolvedMetadata): string {
68
68
  if (tw.images) {
69
69
  for (let i = 0; i < tw.images.length; i++) {
70
70
  const image = tw.images[i]
71
+ const imageUrl = urlToString(image.url)
71
72
  if (i === 0) {
72
- tags.push(twitter('image', urlToString(image.url)))
73
+ if (imageUrl) tags.push(twitter('image', imageUrl))
73
74
  if (image.alt) {
74
75
  tags.push(twitter('image:alt', image.alt))
75
76
  }
76
77
  } else {
77
78
  // Multiple images (for galleries)
78
- tags.push(twitter(`image${i}`, urlToString(image.url)))
79
+ if (imageUrl) tags.push(twitter(`image${i}`, imageUrl))
79
80
  if (image.alt) {
80
81
  tags.push(twitter(`image${i}:alt`, image.alt))
81
82
  }
@@ -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
  // ============================================================================
@@ -2,8 +2,8 @@ import { z } from "zod";
2
2
 
3
3
  // ========== Hydration 설정 ==========
4
4
 
5
- export const HydrationStrategy = z.enum(["none", "island", "full", "progressive"]);
6
- export type HydrationStrategy = z.infer<typeof HydrationStrategy>;
5
+ export const SpecHydrationStrategy = z.enum(["none", "island", "full", "progressive"]);
6
+ export type SpecHydrationStrategy = z.infer<typeof SpecHydrationStrategy>;
7
7
 
8
8
  export const HydrationPriority = z.enum(["immediate", "visible", "idle", "interaction"]);
9
9
  export type HydrationPriority = z.infer<typeof HydrationPriority>;
@@ -16,7 +16,7 @@ export const HydrationConfig = z.object({
16
16
  * - full: 전체 페이지 hydrate
17
17
  * - progressive: 점진적 hydrate
18
18
  */
19
- strategy: HydrationStrategy,
19
+ strategy: SpecHydrationStrategy,
20
20
 
21
21
  /**
22
22
  * Hydration 우선순위
@@ -56,71 +56,37 @@ export type LoaderConfig = z.infer<typeof LoaderConfig>;
56
56
  export const RouteKind = z.enum(["page", "api"]);
57
57
  export type RouteKind = z.infer<typeof RouteKind>;
58
58
 
59
- export const HttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
60
- export type HttpMethod = z.infer<typeof HttpMethod>;
61
-
62
- export const RouteSpec = z
59
+ export const SpecHttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
60
+ export type SpecHttpMethod = z.infer<typeof SpecHttpMethod>;
61
+
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용)
69
- methods: z.array(HttpMethod).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)
78
+ ...RouteSpecBase,
79
+ kind: z.literal("page"),
80
+ // page 필수
81
+ componentModule: z.string().min(1, "kind가 'page'인 경우 componentModule은 필수입니다"),
82
+ // page 전용 optional
83
+ methods: z.array(SpecHttpMethod).optional(),
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
  /**