콘텐츠 유형 (Collections)
하나의 글 목록을 **여러 섹션(stream)**으로 나눠 서로 다른 URL·레이아웃으로 보여주는
기능입니다. 가장 흔한 형태는 공지 게시판(/notice) + 블로그(/blog) 분리입니다.
핵심 모델 (2024 ADR-0060 Amendment 1): 글은 하나의 섹션에 속하고(
collection_key), 그 안에서 여러 주제(category)를 가질 수 있습니다. 섹션은 글쓰기 화면의 **"어디에 올릴까요?"**에서 고르고, 주제는 섹션 안의 세부 분류(예: 블로그의 칼럼·소식)입니다.
URL이 어떻게 정해지나
글의 URL = 그 글이 속한 섹션의
basePath+/{slug}— 섹션은 글의collection_key로 정해집니다.
글의 섹션 (collection_key) | basePath | URL |
|---|---|---|
notice (공지) | /notice | /notice/{slug} |
blog (블로그) | /blog | /blog/{slug} |
예: 섹션이 notice인 글 "개강안내" → https://내사이트/notice/개강안내
섹션이 blog인 글 "비문학공부법" → https://내사이트/blog/비문학공부법
섹션별로 함께 만들어지는 경로:
| 경로 | 설명 |
|---|---|
{basePath} | 섹션 목록 (예: /notice, /blog) |
{basePath}/{slug} | 글 상세 (detail 켠 섹션만 — 아래 "목록 전용 유형" 참고) |
{basePath}/categories/{slug} | 주제 아카이브 (archives 켠 섹션만) |
/feed.xml | RSS — feed 켠 섹션들의 통합 피드 (단 detail:false 섹션 글은 제외) |
/sitemap.xml | 글마다 소속 섹션 basePath로 정확히 매핑 (detail:false 섹션 글은 미포함) |
{basePath}/{slug}/opengraph-image | 글별 동적 OG 카드 (배선 시) |
규칙:
- slug은 한글 그대로 됩니다(예:
/blog/비문학독해). 내부적으로 percent-encoding. - 같은 글이 두 섹션에 안 뜸: 공지 글을
/blog/개강안내로 열면 404, 반대도 404(가드). - 섹션(
collection_key)이 없는 글은 sitemap·상세 라우트에서 제외됩니다.
카테고리를 주소에 넣는 유형
칼럼처럼 카테고리가 정보 구조의 일부라면 유형별로 아래 정책을 켤 수 있습니다.
const COLUMN: RouteCollection = {
key: "column",
basePath: "/column",
categories: ["headache", "dizziness"],
archives: true,
detailPath: "category",
categoryPath: "direct",
categoryCardinality: "exactly-one",
};
이 설정은 카테고리 허브를 /column/{categorySlug}, 글 상세를
/column/{categorySlug}/{postSlug}로 만듭니다. categoryPath를 생략하면 기존
형식인 /column/categories/{categorySlug}를 유지합니다. detailPath를 생략하면
글 상세도 기존의 /column/{postSlug}를 유지합니다.
detailPath: "category"는 archives: true와
categoryCardinality: "exactly-one"이 반드시 필요합니다. ROOT-ADMIN은 공개·예약
글의 카테고리가 정확히 하나인지 서버에서 검증하며, 기존 공개·예약 글에 누락이 있으면
이 정책 자체를 저장하지 않습니다. 이미 주소에 쓰이는 카테고리의 slug·소속·삭제도
URL 이전 계획 없이 바로 바꿀 수 없습니다.
주소 없는 유형 (base_path: "") — 페이지 안에서만 쓰는 콘텐츠
강사·리뷰처럼 독립된 URL이 없고 홈페이지의 다른 페이지 안에서 불러와 쓰는
콘텐츠는 섹션의 주소 경로를 비워두면 됩니다. base_path가 빈 문자열이면:
resolvePostUrl/resolvePostPath(cms-core) 가 이 섹션 글에 대해 항상null을 반환 —detail: false(목록 전용) 여부와 무관합니다.- 라우트·sitemap·feed·
/{basePath}/categories/{slug}아카이브를 일절 파생하지 않습니다.createSitemapIndex/createFeedRoute등 route 팩토리에 넘겨도 이 섹션의 글은 조용히 빠집니다(에러 아님). - 소속 판정(
resolvePostCollection)은 영향 없습니다 — 글은 여전히 이 섹션에 속하고, 글쓰기 화면의 "어디에 올릴까요?" 에도 그대로 나타납니다. 주소와 소속은 별개입니다. - 외부 사이트는 이 유형의 내용을 posts API의
collectionKey필터 (fetchPosts({ collectionKey: "instructors" }))로 가져와 원하는 페이지 안에서 직접 렌더합니다.RootTaleBlogList를 이 유형에 임베디드로 쓸 때는 자체postHref를 제공하세요 — 컴포넌트의 기본 링크 로직은 이 유형의 글에 목록 페이지 폴백조차 만들지 않습니다(목록 페이지 자체가 없으므로).
export const COLLECTIONS: RouteCollection[] = [
{ key: "blog", basePath: "/blog", categories: ["column", "news"], feed: true, archives: true },
// 주소 없음 — 강사 소개는 /about 페이지 안에서 fetchPosts 로 불러와 렌더.
{ key: "instructors", basePath: "", categories: [] },
];
어드민 "콘텐츠 유형" 편집기에서는 주소 경로 칸을 비워두면 됩니다 — 비우면 자동으로 RSS 피드·카테고리 모음·공유 미리보기 이미지·글별 상세 페이지 토글이 꺼지고 비활성화됩니다(주소가 없으니 의미가 없는 기능들). 카드에는 "주소 없음" 표시가 붙습니다.
목록 전용 유형 (detail: false) — 상세 페이지 없음
글별 상세 라우트를 만들지 않고 목록 화면에서만 보여주는 섹션을 만들 수 있습니다.
가장 흔한 예는 후기(/reviews) — 목록에 전문을 그대로 노출하고, 글마다 별도 URL로
들어가는 상세 페이지는 없는 경우입니다.
어드민 "콘텐츠 유형" 편집기에서 각 유형의 "글별 상세 페이지" 토글을 끄면 됩니다 (기본은 켜짐 — 기존 유형은 그대로 상세 페이지를 가집니다). 끄면:
resolvePostUrl/resolvePostPath(cms-core) 가 이 섹션 글에 대해null을 반환 — 글쓰기 화면의 "어디에 올릴까요?" 는 그대로 노출되지만(소속 판정과 상세 URL 존재 여부는 별개), sitemap·feed·llms.txt 는 이 글의 상세 URL 을 발행하지 않습니다(존재하지 않는 페이지로 404 유발 방지).RootTaleBlogList/관련 글 카드의 링크는 상세 URL 대신 그 섹션의 목록 페이지 (basePath) 로 향합니다 — 카드가 이미 목록에서 전문을 보여주므로 상세 이동 없이도 의미가 있습니다.- 사이트 코드 변경은 필요 없습니다 — 이 값은 route 팩토리(
createSitemapIndex/createFeedRoute/createLlmsTxtRoute)가 넘겨받는collections배열의 값일 뿐입니다.
export const COLLECTIONS: RouteCollection[] = [
{ key: "notice", basePath: "/notice", categories: [] },
{ key: "blog", basePath: "/blog", categories: ["column", "news"], feed: true, archives: true },
// 목록 전용 — 후기는 /reviews 목록에서 전문 노출, 글별 상세 URL 없음.
{ key: "reviews", basePath: "/reviews", categories: [], detail: false },
];
필드명은
detail(camelCase, cms-coreRouteCollection), 공개 API 응답은detail(snake_case 와 동일 철자). 미설정 시 기본값은true(하위 호환 — 기존 사이트는 아무 것도 안 바꿔도 그대로 상세 페이지를 가집니다).
섹션 vs 주제 (자주 헷갈리는 부분)
| 섹션 (collection) | 주제 (category) | |
|---|---|---|
| 무엇 | 글이 사는 곳 (공지 / 블로그) | 섹션 안의 세부 분류 (칼럼 / 소식) |
| 글당 | 딱 하나 (배타적) | 기본 0개 이상, 유형 정책으로 정확히 1개 가능 |
| 정하는 곳 | 글쓰기 "어디에 올릴까요?" | 글 > 분류 > 카테고리 / 글쓰기 주제 칩 |
| 저장 | post.collection_key | 글의 category terms |
| 라우팅 | basePath 결정 (/notice) | 아카이브만 (/blog/categories/칼럼) |
이전 버전은 카테고리로 섹션을 추론했지만, 지금은 섹션이 글에 명시됩니다. 카테고리는 글의 섹션을 결정하지 않습니다. 카테고리 자체에는 사용할 섹션을 연결할 수 있고, 글에서는 그 섹션에 연결된 카테고리만 주제로 고릅니다.
어드민에서 설정 (admin.roottale.com)
설정 > 콘텐츠 유형 에서 섹션을 정의합니다. 빈 상태의 "공지 + 블로그 한 번에 만들기" 버튼으로 표준 두 섹션을 한 번에 만들 수 있습니다. 각 섹션은:
| 항목 | 의미 |
|---|---|
| key | 안정 식별자 (notice, blog) — 사이트 코드와 맞물리므로 보통 고정 |
| 라벨 | 메뉴·작성 화면 표시 이름 (공지/블로그) |
| basePath | URL 앞부분 (/notice, /blog). 비워두면 주소 없는 유형 — 아래 "주소 없는 유형" 참고 |
| feed / archives / og | RSS 포함 / 주제 아카이브 / 동적 OG |
글별 상세 페이지 (detail) | 기본 켜짐. 끄면 목록 전용(상세 URL 없음) — 아래 "목록 전용 유형" 참고 |
상세 주소 (detail_path) | flat(기본) 또는 category — 카테고리를 상세 주소에 포함 |
카테고리 주소 (category_path) | namespaced(기본 /categories/{slug}) 또는 direct(/{slug}) |
카테고리 개수 (category_cardinality) | multiple(기본) 또는 exactly-one |
| 순서 | 메뉴·표시 순서 |
섹션을 하나도 안 만들면 단일 블로그(/blog) 로 동작합니다(설정 전 기본값).
카테고리 소속은 글 > 분류 > 카테고리에서 정합니다. 카테고리를 만들거나
편집할 때 콘텐츠 유형을 하나 고르면, 공개 collections[].categories에도 그
유형의 주제로 나타납니다. 콘텐츠 유형 설정과 카테고리 소속을 서로 다른 화면에서
동시에 바꿔도 JSON 배열을 덮어쓰지 않도록 별도 데이터로 저장됩니다.
⚠️ basePath는 "라우트가 있어야" 동작합니다 (데이터=DB, 라우트=코드)
basePath·라벨·플래그는 어드민에서 바꾸면 sitemap·feed·라우팅이 즉시 따라갑니다. 단 basePath에 해당하는 페이지 파일이 사이트에 있어야 실제로 열립니다:
/notice·/blog처럼 이미 라우트가 있는 경로는 어드민만으로 자유롭게 편집 → 동작.- basePath를 완전히 새 경로(예:
/news)로 바꾸면 sitemap엔/news/{slug}가 나가지만 사이트에app/news/[slug]라우트가 없으면 404. 개발자가 라우트를 먼저 추가해야 합니다. (새 basePath도 코드 수정 없이 동작시키려면 catch-all 동적 라우트 — 아래.)
글을 섹션에 올리기
글쓰기 화면 맨 위 **"어디에 올릴까요?"**에서 공지/블로그 카드를 고릅니다 — 이게 글의
섹션(collection_key)이 됩니다. 그 아래 주제는 고른 섹션의 카테고리만 보입니다
(블로그면 칼럼·소식 등). 주제는 선택이며, 글 > 분류 > 카테고리에서 미리 만들어
콘텐츠 유형에 연결합니다.
콘텐츠 유형별 커스텀 필드 (ACF 방식)
글 > 필드 그룹에서 콘텐츠 유형마다 별도 입력칸을 만들 수 있습니다. 새 필드
그룹의 적용 기준을 콘텐츠 유형으로 고른 뒤 doctors, reviews 같은 유형을
선택하면, 그 유형의 글 편집 화면에만 해당 입력칸이 나타납니다. 카테고리를 임시로
붙일 필요가 없습니다.
필드 값은 글의 meta_json.acf에 저장되며 공개 글 API에서는 정의에 맞게 변환된
fields와 표시용 fields_meta로 제공됩니다. 이미지·관계 필드는 raw ID가 아니라
공개 URL과 안전한 참조 객체로 해석됩니다.
const doctors = await fetchPosts({
apiKey: process.env.ROOTTALE_API_KEY!,
collectionKey: "doctors",
});
for (const doctor of doctors.items) {
// 예: { name, specialty, photo, education, schedule, ... }
console.log(doctor.fields);
}
ROOT-ADMIN의 코드 정의 필드 그룹도 같은 규칙을 씁니다. 의료 vertical의 기본
의료진 정보 그룹은 doctors 콘텐츠 유형에 연결되며 이름·전문분야·사진·대체
텍스트·학력/경력 반복 목록·진료 시간표·담당 치료·검수 책임자를 제공합니다.
사이트 연동 코드
섹션 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지 방식이 있습니다.
방식 A — 코드 상수 (간단, 고정)
import type { RouteCollection } from "@roottale/cms-renderer-next/routes";
export const COLLECTIONS: RouteCollection[] = [
{ key: "notice", basePath: "/notice", categories: [] }, // 주제 없는 게시판
{
key: "blog",
basePath: "/blog",
categories: ["column", "news"], // 이 섹션의 주제(아카이브 범위)
feed: true,
archives: true,
},
];
categories는 이제 그 섹션이 제공하는 주제 목록(아카이브/blog/categories/{slug}생성 범위)입니다. 섹션 소속은 글의collection_key로 정해지므로, 이 배열은 라우팅 소유권이 아닙니다.
// app/sitemap.ts
import { createSitemapIndex } from "@roottale/cms-renderer-next/routes";
const { generateSitemaps, sitemap } = createSitemapIndex(
{ apiKey, siteUrl, title, collections: COLLECTIONS },
[ /* 정적 경로 */ ],
);
export { generateSitemaps };
export default sitemap;
// app/feed.xml/route.ts
export const dynamic = "force-dynamic";
export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: COLLECTIONS });
방식 B — 어드민에서 동적 로드 (운영자가 직접 편집)
상수 대신 어드민 "콘텐츠 유형" 값을 fetchCollections()로 가져와 팩토리에 resolver
함수로 넘깁니다. 운영자가 어드민에서 섹션을 바꾸면 사이트가 따라갑니다.
import {
COLLECTIONS_CACHE_TAG,
fetchCollections,
} from "@roottale/cms-client/server";
const DEFAULT: RouteCollection[] = [ /* 위와 동일 — fail-soft 기본값 */ ];
async function getCollections(): Promise<RouteCollection[]> {
try {
const c = await fetchCollections({
apiKey: process.env.ROOTTALE_API_KEY!,
tags: [COLLECTIONS_CACHE_TAG], // 어드민 저장 시 즉시 반영 (아래 revalidate 절)
});
return c.length ? c : DEFAULT;
} catch {
return DEFAULT; // API 미설정/실패 시 기본값
}
}
const { generateSitemaps, sitemap } = createSitemapIndex(
{ apiKey, siteUrl, title, collections: getCollections },
[ ],
);
export { generateSitemaps };
export default sitemap;
export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: getCollections });
공개 엔드포인트: GET /v1/cms/public/collections (블로그 조회와 같은 API 키).
응답은 RouteCollection과 구조 호환이라 그대로 넘길 수 있습니다. 매 요청 fetch를 피하려면
사이트 경계에서 캐시하세요(예: Next fetch(url, { next: { revalidate: 300 } })).
categories는 콘텐츠 유형 설정 JSON이 아니라 카테고리의 현재 소속을 기준으로
서버가 조립하므로, 카테고리 이름 변경·삭제·이동이 즉시 반영됩니다. 빈 배열은
catch-all이 아니라 그 유형에 연결된 카테고리가 없음을 뜻합니다.
섹션 목록 페이지 (공지/블로그 분리 렌더)
각 섹션 목록은 RootTaleBlogList 에 collection+collections 를 넘겨 그 섹션
글만 보이게 합니다. 이걸 안 주면 컴포넌트가 전체 글을 렌더하므로 공지 글이
/blog 목록에 섞여 들어옵니다(섹션을 나눈 사이트의 가장 흔한 버그). collections
를 주면 글 링크도 소속 섹션 basePath 로 자동 라우팅됩니다(공지→/notice/{slug}).
서버(api-core collection_key=)가 fetch 단계에서부터 그 섹션 글만 가져오므로
limit(예: limit=10)이 다른 섹션 글에 낭비되지 않습니다.
import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";
import { COLLECTIONS } from "@/lib/collections"; // 위 "방식 A" 의 상수
// app/notice/page.tsx — 공지 게시판
export default function NoticePage() {
return (
<RootTaleBlogList
apiKey={process.env.ROOTTALE_API_KEY!}
collection="notice"
collections={COLLECTIONS}
/>
);
}
// app/blog/page.tsx — 블로그
export default function BlogPage() {
return (
<RootTaleBlogList
apiKey={process.env.ROOTTALE_API_KEY!}
collection="blog"
collections={COLLECTIONS}
showCategoryFilter
/>
);
}
RootTaleBlogCategories 도 같은 collection+collections 를 받아 그 섹션의 주제만
집계합니다. 카테고리 칩/사이드바를 섹션별로 나눌 때 쓰세요.
/blog 가 선언 섹션이 아닌 사이트 — excludeCollections (네거티브 스코프)
증상/질환/치료처럼 아카이브 전용 스트림만 선언하고, 나머지("미소속") 글을
/blog 로 보여주는 사이트도 있습니다. 이런 사이트는 blog 라는 섹션 자체가
없으므로 collection="blog" 로 스코프할 수 없습니다 — 대신 collections+
excludeCollections 로 "어느 스트림에도 안 속한 글만" 골라냅니다.
// app/blog/page.tsx — 증상/질환/치료 스트림은 따로, 나머지는 블로그
import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";
import { ARCHIVE_COLLECTIONS } from "@/lib/collections"; // symptoms/conditions/treatments
export default function BlogPage() {
return (
<RootTaleBlogList
apiKey={process.env.ROOTTALE_API_KEY!}
collections={ARCHIVE_COLLECTIONS}
excludeCollections
showCategoryFilter
/>
);
}
⚠️ blog 자체가 선언 섹션인 사이트에는 쓰지 마세요. collections 목록에
{ key: "blog", ... } 가 있고 글의 collectionKey === "blog" 라면, 그 글은
"소속 글"로 판정되어 excludeCollections 가 오히려 전부 제외해 버립니다(빈
목록). 그런 사이트는 위 "섹션 목록 페이지" 예시처럼 포지티브 collection="blog"
가 정경로입니다.
규칙:
collection과excludeCollections동시 지정 = throw(포지티브/네거티브 배타).collections없이excludeCollections만 지정 = throw(조용한 no-op 금지).
동적 basePath(어드민에서 자유 편집)나 catch-all 라우트를 쓰면
collection값을 요청 경로에서 판정해 넘기세요(아래 "동적 basePath" 참고).
상세 페이지 가드 (섹션 누출 차단)
상세 라우트는 글이 그 섹션 소속인지 확인해 다른 섹션 글이 새는 것을 막습니다.
resolvePostCollection은 글의 collectionKey로 섹션을 해석합니다(공개 API가 글마다
collection_key를 내려줍니다 → cms-client의 CmsPostContent.collectionKey).
import { resolvePostCollection } from "@roottale/cms-renderer-next/routes";
// app/blog/[slug]/page.tsx
const post = await getPost(slug);
if (!post || resolvePostCollection(post, COLLECTIONS)?.key !== "blog") notFound();
상세 페이지 메타데이터 (canonical 공지/블로그 구분)
상세 라우트의 generateMetadata 도 collections 를 넘겨야 canonical 이 글의
섹션에 맞게 나옵니다. path 를 /blog/... 로 하드코딩하면 공지 글이 잘못된
블로그 canonical 을 갖게 됩니다.
import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
// app/blog/[slug]/page.tsx (공지면 app/notice/[slug])
return buildPostMetadata(post, {
siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
collections: COLLECTIONS, // 글의 collectionKey 로 /notice·/blog canonical 자동 해석
});
원본 post 를 넘기면 post.path(플랫폼이 저장한 정규 공개 경로, ADR-0105)가 먼저
canonical 이 되고 collections 는 저장 경로가 없는 글의 폴백입니다.
RootTaleBlogList·RootTaleBlogPost(연관 글·빵부스러기)의 글 링크도 같은 순서 —
post.path → collections 규칙 → /blog/{slug} — 로 정해집니다.
자세한 동작은 seo.md 의 "글 메타데이터 → 공지·블로그 다중 스트림" 참고.
revalidate
import { revalidatePath, revalidateTag } from "next/cache";
import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
export const POST = createRevalidateRoute({
apiKey,
// 2번째 인자(type)를 반드시 그대로 넘긴다 — 안 넘기면 레이아웃 전역 무효화가
// 페이지 1개 무효화로 줄어든다.
revalidate: (path, type) => revalidatePath(path, type),
collections: COLLECTIONS,
// 어드민에서 콘텐츠 유형·메뉴·사업장 정보 등 **설정**을 저장했을 때 그 값을
// 담은 fetch 캐시를 지운다. `{ expire: 0 }` 이어야 즉시 만료다
// (revalidation-webhooks.md §1 "설정 저장").
revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
themeTag: THEME_CACHE_TAG,
});
각 섹션 basePath(+ archives면 /categories)와 /feed.xml·/sitemap.xml·/llms.txt를
자동 무효화합니다. resolver가 실패하면 잘못된 경로를 추측하지 않고 불변 경로만 갱신하며
응답에 warning을 노출합니다.
fetchCollections로 유형을 동적 로드한다면 그 조회에도
tags: [COLLECTIONS_CACHE_TAG]를 붙이세요 — 어드민에서 유형을 추가·수정했을 때
사이트가 옛 목록을 붙들고 있지 않게 됩니다.
동적 OG 이미지
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import {
createPostOgImage, OG_IMAGE_SIZE, OG_IMAGE_CONTENT_TYPE,
type ImageResponseLike,
} from "@roottale/cms-renderer-next/routes";
export const size = OG_IMAGE_SIZE;
export const contentType = OG_IMAGE_CONTENT_TYPE;
export default createPostOgImage(
{ apiKey, siteUrl, title, brandLabel: "내 사이트" },
{ ImageResponse: ImageResponse as unknown as ImageResponseLike }, // Next 16 타입 cast
);
slug 변경과 301
글의 slug(/{slug} 부분)는 글 편집 화면에서 바꿉니다. 바꾼 뒤 옛 slug로 들어오면 postRedirectPath로
301 리다이렉트되어 새 slug로 넘어갑니다(검색 순위 보존).
동적 basePath (advanced)
basePath를 코드 수정 없이 어드민에서 자유롭게 바꾸고 싶으면, 정적 app/notice/[slug]
대신 catch-all 동적 라우트 app/[stream]/[slug]/page.tsx(또는 app/[...path])를 두고,
그 안에서 collections를 읽어 요청 경로가 어떤 섹션 basePath인지 판정해 렌더합니다. 그러면
어드민에서 basePath를 /news로 바꿔도 동작합니다. 트레이드오프: 정적 라우트보다 캐시·타입
안전성이 떨어지므로, 섹션 구조가 자주 바뀌는 사이트에만 권장합니다.
자주 막히는 곳
- 글이 안 보여요 → 글에 **섹션(
collection_key)**이 지정됐는지 확인. 글쓰기 "어디에 올릴까요?"에서 섹션을 골라야 합니다. (섹션 없는 글은 sitemap·상세에서 제외.) - 404가 떠요 → 공지 글을
/blog/...로(또는 그 반대로) 열면 가드가 막습니다. 올바른 섹션 basePath로 접근하세요. basePath를 바꿨다면 사이트에 그 라우트 파일이 있는지 확인. - 주제 아카이브가 비어요 → 그 주제(카테고리)가 섹션의
categories(주제 목록)에 있고archives가 켜져 있는지 확인. - sitemap에 글이 빠졌어요 → 그 글에 섹션이 없거나, 소속 섹션의
base_path가 빈 값(주소 없는 유형)이면 의도적으로 제외됩니다. RootTaleBlogList카드를 눌렀는데 이상한 곳으로 가요 → 그 글의 섹션이 주소 없는 유형이면 목록 페이지가 없어 기본 링크 로직이 레거시/blog/{slug}로 폴백합니다. 이 유형은RootTaleBlogList로 임베디드 렌더하지 말고fetchPosts({ collectionKey })로 직접 페이지에 꽂아 쓰세요.