@roottale/cms-mcp 0.34.1 → 0.36.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,29 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.36.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 1fe867f: 문서 갱신 — 블로그 설정 응답의 글 하단 CTA(`post_cta`)와 사이트 SEO 기본값
8
+ (`site_profile`: 설명·로고·파비콘·기본 OG)을 api-reference / blog 가이드에 반영.
9
+ CTA 는 `RootTaleBlogPost` 가 본문 끝에 자동 렌더하고, `site_profile` 은 외부
10
+ 사이트가 head + OG 폴백으로 사용한다.
11
+
12
+ ## 0.35.0
13
+
14
+ ### Minor Changes
15
+
16
+ - cfddfc7: 커스텀 리다이렉트 (WP RankMath Redirections 패리티) — 운영자가 어드민 "설정 >
17
+ 주소 이동"에서 글이 아닌 임의 경로 이동 규칙(`/old-event → /promo`, 301/302)을
18
+ 정의하고, 사이트 미들웨어가 적용한다.
19
+ - `@roottale/cms-client`: `fetchRedirects()` 추가 — `GET /v1/cms/public/redirects`
20
+ 의 활성 규칙 목록을 가져온다(404 → 빈 배열 fail-soft).
21
+ - `@roottale/cms-renderer-next`: `createRedirectMiddleware()` 추가(+ 순수 함수
22
+ `matchRedirect`/`normalizeRedirectPath`), `/routes` 서브패스로 노출. 규칙을
23
+ TTL 캐시하고 API 실패 시 트래픽을 막지 않는다.
24
+ - `@roottale/cms-mcp`: `custom-redirects.md` 연동 가이드 + api-reference 의
25
+ `/redirects` 항목 + nextjs `middleware.ts` 예시.
26
+
3
27
  ## 0.34.1
4
28
 
5
29
  ### Patch 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.34.1" : "dev";
282
+ var VERSION = true ? "0.36.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.
@@ -83,6 +83,25 @@ JS/TS 는 `@roottale/cms-client/server` 의 `searchPosts({ apiKey, query })` 를
83
83
  위치 핸들(`primary`, `footer` 등)로 메뉴 1개. 없으면 `404` — 사이트는 자체
84
84
  fallback 네비를 렌더하세요 (`menus.md` 참고).
85
85
 
86
+ ## GET /v1/cms/public/redirects
87
+
88
+ 어드민 "설정 > 주소 이동"에서 정의한 **활성** 커스텀 리다이렉트 규칙 전체.
89
+ 사이트 미들웨어가 요청 경로를 매칭해 301/302 처리합니다. 비활성 규칙과 운영자
90
+ 메모는 응답에 포함되지 않습니다. 라우트 미배포(구 서버)는 `404` — 빈 목록으로
91
+ 처리하세요. 연동은 `custom-redirects.md` 참고.
92
+
93
+ ```json
94
+ { "tenant_id": "…", "site_id": "…",
95
+ "items": [ { "id": "…", "from_path": "/old-event",
96
+ "to_target": "/promo", "status_code": 301 } ] }
97
+ ```
98
+
99
+ | 필드 | 설명 |
100
+ |---|---|
101
+ | `from_path` | 출발 경로 — 정규화된 사이트 내부 절대 경로(앞 슬래시, 쿼리 제외). |
102
+ | `to_target` | 도착지 — 내부 경로(`/promo`) 또는 절대 URL(`https://…`). |
103
+ | `status_code` | `301`(영구) 또는 `302`(임시). |
104
+
86
105
  ## GET /v1/cms/public/theme
87
106
 
88
107
  어드민에서 설정한 디자인 토큰. 설정된 그룹만 포함됩니다.
@@ -94,16 +113,29 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
94
113
 
95
114
  ## GET /v1/cms/public/blog-settings
96
115
 
97
- 블로그 표시 설정 (TOC·작성자·발행일·작성자 카드, 저자 프로필).
116
+ 블로그 표시 설정 (TOC·작성자·발행일·작성자 카드, 저자 프로필, 글 하단 CTA).
117
+ `post_cta` 는 admin 에서 활성화하고 버튼 문구·링크를 채웠을 때만 객체이며,
118
+ 그 외에는 `null`. `RootTaleBlogPost` 가 본문 끝에 자동으로 렌더하므로 별도
119
+ 연동 코드는 필요 없다.
98
120
 
99
121
  ```json
100
122
  { "show_table_of_contents": false, "show_author": true, "show_date": true,
101
123
  "show_author_card": true, "toc_title": null,
102
124
  "author_profile_name": null, "author_profile_bio": null,
103
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" },
104
130
  "updated_at": null }
105
131
  ```
106
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
+
107
139
  ## GET /v1/cms/public/site-knowledge
108
140
 
109
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
 
@@ -152,6 +152,47 @@ export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: getCol
152
152
  응답은 `RouteCollection`과 구조 호환이라 그대로 넘길 수 있습니다. 매 요청 fetch를 피하려면
153
153
  사이트 경계에서 캐시하세요(예: Next `fetch(url, { next: { revalidate: 300 } })`).
154
154
 
155
+ ### 섹션 목록 페이지 (공지/블로그 분리 렌더)
156
+
157
+ 각 섹션 목록은 `RootTaleBlogList` 에 **`collection`+`collections`** 를 넘겨 그 섹션
158
+ 글만 보이게 합니다. **이걸 안 주면 컴포넌트가 전체 글을 렌더**하므로 공지 글이
159
+ `/blog` 목록에 섞여 들어옵니다(섹션을 나눈 사이트의 가장 흔한 버그). `collections`
160
+ 를 주면 글 링크도 소속 섹션 basePath 로 자동 라우팅됩니다(공지→`/notice/{slug}`).
161
+
162
+ ```tsx
163
+ import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";
164
+ import { COLLECTIONS } from "@/lib/collections"; // 위 "방식 A" 의 상수
165
+
166
+ // app/notice/page.tsx — 공지 게시판
167
+ export default function NoticePage() {
168
+ return (
169
+ <RootTaleBlogList
170
+ apiKey={process.env.ROOTTALE_API_KEY!}
171
+ collection="notice"
172
+ collections={COLLECTIONS}
173
+ />
174
+ );
175
+ }
176
+
177
+ // app/blog/page.tsx — 블로그
178
+ export default function BlogPage() {
179
+ return (
180
+ <RootTaleBlogList
181
+ apiKey={process.env.ROOTTALE_API_KEY!}
182
+ collection="blog"
183
+ collections={COLLECTIONS}
184
+ showCategoryFilter
185
+ />
186
+ );
187
+ }
188
+ ```
189
+
190
+ `RootTaleBlogCategories` 도 같은 `collection`+`collections` 를 받아 그 섹션의 주제만
191
+ 집계합니다. 카테고리 칩/사이드바를 섹션별로 나눌 때 쓰세요.
192
+
193
+ > 동적 basePath(어드민에서 자유 편집)나 catch-all 라우트를 쓰면 `collection` 값을
194
+ > 요청 경로에서 판정해 넘기세요(아래 "동적 basePath" 참고).
195
+
155
196
  ### 상세 페이지 가드 (섹션 누출 차단)
156
197
 
157
198
  상세 라우트는 글이 그 섹션 소속인지 확인해 다른 섹션 글이 새는 것을 막습니다.
@@ -165,6 +206,23 @@ const post = await getPost(slug);
165
206
  if (!post || resolvePostCollection(post, COLLECTIONS)?.key !== "blog") notFound();
166
207
  ```
167
208
 
209
+ ### 상세 페이지 메타데이터 (canonical 공지/블로그 구분)
210
+
211
+ 상세 라우트의 `generateMetadata` 도 `collections` 를 넘겨야 canonical 이 글의
212
+ 섹션에 맞게 나옵니다. `path` 를 `/blog/...` 로 하드코딩하면 공지 글이 잘못된
213
+ 블로그 canonical 을 갖게 됩니다.
214
+
215
+ ```ts
216
+ import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
217
+ // app/blog/[slug]/page.tsx (공지면 app/notice/[slug])
218
+ return buildPostMetadata(post, {
219
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
220
+ collections: COLLECTIONS, // 글의 collectionKey 로 /notice·/blog canonical 자동 해석
221
+ });
222
+ ```
223
+
224
+ 자세한 동작은 `seo.md` 의 "글 메타데이터 → 공지·블로그 다중 스트림" 참고.
225
+
168
226
  ### revalidate
169
227
 
170
228
  ```ts
@@ -0,0 +1,83 @@
1
+ ---
2
+ title: 주소 이동 (커스텀 리다이렉트) 연동
3
+ description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로 리다이렉트를 사이트 미들웨어로 적용
4
+ ---
5
+
6
+ # 주소 이동 (커스텀 리다이렉트) 연동
7
+
8
+ 어드민(mysite.roottale.com)의 **설정 > 주소 이동**에서 운영자가 정의한 임의
9
+ 경로 이동 규칙(`/old-event → /promo`)을 사이트 미들웨어로 적용합니다. 코드
10
+ 수정 없이 고객이 직접 규칙을 추가·수정·삭제할 수 있습니다.
11
+
12
+ 두 종류의 주소 이동이 있습니다.
13
+
14
+ - **글 주소 변경 자동 301** — 블로그 글의 슬러그를 바꾸면 자동으로 옛 주소가
15
+ 새 주소로 이어집니다. 글 라우트에서 처리되며 별도 설정이 필요 없습니다
16
+ (`blog.md` 의 `postRedirectPath` 참고).
17
+ - **커스텀 리다이렉트(이 문서)** — 글이 아닌 임의 경로를 옮깁니다. 라우팅
18
+ *이전* 단계인 **미들웨어**에서만 가로챌 수 있어, 아래 설정이 필요합니다.
19
+
20
+ 규칙은 `GET /v1/cms/public/redirects` 로 내려오며 **활성** 규칙만 포함됩니다
21
+ (`api-reference.md`). 출발 경로는 정규화된 사이트 내부 절대 경로, 도착지는
22
+ 내부 경로 또는 절대 URL, 상태는 `301`(영구) 또는 `302`(임시)입니다.
23
+
24
+ ## 미들웨어 설정
25
+
26
+ `@roottale/cms-renderer-next` 의 `createRedirectMiddleware` 를 프로젝트 루트
27
+ `middleware.ts` 에 마운트합니다. 규칙을 자동 캐시(기본 60초)하며, API 실패 시
28
+ 트래픽을 막지 않고 통과시킵니다(fail-soft).
29
+
30
+ ```ts
31
+ // middleware.ts
32
+ import { NextResponse } from "next/server";
33
+ import { createRedirectMiddleware } from "@roottale/cms-renderer-next/routes";
34
+
35
+ const redirects = createRedirectMiddleware({
36
+ apiKey: process.env.ROOTTALE_API_KEY!,
37
+ // apiBase, siteId, cacheTtlMs 는 선택.
38
+ });
39
+
40
+ export async function middleware(req: Request) {
41
+ return (await redirects(req)) ?? NextResponse.next();
42
+ }
43
+
44
+ // Next 내부·API·알려진 정적 자산만 제외합니다. 점 포함 경로를 전부 막으면
45
+ // `/old.html`·`/foo.php` 같은 레거시 마이그레이션 리다이렉트가 동작하지
46
+ // 않으므로, 자산 확장자만 명시적으로 제외합니다.
47
+ export const config = {
48
+ matcher: [
49
+ "/((?!_next/|api/|.*\\.(?:ico|png|jpg|jpeg|gif|svg|webp|css|js|txt|xml|json|woff2?|map)$).*)",
50
+ ],
51
+ };
52
+ ```
53
+
54
+ - `ROOTTALE_API_KEY` 는 블로그 조회와 **같은** 키입니다. 서버 전용 — 절대
55
+ 브라우저에 노출하지 마세요.
56
+ - 매칭은 **정확 경로 일치**입니다(와일드카드 없음). 출발 경로의 앞/뒤 슬래시와
57
+ 한글 percent-encoding 차이는 자동 정규화해 비교합니다.
58
+ - 도착지가 내부 경로면 요청 origin 기준 절대 URL 로 변환해 리다이렉트합니다.
59
+ - 매칭이 없으면 `null` 을 반환하므로 `NextResponse.next()` 로 통과시키세요.
60
+
61
+ ## 캐시와 즉시성
62
+
63
+ 규칙은 미들웨어가 TTL(기본 60초) 동안 캐시합니다. 운영자가 규칙을 바꾸면
64
+ 최대 TTL 만큼 뒤 반영됩니다. 더 빠른 반영이 필요하면 `cacheTtlMs` 를 줄이세요
65
+ (요청당 API 호출이 늘어납니다).
66
+
67
+ ```ts
68
+ const redirects = createRedirectMiddleware({
69
+ apiKey: process.env.ROOTTALE_API_KEY!,
70
+ cacheTtlMs: 10_000, // 10초
71
+ });
72
+ ```
73
+
74
+ ## 직접 호출 (미들웨어 없이)
75
+
76
+ 규칙 목록만 필요하면 `fetchRedirects` 로 직접 가져올 수 있습니다.
77
+
78
+ ```ts
79
+ import { fetchRedirects } from "@roottale/cms-client/server";
80
+
81
+ const rules = await fetchRedirects({ apiKey: process.env.ROOTTALE_API_KEY! });
82
+ // [{ id, fromPath, toTarget, statusCode }]
83
+ ```
package/docs/seo.md CHANGED
@@ -114,6 +114,68 @@ const crumbs = breadcrumbSchema([
114
114
  발행 웹훅의 `alsoRevalidate`에 `/feed.xml`, `/sitemap.xml`을 포함해 글 변경
115
115
  시 함께 갱신하세요 (`revalidation-webhooks.md` 참고).
116
116
 
117
+ ## 글 메타데이터 (canonical·robots·OG)
118
+
119
+ 블로그 글 상세의 `generateMetadata` 에서 어드민 SEO 패널(`metaJson.seo`) 값을
120
+ `buildPostMetadata` 로 한 줄 변환합니다. canonical/robots/openGraph 분기를
121
+ 직접 쓰면 한 곳이라도 빠뜨려 SEO 가 새기 쉬운데(특히 noindex 누락·canonical
122
+ 미설정), 이 헬퍼로 표준화합니다.
123
+
124
+ ```tsx
125
+ // app/blog/[slug]/page.tsx
126
+ import type { Metadata } from "next";
127
+ import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
128
+
129
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
130
+ const { slug } = await params;
131
+ const post = await getPost(slug);
132
+ if (!post) return {};
133
+ return buildPostMetadata(post, {
134
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL, // self-canonical 기본값 origin
135
+ path: `/blog/${post.slug}`, // 현재 slug 기준
136
+ });
137
+ }
138
+ ```
139
+
140
+ - `seo.title`/`seo.description`/`seo.ogImage` override → 없으면 글 값 fallback
141
+ - canonical: `seo.canonical` override → 없으면 `siteUrl`+`path` 로 **self-canonical
142
+ 자동 생성**(모든 글이 자기 자신을 가리키는 canonical 을 갖도록 — 권장).
143
+ `siteUrl`/`path` 를 안 넘기면 override 가 있을 때만 canonical 출력.
144
+ - `seo.noindex`/`seo.nofollow` 중 하나라도 켜지면 `robots` 출력
145
+ - `openGraph`(type:`article`·`publishedTime`·`images`) 자동 구성
146
+
147
+ 입력은 `{ title, description?, date?, image?, seo? }` 구조면 됩니다(`getPost`
148
+ 의 `BlogPostMeta` 호환). 원본 post 를 쓸 땐 `seo: (post.metaJson as {
149
+ seo?: PostSeoOverrides }).seo` 로 넘기세요. `path` 는 redirect 후의 **현재
150
+ slug**(`post.slug`) 기준으로 주세요(아래 301 참고).
151
+
152
+ ### 공지·블로그 다중 스트림 (ADR-0060)
153
+
154
+ 섹션을 나눈 사이트(공지 `/notice` + 블로그 `/blog`)는 `path` 를 직접 쓰지 말고
155
+ **`collections` 를 넘기세요.** 글의 `collectionKey` 로 소속 섹션 basePath 를 찾아
156
+ canonical 을 **공지/블로그로 구분**해 해석합니다(공지 글 → `/notice/{slug}`, 블로그
157
+ 글 → `/blog/{slug}`). `path` 를 `/blog/...` 로 하드코딩하면 공지 글이 잘못된
158
+ 블로그 canonical 을 갖게 됩니다.
159
+
160
+ ```tsx
161
+ import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
162
+ import { COLLECTIONS } from "@/lib/collections"; // collections.md 참고
163
+
164
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
165
+ const { slug } = await params;
166
+ const post = await getPost(slug);
167
+ if (!post) return {};
168
+ return buildPostMetadata(post, {
169
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
170
+ collections: COLLECTIONS, // collectionKey 로 /notice·/blog 자동 구분
171
+ });
172
+ }
173
+ ```
174
+
175
+ `post.collectionKey`(공개 API의 `collection_key`)가 어느 섹션에도 안 맞으면
176
+ canonical 을 생략합니다(섹션 없는 글은 상세·sitemap에서 제외되는 규칙과 동일).
177
+ 단일 블로그 사이트는 기존처럼 `path: "/blog/" + post.slug` 만 주면 됩니다.
178
+
117
179
  ## slug 변경 시 301 리다이렉트
118
180
 
119
181
  글 slug를 바꿔도 옛 URL이 깨지지 않습니다. API가 slug history로 글을 찾아
@@ -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) {
@@ -0,0 +1,23 @@
1
+ // middleware.ts — 커스텀 리다이렉트(어드민 "설정 > 주소 이동") 적용.
2
+ //
3
+ // 글 슬러그 변경 자동 301 은 글 라우트에서 처리되지만(blog 예시 참고), 글이
4
+ // 아닌 임의 경로(`/old-event → /promo`)는 라우팅 이전 단계인 미들웨어에서만
5
+ // 가로챌 수 있다. 규칙은 자동 캐시되고, API 실패 시 트래픽을 막지 않는다.
6
+ import { NextResponse } from "next/server";
7
+ import { createRedirectMiddleware } from "@roottale/cms-renderer-next/routes";
8
+
9
+ const redirects = createRedirectMiddleware({
10
+ apiKey: process.env.ROOTTALE_API_KEY!,
11
+ });
12
+
13
+ export async function middleware(req: Request) {
14
+ return (await redirects(req)) ?? NextResponse.next();
15
+ }
16
+
17
+ // Next 내부·API·알려진 정적 자산만 제외. 점 포함 경로를 전부 막으면
18
+ // `/old.html`·`/foo.php` 같은 레거시 마이그레이션 리다이렉트가 동작하지 않는다.
19
+ export const config = {
20
+ matcher: [
21
+ "/((?!_next/|api/|.*\\.(?:ico|png|jpg|jpeg|gif|svg|webp|css|js|txt|xml|json|woff2?|map)$).*)",
22
+ ],
23
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.34.1",
3
+ "version": "0.36.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": {