@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.
@@ -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`를 사용하세요. 글쓰기·미디어 자동화는 아래 HTTP API,
12
+ `@roottale/cms-client`를 사용하세요. 전체 CMS 관리는 아래 HTTP API,
13
13
  MCP tool 또는 공개 CLI를 사용합니다.
14
14
 
15
- 글쓰기·미디어 자동화는 `read_write` API 키가 필요합니다. `read` 키는 공개
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
 
@@ -146,6 +160,9 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
146
160
  권한으로 발급한 키가 필요합니다** (`settings:write`). 글쓰기 키(`read_write`)로
147
161
  호출하면 `403 insufficient_scope` 입니다.
148
162
 
163
+ 페이지·글·정보 모델과 배너·팝업 슬롯 연동은
164
+ [콘텐츠 모델과 노출 슬롯](./content-models-and-exposures.md)을 참고하세요.
165
+
149
166
  세 가지를 먼저 알아 두세요.
150
167
 
151
168
  1. **두 엔드포인트 모두 "그 설정 블록 전체 교체"입니다.** 메서드는 `PATCH`
@@ -264,6 +281,8 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
264
281
  {
265
282
  "items": [ { "id": "…", "slug": "…", "title": "…", "body_json": {…},
266
283
  "collection_key": "blog",
284
+ "author_profile_id": "site-author-id",
285
+ "author_name": "공통 작성자", "author_slug": "writer",
267
286
  "terms": [{ "taxonomy": "category", "name": "…", "slug": "…" }],
268
287
  "published_at": "…" } ],
269
288
  "has_more": false,
@@ -275,32 +294,65 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
275
294
  > 미설정이면 `null`. 카테고리(`terms`)는 섹션 안의 주제로 아카이브에만 쓰입니다 →
276
295
  > [콘텐츠 유형 (Collections)](./collections.md).
277
296
 
297
+ `author_profile_id`는 사이트 공통 공개 작성자 ID입니다. 이름·사진·소개·작가 주소는
298
+ 같은 원장의 `author_name`·`author_image_url`·`author_bio`·`author_slug`로
299
+ 제공됩니다. 기존 `author_id`는 하위 호환용이므로 새 연동에서는 공개 작성자
300
+ 식별자로 사용하지 마세요.
301
+
278
302
  ## GET /v1/cms/public/posts/{identifier}
279
303
 
280
304
  글 1개 — `identifier`는 slug 또는 UUID. 미발행/없는 글은 `404`.
281
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
+
282
329
  ## GET /v1/cms/public/search
283
330
 
284
- 발행된 글 키워드 검색 (사이트 내 검색, WP `?s=` 패리티). title·excerpt·본문
285
- 텍스트의 case-insensitive 부분일치, 최신 발행순. 응답은 카드 렌더용 슬림
286
- hit — 본문(`body_json`)은 미포함이므로 상세는 slug 로 글 1개 API를 호출하세요.
331
+ 발행된 콘텐츠 키워드 검색입니다. 제목 완전일치, 제목 부분일치, 요약, 본문
332
+ 순으로 관련도를 계산하고 같은 점수에서는 최신 발행순으로 정렬합니다. 응답은
333
+ 카드 렌더용 슬림 hit이며 본문(`body_json`)은 포함하지 않습니다.
287
334
 
288
335
  | 쿼리 | 설명 |
289
336
  |---|---|
290
337
  | `q` | 검색 키워드 (필수, 1~100자) |
291
338
  | `limit` | 결과 수 1~50, 기본 10 |
292
- | `type` | `post`(기본) \| `page` |
339
+ | `type` | `post`(기본, 하위 호환) \| `page` \| `all`(글+페이지 통합) |
340
+ | `locale` | BCP-47 언어 코드. 생략하면 사이트 기본 언어 |
293
341
  | `site_id` | 멀티 사이트 키일 때만 |
294
342
 
295
343
  ```json
296
344
  { "tenant_id": "…", "site_id": "…", "query": "세무",
297
- "items": [ { "id": "…", "type": "post", "title": "…", "slug": "…",
345
+ "items": [ { "id": "…", "type": "post", "collection_key": "notice",
346
+ "locale": "ko", "title": "…", "slug": "…",
298
347
  "excerpt": "…", "featured_media_url": "…",
299
348
  "published_at": "…" } ] }
300
349
  ```
301
350
 
302
351
  JS/TS 는 `@roottale/cms-client/server` 의 `searchPosts({ apiKey, query })` 를
303
- 사용하세요 — 구 서버(라우트 미배포)의 404 를 빈 배열로 처리합니다.
352
+ 사용하세요. 사이트 전체 검색은 `type: "all"`을 지정합니다. 결과 링크는
353
+ `resolveSearchHitPath(hit, collections, locale?)`로 계산해야 콘텐츠 유형의
354
+ `basePath`와 다국어 경로를 그대로 따릅니다. `ROOTTALE_API_KEY`는 브라우저에
355
+ 노출하지 말고 Server Component·Route Handler에서만 사용하세요.
304
356
 
305
357
  ## GET /v1/cms/public/menus
306
358
 
@@ -403,7 +455,12 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
403
455
  { "tenant_id": "…", "site_id": "…",
404
456
  "categories": [
405
457
  { "slug": "tax", "name": "세무", "published_post_count": 412,
406
- "collection_key": null, "hub_promoted": true },
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" },
407
464
  { "slug": "law", "name": "법률", "published_post_count": 0,
408
465
  "collection_key": null, "hub_promoted": false }
409
466
  ] }
@@ -411,7 +468,15 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
411
468
 
412
469
  - 글이 0건인 분류도 그대로 내려옵니다(`published_post_count: 0`) — "그런 분류가
413
470
  없다"와 "분류는 있는데 이 범위에 글이 없다"를 구분할 수 있게.
414
- - `hub_promoted` 는 어드민의 "검색에 이 분류 페이지 노출" 값이지만, **검색 노출
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` 는 어드민의 "이 카테고리 모음 페이지를 검색에 공개" 값이지만, **검색 노출
415
480
  판정에는 쓰지 마세요**. 그 판정의 단일 소스는 `/blog-settings` 의
416
481
  `sitemap.promoted_category_slugs` 입니다(두 소스를 섞으면 사이트맵과 페이지가
417
482
  서로 다른 시점의 값을 볼 수 있습니다). 여기 값은 진단·표시용입니다.
@@ -481,7 +546,7 @@ const { categories, truncated } = await fetchCategoryCounts({
481
546
 
482
547
  ## GET /v1/cms/public/analytics
483
548
 
484
- 분석 태그 설정.
549
+ ROOT-ANALYTICS 사이트 ID와 외부 태그 설정.
485
550
 
486
551
  ```json
487
552
  { "tags": [ { "provider": "ga4", "id": "G-XXXXXXX", "enabled": true } ] }
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`/
@@ -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`)** 로 동작합니다(설정 전 기본값).
@@ -158,6 +188,33 @@ basePath·라벨·플래그는 어드민에서 바꾸면 sitemap·feed·라우
158
188
  (블로그면 칼럼·소식 등). 주제는 선택이며, **글 > 분류 > 카테고리**에서 미리 만들어
159
189
  콘텐츠 유형에 연결합니다.
160
190
 
191
+ ### 콘텐츠 유형별 커스텀 필드 (ACF 방식)
192
+
193
+ **글 > 필드 그룹**에서 콘텐츠 유형마다 별도 입력칸을 만들 수 있습니다. 새 필드
194
+ 그룹의 적용 기준을 **콘텐츠 유형**으로 고른 뒤 `doctors`, `reviews` 같은 유형을
195
+ 선택하면, 그 유형의 글 편집 화면에만 해당 입력칸이 나타납니다. 카테고리를 임시로
196
+ 붙일 필요가 없습니다.
197
+
198
+ 필드 값은 글의 `meta_json.acf`에 저장되며 공개 글 API에서는 정의에 맞게 변환된
199
+ `fields`와 표시용 `fields_meta`로 제공됩니다. 이미지·관계 필드는 raw ID가 아니라
200
+ 공개 URL과 안전한 참조 객체로 해석됩니다.
201
+
202
+ ```ts
203
+ const doctors = await fetchPosts({
204
+ apiKey: process.env.ROOTTALE_API_KEY!,
205
+ collectionKey: "doctors",
206
+ });
207
+
208
+ for (const doctor of doctors.items) {
209
+ // 예: { name, specialty, photo, education, schedule, ... }
210
+ console.log(doctor.fields);
211
+ }
212
+ ```
213
+
214
+ ROOT-ADMIN의 코드 정의 필드 그룹도 같은 규칙을 씁니다. 의료 vertical의 기본
215
+ `의료진 정보` 그룹은 `doctors` 콘텐츠 유형에 연결되며 이름·전문분야·사진·대체
216
+ 텍스트·학력/경력 반복 목록·진료 시간표·담당 치료·검수 책임자를 제공합니다.
217
+
161
218
  ## 사이트 연동 코드
162
219
 
163
220
  섹션 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지