@roottale/cms-mcp 0.53.1 → 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 +45 -0
- package/README.md +17 -9
- package/dist/index.js +660 -45
- package/dist/index.js.map +1 -1
- package/docs/api-reference.md +64 -7
- package/docs/blog.md +93 -0
- package/docs/collections.md +31 -1
- package/docs/content-models-and-exposures.md +274 -32
- package/docs/getting-started.md +20 -10
- package/examples/nextjs/app/preview/post/[id]/page.tsx +70 -0
- package/examples/nextjs/lib/blog.ts +22 -0
- package/examples/nextjs/lib/content-models.ts +12 -0
- package/package.json +2 -2
package/docs/api-reference.md
CHANGED
|
@@ -9,10 +9,11 @@ description: 공개 API raw 엔드포인트 — JS 외 스택이나 저수준
|
|
|
9
9
|
인증: 모든 요청에 `Authorization: Bearer rtlk_cust_...` 헤더.
|
|
10
10
|
|
|
11
11
|
공개 콘텐츠 조회를 JS/TS로 연동할 때는 raw 호출 대신
|
|
12
|
-
`@roottale/cms-client`를 사용하세요.
|
|
12
|
+
`@roottale/cms-client`를 사용하세요. 전체 CMS 관리는 아래 HTTP API,
|
|
13
13
|
MCP tool 또는 공개 CLI를 사용합니다.
|
|
14
14
|
|
|
15
|
-
글쓰기·미디어 자동화는 `read_write` API 키가 필요합니다.
|
|
15
|
+
글쓰기·미디어 자동화는 `read_write` API 키가 필요합니다. 모델·필드·노출까지
|
|
16
|
+
관리하려면 `full_management` 키를 권장합니다. `read` 키는 공개
|
|
16
17
|
콘텐츠뿐 아니라 관리 API의 초안·예약·비공개 글과 미디어 목록도 조회할 수
|
|
17
18
|
있습니다. 다만 **완전한 읽기 전용은 아닙니다** — 상담 게시판 글 작성
|
|
18
19
|
(`POST /v1/cms/public/inquiries`)이 `cms:read`로 허용됩니다. 기존 글·미디어·
|
|
@@ -26,6 +27,9 @@ MCP tool 또는 공개 CLI를 사용합니다.
|
|
|
26
27
|
관리 API는 초안 작성 → 미디어 연결 → 발행을 분리합니다. 자동화가 실수로
|
|
27
28
|
미완성 글을 공개하지 않도록 글 생성의 기본 상태는 `draft`입니다.
|
|
28
29
|
|
|
30
|
+
전체 리소스 경로와 권한은
|
|
31
|
+
[콘텐츠 모델과 노출 관리](./content-models-and-exposures.md)에 정리되어 있습니다.
|
|
32
|
+
|
|
29
33
|
### POST /v1/cms/posts
|
|
30
34
|
|
|
31
35
|
Tiptap JSON 본문으로 글 또는 페이지를 만듭니다.
|
|
@@ -40,6 +44,7 @@ curl https://api.roottale.com/v1/cms/posts \
|
|
|
40
44
|
"title": "자동화로 작성한 글",
|
|
41
45
|
"slug": "automated-post",
|
|
42
46
|
"body_json": {"type":"doc","content":[]},
|
|
47
|
+
"author_profile_id": "site-author-id",
|
|
43
48
|
"featured_media_id": null,
|
|
44
49
|
"category_ids": [],
|
|
45
50
|
"tag_ids": []
|
|
@@ -51,20 +56,25 @@ curl https://api.roottale.com/v1/cms/posts \
|
|
|
51
56
|
| 필드 | 설명 |
|
|
52
57
|
|---|---|
|
|
53
58
|
| `body_json` | Tiptap 문서 JSON 객체 |
|
|
59
|
+
| `author_profile_id` | 사이트 공통 공개 작성자 ID. 생략한 사용자 키는 계정별 기본값, `null`은 미지정 |
|
|
54
60
|
| `featured_media_id` | 미디어 업로드 완료 응답의 `id`; `null`이면 썸네일 없음 |
|
|
55
61
|
| `publish` | `true`면 즉시 발행. 생략하면 초안 |
|
|
56
62
|
| `scheduled_at` | 미래 ISO 8601 시각. `publish`와 동시 사용 불가 |
|
|
57
63
|
| `category_ids`, `tag_ids` | 생성과 동시에 연결할 term ID 배열 |
|
|
64
|
+
| `model_key` | 페이지·글·정보 모델의 안정 key. 모델이 여러 개면 필수 |
|
|
65
|
+
| `field_values` | 모델 필드 정의로 검증할 편집 원문 객체 |
|
|
58
66
|
|
|
59
67
|
### PATCH /v1/cms/posts/{post_id}
|
|
60
68
|
|
|
61
69
|
보낸 필드만 수정합니다. `featured_media_id: null`이면 썸네일을 해제하고,
|
|
62
|
-
`scheduled_at: null`이면 예약을 해제합니다.
|
|
70
|
+
`scheduled_at: null`이면 예약을 해제합니다. `author_profile_id`를 생략하면 기존
|
|
71
|
+
공개 작성자를 유지하고, `null`이면 초안의 공개 작성자를 비웁니다.
|
|
63
72
|
|
|
64
73
|
### POST /v1/cms/posts/{post_id}/publish
|
|
65
74
|
|
|
66
75
|
초안 또는 예약 글을 즉시 발행하고 등록된 revalidate 웹훅을 전송합니다.
|
|
67
|
-
본문은 `{}` 또는 `{"site_id":"..."}`입니다.
|
|
76
|
+
본문은 `{}` 또는 `{"site_id":"..."}`입니다. 글(`type: "post"`)은 현재
|
|
77
|
+
사이트의 활성 공통 작성자가 지정돼 있어야 발행·예약할 수 있습니다.
|
|
68
78
|
|
|
69
79
|
### POST /v1/cms/posts/{post_id}/unpublish
|
|
70
80
|
|
|
@@ -81,7 +91,11 @@ curl https://api.roottale.com/v1/cms/posts \
|
|
|
81
91
|
### GET /v1/cms/posts
|
|
82
92
|
|
|
83
93
|
초안·예약·비공개·발행 글을 조회합니다. 쿼리는 `limit`, `cursor`, `type`,
|
|
84
|
-
`status`, `site_id`를 지원합니다.
|
|
94
|
+
`status`, `site_id`, `model_key`를 지원합니다.
|
|
95
|
+
|
|
96
|
+
관리 응답은 `owner_user_id`(편집 소유 계정)와
|
|
97
|
+
`author_profile_id`(사이트 공통 공개 작성자)를 별도로 반환합니다. 기존
|
|
98
|
+
`author_id`는 하위 호환용이며 새 연동에서는 두 분리 필드를 사용하세요.
|
|
85
99
|
|
|
86
100
|
## 미디어 업로드 API
|
|
87
101
|
|
|
@@ -267,6 +281,8 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
|
|
|
267
281
|
{
|
|
268
282
|
"items": [ { "id": "…", "slug": "…", "title": "…", "body_json": {…},
|
|
269
283
|
"collection_key": "blog",
|
|
284
|
+
"author_profile_id": "site-author-id",
|
|
285
|
+
"author_name": "공통 작성자", "author_slug": "writer",
|
|
270
286
|
"terms": [{ "taxonomy": "category", "name": "…", "slug": "…" }],
|
|
271
287
|
"published_at": "…" } ],
|
|
272
288
|
"has_more": false,
|
|
@@ -278,10 +294,38 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
|
|
|
278
294
|
> 미설정이면 `null`. 카테고리(`terms`)는 섹션 안의 주제로 아카이브에만 쓰입니다 →
|
|
279
295
|
> [콘텐츠 유형 (Collections)](./collections.md).
|
|
280
296
|
|
|
297
|
+
`author_profile_id`는 사이트 공통 공개 작성자 ID입니다. 이름·사진·소개·작가 주소는
|
|
298
|
+
같은 원장의 `author_name`·`author_image_url`·`author_bio`·`author_slug`로
|
|
299
|
+
제공됩니다. 기존 `author_id`는 하위 호환용이므로 새 연동에서는 공개 작성자
|
|
300
|
+
식별자로 사용하지 마세요.
|
|
301
|
+
|
|
281
302
|
## GET /v1/cms/public/posts/{identifier}
|
|
282
303
|
|
|
283
304
|
글 1개 — `identifier`는 slug 또는 UUID. 미발행/없는 글은 `404`.
|
|
284
305
|
|
|
306
|
+
## GET /v1/cms/public/posts/preview
|
|
307
|
+
|
|
308
|
+
관리자 편집기가 발급한 **미리보기 토큰**으로 초안(미발행 포함)을 **발행 글과
|
|
309
|
+
같은 모양**으로 받습니다. 사이트가 자기 글 템플릿으로 미리보기를 그리게 하는
|
|
310
|
+
용도입니다 — 관리자 미리보기와 발행 결과가 다르던 문제의 해법(`blog.md`
|
|
311
|
+
"미리보기 페이지" 참고).
|
|
312
|
+
|
|
313
|
+
| 쿼리 | 설명 |
|
|
314
|
+
|---|---|
|
|
315
|
+
| `token` | 편집기 '미리보기'·'공유 링크'가 URL `?token=`으로 넘긴 값 (글 1건 전용, 1시간) |
|
|
316
|
+
| `site_id` | 멀티 사이트 키일 때만 |
|
|
317
|
+
|
|
318
|
+
- 응답은 `GET /posts/{identifier}`와 같은 형식이며 `preview` 블록이 추가됩니다:
|
|
319
|
+
`{ "expires_at": "…", "source_status": "draft" }`. `status`는 템플릿 재사용을
|
|
320
|
+
위해 항상 `published`, `title`·`slug`·`excerpt`·`body_json`은 토큰 발급
|
|
321
|
+
시점의 편집기 내용, `body_html`은 항상 `null`, 발행 전이면 `published_at`은
|
|
322
|
+
토큰 발급 시각.
|
|
323
|
+
- 토큰이 없거나 다른 사이트의 것이면 `404`, 만료면 `410 preview_expired`.
|
|
324
|
+
- 응답 헤더 `cache-control: no-store` — 사이트도 절대 캐시하지 마세요.
|
|
325
|
+
|
|
326
|
+
JS/TS 는 `@roottale/cms-client/server` 의 `fetchPostPreview({ apiKey, token })`
|
|
327
|
+
와 `isPreviewExpiredError(error)` 를 사용하세요.
|
|
328
|
+
|
|
285
329
|
## GET /v1/cms/public/search
|
|
286
330
|
|
|
287
331
|
발행된 콘텐츠 키워드 검색입니다. 제목 완전일치, 제목 부분일치, 요약, 본문
|
|
@@ -411,7 +455,12 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
|
|
|
411
455
|
{ "tenant_id": "…", "site_id": "…",
|
|
412
456
|
"categories": [
|
|
413
457
|
{ "slug": "tax", "name": "세무", "published_post_count": 412,
|
|
414
|
-
"collection_key":
|
|
458
|
+
"collection_key": "column", "hub_promoted": true,
|
|
459
|
+
"description": "세무 칼럼", "seo_title": "세무 칼럼 모음",
|
|
460
|
+
"seo_description": "세무 칼럼을 주제별로 확인하세요.",
|
|
461
|
+
"image_media_id": "019…", "image_url": "https://imagedelivery.net/…/md",
|
|
462
|
+
"image_alt": "세무 자료와 계산기",
|
|
463
|
+
"path": "/column/tax" },
|
|
415
464
|
{ "slug": "law", "name": "법률", "published_post_count": 0,
|
|
416
465
|
"collection_key": null, "hub_promoted": false }
|
|
417
466
|
] }
|
|
@@ -419,7 +468,15 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
|
|
|
419
468
|
|
|
420
469
|
- 글이 0건인 분류도 그대로 내려옵니다(`published_post_count: 0`) — "그런 분류가
|
|
421
470
|
없다"와 "분류는 있는데 이 범위에 글이 없다"를 구분할 수 있게.
|
|
422
|
-
- `
|
|
471
|
+
- `description`·`seo_title`·`seo_description`은 ROOT-ADMIN 카테고리 편집값입니다.
|
|
472
|
+
검색 화면에서는 `seo_title → "{name} 글 모음"`, `seo_description → description
|
|
473
|
+
→ 사이트별 기본 소개문` 순으로 대체값을 적용하세요.
|
|
474
|
+
- `image_media_id`는 대표 이미지의 안정 ID, `image_url`은 바로 표시할 수 있는 URL,
|
|
475
|
+
`image_alt`는 대체 텍스트입니다. 세 값은 이미지 미설정 또는 구버전 API에서
|
|
476
|
+
`null`이거나 생략될 수 있습니다.
|
|
477
|
+
`path`는 콘텐츠 유형의 `category_path` 정책까지 적용한 정규 상대 경로이며, 해당
|
|
478
|
+
유형에 카테고리 아카이브가 없으면 `null`입니다.
|
|
479
|
+
- `hub_promoted` 는 어드민의 "이 카테고리 모음 페이지를 검색에 공개" 값이지만, **검색 노출
|
|
423
480
|
판정에는 쓰지 마세요**. 그 판정의 단일 소스는 `/blog-settings` 의
|
|
424
481
|
`sitemap.promoted_category_slugs` 입니다(두 소스를 섞으면 사이트맵과 페이지가
|
|
425
482
|
서로 다른 시점의 값을 볼 수 있습니다). 여기 값은 진단·표시용입니다.
|
package/docs/blog.md
CHANGED
|
@@ -216,6 +216,99 @@ export default async function PostPage({
|
|
|
216
216
|
> 태그명(`span`)을 직접 선택자로 쓴 고객 CSS/스크립트가 있다면 `time` 으로
|
|
217
217
|
> 갱신하세요.
|
|
218
218
|
|
|
219
|
+
### 미리보기 페이지 — 관리자 미리보기 = 발행 결과
|
|
220
|
+
|
|
221
|
+
관리자(ROOT-ADMIN) 편집기의 **미리보기**·**공유 링크**는 기본으로 관리자가
|
|
222
|
+
RootTale 기본 테마로 그린 화면을 엽니다. 사이트 고유 폰트·본문 너비·목차와는
|
|
223
|
+
다를 수 있어서, 사이트에 아래 라우트를 두면 **사이트의 실제 글 템플릿**으로
|
|
224
|
+
미리보기를 그리게 할 수 있습니다. 라우트를 배포한 뒤 관리자 **설정 › 외부
|
|
225
|
+
연결(Webhook) › 글 미리보기 여는 곳**에서 "홈페이지에서 열기"를 켜면 편집기의
|
|
226
|
+
미리보기·공유 링크가 `https://{사이트}/preview/post/{글 ID}?token=…` 으로 열립니다.
|
|
227
|
+
|
|
228
|
+
```tsx
|
|
229
|
+
// app/preview/post/[id]/page.tsx
|
|
230
|
+
import type { Metadata } from "next";
|
|
231
|
+
import { notFound } from "next/navigation";
|
|
232
|
+
import {
|
|
233
|
+
fetchPostPreview,
|
|
234
|
+
isPreviewExpiredError,
|
|
235
|
+
} from "@roottale/cms-client/server";
|
|
236
|
+
import {
|
|
237
|
+
RootTaleBlogPost,
|
|
238
|
+
RootTalePreviewNotice,
|
|
239
|
+
} from "@roottale/cms-renderer-next/server";
|
|
240
|
+
import { buildPreviewMetadata } from "@roottale/cms-renderer-next/routes";
|
|
241
|
+
|
|
242
|
+
// 초안이 ISR·CDN 에 남으면 안 된다 — 항상 동적 렌더.
|
|
243
|
+
export const dynamic = "force-dynamic";
|
|
244
|
+
export const revalidate = 0;
|
|
245
|
+
|
|
246
|
+
interface Props {
|
|
247
|
+
params: Promise<{ id: string }>;
|
|
248
|
+
searchParams: Promise<{ token?: string }>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
|
|
252
|
+
const { token } = await searchParams;
|
|
253
|
+
const post = token
|
|
254
|
+
? await fetchPostPreview({
|
|
255
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
256
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
257
|
+
token,
|
|
258
|
+
}).catch(() => null)
|
|
259
|
+
: null;
|
|
260
|
+
return buildPreviewMetadata({ title: post?.title }); // 항상 noindex/nofollow
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export default async function PostPreviewPage({ params, searchParams }: Props) {
|
|
264
|
+
const { id } = await params;
|
|
265
|
+
const { token } = await searchParams;
|
|
266
|
+
if (!token) notFound();
|
|
267
|
+
|
|
268
|
+
let expiresAt: string | undefined;
|
|
269
|
+
try {
|
|
270
|
+
const post = await fetchPostPreview({
|
|
271
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
272
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
273
|
+
token,
|
|
274
|
+
});
|
|
275
|
+
if (!post || post.id !== id) notFound(); // 토큰은 글 1건 전용
|
|
276
|
+
expiresAt = post.preview.expiresAt;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (!isPreviewExpiredError(error)) throw error; // 만료는 컴포넌트가 안내
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<>
|
|
283
|
+
<RootTalePreviewNotice expiresAt={expiresAt} />
|
|
284
|
+
{/* 발행 글 상세와 같은 props 를 쓰되 slugOrId 대신 previewToken 만 넘긴다. */}
|
|
285
|
+
<RootTaleBlogPost
|
|
286
|
+
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
287
|
+
baseUrl={process.env.ROOTTALE_API_BASE}
|
|
288
|
+
previewToken={token}
|
|
289
|
+
relatedPostsCount={3}
|
|
290
|
+
/>
|
|
291
|
+
</>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
지켜야 할 것:
|
|
297
|
+
|
|
298
|
+
- `RootTaleBlogPost`에 `previewToken`을 주면 `slugOrId` 대신 미리보기 조회
|
|
299
|
+
(`fetchPostPreview`)를 쓰고 **같은 템플릿**으로 그립니다. 만료(410)면
|
|
300
|
+
`previewExpiredElement`(기본 안내 문구), 없으면(404) `notFoundElement`.
|
|
301
|
+
- 항상 `dynamic = "force-dynamic"` — 응답이 `no-store` 라도 페이지 자체가 정적
|
|
302
|
+
생성되면 안 됩니다.
|
|
303
|
+
- `buildPreviewMetadata`는 항상 `noindex, nofollow` 이며 canonical·og 를 내지
|
|
304
|
+
않습니다. `robots.ts`에도 `disallow: ["/preview/"]`를 더하세요.
|
|
305
|
+
- 토큰 없이 접근하면 `notFound()` — 주소만으로는 아무것도 보이지 않습니다.
|
|
306
|
+
- 커스텀 템플릿(직접 fetch)이라면 `fetchPostPreview`가 돌려주는 값이
|
|
307
|
+
`fetchPost`와 같은 `CmsPostContent` 형식(+`preview`)이므로 상세 렌더 함수를
|
|
308
|
+
그대로 재사용하면 됩니다.
|
|
309
|
+
|
|
310
|
+
전체 예시는 `examples/nextjs/app/preview/post/[id]/page.tsx` 에 있습니다.
|
|
311
|
+
|
|
219
312
|
### 다국어 (ADR-0052 A안, W4-6 PR C1/C2)
|
|
220
313
|
|
|
221
314
|
번역 사이트(어드민에서 "번역 추가"로 언어판을 만든 글)는 `RootTaleBlogList`/
|
package/docs/collections.md
CHANGED
|
@@ -42,6 +42,33 @@ description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등
|
|
|
42
42
|
- **같은 글이 두 섹션에 안 뜸**: 공지 글을 `/blog/개강안내`로 열면 404, 반대도 404(가드).
|
|
43
43
|
- **섹션(`collection_key`)이 없는 글**은 sitemap·상세 라우트에서 제외됩니다.
|
|
44
44
|
|
|
45
|
+
### 카테고리를 주소에 넣는 유형
|
|
46
|
+
|
|
47
|
+
칼럼처럼 카테고리가 정보 구조의 일부라면 유형별로 아래 정책을 켤 수 있습니다.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const COLUMN: RouteCollection = {
|
|
51
|
+
key: "column",
|
|
52
|
+
basePath: "/column",
|
|
53
|
+
categories: ["headache", "dizziness"],
|
|
54
|
+
archives: true,
|
|
55
|
+
detailPath: "category",
|
|
56
|
+
categoryPath: "direct",
|
|
57
|
+
categoryCardinality: "exactly-one",
|
|
58
|
+
};
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
이 설정은 카테고리 허브를 `/column/{categorySlug}`, 글 상세를
|
|
62
|
+
`/column/{categorySlug}/{postSlug}`로 만듭니다. `categoryPath`를 생략하면 기존
|
|
63
|
+
형식인 `/column/categories/{categorySlug}`를 유지합니다. `detailPath`를 생략하면
|
|
64
|
+
글 상세도 기존의 `/column/{postSlug}`를 유지합니다.
|
|
65
|
+
|
|
66
|
+
`detailPath: "category"`는 `archives: true`와
|
|
67
|
+
`categoryCardinality: "exactly-one"`이 반드시 필요합니다. ROOT-ADMIN은 공개·예약
|
|
68
|
+
글의 카테고리가 정확히 하나인지 서버에서 검증하며, 기존 공개·예약 글에 누락이 있으면
|
|
69
|
+
이 정책 자체를 저장하지 않습니다. 이미 주소에 쓰이는 카테고리의 slug·소속·삭제도
|
|
70
|
+
URL 이전 계획 없이 바로 바꿀 수 없습니다.
|
|
71
|
+
|
|
45
72
|
## 주소 없는 유형 (`base_path: ""`) — 페이지 안에서만 쓰는 콘텐츠
|
|
46
73
|
|
|
47
74
|
강사·리뷰처럼 **독립된 URL이 없고 홈페이지의 다른 페이지 안에서 불러와 쓰는**
|
|
@@ -111,7 +138,7 @@ export const COLLECTIONS: RouteCollection[] = [
|
|
|
111
138
|
| | 섹션 (collection) | 주제 (category) |
|
|
112
139
|
|---|---|---|
|
|
113
140
|
| 무엇 | 글이 사는 곳 (공지 / 블로그) | 섹션 안의 세부 분류 (칼럼 / 소식) |
|
|
114
|
-
| 글당 | **딱 하나** (배타적) | 0개
|
|
141
|
+
| 글당 | **딱 하나** (배타적) | 기본 0개 이상, 유형 정책으로 정확히 1개 가능 |
|
|
115
142
|
| 정하는 곳 | 글쓰기 "어디에 올릴까요?" | 글 > 분류 > 카테고리 / 글쓰기 주제 칩 |
|
|
116
143
|
| 저장 | `post.collection_key` | 글의 category terms |
|
|
117
144
|
| 라우팅 | basePath 결정 (`/notice`) | 아카이브만 (`/blog/categories/칼럼`) |
|
|
@@ -132,6 +159,9 @@ export const COLLECTIONS: RouteCollection[] = [
|
|
|
132
159
|
| basePath | URL 앞부분 (`/notice`, `/blog`). **비워두면 주소 없는 유형** — 아래 "주소 없는 유형" 참고 |
|
|
133
160
|
| feed / archives / og | RSS 포함 / 주제 아카이브 / 동적 OG |
|
|
134
161
|
| 글별 상세 페이지 (`detail`) | 기본 켜짐. 끄면 목록 전용(상세 URL 없음) — 아래 "목록 전용 유형" 참고 |
|
|
162
|
+
| 상세 주소 (`detail_path`) | `flat`(기본) 또는 `category` — 카테고리를 상세 주소에 포함 |
|
|
163
|
+
| 카테고리 주소 (`category_path`) | `namespaced`(기본 `/categories/{slug}`) 또는 `direct`(`/{slug}`) |
|
|
164
|
+
| 카테고리 개수 (`category_cardinality`) | `multiple`(기본) 또는 `exactly-one` |
|
|
135
165
|
| 순서 | 메뉴·표시 순서 |
|
|
136
166
|
|
|
137
167
|
**섹션을 하나도 안 만들면 단일 블로그(`/blog`)** 로 동작합니다(설정 전 기본값).
|
|
@@ -1,36 +1,284 @@
|
|
|
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
|
+
}
|
|
27
66
|
```
|
|
28
67
|
|
|
29
|
-
|
|
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
|
+
```
|
|
30
160
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
표시됩니다.
|
|
34
282
|
|
|
35
283
|
```tsx
|
|
36
284
|
import { RootTaleExposureSlot } from "@roottale/cms-renderer-next/server";
|
|
@@ -42,22 +290,16 @@ export default async function Layout({ children }: { children: React.ReactNode }
|
|
|
42
290
|
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
43
291
|
slotKey="global-popup"
|
|
44
292
|
path="/"
|
|
45
|
-
allowedVariants={["
|
|
293
|
+
allowedVariants={["notice"]}
|
|
46
294
|
revalidate={60}
|
|
47
295
|
/>
|
|
48
296
|
</>;
|
|
49
297
|
}
|
|
50
298
|
```
|
|
51
299
|
|
|
52
|
-
|
|
53
|
-
팝업은 닫기 버튼과 Escape 닫기를 제공하고 `always|session|day|never` 재노출 정책을
|
|
54
|
-
적용합니다. `@roottale/cms-renderer-next/styles`를 root layout에서 한 번 불러오세요.
|
|
55
|
-
|
|
56
|
-
## Raw API
|
|
300
|
+
공개 raw API는 다음 세 경로입니다.
|
|
57
301
|
|
|
58
302
|
- `GET /v1/cms/public/content-models`
|
|
59
|
-
- `GET /v1/cms/public/posts?model_key=
|
|
303
|
+
- `GET /v1/cms/public/posts?model_key=team-member`
|
|
304
|
+
- `GET /v1/cms/public/categories?collection_key=faq` (`id`, `parent_id` 포함)
|
|
60
305
|
- `GET /v1/cms/public/exposures?slot_key=global-popup&path=%2Fabout`
|
|
61
|
-
|
|
62
|
-
모두 고객용 API key와 `cms:read` 범위가 필요합니다. 노출 API는 선택된 공개 내용만
|
|
63
|
-
반환하며 초안, 보관함, 내부 일정·타기팅 원문은 반환하지 않습니다.
|