/** * Mandu SEO - SSR Integration * * Streaming SSR 파이프라인에 SEO 메타데이터 통합 * * @example * ```typescript * // app/blog/[slug]/page.tsx * import { renderWithSEO } from '@mandujs/core' * * export default async function handler(req: Request) { * const slug = getSlug(req) * const post = await getPost(slug) * * return renderWithSEO(, { * metadata: [ * layoutMetadata, // from layout.tsx * { * title: post.title, * description: post.excerpt, * openGraph: { title: post.title, images: [post.image] }, * }, * ], * routeParams: { slug }, * }) * } * ``` */ import type { Metadata, MetadataItem, ResolvedMetadata } from '../types' import { resolveMetadata, createDefaultMetadata } from '../resolve' import { renderMetadata } from '../render' // ============================================================================ // Types // ============================================================================ export interface SEOOptions { /** * 메타데이터 항목들 (Layout 체인 순서) * [rootLayout, ...nestedLayouts, page] */ metadata?: MetadataItem[] /** * 단일 정적 메타데이터 (간단한 경우) */ staticMetadata?: Metadata /** * 라우트 파라미터 (동적 메타데이터용) */ routeParams?: Record /** * 쿼리 파라미터 (동적 메타데이터용) */ searchParams?: Record } export interface SEOResult { /** 해석된 메타데이터 */ resolved: ResolvedMetadata /** 렌더링된 HTML 문자열 ( 내부) */ html: string /** 페이지 타이틀 */ title: string | null } // ============================================================================ // SEO Resolution // ============================================================================ /** * SEO 메타데이터 해석 및 렌더링 * * @param options - SEO 옵션 * @returns 해석된 메타데이터와 HTML */ export async function resolveSEO(options: SEOOptions = {}): Promise { const { metadata, staticMetadata, routeParams = {}, searchParams = {} } = options let resolved: ResolvedMetadata if (metadata && metadata.length > 0) { // Layout 체인에서 메타데이터 해석 resolved = await resolveMetadata(metadata, routeParams, searchParams) } else if (staticMetadata) { // 단일 정적 메타데이터 const _base = createDefaultMetadata() resolved = await resolveMetadata([staticMetadata], routeParams, searchParams) } else { // 기본 메타데이터 resolved = createDefaultMetadata() } // HTML 렌더링 const html = renderMetadata(resolved) const title = resolved.title?.absolute || null return { resolved, html, title } } /** * 동기 버전 (정적 메타데이터 전용) * * 주의: 동기 버전은 간단한 메타데이터만 처리합니다. * 복잡한 메타데이터(OG, Twitter 등)는 resolveSEO를 사용하세요. */ export function resolveSEOSync(staticMetadata: Metadata): SEOResult { const base = createDefaultMetadata() // title 해석 let resolvedTitle: { absolute: string; template: string | null } | null = null if (staticMetadata.title) { if (typeof staticMetadata.title === 'string') { resolvedTitle = { absolute: staticMetadata.title, template: null } } else if ('absolute' in staticMetadata.title) { resolvedTitle = { absolute: staticMetadata.title.absolute ?? '', template: staticMetadata.title.template ?? null, } } else if ('default' in staticMetadata.title) { resolvedTitle = { absolute: staticMetadata.title.default ?? '', template: staticMetadata.title.template ?? null, } } } // 간단한 병합 (동기) const resolved: ResolvedMetadata = { ...base, title: resolvedTitle, description: staticMetadata.description || null, keywords: staticMetadata.keywords ? typeof staticMetadata.keywords === 'string' ? staticMetadata.keywords.split(',').map(k => k.trim()) : staticMetadata.keywords : null, robots: staticMetadata.robots ? typeof staticMetadata.robots === 'string' ? { basic: staticMetadata.robots, googleBot: null } : { basic: [ staticMetadata.robots.index === false ? 'noindex' : 'index', staticMetadata.robots.follow === false ? 'nofollow' : 'follow', ].join(', '), googleBot: null, } : null, } const html = renderMetadata(resolved) const title = resolved.title?.absolute || null return { resolved, html, title } } // ============================================================================ // Streaming SSR Integration // ============================================================================ /** * StreamingSSROptions에 추가할 SEO 확장 옵션 */ export interface StreamingSSRWithSEOOptions { /** SEO 옵션 */ seo?: SEOOptions } /** * Streaming SSR 옵션에 SEO headTags 주입 * * @example * ```typescript * const baseOptions = { routeId: 'blog-post', isDev: true } * const seoOptions = { metadata: [layoutMeta, pageMeta] } * * const options = await injectSEOIntoOptions(baseOptions, seoOptions) * // → { ...baseOptions, title: 'Post Title', headTags: '' } * ``` */ export async function injectSEOIntoOptions( options: T, seoOptions: SEOOptions ): Promise { const { resolved: _resolved, html, title } = await resolveSEO(seoOptions) // 기존 headTags와 병합 const existingHeadTags = options.headTags || '' const mergedHeadTags = html + (existingHeadTags ? '\n' + existingHeadTags : '') return { ...options, title: title || options.title || 'Mandu App', headTags: mergedHeadTags, } } // ============================================================================ // Layout Chain Helpers // ============================================================================ /** * 레이아웃 체인에서 메타데이터 수집 * * @example * ```typescript * // 파일 시스템 기반 라우팅에서 사용 * const chain = await collectLayoutMetadata([ * { path: 'app/layout.tsx', metadata: rootMeta }, * { path: 'app/blog/layout.tsx', metadata: blogMeta }, * { path: 'app/blog/[slug]/page.tsx', generateMetadata: generatePostMeta }, * ]) * ``` */ export interface LayoutMetadataEntry { /** 레이아웃/페이지 경로 */ path: string /** 정적 메타데이터 */ metadata?: Metadata /** 동적 메타데이터 생성 함수 */ generateMetadata?: (props: { params: Record searchParams: Record }) => Metadata | Promise } /** * 레이아웃 엔트리를 MetadataItem 배열로 변환 */ export function layoutEntriesToMetadataItems( entries: LayoutMetadataEntry[] ): MetadataItem[] { return entries.map((entry) => { if (entry.generateMetadata) { return entry.generateMetadata } return entry.metadata || null }) } // ============================================================================ // React Component Integration // ============================================================================ /** * SEO Context 타입 (React Context 사용 시) */ export interface SEOContextValue { metadata: ResolvedMetadata updateMetadata: (partial: Partial) => void } /** * 메타데이터를 React 컴포넌트에서 사용할 수 있는 props로 변환 */ export function metadataToProps(resolved: ResolvedMetadata): { title: string | null description: string | null ogImage: string | null ogUrl: string | null } { return { title: resolved.title?.absolute || null, description: resolved.description || null, ogImage: resolved.openGraph?.images?.[0]?.url ? typeof resolved.openGraph.images[0].url === 'string' ? resolved.openGraph.images[0].url : resolved.openGraph.images[0].url.toString() : null, ogUrl: resolved.openGraph?.url?.href || null, } } // ============================================================================ // Head Component Support // ============================================================================ /** * 동적 Head 업데이트를 위한 스크립트 생성 * (클라이언트에서 document.title 등 업데이트) */ export function generateHeadUpdateScript(metadata: ResolvedMetadata): string { const updates: string[] = [] // Title 업데이트 if (metadata.title?.absolute) { updates.push(`document.title = ${JSON.stringify(metadata.title.absolute)};`) } // Description 업데이트 if (metadata.description) { updates.push(` (function() { var meta = document.querySelector('meta[name="description"]'); if (meta) meta.content = ${JSON.stringify(metadata.description)}; })(); `) } if (updates.length === 0) return '' return `` }