@roottale/cms-mcp 0.58.0 → 0.60.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 CHANGED
@@ -1,5 +1,51 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.60.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6074c63: 아직 없는 콘텐츠 예약 — 관련 콘텐츠와 한 세트인 플랫폼 공통 필드. 글이 미래 글을 공개 주소
8
+ 키(`유형.분류.슬러그`)로 예약해 두면 대상이 발행되는 순간 공개 `related_posts` 에 자동 합류하고,
9
+ 발행 웹훅 `paths` 에 예약한 글의 경로가 함께 실려 참조 페이지가 즉시 갱신된다. 관리
10
+ `PATCH /v1/cms/posts/{post_id}/related` 에 `reserved_keys`(선택) 추가.
11
+
12
+ ### Patch Changes
13
+
14
+ - a5e53a6: ROOT-ADMIN의 UTC 발행 시각을 사이트 운영 시간대로 표시하는 방법과 UTC 자정 경계 확인값을 블로그 연동 문서에 추가합니다.
15
+ - 553623c: Next.js 16 연동 문서와 예시를 `middleware.ts`에서 `proxy.ts` 규약으로 갱신합니다.
16
+ - 819f01c: 작성자 사진의 초점 좌표를 작성자 콘텐츠에 두고 공개 글 렌더러가 이를 적용합니다.
17
+ 공통 글 하단 블록이 없는 기존 사이트에서는 레거시 CTA를 계속 렌더해 화면을 보존하며,
18
+ 사이트맵·이미지 모양 같은 사이트 표현은 프로젝트의 시멘틱 토큰과 코드가 소유합니다.
19
+ - 0c460bd: 계층형 콘텐츠 모델의 발행 웹훅이 모델 설정의 실제 허브·상세 주소를 보내도록 고치고,
20
+ 수신 사이트가 처리하지 못한 글 갱신을 오류로 드러내는 운영 계약을 문서화합니다.
21
+
22
+ ## 0.59.0
23
+
24
+ ### Minor Changes
25
+
26
+ - c4099f2: 관련 콘텐츠(공통 필드) — 편집자가 어드민에서 유형 상관없이 고른 발행 글이 글 목록·상세·미리보기
27
+ 응답 `related_posts` 로 내려온다(제목·주소·유형·요약·대표이미지·발행일, 고른 순서).
28
+ - cms-client: `CmsPostContent.relatedPosts?: CmsRelatedPost[]` + 와이어 매핑(구 서버는 빈 배열)
29
+ - cms-renderer-next: `RootTaleBlogPost` 가 편집자 선택이 있으면 그것을 그리고(저장 주소로 링크),
30
+ 없을 때만 `relatedPostsCount` 자동 추천(같은 카테고리 최신)
31
+ - cms-mcp 문서: 공개 `related_posts` 필드, 관리 `PATCH /v1/cms/posts/{post_id}/related`, blog.md 안내
32
+
33
+ ### Patch Changes
34
+
35
+ - c0b3679: 공통 블록 배치 규칙의 유형 키를 컬렉션(`collectionKey`)에서 콘텐츠 모델(`modelKey`)로 바꾼다
36
+ (ADR-0109 Amendment 1 · ADR-0105). `SitePatternPlacementRule.modelKey`,
37
+ `findSitePatternPlacementRule`/`setSitePatternPlacementRule`/`resolvePatternSlots` 가 `modelKey` 를
38
+ 받는다. 저장된 옛 규칙의 `collectionKey` 필드는 `readSitePatternPlacements` 가 `modelKey` 로 읽는다.
39
+ 공개 API 응답 모양(`pattern_slots`)은 그대로이며, 계산 근거가 글의 `model_key` 로 바뀐다 —
40
+ 컬렉션 설정이 없는 사이트 글·2단계 분류 모델(FAQ)·모델 key 와 컬렉션 key 가 다른 글도 유형별
41
+ 규칙을 받는다. cms-mcp `api-reference.md` 의 `pattern_slots` 설명을 맞춘다.
42
+ - a9e3fc1: 발행 웹훅 문서: 알림 주소를 따로 적지 않는다 — 어드민 사이트 정보의 공개 도메인·스테이징 주소에서
43
+ `https://<도메인>/api/revalidate` 가 자동으로 정해지고 운영·스테이징 스위치로 켜고 끈다
44
+ (`revalidation-webhooks.md` §2·문제 해결 표).
45
+ - 960677c: 발행 웹훅 문서: 주소가 바뀐 글(slug 변경·카테고리 이동)은 `paths` 에 옛 상세 주소도 함께 온다
46
+ (`revalidation-webhooks.md` §글 웹훅의 paths). 수신 측은 옛 주소 캐시와 옛 주소를 참조하던
47
+ 페이지를 같은 요청에서 갱신할 수 있다.
48
+
3
49
  ## 0.58.0
4
50
 
5
51
  ### Minor Changes
package/dist/index.js CHANGED
@@ -1559,7 +1559,7 @@ function registerTools(server) {
1559
1559
  }
1560
1560
 
1561
1561
  // src/server.ts
1562
- var VERSION = true ? "0.58.0" : "dev";
1562
+ var VERSION = true ? "0.60.0" : "dev";
1563
1563
  var SERVER_INSTRUCTIONS = `
1564
1564
  roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uACE0
1565
1565
  \uAE00\xB7\uC378\uB124\uC77C\xB7\uBCF8\uBB38 \uC774\uBBF8\uC9C0\uB97C \uC790\uB3D9\uD654\uD558\uAE30 \uC704\uD55C \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7API tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
@@ -88,6 +88,24 @@ curl https://api.roottale.com/v1/cms/posts \
88
88
  {"taxonomy":"category","term_ids":["term-id-1","term-id-2"]}
89
89
  ```
90
90
 
91
+ ### PATCH /v1/cms/posts/{post_id}/related
92
+
93
+ 글의 **관련 콘텐츠**(어드민 편집기 "관련 콘텐츠" 패널과 같은 원장)를 전체 교체합니다.
94
+ 유형(모델)과 상관없이 같은 사이트의 글을 순서대로 최대 10개. 자기 자신·중복은 무시되고,
95
+ 다른 사이트 글은 400. 발행 글이면 `post.updated` 웹훅이 나갑니다.
96
+
97
+ ```json
98
+ {"related_post_ids":["post-id-1","post-id-2"],
99
+ "reserved_keys":["faq.headache.migraine.aura-symptoms"]}
100
+ ```
101
+
102
+ `reserved_keys` = **아직 없는 콘텐츠 예약**(선택). 공개 주소 조각을 `.` 로 이은 키를 적어 두면
103
+ 대상이 그 주소로 발행되는 순간 공개 `related_posts` 뒤쪽에 자동 합류합니다. 미전달이면 예약을
104
+ 손대지 않고, 빈 배열이면 전부 해제합니다(최대 10개).
105
+
106
+ 응답은 `related_posts`(발행 여부 무관, 순서대로, `status` 포함)입니다. 공개 응답에는 그중
107
+ **발행 글만** `related_posts` 로 나갑니다(아래 글 목록·상세 참고).
108
+
91
109
  ### GET /v1/cms/posts
92
110
 
93
111
  초안·예약·비공개·발행 글을 조회합니다. 쿼리는 `limit`, `cursor`, `type`,
@@ -283,6 +301,7 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
283
301
  "collection_key": "blog",
284
302
  "author_profile_id": "site-author-id",
285
303
  "author_name": "공통 작성자", "author_slug": "writer",
304
+ "author_image_position_x": 50, "author_image_position_y": 50,
286
305
  "terms": [{ "taxonomy": "category", "name": "…", "slug": "…" }],
287
306
  "pattern_slots": { "post_footer": "clinic-guide" },
288
307
  "published_at": "…" } ],
@@ -296,14 +315,29 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
296
315
  > [콘텐츠 유형 (Collections)](./collections.md).
297
316
 
298
317
  `pattern_slots` = 공통 블록 자리별 블록 key(자리 key → `GET /patterns` 의 `key` 또는
299
- `null`). 어드민 배치 규칙을 서버가 이 글의 `collection_key`에 맞춰 계산한 결과이며,
318
+ `null`). 어드민 배치 규칙을 서버가 이 글의 `model_key`(콘텐츠 모델)에 맞춰 계산한 결과이며,
300
319
  선언된 자리(현재 `post_footer` = 글 하단)는 항상 키로 존재합니다. 글 상세·미리보기
301
320
  응답에도 같은 필드가 붙습니다 → 아래 `GET /v1/cms/public/patterns` 참고.
302
321
 
322
+ `related_posts` = 편집자가 어드민 "관련 콘텐츠"에서 고른 글(유형 무관, 고른 순서) + **발행된
323
+ 예약 키 대상**(그 뒤, 적은 순서), **발행 글만**·중복 제거.
324
+ 각 항목은 `{ id, title, slug, path, type, model_key, collection_key, excerpt, featured_media_url,
325
+ published_at }` 로 카드 하나를 그릴 만큼만 담습니다. 목록·상세·미리보기 응답에 모두 붙습니다
326
+ (구 서버는 미포함 → 빈 배열로 취급). 비어 있으면 사이트가 "같은 카테고리 최신 글" 같은
327
+ 자동 추천을 채우면 됩니다 — `@roottale/cms-renderer-next` 의 `RootTaleBlogPost` 는 편집자 선택이
328
+ 있으면 그것을, 없으면 `relatedPostsCount` 만큼 자동 추천을 그립니다.
329
+
330
+ ```json
331
+ { "related_posts": [ { "id": "…", "title": "…", "slug": "…", "path": "/faq/headache/migraine/…",
332
+ "type": "post", "model_key": "faq", "collection_key": "faq",
333
+ "excerpt": "…", "featured_media_url": null, "published_at": "…" } ] }
334
+ ```
335
+
303
336
  `author_profile_id`는 사이트 공통 공개 작성자 ID입니다. 이름·사진·소개·작가 주소는
304
337
  같은 원장의 `author_name`·`author_image_url`·`author_bio`·`author_slug`로
305
- 제공됩니다. 기존 `author_id`는 하위 호환용이므로 새 연동에서는 공개 작성자
306
- 식별자로 사용하지 마세요.
338
+ 제공됩니다. 사진 초점은 `author_image_position_x/y`(0~100, 구 서버는 `null`)이며
339
+ 이미지 모양은 사이트의 시멘틱 토큰/CSS가 정합니다. 기존 `author_id`는 하위
340
+ 호환용이므로 새 연동에서는 공개 작성자 식별자로 사용하지 마세요.
307
341
 
308
342
  ## GET /v1/cms/public/posts/{identifier}
309
343
 
@@ -382,7 +416,7 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
382
416
  ## GET /v1/cms/public/redirects
383
417
 
384
418
  어드민 "설정 > 주소 이동"에서 정의한 **활성** 커스텀 리다이렉트 규칙 전체.
385
- 사이트 미들웨어가 요청 경로를 매칭해 301/302 처리합니다. 비활성 규칙과 운영자
419
+ 사이트 Proxy가 요청 경로를 매칭해 301/302 처리합니다. 비활성 규칙과 운영자
386
420
  메모는 응답에 포함되지 않습니다. 라우트 미배포(구 서버)는 `404` — 빈 목록으로
387
421
  처리하세요. 연동은 `custom-redirects.md` 참고.
388
422
 
@@ -409,10 +443,11 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
409
443
 
410
444
  ## GET /v1/cms/public/blog-settings
411
445
 
412
- 블로그 표시 설정 (TOC·작성자·발행일·작성자 카드 표시 정책, 글 하단 CTA).
413
- `post_cta` 는 admin 에서 활성화하고 버튼 문구·링크를 채웠을 때만 객체이며,
414
- 그 외에는 `null`. `RootTaleBlogPost` 가 본문 끝에 자동으로 렌더하므로 별도
415
- 연동 코드는 필요 없다. `toc_position` 은 목차 배치(`"inline"`=본문 위 접이식,
446
+ 블로그 표시 설정 (TOC·작성자·발행일·작성자 카드 표시 정책).
447
+ `post_cta`는 공통 글 하단 블록이 없는 기존 글에서 `RootTaleBlogPost`가 적용하고,
448
+ 공통 블록이 있으면 중복을 막기 위해 생략한다. 작성자 이미지 모양과 과거 전역 초점
449
+ 필드는 호환용이며, 모양은 사이트 시멘틱 토큰/CSS, 초점은 글 응답의 작성자별 값이
450
+ 소유한다. `toc_position`은 목차 배치(`"inline"`=본문 위 접이식,
416
451
  `"sidebar"`=넓은 화면에서 본문 오른쪽 sticky, 좁은 화면은 자동 인라인)로,
417
452
  렌더러가 알아서 반영하므로 연동 코드 변경은 불필요하다.
418
453
 
package/docs/blog.md CHANGED
@@ -136,6 +136,10 @@ export default async function PostPage({
136
136
  카테고리가 있으면 H1 위에 링크로 표시됩니다. 메타 줄에는 발행일이 명시되고,
137
137
  수정일의 달력 날짜가 발행일과 다를 때만 수정일을 따로 표시합니다.
138
138
 
139
+ 편집자가 어드민 "관련 콘텐츠"에서 글을 골라 두면(유형 무관, 최대 10개) `RootTaleBlogPost` 는
140
+ `relatedPostsCount` 와 무관하게 **그 글들을 고른 순서대로** 글 하단에 그립니다(발행 글만, 저장된
141
+ 공개 주소로 링크). 고른 것이 없을 때만 아래 자동 추천이 동작합니다.
142
+
139
143
  `relatedPostsCount`(기본 0=off)를 주면 글 하단에 **같은 카테고리 최근 글**을 N개
140
144
  `<nav class="rt-cms-related">` 로 노출합니다(현재 글 제외, 발행일 내림차순).
141
145
  제목은 `relatedPostsTitle`(기본 "관련 글"), 링크는 목록과 동일하게 `postHref`
@@ -143,9 +147,10 @@ export default async function PostPage({
143
147
  렌더되지 않습니다.
144
148
 
145
149
  목차(ToC)·작성자 카드·발행일 표시는 어드민의 블로그 표시 설정으로도 제어됩니다
146
- (`theme-and-settings.md` 참고). 어드민 설정 > 블로그에서 **글 하단 CTA**(제목·설명·
147
- 버튼)를 켜면 `RootTaleBlogPost` 가 모든 글 본문 끝에 같은 CTA 블록을 자동으로
148
- 렌더합니다 — 별도 연동 코드는 필요 없습니다.
150
+ (`theme-and-settings.md` 참고). 여러 글에 같은 CTA가 필요하면 공통 블록의
151
+ `post_footer` 자리를 쓰세요. 공통 블록으로 아직 옮기지 않은 기존 사이트는
152
+ `RootTaleBlogPost`가 레거시 `postCta`를 계속 렌더하며, 공통 블록이 배치되면
153
+ 레거시 CTA를 생략해 고객 화면에 두 개가 겹치지 않습니다.
149
154
 
150
155
  #### 목차 블록 (본문 임의 위치)
151
156
 
@@ -197,6 +202,11 @@ export default async function PostPage({
197
202
  로 감쌉니다. slug 가 없는 작가는 지금처럼 plain text 로 렌더됩니다. 링크
198
203
  대상을 바꾸려면 `authorHref`:
199
204
 
205
+ 작성자 사진은 같은 작성자 원장의 `author_image_position_x/y`를 초점으로 사용합니다.
206
+ 관리자는 작성자 화면에서 사진과 초점을 함께 바꿀 수 있고, 원형·사각형 같은 모양은
207
+ 각 사이트의 시멘틱 토큰/CSS가 정합니다. 구 서버처럼 좌표가 없으면 가운데(50/50)를
208
+ 사용합니다.
209
+
200
210
  ```tsx
201
211
  <RootTaleBlogPost
202
212
  apiKey={apiKey}
@@ -413,7 +423,7 @@ export async function getPost(slug: string) {
413
423
  |---|---|
414
424
  | `id`, `slug`, `title` | 식별자·제목 |
415
425
  | `excerpt` | 요약 (목록 카드용) |
416
- | `publishedAt` | 발행 시각 (ISO) |
426
+ | `publishedAt` | 발행 시각 (UTC ISO). 화면에는 사이트 운영 시간대로 변환해서 표시 |
417
427
  | `bodyJson` | 본문 블록 JSON — `RootTaleBlogPost` 또는 `renderBlocks`로 렌더 |
418
428
  | `terms` | 분류 용어 배열 (`taxonomy: "category" \| "tag"`, `name`, `slug`) |
419
429
  | `featuredImageUrl` | 대표 이미지 |
@@ -422,6 +432,45 @@ export async function getPost(slug: string) {
422
432
  | `metaJson` | 부가 메타 — `metaJson.seo`에 SEO 오버라이드 |
423
433
  | `patternSlots` | 공통 블록 자리별 블록 key(`{ post_footer: "clinic-guide" }`) — `RootTaleBlogPost`가 자동 렌더, 자체 화면은 `fetchSitePatterns` + `selectSitePatternForSlot`(`theme-and-settings.md` "공통 블록") |
424
434
 
435
+ ### 발행일 시간대 — 서버 기본값에 맡기지 않기
436
+
437
+ ROOT-ADMIN은 발행 시점을 UTC ISO 문자열로 저장하고, 관리자 화면에서는 한국 운영
438
+ 시간대(`Asia/Seoul`)로 표시합니다. `publishedAt`은 날짜 문자열이 아니라 **한 시점**이므로,
439
+ 커스텀 UI에서 `new Date()` 뒤 서버 기본 시간대로 포맷하거나 `YYYY-MM-DD` 부분만 자르면
440
+ UTC 자정 부근의 글이 관리자보다 하루 전 날짜로 보일 수 있습니다. Vercel 같은 서버
441
+ 런타임은 UTC를 기본값으로 사용할 수 있으므로 실행 환경에 기대지 마세요.
442
+
443
+ 사이트의 운영 시간대를 한 곳에 정하고 `Intl.DateTimeFormat`에 항상 명시합니다. 한국
444
+ ROOT-ADMIN 사이트의 기본 구현은 다음과 같습니다.
445
+
446
+ ```tsx
447
+ const SITE_TIME_ZONE = "Asia/Seoul";
448
+
449
+ export function formatPublishedDate(publishedAt: string): string {
450
+ const date = new Date(publishedAt);
451
+ if (Number.isNaN(date.getTime())) return "";
452
+
453
+ return new Intl.DateTimeFormat("ko-KR", {
454
+ year: "numeric",
455
+ month: "long",
456
+ day: "numeric",
457
+ timeZone: SITE_TIME_ZONE,
458
+ }).format(date);
459
+ }
460
+
461
+ <time dateTime={post.publishedAt}>
462
+ {formatPublishedDate(post.publishedAt)}
463
+ </time>;
464
+ ```
465
+
466
+ - `dateTime`에는 API 원본 ISO 시각을 그대로 둡니다.
467
+ - 보이는 날짜에만 사이트 운영 시간대를 적용합니다.
468
+ - 목록 정렬·예약 판정은 보이는 날짜 문자열이 아니라 원본 시각으로 처리합니다.
469
+ - 최소 경계 확인값: `2026-08-22T21:40:50.666Z`는 서울에서
470
+ `2026년 8월 23일`이어야 합니다.
471
+ - 다른 국가 사이트는 `SITE_TIME_ZONE`만 해당 지역의 IANA 시간대 이름으로 바꾸고,
472
+ 목록·상세·OG 이미지 등 모든 날짜 표시가 같은 값을 사용하게 합니다.
473
+
425
474
  ### 정적 경로 사전 생성 + 메타데이터
426
475
 
427
476
  ```tsx
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  title: 주소 이동 (커스텀 리다이렉트) 연동
3
- description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로 리다이렉트를 사이트 미들웨어로 적용
3
+ description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로 리다이렉트를 사이트 Proxy로 적용
4
4
  ---
5
5
 
6
6
  # 주소 이동 (커스텀 리다이렉트) 연동
7
7
 
8
8
  어드민(admin.roottale.com)의 **설정 > 주소 이동**에서 운영자가 정의한 임의
9
- 경로 이동 규칙(`/old-event → /promo`)을 사이트 미들웨어로 적용합니다. 코드
9
+ 경로 이동 규칙(`/old-event → /promo`)을 사이트 Proxy로 적용합니다. 코드
10
10
  수정 없이 고객이 직접 규칙을 추가·수정·삭제할 수 있습니다.
11
11
 
12
12
  두 종류의 주소 이동이 있습니다.
@@ -15,26 +15,26 @@ description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로
15
15
  새 주소로 이어집니다. 글 라우트에서 처리되며 별도 설정이 필요 없습니다
16
16
  (`blog.md` 의 `postRedirectPath` 참고).
17
17
  - **커스텀 리다이렉트(이 문서)** — 글이 아닌 임의 경로를 옮깁니다. 라우팅
18
- *이전* 단계인 **미들웨어**에서만 가로챌 수 있어, 아래 설정이 필요합니다.
18
+ *이전* 단계인 **Proxy**에서만 가로챌 수 있어, 아래 설정이 필요합니다.
19
19
 
20
20
  글의 공개 주소가 바뀌면(slug 변경·분류 이동·모델 규칙 변경) 플랫폼이 옛 경로 → 현재
21
21
  경로 301 을 **자동으로** 이 목록에 더합니다(`id` 가 `path-history:` 로 시작). 운영자가
22
- 같은 출발 경로 규칙을 만들었으면 운영자 규칙이 이깁니다. 미들웨어를 쓰고 있다면 별도
22
+ 같은 출발 경로 규칙을 만들었으면 운영자 규칙이 이깁니다. Proxy를 쓰고 있다면 별도
23
23
  작업 없이 옛 링크가 새 주소로 갑니다.
24
24
 
25
25
  규칙은 `GET /v1/cms/public/redirects` 로 내려오며 **활성** 규칙만 포함됩니다
26
26
  (`api-reference.md`). 출발 경로는 정규화된 사이트 내부 절대 경로, 도착지는
27
27
  내부 경로 또는 절대 URL, 상태는 `301`(영구) 또는 `302`(임시)입니다.
28
28
 
29
- ## 미들웨어 설정
29
+ ## Proxy 설정
30
30
 
31
31
  `@roottale/cms-renderer-next` 의 `createRedirectMiddleware` 를 프로젝트 루트
32
- `middleware.ts` 에 마운트합니다. 규칙을 자동 캐시(기본 60초)하며, API 실패 시
32
+ `proxy.ts` 에 마운트합니다. 규칙을 자동 캐시(기본 60초)하며, API 실패 시
33
33
  기존 캐시가 있으면 오래된 규칙을 우선 사용하고(stale-first), 캐시가
34
34
  없으면 트래픽을 막지 않고 통과시킵니다(fail-soft).
35
35
 
36
36
  ```ts
37
- // middleware.ts
37
+ // proxy.ts
38
38
  import { NextResponse } from "next/server";
39
39
  import { createRedirectMiddleware } from "@roottale/cms-renderer-next/routes";
40
40
 
@@ -43,7 +43,7 @@ const redirects = createRedirectMiddleware({
43
43
  // apiBase, siteId, cacheTtlMs 는 선택.
44
44
  });
45
45
 
46
- export async function middleware(req: Request) {
46
+ export async function proxy(req: Request) {
47
47
  return (await redirects(req)) ?? NextResponse.next();
48
48
  }
49
49
 
@@ -66,14 +66,14 @@ export const config = {
66
66
  왕복을 막기 위해 해당 요청을 통과시킵니다.
67
67
  - 매칭이 없으면 `null` 을 반환하므로 `NextResponse.next()` 로 통과시키세요.
68
68
 
69
- Site Materializer로 새 사이트를 만들면 `middleware.ts`와
70
- `tests/redirect-middleware.test.ts`가 필수 산출물로 포함됩니다. 두 파일은
69
+ Site Materializer로 새 사이트를 만들면 `proxy.ts`와
70
+ `tests/redirect-proxy.test.ts`가 필수 산출물로 포함됩니다. 두 파일은
71
71
  내부 Materializer 영수증에도 기록되므로, 설치 여부를 추측하지 않고
72
72
  실제 납품 산출물로 확인할 수 있습니다.
73
73
 
74
74
  ## 캐시와 즉시성
75
75
 
76
- 규칙은 미들웨어가 TTL(기본 60초) 동안 캐시합니다. 운영자가 규칙을 바꾸면
76
+ 규칙은 Proxy가 TTL(기본 60초) 동안 캐시합니다. 운영자가 규칙을 바꾸면
77
77
  최대 TTL 만큼 뒤 반영됩니다. 더 빠른 반영이 필요하면 `cacheTtlMs` 를 줄이세요
78
78
  (요청당 API 호출이 늘어납니다).
79
79
 
@@ -84,7 +84,7 @@ const redirects = createRedirectMiddleware({
84
84
  });
85
85
  ```
86
86
 
87
- ## 직접 호출 (미들웨어 없이)
87
+ ## 직접 호출 (Proxy 없이)
88
88
 
89
89
  규칙 목록만 필요하면 `fetchRedirects` 로 직접 가져올 수 있습니다.
90
90
 
@@ -117,15 +117,17 @@ const menu = await fetchMenu({ apiKey, slug: "primary", tags: [MENUS_CACHE_TAG]
117
117
  > 어드민에도 오류가 안 뜨기 때문에, 이 누락은 "가끔 늦게 반영된다"로만 보입니다.
118
118
  > 응답 본문의 `revalidated.requestedTags`가 빈 배열이면 주입이 빠진 것입니다.
119
119
 
120
- ## 2. 어드민에 웹훅 URL 등록
120
+ ## 2. 어드민에서 알림 보낼 주소 확인
121
121
 
122
- 1. 어드민 **내 사이트 > (사이트 선택)** 페이지로 이동
123
- 2. "글 발행 후 사이트 자동 갱신" 카드에서:
124
- - **자동 갱신 URL**: `https://<사이트 도메인>/api/revalidate`
125
- - **활성화** 체크
126
- 3. 저장 — ES256 키페어가 자동 발급됩니다 (고객 측 보관 항목 없음)
122
+ 웹훅 주소는 따로 적지 않습니다. 어드민 **설정 > 사이트 정보**의 **공개 도메인**과
123
+ **스테이징 주소**에서 `https://<도메인>/api/revalidate` 가 자동으로 정해지고, 사이트를
124
+ 만들거나 도메인을 바꾸면 알림 주소도 같이 따라갑니다(스테이징 주소를 지우면 그 목적지도
125
+ 사라집니다). 켜고 끄기는 **설정 > 발행 알림 기록(Webhook)** 또는 **내 사이트 > (사이트
126
+ 선택)** 의 "글 발행 후 사이트 자동 갱신" 카드에서 운영·스테이징 스위치로 합니다.
127
+ 처음 켤 때 ES256 키페어가 자동 발급됩니다(고객 측 보관 항목 없음).
127
128
 
128
- URL을 비우고 저장하면 웹훅이 비활성화됩니다.
129
+ `/api/revalidate` 가 아닌 경로나 임시 미리보기 배포처럼 규칙 밖 주소가 필요하면 같은
130
+ 카드의 **다른 주소 추가**로 직접 등록할 수 있습니다.
129
131
 
130
132
  ### 주소는 "실제로 서비스되는 주소" 여야 합니다
131
133
 
@@ -134,10 +136,11 @@ URL을 비우고 저장하면 웹훅이 비활성화됩니다.
134
136
  그대로 실패합니다 — 화면에는 아무 오류가 안 뜨고, 발행한 글만 조용히 늦게
135
137
  반영됩니다.
136
138
 
137
- - 사이트 도메인을 바꿨다면 **이 URL도 같이 바꾸세요**. 옛 주소가 새 주소로
138
- 넘어가도록 해 뒀더라도 웹훅에는 소용이 없습니다.
139
- - `example.com` 이 `www.example.com` 으로 넘어가는 구성이라면 **넘어간 뒤의
140
- 주소**(`https://www.example.com/api/revalidate`)를 등록하세요.
139
+ - 사이트 도메인을 바꿨다면 **사이트 정보의 공개 도메인을 실제 주소로 고치세요** —
140
+ 알림 주소는 거기서 자동으로 따라갑니다. 옛 주소가 새 주소로 넘어가도록 해 뒀더라도
141
+ 웹훅에는 소용이 없습니다.
142
+ - `example.com` 이 `www.example.com` 으로 넘어가는 구성이라면 공개 도메인을 **넘어간
143
+ 뒤의 주소**(`www.example.com`)로 두세요.
141
144
 
142
145
  ### Cloudflare를 쓴다면 SSL 모드가 Flexible이면 안 됩니다
143
146
 
@@ -206,6 +209,12 @@ curl -sI -X POST https://<사이트 도메인>/api/revalidate
206
209
  `{유형 주소}/categories/{카테고리}`
207
210
  - **유형을 옮긴 글**은 옮기기 전·후 주소가 함께 옵니다 — 옮기기 전 목록에서도
208
211
  글이 빠져야 하기 때문입니다
212
+ - **주소가 바뀐 글**(slug 변경·카테고리 이동 등으로 상세 주소가 옮겨진 글)은 **옛 상세
213
+ 주소도 함께** 옵니다 — 옛 주소의 캐시가 비워져 바로 새 주소로 이동하고, 옛 주소를
214
+ 참조하던 페이지도 같이 갱신할 수 있게 하기 위해서입니다
215
+ - `category_tree` 표시 모델은 설정한 `basePath`, 선택한 분류까지의 모든 허브,
216
+ 분류를 포함한 상세 주소가 옵니다. 모델 preset이 `article`이어도 `/blog`로
217
+ 바꾸지 않습니다
209
218
 
210
219
  주소가 안 나오는 경우도 있습니다.
211
220
 
@@ -219,6 +228,13 @@ curl -sI -X POST https://<사이트 도메인>/api/revalidate
219
228
  수신 측은 모든 서명 이벤트에서 블로그 목록과 피드·사이트맵을 함께 갱신하고,
220
229
  `collections`를 넘긴 사이트는 각 유형의 목록 주소도 자동으로 갱신합니다.
221
230
 
231
+ 직접 만든 수신 라우트가 `modelKey`별 주소를 엄격히 나누는 경우에는 글 이벤트의
232
+ `paths`가 지원하는 주소를 하나도 포함하지 않거나 `modelKey`의 주소 계열과 다르면
233
+ `422` 같은 non-2xx로 답하세요. 아무 캐시도 지우지 않았는데 `200`을 반환하면
234
+ RootTale은 성공으로 기록하므로 경로 계산 오류가 조용히 숨습니다. `modelKey`를
235
+ 경로 대체값으로 써서 성공시키지 말고, 발신 경로와 설정의 불일치를 드러내는 검증값으로
236
+ 사용해야 합니다.
237
+
222
238
  ## 저수준 검증 — verifyRootTaleWebhook
223
239
 
224
240
  `createRevalidateRoute`를 못 쓰는 환경(다른 프레임워크 등)은
@@ -319,8 +335,8 @@ Content-Type: application/json
319
335
 
320
336
  | 증상 | 확인 |
321
337
  |---|---|
322
- | 발행해도 사이트 미반영 | 어드민의 자동 갱신 URL·활성화 체크, 배포 도메인 일치 여부 |
323
- | 전송 기록이 `308`·`301` | 등록한 주소가 다른 주소로 넘어가고 있습니다. 넘어간 뒤의 주소를 등록하세요. 이동 주소가 요청 주소와 같으면 Cloudflare SSL 모드가 Flexible입니다(위 §2 참고) |
338
+ | 발행해도 사이트 미반영 | 어드민 발행 알림 화면의 운영·스테이징 스위치가 켜져 있는지, 사이트 정보의 도메인이 실제 배포 도메인과 같은지 |
339
+ | 전송 기록이 `308`·`301` | 알림 주소가 다른 주소로 넘어가고 있습니다. 사이트 정보의 공개 도메인을 넘어간 뒤의 주소로 고치세요. 이동 주소가 요청 주소와 같으면 Cloudflare SSL 모드가 Flexible입니다(위 §2 참고) |
324
340
  | 401 `invalid_signature` | `ROOTTALE_API_KEY`가 해당 사이트 스코프 키인지 |
325
341
  | 401 `timestamp_out_of_window` | 서버 시계 동기화 (NTP) |
326
342
  | 일부 페이지만 갱신 | `alsoRevalidate`·동적 경로 콜백 누락 |
package/docs/seo.md CHANGED
@@ -53,6 +53,11 @@ const { generateSitemaps, sitemap } = createSitemapIndex(
53
53
  { url: `${SITE_URL}/blog` },
54
54
  { url: `${SITE_URL}/contact` },
55
55
  ],
56
+ {
57
+ // 사이트에 실제로 존재하는 라우트와 콘텐츠에 맞춰 코드에서 결정합니다.
58
+ blogImages: true,
59
+ authors: false,
60
+ },
56
61
  );
57
62
 
58
63
  // Next 16: 인덱스를 만들려면 generateSitemaps·default 를 둘 다 export.
@@ -60,8 +65,8 @@ export { generateSitemaps };
60
65
  export default sitemap;
61
66
  ```
62
67
 
63
- - **블로그 이미지 색인** — 글에 대표 이미지가 있으면 `<image:image>`로 함께 색인합니다.
64
- 어드민 **설정 > 블로그 > 사이트맵**에서 켜고 끌 수 있어요(기본 켜짐).
68
+ - **블로그 이미지 색인** — 글에 대표 이미지가 있으면 `<image:image>`로 함께 색인할지
69
+ 사이트 코드의 `blogImages`로 정합니다.
65
70
  - **분류·작가 모음 페이지는 기본으로 사이트맵에서 빠집니다** — 어드민에서 "검색에
66
71
  노출"을 켠 것만 들어갑니다. 아래 "색인 위생" 절을 보세요.
67
72
  - `changeFrequency`/`priority`는 Google이 무시하므로 더 이상 내보내지 않습니다(`loc` +
@@ -71,9 +76,9 @@ export default sitemap;
71
76
 
72
77
  ### 작가 아카이브 (`/blog/author/{slug}`)
73
78
 
74
- 어드민 **설정 > 팀**에서 작가에게 주소(slug)를 발급하면, 그 작가의 글 모음 페이지와
75
- 작가 사이트맵(`/sitemap/authors.xml`)을 만들 수 있습니다. 어드민 **설정 > 블로그 >
76
- 사이트맵**에서 "작가 사이트맵"을 켜면 인덱스에 `authors` 섹션이 추가됩니다.
79
+ 어드민 **설정 > 팀**에서 작가에게 주소(slug)를 발급하고 사이트 프로젝트에 실제 작가
80
+ 모음 라우트를 만든 뒤, `createSitemapIndex` 옵션의 `authors: true`를 지정하면 작가
81
+ 사이트맵(`/sitemap/authors.xml`)이 추가됩니다. 라우트가 없으면 반드시 `false`로 둡니다.
77
82
 
78
83
  ```tsx
79
84
  // app/blog/author/[slug]/page.tsx
@@ -153,8 +158,8 @@ const jsonLd = collectionPageSchema({
153
158
 
154
159
  - 켜는 곳: 어드민 **내 사이트 > 카테고리**에서 분류를 펼치고 "검색에 이 분류 페이지
155
160
  노출"을 체크합니다. 켠 분류만 사이트맵에 들어가고 `robots`도 색인 허용이 됩니다.
156
- - 작가 모음은 **설정 > 블로그 > 사이트맵**의 "작가별 글 모음도 검색엔진에 알리기"가
157
- 같은 역할을 합니다.
161
+ - 작가 모음은 프로젝트에 실제 라우트가 있을 때만 사이트 코드에서 `authors: true`로
162
+ 같은 결정을 내립니다.
158
163
  - 글 자체는 영향을 받지 않습니다 — 모음 페이지만 빠집니다.
159
164
 
160
165
  판정은 `decideArchiveIndex` 하나에서 나옵니다. 사이트맵 필터와 페이지의 `robots`가
@@ -797,9 +802,8 @@ export default async function RootLayout({ children }) {
797
802
  - `@type`: `[업종, "LocalBusiness"]` (중복 제거) — 어드민에서 고른 업종
798
803
  (세무·회계 = `AccountingService`, 병원·의원 = `MedicalClinic` 등)
799
804
  - `address`(PostalAddress) / `geo`(GeoCoordinates) /
800
- `openingHoursSpecification` / `priceRange` / `areaServed`
805
+ `openingHoursSpecification` / `areaServed`
801
806
  - `alternateName`: 띄어쓰기 변형처럼 같은 사업장을 다르게 부르는 이름
802
- - `faxNumber`: 팩스를 쓰는 업종(세무·법률 등)의 연락처
803
807
  - `hasOfferCatalog`: 어드민 "취급 업무" 목록 — "무엇을 하는 곳인가"를 명시하는
804
808
  신호입니다. 명함·간판에 적힌 업무를 그대로 옮기면 됩니다.
805
809
  - `hasMap`: 네이버플레이스 주소(없으면 카카오맵 주소)
@@ -807,6 +811,11 @@ export default async function RootLayout({ children }) {
807
811
  등의 URL — 검색엔진이 동일 사업장임을 연결합니다. 지도 딥링크(카카오맵)는
808
812
  프로필 페이지가 아니므로 `hasMap` 으로만 나가고 여기엔 포함되지 않습니다.
809
813
 
814
+ `faxNumber`는 고객이 사업장 정보에서 바꿀 수 있으며, 스타터와
815
+ `localBusinessSchema`가 자동으로 JSON-LD에 반영합니다. `priceRange`는 업종별 의미가
816
+ 달라 공통 관리자·스타터가 관리하지 않습니다. 필요하면 사이트 프로젝트가
817
+ `localBusinessSchema` 입력에 명시적으로 추가합니다.
818
+
810
819
  네이버플레이스([new.smartplace.naver.com](https://new.smartplace.naver.com))와
811
820
  구글 비즈니스 프로필([business.google.com](https://business.google.com)) 등록
812
821
  자체는 공개 API가 없어 사장님이 직접 해야 하며, 어드민 화면에 등록 안내와
@@ -126,7 +126,7 @@ const settings = await fetchBlogSettings({
126
126
  tags: [BLOG_SETTINGS_CACHE_TAG],
127
127
  });
128
128
  // showTableOfContents, showAuthor, showDate, showAuthorCard,
129
- // tocTitle, authorProfileImageRadius / Position 등
129
+ // tocTitle, tocPosition, authorCardEyebrow 등
130
130
  // siteProfile: siteDescription · homeTitle · homeDescription(메인 홈 전용
131
131
  // 검색 제목·설명, null 이면 사이트 이름·사이트 설명으로 폴백) · logoUrl ·
132
132
  // faviconUrl · defaultOgImageUrl — 사이트 <head>/OG 폴백 (seo.md 참고)
@@ -135,8 +135,11 @@ const settings = await fetchBlogSettings({
135
135
  const display = resolvePostDisplay(settings, post);
136
136
  ```
137
137
 
138
- `RootTaleBlogPost` 컴포넌트를 쓰면 이 설정이 자동 반영됩니다 — 커스텀 UI를
139
- 만들 때만 직접 조회하면 됩니다. 호환 필드인 `authorProfileName`·
138
+ `RootTaleBlogPost` 컴포넌트를 쓰면 위 글 표시 설정이 자동 반영됩니다. 작성자
139
+ 사진의 초점은 글에 연결된 작성자 콘텐츠 값을 적용하고, 사진 모양은 사이트의
140
+ 시멘틱 토큰/CSS를 따릅니다. 레거시 `postCta`는 아래 공통 블록이 없는 기존 글에서만
141
+ 자동 반영되며, 공통 블록이 배치되면 함께 표시되지 않습니다.
142
+ 호환 필드인 `authorProfileName`·
140
143
  `authorProfileBio`·`authorProfileImageUrl`·`authorCardDescription`은 항상
141
144
  `null`이며 새 코드에서 사용하지 마세요.
142
145
 
@@ -192,9 +195,13 @@ CSS 에 색·모양을 고정하지 말고 `sitePatternPresentationAttributes(pa
192
195
  ## 비즈니스 프로필 (로컬 SEO) — fetchBusinessProfile
193
196
 
194
197
  어드민 **운영 > 비즈니스 프로필**에서 저장한 사업장 정보(이름·별칭·업종·
195
- 연락처·팩스·주소·좌표·영업시간·취급 업무·네이버플레이스/구글/카카오 URL)를
198
+ 연락처·주소·좌표·영업시간·취급 업무·네이버플레이스/구글/카카오 URL)를
196
199
  조회합니다.
197
200
 
201
+ `fax_number`와 `price_range`는 구 소비자 호환을 위해 공개 응답에 남아 있지만 공통
202
+ 관리자와 스타터는 편집·자동 소비하지 않습니다. 필요한 사이트가 프로젝트 코드로
203
+ 소유합니다.
204
+
198
205
  ```ts
199
206
  import {
200
207
  BUSINESS_CACHE_TAG,
@@ -207,8 +214,7 @@ const business = await fetchBusinessProfile({
207
214
  tags: [BUSINESS_CACHE_TAG],
208
215
  });
209
216
  // 미설정이면 null. 설정돼 있으면 name, alternateName, businessType, telephone,
210
- // faxNumber, address, geo, openingHours, priceRange, areaServed, services,
211
- // profiles 포함.
217
+ // address, geo, openingHours, areaServed, services, profiles 포함.
212
218
 
213
219
  if (business) {
214
220
  const jsonLd = localBusinessSchema(business, {
@@ -223,7 +229,6 @@ if (business) {
223
229
  | 프로필 값 | JSON-LD |
224
230
  |---|---|
225
231
  | `alternateName` | `alternateName` — 띄어쓰기 변형 등 같은 사업장의 다른 표기 |
226
- | `faxNumber` | `faxNumber` |
227
232
  | `services` | `hasOfferCatalog` (각 항목이 `Offer` → `Service`) |
228
233
  | `profiles.naverPlace` \| `profiles.kakaoMap` | `hasMap` (네이버 플레이스 우선) |
229
234
  | 그 밖의 `profiles` 값 | `sameAs` — 지도 딥링크(`kakaoMap`)는 제외 |
@@ -232,6 +237,10 @@ if (business) {
232
237
  > 반환합니다. 전화·주소만 채우고 이름을 비워두면 **나머지 입력이 전부 무시**되니
233
238
  > 고객 안내 시 이름을 필수로 안내하세요.
234
239
 
240
+ `faxNumber`는 고객이 사업장 정보에서 바꾸며 스타터와 `localBusinessSchema`가
241
+ 자동 반영합니다. `priceRange`는 업종·사이트마다 의미와 표현이 달라 관리자에서
242
+ 관리하지 않습니다. 필요하면 사이트 프로젝트의 구조화 데이터 코드에 둡니다.
243
+
235
244
  ## ROOT-ANALYTICS 설정 — fetchAnalyticsConfig
236
245
 
237
246
  ROOT-ADMIN에서 등록한 외부 태그(GA4, Microsoft Clarity, Meta Pixel, 네이버)와
@@ -1,7 +1,7 @@
1
- // middleware.ts — 커스텀 리다이렉트(어드민 "설정 > 주소 이동") 적용.
1
+ // proxy.ts — 커스텀 리다이렉트(어드민 "설정 > 주소 이동") 적용.
2
2
  //
3
3
  // 글 슬러그 변경 자동 301 은 글 라우트에서 처리되지만(blog 예시 참고), 글이
4
- // 아닌 임의 경로(`/old-event → /promo`)는 라우팅 이전 단계인 미들웨어에서만
4
+ // 아닌 임의 경로(`/old-event → /promo`)는 라우팅 이전 단계인 Proxy에서만
5
5
  // 가로챌 수 있다. 규칙은 자동 캐시되고, API 실패 시 stale 캐시를
6
6
  // 우선 사용한다. 캐시가 없거나 순환 규칙이면 트래픽을 막지 않는다.
7
7
  import { NextResponse } from "next/server";
@@ -11,7 +11,7 @@ const redirects = createRedirectMiddleware({
11
11
  apiKey: process.env.ROOTTALE_API_KEY!,
12
12
  });
13
13
 
14
- export async function middleware(req: Request) {
14
+ export async function proxy(req: Request) {
15
15
  return (await redirects(req)) ?? NextResponse.next();
16
16
  }
17
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.58.0",
3
+ "version": "0.60.0",
4
4
  "type": "module",
5
5
  "description": "RootTale CMS MCP server and CLI for models, entries, exposures, media, integration docs, and public API access.",
6
6
  "bin": {