@mandujs/core 0.13.0 → 0.13.2
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/README.ko.md +4 -4
- package/README.md +653 -653
- package/package.json +1 -1
- package/src/bundler/build.ts +91 -91
- package/src/bundler/css.ts +302 -302
- package/src/client/Link.tsx +227 -227
- package/src/client/globals.ts +44 -44
- package/src/client/hooks.ts +267 -267
- package/src/client/index.ts +5 -5
- package/src/client/island.ts +8 -8
- package/src/client/router.ts +435 -435
- package/src/client/runtime.ts +23 -23
- package/src/client/serialize.ts +404 -404
- package/src/client/window-state.ts +101 -101
- package/src/config/mandu.ts +9 -0
- package/src/config/validate.ts +12 -0
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/content-layer.ts +314 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/data-store.ts +245 -245
- package/src/content/digest.ts +133 -133
- package/src/content/index.ts +164 -164
- package/src/content/loader-context.ts +172 -172
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +169 -169
- package/src/content/loaders/glob.ts +252 -252
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/client.ts +16 -16
- package/src/contract/define.ts +459 -459
- package/src/contract/handler.ts +10 -10
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +404 -404
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/registry.ts +568 -568
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/contract/validator.ts +32 -32
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +465 -465
- package/src/devtools/client/catchers/error-catcher.ts +327 -327
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/catchers/network-proxy.ts +363 -363
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/kitchen-root.tsx +362 -362
- package/src/devtools/client/components/mandu-character.tsx +241 -241
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
- package/src/devtools/client/components/panel/index.ts +32 -32
- package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
- package/src/devtools/client/components/panel/network-panel.tsx +292 -292
- package/src/devtools/client/components/panel/panel-container.tsx +259 -259
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/client/state-manager.ts +478 -478
- package/src/devtools/design-tokens.ts +263 -263
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +266 -266
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +444 -444
- package/src/devtools/types.ts +319 -319
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +222 -222
- package/src/devtools/worker/worker-manager.ts +409 -409
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +46 -46
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/auth.ts +308 -308
- package/src/filling/context.ts +24 -1
- package/src/filling/deps.ts +238 -238
- package/src/filling/index.ts +4 -0
- package/src/filling/sse-catchup.test.ts +56 -0
- package/src/filling/sse-catchup.ts +67 -0
- package/src/filling/sse.test.ts +168 -0
- package/src/filling/sse.ts +162 -0
- package/src/generator/index.ts +3 -3
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/presets/index.ts +291 -291
- package/src/guard/reporter.ts +445 -445
- package/src/guard/rules.ts +12 -12
- package/src/guard/statistics.ts +578 -578
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/types.ts +348 -348
- package/src/guard/validator.ts +834 -834
- package/src/guard/watcher.ts +404 -404
- package/src/index.ts +6 -1
- package/src/intent/index.ts +310 -310
- package/src/island/index.ts +304 -304
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/plugins/index.ts +38 -38
- package/src/plugins/registry.ts +377 -377
- package/src/plugins/types.ts +363 -363
- package/src/report/index.ts +1 -1
- package/src/router/fs-patterns.ts +387 -387
- package/src/router/fs-scanner.ts +497 -497
- package/src/runtime/boundary.tsx +232 -232
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/escape.ts +44 -0
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/logger.ts +677 -677
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/router.ts +105 -105
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +257 -0
- package/src/runtime/session-key.ts +328 -328
- package/src/runtime/ssr.ts +16 -21
- package/src/runtime/streaming-ssr.ts +24 -33
- package/src/runtime/trace.ts +144 -144
- package/src/seo/index.ts +214 -214
- package/src/seo/integration/ssr.ts +307 -307
- package/src/seo/render/basic.ts +427 -427
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +191 -191
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +126 -126
- package/src/seo/resolve/index.ts +353 -353
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +97 -97
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +575 -575
- package/src/slot/validator.ts +39 -39
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
- package/src/utils/bun.ts +8 -8
- package/src/utils/lru-cache.ts +75 -75
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
|
@@ -1,307 +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
|
-
}
|
|
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
|
+
}
|