@roottale/cms-mcp 0.53.1 → 0.56.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 +74 -0
- package/README.md +17 -9
- package/dist/index.js +660 -45
- package/dist/index.js.map +1 -1
- package/docs/api-reference.md +70 -9
- package/docs/blog.md +108 -1
- package/docs/collections.md +35 -1
- package/docs/content-models-and-exposures.md +329 -32
- package/docs/custom-redirects.md +5 -0
- package/docs/getting-started.md +20 -10
- package/docs/revalidation-webhooks.md +7 -1
- package/docs/seo.md +66 -0
- package/docs/theme-and-settings.md +3 -0
- package/examples/nextjs/app/blog/[slug]/page.tsx +4 -2
- package/examples/nextjs/app/blog/categories/[slug]/page.tsx +1 -1
- package/examples/nextjs/app/blog/page.tsx +1 -1
- package/examples/nextjs/app/preview/post/[id]/page.tsx +70 -0
- package/examples/nextjs/lib/blog.ts +29 -0
- package/examples/nextjs/lib/content-models.ts +12 -0
- package/package.json +2 -2
|
@@ -1,36 +1,339 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: 콘텐츠 모델과 노출
|
|
3
|
-
description: 페이지·글·정보
|
|
2
|
+
title: 콘텐츠 모델과 노출 관리
|
|
3
|
+
description: 페이지·글·정보 모델, 확장 필드, 배너·팝업을 API·MCP·CLI로 관리하는 방법
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# 콘텐츠 모델과 노출
|
|
6
|
+
# 콘텐츠 모델과 노출 관리
|
|
7
7
|
|
|
8
|
-
RootTale
|
|
8
|
+
RootTale의 콘텐츠 관리 단위는 다음과 같습니다.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
| 리소스 | 뜻 | 안정 식별자 |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| 콘텐츠 모델 | 페이지·글·정보의 구조와 화면 연결 | `model_key` |
|
|
13
|
+
| 필드 그룹 | 모델에 붙는 구조화된 추가 필드 | `model_key` + `group_key` |
|
|
14
|
+
| 항목 | 모델에 속한 실제 페이지·글·정보 | `post_id` |
|
|
15
|
+
| 노출 슬롯 | FRONT가 선언한 배너·팝업 위치 계약 | `slot_key` |
|
|
16
|
+
| 노출 캠페인 | 슬롯에 표시할 내용·기간·경로 | `exposure_id` |
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
`
|
|
18
|
+
`source: "code"`인 모델·필드 그룹은 개발자 계약입니다. 구조를 API로 바꾸거나
|
|
19
|
+
삭제할 수 없습니다. 모델 상태만 활성화·비활성화할 수 있습니다.
|
|
20
|
+
`source: "site"`인 리소스는 관리 API로 만들고 수정할 수 있습니다.
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
import { fetchContentModels, fetchPosts } from "@roottale/cms-client/server";
|
|
22
|
+
## 권한
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
전체 자동화에는 API 키 프로필 `full_management`를 권장합니다. 세부 권한은
|
|
25
|
+
다음과 같이 분리됩니다.
|
|
26
|
+
|
|
27
|
+
| 권한 | 허용 작업 |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `cms:read` | 모델·필드·항목·노출 슬롯·캠페인 조회 |
|
|
30
|
+
| `cms:write` | 항목 작성·수정·삭제 |
|
|
31
|
+
| `cms:publish` | 항목 발행·발행 취소 |
|
|
32
|
+
| `content-models:write` | site 모델·필드 그룹 관리, code 모델 상태 변경 |
|
|
33
|
+
| `exposures:write` | 노출 캠페인 초안 작성·수정 |
|
|
34
|
+
| `exposures:publish` | 노출 발행·보관, 발행 중 캠페인 수정 |
|
|
35
|
+
|
|
36
|
+
이전 키 프로필에는 새 쓰기 권한이 자동으로 추가되지 않습니다. 자동화에 필요한
|
|
37
|
+
권한을 명시해 새 키를 발급하세요.
|
|
38
|
+
|
|
39
|
+
## 콘텐츠 모델과 필드 그룹
|
|
40
|
+
|
|
41
|
+
모델 API:
|
|
42
|
+
|
|
43
|
+
- `GET|POST /v1/cms/content-models`
|
|
44
|
+
- `GET|PATCH|DELETE /v1/cms/content-models/{model_key}`
|
|
45
|
+
- `GET|POST /v1/cms/content-models/{model_key}/field-groups`
|
|
46
|
+
- `GET|PATCH|DELETE /v1/cms/content-models/{model_key}/field-groups/{group_key}`
|
|
47
|
+
|
|
48
|
+
수정·삭제는 응답의 `updated_at`을 `expected_updated_at`으로 다시 보내야 합니다.
|
|
49
|
+
다른 사용자가 먼저 바꿨다면 `409 version_conflict`가 납니다. 사용 중인 모델과
|
|
50
|
+
필드 그룹은 삭제할 수 없습니다.
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"key": "team-member",
|
|
55
|
+
"label": "구성원",
|
|
56
|
+
"cardinality": "collection",
|
|
57
|
+
"preset": "entity",
|
|
58
|
+
"presentation": {
|
|
59
|
+
"kind": "detail",
|
|
60
|
+
"detailPath": "/team/:slug",
|
|
61
|
+
"templateKey": "team-member"
|
|
62
|
+
},
|
|
63
|
+
"publication": null,
|
|
64
|
+
"definition": {}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
필드 그룹의 `definition`은 필드 배열과 검증 규칙을 담습니다. 정의되지 않은
|
|
69
|
+
`field_values` 키, 형식이 틀린 값, 다른 사이트 항목을 가리키는 관계 값은 저장되지
|
|
70
|
+
않습니다.
|
|
71
|
+
|
|
72
|
+
## 계층형 분류 모델
|
|
73
|
+
|
|
74
|
+
FAQ·도움말·문서처럼 목록 아래에 여러 단계의 분류 허브가 필요한 모델은
|
|
75
|
+
`presentation.kind: "category_tree"`를 사용합니다. 이 계약은 FAQ 전용 기능이
|
|
76
|
+
아니며 1~3단계 분류를 지원합니다.
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"key": "faq",
|
|
81
|
+
"cardinality": "collection",
|
|
82
|
+
"preset": "article",
|
|
83
|
+
"presentation": {
|
|
84
|
+
"kind": "category_tree",
|
|
85
|
+
"basePath": "/faq",
|
|
86
|
+
"categoryDepth": 2,
|
|
87
|
+
"templateKey": "faq"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 목록·피드 규칙도 모델이 소유합니다
|
|
93
|
+
|
|
94
|
+
글 목록의 카테고리 모음, RSS 피드, 허브 주소 형식처럼 예전 `collections`
|
|
95
|
+
설정에만 있던 규칙은 이제 `detail`·`category_tree` presentation의 선택 필드로
|
|
96
|
+
선언합니다. 모두 생략 가능하며 기본값은 컬렉션이 없을 때의 동작과 같습니다.
|
|
97
|
+
|
|
98
|
+
| 필드 | 의미 | 기본값 |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `archives` | 카테고리 모음 주소(`{목록}/categories/{slug}` 또는 direct)를 발행·갱신 | `false` |
|
|
101
|
+
| `feed` | RSS 피드에 포함 | `false` |
|
|
102
|
+
| `categoryPath` | 카테고리 허브 주소 형식 — `namespaced` = `{목록}/categories/{slug}`, `direct` = `{목록}/{slug}` | `namespaced` |
|
|
103
|
+
| `layout` | 목록 레이아웃 힌트(FRONT가 해석, 라우팅과 무관) | 없음 |
|
|
104
|
+
|
|
105
|
+
`detail`의 목록 주소는 `detailPath`에서 `/:slug`를 뗀 부모 경로이고,
|
|
106
|
+
`category_tree`는 `basePath`가 목록입니다. 예전 `collections` 응답은 호환을 위해
|
|
107
|
+
유지되지만 새 사이트는 모델 presentation만 읽으면 됩니다.
|
|
108
|
+
|
|
109
|
+
**RootTale 표준 블로그 주소** — RootTale이 만드는 사이트(스타터·`roottale init` 시드)의
|
|
110
|
+
`blog` 모델은 아래 규칙 하나를 씁니다: 글 `/blog/{category}/{slug}`, 카테고리 허브
|
|
111
|
+
`/blog/{category}`, 목록 `/blog`. 1단계 `category_tree`이고 글마다 카테고리를 정확히
|
|
112
|
+
하나 고릅니다(`categoryCardinality: "exactly-one"`). 옛 평면 주소 `/blog/{slug}`와
|
|
113
|
+
`/blog/categories/{slug}`는 FRONT가 정본으로 301 합니다.
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"kind": "category_tree",
|
|
118
|
+
"basePath": "/blog",
|
|
119
|
+
"categoryDepth": 1,
|
|
120
|
+
"templateKey": "blog",
|
|
121
|
+
"categoryPath": "direct",
|
|
122
|
+
"categoryCardinality": "exactly-one",
|
|
123
|
+
"archives": true,
|
|
124
|
+
"feed": true
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
직접 만드는 사이트가 평면 상세 주소를 유지해도 됩니다 — 그때는 `detail` 규칙을
|
|
129
|
+
선언하고 FRONT 라우트를 그 규칙에 맞추면 됩니다.
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"kind": "detail",
|
|
134
|
+
"detailPath": "/blog/:slug",
|
|
135
|
+
"templateKey": "blog",
|
|
136
|
+
"archives": true,
|
|
137
|
+
"feed": true
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
공개 사이트는 `fetchCategories({ collectionKey: "faq" })`의 `id`와 `parentId`로
|
|
142
|
+
루트부터 말단까지의 분류 사슬을 만들고, `fetchPosts({ modelKey: "faq" })`의 글마다
|
|
143
|
+
말단 카테고리를 정확히 하나 연결합니다. 위 예시는 다음 주소를 표현합니다.
|
|
144
|
+
|
|
145
|
+
- `/faq`
|
|
146
|
+
- `/faq/headache`
|
|
147
|
+
- `/faq/headache/migraine`
|
|
148
|
+
- `/faq/headache/migraine/{slug}`
|
|
149
|
+
|
|
150
|
+
ROOT-ADMIN은 발행·예약 시 선언한 깊이의 말단 분류인지 다시 검사합니다. 부모만
|
|
151
|
+
고르거나 복수 분류를 연결한 항목은 발행하지 않습니다. 분류 `slug`나 부모 관계를
|
|
152
|
+
바꾸는 일은 공개 주소 변경이므로 기존 발행 글이 있으면 일반 라우팅 변경 보호를
|
|
153
|
+
따릅니다.
|
|
154
|
+
|
|
155
|
+
코드로 배포하는 모델은 `editor`에서 저장 구조를 바꾸지 않고 ROOT-ADMIN의 기본
|
|
156
|
+
필드 이름과 분류 단계 이름만 바꿀 수 있습니다.
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"editor": {
|
|
161
|
+
"labels": {
|
|
162
|
+
"title": "질문",
|
|
163
|
+
"excerpt": "짧은 답변",
|
|
164
|
+
"body": "상세 답변",
|
|
165
|
+
"categories": "질환 분류",
|
|
166
|
+
"tags": "검색 태그"
|
|
167
|
+
},
|
|
168
|
+
"categoryLevels": ["진료 영역", "세부 질환"]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`categoryLevels`의 개수는 `categoryDepth`와 같아야 합니다. 최종 선택에서는 말단
|
|
174
|
+
분류 ID 하나만 기존 카테고리 관계로 저장됩니다.
|
|
175
|
+
|
|
176
|
+
관계형 커스텀 필드는 `postType`과 함께 `modelKey`를 지정하면 선택 대상을 같은
|
|
177
|
+
콘텐츠 모델로 좁힐 수 있습니다. 예를 들어 `relationship` 필드에
|
|
178
|
+
`"postType": "post", "modelKey": "faq"`를 선언하면 관련 FAQ만 표시됩니다.
|
|
179
|
+
|
|
180
|
+
아직 CMS에 글이나 초안이 없는 미래 콘텐츠는 `textarea`에 내부 콘텐츠 키 형식을
|
|
181
|
+
선언해 예약할 수 있습니다. ROOT-ADMIN은 전용 입력기에서 접두사·중복·최대 개수를
|
|
182
|
+
검사하며, 저장 API도 같은 규칙을 적용합니다. 값은 공개 `fields`에 줄바꿈 문자열로
|
|
183
|
+
그대로 제공되므로 고객 FRONT가 현재 발행 원장과 대조해 링크 노출을 결정합니다.
|
|
184
|
+
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"key": "field_related_content_keys",
|
|
188
|
+
"name": "related_content_keys",
|
|
189
|
+
"label": "미발행 FAQ 예약 키",
|
|
190
|
+
"type": "textarea",
|
|
191
|
+
"format": "internal_content_keys",
|
|
192
|
+
"keyPrefix": "faq",
|
|
193
|
+
"maxItems": 5
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
키는 `faq.headache.migraine.aura-symptoms`처럼 영문 소문자·숫자·한글과 점·
|
|
198
|
+
하이픈으로 구성합니다. `keyPrefix`를 주면 해당 접두사로 시작하는 키만 저장할 수
|
|
199
|
+
있습니다. 이 필드 자체는 미발행 URL을 만들지 않습니다.
|
|
200
|
+
|
|
201
|
+
### 본문 안의 예약 내부 링크
|
|
202
|
+
|
|
203
|
+
글 본문에서는 다른 글을 텍스트 표기로 연결할 수 있고, 아직 발행되지 않은 글도 미리
|
|
204
|
+
연결해 둘 수 있습니다.
|
|
205
|
+
|
|
206
|
+
```text
|
|
207
|
+
[[internal:{키}|표시 문구]]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**키는 대상 글의 정규 공개 경로 조각을 점으로 이은 것**입니다. 경로는 사이트맵·
|
|
211
|
+
단축링크와 같은 규칙으로 정해지므로 콘텐츠 유형에 관계없이 한 규칙입니다.
|
|
212
|
+
|
|
213
|
+
| 공개 경로 | 키 |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `/faq/headache/migraine/aura-symptoms` (계층형 분류 모델) | `faq.headache.migraine.aura-symptoms` |
|
|
216
|
+
| `/column/headache/my-post` (카테고리 주소 컬렉션) | `column.headache.my-post` |
|
|
217
|
+
| `/blog/my-post` (일반 컬렉션) | `blog.my-post` |
|
|
218
|
+
| `/team/hong` (상세 프리셋 모델) | `team.hong` |
|
|
219
|
+
|
|
220
|
+
고정 페이지·`data_only` 모델처럼 글별 상세 주소가 없는 콘텐츠는 대상이 되지 않습니다.
|
|
221
|
+
|
|
222
|
+
ROOT-ADMIN 편집기 도구 모음의 **내부 링크 삽입** 버튼이 이 표기를 대신 만들어
|
|
223
|
+
줍니다. 작성자는 콘텐츠 유형과 분류를 이름으로 고르고 글(초안 포함)을 목록에서
|
|
224
|
+
고르거나, 아직 없는 글의 slug만 적습니다. 각 글의 게시 주소 영역에는 다른 글에서
|
|
225
|
+
이 글을 연결할 때 쓰는 키가 복사 버튼과 함께 표시됩니다.
|
|
226
|
+
|
|
227
|
+
이 표기는 본문 `body_json`의 일반 텍스트로 저장되며 공개 API도 그대로 내보냅니다.
|
|
228
|
+
해석은 고객 FRONT의 몫입니다 — 텍스트 구간에서 표기를 찾아, 현재 발행 원장의 글
|
|
229
|
+
경로를 같은 규칙으로 키로 바꿔 대조한 뒤 있으면 링크로, 없으면 표시 문구만
|
|
230
|
+
렌더링하세요. `code`·`pre`·이미 링크된 구간과 속성값은 변환하지 않는 것을 권장합니다.
|
|
231
|
+
|
|
232
|
+
**글의 주소는 계산하지 말고 읽으세요.** 공개 글 응답의 `path`(`@roottale/cms-client`
|
|
233
|
+
에서는 `post.path`)가 플랫폼이 저장한 정규 공개 경로입니다(예 `/column/my-post`,
|
|
234
|
+
상세 주소가 없는 글은 `null`). 링크·사이트맵·내부 링크 키에 이 값을 그대로 쓰면
|
|
235
|
+
모델 규칙이 바뀌어도 FRONT 코드를 고칠 필요가 없습니다. 내부 링크 키는 이 경로의
|
|
236
|
+
조각을 점으로 이은 값과 같습니다.
|
|
237
|
+
|
|
238
|
+
**주소가 바뀐 글도 자동으로 따라가게 하려면** 공개 글 응답의 `previous_slugs`(옛 slug
|
|
239
|
+
목록, 최신순 — `@roottale/cms-client`에서는 `previousSlugs`)를 함께 쓰세요. 현재 경로의
|
|
240
|
+
마지막 조각을 옛 slug로 바꾼 경로도 같은 글의 키로 등록하면, `[[internal:blog.old-slug|…]]`
|
|
241
|
+
처럼 옛 키로 남아 있는 본문도 현재 주소로 렌더됩니다(분류 이동은 이력이 없어 대상 밖).
|
|
242
|
+
|
|
243
|
+
## 페이지·글·정보 항목
|
|
244
|
+
|
|
245
|
+
항목 API는 기존 `/v1/cms/posts`를 그대로 쓰며 모든 응답에 `model_key`와
|
|
246
|
+
`field_values`가 포함됩니다.
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{
|
|
250
|
+
"model_key": "team-member",
|
|
251
|
+
"title": "홍길동 세무사",
|
|
252
|
+
"slug": "hong-gildong",
|
|
253
|
+
"body_json": {"type":"doc","content":[]},
|
|
254
|
+
"field_values": {
|
|
255
|
+
"position": "대표 세무사",
|
|
256
|
+
"specialties": ["법인세", "상속세"]
|
|
257
|
+
}
|
|
258
|
+
}
|
|
27
259
|
```
|
|
28
260
|
|
|
29
|
-
|
|
261
|
+
- `page` 모델은 `type: "page"`, `article`·`entity` 모델은 `type: "post"`로
|
|
262
|
+
저장됩니다. `model_key`와 `type`이 다르면 요청을 거부합니다.
|
|
263
|
+
- `entity` 항목에는 작성자·카테고리·태그를 붙일 수 없습니다.
|
|
264
|
+
- 작성자를 사용하는 `article` 모델은 발행·예약 전에 활성 공개 작성자가 필요합니다.
|
|
265
|
+
- 기존 자동화가 `model_key`를 생략하면 활성 page/article 모델이 정확히 하나일 때만
|
|
266
|
+
호환 처리됩니다. 후보가 여러 개면 `400 model_key_required`입니다.
|
|
267
|
+
- 항목의 모델은 생성 뒤 바꿀 수 없습니다. 다른 모델로 옮기려면 새 항목을 만드세요.
|
|
30
268
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
269
|
+
공개 FRONT는 검증된 표시 값 `fields`를 읽습니다. 관리 자동화는 편집 가능한 원문
|
|
270
|
+
`field_values`를 사용합니다.
|
|
271
|
+
|
|
272
|
+
## 노출 슬롯과 캠페인
|
|
273
|
+
|
|
274
|
+
노출 슬롯은 FRONT의 사이트 콘텐츠 계약에서 옵니다. 관리 API로 슬롯을 만들거나
|
|
275
|
+
수정할 수 없습니다.
|
|
276
|
+
|
|
277
|
+
- `GET /v1/cms/exposure-slots`
|
|
278
|
+
- `GET|POST /v1/cms/exposures`
|
|
279
|
+
- `GET|PATCH /v1/cms/exposures/{exposure_id}`
|
|
280
|
+
- `POST /v1/cms/exposures/{exposure_id}/publish`
|
|
281
|
+
- `POST /v1/cms/exposures/{exposure_id}/archive`
|
|
282
|
+
|
|
283
|
+
생성은 항상 초안입니다. 수정·발행·보관은 `version`을 확인합니다. 발행 응답의
|
|
284
|
+
`overlap`은 같은 슬롯·경로·기간에 겹치는 캠페인 수와 ID를 알려 줍니다. 겹침은
|
|
285
|
+
오류가 아니며, 공개 FRONT는 우선순위와 최신순 규칙으로 한 건을 선택합니다.
|
|
286
|
+
|
|
287
|
+
```json
|
|
288
|
+
{
|
|
289
|
+
"slot_key": "global-popup",
|
|
290
|
+
"kind": "popup",
|
|
291
|
+
"content": {"variant":"notice","title":"여름 휴무 안내"},
|
|
292
|
+
"starts_at": "2026-08-20T00:00:00.000Z",
|
|
293
|
+
"ends_at": "2026-08-25T00:00:00.000Z",
|
|
294
|
+
"target_paths": ["/"],
|
|
295
|
+
"priority": 10,
|
|
296
|
+
"repeat": "session"
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## MCP와 CLI
|
|
301
|
+
|
|
302
|
+
MCP는 각 HTTP 작업을 같은 이름의 도구로 제공합니다.
|
|
303
|
+
|
|
304
|
+
- 모델: `listCmsContentModels`, `getCmsContentModel`, `createCmsContentModel`,
|
|
305
|
+
`updateCmsContentModel`, `activateCmsContentModel`, `deactivateCmsContentModel`,
|
|
306
|
+
`deleteCmsContentModel`
|
|
307
|
+
- 필드: `listCmsFieldGroups`, `getCmsFieldGroup`, `createCmsFieldGroup`,
|
|
308
|
+
`updateCmsFieldGroup`, `activateCmsFieldGroup`, `deactivateCmsFieldGroup`,
|
|
309
|
+
`deleteCmsFieldGroup`
|
|
310
|
+
- 항목: `listManagedCmsPosts`, `getManagedCmsPost`, `createCmsPost`,
|
|
311
|
+
`updateCmsPost`, `publishCmsPost`, `unpublishCmsPost`, `deleteCmsPost`
|
|
312
|
+
- 노출: `listCmsExposureSlots`, `listCmsExposures`, `getCmsExposure`,
|
|
313
|
+
`createCmsExposure`, `updateCmsExposure`, `publishCmsExposure`,
|
|
314
|
+
`archiveCmsExposure`
|
|
315
|
+
|
|
316
|
+
CLI의 큰 JSON 입력은 camelCase 키를 쓰는 `--input-file`로 전달합니다.
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
npx -y @roottale/cms-mcp cli models list
|
|
320
|
+
npx -y @roottale/cms-mcp cli models create --input-file ./model.json
|
|
321
|
+
npx -y @roottale/cms-mcp cli fields create team-member --input-file ./fields.json
|
|
322
|
+
npx -y @roottale/cms-mcp cli entries create \
|
|
323
|
+
--model-key team-member --title "홍길동" --slug hong-gildong \
|
|
324
|
+
--body-file ./body.json --field-values-file ./values.json
|
|
325
|
+
npx -y @roottale/cms-mcp cli exposure-slots list
|
|
326
|
+
npx -y @roottale/cms-mcp cli exposures create --input-file ./exposure.json
|
|
327
|
+
npx -y @roottale/cms-mcp cli exposures publish "<exposure-id>"
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
`posts`는 기존 스크립트 호환 별칭입니다. 새 자동화는 `entries`를 권장합니다.
|
|
331
|
+
|
|
332
|
+
## 공개 FRONT 연결
|
|
333
|
+
|
|
334
|
+
고객 FRONT는 `fetchContentModels()`와 `fetchPosts({ modelKey })`로 활성 모델과
|
|
335
|
+
항목을 읽습니다. 노출은 개발자가 `RootTaleExposureSlot`을 배치한 위치에만
|
|
336
|
+
표시됩니다.
|
|
34
337
|
|
|
35
338
|
```tsx
|
|
36
339
|
import { RootTaleExposureSlot } from "@roottale/cms-renderer-next/server";
|
|
@@ -42,22 +345,16 @@ export default async function Layout({ children }: { children: React.ReactNode }
|
|
|
42
345
|
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
43
346
|
slotKey="global-popup"
|
|
44
347
|
path="/"
|
|
45
|
-
allowedVariants={["
|
|
348
|
+
allowedVariants={["notice"]}
|
|
46
349
|
revalidate={60}
|
|
47
350
|
/>
|
|
48
351
|
</>;
|
|
49
352
|
}
|
|
50
353
|
```
|
|
51
354
|
|
|
52
|
-
|
|
53
|
-
팝업은 닫기 버튼과 Escape 닫기를 제공하고 `always|session|day|never` 재노출 정책을
|
|
54
|
-
적용합니다. `@roottale/cms-renderer-next/styles`를 root layout에서 한 번 불러오세요.
|
|
55
|
-
|
|
56
|
-
## Raw API
|
|
355
|
+
공개 raw API는 다음 세 경로입니다.
|
|
57
356
|
|
|
58
357
|
- `GET /v1/cms/public/content-models`
|
|
59
|
-
- `GET /v1/cms/public/posts?model_key=
|
|
358
|
+
- `GET /v1/cms/public/posts?model_key=team-member`
|
|
359
|
+
- `GET /v1/cms/public/categories?collection_key=faq` (`id`, `parent_id` 포함)
|
|
60
360
|
- `GET /v1/cms/public/exposures?slot_key=global-popup&path=%2Fabout`
|
|
61
|
-
|
|
62
|
-
모두 고객용 API key와 `cms:read` 범위가 필요합니다. 노출 API는 선택된 공개 내용만
|
|
63
|
-
반환하며 초안, 보관함, 내부 일정·타기팅 원문은 반환하지 않습니다.
|
package/docs/custom-redirects.md
CHANGED
|
@@ -17,6 +17,11 @@ description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로
|
|
|
17
17
|
- **커스텀 리다이렉트(이 문서)** — 글이 아닌 임의 경로를 옮깁니다. 라우팅
|
|
18
18
|
*이전* 단계인 **미들웨어**에서만 가로챌 수 있어, 아래 설정이 필요합니다.
|
|
19
19
|
|
|
20
|
+
글의 공개 주소가 바뀌면(slug 변경·분류 이동·모델 규칙 변경) 플랫폼이 옛 경로 → 현재
|
|
21
|
+
경로 301 을 **자동으로** 이 목록에 더합니다(`id` 가 `path-history:` 로 시작). 운영자가
|
|
22
|
+
같은 출발 경로 규칙을 만들었으면 운영자 규칙이 이깁니다. 미들웨어를 쓰고 있다면 별도
|
|
23
|
+
작업 없이 옛 링크가 새 주소로 갑니다.
|
|
24
|
+
|
|
20
25
|
규칙은 `GET /v1/cms/public/redirects` 로 내려오며 **활성** 규칙만 포함됩니다
|
|
21
26
|
(`api-reference.md`). 출발 경로는 정규화된 사이트 내부 절대 경로, 도착지는
|
|
22
27
|
내부 경로 또는 절대 URL, 상태는 `301`(영구) 또는 `302`(임시)입니다.
|
package/docs/getting-started.md
CHANGED
|
@@ -137,10 +137,11 @@ console.log(page.items.map((p) => p.slug));
|
|
|
137
137
|
|
|
138
138
|
`401` 에러(`invalid_key`)면 키 값/환경변수 로딩을 확인하세요.
|
|
139
139
|
|
|
140
|
-
## 5. MCP로
|
|
140
|
+
## 5. MCP로 전체 CMS 자동화
|
|
141
141
|
|
|
142
|
-
MCP 설정의 `ROOTTALE_API_KEY`에 `
|
|
143
|
-
있습니다.
|
|
142
|
+
MCP 설정의 `ROOTTALE_API_KEY`에 `full_management` 키를 넣으면 모델·필드·항목·
|
|
143
|
+
노출·미디어 도구를 모두 쓸 수 있습니다. 글과 이미지만 관리한다면 기존
|
|
144
|
+
`read_write` 키로 충분합니다.
|
|
144
145
|
|
|
145
146
|
1. `getSiteKnowledge`로 브랜드 보이스와 금지어 확인
|
|
146
147
|
2. `uploadCmsMedia`로 썸네일 또는 본문 삽화 업로드
|
|
@@ -148,6 +149,11 @@ MCP 설정의 `ROOTTALE_API_KEY`에 `read_write` 키를 넣으면 다음 tool을
|
|
|
148
149
|
4. 필요하면 `setCmsPostTerms`로 카테고리·태그 연결
|
|
149
150
|
5. 검토 후 `publishCmsPost`로 발행
|
|
150
151
|
|
|
152
|
+
정보 모델을 새로 자동화할 때는 `createCmsContentModel` →
|
|
153
|
+
`createCmsFieldGroup` → `createCmsPost(modelKey, fieldValues)` 순서로 진행합니다.
|
|
154
|
+
배너·팝업은 `listCmsExposureSlots`로 FRONT 계약을 먼저 읽고
|
|
155
|
+
`createCmsExposure` → `publishCmsExposure` 순서로 진행합니다.
|
|
156
|
+
|
|
151
157
|
썸네일은 업로드 응답의 `id`를 `featuredMediaId`에 넣습니다. 본문 삽화는
|
|
152
158
|
응답의 `url`을 Tiptap image 노드에 넣습니다.
|
|
153
159
|
|
|
@@ -175,31 +181,35 @@ export ROOTTALE_API_KEY=rtlk_cust_xxxxxxxxxxxxxxxxxxxxxxxx
|
|
|
175
181
|
npx -y @roottale/cms-mcp cli media upload ./thumbnail.webp \
|
|
176
182
|
--alt "글 대표 이미지"
|
|
177
183
|
|
|
178
|
-
npx -y @roottale/cms-mcp cli
|
|
184
|
+
npx -y @roottale/cms-mcp cli entries create \
|
|
179
185
|
--title "새 글" \
|
|
180
186
|
--slug "new-post" \
|
|
181
187
|
--body-file ./post.json \
|
|
182
188
|
--featured-media-id "<업로드 응답의 id>"
|
|
183
189
|
|
|
184
|
-
npx -y @roottale/cms-mcp cli
|
|
190
|
+
npx -y @roottale/cms-mcp cli entries publish "<글 id>"
|
|
185
191
|
```
|
|
186
192
|
|
|
187
|
-
상위 `cli --help`는 `
|
|
193
|
+
상위 `cli --help`는 `models`, `fields`, `entries`, `exposure-slots`,
|
|
194
|
+
`exposures`, `media` 그룹을 보여줍니다. 전체 하위 명령은
|
|
188
195
|
다음 도움말에서 확인하세요.
|
|
189
196
|
|
|
190
197
|
```bash
|
|
191
|
-
npx -y @roottale/cms-mcp cli
|
|
198
|
+
npx -y @roottale/cms-mcp cli entries --help
|
|
199
|
+
npx -y @roottale/cms-mcp cli models --help
|
|
200
|
+
npx -y @roottale/cms-mcp cli exposures --help
|
|
192
201
|
npx -y @roottale/cms-mcp cli media --help
|
|
193
202
|
|
|
194
203
|
# 특정 명령의 모든 옵션
|
|
195
|
-
npx -y @roottale/cms-mcp cli
|
|
204
|
+
npx -y @roottale/cms-mcp cli entries create --help
|
|
196
205
|
npx -y @roottale/cms-mcp cli media upload --help
|
|
197
206
|
```
|
|
198
207
|
|
|
199
|
-
|
|
208
|
+
항목 명령은 `list`, `get`, `create`, `update`, `publish`, `unpublish`,
|
|
209
|
+
`set-terms`, `delete`,
|
|
200
210
|
미디어 명령은 `list`, `upload`, `update`, `delete`를 제공합니다.
|
|
201
211
|
|
|
202
|
-
내부 운영 저장소에서는 같은 기능을 `rt cms
|
|
212
|
+
내부 운영 저장소에서는 같은 기능을 `rt cms entries ...`,
|
|
203
213
|
`rt cms media ...` 명령으로도 실행할 수 있습니다.
|
|
204
214
|
|
|
205
215
|
## 다음 단계
|
|
@@ -12,7 +12,13 @@ description: 글 발행/수정 시 사이트 캐시를 near-real-time으로 갱
|
|
|
12
12
|
- 별도 webhook secret을 보관할 필요가 없습니다 — 검증은 사이트 스코프 API
|
|
13
13
|
키로 JWKS 공개키를 가져와 수행합니다.
|
|
14
14
|
- ISR `revalidate = 1800` 같은 시간 기반 설정은 **fallback**입니다. 정상
|
|
15
|
-
경로는 웹훅입니다.
|
|
15
|
+
경로는 웹훅입니다. 웹훅이 실패하면(타임아웃·5xx·연결 오류) 플랫폼이 변경을
|
|
16
|
+
outbox 에 남겨 1·2·4분… 간격(최대 6시간, 8회)으로 자동으로 다시 보내고,
|
|
17
|
+
어드민 "발행 알림 기록" 화면에서 즉시 다시 보낼 수도 있습니다 — 사이트가
|
|
18
|
+
잠시 내려가 있어도 변경은 유실되지 않습니다. 웹훅 배선이 끝난 사이트는 fallback 을 길게 잡아도
|
|
19
|
+
됩니다(`sites/starter` 는 6시간, `21600`) — 짧게 잡으면 방문이 있는 페이지마다
|
|
20
|
+
그 주기로 재생성이 일어나 함수 실행·ISR 쓰기·API 호출이 늘어날 뿐, 정상
|
|
21
|
+
반영 속도는 웹훅이 정합니다.
|
|
16
22
|
|
|
17
23
|
## 1. revalidate 라우트 추가 (Next.js)
|
|
18
24
|
|
package/docs/seo.md
CHANGED
|
@@ -415,6 +415,59 @@ const crumbs = breadcrumbSchema([
|
|
|
415
415
|
발행 웹훅의 `alsoRevalidate`에 `/feed.xml`, `/sitemap.xml`을 포함해 글 변경
|
|
416
416
|
시 함께 갱신하세요 (`revalidation-webhooks.md` 참고).
|
|
417
417
|
|
|
418
|
+
## 메인 홈 메타데이터 (홈 제목·홈 설명)
|
|
419
|
+
|
|
420
|
+
어드민 **설정 > 검색·공유 표시 > 메인 홈 검색 노출**에서 저장한 홈 제목·홈
|
|
421
|
+
설명은 `fetchBlogSettings().siteProfile.homeTitle` / `homeDescription` 으로
|
|
422
|
+
내려옵니다(공개 API `site_profile.home_title` / `home_description`). 홈(`/`)
|
|
423
|
+
라우트의 `generateMetadata` 에서 이 값을 우선 쓰고, 비어 있으면(null) 사이트
|
|
424
|
+
이름(`fetchTheme().siteName`)·사이트 설명(`siteProfile.siteDescription`)으로
|
|
425
|
+
폴백하세요. `title` 은 root layout 의 `%s | 사이트명` 템플릿을 타지 않도록
|
|
426
|
+
`{ absolute }` 로 넘깁니다. `openGraph` 는 page 값이 layout 값을 통째로
|
|
427
|
+
대체하므로 `siteName`·`images` 까지 다시 채웁니다.
|
|
428
|
+
|
|
429
|
+
```tsx
|
|
430
|
+
// app/page.tsx
|
|
431
|
+
import type { Metadata } from "next";
|
|
432
|
+
import {
|
|
433
|
+
BLOG_SETTINGS_CACHE_TAG,
|
|
434
|
+
fetchBlogSettings,
|
|
435
|
+
fetchTheme,
|
|
436
|
+
THEME_CACHE_TAG,
|
|
437
|
+
} from "@roottale/cms-client/server";
|
|
438
|
+
|
|
439
|
+
const apiKey = process.env.ROOTTALE_API_KEY!;
|
|
440
|
+
|
|
441
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
442
|
+
const [theme, settings] = await Promise.all([
|
|
443
|
+
fetchTheme({ apiKey, tags: [THEME_CACHE_TAG] }).catch(() => null),
|
|
444
|
+
fetchBlogSettings({ apiKey, tags: [BLOG_SETTINGS_CACHE_TAG] }).catch(() => null),
|
|
445
|
+
]);
|
|
446
|
+
const siteName = theme?.siteName ?? "예시 사이트";
|
|
447
|
+
const profile = settings?.siteProfile;
|
|
448
|
+
const title = profile?.homeTitle ?? siteName;
|
|
449
|
+
const description = profile?.homeDescription ?? profile?.siteDescription ?? undefined;
|
|
450
|
+
const image = profile?.defaultOgImageUrl ?? undefined;
|
|
451
|
+
return {
|
|
452
|
+
title: { absolute: title },
|
|
453
|
+
description,
|
|
454
|
+
alternates: { canonical: "/" },
|
|
455
|
+
openGraph: {
|
|
456
|
+
type: "website",
|
|
457
|
+
siteName,
|
|
458
|
+
title,
|
|
459
|
+
description,
|
|
460
|
+
...(image ? { images: [image] } : {}),
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
어드민에서 저장하면 서버가 `theme.updated` 알림을 보내므로 `revalidation-
|
|
467
|
+
webhooks.md` 의 태그 배선(`BLOG_SETTINGS_CACHE_TAG`)이 되어 있으면 홈
|
|
468
|
+
제목·설명이 곧바로 반영됩니다. `sites/starter` 는 `buildHomeMetadata()`
|
|
469
|
+
(`components/SiteRootShell.tsx`)로 같은 동작이 이미 배선돼 있습니다.
|
|
470
|
+
|
|
418
471
|
## 글 메타데이터 (canonical·robots·OG)
|
|
419
472
|
|
|
420
473
|
블로그 글 상세의 `generateMetadata` 에서 어드민 SEO 패널(`metaJson.seo`) 값을
|
|
@@ -473,6 +526,15 @@ return buildPostMetadata(post, {
|
|
|
473
526
|
seo?: PostSeoOverrides }).seo` 로 넘기세요. `path` 는 redirect 후의 **현재
|
|
474
527
|
slug**(`post.slug`) 기준으로 주세요(아래 301 참고).
|
|
475
528
|
|
|
529
|
+
**canonical 경로 우선순위(ADR-0105)** — `path` 옵션 → 글의 저장된 정규 공개 경로
|
|
530
|
+
(`post.path`, 원본 post 나 `path`·`collectionKey`·`modelKey` 를 함께 넘기면 자동) →
|
|
531
|
+
`collections` 계산. 플랫폼이 글마다 주소를 저장해 내려주므로 사이트는 주소 규칙을
|
|
532
|
+
알 필요가 없습니다 — 권장 형태는 `path: post.path ?? "/blog/" + post.slug` 처럼
|
|
533
|
+
**원장을 먼저 읽고 이 라우트의 기본 주소로만 폴백**하는 것입니다(구 서버·주소 규칙이
|
|
534
|
+
없는 유형이면 `post.path` 가 없거나 `null`). `storedPostPath(post)` 헬퍼
|
|
535
|
+
(`@roottale/cms-renderer-next/routes`)는 소속(모델·컬렉션)이 있는 글의 저장 경로만
|
|
536
|
+
돌려주고, 어느 유형에도 속하지 않는 레거시 글의 `/blog` 폴백값은 무시합니다.
|
|
537
|
+
|
|
476
538
|
### 공지·블로그 다중 스트림 (ADR-0060)
|
|
477
539
|
|
|
478
540
|
섹션을 나눈 사이트(공지 `/notice` + 블로그 `/blog`)는 `path` 를 직접 쓰지 말고
|
|
@@ -500,6 +562,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
500
562
|
canonical 을 생략합니다(섹션 없는 글은 상세·sitemap에서 제외되는 규칙과 동일).
|
|
501
563
|
단일 블로그 사이트는 기존처럼 `path: "/blog/" + post.slug` 만 주면 됩니다.
|
|
502
564
|
|
|
565
|
+
원본 post 를 그대로 넘기면 `post.path`(플랫폼 저장 경로)가 `collections` 계산보다
|
|
566
|
+
먼저 canonical 이 됩니다 — 컬렉션 규칙이 바뀌어도 사이트 코드를 고칠 필요가 없고,
|
|
567
|
+
`collections` 는 저장 경로가 없는 글(구 서버·주소 규칙 없는 유형)의 폴백으로만 쓰입니다.
|
|
568
|
+
|
|
503
569
|
### 다국어 hreflang (ADR-0052 A안, W4-6 PR C1)
|
|
504
570
|
|
|
505
571
|
번역 사이트는 `translations`(글 응답의 `translations[]`)와 `locales` 옵션을
|
|
@@ -126,6 +126,9 @@ const settings = await fetchBlogSettings({
|
|
|
126
126
|
});
|
|
127
127
|
// showTableOfContents, showAuthor, showDate, showAuthorCard,
|
|
128
128
|
// tocTitle, authorProfileImageRadius / Position 등
|
|
129
|
+
// siteProfile: siteDescription · homeTitle · homeDescription(메인 홈 전용
|
|
130
|
+
// 검색 제목·설명, null 이면 사이트 이름·사이트 설명으로 폴백) · logoUrl ·
|
|
131
|
+
// faviconUrl · defaultOgImageUrl — 사이트 <head>/OG 폴백 (seo.md 참고)
|
|
129
132
|
|
|
130
133
|
// 글 단위 오버라이드(metaJson)와 합성해 최종 표시값 계산
|
|
131
134
|
const display = resolvePostDisplay(settings, post);
|
|
@@ -37,7 +37,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
37
37
|
section: post.category || undefined,
|
|
38
38
|
tags: post.tags.map((t) => t.name),
|
|
39
39
|
},
|
|
40
|
-
|
|
40
|
+
// ADR-0105 — 주소는 플랫폼 원장(post.path)을 읽고, 없을 때만 이 라우트 기본값.
|
|
41
|
+
{ siteUrl: SITE_URL, path: post.path ?? `/blog/${post.slug}` },
|
|
41
42
|
);
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -49,7 +50,8 @@ export default async function PostPage({ params }: Props) {
|
|
|
49
50
|
const redirect = postRedirectPath(post, slug);
|
|
50
51
|
if (redirect) permanentRedirect(redirect);
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
// ADR-0105 — 정본 주소는 플랫폼 원장(post.path). 없을 때만 이 라우트 기본값.
|
|
54
|
+
const url = `${SITE_URL}${post.path ?? `/blog/${post.slug}`}`;
|
|
53
55
|
// avcd 구조 — BlogPosting + 확장 옵션(전부 선택, 있는 값만 채우면 됩니다).
|
|
54
56
|
const jsonLd = articleSchema({
|
|
55
57
|
type: "BlogPosting",
|
|
@@ -80,7 +80,7 @@ export default async function CategoryArchivePage({ params }: Props) {
|
|
|
80
80
|
<ul>
|
|
81
81
|
{posts.map((post) => (
|
|
82
82
|
<li key={post.id}>
|
|
83
|
-
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
|
|
83
|
+
<Link href={post.path ?? `/blog/${post.slug}`}>{post.title}</Link>
|
|
84
84
|
</li>
|
|
85
85
|
))}
|
|
86
86
|
</ul>
|
|
@@ -18,7 +18,7 @@ export default async function BlogPage() {
|
|
|
18
18
|
<ul>
|
|
19
19
|
{posts.map((post) => (
|
|
20
20
|
<li key={post.id}>
|
|
21
|
-
<Link href={`/blog/${post.slug}`}>
|
|
21
|
+
<Link href={post.path ?? `/blog/${post.slug}`}>
|
|
22
22
|
{post.category && <span>{post.category}</span>}
|
|
23
23
|
<h2>{post.title}</h2>
|
|
24
24
|
<p>{post.description}</p>
|