@roottale/cms-mcp 0.52.0 → 0.53.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,57 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.53.0
4
+
5
+ ### Patch Changes
6
+
7
+ - c4dd18d: API 키 발급 권한에 **설정 쓰기(`read_write_settings`)** 가 생겼다 — 어드민 화면 대신 외부 도구가 사업장 정보·상단 메뉴를 고칠 수 있게 하는 권한이다(ADR-0096 B3).
8
+ - `getting-started.md` — 권한 선택지에 `read_write_settings` 추가 + 권한별 scope 표. 지금까지 문서에는 scope 이름이 흩어져 있어서, 어떤 키가 무엇을 할 수 있는지 한눈에 볼 곳이 없었다.
9
+ - 어드민 화면은 scope 이름 대신 사람 말로 된 권한 이름("읽기" · "읽기 + 쓰기" · "읽기 + 쓰기 + 설정 변경")을 쓰고, 발급된 키 목록에도 그 이름과 실제 scope 전체가 붙는다. 문서의 권한 목록에 화면 이름을 함께 적어 두 곳을 이어 놓았다.
10
+ - **권한을 실제보다 좁게 안내하던 두 곳을 바로잡았다.** ① `read` 를 "읽기 전용"이라고 했지만 `cms:read` 하나로 **상담 게시판 글이 생성된다**(`POST /v1/cms/public/inquiries`) — 최소 권한이라고 믿고 발급한 키로 데이터가 만들어지고 있었다. ② `read_write` 설명이 작성·발행만 말하고 **발행 취소·글 영구 삭제·미디어 삭제·분류 삭제**를 빼놓아, 키 유출 시 피해를 실제보다 작게 안내했다. `getting-started.md` · `api-reference.md` 양쪽을 고쳤다.
11
+ - 키 교체 절차의 4단계에 판정 기준을 넣었다. "최근 사용 시각으로 확인"만으로는 부족하다 — 목록이 날짜만 보여 주면 같은 날 교체했을 때 옛 키가 아직 호출되는지 구분할 수 없다(화면은 시각까지 보여 주도록 함께 고쳤다). 이제 "정상 호출 주기를 한 번 넘길 때까지 관찰"을 기준으로 적는다.
12
+ - **이미 발급된 `read_write` 키는 그대로다.** `settings:write` 는 새로 발급하는 키에만 붙는다 — 남의 서버에 들어가 있는 글쓰기 키가 하루아침에 사업장 정보·메뉴까지 바꿀 수 있게 되는 일은 없다.
13
+ - `getting-started.md` 에 **키 교체 절차** 신설. 발급된 키의 권한은 나중에 바꿀 수 없어서, 권한을 올리려면 새 키로 갈아타야 한다. 순서를 틀리면(옛 키를 먼저 삭제) 그 사이 요청이 `401 invalid_key` 로 실패하므로 "새 키 발급 → 환경변수 교체 → 재배포 → 최근 사용 확인 → 옛 키 삭제" 순서를 못박았다. 키 유출이 의심될 때만 순서를 뒤집는다.
14
+
15
+ `settings:write` 를 실제로 요구하는 엔드포인트(사업장 정보·상단 메뉴 쓰기)는 아직 없다. 권한 표면을 먼저 여는 이유는, 엔드포인트가 열리는 날 모든 사이트가 키를 재발급해야 하는 상황을 만들지 않기 위해서다.
16
+
17
+ - a133f27: 블로그 상세 글의 작성·출력 기준을 하나로 맞췄습니다.
18
+ - 이미지 대체 텍스트를 한 줄, 최대 160자로 정규화하는 공용 계약을 추가했습니다.
19
+ - 상세 글 위에 카테고리를 연결하고, 발행일과 실제로 다른 수정일을 구분해 표시합니다.
20
+ - 본문의 공식 출처 블록을 제목과 목록이 있는 시맨틱 구간으로 렌더합니다.
21
+ - 블로그 전용 SEO 점검에서 목차, 외부 링크가 있는 공식 출처, 내용이 채워진 FAQ를 확인합니다.
22
+ - Next.js 상세 라우트가 글을 먼저 조회한 뒤 `notFound()`를 호출해야 실제 404가 된다는 통합 예제를 보강했습니다.
23
+
24
+ - 2eb82d3: 블로그 작성자 프로필의 기준을 글에 지정된 사이트 작성자 한 곳으로 통합했습니다.
25
+ - 작성자가 없는 글은 전역 설정으로 작성자 카드가 만들어지지 않습니다.
26
+ - 작성자 이름·사진·소개는 글 응답의 작성자 프로필만 사용합니다.
27
+ - 레거시 전역 작성자 응답 필드는 호환을 위해 남지만 항상 `null`입니다.
28
+
29
+ - 3e5ac18: **수동 갱신 API로 설정 저장 신호(`theme.updated`)를 보낼 수 있게 됐다** (ADR-0096 Phase C1).
30
+
31
+ 지금까지 `POST /v1/cms/revalidate` 의 `event` 는 글 이벤트 셋(`post.published`·`post.updated`·`post.deleted`)뿐이었다. 그래서 알림 주소를 나중에 등록해 저장 시점의 신호를 놓쳤거나, 수신 라우트를 고친 뒤 설정 캐시를 한 번 비우고 싶을 때 **손으로 할 수 있는 일이 없었다** — 사이트의 재검증 주기(길면 30분)를 기다리거나 어드민에서 설정을 의미 없이 다시 저장하는 것뿐이었다.
32
+ - `event: "theme.updated"` 를 받는다. 받는 값 넷을 `api-reference.md` 와 `revalidation-webhooks.md` 양쪽에 적었다.
33
+ - **이 값만 한 단계 위 권한을 요구한다.** `read_write_settings`("읽기 + 쓰기 + 설정 변경", scope `settings:write`)로 발급한 키가 필요하고, 글쓰기 키(`read_write`)로 보내면 `403 insufficient_scope` 다. 나머지 세 값은 지금까지 그대로 글쓰기 키로 보낸다 — **기존 호출자의 권한은 조이지 않았다.**
34
+ - 권한을 나눈 이유를 문서에 적었다. `theme.updated` 는 경로 몇 개가 아니라 **설정 캐시 이름표 전체 + 루트 레이아웃(그 아래 모든 페이지)** 을 다시 만들게 한다. 글을 쓰라고 내준 키가 사이트 전체 재생성을 반복해서 돌릴 수 있으면 안 된다.
35
+ - 설정 쓰기 API(`PATCH /v1/cms/settings/*`)로 사업장 정보·상단 메뉴를 바꾸면 이 신호는 **저장과 함께 자동으로** 나간다는 점도 함께 적었다 — 직접 보낼 필요가 없는 경우와 있는 경우를 가려 놓았다.
36
+ - 웹훅 발송 트리거 목록에 설정 쓰기 API 호출을 추가하고, 수동 호출 항목에 어떤 이벤트를 지정할 수 있는지 링크를 달았다.
37
+
38
+ `paths` 를 함께 보내면 이름표·레이아웃 무효화 **위에** 그 경로들이 더해진다. 비워 두면 홈(`/`)이 기본으로 들어가는데, 이는 `theme.updated` 분기가 없는 옛 수신 라우트(`@roottale/cms-renderer-next` 0.41.0 미만)를 위한 값이다.
39
+
40
+ - 91aee06: **사업장 정보와 상단 메뉴를 API로 바꿀 수 있게 됐다.** 지금까지 이 두 설정을 고치는 길은 어드민 화면뿐이어서, 사이트를 새로 붙일 때마다 데이터베이스를 직접 손대는 일이 반복됐다(ADR-0096 Phase B).
41
+ - `PATCH /v1/cms/settings/business-profile` · `PATCH /v1/cms/settings/site-nav` 두 엔드포인트를 `api-reference.md` 에 "설정 쓰기 API" 절로 넣었다. 요청·응답 예시, 저장 규칙(길이·개수 상한, https 전용 주소, `HH:MM` 표기), 오류 코드까지 한 곳에 있다.
42
+ - **메서드는 `PATCH` 지만 "그 설정 블록 전체 교체"다.** 부분 병합이 아니라는 사실을 문서 맨 앞에 못박았다 — 한 칸만 고칠 생각으로 그 칸만 보내면 나머지가 지워진다. 주소·좌표·메뉴 그룹처럼 겹겹이 중첩된 값에 병합 규칙을 만들면 "어디까지 덮어쓰는가"가 매번 헷갈리기 때문에 택한 방식이고, 대신 그 사실을 숨기지 않는다.
43
+ - **대상 사이트를 설정 본문과 섞지 않는다.** `site_id` 는 본문 바깥에 적고, 사이트가 둘 이상인 테넌트가 이를 빠뜨리면 `400` 으로 거부한다. 예전 규칙대로라면 "가장 오래된 사이트"가 조용히 선택돼 **엉뚱한 사이트의 간판 정보와 메뉴가 바뀐다.**
44
+ - 응답은 **저장된 값 그대로**다. 보낸 값과 다를 수 있어서(요일 순서 정렬, 목록 중복 제거, 업종 기본값) 다음에 무엇을 보게 될지는 이 응답이 정답이다. 공개 조회로 확인하려 하지 말라고 적었다 — 거기엔 최대 10초 캐시가 걸려 있다.
45
+ - 저장 뒤 나가는 갱신 알림의 결과를 `revalidate` 로 함께 돌려준다. `configured`·`delivered`·`failed`·`degraded` 네 값으로 **"알림을 등록하지 않은 정상 상태"와 "보내지도 못한 상태"를 구분**한다. 지금까지는 둘 다 빈 결과라 겉보기가 같았다.
46
+ - `theme-and-settings.md` 의 상단 메뉴 절과 `getting-started.md` 의 권한 목록에서 이 문서로 가는 길을 열었다.
47
+
48
+ 권한은 `read_write_settings` 키에만 있다. 이미 쓰고 있는 글쓰기 키(`read_write`)에는 붙지 않으므로, 남의 서버에 들어가 있는 키가 하루아침에 사업장 정보와 메뉴까지 바꿀 수 있게 되는 일은 없다.
49
+
50
+ - 3ab5570: RSS 생성기에 선택적인 WebSub `atom:hub` 링크를 추가했다. `createFeedRoute`는
51
+ `webSubHubUrl`을 받아 이를 피드에 전달하며, 값을 주지 않은 기존 사이트의 출력은
52
+ 바뀌지 않는다. Site Kit은 Google WebSub hub를 기본 연결하고 IndexNow 소유권 확인용
53
+ `/indexnow-key.txt` 라우트를 제공한다.
54
+
3
55
  ## 0.52.0
4
56
 
5
57
  ### Patch Changes
package/dist/index.js CHANGED
@@ -944,7 +944,7 @@ function registerTools(server) {
944
944
  }
945
945
 
946
946
  // src/server.ts
947
- var VERSION = true ? "0.52.0" : "dev";
947
+ var VERSION = true ? "0.53.0" : "dev";
948
948
  var SERVER_INSTRUCTIONS = `
949
949
  roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uACE0
950
950
  \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.
@@ -12,9 +12,14 @@ description: 공개 API raw 엔드포인트 — JS 외 스택이나 저수준
12
12
  `@roottale/cms-client`를 사용하세요. 글쓰기·미디어 자동화는 아래 HTTP API,
13
13
  MCP tool 또는 공개 CLI를 사용합니다.
14
14
 
15
- 글쓰기·미디어 자동화는 `read_write` API 키가 필요합니다. `read` 키는
16
- 읽기 전용이며 공개 콘텐츠뿐 아니라 관리 API의 초안·예약·비공개 글과
17
- 미디어 목록도 조회할 수 있습니다.
15
+ 글쓰기·미디어 자동화는 `read_write` API 키가 필요합니다. `read` 키는 공개
16
+ 콘텐츠뿐 아니라 관리 API의 초안·예약·비공개 글과 미디어 목록도 조회할 수
17
+ 있습니다. 다만 **완전한 읽기 전용은 아닙니다** — 상담 게시판 글 작성
18
+ (`POST /v1/cms/public/inquiries`)이 `cms:read`로 허용됩니다. 기존 글·미디어·
19
+ 설정을 고치거나 지우려면 `read_write` 이상이 필요합니다.
20
+
21
+ 사업장 정보·상단 메뉴를 바꾸는 일(아래 "설정 쓰기 API")은 한 단계 위인
22
+ `read_write_settings` 키가 따로 필요합니다 — 글쓰기 키로는 열리지 않습니다.
18
23
 
19
24
  ## 관리 API 빠른 흐름
20
25
 
@@ -135,6 +140,115 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
135
140
  미디어 삭제 전 해당 URL을 쓰는 본문과 `featured_media_id` 연결을 먼저
136
141
  교체하세요. 삭제 후 기존 공개 URL은 더 이상 유효하지 않습니다.
137
142
 
143
+ ## 설정 쓰기 API
144
+
145
+ 사업장 정보와 상단 메뉴를 어드민 화면 대신 API로 바꿉니다. **`read_write_settings`
146
+ 권한으로 발급한 키가 필요합니다** (`settings:write`). 글쓰기 키(`read_write`)로
147
+ 호출하면 `403 insufficient_scope` 입니다.
148
+
149
+ 세 가지를 먼저 알아 두세요.
150
+
151
+ 1. **두 엔드포인트 모두 "그 설정 블록 전체 교체"입니다.** 메서드는 `PATCH`
152
+ 지만 부분 병합이 아닙니다 — 보낸 값이 그 설정의 전부가 되고, 빠뜨린
153
+ 필드는 지워집니다. 한 칸만 고치고 싶으면 공개 조회로 현재 값을 받아
154
+ 그 값을 고쳐서 통째로 보내세요.
155
+ 2. **대상 사이트는 `settings` 바깥(envelope)에 적습니다.** 사이트가 둘
156
+ 이상인 테넌트가 `site_id` 를 생략하면 `400 site_id_required` 입니다 —
157
+ 임의의 사이트를 고르지 않습니다. 사이트에 묶인 키(site-scoped)는 생략하고,
158
+ 다른 사이트를 지목하면 `404` 입니다.
159
+ 3. **저장 뒤 연결된 사이트에 `theme.updated` 알림을 보냅니다.** 알림이
160
+ 실패해도 저장은 되돌아가지 않습니다 — 결과는 응답의 `revalidate` 로
161
+ 확인합니다.
162
+
163
+ ### PATCH /v1/cms/settings/business-profile
164
+
165
+ ```json
166
+ {
167
+ "site_id": "019eb70c-…",
168
+ "settings": {
169
+ "name": "길동세무회계",
170
+ "business_type": "AccountingService",
171
+ "telephone": "02-1234-5678",
172
+ "address": { "street_address": "테헤란로 123", "address_locality": "강남구" },
173
+ "opening_hours": [ { "days": ["Mo","Tu","We","Th","Fr"], "opens": "09:00", "closes": "18:00" } ],
174
+ "area_served": ["서울 강남구"],
175
+ "services": ["기장·신고대리", "세무고문"],
176
+ "profiles": { "naver_place": "https://map.naver.com/p/entry/place/1" }
177
+ }
178
+ }
179
+ ```
180
+
181
+ `settings` 안쪽 필드는 `GET /v1/cms/public/business-profile` 응답과 같은
182
+ 이름입니다. 서버가 정하는 값(`tenant_id`·`site_id`·`configured`·`updated_at`)은
183
+ 넣을 수 없습니다 — 넣으면 `400` 입니다.
184
+
185
+ 저장 규칙:
186
+
187
+ | 항목 | 규칙 |
188
+ |---|---|
189
+ | `profiles.*` | **https 절대 주소만.** `http://` 와 로컬 주소는 거부 |
190
+ | `opening_hours` | 최대 7행. 시각은 `HH:MM` 24시간 표기(`09:00`) |
191
+ | `area_served` | 10개 / 각 40자 |
192
+ | `services` | 12개 / 각 80자 (중복은 자동 제거) |
193
+ | `alternate_name`·`fax_number` | 각 120자 / 40자 |
194
+ | 모르는 필드 | 거부 (`400`) |
195
+
196
+ ### PATCH /v1/cms/settings/site-nav
197
+
198
+ ```json
199
+ {
200
+ "settings": {
201
+ "navGroups": [
202
+ { "label": "사무소 소개", "href": "/about" },
203
+ { "label": "소식",
204
+ "columns": [ { "title": "알림",
205
+ "links": [ { "label": "공지", "href": "/notice" } ] } ] }
206
+ ],
207
+ "cta": { "label": "상담 문의", "href": "/contact" }
208
+ }
209
+ }
210
+ ```
211
+
212
+ `settings` 는 `GET /v1/cms/public/theme` 의 `siteNav` 와 같은 모양입니다.
213
+
214
+ 저장 규칙: 메뉴 그룹 7개 / 그룹당 열 3개 / 열당 링크 6개 / 보조 링크
215
+ (`utilityLinks`) 4개. 주소는 `#앵커`·`/경로`·`http(s)://` 만 허용합니다.
216
+ 그룹에는 `href` 나 `columns` 중 하나가 반드시 있어야 합니다.
217
+
218
+ > 상단 메뉴는 한 번 만들면 빈 값으로 되돌릴 수 없습니다(`navGroups` 는 최소
219
+ > 1개). 메뉴를 없애려면 어드민 화면에서 지우세요.
220
+
221
+ ### 응답
222
+
223
+ 두 엔드포인트가 같은 모양으로 답합니다.
224
+
225
+ ```json
226
+ {
227
+ "tenant_id": "…", "site_id": "…",
228
+ "updated_at": "2026-07-28T04:20:00.000Z",
229
+ "settings": { "…": "저장된 값 그대로" },
230
+ "revalidate": { "configured": 1, "delivered": 1, "failed": 0, "degraded": false }
231
+ }
232
+ ```
233
+
234
+ `settings` 는 **저장된 값**입니다 — 보낸 값과 다를 수 있습니다(요일 순서 정렬,
235
+ 목록 중복 제거, `business_type` 기본값 `LocalBusiness` 등). 다음 조회에서 무엇을
236
+ 보게 될지는 이 값이 정답이고, 공개 조회로 확인하려 하지 마세요(공개 응답에는
237
+ 최대 10초 캐시가 걸립니다).
238
+
239
+ `revalidate` 는 저장 뒤 보낸 갱신 알림의 결과입니다.
240
+
241
+ | 필드 | 뜻 |
242
+ |---|---|
243
+ | `configured` | 등록되어 있고 켜져 있는 알림 목적지 수 |
244
+ | `delivered` | 성공 |
245
+ | `failed` | 목적지가 오류를 돌려줌 |
246
+ | `degraded` | **보내지도 못한** 목적지가 있음 — 설정을 점검해야 합니다 |
247
+
248
+ `configured: 0, degraded: false` 는 정상입니다(알림을 등록하지 않은 사이트).
249
+ `degraded: true` 면 사이트에 반영되지 않았을 수 있으니 어드민의 발행 알림
250
+ 설정을 확인하세요.
251
+
138
252
  ## GET /v1/cms/public/posts
139
253
 
140
254
  발행된 글 목록 (커서 페이지네이션).
@@ -237,7 +351,7 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
237
351
 
238
352
  ## GET /v1/cms/public/blog-settings
239
353
 
240
- 블로그 표시 설정 (TOC·작성자·발행일·작성자 카드, 저자 프로필, 글 하단 CTA).
354
+ 블로그 표시 설정 (TOC·작성자·발행일·작성자 카드 표시 정책, 글 하단 CTA).
241
355
  `post_cta` 는 admin 에서 활성화하고 버튼 문구·링크를 채웠을 때만 객체이며,
242
356
  그 외에는 `null`. `RootTaleBlogPost` 가 본문 끝에 자동으로 렌더하므로 별도
243
357
  연동 코드는 필요 없다. `toc_position` 은 목차 배치(`"inline"`=본문 위 접이식,
@@ -256,6 +370,13 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
256
370
  "updated_at": null }
257
371
  ```
258
372
 
373
+ `author_profile_name`·`author_profile_bio`·`author_profile_image_url`·
374
+ `author_card_description`은 이전 연동을 깨지 않기 위해 응답 모양에만 남아 있고
375
+ 항상 `null`입니다. 작성자 이름·사진·소개는 글 응답의 `author_name`·
376
+ `author_image_url`·`author_bio`를 사용하세요. 이 값은 글에 지정된 사이트별
377
+ 작성자 프로필에서 옵니다. 글에 작성자가 없으면 작성자 메타와 카드를 표시하지
378
+ 않습니다.
379
+
259
380
  `site_profile` 은 사이트 공통 SEO 값. `default_og_image_url`(1200×630 권장)은
260
381
  글에 대표/OG 이미지가 없을 때 SNS 공유 썸네일 폴백으로 쓰세요 —
261
382
  `generateMetadata` 에서 `post.seo?.ogImage ?? post.featured_media_url ??
@@ -410,6 +531,24 @@ IP/tenant rate limit 초과 시 `429 rate_limited`를 반환합니다.
410
531
  { "event": "post.updated", "paths": ["/blog", "/blog/my-post"], "slug": "my-post" }
411
532
  ```
412
533
 
534
+ `event`는 `post.published` · `post.updated` · `post.deleted` · `theme.updated`
535
+ 중 하나이고, 생략하면 `post.updated`입니다. 앞의 세 값은 `read_write` 권한
536
+ (`cms:write`)으로 보냅니다.
537
+
538
+ **`theme.updated`(설정 저장 신호)만 `read_write_settings` 권한이 필요합니다**
539
+ ("읽기 + 쓰기 + 설정 변경", scope `settings:write`). 글쓰기 키로 보내면
540
+ `403 insufficient_scope`입니다. 이 신호는 경로 몇 개가 아니라 수신 측의 **설정
541
+ 캐시 이름표 전체와 루트 레이아웃(그 아래 모든 페이지)** 을 다시 만들게 하므로,
542
+ 글을 쓰라고 내준 키에는 열지 않습니다.
543
+
544
+ ```json
545
+ { "event": "theme.updated" }
546
+ ```
547
+
548
+ 위 "설정 쓰기 API"로 사업장 정보·상단 메뉴를 바꾸면 이 신호는 저장과 함께
549
+ 자동으로 나갑니다 — 직접 보낼 필요는 없습니다. 자세한 동작은
550
+ [재검증 웹훅 문서](./revalidation-webhooks.md#수동-revalidation-api)에 있습니다.
551
+
413
552
  ## 에러 형식
414
553
 
415
554
  비 2xx 응답은 JSON 에러 바디(`code`, `message`)를 가집니다. 주요 코드:
package/docs/blog.md CHANGED
@@ -76,7 +76,9 @@ export default function BlogPage() {
76
76
 
77
77
  ```tsx
78
78
  // app/blog/[slug]/page.tsx
79
+ import { fetchPost } from "@roottale/cms-client/server";
79
80
  import { RootTaleBlogPost } from "@roottale/cms-renderer-next/server";
81
+ import { notFound } from "next/navigation";
80
82
 
81
83
  export const revalidate = 1800;
82
84
 
@@ -86,24 +88,40 @@ export default async function PostPage({
86
88
  params: Promise<{ slug: string }>;
87
89
  }) {
88
90
  const { slug } = await params; // Next.js 15+ async params
91
+ const post = await fetchPost({
92
+ apiKey: process.env.ROOTTALE_API_KEY!,
93
+ baseUrl: process.env.ROOTTALE_API_BASE,
94
+ slugOrId: slug,
95
+ });
96
+ if (!post) notFound();
97
+
89
98
  return (
90
99
  <RootTaleBlogPost
91
100
  apiKey={process.env.ROOTTALE_API_KEY!}
92
101
  baseUrl={process.env.ROOTTALE_API_BASE}
93
- slugOrId={slug}
102
+ slugOrId={post.id}
94
103
  showTableOfContents
95
104
  tableOfContentsTitle="목차"
96
105
  relatedPostsCount={3}
106
+ breadcrumb={{ siteUrl: process.env.NEXT_PUBLIC_SITE_URL }}
97
107
  />
98
108
  );
99
109
  }
100
110
  ```
101
111
 
102
- `RootTaleBlogPost`는 기본적으로 글 제목을 `<h1>`으로 출력합니다. 공통 배너나
112
+ `RootTaleBlogPost`는 기본적으로 글 제목을 `<h1>`으로 출력합니다. CMS에서 검토일과
113
+ 검토자를 지정한 글은 글 상단 메타 영역에 검토 이력도 자동으로 표시됩니다. 공통 배너나
103
114
  페이지 전용 헤더에서 같은 제목을 직접 마크업한다면 `showTitle={false}`를
104
115
  넘기세요. 이 옵션은 CMS 제목만 생략하며 요약·발행일·작성자와 본문은 그대로
105
116
  렌더합니다.
106
117
 
118
+ 상세 라우트는 렌더 전에 글을 조회하고, 없으면 Next.js `notFound()`를 호출해야
119
+ 실제 HTTP 404가 됩니다. `notFoundElement`는 컴포넌트 안의 대체 화면일 뿐 응답
120
+ 상태를 404로 바꾸지 못합니다.
121
+
122
+ 카테고리가 있으면 H1 위에 링크로 표시됩니다. 메타 줄에는 발행일이 명시되고,
123
+ 수정일의 달력 날짜가 발행일과 다를 때만 수정일을 따로 표시합니다.
124
+
107
125
  `relatedPostsCount`(기본 0=off)를 주면 글 하단에 **같은 카테고리 최근 글**을 N개
108
126
  `<nav class="rt-cms-related">` 로 노출합니다(현재 글 제외, 발행일 내림차순).
109
127
  제목은 `relatedPostsTitle`(기본 "관련 글"), 링크는 목록과 동일하게 `postHref`
@@ -127,6 +145,21 @@ export default async function PostPage({
127
145
  블록을 본문에 직접 배치할 때는 상단 자동 ToC 와 중복되지 않도록
128
146
  `showTableOfContents` 를 생략(기본 `false`)하는 것을 권장합니다.
129
147
 
148
+ #### 블로그 글 구성 + 공식 출처
149
+
150
+ 어드민 에디터의 슬래시 메뉴 `/블로그 글 구성` 또는 툴바의 **블로그 글 구성**을
151
+ 누르면 인트로 → 목차 → H2 본문 → 공식 출처 → FAQ 골격이 한 번에 들어갑니다.
152
+ 저장될 안내 문구는 넣지 않고 실제 작성 칸만 비워 둡니다.
153
+
154
+ `/공식 출처`는 `references` 블록을 삽입합니다. 학회·정부·연구기관처럼 본문
155
+ 주장을 직접 뒷받침하는 원문을 불릿 링크로 적으세요. 공개 화면에서는
156
+ `<section class="rt-cms-references">`와 보이는 제목으로 렌더됩니다. SEO 점검은
157
+ 이 블록 안에 외부 원문 링크가 있는지도 확인합니다.
158
+
159
+ 이미지 삽입 시 대체 텍스트를 함께 입력합니다. 에디터와 공개 렌더러 모두 줄바꿈·
160
+ 과도한 공백을 정리하고 최대 160자로 제한하므로, 이미지 주변 문단이나 캡션 전체를
161
+ 복사하지 말고 이미지의 핵심 의미만 짧게 적으세요.
162
+
130
163
  #### FAQ 블록 (자주 묻는 질문 + 구조화데이터)
131
164
 
132
165
  어드민 에디터의 슬래시 메뉴 `/FAQ` 로 **FAQ 블록**(`roottale/faq`)을 삽입하면,
@@ -323,9 +356,13 @@ export async function generateMetadata({
323
356
  (`getPost` 의 `BlogPostMeta` 가 그대로 호환), 원본 post 를 쓸 땐
324
357
  `seo`로 넘길 값은 `(post.metaJson as { seo?: ... }).seo`입니다.
325
358
 
326
- SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`,
359
+ SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`, `ogImageAlt`,
327
360
  `noindex`, `nofollow`.
328
361
 
362
+ `buildPostMetadata`는 글 페이지에 Google 큰 이미지 미리보기
363
+ (`max-image-preview:large`)를 기본으로 허용합니다. `ogImageAlt`가 있으면 OG 이미지의
364
+ 대체 텍스트로 사용하고, 없으면 검색 제목을 사용합니다.
365
+
329
366
  ### slug 변경 시 301 리다이렉트 (필수 권장)
330
367
 
331
368
  어드민에서 글 slug를 바꿔도 API는 **옛 slug로 글을 찾아 현재 slug로
@@ -9,16 +9,69 @@ description: API 키 발급, 환경변수 설정, 패키지 설치, 첫 콘텐
9
9
 
10
10
  1. 어드민(`admin.roottale.com`) 로그인
11
11
  2. **설정 > 사이트 연결 키** 메뉴로 이동
12
- 3. 새 키 발급 — 권한 선택:
13
- - **read** (기본): 읽기 전용. 공개 콘텐츠와 관리 API의
14
- 초안·예약·비공개 글, 미디어 목록을 조회할 수 있음
15
- - **read_write**: 글 작성·수정·발행, 카테고리/태그, 미디어 업로드 포함
12
+ 3. 새 키 발급 — 권한 선택 (괄호 안은 화면에 보이는 이름):
13
+ - **read** — "읽기" (기본): 공개 콘텐츠와 관리 API의
14
+ 초안·예약·비공개 글, 미디어 목록을 조회할 수 있음.
15
+ **완전한 읽기 전용은 아닙니다** — 상담 게시판
16
+ 글 작성(`POST /v1/cms/public/inquiries`)이 이 권한으로 됩니다(사이트의
17
+ 상담 폼이 동작하려면 필요). 기존 글·미디어·설정을 고치거나 지우지는 못함
18
+ - **read_write** — "읽기 + 쓰기": 위에 더해 글 작성·수정·**발행·발행 취소·
19
+ 영구 삭제**, 미디어 업로드·**삭제**, 카테고리/태그 생성·**삭제**(딸린
20
+ 연결까지 cascade). 유출 시 콘텐츠가 지워질 수 있는 권한입니다
21
+ - **read_write_settings** — "읽기 + 쓰기 + 설정 변경": 위에 더해
22
+ **사이트 설정 쓰기**(사업장 정보·상단 메뉴)와, 수동 갱신 API로
23
+ **설정 저장 신호(`theme.updated`) 보내기**. 뒤엣것은 사이트의 모든 페이지를
24
+ 다시 만들게 하는 신호라 글쓰기 권한과 나눠 두었습니다. 어드민 화면 대신
25
+ 외부 도구가 가게 이름·주소·전화번호·메뉴를 고쳐야 할 때만 선택하세요.
26
+ 쓰는 방법은 [HTTP API 레퍼런스의 "설정 쓰기 API"](./api-reference.md#설정-쓰기-api)
16
27
  4. 발급된 키(`rtlk_cust_` + 24자)는 **발급 직후 1회만 평문 표시**됩니다. 바로
17
28
  복사해 환경변수에 저장하세요.
18
29
 
30
+ 발급된 뒤에는 키 목록에서 각 키의 권한 이름과 **실제 scope 전체**를 확인할 수
31
+ 있습니다("이 키의 권한 자세히"). 설정 변경이 가능하거나 화면이 모르는 scope가
32
+ 섞인 키는 눈에 띄게 표시됩니다.
33
+
19
34
  키는 사이트 단위로 스코프되어(site-scoped) 해당 사이트의 콘텐츠·웹훅 검증에만
20
35
  사용됩니다.
21
36
 
37
+ 권한별 scope는 다음과 같습니다. 넓은 권한은 좁은 권한을 그대로 포함합니다.
38
+
39
+ | 권한 | scope |
40
+ |---|---|
41
+ | `read` | `cms:read` |
42
+ | `read_write` | `cms:read` `cms:write` `post:draft:write` `post:publish` `media:write` `taxonomies:write` |
43
+ | `read_write_settings` | `read_write` + `settings:write` |
44
+
45
+ > `cms:read`라는 이름과 달리 이 scope 하나로 상담 게시판 글이 **생성**됩니다.
46
+ > "최소 권한 = 아무것도 못 바꿈"으로 읽지 마세요.
47
+
48
+ `settings:write`는 **새로 발급하는 키에만** 붙습니다. 이미 쓰고 있는
49
+ `read_write` 키는 그대로 두어도 동작이 달라지지 않고, 설정 쓰기 권한이
50
+ 저절로 생기지도 않습니다.
51
+
52
+ ### 발급한 뒤에 권한을 바꾸려면 — 새 키로 교체
53
+
54
+ 발급된 키의 권한은 **나중에 바꿀 수 없습니다.** 예를 들어 `read_write` 키를
55
+ 쓰다가 설정 쓰기가 필요해졌다면, 그 키에 권한을 더하는 것이 아니라 새 키를
56
+ 발급해 교체합니다. 순서를 지키면 서비스가 끊기지 않습니다.
57
+
58
+ 1. 어드민 **설정 > 사이트 연결 키**에서 원하는 권한으로 **새 키를 발급**합니다.
59
+ 이름은 교체 대상과 구분되게 적으세요(예: `운영 서버 2026-07`). 목록에 붙는
60
+ 권한 이름으로 어떤 키를 교체해야 하는지 찾을 수 있습니다.
61
+ 2. 배포 환경의 `ROOTTALE_API_KEY`를 새 키로 바꾸고 **재배포**합니다.
62
+ (환경변수만 바꾸고 재배포하지 않으면 옛 키가 계속 쓰입니다.)
63
+ 3. 사이트가 정상 동작하는지 확인합니다 — 글 목록이 보이는지, 자동화가
64
+ `401 invalid_key` 없이 도는지.
65
+ 4. 목록의 **최근 사용**(날짜 + 시각)이 교체 시점 이후로 갱신되지 않는지 확인한 뒤
66
+ 삭제합니다. 판정 기준은 **그 키의 정상 호출 주기를 한 번 넘길 때까지 관찰**
67
+ 입니다 — 하루 한 번 도는 배치라면 하루, ISR 재검증이 30분이면 30분. 배포가
68
+ 여러 곳(프리뷰·스테이징·사내 도구)이면 전부 교체됐는지 함께 봅니다.
69
+ 옛 키를 먼저 지우면 그 사이 요청이 `401 invalid_key`로 실패합니다.
70
+
71
+ 같은 절차를 키 유출이 의심될 때도 씁니다. 다만 그때는 순서를 뒤집어, 옛 키를
72
+ 먼저 삭제하고 새 키로 교체하세요 — 잠깐의 중단보다 유출된 키가 살아 있는 쪽이
73
+ 위험합니다.
74
+
22
75
  ## 2. 환경변수
23
76
 
24
77
  ```bash
@@ -30,6 +83,9 @@ ROOTTALE_API_KEY=rtlk_cust_xxxxxxxxxxxxxxxxxxxxxxxx
30
83
 
31
84
  # 사이트 정식 도메인 (RSS/sitemap/canonical 생성용)
32
85
  NEXT_PUBLIC_SITE_URL=https://example.com
86
+
87
+ # (선택) RootTale 관리자에서 IndexNow를 켰을 때 발급된 검증 키
88
+ INDEXNOW_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
33
89
  ```
34
90
 
35
91
  **중요**: `ROOTTALE_API_KEY`는 절대 `NEXT_PUBLIC_*` 접두를 붙이지 마세요.
@@ -160,7 +160,8 @@ curl -sI -X POST https://<사이트 도메인>/api/revalidate
160
160
  - 사업장 정보(비즈니스 프로필) 변경 → `theme.updated`
161
161
  - 블로그 표시 설정 변경 (TOC, 작성자/발행일, 작성자 카드) → `theme.updated`
162
162
  - 콘텐츠 유형(스트림) 변경 → `theme.updated`
163
- - 수동 revalidation API 호출
163
+ - 설정 쓰기 API(`PATCH /v1/cms/settings/*`) 호출 → `theme.updated`
164
+ - 수동 revalidation API 호출 (`event`로 지정 — 아래 §"수동 revalidation API")
164
165
 
165
166
  설정 저장은 **전부 `theme.updated` 하나**로 옵니다 — 무엇이 바뀌었는지는 본문에
166
167
  없습니다. 그래서 수신 측은 이 이벤트에서 설정 이름표를 통째로 지웁니다.
@@ -276,6 +277,37 @@ Content-Type: application/json
276
277
  }
277
278
  ```
278
279
 
280
+ `event`에 넣을 수 있는 값은 `post.published` · `post.updated` · `post.deleted` ·
281
+ `theme.updated` 넷입니다. 생략하면 `post.updated`입니다.
282
+
283
+ ### 설정을 직접 바꿨을 때 — `theme.updated`
284
+
285
+ 사업장 정보·상단 메뉴를 [설정 쓰기 API](./api-reference.md#설정-쓰기-api)로
286
+ 바꾸면 이 신호는 **저장과 함께 자동으로** 나갑니다. 직접 보낼 일은 그 밖의
287
+ 경우입니다 — 예를 들어 알림 주소를 나중에 등록해서 저장 시점의 신호를 놓쳤거나,
288
+ 수신 라우트를 고친 뒤 캐시 이름표를 한 번 비우고 싶을 때입니다.
289
+
290
+ ```http
291
+ POST https://api.roottale.com/v1/cms/revalidate
292
+ Authorization: Bearer rtlk_cust_...
293
+ Content-Type: application/json
294
+
295
+ { "event": "theme.updated" }
296
+ ```
297
+
298
+ > **이 값만 한 단계 위 권한이 필요합니다.** `read_write_settings` 권한("읽기 +
299
+ > 쓰기 + 설정 변경", scope `settings:write`)으로 발급한 키여야 합니다. 글쓰기
300
+ > 키(`read_write`)로 보내면 `403 insufficient_scope`이고, 나머지 세 값은 지금까지
301
+ > 그대로 글쓰기 키로 보낼 수 있습니다.
302
+ >
303
+ > 권한을 나눈 이유는 비용입니다. `theme.updated`는 경로 몇 개가 아니라 **설정
304
+ > 이름표 전체 + 루트 레이아웃(그 아래 모든 페이지)** 을 다시 만들게 합니다.
305
+ > 글을 쓰라고 내준 키가 사이트 전체 재생성을 반복해서 돌릴 수 있으면 안 됩니다.
306
+
307
+ `paths`를 함께 보내면 이름표·레이아웃 무효화 **위에** 그 경로들이 더해집니다.
308
+ 비워 두면 홈(`/`)이 기본으로 들어갑니다 — `theme.updated` 분기가 없는 옛
309
+ 수신 라우트(`@roottale/cms-renderer-next` 0.41.0 미만)를 위한 기본값입니다.
310
+
279
311
  ## 트러블슈팅
280
312
 
281
313
  | 증상 | 확인 |
package/docs/seo.md CHANGED
@@ -340,6 +340,29 @@ export default function robots(): MetadataRoute.Robots {
340
340
  }
341
341
  ```
342
342
 
343
+ ## 새 글 발견 알림: WebSub와 IndexNow
344
+
345
+ 사이트맵은 전체 URL 목록의 원장이고, 발행 직후 알림은 보조 수단입니다. 일반 블로그
346
+ 글에는 Google Indexing API를 쓰지 마세요. 이 API는 `JobPosting`과
347
+ `BroadcastEvent`가 포함된 라이브 스트림 페이지에만 허용됩니다.
348
+
349
+ `createFeedRoute`에 `webSubHubUrl`을 주면 RSS에 `rel="hub"`와 기존
350
+ `rel="self"`가 함께 들어갑니다.
351
+
352
+ ```ts
353
+ export const GET = createFeedRoute({
354
+ apiKey: process.env.ROOTTALE_API_KEY!,
355
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
356
+ title: "사이트 블로그",
357
+ webSubHubUrl: "https://pubsubhubbub.appspot.com/",
358
+ });
359
+ ```
360
+
361
+ IndexNow는 Bing·Naver 등 참여 검색엔진용이며 Google 색인 요청이 아닙니다. 소유권
362
+ 확인을 위해 같은 host의 공개 키 파일이 필요합니다. Site Kit은
363
+ `INDEXNOW_KEY`가 설정된 경우 `/indexnow-key.txt`에서 그 키만 반환합니다. 플랫폼이
364
+ 보낸 알림의 200·202 응답은 “접수됨”을 뜻하며 검색결과 노출을 보장하지 않습니다.
365
+
343
366
  ## 브레드크럼 (BreadcrumbList)
344
367
 
345
368
  사이트 구조를 검색엔진에 전달하고 검색결과에 경로가 표시됩니다.
@@ -800,4 +823,3 @@ const violations = scanAssetHosts(
800
823
  `@import`는 1-depth만 보며(중첩 `@import`는 범위 밖), CSS escape 시퀀스
801
824
  (`\3a ` 등)까지는 디코드하지 않습니다. 런타임에 JS로 삽입되는 요청은 배포
802
825
  후 실제 네트워크 요청을 검사하는 E2E 프로브로 보완하세요.
803
-
@@ -95,10 +95,22 @@ const navGroups = theme.siteNav?.navGroups ?? FALLBACK_NAV;
95
95
  > 않도록 사이트에는 항상 폴백 메뉴를 두세요. 어드민 폼은 위 상한까지만 입력칸을
96
96
  > 제공하므로 어드민으로 저장한 값은 이 조건을 이미 만족합니다.
97
97
 
98
+ ### 어드민 대신 API로 바꾸기
99
+
100
+ 상단 메뉴와 사업장 정보는 API로도 바꿀 수 있습니다 —
101
+ `PATCH /v1/cms/settings/site-nav` · `PATCH /v1/cms/settings/business-profile`.
102
+ `read_write_settings` 권한으로 발급한 키가 필요하고, 저장은 **그 블록 전체
103
+ 교체**입니다. 요청·응답 모양과 저장 규칙은
104
+ [HTTP API 레퍼런스의 "설정 쓰기 API"](./api-reference.md#설정-쓰기-api)를 보세요.
105
+
106
+ 저장에 성공하면 서버가 `theme.updated` 알림을 보내므로, 위 이름표 배선이
107
+ 되어 있으면 사이트에 곧바로 반영됩니다.
108
+
98
109
  ## 블로그 표시 설정 — fetchBlogSettings
99
110
 
100
- 어드민의 블로그 표시 옵션(TOC 노출, 작성자/발행일 표시, 작성자 카드, 저자
101
- 프로필)을 조회합니다.
111
+ 어드민의 블로그 표시 옵션(TOC 노출, 작성자/발행일 표시, 작성자 카드 표시
112
+ 정책)을 조회합니다. 작성자 이름·사진·소개는 이 설정이 아니라 각 글의
113
+ `authorName`·`authorImageUrl`·`authorBio`가 정본입니다.
102
114
 
103
115
  ```ts
104
116
  import {
@@ -113,14 +125,16 @@ const settings = await fetchBlogSettings({
113
125
  tags: [BLOG_SETTINGS_CACHE_TAG],
114
126
  });
115
127
  // showTableOfContents, showAuthor, showDate, showAuthorCard,
116
- // tocTitle, authorProfileName / Bio / ImageUrl 등
128
+ // tocTitle, authorProfileImageRadius / Position 등
117
129
 
118
130
  // 글 단위 오버라이드(metaJson)와 합성해 최종 표시값 계산
119
131
  const display = resolvePostDisplay(settings, post);
120
132
  ```
121
133
 
122
134
  `RootTaleBlogPost` 컴포넌트를 쓰면 이 설정이 자동 반영됩니다 — 커스텀 UI를
123
- 만들 때만 직접 조회하면 됩니다.
135
+ 만들 때만 직접 조회하면 됩니다. 호환 필드인 `authorProfileName`·
136
+ `authorProfileBio`·`authorProfileImageUrl`·`authorCardDescription`은 항상
137
+ `null`이며 새 코드에서 사용하지 마세요.
124
138
 
125
139
  ## 비즈니스 프로필 (로컬 SEO) — fetchBusinessProfile
126
140
 
@@ -30,13 +30,15 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
30
30
  // 어드민 SEO 패널(metaJson.seo) override + self-canonical + avcd 구조(RSS
31
31
  // alternate·Twitter Card·og article 확장)까지 1줄로. modified/section/tags 는
32
32
  // 있는 만큼만 넘기면 됩니다 — 없으면 해당 필드만 생략.
33
- return buildPostMetadata(post, {
34
- siteUrl: SITE_URL,
35
- path: `/blog/${post.slug}`,
36
- modified: post.modified,
37
- section: post.category || undefined,
38
- tags: post.tags.map((t) => t.name),
39
- });
33
+ return buildPostMetadata(
34
+ {
35
+ ...post,
36
+ modified: post.modified,
37
+ section: post.category || undefined,
38
+ tags: post.tags.map((t) => t.name),
39
+ },
40
+ { siteUrl: SITE_URL, path: `/blog/${post.slug}` },
41
+ );
40
42
  }
41
43
 
42
44
  export default async function PostPage({ params }: Props) {
@@ -79,6 +81,8 @@ export default async function PostPage({ params }: Props) {
79
81
  baseUrl={process.env.ROOTTALE_API_BASE}
80
82
  slugOrId={post.id}
81
83
  showTitle={false}
84
+ showTableOfContents
85
+ relatedPostsCount={3}
82
86
  // opt-in — 시각 브레드크럼 + BreadcrumbList JSON-LD. siteUrl 없으면
83
87
  // 시각 브레드크럼만(JSON-LD 미emit).
84
88
  breadcrumb={{ siteUrl: SITE_URL }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "type": "module",
5
5
  "description": "RootTale CMS MCP server and CLI for post publishing, media uploads, integration docs, and public API access.",
6
6
  "bin": {