/** * Mandu SEO - Metadata Resolution * * Layout 체인에서 메타데이터를 수집하고 병합 */ import type { Metadata, ResolvedMetadata, MetadataItem, MetadataParams, Author, Icons, IconDescriptor, ResolvedIcons, Verification, ResolvedVerification, JsonLd, ThemeColor, } from '../types' import { resolveTitle, extractTitleTemplate } from './title' import { normalizeMetadataBase, resolveUrl } from './url' import { resolveRobots } from './robots' import { resolveOpenGraph } from './opengraph' import { resolveTwitter } from './twitter' /** * 기본 메타데이터 생성 */ export function createDefaultMetadata(): ResolvedMetadata { return { metadataBase: null, title: null, description: null, applicationName: null, authors: null, generator: 'Mandu', keywords: null, referrer: null, creator: null, publisher: null, robots: null, alternates: null, icons: null, manifest: null, openGraph: null, twitter: null, verification: null, category: null, classification: null, jsonLd: null, // Google SEO 최적화 google: null, formatDetection: null, resourceHints: null, themeColor: null, viewport: null, appLinks: null, other: null, } } /** * Author 배열로 정규화 */ function resolveAuthors(authors: Author | Author[] | null | undefined): Author[] | null { if (!authors) return null return Array.isArray(authors) ? authors : [authors] } /** * Keywords 배열로 정규화 */ function resolveKeywords(keywords: string | string[] | null | undefined): string[] | null { if (!keywords) return null if (typeof keywords === 'string') { return keywords.split(',').map(k => k.trim()) } return keywords } /** * Icons 해석 */ function resolveIcons(icons: Metadata['icons'], metadataBase: URL | null): ResolvedIcons | null { if (!icons) return null const result: ResolvedIcons = { icon: [], apple: [], shortcut: [], other: [], } // 단순 문자열 URL if (typeof icons === 'string') { const url = resolveUrl(icons, metadataBase) if (url) { result.icon.push({ url: url.href }) } return result } // 배열 if (Array.isArray(icons)) { for (const icon of icons) { const descriptor = typeof icon === 'string' ? { url: icon } : icon instanceof URL ? { url: icon.href } : { ...icon, url: typeof icon.url === 'string' ? icon.url : icon.url.href } const resolvedUrl = resolveUrl(descriptor.url, metadataBase) if (resolvedUrl) { result.icon.push({ ...descriptor, url: resolvedUrl.href }) } } return result } // Icons 객체 const iconsObj = icons as Icons const processIconArray = ( items: typeof iconsObj.icon, target: IconDescriptor[] ) => { if (!items) return const arr = Array.isArray(items) ? items : [items] for (const item of arr) { const descriptor = typeof item === 'string' ? { url: item } : item instanceof URL ? { url: item.href } : { ...item, url: typeof item.url === 'string' ? item.url : item.url.href } const resolvedUrl = resolveUrl(descriptor.url, metadataBase) if (resolvedUrl) { target.push({ ...descriptor, url: resolvedUrl.href }) } } } processIconArray(iconsObj.icon, result.icon) processIconArray(iconsObj.apple, result.apple) processIconArray(iconsObj.shortcut, result.shortcut) if (iconsObj.other) { const others = Array.isArray(iconsObj.other) ? iconsObj.other : [iconsObj.other] for (const other of others) { const resolvedUrl = resolveUrl(other.url, metadataBase) if (resolvedUrl) { result.other.push({ ...other, url: resolvedUrl.href }) } } } return result } /** * Verification 해석 */ function resolveVerification(verification: Verification | null | undefined): ResolvedVerification | null { if (!verification) return null const toArray = (val: string | string[] | undefined): string[] | null => { if (!val) return null return Array.isArray(val) ? val : [val] } return { google: toArray(verification.google), yahoo: toArray(verification.yahoo), yandex: toArray(verification.yandex), me: toArray(verification.me), other: verification.other ? Object.fromEntries( Object.entries(verification.other).map(([k, v]) => [k, toArray(v)!]) ) : null, } } /** * JSON-LD 배열로 정규화 */ function resolveJsonLd(jsonLd: JsonLd | JsonLd[] | null | undefined): JsonLd[] | null { if (!jsonLd) return null const arr = Array.isArray(jsonLd) ? jsonLd : [jsonLd] // @context 기본값 추가 return arr.map(item => ({ '@context': 'https://schema.org', ...item, })) } /** * Theme Color 배열로 정규화 */ function resolveThemeColor( themeColor: string | ThemeColor | ThemeColor[] | null | undefined ): ThemeColor[] | null { if (!themeColor) return null if (typeof themeColor === 'string') { return [{ color: themeColor }] } return Array.isArray(themeColor) ? themeColor : [themeColor] } /** * Viewport 문자열로 정규화 */ function resolveViewport( viewport: Metadata['viewport'] ): string | null { if (!viewport) return null if (typeof viewport === 'string') return viewport const parts: string[] = [] if (viewport.width !== undefined) { parts.push(`width=${viewport.width}`) } if (viewport.height !== undefined) { parts.push(`height=${viewport.height}`) } if (viewport.initialScale !== undefined) { parts.push(`initial-scale=${viewport.initialScale}`) } if (viewport.minimumScale !== undefined) { parts.push(`minimum-scale=${viewport.minimumScale}`) } if (viewport.maximumScale !== undefined) { parts.push(`maximum-scale=${viewport.maximumScale}`) } if (viewport.userScalable !== undefined) { parts.push(`user-scalable=${viewport.userScalable ? 'yes' : 'no'}`) } if (viewport.viewportFit !== undefined) { parts.push(`viewport-fit=${viewport.viewportFit}`) } return parts.length > 0 ? parts.join(', ') : null } /** * 두 메타데이터 객체 병합 */ export function mergeMetadata( parent: ResolvedMetadata, child: Metadata ): ResolvedMetadata { const metadataBase = normalizeMetadataBase(child.metadataBase) || parent.metadataBase // 부모의 title template 추출 const parentTemplate = parent.title?.template || null return { metadataBase, title: resolveTitle(child.title, parentTemplate) ?? parent.title, description: child.description ?? parent.description, applicationName: child.applicationName ?? parent.applicationName, authors: resolveAuthors(child.authors) ?? parent.authors, generator: child.generator ?? parent.generator, keywords: resolveKeywords(child.keywords) ?? parent.keywords, referrer: child.referrer ?? parent.referrer, creator: child.creator ?? parent.creator, publisher: child.publisher ?? parent.publisher, robots: resolveRobots(child.robots) ?? parent.robots, alternates: parent.alternates, // TODO: resolve alternates icons: resolveIcons(child.icons, metadataBase) ?? parent.icons, manifest: resolveUrl(child.manifest, metadataBase) ?? parent.manifest, openGraph: resolveOpenGraph(child.openGraph, metadataBase) ?? parent.openGraph, twitter: resolveTwitter(child.twitter, metadataBase) ?? parent.twitter, verification: resolveVerification(child.verification) ?? parent.verification, category: child.category ?? parent.category, classification: child.classification ?? parent.classification, jsonLd: resolveJsonLd(child.jsonLd) ?? parent.jsonLd, // Google SEO 최적화 google: child.google ?? parent.google, formatDetection: child.formatDetection ?? parent.formatDetection, resourceHints: child.resourceHints ?? parent.resourceHints, themeColor: resolveThemeColor(child.themeColor) ?? parent.themeColor, viewport: resolveViewport(child.viewport) ?? parent.viewport, appLinks: child.appLinks ?? parent.appLinks, other: child.other ?? parent.other, } } /** * 메타데이터 항목 해석 (정적/동적) */ async function resolveMetadataItem( item: MetadataItem, params: MetadataParams, parentPromise: Promise ): Promise { if (!item) return null // 정적 메타데이터 if (typeof item !== 'function') { return item } // 동적 메타데이터 (generateMetadata) return await item(params, parentPromise) } /** * 전체 메타데이터 해석 파이프라인 * * Layout 체인을 순회하며 메타데이터를 병합 * * @param metadataItems - [rootLayout, ...nestedLayouts, page] * @param params - URL 파라미터 * @param searchParams - 쿼리 파라미터 */ export async function resolveMetadata( metadataItems: MetadataItem[], params: Record = {}, searchParams: Record = {} ): Promise { let resolved = createDefaultMetadata() for (const item of metadataItems) { // 현재 resolved를 Promise로 래핑 (generateMetadata의 parent 파라미터용) const parentPromise = Promise.resolve(resolved) const metadata = await resolveMetadataItem( item, { params, searchParams }, parentPromise ) if (metadata) { resolved = mergeMetadata(resolved, metadata) } } return resolved } // Re-export sub-modules export { resolveTitle, extractTitleTemplate } from './title' export { normalizeMetadataBase, resolveUrl, urlToString } from './url' export { resolveRobots } from './robots' export { resolveOpenGraph } from './opengraph' export { resolveTwitter } from './twitter'