@mandujs/core 0.9.39 → 0.9.40

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 (49) hide show
  1. package/README.ko.md +27 -0
  2. package/README.md +21 -5
  3. package/package.json +1 -1
  4. package/src/config/index.ts +1 -0
  5. package/src/config/mandu.ts +60 -0
  6. package/src/contract/client-safe.test.ts +42 -0
  7. package/src/contract/client-safe.ts +114 -0
  8. package/src/contract/client.ts +12 -11
  9. package/src/contract/handler.ts +10 -11
  10. package/src/contract/index.ts +25 -16
  11. package/src/contract/registry.test.ts +206 -0
  12. package/src/contract/registry.ts +568 -0
  13. package/src/contract/schema.ts +48 -12
  14. package/src/contract/types.ts +58 -35
  15. package/src/contract/validator.ts +32 -17
  16. package/src/filling/context.ts +103 -0
  17. package/src/generator/templates.ts +70 -17
  18. package/src/guard/analyzer.ts +9 -4
  19. package/src/guard/check.ts +66 -30
  20. package/src/guard/contract-guard.ts +9 -9
  21. package/src/guard/file-type.test.ts +24 -0
  22. package/src/guard/presets/index.ts +193 -60
  23. package/src/guard/rules.ts +12 -6
  24. package/src/guard/statistics.ts +6 -0
  25. package/src/guard/suggestions.ts +9 -2
  26. package/src/guard/types.ts +11 -1
  27. package/src/guard/validator.ts +160 -9
  28. package/src/guard/watcher.ts +2 -0
  29. package/src/index.ts +8 -1
  30. package/src/runtime/index.ts +1 -0
  31. package/src/runtime/streaming-ssr.ts +123 -2
  32. package/src/seo/index.ts +214 -0
  33. package/src/seo/integration/ssr.ts +307 -0
  34. package/src/seo/render/basic.ts +427 -0
  35. package/src/seo/render/index.ts +143 -0
  36. package/src/seo/render/jsonld.ts +539 -0
  37. package/src/seo/render/opengraph.ts +191 -0
  38. package/src/seo/render/robots.ts +116 -0
  39. package/src/seo/render/sitemap.ts +137 -0
  40. package/src/seo/render/twitter.ts +126 -0
  41. package/src/seo/resolve/index.ts +353 -0
  42. package/src/seo/resolve/opengraph.ts +143 -0
  43. package/src/seo/resolve/robots.ts +73 -0
  44. package/src/seo/resolve/title.ts +94 -0
  45. package/src/seo/resolve/twitter.ts +73 -0
  46. package/src/seo/resolve/url.ts +97 -0
  47. package/src/seo/routes/index.ts +290 -0
  48. package/src/seo/types.ts +575 -0
  49. package/src/slot/validator.ts +39 -16
package/src/index.ts CHANGED
@@ -13,10 +13,12 @@ export * from "./openapi";
13
13
  export * from "./brain";
14
14
  export * from "./watcher";
15
15
  export * from "./router";
16
+ export * from "./config";
17
+ export * from "./seo";
16
18
 
17
19
  // Consolidated Mandu namespace
18
20
  import { ManduFilling, ManduContext, ManduFillingFactory } from "./filling";
19
- import { createContract, defineHandler, defineRoute, createClient, contractFetch } from "./contract";
21
+ import { createContract, defineHandler, defineRoute, createClient, contractFetch, createClientContract } from "./contract";
20
22
  import type { ContractDefinition, ContractInstance, ContractSchema } from "./contract";
21
23
  import type { ContractHandlers, ClientOptions } from "./contract";
22
24
 
@@ -83,6 +85,11 @@ export const Mandu = {
83
85
  */
84
86
  client: createClient,
85
87
 
88
+ /**
89
+ * Create a client-safe contract
90
+ */
91
+ clientContract: createClientContract,
92
+
86
93
  /**
87
94
  * Make a type-safe fetch call
88
95
  */
@@ -1,4 +1,5 @@
1
1
  export * from "./ssr";
2
+ export * from "./streaming-ssr";
2
3
  export * from "./router";
3
4
  export * from "./server";
4
5
  export * from "./cors";
@@ -17,6 +17,8 @@ import React, { Suspense } from "react";
17
17
  import type { BundleManifest } from "../bundler/types";
18
18
  import type { HydrationConfig, HydrationPriority } from "../spec/schema";
19
19
  import { serializeProps } from "../client/serialize";
20
+ import type { Metadata, MetadataItem } from "../seo/types";
21
+ import { injectSEOIntoOptions, resolveSEO, type SEOOptions } from "../seo/integration/ssr";
20
22
 
21
23
  // ========== Types ==========
22
24
 
@@ -73,7 +75,7 @@ export interface StreamingMetrics {
73
75
  }
74
76
 
75
77
  export interface StreamingSSROptions {
76
- /** 페이지 타이틀 */
78
+ /** 페이지 타이틀 (SEO metadata 사용 시 자동 설정됨) */
77
79
  title?: string;
78
80
  /** HTML lang 속성 */
79
81
  lang?: string;
@@ -88,8 +90,18 @@ export interface StreamingSSROptions {
88
90
  hydration?: HydrationConfig;
89
91
  /** 번들 매니페스트 */
90
92
  bundleManifest?: BundleManifest;
91
- /** 추가 head 태그 */
93
+ /** 추가 head 태그 (SEO metadata와 병합됨) */
92
94
  headTags?: string;
95
+ /**
96
+ * SEO 메타데이터 (Layout 체인 또는 단일 객체)
97
+ * - 배열: [rootLayout, ...nestedLayouts, page] 순서로 병합
98
+ * - 객체: 단일 정적 메타데이터
99
+ */
100
+ metadata?: MetadataItem[] | Metadata;
101
+ /** 라우트 파라미터 (동적 메타데이터용) */
102
+ routeParams?: Record<string, string>;
103
+ /** 쿼리 파라미터 (동적 메타데이터용) */
104
+ searchParams?: Record<string, string>;
93
105
  /** 개발 모드 여부 */
94
106
  isDev?: boolean;
95
107
  /** HMR 포트 */
@@ -1102,6 +1114,111 @@ export function defer<T>(promise: Promise<T>): Promise<T> {
1102
1114
  return promise;
1103
1115
  }
1104
1116
 
1117
+ // ========== SEO Integration ==========
1118
+
1119
+ /**
1120
+ * SEO 메타데이터와 함께 Streaming SSR 렌더링
1121
+ *
1122
+ * Layout 체인에서 메타데이터를 자동으로 수집하고 병합하여
1123
+ * HTML head에 삽입합니다.
1124
+ *
1125
+ * @example
1126
+ * ```typescript
1127
+ * // 정적 메타데이터
1128
+ * const response = await renderWithSEO(<Page />, {
1129
+ * metadata: {
1130
+ * title: 'Home',
1131
+ * description: 'Welcome to my site',
1132
+ * openGraph: { type: 'website' },
1133
+ * },
1134
+ * })
1135
+ *
1136
+ * // Layout 체인 메타데이터
1137
+ * const response = await renderWithSEO(<Page />, {
1138
+ * metadata: [
1139
+ * layoutMetadata, // { title: { template: '%s | Site' } }
1140
+ * pageMetadata, // { title: 'Blog Post' }
1141
+ * ],
1142
+ * routeParams: { slug: 'hello' },
1143
+ * })
1144
+ * // → title: "Blog Post | Site"
1145
+ * ```
1146
+ */
1147
+ export async function renderWithSEO(
1148
+ element: ReactElement,
1149
+ options: StreamingSSROptions = {}
1150
+ ): Promise<Response> {
1151
+ const { metadata, routeParams, searchParams, ...restOptions } = options;
1152
+
1153
+ // SEO 메타데이터 처리
1154
+ if (metadata) {
1155
+ const seoOptions: SEOOptions = {
1156
+ routeParams,
1157
+ searchParams,
1158
+ };
1159
+
1160
+ // 배열이면 Layout 체인, 아니면 단일 메타데이터
1161
+ if (Array.isArray(metadata)) {
1162
+ seoOptions.metadata = metadata;
1163
+ } else {
1164
+ seoOptions.staticMetadata = metadata as Metadata;
1165
+ }
1166
+
1167
+ // SEO를 옵션에 주입
1168
+ const optionsWithSEO = await injectSEOIntoOptions(restOptions, seoOptions);
1169
+ return renderStreamingResponse(element, optionsWithSEO);
1170
+ }
1171
+
1172
+ // SEO 없이 기본 렌더링
1173
+ return renderStreamingResponse(element, restOptions);
1174
+ }
1175
+
1176
+ /**
1177
+ * Deferred 데이터 + SEO 메타데이터와 함께 Streaming SSR 렌더링
1178
+ *
1179
+ * @example
1180
+ * ```typescript
1181
+ * const response = await renderWithDeferredDataAndSEO(<Page />, {
1182
+ * metadata: {
1183
+ * title: post.title,
1184
+ * openGraph: { images: [post.image] },
1185
+ * },
1186
+ * deferredPromises: {
1187
+ * comments: fetchComments(postId),
1188
+ * related: fetchRelatedPosts(postId),
1189
+ * },
1190
+ * })
1191
+ * ```
1192
+ */
1193
+ export async function renderWithDeferredDataAndSEO(
1194
+ element: ReactElement,
1195
+ options: StreamingSSROptions & {
1196
+ deferredPromises?: Record<string, Promise<unknown>>;
1197
+ deferredTimeout?: number;
1198
+ } = {}
1199
+ ): Promise<Response> {
1200
+ const { metadata, routeParams, searchParams, ...restOptions } = options;
1201
+
1202
+ // SEO 메타데이터 처리
1203
+ if (metadata) {
1204
+ const seoOptions: SEOOptions = {
1205
+ routeParams,
1206
+ searchParams,
1207
+ };
1208
+
1209
+ if (Array.isArray(metadata)) {
1210
+ seoOptions.metadata = metadata;
1211
+ } else {
1212
+ seoOptions.staticMetadata = metadata as Metadata;
1213
+ }
1214
+
1215
+ const optionsWithSEO = await injectSEOIntoOptions(restOptions, seoOptions);
1216
+ return renderWithDeferredData(element, optionsWithSEO);
1217
+ }
1218
+
1219
+ return renderWithDeferredData(element, restOptions);
1220
+ }
1221
+
1105
1222
  // ========== Exports ==========
1106
1223
 
1107
1224
  export {
@@ -1109,3 +1226,7 @@ export {
1109
1226
  generateHTMLTail,
1110
1227
  generateDeferredDataScript,
1111
1228
  };
1229
+
1230
+ // Re-export SEO integration utilities
1231
+ export { resolveSEO, injectSEOIntoOptions } from "../seo/integration/ssr";
1232
+ export type { SEOOptions, SEOResult } from "../seo/integration/ssr";
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Mandu SEO Module
3
+ *
4
+ * Next.js Metadata API 패턴 기반의 SEO 지원
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * // app/layout.tsx - 정적 메타데이터
9
+ * import type { Metadata } from '@mandujs/core'
10
+ *
11
+ * export const metadata: Metadata = {
12
+ * metadataBase: new URL('https://example.com'),
13
+ * title: {
14
+ * template: '%s | My Site',
15
+ * default: 'My Site',
16
+ * },
17
+ * description: 'Welcome to my site',
18
+ * openGraph: {
19
+ * siteName: 'My Site',
20
+ * type: 'website',
21
+ * },
22
+ * }
23
+ * ```
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * // app/blog/[slug]/page.tsx - 동적 메타데이터
28
+ * import type { Metadata, MetadataParams } from '@mandujs/core'
29
+ *
30
+ * export async function generateMetadata({ params }: MetadataParams): Promise<Metadata> {
31
+ * const post = await getPost(params.slug)
32
+ * return {
33
+ * title: post.title,
34
+ * description: post.excerpt,
35
+ * openGraph: {
36
+ * title: post.title,
37
+ * images: [post.coverImage],
38
+ * },
39
+ * }
40
+ * }
41
+ * ```
42
+ */
43
+
44
+ // Types
45
+ export type {
46
+ // Main types
47
+ Metadata,
48
+ ResolvedMetadata,
49
+ MetadataItem,
50
+ MetadataParams,
51
+ GenerateMetadata,
52
+
53
+ // Title types
54
+ Title,
55
+ TemplateString,
56
+ AbsoluteString,
57
+ AbsoluteTemplateString,
58
+
59
+ // Basic types
60
+ Author,
61
+ ReferrerEnum,
62
+ ColorSchemeEnum,
63
+ Robots,
64
+ ResolvedRobots,
65
+
66
+ // Icons
67
+ Icon,
68
+ IconURL,
69
+ IconDescriptor,
70
+ Icons,
71
+ ResolvedIcons,
72
+
73
+ // Alternates
74
+ AlternateURLs,
75
+ ResolvedAlternateURLs,
76
+ Languages,
77
+
78
+ // Verification
79
+ Verification,
80
+ ResolvedVerification,
81
+
82
+ // Open Graph
83
+ OpenGraph,
84
+ OpenGraphType,
85
+ OpenGraphImage,
86
+ OpenGraphVideo,
87
+ OpenGraphAudio,
88
+ OpenGraphArticle,
89
+ OpenGraphProfile,
90
+ OpenGraphBook,
91
+ ResolvedOpenGraph,
92
+
93
+ // Twitter
94
+ Twitter,
95
+ TwitterCardType,
96
+ TwitterImage,
97
+ TwitterPlayer,
98
+ TwitterApp,
99
+ ResolvedTwitter,
100
+
101
+ // JSON-LD
102
+ JsonLd,
103
+ JsonLdType,
104
+
105
+ // Google SEO 최적화
106
+ GoogleMeta,
107
+ FormatDetection,
108
+ ResourceHint,
109
+ AppLinks,
110
+ ThemeColor,
111
+
112
+ // Metadata Routes
113
+ Sitemap,
114
+ SitemapEntry,
115
+ RobotsFile,
116
+ RobotsRule,
117
+ MetadataRoute,
118
+ } from './types'
119
+
120
+ // Resolve functions
121
+ export {
122
+ resolveMetadata,
123
+ mergeMetadata,
124
+ createDefaultMetadata,
125
+ resolveTitle,
126
+ extractTitleTemplate,
127
+ normalizeMetadataBase,
128
+ resolveUrl,
129
+ urlToString,
130
+ resolveRobots,
131
+ resolveOpenGraph,
132
+ resolveTwitter,
133
+ } from './resolve'
134
+
135
+ // Render functions
136
+ export {
137
+ renderMetadata,
138
+ renderTitle,
139
+ renderBasicMeta,
140
+ renderVerification,
141
+ renderAlternates,
142
+ renderIcons,
143
+ renderManifest,
144
+ renderOther,
145
+ renderOpenGraph,
146
+ renderTwitter,
147
+ renderJsonLd,
148
+ // JSON-LD helpers
149
+ createArticleJsonLd,
150
+ createWebSiteJsonLd,
151
+ createOrganizationJsonLd,
152
+ createBreadcrumbJsonLd,
153
+ createFAQJsonLd,
154
+ createProductJsonLd,
155
+ createLocalBusinessJsonLd,
156
+ createVideoJsonLd,
157
+ createReviewJsonLd,
158
+ createCourseJsonLd,
159
+ createEventJsonLd,
160
+ createSoftwareAppJsonLd,
161
+ // Google SEO 최적화 렌더링
162
+ renderGoogle,
163
+ renderFormatDetection,
164
+ renderThemeColor,
165
+ renderViewport,
166
+ renderResourceHints,
167
+ renderAppLinks,
168
+ // Metadata Routes rendering
169
+ renderSitemap,
170
+ renderSitemapIndex,
171
+ renderRobots,
172
+ createDefaultRobots,
173
+ createDevRobots,
174
+ } from './render'
175
+
176
+ // Metadata Routes (sitemap.ts, robots.ts)
177
+ export {
178
+ createSitemapHandler,
179
+ createRobotsHandler,
180
+ createSitemapIndexHandler,
181
+ buildMetadataRoutes,
182
+ getMetadataRouteType,
183
+ createMetadataRouteInfo,
184
+ createDynamicSitemapRouteInfo,
185
+ METADATA_ROUTE_PATTERNS,
186
+ } from './routes'
187
+
188
+ export type {
189
+ SitemapFunction,
190
+ RobotsFunction,
191
+ MetadataRouteModule,
192
+ MetadataRouteHandler,
193
+ MetadataRouteConfig,
194
+ DiscoveredRoutes,
195
+ MetadataRouteDefinition,
196
+ } from './routes'
197
+
198
+ // SSR Integration
199
+ export {
200
+ resolveSEO,
201
+ resolveSEOSync,
202
+ injectSEOIntoOptions,
203
+ layoutEntriesToMetadataItems,
204
+ metadataToProps,
205
+ generateHeadUpdateScript,
206
+ } from './integration/ssr'
207
+
208
+ export type {
209
+ SEOOptions,
210
+ SEOResult,
211
+ LayoutMetadataEntry,
212
+ SEOContextValue,
213
+ StreamingSSRWithSEOOptions,
214
+ } from './integration/ssr'
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Mandu SEO - SSR Integration
3
+ *
4
+ * Streaming SSR 파이프라인에 SEO 메타데이터 통합
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * // app/blog/[slug]/page.tsx
9
+ * import { renderWithSEO } from '@mandujs/core'
10
+ *
11
+ * export default async function handler(req: Request) {
12
+ * const slug = getSlug(req)
13
+ * const post = await getPost(slug)
14
+ *
15
+ * return renderWithSEO(<BlogPost post={post} />, {
16
+ * metadata: [
17
+ * layoutMetadata, // from layout.tsx
18
+ * {
19
+ * title: post.title,
20
+ * description: post.excerpt,
21
+ * openGraph: { title: post.title, images: [post.image] },
22
+ * },
23
+ * ],
24
+ * routeParams: { slug },
25
+ * })
26
+ * }
27
+ * ```
28
+ */
29
+
30
+ import type { ReactElement } from 'react'
31
+ import type { Metadata, MetadataItem, ResolvedMetadata } from '../types'
32
+ import { resolveMetadata, createDefaultMetadata } from '../resolve'
33
+ import { renderMetadata } from '../render'
34
+
35
+ // ============================================================================
36
+ // Types
37
+ // ============================================================================
38
+
39
+ export interface SEOOptions {
40
+ /**
41
+ * 메타데이터 항목들 (Layout 체인 순서)
42
+ * [rootLayout, ...nestedLayouts, page]
43
+ */
44
+ metadata?: MetadataItem[]
45
+
46
+ /**
47
+ * 단일 정적 메타데이터 (간단한 경우)
48
+ */
49
+ staticMetadata?: Metadata
50
+
51
+ /**
52
+ * 라우트 파라미터 (동적 메타데이터용)
53
+ */
54
+ routeParams?: Record<string, string>
55
+
56
+ /**
57
+ * 쿼리 파라미터 (동적 메타데이터용)
58
+ */
59
+ searchParams?: Record<string, string>
60
+ }
61
+
62
+ export interface SEOResult {
63
+ /** 해석된 메타데이터 */
64
+ resolved: ResolvedMetadata
65
+ /** 렌더링된 HTML 문자열 (<head> 내부) */
66
+ html: string
67
+ /** 페이지 타이틀 */
68
+ title: string | null
69
+ }
70
+
71
+ // ============================================================================
72
+ // SEO Resolution
73
+ // ============================================================================
74
+
75
+ /**
76
+ * SEO 메타데이터 해석 및 렌더링
77
+ *
78
+ * @param options - SEO 옵션
79
+ * @returns 해석된 메타데이터와 HTML
80
+ */
81
+ export async function resolveSEO(options: SEOOptions = {}): Promise<SEOResult> {
82
+ const { metadata, staticMetadata, routeParams = {}, searchParams = {} } = options
83
+
84
+ let resolved: ResolvedMetadata
85
+
86
+ if (metadata && metadata.length > 0) {
87
+ // Layout 체인에서 메타데이터 해석
88
+ resolved = await resolveMetadata(metadata, routeParams, searchParams)
89
+ } else if (staticMetadata) {
90
+ // 단일 정적 메타데이터
91
+ const base = createDefaultMetadata()
92
+ resolved = await resolveMetadata([staticMetadata], routeParams, searchParams)
93
+ } else {
94
+ // 기본 메타데이터
95
+ resolved = createDefaultMetadata()
96
+ }
97
+
98
+ // HTML 렌더링
99
+ const html = renderMetadata(resolved)
100
+ const title = resolved.title?.absolute || null
101
+
102
+ return { resolved, html, title }
103
+ }
104
+
105
+ /**
106
+ * 동기 버전 (정적 메타데이터 전용)
107
+ *
108
+ * 주의: 동기 버전은 간단한 메타데이터만 처리합니다.
109
+ * 복잡한 메타데이터(OG, Twitter 등)는 resolveSEO를 사용하세요.
110
+ */
111
+ export function resolveSEOSync(staticMetadata: Metadata): SEOResult {
112
+ const base = createDefaultMetadata()
113
+
114
+ // title 해석
115
+ let resolvedTitle: { absolute: string; template: string | null } | null = null
116
+ if (staticMetadata.title) {
117
+ if (typeof staticMetadata.title === 'string') {
118
+ resolvedTitle = { absolute: staticMetadata.title, template: null }
119
+ } else if ('absolute' in staticMetadata.title) {
120
+ resolvedTitle = {
121
+ absolute: staticMetadata.title.absolute,
122
+ template: staticMetadata.title.template || null,
123
+ }
124
+ } else if ('default' in staticMetadata.title) {
125
+ resolvedTitle = {
126
+ absolute: staticMetadata.title.default,
127
+ template: staticMetadata.title.template || null,
128
+ }
129
+ }
130
+ }
131
+
132
+ // 간단한 병합 (동기)
133
+ const resolved: ResolvedMetadata = {
134
+ ...base,
135
+ title: resolvedTitle,
136
+ description: staticMetadata.description || null,
137
+ keywords: staticMetadata.keywords
138
+ ? typeof staticMetadata.keywords === 'string'
139
+ ? staticMetadata.keywords.split(',').map(k => k.trim())
140
+ : staticMetadata.keywords
141
+ : null,
142
+ robots: staticMetadata.robots
143
+ ? typeof staticMetadata.robots === 'string'
144
+ ? { basic: staticMetadata.robots, googleBot: null }
145
+ : {
146
+ basic: [
147
+ staticMetadata.robots.index === false ? 'noindex' : 'index',
148
+ staticMetadata.robots.follow === false ? 'nofollow' : 'follow',
149
+ ].join(', '),
150
+ googleBot: null,
151
+ }
152
+ : null,
153
+ }
154
+
155
+ const html = renderMetadata(resolved)
156
+ const title = resolved.title?.absolute || null
157
+
158
+ return { resolved, html, title }
159
+ }
160
+
161
+ // ============================================================================
162
+ // Streaming SSR Integration
163
+ // ============================================================================
164
+
165
+ /**
166
+ * StreamingSSROptions에 추가할 SEO 확장 옵션
167
+ */
168
+ export interface StreamingSSRWithSEOOptions {
169
+ /** SEO 옵션 */
170
+ seo?: SEOOptions
171
+ }
172
+
173
+ /**
174
+ * Streaming SSR 옵션에 SEO headTags 주입
175
+ *
176
+ * @example
177
+ * ```typescript
178
+ * const baseOptions = { routeId: 'blog-post', isDev: true }
179
+ * const seoOptions = { metadata: [layoutMeta, pageMeta] }
180
+ *
181
+ * const options = await injectSEOIntoOptions(baseOptions, seoOptions)
182
+ * // → { ...baseOptions, title: 'Post Title', headTags: '<meta ...>' }
183
+ * ```
184
+ */
185
+ export async function injectSEOIntoOptions<T extends { title?: string; headTags?: string }>(
186
+ options: T,
187
+ seoOptions: SEOOptions
188
+ ): Promise<T & { title: string; headTags: string }> {
189
+ const { resolved, html, title } = await resolveSEO(seoOptions)
190
+
191
+ // 기존 headTags와 병합
192
+ const existingHeadTags = options.headTags || ''
193
+ const mergedHeadTags = html + (existingHeadTags ? '\n' + existingHeadTags : '')
194
+
195
+ return {
196
+ ...options,
197
+ title: title || options.title || 'Mandu App',
198
+ headTags: mergedHeadTags,
199
+ }
200
+ }
201
+
202
+ // ============================================================================
203
+ // Layout Chain Helpers
204
+ // ============================================================================
205
+
206
+ /**
207
+ * 레이아웃 체인에서 메타데이터 수집
208
+ *
209
+ * @example
210
+ * ```typescript
211
+ * // 파일 시스템 기반 라우팅에서 사용
212
+ * const chain = await collectLayoutMetadata([
213
+ * { path: 'app/layout.tsx', metadata: rootMeta },
214
+ * { path: 'app/blog/layout.tsx', metadata: blogMeta },
215
+ * { path: 'app/blog/[slug]/page.tsx', generateMetadata: generatePostMeta },
216
+ * ])
217
+ * ```
218
+ */
219
+ export interface LayoutMetadataEntry {
220
+ /** 레이아웃/페이지 경로 */
221
+ path: string
222
+ /** 정적 메타데이터 */
223
+ metadata?: Metadata
224
+ /** 동적 메타데이터 생성 함수 */
225
+ generateMetadata?: (props: {
226
+ params: Record<string, string>
227
+ searchParams: Record<string, string>
228
+ }) => Metadata | Promise<Metadata>
229
+ }
230
+
231
+ /**
232
+ * 레이아웃 엔트리를 MetadataItem 배열로 변환
233
+ */
234
+ export function layoutEntriesToMetadataItems(
235
+ entries: LayoutMetadataEntry[]
236
+ ): MetadataItem[] {
237
+ return entries.map((entry) => {
238
+ if (entry.generateMetadata) {
239
+ return entry.generateMetadata
240
+ }
241
+ return entry.metadata || null
242
+ })
243
+ }
244
+
245
+ // ============================================================================
246
+ // React Component Integration
247
+ // ============================================================================
248
+
249
+ /**
250
+ * SEO Context 타입 (React Context 사용 시)
251
+ */
252
+ export interface SEOContextValue {
253
+ metadata: ResolvedMetadata
254
+ updateMetadata: (partial: Partial<Metadata>) => void
255
+ }
256
+
257
+ /**
258
+ * 메타데이터를 React 컴포넌트에서 사용할 수 있는 props로 변환
259
+ */
260
+ export function metadataToProps(resolved: ResolvedMetadata): {
261
+ title: string | null
262
+ description: string | null
263
+ ogImage: string | null
264
+ ogUrl: string | null
265
+ } {
266
+ return {
267
+ title: resolved.title?.absolute || null,
268
+ description: resolved.description || null,
269
+ ogImage: resolved.openGraph?.images?.[0]?.url
270
+ ? typeof resolved.openGraph.images[0].url === 'string'
271
+ ? resolved.openGraph.images[0].url
272
+ : resolved.openGraph.images[0].url.toString()
273
+ : null,
274
+ ogUrl: resolved.openGraph?.url?.href || null,
275
+ }
276
+ }
277
+
278
+ // ============================================================================
279
+ // Head Component Support
280
+ // ============================================================================
281
+
282
+ /**
283
+ * 동적 Head 업데이트를 위한 스크립트 생성
284
+ * (클라이언트에서 document.title 등 업데이트)
285
+ */
286
+ export function generateHeadUpdateScript(metadata: ResolvedMetadata): string {
287
+ const updates: string[] = []
288
+
289
+ // Title 업데이트
290
+ if (metadata.title?.absolute) {
291
+ updates.push(`document.title = ${JSON.stringify(metadata.title.absolute)};`)
292
+ }
293
+
294
+ // Description 업데이트
295
+ if (metadata.description) {
296
+ updates.push(`
297
+ (function() {
298
+ var meta = document.querySelector('meta[name="description"]');
299
+ if (meta) meta.content = ${JSON.stringify(metadata.description)};
300
+ })();
301
+ `)
302
+ }
303
+
304
+ if (updates.length === 0) return ''
305
+
306
+ return `<script>(function(){${updates.join('')}})()</script>`
307
+ }