/**
* Mandu useHead / useSeoMeta
* SSR + 클라이언트에서
태그를 선언적으로 관리
*/
import { useEffect, useRef } from "react";
// ========== Types ==========
export interface HeadTag {
tag: "title" | "meta" | "link" | "script" | "style";
attrs?: Record;
children?: string;
}
export interface HeadConfig {
title?: string;
meta?: Array<{ name?: string; property?: string; content: string; httpEquiv?: string }>;
link?: Array<{ rel: string; href: string; [key: string]: string }>;
}
export interface SeoMetaConfig {
title?: string;
description?: string;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
ogUrl?: string;
ogType?: string;
twitterCard?: "summary" | "summary_large_image" | "app" | "player";
twitterTitle?: string;
twitterDescription?: string;
twitterImage?: string;
canonical?: string;
robots?: string;
}
// ========== SSR Head Collection ==========
/** SSR 렌더링 중 수집된 head 태그 */
let ssrHeadTags: string[] = [];
/** SSR 렌더링 전 초기화 */
export function resetSSRHead(): void {
ssrHeadTags = [];
}
/** SSR 렌더링 후 수집된 head 태그 반환 */
export function getSSRHeadTags(): string {
return ssrHeadTags.join("\n");
}
function pushSSRTag(html: string): void {
if (typeof window === "undefined") {
ssrHeadTags.push(html);
}
}
// ========== Client-side DOM Management ==========
const managedElements = new Set();
function updateClientHead(tags: HeadTag[]): void {
if (typeof document === "undefined") return;
// 이전 managed 태그 제거
for (const el of managedElements) {
el.remove();
}
managedElements.clear();
// 새 태그 삽입
for (const tag of tags) {
if (tag.tag === "title") {
document.title = tag.children ?? "";
continue;
}
const el = document.createElement(tag.tag);
if (tag.attrs) {
for (const [key, value] of Object.entries(tag.attrs)) {
el.setAttribute(key, value);
}
}
if (tag.children) {
el.textContent = tag.children;
}
el.setAttribute("data-mandu-head", "");
document.head.appendChild(el);
managedElements.add(el);
}
}
// ========== Hooks ==========
/**
* 선언적 태그 관리
*
* @example
* ```tsx
* useHead({
* title: "My Page",
* meta: [{ name: "description", content: "Page description" }],
* link: [{ rel: "canonical", href: "https://example.com/page" }],
* });
* ```
*/
export function useHead(config: HeadConfig): void {
const tags: HeadTag[] = [];
if (config.title) {
tags.push({ tag: "title", children: config.title });
// SSR: title은 별도 처리 (renderToHTML의 title 옵션 대신)
pushSSRTag(`${escapeHtml(config.title)}`);
}
if (config.meta) {
for (const meta of config.meta) {
const attrs: Record = { content: meta.content };
if (meta.name) attrs.name = meta.name;
if (meta.property) attrs.property = meta.property;
if (meta.httpEquiv) attrs["http-equiv"] = meta.httpEquiv;
tags.push({ tag: "meta", attrs });
pushSSRTag(` `${k}="${escapeHtml(v)}"`).join(" ")}>`);
}
}
if (config.link) {
for (const link of config.link) {
tags.push({ tag: "link", attrs: link });
pushSSRTag(` `${k}="${escapeHtml(v)}"`).join(" ")}>`);
}
}
// 클라이언트: DOM 업데이트
const prevTagsRef = useRef([]);
useEffect(() => {
prevTagsRef.current = tags;
updateClientHead(tags);
return () => {
// unmount 시 정리
for (const el of managedElements) {
el.remove();
}
managedElements.clear();
};
}, [JSON.stringify(config)]);
}
/**
* SEO 메타 태그 전용 — 간편 API
*
* @example
* ```tsx
* useSeoMeta({
* title: "Blog Post Title",
* description: "Post excerpt...",
* ogTitle: "Blog Post Title",
* ogImage: "/images/cover.jpg",
* twitterCard: "summary_large_image",
* });
* ```
*/
export function useSeoMeta(config: SeoMetaConfig): void {
const headConfig: HeadConfig = {
meta: [],
link: [],
};
if (config.title) headConfig.title = config.title;
if (config.description) headConfig.meta!.push({ name: "description", content: config.description });
if (config.robots) headConfig.meta!.push({ name: "robots", content: config.robots });
// Open Graph
if (config.ogTitle) headConfig.meta!.push({ property: "og:title", content: config.ogTitle });
if (config.ogDescription) headConfig.meta!.push({ property: "og:description", content: config.ogDescription });
if (config.ogImage) headConfig.meta!.push({ property: "og:image", content: config.ogImage });
if (config.ogUrl) headConfig.meta!.push({ property: "og:url", content: config.ogUrl });
if (config.ogType) headConfig.meta!.push({ property: "og:type", content: config.ogType });
// Twitter
if (config.twitterCard) headConfig.meta!.push({ name: "twitter:card", content: config.twitterCard });
if (config.twitterTitle) headConfig.meta!.push({ name: "twitter:title", content: config.twitterTitle });
if (config.twitterDescription) headConfig.meta!.push({ name: "twitter:description", content: config.twitterDescription });
if (config.twitterImage) headConfig.meta!.push({ name: "twitter:image", content: config.twitterImage });
// Canonical
if (config.canonical) headConfig.link!.push({ rel: "canonical", href: config.canonical });
useHead(headConfig);
}
function escapeHtml(str: string): string {
return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
}