@roottale/cms-mcp 0.53.0 → 0.55.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 +55 -0
- package/README.md +17 -9
- package/dist/index.js +660 -45
- package/dist/index.js.map +1 -1
- package/docs/api-reference.md +79 -14
- package/docs/blog.md +93 -0
- package/docs/collections.md +58 -1
- package/docs/content-models-and-exposures.md +305 -0
- package/docs/custom-redirects.md +9 -1
- package/docs/getting-started.md +20 -10
- package/docs/inquiries.md +1 -1
- package/docs/overview.md +11 -6
- package/docs/search.md +122 -0
- package/docs/seo.md +23 -7
- package/docs/theme-and-settings.md +21 -10
- package/examples/nextjs/app/preview/post/[id]/page.tsx +70 -0
- package/examples/nextjs/app/search/page.tsx +64 -0
- package/examples/nextjs/components/global-popup.tsx +13 -0
- package/examples/nextjs/lib/blog.ts +22 -0
- package/examples/nextjs/lib/content-models.ts +12 -0
- package/examples/nextjs/middleware.ts +2 -1
- package/package.json +2 -2
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 콘텐츠 모델과 노출 관리
|
|
3
|
+
description: 페이지·글·정보 모델, 확장 필드, 배너·팝업을 API·MCP·CLI로 관리하는 방법
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 콘텐츠 모델과 노출 관리
|
|
7
|
+
|
|
8
|
+
RootTale의 콘텐츠 관리 단위는 다음과 같습니다.
|
|
9
|
+
|
|
10
|
+
| 리소스 | 뜻 | 안정 식별자 |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| 콘텐츠 모델 | 페이지·글·정보의 구조와 화면 연결 | `model_key` |
|
|
13
|
+
| 필드 그룹 | 모델에 붙는 구조화된 추가 필드 | `model_key` + `group_key` |
|
|
14
|
+
| 항목 | 모델에 속한 실제 페이지·글·정보 | `post_id` |
|
|
15
|
+
| 노출 슬롯 | FRONT가 선언한 배너·팝업 위치 계약 | `slot_key` |
|
|
16
|
+
| 노출 캠페인 | 슬롯에 표시할 내용·기간·경로 | `exposure_id` |
|
|
17
|
+
|
|
18
|
+
`source: "code"`인 모델·필드 그룹은 개발자 계약입니다. 구조를 API로 바꾸거나
|
|
19
|
+
삭제할 수 없습니다. 모델 상태만 활성화·비활성화할 수 있습니다.
|
|
20
|
+
`source: "site"`인 리소스는 관리 API로 만들고 수정할 수 있습니다.
|
|
21
|
+
|
|
22
|
+
## 권한
|
|
23
|
+
|
|
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
|
+
공개 사이트는 `fetchCategories({ collectionKey: "faq" })`의 `id`와 `parentId`로
|
|
93
|
+
루트부터 말단까지의 분류 사슬을 만들고, `fetchPosts({ modelKey: "faq" })`의 글마다
|
|
94
|
+
말단 카테고리를 정확히 하나 연결합니다. 위 예시는 다음 주소를 표현합니다.
|
|
95
|
+
|
|
96
|
+
- `/faq`
|
|
97
|
+
- `/faq/headache`
|
|
98
|
+
- `/faq/headache/migraine`
|
|
99
|
+
- `/faq/headache/migraine/{slug}`
|
|
100
|
+
|
|
101
|
+
ROOT-ADMIN은 발행·예약 시 선언한 깊이의 말단 분류인지 다시 검사합니다. 부모만
|
|
102
|
+
고르거나 복수 분류를 연결한 항목은 발행하지 않습니다. 분류 `slug`나 부모 관계를
|
|
103
|
+
바꾸는 일은 공개 주소 변경이므로 기존 발행 글이 있으면 일반 라우팅 변경 보호를
|
|
104
|
+
따릅니다.
|
|
105
|
+
|
|
106
|
+
코드로 배포하는 모델은 `editor`에서 저장 구조를 바꾸지 않고 ROOT-ADMIN의 기본
|
|
107
|
+
필드 이름과 분류 단계 이름만 바꿀 수 있습니다.
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"editor": {
|
|
112
|
+
"labels": {
|
|
113
|
+
"title": "질문",
|
|
114
|
+
"excerpt": "짧은 답변",
|
|
115
|
+
"body": "상세 답변",
|
|
116
|
+
"categories": "질환 분류",
|
|
117
|
+
"tags": "검색 태그"
|
|
118
|
+
},
|
|
119
|
+
"categoryLevels": ["진료 영역", "세부 질환"]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`categoryLevels`의 개수는 `categoryDepth`와 같아야 합니다. 최종 선택에서는 말단
|
|
125
|
+
분류 ID 하나만 기존 카테고리 관계로 저장됩니다.
|
|
126
|
+
|
|
127
|
+
관계형 커스텀 필드는 `postType`과 함께 `modelKey`를 지정하면 선택 대상을 같은
|
|
128
|
+
콘텐츠 모델로 좁힐 수 있습니다. 예를 들어 `relationship` 필드에
|
|
129
|
+
`"postType": "post", "modelKey": "faq"`를 선언하면 관련 FAQ만 표시됩니다.
|
|
130
|
+
|
|
131
|
+
아직 CMS에 글이나 초안이 없는 미래 콘텐츠는 `textarea`에 내부 콘텐츠 키 형식을
|
|
132
|
+
선언해 예약할 수 있습니다. ROOT-ADMIN은 전용 입력기에서 접두사·중복·최대 개수를
|
|
133
|
+
검사하며, 저장 API도 같은 규칙을 적용합니다. 값은 공개 `fields`에 줄바꿈 문자열로
|
|
134
|
+
그대로 제공되므로 고객 FRONT가 현재 발행 원장과 대조해 링크 노출을 결정합니다.
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"key": "field_related_content_keys",
|
|
139
|
+
"name": "related_content_keys",
|
|
140
|
+
"label": "미발행 FAQ 예약 키",
|
|
141
|
+
"type": "textarea",
|
|
142
|
+
"format": "internal_content_keys",
|
|
143
|
+
"keyPrefix": "faq",
|
|
144
|
+
"maxItems": 5
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
키는 `faq.headache.migraine.aura-symptoms`처럼 영문 소문자·숫자·한글과 점·
|
|
149
|
+
하이픈으로 구성합니다. `keyPrefix`를 주면 해당 접두사로 시작하는 키만 저장할 수
|
|
150
|
+
있습니다. 이 필드 자체는 미발행 URL을 만들지 않습니다.
|
|
151
|
+
|
|
152
|
+
### 본문 안의 예약 내부 링크
|
|
153
|
+
|
|
154
|
+
글 본문에서는 다른 글을 텍스트 표기로 연결할 수 있고, 아직 발행되지 않은 글도 미리
|
|
155
|
+
연결해 둘 수 있습니다.
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
[[internal:{키}|표시 문구]]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**키는 대상 글의 정규 공개 경로 조각을 점으로 이은 것**입니다. 경로는 사이트맵·
|
|
162
|
+
단축링크와 같은 규칙으로 정해지므로 콘텐츠 유형에 관계없이 한 규칙입니다.
|
|
163
|
+
|
|
164
|
+
| 공개 경로 | 키 |
|
|
165
|
+
|---|---|
|
|
166
|
+
| `/faq/headache/migraine/aura-symptoms` (계층형 분류 모델) | `faq.headache.migraine.aura-symptoms` |
|
|
167
|
+
| `/column/headache/my-post` (카테고리 주소 컬렉션) | `column.headache.my-post` |
|
|
168
|
+
| `/blog/my-post` (일반 컬렉션) | `blog.my-post` |
|
|
169
|
+
| `/team/hong` (상세 프리셋 모델) | `team.hong` |
|
|
170
|
+
|
|
171
|
+
고정 페이지·`data_only` 모델처럼 글별 상세 주소가 없는 콘텐츠는 대상이 되지 않습니다.
|
|
172
|
+
|
|
173
|
+
ROOT-ADMIN 편집기 도구 모음의 **내부 링크 삽입** 버튼이 이 표기를 대신 만들어
|
|
174
|
+
줍니다. 작성자는 콘텐츠 유형과 분류를 이름으로 고르고 글(초안 포함)을 목록에서
|
|
175
|
+
고르거나, 아직 없는 글의 slug만 적습니다. 각 글의 게시 주소 영역에는 다른 글에서
|
|
176
|
+
이 글을 연결할 때 쓰는 키가 복사 버튼과 함께 표시됩니다.
|
|
177
|
+
|
|
178
|
+
이 표기는 본문 `body_json`의 일반 텍스트로 저장되며 공개 API도 그대로 내보냅니다.
|
|
179
|
+
해석은 고객 FRONT의 몫입니다 — 텍스트 구간에서 표기를 찾아, 현재 발행 원장의 글
|
|
180
|
+
경로를 같은 규칙으로 키로 바꿔 대조한 뒤 있으면 링크로, 없으면 표시 문구만
|
|
181
|
+
렌더링하세요. `code`·`pre`·이미 링크된 구간과 속성값은 변환하지 않는 것을 권장합니다.
|
|
182
|
+
|
|
183
|
+
**주소가 바뀐 글도 자동으로 따라가게 하려면** 공개 글 응답의 `previous_slugs`(옛 slug
|
|
184
|
+
목록, 최신순 — `@roottale/cms-client`에서는 `previousSlugs`)를 함께 쓰세요. 현재 경로의
|
|
185
|
+
마지막 조각을 옛 slug로 바꾼 경로도 같은 글의 키로 등록하면, `[[internal:blog.old-slug|…]]`
|
|
186
|
+
처럼 옛 키로 남아 있는 본문도 현재 주소로 렌더됩니다(분류 이동은 이력이 없어 대상 밖).
|
|
187
|
+
|
|
188
|
+
## 페이지·글·정보 항목
|
|
189
|
+
|
|
190
|
+
항목 API는 기존 `/v1/cms/posts`를 그대로 쓰며 모든 응답에 `model_key`와
|
|
191
|
+
`field_values`가 포함됩니다.
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{
|
|
195
|
+
"model_key": "team-member",
|
|
196
|
+
"title": "홍길동 세무사",
|
|
197
|
+
"slug": "hong-gildong",
|
|
198
|
+
"body_json": {"type":"doc","content":[]},
|
|
199
|
+
"field_values": {
|
|
200
|
+
"position": "대표 세무사",
|
|
201
|
+
"specialties": ["법인세", "상속세"]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
- `page` 모델은 `type: "page"`, `article`·`entity` 모델은 `type: "post"`로
|
|
207
|
+
저장됩니다. `model_key`와 `type`이 다르면 요청을 거부합니다.
|
|
208
|
+
- `entity` 항목에는 작성자·카테고리·태그를 붙일 수 없습니다.
|
|
209
|
+
- 작성자를 사용하는 `article` 모델은 발행·예약 전에 활성 공개 작성자가 필요합니다.
|
|
210
|
+
- 기존 자동화가 `model_key`를 생략하면 활성 page/article 모델이 정확히 하나일 때만
|
|
211
|
+
호환 처리됩니다. 후보가 여러 개면 `400 model_key_required`입니다.
|
|
212
|
+
- 항목의 모델은 생성 뒤 바꿀 수 없습니다. 다른 모델로 옮기려면 새 항목을 만드세요.
|
|
213
|
+
|
|
214
|
+
공개 FRONT는 검증된 표시 값 `fields`를 읽습니다. 관리 자동화는 편집 가능한 원문
|
|
215
|
+
`field_values`를 사용합니다.
|
|
216
|
+
|
|
217
|
+
## 노출 슬롯과 캠페인
|
|
218
|
+
|
|
219
|
+
노출 슬롯은 FRONT의 사이트 콘텐츠 계약에서 옵니다. 관리 API로 슬롯을 만들거나
|
|
220
|
+
수정할 수 없습니다.
|
|
221
|
+
|
|
222
|
+
- `GET /v1/cms/exposure-slots`
|
|
223
|
+
- `GET|POST /v1/cms/exposures`
|
|
224
|
+
- `GET|PATCH /v1/cms/exposures/{exposure_id}`
|
|
225
|
+
- `POST /v1/cms/exposures/{exposure_id}/publish`
|
|
226
|
+
- `POST /v1/cms/exposures/{exposure_id}/archive`
|
|
227
|
+
|
|
228
|
+
생성은 항상 초안입니다. 수정·발행·보관은 `version`을 확인합니다. 발행 응답의
|
|
229
|
+
`overlap`은 같은 슬롯·경로·기간에 겹치는 캠페인 수와 ID를 알려 줍니다. 겹침은
|
|
230
|
+
오류가 아니며, 공개 FRONT는 우선순위와 최신순 규칙으로 한 건을 선택합니다.
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"slot_key": "global-popup",
|
|
235
|
+
"kind": "popup",
|
|
236
|
+
"content": {"variant":"notice","title":"여름 휴무 안내"},
|
|
237
|
+
"starts_at": "2026-08-20T00:00:00.000Z",
|
|
238
|
+
"ends_at": "2026-08-25T00:00:00.000Z",
|
|
239
|
+
"target_paths": ["/"],
|
|
240
|
+
"priority": 10,
|
|
241
|
+
"repeat": "session"
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## MCP와 CLI
|
|
246
|
+
|
|
247
|
+
MCP는 각 HTTP 작업을 같은 이름의 도구로 제공합니다.
|
|
248
|
+
|
|
249
|
+
- 모델: `listCmsContentModels`, `getCmsContentModel`, `createCmsContentModel`,
|
|
250
|
+
`updateCmsContentModel`, `activateCmsContentModel`, `deactivateCmsContentModel`,
|
|
251
|
+
`deleteCmsContentModel`
|
|
252
|
+
- 필드: `listCmsFieldGroups`, `getCmsFieldGroup`, `createCmsFieldGroup`,
|
|
253
|
+
`updateCmsFieldGroup`, `activateCmsFieldGroup`, `deactivateCmsFieldGroup`,
|
|
254
|
+
`deleteCmsFieldGroup`
|
|
255
|
+
- 항목: `listManagedCmsPosts`, `getManagedCmsPost`, `createCmsPost`,
|
|
256
|
+
`updateCmsPost`, `publishCmsPost`, `unpublishCmsPost`, `deleteCmsPost`
|
|
257
|
+
- 노출: `listCmsExposureSlots`, `listCmsExposures`, `getCmsExposure`,
|
|
258
|
+
`createCmsExposure`, `updateCmsExposure`, `publishCmsExposure`,
|
|
259
|
+
`archiveCmsExposure`
|
|
260
|
+
|
|
261
|
+
CLI의 큰 JSON 입력은 camelCase 키를 쓰는 `--input-file`로 전달합니다.
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
npx -y @roottale/cms-mcp cli models list
|
|
265
|
+
npx -y @roottale/cms-mcp cli models create --input-file ./model.json
|
|
266
|
+
npx -y @roottale/cms-mcp cli fields create team-member --input-file ./fields.json
|
|
267
|
+
npx -y @roottale/cms-mcp cli entries create \
|
|
268
|
+
--model-key team-member --title "홍길동" --slug hong-gildong \
|
|
269
|
+
--body-file ./body.json --field-values-file ./values.json
|
|
270
|
+
npx -y @roottale/cms-mcp cli exposure-slots list
|
|
271
|
+
npx -y @roottale/cms-mcp cli exposures create --input-file ./exposure.json
|
|
272
|
+
npx -y @roottale/cms-mcp cli exposures publish "<exposure-id>"
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`posts`는 기존 스크립트 호환 별칭입니다. 새 자동화는 `entries`를 권장합니다.
|
|
276
|
+
|
|
277
|
+
## 공개 FRONT 연결
|
|
278
|
+
|
|
279
|
+
고객 FRONT는 `fetchContentModels()`와 `fetchPosts({ modelKey })`로 활성 모델과
|
|
280
|
+
항목을 읽습니다. 노출은 개발자가 `RootTaleExposureSlot`을 배치한 위치에만
|
|
281
|
+
표시됩니다.
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
import { RootTaleExposureSlot } from "@roottale/cms-renderer-next/server";
|
|
285
|
+
|
|
286
|
+
export default async function Layout({ children }: { children: React.ReactNode }) {
|
|
287
|
+
return <>
|
|
288
|
+
{children}
|
|
289
|
+
<RootTaleExposureSlot
|
|
290
|
+
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
291
|
+
slotKey="global-popup"
|
|
292
|
+
path="/"
|
|
293
|
+
allowedVariants={["notice"]}
|
|
294
|
+
revalidate={60}
|
|
295
|
+
/>
|
|
296
|
+
</>;
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
공개 raw API는 다음 세 경로입니다.
|
|
301
|
+
|
|
302
|
+
- `GET /v1/cms/public/content-models`
|
|
303
|
+
- `GET /v1/cms/public/posts?model_key=team-member`
|
|
304
|
+
- `GET /v1/cms/public/categories?collection_key=faq` (`id`, `parent_id` 포함)
|
|
305
|
+
- `GET /v1/cms/public/exposures?slot_key=global-popup&path=%2Fabout`
|
package/docs/custom-redirects.md
CHANGED
|
@@ -25,7 +25,8 @@ description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로
|
|
|
25
25
|
|
|
26
26
|
`@roottale/cms-renderer-next` 의 `createRedirectMiddleware` 를 프로젝트 루트
|
|
27
27
|
`middleware.ts` 에 마운트합니다. 규칙을 자동 캐시(기본 60초)하며, API 실패 시
|
|
28
|
-
|
|
28
|
+
기존 캐시가 있으면 오래된 규칙을 우선 사용하고(stale-first), 캐시가
|
|
29
|
+
없으면 트래픽을 막지 않고 통과시킵니다(fail-soft).
|
|
29
30
|
|
|
30
31
|
```ts
|
|
31
32
|
// middleware.ts
|
|
@@ -56,8 +57,15 @@ export const config = {
|
|
|
56
57
|
- 매칭은 **정확 경로 일치**입니다(와일드카드 없음). 출발 경로의 앞/뒤 슬래시와
|
|
57
58
|
한글 percent-encoding 차이는 자동 정규화해 비교합니다.
|
|
58
59
|
- 도착지가 내부 경로면 요청 origin 기준 절대 URL 로 변환해 리다이렉트합니다.
|
|
60
|
+
- 내부 경로 체인은 최종 도착지로 평탄화하고, 순환이 발견되면 브라우저
|
|
61
|
+
왕복을 막기 위해 해당 요청을 통과시킵니다.
|
|
59
62
|
- 매칭이 없으면 `null` 을 반환하므로 `NextResponse.next()` 로 통과시키세요.
|
|
60
63
|
|
|
64
|
+
Site Materializer로 새 사이트를 만들면 `middleware.ts`와
|
|
65
|
+
`tests/redirect-middleware.test.ts`가 필수 산출물로 포함됩니다. 두 파일은
|
|
66
|
+
내부 Materializer 영수증에도 기록되므로, 설치 여부를 추측하지 않고
|
|
67
|
+
실제 납품 산출물로 확인할 수 있습니다.
|
|
68
|
+
|
|
61
69
|
## 캐시와 즉시성
|
|
62
70
|
|
|
63
71
|
규칙은 미들웨어가 TTL(기본 60초) 동안 캐시합니다. 운영자가 규칙을 바꾸면
|
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
|
## 다음 단계
|
package/docs/inquiries.md
CHANGED
|
@@ -95,7 +95,7 @@ export async function submitContact(
|
|
|
95
95
|
## 유입 어트리뷰션 (`attribution`)
|
|
96
96
|
|
|
97
97
|
문의가 **어느 글·검색·단축링크/QR에서 왔는지**를 CRM에 표시하려면 두 줄만
|
|
98
|
-
추가하면 됩니다.
|
|
98
|
+
추가하면 됩니다. ROOT-ANALYTICS 비콘이 방문자의 first-touch(처음 도착한
|
|
99
99
|
경로·`rt_src` 토큰·utm·외부 referrer 호스트명)를 30일간 기억하며,
|
|
100
100
|
`readAttribution()`(브라우저 전용, `@roottale/cms-client/attribution`)으로
|
|
101
101
|
읽습니다. 식별자가 아니므로 개인정보가 아닙니다.
|
package/docs/overview.md
CHANGED
|
@@ -13,10 +13,12 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
13
13
|
|
|
14
14
|
1. `getting-started.md` — 키 발급 + 환경 설정
|
|
15
15
|
2. `blog.md` — `/blog` 목록·상세 페이지
|
|
16
|
-
3. `
|
|
17
|
-
4. `
|
|
18
|
-
5. `
|
|
19
|
-
6. `
|
|
16
|
+
3. `search.md` — 글·페이지 통합 검색 (선택)
|
|
17
|
+
4. `revalidation-webhooks.md` — 웹훅 등록 (발행 → 즉시 반영)
|
|
18
|
+
5. `seo.md` — RSS·사이트맵·동적 OG 이미지
|
|
19
|
+
6. `theme-and-settings.md` — ROOT-ANALYTICS 연결 (권장)
|
|
20
|
+
7. `inquiries.md` — 상담문의 폼과 유입·여정 저장 (선택)
|
|
21
|
+
8. `menus.md` — 어드민 관리 네비게이션 (선택)
|
|
20
22
|
|
|
21
23
|
```
|
|
22
24
|
어드민 (admin.roottale.com) 고객 사이트 (예: example.com)
|
|
@@ -34,9 +36,10 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
34
36
|
| 기능 | 같은 키 하나로 |
|
|
35
37
|
|---|---|
|
|
36
38
|
| 블로그 글 목록/상세 조회 | `fetchPosts` / `fetchPost` |
|
|
39
|
+
| 발행 글·페이지 검색 | `searchPosts` / `resolveSearchHitPath` |
|
|
37
40
|
| 발행 웹훅 서명 검증 + 캐시 갱신 | `createRevalidateRoute` (JWKS 공개키 — 별도 secret 보관 불필요). 설정 저장을 즉시 반영하려면 `revalidateTag` 주입 필수 — `revalidation-webhooks.md` §1 |
|
|
38
41
|
| 상담문의(리드) 접수 | `submitInquiry` — 키가 테넌트를 식별 |
|
|
39
|
-
| 테마·블로그
|
|
42
|
+
| 테마·블로그 표시·ROOT-ANALYTICS 설정 조회 | `fetchTheme` / `fetchBlogSettings` / `fetchAnalyticsConfig` |
|
|
40
43
|
| 사업장 정보·메뉴·콘텐츠 유형 조회 | `fetchBusinessProfile` / `fetchMenu`·`fetchMenus` / `fetchCollections` |
|
|
41
44
|
|
|
42
45
|
키는 **서버 전용**입니다. 브라우저로 노출되면 안 됩니다(`NEXT_PUBLIC_*` 금지).
|
|
@@ -49,6 +52,7 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
49
52
|
| [`@roottale/cms-client`](https://www.npmjs.com/package/@roottale/cms-client) | 서버 전용 fetch 클라이언트 — 글/테마/설정 조회, 문의 접수, 웹훅 검증 (raw) |
|
|
50
53
|
| [`@roottale/cms-renderer-next`](https://www.npmjs.com/package/@roottale/cms-renderer-next) | Next.js(RSC) 렌더러 — 블로그 컴포넌트, revalidate/RSS/sitemap 라우트 팩토리 |
|
|
51
54
|
| [`@roottale/cms-core`](https://www.npmjs.com/package/@roottale/cms-core) | 블록 JSON 공통 코어 (렌더러가 의존) |
|
|
55
|
+
| [`@roottale/analytics-runtime`](https://www.npmjs.com/package/@roottale/analytics-runtime) | ROOT-ANALYTICS 비콘·동의·Next.js SPA 추적 런타임 |
|
|
52
56
|
| `@roottale/cms-mcp` | 본 MCP 서버 — 통합 문서·예시 코드·API 조회 tool |
|
|
53
57
|
|
|
54
58
|
## 문서 맵
|
|
@@ -57,9 +61,10 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
57
61
|
|---|---|
|
|
58
62
|
| `getting-started.md` | API 키 발급, 환경변수, 패키지 설치, 첫 조회 |
|
|
59
63
|
| `blog.md` | 블로그 목록/상세 페이지 구현 (컴포넌트 또는 직접 fetch) |
|
|
64
|
+
| `search.md` | 글·페이지 통합 검색, 실제 공개 주소 계산, 보안·캐시·장애 처리 |
|
|
60
65
|
| `revalidation-webhooks.md` | 발행 웹훅으로 near-real-time 캐시 갱신 |
|
|
61
66
|
| `inquiries.md` | 상담문의(리드) 폼 연동 |
|
|
62
67
|
| `menus.md` | 메뉴(네비게이션) — 어드민 "디자인 > 메뉴" 트리를 헤더/푸터에 렌더 |
|
|
63
68
|
| `seo.md` | RSS 피드, 사이트맵, JSON-LD, 동적 OG 이미지, 공개 검색, fleet 프로브 |
|
|
64
|
-
| `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정,
|
|
69
|
+
| `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정, ROOT-ANALYTICS |
|
|
65
70
|
| `api-reference.md` | HTTP API 레퍼런스 (비 JS 스택용 raw 엔드포인트) |
|
package/docs/search.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 사이트 검색 연동
|
|
3
|
+
description: 발행된 글과 페이지를 서버에서 안전하게 검색하고 실제 공개 주소로 연결
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 사이트 검색 연동
|
|
7
|
+
|
|
8
|
+
RootTale 검색은 고객 사이트의 **서버**가 공개 CMS API를 호출하는 방식입니다.
|
|
9
|
+
검색창은 일반 GET 폼으로 만들되, `ROOTTALE_API_KEY`는 Server Component나 Route
|
|
10
|
+
Handler 안에서만 사용합니다. 브라우저가 RootTale API를 직접 호출하지 않습니다.
|
|
11
|
+
|
|
12
|
+
## 권장 구조
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
방문자 브라우저
|
|
16
|
+
GET /search?q=상담
|
|
17
|
+
│
|
|
18
|
+
▼
|
|
19
|
+
고객 사이트 Server Component
|
|
20
|
+
searchPosts({ type: "all", locale })
|
|
21
|
+
│ Authorization: Bearer rtlk_cust_*
|
|
22
|
+
▼
|
|
23
|
+
RootTale 공개 검색 API
|
|
24
|
+
tenant + site + locale + published 범위 검색
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
이 구조는 API 키를 숨기고, 검색 결과 페이지를 서버 렌더링하며, 고객사와 사이트
|
|
28
|
+
경계를 API에서 강제합니다. `NEXT_PUBLIC_ROOTTALE_API_KEY`처럼 공개 환경변수에
|
|
29
|
+
키를 넣으면 안 됩니다.
|
|
30
|
+
|
|
31
|
+
## Next.js 구현
|
|
32
|
+
|
|
33
|
+
전체 예시는 `examples/nextjs/app/search/page.tsx`에 있습니다. 핵심 흐름은 다음과
|
|
34
|
+
같습니다.
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import {
|
|
38
|
+
fetchCollections,
|
|
39
|
+
resolveSearchHitPath,
|
|
40
|
+
searchPosts,
|
|
41
|
+
} from "@roottale/cms-client/server";
|
|
42
|
+
|
|
43
|
+
const [hits, collections] = await Promise.all([
|
|
44
|
+
searchPosts({
|
|
45
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
46
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
47
|
+
query,
|
|
48
|
+
type: "all",
|
|
49
|
+
locale,
|
|
50
|
+
limit: 20,
|
|
51
|
+
revalidate: 60,
|
|
52
|
+
}),
|
|
53
|
+
fetchCollections({
|
|
54
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
55
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
56
|
+
}).catch(() => []),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const results = hits.flatMap((hit) => {
|
|
60
|
+
const href = resolveSearchHitPath(hit, collections, locale);
|
|
61
|
+
return href ? [{ hit, href }] : [];
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
사이트 전체 검색은 `type: "all"`을 명시합니다. 이 값을 생략하면 하위 호환을
|
|
66
|
+
위해 글(`post`)만 검색합니다. 다국어 사이트는 현재 경로의 `locale`을 검색 API와
|
|
67
|
+
`resolveSearchHitPath` 양쪽에 같은 값으로 전달합니다.
|
|
68
|
+
|
|
69
|
+
## 결과 주소 계산
|
|
70
|
+
|
|
71
|
+
검색 결과의 `slug`만 보고 `/blog/{slug}`를 직접 만들지 않습니다.
|
|
72
|
+
`resolveSearchHitPath`는 다음 규칙을 적용합니다.
|
|
73
|
+
|
|
74
|
+
| 콘텐츠 | 공개 주소 |
|
|
75
|
+
|---|---|
|
|
76
|
+
| 고정 페이지 | `/{slug}` |
|
|
77
|
+
| 기본 블로그 글 | `/blog/{slug}` |
|
|
78
|
+
| 콘텐츠 유형 글 | `/{collection.basePath}/{slug}` |
|
|
79
|
+
| 다국어 콘텐츠 | 위 주소 앞에 `/{locale}` 추가 |
|
|
80
|
+
| 상세 화면이 없는 콘텐츠 유형 | `null` — 검색 목록에서 제외 |
|
|
81
|
+
|
|
82
|
+
따라서 `fetchCollections`가 실패해도 일반 글은 `/blog/{slug}`로 연결할 수 있지만,
|
|
83
|
+
공지·자료실 같은 별도 콘텐츠 유형의 주소 정확도를 위해 정상 응답을 권장합니다.
|
|
84
|
+
|
|
85
|
+
## 검색 범위와 정렬
|
|
86
|
+
|
|
87
|
+
- API 키에 연결된 tenant와 site 밖의 콘텐츠는 검색하지 않습니다.
|
|
88
|
+
- 요청한 locale의 `published` 콘텐츠만 반환합니다.
|
|
89
|
+
- 제목 완전일치 → 제목 부분일치 → 요약 → 본문 순으로 우선합니다.
|
|
90
|
+
- 같은 점수에서는 최근 발행 콘텐츠가 먼저 나옵니다.
|
|
91
|
+
- 응답은 카드용 슬림 결과이며 본문 전체는 포함하지 않습니다.
|
|
92
|
+
|
|
93
|
+
현재 한 요청은 최대 50건입니다. 첫 버전에는 페이지네이션, 형태소 분석,
|
|
94
|
+
오타 교정, 동의어 확장이 없습니다. 실제 검색 로그에서 필요성이 확인되면
|
|
95
|
+
추가하는 범위입니다.
|
|
96
|
+
|
|
97
|
+
## 캐시와 새 글 반영
|
|
98
|
+
|
|
99
|
+
`revalidate`를 지정하면 Next.js 서버 캐시에 검색 응답이 저장됩니다. 예를 들어
|
|
100
|
+
`revalidate: 60`이면 발행·수정 후 검색 결과가 최대 약 60초 늦게 바뀔 수 있습니다.
|
|
101
|
+
항상 최신 결과가 필요하면 `revalidate: 0`을 사용하되 API 호출량 증가를 고려하세요.
|
|
102
|
+
|
|
103
|
+
플랫폼 배포 순서는 **migration → API → SDK·고객 사이트**입니다. 검색용 생성 열과
|
|
104
|
+
GIN 색인이 먼저 준비되어야 새 API가 안전하게 조회할 수 있습니다.
|
|
105
|
+
|
|
106
|
+
## 빈 결과와 장애 처리
|
|
107
|
+
|
|
108
|
+
- 빈 검색어는 API를 호출하지 않고 입력 안내를 표시합니다.
|
|
109
|
+
- 정상 응답이지만 결과가 없으면 검색어와 함께 `0건` 안내를 표시합니다.
|
|
110
|
+
- API 장애는 빈 결과와 구분해 “잠시 후 다시 시도” 안내를 표시합니다.
|
|
111
|
+
- `searchPosts`는 구 API의 `404`에 한해 빈 배열로 처리하고, 그 밖의 오류는
|
|
112
|
+
`CmsApiError`로 전달합니다.
|
|
113
|
+
|
|
114
|
+
검색 입력은 `type="search"`, `name="q"`, 연결된 `<label>`을 사용하고 결과 수는
|
|
115
|
+
`aria-live="polite"`로 알립니다. 검색 결과 페이지는 보통 중복·저가치 URL이므로
|
|
116
|
+
`robots: { index: false, follow: true }`를 권장합니다.
|
|
117
|
+
|
|
118
|
+
## HTTP API
|
|
119
|
+
|
|
120
|
+
JavaScript 이외의 서버에서는
|
|
121
|
+
`GET /v1/cms/public/search?q=...&type=all&locale=ko&limit=20`을 사용합니다.
|
|
122
|
+
쿼리와 응답 필드는 [HTTP API 레퍼런스](./api-reference.md)를 참고하세요.
|
package/docs/seo.md
CHANGED
|
@@ -591,21 +591,37 @@ export default createPostOgImage(
|
|
|
591
591
|
|
|
592
592
|
## 공개 검색 (사이트 내 검색)
|
|
593
593
|
|
|
594
|
-
`searchPosts` 로
|
|
594
|
+
`searchPosts` 로 발행된 글과 페이지의 통합 검색을 붙일 수 있습니다. API 키는
|
|
595
|
+
브라우저에 보내지 않고 Server Component나 Route Handler에서만 사용합니다:
|
|
595
596
|
|
|
596
597
|
```tsx
|
|
597
598
|
// app/search/page.tsx (Server Component)
|
|
598
|
-
import {
|
|
599
|
+
import {
|
|
600
|
+
fetchCollections,
|
|
601
|
+
resolveSearchHitPath,
|
|
602
|
+
searchPosts,
|
|
603
|
+
} from "@roottale/cms-client/server";
|
|
599
604
|
|
|
600
|
-
const hits = await
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
605
|
+
const [hits, collections] = await Promise.all([
|
|
606
|
+
searchPosts({
|
|
607
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
608
|
+
query: q, // ?q= 쿼리
|
|
609
|
+
type: "all", // 글 + 페이지
|
|
610
|
+
locale: "ko",
|
|
611
|
+
limit: 20,
|
|
612
|
+
}),
|
|
613
|
+
fetchCollections({ apiKey: process.env.ROOTTALE_API_KEY! }),
|
|
614
|
+
]);
|
|
615
|
+
|
|
616
|
+
const links = hits.flatMap((hit) => {
|
|
617
|
+
const href = resolveSearchHitPath(hit, collections);
|
|
618
|
+
return href ? [{ hit, href }] : [];
|
|
604
619
|
});
|
|
605
620
|
// hits: { id, title, slug, excerpt, featuredImageUrl, publishedAt }[]
|
|
606
621
|
```
|
|
607
622
|
|
|
608
|
-
본문은 미포함 슬림 hit
|
|
623
|
+
본문은 미포함 슬림 hit입니다. 주소를 `/blog/{slug}`로 직접 조립하면 공지·자료실
|
|
624
|
+
등 콘텐츠 유형의 실제 주소를 놓칠 수 있으므로 `resolveSearchHitPath`를 사용하세요.
|
|
609
625
|
검색결과 페이지는 위 체크리스트대로 **noindex** 처리를 잊지 마세요.
|
|
610
626
|
|
|
611
627
|
## JSON-LD 스키마 헬퍼
|