@roottale/cms-mcp 0.25.0 → 0.32.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 +14 -0
- package/dist/index.js +1 -1
- package/docs/blog.md +12 -0
- package/docs/collections.md +211 -0
- package/docs/seo.md +11 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @roottale/cms-mcp
|
|
2
2
|
|
|
3
|
+
## 0.32.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- dfe7814: docs: 콘텐츠 유형(Collections) 전용 문서 추가 (ADR-0060)
|
|
8
|
+
|
|
9
|
+
신규 `docs/collections.md` — 공지·블로그 다중 스트림의 URL 구조, 어드민 "콘텐츠 유형"
|
|
10
|
+
설정, 연동 코드(코드 상수 vs `fetchCollections` 동적 로드), 가드·catch-all·slug 301·동적
|
|
11
|
+
OG·동적 basePath·트러블슈팅을 한 곳에 정리. `docs/seo.md` 의 collections 섹션은 이 문서로
|
|
12
|
+
포인터 처리(드리프트 방지). 공개 엔드포인트 `GET /v1/cms/public/collections` 명시.
|
|
13
|
+
|
|
14
|
+
MCP `listDocs()` 는 docs/ 자동 발견이라 새 문서가 바로 노출되고, roottale-web 문서 사이트는
|
|
15
|
+
`PREFERRED_ORDER` 에 `collections` 추가로 노출된다.
|
|
16
|
+
|
|
3
17
|
## 0.25.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/dist/index.js
CHANGED
|
@@ -256,7 +256,7 @@ function registerTools(server2) {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
// src/server.ts
|
|
259
|
-
var VERSION = true ? "0.
|
|
259
|
+
var VERSION = true ? "0.32.0" : "dev";
|
|
260
260
|
var SERVER_INSTRUCTIONS = `
|
|
261
261
|
roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uAE30 \uC704\uD55C
|
|
262
262
|
\uD1B5\uD569 \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7\uACF5\uAC1C API \uC870\uD68C tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
|
package/docs/blog.md
CHANGED
|
@@ -67,6 +67,18 @@ export default async function PostPage({
|
|
|
67
67
|
목차(ToC)·작성자 카드·발행일 표시는 어드민의 블로그 표시 설정으로도 제어됩니다
|
|
68
68
|
(`theme-and-settings.md` 참고).
|
|
69
69
|
|
|
70
|
+
#### 목차 블록 (본문 임의 위치)
|
|
71
|
+
|
|
72
|
+
`showTableOfContents` 는 본문 **상단**에 목차를 자동으로 붙입니다. 글 안의
|
|
73
|
+
원하는 위치(예: 인트로 문단 다음)에 목차를 넣고 싶다면, 어드민 에디터에서
|
|
74
|
+
슬래시 메뉴 `/목차` 로 **목차 블록**(`roottale/table-of-contents`)을 삽입하세요.
|
|
75
|
+
렌더러가 그 위치에 문서의 h2–h4 제목으로 목차(`<nav class="rt-cms-toc">`)를
|
|
76
|
+
생성하고 각 제목에 앵커 id 를 부여합니다 — 상단 자동 ToC 와 동일 마크업·클래스라
|
|
77
|
+
스타일은 그대로 적용됩니다. 헤딩이 하나도 없으면 아무것도 렌더되지 않습니다.
|
|
78
|
+
|
|
79
|
+
블록을 본문에 직접 배치할 때는 상단 자동 ToC 와 중복되지 않도록
|
|
80
|
+
`showTableOfContents` 를 생략(기본 `false`)하는 것을 권장합니다.
|
|
81
|
+
|
|
70
82
|
### 고정 페이지 (회사소개 등)
|
|
71
83
|
|
|
72
84
|
어드민의 고정 페이지(`type: "page"`)는 `RootTalePage`로 렌더링합니다 — 블로그
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 콘텐츠 유형 (Collections) — 공지·블로그 URL 분리
|
|
3
|
+
description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등 여러 스트림으로 나누는 법. URL 구조, 어드민 설정, 연동 코드, slug·301·OG.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 콘텐츠 유형 (Collections)
|
|
7
|
+
|
|
8
|
+
하나의 글 목록을 **여러 스트림**으로 나눠 서로 다른 URL·레이아웃으로 보여주는 기능입니다.
|
|
9
|
+
가장 흔한 형태는 **공지 게시판(`/notice`) + 블로그(`/blog`)** 분리입니다. 어느 글이 어느
|
|
10
|
+
스트림에 속할지는 그 글의 **카테고리**로 정해집니다.
|
|
11
|
+
|
|
12
|
+
## URL이 어떻게 정해지나
|
|
13
|
+
|
|
14
|
+
> **글의 URL = 그 글이 속한 스트림의 `basePath` + `/{slug}`**
|
|
15
|
+
|
|
16
|
+
| 글의 카테고리 | 속하는 스트림 | URL |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `notice` | 공지 (basePath `/notice`) | `/notice/{slug}` |
|
|
19
|
+
| `column` · `news` | 블로그 (basePath `/blog`) | `/blog/{slug}` |
|
|
20
|
+
|
|
21
|
+
예: `notice` 카테고리 글 "개강안내" → `https://내사이트/notice/개강안내`
|
|
22
|
+
`column` 카테고리 글 "비문학공부법" → `https://내사이트/blog/비문학공부법`
|
|
23
|
+
|
|
24
|
+
스트림별로 함께 만들어지는 경로:
|
|
25
|
+
|
|
26
|
+
| 경로 | 설명 |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `{basePath}` | 스트림 목록 (예: `/notice`, `/blog`) |
|
|
29
|
+
| `{basePath}/{slug}` | 글 상세 |
|
|
30
|
+
| `{basePath}/categories/{slug}` | 카테고리 아카이브 (`archives` 켠 스트림만) |
|
|
31
|
+
| `/feed.xml` | RSS — `feed` 켠 스트림들의 통합 피드 |
|
|
32
|
+
| `/sitemap.xml` | 글마다 **소속 스트림 basePath로** 정확히 매핑 |
|
|
33
|
+
| `{basePath}/{slug}/opengraph-image` | 글별 동적 OG 카드 (배선 시) |
|
|
34
|
+
|
|
35
|
+
규칙:
|
|
36
|
+
|
|
37
|
+
- **slug은 한글 그대로** 됩니다(예: `/blog/비문학독해`). 내부적으로 percent-encoding.
|
|
38
|
+
- **같은 글이 두 스트림에 안 뜸**: 공지 글을 `/blog/개강안내`로 열면 404, 반대도 404(가드).
|
|
39
|
+
- **글이 어느 스트림에도 안 속하면**(분류 전용 카테고리 등) sitemap·feed에서 제외됩니다.
|
|
40
|
+
- 한 글이 여러 스트림 카테고리를 동시에 가지면 **선언 순서가 빠른 스트림**이 이깁니다(first-wins).
|
|
41
|
+
|
|
42
|
+
## 어드민에서 설정 (`mysite.roottale.com`)
|
|
43
|
+
|
|
44
|
+
**설정 > 콘텐츠 유형** 에서 스트림을 정의합니다. 각 스트림은:
|
|
45
|
+
|
|
46
|
+
| 항목 | 의미 |
|
|
47
|
+
|---|---|
|
|
48
|
+
| key | 안정 식별자 (`notice`, `blog`) |
|
|
49
|
+
| 라벨 | 메뉴·작성 화면 표시 이름 (공지/블로그) |
|
|
50
|
+
| basePath | URL 앞부분 (`/notice`, `/blog`) |
|
|
51
|
+
| 카테고리 | 이 스트림에 속하는 카테고리 slug들. **비우면 catch-all**(나머지 전부) |
|
|
52
|
+
| feed / archives / og | RSS 포함 / 카테고리 아카이브 / 동적 OG |
|
|
53
|
+
| 순서 | 위에서부터 우선순위 |
|
|
54
|
+
|
|
55
|
+
**비우면 단일 블로그(`/blog`)** 로 동작합니다(설정 전 기본값).
|
|
56
|
+
|
|
57
|
+
### ⚠️ basePath는 "라우트가 있어야" 동작합니다 (데이터=DB, 라우트=코드)
|
|
58
|
+
|
|
59
|
+
basePath·라벨·카테고리·플래그는 어드민에서 바꾸면 sitemap·feed·라우팅이 즉시 따라갑니다.
|
|
60
|
+
**단 basePath에 해당하는 페이지 파일이 사이트에 있어야** 실제로 열립니다:
|
|
61
|
+
|
|
62
|
+
- `/notice`·`/blog`처럼 **이미 라우트가 있는 경로**는 어드민만으로 자유롭게 편집 → 동작.
|
|
63
|
+
- basePath를 **완전히 새 경로**(예: `/news`)로 바꾸면 sitemap엔 `/news/{slug}`가 나가지만
|
|
64
|
+
사이트에 `app/news/[slug]` 라우트가 없으면 **404**. 이 경우 개발자가 라우트를 먼저 추가해야
|
|
65
|
+
합니다. (새 basePath도 코드 수정 없이 동작시키려면 catch-all 동적 라우트를 쓰면 됩니다 — 아래.)
|
|
66
|
+
|
|
67
|
+
### 카테고리 만들기
|
|
68
|
+
|
|
69
|
+
각 스트림의 카테고리(`notice`, `column`, `news` 등)는 **설정 > 카테고리**(taxonomy)에서
|
|
70
|
+
만들고, 글 작성 화면에서 글에 붙입니다. 작성 화면에는 *"이 글은 → /notice 에 게시됩니다"*
|
|
71
|
+
표시가 떠서 어느 스트림으로 가는지 바로 확인됩니다.
|
|
72
|
+
|
|
73
|
+
## 사이트 연동 코드
|
|
74
|
+
|
|
75
|
+
스트림 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지
|
|
76
|
+
방식이 있습니다.
|
|
77
|
+
|
|
78
|
+
### 방식 A — 코드 상수 (간단, 고정)
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import type { RouteCollection } from "@roottale/cms-renderer-next/routes";
|
|
82
|
+
|
|
83
|
+
export const COLLECTIONS: RouteCollection[] = [
|
|
84
|
+
{ key: "notice", basePath: "/notice", categories: ["notice"] },
|
|
85
|
+
{
|
|
86
|
+
key: "blog",
|
|
87
|
+
basePath: "/blog",
|
|
88
|
+
categories: ["column", "news"], // 또는 [] = catch-all(공지 외 전부)
|
|
89
|
+
feed: true,
|
|
90
|
+
archives: true,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// app/sitemap.ts
|
|
97
|
+
import { createSitemap } from "@roottale/cms-renderer-next/routes";
|
|
98
|
+
export default createSitemap({ apiKey, siteUrl, title, collections: COLLECTIONS }, [
|
|
99
|
+
/* 정적 경로 */
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
// app/feed.xml/route.ts
|
|
103
|
+
export const dynamic = "force-dynamic";
|
|
104
|
+
export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: COLLECTIONS });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 방식 B — 어드민에서 동적 로드 (운영자가 직접 편집)
|
|
108
|
+
|
|
109
|
+
상수 대신 **어드민 "콘텐츠 유형"** 값을 `fetchCollections()`로 가져와 팩토리에 **resolver
|
|
110
|
+
함수**로 넘깁니다. 운영자가 어드민에서 스트림을 바꾸면 사이트가 따라갑니다.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { fetchCollections } from "@roottale/cms-client/server";
|
|
114
|
+
|
|
115
|
+
const DEFAULT: RouteCollection[] = [ /* 위와 동일 — fail-soft 기본값 */ ];
|
|
116
|
+
|
|
117
|
+
async function getCollections(): Promise<RouteCollection[]> {
|
|
118
|
+
try {
|
|
119
|
+
const c = await fetchCollections({ apiKey: process.env.ROOTTALE_API_KEY! });
|
|
120
|
+
return c.length ? c : DEFAULT;
|
|
121
|
+
} catch {
|
|
122
|
+
return DEFAULT; // API 미설정/실패 시 기본값
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default createSitemap({ apiKey, siteUrl, title, collections: getCollections }, [ ]);
|
|
127
|
+
export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: getCollections });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
공개 엔드포인트: `GET /v1/cms/public/collections` (블로그 조회와 같은 API 키).
|
|
131
|
+
응답은 `RouteCollection`과 구조 호환이라 그대로 넘길 수 있습니다. 매 요청 fetch를 피하려면
|
|
132
|
+
사이트 경계에서 캐시하세요(예: Next `fetch(url, { next: { revalidate: 300 } })`).
|
|
133
|
+
|
|
134
|
+
### 상세 페이지 가드 (스트림 누출 차단)
|
|
135
|
+
|
|
136
|
+
상세 라우트는 글이 그 스트림 소속인지 확인해 다른 스트림 글이 새는 것을 막습니다.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { resolvePostCollection } from "@roottale/cms-renderer-next/routes";
|
|
140
|
+
// app/blog/[slug]/page.tsx
|
|
141
|
+
const post = await getPost(slug);
|
|
142
|
+
if (!post || resolvePostCollection(post, COLLECTIONS)?.key !== "blog") notFound();
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### revalidate
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
|
|
149
|
+
export const POST = createRevalidateRoute({ apiKey, revalidate, collections: COLLECTIONS });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
각 스트림 basePath(+ `archives`면 `/categories`)와 `/feed.xml`·`/sitemap.xml`·`/llms.txt`를
|
|
153
|
+
자동 무효화합니다. resolver가 실패하면 잘못된 경로를 추측하지 않고 불변 경로만 갱신하며
|
|
154
|
+
응답에 `warning`을 노출합니다.
|
|
155
|
+
|
|
156
|
+
### 동적 OG 이미지
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
// app/blog/[slug]/opengraph-image.tsx
|
|
160
|
+
import { ImageResponse } from "next/og";
|
|
161
|
+
import {
|
|
162
|
+
createPostOgImage, OG_IMAGE_SIZE, OG_IMAGE_CONTENT_TYPE,
|
|
163
|
+
type ImageResponseLike,
|
|
164
|
+
} from "@roottale/cms-renderer-next/routes";
|
|
165
|
+
|
|
166
|
+
export const size = OG_IMAGE_SIZE;
|
|
167
|
+
export const contentType = OG_IMAGE_CONTENT_TYPE;
|
|
168
|
+
export default createPostOgImage(
|
|
169
|
+
{ apiKey, siteUrl, title, brandLabel: "내 사이트" },
|
|
170
|
+
{ ImageResponse: ImageResponse as unknown as ImageResponseLike }, // Next 16 타입 cast
|
|
171
|
+
);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
> Astro 사이트는 `@roottale/cms-renderer-astro`에서 동일한 `resolvePostCollection`/
|
|
175
|
+
> `resolvePostPath`/`RouteCollection`을 import 하고 `renderBlogList({ collections })`로
|
|
176
|
+
> 링크를 스트림별로 라우팅합니다(동등 surface).
|
|
177
|
+
|
|
178
|
+
## catch-all 스트림
|
|
179
|
+
|
|
180
|
+
블로그 카테고리가 계속 늘어나는 사이트는, 블로그를 **`categories: []`(빈 배열) = catch-all**로
|
|
181
|
+
두면 됩니다 — 공지로 분류되지 않은 글을 전부 흡수합니다. catch-all은 **맨 뒤**에 두세요
|
|
182
|
+
(선언 순서가 우선순위라, 뒤에 둔 스트림은 도달 못 합니다).
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
[
|
|
186
|
+
{ key: "notice", basePath: "/notice", categories: ["notice"] },
|
|
187
|
+
{ key: "blog", basePath: "/blog", categories: [], feed: true }, // 나머지 전부
|
|
188
|
+
]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## slug 변경과 301
|
|
192
|
+
|
|
193
|
+
글의 slug(`/{slug}` 부분)는 글 편집 화면에서 바꿉니다. 바꾼 뒤 옛 slug로 들어오면 `postRedirectPath`로
|
|
194
|
+
**301 리다이렉트**되어 새 slug로 넘어갑니다(검색 순위 보존).
|
|
195
|
+
|
|
196
|
+
## 동적 basePath (advanced)
|
|
197
|
+
|
|
198
|
+
basePath를 **코드 수정 없이 어드민에서 자유롭게** 바꾸고 싶으면, 정적 `app/notice/[slug]`
|
|
199
|
+
대신 **catch-all 동적 라우트** `app/[stream]/[slug]/page.tsx`(또는 `app/[...path]`)를 두고,
|
|
200
|
+
그 안에서 collections를 읽어 요청 경로가 어떤 스트림 basePath인지 판정해 렌더합니다. 그러면
|
|
201
|
+
어드민에서 basePath를 `/news`로 바꿔도 동작합니다. 트레이드오프: 정적 라우트보다 캐시·타입
|
|
202
|
+
안전성이 떨어지므로, 스트림 구조가 자주 바뀌는 사이트에만 권장합니다.
|
|
203
|
+
|
|
204
|
+
## 자주 막히는 곳
|
|
205
|
+
|
|
206
|
+
- **글이 안 보여요** → 그 글에 스트림 카테고리(`notice`/`column`/`news` 등)가 붙어 있는지 확인.
|
|
207
|
+
어드민 콘텐츠 유형이 비어 있으면 사이트는 코드 기본값으로만 동작합니다.
|
|
208
|
+
- **404가 떠요** → 공지 글을 `/blog/...`로(또는 그 반대로) 열면 가드가 막습니다. 올바른 스트림
|
|
209
|
+
basePath로 접근하세요. basePath를 바꿨다면 사이트에 그 라우트 파일이 있는지 확인.
|
|
210
|
+
- **sitemap에 글이 빠졌어요** → 그 글이 어느 스트림에도 안 속하면(상세 라우트 없는 분류 전용)
|
|
211
|
+
의도적으로 제외됩니다.
|
package/docs/seo.md
CHANGED
|
@@ -51,6 +51,13 @@ export default createSitemap(
|
|
|
51
51
|
);
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
## 다중 스트림 (collections) — 공지·블로그 분리
|
|
55
|
+
|
|
56
|
+
같은 글 풀을 공지 게시판(`/notice`) + 블로그(`/blog`) 등 여러 스트림으로 나눠 서로 다른
|
|
57
|
+
URL·레이아웃으로 보여줄 수 있습니다. URL 구조·어드민 설정·연동 코드(코드 상수 vs 어드민
|
|
58
|
+
fetch)·가드·catch-all·동적 basePath는 별도 문서 [콘텐츠 유형 (Collections)](./collections.md)
|
|
59
|
+
에 정리되어 있습니다. 여기 sitemap/feed 예시도 `collections`를 넘기면 스트림별로 파생됩니다.
|
|
60
|
+
|
|
54
61
|
## robots.txt
|
|
55
62
|
|
|
56
63
|
크롤링 제어의 기본. sitemap 위치를 알려주고, 크롤링이 무의미한 경로만
|
|
@@ -137,6 +144,7 @@ import {
|
|
|
137
144
|
createPostOgImage,
|
|
138
145
|
OG_IMAGE_SIZE,
|
|
139
146
|
OG_IMAGE_CONTENT_TYPE,
|
|
147
|
+
type ImageResponseLike,
|
|
140
148
|
} from "@roottale/cms-renderer-next/routes";
|
|
141
149
|
|
|
142
150
|
export const size = OG_IMAGE_SIZE; // { width: 1200, height: 630 }
|
|
@@ -150,7 +158,9 @@ export default createPostOgImage(
|
|
|
150
158
|
// 선택 — 브랜드 색 커스텀:
|
|
151
159
|
// backgroundColor: "#10172a", accentColor: "#38bdf8", brandLabel: "예시",
|
|
152
160
|
},
|
|
153
|
-
|
|
161
|
+
// Next 16 의 ImageResponse 타입은 패키지 ImageResponseLike 와 미묘하게 달라
|
|
162
|
+
// cast 가 필요하다(패키지는 next 비의존이라 구조적 타입만 안다).
|
|
163
|
+
{ ImageResponse: ImageResponse as unknown as ImageResponseLike },
|
|
154
164
|
);
|
|
155
165
|
```
|
|
156
166
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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": {
|