@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.
- package/README.ko.md +27 -0
- package/README.md +21 -5
- package/package.json +1 -1
- package/src/config/index.ts +1 -0
- package/src/config/mandu.ts +60 -0
- package/src/contract/client-safe.test.ts +42 -0
- package/src/contract/client-safe.ts +114 -0
- package/src/contract/client.ts +12 -11
- package/src/contract/handler.ts +10 -11
- package/src/contract/index.ts +25 -16
- package/src/contract/registry.test.ts +206 -0
- package/src/contract/registry.ts +568 -0
- package/src/contract/schema.ts +48 -12
- package/src/contract/types.ts +58 -35
- package/src/contract/validator.ts +32 -17
- package/src/filling/context.ts +103 -0
- package/src/generator/templates.ts +70 -17
- package/src/guard/analyzer.ts +9 -4
- package/src/guard/check.ts +66 -30
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -0
- package/src/guard/presets/index.ts +193 -60
- package/src/guard/rules.ts +12 -6
- package/src/guard/statistics.ts +6 -0
- package/src/guard/suggestions.ts +9 -2
- package/src/guard/types.ts +11 -1
- package/src/guard/validator.ts +160 -9
- package/src/guard/watcher.ts +2 -0
- package/src/index.ts +8 -1
- package/src/runtime/index.ts +1 -0
- package/src/runtime/streaming-ssr.ts +123 -2
- package/src/seo/index.ts +214 -0
- package/src/seo/integration/ssr.ts +307 -0
- package/src/seo/render/basic.ts +427 -0
- package/src/seo/render/index.ts +143 -0
- package/src/seo/render/jsonld.ts +539 -0
- package/src/seo/render/opengraph.ts +191 -0
- package/src/seo/render/robots.ts +116 -0
- package/src/seo/render/sitemap.ts +137 -0
- package/src/seo/render/twitter.ts +126 -0
- package/src/seo/resolve/index.ts +353 -0
- package/src/seo/resolve/opengraph.ts +143 -0
- package/src/seo/resolve/robots.ts +73 -0
- package/src/seo/resolve/title.ts +94 -0
- package/src/seo/resolve/twitter.ts +73 -0
- package/src/seo/resolve/url.ts +97 -0
- package/src/seo/routes/index.ts +290 -0
- package/src/seo/types.ts +575 -0
- 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
|
*/
|
package/src/runtime/index.ts
CHANGED
|
@@ -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";
|
package/src/seo/index.ts
ADDED
|
@@ -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
|
+
}
|