본문 바로가기

콘텐츠 유형 (Collections)

하나의 글 목록을 **여러 섹션(stream)**으로 나눠 서로 다른 URL·레이아웃으로 보여주는 기능입니다. 가장 흔한 형태는 공지 게시판(/notice) + 블로그(/blog) 분리입니다.

핵심 모델 (2024 ADR-0060 Amendment 1): 글은 하나의 섹션에 속하고(collection_key), 그 안에서 여러 주제(category)를 가질 수 있습니다. 섹션은 글쓰기 화면의 **"어디에 올릴까요?"**에서 고르고, 주제는 섹션 안의 세부 분류(예: 블로그의 칼럼·소식)입니다.

URL이 어떻게 정해지나

글의 URL = 그 글이 속한 섹션의 basePath + /{slug} — 섹션은 글의 collection_key로 정해집니다.

글의 섹션 (collection_key)basePathURL
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.xmlRSS — 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-core RouteCollection), 공개 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) — 사이트 코드와 맞물리므로 보통 고정
라벨메뉴·작성 화면 표시 이름 (공지/블로그)
basePathURL 앞부분 (/notice, /blog). 비워두면 주소 없는 유형 — 아래 "주소 없는 유형" 참고
feed / archives / ogRSS 포함 / 주제 아카이브 / 동적 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 }) 로 직접 페이지에 꽂아 쓰세요.