@roottale/cms-renderer-next 0.53.1 → 0.55.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 +35 -0
- package/dist/{blog-filter-BKCGM0qd.d.ts → blog-filter-BQNQL3Mk.d.ts} +5 -0
- package/dist/{chunk-HM5VP7SR.js → chunk-FJANIAZN.js} +1 -1
- package/dist/chunk-FJANIAZN.js.map +1 -0
- package/dist/cms-public.css +23 -0
- package/dist/routes.d.ts +17 -2
- package/dist/routes.js +17 -3
- package/dist/routes.js.map +1 -1
- package/dist/server.d.ts +27 -3
- package/dist/server.js +70 -11
- package/dist/server.js.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-HM5VP7SR.js.map +0 -1
package/dist/routes.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/routes.ts","../src/routes-feed-sitemap.ts","../src/routes-metadata.ts","../src/archive-index-policy.ts","../src/routes-sitemap-entries.ts","../src/archive-categories.ts","../src/routes-llms.ts","../src/routes-revalidate.ts","../src/routes-og.ts","../src/routes-redirects.ts"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/routes` — Next.js App Router 통합 라우트 팩토리.\n *\n * 외부 고객사 사이트가 매번 직접 작성하던 공통 라우트(RSS feed / sitemap /\n * revalidate webhook / llms.txt / OG 이미지)를 1줄 팩토리로 표준화. 블로그 조회와\n * *동일한* 단일 customer API key(`ROOTTALE_API_KEY`)를 사용한다.\n *\n * 설계 원칙:\n * - feed/sitemap/llms 는 fetch 실패를 swallow → 라우트가 *항상 200*.\n * - revalidate 는 서명 webhook 을 검증하고 본문 paths + 파생 경로를 revalidate.\n * - `next` 에 직접 의존하지 않음(revalidate 콜백·ImageResponse 주입).\n *\n * 구현은 `routes-*.ts` 모듈로 분리되어 있고 본 파일은 공개 배럴이다(파일당 ≤400줄).\n * 사용 예는 roottale-docs `services/external-site-integration.md` 참조.\n */\n\nimport { CMS_CLIENT_VERSION } from \"@roottale/cms-client/server\";\n\nimport { trimSlash } from \"./routes-shared.js\";\n\n// ── 공개 표면 배럴 (모듈별 구현 re-export) ───────────────────────────────────\nexport * from \"./routes-shared.js\";\nexport * from \"./routes-feed-sitemap.js\";\nexport * from \"./routes-llms.js\";\nexport * from \"./routes-revalidate.js\";\nexport * from \"./routes-og.js\";\n// 커스텀 리다이렉트 미들웨어 (WP RankMath Redirections 패리티).\nexport * from \"./routes-redirects.js\";\n// 글 메타데이터 빌더 (canonical/robots/og 1줄 — 어드민 SEO 패널 반영).\nexport * from \"./routes-metadata.js\";\n// 아카이브 색인 정책 단일 SSoT — sitemap 필터와 robots 를 같은 판정에서 파생.\nexport * from \"./archive-index-policy.js\";\n// 아카이브 카테고리 모집단 — sitemap 과 아카이브 페이지가 같은 글 집합을 센다.\nexport * from \"./archive-categories.js\";\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 * `routes` — RSS feed + sitemap 라우트 팩토리. collection 모드/레거시 양쪽.\n * 사이트맵 entry 조립(정책·설정 해석)은 `routes-sitemap-entries.ts` 가 맡는다.\n */\n\nimport {\n generateRssXml,\n rssItemsFromPosts,\n} from \"@roottale/cms-client/server\";\nimport {\n isInCollectionMode,\n resolvePostCollection,\n resolvePostUrl,\n} from \"@roottale/cms-core\";\n\nimport { ARCHIVE_POST_COUNT_LIMIT } from \"./archive-index-policy.js\";\nimport {\n type SitemapEntry,\n type SitemapIndexOptions,\n authorEntries,\n categoryEntries,\n localesWithPublishedContent,\n postEntries,\n resolveSitemapSettings,\n staticEntries,\n} from \"./routes-sitemap-entries.js\";\nimport {\n type RootTaleSiteConfig,\n resolveCollections,\n safeFetchPosts,\n trimSlash,\n} from \"./routes-shared.js\";\n\n// 공개 표면 유지 — 이 두 타입은 팩토리 시그니처의 일부라 배럴(`routes.ts`)에서\n// 계속 나가야 한다(파일을 나눴다고 소비자 import 경로가 바뀌면 안 된다).\nexport type { SitemapEntry, SitemapIndexOptions };\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 = await resolveCollections(config.collections);\n const allPosts = await safeFetchPosts(config, 50);\n\n // collection 모드: /feed.xml = `feed:true` 스트림들의 합집합(aggregate). RSS\n // 리더는 안정적 사이트 피드를 기대하므로 스트림별 분리 대신 통합 피드(codex).\n // 각 글 링크는 소속 스트림 basePath 로. 레거시: 단일 blogBasePath.\n // detail:false(목록 전용) 스트림은 글별 permalink 가 없으므로 RSS item 에서도\n // 제외 — 안 그러면 postUrl fallback 이 존재하지 않는 상세 URL 을 만든다.\n // 주소 없는 유형(basePath === \"\")도 같은 이유로 제외 — 코드에서 실수로\n // feed:true 를 선언해도(admin UI 는 강제 off 하지만 코드 상수 경로는 우회\n // 가능) resolvePostUrl 이 null 을 반환해 아래 postUrl fallback 이 존재하지\n // 않는 `/blog/{slug}` 를 RSS item 으로 발행하는 걸 막는다.\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 && c.detail !== false && c.basePath !== \"\" : 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 hubUrl: config.webSubHubUrl,\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// ── 사이트맵 라우트 팩토리 ───────────────────────────────────────────────────\n\n/**\n * @deprecated `createSitemapIndex` 를 쓰세요 — 단일 평면 사이트맵 대신 인덱스 분리\n * (static/blog/categories) + 이미지 + admin 토글을 지원한다. 본 함수는 하위호환용으로\n * 유지되며 `changeFrequency`/`priority` 는 더 이상 emit 하지 않는다(blanche식).\n *\n * `app/sitemap.ts` default export. 정적 경로 + 글 + 카테고리를 단일 파일로 합친다.\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 collections = await resolveCollections(config.collections);\n const posts = await safeFetchPosts(config, ARCHIVE_POST_COUNT_LIMIT);\n // 레거시 동등성 유지 — admin 설정 fetch 생략 + 이미지 off(기존 동작 = freq/priority\n // 만 제거). 이미지·admin 토글이 필요하면 createSitemapIndex 를 쓴다(codex P2).\n const { settings } = await resolveSitemapSettings(config, {\n skipRemoteSettings: true,\n blogImages: false,\n });\n return [\n ...staticEntries(staticRoutes),\n ...postEntries(config, siteUrl, posts, collections, settings),\n // settings.promotedCategorySlugs 는 여기서 항상 undefined(위 skipRemoteSettings)\n // → 색인 정책 미적용 = 레거시 동등. 정책이 필요하면 createSitemapIndex 로.\n ...(await categoryEntries(config, siteUrl, collections, undefined)),\n ];\n };\n}\n\n/** blanche식 인덱스 분리 사이트맵의 하위 섹션 id. */\nexport type SitemapSectionId = \"static\" | \"blog\" | \"categories\" | \"authors\";\n\n/**\n * blanche식 사이트맵 인덱스 — `/sitemap.xml`(인덱스) + `/sitemap/<id>.xml` 하위\n * 사이트맵(static/blog/categories)으로 분리한다(Next 16 `generateSitemaps`). 50k\n * URL/50MB 한도 확장 + 검색엔진이 섹션별로 크롤. 블로그 글에는 대표 이미지를\n * `<image:image>` 로 함께 색인(admin 토글). `changeFrequency`/`priority` 는 emit\n * 하지 않는다.\n *\n * ```ts\n * // app/sitemap.ts\n * import { createSitemapIndex } from \"@roottale/cms-renderer-next/routes\";\n * const { generateSitemaps, sitemap } = createSitemapIndex(\n * { apiKey: process.env.ROOTTALE_API_KEY!, siteUrl: \"https://example.com\", title: \"...\" },\n * [{ url: \"https://example.com\" }, { url: \"https://example.com/contact\" }],\n * );\n * export { generateSitemaps };\n * export default sitemap;\n * ```\n */\nexport function createSitemapIndex(\n config: RootTaleSiteConfig,\n staticRoutes: SitemapEntry[] = [],\n opts: SitemapIndexOptions = {},\n): {\n generateSitemaps: () => Promise<{ id: SitemapSectionId }[]>;\n sitemap: (props: { id: string | Promise<string> }) => Promise<SitemapEntry[]>;\n} {\n async function generateSitemaps(): Promise<{ id: SitemapSectionId }[]> {\n // static/blog/categories 항상. authors 는 admin 토글(settings.authors) 시만 —\n // 작가 아카이브 라우트가 있어야 404 가 안 난다. per-locale(PR C)은 후속.\n //\n // **색인 정책과의 상호작용(§1)**: 섹션 존재 여부는 **토글만** 본다. 색인\n // 정책(`decideArchiveIndex`)은 섹션 *안의 URL* 만 거른다. 정책 때문에 항목이\n // 0개가 되어도 섹션 id 는 그대로 둔다 — id 를 빼면 Next 가 그 라우트를 아예\n // 생성하지 않아 `/sitemap/authors.xml`·`/sitemap/categories.xml` 이 404 가\n // 되고, 그건 이미 배포된 사이트맵 인덱스가 가리키는 주소를 깨뜨린다. 빈\n // `<urlset>` 은 유효한 응답이므로 이쪽이 안전하다.\n //\n // 같은 이유로 **설정 fetch 가 실패하면 섹션 목록을 줄이지 않는다**:\n // `generateSitemaps` 와 하위 handler 는 서로 다른 요청에서 각각 설정을 읽으므로\n // 한쪽만 실패할 수 있는데, 실패한 쪽이 authors 를 빼면 이미 배포된 인덱스가\n // 가리키던 `/sitemap/authors.xml` 이 404 가 된다. 토글을 못 읽었을 때는 섹션을\n // 남기고 내용만 비운다(`authorEntries` 가 fail-closed). `skipRemoteSettings`\n // 는 장애가 아니라 설정이므로 이 보존 규칙에서 제외한다.\n const { settings, settingsFetchFailed } = await resolveSitemapSettings(\n config,\n opts,\n );\n const ids: SitemapSectionId[] = [\"static\", \"blog\", \"categories\"];\n if (settings.authors || settingsFetchFailed) ids.push(\"authors\");\n return ids.map((id) => ({ id }));\n }\n\n async function sitemap(props: {\n id: string | Promise<string>;\n }): Promise<SitemapEntry[]> {\n const id = await props.id;\n const siteUrl = trimSlash(config.siteUrl);\n const { settings } = await resolveSitemapSettings(config, opts);\n // ADR-0052 A안 — fail-closed: 발행 글이 0건인 locale 은 hreflang 생성 대상에서\n // 제외(§1 404 배관 위험 차단).\n //\n // `blog` 섹션(PR C1)만 글 단위 실제 `translations[]` 기반 정밀 alternates —\n // `postEntries` 가 직접 계산한다(아래 `id === \"blog\"` 분기, `localize()` 를\n // 거치지 않음). static/categories/authors 는 **v1 라우트 범위 밖**(§3 PR\n // C1-1: locale 세그먼트는 블로그+CMS `[slug]` 페이지뿐, nav 정적 경로·카테고리\n // 아카이브·작가 아카이브는 locale 라우트 자체가 없다) 이라 옛 slug-1:1\n // path-prefix 스캐폴딩(`localizeEntries`)을 계속 쓰면 존재하지 않는\n // `/{locale}/...` URL 의 hreflang 을 만들게 된다(§1 위험 재발). 그래서 이\n // 세 섹션은 **alternates 를 아예 붙이지 않는다** — authors 는 기존 주석대로\n // \"per-locale 은 후속\"(§3 PR C1-4)이었고, static/categories 도 같은 처지라\n // 동일하게 보류한다(design deviation — 최종 보고 참조).\n const activeLocales = await localesWithPublishedContent(config, settings.locales);\n const defaultLocale = activeLocales[0];\n\n if (id === \"static\") return staticEntries(staticRoutes);\n\n if (id === \"authors\") {\n // generateSitemaps 와 동일하게 토글 gate — 직접 호출 시에도 off 면 빈 배열\n // (authorEntries 안에서 판정).\n return authorEntries(config, siteUrl, settings, opts.authorBasePath);\n }\n\n const collections = await resolveCollections(config.collections);\n if (id === \"categories\") {\n // 모집단·한도는 `categoryEntries` 가 직접 정한다 — 아카이브 페이지와 **같은\n // 헬퍼**를 써야 \"사이트맵엔 URL 이 있는데 페이지는 404\" 가 안 생긴다\n // (`archive-categories.ts` 모듈 주석). 그래서 여기서 글 배치를 주입하지 않는다.\n return categoryEntries(\n config,\n siteUrl,\n collections,\n settings.promotedCategorySlugs,\n );\n }\n if (id === \"blog\") {\n const posts = await safeFetchPosts(config, ARCHIVE_POST_COUNT_LIMIT);\n return postEntries(config, siteUrl, posts, collections, settings, defaultLocale);\n }\n return [];\n }\n\n return { generateSitemaps, sitemap };\n}\n","/**\n * `@roottale/cms-renderer-next/routes` — 블로그 글 메타데이터 빌더.\n *\n * 어드민 SEO 패널(`metaJson.seo`) 값을 Next.js `Metadata` 형태로 1줄 변환한다.\n * 고객사 `app/blog/[slug]/page.tsx` 의 `generateMetadata` 에서 매번 직접 쓰던\n * canonical/robots/openGraph 분기 보일러플레이트를 표준화 — 한 곳이라도\n * 빠뜨려 SEO 가 새는 사고(noindex 누락·canonical 미설정)를 방지한다.\n *\n * 설계 원칙(패키지 공통): `next` 에 직접 의존하지 않는다. 반환은 Next `Metadata`\n * 의 구조적 부분집합인 plain 객체이므로, 소비자는 `generateMetadata` 에서 그대로\n * `return` 하면 된다.\n */\n\nimport { resolvePostPath, type RouteCollection } from \"@roottale/cms-core\";\nimport type { CmsPostTranslationRef } from \"@roottale/cms-client/server\";\n\nimport { trimSlash } from \"./routes-shared.js\";\n\n/** 어드민 SEO 패널 override (`metaJson.seo`). 전부 선택. */\nexport interface PostSeoOverrides {\n /** 검색 제목 — 비면 글 제목 사용. */\n title?: string;\n /** 메타 설명 — 비면 글 요약 사용. */\n description?: string;\n /** 대표 URL. 비면 `siteUrl`+`path` 로 self-canonical 생성(옵션 제공 시). */\n canonical?: string;\n /** OG 이미지 — 비면 글 대표 이미지 사용. */\n ogImage?: string;\n /** OG 이미지 대체 텍스트 — 비면 글 제목 사용. */\n ogImageAlt?: string;\n /** 색인 제외. */\n noindex?: boolean;\n /** 링크 추적 제외. */\n nofollow?: boolean;\n}\n\n/** `buildPostMetadata` 입력 — `BlogPostMeta`·원본 post 모두 구조적으로 호환. */\nexport interface PostMetadataInput {\n /**\n * 글 ID (uuidv7). 주면 `<meta name=\"rt:content-id\">`(ADR-0065)를 자동 emit —\n * RootTale 비콘이 읽어 pageview 를 이 글에 귀속시켜 조회수를 정확히 집계한다.\n * `BlogPostMeta`·원본 post 모두 `id` 를 가지므로 별도 작업 없이 동작한다.\n */\n id?: string;\n /** 글 제목 (`seo.title` 없을 때 fallback). */\n title: string;\n /** 글 요약/설명 (`seo.description` 없을 때 fallback). */\n description?: string | null;\n /** 발행 ISO 일시 → `openGraph.publishedTime`. */\n date?: string | null;\n /** 대표 이미지 URL → `openGraph.images` (`seo.ogImage` 우선). */\n image?: string | null;\n /** 어드민 SEO 패널 override (`metaJson.seo`). */\n seo?: PostSeoOverrides | null;\n /** 글 slug — `collections` 기반 canonical 자동 해석에 사용. */\n slug?: string;\n /**\n * 소속 섹션 key (ADR-0060). `collections` 와 함께 주면 canonical basePath 를\n * 공지/블로그로 구분해 해석한다(`collectionKey:\"notice\"` → `/notice`).\n */\n collectionKey?: string | null;\n /**\n * ADR-0052 A안 (W4-6 PR C1) — 이 글의 발행된 언어판 목록(자기 자신 포함, PR B\n * 응답 `CmsPostContent.translations`). `options.locales` 와 함께 주면 hreflang\n * `alternates.languages` 를 계산한다. 없거나 자기 자신 1건뿐이면 languages 생략.\n */\n translations?: readonly CmsPostTranslationRef[];\n /**\n * avcd 구조(og article 확장) — 최근 수정 ISO 일시 → `openGraph.modifiedTime`.\n * `CmsPostContent.updatedAt` 등을 그대로 넘기면 된다. 미지정 시 생략.\n */\n modified?: string | null;\n /** avcd 구조 — 글 섹션/카테고리명 → `openGraph.section`. 미지정 시 생략. */\n section?: string | null;\n /** avcd 구조 — 글 태그명 목록 → `openGraph.tags`. 빈 배열/미지정 시 생략. */\n tags?: readonly string[] | null;\n /**\n * avcd 구조 — 저자 프로필 URL 목록 → `openGraph.authors` (Next `Metadata`\n * article 확장). 빈 배열/미지정 시 생략.\n */\n authors?: readonly string[] | null;\n}\n\nexport interface BuildPostMetadataOptions {\n /**\n * self-canonical 기본값 origin (예: `https://example.com`).\n * `seo.canonical` override 가 있으면 무시된다. `path` 또는 `collections` 와\n * 함께 줄 때만 동작.\n */\n siteUrl?: string;\n /**\n * self-canonical 기본값 경로 (예: `/blog/my-post`). `siteUrl` 과 함께.\n * 명시하면 `collections` 파생보다 우선한다(단일 라우트 사이트용 명시 경로).\n */\n path?: string;\n /**\n * 섹션 정의 (ADR-0060). `path` 를 안 주면 글의 `collectionKey` 로 소속 섹션\n * basePath 를 찾아 canonical 경로를 **공지/블로그로 구분해 자동 해석**한다\n * (공지 글 → `/notice/{slug}`, 블로그 글 → `/blog/{slug}`). 어느 섹션에도\n * 안 속하면(미설정·미존재 key) canonical 을 생략한다. `post.slug` 필요.\n */\n collections?: readonly RouteCollection[];\n /**\n * ADR-0052 A안 (W4-6 PR C1) — hreflang alternates.languages 계산 옵션. 주지\n * 않으면(undefined) `translations` 가 있어도 languages 를 계산하지 않는다\n * (opt-in — 명시 안 한 기존 소비자는 canonical-only 로 diff 0).\n */\n locales?: PostLocaleOptions;\n /**\n * avcd 구조 — RSS 피드 절대 경로 → `alternates.types[\"application/rss+xml\"]`.\n * 기본 `\"/feed.xml\"`. `siteUrl` 이 있으면 canonical 유무와 무관하게 항상 emit\n * (RSS 발견성은 개별 글의 canonical 해석 성공 여부와 무관한 사이트 차원 신호).\n */\n feedPath?: string;\n}\n\n/**\n * ADR-0052 A안 (W4-6 PR C1) — hreflang 경로 계산 옵션. `buildPostMetadata` 와\n * `buildHreflangLanguages` 가 공유한다.\n */\nexport interface PostLocaleOptions {\n /** 사이트 기본 locale — prefix 없는 경로, `x-default` 겸함. */\n defaultLocale: string;\n /**\n * locale + slug → path(선행 슬래시 포함). 기본값: 기본 locale 은 `/blog/{slug}`,\n * 그 외는 `/{locale}/blog/{slug}` (starter 참조 구현 관용구, `tenant-site-i18n-plan.md`\n * §2③). 블로그가 아닌 라우트(CMS `[slug]` 페이지 등)는 override 필수.\n */\n pathFor?: (locale: string, slug: string) => string;\n}\n\n/** robots 지시자 — Next `Metadata.robots` 의 구조적 부분집합. */\nexport interface RobotsMeta {\n index: boolean;\n follow: boolean;\n googleBot?: {\n index: boolean;\n follow: boolean;\n \"max-image-preview\": \"large\";\n };\n}\n\n/**\n * noindex/nofollow 플래그 → `Metadata.robots`. **글과 아카이브가 공유하는 단\n * 하나의 robots 표현** — 둘 중 한쪽만 형태가 바뀌면 색인 정책이 갈리므로\n * `buildPostMetadata` 와 `buildArchiveMetadata` 가 이 함수만 쓴다.\n *\n * 둘 다 꺼져 있으면 `undefined` — robots 를 아예 emit 하지 않아 검색엔진 기본값\n * (index, follow)을 그대로 둔다.\n */\nexport function resolveRobotsMeta(flags: {\n noindex?: boolean;\n nofollow?: boolean;\n}): RobotsMeta | undefined {\n if (!flags.noindex && !flags.nofollow) return undefined;\n return { index: !flags.noindex, follow: !flags.nofollow };\n}\n\n/** Next `Metadata` 의 구조적 부분집합 — 소비자는 그대로 return 가능. */\nexport interface PostMetadata {\n title: string;\n description?: string;\n alternates?: {\n /** `types` 만 emit 되는 경우(예: siteUrl 만 있고 path/collections 미해석)도 있어 optional. */\n canonical?: string;\n languages?: Record<string, string>;\n /** avcd 구조 — RSS 등 대체 표현. Next `Metadata.alternates.types` 그대로. */\n types?: Record<string, string>;\n };\n robots?: RobotsMeta;\n openGraph: {\n type: \"article\";\n title: string;\n description?: string;\n url?: string;\n publishedTime?: string;\n /** avcd 구조 — og article 확장. */\n modifiedTime?: string;\n section?: string;\n tags?: string[];\n authors?: string[];\n images?: { url: string; alt: string }[];\n };\n /**\n * avcd 구조 — Twitter Card. og 와 동일 소스(seo override → 글 값)에서 title/\n * description/images 를 채운다. `card`+`title` 은 항상 emit(og 이미지 유무와\n * 무관), `images` 는 og 이미지가 있을 때만.\n */\n twitter?: {\n card: \"summary_large_image\";\n title: string;\n description?: string;\n images?: string[];\n };\n /** ADR-0065 — `<meta name=\"rt:content-id\">` 등 임의 head meta (조회수 식별자). */\n other?: Record<string, string>;\n}\n\n/**\n * 글 메타데이터를 Next `Metadata` 형태로 빌드.\n *\n * - 제목/설명/OG 이미지는 어드민 SEO override 가 있으면 우선, 없으면 글 값 fallback.\n * - canonical: `seo.canonical` override → 없으면 `siteUrl`+`path`(명시) → 없으면\n * `siteUrl`+`collections`(글의 `collectionKey` 로 공지/블로그 basePath 자동 해석)\n * → 없으면 생략. 모든 글이 자기 자신을 가리키는 canonical 을 갖는 게 권장이므로\n * `siteUrl` 과 함께 `path` 또는 `collections` 전달을 권장한다.\n * - `noindex`/`nofollow` 중 하나라도 켜지면 `robots` 출력.\n * - avcd 구조 — `siteUrl` 이 있으면 `alternates.types[\"application/rss+xml\"]`\n * (기본 `/feed.xml`, `feedPath` 로 override)을 canonical 유무와 무관하게 emit.\n * Twitter Card(`summary_large_image`)는 og 와 같은 소스로 항상 emit.\n *\n * ```ts\n * // 단일 블로그(레거시): 라우트가 곧 섹션이므로 path 를 직접 준다.\n * return buildPostMetadata(post, {\n * siteUrl: process.env.NEXT_PUBLIC_SITE_URL,\n * path: `/blog/${post.slug}`,\n * });\n *\n * // 다중 스트림(ADR-0060): collections 를 주면 공지/블로그를 글의\n * // collectionKey 로 구분해 canonical 을 자동 해석한다(공지→/notice, 블로그→/blog).\n * return buildPostMetadata(post, {\n * siteUrl: process.env.NEXT_PUBLIC_SITE_URL,\n * collections: COLLECTIONS,\n * });\n * ```\n */\nexport function buildPostMetadata(\n post: PostMetadataInput,\n options: BuildPostMetadataOptions = {},\n): PostMetadata {\n const seo = post.seo ?? undefined;\n const title = seo?.title || post.title;\n const description = seo?.description || post.description || undefined;\n const ogImage = seo?.ogImage || post.image || undefined;\n\n let canonical = seo?.canonical || undefined;\n if (!canonical && options.siteUrl) {\n // 경로 우선순위: 명시 path → collections 로 글의 섹션 basePath 자동 해석(공지/블로그).\n let path = options.path;\n if (!path && options.collections && post.slug) {\n path =\n resolvePostPath(\n { slug: post.slug, terms: [], collectionKey: post.collectionKey ?? null },\n options.collections,\n ) ?? undefined;\n }\n if (path) {\n const normalized = path.startsWith(\"/\") ? path : `/${path}`;\n canonical = `${trimSlash(options.siteUrl)}${normalized}`;\n }\n }\n\n const openGraph: PostMetadata[\"openGraph\"] = { type: \"article\", title };\n if (description) openGraph.description = description;\n if (canonical) openGraph.url = canonical;\n if (post.date) openGraph.publishedTime = post.date;\n // avcd 구조 — og article 확장. 입력에 값이 있을 때만 emit(기존 호출부는 diff 0).\n if (post.modified) openGraph.modifiedTime = post.modified;\n if (post.section) openGraph.section = post.section;\n if (post.tags && post.tags.length > 0) openGraph.tags = [...post.tags];\n if (post.authors && post.authors.length > 0) openGraph.authors = [...post.authors];\n if (ogImage) {\n openGraph.images = [{ url: ogImage, alt: seo?.ogImageAlt || title }];\n }\n\n const meta: PostMetadata = { title, openGraph };\n if (description) meta.description = description;\n if (canonical) {\n meta.alternates = { canonical };\n // hreflang — canonical 이 있을 때만(languages 단독 방출은 의미 없음).\n if (options.siteUrl && options.locales) {\n const languages = buildHreflangLanguages(\n post.translations,\n options.siteUrl,\n options.locales,\n );\n if (languages) meta.alternates.languages = languages;\n }\n }\n // avcd 구조 — RSS alternate. canonical 유무와 무관하게 siteUrl 만 있으면 emit\n // (사이트 차원 발견성 신호라 개별 글의 canonical 해석 성공 여부에 기대지 않음).\n if (options.siteUrl) {\n const feedPath = options.feedPath ?? \"/feed.xml\";\n const normalizedFeedPath = feedPath.startsWith(\"/\") ? feedPath : `/${feedPath}`;\n meta.alternates = {\n ...meta.alternates,\n types: {\n \"application/rss+xml\": `${trimSlash(options.siteUrl)}${normalizedFeedPath}`,\n },\n };\n }\n const baseRobots = resolveRobotsMeta({\n ...(seo?.noindex !== undefined && { noindex: seo.noindex }),\n ...(seo?.nofollow !== undefined && { nofollow: seo.nofollow }),\n });\n const index = baseRobots?.index ?? true;\n const follow = baseRobots?.follow ?? true;\n meta.robots = {\n index,\n follow,\n googleBot: {\n index,\n follow,\n \"max-image-preview\": \"large\",\n },\n };\n // ADR-0065 — 조회수 식별자 meta 자동 emit. 소비자가 따로 신경 쓸 필요 없이\n // buildPostMetadata 만 쓰면 비콘이 글별 조회수를 정확히 집계한다.\n if (post.id) meta.other = { \"rt:content-id\": post.id };\n // avcd 구조 — Twitter Card. og 와 같은 소스(seo override → 글 값)에서 채운다.\n // card+title 은 og 이미지 유무와 무관하게 항상 emit, images 는 og 이미지가 있을 때만.\n const twitter: PostMetadata[\"twitter\"] = { card: \"summary_large_image\", title };\n if (description) twitter.description = description;\n if (ogImage) twitter.images = [ogImage];\n meta.twitter = twitter;\n return meta;\n}\n\n/**\n * ADR-0052 A안 (W4-6 PR C1) — 글의 `translations[]`(PR B 응답, 자기 자신 포함)를\n * hreflang `alternates.languages` 로 변환. **양방향 + `x-default` = 기본 locale**\n * (Google 요건 — 기본판도 자기 자신을 가리키는 entry 를 갖는다).\n *\n * `buildPostMetadata` 가 내부적으로 쓰지만, OG `type:\"article\"` 이 부적절한\n * 라우트(CMS `[slug]` 페이지 등)는 이 함수를 직접 호출해 plain `Metadata.alternates`\n * 를 조립한다.\n *\n * 번역이 없거나(자기 자신 1건뿐) 기본 locale 판 URL 을 못 만들면(예: `translations`\n * 에 기본 locale 항목 자체가 없음 — 데이터 이상) undefined 를 반환해 languages\n * 방출을 생략한다(부분 hreflang 은 검색엔진이 무시하거나 오해할 수 있어 전부 생략이\n * 더 안전 — fail-closed).\n */\nexport function buildHreflangLanguages(\n translations: readonly CmsPostTranslationRef[] | undefined,\n siteUrl: string,\n locales: PostLocaleOptions,\n): Record<string, string> | undefined {\n if (!translations || translations.length <= 1) return undefined;\n const { defaultLocale, pathFor } = locales;\n const buildPath =\n pathFor ??\n ((locale: string, slug: string): string =>\n locale === defaultLocale ? `/blog/${slug}` : `/${locale}/blog/${slug}`);\n const base = trimSlash(siteUrl);\n const languages: Record<string, string> = {};\n for (const translation of translations) {\n const path = buildPath(translation.locale, translation.slug);\n const normalized = path.startsWith(\"/\") ? path : `/${path}`;\n languages[translation.locale] = `${base}${normalized}`;\n }\n const defaultUrl = languages[defaultLocale];\n if (!defaultUrl) return undefined;\n languages[\"x-default\"] = defaultUrl;\n return languages;\n}\n","/**\n * 아카이브(모음 페이지) 색인 정책 — **단일 SSoT**.\n *\n * seo-geo-contract v3.1 §1 \"색인 위생\": robots 메타(noindex)와 sitemap 포함은\n * **한 소스에서 파생**해야 한다. 둘이 갈리면 \"noindex 인데 sitemap 에 제출된 URL\"\n * 이 생기고, 검색엔진은 이를 크롤 예산 낭비 + 품질 신호 하락으로 취급한다.\n *\n * 그래서 이 파일의 `decideArchiveIndex` 하나가\n * - `routes-sitemap-entries.ts` 의 sitemap entry 필터(`inSitemap`)\n * - 아카이브 페이지 `generateMetadata` 의 robots(`buildArchiveMetadata` → `index`)\n * - 0건 아카이브의 404 처리(`render`)\n * 를 **동시에** 결정한다. 소비처가 따로 판단하지 않는다.\n *\n * 정책 요지: 아카이브는 **기본 noindex**. 고유한 소개 글을 채워 \"허브\"로 승격한\n * 분류·작가만 색인 대상이 된다(`promotion`).\n *\n * **판정 입력도 이 파일에서 만든다**(`resolveCategoryPromotion`·\n * `resolveAuthorPromotion`). 판정 함수만 공유하고 입력 조립을 소비처가 따로\n * 하면 \"sitemap 은 모름을 통과로, 페이지는 모름을 미승격으로\" 읽는 식으로\n * 결론이 갈린다 — 실제로 그렇게 갈렸던 것이 이 3-state 도입의 계기다.\n * (같은 이유로 **모집단**도 한 곳에서 정한다 — `archive-categories.ts`.)\n *\n * ## 불변식의 범위 — 정상 상태(steady state) 보장이다\n *\n * \"noindex 인데 sitemap 포함\" 이 없다는 계약은 **두 요청이 같은 설정을 봤을 때**\n * 성립한다. sitemap 과 아카이브 페이지는 서로 다른 요청에서 각자 설정을 조회하고\n * (sitemap 은 ISR 캐시, 페이지는 요청 시 렌더), 한쪽만 실패할 수 있다. 예를 들어\n * 페이지 조회는 성공(`promotedCategorySlugs: []` → `not-promoted` → noindex)인데\n * sitemap 조회는 실패(`undefined` → `unknown` → 포함)면 그 창 동안 두 결론이\n * 어긋난다. 이건 코드로 없앨 수 없다 — 두 표면이 같은 시점의 설정을 본다고\n * 보장할 방법이 없기 때문이다(공유 캐시를 둬도 만료 시점이 갈린다).\n *\n * **자가 치유 시간 = 사이트맵 revalidate 주기.** 다음 재생성 때 설정을 정상\n * 조회하면 어긋남이 사라진다. Site Kit(`sites/starter/app/sitemap.ts`)은\n * `export const revalidate = 60` 이므로 **상한 60초**다(아카이브 페이지도 60초).\n * 고객 사이트가 값을 바꿨으면 그 값이 곧 상한이다.\n *\n * **왜 이 방향인가(sitemap 조회 실패 시 포함 유지)**: 반대로 fail-closed 하면\n * 일시적 API 장애 한 번에 이미 색인된 URL 이 sitemap 에서 통째로 빠진다 —\n * 재발견까지 걸리는 노출 손실이 \"잠깐 noindex 인 URL 이 sitemap 에 남는\" 비용보다\n * 크다. 후자는 크롤러가 해당 URL 을 방문해 noindex 를 읽고 색인에서 빼는, 되돌릴\n * 수 있는 낭비다. (작가 **entry** 쪽만 예외로 fail-closed 인데, 이유는\n * `routes-sitemap-entries.ts` 의 `authorEntries` 주석 참조 — 라우트 자체가 없는\n * 사이트에 존재하지 않는 URL 을 제출할 위험이 더 크기 때문이다.)\n *\n * ## 정책 대상에서 뺀 것 (스펙 예외)\n *\n * 분류 **목록** 허브(`/blog/categories`, collection 모드의\n * `{basePath}/categories`)는 이 정책을 적용하지 않고 항상 sitemap 에 넣는다.\n * 개별 아카이브가 아니라 \"이 사이트에 어떤 분류가 있는지\" 를 훑는 **내비게이션\n * 허브**라, 중복 콘텐츠 위험(같은 글이 여러 모음에 중복 노출)이 없고 크롤러가\n * 분류 구조를 발견하는 진입점 역할을 하기 때문이다. 정본 스펙(seo-geo-contract\n * v3.1 §2)에는 이 예외가 아직 명문화돼 있지 않다 — 스펙 개정이 필요하다.\n */\n\nimport { type RobotsMeta, resolveRobotsMeta } from \"./routes-metadata.js\";\nimport { trimSlash } from \"./routes-shared.js\";\n\n/** 승격(\"허브로 올리기\") 개념이 있는 아카이브 종류. */\nexport type PromotableArchiveKind = \"category\" | \"author\";\n\n/** 종류만으로 항상 noindex 인 아카이브 — 승격 개념이 없다. */\nexport type AlwaysNoindexArchiveKind = \"tag\" | \"date\" | \"search\" | \"paginated\";\n\n/** 아카이브 종류. `paginated` 는 종류 불명의 2페이지 이상 목록. */\nexport type ArchiveKind = PromotableArchiveKind | AlwaysNoindexArchiveKind;\n\n/**\n * 허브 승격 상태 — **boolean 이 아니라 3-state 다**.\n *\n * - `promoted` — 어드민에서 노출을 켰다(확정).\n * - `not-promoted` — 켜지 않았다(확정). 아카이브 기본값.\n * - `unknown` — 승격 설정을 **읽지 못했다**. 구버전 API 응답이라 필드가 없거나,\n * 설정 fetch 가 실패했거나, 빌드 타임 사이트맵이 설정 조회를 생략한 경우.\n *\n * `unknown` 을 `not-promoted` 로 뭉개면 \"설정을 못 읽었다\" 는 이유만으로 이미\n * 색인된 URL 에 noindex 를 붙이고 sitemap 에서 지우게 된다 — 근거 없는 노출\n * 손실이다. 그래서 `unknown` 은 **정책 미적용 = 기존 동작 보존**(색인 + sitemap\n * 포함)으로 fail-soft 한다. 이 결정을 소비처가 아니라 정책 함수 안에 두는 이유는\n * 위 모듈 주석 참조.\n */\nexport type ArchivePromotion = \"promoted\" | \"not-promoted\" | \"unknown\";\n\n/**\n * 아카이브 정책이 `postCount` 를 세는 **폴백 모집단 한도**. sitemap 과 아카이브\n * 페이지가 이 상수를 함께 import 해 같은 크기의 최근 글 배치에서 개수를 파생한다.\n * 소비처마다 N 이 다르면 같은 카테고리에 대해 sitemap 은 \"글 있음\", 페이지는\n * \"0건 → 404\" 라는 상충이 생긴다(정확히 이 상수를 만든 이유다).\n *\n * **이제는 폴백 경로에서만 쓰인다**: 카테고리 `postCount` 의 1순위 소스는 서버측\n * 집계 API(`GET /v1/cms/public/categories`)이고, 그쪽은 발행 글 수와 무관하게\n * 모든 분류의 정확한 개수를 준다. 이 배치 파생은 그 API 를 못 받았을 때\n * (구버전 API·일시 장애) 기존 동작을 보존하는 fail-soft 경로다 —\n * `archive-categories.ts` 참조.\n *\n * **폴백 경로의 알려진 한계(그대로 유지)**: 발행 글이 이 한도를 넘는 사이트에서는\n * 한도 밖에만 글이 있는 카테고리가 sitemap·페이지 **양쪽 모두에서** 누락된다\n * (페이지는 404). 양쪽이 같은 방식으로 틀리므로 색인 위생 계약(noindex ↔ sitemap\n * 불일치)은 깨지지 않는다.\n *\n * collection 모드 스트림 아카이브(`collectCollectionCategories`)는 아직 이 배치\n * 파생만 쓴다 — 집계 API 는 `collection_key` 스코프를 이미 지원하므로 배선만\n * 남았다(후속).\n */\nexport const ARCHIVE_POST_COUNT_LIMIT = 200;\n\n/** 개수·페이지 입력 — 종류와 무관한 공통 부분. */\ninterface ArchiveScopeInput {\n /**\n * 이 아카이브에 실제로 걸린 발행 글 수. 0 = 빈 아카이브.\n * 유한한 0 이상 정수여야 한다(아래 \"입력 경계\" 참조).\n */\n postCount: number;\n /** 1-base 페이지 번호. 미지정 = 1페이지. 유한한 1 이상 정수여야 한다. */\n page?: number;\n}\n\n/**\n * `decideArchiveIndex` 입력. 종류에 따라 갈리는 판별 유니온이라,\n * **승격 상태는 의미가 있는 종류에서만 요구되고 나머지에서는 아예 받지 않는다** —\n * 태그 아카이브에 `promotion` 을 넘겨놓고 반영되길 기대하는 오해를 타입이 막는다.\n */\nexport type ArchiveIndexInput =\n | (ArchiveScopeInput & {\n kind: PromotableArchiveKind;\n promotion: ArchivePromotion;\n })\n | (ArchiveScopeInput & { kind: AlwaysNoindexArchiveKind });\n\nexport interface ArchiveIndexDecision {\n /** `notFound` 면 페이지가 404 를 반환해야 한다(빈 아카이브 = 색인 쓰레기). */\n render: \"ok\" | \"notFound\";\n /** robots 색인 허용 여부. `buildArchiveMetadata` 가 이 값으로 robots 를 만든다. */\n index: boolean;\n /** sitemap 에 이 URL 을 넣을지. `index` 가 false 면 반드시 false. */\n inSitemap: boolean;\n}\n\nconst NOT_FOUND: ArchiveIndexDecision = {\n render: \"notFound\",\n index: false,\n inSitemap: false,\n};\nconst NOINDEX: ArchiveIndexDecision = {\n render: \"ok\",\n index: false,\n inSitemap: false,\n};\nconst INDEXED: ArchiveIndexDecision = {\n render: \"ok\",\n index: true,\n inSitemap: true,\n};\n/**\n * 승격 상태를 모를 때(`unknown`)의 결론 — 정책을 적용하지 않고 **기존 동작 보존**.\n * 값은 `INDEXED` 와 같지만 이유가 다르다(\"승격 확인됨\" 이 아니라 \"판단 근거 없음\").\n */\nconst POLICY_NOT_APPLIED: ArchiveIndexDecision = INDEXED;\n\nfunction isNonNegativeInteger(value: number): boolean {\n return Number.isInteger(value) && value >= 0;\n}\n\n// ── 판정 입력 조립 (sitemap·페이지가 공유) ───────────────────────────────────\n\n/**\n * 카테고리 승격 상태. 소스는 공개 projection\n * `blog-settings.sitemap.promotedCategorySlugs`(어드민 카테고리 편집의\n * \"검색에 이 분류 페이지 노출\" 토글 집계).\n *\n * 목록 자체가 `undefined` 면 `unknown` — 빈 배열 `[]`(\"승격 0건\" 확정)과 의미가\n * 다르다. cms-client `fetchBlogSettings` 가 구버전 응답에서 필드를 **생략**하고,\n * 설정 fetch 실패 경로도 목록을 만들지 않으므로 이 구분이 그대로 전달된다.\n */\nexport function resolveCategoryPromotion(\n promotedSlugs: readonly string[] | undefined,\n slug: string,\n): ArchivePromotion {\n if (!promotedSlugs) return \"unknown\";\n return promotedSlugs.includes(slug) ? \"promoted\" : \"not-promoted\";\n}\n\n/**\n * 작가 승격 상태. 카테고리와 달리 작가는 개별 승격 UI 가 없고 **사이트 단위 토글**\n * (`blog-settings.sitemap.authors`, \"작가별 글 모음도 검색엔진에 알리기\")이 곧\n * 승격 신호다. 토글 값을 못 읽었으면 `undefined` 를 넘겨 `unknown` 을 받는다.\n */\nexport function resolveAuthorPromotion(\n authorsEnabled: boolean | undefined,\n): ArchivePromotion {\n if (authorsEnabled === undefined) return \"unknown\";\n return authorsEnabled ? \"promoted\" : \"not-promoted\";\n}\n\n// ── 판정 ─────────────────────────────────────────────────────────────────────\n\n/**\n * 아카이브 URL 하나의 색인·sitemap·렌더 판정. **판정 순서가 곧 정책이다**:\n *\n * 0. **입력 경계** — `postCount` 가 유한한 0 이상 정수가 아니거나 `page` 가 유한한\n * 1 이상 정수가 아니면 noindex + sitemap 제외로 중립 처리한다. 개수를 못 세는\n * 상태에서 \"좋은 모음 페이지\" 라고 주장할 근거가 없고, 그렇다고 404 로 화면을\n * 없애는 것은 과한 파괴라서 **화면은 살리고 색인만 보류**한다.\n * 1. **0건** (검색 제외) → 404. 빈 모음 페이지는 애초에 존재하면 안 된다.\n * 검색 결과는 0건이어도 정상 화면이라 404 로 만들지 않는다.\n * 2. **2페이지 이상** 이거나 **검색·날짜·태그** → noindex + sitemap 제외.\n * (승격 여부와 무관 — 승격된 카테고리의 2페이지도 noindex 다.)\n * 3. **카테고리·작가** → 승격 상태 3-state 로 결정. `promoted` 면 색인 + sitemap,\n * `not-promoted` 면 noindex + 제외, `unknown` 이면 정책 미적용(기존 동작 보존).\n */\nexport function decideArchiveIndex(input: ArchiveIndexInput): ArchiveIndexDecision {\n const page = input.page ?? 1;\n if (!isNonNegativeInteger(input.postCount)) return NOINDEX;\n if (!isNonNegativeInteger(page) || page < 1) return NOINDEX;\n\n if (input.postCount === 0 && input.kind !== \"search\") return NOT_FOUND;\n if (page >= 2) return NOINDEX;\n if (input.kind !== \"category\" && input.kind !== \"author\") return NOINDEX;\n\n switch (input.promotion) {\n case \"promoted\":\n return INDEXED;\n case \"unknown\":\n return POLICY_NOT_APPLIED;\n default:\n return NOINDEX;\n }\n}\n\n// ── 아카이브 metadata (같은 decision 에서 robots 파생) ────────────────────────\n\nexport interface ArchiveMetadataFields {\n /** 아카이브 화면 제목 (예: \"세무 | 블로그\"). */\n title: string;\n /** 메타 설명 — 분류 소개 글 등. 비면 생략. */\n description?: string | null;\n /** 링크 추적 제외. 색인과 별개 축이라 정책이 건드리지 않는다. */\n nofollow?: boolean;\n}\n\nexport type ArchiveMetadataInput = ArchiveIndexInput & ArchiveMetadataFields;\n\nexport interface BuildArchiveMetadataOptions {\n /** self-canonical origin (예: `https://example.com`). `path` 와 함께. */\n siteUrl?: string;\n /** self-canonical 경로 (예: `/blog/categories/tax`). `siteUrl` 과 함께. */\n path?: string;\n}\n\n/** Next `Metadata` 의 구조적 부분집합 — 아카이브 `generateMetadata` 가 그대로 return. */\nexport interface ArchiveMetadata {\n title: string;\n description?: string;\n alternates?: { canonical: string };\n robots?: RobotsMeta;\n}\n\n/**\n * 아카이브 페이지 metadata. **robots 는 `decideArchiveIndex` 의 `index` 에서만**\n * 파생된다 — sitemap 필터와 같은 판정이라 \"noindex 인데 sitemap 에 있음\" 이\n * 구조적으로 불가능하다.\n *\n * `render === \"notFound\"` 판정(0건 아카이브)은 metadata 로 표현할 수 없으므로\n * 페이지가 `decideArchiveIndex` 를 직접 호출해 `notFound()` 를 부른다:\n *\n * ```ts\n * const promotion = resolveCategoryPromotion(settings.sitemap?.promotedCategorySlugs, slug);\n * const decision = decideArchiveIndex({ kind: \"category\", promotion, postCount });\n * if (decision.render === \"notFound\") notFound();\n * // generateMetadata 에서:\n * return buildArchiveMetadata(\n * { kind: \"category\", promotion, postCount, title: `${name} | 블로그` },\n * { siteUrl, path: `/blog/categories/${slug}` },\n * );\n * ```\n */\nexport function buildArchiveMetadata(\n input: ArchiveMetadataInput,\n options: BuildArchiveMetadataOptions = {},\n): ArchiveMetadata {\n const decision = decideArchiveIndex(input);\n const meta: ArchiveMetadata = { title: input.title };\n if (input.description) meta.description = input.description;\n if (options.siteUrl && options.path) {\n const normalized = options.path.startsWith(\"/\") ? options.path : `/${options.path}`;\n meta.alternates = { canonical: `${trimSlash(options.siteUrl)}${normalized}` };\n }\n const robots = resolveRobotsMeta({\n noindex: !decision.index,\n ...(input.nofollow !== undefined && { nofollow: input.nofollow }),\n });\n if (robots) meta.robots = robots;\n return meta;\n}\n","/**\n * 사이트맵 entry 조립 — `createSitemap`·`createSitemapIndex` 가 공유하는 내부\n * 빌더. 라우트 팩토리(`routes-feed-sitemap.ts`)와 분리해 \"무엇을 내보낼지\"(정책·\n * 조립)와 \"어떤 라우트로 내보낼지\"(Next 계약)를 나눈다. 공개 표면은 `routes.ts` 배럴.\n */\n\nimport {\n type CmsPostContent,\n type RootTaleSitemapSettings,\n fetchAuthors,\n fetchBlogSettings,\n fetchPosts,\n} from \"@roottale/cms-client/server\";\nimport { isInCollectionMode, resolvePostCollection, resolvePostUrl } from \"@roottale/cms-core\";\n\nimport { fetchBlogArchiveCategories } from \"./archive-categories.js\";\nimport {\n ARCHIVE_POST_COUNT_LIMIT,\n decideArchiveIndex,\n resolveAuthorPromotion,\n resolveCategoryPromotion,\n} from \"./archive-index-policy.js\";\nimport {\n type BlogCategoryWithCount,\n collectCollectionCategories,\n} from \"./blog-filter.js\";\nimport { buildHreflangLanguages } from \"./routes-metadata.js\";\nimport {\n type RootTaleSiteConfig,\n type RouteCollection,\n safeFetchPosts,\n trimSlash,\n} from \"./routes-shared.js\";\n\n/** Next `MetadataRoute.Sitemap[number]` 와 구조 호환(타입 결합 회피). */\nexport interface SitemapEntry {\n url: string;\n lastModified?: string | Date;\n /** @deprecated Google 이 무시 — emit 하지 않는다(blanche식 loc+lastmod). */\n changeFrequency?:\n | \"always\"\n | \"hourly\"\n | \"daily\"\n | \"weekly\"\n | \"monthly\"\n | \"yearly\"\n | \"never\";\n /** @deprecated Google 이 무시 — emit 하지 않는다. */\n priority?: number;\n /** `<image:image>` — 글 대표 이미지 등. Next `MetadataRoute.Sitemap` 호환. */\n images?: string[];\n /** hreflang 대체 언어 — 다국어(`alternates.languages`). Next 호환. */\n alternates?: { languages?: Record<string, string> };\n}\n\n// ── 사이트맵 빌더 (createSitemap / createSitemapIndex 공유) ───────────────────\n\nconst SITEMAP_DEFAULTS: RootTaleSitemapSettings = {\n blogImages: true,\n locales: [],\n authors: false,\n // promotedCategorySlugs 는 의도적으로 기본값이 없다 — \"모름\"(undefined)과\n // \"승격 0건\"([])을 구분해야 색인 정책을 fail-soft 로 적용할 수 있다.\n};\n\n/** `createSitemapIndex` 옵션 — admin 설정을 코드에서 덮어쓰거나 fetch 를 끌 때만. */\nexport interface SitemapIndexOptions {\n /** admin 의 `blogImages` 토글을 덮어쓴다. */\n blogImages?: boolean;\n /** admin 의 `locales` 를 덮어쓴다 (다국어, PR C). */\n locales?: string[];\n /** admin 의 `authors` 토글을 덮어쓴다 (작가 사이트맵). */\n authors?: boolean;\n /**\n * admin 의 \"검색에 이 분류 페이지 노출\" 집계를 덮어쓴다 — 색인 정책상 여기\n * 없는 카테고리의 아카이브는 sitemap 에서 빠진다. `skipRemoteSettings` 와 함께\n * 쓰면 네트워크 없이 정책을 켤 수 있다(빌드 타임 사이트맵).\n */\n promotedCategorySlugs?: string[];\n /** 작가 아카이브 base path. 기본 \"/blog/author\" (→ /blog/author/{slug}). */\n authorBasePath?: string;\n /** true 면 admin 설정 fetch 생략(opts/기본값만). 빌드 시 네트워크 회피용. */\n skipRemoteSettings?: boolean;\n}\n\n/** 작가 fetch 실패 시 빈 목록 — 사이트맵은 항상 정상 응답(404 방지). */\nasync function safeFetchAuthors(\n config: RootTaleSiteConfig,\n): Promise<Awaited<ReturnType<typeof fetchAuthors>>> {\n try {\n return await fetchAuthors({ apiKey: config.apiKey, baseUrl: config.apiBase });\n } catch (err) {\n console.error(\"[@roottale/cms-renderer-next/routes] fetchAuthors 실패:\", err);\n return [];\n }\n}\n\n/**\n * `resolveSitemapSettings` 결과. 설정 **값**과 그 값을 얼마나 믿을 수 있는지를\n * 함께 돌려준다 — 사이트맵 인덱스의 섹션 목록이 \"토글이 꺼졌다\" 와 \"토글을 못\n * 읽었다\" 를 구분해야 하기 때문(아래 `settingsFetchFailed` 참조).\n */\nexport interface ResolvedSitemapSettings {\n settings: RootTaleSitemapSettings;\n /**\n * admin 설정 fetch 를 **시도했는데 실패**했으면 true. 의도적으로 생략한\n * (`skipRemoteSettings`) 경우는 false — 그건 장애가 아니라 설정이다.\n */\n settingsFetchFailed: boolean;\n}\n\n/** opts override > admin 설정(fetch) > 기본값. fetch 실패는 fail-soft. */\nexport async function resolveSitemapSettings(\n config: RootTaleSiteConfig,\n opts: SitemapIndexOptions,\n): Promise<ResolvedSitemapSettings> {\n let remote: Partial<RootTaleSitemapSettings> = {};\n let settingsFetchFailed = false;\n if (!opts.skipRemoteSettings) {\n try {\n // 구버전 cms-client(블로그 설정에 sitemap 없음)면 `?? {}` 로 fail-soft.\n remote =\n (\n await fetchBlogSettings({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n })\n ).sitemap ?? {};\n } catch (err) {\n settingsFetchFailed = true;\n console.error(\n \"[@roottale/cms-renderer-next/routes] fetchBlogSettings 실패(기본값으로 진행):\",\n err,\n );\n }\n }\n const settings: RootTaleSitemapSettings = {\n blogImages: opts.blogImages ?? remote.blogImages ?? SITEMAP_DEFAULTS.blogImages,\n locales: opts.locales ?? remote.locales ?? SITEMAP_DEFAULTS.locales,\n authors: opts.authors ?? remote.authors ?? SITEMAP_DEFAULTS.authors,\n };\n // \"모름\" 을 기본값으로 덮지 않는다 — 아래 `categoryEntries` 가 undefined 를\n // `resolveCategoryPromotion` 을 거쳐 \"unknown = 정책 미적용\" 으로 읽는다.\n const promoted = opts.promotedCategorySlugs ?? remote.promotedCategorySlugs;\n if (promoted) settings.promotedCategorySlugs = promoted;\n return { settings, settingsFetchFailed };\n}\n\n/**\n * ADR-0052 A안 (W4-6 PR B) — sitemap fail-closed. admin 이 `locales` 에 값을\n * 넣었지만 그 locale 로 발행된 글이 실제로는 0건이면(라우트 미배포·오타 등)\n * 존재하지 않는 `/{locale}/...` URL 의 hreflang 을 만들지 않는다 — 이 검증은\n * site-wide 단일 게이트(글 단위 실제 translations 매칭은 PR C1 후속, 여긴\n * \"이 사이트에 이 locale 콘텐츠가 아예 있는가\"만 판정). fetch 실패는 fail-soft\n * 로 그 locale 을 제외(존재 확인 불가 = 생성 안 함, §1 404 배관 위험 우선 차단).\n */\nexport async function localesWithPublishedContent(\n config: RootTaleSiteConfig,\n locales: string[],\n): Promise<string[]> {\n if (locales.length === 0) return locales;\n const checks = await Promise.all(\n locales.map(async (locale) => {\n try {\n const page = await fetchPosts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n type: \"post\",\n locale,\n limit: 1,\n });\n return page.items.length > 0 ? locale : null;\n } catch (err) {\n console.error(\n `[@roottale/cms-renderer-next/routes] locale '${locale}' 발행 글 확인 실패(제외):`,\n err,\n );\n return null;\n }\n }),\n );\n return checks.filter((locale): locale is string => locale !== null);\n}\n\n/** 정적 경로 — changeFrequency/priority 는 떨군다(blanche식). 나머지는 보존. */\nexport function staticEntries(staticRoutes: SitemapEntry[]): SitemapEntry[] {\n return staticRoutes.map((r) => {\n const entry: SitemapEntry = { url: r.url };\n if (r.lastModified !== undefined) entry.lastModified = r.lastModified;\n if (r.images) entry.images = r.images;\n if (r.alternates) entry.alternates = r.alternates;\n return entry;\n });\n}\n\n/**\n * 발행 글 entry. collection 모드면 소속 스트림 basePath(`resolvePostUrl`), 레거시면\n * 단일 `/blog`. 어느 스트림에도 안 속한 글(상세 라우트 없음)은 제외. `blogImages`\n * 가 켜져 있으면 대표 이미지를 `<image:image>` 로 함께 색인.\n *\n * `defaultLocale` 이 주어지면(다국어 사이트, PR C1) 각 글의 실제 `translations[]`\n * (PR B 응답)로 hreflang `alternates.languages` 를 단다 — **글 단위 정밀**, 옛\n * `localizeEntries` 의 slug 1:1 prefix 가정을 쓰지 않는다(언어판 없는 글은 생략,\n * `tenant-site-i18n-plan.md` §3 PR C1-4). 번역 그룹이 다른 스트림에 걸쳐 있을 순\n * 없다는 전제(v1: 스트림 글은 언어판 없음, §3 PR C1-1)로 언어판 전부 이 글과 같은\n * basePath 를 쓴다.\n */\nexport function postEntries(\n config: RootTaleSiteConfig,\n siteUrl: string,\n posts: CmsPostContent[],\n collections: RouteCollection[] | undefined,\n settings: RootTaleSitemapSettings,\n defaultLocale?: string,\n): SitemapEntry[] {\n const inCollection = isInCollectionMode(collections);\n const blogBase = config.blogBasePath ?? \"/blog\";\n return posts.flatMap((p) => {\n const url = inCollection\n ? resolvePostUrl(siteUrl, p, collections!)\n : `${siteUrl}${blogBase}/${p.slug}`;\n if (!url) return [];\n const entry: SitemapEntry = {\n url,\n lastModified: p.updatedAt ?? p.publishedAt ?? undefined,\n };\n if (settings.blogImages && p.featuredImageUrl) {\n entry.images = [p.featuredImageUrl];\n }\n if (defaultLocale) {\n const basePath = inCollection\n ? (resolvePostCollection(p, collections!)?.basePath ?? blogBase)\n : blogBase;\n const trimmedBase = trimSlash(basePath);\n const languages = buildHreflangLanguages(p.translations, siteUrl, {\n defaultLocale,\n pathFor: (locale, slug) =>\n locale === defaultLocale ? `${trimmedBase}/${slug}` : `/${locale}${trimmedBase}/${slug}`,\n });\n if (languages) entry.alternates = { languages };\n }\n return [entry];\n });\n}\n\n/**\n * 레거시 `/blog` 계열 아카이브 모집단 — 실패는 빈 목록(사이트맵 always-200).\n * 페이지는 같은 헬퍼를 쓰되 실패를 throw 로 올린다(API 장애 ≠ 404).\n */\nasync function safeBlogArchiveCategories(\n config: RootTaleSiteConfig,\n): Promise<BlogCategoryWithCount[]> {\n try {\n return await fetchBlogArchiveCategories(config);\n } catch (err) {\n console.error(\n \"[@roottale/cms-renderer-next/routes] 아카이브 카테고리 조회 실패:\",\n err,\n );\n return [];\n }\n}\n\n/**\n * 카테고리 아카이브 entry. collection 모드는 `archives:true` 스트림만.\n *\n * **색인 위생(§1)**: 개별 카테고리 아카이브(`/categories/{slug}`)는\n * `decideArchiveIndex` 로 걸러 \"허브로 승격\" 한 것만 남긴다 — 아카이브 페이지가\n * `buildArchiveMetadata` 로 noindex 를 내보내는 것과 **같은 판정**이라 noindex URL\n * 이 sitemap 에 남는 상충이 생기지 않는다.\n *\n * 카테고리 **목록** 페이지(`/categories`)는 개별 아카이브가 아니라 분류를 훑는\n * 내비게이션 허브라 정책 대상이 아니다 — 기존대로 카테고리가 1개 이상일 때 포함.\n *\n * `promotedSlugs === undefined` = 승격 정보를 모르는 호출 경로(admin 설정 fetch 를\n * 생략한 `createSitemap`·`skipRemoteSettings`, 또는 구버전 API 응답). 이때\n * `resolveCategoryPromotion` 이 `unknown` 을 만들고 정책이 \"미적용 = 기존 동작\n * 유지\" 로 fail-soft 한다 — 아카이브 페이지도 **같은 헬퍼로 같은 3-state 를**\n * 만들므로 두 소스의 결론이 갈리지 않는다.\n *\n * **모집단(어떤 글에서 카테고리를 세는가)도 이 함수가 직접 정한다** — 호출부가\n * 글 배치를 주입하던 옛 시그니처에서는 사이트맵이 \"발행 글 전체\", 페이지가\n * \"미소속 글\" 을 세어 결론이 갈렸다(`archive-categories.ts` 모듈 주석 참조).\n * 지금은 계열별로 `archive-categories`/`collectCollectionCategories` 라는 **한\n * 함수**가 모집단을 정하고, 아카이브 페이지도 같은 함수를 호출한다.\n */\nexport async function categoryEntries(\n config: RootTaleSiteConfig,\n siteUrl: string,\n collections: RouteCollection[] | undefined,\n promotedSlugs: readonly string[] | undefined,\n): Promise<SitemapEntry[]> {\n const inSitemap = (category: BlogCategoryWithCount): boolean =>\n decideArchiveIndex({\n kind: \"category\",\n promotion: resolveCategoryPromotion(promotedSlugs, category.slug),\n postCount: category.count,\n }).inSitemap;\n\n if (isInCollectionMode(collections)) {\n // 스트림 아카이브의 모집단은 포지티브 스코프(그 스트림 귀속 글 ∩ 주제\n // allowlist)라 발행 글 전체 배치에서 파생한다 — 스트림 아카이브 페이지도\n // 같은 `collectCollectionCategories` 를 쓴다.\n const posts = await safeFetchPosts(config, ARCHIVE_POST_COUNT_LIMIT);\n return collections!\n // 주소 없는 유형(basePath === \"\")은 아카이브 라우트 자체가 없으므로\n // archives:true 로 잘못 선언돼 있어도(코드 상수 경로 방어) 제외한다.\n .filter((c) => c.archives && c.basePath)\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` },\n ...cats.filter(inSitemap).map((category) => ({\n url: `${base}/categories/${category.slug}`,\n })),\n ];\n });\n }\n const categories = await safeBlogArchiveCategories(config);\n if (!categories.length) return [];\n const blogBase = config.blogBasePath ?? \"/blog\";\n return [\n { url: `${siteUrl}${blogBase}/categories` },\n ...categories.filter(inSitemap).map((category) => ({\n url: `${siteUrl}${blogBase}/categories/${category.slug}`,\n })),\n ];\n}\n\n/**\n * 작가 아카이브 entry. 사이트 단위 admin 토글(`settings.authors`)이 곧 \"허브 승격\"\n * 신호다 — 카테고리처럼 개별 승격 UI 가 없다. 토글이 꺼져 있으면 빈 목록이고,\n * 켜져 있어도 발행 글 0건 작가는 색인 정책(빈 아카이브)이 걸러낸다.\n *\n * **entry 쪽은 카테고리와 달리 fail-closed** 다: 토글을 못 읽었을 때 URL 을\n * 만들어 넣으면 작가 아카이브 라우트 자체가 없는 사이트에 존재하지 않는 URL 을\n * 제출하게 된다(§1 \"404 배관\" 위험). 카테고리는 라우트가 이미 있다는 전제라\n * 반대로 fail-soft 다. 그래서 설정 fetch 실패 시 작가 페이지는 색인 유지(기존\n * 동작) + sitemap 에는 미포함이 되는데, 금지된 조합은 \"noindex 인데 sitemap 포함\"\n * 이고 그 반대(색인인데 sitemap 미포함)는 무해하므로 계약을 깨지 않는다.\n */\nexport async function authorEntries(\n config: RootTaleSiteConfig,\n siteUrl: string,\n settings: RootTaleSitemapSettings,\n authorBasePath: string | undefined,\n): Promise<SitemapEntry[]> {\n if (!settings.authors) return [];\n const authors = await safeFetchAuthors(config);\n const base = trimSlash(authorBasePath ?? \"/blog/author\");\n const promotion = resolveAuthorPromotion(settings.authors);\n return authors\n .filter(\n (a) =>\n decideArchiveIndex({\n kind: \"author\",\n promotion,\n postCount: a.postCount,\n }).inSitemap,\n )\n .map((a) => ({\n url: `${siteUrl}${base}/${encodeURIComponent(a.slug)}`,\n lastModified: a.lastUpdatedAt ?? undefined,\n }));\n}\n","/**\n * 아카이브 카테고리 **모집단** — \"이 아카이브 라우트가 실제로 다루는 글 집합\" 을\n * 정하는 단일 지점. 사이트맵과 아카이브 페이지가 **같은 함수**를 호출한다.\n *\n * 판정 함수(`decideArchiveIndex`)와 승격 상태 조립(`resolveCategoryPromotion`)을\n * 공유해도 **세는 글 집합이 다르면** 결론이 갈린다. 실제로 갈렸다:\n *\n * - 사이트맵은 발행 글 **전체**에서 카테고리를 셌고,\n * - `/blog` 계열 아카이브 페이지는 스트림(collection) 소속 글을 뺀 **\"미소속 글\"**\n * 만 다룬다(`blog-list-collection-scope.md` §2① — \"/blog 계열 표면은 전부\n * 미소속 글 = 블로그로 통일\", `RootTaleBlogList` 의 `excludeCollections`).\n *\n * 그래서 스트림 글에만 달린 카테고리(예: `symptoms` 스트림 글 1건뿐인 `symptom`)가\n * **사이트맵엔 URL 이 있는데 페이지는 404** 인 상충을 만들었다.\n *\n * ## 어느 쪽으로 맞췄나 — 사이트맵을 페이지에 맞춰 **좁혔다**\n *\n * 반대(페이지를 넓히기)는 카테고리는 유효하다고 인정하면서 목록은\n * `excludeCollections` 라 **비어 있는 화면**이 되거나, 그 prop 을 떼면 `/blog` 에\n * 스트림 글이 새어 들어온다 — §2① 을 되돌리는 셈이라 택하지 않았다. 페이지가\n * 실제로 서빙하는 집합이 이 라우트의 진실이고, 사이트맵은 거기에 따른다.\n *\n * ## \"미소속\" 판정은 서버가 한다\n *\n * `exclude_collections=true`(공개 posts API)는 **사이트가 선언한\n * `content_collections`** 기준으로 미소속 글을 고른다 — 판정 정의가 DB 한 곳에\n * 있다. 사이트맵 팩토리는 라우팅용 `collections` 를 모른 채 도는 경우가 많은데\n * (레거시 모드 = `config.collections` 미전달), 이 서버 필터는 그 지식 없이도\n * 페이지와 같은 집합을 준다. 클라이언트에서 각자 collections 목록을 조립해 거르면\n * \"어느 목록을 봤느냐\" 에 따라 또 갈린다 — 그게 이 결함의 원인이었다.\n *\n * collection 모드 스트림 아카이브(`{basePath}/categories/{slug}`)의 모집단은\n * 대칭적으로 `collectCollectionCategories`(포지티브 스코프: 그 스트림에 귀속된 글\n * ∩ 스트림의 주제 allowlist) 하나다 — 사이트맵과 스트림 아카이브 페이지가 역시\n * 같은 함수를 쓴다.\n */\n\nimport {\n type CmsPostContent,\n fetchCategoryCounts,\n fetchPosts,\n} from \"@roottale/cms-client/server\";\n\nimport { ARCHIVE_POST_COUNT_LIMIT } from \"./archive-index-policy.js\";\nimport {\n type BlogCategoryWithCount,\n collectCategoriesWithCounts,\n} from \"./blog-filter.js\";\n\n/** 모집단 조회에 필요한 최소 설정 — `RootTaleSiteConfig` 가 구조적으로 만족한다. */\nexport interface ArchiveCategoriesConfig {\n /** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만. */\n apiKey: string;\n /** API base 오버라이드. 기본 `https://api.roottale.com`. */\n apiBase?: string;\n}\n\nexport interface ArchiveCategoriesOptions {\n /** Next fetch 재검증 주기(초). 미지정 = cms-client 기본. */\n revalidate?: number;\n}\n\n/**\n * 레거시 `/blog` 계열 아카이브의 **모집단** — 어느 스트림에도 안 속한 발행 글.\n *\n * 한도는 `ARCHIVE_POST_COUNT_LIMIT`. 소비처마다 한도가 갈리면 같은 카테고리를 두고\n * 사이트맵은 \"글 있음\", 페이지는 \"0건 → 404\" 가 된다(그래서 상수를 공유한다).\n * 실패는 **throw** 한다 — 사이트맵은 always-200 이라 호출부에서 삼키고, 페이지는\n * API 장애를 404 로 둔갑시키면 안 되므로 그대로 올린다(에러 정책이 서로 다르므로\n * 여기서 임의로 fail-soft 하지 않는다).\n */\nexport async function fetchBlogArchivePosts(\n config: ArchiveCategoriesConfig,\n options: ArchiveCategoriesOptions = {},\n): Promise<CmsPostContent[]> {\n const page = await fetchPosts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n type: \"post\",\n limit: ARCHIVE_POST_COUNT_LIMIT,\n // 서버측 \"미소속 글\" 필터 — 페이지(`excludeCollections`)와 같은 정의.\n excludeCollections: true,\n ...(options.revalidate !== undefined && { revalidate: options.revalidate }),\n });\n return page.items;\n}\n\n/**\n * 서버측 집계(`GET /v1/cms/public/categories`)로 카테고리 + 글 수를 받는다.\n * 실패(네트워크 장애·구버전 API 의 404·예상 밖 응답)면 **null** — 호출부가 옛\n * 배치 파생으로 폴백한다.\n *\n * `null`(모름)과 `[]`(분류 0건 확정)을 구분하는 것이 요점이다. 실패를 빈 배열로\n * 뭉개면 \"이 사이트엔 분류가 없다\" 는 확정 정보가 되어 모든 분류 아카이브가 404 로\n * 사라진다 — 색인 정책의 `unknown` 3-state 와 같은 결의 판단이다.\n */\nasync function fetchServerCategoryCounts(\n config: ArchiveCategoriesConfig,\n options: ArchiveCategoriesOptions,\n): Promise<BlogCategoryWithCount[] | null> {\n try {\n const result = await fetchCategoryCounts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n // 레거시 `/blog` 계열의 모집단 = 미소속 글. 페이지(`excludeCollections`)·\n // 사이트맵과 **같은 서버 판정**을 쓰기 위해 서버 필터로 지정한다.\n excludeCollections: true,\n ...(options.revalidate !== undefined && { revalidate: options.revalidate }),\n });\n if (result.truncated) {\n // 조용한 절단 금지 — 상한을 넘겨 빠진 분류가 있으면 드러낸다.\n console.warn(\n \"[@roottale/cms-renderer-next/routes] 분류 수가 서버 상한을 넘어 일부가 응답에서 빠졌습니다(색인에서 누락될 수 있음).\",\n );\n }\n // 글 0건 분류는 제외한다 — 이 헬퍼의 계약은 \"이 아카이브 라우트가 인정하는\n // 카테고리\" 이고, 배치 파생 시절에도 0건 분류는 애초에 등장하지 않았다.\n // (0건이면 `decideArchiveIndex` 가 어차피 404 + sitemap 제외로 판정한다.)\n return result.categories\n .filter((category) => category.publishedPostCount > 0)\n .map((category) => ({\n name: category.name,\n slug: category.slug,\n count: category.publishedPostCount,\n }));\n } catch (err) {\n console.warn(\n \"[@roottale/cms-renderer-next/routes] 분류별 글 수 집계 API 실패 — 최근 글 배치 파생으로 폴백:\",\n err,\n );\n return null;\n }\n}\n\n/**\n * 레거시 `/blog` 계열 아카이브 라우트가 인정하는 카테고리 + 글 수.\n *\n * 사이트맵의 `/blog/categories/{slug}` 필터와 아카이브 페이지의 slug 유효성·\n * `postCount` 가 **이 함수 하나**에서 나온다.\n *\n * ## 우선순위 — 서버 집계 → (실패 시) 최근 글 배치 파생\n *\n * 1순위는 서버측 집계 API 다. 발행 글 수와 무관하게 **모든** 분류의 정확한 글 수를\n * 주므로, 최근 `ARCHIVE_POST_COUNT_LIMIT` 건 밖에만 글이 있는 분류가 페이지(404)·\n * 사이트맵에서 함께 사라지던 한계가 없어진다.\n *\n * 서버 집계를 못 받으면(구버전 API·일시 장애) **옛 배치 파생으로 fail-soft** 한다 —\n * 새 API 가 없다는 이유로 아카이브를 통째로 죽이지 않는다. 이때의 동작은 이 변경\n * 이전과 정확히 같다(한도 안 분류만 인정). 배치 조회까지 실패하면 그때는 throw 가\n * 그대로 올라간다(`fetchBlogArchivePosts` 주석 — API 장애를 404 로 둔갑시키지 않음).\n *\n * 두 경로 모두 이 함수 **하나**를 통과하므로, 사이트맵과 아카이브 페이지는 어느\n * 경로가 쓰이든 같은 값을 본다(그 계약이 이 모듈의 존재 이유다).\n */\nexport async function fetchBlogArchiveCategories(\n config: ArchiveCategoriesConfig,\n options: ArchiveCategoriesOptions = {},\n): Promise<BlogCategoryWithCount[]> {\n const counts = await fetchServerCategoryCounts(config, options);\n if (counts) return counts;\n return collectCategoriesWithCounts(await fetchBlogArchivePosts(config, options));\n}\n","/** `routes` — llms.txt (AEO/GEO, AI 검색·생성엔진용 마크다운 인덱스). */\n\nimport { isInCollectionMode, resolvePostUrl } from \"@roottale/cms-core\";\n\nimport {\n type RootTaleSiteConfig,\n resolveCollections,\n safeFetchPosts,\n trimSlash,\n} from \"./routes-shared.js\";\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 = await resolveCollections(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","/** `routes` — revalidate webhook 핸들러 (서명 검증 + 파생 경로 재검증). */\n\nimport {\n SETTINGS_CACHE_TAGS,\n THEME_CACHE_TAG,\n} from \"@roottale/cms-client/server\";\nimport {\n verifyRootTaleWebhook,\n type VerifyRootTaleWebhookResult,\n} from \"@roottale/cms-client/webhook\";\nimport { isInCollectionMode } from \"@roottale/cms-core\";\n\nimport { type CollectionsInput, resolveCollectionsResult } from \"./routes-shared.js\";\n\nexport interface RevalidateRouteConfig {\n /** `rtlk_cust_*` — 블로그 조회/webhook 검증 공통 키. */\n apiKey: string;\n apiBase?: string;\n /**\n * Next 의 `revalidatePath` (또는 동등 콜백) 주입. 2번째 인자(`\"layout\"`)로\n * 레이아웃 전역 무효화를 지원 — `theme.updated` 시 정적 페이지까지 반영하려면\n * 필요(next `revalidatePath(path, \"layout\")` 시그니처와 호환).\n *\n * **콜백으로 감쌀 때 2번째 인자를 반드시 그대로 넘겨라.** `(path) =>\n * revalidatePath(path)` 처럼 1개만 받으면 이 팩토리가 호출하는\n * `revalidate(\"/\", \"layout\")` 의 `\"layout\"` 이 사라져 레이아웃 전역 무효화가\n * 페이지 1개 무효화로 줄어든다(정적 고객 페이지가 안 바뀐다).\n * 올바른 모양: `(path, type) => revalidatePath(path, type)`.\n */\n revalidate: (path: string, type?: \"layout\" | \"page\") => void | Promise<void>;\n /**\n * Next 의 `revalidateTag` (또는 동등 콜백) 주입 — `theme.updated`(W0-4) 시\n * 테마 + 설정류 fetch 캐시 tag(ADR-0096 Phase C)를 무효화한다. 미지정 시 tag\n * 무효화 생략(layout 만) — 그러면 `fetchTheme`·`fetchBusinessProfile` 등의\n * Data Cache 엔트리는 각자의 `revalidate` 초만큼 낡은 값을 계속 낸다.\n * **주입을 권장한다.**\n *\n * **즉시 만료 프로파일을 넘겨라** — `(tag) => revalidateTag(tag, { expire: 0 })`.\n * Next 는 `expire: 0` 이 아닌 프로파일을 stale-while-revalidate 업데이트로\n * 취급해 다음 요청이 여전히 낡은 값을 받는다(`\"max\"` 는 expire 가 1년이라 즉시\n * 만료가 아니다). `updateTag` 는 서버 액션 전용이어서 이 라우트에서는 throw\n * 한다 — 쓰지 마라.\n */\n revalidateTag?: (tag: string) => void | Promise<void>;\n /** 테마 fetch 캐시 tag 이름. 기본 \"rt-theme\"(cms-client `THEME_CACHE_TAG`). */\n themeTag?: string;\n /** 블로그 base path. 기본 \"/blog\". */\n blogBasePath?: string;\n /**\n * 다중 스트림 (ADR-0060). 지정 시 `alsoRevalidate` 기본값을 각 스트림 basePath\n * (+ `archives:true` 면 `${basePath}/categories`)로 파생. 미지정 = 레거시 단일 blog.\n * 배열 또는 async resolver(`fetchCollections()`) — resolver 실패 시 레거시로 fail-soft.\n */\n collections?: CollectionsInput;\n /**\n * 모든 글/분류/미디어 변경에 대해 *항상 함께* revalidate 할 파생 경로.\n * 피드·사이트맵·블로그 인덱스처럼 \"글 목록\"에 의존하는 경로 — admin 변경이\n * 자동 반영되려면 필수. 명시 시 collections 파생을 덮어쓴다.\n * 기본(레거시): [\"/feed.xml\", \"/llms.txt\", \"/sitemap.xml\", \"/sitemap/static.xml\",\n * \"/sitemap/blog.xml\", \"/sitemap/categories.xml\", 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\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 // 설정 저장(W0-4 테마 · ADR-0096 Phase C 사업장 정보·메뉴 등): path 리스트가\n // 아니라 tag + layout 전역 무효화. path 리스트는 정적 고객 페이지\n // (services/contact)를 빠뜨렸고, `revalidatePath` 는 애초에 fetch 캐시\n // (Data Cache) 엔트리를 못 지운다 — 그래서 설정류 fetch 는 tag 로 지운다.\n //\n // 테마 하나만 지우지 않는 이유: 어드민의 \"설정 저장\"은 전부 이 이벤트 하나로\n // 오고, 무엇이 바뀌었는지 payload 에 없다. 사이트가 안 쓰는 tag 의\n // revalidateTag 는 no-op 이라 전부 지우는 쪽이 안전하다.\n if (result.event === \"theme.updated\") {\n const themeTag = config.themeTag ?? THEME_CACHE_TAG;\n // themeTag 를 커스텀 이름으로 덮어쓴 사이트를 존중한다 — 기본 이름 대신\n // 그 이름을 지운다.\n const requestedTags = [\n ...new Set([\n themeTag,\n ...SETTINGS_CACHE_TAGS.filter((t) => t !== THEME_CACHE_TAG),\n ]),\n ];\n // 본문 paths 도 함께 갱신한다. 설정 저장이 라우팅·피드까지 바꾸는 경우가\n // 있어서다 — 콘텐츠 유형 저장은 `/sitemap.xml`·`/feed.xml`·`/llms.txt`·각\n // 스트림 인덱스를 보내는데, 이건 route handler 라서 layout 무효화에 딸려\n // 온다고 가정할 수 없다. tag + layout 이 기본이고 paths 는 그 위에 더한다\n // (테마 저장처럼 `paths: []` 면 아무 일도 안 일어난다).\n const paths = pathsFromBody(rawBody);\n // 하나가 실패해도 나머지를 **전부 시도한다.** 첫 실패에서 멈추면 남은 tag 와\n // layout 무효화까지 건너뛰어, 사이트가 옛 값을 붙든 채로 재시도만 기다린다.\n // 실패를 삼키지 않고 500 으로 알려 플랫폼이 재시도하게 한다.\n const failed: { target: string; reason: string }[] = [];\n const attempt = async (target: string, run: () => void | Promise<void>) => {\n try {\n await run();\n } catch (e) {\n const reason = e instanceof Error ? e.message : String(e);\n failed.push({ target, reason });\n console.error(\n `[@roottale/cms-renderer-next/routes] theme.updated 무효화 실패(${target}):`,\n reason,\n );\n }\n };\n if (config.revalidateTag) {\n const revalidateTag = config.revalidateTag;\n for (const tag of requestedTags) {\n await attempt(`tag:${tag}`, () => revalidateTag(tag));\n }\n }\n // 루트 레이아웃 무효화 = 그 아래 전 라우트(정적 포함) 재생성.\n await attempt(\"layout:/\", () => config.revalidate(\"/\", \"layout\"));\n for (const p of paths) {\n await attempt(`path:${p}`, () => config.revalidate(p));\n }\n return Response.json(\n {\n ok: failed.length === 0,\n event: result.event,\n deliveryId: result.deliveryId,\n revalidated: {\n // `tag` 는 테마 tag 만 담는 기존 필드(로그 호환).\n tag: config.revalidateTag ? themeTag : null,\n // 콜백에 **넘긴** tag 목록이다. 콜백이 실제로 무엇을 지웠는지는 이\n // 라우트가 알 수 없다(Next 가 결과를 돌려주지 않는다) — 실패한 것만\n // `failed` 로 구분된다. `revalidateTag` 미주입이면 빈 배열.\n requestedTags: config.revalidateTag ? requestedTags : [],\n layout: \"/\",\n paths,\n },\n ...(failed.length > 0 ? { failed } : {}),\n },\n { status: failed.length === 0 ? 200 : 500 },\n );\n }\n\n // 글 목록 의존 경로. 명시 alsoRevalidate 가 있으면 collections 를 resolve 하지 않는다\n // (불필요한 fetch/지연 방지 — codex#1). 없으면 요청 시 resolve 해 파생.\n // 항상 갱신하는 불변 경로 — feed/sitemap/llms 는 글 목록에 의존. 사이트맵은\n // 인덱스(/sitemap.xml) + 분리된 하위(static/blog/categories) 모두 무효화해야\n // 글·이미지 토글 변경이 반영된다(createSitemapIndex — codex P1).\n const INVARIANTS = [\n \"/feed.xml\",\n \"/llms.txt\",\n \"/sitemap.xml\",\n \"/sitemap/static.xml\",\n \"/sitemap/blog.xml\",\n \"/sitemap/categories.xml\",\n // 작가 사이트맵 — 글 발행/해제로 작가 등장·lastmod 가 바뀐다(authors 토글 off 면\n // 빈 사이트맵이라 무효화해도 무해).\n \"/sitemap/authors.xml\",\n ];\n let alsoRevalidate: string[];\n let warning: string | undefined;\n if (config.alsoRevalidate) {\n alsoRevalidate = config.alsoRevalidate;\n } else {\n const { collections, failed } = await resolveCollectionsResult(config.collections);\n if (failed) {\n // resolver 실패: 스트림을 추측하지 않는다(잘못된 /blog 무효화 방지 — codex#2).\n // 불변 경로 + 본문 paths 만 갱신하고, 플랫폼이 재시도/알림하도록 warning 노출.\n alsoRevalidate = INVARIANTS;\n warning = \"collections_resolve_failed\";\n } else if (isInCollectionMode(collections)) {\n alsoRevalidate = [\n ...INVARIANTS,\n // 주소 없는 유형(basePath === \"\")은 라우트가 없으므로 revalidate 대상에서\n // 제외 — 안 그러면 빈 경로(\"\")를 무효화 요청하게 된다.\n ...collections!\n .filter((c) => c.basePath)\n .flatMap((c) =>\n c.archives ? [c.basePath, `${c.basePath}/categories`] : [c.basePath],\n ),\n ];\n } else {\n alsoRevalidate = [...INVARIANTS, blogBase, `${blogBase}/categories`];\n }\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 ...(warning ? { warning } : {}),\n });\n };\n}\n","/** `routes` — 동적 OG 이미지 (글별 1200×630, 한글 폰트 subset). */\n\nimport { fetchPost } from \"@roottale/cms-client/server\";\n\nimport { type RootTaleSiteConfig, trimSlash } from \"./routes-shared.js\";\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/**\n * `next/og` 의 ImageResponse 와 구조 호환(직접 의존 회피 — revalidate 콜백 주입과\n * 동일 원칙). 생성자 파라미터는 반변이라 `unknown` 으로 두면 ReactElement 를\n * 요구하는 실제 ImageResponse 가 대입 불가(고객 연동 TS 에러) — `any` 필요.\n */\nexport interface ImageResponseLike {\n new (\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n element: any,\n options?: {\n width?: number;\n height?: number;\n fonts?: {\n name: string;\n data: ArrayBuffer;\n // satori Weight 리터럴 — 넓은 number 면 반변성 탓에 대입 불가.\n weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;\n style?: \"normal\";\n }[];\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","/**\n * 커스텀 리다이렉트 미들웨어 — 고객 Next 앱의 `middleware.ts` 에서 마운트.\n *\n * admin \"설정 > 주소 이동\" 에서 정의한 임의 경로 규칙(`/old → /new`)을 공개\n * API(`GET /v1/cms/public/redirects`)로 읽어 요청 경로에 매칭, 301/302 를\n * 발행한다. 슬러그 301(`postRedirectPath`)이 글 라우트에서 도는 것과 달리,\n * 임의 경로는 라우팅 *이전* 단계인 middleware 에서만 가로챌 수 있다.\n *\n * `next` 에 직접 의존하지 않는다(패키지 정책): 입력은 표준 `Request`(NextRequest\n * 가 상속), 출력은 표준 `Response`(NextResponse 가 상속)다.\n *\n * 사용 (Next App Router):\n * ```ts\n * // middleware.ts\n * import { NextResponse } from \"next/server\";\n * import { createRedirectMiddleware } from \"@roottale/cms-renderer-next/routes\";\n *\n * const redirects = createRedirectMiddleware({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * });\n *\n * export async function middleware(req: Request) {\n * return (await redirects(req)) ?? NextResponse.next();\n * }\n *\n * // Next 내부·정적 자산만 제외. `.*\\\\..*` 처럼 점 포함 경로를 전부 막으면\n * // `/old.html`·`/foo.php` 같은 레거시 마이그레이션 리다이렉트가 동작하지\n * // 않으므로, 알려진 자산 확장자만 제외한다.\n * export const config = {\n * matcher: [\n * \"/((?!_next/|api/|.*\\\\.(?:ico|png|jpg|jpeg|gif|svg|webp|css|js|txt|xml|json|woff2?|map)$).*)\",\n * ],\n * };\n * ```\n */\n\nimport { fetchRedirects } from \"@roottale/cms-client/server\";\nimport type { RootTaleRedirect } from \"@roottale/cms-client/server\";\n\nexport interface RedirectMiddlewareConfig {\n /** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만. */\n apiKey: string;\n /** API base 오버라이드. 기본 `https://api.roottale.com`. */\n apiBase?: string;\n /** site_id 명시(멀티사이트 키). 기본 = 키의 기본 사이트. */\n siteId?: string;\n /** 규칙 캐시 TTL(ms). 기본 60_000 (1분). */\n cacheTtlMs?: number;\n}\n\n/**\n * 경로 정규화 — 앞 슬래시 보장, 뒤 슬래시 제거(루트 제외), 쿼리/해시 제거,\n * percent-decode. 출발 경로 비교·한글 slug 매칭·루프 탐지에 요청·규칙 양쪽에\n * 동일 적용한다. 쿼리 제거는 `/a → /a?x` 류의 무한 루프 방지에 중요.\n */\nexport function normalizeRedirectPath(path: string): string {\n let p = path;\n try {\n p = decodeURIComponent(path);\n } catch {\n /* 잘못된 escape — 원본 유지 */\n }\n p = p.split(/[?#]/)[0] ?? p; // 쿼리·해시 제거\n if (!p.startsWith(\"/\")) p = `/${p}`;\n if (p.length > 1) p = p.replace(/\\/+$/, \"\");\n return p === \"\" ? \"/\" : p;\n}\n\nfunction isAbsoluteUrl(target: string): boolean {\n return /^https?:\\/\\//i.test(target);\n}\n\nfunction internalTargetPath(target: string, origin?: string): string | null {\n if (!isAbsoluteUrl(target)) return normalizeRedirectPath(target);\n if (!origin) return null;\n try {\n const url = new URL(target);\n return url.origin === origin ? normalizeRedirectPath(url.pathname) : null;\n } catch {\n return null;\n }\n}\n\n/**\n * 요청 경로에 맞는 규칙을 찾는다(순수 함수, fetch 비의존 — 테스트 용이).\n * 정확 경로 일치만(와일드카드 없음).\n *\n * 내부 경로 체인을 따라가 최종 도착지를 한 번에 반환한다(`/a→/b→/c` ⇒ `/c`):\n * 리다이렉트 체인을 줄여 SEO·왕복을 개선하고, 한 호출 안에서의 무한 루프를\n * 막는다. 도착지가 출발지로 정규화되면(쿼리만 다른 경우 포함) 자기 루프로 보고\n * 매칭하지 않는다. 절대 URL 도착지는 체인 종단(다른 origin 위임). 상태 코드는\n * 실제 요청 경로에 매칭된 첫 규칙을 따른다. null = 해당 없음.\n */\nexport function matchRedirect(\n redirects: readonly RootTaleRedirect[],\n pathname: string,\n origin?: string,\n): { toTarget: string; statusCode: 301 | 302 } | null {\n const byFrom = new Map<string, RootTaleRedirect>();\n for (const r of redirects) {\n const key = normalizeRedirectPath(r.fromPath);\n if (!byFrom.has(key)) byFrom.set(key, r);\n }\n\n const start = normalizeRedirectPath(pathname);\n const visited = new Set<string>([start]);\n let current = start;\n let finalTarget: string | null = null;\n let firstStatus: 301 | 302 | null = null;\n\n for (;;) {\n const rule = byFrom.get(current);\n if (!rule) break;\n if (firstStatus === null) firstStatus = rule.statusCode;\n\n const nextPath = internalTargetPath(rule.toTarget, origin);\n if (nextPath === null) {\n finalTarget = rule.toTarget; // 절대 URL = 체인 종단\n break;\n }\n if (nextPath === start) return null;\n finalTarget = rule.toTarget;\n // 사이클의 일부만 적용하면 다음 요청에서 반대쪽 규칙을 만나\n // 브라우저가 왕복한다. 저장소 검증의 마지막 방어선으로 전체를 통과시킨다.\n if (visited.has(nextPath)) return null;\n visited.add(nextPath);\n current = nextPath;\n }\n\n if (finalTarget === null || firstStatus === null) return null;\n return { toTarget: finalTarget, statusCode: firstStatus };\n}\n\ninterface CacheEntry {\n expires: number;\n value: RootTaleRedirect[];\n}\n\n/**\n * 미들웨어 팩토리. 규칙을 TTL 캐시하고(요청마다 API 호출 회피), fetch 실패는\n * swallow 해 트래픽을 절대 막지 않는다(stale 우선, 없으면 빈 목록 → 통과).\n *\n * 반환 함수: 매칭 시 redirect `Response`, 아니면 `null`(호출부가 통과 처리).\n */\nexport function createRedirectMiddleware(\n config: RedirectMiddlewareConfig,\n): (request: Request) => Promise<Response | null> {\n const ttl = config.cacheTtlMs ?? 60_000;\n let cache: CacheEntry | null = null;\n\n async function loadRedirects(now: number): Promise<RootTaleRedirect[]> {\n if (cache && cache.expires > now) return cache.value;\n try {\n const value = await fetchRedirects({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n siteId: config.siteId,\n });\n cache = { expires: now + ttl, value };\n return value;\n } catch (err) {\n console.error(\n \"[@roottale/cms-renderer-next] fetchRedirects 실패 — 통과(fail-soft):\",\n err,\n );\n // stale 값이 있으면 그것으로, 없으면 빈 목록. 짧게 재시도 억제.\n if (cache) {\n cache = { expires: now + Math.min(ttl, 10_000), value: cache.value };\n return cache.value;\n }\n return [];\n }\n }\n\n return async function redirectMiddleware(\n request: Request,\n ): Promise<Response | null> {\n let pathname: string;\n let origin: string;\n try {\n const u = new URL(request.url);\n pathname = u.pathname;\n origin = u.origin;\n } catch {\n return null;\n }\n\n // 캐시 키가 stable 한 시각 — Date.now 는 미들웨어 런타임에서 허용.\n const now = Date.now();\n const redirects = await loadRedirects(now);\n const hit = matchRedirect(redirects, pathname, origin);\n if (!hit) return null;\n\n // 내부 경로는 요청 origin 기준 절대화, 절대 URL 은 그대로.\n const dest = new URL(hit.toTarget, origin);\n // 같은 origin·같은 경로로의 redirect 는 무한 루프 — 통과시킨다. matchRedirect\n // 의 순수 루프 가드가 못 잡는 절대 URL(같은 origin) 자기참조까지 여기서 차단.\n if (\n dest.origin === origin &&\n normalizeRedirectPath(dest.pathname) === normalizeRedirectPath(pathname)\n ) {\n return null;\n }\n return Response.redirect(dest.toString(), hit.statusCode);\n };\n}\n"],"mappings":"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","names":["isInCollectionMode","resolvePostCollection","resolvePostUrl","fetchPosts","resolvePostCollection","resolvePostUrl","fetchPosts","resolvePostUrl","resolvePostCollection","isInCollectionMode","resolvePostCollection","resolvePostUrl","isInCollectionMode","resolvePostUrl","isInCollectionMode","resolvePostUrl","isInCollectionMode","paths","isInCollectionMode"]}
|
|
1
|
+
{"version":3,"sources":["../src/routes.ts","../src/routes-feed-sitemap.ts","../src/routes-metadata.ts","../src/archive-index-policy.ts","../src/routes-sitemap-entries.ts","../src/archive-categories.ts","../src/routes-llms.ts","../src/routes-revalidate.ts","../src/routes-og.ts","../src/routes-redirects.ts"],"sourcesContent":["/**\n * `@roottale/cms-renderer-next/routes` — Next.js App Router 통합 라우트 팩토리.\n *\n * 외부 고객사 사이트가 매번 직접 작성하던 공통 라우트(RSS feed / sitemap /\n * revalidate webhook / llms.txt / OG 이미지)를 1줄 팩토리로 표준화. 블로그 조회와\n * *동일한* 단일 customer API key(`ROOTTALE_API_KEY`)를 사용한다.\n *\n * 설계 원칙:\n * - feed/sitemap/llms 는 fetch 실패를 swallow → 라우트가 *항상 200*.\n * - revalidate 는 서명 webhook 을 검증하고 본문 paths + 파생 경로를 revalidate.\n * - `next` 에 직접 의존하지 않음(revalidate 콜백·ImageResponse 주입).\n *\n * 구현은 `routes-*.ts` 모듈로 분리되어 있고 본 파일은 공개 배럴이다(파일당 ≤400줄).\n * 사용 예는 roottale-docs `services/external-site-integration.md` 참조.\n */\n\nimport { CMS_CLIENT_VERSION } from \"@roottale/cms-client/server\";\n\nimport { trimSlash } from \"./routes-shared.js\";\n\n// ── 공개 표면 배럴 (모듈별 구현 re-export) ───────────────────────────────────\nexport * from \"./routes-shared.js\";\nexport * from \"./routes-feed-sitemap.js\";\nexport * from \"./routes-llms.js\";\nexport * from \"./routes-revalidate.js\";\nexport * from \"./routes-og.js\";\n// 커스텀 리다이렉트 미들웨어 (WP RankMath Redirections 패리티).\nexport * from \"./routes-redirects.js\";\n// 글 메타데이터 빌더 (canonical/robots/og 1줄 — 어드민 SEO 패널 반영).\nexport * from \"./routes-metadata.js\";\n// 아카이브 색인 정책 단일 SSoT — sitemap 필터와 robots 를 같은 판정에서 파생.\nexport * from \"./archive-index-policy.js\";\n// 아카이브 카테고리 모집단 — sitemap 과 아카이브 페이지가 같은 글 집합을 센다.\nexport * from \"./archive-categories.js\";\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 * `routes` — RSS feed + sitemap 라우트 팩토리. collection 모드/레거시 양쪽.\n * 사이트맵 entry 조립(정책·설정 해석)은 `routes-sitemap-entries.ts` 가 맡는다.\n */\n\nimport {\n generateRssXml,\n rssItemsFromPosts,\n} from \"@roottale/cms-client/server\";\nimport {\n isInCollectionMode,\n resolvePostCollection,\n resolvePostUrl,\n} from \"@roottale/cms-core\";\n\nimport { ARCHIVE_POST_COUNT_LIMIT } from \"./archive-index-policy.js\";\nimport {\n type SitemapEntry,\n type SitemapIndexOptions,\n authorEntries,\n categoryEntries,\n localesWithPublishedContent,\n postEntries,\n resolveSitemapSettings,\n staticEntries,\n} from \"./routes-sitemap-entries.js\";\nimport {\n type RootTaleSiteConfig,\n resolveCollections,\n safeFetchPosts,\n trimSlash,\n} from \"./routes-shared.js\";\n\n// 공개 표면 유지 — 이 두 타입은 팩토리 시그니처의 일부라 배럴(`routes.ts`)에서\n// 계속 나가야 한다(파일을 나눴다고 소비자 import 경로가 바뀌면 안 된다).\nexport type { SitemapEntry, SitemapIndexOptions };\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 = await resolveCollections(config.collections);\n const allPosts = await safeFetchPosts(config, 50);\n\n // collection 모드: /feed.xml = `feed:true` 스트림들의 합집합(aggregate). RSS\n // 리더는 안정적 사이트 피드를 기대하므로 스트림별 분리 대신 통합 피드(codex).\n // 각 글 링크는 소속 스트림 basePath 로. 레거시: 단일 blogBasePath.\n // detail:false(목록 전용) 스트림은 글별 permalink 가 없으므로 RSS item 에서도\n // 제외 — 안 그러면 postUrl fallback 이 존재하지 않는 상세 URL 을 만든다.\n // 주소 없는 유형(basePath === \"\")도 같은 이유로 제외 — 코드에서 실수로\n // feed:true 를 선언해도(admin UI 는 강제 off 하지만 코드 상수 경로는 우회\n // 가능) resolvePostUrl 이 null 을 반환해 아래 postUrl fallback 이 존재하지\n // 않는 `/blog/{slug}` 를 RSS item 으로 발행하는 걸 막는다.\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 && c.detail !== false && c.basePath !== \"\" : 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 hubUrl: config.webSubHubUrl,\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// ── 사이트맵 라우트 팩토리 ───────────────────────────────────────────────────\n\n/**\n * @deprecated `createSitemapIndex` 를 쓰세요 — 단일 평면 사이트맵 대신 인덱스 분리\n * (static/blog/categories) + 이미지 + admin 토글을 지원한다. 본 함수는 하위호환용으로\n * 유지되며 `changeFrequency`/`priority` 는 더 이상 emit 하지 않는다(blanche식).\n *\n * `app/sitemap.ts` default export. 정적 경로 + 글 + 카테고리를 단일 파일로 합친다.\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 collections = await resolveCollections(config.collections);\n const posts = await safeFetchPosts(config, ARCHIVE_POST_COUNT_LIMIT);\n // 레거시 동등성 유지 — admin 설정 fetch 생략 + 이미지 off(기존 동작 = freq/priority\n // 만 제거). 이미지·admin 토글이 필요하면 createSitemapIndex 를 쓴다(codex P2).\n const { settings } = await resolveSitemapSettings(config, {\n skipRemoteSettings: true,\n blogImages: false,\n });\n return [\n ...staticEntries(staticRoutes),\n ...postEntries(config, siteUrl, posts, collections, settings),\n // settings.promotedCategorySlugs 는 여기서 항상 undefined(위 skipRemoteSettings)\n // → 색인 정책 미적용 = 레거시 동등. 정책이 필요하면 createSitemapIndex 로.\n ...(await categoryEntries(config, siteUrl, collections, undefined)),\n ];\n };\n}\n\n/** blanche식 인덱스 분리 사이트맵의 하위 섹션 id. */\nexport type SitemapSectionId = \"static\" | \"blog\" | \"categories\" | \"authors\";\n\n/**\n * blanche식 사이트맵 인덱스 — `/sitemap.xml`(인덱스) + `/sitemap/<id>.xml` 하위\n * 사이트맵(static/blog/categories)으로 분리한다(Next 16 `generateSitemaps`). 50k\n * URL/50MB 한도 확장 + 검색엔진이 섹션별로 크롤. 블로그 글에는 대표 이미지를\n * `<image:image>` 로 함께 색인(admin 토글). `changeFrequency`/`priority` 는 emit\n * 하지 않는다.\n *\n * ```ts\n * // app/sitemap.ts\n * import { createSitemapIndex } from \"@roottale/cms-renderer-next/routes\";\n * const { generateSitemaps, sitemap } = createSitemapIndex(\n * { apiKey: process.env.ROOTTALE_API_KEY!, siteUrl: \"https://example.com\", title: \"...\" },\n * [{ url: \"https://example.com\" }, { url: \"https://example.com/contact\" }],\n * );\n * export { generateSitemaps };\n * export default sitemap;\n * ```\n */\nexport function createSitemapIndex(\n config: RootTaleSiteConfig,\n staticRoutes: SitemapEntry[] = [],\n opts: SitemapIndexOptions = {},\n): {\n generateSitemaps: () => Promise<{ id: SitemapSectionId }[]>;\n sitemap: (props: { id: string | Promise<string> }) => Promise<SitemapEntry[]>;\n} {\n async function generateSitemaps(): Promise<{ id: SitemapSectionId }[]> {\n // static/blog/categories 항상. authors 는 admin 토글(settings.authors) 시만 —\n // 작가 아카이브 라우트가 있어야 404 가 안 난다. per-locale(PR C)은 후속.\n //\n // **색인 정책과의 상호작용(§1)**: 섹션 존재 여부는 **토글만** 본다. 색인\n // 정책(`decideArchiveIndex`)은 섹션 *안의 URL* 만 거른다. 정책 때문에 항목이\n // 0개가 되어도 섹션 id 는 그대로 둔다 — id 를 빼면 Next 가 그 라우트를 아예\n // 생성하지 않아 `/sitemap/authors.xml`·`/sitemap/categories.xml` 이 404 가\n // 되고, 그건 이미 배포된 사이트맵 인덱스가 가리키는 주소를 깨뜨린다. 빈\n // `<urlset>` 은 유효한 응답이므로 이쪽이 안전하다.\n //\n // 같은 이유로 **설정 fetch 가 실패하면 섹션 목록을 줄이지 않는다**:\n // `generateSitemaps` 와 하위 handler 는 서로 다른 요청에서 각각 설정을 읽으므로\n // 한쪽만 실패할 수 있는데, 실패한 쪽이 authors 를 빼면 이미 배포된 인덱스가\n // 가리키던 `/sitemap/authors.xml` 이 404 가 된다. 토글을 못 읽었을 때는 섹션을\n // 남기고 내용만 비운다(`authorEntries` 가 fail-closed). `skipRemoteSettings`\n // 는 장애가 아니라 설정이므로 이 보존 규칙에서 제외한다.\n const { settings, settingsFetchFailed } = await resolveSitemapSettings(\n config,\n opts,\n );\n const ids: SitemapSectionId[] = [\"static\", \"blog\", \"categories\"];\n if (settings.authors || settingsFetchFailed) ids.push(\"authors\");\n return ids.map((id) => ({ id }));\n }\n\n async function sitemap(props: {\n id: string | Promise<string>;\n }): Promise<SitemapEntry[]> {\n const id = await props.id;\n const siteUrl = trimSlash(config.siteUrl);\n const { settings } = await resolveSitemapSettings(config, opts);\n // ADR-0052 A안 — fail-closed: 발행 글이 0건인 locale 은 hreflang 생성 대상에서\n // 제외(§1 404 배관 위험 차단).\n //\n // `blog` 섹션(PR C1)만 글 단위 실제 `translations[]` 기반 정밀 alternates —\n // `postEntries` 가 직접 계산한다(아래 `id === \"blog\"` 분기, `localize()` 를\n // 거치지 않음). static/categories/authors 는 **v1 라우트 범위 밖**(§3 PR\n // C1-1: locale 세그먼트는 블로그+CMS `[slug]` 페이지뿐, nav 정적 경로·카테고리\n // 아카이브·작가 아카이브는 locale 라우트 자체가 없다) 이라 옛 slug-1:1\n // path-prefix 스캐폴딩(`localizeEntries`)을 계속 쓰면 존재하지 않는\n // `/{locale}/...` URL 의 hreflang 을 만들게 된다(§1 위험 재발). 그래서 이\n // 세 섹션은 **alternates 를 아예 붙이지 않는다** — authors 는 기존 주석대로\n // \"per-locale 은 후속\"(§3 PR C1-4)이었고, static/categories 도 같은 처지라\n // 동일하게 보류한다(design deviation — 최종 보고 참조).\n const activeLocales = await localesWithPublishedContent(config, settings.locales);\n const defaultLocale = activeLocales[0];\n\n if (id === \"static\") return staticEntries(staticRoutes);\n\n if (id === \"authors\") {\n // generateSitemaps 와 동일하게 토글 gate — 직접 호출 시에도 off 면 빈 배열\n // (authorEntries 안에서 판정).\n return authorEntries(config, siteUrl, settings, opts.authorBasePath);\n }\n\n const collections = await resolveCollections(config.collections);\n if (id === \"categories\") {\n // 모집단·한도는 `categoryEntries` 가 직접 정한다 — 아카이브 페이지와 **같은\n // 헬퍼**를 써야 \"사이트맵엔 URL 이 있는데 페이지는 404\" 가 안 생긴다\n // (`archive-categories.ts` 모듈 주석). 그래서 여기서 글 배치를 주입하지 않는다.\n return categoryEntries(\n config,\n siteUrl,\n collections,\n settings.promotedCategorySlugs,\n );\n }\n if (id === \"blog\") {\n const posts = await safeFetchPosts(config, ARCHIVE_POST_COUNT_LIMIT);\n return postEntries(config, siteUrl, posts, collections, settings, defaultLocale);\n }\n return [];\n }\n\n return { generateSitemaps, sitemap };\n}\n","/**\n * `@roottale/cms-renderer-next/routes` — 블로그 글 메타데이터 빌더.\n *\n * 어드민 SEO 패널(`metaJson.seo`) 값을 Next.js `Metadata` 형태로 1줄 변환한다.\n * 고객사 `app/blog/[slug]/page.tsx` 의 `generateMetadata` 에서 매번 직접 쓰던\n * canonical/robots/openGraph 분기 보일러플레이트를 표준화 — 한 곳이라도\n * 빠뜨려 SEO 가 새는 사고(noindex 누락·canonical 미설정)를 방지한다.\n *\n * 설계 원칙(패키지 공통): `next` 에 직접 의존하지 않는다. 반환은 Next `Metadata`\n * 의 구조적 부분집합인 plain 객체이므로, 소비자는 `generateMetadata` 에서 그대로\n * `return` 하면 된다.\n */\n\nimport { resolvePostPath, type RouteCollection } from \"@roottale/cms-core\";\nimport type { CmsPostTranslationRef } from \"@roottale/cms-client/server\";\n\nimport { trimSlash } from \"./routes-shared.js\";\n\n/** 어드민 SEO 패널 override (`metaJson.seo`). 전부 선택. */\nexport interface PostSeoOverrides {\n /** 검색 제목 — 비면 글 제목 사용. */\n title?: string;\n /** 메타 설명 — 비면 글 요약 사용. */\n description?: string;\n /** 대표 URL. 비면 `siteUrl`+`path` 로 self-canonical 생성(옵션 제공 시). */\n canonical?: string;\n /** OG 이미지 — 비면 글 대표 이미지 사용. */\n ogImage?: string;\n /** OG 이미지 대체 텍스트 — 비면 글 제목 사용. */\n ogImageAlt?: string;\n /** 색인 제외. */\n noindex?: boolean;\n /** 링크 추적 제외. */\n nofollow?: boolean;\n}\n\n/** `buildPostMetadata` 입력 — `BlogPostMeta`·원본 post 모두 구조적으로 호환. */\nexport interface PostMetadataInput {\n /**\n * 글 ID (uuidv7). 주면 `<meta name=\"rt:content-id\">`(ADR-0065)를 자동 emit —\n * RootTale 비콘이 읽어 pageview 를 이 글에 귀속시켜 조회수를 정확히 집계한다.\n * `BlogPostMeta`·원본 post 모두 `id` 를 가지므로 별도 작업 없이 동작한다.\n */\n id?: string;\n /** 글 제목 (`seo.title` 없을 때 fallback). */\n title: string;\n /** 글 요약/설명 (`seo.description` 없을 때 fallback). */\n description?: string | null;\n /** 발행 ISO 일시 → `openGraph.publishedTime`. */\n date?: string | null;\n /** 대표 이미지 URL → `openGraph.images` (`seo.ogImage` 우선). */\n image?: string | null;\n /** 어드민 SEO 패널 override (`metaJson.seo`). */\n seo?: PostSeoOverrides | null;\n /** 글 slug — `collections` 기반 canonical 자동 해석에 사용. */\n slug?: string;\n /**\n * 소속 섹션 key (ADR-0060). `collections` 와 함께 주면 canonical basePath 를\n * 공지/블로그로 구분해 해석한다(`collectionKey:\"notice\"` → `/notice`).\n */\n collectionKey?: string | null;\n /**\n * ADR-0052 A안 (W4-6 PR C1) — 이 글의 발행된 언어판 목록(자기 자신 포함, PR B\n * 응답 `CmsPostContent.translations`). `options.locales` 와 함께 주면 hreflang\n * `alternates.languages` 를 계산한다. 없거나 자기 자신 1건뿐이면 languages 생략.\n */\n translations?: readonly CmsPostTranslationRef[];\n /**\n * avcd 구조(og article 확장) — 최근 수정 ISO 일시 → `openGraph.modifiedTime`.\n * `CmsPostContent.updatedAt` 등을 그대로 넘기면 된다. 미지정 시 생략.\n */\n modified?: string | null;\n /** avcd 구조 — 글 섹션/카테고리명 → `openGraph.section`. 미지정 시 생략. */\n section?: string | null;\n /** avcd 구조 — 글 태그명 목록 → `openGraph.tags`. 빈 배열/미지정 시 생략. */\n tags?: readonly string[] | null;\n /**\n * avcd 구조 — 저자 프로필 URL 목록 → `openGraph.authors` (Next `Metadata`\n * article 확장). 빈 배열/미지정 시 생략.\n */\n authors?: readonly string[] | null;\n}\n\nexport interface BuildPostMetadataOptions {\n /**\n * self-canonical 기본값 origin (예: `https://example.com`).\n * `seo.canonical` override 가 있으면 무시된다. `path` 또는 `collections` 와\n * 함께 줄 때만 동작.\n */\n siteUrl?: string;\n /**\n * self-canonical 기본값 경로 (예: `/blog/my-post`). `siteUrl` 과 함께.\n * 명시하면 `collections` 파생보다 우선한다(단일 라우트 사이트용 명시 경로).\n */\n path?: string;\n /**\n * 섹션 정의 (ADR-0060). `path` 를 안 주면 글의 `collectionKey` 로 소속 섹션\n * basePath 를 찾아 canonical 경로를 **공지/블로그로 구분해 자동 해석**한다\n * (공지 글 → `/notice/{slug}`, 블로그 글 → `/blog/{slug}`). 어느 섹션에도\n * 안 속하면(미설정·미존재 key) canonical 을 생략한다. `post.slug` 필요.\n */\n collections?: readonly RouteCollection[];\n /**\n * ADR-0052 A안 (W4-6 PR C1) — hreflang alternates.languages 계산 옵션. 주지\n * 않으면(undefined) `translations` 가 있어도 languages 를 계산하지 않는다\n * (opt-in — 명시 안 한 기존 소비자는 canonical-only 로 diff 0).\n */\n locales?: PostLocaleOptions;\n /**\n * avcd 구조 — RSS 피드 절대 경로 → `alternates.types[\"application/rss+xml\"]`.\n * 기본 `\"/feed.xml\"`. `siteUrl` 이 있으면 canonical 유무와 무관하게 항상 emit\n * (RSS 발견성은 개별 글의 canonical 해석 성공 여부와 무관한 사이트 차원 신호).\n */\n feedPath?: string;\n}\n\n/**\n * ADR-0052 A안 (W4-6 PR C1) — hreflang 경로 계산 옵션. `buildPostMetadata` 와\n * `buildHreflangLanguages` 가 공유한다.\n */\nexport interface PostLocaleOptions {\n /** 사이트 기본 locale — prefix 없는 경로, `x-default` 겸함. */\n defaultLocale: string;\n /**\n * locale + slug → path(선행 슬래시 포함). 기본값: 기본 locale 은 `/blog/{slug}`,\n * 그 외는 `/{locale}/blog/{slug}` (starter 참조 구현 관용구, `tenant-site-i18n-plan.md`\n * §2③). 블로그가 아닌 라우트(CMS `[slug]` 페이지 등)는 override 필수.\n */\n pathFor?: (locale: string, slug: string) => string;\n}\n\n/** robots 지시자 — Next `Metadata.robots` 의 구조적 부분집합. */\nexport interface RobotsMeta {\n index: boolean;\n follow: boolean;\n googleBot?: {\n index: boolean;\n follow: boolean;\n \"max-image-preview\": \"large\";\n };\n}\n\n/**\n * noindex/nofollow 플래그 → `Metadata.robots`. **글과 아카이브가 공유하는 단\n * 하나의 robots 표현** — 둘 중 한쪽만 형태가 바뀌면 색인 정책이 갈리므로\n * `buildPostMetadata` 와 `buildArchiveMetadata` 가 이 함수만 쓴다.\n *\n * 둘 다 꺼져 있으면 `undefined` — robots 를 아예 emit 하지 않아 검색엔진 기본값\n * (index, follow)을 그대로 둔다.\n */\nexport function resolveRobotsMeta(flags: {\n noindex?: boolean;\n nofollow?: boolean;\n}): RobotsMeta | undefined {\n if (!flags.noindex && !flags.nofollow) return undefined;\n return { index: !flags.noindex, follow: !flags.nofollow };\n}\n\n/** `buildPreviewMetadata` 반환 — Next `Metadata` 의 구조적 부분집합. */\nexport interface PreviewMetadata {\n title: string;\n robots: RobotsMeta;\n}\n\n/**\n * 글 미리보기 페이지(`/preview/post/[id]?token=`) 의 metadata. 미리보기는 초안이므로\n * **항상 noindex/nofollow** — 검색엔진이 미리보기 주소를 색인하면 비공개 글이\n * 새고, 발행 주소와 중복 콘텐츠가 된다. 제목만 받아 `(미리보기)` 접미를 붙인다.\n * canonical·og 는 일부러 내지 않는다(공유 카드로 초안이 캐시되지 않게).\n */\nexport function buildPreviewMetadata(input: {\n title?: string | null;\n locale?: string;\n}): PreviewMetadata {\n const suffix = input.locale === \"en\" ? \"(Preview)\" : \"(미리보기)\";\n const base = input.title?.trim();\n return {\n title: base ? `${base} ${suffix}` : suffix,\n robots: { index: false, follow: false },\n };\n}\n\n/** Next `Metadata` 의 구조적 부분집합 — 소비자는 그대로 return 가능. */\nexport interface PostMetadata {\n title: string;\n description?: string;\n alternates?: {\n /** `types` 만 emit 되는 경우(예: siteUrl 만 있고 path/collections 미해석)도 있어 optional. */\n canonical?: string;\n languages?: Record<string, string>;\n /** avcd 구조 — RSS 등 대체 표현. Next `Metadata.alternates.types` 그대로. */\n types?: Record<string, string>;\n };\n robots?: RobotsMeta;\n openGraph: {\n type: \"article\";\n title: string;\n description?: string;\n url?: string;\n publishedTime?: string;\n /** avcd 구조 — og article 확장. */\n modifiedTime?: string;\n section?: string;\n tags?: string[];\n authors?: string[];\n images?: { url: string; alt: string }[];\n };\n /**\n * avcd 구조 — Twitter Card. og 와 동일 소스(seo override → 글 값)에서 title/\n * description/images 를 채운다. `card`+`title` 은 항상 emit(og 이미지 유무와\n * 무관), `images` 는 og 이미지가 있을 때만.\n */\n twitter?: {\n card: \"summary_large_image\";\n title: string;\n description?: string;\n images?: string[];\n };\n /** ADR-0065 — `<meta name=\"rt:content-id\">` 등 임의 head meta (조회수 식별자). */\n other?: Record<string, string>;\n}\n\n/**\n * 글 메타데이터를 Next `Metadata` 형태로 빌드.\n *\n * - 제목/설명/OG 이미지는 어드민 SEO override 가 있으면 우선, 없으면 글 값 fallback.\n * - canonical: `seo.canonical` override → 없으면 `siteUrl`+`path`(명시) → 없으면\n * `siteUrl`+`collections`(글의 `collectionKey` 로 공지/블로그 basePath 자동 해석)\n * → 없으면 생략. 모든 글이 자기 자신을 가리키는 canonical 을 갖는 게 권장이므로\n * `siteUrl` 과 함께 `path` 또는 `collections` 전달을 권장한다.\n * - `noindex`/`nofollow` 중 하나라도 켜지면 `robots` 출력.\n * - avcd 구조 — `siteUrl` 이 있으면 `alternates.types[\"application/rss+xml\"]`\n * (기본 `/feed.xml`, `feedPath` 로 override)을 canonical 유무와 무관하게 emit.\n * Twitter Card(`summary_large_image`)는 og 와 같은 소스로 항상 emit.\n *\n * ```ts\n * // 단일 블로그(레거시): 라우트가 곧 섹션이므로 path 를 직접 준다.\n * return buildPostMetadata(post, {\n * siteUrl: process.env.NEXT_PUBLIC_SITE_URL,\n * path: `/blog/${post.slug}`,\n * });\n *\n * // 다중 스트림(ADR-0060): collections 를 주면 공지/블로그를 글의\n * // collectionKey 로 구분해 canonical 을 자동 해석한다(공지→/notice, 블로그→/blog).\n * return buildPostMetadata(post, {\n * siteUrl: process.env.NEXT_PUBLIC_SITE_URL,\n * collections: COLLECTIONS,\n * });\n * ```\n */\nexport function buildPostMetadata(\n post: PostMetadataInput,\n options: BuildPostMetadataOptions = {},\n): PostMetadata {\n const seo = post.seo ?? undefined;\n const title = seo?.title || post.title;\n const description = seo?.description || post.description || undefined;\n const ogImage = seo?.ogImage || post.image || undefined;\n\n let canonical = seo?.canonical || undefined;\n if (!canonical && options.siteUrl) {\n // 경로 우선순위: 명시 path → collections 로 글의 섹션 basePath 자동 해석(공지/블로그).\n let path = options.path;\n if (!path && options.collections && post.slug) {\n path =\n resolvePostPath(\n { slug: post.slug, terms: [], collectionKey: post.collectionKey ?? null },\n options.collections,\n ) ?? undefined;\n }\n if (path) {\n const normalized = path.startsWith(\"/\") ? path : `/${path}`;\n canonical = `${trimSlash(options.siteUrl)}${normalized}`;\n }\n }\n\n const openGraph: PostMetadata[\"openGraph\"] = { type: \"article\", title };\n if (description) openGraph.description = description;\n if (canonical) openGraph.url = canonical;\n if (post.date) openGraph.publishedTime = post.date;\n // avcd 구조 — og article 확장. 입력에 값이 있을 때만 emit(기존 호출부는 diff 0).\n if (post.modified) openGraph.modifiedTime = post.modified;\n if (post.section) openGraph.section = post.section;\n if (post.tags && post.tags.length > 0) openGraph.tags = [...post.tags];\n if (post.authors && post.authors.length > 0) openGraph.authors = [...post.authors];\n if (ogImage) {\n openGraph.images = [{ url: ogImage, alt: seo?.ogImageAlt || title }];\n }\n\n const meta: PostMetadata = { title, openGraph };\n if (description) meta.description = description;\n if (canonical) {\n meta.alternates = { canonical };\n // hreflang — canonical 이 있을 때만(languages 단독 방출은 의미 없음).\n if (options.siteUrl && options.locales) {\n const languages = buildHreflangLanguages(\n post.translations,\n options.siteUrl,\n options.locales,\n );\n if (languages) meta.alternates.languages = languages;\n }\n }\n // avcd 구조 — RSS alternate. canonical 유무와 무관하게 siteUrl 만 있으면 emit\n // (사이트 차원 발견성 신호라 개별 글의 canonical 해석 성공 여부에 기대지 않음).\n if (options.siteUrl) {\n const feedPath = options.feedPath ?? \"/feed.xml\";\n const normalizedFeedPath = feedPath.startsWith(\"/\") ? feedPath : `/${feedPath}`;\n meta.alternates = {\n ...meta.alternates,\n types: {\n \"application/rss+xml\": `${trimSlash(options.siteUrl)}${normalizedFeedPath}`,\n },\n };\n }\n const baseRobots = resolveRobotsMeta({\n ...(seo?.noindex !== undefined && { noindex: seo.noindex }),\n ...(seo?.nofollow !== undefined && { nofollow: seo.nofollow }),\n });\n const index = baseRobots?.index ?? true;\n const follow = baseRobots?.follow ?? true;\n meta.robots = {\n index,\n follow,\n googleBot: {\n index,\n follow,\n \"max-image-preview\": \"large\",\n },\n };\n // ADR-0065 — 조회수 식별자 meta 자동 emit. 소비자가 따로 신경 쓸 필요 없이\n // buildPostMetadata 만 쓰면 비콘이 글별 조회수를 정확히 집계한다.\n if (post.id) meta.other = { \"rt:content-id\": post.id };\n // avcd 구조 — Twitter Card. og 와 같은 소스(seo override → 글 값)에서 채운다.\n // card+title 은 og 이미지 유무와 무관하게 항상 emit, images 는 og 이미지가 있을 때만.\n const twitter: PostMetadata[\"twitter\"] = { card: \"summary_large_image\", title };\n if (description) twitter.description = description;\n if (ogImage) twitter.images = [ogImage];\n meta.twitter = twitter;\n return meta;\n}\n\n/**\n * ADR-0052 A안 (W4-6 PR C1) — 글의 `translations[]`(PR B 응답, 자기 자신 포함)를\n * hreflang `alternates.languages` 로 변환. **양방향 + `x-default` = 기본 locale**\n * (Google 요건 — 기본판도 자기 자신을 가리키는 entry 를 갖는다).\n *\n * `buildPostMetadata` 가 내부적으로 쓰지만, OG `type:\"article\"` 이 부적절한\n * 라우트(CMS `[slug]` 페이지 등)는 이 함수를 직접 호출해 plain `Metadata.alternates`\n * 를 조립한다.\n *\n * 번역이 없거나(자기 자신 1건뿐) 기본 locale 판 URL 을 못 만들면(예: `translations`\n * 에 기본 locale 항목 자체가 없음 — 데이터 이상) undefined 를 반환해 languages\n * 방출을 생략한다(부분 hreflang 은 검색엔진이 무시하거나 오해할 수 있어 전부 생략이\n * 더 안전 — fail-closed).\n */\nexport function buildHreflangLanguages(\n translations: readonly CmsPostTranslationRef[] | undefined,\n siteUrl: string,\n locales: PostLocaleOptions,\n): Record<string, string> | undefined {\n if (!translations || translations.length <= 1) return undefined;\n const { defaultLocale, pathFor } = locales;\n const buildPath =\n pathFor ??\n ((locale: string, slug: string): string =>\n locale === defaultLocale ? `/blog/${slug}` : `/${locale}/blog/${slug}`);\n const base = trimSlash(siteUrl);\n const languages: Record<string, string> = {};\n for (const translation of translations) {\n const path = buildPath(translation.locale, translation.slug);\n const normalized = path.startsWith(\"/\") ? path : `/${path}`;\n languages[translation.locale] = `${base}${normalized}`;\n }\n const defaultUrl = languages[defaultLocale];\n if (!defaultUrl) return undefined;\n languages[\"x-default\"] = defaultUrl;\n return languages;\n}\n","/**\n * 아카이브(모음 페이지) 색인 정책 — **단일 SSoT**.\n *\n * seo-geo-contract v3.1 §1 \"색인 위생\": robots 메타(noindex)와 sitemap 포함은\n * **한 소스에서 파생**해야 한다. 둘이 갈리면 \"noindex 인데 sitemap 에 제출된 URL\"\n * 이 생기고, 검색엔진은 이를 크롤 예산 낭비 + 품질 신호 하락으로 취급한다.\n *\n * 그래서 이 파일의 `decideArchiveIndex` 하나가\n * - `routes-sitemap-entries.ts` 의 sitemap entry 필터(`inSitemap`)\n * - 아카이브 페이지 `generateMetadata` 의 robots(`buildArchiveMetadata` → `index`)\n * - 0건 아카이브의 404 처리(`render`)\n * 를 **동시에** 결정한다. 소비처가 따로 판단하지 않는다.\n *\n * 정책 요지: 아카이브는 **기본 noindex**. 고유한 소개 글을 채워 \"허브\"로 승격한\n * 분류·작가만 색인 대상이 된다(`promotion`).\n *\n * **판정 입력도 이 파일에서 만든다**(`resolveCategoryPromotion`·\n * `resolveAuthorPromotion`). 판정 함수만 공유하고 입력 조립을 소비처가 따로\n * 하면 \"sitemap 은 모름을 통과로, 페이지는 모름을 미승격으로\" 읽는 식으로\n * 결론이 갈린다 — 실제로 그렇게 갈렸던 것이 이 3-state 도입의 계기다.\n * (같은 이유로 **모집단**도 한 곳에서 정한다 — `archive-categories.ts`.)\n *\n * ## 불변식의 범위 — 정상 상태(steady state) 보장이다\n *\n * \"noindex 인데 sitemap 포함\" 이 없다는 계약은 **두 요청이 같은 설정을 봤을 때**\n * 성립한다. sitemap 과 아카이브 페이지는 서로 다른 요청에서 각자 설정을 조회하고\n * (sitemap 은 ISR 캐시, 페이지는 요청 시 렌더), 한쪽만 실패할 수 있다. 예를 들어\n * 페이지 조회는 성공(`promotedCategorySlugs: []` → `not-promoted` → noindex)인데\n * sitemap 조회는 실패(`undefined` → `unknown` → 포함)면 그 창 동안 두 결론이\n * 어긋난다. 이건 코드로 없앨 수 없다 — 두 표면이 같은 시점의 설정을 본다고\n * 보장할 방법이 없기 때문이다(공유 캐시를 둬도 만료 시점이 갈린다).\n *\n * **자가 치유 시간 = 사이트맵 revalidate 주기.** 다음 재생성 때 설정을 정상\n * 조회하면 어긋남이 사라진다. Site Kit(`sites/starter/app/sitemap.ts`)은\n * `export const revalidate = 60` 이므로 **상한 60초**다(아카이브 페이지도 60초).\n * 고객 사이트가 값을 바꿨으면 그 값이 곧 상한이다.\n *\n * **왜 이 방향인가(sitemap 조회 실패 시 포함 유지)**: 반대로 fail-closed 하면\n * 일시적 API 장애 한 번에 이미 색인된 URL 이 sitemap 에서 통째로 빠진다 —\n * 재발견까지 걸리는 노출 손실이 \"잠깐 noindex 인 URL 이 sitemap 에 남는\" 비용보다\n * 크다. 후자는 크롤러가 해당 URL 을 방문해 noindex 를 읽고 색인에서 빼는, 되돌릴\n * 수 있는 낭비다. (작가 **entry** 쪽만 예외로 fail-closed 인데, 이유는\n * `routes-sitemap-entries.ts` 의 `authorEntries` 주석 참조 — 라우트 자체가 없는\n * 사이트에 존재하지 않는 URL 을 제출할 위험이 더 크기 때문이다.)\n *\n * ## 정책 대상에서 뺀 것 (스펙 예외)\n *\n * 분류 **목록** 허브(`/blog/categories`, collection 모드의\n * `{basePath}/categories`)는 이 정책을 적용하지 않고 항상 sitemap 에 넣는다.\n * 개별 아카이브가 아니라 \"이 사이트에 어떤 분류가 있는지\" 를 훑는 **내비게이션\n * 허브**라, 중복 콘텐츠 위험(같은 글이 여러 모음에 중복 노출)이 없고 크롤러가\n * 분류 구조를 발견하는 진입점 역할을 하기 때문이다. 정본 스펙(seo-geo-contract\n * v3.1 §2)에는 이 예외가 아직 명문화돼 있지 않다 — 스펙 개정이 필요하다.\n */\n\nimport { type RobotsMeta, resolveRobotsMeta } from \"./routes-metadata.js\";\nimport { trimSlash } from \"./routes-shared.js\";\n\n/** 승격(\"허브로 올리기\") 개념이 있는 아카이브 종류. */\nexport type PromotableArchiveKind = \"category\" | \"author\";\n\n/** 종류만으로 항상 noindex 인 아카이브 — 승격 개념이 없다. */\nexport type AlwaysNoindexArchiveKind = \"tag\" | \"date\" | \"search\" | \"paginated\";\n\n/** 아카이브 종류. `paginated` 는 종류 불명의 2페이지 이상 목록. */\nexport type ArchiveKind = PromotableArchiveKind | AlwaysNoindexArchiveKind;\n\n/**\n * 허브 승격 상태 — **boolean 이 아니라 3-state 다**.\n *\n * - `promoted` — 어드민에서 노출을 켰다(확정).\n * - `not-promoted` — 켜지 않았다(확정). 아카이브 기본값.\n * - `unknown` — 승격 설정을 **읽지 못했다**. 구버전 API 응답이라 필드가 없거나,\n * 설정 fetch 가 실패했거나, 빌드 타임 사이트맵이 설정 조회를 생략한 경우.\n *\n * `unknown` 을 `not-promoted` 로 뭉개면 \"설정을 못 읽었다\" 는 이유만으로 이미\n * 색인된 URL 에 noindex 를 붙이고 sitemap 에서 지우게 된다 — 근거 없는 노출\n * 손실이다. 그래서 `unknown` 은 **정책 미적용 = 기존 동작 보존**(색인 + sitemap\n * 포함)으로 fail-soft 한다. 이 결정을 소비처가 아니라 정책 함수 안에 두는 이유는\n * 위 모듈 주석 참조.\n */\nexport type ArchivePromotion = \"promoted\" | \"not-promoted\" | \"unknown\";\n\n/**\n * 아카이브 정책이 `postCount` 를 세는 **폴백 모집단 한도**. sitemap 과 아카이브\n * 페이지가 이 상수를 함께 import 해 같은 크기의 최근 글 배치에서 개수를 파생한다.\n * 소비처마다 N 이 다르면 같은 카테고리에 대해 sitemap 은 \"글 있음\", 페이지는\n * \"0건 → 404\" 라는 상충이 생긴다(정확히 이 상수를 만든 이유다).\n *\n * **이제는 폴백 경로에서만 쓰인다**: 카테고리 `postCount` 의 1순위 소스는 서버측\n * 집계 API(`GET /v1/cms/public/categories`)이고, 그쪽은 발행 글 수와 무관하게\n * 모든 분류의 정확한 개수를 준다. 이 배치 파생은 그 API 를 못 받았을 때\n * (구버전 API·일시 장애) 기존 동작을 보존하는 fail-soft 경로다 —\n * `archive-categories.ts` 참조.\n *\n * **폴백 경로의 알려진 한계(그대로 유지)**: 발행 글이 이 한도를 넘는 사이트에서는\n * 한도 밖에만 글이 있는 카테고리가 sitemap·페이지 **양쪽 모두에서** 누락된다\n * (페이지는 404). 양쪽이 같은 방식으로 틀리므로 색인 위생 계약(noindex ↔ sitemap\n * 불일치)은 깨지지 않는다.\n *\n * collection 모드 스트림 아카이브(`collectCollectionCategories`)는 아직 이 배치\n * 파생만 쓴다 — 집계 API 는 `collection_key` 스코프를 이미 지원하므로 배선만\n * 남았다(후속).\n */\nexport const ARCHIVE_POST_COUNT_LIMIT = 200;\n\n/** 개수·페이지 입력 — 종류와 무관한 공통 부분. */\ninterface ArchiveScopeInput {\n /**\n * 이 아카이브에 실제로 걸린 발행 글 수. 0 = 빈 아카이브.\n * 유한한 0 이상 정수여야 한다(아래 \"입력 경계\" 참조).\n */\n postCount: number;\n /** 1-base 페이지 번호. 미지정 = 1페이지. 유한한 1 이상 정수여야 한다. */\n page?: number;\n}\n\n/**\n * `decideArchiveIndex` 입력. 종류에 따라 갈리는 판별 유니온이라,\n * **승격 상태는 의미가 있는 종류에서만 요구되고 나머지에서는 아예 받지 않는다** —\n * 태그 아카이브에 `promotion` 을 넘겨놓고 반영되길 기대하는 오해를 타입이 막는다.\n */\nexport type ArchiveIndexInput =\n | (ArchiveScopeInput & {\n kind: PromotableArchiveKind;\n promotion: ArchivePromotion;\n })\n | (ArchiveScopeInput & { kind: AlwaysNoindexArchiveKind });\n\nexport interface ArchiveIndexDecision {\n /** `notFound` 면 페이지가 404 를 반환해야 한다(빈 아카이브 = 색인 쓰레기). */\n render: \"ok\" | \"notFound\";\n /** robots 색인 허용 여부. `buildArchiveMetadata` 가 이 값으로 robots 를 만든다. */\n index: boolean;\n /** sitemap 에 이 URL 을 넣을지. `index` 가 false 면 반드시 false. */\n inSitemap: boolean;\n}\n\nconst NOT_FOUND: ArchiveIndexDecision = {\n render: \"notFound\",\n index: false,\n inSitemap: false,\n};\nconst NOINDEX: ArchiveIndexDecision = {\n render: \"ok\",\n index: false,\n inSitemap: false,\n};\nconst INDEXED: ArchiveIndexDecision = {\n render: \"ok\",\n index: true,\n inSitemap: true,\n};\n/**\n * 승격 상태를 모를 때(`unknown`)의 결론 — 정책을 적용하지 않고 **기존 동작 보존**.\n * 값은 `INDEXED` 와 같지만 이유가 다르다(\"승격 확인됨\" 이 아니라 \"판단 근거 없음\").\n */\nconst POLICY_NOT_APPLIED: ArchiveIndexDecision = INDEXED;\n\nfunction isNonNegativeInteger(value: number): boolean {\n return Number.isInteger(value) && value >= 0;\n}\n\n// ── 판정 입력 조립 (sitemap·페이지가 공유) ───────────────────────────────────\n\n/**\n * 카테고리 승격 상태. 소스는 공개 projection\n * `blog-settings.sitemap.promotedCategorySlugs`(어드민 카테고리 편집의\n * \"검색에 이 분류 페이지 노출\" 토글 집계).\n *\n * 목록 자체가 `undefined` 면 `unknown` — 빈 배열 `[]`(\"승격 0건\" 확정)과 의미가\n * 다르다. cms-client `fetchBlogSettings` 가 구버전 응답에서 필드를 **생략**하고,\n * 설정 fetch 실패 경로도 목록을 만들지 않으므로 이 구분이 그대로 전달된다.\n */\nexport function resolveCategoryPromotion(\n promotedSlugs: readonly string[] | undefined,\n slug: string,\n): ArchivePromotion {\n if (!promotedSlugs) return \"unknown\";\n return promotedSlugs.includes(slug) ? \"promoted\" : \"not-promoted\";\n}\n\n/**\n * 작가 승격 상태. 카테고리와 달리 작가는 개별 승격 UI 가 없고 **사이트 단위 토글**\n * (`blog-settings.sitemap.authors`, \"작가별 글 모음도 검색엔진에 알리기\")이 곧\n * 승격 신호다. 토글 값을 못 읽었으면 `undefined` 를 넘겨 `unknown` 을 받는다.\n */\nexport function resolveAuthorPromotion(\n authorsEnabled: boolean | undefined,\n): ArchivePromotion {\n if (authorsEnabled === undefined) return \"unknown\";\n return authorsEnabled ? \"promoted\" : \"not-promoted\";\n}\n\n// ── 판정 ─────────────────────────────────────────────────────────────────────\n\n/**\n * 아카이브 URL 하나의 색인·sitemap·렌더 판정. **판정 순서가 곧 정책이다**:\n *\n * 0. **입력 경계** — `postCount` 가 유한한 0 이상 정수가 아니거나 `page` 가 유한한\n * 1 이상 정수가 아니면 noindex + sitemap 제외로 중립 처리한다. 개수를 못 세는\n * 상태에서 \"좋은 모음 페이지\" 라고 주장할 근거가 없고, 그렇다고 404 로 화면을\n * 없애는 것은 과한 파괴라서 **화면은 살리고 색인만 보류**한다.\n * 1. **0건** (검색 제외) → 404. 빈 모음 페이지는 애초에 존재하면 안 된다.\n * 검색 결과는 0건이어도 정상 화면이라 404 로 만들지 않는다.\n * 2. **2페이지 이상** 이거나 **검색·날짜·태그** → noindex + sitemap 제외.\n * (승격 여부와 무관 — 승격된 카테고리의 2페이지도 noindex 다.)\n * 3. **카테고리·작가** → 승격 상태 3-state 로 결정. `promoted` 면 색인 + sitemap,\n * `not-promoted` 면 noindex + 제외, `unknown` 이면 정책 미적용(기존 동작 보존).\n */\nexport function decideArchiveIndex(input: ArchiveIndexInput): ArchiveIndexDecision {\n const page = input.page ?? 1;\n if (!isNonNegativeInteger(input.postCount)) return NOINDEX;\n if (!isNonNegativeInteger(page) || page < 1) return NOINDEX;\n\n if (input.postCount === 0 && input.kind !== \"search\") return NOT_FOUND;\n if (page >= 2) return NOINDEX;\n if (input.kind !== \"category\" && input.kind !== \"author\") return NOINDEX;\n\n switch (input.promotion) {\n case \"promoted\":\n return INDEXED;\n case \"unknown\":\n return POLICY_NOT_APPLIED;\n default:\n return NOINDEX;\n }\n}\n\n// ── 아카이브 metadata (같은 decision 에서 robots 파생) ────────────────────────\n\nexport interface ArchiveMetadataFields {\n /** 아카이브 화면 제목 (예: \"세무 | 블로그\"). */\n title: string;\n /** 메타 설명 — 분류 소개 글 등. 비면 생략. */\n description?: string | null;\n /** 링크 추적 제외. 색인과 별개 축이라 정책이 건드리지 않는다. */\n nofollow?: boolean;\n}\n\nexport type ArchiveMetadataInput = ArchiveIndexInput & ArchiveMetadataFields;\n\nexport interface BuildArchiveMetadataOptions {\n /** self-canonical origin (예: `https://example.com`). `path` 와 함께. */\n siteUrl?: string;\n /** self-canonical 경로 (예: `/blog/categories/tax`). `siteUrl` 과 함께. */\n path?: string;\n}\n\n/** Next `Metadata` 의 구조적 부분집합 — 아카이브 `generateMetadata` 가 그대로 return. */\nexport interface ArchiveMetadata {\n title: string;\n description?: string;\n alternates?: { canonical: string };\n robots?: RobotsMeta;\n}\n\n/**\n * 아카이브 페이지 metadata. **robots 는 `decideArchiveIndex` 의 `index` 에서만**\n * 파생된다 — sitemap 필터와 같은 판정이라 \"noindex 인데 sitemap 에 있음\" 이\n * 구조적으로 불가능하다.\n *\n * `render === \"notFound\"` 판정(0건 아카이브)은 metadata 로 표현할 수 없으므로\n * 페이지가 `decideArchiveIndex` 를 직접 호출해 `notFound()` 를 부른다:\n *\n * ```ts\n * const promotion = resolveCategoryPromotion(settings.sitemap?.promotedCategorySlugs, slug);\n * const decision = decideArchiveIndex({ kind: \"category\", promotion, postCount });\n * if (decision.render === \"notFound\") notFound();\n * // generateMetadata 에서:\n * return buildArchiveMetadata(\n * { kind: \"category\", promotion, postCount, title: `${name} | 블로그` },\n * { siteUrl, path: `/blog/categories/${slug}` },\n * );\n * ```\n */\nexport function buildArchiveMetadata(\n input: ArchiveMetadataInput,\n options: BuildArchiveMetadataOptions = {},\n): ArchiveMetadata {\n const decision = decideArchiveIndex(input);\n const meta: ArchiveMetadata = { title: input.title };\n if (input.description) meta.description = input.description;\n if (options.siteUrl && options.path) {\n const normalized = options.path.startsWith(\"/\") ? options.path : `/${options.path}`;\n meta.alternates = { canonical: `${trimSlash(options.siteUrl)}${normalized}` };\n }\n const robots = resolveRobotsMeta({\n noindex: !decision.index,\n ...(input.nofollow !== undefined && { nofollow: input.nofollow }),\n });\n if (robots) meta.robots = robots;\n return meta;\n}\n","/**\n * 사이트맵 entry 조립 — `createSitemap`·`createSitemapIndex` 가 공유하는 내부\n * 빌더. 라우트 팩토리(`routes-feed-sitemap.ts`)와 분리해 \"무엇을 내보낼지\"(정책·\n * 조립)와 \"어떤 라우트로 내보낼지\"(Next 계약)를 나눈다. 공개 표면은 `routes.ts` 배럴.\n */\n\nimport {\n type CmsPostContent,\n type RootTaleSitemapSettings,\n fetchAuthors,\n fetchBlogSettings,\n fetchPosts,\n} from \"@roottale/cms-client/server\";\nimport { isInCollectionMode, resolvePostCollection, resolvePostUrl } from \"@roottale/cms-core\";\n\nimport { fetchBlogArchiveCategories } from \"./archive-categories.js\";\nimport {\n ARCHIVE_POST_COUNT_LIMIT,\n decideArchiveIndex,\n resolveAuthorPromotion,\n resolveCategoryPromotion,\n} from \"./archive-index-policy.js\";\nimport {\n type BlogCategoryWithCount,\n collectCollectionCategories,\n} from \"./blog-filter.js\";\nimport { buildHreflangLanguages } from \"./routes-metadata.js\";\nimport {\n type RootTaleSiteConfig,\n type RouteCollection,\n safeFetchPosts,\n trimSlash,\n} from \"./routes-shared.js\";\n\n/** Next `MetadataRoute.Sitemap[number]` 와 구조 호환(타입 결합 회피). */\nexport interface SitemapEntry {\n url: string;\n lastModified?: string | Date;\n /** @deprecated Google 이 무시 — emit 하지 않는다(blanche식 loc+lastmod). */\n changeFrequency?:\n | \"always\"\n | \"hourly\"\n | \"daily\"\n | \"weekly\"\n | \"monthly\"\n | \"yearly\"\n | \"never\";\n /** @deprecated Google 이 무시 — emit 하지 않는다. */\n priority?: number;\n /** `<image:image>` — 글 대표 이미지 등. Next `MetadataRoute.Sitemap` 호환. */\n images?: string[];\n /** hreflang 대체 언어 — 다국어(`alternates.languages`). Next 호환. */\n alternates?: { languages?: Record<string, string> };\n}\n\n// ── 사이트맵 빌더 (createSitemap / createSitemapIndex 공유) ───────────────────\n\nconst SITEMAP_DEFAULTS: RootTaleSitemapSettings = {\n blogImages: true,\n locales: [],\n authors: false,\n // promotedCategorySlugs 는 의도적으로 기본값이 없다 — \"모름\"(undefined)과\n // \"승격 0건\"([])을 구분해야 색인 정책을 fail-soft 로 적용할 수 있다.\n};\n\n/** `createSitemapIndex` 옵션 — admin 설정을 코드에서 덮어쓰거나 fetch 를 끌 때만. */\nexport interface SitemapIndexOptions {\n /** admin 의 `blogImages` 토글을 덮어쓴다. */\n blogImages?: boolean;\n /** admin 의 `locales` 를 덮어쓴다 (다국어, PR C). */\n locales?: string[];\n /** admin 의 `authors` 토글을 덮어쓴다 (작가 사이트맵). */\n authors?: boolean;\n /**\n * admin 의 \"검색에 이 분류 페이지 노출\" 집계를 덮어쓴다 — 색인 정책상 여기\n * 없는 카테고리의 아카이브는 sitemap 에서 빠진다. `skipRemoteSettings` 와 함께\n * 쓰면 네트워크 없이 정책을 켤 수 있다(빌드 타임 사이트맵).\n */\n promotedCategorySlugs?: string[];\n /** 작가 아카이브 base path. 기본 \"/blog/author\" (→ /blog/author/{slug}). */\n authorBasePath?: string;\n /** true 면 admin 설정 fetch 생략(opts/기본값만). 빌드 시 네트워크 회피용. */\n skipRemoteSettings?: boolean;\n}\n\n/** 작가 fetch 실패 시 빈 목록 — 사이트맵은 항상 정상 응답(404 방지). */\nasync function safeFetchAuthors(\n config: RootTaleSiteConfig,\n): Promise<Awaited<ReturnType<typeof fetchAuthors>>> {\n try {\n return await fetchAuthors({ apiKey: config.apiKey, baseUrl: config.apiBase });\n } catch (err) {\n console.error(\"[@roottale/cms-renderer-next/routes] fetchAuthors 실패:\", err);\n return [];\n }\n}\n\n/**\n * `resolveSitemapSettings` 결과. 설정 **값**과 그 값을 얼마나 믿을 수 있는지를\n * 함께 돌려준다 — 사이트맵 인덱스의 섹션 목록이 \"토글이 꺼졌다\" 와 \"토글을 못\n * 읽었다\" 를 구분해야 하기 때문(아래 `settingsFetchFailed` 참조).\n */\nexport interface ResolvedSitemapSettings {\n settings: RootTaleSitemapSettings;\n /**\n * admin 설정 fetch 를 **시도했는데 실패**했으면 true. 의도적으로 생략한\n * (`skipRemoteSettings`) 경우는 false — 그건 장애가 아니라 설정이다.\n */\n settingsFetchFailed: boolean;\n}\n\n/** opts override > admin 설정(fetch) > 기본값. fetch 실패는 fail-soft. */\nexport async function resolveSitemapSettings(\n config: RootTaleSiteConfig,\n opts: SitemapIndexOptions,\n): Promise<ResolvedSitemapSettings> {\n let remote: Partial<RootTaleSitemapSettings> = {};\n let settingsFetchFailed = false;\n if (!opts.skipRemoteSettings) {\n try {\n // 구버전 cms-client(블로그 설정에 sitemap 없음)면 `?? {}` 로 fail-soft.\n remote =\n (\n await fetchBlogSettings({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n })\n ).sitemap ?? {};\n } catch (err) {\n settingsFetchFailed = true;\n console.error(\n \"[@roottale/cms-renderer-next/routes] fetchBlogSettings 실패(기본값으로 진행):\",\n err,\n );\n }\n }\n const settings: RootTaleSitemapSettings = {\n blogImages: opts.blogImages ?? remote.blogImages ?? SITEMAP_DEFAULTS.blogImages,\n locales: opts.locales ?? remote.locales ?? SITEMAP_DEFAULTS.locales,\n authors: opts.authors ?? remote.authors ?? SITEMAP_DEFAULTS.authors,\n };\n // \"모름\" 을 기본값으로 덮지 않는다 — 아래 `categoryEntries` 가 undefined 를\n // `resolveCategoryPromotion` 을 거쳐 \"unknown = 정책 미적용\" 으로 읽는다.\n const promoted = opts.promotedCategorySlugs ?? remote.promotedCategorySlugs;\n if (promoted) settings.promotedCategorySlugs = promoted;\n return { settings, settingsFetchFailed };\n}\n\n/**\n * ADR-0052 A안 (W4-6 PR B) — sitemap fail-closed. admin 이 `locales` 에 값을\n * 넣었지만 그 locale 로 발행된 글이 실제로는 0건이면(라우트 미배포·오타 등)\n * 존재하지 않는 `/{locale}/...` URL 의 hreflang 을 만들지 않는다 — 이 검증은\n * site-wide 단일 게이트(글 단위 실제 translations 매칭은 PR C1 후속, 여긴\n * \"이 사이트에 이 locale 콘텐츠가 아예 있는가\"만 판정). fetch 실패는 fail-soft\n * 로 그 locale 을 제외(존재 확인 불가 = 생성 안 함, §1 404 배관 위험 우선 차단).\n */\nexport async function localesWithPublishedContent(\n config: RootTaleSiteConfig,\n locales: string[],\n): Promise<string[]> {\n if (locales.length === 0) return locales;\n const checks = await Promise.all(\n locales.map(async (locale) => {\n try {\n const page = await fetchPosts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n type: \"post\",\n locale,\n limit: 1,\n });\n return page.items.length > 0 ? locale : null;\n } catch (err) {\n console.error(\n `[@roottale/cms-renderer-next/routes] locale '${locale}' 발행 글 확인 실패(제외):`,\n err,\n );\n return null;\n }\n }),\n );\n return checks.filter((locale): locale is string => locale !== null);\n}\n\n/** 정적 경로 — changeFrequency/priority 는 떨군다(blanche식). 나머지는 보존. */\nexport function staticEntries(staticRoutes: SitemapEntry[]): SitemapEntry[] {\n return staticRoutes.map((r) => {\n const entry: SitemapEntry = { url: r.url };\n if (r.lastModified !== undefined) entry.lastModified = r.lastModified;\n if (r.images) entry.images = r.images;\n if (r.alternates) entry.alternates = r.alternates;\n return entry;\n });\n}\n\n/**\n * 발행 글 entry. collection 모드면 소속 스트림 basePath(`resolvePostUrl`), 레거시면\n * 단일 `/blog`. 어느 스트림에도 안 속한 글(상세 라우트 없음)은 제외. `blogImages`\n * 가 켜져 있으면 대표 이미지를 `<image:image>` 로 함께 색인.\n *\n * `defaultLocale` 이 주어지면(다국어 사이트, PR C1) 각 글의 실제 `translations[]`\n * (PR B 응답)로 hreflang `alternates.languages` 를 단다 — **글 단위 정밀**, 옛\n * `localizeEntries` 의 slug 1:1 prefix 가정을 쓰지 않는다(언어판 없는 글은 생략,\n * `tenant-site-i18n-plan.md` §3 PR C1-4). 번역 그룹이 다른 스트림에 걸쳐 있을 순\n * 없다는 전제(v1: 스트림 글은 언어판 없음, §3 PR C1-1)로 언어판 전부 이 글과 같은\n * basePath 를 쓴다.\n */\nexport function postEntries(\n config: RootTaleSiteConfig,\n siteUrl: string,\n posts: CmsPostContent[],\n collections: RouteCollection[] | undefined,\n settings: RootTaleSitemapSettings,\n defaultLocale?: string,\n): SitemapEntry[] {\n const inCollection = isInCollectionMode(collections);\n const blogBase = config.blogBasePath ?? \"/blog\";\n return posts.flatMap((p) => {\n const url = inCollection\n ? resolvePostUrl(siteUrl, p, collections!)\n : `${siteUrl}${blogBase}/${p.slug}`;\n if (!url) return [];\n const entry: SitemapEntry = {\n url,\n lastModified: p.updatedAt ?? p.publishedAt ?? undefined,\n };\n if (settings.blogImages && p.featuredImageUrl) {\n entry.images = [p.featuredImageUrl];\n }\n if (defaultLocale) {\n const basePath = inCollection\n ? (resolvePostCollection(p, collections!)?.basePath ?? blogBase)\n : blogBase;\n const trimmedBase = trimSlash(basePath);\n const languages = buildHreflangLanguages(p.translations, siteUrl, {\n defaultLocale,\n pathFor: (locale, slug) =>\n locale === defaultLocale ? `${trimmedBase}/${slug}` : `/${locale}${trimmedBase}/${slug}`,\n });\n if (languages) entry.alternates = { languages };\n }\n return [entry];\n });\n}\n\n/**\n * 레거시 `/blog` 계열 아카이브 모집단 — 실패는 빈 목록(사이트맵 always-200).\n * 페이지는 같은 헬퍼를 쓰되 실패를 throw 로 올린다(API 장애 ≠ 404).\n */\nasync function safeBlogArchiveCategories(\n config: RootTaleSiteConfig,\n): Promise<BlogCategoryWithCount[]> {\n try {\n return await fetchBlogArchiveCategories(config);\n } catch (err) {\n console.error(\n \"[@roottale/cms-renderer-next/routes] 아카이브 카테고리 조회 실패:\",\n err,\n );\n return [];\n }\n}\n\n/**\n * 카테고리 아카이브 entry. collection 모드는 `archives:true` 스트림만.\n *\n * **색인 위생(§1)**: 개별 카테고리 아카이브(`/categories/{slug}`)는\n * `decideArchiveIndex` 로 걸러 \"허브로 승격\" 한 것만 남긴다 — 아카이브 페이지가\n * `buildArchiveMetadata` 로 noindex 를 내보내는 것과 **같은 판정**이라 noindex URL\n * 이 sitemap 에 남는 상충이 생기지 않는다.\n *\n * 카테고리 **목록** 페이지(`/categories`)는 개별 아카이브가 아니라 분류를 훑는\n * 내비게이션 허브라 정책 대상이 아니다 — 기존대로 카테고리가 1개 이상일 때 포함.\n *\n * `promotedSlugs === undefined` = 승격 정보를 모르는 호출 경로(admin 설정 fetch 를\n * 생략한 `createSitemap`·`skipRemoteSettings`, 또는 구버전 API 응답). 이때\n * `resolveCategoryPromotion` 이 `unknown` 을 만들고 정책이 \"미적용 = 기존 동작\n * 유지\" 로 fail-soft 한다 — 아카이브 페이지도 **같은 헬퍼로 같은 3-state 를**\n * 만들므로 두 소스의 결론이 갈리지 않는다.\n *\n * **모집단(어떤 글에서 카테고리를 세는가)도 이 함수가 직접 정한다** — 호출부가\n * 글 배치를 주입하던 옛 시그니처에서는 사이트맵이 \"발행 글 전체\", 페이지가\n * \"미소속 글\" 을 세어 결론이 갈렸다(`archive-categories.ts` 모듈 주석 참조).\n * 지금은 계열별로 `archive-categories`/`collectCollectionCategories` 라는 **한\n * 함수**가 모집단을 정하고, 아카이브 페이지도 같은 함수를 호출한다.\n */\nexport async function categoryEntries(\n config: RootTaleSiteConfig,\n siteUrl: string,\n collections: RouteCollection[] | undefined,\n promotedSlugs: readonly string[] | undefined,\n): Promise<SitemapEntry[]> {\n const inSitemap = (category: BlogCategoryWithCount): boolean =>\n decideArchiveIndex({\n kind: \"category\",\n promotion: resolveCategoryPromotion(promotedSlugs, category.slug),\n postCount: category.count,\n }).inSitemap;\n\n if (isInCollectionMode(collections)) {\n // 스트림 아카이브의 모집단은 포지티브 스코프(그 스트림 귀속 글 ∩ 주제\n // allowlist)라 발행 글 전체 배치에서 파생한다 — 스트림 아카이브 페이지도\n // 같은 `collectCollectionCategories` 를 쓴다.\n const posts = await safeFetchPosts(config, ARCHIVE_POST_COUNT_LIMIT);\n return collections!\n // 주소 없는 유형(basePath === \"\")은 아카이브 라우트 자체가 없으므로\n // archives:true 로 잘못 선언돼 있어도(코드 상수 경로 방어) 제외한다.\n .filter((c) => c.archives && c.basePath)\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` },\n ...cats.filter(inSitemap).map((category) => ({\n url: `${base}/categories/${category.slug}`,\n })),\n ];\n });\n }\n const categories = await safeBlogArchiveCategories(config);\n if (!categories.length) return [];\n const blogBase = config.blogBasePath ?? \"/blog\";\n return [\n { url: `${siteUrl}${blogBase}/categories` },\n ...categories.filter(inSitemap).map((category) => ({\n url: `${siteUrl}${blogBase}/categories/${category.slug}`,\n })),\n ];\n}\n\n/**\n * 작가 아카이브 entry. 사이트 단위 admin 토글(`settings.authors`)이 곧 \"허브 승격\"\n * 신호다 — 카테고리처럼 개별 승격 UI 가 없다. 토글이 꺼져 있으면 빈 목록이고,\n * 켜져 있어도 발행 글 0건 작가는 색인 정책(빈 아카이브)이 걸러낸다.\n *\n * **entry 쪽은 카테고리와 달리 fail-closed** 다: 토글을 못 읽었을 때 URL 을\n * 만들어 넣으면 작가 아카이브 라우트 자체가 없는 사이트에 존재하지 않는 URL 을\n * 제출하게 된다(§1 \"404 배관\" 위험). 카테고리는 라우트가 이미 있다는 전제라\n * 반대로 fail-soft 다. 그래서 설정 fetch 실패 시 작가 페이지는 색인 유지(기존\n * 동작) + sitemap 에는 미포함이 되는데, 금지된 조합은 \"noindex 인데 sitemap 포함\"\n * 이고 그 반대(색인인데 sitemap 미포함)는 무해하므로 계약을 깨지 않는다.\n */\nexport async function authorEntries(\n config: RootTaleSiteConfig,\n siteUrl: string,\n settings: RootTaleSitemapSettings,\n authorBasePath: string | undefined,\n): Promise<SitemapEntry[]> {\n if (!settings.authors) return [];\n const authors = await safeFetchAuthors(config);\n const base = trimSlash(authorBasePath ?? \"/blog/author\");\n const promotion = resolveAuthorPromotion(settings.authors);\n return authors\n .filter(\n (a) =>\n decideArchiveIndex({\n kind: \"author\",\n promotion,\n postCount: a.postCount,\n }).inSitemap,\n )\n .map((a) => ({\n url: `${siteUrl}${base}/${encodeURIComponent(a.slug)}`,\n lastModified: a.lastUpdatedAt ?? undefined,\n }));\n}\n","/**\n * 아카이브 카테고리 **모집단** — \"이 아카이브 라우트가 실제로 다루는 글 집합\" 을\n * 정하는 단일 지점. 사이트맵과 아카이브 페이지가 **같은 함수**를 호출한다.\n *\n * 판정 함수(`decideArchiveIndex`)와 승격 상태 조립(`resolveCategoryPromotion`)을\n * 공유해도 **세는 글 집합이 다르면** 결론이 갈린다. 실제로 갈렸다:\n *\n * - 사이트맵은 발행 글 **전체**에서 카테고리를 셌고,\n * - `/blog` 계열 아카이브 페이지는 스트림(collection) 소속 글을 뺀 **\"미소속 글\"**\n * 만 다룬다(`blog-list-collection-scope.md` §2① — \"/blog 계열 표면은 전부\n * 미소속 글 = 블로그로 통일\", `RootTaleBlogList` 의 `excludeCollections`).\n *\n * 그래서 스트림 글에만 달린 카테고리(예: `symptoms` 스트림 글 1건뿐인 `symptom`)가\n * **사이트맵엔 URL 이 있는데 페이지는 404** 인 상충을 만들었다.\n *\n * ## 어느 쪽으로 맞췄나 — 사이트맵을 페이지에 맞춰 **좁혔다**\n *\n * 반대(페이지를 넓히기)는 카테고리는 유효하다고 인정하면서 목록은\n * `excludeCollections` 라 **비어 있는 화면**이 되거나, 그 prop 을 떼면 `/blog` 에\n * 스트림 글이 새어 들어온다 — §2① 을 되돌리는 셈이라 택하지 않았다. 페이지가\n * 실제로 서빙하는 집합이 이 라우트의 진실이고, 사이트맵은 거기에 따른다.\n *\n * ## \"미소속\" 판정은 서버가 한다\n *\n * `exclude_collections=true`(공개 posts API)는 **사이트가 선언한\n * `content_collections`** 기준으로 미소속 글을 고른다 — 판정 정의가 DB 한 곳에\n * 있다. 사이트맵 팩토리는 라우팅용 `collections` 를 모른 채 도는 경우가 많은데\n * (레거시 모드 = `config.collections` 미전달), 이 서버 필터는 그 지식 없이도\n * 페이지와 같은 집합을 준다. 클라이언트에서 각자 collections 목록을 조립해 거르면\n * \"어느 목록을 봤느냐\" 에 따라 또 갈린다 — 그게 이 결함의 원인이었다.\n *\n * collection 모드 스트림 아카이브(`{basePath}/categories/{slug}`)의 모집단은\n * 대칭적으로 `collectCollectionCategories`(포지티브 스코프: 그 스트림에 귀속된 글\n * ∩ 스트림의 주제 allowlist) 하나다 — 사이트맵과 스트림 아카이브 페이지가 역시\n * 같은 함수를 쓴다.\n */\n\nimport {\n type CmsPostContent,\n fetchCategoryCounts,\n fetchPosts,\n} from \"@roottale/cms-client/server\";\n\nimport { ARCHIVE_POST_COUNT_LIMIT } from \"./archive-index-policy.js\";\nimport {\n type BlogCategoryWithCount,\n collectCategoriesWithCounts,\n} from \"./blog-filter.js\";\n\n/** 모집단 조회에 필요한 최소 설정 — `RootTaleSiteConfig` 가 구조적으로 만족한다. */\nexport interface ArchiveCategoriesConfig {\n /** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만. */\n apiKey: string;\n /** API base 오버라이드. 기본 `https://api.roottale.com`. */\n apiBase?: string;\n}\n\nexport interface ArchiveCategoriesOptions {\n /** Next fetch 재검증 주기(초). 미지정 = cms-client 기본. */\n revalidate?: number;\n}\n\n/**\n * 레거시 `/blog` 계열 아카이브의 **모집단** — 어느 스트림에도 안 속한 발행 글.\n *\n * 한도는 `ARCHIVE_POST_COUNT_LIMIT`. 소비처마다 한도가 갈리면 같은 카테고리를 두고\n * 사이트맵은 \"글 있음\", 페이지는 \"0건 → 404\" 가 된다(그래서 상수를 공유한다).\n * 실패는 **throw** 한다 — 사이트맵은 always-200 이라 호출부에서 삼키고, 페이지는\n * API 장애를 404 로 둔갑시키면 안 되므로 그대로 올린다(에러 정책이 서로 다르므로\n * 여기서 임의로 fail-soft 하지 않는다).\n */\nexport async function fetchBlogArchivePosts(\n config: ArchiveCategoriesConfig,\n options: ArchiveCategoriesOptions = {},\n): Promise<CmsPostContent[]> {\n const page = await fetchPosts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n type: \"post\",\n limit: ARCHIVE_POST_COUNT_LIMIT,\n // 서버측 \"미소속 글\" 필터 — 페이지(`excludeCollections`)와 같은 정의.\n excludeCollections: true,\n ...(options.revalidate !== undefined && { revalidate: options.revalidate }),\n });\n return page.items;\n}\n\n/**\n * 서버측 집계(`GET /v1/cms/public/categories`)로 카테고리 + 글 수를 받는다.\n * 실패(네트워크 장애·구버전 API 의 404·예상 밖 응답)면 **null** — 호출부가 옛\n * 배치 파생으로 폴백한다.\n *\n * `null`(모름)과 `[]`(분류 0건 확정)을 구분하는 것이 요점이다. 실패를 빈 배열로\n * 뭉개면 \"이 사이트엔 분류가 없다\" 는 확정 정보가 되어 모든 분류 아카이브가 404 로\n * 사라진다 — 색인 정책의 `unknown` 3-state 와 같은 결의 판단이다.\n */\nasync function fetchServerCategoryCounts(\n config: ArchiveCategoriesConfig,\n options: ArchiveCategoriesOptions,\n): Promise<BlogCategoryWithCount[] | null> {\n try {\n const result = await fetchCategoryCounts({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n // 레거시 `/blog` 계열의 모집단 = 미소속 글. 페이지(`excludeCollections`)·\n // 사이트맵과 **같은 서버 판정**을 쓰기 위해 서버 필터로 지정한다.\n excludeCollections: true,\n ...(options.revalidate !== undefined && { revalidate: options.revalidate }),\n });\n if (result.truncated) {\n // 조용한 절단 금지 — 상한을 넘겨 빠진 분류가 있으면 드러낸다.\n console.warn(\n \"[@roottale/cms-renderer-next/routes] 분류 수가 서버 상한을 넘어 일부가 응답에서 빠졌습니다(색인에서 누락될 수 있음).\",\n );\n }\n // 글 0건 분류는 제외한다 — 이 헬퍼의 계약은 \"이 아카이브 라우트가 인정하는\n // 카테고리\" 이고, 배치 파생 시절에도 0건 분류는 애초에 등장하지 않았다.\n // (0건이면 `decideArchiveIndex` 가 어차피 404 + sitemap 제외로 판정한다.)\n return result.categories\n .filter((category) => category.publishedPostCount > 0)\n .map((category) => ({\n name: category.name,\n slug: category.slug,\n count: category.publishedPostCount,\n ...(category.description ? { description: category.description } : {}),\n ...(category.seoTitle ? { seoTitle: category.seoTitle } : {}),\n ...(category.seoDescription\n ? { seoDescription: category.seoDescription }\n : {}),\n ...(category.imageUrl ? { imageUrl: category.imageUrl } : {}),\n ...(category.imageAlt ? { imageAlt: category.imageAlt } : {}),\n }));\n } catch (err) {\n console.warn(\n \"[@roottale/cms-renderer-next/routes] 분류별 글 수 집계 API 실패 — 최근 글 배치 파생으로 폴백:\",\n err,\n );\n return null;\n }\n}\n\n/**\n * 레거시 `/blog` 계열 아카이브 라우트가 인정하는 카테고리 + 글 수.\n *\n * 사이트맵의 `/blog/categories/{slug}` 필터와 아카이브 페이지의 slug 유효성·\n * `postCount` 가 **이 함수 하나**에서 나온다.\n *\n * ## 우선순위 — 서버 집계 → (실패 시) 최근 글 배치 파생\n *\n * 1순위는 서버측 집계 API 다. 발행 글 수와 무관하게 **모든** 분류의 정확한 글 수를\n * 주므로, 최근 `ARCHIVE_POST_COUNT_LIMIT` 건 밖에만 글이 있는 분류가 페이지(404)·\n * 사이트맵에서 함께 사라지던 한계가 없어진다.\n *\n * 서버 집계를 못 받으면(구버전 API·일시 장애) **옛 배치 파생으로 fail-soft** 한다 —\n * 새 API 가 없다는 이유로 아카이브를 통째로 죽이지 않는다. 이때의 동작은 이 변경\n * 이전과 정확히 같다(한도 안 분류만 인정). 배치 조회까지 실패하면 그때는 throw 가\n * 그대로 올라간다(`fetchBlogArchivePosts` 주석 — API 장애를 404 로 둔갑시키지 않음).\n *\n * 두 경로 모두 이 함수 **하나**를 통과하므로, 사이트맵과 아카이브 페이지는 어느\n * 경로가 쓰이든 같은 값을 본다(그 계약이 이 모듈의 존재 이유다).\n */\nexport async function fetchBlogArchiveCategories(\n config: ArchiveCategoriesConfig,\n options: ArchiveCategoriesOptions = {},\n): Promise<BlogCategoryWithCount[]> {\n const counts = await fetchServerCategoryCounts(config, options);\n if (counts) return counts;\n return collectCategoriesWithCounts(await fetchBlogArchivePosts(config, options));\n}\n","/** `routes` — llms.txt (AEO/GEO, AI 검색·생성엔진용 마크다운 인덱스). */\n\nimport { isInCollectionMode, resolvePostUrl } from \"@roottale/cms-core\";\n\nimport {\n type RootTaleSiteConfig,\n resolveCollections,\n safeFetchPosts,\n trimSlash,\n} from \"./routes-shared.js\";\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 = await resolveCollections(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","/** `routes` — revalidate webhook 핸들러 (서명 검증 + 파생 경로 재검증). */\n\nimport {\n SETTINGS_CACHE_TAGS,\n THEME_CACHE_TAG,\n} from \"@roottale/cms-client/server\";\nimport {\n verifyRootTaleWebhook,\n type VerifyRootTaleWebhookResult,\n} from \"@roottale/cms-client/webhook\";\nimport { isInCollectionMode } from \"@roottale/cms-core\";\n\nimport { type CollectionsInput, resolveCollectionsResult } from \"./routes-shared.js\";\n\nexport interface RevalidateRouteConfig {\n /** `rtlk_cust_*` — 블로그 조회/webhook 검증 공통 키. */\n apiKey: string;\n apiBase?: string;\n /**\n * Next 의 `revalidatePath` (또는 동등 콜백) 주입. 2번째 인자(`\"layout\"`)로\n * 레이아웃 전역 무효화를 지원 — `theme.updated` 시 정적 페이지까지 반영하려면\n * 필요(next `revalidatePath(path, \"layout\")` 시그니처와 호환).\n *\n * **콜백으로 감쌀 때 2번째 인자를 반드시 그대로 넘겨라.** `(path) =>\n * revalidatePath(path)` 처럼 1개만 받으면 이 팩토리가 호출하는\n * `revalidate(\"/\", \"layout\")` 의 `\"layout\"` 이 사라져 레이아웃 전역 무효화가\n * 페이지 1개 무효화로 줄어든다(정적 고객 페이지가 안 바뀐다).\n * 올바른 모양: `(path, type) => revalidatePath(path, type)`.\n */\n revalidate: (path: string, type?: \"layout\" | \"page\") => void | Promise<void>;\n /**\n * Next 의 `revalidateTag` (또는 동등 콜백) 주입 — `theme.updated`(W0-4) 시\n * 테마 + 설정류 fetch 캐시 tag(ADR-0096 Phase C)를 무효화한다. 미지정 시 tag\n * 무효화 생략(layout 만) — 그러면 `fetchTheme`·`fetchBusinessProfile` 등의\n * Data Cache 엔트리는 각자의 `revalidate` 초만큼 낡은 값을 계속 낸다.\n * **주입을 권장한다.**\n *\n * **즉시 만료 프로파일을 넘겨라** — `(tag) => revalidateTag(tag, { expire: 0 })`.\n * Next 는 `expire: 0` 이 아닌 프로파일을 stale-while-revalidate 업데이트로\n * 취급해 다음 요청이 여전히 낡은 값을 받는다(`\"max\"` 는 expire 가 1년이라 즉시\n * 만료가 아니다). `updateTag` 는 서버 액션 전용이어서 이 라우트에서는 throw\n * 한다 — 쓰지 마라.\n */\n revalidateTag?: (tag: string) => void | Promise<void>;\n /** 테마 fetch 캐시 tag 이름. 기본 \"rt-theme\"(cms-client `THEME_CACHE_TAG`). */\n themeTag?: string;\n /** 블로그 base path. 기본 \"/blog\". */\n blogBasePath?: string;\n /**\n * 다중 스트림 (ADR-0060). 지정 시 `alsoRevalidate` 기본값을 각 스트림 basePath\n * (+ `archives:true` 면 `${basePath}/categories`)로 파생. 미지정 = 레거시 단일 blog.\n * 배열 또는 async resolver(`fetchCollections()`) — resolver 실패 시 레거시로 fail-soft.\n */\n collections?: CollectionsInput;\n /**\n * 모든 글/분류/미디어 변경에 대해 *항상 함께* revalidate 할 파생 경로.\n * 피드·사이트맵·블로그 인덱스처럼 \"글 목록\"에 의존하는 경로 — admin 변경이\n * 자동 반영되려면 필수. 명시 시 collections 파생을 덮어쓴다.\n * 기본(레거시): [\"/feed.xml\", \"/llms.txt\", \"/sitemap.xml\", \"/sitemap/static.xml\",\n * \"/sitemap/blog.xml\", \"/sitemap/categories.xml\", 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\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 // 설정 저장(W0-4 테마 · ADR-0096 Phase C 사업장 정보·메뉴 등): path 리스트가\n // 아니라 tag + layout 전역 무효화. path 리스트는 정적 고객 페이지\n // (services/contact)를 빠뜨렸고, `revalidatePath` 는 애초에 fetch 캐시\n // (Data Cache) 엔트리를 못 지운다 — 그래서 설정류 fetch 는 tag 로 지운다.\n //\n // 테마 하나만 지우지 않는 이유: 어드민의 \"설정 저장\"은 전부 이 이벤트 하나로\n // 오고, 무엇이 바뀌었는지 payload 에 없다. 사이트가 안 쓰는 tag 의\n // revalidateTag 는 no-op 이라 전부 지우는 쪽이 안전하다.\n if (result.event === \"theme.updated\") {\n const themeTag = config.themeTag ?? THEME_CACHE_TAG;\n // themeTag 를 커스텀 이름으로 덮어쓴 사이트를 존중한다 — 기본 이름 대신\n // 그 이름을 지운다.\n const requestedTags = [\n ...new Set([\n themeTag,\n ...SETTINGS_CACHE_TAGS.filter((t) => t !== THEME_CACHE_TAG),\n ]),\n ];\n // 본문 paths 도 함께 갱신한다. 설정 저장이 라우팅·피드까지 바꾸는 경우가\n // 있어서다 — 콘텐츠 유형 저장은 `/sitemap.xml`·`/feed.xml`·`/llms.txt`·각\n // 스트림 인덱스를 보내는데, 이건 route handler 라서 layout 무효화에 딸려\n // 온다고 가정할 수 없다. tag + layout 이 기본이고 paths 는 그 위에 더한다\n // (테마 저장처럼 `paths: []` 면 아무 일도 안 일어난다).\n const paths = pathsFromBody(rawBody);\n // 하나가 실패해도 나머지를 **전부 시도한다.** 첫 실패에서 멈추면 남은 tag 와\n // layout 무효화까지 건너뛰어, 사이트가 옛 값을 붙든 채로 재시도만 기다린다.\n // 실패를 삼키지 않고 500 으로 알려 플랫폼이 재시도하게 한다.\n const failed: { target: string; reason: string }[] = [];\n const attempt = async (target: string, run: () => void | Promise<void>) => {\n try {\n await run();\n } catch (e) {\n const reason = e instanceof Error ? e.message : String(e);\n failed.push({ target, reason });\n console.error(\n `[@roottale/cms-renderer-next/routes] theme.updated 무효화 실패(${target}):`,\n reason,\n );\n }\n };\n if (config.revalidateTag) {\n const revalidateTag = config.revalidateTag;\n for (const tag of requestedTags) {\n await attempt(`tag:${tag}`, () => revalidateTag(tag));\n }\n }\n // 루트 레이아웃 무효화 = 그 아래 전 라우트(정적 포함) 재생성.\n await attempt(\"layout:/\", () => config.revalidate(\"/\", \"layout\"));\n for (const p of paths) {\n await attempt(`path:${p}`, () => config.revalidate(p));\n }\n return Response.json(\n {\n ok: failed.length === 0,\n event: result.event,\n deliveryId: result.deliveryId,\n revalidated: {\n // `tag` 는 테마 tag 만 담는 기존 필드(로그 호환).\n tag: config.revalidateTag ? themeTag : null,\n // 콜백에 **넘긴** tag 목록이다. 콜백이 실제로 무엇을 지웠는지는 이\n // 라우트가 알 수 없다(Next 가 결과를 돌려주지 않는다) — 실패한 것만\n // `failed` 로 구분된다. `revalidateTag` 미주입이면 빈 배열.\n requestedTags: config.revalidateTag ? requestedTags : [],\n layout: \"/\",\n paths,\n },\n ...(failed.length > 0 ? { failed } : {}),\n },\n { status: failed.length === 0 ? 200 : 500 },\n );\n }\n\n // 글 목록 의존 경로. 명시 alsoRevalidate 가 있으면 collections 를 resolve 하지 않는다\n // (불필요한 fetch/지연 방지 — codex#1). 없으면 요청 시 resolve 해 파생.\n // 항상 갱신하는 불변 경로 — feed/sitemap/llms 는 글 목록에 의존. 사이트맵은\n // 인덱스(/sitemap.xml) + 분리된 하위(static/blog/categories) 모두 무효화해야\n // 글·이미지 토글 변경이 반영된다(createSitemapIndex — codex P1).\n const INVARIANTS = [\n \"/feed.xml\",\n \"/llms.txt\",\n \"/sitemap.xml\",\n \"/sitemap/static.xml\",\n \"/sitemap/blog.xml\",\n \"/sitemap/categories.xml\",\n // 작가 사이트맵 — 글 발행/해제로 작가 등장·lastmod 가 바뀐다(authors 토글 off 면\n // 빈 사이트맵이라 무효화해도 무해).\n \"/sitemap/authors.xml\",\n ];\n let alsoRevalidate: string[];\n let warning: string | undefined;\n if (config.alsoRevalidate) {\n alsoRevalidate = config.alsoRevalidate;\n } else {\n const { collections, failed } = await resolveCollectionsResult(config.collections);\n if (failed) {\n // resolver 실패: 스트림을 추측하지 않는다(잘못된 /blog 무효화 방지 — codex#2).\n // 불변 경로 + 본문 paths 만 갱신하고, 플랫폼이 재시도/알림하도록 warning 노출.\n alsoRevalidate = INVARIANTS;\n warning = \"collections_resolve_failed\";\n } else if (isInCollectionMode(collections)) {\n alsoRevalidate = [\n ...INVARIANTS,\n // 주소 없는 유형(basePath === \"\")은 라우트가 없으므로 revalidate 대상에서\n // 제외 — 안 그러면 빈 경로(\"\")를 무효화 요청하게 된다.\n ...collections!\n .filter((c) => c.basePath)\n .flatMap((c) =>\n c.archives ? [c.basePath, `${c.basePath}/categories`] : [c.basePath],\n ),\n ];\n } else {\n alsoRevalidate = [...INVARIANTS, blogBase, `${blogBase}/categories`];\n }\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 ...(warning ? { warning } : {}),\n });\n };\n}\n","/** `routes` — 동적 OG 이미지 (글별 1200×630, 한글 폰트 subset). */\n\nimport { fetchPost } from \"@roottale/cms-client/server\";\n\nimport { type RootTaleSiteConfig, trimSlash } from \"./routes-shared.js\";\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/**\n * `next/og` 의 ImageResponse 와 구조 호환(직접 의존 회피 — revalidate 콜백 주입과\n * 동일 원칙). 생성자 파라미터는 반변이라 `unknown` 으로 두면 ReactElement 를\n * 요구하는 실제 ImageResponse 가 대입 불가(고객 연동 TS 에러) — `any` 필요.\n */\nexport interface ImageResponseLike {\n new (\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n element: any,\n options?: {\n width?: number;\n height?: number;\n fonts?: {\n name: string;\n data: ArrayBuffer;\n // satori Weight 리터럴 — 넓은 number 면 반변성 탓에 대입 불가.\n weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;\n style?: \"normal\";\n }[];\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","/**\n * 커스텀 리다이렉트 미들웨어 — 고객 Next 앱의 `middleware.ts` 에서 마운트.\n *\n * admin \"설정 > 주소 이동\" 에서 정의한 임의 경로 규칙(`/old → /new`)을 공개\n * API(`GET /v1/cms/public/redirects`)로 읽어 요청 경로에 매칭, 301/302 를\n * 발행한다. 슬러그 301(`postRedirectPath`)이 글 라우트에서 도는 것과 달리,\n * 임의 경로는 라우팅 *이전* 단계인 middleware 에서만 가로챌 수 있다.\n *\n * `next` 에 직접 의존하지 않는다(패키지 정책): 입력은 표준 `Request`(NextRequest\n * 가 상속), 출력은 표준 `Response`(NextResponse 가 상속)다.\n *\n * 사용 (Next App Router):\n * ```ts\n * // middleware.ts\n * import { NextResponse } from \"next/server\";\n * import { createRedirectMiddleware } from \"@roottale/cms-renderer-next/routes\";\n *\n * const redirects = createRedirectMiddleware({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * });\n *\n * export async function middleware(req: Request) {\n * return (await redirects(req)) ?? NextResponse.next();\n * }\n *\n * // Next 내부·정적 자산만 제외. `.*\\\\..*` 처럼 점 포함 경로를 전부 막으면\n * // `/old.html`·`/foo.php` 같은 레거시 마이그레이션 리다이렉트가 동작하지\n * // 않으므로, 알려진 자산 확장자만 제외한다.\n * export const config = {\n * matcher: [\n * \"/((?!_next/|api/|.*\\\\.(?:ico|png|jpg|jpeg|gif|svg|webp|css|js|txt|xml|json|woff2?|map)$).*)\",\n * ],\n * };\n * ```\n */\n\nimport { fetchRedirects } from \"@roottale/cms-client/server\";\nimport type { RootTaleRedirect } from \"@roottale/cms-client/server\";\n\nexport interface RedirectMiddlewareConfig {\n /** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만. */\n apiKey: string;\n /** API base 오버라이드. 기본 `https://api.roottale.com`. */\n apiBase?: string;\n /** site_id 명시(멀티사이트 키). 기본 = 키의 기본 사이트. */\n siteId?: string;\n /** 규칙 캐시 TTL(ms). 기본 60_000 (1분). */\n cacheTtlMs?: number;\n}\n\n/**\n * 경로 정규화 — 앞 슬래시 보장, 뒤 슬래시 제거(루트 제외), 쿼리/해시 제거,\n * percent-decode. 출발 경로 비교·한글 slug 매칭·루프 탐지에 요청·규칙 양쪽에\n * 동일 적용한다. 쿼리 제거는 `/a → /a?x` 류의 무한 루프 방지에 중요.\n */\nexport function normalizeRedirectPath(path: string): string {\n let p = path;\n try {\n p = decodeURIComponent(path);\n } catch {\n /* 잘못된 escape — 원본 유지 */\n }\n p = p.split(/[?#]/)[0] ?? p; // 쿼리·해시 제거\n if (!p.startsWith(\"/\")) p = `/${p}`;\n if (p.length > 1) p = p.replace(/\\/+$/, \"\");\n return p === \"\" ? \"/\" : p;\n}\n\nfunction isAbsoluteUrl(target: string): boolean {\n return /^https?:\\/\\//i.test(target);\n}\n\nfunction internalTargetPath(target: string, origin?: string): string | null {\n if (!isAbsoluteUrl(target)) return normalizeRedirectPath(target);\n if (!origin) return null;\n try {\n const url = new URL(target);\n return url.origin === origin ? normalizeRedirectPath(url.pathname) : null;\n } catch {\n return null;\n }\n}\n\n/**\n * 요청 경로에 맞는 규칙을 찾는다(순수 함수, fetch 비의존 — 테스트 용이).\n * 정확 경로 일치만(와일드카드 없음).\n *\n * 내부 경로 체인을 따라가 최종 도착지를 한 번에 반환한다(`/a→/b→/c` ⇒ `/c`):\n * 리다이렉트 체인을 줄여 SEO·왕복을 개선하고, 한 호출 안에서의 무한 루프를\n * 막는다. 도착지가 출발지로 정규화되면(쿼리만 다른 경우 포함) 자기 루프로 보고\n * 매칭하지 않는다. 절대 URL 도착지는 체인 종단(다른 origin 위임). 상태 코드는\n * 실제 요청 경로에 매칭된 첫 규칙을 따른다. null = 해당 없음.\n */\nexport function matchRedirect(\n redirects: readonly RootTaleRedirect[],\n pathname: string,\n origin?: string,\n): { toTarget: string; statusCode: 301 | 302 } | null {\n const byFrom = new Map<string, RootTaleRedirect>();\n for (const r of redirects) {\n const key = normalizeRedirectPath(r.fromPath);\n if (!byFrom.has(key)) byFrom.set(key, r);\n }\n\n const start = normalizeRedirectPath(pathname);\n const visited = new Set<string>([start]);\n let current = start;\n let finalTarget: string | null = null;\n let firstStatus: 301 | 302 | null = null;\n\n for (;;) {\n const rule = byFrom.get(current);\n if (!rule) break;\n if (firstStatus === null) firstStatus = rule.statusCode;\n\n const nextPath = internalTargetPath(rule.toTarget, origin);\n if (nextPath === null) {\n finalTarget = rule.toTarget; // 절대 URL = 체인 종단\n break;\n }\n if (nextPath === start) return null;\n finalTarget = rule.toTarget;\n // 사이클의 일부만 적용하면 다음 요청에서 반대쪽 규칙을 만나\n // 브라우저가 왕복한다. 저장소 검증의 마지막 방어선으로 전체를 통과시킨다.\n if (visited.has(nextPath)) return null;\n visited.add(nextPath);\n current = nextPath;\n }\n\n if (finalTarget === null || firstStatus === null) return null;\n return { toTarget: finalTarget, statusCode: firstStatus };\n}\n\ninterface CacheEntry {\n expires: number;\n value: RootTaleRedirect[];\n}\n\n/**\n * 미들웨어 팩토리. 규칙을 TTL 캐시하고(요청마다 API 호출 회피), fetch 실패는\n * swallow 해 트래픽을 절대 막지 않는다(stale 우선, 없으면 빈 목록 → 통과).\n *\n * 반환 함수: 매칭 시 redirect `Response`, 아니면 `null`(호출부가 통과 처리).\n */\nexport function createRedirectMiddleware(\n config: RedirectMiddlewareConfig,\n): (request: Request) => Promise<Response | null> {\n const ttl = config.cacheTtlMs ?? 60_000;\n let cache: CacheEntry | null = null;\n\n async function loadRedirects(now: number): Promise<RootTaleRedirect[]> {\n if (cache && cache.expires > now) return cache.value;\n try {\n const value = await fetchRedirects({\n apiKey: config.apiKey,\n baseUrl: config.apiBase,\n siteId: config.siteId,\n });\n cache = { expires: now + ttl, value };\n return value;\n } catch (err) {\n console.error(\n \"[@roottale/cms-renderer-next] fetchRedirects 실패 — 통과(fail-soft):\",\n err,\n );\n // stale 값이 있으면 그것으로, 없으면 빈 목록. 짧게 재시도 억제.\n if (cache) {\n cache = { expires: now + Math.min(ttl, 10_000), value: cache.value };\n return cache.value;\n }\n return [];\n }\n }\n\n return async function redirectMiddleware(\n request: Request,\n ): Promise<Response | null> {\n let pathname: string;\n let origin: string;\n try {\n const u = new URL(request.url);\n pathname = u.pathname;\n origin = u.origin;\n } catch {\n return null;\n }\n\n // 캐시 키가 stable 한 시각 — Date.now 는 미들웨어 런타임에서 허용.\n const now = Date.now();\n const redirects = await loadRedirects(now);\n const hit = matchRedirect(redirects, pathname, origin);\n if (!hit) return null;\n\n // 내부 경로는 요청 origin 기준 절대화, 절대 URL 은 그대로.\n const dest = new URL(hit.toTarget, origin);\n // 같은 origin·같은 경로로의 redirect 는 무한 루프 — 통과시킨다. matchRedirect\n // 의 순수 루프 가드가 못 잡는 절대 URL(같은 origin) 자기참조까지 여기서 차단.\n if (\n dest.origin === origin &&\n normalizeRedirectPath(dest.pathname) === normalizeRedirectPath(pathname)\n ) {\n return null;\n }\n return Response.redirect(dest.toString(), hit.statusCode);\n };\n}\n"],"mappings":"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","names":["isInCollectionMode","resolvePostCollection","resolvePostUrl","fetchPosts","resolvePostCollection","resolvePostUrl","fetchPosts","resolvePostUrl","resolvePostCollection","isInCollectionMode","resolvePostCollection","resolvePostUrl","isInCollectionMode","resolvePostUrl","isInCollectionMode","resolvePostUrl","isInCollectionMode","paths","isInCollectionMode"]}
|