@roottale/cms-mcp 0.35.0 → 0.37.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 CHANGED
@@ -1,5 +1,49 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.37.0
4
+
5
+ ### Minor Changes
6
+
7
+ - de62ad1: 작가 아카이브 페이지 + 작가 사이트맵 (blanche식 /blog/author/{slug})
8
+ - `fetchAuthors()` 추가 (`GET /v1/cms/public/authors`) — slug가 있고 발행 글이 1건
9
+ 이상인 작가 목록(slug·이름·이미지·소개·글 수·최근 수정일). 어드민 **설정 > 팀**에서
10
+ 작가 slug를 발급한다(site 내 unique).
11
+ - `RootTaleBlogList` 에 `author` prop, `fetchPosts` 에 `author` 옵션 추가 —
12
+ `GET /v1/cms/public/posts?author={slug}` 로 그 작가의 발행 글만 렌더(작가 아카이브용).
13
+ - `createSitemapIndex` 에 `authors` 하위 사이트맵(`/sitemap/authors.xml`) 추가 — 어드민
14
+ **설정 > 블로그 > 사이트맵**의 "작가 사이트맵"(`authors`) 토글이 켜졌을 때만 인덱스에
15
+ 포함. `authorBasePath` 옵션(기본 `/blog/author`)으로 경로 커스터마이즈.
16
+ - starter 에 `app/blog/author/[slug]/page.tsx` 추가 + cms-mcp seo.md 문서 갱신.
17
+
18
+ - c77238d: 사이트맵 다국어 hreflang (스캐폴딩)
19
+ - `createSitemapIndex` 가 사이트맵 설정의 `locales`(예 `["ko","ja","en"]`)가 있으면
20
+ 모든 섹션(static/blog/categories/authors) entry 에 `alternates.languages`(hreflang)
21
+ 를 단다. 첫 로케일 = 기본(현 URL) + `x-default`, 나머지는 path-prefix(`/ja/...`).
22
+ 어드민 **설정 > 블로그 > 사이트맵** 의 "다국어 로케일"에서 입력.
23
+ - 같은 slug + 언어 접두사 1:1 을 가정하는 스캐폴딩 — 실제 per-locale 페이지 서빙은
24
+ 사이트 책임(번역 콘텐츠 모델 도입 시 재방문). 비우면 단일 언어(기본).
25
+ - 어드민 사이트맵 카드에 "작가 사이트맵"(authors) 토글도 노출 — 작가 사이트맵을
26
+ UI 에서 켤 수 있다.
27
+
28
+ - 36e2dae: 사이트맵 인덱스 분리 + 블로그 이미지 색인 (blanche식)
29
+ - `createSitemapIndex(config, staticRoutes, opts)` 추가 — Next 16 `generateSitemaps`
30
+ 로 `/sitemap.xml`(인덱스) + `/sitemap/static.xml`·`/sitemap/blog.xml`·
31
+ `/sitemap/categories.xml` 하위 사이트맵으로 분리. 50k URL/50MB 한도 확장 + 검색엔진
32
+ 섹션별 크롤. 소비자는 `export { generateSitemaps }; export default sitemap;`.
33
+ - 블로그 글 entry 에 대표 이미지(`featuredImageUrl`)를 `<image:image>` 로 색인 —
34
+ 어드민 **설정 > 블로그 > 사이트맵**의 토글(`blogImages`)로 제어. `RootTaleBlogSettings.sitemap`
35
+ (`/v1/cms/public/blog-settings` 응답)에 사이트맵 정책(`blogImages`/`locales`/`authors`) 추가.
36
+ - `changeFrequency`/`priority` 는 더 이상 emit 하지 않음(Google 무시 — `loc`+`lastmod` +이미지만). 레거시 `createSitemap`(단일 평면)은 deprecate 되었으나 하위호환 유지.
37
+
38
+ ## 0.36.0
39
+
40
+ ### Patch Changes
41
+
42
+ - 1fe867f: 문서 갱신 — 블로그 설정 응답의 글 하단 CTA(`post_cta`)와 사이트 SEO 기본값
43
+ (`site_profile`: 설명·로고·파비콘·기본 OG)을 api-reference / blog 가이드에 반영.
44
+ CTA 는 `RootTaleBlogPost` 가 본문 끝에 자동 렌더하고, `site_profile` 은 외부
45
+ 사이트가 head + OG 폴백으로 사용한다.
46
+
3
47
  ## 0.35.0
4
48
 
5
49
  ### Minor Changes
package/dist/index.js CHANGED
@@ -279,7 +279,7 @@ function registerTools(server2) {
279
279
  }
280
280
 
281
281
  // src/server.ts
282
- var VERSION = true ? "0.35.0" : "dev";
282
+ var VERSION = true ? "0.37.0" : "dev";
283
283
  var SERVER_INSTRUCTIONS = `
284
284
  roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uAE30 \uC704\uD55C
285
285
  \uD1B5\uD569 \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7\uACF5\uAC1C API \uC870\uD68C tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
@@ -113,16 +113,29 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
113
113
 
114
114
  ## GET /v1/cms/public/blog-settings
115
115
 
116
- 블로그 표시 설정 (TOC·작성자·발행일·작성자 카드, 저자 프로필).
116
+ 블로그 표시 설정 (TOC·작성자·발행일·작성자 카드, 저자 프로필, 글 하단 CTA).
117
+ `post_cta` 는 admin 에서 활성화하고 버튼 문구·링크를 채웠을 때만 객체이며,
118
+ 그 외에는 `null`. `RootTaleBlogPost` 가 본문 끝에 자동으로 렌더하므로 별도
119
+ 연동 코드는 필요 없다.
117
120
 
118
121
  ```json
119
122
  { "show_table_of_contents": false, "show_author": true, "show_date": true,
120
123
  "show_author_card": true, "toc_title": null,
121
124
  "author_profile_name": null, "author_profile_bio": null,
122
125
  "author_profile_image_url": null, "author_profile_image_radius": "circle",
126
+ "site_profile": { "site_description": null, "logo_url": null,
127
+ "favicon_url": null, "default_og_image_url": null },
128
+ "post_cta": { "title": "상담이 필요하신가요?", "description": "첫 상담은 무료입니다.",
129
+ "button_label": "상담 문의하기", "button_href": "/contact" },
123
130
  "updated_at": null }
124
131
  ```
125
132
 
133
+ `site_profile` 은 사이트 공통 SEO 값. `default_og_image_url`(1200×630 권장)은
134
+ 글에 대표/OG 이미지가 없을 때 SNS 공유 썸네일 폴백으로 쓰세요 —
135
+ `generateMetadata` 에서 `post.seo?.ogImage ?? post.featured_media_url ??
136
+ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
137
+ `favicon_url`·`site_description` 은 사이트 `<head>` 에 적용합니다.
138
+
126
139
  ## GET /v1/cms/public/site-knowledge
127
140
 
128
141
  사이트 지식 — 브랜드 보이스(어조·톤·화자) + 용어 규칙(금지어·교정어). AI
package/docs/blog.md CHANGED
@@ -38,6 +38,22 @@ export default function BlogPage() {
38
38
  }
39
39
  ```
40
40
 
41
+ > **공지·블로그를 나눈 사이트(ADR-0060)는 목록을 반드시 섹션으로 스코프하세요.**
42
+ > `RootTaleBlogList` 는 기본적으로 **모든 글**을 렌더하므로, 섹션을 나눴는데
43
+ > `collection` 을 안 주면 공지 글이 `/blog` 목록에 섞여 나옵니다. 각 목록 페이지에서
44
+ > `collection`+`collections` 를 넘겨 그 섹션 글만 보이게 하세요(글 링크도 소속 섹션
45
+ > basePath 로 자동 라우팅). 단일 블로그 사이트는 지금처럼 안 줘도 됩니다.
46
+ >
47
+ > ```tsx
48
+ > import { COLLECTIONS } from "@/lib/collections"; // collections.md 참고
49
+ > // app/notice/page.tsx
50
+ > <RootTaleBlogList apiKey={apiKey} collection="notice" collections={COLLECTIONS} />
51
+ > // app/blog/page.tsx
52
+ > <RootTaleBlogList apiKey={apiKey} collection="blog" collections={COLLECTIONS} showCategoryFilter />
53
+ > ```
54
+ >
55
+ > 자세한 내용은 `collections.md` 참고.
56
+
41
57
  ### 상세 페이지
42
58
 
43
59
  ```tsx
@@ -65,7 +81,9 @@ export default async function PostPage({
65
81
  ```
66
82
 
67
83
  목차(ToC)·작성자 카드·발행일 표시는 어드민의 블로그 표시 설정으로도 제어됩니다
68
- (`theme-and-settings.md` 참고).
84
+ (`theme-and-settings.md` 참고). 어드민 설정 > 블로그에서 **글 하단 CTA**(제목·설명·
85
+ 버튼)를 켜면 `RootTaleBlogPost` 가 모든 글 본문 끝에 같은 CTA 블록을 자동으로
86
+ 렌더합니다 — 별도 연동 코드는 필요 없습니다.
69
87
 
70
88
  #### 목차 블록 (본문 임의 위치)
71
89
 
@@ -147,6 +165,7 @@ export async function getPost(slug: string) {
147
165
  ```tsx
148
166
  // app/blog/[slug]/page.tsx (커스텀 UI 버전)
149
167
  import type { Metadata } from "next";
168
+ import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
150
169
  import { getAllPosts, getPost } from "@/lib/blog";
151
170
 
152
171
  export async function generateStaticParams() {
@@ -162,18 +181,27 @@ export async function generateMetadata({
162
181
  const { slug } = await params;
163
182
  const post = await getPost(slug);
164
183
  if (!post) return {};
165
- // 어드민 글 에디터의 SEO 패널 값(metaJson.seo)을 우선 적용
166
- const seo = (post.metaJson as { seo?: Record<string, string | boolean> })?.seo;
167
- return {
168
- title: (seo?.title as string) || post.title,
169
- description: (seo?.description as string) || post.excerpt,
170
- ...(seo?.noindex || seo?.nofollow
171
- ? { robots: { index: !seo?.noindex, follow: !seo?.nofollow } }
172
- : {}),
173
- };
184
+ // 어드민 글 에디터의 SEO 패널(metaJson.seo) override 적용 + self-canonical.
185
+ // canonical/robots/openGraph 분기를 직접 안 써도 한 곳도 빠뜨리지 않습니다.
186
+ return buildPostMetadata(post, {
187
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
188
+ path: `/blog/${post.slug}`,
189
+ });
174
190
  }
175
191
  ```
176
192
 
193
+ `buildPostMetadata(post, opts)`는 어드민 SEO 패널 값을 Next `Metadata`로
194
+ 한 줄 변환합니다:
195
+
196
+ - `seo.title`/`seo.description`/`seo.ogImage` 가 있으면 우선, 없으면 글 값으로 fallback
197
+ - `seo.canonical` override → 없으면 `siteUrl`+`path` 로 self-canonical 자동 생성
198
+ - `seo.noindex`/`seo.nofollow` 중 하나라도 켜지면 `robots` 출력
199
+ - `openGraph`(article·publishedTime·images) 자동 구성
200
+
201
+ 입력은 `{ title, description?, date?, image?, seo? }` 형태면 되고
202
+ (`getPost` 의 `BlogPostMeta` 가 그대로 호환), 원본 post 를 쓸 땐
203
+ `seo: (post.metaJson as { seo?: ... }).seo` 로 넘기세요.
204
+
177
205
  SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`,
178
206
  `noindex`, `nofollow`.
179
207
 
@@ -115,10 +115,13 @@ export const COLLECTIONS: RouteCollection[] = [
115
115
 
116
116
  ```ts
117
117
  // app/sitemap.ts
118
- import { createSitemap } from "@roottale/cms-renderer-next/routes";
119
- export default createSitemap({ apiKey, siteUrl, title, collections: COLLECTIONS }, [
120
- /* 정적 경로 */
121
- ]);
118
+ import { createSitemapIndex } from "@roottale/cms-renderer-next/routes";
119
+ const { generateSitemaps, sitemap } = createSitemapIndex(
120
+ { apiKey, siteUrl, title, collections: COLLECTIONS },
121
+ [ /* 정적 경로 */ ],
122
+ );
123
+ export { generateSitemaps };
124
+ export default sitemap;
122
125
 
123
126
  // app/feed.xml/route.ts
124
127
  export const dynamic = "force-dynamic";
@@ -144,7 +147,12 @@ async function getCollections(): Promise<RouteCollection[]> {
144
147
  }
145
148
  }
146
149
 
147
- export default createSitemap({ apiKey, siteUrl, title, collections: getCollections }, [ ]);
150
+ const { generateSitemaps, sitemap } = createSitemapIndex(
151
+ { apiKey, siteUrl, title, collections: getCollections },
152
+ [ ],
153
+ );
154
+ export { generateSitemaps };
155
+ export default sitemap;
148
156
  export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: getCollections });
149
157
  ```
150
158
 
@@ -152,6 +160,47 @@ export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: getCol
152
160
  응답은 `RouteCollection`과 구조 호환이라 그대로 넘길 수 있습니다. 매 요청 fetch를 피하려면
153
161
  사이트 경계에서 캐시하세요(예: Next `fetch(url, { next: { revalidate: 300 } })`).
154
162
 
163
+ ### 섹션 목록 페이지 (공지/블로그 분리 렌더)
164
+
165
+ 각 섹션 목록은 `RootTaleBlogList` 에 **`collection`+`collections`** 를 넘겨 그 섹션
166
+ 글만 보이게 합니다. **이걸 안 주면 컴포넌트가 전체 글을 렌더**하므로 공지 글이
167
+ `/blog` 목록에 섞여 들어옵니다(섹션을 나눈 사이트의 가장 흔한 버그). `collections`
168
+ 를 주면 글 링크도 소속 섹션 basePath 로 자동 라우팅됩니다(공지→`/notice/{slug}`).
169
+
170
+ ```tsx
171
+ import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";
172
+ import { COLLECTIONS } from "@/lib/collections"; // 위 "방식 A" 의 상수
173
+
174
+ // app/notice/page.tsx — 공지 게시판
175
+ export default function NoticePage() {
176
+ return (
177
+ <RootTaleBlogList
178
+ apiKey={process.env.ROOTTALE_API_KEY!}
179
+ collection="notice"
180
+ collections={COLLECTIONS}
181
+ />
182
+ );
183
+ }
184
+
185
+ // app/blog/page.tsx — 블로그
186
+ export default function BlogPage() {
187
+ return (
188
+ <RootTaleBlogList
189
+ apiKey={process.env.ROOTTALE_API_KEY!}
190
+ collection="blog"
191
+ collections={COLLECTIONS}
192
+ showCategoryFilter
193
+ />
194
+ );
195
+ }
196
+ ```
197
+
198
+ `RootTaleBlogCategories` 도 같은 `collection`+`collections` 를 받아 그 섹션의 주제만
199
+ 집계합니다. 카테고리 칩/사이드바를 섹션별로 나눌 때 쓰세요.
200
+
201
+ > 동적 basePath(어드민에서 자유 편집)나 catch-all 라우트를 쓰면 `collection` 값을
202
+ > 요청 경로에서 판정해 넘기세요(아래 "동적 basePath" 참고).
203
+
155
204
  ### 상세 페이지 가드 (섹션 누출 차단)
156
205
 
157
206
  상세 라우트는 글이 그 섹션 소속인지 확인해 다른 섹션 글이 새는 것을 막습니다.
@@ -165,6 +214,23 @@ const post = await getPost(slug);
165
214
  if (!post || resolvePostCollection(post, COLLECTIONS)?.key !== "blog") notFound();
166
215
  ```
167
216
 
217
+ ### 상세 페이지 메타데이터 (canonical 공지/블로그 구분)
218
+
219
+ 상세 라우트의 `generateMetadata` 도 `collections` 를 넘겨야 canonical 이 글의
220
+ 섹션에 맞게 나옵니다. `path` 를 `/blog/...` 로 하드코딩하면 공지 글이 잘못된
221
+ 블로그 canonical 을 갖게 됩니다.
222
+
223
+ ```ts
224
+ import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
225
+ // app/blog/[slug]/page.tsx (공지면 app/notice/[slug])
226
+ return buildPostMetadata(post, {
227
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
228
+ collections: COLLECTIONS, // 글의 collectionKey 로 /notice·/blog canonical 자동 해석
229
+ });
230
+ ```
231
+
232
+ 자세한 동작은 `seo.md` 의 "글 메타데이터 → 공지·블로그 다중 스트림" 참고.
233
+
168
234
  ### revalidate
169
235
 
170
236
  ```ts
package/docs/seo.md CHANGED
@@ -29,13 +29,18 @@ export const GET = createFeedRoute({
29
29
 
30
30
  ## 사이트맵
31
31
 
32
+ `createSitemapIndex`는 **사이트맵 인덱스**(`/sitemap.xml`)와 섹션별 하위 사이트맵
33
+ (`/sitemap/static.xml`·`/sitemap/blog.xml`·`/sitemap/categories.xml`)을 만듭니다.
34
+ 하나의 거대한 파일 대신 섹션별로 나뉘어 검색엔진이 더 잘 크롤하고, 50,000 URL/50MB
35
+ 한도에도 안전합니다. 발행 글·카테고리는 자동 포함됩니다.
36
+
32
37
  ```ts
33
38
  // app/sitemap.ts
34
- import { createSitemap } from "@roottale/cms-renderer-next/routes";
39
+ import { createSitemapIndex } from "@roottale/cms-renderer-next/routes";
35
40
 
36
41
  const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
37
42
 
38
- export default createSitemap(
43
+ const { generateSitemaps, sitemap } = createSitemapIndex(
39
44
  {
40
45
  apiKey: process.env.ROOTTALE_API_KEY!,
41
46
  apiBase: process.env.ROOTTALE_API_BASE,
@@ -43,14 +48,77 @@ export default createSitemap(
43
48
  title: "예시 사이트",
44
49
  },
45
50
  [
46
- // 정적 경로 — 발행 글 URL은 자동 추가됨
47
- { url: SITE_URL, changeFrequency: "weekly", priority: 1.0 },
48
- { url: `${SITE_URL}/blog`, changeFrequency: "weekly", priority: 0.7 },
49
- { url: `${SITE_URL}/contact`, changeFrequency: "monthly", priority: 0.9 },
51
+ // 정적 경로 — 발행 글·카테고리 URL은 자동 추가됨
52
+ { url: SITE_URL },
53
+ { url: `${SITE_URL}/blog` },
54
+ { url: `${SITE_URL}/contact` },
50
55
  ],
51
56
  );
57
+
58
+ // Next 16: 인덱스를 만들려면 generateSitemaps·default 를 둘 다 export.
59
+ export { generateSitemaps };
60
+ export default sitemap;
61
+ ```
62
+
63
+ - **블로그 이미지 색인** — 글에 대표 이미지가 있으면 `<image:image>`로 함께 색인합니다.
64
+ 어드민 **설정 > 블로그 > 사이트맵**에서 켜고 끌 수 있어요(기본 켜짐).
65
+ - `changeFrequency`/`priority`는 Google이 무시하므로 더 이상 내보내지 않습니다(`loc` +
66
+ `lastmod` + 이미지만).
67
+ - 단일 평면 사이트맵이 필요하면 레거시 `createSitemap`(default export 하나)도 그대로
68
+ 동작하지만, 신규 사이트는 `createSitemapIndex`를 권장합니다.
69
+
70
+ ### 작가 아카이브 (`/blog/author/{slug}`)
71
+
72
+ 어드민 **설정 > 팀**에서 작가에게 주소(slug)를 발급하면, 그 작가의 글 모음 페이지와
73
+ 작가 사이트맵(`/sitemap/authors.xml`)을 만들 수 있습니다. 어드민 **설정 > 블로그 >
74
+ 사이트맵**에서 "작가 사이트맵"을 켜면 인덱스에 `authors` 섹션이 추가됩니다.
75
+
76
+ ```ts
77
+ // app/blog/author/[slug]/page.tsx
78
+ import { notFound } from "next/navigation";
79
+ import { fetchAuthors } from "@roottale/cms-client/server";
80
+ import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";
81
+
82
+ export default async function AuthorArchive({
83
+ params,
84
+ }: {
85
+ params: Promise<{ slug: string }>;
86
+ }) {
87
+ const { slug } = await params;
88
+ const authors = await fetchAuthors({ apiKey: process.env.ROOTTALE_API_KEY! });
89
+ const author = authors.find((a) => a.slug === slug);
90
+ if (!author) notFound();
91
+
92
+ return (
93
+ <>
94
+ <h1>{author.name}</h1>
95
+ {author.bio ? <p>{author.bio}</p> : null}
96
+ {/* author= 로 그 작가의 발행 글만 렌더 */}
97
+ <RootTaleBlogList apiKey={process.env.ROOTTALE_API_KEY!} author={slug} />
98
+ </>
99
+ );
100
+ }
52
101
  ```
53
102
 
103
+ - `fetchAuthors()` 는 slug가 있고 발행 글이 1건 이상인 작가만 반환합니다
104
+ (`GET /v1/cms/public/authors`).
105
+ - `RootTaleBlogList`에 `author={slug}` 를 주면 그 작가의 글만 가져옵니다
106
+ (`GET /v1/cms/public/posts?author={slug}`).
107
+
108
+ ### 다국어 (hreflang)
109
+
110
+ 번역 페이지가 있는 사이트는 어드민 **설정 > 블로그 > 사이트맵**의 "다국어 로케일"에
111
+ 언어 코드를 입력하면(예: `ko, ja, en`), 사이트맵의 모든 항목에 `hreflang` 대체 언어
112
+ 링크(`<xhtml:link rel="alternate">`)가 붙습니다.
113
+
114
+ - **첫 번째** 로케일이 기본 언어(현재 URL 그대로) + `x-default`.
115
+ - 나머지는 **경로 접두사**로 매핑됩니다 — `ko`가 기본이면 `ja`는
116
+ `/ja/blog/{slug}`, `en`은 `/en/blog/{slug}`.
117
+ - 같은 slug에 언어 접두사만 붙는 1:1 구조를 가정합니다. **실제 번역 페이지를
118
+ 그 경로(`/ja/...`)에 서빙하는 것은 사이트 쪽 책임**입니다 — 사이트맵은 검색엔진에
119
+ 대체 언어를 알릴 뿐입니다.
120
+ - 비워두면 단일 언어로 동작합니다(기본).
121
+
54
122
  ## 다중 스트림 (collections) — 공지·블로그 분리
55
123
 
56
124
  같은 글 풀을 공지 게시판(`/notice`) + 블로그(`/blog`) 등 여러 섹션으로 나눠 서로 다른
@@ -114,6 +182,68 @@ const crumbs = breadcrumbSchema([
114
182
  발행 웹훅의 `alsoRevalidate`에 `/feed.xml`, `/sitemap.xml`을 포함해 글 변경
115
183
  시 함께 갱신하세요 (`revalidation-webhooks.md` 참고).
116
184
 
185
+ ## 글 메타데이터 (canonical·robots·OG)
186
+
187
+ 블로그 글 상세의 `generateMetadata` 에서 어드민 SEO 패널(`metaJson.seo`) 값을
188
+ `buildPostMetadata` 로 한 줄 변환합니다. canonical/robots/openGraph 분기를
189
+ 직접 쓰면 한 곳이라도 빠뜨려 SEO 가 새기 쉬운데(특히 noindex 누락·canonical
190
+ 미설정), 이 헬퍼로 표준화합니다.
191
+
192
+ ```tsx
193
+ // app/blog/[slug]/page.tsx
194
+ import type { Metadata } from "next";
195
+ import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
196
+
197
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
198
+ const { slug } = await params;
199
+ const post = await getPost(slug);
200
+ if (!post) return {};
201
+ return buildPostMetadata(post, {
202
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL, // self-canonical 기본값 origin
203
+ path: `/blog/${post.slug}`, // 현재 slug 기준
204
+ });
205
+ }
206
+ ```
207
+
208
+ - `seo.title`/`seo.description`/`seo.ogImage` override → 없으면 글 값 fallback
209
+ - canonical: `seo.canonical` override → 없으면 `siteUrl`+`path` 로 **self-canonical
210
+ 자동 생성**(모든 글이 자기 자신을 가리키는 canonical 을 갖도록 — 권장).
211
+ `siteUrl`/`path` 를 안 넘기면 override 가 있을 때만 canonical 출력.
212
+ - `seo.noindex`/`seo.nofollow` 중 하나라도 켜지면 `robots` 출력
213
+ - `openGraph`(type:`article`·`publishedTime`·`images`) 자동 구성
214
+
215
+ 입력은 `{ title, description?, date?, image?, seo? }` 구조면 됩니다(`getPost`
216
+ 의 `BlogPostMeta` 호환). 원본 post 를 쓸 땐 `seo: (post.metaJson as {
217
+ seo?: PostSeoOverrides }).seo` 로 넘기세요. `path` 는 redirect 후의 **현재
218
+ slug**(`post.slug`) 기준으로 주세요(아래 301 참고).
219
+
220
+ ### 공지·블로그 다중 스트림 (ADR-0060)
221
+
222
+ 섹션을 나눈 사이트(공지 `/notice` + 블로그 `/blog`)는 `path` 를 직접 쓰지 말고
223
+ **`collections` 를 넘기세요.** 글의 `collectionKey` 로 소속 섹션 basePath 를 찾아
224
+ canonical 을 **공지/블로그로 구분**해 해석합니다(공지 글 → `/notice/{slug}`, 블로그
225
+ 글 → `/blog/{slug}`). `path` 를 `/blog/...` 로 하드코딩하면 공지 글이 잘못된
226
+ 블로그 canonical 을 갖게 됩니다.
227
+
228
+ ```tsx
229
+ import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
230
+ import { COLLECTIONS } from "@/lib/collections"; // collections.md 참고
231
+
232
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
233
+ const { slug } = await params;
234
+ const post = await getPost(slug);
235
+ if (!post) return {};
236
+ return buildPostMetadata(post, {
237
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
238
+ collections: COLLECTIONS, // collectionKey 로 /notice·/blog 자동 구분
239
+ });
240
+ }
241
+ ```
242
+
243
+ `post.collectionKey`(공개 API의 `collection_key`)가 어느 섹션에도 안 맞으면
244
+ canonical 을 생략합니다(섹션 없는 글은 상세·sitemap에서 제외되는 규칙과 동일).
245
+ 단일 블로그 사이트는 기존처럼 `path: "/blog/" + post.slug` 만 주면 됩니다.
246
+
117
247
  ## slug 변경 시 301 리다이렉트
118
248
 
119
249
  글 slug를 바꿔도 옛 URL이 깨지지 않습니다. API가 slug history로 글을 찾아
@@ -13,8 +13,18 @@ export const POST = createRevalidateRoute({
13
13
  apiKey: process.env.ROOTTALE_API_KEY!,
14
14
  apiBase: process.env.ROOTTALE_API_BASE,
15
15
  revalidate: revalidateBlogPath,
16
- // 홈에 최신 글 섹션이 있으면 "/" 포함 — 글 변경 시 홈도 함께 갱신
17
- alsoRevalidate: ["/feed.xml", "/sitemap.xml", "/blog", "/"],
16
+ // 사이트맵 인덱스 + 분리된 하위 사이트맵(static/blog/categories)을 모두 무효화.
17
+ // 홈에 최신 글 섹션이 있으면 "/" 포함 — 글 변경 시 홈도 함께 갱신.
18
+ alsoRevalidate: [
19
+ "/feed.xml",
20
+ "/sitemap.xml",
21
+ "/sitemap/static.xml",
22
+ "/sitemap/blog.xml",
23
+ "/sitemap/categories.xml",
24
+ "/sitemap/authors.xml",
25
+ "/blog",
26
+ "/",
27
+ ],
18
28
  });
19
29
 
20
30
  export function GET(): Response {
@@ -4,7 +4,10 @@
4
4
  import type { Metadata } from "next";
5
5
  import { notFound, permanentRedirect } from "next/navigation";
6
6
  import { RootTaleBlogPost } from "@roottale/cms-renderer-next/server";
7
- import { postRedirectPath } from "@roottale/cms-renderer-next/routes";
7
+ import {
8
+ buildPostMetadata,
9
+ postRedirectPath,
10
+ } from "@roottale/cms-renderer-next/routes";
8
11
 
9
12
  import { getAllPosts, getPost } from "@/lib/blog";
10
13
 
@@ -21,24 +24,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
21
24
  const { slug } = await params;
22
25
  const post = await getPost(slug);
23
26
  if (!post) return {};
24
- const seo = post.seo;
25
- const title = seo?.title || post.title;
26
- const description = seo?.description || post.description;
27
- return {
28
- title,
29
- description,
30
- ...(seo?.canonical ? { alternates: { canonical: seo.canonical } } : {}),
31
- ...(seo?.noindex || seo?.nofollow
32
- ? { robots: { index: !seo?.noindex, follow: !seo?.nofollow } }
33
- : {}),
34
- openGraph: {
35
- type: "article",
36
- title,
37
- description,
38
- publishedTime: post.date,
39
- ...(post.image ? { images: [{ url: seo?.ogImage || post.image, alt: title }] } : {}),
40
- },
41
- };
27
+ // 어드민 SEO 패널(metaJson.seo) override + self-canonical 을 1줄로.
28
+ return buildPostMetadata(post, {
29
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
30
+ path: `/blog/${post.slug}`,
31
+ });
42
32
  }
43
33
 
44
34
  export default async function PostPage({ params }: Props) {
@@ -1,19 +1,20 @@
1
- // 사이트맵 — 정적 경로 + 발행 글 URL 자동 포함.
2
- import { createSitemap } from "@roottale/cms-renderer-next/routes";
1
+ // 사이트맵 — 인덱스 분리(/sitemap.xml → /sitemap/static.xml·blog·categories).
2
+ // 정적 경로 + 발행 글·카테고리 자동 포함. 블로그 글 대표 이미지는 admin 토글에 따라
3
+ // <image:image> 로 함께 색인(어드민 설정 > 블로그 > 사이트맵).
4
+ import { createSitemapIndex } from "@roottale/cms-renderer-next/routes";
3
5
 
4
6
  const SITE_URL =
5
7
  process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") || "https://example.com";
6
8
 
7
- export default createSitemap(
9
+ const { generateSitemaps, sitemap } = createSitemapIndex(
8
10
  {
9
11
  apiKey: process.env.ROOTTALE_API_KEY!,
10
12
  apiBase: process.env.ROOTTALE_API_BASE,
11
13
  siteUrl: SITE_URL,
12
14
  title: "예시 사이트",
13
15
  },
14
- [
15
- { url: SITE_URL, changeFrequency: "weekly", priority: 1.0 },
16
- { url: `${SITE_URL}/blog`, changeFrequency: "weekly", priority: 0.7 },
17
- { url: `${SITE_URL}/contact`, changeFrequency: "monthly", priority: 0.9 },
18
- ],
16
+ [{ url: SITE_URL }, { url: `${SITE_URL}/blog` }, { url: `${SITE_URL}/contact` }],
19
17
  );
18
+
19
+ export { generateSitemaps };
20
+ export default sitemap;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "description": "RootTale CMS integration MCP server — bundled integration docs, Next.js example code, and public API lookup tools. Run with: npx @roottale/cms-mcp",
6
6
  "bin": {