@roottale/cms-renderer-next 0.25.0 → 0.29.0
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/CHANGELOG.md +37 -0
- package/dist/{chunk-RJGHU6IL.js → chunk-HPERSTZ2.js} +19 -3
- package/dist/chunk-HPERSTZ2.js.map +1 -0
- package/dist/{chunk-QWBFHYB6.js → chunk-O6KHDNGH.js} +20 -2
- package/dist/chunk-O6KHDNGH.js.map +1 -0
- package/dist/routes.d.ts +19 -3
- package/dist/routes.js +61 -13
- package/dist/routes.js.map +1 -1
- package/dist/server.js +2 -2
- package/dist/templates.d.ts +85 -6
- package/dist/templates.js +32 -2
- package/dist/templates.js.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-QWBFHYB6.js.map +0 -1
- package/dist/chunk-RJGHU6IL.js.map +0 -1
package/dist/routes.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/routes.ts"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/routes` — Next.js App Router 통합 라우트 팩토리.\n *\n * 외부 고객사 사이트가 매번 직접 작성하던 공통 라우트(RSS feed / sitemap /\n * revalidate webhook)를 1줄 팩토리로 표준화. 블로그 조회와 *동일한* 단일\n * customer API key(`ROOTTALE_API_KEY`)를 사용한다.\n *\n * 설계 원칙:\n * - feed/sitemap 은 `fetchPosts` 실패를 swallow → 라우트가 *항상 200* (빌드 시\n * 정적 생성이 throw 해서 404 나는 사고 방지).\n * - revalidate 는 admin(mysite.roottale.com)에서 글/카테고리/미디어 변경 시 보내는\n * 서명 webhook 을 검증하고, 본문 paths + 파생 경로(feed·sitemap·블로그 인덱스)를\n * 모두 revalidate → 각 사이트에 자동 반영.\n * - `next` 에 직접 의존하지 않음: revalidate 콜백(`revalidatePath`)을 주입받는다.\n *\n * 사용 예는 roottale-docs `services/external-site-integration.md` 참조.\n */\n\nimport {\n CMS_CLIENT_VERSION,\n fetchPost,\n fetchPosts,\n generateRssXml,\n rssItemsFromPosts,\n type CmsPostContent,\n} from \"@roottale/cms-client/server\";\nimport {\n verifyRootTaleWebhook,\n type VerifyRootTaleWebhookResult,\n} from \"@roottale/cms-client/webhook\";\nimport { collectCategories } from \"./blog-filter.js\";\n\nexport interface RootTaleSiteConfig {\n /** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만(절대 브라우저 금지). */\n apiKey: string;\n /** API base 오버라이드. 기본 `https://api.roottale.com`. */\n apiBase?: string;\n /** 사이트 절대 URL (뒤 슬래시 없이). 예: `https://kjmtax.roottale.app`. */\n siteUrl: string;\n /** 피드/사이트 제목. */\n title: string;\n description?: string;\n /** 기본 \"ko-KR\". */\n language?: string;\n /** 블로그 base path. 기본 \"/blog\". */\n blogBasePath?: string;\n}\n\nfunction trimSlash(u: string): string {\n return u.replace(/\\/+$/, \"\");\n}\n\nasync function safeFetchPosts(\n config: RootTaleSiteConfig,\n limit: number,\n): Promise<CmsPostContent[]> {\n try {\n const page = await fetchPosts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n type: \"post\",\n limit,\n });\n return page.items;\n } catch (err) {\n // 빌드/런타임 fetch 실패 시 빈 목록 — 라우트는 항상 정상 응답(404 방지).\n console.error(\"[@roottale/cms-renderer-next/routes] fetchPosts 실패:\", err);\n return [];\n }\n}\n\n/**\n * `app/feed.xml/route.ts` 용 GET 핸들러. RSS 2.0.\n *\n * ```ts\n * import { createFeedRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const dynamic = \"force-dynamic\";\n * export const GET = createFeedRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * siteUrl: \"https://example.com\",\n * title: \"Example 블로그\",\n * description: \"...\",\n * });\n * ```\n */\nexport function createFeedRoute(\n config: RootTaleSiteConfig,\n): () => Promise<Response> {\n return async function GET(): Promise<Response> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const posts = await safeFetchPosts(config, 50);\n const xml = generateRssXml({\n title: config.title,\n link: `${siteUrl}${blogBase}`,\n feedUrl: `${siteUrl}/feed.xml`,\n description: config.description,\n language: config.language,\n items: rssItemsFromPosts(posts, {\n postUrl: (p) => `${siteUrl}${blogBase}/${p.slug}`,\n }),\n });\n return new Response(xml, {\n headers: {\n \"content-type\": \"application/rss+xml; charset=utf-8\",\n \"cache-control\": \"public, max-age=1800, s-maxage=1800\",\n },\n });\n };\n}\n\n/** Next `MetadataRoute.Sitemap[number]` 와 구조 호환(타입 결합 회피). */\nexport interface SitemapEntry {\n url: string;\n lastModified?: string | Date;\n changeFrequency?:\n | \"always\"\n | \"hourly\"\n | \"daily\"\n | \"weekly\"\n | \"monthly\"\n | \"yearly\"\n | \"never\";\n priority?: number;\n}\n\n/**\n * `app/sitemap.ts` default export. 정적 경로 + 글 경로를 합친다.\n *\n * ```ts\n * import { createSitemap } from \"@roottale/cms-renderer-next/routes\";\n * export default createSitemap(\n * { apiKey: process.env.ROOTTALE_API_KEY!, siteUrl: \"https://example.com\", title: \"...\" },\n * [{ url: \"https://example.com\", changeFrequency: \"weekly\", priority: 1 }],\n * );\n * ```\n */\nexport function createSitemap(\n config: RootTaleSiteConfig,\n staticRoutes: SitemapEntry[] = [],\n): () => Promise<SitemapEntry[]> {\n return async function sitemap(): Promise<SitemapEntry[]> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const posts = await safeFetchPosts(config, 200);\n const postEntries: SitemapEntry[] = posts.map((p) => ({\n url: `${siteUrl}${blogBase}/${p.slug}`,\n lastModified: p.updatedAt ?? p.publishedAt ?? undefined,\n changeFrequency: \"weekly\",\n priority: 0.6,\n }));\n const categories = collectCategories(posts);\n const categoryEntries: SitemapEntry[] = categories.length\n ? [\n {\n url: `${siteUrl}${blogBase}/categories`,\n changeFrequency: \"weekly\",\n priority: 0.5,\n },\n ...categories.map((category) => ({\n url: `${siteUrl}${blogBase}/categories/${category.slug}`,\n changeFrequency: \"weekly\" as const,\n priority: 0.5,\n })),\n ]\n : [];\n return [...staticRoutes, ...postEntries, ...categoryEntries];\n };\n}\n\n// ── llms.txt (AEO/GEO — AI 검색·생성엔진용 마크다운 인덱스) ──────────────────\n\n/** llms.txt 정적 섹션의 링크 1개. */\nexport interface LlmsTxtLink {\n title: string;\n url: string;\n /** 링크 뒤 \": {note}\" 로 붙는 한 줄 설명 (선택). */\n note?: string;\n}\n\n/** llms.txt 의 정적 페이지 링크 그룹 — 블로그 목록 앞에 출력. */\nexport interface LlmsTxtSection {\n title: string;\n links: LlmsTxtLink[];\n}\n\nexport type LlmsTxtRouteConfig = RootTaleSiteConfig & {\n /** (선택) 서비스 소개 등 정적 페이지 링크 그룹. */\n sections?: LlmsTxtSection[];\n};\n\n/** 마크다운 링크 텍스트를 깨뜨리는 문자([, ], (, )) 제거. */\nfunction mdLinkSafe(text: string): string {\n return text.replace(/[[\\]()]/g, \"\").trim();\n}\n\n/** excerpt 를 1줄로 — 개행·연속 공백을 공백 1개로 정리 후 200자 절단. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, \" \").trim().slice(0, 200);\n}\n\n/**\n * `app/llms.txt/route.ts` 용 GET 핸들러 — AEO/GEO(AI 검색·생성엔진 최적화).\n *\n * AI 크롤러(ChatGPT/Claude/Perplexity 등)가 사이트 구조·콘텐츠를 빠르게\n * 파악하도록 llms.txt 스펙(https://llmstxt.org)의 마크다운 인덱스를 제공한다.\n * 발행 글 최대 100개를 \"## 블로그\" 섹션으로 자동 포함 — feed/sitemap 처럼\n * fetch 실패도 swallow 하여 *항상 200* (헤더 부분만 출력).\n *\n * ```ts\n * import { createLlmsTxtRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const dynamic = \"force-dynamic\";\n * export const GET = createLlmsTxtRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * siteUrl: \"https://example.com\",\n * title: \"Example\",\n * description: \"...\",\n * sections: [{ title: \"주요 페이지\", links: [{ title: \"서비스\", url: \"https://example.com/services\" }] }],\n * });\n * ```\n */\nexport function createLlmsTxtRoute(\n config: LlmsTxtRouteConfig,\n): () => Promise<Response> {\n return async function GET(): Promise<Response> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const posts = await safeFetchPosts(config, 100);\n\n const lines: string[] = [`# ${config.title}`];\n if (config.description) {\n lines.push(\"\", `> ${oneLine(config.description)}`);\n }\n for (const section of config.sections ?? []) {\n lines.push(\"\", `## ${section.title}`);\n for (const link of section.links) {\n const note = link.note ? `: ${oneLine(link.note)}` : \"\";\n lines.push(`- [${mdLinkSafe(link.title)}](${link.url})${note}`);\n }\n }\n if (posts.length > 0) {\n lines.push(\"\", \"## 블로그\");\n for (const p of posts) {\n const excerpt = p.excerpt ? `: ${oneLine(p.excerpt)}` : \"\";\n lines.push(\n `- [${mdLinkSafe(p.title)}](${siteUrl}${blogBase}/${p.slug})${excerpt}`,\n );\n }\n }\n lines.push(\"\");\n\n return new Response(lines.join(\"\\n\"), {\n headers: {\n \"content-type\": \"text/plain; charset=utf-8\",\n // feed 와 동일한 30분 캐시 — webhook alsoRevalidate 로 즉시 갱신됨.\n \"cache-control\": \"public, max-age=1800, s-maxage=1800\",\n },\n });\n };\n}\n\nexport interface RevalidateRouteConfig {\n /** `rtlk_cust_*` — 블로그 조회/webhook 검증 공통 키. */\n apiKey: string;\n apiBase?: string;\n /** Next 의 `revalidatePath` (또는 동등 콜백) 주입. */\n revalidate: (path: string) => void | Promise<void>;\n /** 블로그 base path. 기본 \"/blog\". */\n blogBasePath?: string;\n /**\n * 모든 글/분류/미디어 변경에 대해 *항상 함께* revalidate 할 파생 경로.\n * 피드·사이트맵·블로그 인덱스처럼 \"글 목록\"에 의존하는 경로 — admin 변경이\n * 자동 반영되려면 필수.\n * 기본: [\"/feed.xml\", \"/sitemap.xml\", \"/llms.txt\", blogBase, `${blogBase}/categories`].\n */\n alsoRevalidate?: string[];\n /** (옵션) jti replay 방지 hook (cms-client verify 로 전달). */\n consumeJti?: (jti: string, exp: number) => Promise<\"new\" | \"seen\">;\n}\n\n/** webhook 본문에서 paths[] 추출(서명 검증 후 호출 — body_hash 가 binding). */\nfunction pathsFromBody(rawBody: string): string[] {\n try {\n const body = JSON.parse(rawBody) as { paths?: unknown };\n if (Array.isArray(body.paths)) {\n return body.paths.filter((p): p is string => typeof p === \"string\");\n }\n } catch {\n /* 비-JSON 본문 — 파생 경로만 revalidate */\n }\n return [];\n}\n\n/**\n * `app/api/revalidate/route.ts` 용 POST 핸들러.\n *\n * admin(mysite.roottale.com)이 글/카테고리/미디어 변경 시 보내는 서명 webhook 을\n * 검증하고, 본문 paths + 파생 경로(feed·sitemap·블로그 인덱스)를 revalidate.\n *\n * ```ts\n * import { revalidatePath } from \"next/cache\";\n * import { createRevalidateRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const POST = createRevalidateRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * revalidate: revalidatePath,\n * });\n * ```\n */\nexport function createRevalidateRoute(\n config: RevalidateRouteConfig,\n): (req: Request) => Promise<Response> {\n const blogBase = config.blogBasePath ?? \"/blog\";\n const alsoRevalidate = config.alsoRevalidate ?? [\n \"/feed.xml\",\n \"/sitemap.xml\",\n // 글 변경 시 AI 크롤러용 llms.txt 인덱스도 함께 갱신 (AEO/GEO).\n \"/llms.txt\",\n blogBase,\n `${blogBase}/categories`,\n ];\n\n return async function POST(req: Request): Promise<Response> {\n const rawBody = await req.text();\n const result: VerifyRootTaleWebhookResult = await verifyRootTaleWebhook({\n rawBody,\n headers: req.headers,\n apiKey: config.apiKey,\n apiBase: config.apiBase,\n consumeJti: config.consumeJti,\n });\n if (!result.ok) {\n return Response.json({ ok: false, reason: result.reason }, { status: 401 });\n }\n\n // 본문 paths + 파생 경로(항상) — 중복 제거.\n const paths = [...new Set([...pathsFromBody(rawBody), ...alsoRevalidate])];\n for (const p of paths) {\n await config.revalidate(p);\n }\n\n return Response.json({\n ok: true,\n event: result.event,\n deliveryId: result.deliveryId,\n revalidated: paths,\n });\n };\n}\n\n// ── slug 변경 301 (WP permalink 패리티) ─────────────────────────────────────\n\n/**\n * 옛 slug → 현재 slug 301 redirect 판단 (순수 함수, `next` 비의존).\n *\n * Public API 는 slug 변경 후에도 옛 slug 로 글을 찾아 *현재* slug 를 응답한다\n * (post_slug_history fallback). 요청 slug 와 응답 slug 가 다르면 새 경로를\n * 반환 — 페이지에서 `permanentRedirect()` 로 301 처리한다:\n *\n * ```ts\n * import { permanentRedirect, notFound } from \"next/navigation\";\n * import { postRedirectPath } from \"@roottale/cms-renderer-next/routes\";\n *\n * const post = await getPost(slug);\n * if (!post) notFound();\n * const redirect = postRedirectPath(post, slug);\n * if (redirect) permanentRedirect(redirect);\n * ```\n *\n * Next App Router 의 `params` 는 percent-encoding 된 채 들어올 수 있어\n * (한글 slug) 디코딩 후 비교한다. redirect 가 불필요하면 null.\n */\nexport function postRedirectPath(\n post: { slug: string },\n requestedSlug: string,\n blogBasePath = \"/blog\",\n): string | null {\n let requested = requestedSlug;\n try {\n requested = decodeURIComponent(requestedSlug);\n } catch {\n /* 잘못된 escape — 원본 그대로 비교 */\n }\n if (post.slug === requested) return null;\n // blogBasePath \"/\" 또는 \"\" = 루트 배치 → \"/{slug}\".\n return `${trimSlash(blogBasePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n// ── fleet well-known (사이트 버전 노출 컨벤션) ───────────────────────────────\n\ndeclare const __CMS_RENDERER_VERSION__: string | undefined;\n\nexport interface FleetInfoRouteConfig {\n /** 사이트 식별 라벨 (선택 — 기본 Vercel project URL 또는 null). */\n site?: string;\n}\n\n/**\n * `GET /.well-known/roottale.json` — fleet 가시성 컨벤션 라우트.\n *\n * 플랫폼 internal fleet API 의 `?probe=1` 이 본 엔드포인트를 읽어 \"배포된\"\n * 패키지 버전을 트래픽 없이도 확인한다 (런타임 check-in 의 보조 신호).\n * 노출은 의도적으로 최소: 패키지 버전 + 짧은 build sha — 비밀 없음.\n *\n * 사용 (Next App Router):\n * ```ts\n * // app/.well-known/roottale.json/route.ts\n * import { createFleetInfoRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const GET = createFleetInfoRoute();\n * ```\n */\nexport function createFleetInfoRoute(\n config: FleetInfoRouteConfig = {},\n): () => Promise<Response> {\n // dts 빌드는 node types 없이 돈다 — process 는 globalThis 경유로 안전 접근.\n const env =\n (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env ?? {};\n return async function GET(): Promise<Response> {\n return Response.json(\n {\n site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,\n framework: \"next\",\n cms_client: CMS_CLIENT_VERSION,\n cms_renderer:\n typeof __CMS_RENDERER_VERSION__ === \"string\"\n ? __CMS_RENDERER_VERSION__\n : \"dev\",\n build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null,\n },\n { headers: { \"cache-control\": \"public, max-age=300\" } },\n );\n };\n}\n\n// ── 동적 OG 이미지 (글별 1200×630) ──────────────────────────────────────────\n\n/** Next `opengraph-image` export 컨벤션과 호환되는 크기 상수. */\nexport const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const;\nexport const OG_IMAGE_CONTENT_TYPE = \"image/png\";\n\nexport interface OgImageRouteConfig extends RootTaleSiteConfig {\n /** 배경색. 기본 진네이비 그라데이션 베이스. */\n backgroundColor?: string;\n /** 본문 텍스트 색. 기본 흰색. */\n foregroundColor?: string;\n /** 상단 액센트 바 + 브랜드 라벨 색. 기본 하늘색. */\n accentColor?: string;\n /** 하단 브랜드 라벨. 기본 = `title`. */\n brandLabel?: string;\n}\n\n/** `next/og` 의 ImageResponse 와 구조 호환(직접 의존 회피 — revalidate 콜백 주입과 동일 원칙). */\nexport interface ImageResponseLike {\n new (\n element: unknown,\n options?: {\n width?: number;\n height?: number;\n fonts?: { name: string; data: ArrayBuffer; weight?: number; style?: \"normal\" }[];\n },\n ): Response;\n}\n\n/**\n * 제목에 쓰인 글자만 부분셋(subset)으로 받아 한글을 렌더 — satori 기본 폰트는\n * 한글 글리프가 없다. 구형 UA 로 css2 를 호출하면 TTF(satori 호환) URL 을 준다.\n * 실패 시 null — 폰트 없이도 이미지는 생성(라틴 fallback).\n */\nasync function loadKoreanFontSubset(text: string): Promise<ArrayBuffer | null> {\n try {\n const unique = Array.from(new Set(Array.from(text))).join(\"\");\n const cssUrl = `https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@700&text=${encodeURIComponent(unique)}`;\n const cssRes = await fetch(cssUrl, {\n headers: { \"user-agent\": \"Mozilla/5.0 (Windows NT 6.1; rv:10.0)\" },\n });\n if (!cssRes.ok) return null;\n const css = await cssRes.text();\n const match = css.match(\n /src:\\s*url\\(([^)]+)\\)\\s*format\\(['\"](?:truetype|opentype)['\"]\\)/,\n );\n if (!match?.[1]) return null;\n const fontRes = await fetch(match[1]);\n if (!fontRes.ok) return null;\n return await fontRes.arrayBuffer();\n } catch (err) {\n console.error(\"[@roottale/cms-renderer-next/routes] OG 폰트 로딩 실패:\", err);\n return null;\n }\n}\n\n/**\n * `app/blog/[slug]/opengraph-image.tsx` 용 동적 OG 이미지 (1200×630).\n *\n * `next/og` 의 ImageResponse 를 주입받는다 — 본 패키지는 `next` 에 직접\n * 의존하지 않는다. 글이 없거나 fetch 실패면 사이트 제목만으로 fail-soft 렌더\n * (소셜 크롤러에 빈 카드 대신 브랜드 카드).\n *\n * ```tsx\n * // app/blog/[slug]/opengraph-image.tsx\n * import { ImageResponse } from \"next/og\";\n * import {\n * createPostOgImage,\n * OG_IMAGE_SIZE,\n * OG_IMAGE_CONTENT_TYPE,\n * } from \"@roottale/cms-renderer-next/routes\";\n * import { roottale } from \"@/lib/roottale\"; // RootTaleSiteConfig\n *\n * export const size = OG_IMAGE_SIZE;\n * export const contentType = OG_IMAGE_CONTENT_TYPE;\n * export default createPostOgImage(roottale, { ImageResponse });\n * ```\n */\nexport function createPostOgImage(\n config: OgImageRouteConfig,\n deps: { ImageResponse: ImageResponseLike },\n): (props: { params: Promise<{ slug: string }> | { slug: string } }) => Promise<Response> {\n const background = config.backgroundColor ?? \"#10172a\";\n const foreground = config.foregroundColor ?? \"#f8fafc\";\n const accent = config.accentColor ?? \"#38bdf8\";\n const brand = config.brandLabel ?? config.title;\n\n return async function OgImage(props): Promise<Response> {\n const { slug } = await props.params;\n\n let title = config.title;\n let dateLabel: string | null = null;\n try {\n const post = await fetchPost({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n slugOrId: slug,\n });\n if (post) {\n title = post.title;\n dateLabel = new Intl.DateTimeFormat(\"ko-KR\", { dateStyle: \"long\" }).format(\n new Date(post.publishedAt),\n );\n }\n } catch (err) {\n console.error(\"[@roottale/cms-renderer-next/routes] OG fetchPost 실패:\", err);\n }\n\n // satori 줄바꿈에 맡기되 3줄을 넘는 초장문 제목만 자른다.\n const display = title.length > 64 ? `${title.slice(0, 63)}…` : title;\n const fontData = await loadKoreanFontSubset(`${display}${brand}${dateLabel ?? \"\"}`);\n\n const { createElement: h } = await import(\"react\");\n const element = h(\n \"div\",\n {\n style: {\n width: \"100%\",\n height: \"100%\",\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n padding: \"72px 80px\",\n backgroundColor: background,\n backgroundImage: `linear-gradient(135deg, ${background} 60%, rgba(56,189,248,0.25))`,\n color: foreground,\n fontFamily: fontData ? \"NotoSansKR\" : \"sans-serif\",\n },\n },\n h(\"div\", {\n style: { width: 96, height: 10, backgroundColor: accent, borderRadius: 5 },\n }),\n h(\n \"div\",\n {\n style: {\n display: \"flex\",\n fontSize: display.length > 36 ? 56 : 68,\n fontWeight: 700,\n lineHeight: 1.25,\n letterSpacing: \"-0.02em\",\n wordBreak: \"keep-all\",\n },\n },\n display,\n ),\n h(\n \"div\",\n {\n style: {\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n fontSize: 28,\n },\n },\n h(\"div\", { style: { display: \"flex\", color: accent, fontWeight: 700 } }, brand),\n h(\n \"div\",\n { style: { display: \"flex\", color: \"rgba(248,250,252,0.72)\" } },\n dateLabel ?? trimSlash(config.siteUrl).replace(/^https?:\\/\\//, \"\"),\n ),\n ),\n );\n\n return new deps.ImageResponse(element, {\n width: OG_IMAGE_SIZE.width,\n height: OG_IMAGE_SIZE.height,\n ...(fontData\n ? { fonts: [{ name: \"NotoSansKR\", data: fontData, weight: 700 as const, style: \"normal\" as const }] }\n : {}),\n });\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/routes.ts"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/routes` — Next.js App Router 통합 라우트 팩토리.\n *\n * 외부 고객사 사이트가 매번 직접 작성하던 공통 라우트(RSS feed / sitemap /\n * revalidate webhook)를 1줄 팩토리로 표준화. 블로그 조회와 *동일한* 단일\n * customer API key(`ROOTTALE_API_KEY`)를 사용한다.\n *\n * 설계 원칙:\n * - feed/sitemap 은 `fetchPosts` 실패를 swallow → 라우트가 *항상 200* (빌드 시\n * 정적 생성이 throw 해서 404 나는 사고 방지).\n * - revalidate 는 admin(mysite.roottale.com)에서 글/카테고리/미디어 변경 시 보내는\n * 서명 webhook 을 검증하고, 본문 paths + 파생 경로(feed·sitemap·블로그 인덱스)를\n * 모두 revalidate → 각 사이트에 자동 반영.\n * - `next` 에 직접 의존하지 않음: revalidate 콜백(`revalidatePath`)을 주입받는다.\n *\n * 사용 예는 roottale-docs `services/external-site-integration.md` 참조.\n */\n\nimport {\n CMS_CLIENT_VERSION,\n fetchPost,\n fetchPosts,\n generateRssXml,\n rssItemsFromPosts,\n type CmsPostContent,\n} from \"@roottale/cms-client/server\";\nimport {\n verifyRootTaleWebhook,\n type VerifyRootTaleWebhookResult,\n} from \"@roottale/cms-client/webhook\";\nimport {\n type RouteCollection,\n isInCollectionMode,\n resolvePostCollection,\n resolvePostUrl,\n} from \"@roottale/cms-core\";\nimport { collectCategories, collectCollectionCategories } from \"./blog-filter.js\";\n\nexport type { RouteCollection };\n// 소비자 가드용 재export (`/blog/[slug]` 에서 스트림 누출 차단 등) — ADR-0060.\nexport { resolvePostCollection, resolvePostUrl } from \"@roottale/cms-core\";\n\nexport interface RootTaleSiteConfig {\n /** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만(절대 브라우저 금지). */\n apiKey: string;\n /** API base 오버라이드. 기본 `https://api.roottale.com`. */\n apiBase?: string;\n /** 사이트 절대 URL (뒤 슬래시 없이). 예: `https://kjmtax.roottale.app`. */\n siteUrl: string;\n /** 피드/사이트 제목. */\n title: string;\n description?: string;\n /** 기본 \"ko-KR\". */\n language?: string;\n /** 블로그 base path. 기본 \"/blog\". 레거시 단일 스트림 — `collections` 미사용 시. */\n blogBasePath?: string;\n /**\n * 다중 스트림 라우팅 (ADR-0060). **길이 > 0 이면 collection 모드** — feed/sitemap/\n * llms.txt 가 글을 소속 스트림 basePath 로 라우팅하고, 어느 스트림에도 안 속한 글은\n * 제외한다. 비우거나 미전달 = 레거시 단일 `blogBasePath`(/blog) 동작(하위호환).\n * 선언 순서 = 우선순위(first-wins).\n */\n collections?: RouteCollection[];\n}\n\nfunction trimSlash(u: string): string {\n return u.replace(/\\/+$/, \"\");\n}\n\nasync function safeFetchPosts(\n config: RootTaleSiteConfig,\n limit: number,\n): Promise<CmsPostContent[]> {\n try {\n const page = await fetchPosts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n type: \"post\",\n limit,\n });\n return page.items;\n } catch (err) {\n // 빌드/런타임 fetch 실패 시 빈 목록 — 라우트는 항상 정상 응답(404 방지).\n console.error(\"[@roottale/cms-renderer-next/routes] fetchPosts 실패:\", err);\n return [];\n }\n}\n\n/**\n * `app/feed.xml/route.ts` 용 GET 핸들러. RSS 2.0.\n *\n * ```ts\n * import { createFeedRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const dynamic = \"force-dynamic\";\n * export const GET = createFeedRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * siteUrl: \"https://example.com\",\n * title: \"Example 블로그\",\n * description: \"...\",\n * });\n * ```\n */\nexport function createFeedRoute(\n config: RootTaleSiteConfig,\n): () => Promise<Response> {\n return async function GET(): Promise<Response> {\n const siteUrl = trimSlash(config.siteUrl);\n const collections = config.collections;\n const allPosts = await safeFetchPosts(config, 50);\n\n // collection 모드: /feed.xml = `feed:true` 스트림들의 합집합(aggregate). RSS\n // 리더는 안정적 사이트 피드를 기대하므로 스트림별 분리 대신 통합 피드(codex).\n // 각 글 링크는 소속 스트림 basePath 로. 레거시: 단일 blogBasePath.\n const inCollectionMode = isInCollectionMode(collections);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const posts = inCollectionMode\n ? allPosts.filter((p) => {\n const c = resolvePostCollection(p, collections!);\n return c ? c.feed === true : false;\n })\n : allPosts;\n\n const xml = generateRssXml({\n title: config.title,\n link: inCollectionMode ? siteUrl : `${siteUrl}${blogBase}`,\n feedUrl: `${siteUrl}/feed.xml`,\n description: config.description,\n language: config.language,\n items: rssItemsFromPosts(posts, {\n postUrl: (p) =>\n inCollectionMode\n ? (resolvePostUrl(siteUrl, p, collections!) ?? `${siteUrl}${blogBase}/${p.slug}`)\n : `${siteUrl}${blogBase}/${p.slug}`,\n }),\n });\n return new Response(xml, {\n headers: {\n \"content-type\": \"application/rss+xml; charset=utf-8\",\n \"cache-control\": \"public, max-age=1800, s-maxage=1800\",\n },\n });\n };\n}\n\n/** Next `MetadataRoute.Sitemap[number]` 와 구조 호환(타입 결합 회피). */\nexport interface SitemapEntry {\n url: string;\n lastModified?: string | Date;\n changeFrequency?:\n | \"always\"\n | \"hourly\"\n | \"daily\"\n | \"weekly\"\n | \"monthly\"\n | \"yearly\"\n | \"never\";\n priority?: number;\n}\n\n/**\n * `app/sitemap.ts` default export. 정적 경로 + 글 경로를 합친다.\n *\n * ```ts\n * import { createSitemap } from \"@roottale/cms-renderer-next/routes\";\n * export default createSitemap(\n * { apiKey: process.env.ROOTTALE_API_KEY!, siteUrl: \"https://example.com\", title: \"...\" },\n * [{ url: \"https://example.com\", changeFrequency: \"weekly\", priority: 1 }],\n * );\n * ```\n */\nexport function createSitemap(\n config: RootTaleSiteConfig,\n staticRoutes: SitemapEntry[] = [],\n): () => Promise<SitemapEntry[]> {\n return async function sitemap(): Promise<SitemapEntry[]> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const collections = config.collections;\n const posts = await safeFetchPosts(config, 200);\n\n if (isInCollectionMode(collections)) {\n // 글마다 소속 스트림 basePath 로(공지→/notice, 블로그→/blog). 어느 스트림에도\n // 안 속한 글(상세 라우트 없음)은 제외. 아카이브는 `archives:true` 스트림만.\n const postEntries: SitemapEntry[] = posts.flatMap((p) => {\n const url = resolvePostUrl(siteUrl, p, collections!);\n if (!url) return [];\n return [\n {\n url,\n lastModified: p.updatedAt ?? p.publishedAt ?? undefined,\n changeFrequency: \"weekly\" as const,\n priority: 0.6,\n },\n ];\n });\n\n const categoryEntries: SitemapEntry[] = collections!\n .filter((c) => c.archives)\n .flatMap((c) => {\n const cats = collectCollectionCategories(posts, collections!, c.key);\n if (!cats.length) return [];\n const base = `${siteUrl}${trimSlash(c.basePath)}`;\n return [\n { url: `${base}/categories`, changeFrequency: \"weekly\" as const, priority: 0.5 },\n ...cats.map((category) => ({\n url: `${base}/categories/${category.slug}`,\n changeFrequency: \"weekly\" as const,\n priority: 0.5,\n })),\n ];\n });\n\n return [...staticRoutes, ...postEntries, ...categoryEntries];\n }\n\n // 레거시: 단일 blogBasePath.\n const postEntries: SitemapEntry[] = posts.map((p) => ({\n url: `${siteUrl}${blogBase}/${p.slug}`,\n lastModified: p.updatedAt ?? p.publishedAt ?? undefined,\n changeFrequency: \"weekly\",\n priority: 0.6,\n }));\n const categories = collectCategories(posts);\n const categoryEntries: SitemapEntry[] = categories.length\n ? [\n {\n url: `${siteUrl}${blogBase}/categories`,\n changeFrequency: \"weekly\",\n priority: 0.5,\n },\n ...categories.map((category) => ({\n url: `${siteUrl}${blogBase}/categories/${category.slug}`,\n changeFrequency: \"weekly\" as const,\n priority: 0.5,\n })),\n ]\n : [];\n return [...staticRoutes, ...postEntries, ...categoryEntries];\n };\n}\n\n// ── llms.txt (AEO/GEO — AI 검색·생성엔진용 마크다운 인덱스) ──────────────────\n\n/** llms.txt 정적 섹션의 링크 1개. */\nexport interface LlmsTxtLink {\n title: string;\n url: string;\n /** 링크 뒤 \": {note}\" 로 붙는 한 줄 설명 (선택). */\n note?: string;\n}\n\n/** llms.txt 의 정적 페이지 링크 그룹 — 블로그 목록 앞에 출력. */\nexport interface LlmsTxtSection {\n title: string;\n links: LlmsTxtLink[];\n}\n\nexport type LlmsTxtRouteConfig = RootTaleSiteConfig & {\n /** (선택) 서비스 소개 등 정적 페이지 링크 그룹. */\n sections?: LlmsTxtSection[];\n};\n\n/** 마크다운 링크 텍스트를 깨뜨리는 문자([, ], (, )) 제거. */\nfunction mdLinkSafe(text: string): string {\n return text.replace(/[[\\]()]/g, \"\").trim();\n}\n\n/** excerpt 를 1줄로 — 개행·연속 공백을 공백 1개로 정리 후 200자 절단. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, \" \").trim().slice(0, 200);\n}\n\n/**\n * `app/llms.txt/route.ts` 용 GET 핸들러 — AEO/GEO(AI 검색·생성엔진 최적화).\n *\n * AI 크롤러(ChatGPT/Claude/Perplexity 등)가 사이트 구조·콘텐츠를 빠르게\n * 파악하도록 llms.txt 스펙(https://llmstxt.org)의 마크다운 인덱스를 제공한다.\n * 발행 글 최대 100개를 \"## 블로그\" 섹션으로 자동 포함 — feed/sitemap 처럼\n * fetch 실패도 swallow 하여 *항상 200* (헤더 부분만 출력).\n *\n * ```ts\n * import { createLlmsTxtRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const dynamic = \"force-dynamic\";\n * export const GET = createLlmsTxtRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * siteUrl: \"https://example.com\",\n * title: \"Example\",\n * description: \"...\",\n * sections: [{ title: \"주요 페이지\", links: [{ title: \"서비스\", url: \"https://example.com/services\" }] }],\n * });\n * ```\n */\nexport function createLlmsTxtRoute(\n config: LlmsTxtRouteConfig,\n): () => Promise<Response> {\n return async function GET(): Promise<Response> {\n const siteUrl = trimSlash(config.siteUrl);\n const blogBase = config.blogBasePath ?? \"/blog\";\n const collections = config.collections;\n const posts = await safeFetchPosts(config, 100);\n\n const lines: string[] = [`# ${config.title}`];\n if (config.description) {\n lines.push(\"\", `> ${oneLine(config.description)}`);\n }\n for (const section of config.sections ?? []) {\n lines.push(\"\", `## ${section.title}`);\n for (const link of section.links) {\n const note = link.note ? `: ${oneLine(link.note)}` : \"\";\n lines.push(`- [${mdLinkSafe(link.title)}](${link.url})${note}`);\n }\n }\n if (posts.length > 0) {\n const inCollectionMode = isInCollectionMode(collections);\n // 레거시 헤딩(\"## 블로그\")은 하위호환 유지, 다중 스트림은 \"## 콘텐츠\".\n lines.push(\"\", inCollectionMode ? \"## 콘텐츠\" : \"## 블로그\");\n for (const p of posts) {\n // collection 모드: 소속 스트림 URL, 미소속 글 제외. 레거시: 단일 blogBase.\n const url = inCollectionMode\n ? resolvePostUrl(siteUrl, p, collections!)\n : `${siteUrl}${blogBase}/${p.slug}`;\n if (!url) continue;\n const excerpt = p.excerpt ? `: ${oneLine(p.excerpt)}` : \"\";\n lines.push(`- [${mdLinkSafe(p.title)}](${url})${excerpt}`);\n }\n }\n lines.push(\"\");\n\n return new Response(lines.join(\"\\n\"), {\n headers: {\n \"content-type\": \"text/plain; charset=utf-8\",\n // feed 와 동일한 30분 캐시 — webhook alsoRevalidate 로 즉시 갱신됨.\n \"cache-control\": \"public, max-age=1800, s-maxage=1800\",\n },\n });\n };\n}\n\nexport interface RevalidateRouteConfig {\n /** `rtlk_cust_*` — 블로그 조회/webhook 검증 공통 키. */\n apiKey: string;\n apiBase?: string;\n /** Next 의 `revalidatePath` (또는 동등 콜백) 주입. */\n revalidate: (path: string) => void | Promise<void>;\n /** 블로그 base path. 기본 \"/blog\". */\n blogBasePath?: string;\n /**\n * 다중 스트림 (ADR-0060). 지정 시 `alsoRevalidate` 기본값을 각 스트림 basePath\n * (+ `archives:true` 면 `${basePath}/categories`)로 파생. 미지정 = 레거시 단일 blog.\n */\n collections?: RouteCollection[];\n /**\n * 모든 글/분류/미디어 변경에 대해 *항상 함께* revalidate 할 파생 경로.\n * 피드·사이트맵·블로그 인덱스처럼 \"글 목록\"에 의존하는 경로 — admin 변경이\n * 자동 반영되려면 필수. 명시 시 collections 파생을 덮어쓴다.\n * 기본(레거시): [\"/feed.xml\", \"/sitemap.xml\", \"/llms.txt\", blogBase, `${blogBase}/categories`].\n */\n alsoRevalidate?: string[];\n /** (옵션) jti replay 방지 hook (cms-client verify 로 전달). */\n consumeJti?: (jti: string, exp: number) => Promise<\"new\" | \"seen\">;\n}\n\n/** webhook 본문에서 paths[] 추출(서명 검증 후 호출 — body_hash 가 binding). */\nfunction pathsFromBody(rawBody: string): string[] {\n try {\n const body = JSON.parse(rawBody) as { paths?: unknown };\n if (Array.isArray(body.paths)) {\n return body.paths.filter((p): p is string => typeof p === \"string\");\n }\n } catch {\n /* 비-JSON 본문 — 파생 경로만 revalidate */\n }\n return [];\n}\n\n/**\n * `app/api/revalidate/route.ts` 용 POST 핸들러.\n *\n * admin(mysite.roottale.com)이 글/카테고리/미디어 변경 시 보내는 서명 webhook 을\n * 검증하고, 본문 paths + 파생 경로(feed·sitemap·블로그 인덱스)를 revalidate.\n *\n * ```ts\n * import { revalidatePath } from \"next/cache\";\n * import { createRevalidateRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const POST = createRevalidateRoute({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * revalidate: revalidatePath,\n * });\n * ```\n */\nexport function createRevalidateRoute(\n config: RevalidateRouteConfig,\n): (req: Request) => Promise<Response> {\n const blogBase = config.blogBasePath ?? \"/blog\";\n // 글 목록 의존 경로는 항상 함께 — collection 모드면 각 스트림 basePath(+아카이브).\n const derivedFromCollections = isInCollectionMode(config.collections)\n ? config.collections!.flatMap((c) =>\n c.archives ? [c.basePath, `${c.basePath}/categories`] : [c.basePath],\n )\n : [blogBase, `${blogBase}/categories`];\n const alsoRevalidate = config.alsoRevalidate ?? [\n \"/feed.xml\",\n \"/sitemap.xml\",\n // 글 변경 시 AI 크롤러용 llms.txt 인덱스도 함께 갱신 (AEO/GEO).\n \"/llms.txt\",\n ...derivedFromCollections,\n ];\n\n return async function POST(req: Request): Promise<Response> {\n const rawBody = await req.text();\n const result: VerifyRootTaleWebhookResult = await verifyRootTaleWebhook({\n rawBody,\n headers: req.headers,\n apiKey: config.apiKey,\n apiBase: config.apiBase,\n consumeJti: config.consumeJti,\n });\n if (!result.ok) {\n return Response.json({ ok: false, reason: result.reason }, { status: 401 });\n }\n\n // 본문 paths + 파생 경로(항상) — 중복 제거.\n const paths = [...new Set([...pathsFromBody(rawBody), ...alsoRevalidate])];\n for (const p of paths) {\n await config.revalidate(p);\n }\n\n return Response.json({\n ok: true,\n event: result.event,\n deliveryId: result.deliveryId,\n revalidated: paths,\n });\n };\n}\n\n// ── slug 변경 301 (WP permalink 패리티) ─────────────────────────────────────\n\n/**\n * 옛 slug → 현재 slug 301 redirect 판단 (순수 함수, `next` 비의존).\n *\n * Public API 는 slug 변경 후에도 옛 slug 로 글을 찾아 *현재* slug 를 응답한다\n * (post_slug_history fallback). 요청 slug 와 응답 slug 가 다르면 새 경로를\n * 반환 — 페이지에서 `permanentRedirect()` 로 301 처리한다:\n *\n * ```ts\n * import { permanentRedirect, notFound } from \"next/navigation\";\n * import { postRedirectPath } from \"@roottale/cms-renderer-next/routes\";\n *\n * const post = await getPost(slug);\n * if (!post) notFound();\n * const redirect = postRedirectPath(post, slug);\n * if (redirect) permanentRedirect(redirect);\n * ```\n *\n * Next App Router 의 `params` 는 percent-encoding 된 채 들어올 수 있어\n * (한글 slug) 디코딩 후 비교한다. redirect 가 불필요하면 null.\n */\nexport function postRedirectPath(\n post: { slug: string },\n requestedSlug: string,\n blogBasePath = \"/blog\",\n): string | null {\n let requested = requestedSlug;\n try {\n requested = decodeURIComponent(requestedSlug);\n } catch {\n /* 잘못된 escape — 원본 그대로 비교 */\n }\n if (post.slug === requested) return null;\n // blogBasePath \"/\" 또는 \"\" = 루트 배치 → \"/{slug}\".\n return `${trimSlash(blogBasePath)}/${encodeURIComponent(post.slug)}`;\n}\n\n// ── fleet well-known (사이트 버전 노출 컨벤션) ───────────────────────────────\n\ndeclare const __CMS_RENDERER_VERSION__: string | undefined;\n\nexport interface FleetInfoRouteConfig {\n /** 사이트 식별 라벨 (선택 — 기본 Vercel project URL 또는 null). */\n site?: string;\n}\n\n/**\n * `GET /.well-known/roottale.json` — fleet 가시성 컨벤션 라우트.\n *\n * 플랫폼 internal fleet API 의 `?probe=1` 이 본 엔드포인트를 읽어 \"배포된\"\n * 패키지 버전을 트래픽 없이도 확인한다 (런타임 check-in 의 보조 신호).\n * 노출은 의도적으로 최소: 패키지 버전 + 짧은 build sha — 비밀 없음.\n *\n * 사용 (Next App Router):\n * ```ts\n * // app/.well-known/roottale.json/route.ts\n * import { createFleetInfoRoute } from \"@roottale/cms-renderer-next/routes\";\n * export const GET = createFleetInfoRoute();\n * ```\n */\nexport function createFleetInfoRoute(\n config: FleetInfoRouteConfig = {},\n): () => Promise<Response> {\n // dts 빌드는 node types 없이 돈다 — process 는 globalThis 경유로 안전 접근.\n const env =\n (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env ?? {};\n return async function GET(): Promise<Response> {\n return Response.json(\n {\n site: config.site ?? env.VERCEL_PROJECT_PRODUCTION_URL ?? null,\n framework: \"next\",\n cms_client: CMS_CLIENT_VERSION,\n cms_renderer:\n typeof __CMS_RENDERER_VERSION__ === \"string\"\n ? __CMS_RENDERER_VERSION__\n : \"dev\",\n build_sha: env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? null,\n },\n { headers: { \"cache-control\": \"public, max-age=300\" } },\n );\n };\n}\n\n// ── 동적 OG 이미지 (글별 1200×630) ──────────────────────────────────────────\n\n/** Next `opengraph-image` export 컨벤션과 호환되는 크기 상수. */\nexport const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const;\nexport const OG_IMAGE_CONTENT_TYPE = \"image/png\";\n\nexport interface OgImageRouteConfig extends RootTaleSiteConfig {\n /** 배경색. 기본 진네이비 그라데이션 베이스. */\n backgroundColor?: string;\n /** 본문 텍스트 색. 기본 흰색. */\n foregroundColor?: string;\n /** 상단 액센트 바 + 브랜드 라벨 색. 기본 하늘색. */\n accentColor?: string;\n /** 하단 브랜드 라벨. 기본 = `title`. */\n brandLabel?: string;\n}\n\n/** `next/og` 의 ImageResponse 와 구조 호환(직접 의존 회피 — revalidate 콜백 주입과 동일 원칙). */\nexport interface ImageResponseLike {\n new (\n element: unknown,\n options?: {\n width?: number;\n height?: number;\n fonts?: { name: string; data: ArrayBuffer; weight?: number; style?: \"normal\" }[];\n },\n ): Response;\n}\n\n/**\n * 제목에 쓰인 글자만 부분셋(subset)으로 받아 한글을 렌더 — satori 기본 폰트는\n * 한글 글리프가 없다. 구형 UA 로 css2 를 호출하면 TTF(satori 호환) URL 을 준다.\n * 실패 시 null — 폰트 없이도 이미지는 생성(라틴 fallback).\n */\nasync function loadKoreanFontSubset(text: string): Promise<ArrayBuffer | null> {\n try {\n const unique = Array.from(new Set(Array.from(text))).join(\"\");\n const cssUrl = `https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@700&text=${encodeURIComponent(unique)}`;\n const cssRes = await fetch(cssUrl, {\n headers: { \"user-agent\": \"Mozilla/5.0 (Windows NT 6.1; rv:10.0)\" },\n });\n if (!cssRes.ok) return null;\n const css = await cssRes.text();\n const match = css.match(\n /src:\\s*url\\(([^)]+)\\)\\s*format\\(['\"](?:truetype|opentype)['\"]\\)/,\n );\n if (!match?.[1]) return null;\n const fontRes = await fetch(match[1]);\n if (!fontRes.ok) return null;\n return await fontRes.arrayBuffer();\n } catch (err) {\n console.error(\"[@roottale/cms-renderer-next/routes] OG 폰트 로딩 실패:\", err);\n return null;\n }\n}\n\n/**\n * `app/blog/[slug]/opengraph-image.tsx` 용 동적 OG 이미지 (1200×630).\n *\n * `next/og` 의 ImageResponse 를 주입받는다 — 본 패키지는 `next` 에 직접\n * 의존하지 않는다. 글이 없거나 fetch 실패면 사이트 제목만으로 fail-soft 렌더\n * (소셜 크롤러에 빈 카드 대신 브랜드 카드).\n *\n * ```tsx\n * // app/blog/[slug]/opengraph-image.tsx\n * import { ImageResponse } from \"next/og\";\n * import {\n * createPostOgImage,\n * OG_IMAGE_SIZE,\n * OG_IMAGE_CONTENT_TYPE,\n * } from \"@roottale/cms-renderer-next/routes\";\n * import { roottale } from \"@/lib/roottale\"; // RootTaleSiteConfig\n *\n * export const size = OG_IMAGE_SIZE;\n * export const contentType = OG_IMAGE_CONTENT_TYPE;\n * export default createPostOgImage(roottale, { ImageResponse });\n * ```\n */\nexport function createPostOgImage(\n config: OgImageRouteConfig,\n deps: { ImageResponse: ImageResponseLike },\n): (props: { params: Promise<{ slug: string }> | { slug: string } }) => Promise<Response> {\n const background = config.backgroundColor ?? \"#10172a\";\n const foreground = config.foregroundColor ?? \"#f8fafc\";\n const accent = config.accentColor ?? \"#38bdf8\";\n const brand = config.brandLabel ?? config.title;\n\n return async function OgImage(props): Promise<Response> {\n const { slug } = await props.params;\n\n let title = config.title;\n let dateLabel: string | null = null;\n try {\n const post = await fetchPost({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n slugOrId: slug,\n });\n if (post) {\n title = post.title;\n dateLabel = new Intl.DateTimeFormat(\"ko-KR\", { dateStyle: \"long\" }).format(\n new Date(post.publishedAt),\n );\n }\n } catch (err) {\n console.error(\"[@roottale/cms-renderer-next/routes] OG fetchPost 실패:\", err);\n }\n\n // satori 줄바꿈에 맡기되 3줄을 넘는 초장문 제목만 자른다.\n const display = title.length > 64 ? `${title.slice(0, 63)}…` : title;\n const fontData = await loadKoreanFontSubset(`${display}${brand}${dateLabel ?? \"\"}`);\n\n const { createElement: h } = await import(\"react\");\n const element = h(\n \"div\",\n {\n style: {\n width: \"100%\",\n height: \"100%\",\n display: \"flex\",\n flexDirection: \"column\",\n justifyContent: \"space-between\",\n padding: \"72px 80px\",\n backgroundColor: background,\n backgroundImage: `linear-gradient(135deg, ${background} 60%, rgba(56,189,248,0.25))`,\n color: foreground,\n fontFamily: fontData ? \"NotoSansKR\" : \"sans-serif\",\n },\n },\n h(\"div\", {\n style: { width: 96, height: 10, backgroundColor: accent, borderRadius: 5 },\n }),\n h(\n \"div\",\n {\n style: {\n display: \"flex\",\n fontSize: display.length > 36 ? 56 : 68,\n fontWeight: 700,\n lineHeight: 1.25,\n letterSpacing: \"-0.02em\",\n wordBreak: \"keep-all\",\n },\n },\n display,\n ),\n h(\n \"div\",\n {\n style: {\n display: \"flex\",\n justifyContent: \"space-between\",\n alignItems: \"center\",\n fontSize: 28,\n },\n },\n h(\"div\", { style: { display: \"flex\", color: accent, fontWeight: 700 } }, brand),\n h(\n \"div\",\n { style: { display: \"flex\", color: \"rgba(248,250,252,0.72)\" } },\n dateLabel ?? trimSlash(config.siteUrl).replace(/^https?:\\/\\//, \"\"),\n ),\n ),\n );\n\n return new deps.ImageResponse(element, {\n width: OG_IMAGE_SIZE.width,\n height: OG_IMAGE_SIZE.height,\n ...(fontData\n ? { fonts: [{ name: \"NotoSansKR\", data: fontData, weight: 700 as const, style: \"normal\" as const }] }\n : {}),\n });\n };\n}\n"],"mappings":";;;;;;AAkBA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP;AAAA,EACE;AAAA,OAEK;AACP;AAAA,EAEE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAKP,SAAS,yBAAAA,wBAAuB,kBAAAC,uBAAsB;AAyBtD,SAAS,UAAU,GAAmB;AACpC,SAAO,EAAE,QAAQ,QAAQ,EAAE;AAC7B;AAEA,eAAe,eACb,QACA,OAC2B;AAC3B,MAAI;AACF,UAAM,OAAO,MAAM,WAAW;AAAA,MAC5B,QAAQ,OAAO;AAAA,MACf,SAAS,OAAO;AAAA,MAChB,MAAM;AAAA,MACN;AAAA,IACF,CAAC;AACD,WAAO,KAAK;AAAA,EACd,SAAS,KAAK;AAEZ,YAAQ,MAAM,iEAAuD,GAAG;AACxE,WAAO,CAAC;AAAA,EACV;AACF;AAgBO,SAAS,gBACd,QACyB;AACzB,SAAO,eAAe,MAAyB;AAC7C,UAAM,UAAU,UAAU,OAAO,OAAO;AACxC,UAAM,cAAc,OAAO;AAC3B,UAAM,WAAW,MAAM,eAAe,QAAQ,EAAE;AAKhD,UAAM,mBAAmB,mBAAmB,WAAW;AACvD,UAAM,WAAW,OAAO,gBAAgB;AACxC,UAAM,QAAQ,mBACV,SAAS,OAAO,CAAC,MAAM;AACrB,YAAM,IAAI,sBAAsB,GAAG,WAAY;AAC/C,aAAO,IAAI,EAAE,SAAS,OAAO;AAAA,IAC/B,CAAC,IACD;AAEJ,UAAM,MAAM,eAAe;AAAA,MACzB,OAAO,OAAO;AAAA,MACd,MAAM,mBAAmB,UAAU,GAAG,OAAO,GAAG,QAAQ;AAAA,MACxD,SAAS,GAAG,OAAO;AAAA,MACnB,aAAa,OAAO;AAAA,MACpB,UAAU,OAAO;AAAA,MACjB,OAAO,kBAAkB,OAAO;AAAA,QAC9B,SAAS,CAAC,MACR,mBACK,eAAe,SAAS,GAAG,WAAY,KAAK,GAAG,OAAO,GAAG,QAAQ,IAAI,EAAE,IAAI,KAC5E,GAAG,OAAO,GAAG,QAAQ,IAAI,EAAE,IAAI;AAAA,MACvC,CAAC;AAAA,IACH,CAAC;AACD,WAAO,IAAI,SAAS,KAAK;AAAA,MACvB,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,iBAAiB;AAAA,MACnB;AAAA,IACF,CAAC;AAAA,EACH;AACF;AA4BO,SAAS,cACd,QACA,eAA+B,CAAC,GACD;AAC/B,SAAO,eAAe,UAAmC;AACvD,UAAM,UAAU,UAAU,OAAO,OAAO;AACxC,UAAM,WAAW,OAAO,gBAAgB;AACxC,UAAM,cAAc,OAAO;AAC3B,UAAM,QAAQ,MAAM,eAAe,QAAQ,GAAG;AAE9C,QAAI,mBAAmB,WAAW,GAAG;AAGnC,YAAMC,eAA8B,MAAM,QAAQ,CAAC,MAAM;AACvD,cAAM,MAAM,eAAe,SAAS,GAAG,WAAY;AACnD,YAAI,CAAC,IAAK,QAAO,CAAC;AAClB,eAAO;AAAA,UACL;AAAA,YACE;AAAA,YACA,cAAc,EAAE,aAAa,EAAE,eAAe;AAAA,YAC9C,iBAAiB;AAAA,YACjB,UAAU;AAAA,UACZ;AAAA,QACF;AAAA,MACF,CAAC;AAED,YAAMC,mBAAkC,YACrC,OAAO,CAAC,MAAM,EAAE,QAAQ,EACxB,QAAQ,CAAC,MAAM;AACd,cAAM,OAAO,4BAA4B,OAAO,aAAc,EAAE,GAAG;AACnE,YAAI,CAAC,KAAK,OAAQ,QAAO,CAAC;AAC1B,cAAM,OAAO,GAAG,OAAO,GAAG,UAAU,EAAE,QAAQ,CAAC;AAC/C,eAAO;AAAA,UACL,EAAE,KAAK,GAAG,IAAI,eAAe,iBAAiB,UAAmB,UAAU,IAAI;AAAA,UAC/E,GAAG,KAAK,IAAI,CAAC,cAAc;AAAA,YACzB,KAAK,GAAG,IAAI,eAAe,SAAS,IAAI;AAAA,YACxC,iBAAiB;AAAA,YACjB,UAAU;AAAA,UACZ,EAAE;AAAA,QACJ;AAAA,MACF,CAAC;AAEH,aAAO,CAAC,GAAG,cAAc,GAAGD,cAAa,GAAGC,gBAAe;AAAA,IAC7D;AAGA,UAAM,cAA8B,MAAM,IAAI,CAAC,OAAO;AAAA,MACpD,KAAK,GAAG,OAAO,GAAG,QAAQ,IAAI,EAAE,IAAI;AAAA,MACpC,cAAc,EAAE,aAAa,EAAE,eAAe;AAAA,MAC9C,iBAAiB;AAAA,MACjB,UAAU;AAAA,IACZ,EAAE;AACF,UAAM,aAAa,kBAAkB,KAAK;AAC1C,UAAM,kBAAkC,WAAW,SAC/C;AAAA,MACE;AAAA,QACE,KAAK,GAAG,OAAO,GAAG,QAAQ;AAAA,QAC1B,iBAAiB;AAAA,QACjB,UAAU;AAAA,MACZ;AAAA,MACA,GAAG,WAAW,IAAI,CAAC,cAAc;AAAA,QAC/B,KAAK,GAAG,OAAO,GAAG,QAAQ,eAAe,SAAS,IAAI;AAAA,QACtD,iBAAiB;AAAA,QACjB,UAAU;AAAA,MACZ,EAAE;AAAA,IACJ,IACA,CAAC;AACL,WAAO,CAAC,GAAG,cAAc,GAAG,aAAa,GAAG,eAAe;AAAA,EAC7D;AACF;AAwBA,SAAS,WAAW,MAAsB;AACxC,SAAO,KAAK,QAAQ,YAAY,EAAE,EAAE,KAAK;AAC3C;AAGA,SAAS,QAAQ,MAAsB;AACrC,SAAO,KAAK,QAAQ,QAAQ,GAAG,EAAE,KAAK,EAAE,MAAM,GAAG,GAAG;AACtD;AAsBO,SAAS,mBACd,QACyB;AACzB,SAAO,eAAe,MAAyB;AAC7C,UAAM,UAAU,UAAU,OAAO,OAAO;AACxC,UAAM,WAAW,OAAO,gBAAgB;AACxC,UAAM,cAAc,OAAO;AAC3B,UAAM,QAAQ,MAAM,eAAe,QAAQ,GAAG;AAE9C,UAAM,QAAkB,CAAC,KAAK,OAAO,KAAK,EAAE;AAC5C,QAAI,OAAO,aAAa;AACtB,YAAM,KAAK,IAAI,KAAK,QAAQ,OAAO,WAAW,CAAC,EAAE;AAAA,IACnD;AACA,eAAW,WAAW,OAAO,YAAY,CAAC,GAAG;AAC3C,YAAM,KAAK,IAAI,MAAM,QAAQ,KAAK,EAAE;AACpC,iBAAW,QAAQ,QAAQ,OAAO;AAChC,cAAM,OAAO,KAAK,OAAO,KAAK,QAAQ,KAAK,IAAI,CAAC,KAAK;AACrD,cAAM,KAAK,MAAM,WAAW,KAAK,KAAK,CAAC,KAAK,KAAK,GAAG,IAAI,IAAI,EAAE;AAAA,MAChE;AAAA,IACF;AACA,QAAI,MAAM,SAAS,GAAG;AACpB,YAAM,mBAAmB,mBAAmB,WAAW;AAEvD,YAAM,KAAK,IAAI,mBAAmB,0BAAW,uBAAQ;AACrD,iBAAW,KAAK,OAAO;AAErB,cAAM,MAAM,mBACR,eAAe,SAAS,GAAG,WAAY,IACvC,GAAG,OAAO,GAAG,QAAQ,IAAI,EAAE,IAAI;AACnC,YAAI,CAAC,IAAK;AACV,cAAM,UAAU,EAAE,UAAU,KAAK,QAAQ,EAAE,OAAO,CAAC,KAAK;AACxD,cAAM,KAAK,MAAM,WAAW,EAAE,KAAK,CAAC,KAAK,GAAG,IAAI,OAAO,EAAE;AAAA,MAC3D;AAAA,IACF;AACA,UAAM,KAAK,EAAE;AAEb,WAAO,IAAI,SAAS,MAAM,KAAK,IAAI,GAAG;AAAA,MACpC,SAAS;AAAA,QACP,gBAAgB;AAAA;AAAA,QAEhB,iBAAiB;AAAA,MACnB;AAAA,IACF,CAAC;AAAA,EACH;AACF;AA2BA,SAAS,cAAc,SAA2B;AAChD,MAAI;AACF,UAAM,OAAO,KAAK,MAAM,OAAO;AAC/B,QAAI,MAAM,QAAQ,KAAK,KAAK,GAAG;AAC7B,aAAO,KAAK,MAAM,OAAO,CAAC,MAAmB,OAAO,MAAM,QAAQ;AAAA,IACpE;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO,CAAC;AACV;AAiBO,SAAS,sBACd,QACqC;AACrC,QAAM,WAAW,OAAO,gBAAgB;AAExC,QAAM,yBAAyB,mBAAmB,OAAO,WAAW,IAChE,OAAO,YAAa;AAAA,IAAQ,CAAC,MAC3B,EAAE,WAAW,CAAC,EAAE,UAAU,GAAG,EAAE,QAAQ,aAAa,IAAI,CAAC,EAAE,QAAQ;AAAA,EACrE,IACA,CAAC,UAAU,GAAG,QAAQ,aAAa;AACvC,QAAM,iBAAiB,OAAO,kBAAkB;AAAA,IAC9C;AAAA,IACA;AAAA;AAAA,IAEA;AAAA,IACA,GAAG;AAAA,EACL;AAEA,SAAO,eAAe,KAAK,KAAiC;AAC1D,UAAM,UAAU,MAAM,IAAI,KAAK;AAC/B,UAAM,SAAsC,MAAM,sBAAsB;AAAA,MACtE;AAAA,MACA,SAAS,IAAI;AAAA,MACb,QAAQ,OAAO;AAAA,MACf,SAAS,OAAO;AAAA,MAChB,YAAY,OAAO;AAAA,IACrB,CAAC;AACD,QAAI,CAAC,OAAO,IAAI;AACd,aAAO,SAAS,KAAK,EAAE,IAAI,OAAO,QAAQ,OAAO,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC5E;AAGA,UAAM,QAAQ,CAAC,GAAG,oBAAI,IAAI,CAAC,GAAG,cAAc,OAAO,GAAG,GAAG,cAAc,CAAC,CAAC;AACzE,eAAW,KAAK,OAAO;AACrB,YAAM,OAAO,WAAW,CAAC;AAAA,IAC3B;AAEA,WAAO,SAAS,KAAK;AAAA,MACnB,IAAI;AAAA,MACJ,OAAO,OAAO;AAAA,MACd,YAAY,OAAO;AAAA,MACnB,aAAa;AAAA,IACf,CAAC;AAAA,EACH;AACF;AAwBO,SAAS,iBACd,MACA,eACA,eAAe,SACA;AACf,MAAI,YAAY;AAChB,MAAI;AACF,gBAAY,mBAAmB,aAAa;AAAA,EAC9C,QAAQ;AAAA,EAER;AACA,MAAI,KAAK,SAAS,UAAW,QAAO;AAEpC,SAAO,GAAG,UAAU,YAAY,CAAC,IAAI,mBAAmB,KAAK,IAAI,CAAC;AACpE;AAyBO,SAAS,qBACd,SAA+B,CAAC,GACP;AAEzB,QAAM,MACH,WACE,SAAS,OAAO,CAAC;AACtB,SAAO,eAAe,MAAyB;AAC7C,WAAO,SAAS;AAAA,MACd;AAAA,QACE,MAAM,OAAO,QAAQ,IAAI,iCAAiC;AAAA,QAC1D,WAAW;AAAA,QACX,YAAY;AAAA,QACZ,cACE,OACI,WACA;AAAA,QACN,WAAW,IAAI,uBAAuB,MAAM,GAAG,CAAC,KAAK;AAAA,MACvD;AAAA,MACA,EAAE,SAAS,EAAE,iBAAiB,sBAAsB,EAAE;AAAA,IACxD;AAAA,EACF;AACF;AAKO,IAAM,gBAAgB,EAAE,OAAO,MAAM,QAAQ,IAAI;AACjD,IAAM,wBAAwB;AA8BrC,eAAe,qBAAqB,MAA2C;AAC7E,MAAI;AACF,UAAM,SAAS,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,IAAI,CAAC,CAAC,EAAE,KAAK,EAAE;AAC5D,UAAM,SAAS,uEAAuE,mBAAmB,MAAM,CAAC;AAChH,UAAM,SAAS,MAAM,MAAM,QAAQ;AAAA,MACjC,SAAS,EAAE,cAAc,wCAAwC;AAAA,IACnE,CAAC;AACD,QAAI,CAAC,OAAO,GAAI,QAAO;AACvB,UAAM,MAAM,MAAM,OAAO,KAAK;AAC9B,UAAM,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AACA,QAAI,CAAC,QAAQ,CAAC,EAAG,QAAO;AACxB,UAAM,UAAU,MAAM,MAAM,MAAM,CAAC,CAAC;AACpC,QAAI,CAAC,QAAQ,GAAI,QAAO;AACxB,WAAO,MAAM,QAAQ,YAAY;AAAA,EACnC,SAAS,KAAK;AACZ,YAAQ,MAAM,mFAAqD,GAAG;AACtE,WAAO;AAAA,EACT;AACF;AAwBO,SAAS,kBACd,QACA,MACwF;AACxF,QAAM,aAAa,OAAO,mBAAmB;AAC7C,QAAM,aAAa,OAAO,mBAAmB;AAC7C,QAAM,SAAS,OAAO,eAAe;AACrC,QAAM,QAAQ,OAAO,cAAc,OAAO;AAE1C,SAAO,eAAe,QAAQ,OAA0B;AACtD,UAAM,EAAE,KAAK,IAAI,MAAM,MAAM;AAE7B,QAAI,QAAQ,OAAO;AACnB,QAAI,YAA2B;AAC/B,QAAI;AACF,YAAM,OAAO,MAAM,UAAU;AAAA,QAC3B,QAAQ,OAAO;AAAA,QACf,SAAS,OAAO;AAAA,QAChB,UAAU;AAAA,MACZ,CAAC;AACD,UAAI,MAAM;AACR,gBAAQ,KAAK;AACb,oBAAY,IAAI,KAAK,eAAe,SAAS,EAAE,WAAW,OAAO,CAAC,EAAE;AAAA,UAClE,IAAI,KAAK,KAAK,WAAW;AAAA,QAC3B;AAAA,MACF;AAAA,IACF,SAAS,KAAK;AACZ,cAAQ,MAAM,mEAAyD,GAAG;AAAA,IAC5E;AAGA,UAAM,UAAU,MAAM,SAAS,KAAK,GAAG,MAAM,MAAM,GAAG,EAAE,CAAC,WAAM;AAC/D,UAAM,WAAW,MAAM,qBAAqB,GAAG,OAAO,GAAG,KAAK,GAAG,aAAa,EAAE,EAAE;AAElF,UAAM,EAAE,eAAe,EAAE,IAAI,MAAM,OAAO,OAAO;AACjD,UAAM,UAAU;AAAA,MACd;AAAA,MACA;AAAA,QACE,OAAO;AAAA,UACL,OAAO;AAAA,UACP,QAAQ;AAAA,UACR,SAAS;AAAA,UACT,eAAe;AAAA,UACf,gBAAgB;AAAA,UAChB,SAAS;AAAA,UACT,iBAAiB;AAAA,UACjB,iBAAiB,2BAA2B,UAAU;AAAA,UACtD,OAAO;AAAA,UACP,YAAY,WAAW,eAAe;AAAA,QACxC;AAAA,MACF;AAAA,MACA,EAAE,OAAO;AAAA,QACP,OAAO,EAAE,OAAO,IAAI,QAAQ,IAAI,iBAAiB,QAAQ,cAAc,EAAE;AAAA,MAC3E,CAAC;AAAA,MACD;AAAA,QACE;AAAA,QACA;AAAA,UACE,OAAO;AAAA,YACL,SAAS;AAAA,YACT,UAAU,QAAQ,SAAS,KAAK,KAAK;AAAA,YACrC,YAAY;AAAA,YACZ,YAAY;AAAA,YACZ,eAAe;AAAA,YACf,WAAW;AAAA,UACb;AAAA,QACF;AAAA,QACA;AAAA,MACF;AAAA,MACA;AAAA,QACE;AAAA,QACA;AAAA,UACE,OAAO;AAAA,YACL,SAAS;AAAA,YACT,gBAAgB;AAAA,YAChB,YAAY;AAAA,YACZ,UAAU;AAAA,UACZ;AAAA,QACF;AAAA,QACA,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,QAAQ,OAAO,QAAQ,YAAY,IAAI,EAAE,GAAG,KAAK;AAAA,QAC9E;AAAA,UACE;AAAA,UACA,EAAE,OAAO,EAAE,SAAS,QAAQ,OAAO,yBAAyB,EAAE;AAAA,UAC9D,aAAa,UAAU,OAAO,OAAO,EAAE,QAAQ,gBAAgB,EAAE;AAAA,QACnE;AAAA,MACF;AAAA,IACF;AAEA,WAAO,IAAI,KAAK,cAAc,SAAS;AAAA,MACrC,OAAO,cAAc;AAAA,MACrB,QAAQ,cAAc;AAAA,MACtB,GAAI,WACA,EAAE,OAAO,CAAC,EAAE,MAAM,cAAc,MAAM,UAAU,QAAQ,KAAc,OAAO,SAAkB,CAAC,EAAE,IAClG,CAAC;AAAA,IACP,CAAC;AAAA,EACH;AACF;","names":["resolvePostCollection","resolvePostUrl","postEntries","categoryEntries"]}
|
package/dist/server.js
CHANGED
|
@@ -15,13 +15,13 @@ import {
|
|
|
15
15
|
renderBlock,
|
|
16
16
|
renderBlocks,
|
|
17
17
|
renderBoundBlocks
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-HPERSTZ2.js";
|
|
19
19
|
import {
|
|
20
20
|
collectCategories,
|
|
21
21
|
collectCategoriesWithCounts,
|
|
22
22
|
defaultCategoryHref,
|
|
23
23
|
filterPostsByCategory
|
|
24
|
-
} from "./chunk-
|
|
24
|
+
} from "./chunk-O6KHDNGH.js";
|
|
25
25
|
export {
|
|
26
26
|
RenderTiptap,
|
|
27
27
|
RootTaleBlogCategories,
|
package/dist/templates.d.ts
CHANGED
|
@@ -527,19 +527,72 @@ declare const SiteConfigSchema: z.ZodObject<{
|
|
|
527
527
|
title: string;
|
|
528
528
|
locale: string;
|
|
529
529
|
description?: string | undefined;
|
|
530
|
-
url?: string | undefined;
|
|
531
530
|
og?: {
|
|
532
531
|
image?: string | undefined;
|
|
533
532
|
} | undefined;
|
|
533
|
+
url?: string | undefined;
|
|
534
534
|
}, {
|
|
535
535
|
title: string;
|
|
536
536
|
description?: string | undefined;
|
|
537
|
-
url?: string | undefined;
|
|
538
|
-
locale?: string | undefined;
|
|
539
537
|
og?: {
|
|
540
538
|
image?: string | undefined;
|
|
541
539
|
} | undefined;
|
|
540
|
+
url?: string | undefined;
|
|
541
|
+
locale?: string | undefined;
|
|
542
542
|
}>;
|
|
543
|
+
/**
|
|
544
|
+
* 콘텐츠 스트림 분리 (ADR-0060). 비우면 단일 /blog(레거시 동작). 선언 순서 =
|
|
545
|
+
* 라우팅 우선순위. `routes.ts` 팩토리에 `collections` 로 전달해 sitemap/feed/
|
|
546
|
+
* 아카이브를 스트림별로 파생.
|
|
547
|
+
*/
|
|
548
|
+
collections: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
549
|
+
/** 안정 식별자 (라우트/가드/파생 키). */
|
|
550
|
+
key: z.ZodString;
|
|
551
|
+
/** 작성자 카드·nav 라벨. */
|
|
552
|
+
label: z.ZodString;
|
|
553
|
+
/** 라우트 base path (예: "/notice" | "/blog"). 앞 슬래시 포함. */
|
|
554
|
+
basePath: z.ZodString;
|
|
555
|
+
/** 레이아웃 레지스트리 키 (board=게시판, grid=카드 그리드, …). 미지 키는 렌더러 fallback. */
|
|
556
|
+
layout: z.ZodDefault<z.ZodString>;
|
|
557
|
+
/** 이 스트림에 속하는 category slug 들. */
|
|
558
|
+
categories: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
559
|
+
/** RSS 피드 포함. */
|
|
560
|
+
feed: z.ZodDefault<z.ZodBoolean>;
|
|
561
|
+
/** `${basePath}/categories/{slug}` 아카이브 생성. */
|
|
562
|
+
archives: z.ZodDefault<z.ZodBoolean>;
|
|
563
|
+
/** 글별 동적 OG. */
|
|
564
|
+
og: z.ZodDefault<z.ZodBoolean>;
|
|
565
|
+
/** 작성 UX: 하위 카테고리 1개 필수(미선택 발행 차단). */
|
|
566
|
+
requireSubcategory: z.ZodDefault<z.ZodBoolean>;
|
|
567
|
+
/** 작성 UX: 상단고정 토글 노출. */
|
|
568
|
+
pinning: z.ZodDefault<z.ZodBoolean>;
|
|
569
|
+
/** nav/카드 아이콘 키 (선택). */
|
|
570
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
571
|
+
}, "strip", z.ZodTypeAny, {
|
|
572
|
+
label: string;
|
|
573
|
+
key: string;
|
|
574
|
+
basePath: string;
|
|
575
|
+
layout: string;
|
|
576
|
+
categories: string[];
|
|
577
|
+
feed: boolean;
|
|
578
|
+
archives: boolean;
|
|
579
|
+
og: boolean;
|
|
580
|
+
requireSubcategory: boolean;
|
|
581
|
+
pinning: boolean;
|
|
582
|
+
icon?: string | undefined;
|
|
583
|
+
}, {
|
|
584
|
+
label: string;
|
|
585
|
+
key: string;
|
|
586
|
+
basePath: string;
|
|
587
|
+
layout?: string | undefined;
|
|
588
|
+
categories?: string[] | undefined;
|
|
589
|
+
feed?: boolean | undefined;
|
|
590
|
+
archives?: boolean | undefined;
|
|
591
|
+
og?: boolean | undefined;
|
|
592
|
+
requireSubcategory?: boolean | undefined;
|
|
593
|
+
pinning?: boolean | undefined;
|
|
594
|
+
icon?: string | undefined;
|
|
595
|
+
}>, "many">>;
|
|
543
596
|
/** 사이트 전역 토큰 override (`--rt-*` CSS 변수 → 값). 비우면 admin /design SSoT. */
|
|
544
597
|
theme: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
545
598
|
nav: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
@@ -578,11 +631,24 @@ declare const SiteConfigSchema: z.ZodObject<{
|
|
|
578
631
|
title: string;
|
|
579
632
|
locale: string;
|
|
580
633
|
description?: string | undefined;
|
|
581
|
-
url?: string | undefined;
|
|
582
634
|
og?: {
|
|
583
635
|
image?: string | undefined;
|
|
584
636
|
} | undefined;
|
|
637
|
+
url?: string | undefined;
|
|
585
638
|
};
|
|
639
|
+
collections: {
|
|
640
|
+
label: string;
|
|
641
|
+
key: string;
|
|
642
|
+
basePath: string;
|
|
643
|
+
layout: string;
|
|
644
|
+
categories: string[];
|
|
645
|
+
feed: boolean;
|
|
646
|
+
archives: boolean;
|
|
647
|
+
og: boolean;
|
|
648
|
+
requireSubcategory: boolean;
|
|
649
|
+
pinning: boolean;
|
|
650
|
+
icon?: string | undefined;
|
|
651
|
+
}[];
|
|
586
652
|
nav: {
|
|
587
653
|
label: string;
|
|
588
654
|
href: string;
|
|
@@ -596,14 +662,27 @@ declare const SiteConfigSchema: z.ZodObject<{
|
|
|
596
662
|
site: {
|
|
597
663
|
title: string;
|
|
598
664
|
description?: string | undefined;
|
|
599
|
-
url?: string | undefined;
|
|
600
|
-
locale?: string | undefined;
|
|
601
665
|
og?: {
|
|
602
666
|
image?: string | undefined;
|
|
603
667
|
} | undefined;
|
|
668
|
+
url?: string | undefined;
|
|
669
|
+
locale?: string | undefined;
|
|
604
670
|
};
|
|
605
671
|
pages: Record<string, SectionInput[]>;
|
|
606
672
|
theme?: Record<string, string> | undefined;
|
|
673
|
+
collections?: {
|
|
674
|
+
label: string;
|
|
675
|
+
key: string;
|
|
676
|
+
basePath: string;
|
|
677
|
+
layout?: string | undefined;
|
|
678
|
+
categories?: string[] | undefined;
|
|
679
|
+
feed?: boolean | undefined;
|
|
680
|
+
archives?: boolean | undefined;
|
|
681
|
+
og?: boolean | undefined;
|
|
682
|
+
requireSubcategory?: boolean | undefined;
|
|
683
|
+
pinning?: boolean | undefined;
|
|
684
|
+
icon?: string | undefined;
|
|
685
|
+
}[] | undefined;
|
|
607
686
|
nav?: {
|
|
608
687
|
label: string;
|
|
609
688
|
href: string;
|
package/dist/templates.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RootTaleBlogList,
|
|
3
3
|
RootTaleLeadForm
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-HPERSTZ2.js";
|
|
5
|
+
import "./chunk-O6KHDNGH.js";
|
|
6
6
|
|
|
7
7
|
// src/templates/site-config.ts
|
|
8
8
|
import { z } from "zod";
|
|
@@ -130,6 +130,30 @@ var SectionSchema = z.object({ type: z.string() }).passthrough().transform((sect
|
|
|
130
130
|
}
|
|
131
131
|
return result.data;
|
|
132
132
|
});
|
|
133
|
+
var CollectionSchema = z.object({
|
|
134
|
+
/** 안정 식별자 (라우트/가드/파생 키). */
|
|
135
|
+
key: z.string(),
|
|
136
|
+
/** 작성자 카드·nav 라벨. */
|
|
137
|
+
label: z.string(),
|
|
138
|
+
/** 라우트 base path (예: "/notice" | "/blog"). 앞 슬래시 포함. */
|
|
139
|
+
basePath: z.string(),
|
|
140
|
+
/** 레이아웃 레지스트리 키 (board=게시판, grid=카드 그리드, …). 미지 키는 렌더러 fallback. */
|
|
141
|
+
layout: z.string().default("grid"),
|
|
142
|
+
/** 이 스트림에 속하는 category slug 들. */
|
|
143
|
+
categories: z.array(z.string()).default([]),
|
|
144
|
+
/** RSS 피드 포함. */
|
|
145
|
+
feed: z.boolean().default(false),
|
|
146
|
+
/** `${basePath}/categories/{slug}` 아카이브 생성. */
|
|
147
|
+
archives: z.boolean().default(false),
|
|
148
|
+
/** 글별 동적 OG. */
|
|
149
|
+
og: z.boolean().default(false),
|
|
150
|
+
/** 작성 UX: 하위 카테고리 1개 필수(미선택 발행 차단). */
|
|
151
|
+
requireSubcategory: z.boolean().default(false),
|
|
152
|
+
/** 작성 UX: 상단고정 토글 노출. */
|
|
153
|
+
pinning: z.boolean().default(false),
|
|
154
|
+
/** nav/카드 아이콘 키 (선택). */
|
|
155
|
+
icon: z.string().optional()
|
|
156
|
+
});
|
|
133
157
|
var SiteConfigSchema = z.object({
|
|
134
158
|
site: z.object({
|
|
135
159
|
/** canonical origin. 생략 시 소비측이 `SITE_URL` env 등으로 해석. */
|
|
@@ -139,6 +163,12 @@ var SiteConfigSchema = z.object({
|
|
|
139
163
|
locale: z.string().default("ko-KR"),
|
|
140
164
|
og: z.object({ image: z.string().optional() }).optional()
|
|
141
165
|
}),
|
|
166
|
+
/**
|
|
167
|
+
* 콘텐츠 스트림 분리 (ADR-0060). 비우면 단일 /blog(레거시 동작). 선언 순서 =
|
|
168
|
+
* 라우팅 우선순위. `routes.ts` 팩토리에 `collections` 로 전달해 sitemap/feed/
|
|
169
|
+
* 아카이브를 스트림별로 파생.
|
|
170
|
+
*/
|
|
171
|
+
collections: z.array(CollectionSchema).default([]),
|
|
142
172
|
/** 사이트 전역 토큰 override (`--rt-*` CSS 변수 → 값). 비우면 admin /design SSoT. */
|
|
143
173
|
theme: z.record(z.string()).default({}),
|
|
144
174
|
nav: z.array(NavItemSchema).default([]),
|
package/dist/templates.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/templates/site-config.ts","../src/templates/sections.tsx","../src/templates/SiteRenderer.tsx"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/templates` — canonical `SiteConfig` 스키마.\n *\n * ADR-0040 P2 + P0.5 RFC §① (`roottale-docs/docs/services/external-site-blockification.md`).\n *\n * 설계 원칙 (codex consult #1 반영):\n * - **계약 = `section type + variant + renderer registry`.** tier 는 스키마 필드가\n * 아니라 문서/거버넌스용 성숙도 메타데이터.\n * - **100% serializable** — 함수/JSX 금지 (Astro parity + admin preview 동일 경로).\n * escape hatch 는 config 안 함수가 아니라 `customSections` registry\n * (`SiteRenderer` 에 빌드 시 type→컴포넌트 주입).\n * - CMS-bound 섹션(`blog-list`/`lead-form`)은 `RootTaleBlogList`/`RootTaleLeadForm`\n * 으로 위임 — templates 는 cms-renderer 코어를 단방향 의존 (RFC §②).\n *\n * RFC §① 대비 구현 정정:\n * - `site.url` 은 optional — 스타터/외부 사이트가 `SITE_URL` env 로 런타임 해석하는\n * 기존 패턴(P4 스타터)과 충돌하므로 config 강제 대신 env fallback 허용.\n * - 빌트인에 `rich-text` 추가 (P4 스타터 기 운영 섹션 — \"동일 패턴\" 군).\n */\nimport { z } from \"zod\";\n\nconst JsonValue: z.ZodType<unknown> = z.lazy(() =>\n z.union([\n z.string(),\n z.number(),\n z.boolean(),\n z.null(),\n z.array(JsonValue),\n z.record(JsonValue),\n ]),\n);\n\nexport const NavItemSchema = z.object({\n label: z.string(),\n href: z.string(),\n children: z\n .array(z.object({ label: z.string(), href: z.string() }))\n .optional(),\n});\nexport type NavItem = z.infer<typeof NavItemSchema>;\n\nconst CtaSchema = z.object({ label: z.string(), href: z.string() });\nexport type SectionCta = z.infer<typeof CtaSchema>;\n\n/**\n * 모든 섹션 공통 필드. `theme` 는 섹션 스코프 CSS 변수 override —\n * key 는 `--rt-*` custom property 이름, 값은 CSS 값.\n * 예) `{ \"--rt-color-primary\": \"#0B1F49\" }`\n */\nconst SectionBase = z.object({\n /** 안정적 anchor·dedup·preview 매핑용. 페이지 내 유일. */\n id: z.string().optional(),\n /** 빌트인 또는 customSections registry 키. kebab-case. */\n type: z.string(),\n /** 같은 type 의 비주얼 변형. 빌트인 type 은 enum 으로 좁힘. */\n variant: z.string().optional(),\n /** 섹션 스코프 토큰 override (`--rt-*` CSS 변수 → 값). */\n theme: z.record(z.string()).optional(),\n /** 섹션별 직렬화 props. */\n props: z.record(JsonValue).default({}),\n});\n\n// ── 빌트인 섹션 (props 좁힘 — 런타임 + 타입) ────────────────────────────────\n\nexport const HeroSectionSchema = SectionBase.extend({\n type: z.literal(\"hero\"),\n variant: z.enum([\"split\", \"image\", \"minimal\"]).default(\"split\"),\n props: z.object({\n eyebrow: z.string().optional(),\n /** `\\n` 으로 줄바꿈. */\n title: z.string(),\n description: z.string().optional(),\n imageSrc: z.string().optional(),\n imageAlt: z.string().optional(),\n cta: CtaSchema.optional(),\n }),\n});\n\nexport const RichTextSectionSchema = SectionBase.extend({\n type: z.literal(\"rich-text\"),\n props: z.object({\n heading: z.string().optional(),\n /** `\\n\\n` 으로 문단 분리. */\n body: z.string(),\n }),\n});\n\nexport const ServicesSectionSchema = SectionBase.extend({\n type: z.literal(\"services\"),\n variant: z.enum([\"grid\", \"list\"]).default(\"grid\"),\n props: z.object({\n heading: z.string().optional(),\n description: z.string().optional(),\n items: z\n .array(\n z.object({\n title: z.string(),\n description: z.string().optional(),\n href: z.string().optional(),\n }),\n )\n .min(1),\n }),\n});\n\nexport const FaqSectionSchema = SectionBase.extend({\n type: z.literal(\"faq\"),\n props: z.object({\n heading: z.string().optional(),\n items: z\n .array(z.object({ question: z.string(), answer: z.string() }))\n .min(1),\n /** FAQPage JSON-LD 주입 (기본 true — SEO 3 pillars). 페이지에 FAQ 섹션이 여럿이면 하나만 true 로. */\n jsonLd: z.boolean().default(true),\n }),\n});\n\nexport const CtaBannerSectionSchema = SectionBase.extend({\n type: z.literal(\"cta-banner\"),\n props: z.object({\n title: z.string(),\n description: z.string().optional(),\n cta: CtaSchema.optional(),\n }),\n});\n\n/** → `RootTaleBlogList` 위임 (cms-bound). */\nexport const BlogListSectionSchema = SectionBase.extend({\n type: z.literal(\"blog-list\"),\n props: z.object({\n heading: z.string().optional(),\n limit: z.number().int().positive().optional(),\n /** 포스트 링크 베이스. 기본 `/blog`. */\n blogBasePath: z.string().optional(),\n emptyMessage: z.string().optional(),\n }),\n});\n\n/** → `RootTaleLeadForm` 위임 (cms-bound). */\nexport const LeadFormSectionSchema = SectionBase.extend({\n type: z.literal(\"lead-form\"),\n props: z.object({\n /** 사이트 자체 server 엔드포인트 (예: `/api/inquiry`). intake API 직접 지정 금지. */\n action: z.string(),\n vertical: z.enum([\"consulting\", \"medical\", \"tax\", \"legal\"]).optional(),\n heading: z.string().optional(),\n description: z.string().optional(),\n submitLabel: z.string().optional(),\n }),\n});\n\n/** customSections 용 fallback — `type` 자유, props 는 직렬화만 강제. */\nexport const CustomSectionSchema = SectionBase;\n\nconst BUILTIN_SECTION_SCHEMAS: Record<string, z.ZodTypeAny> = {\n hero: HeroSectionSchema,\n \"rich-text\": RichTextSectionSchema,\n services: ServicesSectionSchema,\n faq: FaqSectionSchema,\n \"cta-banner\": CtaBannerSectionSchema,\n \"blog-list\": BlogListSectionSchema,\n \"lead-form\": LeadFormSectionSchema,\n};\n\n/** 빌트인 type 목록 (renderer registry 와 1:1). */\nexport const BUILTIN_SECTION_TYPES = Object.keys(\n BUILTIN_SECTION_SCHEMAS,\n) as readonly string[];\n\nexport type HeroSection = z.output<typeof HeroSectionSchema>;\nexport type RichTextSection = z.output<typeof RichTextSectionSchema>;\nexport type ServicesSection = z.output<typeof ServicesSectionSchema>;\nexport type FaqSection = z.output<typeof FaqSectionSchema>;\nexport type CtaBannerSection = z.output<typeof CtaBannerSectionSchema>;\nexport type BlogListSection = z.output<typeof BlogListSectionSchema>;\nexport type LeadFormSection = z.output<typeof LeadFormSectionSchema>;\nexport type CustomSection = z.output<typeof CustomSectionSchema>;\n\n/** 파싱 결과 섹션 (defaults 적용 후). */\nexport type Section =\n | HeroSection\n | RichTextSection\n | ServicesSection\n | FaqSection\n | CtaBannerSection\n | BlogListSection\n | LeadFormSection\n | CustomSection;\n\n/** config 작성용 섹션 (defaults 생략 가능). `site.config.ts` 에서 이 타입 사용. */\nexport type SectionInput =\n | z.input<typeof HeroSectionSchema>\n | z.input<typeof RichTextSectionSchema>\n | z.input<typeof ServicesSectionSchema>\n | z.input<typeof FaqSectionSchema>\n | z.input<typeof CtaBannerSectionSchema>\n | z.input<typeof BlogListSectionSchema>\n | z.input<typeof LeadFormSectionSchema>\n | z.input<typeof CustomSectionSchema>;\n\n/**\n * 섹션 스키마 — 빌트인 type 이면 해당 스키마로 *강제* 검증 (union fallthrough 로\n * 빌트인 오타이핑이 CustomSection 으로 조용히 통과하는 것을 차단), 미지 type 은\n * CustomSection 으로 통과 (렌더러가 customSections registry 로 해석).\n */\nexport const SectionSchema: z.ZodType<Section, z.ZodTypeDef, SectionInput> = z\n .object({ type: z.string() })\n .passthrough()\n .transform((section, ctx) => {\n const schema = BUILTIN_SECTION_SCHEMAS[section.type] ?? CustomSectionSchema;\n const result = schema.safeParse(section);\n if (!result.success) {\n for (const issue of result.error.issues) ctx.addIssue(issue);\n return z.NEVER;\n }\n return result.data as Section;\n }) as unknown as z.ZodType<Section, z.ZodTypeDef, SectionInput>;\n\nexport const SiteConfigSchema = z.object({\n site: z.object({\n /** canonical origin. 생략 시 소비측이 `SITE_URL` env 등으로 해석. */\n url: z.string().url().optional(),\n title: z.string(),\n description: z.string().optional(),\n locale: z.string().default(\"ko-KR\"),\n og: z.object({ image: z.string().optional() }).optional(),\n }),\n /** 사이트 전역 토큰 override (`--rt-*` CSS 변수 → 값). 비우면 admin /design SSoT. */\n theme: z.record(z.string()).default({}),\n nav: z.array(NavItemSchema).default([]),\n /** path(\"/\", \"/services\", …) → 섹션 배열. */\n pages: z.record(z.array(SectionSchema)),\n});\n\nexport type SiteConfig = z.output<typeof SiteConfigSchema>;\n/** config 작성용 (defaults 생략 가능). */\nexport type SiteConfigInput = Omit<\n z.input<typeof SiteConfigSchema>,\n \"pages\"\n> & { pages: Record<string, SectionInput[]> };\n\n/** config 검증 + defaults 적용. 실패 시 ZodError throw (빌드 타임 fail-fast 용). */\nexport function parseSiteConfig(input: unknown): SiteConfig {\n return SiteConfigSchema.parse(input);\n}\n\n/**\n * `site.config.ts` 작성 헬퍼 — 타입 추론 + 즉시 검증.\n *\n * ```ts\n * import { defineSiteConfig } from \"@roottale/cms-renderer-next/templates\";\n * export default defineSiteConfig({ site: { title: \"…\" }, pages: { \"/\": [ … ] } });\n * ```\n */\nexport function defineSiteConfig(input: SiteConfigInput): SiteConfig {\n return parseSiteConfig(input);\n}\n","/**\n * 빌트인 섹션 템플릿 — MUI-free, 0 JS RSC (ADR-0040 P2).\n *\n * 클래스는 전부 `rt-tpl-*` prefix, 스코프는 섹션 루트의 `data-roottale-tpl`\n * (templates.css 가 `[data-roottale-tpl] :where(…)` 로 zero-specificity 적용 —\n * customer 는 plain class / Tailwind utility 로 자유 override, cms-public.css\n * 와 동일 계약).\n *\n * 토큰은 `--rt-*` CSS 변수 + 정적 fallback — `@roottale/tokens`(private) 미의존\n * (RFC §②). admin /design 값은 `RootTaleThemeProvider` 가 주입.\n */\nimport type { CSSProperties, ReactElement } from \"react\";\n\nimport type {\n CtaBannerSection,\n FaqSection,\n HeroSection,\n RichTextSection,\n SectionCta,\n ServicesSection,\n} from \"./site-config.js\";\n\n/** 섹션 `theme`(--rt-* 변수 override)를 inline style 로. */\nexport function sectionThemeStyle(\n theme: Record<string, string> | undefined,\n): CSSProperties | undefined {\n if (!theme) return undefined;\n const style: Record<string, string> = {};\n for (const [key, value] of Object.entries(theme)) {\n if (key.startsWith(\"--\")) style[key] = value;\n }\n return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined;\n}\n\nfunction CtaLink({\n cta,\n className,\n}: {\n cta: SectionCta;\n className?: string;\n}): ReactElement {\n return (\n <a className={className ?? \"rt-tpl-btn rt-tpl-btn--primary\"} href={cta.href}>\n {cta.label}\n </a>\n );\n}\n\n/** 빌트인 `hero`. variant: split(기본) / image / minimal. */\nexport function TemplateHero(section: HeroSection): ReactElement {\n const { eyebrow, title, description, imageSrc, imageAlt, cta } =\n section.props;\n const variant = section.variant;\n const showImage = variant !== \"minimal\" && imageSrc;\n return (\n <section\n data-roottale-tpl=\"hero\"\n data-variant={variant}\n id={section.id}\n className=\"rt-tpl-section rt-tpl-hero\"\n style={sectionThemeStyle(section.theme)}\n >\n <div className=\"rt-tpl-container rt-tpl-hero-inner\">\n <div className=\"rt-tpl-hero-copy\">\n {eyebrow ? <p className=\"rt-tpl-hero-eyebrow\">{eyebrow}</p> : null}\n <h1 className=\"rt-tpl-hero-title\">\n {title.split(\"\\n\").map((line, i) => (\n <span key={i} className=\"rt-tpl-hero-title-line\">\n {line}\n </span>\n ))}\n </h1>\n {description ? (\n <p className=\"rt-tpl-hero-desc\">{description}</p>\n ) : null}\n {cta ? <CtaLink cta={cta} /> : null}\n </div>\n {showImage ? (\n <div className=\"rt-tpl-hero-media\">\n <img\n className=\"rt-tpl-hero-img\"\n src={imageSrc}\n alt={imageAlt ?? \"\"}\n loading=\"eager\"\n />\n </div>\n ) : null}\n </div>\n </section>\n );\n}\n\n/** 빌트인 `rich-text`. 문단은 `\\n\\n` 분리. */\nexport function TemplateRichText(section: RichTextSection): ReactElement {\n const { heading, body } = section.props;\n return (\n <section\n data-roottale-tpl=\"rich-text\"\n id={section.id}\n className=\"rt-tpl-section rt-tpl-rich-text\"\n style={sectionThemeStyle(section.theme)}\n >\n <div className=\"rt-tpl-container\">\n {heading ? <h2 className=\"rt-tpl-heading\">{heading}</h2> : null}\n {body.split(\"\\n\\n\").map((para, i) => (\n <p key={i} className=\"rt-tpl-rich-text-p\">\n {para}\n </p>\n ))}\n </div>\n </section>\n );\n}\n\n/** 빌트인 `services`. variant: grid(기본) / list. */\nexport function TemplateServices(section: ServicesSection): ReactElement {\n const { heading, description, items } = section.props;\n return (\n <section\n data-roottale-tpl=\"services\"\n data-variant={section.variant}\n id={section.id}\n className=\"rt-tpl-section rt-tpl-services\"\n style={sectionThemeStyle(section.theme)}\n >\n <div className=\"rt-tpl-container\">\n {heading ? <h2 className=\"rt-tpl-heading\">{heading}</h2> : null}\n {description ? (\n <p className=\"rt-tpl-section-desc\">{description}</p>\n ) : null}\n <ul className=\"rt-tpl-services-list\">\n {items.map((item, i) => {\n const body = (\n <>\n <h3 className=\"rt-tpl-services-title\">{item.title}</h3>\n {item.description ? (\n <p className=\"rt-tpl-services-desc\">{item.description}</p>\n ) : null}\n </>\n );\n return (\n <li key={i} className=\"rt-tpl-services-item\">\n {item.href ? (\n <a className=\"rt-tpl-services-link\" href={item.href}>\n {body}\n </a>\n ) : (\n body\n )}\n </li>\n );\n })}\n </ul>\n </div>\n </section>\n );\n}\n\n/** 빌트인 `faq`. `<details>` 0 JS + FAQPage JSON-LD (기본 on). */\nexport function TemplateFaq(section: FaqSection): ReactElement {\n const { heading, items, jsonLd } = section.props;\n return (\n <section\n data-roottale-tpl=\"faq\"\n id={section.id}\n className=\"rt-tpl-section rt-tpl-faq\"\n style={sectionThemeStyle(section.theme)}\n >\n <div className=\"rt-tpl-container\">\n {heading ? <h2 className=\"rt-tpl-heading\">{heading}</h2> : null}\n <div className=\"rt-tpl-faq-list\">\n {items.map((item, i) => (\n <details key={i} className=\"rt-tpl-faq-item\">\n <summary className=\"rt-tpl-faq-q\">{item.question}</summary>\n <p className=\"rt-tpl-faq-a\">{item.answer}</p>\n </details>\n ))}\n </div>\n </div>\n {jsonLd ? (\n <script\n type=\"application/ld+json\"\n dangerouslySetInnerHTML={{\n __html: JSON.stringify({\n \"@context\": \"https://schema.org\",\n \"@type\": \"FAQPage\",\n mainEntity: items.map((item) => ({\n \"@type\": \"Question\",\n name: item.question,\n acceptedAnswer: { \"@type\": \"Answer\", text: item.answer },\n })),\n }),\n }}\n />\n ) : null}\n </section>\n );\n}\n\n/** 빌트인 `cta-banner`. */\nexport function TemplateCtaBanner(section: CtaBannerSection): ReactElement {\n const { title, description, cta } = section.props;\n return (\n <section\n data-roottale-tpl=\"cta-banner\"\n id={section.id}\n className=\"rt-tpl-section rt-tpl-cta-banner\"\n style={sectionThemeStyle(section.theme)}\n >\n <div className=\"rt-tpl-container rt-tpl-cta-banner-inner\">\n <h2 className=\"rt-tpl-cta-banner-title\">{title}</h2>\n {description ? (\n <p className=\"rt-tpl-cta-banner-desc\">{description}</p>\n ) : null}\n {cta ? <CtaLink cta={cta} /> : null}\n </div>\n </section>\n );\n}\n","/**\n * `SiteRenderer` — `SiteConfig.pages[path]` 섹션 배열을 registry 로 렌더.\n *\n * ADR-0040 P2 + P0.5 RFC §①:\n * - 빌트인 registry + 클라 주입 `customSections` 머지. **빌트인이 우선** —\n * 클라가 빌트인 type 을 가로채 계약이 갈라지는 것 방지 (고유 섹션은 고유\n * type 으로).\n * - **render-prop 금지**: 임의 JSX escape hatch 는 customSections registry 로만.\n * - **admin preview parity**: admin draft/preview 도 동일 `SiteRenderer` + 동일\n * 빌트인 registry 를 탄다. customSections 는 클라 repo 자산 — preview 에선\n * 미주입 → `unknownSection: \"placeholder\"` 로 skeleton 표시.\n * - cms-bound 섹션(blog-list/lead-form)은 `RootTaleBlogList`/`RootTaleLeadForm`\n * 위임 (단방향: templates → renderer 코어).\n */\nimport {\n Fragment,\n type CSSProperties,\n type ReactElement,\n type ReactNode,\n} from \"react\";\n\nimport { RootTaleLeadForm } from \"../LeadForm.js\";\nimport { RootTaleBlogList } from \"../server.js\";\nimport {\n TemplateCtaBanner,\n TemplateFaq,\n TemplateHero,\n TemplateRichText,\n TemplateServices,\n sectionThemeStyle,\n} from \"./sections.js\";\nimport {\n type BlogListSection,\n type CtaBannerSection,\n type FaqSection,\n type HeroSection,\n type LeadFormSection,\n type RichTextSection,\n type Section,\n type ServicesSection,\n type SiteConfig,\n type SiteConfigInput,\n parseSiteConfig,\n} from \"./site-config.js\";\n\n/** customSections registry 컴포넌트 계약 (RFC §①). */\nexport type SectionComponent = (p: {\n id?: string;\n props: Record<string, unknown>;\n variant?: string;\n theme?: Record<string, string>;\n}) => ReactNode;\n\nexport interface SiteRendererProps {\n /** `defineSiteConfig()` 결과 또는 raw config (내부에서 검증). */\n config: SiteConfig | SiteConfigInput;\n /** 현재 페이지 경로 — `config.pages` 의 key (\"/\", \"/services\", …). */\n path: string;\n /** cms-bound 섹션(blog-list/lead-form 의 theme fetch) 위임용. 서버 전용. */\n apiKey: string;\n /** self-host 등 API base override (`ROOTTALE_API_BASE`). */\n baseUrl?: string;\n /** 클라 repo 고유 섹션 registry (escape hatch). 빌트인 type 은 가로챌 수 없음. */\n customSections?: Record<string, SectionComponent>;\n /**\n * registry 에 없는 type 처리. 기본 \"warn-dev\" = dev 에서 경고 박스, prod 에서\n * 생략. admin preview 는 \"placeholder\" (customSections 미주입 환경).\n */\n unknownSection?: \"warn-dev\" | \"placeholder\" | \"ignore\";\n}\n\nfunction isDev(): boolean {\n // dts 빌드는 node types 없이 돈다 — process 는 globalThis 경유로 안전 접근.\n const env =\n (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env ?? {};\n return env.NODE_ENV !== \"production\";\n}\n\nfunction UnknownSection({\n type,\n mode,\n}: {\n type: string;\n mode: \"warn-dev\" | \"placeholder\" | \"ignore\";\n}): ReactElement | null {\n if (mode === \"ignore\") return null;\n if (mode === \"placeholder\") {\n return (\n <section\n data-roottale-tpl=\"placeholder\"\n className=\"rt-tpl-section rt-tpl-placeholder\"\n aria-hidden\n >\n <div className=\"rt-tpl-container\">\n <div className=\"rt-tpl-placeholder-box\">{type}</div>\n </div>\n </section>\n );\n }\n if (!isDev()) return null;\n const style: CSSProperties = {\n padding: \"1rem\",\n border: \"1px dashed #c00\",\n color: \"#c00\",\n };\n return (\n <div style={style}>\n 알 수 없는 섹션 type: <code>{type}</code> — SiteRenderer 의 customSections\n 에 등록하세요.\n </div>\n );\n}\n\nfunction renderBuiltin(\n section: Section,\n ctx: { apiKey: string; baseUrl?: string },\n): ReactNode | undefined {\n switch (section.type) {\n case \"hero\":\n return <TemplateHero {...(section as HeroSection)} />;\n case \"rich-text\":\n return <TemplateRichText {...(section as RichTextSection)} />;\n case \"services\":\n return <TemplateServices {...(section as ServicesSection)} />;\n case \"faq\":\n return <TemplateFaq {...(section as FaqSection)} />;\n case \"cta-banner\":\n return <TemplateCtaBanner {...(section as CtaBannerSection)} />;\n case \"blog-list\": {\n const s = section as BlogListSection;\n const base = s.props.blogBasePath ?? \"/blog\";\n return (\n <section\n data-roottale-tpl=\"blog-list\"\n data-roottale-cms=\"list\"\n id={s.id}\n className=\"rt-tpl-section rt-tpl-blog-list\"\n style={sectionThemeStyle(s.theme)}\n >\n <div className=\"rt-tpl-container\">\n {s.props.heading ? (\n <h2 className=\"rt-tpl-heading\">{s.props.heading}</h2>\n ) : null}\n <RootTaleBlogList\n apiKey={ctx.apiKey}\n baseUrl={ctx.baseUrl}\n limit={s.props.limit}\n emptyMessage={s.props.emptyMessage}\n postHref={(post) => `${base}/${post.slug}`}\n />\n </div>\n </section>\n );\n }\n case \"lead-form\": {\n const s = section as LeadFormSection;\n return (\n <section\n data-roottale-tpl=\"lead-form\"\n data-roottale-cms=\"lead-form\"\n id={s.id}\n className=\"rt-tpl-section rt-tpl-lead-form\"\n style={sectionThemeStyle(s.theme)}\n >\n <div className=\"rt-tpl-container\">\n <RootTaleLeadForm\n action={s.props.action}\n vertical={s.props.vertical}\n heading={s.props.heading}\n description={s.props.description}\n submitLabel={s.props.submitLabel}\n />\n </div>\n </section>\n );\n }\n default:\n return undefined;\n }\n}\n\n/**\n * 한 페이지의 섹션 배열을 순서대로 렌더.\n *\n * ```tsx\n * // app/services/page.tsx\n * import config from \"@/site.config\";\n * import { SiteRenderer } from \"@roottale/cms-renderer-next/templates\";\n *\n * export default function ServicesPage() {\n * return (\n * <SiteRenderer config={config} path=\"/services\"\n * apiKey={process.env.ROOTTALE_API_KEY!} />\n * );\n * }\n * ```\n */\nexport function SiteRenderer(props: SiteRendererProps): ReactElement {\n const {\n path,\n apiKey,\n baseUrl,\n customSections = {},\n unknownSection = \"warn-dev\",\n } = props;\n const config = parseSiteConfig(props.config);\n const sections = config.pages[path] ?? [];\n return (\n <>\n {sections.length === 0 ? (\n <UnknownSection\n type={`pages[\"${path}\"] (섹션 0개)`}\n mode={unknownSection}\n />\n ) : null}\n {sections.map((section, i) => {\n const key = section.id ?? `${section.type}-${i}`;\n const builtin = renderBuiltin(section, { apiKey, baseUrl });\n if (builtin !== undefined) {\n return <Fragment key={key}>{builtin}</Fragment>;\n }\n const Custom = customSections[section.type];\n if (Custom) {\n return (\n <Fragment key={key}>\n {Custom({\n id: section.id,\n props: section.props as Record<string, unknown>,\n variant: section.variant,\n theme: section.theme,\n })}\n </Fragment>\n );\n }\n return (\n <UnknownSection key={key} type={section.type} mode={unknownSection} />\n );\n })}\n </>\n );\n}\n"],"mappings":"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","names":["Fragment","Fragment","jsx","jsxs"]}
|
|
1
|
+
{"version":3,"sources":["../src/templates/site-config.ts","../src/templates/sections.tsx","../src/templates/SiteRenderer.tsx"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/templates` — canonical `SiteConfig` 스키마.\n *\n * ADR-0040 P2 + P0.5 RFC §① (`roottale-docs/docs/services/external-site-blockification.md`).\n *\n * 설계 원칙 (codex consult #1 반영):\n * - **계약 = `section type + variant + renderer registry`.** tier 는 스키마 필드가\n * 아니라 문서/거버넌스용 성숙도 메타데이터.\n * - **100% serializable** — 함수/JSX 금지 (Astro parity + admin preview 동일 경로).\n * escape hatch 는 config 안 함수가 아니라 `customSections` registry\n * (`SiteRenderer` 에 빌드 시 type→컴포넌트 주입).\n * - CMS-bound 섹션(`blog-list`/`lead-form`)은 `RootTaleBlogList`/`RootTaleLeadForm`\n * 으로 위임 — templates 는 cms-renderer 코어를 단방향 의존 (RFC §②).\n *\n * RFC §① 대비 구현 정정:\n * - `site.url` 은 optional — 스타터/외부 사이트가 `SITE_URL` env 로 런타임 해석하는\n * 기존 패턴(P4 스타터)과 충돌하므로 config 강제 대신 env fallback 허용.\n * - 빌트인에 `rich-text` 추가 (P4 스타터 기 운영 섹션 — \"동일 패턴\" 군).\n */\nimport { z } from \"zod\";\n\nconst JsonValue: z.ZodType<unknown> = z.lazy(() =>\n z.union([\n z.string(),\n z.number(),\n z.boolean(),\n z.null(),\n z.array(JsonValue),\n z.record(JsonValue),\n ]),\n);\n\nexport const NavItemSchema = z.object({\n label: z.string(),\n href: z.string(),\n children: z\n .array(z.object({ label: z.string(), href: z.string() }))\n .optional(),\n});\nexport type NavItem = z.infer<typeof NavItemSchema>;\n\nconst CtaSchema = z.object({ label: z.string(), href: z.string() });\nexport type SectionCta = z.infer<typeof CtaSchema>;\n\n/**\n * 모든 섹션 공통 필드. `theme` 는 섹션 스코프 CSS 변수 override —\n * key 는 `--rt-*` custom property 이름, 값은 CSS 값.\n * 예) `{ \"--rt-color-primary\": \"#0B1F49\" }`\n */\nconst SectionBase = z.object({\n /** 안정적 anchor·dedup·preview 매핑용. 페이지 내 유일. */\n id: z.string().optional(),\n /** 빌트인 또는 customSections registry 키. kebab-case. */\n type: z.string(),\n /** 같은 type 의 비주얼 변형. 빌트인 type 은 enum 으로 좁힘. */\n variant: z.string().optional(),\n /** 섹션 스코프 토큰 override (`--rt-*` CSS 변수 → 값). */\n theme: z.record(z.string()).optional(),\n /** 섹션별 직렬화 props. */\n props: z.record(JsonValue).default({}),\n});\n\n// ── 빌트인 섹션 (props 좁힘 — 런타임 + 타입) ────────────────────────────────\n\nexport const HeroSectionSchema = SectionBase.extend({\n type: z.literal(\"hero\"),\n variant: z.enum([\"split\", \"image\", \"minimal\"]).default(\"split\"),\n props: z.object({\n eyebrow: z.string().optional(),\n /** `\\n` 으로 줄바꿈. */\n title: z.string(),\n description: z.string().optional(),\n imageSrc: z.string().optional(),\n imageAlt: z.string().optional(),\n cta: CtaSchema.optional(),\n }),\n});\n\nexport const RichTextSectionSchema = SectionBase.extend({\n type: z.literal(\"rich-text\"),\n props: z.object({\n heading: z.string().optional(),\n /** `\\n\\n` 으로 문단 분리. */\n body: z.string(),\n }),\n});\n\nexport const ServicesSectionSchema = SectionBase.extend({\n type: z.literal(\"services\"),\n variant: z.enum([\"grid\", \"list\"]).default(\"grid\"),\n props: z.object({\n heading: z.string().optional(),\n description: z.string().optional(),\n items: z\n .array(\n z.object({\n title: z.string(),\n description: z.string().optional(),\n href: z.string().optional(),\n }),\n )\n .min(1),\n }),\n});\n\nexport const FaqSectionSchema = SectionBase.extend({\n type: z.literal(\"faq\"),\n props: z.object({\n heading: z.string().optional(),\n items: z\n .array(z.object({ question: z.string(), answer: z.string() }))\n .min(1),\n /** FAQPage JSON-LD 주입 (기본 true — SEO 3 pillars). 페이지에 FAQ 섹션이 여럿이면 하나만 true 로. */\n jsonLd: z.boolean().default(true),\n }),\n});\n\nexport const CtaBannerSectionSchema = SectionBase.extend({\n type: z.literal(\"cta-banner\"),\n props: z.object({\n title: z.string(),\n description: z.string().optional(),\n cta: CtaSchema.optional(),\n }),\n});\n\n/** → `RootTaleBlogList` 위임 (cms-bound). */\nexport const BlogListSectionSchema = SectionBase.extend({\n type: z.literal(\"blog-list\"),\n props: z.object({\n heading: z.string().optional(),\n limit: z.number().int().positive().optional(),\n /** 포스트 링크 베이스. 기본 `/blog`. */\n blogBasePath: z.string().optional(),\n emptyMessage: z.string().optional(),\n }),\n});\n\n/** → `RootTaleLeadForm` 위임 (cms-bound). */\nexport const LeadFormSectionSchema = SectionBase.extend({\n type: z.literal(\"lead-form\"),\n props: z.object({\n /** 사이트 자체 server 엔드포인트 (예: `/api/inquiry`). intake API 직접 지정 금지. */\n action: z.string(),\n vertical: z.enum([\"consulting\", \"medical\", \"tax\", \"legal\"]).optional(),\n heading: z.string().optional(),\n description: z.string().optional(),\n submitLabel: z.string().optional(),\n }),\n});\n\n/** customSections 용 fallback — `type` 자유, props 는 직렬화만 강제. */\nexport const CustomSectionSchema = SectionBase;\n\nconst BUILTIN_SECTION_SCHEMAS: Record<string, z.ZodTypeAny> = {\n hero: HeroSectionSchema,\n \"rich-text\": RichTextSectionSchema,\n services: ServicesSectionSchema,\n faq: FaqSectionSchema,\n \"cta-banner\": CtaBannerSectionSchema,\n \"blog-list\": BlogListSectionSchema,\n \"lead-form\": LeadFormSectionSchema,\n};\n\n/** 빌트인 type 목록 (renderer registry 와 1:1). */\nexport const BUILTIN_SECTION_TYPES = Object.keys(\n BUILTIN_SECTION_SCHEMAS,\n) as readonly string[];\n\nexport type HeroSection = z.output<typeof HeroSectionSchema>;\nexport type RichTextSection = z.output<typeof RichTextSectionSchema>;\nexport type ServicesSection = z.output<typeof ServicesSectionSchema>;\nexport type FaqSection = z.output<typeof FaqSectionSchema>;\nexport type CtaBannerSection = z.output<typeof CtaBannerSectionSchema>;\nexport type BlogListSection = z.output<typeof BlogListSectionSchema>;\nexport type LeadFormSection = z.output<typeof LeadFormSectionSchema>;\nexport type CustomSection = z.output<typeof CustomSectionSchema>;\n\n/** 파싱 결과 섹션 (defaults 적용 후). */\nexport type Section =\n | HeroSection\n | RichTextSection\n | ServicesSection\n | FaqSection\n | CtaBannerSection\n | BlogListSection\n | LeadFormSection\n | CustomSection;\n\n/** config 작성용 섹션 (defaults 생략 가능). `site.config.ts` 에서 이 타입 사용. */\nexport type SectionInput =\n | z.input<typeof HeroSectionSchema>\n | z.input<typeof RichTextSectionSchema>\n | z.input<typeof ServicesSectionSchema>\n | z.input<typeof FaqSectionSchema>\n | z.input<typeof CtaBannerSectionSchema>\n | z.input<typeof BlogListSectionSchema>\n | z.input<typeof LeadFormSectionSchema>\n | z.input<typeof CustomSectionSchema>;\n\n/**\n * 섹션 스키마 — 빌트인 type 이면 해당 스키마로 *강제* 검증 (union fallthrough 로\n * 빌트인 오타이핑이 CustomSection 으로 조용히 통과하는 것을 차단), 미지 type 은\n * CustomSection 으로 통과 (렌더러가 customSections registry 로 해석).\n */\nexport const SectionSchema: z.ZodType<Section, z.ZodTypeDef, SectionInput> = z\n .object({ type: z.string() })\n .passthrough()\n .transform((section, ctx) => {\n const schema = BUILTIN_SECTION_SCHEMAS[section.type] ?? CustomSectionSchema;\n const result = schema.safeParse(section);\n if (!result.success) {\n for (const issue of result.error.issues) ctx.addIssue(issue);\n return z.NEVER;\n }\n return result.data as Section;\n }) as unknown as z.ZodType<Section, z.ZodTypeDef, SectionInput>;\n\n/**\n * 콘텐츠 Collection (ADR-0060) — 같은 글 풀(post)을 스트림으로 분리(공지 게시판 /\n * 블로그 그리드 등). `routes.ts` 의 `RouteCollection` 의 타입드·직렬화 상위집합 —\n * 한 category slug 는 정확히 하나의 collection 에 속해야 한다(다대일 = 라우팅 모호).\n * `layout` 은 레지스트리 키(open string) — 신규 레이아웃 추가 시 스키마 불변.\n * 선언 순서 = 라우팅 우선순위(`resolvePostCollection`).\n */\nexport const CollectionSchema = z.object({\n /** 안정 식별자 (라우트/가드/파생 키). */\n key: z.string(),\n /** 작성자 카드·nav 라벨. */\n label: z.string(),\n /** 라우트 base path (예: \"/notice\" | \"/blog\"). 앞 슬래시 포함. */\n basePath: z.string(),\n /** 레이아웃 레지스트리 키 (board=게시판, grid=카드 그리드, …). 미지 키는 렌더러 fallback. */\n layout: z.string().default(\"grid\"),\n /** 이 스트림에 속하는 category slug 들. */\n categories: z.array(z.string()).default([]),\n /** RSS 피드 포함. */\n feed: z.boolean().default(false),\n /** `${basePath}/categories/{slug}` 아카이브 생성. */\n archives: z.boolean().default(false),\n /** 글별 동적 OG. */\n og: z.boolean().default(false),\n /** 작성 UX: 하위 카테고리 1개 필수(미선택 발행 차단). */\n requireSubcategory: z.boolean().default(false),\n /** 작성 UX: 상단고정 토글 노출. */\n pinning: z.boolean().default(false),\n /** nav/카드 아이콘 키 (선택). */\n icon: z.string().optional(),\n});\nexport type Collection = z.output<typeof CollectionSchema>;\n\nexport const SiteConfigSchema = z.object({\n site: z.object({\n /** canonical origin. 생략 시 소비측이 `SITE_URL` env 등으로 해석. */\n url: z.string().url().optional(),\n title: z.string(),\n description: z.string().optional(),\n locale: z.string().default(\"ko-KR\"),\n og: z.object({ image: z.string().optional() }).optional(),\n }),\n /**\n * 콘텐츠 스트림 분리 (ADR-0060). 비우면 단일 /blog(레거시 동작). 선언 순서 =\n * 라우팅 우선순위. `routes.ts` 팩토리에 `collections` 로 전달해 sitemap/feed/\n * 아카이브를 스트림별로 파생.\n */\n collections: z.array(CollectionSchema).default([]),\n /** 사이트 전역 토큰 override (`--rt-*` CSS 변수 → 값). 비우면 admin /design SSoT. */\n theme: z.record(z.string()).default({}),\n nav: z.array(NavItemSchema).default([]),\n /** path(\"/\", \"/services\", …) → 섹션 배열. */\n pages: z.record(z.array(SectionSchema)),\n});\n\nexport type SiteConfig = z.output<typeof SiteConfigSchema>;\n/** config 작성용 (defaults 생략 가능). */\nexport type SiteConfigInput = Omit<\n z.input<typeof SiteConfigSchema>,\n \"pages\"\n> & { pages: Record<string, SectionInput[]> };\n\n/** config 검증 + defaults 적용. 실패 시 ZodError throw (빌드 타임 fail-fast 용). */\nexport function parseSiteConfig(input: unknown): SiteConfig {\n return SiteConfigSchema.parse(input);\n}\n\n/**\n * `site.config.ts` 작성 헬퍼 — 타입 추론 + 즉시 검증.\n *\n * ```ts\n * import { defineSiteConfig } from \"@roottale/cms-renderer-next/templates\";\n * export default defineSiteConfig({ site: { title: \"…\" }, pages: { \"/\": [ … ] } });\n * ```\n */\nexport function defineSiteConfig(input: SiteConfigInput): SiteConfig {\n return parseSiteConfig(input);\n}\n","/**\n * 빌트인 섹션 템플릿 — MUI-free, 0 JS RSC (ADR-0040 P2).\n *\n * 클래스는 전부 `rt-tpl-*` prefix, 스코프는 섹션 루트의 `data-roottale-tpl`\n * (templates.css 가 `[data-roottale-tpl] :where(…)` 로 zero-specificity 적용 —\n * customer 는 plain class / Tailwind utility 로 자유 override, cms-public.css\n * 와 동일 계약).\n *\n * 토큰은 `--rt-*` CSS 변수 + 정적 fallback — `@roottale/tokens`(private) 미의존\n * (RFC §②). admin /design 값은 `RootTaleThemeProvider` 가 주입.\n */\nimport type { CSSProperties, ReactElement } from \"react\";\n\nimport type {\n CtaBannerSection,\n FaqSection,\n HeroSection,\n RichTextSection,\n SectionCta,\n ServicesSection,\n} from \"./site-config.js\";\n\n/** 섹션 `theme`(--rt-* 변수 override)를 inline style 로. */\nexport function sectionThemeStyle(\n theme: Record<string, string> | undefined,\n): CSSProperties | undefined {\n if (!theme) return undefined;\n const style: Record<string, string> = {};\n for (const [key, value] of Object.entries(theme)) {\n if (key.startsWith(\"--\")) style[key] = value;\n }\n return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined;\n}\n\nfunction CtaLink({\n cta,\n className,\n}: {\n cta: SectionCta;\n className?: string;\n}): ReactElement {\n return (\n <a className={className ?? \"rt-tpl-btn rt-tpl-btn--primary\"} href={cta.href}>\n {cta.label}\n </a>\n );\n}\n\n/** 빌트인 `hero`. variant: split(기본) / image / minimal. */\nexport function TemplateHero(section: HeroSection): ReactElement {\n const { eyebrow, title, description, imageSrc, imageAlt, cta } =\n section.props;\n const variant = section.variant;\n const showImage = variant !== \"minimal\" && imageSrc;\n return (\n <section\n data-roottale-tpl=\"hero\"\n data-variant={variant}\n id={section.id}\n className=\"rt-tpl-section rt-tpl-hero\"\n style={sectionThemeStyle(section.theme)}\n >\n <div className=\"rt-tpl-container rt-tpl-hero-inner\">\n <div className=\"rt-tpl-hero-copy\">\n {eyebrow ? <p className=\"rt-tpl-hero-eyebrow\">{eyebrow}</p> : null}\n <h1 className=\"rt-tpl-hero-title\">\n {title.split(\"\\n\").map((line, i) => (\n <span key={i} className=\"rt-tpl-hero-title-line\">\n {line}\n </span>\n ))}\n </h1>\n {description ? (\n <p className=\"rt-tpl-hero-desc\">{description}</p>\n ) : null}\n {cta ? <CtaLink cta={cta} /> : null}\n </div>\n {showImage ? (\n <div className=\"rt-tpl-hero-media\">\n <img\n className=\"rt-tpl-hero-img\"\n src={imageSrc}\n alt={imageAlt ?? \"\"}\n loading=\"eager\"\n />\n </div>\n ) : null}\n </div>\n </section>\n );\n}\n\n/** 빌트인 `rich-text`. 문단은 `\\n\\n` 분리. */\nexport function TemplateRichText(section: RichTextSection): ReactElement {\n const { heading, body } = section.props;\n return (\n <section\n data-roottale-tpl=\"rich-text\"\n id={section.id}\n className=\"rt-tpl-section rt-tpl-rich-text\"\n style={sectionThemeStyle(section.theme)}\n >\n <div className=\"rt-tpl-container\">\n {heading ? <h2 className=\"rt-tpl-heading\">{heading}</h2> : null}\n {body.split(\"\\n\\n\").map((para, i) => (\n <p key={i} className=\"rt-tpl-rich-text-p\">\n {para}\n </p>\n ))}\n </div>\n </section>\n );\n}\n\n/** 빌트인 `services`. variant: grid(기본) / list. */\nexport function TemplateServices(section: ServicesSection): ReactElement {\n const { heading, description, items } = section.props;\n return (\n <section\n data-roottale-tpl=\"services\"\n data-variant={section.variant}\n id={section.id}\n className=\"rt-tpl-section rt-tpl-services\"\n style={sectionThemeStyle(section.theme)}\n >\n <div className=\"rt-tpl-container\">\n {heading ? <h2 className=\"rt-tpl-heading\">{heading}</h2> : null}\n {description ? (\n <p className=\"rt-tpl-section-desc\">{description}</p>\n ) : null}\n <ul className=\"rt-tpl-services-list\">\n {items.map((item, i) => {\n const body = (\n <>\n <h3 className=\"rt-tpl-services-title\">{item.title}</h3>\n {item.description ? (\n <p className=\"rt-tpl-services-desc\">{item.description}</p>\n ) : null}\n </>\n );\n return (\n <li key={i} className=\"rt-tpl-services-item\">\n {item.href ? (\n <a className=\"rt-tpl-services-link\" href={item.href}>\n {body}\n </a>\n ) : (\n body\n )}\n </li>\n );\n })}\n </ul>\n </div>\n </section>\n );\n}\n\n/** 빌트인 `faq`. `<details>` 0 JS + FAQPage JSON-LD (기본 on). */\nexport function TemplateFaq(section: FaqSection): ReactElement {\n const { heading, items, jsonLd } = section.props;\n return (\n <section\n data-roottale-tpl=\"faq\"\n id={section.id}\n className=\"rt-tpl-section rt-tpl-faq\"\n style={sectionThemeStyle(section.theme)}\n >\n <div className=\"rt-tpl-container\">\n {heading ? <h2 className=\"rt-tpl-heading\">{heading}</h2> : null}\n <div className=\"rt-tpl-faq-list\">\n {items.map((item, i) => (\n <details key={i} className=\"rt-tpl-faq-item\">\n <summary className=\"rt-tpl-faq-q\">{item.question}</summary>\n <p className=\"rt-tpl-faq-a\">{item.answer}</p>\n </details>\n ))}\n </div>\n </div>\n {jsonLd ? (\n <script\n type=\"application/ld+json\"\n dangerouslySetInnerHTML={{\n __html: JSON.stringify({\n \"@context\": \"https://schema.org\",\n \"@type\": \"FAQPage\",\n mainEntity: items.map((item) => ({\n \"@type\": \"Question\",\n name: item.question,\n acceptedAnswer: { \"@type\": \"Answer\", text: item.answer },\n })),\n }),\n }}\n />\n ) : null}\n </section>\n );\n}\n\n/** 빌트인 `cta-banner`. */\nexport function TemplateCtaBanner(section: CtaBannerSection): ReactElement {\n const { title, description, cta } = section.props;\n return (\n <section\n data-roottale-tpl=\"cta-banner\"\n id={section.id}\n className=\"rt-tpl-section rt-tpl-cta-banner\"\n style={sectionThemeStyle(section.theme)}\n >\n <div className=\"rt-tpl-container rt-tpl-cta-banner-inner\">\n <h2 className=\"rt-tpl-cta-banner-title\">{title}</h2>\n {description ? (\n <p className=\"rt-tpl-cta-banner-desc\">{description}</p>\n ) : null}\n {cta ? <CtaLink cta={cta} /> : null}\n </div>\n </section>\n );\n}\n","/**\n * `SiteRenderer` — `SiteConfig.pages[path]` 섹션 배열을 registry 로 렌더.\n *\n * ADR-0040 P2 + P0.5 RFC §①:\n * - 빌트인 registry + 클라 주입 `customSections` 머지. **빌트인이 우선** —\n * 클라가 빌트인 type 을 가로채 계약이 갈라지는 것 방지 (고유 섹션은 고유\n * type 으로).\n * - **render-prop 금지**: 임의 JSX escape hatch 는 customSections registry 로만.\n * - **admin preview parity**: admin draft/preview 도 동일 `SiteRenderer` + 동일\n * 빌트인 registry 를 탄다. customSections 는 클라 repo 자산 — preview 에선\n * 미주입 → `unknownSection: \"placeholder\"` 로 skeleton 표시.\n * - cms-bound 섹션(blog-list/lead-form)은 `RootTaleBlogList`/`RootTaleLeadForm`\n * 위임 (단방향: templates → renderer 코어).\n */\nimport {\n Fragment,\n type CSSProperties,\n type ReactElement,\n type ReactNode,\n} from \"react\";\n\nimport { RootTaleLeadForm } from \"../LeadForm.js\";\nimport { RootTaleBlogList } from \"../server.js\";\nimport {\n TemplateCtaBanner,\n TemplateFaq,\n TemplateHero,\n TemplateRichText,\n TemplateServices,\n sectionThemeStyle,\n} from \"./sections.js\";\nimport {\n type BlogListSection,\n type CtaBannerSection,\n type FaqSection,\n type HeroSection,\n type LeadFormSection,\n type RichTextSection,\n type Section,\n type ServicesSection,\n type SiteConfig,\n type SiteConfigInput,\n parseSiteConfig,\n} from \"./site-config.js\";\n\n/** customSections registry 컴포넌트 계약 (RFC §①). */\nexport type SectionComponent = (p: {\n id?: string;\n props: Record<string, unknown>;\n variant?: string;\n theme?: Record<string, string>;\n}) => ReactNode;\n\nexport interface SiteRendererProps {\n /** `defineSiteConfig()` 결과 또는 raw config (내부에서 검증). */\n config: SiteConfig | SiteConfigInput;\n /** 현재 페이지 경로 — `config.pages` 의 key (\"/\", \"/services\", …). */\n path: string;\n /** cms-bound 섹션(blog-list/lead-form 의 theme fetch) 위임용. 서버 전용. */\n apiKey: string;\n /** self-host 등 API base override (`ROOTTALE_API_BASE`). */\n baseUrl?: string;\n /** 클라 repo 고유 섹션 registry (escape hatch). 빌트인 type 은 가로챌 수 없음. */\n customSections?: Record<string, SectionComponent>;\n /**\n * registry 에 없는 type 처리. 기본 \"warn-dev\" = dev 에서 경고 박스, prod 에서\n * 생략. admin preview 는 \"placeholder\" (customSections 미주입 환경).\n */\n unknownSection?: \"warn-dev\" | \"placeholder\" | \"ignore\";\n}\n\nfunction isDev(): boolean {\n // dts 빌드는 node types 없이 돈다 — process 는 globalThis 경유로 안전 접근.\n const env =\n (globalThis as { process?: { env?: Record<string, string | undefined> } })\n .process?.env ?? {};\n return env.NODE_ENV !== \"production\";\n}\n\nfunction UnknownSection({\n type,\n mode,\n}: {\n type: string;\n mode: \"warn-dev\" | \"placeholder\" | \"ignore\";\n}): ReactElement | null {\n if (mode === \"ignore\") return null;\n if (mode === \"placeholder\") {\n return (\n <section\n data-roottale-tpl=\"placeholder\"\n className=\"rt-tpl-section rt-tpl-placeholder\"\n aria-hidden\n >\n <div className=\"rt-tpl-container\">\n <div className=\"rt-tpl-placeholder-box\">{type}</div>\n </div>\n </section>\n );\n }\n if (!isDev()) return null;\n const style: CSSProperties = {\n padding: \"1rem\",\n border: \"1px dashed #c00\",\n color: \"#c00\",\n };\n return (\n <div style={style}>\n 알 수 없는 섹션 type: <code>{type}</code> — SiteRenderer 의 customSections\n 에 등록하세요.\n </div>\n );\n}\n\nfunction renderBuiltin(\n section: Section,\n ctx: { apiKey: string; baseUrl?: string },\n): ReactNode | undefined {\n switch (section.type) {\n case \"hero\":\n return <TemplateHero {...(section as HeroSection)} />;\n case \"rich-text\":\n return <TemplateRichText {...(section as RichTextSection)} />;\n case \"services\":\n return <TemplateServices {...(section as ServicesSection)} />;\n case \"faq\":\n return <TemplateFaq {...(section as FaqSection)} />;\n case \"cta-banner\":\n return <TemplateCtaBanner {...(section as CtaBannerSection)} />;\n case \"blog-list\": {\n const s = section as BlogListSection;\n const base = s.props.blogBasePath ?? \"/blog\";\n return (\n <section\n data-roottale-tpl=\"blog-list\"\n data-roottale-cms=\"list\"\n id={s.id}\n className=\"rt-tpl-section rt-tpl-blog-list\"\n style={sectionThemeStyle(s.theme)}\n >\n <div className=\"rt-tpl-container\">\n {s.props.heading ? (\n <h2 className=\"rt-tpl-heading\">{s.props.heading}</h2>\n ) : null}\n <RootTaleBlogList\n apiKey={ctx.apiKey}\n baseUrl={ctx.baseUrl}\n limit={s.props.limit}\n emptyMessage={s.props.emptyMessage}\n postHref={(post) => `${base}/${post.slug}`}\n />\n </div>\n </section>\n );\n }\n case \"lead-form\": {\n const s = section as LeadFormSection;\n return (\n <section\n data-roottale-tpl=\"lead-form\"\n data-roottale-cms=\"lead-form\"\n id={s.id}\n className=\"rt-tpl-section rt-tpl-lead-form\"\n style={sectionThemeStyle(s.theme)}\n >\n <div className=\"rt-tpl-container\">\n <RootTaleLeadForm\n action={s.props.action}\n vertical={s.props.vertical}\n heading={s.props.heading}\n description={s.props.description}\n submitLabel={s.props.submitLabel}\n />\n </div>\n </section>\n );\n }\n default:\n return undefined;\n }\n}\n\n/**\n * 한 페이지의 섹션 배열을 순서대로 렌더.\n *\n * ```tsx\n * // app/services/page.tsx\n * import config from \"@/site.config\";\n * import { SiteRenderer } from \"@roottale/cms-renderer-next/templates\";\n *\n * export default function ServicesPage() {\n * return (\n * <SiteRenderer config={config} path=\"/services\"\n * apiKey={process.env.ROOTTALE_API_KEY!} />\n * );\n * }\n * ```\n */\nexport function SiteRenderer(props: SiteRendererProps): ReactElement {\n const {\n path,\n apiKey,\n baseUrl,\n customSections = {},\n unknownSection = \"warn-dev\",\n } = props;\n const config = parseSiteConfig(props.config);\n const sections = config.pages[path] ?? [];\n return (\n <>\n {sections.length === 0 ? (\n <UnknownSection\n type={`pages[\"${path}\"] (섹션 0개)`}\n mode={unknownSection}\n />\n ) : null}\n {sections.map((section, i) => {\n const key = section.id ?? `${section.type}-${i}`;\n const builtin = renderBuiltin(section, { apiKey, baseUrl });\n if (builtin !== undefined) {\n return <Fragment key={key}>{builtin}</Fragment>;\n }\n const Custom = customSections[section.type];\n if (Custom) {\n return (\n <Fragment key={key}>\n {Custom({\n id: section.id,\n props: section.props as Record<string, unknown>,\n variant: section.variant,\n theme: section.theme,\n })}\n </Fragment>\n );\n }\n return (\n <UnknownSection key={key} type={section.type} mode={unknownSection} />\n );\n })}\n </>\n );\n}\n"],"mappings":"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","names":["Fragment","Fragment","jsx","jsxs"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-renderer-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "RootTale CMS public-render React/Next.js Server Components. SSR-only RSC components (RootTaleBlogList / RootTaleBlogPost / RootTaleLeadForm) for external customer sites. Companion of @roottale/cms-renderer-astro (ADR-0034 §1.5 amended).",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"zod": "^3.23.8",
|
|
45
|
-
"@roottale/cms-
|
|
46
|
-
"@roottale/cms-
|
|
45
|
+
"@roottale/cms-core": "^0.29.0",
|
|
46
|
+
"@roottale/cms-client": "^0.25.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"react": "^19.0.0"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/blog-filter.ts"],"sourcesContent":["/**\n * `RootTaleBlogList` 카테고리 필터 — 순수 헬퍼(서버 필터, client JS 불요).\n *\n * SSR-only 정책 유지: 필터는 `?category=<slug>` URL 파라미터로 동작하고(칩 =\n * 서버 렌더 `<a>` 링크), consumer 가 searchParams 의 category 를 `activeCategory`\n * 로 주입한다. hydration 없이 SEO 친화 + 공유 가능한 URL.\n */\n\nimport type { CmsPostContent, CmsTermRef } from \"@roottale/cms-client/server\";\n\nexport interface BlogCategory {\n name: string;\n slug: string;\n}\n\nexport interface BlogCategoryWithCount extends BlogCategory {\n count: number;\n}\n\nfunction categoryTerms(post: CmsPostContent): CmsTermRef[] {\n return (post.terms ?? []).filter((t) => t.taxonomy === \"category\");\n}\n\n/**\n * 글 목록에 등장하는 카테고리를 slug 기준 unique 로 수집. 서버가 보내는 정렬\n * (category → name asc)을 보존하기 위해 처음 등장 순서를 유지한다.\n */\nexport function collectCategories(posts: CmsPostContent[]): BlogCategory[] {\n return collectCategoriesWithCounts(posts).map(({ name, slug }) => ({\n name,\n slug,\n }));\n}\n\nexport function collectCategoriesWithCounts(\n posts: CmsPostContent[],\n): BlogCategoryWithCount[] {\n const bySlug = new Map<string, BlogCategoryWithCount>();\n const out: BlogCategoryWithCount[] = [];\n for (const post of posts) {\n for (const t of categoryTerms(post)) {\n const current = bySlug.get(t.slug);\n if (current) {\n current.count += 1;\n } else {\n const category = { name: t.name, slug: t.slug, count: 1 };\n bySlug.set(t.slug, category);\n out.push(category);\n }\n }\n }\n return out;\n}\n\n/** activeSlug 가 비면 전체 반환. 아니면 그 카테고리를 가진 글만. */\nexport function filterPostsByCategory(\n posts: CmsPostContent[],\n activeSlug: string | null | undefined,\n): CmsPostContent[] {\n if (!activeSlug) return posts;\n return posts.filter((p) => categoryTerms(p).some((t) => t.slug === activeSlug));\n}\n\n/** 기본 카테고리 링크: 전체 → `/blog`, 특정 → `/blog/categories/<slug>`. */\nexport function defaultCategoryHref(slug: string | null): string {\n return slug ? `/blog/categories/${encodeURIComponent(slug)}` : \"/blog\";\n}\n"],"mappings":";AAmBA,SAAS,cAAc,MAAoC;AACzD,UAAQ,KAAK,SAAS,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,aAAa,UAAU;AACnE;AAMO,SAAS,kBAAkB,OAAyC;AACzE,SAAO,4BAA4B,KAAK,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO;AAAA,IACjE;AAAA,IACA;AAAA,EACF,EAAE;AACJ;AAEO,SAAS,4BACd,OACyB;AACzB,QAAM,SAAS,oBAAI,IAAmC;AACtD,QAAM,MAA+B,CAAC;AACtC,aAAW,QAAQ,OAAO;AACxB,eAAW,KAAK,cAAc,IAAI,GAAG;AACnC,YAAM,UAAU,OAAO,IAAI,EAAE,IAAI;AACjC,UAAI,SAAS;AACX,gBAAQ,SAAS;AAAA,MACnB,OAAO;AACL,cAAM,WAAW,EAAE,MAAM,EAAE,MAAM,MAAM,EAAE,MAAM,OAAO,EAAE;AACxD,eAAO,IAAI,EAAE,MAAM,QAAQ;AAC3B,YAAI,KAAK,QAAQ;AAAA,MACnB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAGO,SAAS,sBACd,OACA,YACkB;AAClB,MAAI,CAAC,WAAY,QAAO;AACxB,SAAO,MAAM,OAAO,CAAC,MAAM,cAAc,CAAC,EAAE,KAAK,CAAC,MAAM,EAAE,SAAS,UAAU,CAAC;AAChF;AAGO,SAAS,oBAAoB,MAA6B;AAC/D,SAAO,OAAO,oBAAoB,mBAAmB,IAAI,CAAC,KAAK;AACjE;","names":[]}
|