@roottale/cms-mcp 0.52.0 → 0.53.1

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,67 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.53.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 6df15b6: 고객 사이트의 서버에서 발행 글과 페이지를 함께 검색할 수 있도록 `searchPosts`에 `type: "all"`과 언어 필터를 추가합니다. 검색 결과에는 콘텐츠 유형과 언어를 포함하고, `resolveSearchHitPath`가 블로그·고정 페이지·콘텐츠 유형·다국어 주소를 안전하게 계산합니다. Next.js 통합 검색 예시와 공개 API 문서도 함께 제공합니다.
8
+ - 1efecbb: 페이지·글·정보의 콘텐츠 모델 공개 계약과 `modelKey` 조회를 추가합니다. 개발자가 선언한 슬롯에서만 배너·팝업을 조회하고 접근성 있게 렌더하는 API, client, renderer, 예제 문서를 함께 제공합니다. 기존 collections와 collectionKey 계약은 유지합니다.
9
+ - a490db2: 사이트 통합 검색의 서버 전용 연동 구조, 실제 콘텐츠 주소 계산, 캐시·장애 처리, 배포 순서와 현재 제약을 설명하는 전용 문서를 추가합니다.
10
+ - 866f4ff: ROOT-ADMIN 필드 그룹을 `collection_key` 기준으로 콘텐츠 유형에 연결하는 방법과 공개 `fields` 연동법을 문서화합니다. 의료 vertical의 `doctors` 유형은 의료진 프로필·반복 이력·진료 시간표·관계 필드를 같은 계약으로 제공합니다.
11
+ - 5cd0e3b: 주소 이동 middleware가 API 장애 시 stale 규칙을 계속 사용하고, 순환 규칙은 브라우저를 왕복시키지 않도록 통과처리합니다. Site Materializer 납품물에 middleware와 회귀 검증 파일을 필수로 포함합니다.
12
+
13
+ ## 0.53.0
14
+
15
+ ### Patch Changes
16
+
17
+ - c4dd18d: API 키 발급 권한에 **설정 쓰기(`read_write_settings`)** 가 생겼다 — 어드민 화면 대신 외부 도구가 사업장 정보·상단 메뉴를 고칠 수 있게 하는 권한이다(ADR-0096 B3).
18
+ - `getting-started.md` — 권한 선택지에 `read_write_settings` 추가 + 권한별 scope 표. 지금까지 문서에는 scope 이름이 흩어져 있어서, 어떤 키가 무엇을 할 수 있는지 한눈에 볼 곳이 없었다.
19
+ - 어드민 화면은 scope 이름 대신 사람 말로 된 권한 이름("읽기" · "읽기 + 쓰기" · "읽기 + 쓰기 + 설정 변경")을 쓰고, 발급된 키 목록에도 그 이름과 실제 scope 전체가 붙는다. 문서의 권한 목록에 화면 이름을 함께 적어 두 곳을 이어 놓았다.
20
+ - **권한을 실제보다 좁게 안내하던 두 곳을 바로잡았다.** ① `read` 를 "읽기 전용"이라고 했지만 `cms:read` 하나로 **상담 게시판 글이 생성된다**(`POST /v1/cms/public/inquiries`) — 최소 권한이라고 믿고 발급한 키로 데이터가 만들어지고 있었다. ② `read_write` 설명이 작성·발행만 말하고 **발행 취소·글 영구 삭제·미디어 삭제·분류 삭제**를 빼놓아, 키 유출 시 피해를 실제보다 작게 안내했다. `getting-started.md` · `api-reference.md` 양쪽을 고쳤다.
21
+ - 키 교체 절차의 4단계에 판정 기준을 넣었다. "최근 사용 시각으로 확인"만으로는 부족하다 — 목록이 날짜만 보여 주면 같은 날 교체했을 때 옛 키가 아직 호출되는지 구분할 수 없다(화면은 시각까지 보여 주도록 함께 고쳤다). 이제 "정상 호출 주기를 한 번 넘길 때까지 관찰"을 기준으로 적는다.
22
+ - **이미 발급된 `read_write` 키는 그대로다.** `settings:write` 는 새로 발급하는 키에만 붙는다 — 남의 서버에 들어가 있는 글쓰기 키가 하루아침에 사업장 정보·메뉴까지 바꿀 수 있게 되는 일은 없다.
23
+ - `getting-started.md` 에 **키 교체 절차** 신설. 발급된 키의 권한은 나중에 바꿀 수 없어서, 권한을 올리려면 새 키로 갈아타야 한다. 순서를 틀리면(옛 키를 먼저 삭제) 그 사이 요청이 `401 invalid_key` 로 실패하므로 "새 키 발급 → 환경변수 교체 → 재배포 → 최근 사용 확인 → 옛 키 삭제" 순서를 못박았다. 키 유출이 의심될 때만 순서를 뒤집는다.
24
+
25
+ `settings:write` 를 실제로 요구하는 엔드포인트(사업장 정보·상단 메뉴 쓰기)는 아직 없다. 권한 표면을 먼저 여는 이유는, 엔드포인트가 열리는 날 모든 사이트가 키를 재발급해야 하는 상황을 만들지 않기 위해서다.
26
+
27
+ - a133f27: 블로그 상세 글의 작성·출력 기준을 하나로 맞췄습니다.
28
+ - 이미지 대체 텍스트를 한 줄, 최대 160자로 정규화하는 공용 계약을 추가했습니다.
29
+ - 상세 글 위에 카테고리를 연결하고, 발행일과 실제로 다른 수정일을 구분해 표시합니다.
30
+ - 본문의 공식 출처 블록을 제목과 목록이 있는 시맨틱 구간으로 렌더합니다.
31
+ - 블로그 전용 SEO 점검에서 목차, 외부 링크가 있는 공식 출처, 내용이 채워진 FAQ를 확인합니다.
32
+ - Next.js 상세 라우트가 글을 먼저 조회한 뒤 `notFound()`를 호출해야 실제 404가 된다는 통합 예제를 보강했습니다.
33
+
34
+ - 2eb82d3: 블로그 작성자 프로필의 기준을 글에 지정된 사이트 작성자 한 곳으로 통합했습니다.
35
+ - 작성자가 없는 글은 전역 설정으로 작성자 카드가 만들어지지 않습니다.
36
+ - 작성자 이름·사진·소개는 글 응답의 작성자 프로필만 사용합니다.
37
+ - 레거시 전역 작성자 응답 필드는 호환을 위해 남지만 항상 `null`입니다.
38
+
39
+ - 3e5ac18: **수동 갱신 API로 설정 저장 신호(`theme.updated`)를 보낼 수 있게 됐다** (ADR-0096 Phase C1).
40
+
41
+ 지금까지 `POST /v1/cms/revalidate` 의 `event` 는 글 이벤트 셋(`post.published`·`post.updated`·`post.deleted`)뿐이었다. 그래서 알림 주소를 나중에 등록해 저장 시점의 신호를 놓쳤거나, 수신 라우트를 고친 뒤 설정 캐시를 한 번 비우고 싶을 때 **손으로 할 수 있는 일이 없었다** — 사이트의 재검증 주기(길면 30분)를 기다리거나 어드민에서 설정을 의미 없이 다시 저장하는 것뿐이었다.
42
+ - `event: "theme.updated"` 를 받는다. 받는 값 넷을 `api-reference.md` 와 `revalidation-webhooks.md` 양쪽에 적었다.
43
+ - **이 값만 한 단계 위 권한을 요구한다.** `read_write_settings`("읽기 + 쓰기 + 설정 변경", scope `settings:write`)로 발급한 키가 필요하고, 글쓰기 키(`read_write`)로 보내면 `403 insufficient_scope` 다. 나머지 세 값은 지금까지 그대로 글쓰기 키로 보낸다 — **기존 호출자의 권한은 조이지 않았다.**
44
+ - 권한을 나눈 이유를 문서에 적었다. `theme.updated` 는 경로 몇 개가 아니라 **설정 캐시 이름표 전체 + 루트 레이아웃(그 아래 모든 페이지)** 을 다시 만들게 한다. 글을 쓰라고 내준 키가 사이트 전체 재생성을 반복해서 돌릴 수 있으면 안 된다.
45
+ - 설정 쓰기 API(`PATCH /v1/cms/settings/*`)로 사업장 정보·상단 메뉴를 바꾸면 이 신호는 **저장과 함께 자동으로** 나간다는 점도 함께 적었다 — 직접 보낼 필요가 없는 경우와 있는 경우를 가려 놓았다.
46
+ - 웹훅 발송 트리거 목록에 설정 쓰기 API 호출을 추가하고, 수동 호출 항목에 어떤 이벤트를 지정할 수 있는지 링크를 달았다.
47
+
48
+ `paths` 를 함께 보내면 이름표·레이아웃 무효화 **위에** 그 경로들이 더해진다. 비워 두면 홈(`/`)이 기본으로 들어가는데, 이는 `theme.updated` 분기가 없는 옛 수신 라우트(`@roottale/cms-renderer-next` 0.41.0 미만)를 위한 값이다.
49
+
50
+ - 91aee06: **사업장 정보와 상단 메뉴를 API로 바꿀 수 있게 됐다.** 지금까지 이 두 설정을 고치는 길은 어드민 화면뿐이어서, 사이트를 새로 붙일 때마다 데이터베이스를 직접 손대는 일이 반복됐다(ADR-0096 Phase B).
51
+ - `PATCH /v1/cms/settings/business-profile` · `PATCH /v1/cms/settings/site-nav` 두 엔드포인트를 `api-reference.md` 에 "설정 쓰기 API" 절로 넣었다. 요청·응답 예시, 저장 규칙(길이·개수 상한, https 전용 주소, `HH:MM` 표기), 오류 코드까지 한 곳에 있다.
52
+ - **메서드는 `PATCH` 지만 "그 설정 블록 전체 교체"다.** 부분 병합이 아니라는 사실을 문서 맨 앞에 못박았다 — 한 칸만 고칠 생각으로 그 칸만 보내면 나머지가 지워진다. 주소·좌표·메뉴 그룹처럼 겹겹이 중첩된 값에 병합 규칙을 만들면 "어디까지 덮어쓰는가"가 매번 헷갈리기 때문에 택한 방식이고, 대신 그 사실을 숨기지 않는다.
53
+ - **대상 사이트를 설정 본문과 섞지 않는다.** `site_id` 는 본문 바깥에 적고, 사이트가 둘 이상인 테넌트가 이를 빠뜨리면 `400` 으로 거부한다. 예전 규칙대로라면 "가장 오래된 사이트"가 조용히 선택돼 **엉뚱한 사이트의 간판 정보와 메뉴가 바뀐다.**
54
+ - 응답은 **저장된 값 그대로**다. 보낸 값과 다를 수 있어서(요일 순서 정렬, 목록 중복 제거, 업종 기본값) 다음에 무엇을 보게 될지는 이 응답이 정답이다. 공개 조회로 확인하려 하지 말라고 적었다 — 거기엔 최대 10초 캐시가 걸려 있다.
55
+ - 저장 뒤 나가는 갱신 알림의 결과를 `revalidate` 로 함께 돌려준다. `configured`·`delivered`·`failed`·`degraded` 네 값으로 **"알림을 등록하지 않은 정상 상태"와 "보내지도 못한 상태"를 구분**한다. 지금까지는 둘 다 빈 결과라 겉보기가 같았다.
56
+ - `theme-and-settings.md` 의 상단 메뉴 절과 `getting-started.md` 의 권한 목록에서 이 문서로 가는 길을 열었다.
57
+
58
+ 권한은 `read_write_settings` 키에만 있다. 이미 쓰고 있는 글쓰기 키(`read_write`)에는 붙지 않으므로, 남의 서버에 들어가 있는 키가 하루아침에 사업장 정보와 메뉴까지 바꿀 수 있게 되는 일은 없다.
59
+
60
+ - 3ab5570: RSS 생성기에 선택적인 WebSub `atom:hub` 링크를 추가했다. `createFeedRoute`는
61
+ `webSubHubUrl`을 받아 이를 피드에 전달하며, 값을 주지 않은 기존 사이트의 출력은
62
+ 바뀌지 않는다. Site Kit은 Google WebSub hub를 기본 연결하고 IndexNow 소유권 확인용
63
+ `/indexnow-key.txt` 라우트를 제공한다.
64
+
3
65
  ## 0.52.0
4
66
 
5
67
  ### 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.1" : "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,118 @@ 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
+ [콘텐츠 모델과 노출 슬롯](./content-models-and-exposures.md)을 참고하세요.
151
+
152
+ 세 가지를 먼저 알아 두세요.
153
+
154
+ 1. **두 엔드포인트 모두 "그 설정 블록 전체 교체"입니다.** 메서드는 `PATCH`
155
+ 지만 부분 병합이 아닙니다 — 보낸 값이 그 설정의 전부가 되고, 빠뜨린
156
+ 필드는 지워집니다. 한 칸만 고치고 싶으면 공개 조회로 현재 값을 받아
157
+ 그 값을 고쳐서 통째로 보내세요.
158
+ 2. **대상 사이트는 `settings` 바깥(envelope)에 적습니다.** 사이트가 둘
159
+ 이상인 테넌트가 `site_id` 를 생략하면 `400 site_id_required` 입니다 —
160
+ 임의의 사이트를 고르지 않습니다. 사이트에 묶인 키(site-scoped)는 생략하고,
161
+ 다른 사이트를 지목하면 `404` 입니다.
162
+ 3. **저장 뒤 연결된 사이트에 `theme.updated` 알림을 보냅니다.** 알림이
163
+ 실패해도 저장은 되돌아가지 않습니다 — 결과는 응답의 `revalidate` 로
164
+ 확인합니다.
165
+
166
+ ### PATCH /v1/cms/settings/business-profile
167
+
168
+ ```json
169
+ {
170
+ "site_id": "019eb70c-…",
171
+ "settings": {
172
+ "name": "길동세무회계",
173
+ "business_type": "AccountingService",
174
+ "telephone": "02-1234-5678",
175
+ "address": { "street_address": "테헤란로 123", "address_locality": "강남구" },
176
+ "opening_hours": [ { "days": ["Mo","Tu","We","Th","Fr"], "opens": "09:00", "closes": "18:00" } ],
177
+ "area_served": ["서울 강남구"],
178
+ "services": ["기장·신고대리", "세무고문"],
179
+ "profiles": { "naver_place": "https://map.naver.com/p/entry/place/1" }
180
+ }
181
+ }
182
+ ```
183
+
184
+ `settings` 안쪽 필드는 `GET /v1/cms/public/business-profile` 응답과 같은
185
+ 이름입니다. 서버가 정하는 값(`tenant_id`·`site_id`·`configured`·`updated_at`)은
186
+ 넣을 수 없습니다 — 넣으면 `400` 입니다.
187
+
188
+ 저장 규칙:
189
+
190
+ | 항목 | 규칙 |
191
+ |---|---|
192
+ | `profiles.*` | **https 절대 주소만.** `http://` 와 로컬 주소는 거부 |
193
+ | `opening_hours` | 최대 7행. 시각은 `HH:MM` 24시간 표기(`09:00`) |
194
+ | `area_served` | 10개 / 각 40자 |
195
+ | `services` | 12개 / 각 80자 (중복은 자동 제거) |
196
+ | `alternate_name`·`fax_number` | 각 120자 / 40자 |
197
+ | 모르는 필드 | 거부 (`400`) |
198
+
199
+ ### PATCH /v1/cms/settings/site-nav
200
+
201
+ ```json
202
+ {
203
+ "settings": {
204
+ "navGroups": [
205
+ { "label": "사무소 소개", "href": "/about" },
206
+ { "label": "소식",
207
+ "columns": [ { "title": "알림",
208
+ "links": [ { "label": "공지", "href": "/notice" } ] } ] }
209
+ ],
210
+ "cta": { "label": "상담 문의", "href": "/contact" }
211
+ }
212
+ }
213
+ ```
214
+
215
+ `settings` 는 `GET /v1/cms/public/theme` 의 `siteNav` 와 같은 모양입니다.
216
+
217
+ 저장 규칙: 메뉴 그룹 7개 / 그룹당 열 3개 / 열당 링크 6개 / 보조 링크
218
+ (`utilityLinks`) 4개. 주소는 `#앵커`·`/경로`·`http(s)://` 만 허용합니다.
219
+ 그룹에는 `href` 나 `columns` 중 하나가 반드시 있어야 합니다.
220
+
221
+ > 상단 메뉴는 한 번 만들면 빈 값으로 되돌릴 수 없습니다(`navGroups` 는 최소
222
+ > 1개). 메뉴를 없애려면 어드민 화면에서 지우세요.
223
+
224
+ ### 응답
225
+
226
+ 두 엔드포인트가 같은 모양으로 답합니다.
227
+
228
+ ```json
229
+ {
230
+ "tenant_id": "…", "site_id": "…",
231
+ "updated_at": "2026-07-28T04:20:00.000Z",
232
+ "settings": { "…": "저장된 값 그대로" },
233
+ "revalidate": { "configured": 1, "delivered": 1, "failed": 0, "degraded": false }
234
+ }
235
+ ```
236
+
237
+ `settings` 는 **저장된 값**입니다 — 보낸 값과 다를 수 있습니다(요일 순서 정렬,
238
+ 목록 중복 제거, `business_type` 기본값 `LocalBusiness` 등). 다음 조회에서 무엇을
239
+ 보게 될지는 이 값이 정답이고, 공개 조회로 확인하려 하지 마세요(공개 응답에는
240
+ 최대 10초 캐시가 걸립니다).
241
+
242
+ `revalidate` 는 저장 뒤 보낸 갱신 알림의 결과입니다.
243
+
244
+ | 필드 | 뜻 |
245
+ |---|---|
246
+ | `configured` | 등록되어 있고 켜져 있는 알림 목적지 수 |
247
+ | `delivered` | 성공 |
248
+ | `failed` | 목적지가 오류를 돌려줌 |
249
+ | `degraded` | **보내지도 못한** 목적지가 있음 — 설정을 점검해야 합니다 |
250
+
251
+ `configured: 0, degraded: false` 는 정상입니다(알림을 등록하지 않은 사이트).
252
+ `degraded: true` 면 사이트에 반영되지 않았을 수 있으니 어드민의 발행 알림
253
+ 설정을 확인하세요.
254
+
138
255
  ## GET /v1/cms/public/posts
139
256
 
140
257
  발행된 글 목록 (커서 페이지네이션).
@@ -167,26 +284,31 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
167
284
 
168
285
  ## GET /v1/cms/public/search
169
286
 
170
- 발행된 글 키워드 검색 (사이트 내 검색, WP `?s=` 패리티). title·excerpt·본문
171
- 텍스트의 case-insensitive 부분일치, 최신 발행순. 응답은 카드 렌더용 슬림
172
- hit — 본문(`body_json`)은 미포함이므로 상세는 slug 로 글 1개 API를 호출하세요.
287
+ 발행된 콘텐츠 키워드 검색입니다. 제목 완전일치, 제목 부분일치, 요약, 본문
288
+ 순으로 관련도를 계산하고 같은 점수에서는 최신 발행순으로 정렬합니다. 응답은
289
+ 카드 렌더용 슬림 hit이며 본문(`body_json`)은 포함하지 않습니다.
173
290
 
174
291
  | 쿼리 | 설명 |
175
292
  |---|---|
176
293
  | `q` | 검색 키워드 (필수, 1~100자) |
177
294
  | `limit` | 결과 수 1~50, 기본 10 |
178
- | `type` | `post`(기본) \| `page` |
295
+ | `type` | `post`(기본, 하위 호환) \| `page` \| `all`(글+페이지 통합) |
296
+ | `locale` | BCP-47 언어 코드. 생략하면 사이트 기본 언어 |
179
297
  | `site_id` | 멀티 사이트 키일 때만 |
180
298
 
181
299
  ```json
182
300
  { "tenant_id": "…", "site_id": "…", "query": "세무",
183
- "items": [ { "id": "…", "type": "post", "title": "…", "slug": "…",
301
+ "items": [ { "id": "…", "type": "post", "collection_key": "notice",
302
+ "locale": "ko", "title": "…", "slug": "…",
184
303
  "excerpt": "…", "featured_media_url": "…",
185
304
  "published_at": "…" } ] }
186
305
  ```
187
306
 
188
307
  JS/TS 는 `@roottale/cms-client/server` 의 `searchPosts({ apiKey, query })` 를
189
- 사용하세요 — 구 서버(라우트 미배포)의 404 를 빈 배열로 처리합니다.
308
+ 사용하세요. 사이트 전체 검색은 `type: "all"`을 지정합니다. 결과 링크는
309
+ `resolveSearchHitPath(hit, collections, locale?)`로 계산해야 콘텐츠 유형의
310
+ `basePath`와 다국어 경로를 그대로 따릅니다. `ROOTTALE_API_KEY`는 브라우저에
311
+ 노출하지 말고 Server Component·Route Handler에서만 사용하세요.
190
312
 
191
313
  ## GET /v1/cms/public/menus
192
314
 
@@ -237,7 +359,7 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
237
359
 
238
360
  ## GET /v1/cms/public/blog-settings
239
361
 
240
- 블로그 표시 설정 (TOC·작성자·발행일·작성자 카드, 저자 프로필, 글 하단 CTA).
362
+ 블로그 표시 설정 (TOC·작성자·발행일·작성자 카드 표시 정책, 글 하단 CTA).
241
363
  `post_cta` 는 admin 에서 활성화하고 버튼 문구·링크를 채웠을 때만 객체이며,
242
364
  그 외에는 `null`. `RootTaleBlogPost` 가 본문 끝에 자동으로 렌더하므로 별도
243
365
  연동 코드는 필요 없다. `toc_position` 은 목차 배치(`"inline"`=본문 위 접이식,
@@ -256,6 +378,13 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
256
378
  "updated_at": null }
257
379
  ```
258
380
 
381
+ `author_profile_name`·`author_profile_bio`·`author_profile_image_url`·
382
+ `author_card_description`은 이전 연동을 깨지 않기 위해 응답 모양에만 남아 있고
383
+ 항상 `null`입니다. 작성자 이름·사진·소개는 글 응답의 `author_name`·
384
+ `author_image_url`·`author_bio`를 사용하세요. 이 값은 글에 지정된 사이트별
385
+ 작성자 프로필에서 옵니다. 글에 작성자가 없으면 작성자 메타와 카드를 표시하지
386
+ 않습니다.
387
+
259
388
  `site_profile` 은 사이트 공통 SEO 값. `default_og_image_url`(1200×630 권장)은
260
389
  글에 대표/OG 이미지가 없을 때 SNS 공유 썸네일 폴백으로 쓰세요 —
261
390
  `generateMetadata` 에서 `post.seo?.ogImage ?? post.featured_media_url ??
@@ -360,7 +489,7 @@ const { categories, truncated } = await fetchCategoryCounts({
360
489
 
361
490
  ## GET /v1/cms/public/analytics
362
491
 
363
- 분석 태그 설정.
492
+ ROOT-ANALYTICS 사이트 ID와 외부 태그 설정.
364
493
 
365
494
  ```json
366
495
  { "tags": [ { "provider": "ga4", "id": "G-XXXXXXX", "enabled": true } ] }
@@ -410,6 +539,24 @@ IP/tenant rate limit 초과 시 `429 rate_limited`를 반환합니다.
410
539
  { "event": "post.updated", "paths": ["/blog", "/blog/my-post"], "slug": "my-post" }
411
540
  ```
412
541
 
542
+ `event`는 `post.published` · `post.updated` · `post.deleted` · `theme.updated`
543
+ 중 하나이고, 생략하면 `post.updated`입니다. 앞의 세 값은 `read_write` 권한
544
+ (`cms:write`)으로 보냅니다.
545
+
546
+ **`theme.updated`(설정 저장 신호)만 `read_write_settings` 권한이 필요합니다**
547
+ ("읽기 + 쓰기 + 설정 변경", scope `settings:write`). 글쓰기 키로 보내면
548
+ `403 insufficient_scope`입니다. 이 신호는 경로 몇 개가 아니라 수신 측의 **설정
549
+ 캐시 이름표 전체와 루트 레이아웃(그 아래 모든 페이지)** 을 다시 만들게 하므로,
550
+ 글을 쓰라고 내준 키에는 열지 않습니다.
551
+
552
+ ```json
553
+ { "event": "theme.updated" }
554
+ ```
555
+
556
+ 위 "설정 쓰기 API"로 사업장 정보·상단 메뉴를 바꾸면 이 신호는 저장과 함께
557
+ 자동으로 나갑니다 — 직접 보낼 필요는 없습니다. 자세한 동작은
558
+ [재검증 웹훅 문서](./revalidation-webhooks.md#수동-revalidation-api)에 있습니다.
559
+
413
560
  ## 에러 형식
414
561
 
415
562
  비 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로
@@ -158,6 +158,33 @@ basePath·라벨·플래그는 어드민에서 바꾸면 sitemap·feed·라우
158
158
  (블로그면 칼럼·소식 등). 주제는 선택이며, **글 > 분류 > 카테고리**에서 미리 만들어
159
159
  콘텐츠 유형에 연결합니다.
160
160
 
161
+ ### 콘텐츠 유형별 커스텀 필드 (ACF 방식)
162
+
163
+ **글 > 필드 그룹**에서 콘텐츠 유형마다 별도 입력칸을 만들 수 있습니다. 새 필드
164
+ 그룹의 적용 기준을 **콘텐츠 유형**으로 고른 뒤 `doctors`, `reviews` 같은 유형을
165
+ 선택하면, 그 유형의 글 편집 화면에만 해당 입력칸이 나타납니다. 카테고리를 임시로
166
+ 붙일 필요가 없습니다.
167
+
168
+ 필드 값은 글의 `meta_json.acf`에 저장되며 공개 글 API에서는 정의에 맞게 변환된
169
+ `fields`와 표시용 `fields_meta`로 제공됩니다. 이미지·관계 필드는 raw ID가 아니라
170
+ 공개 URL과 안전한 참조 객체로 해석됩니다.
171
+
172
+ ```ts
173
+ const doctors = await fetchPosts({
174
+ apiKey: process.env.ROOTTALE_API_KEY!,
175
+ collectionKey: "doctors",
176
+ });
177
+
178
+ for (const doctor of doctors.items) {
179
+ // 예: { name, specialty, photo, education, schedule, ... }
180
+ console.log(doctor.fields);
181
+ }
182
+ ```
183
+
184
+ ROOT-ADMIN의 코드 정의 필드 그룹도 같은 규칙을 씁니다. 의료 vertical의 기본
185
+ `의료진 정보` 그룹은 `doctors` 콘텐츠 유형에 연결되며 이름·전문분야·사진·대체
186
+ 텍스트·학력/경력 반복 목록·진료 시간표·담당 치료·검수 책임자를 제공합니다.
187
+
161
188
  ## 사이트 연동 코드
162
189
 
163
190
  섹션 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지
@@ -0,0 +1,63 @@
1
+ ---
2
+ title: 콘텐츠 모델과 노출 슬롯
3
+ description: 페이지·글·정보 모델과 배너·팝업을 고객 FRONT에 연결하는 방법
4
+ ---
5
+
6
+ # 콘텐츠 모델과 노출 슬롯
7
+
8
+ RootTale은 운영 화면의 콘텐츠를 세 가지로 나눕니다.
9
+
10
+ - 페이지: 개발자가 주소와 화면을 만들고 운영자가 내용을 수정합니다.
11
+ - 글: 운영자가 항목을 만들며 목록·상세 주소가 있습니다.
12
+ - 정보: 인물·서비스·후기처럼 구조화된 값을 운영자가 만들고, 화면은 개발자가 정합니다.
13
+
14
+ 고객 FRONT는 `fetchContentModels()`로 활성 모델을 읽고, `fetchPosts({ modelKey })`로
15
+ 특정 모델의 항목만 가져올 수 있습니다. 기존 `fetchCollections()`와
16
+ `collectionKey`는 지원 기간 동안 그대로 동작합니다.
17
+
18
+ ```ts
19
+ import { fetchContentModels, fetchPosts } from "@roottale/cms-client/server";
20
+
21
+ const models = await fetchContentModels({ apiKey: process.env.ROOTTALE_API_KEY! });
22
+ const people = await fetchPosts({
23
+ apiKey: process.env.ROOTTALE_API_KEY!,
24
+ modelKey: "people",
25
+ limit: 20,
26
+ });
27
+ ```
28
+
29
+ ## 배너·팝업
30
+
31
+ 운영자는 임의 위치에 팝업을 만들 수 없습니다. 개발자가 사이트 콘텐츠 계약에
32
+ 등록한 슬롯 안에서만 내용·기간·대상 경로·우선순위를 관리합니다. FRONT는 슬롯을
33
+ 코드에 명시하고 `RootTaleExposureSlot`을 놓습니다.
34
+
35
+ ```tsx
36
+ import { RootTaleExposureSlot } from "@roottale/cms-renderer-next/server";
37
+
38
+ export default async function Layout({ children }: { children: React.ReactNode }) {
39
+ return <>
40
+ {children}
41
+ <RootTaleExposureSlot
42
+ apiKey={process.env.ROOTTALE_API_KEY!}
43
+ slotKey="global-popup"
44
+ path="/"
45
+ allowedVariants={["card", "image-card"]}
46
+ revalidate={60}
47
+ />
48
+ </>;
49
+ }
50
+ ```
51
+
52
+ 알 수 없는 variant, 계약에서 제거된 슬롯, 발행 기간 밖 캠페인은 렌더하지 않습니다.
53
+ 팝업은 닫기 버튼과 Escape 닫기를 제공하고 `always|session|day|never` 재노출 정책을
54
+ 적용합니다. `@roottale/cms-renderer-next/styles`를 root layout에서 한 번 불러오세요.
55
+
56
+ ## Raw API
57
+
58
+ - `GET /v1/cms/public/content-models`
59
+ - `GET /v1/cms/public/posts?model_key=people`
60
+ - `GET /v1/cms/public/exposures?slot_key=global-popup&path=%2Fabout`
61
+
62
+ 모두 고객용 API key와 `cms:read` 범위가 필요합니다. 노출 API는 선택된 공개 내용만
63
+ 반환하며 초안, 보관함, 내부 일정·타기팅 원문은 반환하지 않습니다.
@@ -25,7 +25,8 @@ description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로
25
25
 
26
26
  `@roottale/cms-renderer-next` 의 `createRedirectMiddleware` 를 프로젝트 루트
27
27
  `middleware.ts` 에 마운트합니다. 규칙을 자동 캐시(기본 60초)하며, API 실패 시
28
- 트래픽을 막지 않고 통과시킵니다(fail-soft).
28
+ 기존 캐시가 있으면 오래된 규칙을 우선 사용하고(stale-first), 캐시가
29
+ 없으면 트래픽을 막지 않고 통과시킵니다(fail-soft).
29
30
 
30
31
  ```ts
31
32
  // middleware.ts
@@ -56,8 +57,15 @@ export const config = {
56
57
  - 매칭은 **정확 경로 일치**입니다(와일드카드 없음). 출발 경로의 앞/뒤 슬래시와
57
58
  한글 percent-encoding 차이는 자동 정규화해 비교합니다.
58
59
  - 도착지가 내부 경로면 요청 origin 기준 절대 URL 로 변환해 리다이렉트합니다.
60
+ - 내부 경로 체인은 최종 도착지로 평탄화하고, 순환이 발견되면 브라우저
61
+ 왕복을 막기 위해 해당 요청을 통과시킵니다.
59
62
  - 매칭이 없으면 `null` 을 반환하므로 `NextResponse.next()` 로 통과시키세요.
60
63
 
64
+ Site Materializer로 새 사이트를 만들면 `middleware.ts`와
65
+ `tests/redirect-middleware.test.ts`가 필수 산출물로 포함됩니다. 두 파일은
66
+ 내부 Materializer 영수증에도 기록되므로, 설치 여부를 추측하지 않고
67
+ 실제 납품 산출물로 확인할 수 있습니다.
68
+
61
69
  ## 캐시와 즉시성
62
70
 
63
71
  규칙은 미들웨어가 TTL(기본 60초) 동안 캐시합니다. 운영자가 규칙을 바꾸면
@@ -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_*` 접두를 붙이지 마세요.
package/docs/inquiries.md CHANGED
@@ -95,7 +95,7 @@ export async function submitContact(
95
95
  ## 유입 어트리뷰션 (`attribution`)
96
96
 
97
97
  문의가 **어느 글·검색·단축링크/QR에서 왔는지**를 CRM에 표시하려면 두 줄만
98
- 추가하면 됩니다. RootTale 비콘이 방문자의 first-touch(처음 도착한
98
+ 추가하면 됩니다. ROOT-ANALYTICS 비콘이 방문자의 first-touch(처음 도착한
99
99
  경로·`rt_src` 토큰·utm·외부 referrer 호스트명)를 30일간 기억하며,
100
100
  `readAttribution()`(브라우저 전용, `@roottale/cms-client/attribution`)으로
101
101
  읽습니다. 식별자가 아니므로 개인정보가 아닙니다.
package/docs/overview.md CHANGED
@@ -13,10 +13,12 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
13
13
 
14
14
  1. `getting-started.md` — 키 발급 + 환경 설정
15
15
  2. `blog.md` — `/blog` 목록·상세 페이지
16
- 3. `revalidation-webhooks.md` — 웹훅 등록 (발행 → 즉시 반영)
17
- 4. `seo.md` — RSS·사이트맵·동적 OG 이미지
18
- 5. `inquiries.md` — 상담문의 폼 (선택)
19
- 6. `menus.md` — 어드민 관리 네비게이션 (선택)
16
+ 3. `search.md` — 글·페이지 통합 검색 (선택)
17
+ 4. `revalidation-webhooks.md` — 웹훅 등록 (발행 → 즉시 반영)
18
+ 5. `seo.md` — RSS·사이트맵·동적 OG 이미지
19
+ 6. `theme-and-settings.md` — ROOT-ANALYTICS 연결 (권장)
20
+ 7. `inquiries.md` — 상담문의 폼과 유입·여정 저장 (선택)
21
+ 8. `menus.md` — 어드민 관리 네비게이션 (선택)
20
22
 
21
23
  ```
22
24
  어드민 (admin.roottale.com) 고객 사이트 (예: example.com)
@@ -34,9 +36,10 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
34
36
  | 기능 | 같은 키 하나로 |
35
37
  |---|---|
36
38
  | 블로그 글 목록/상세 조회 | `fetchPosts` / `fetchPost` |
39
+ | 발행 글·페이지 검색 | `searchPosts` / `resolveSearchHitPath` |
37
40
  | 발행 웹훅 서명 검증 + 캐시 갱신 | `createRevalidateRoute` (JWKS 공개키 — 별도 secret 보관 불필요). 설정 저장을 즉시 반영하려면 `revalidateTag` 주입 필수 — `revalidation-webhooks.md` §1 |
38
41
  | 상담문의(리드) 접수 | `submitInquiry` — 키가 테넌트를 식별 |
39
- | 테마·블로그 표시·분석 태그 설정 조회 | `fetchTheme` / `fetchBlogSettings` / `fetchAnalyticsConfig` |
42
+ | 테마·블로그 표시·ROOT-ANALYTICS 설정 조회 | `fetchTheme` / `fetchBlogSettings` / `fetchAnalyticsConfig` |
40
43
  | 사업장 정보·메뉴·콘텐츠 유형 조회 | `fetchBusinessProfile` / `fetchMenu`·`fetchMenus` / `fetchCollections` |
41
44
 
42
45
  키는 **서버 전용**입니다. 브라우저로 노출되면 안 됩니다(`NEXT_PUBLIC_*` 금지).
@@ -49,6 +52,7 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
49
52
  | [`@roottale/cms-client`](https://www.npmjs.com/package/@roottale/cms-client) | 서버 전용 fetch 클라이언트 — 글/테마/설정 조회, 문의 접수, 웹훅 검증 (raw) |
50
53
  | [`@roottale/cms-renderer-next`](https://www.npmjs.com/package/@roottale/cms-renderer-next) | Next.js(RSC) 렌더러 — 블로그 컴포넌트, revalidate/RSS/sitemap 라우트 팩토리 |
51
54
  | [`@roottale/cms-core`](https://www.npmjs.com/package/@roottale/cms-core) | 블록 JSON 공통 코어 (렌더러가 의존) |
55
+ | [`@roottale/analytics-runtime`](https://www.npmjs.com/package/@roottale/analytics-runtime) | ROOT-ANALYTICS 비콘·동의·Next.js SPA 추적 런타임 |
52
56
  | `@roottale/cms-mcp` | 본 MCP 서버 — 통합 문서·예시 코드·API 조회 tool |
53
57
 
54
58
  ## 문서 맵
@@ -57,9 +61,10 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
57
61
  |---|---|
58
62
  | `getting-started.md` | API 키 발급, 환경변수, 패키지 설치, 첫 조회 |
59
63
  | `blog.md` | 블로그 목록/상세 페이지 구현 (컴포넌트 또는 직접 fetch) |
64
+ | `search.md` | 글·페이지 통합 검색, 실제 공개 주소 계산, 보안·캐시·장애 처리 |
60
65
  | `revalidation-webhooks.md` | 발행 웹훅으로 near-real-time 캐시 갱신 |
61
66
  | `inquiries.md` | 상담문의(리드) 폼 연동 |
62
67
  | `menus.md` | 메뉴(네비게이션) — 어드민 "디자인 > 메뉴" 트리를 헤더/푸터에 렌더 |
63
68
  | `seo.md` | RSS 피드, 사이트맵, JSON-LD, 동적 OG 이미지, 공개 검색, fleet 프로브 |
64
- | `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정, 분석 태그 |
69
+ | `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정, ROOT-ANALYTICS |
65
70
  | `api-reference.md` | HTTP API 레퍼런스 (비 JS 스택용 raw 엔드포인트) |
@@ -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/search.md ADDED
@@ -0,0 +1,122 @@
1
+ ---
2
+ title: 사이트 검색 연동
3
+ description: 발행된 글과 페이지를 서버에서 안전하게 검색하고 실제 공개 주소로 연결
4
+ ---
5
+
6
+ # 사이트 검색 연동
7
+
8
+ RootTale 검색은 고객 사이트의 **서버**가 공개 CMS API를 호출하는 방식입니다.
9
+ 검색창은 일반 GET 폼으로 만들되, `ROOTTALE_API_KEY`는 Server Component나 Route
10
+ Handler 안에서만 사용합니다. 브라우저가 RootTale API를 직접 호출하지 않습니다.
11
+
12
+ ## 권장 구조
13
+
14
+ ```text
15
+ 방문자 브라우저
16
+ GET /search?q=상담
17
+ │
18
+ ▼
19
+ 고객 사이트 Server Component
20
+ searchPosts({ type: "all", locale })
21
+ │ Authorization: Bearer rtlk_cust_*
22
+ ▼
23
+ RootTale 공개 검색 API
24
+ tenant + site + locale + published 범위 검색
25
+ ```
26
+
27
+ 이 구조는 API 키를 숨기고, 검색 결과 페이지를 서버 렌더링하며, 고객사와 사이트
28
+ 경계를 API에서 강제합니다. `NEXT_PUBLIC_ROOTTALE_API_KEY`처럼 공개 환경변수에
29
+ 키를 넣으면 안 됩니다.
30
+
31
+ ## Next.js 구현
32
+
33
+ 전체 예시는 `examples/nextjs/app/search/page.tsx`에 있습니다. 핵심 흐름은 다음과
34
+ 같습니다.
35
+
36
+ ```tsx
37
+ import {
38
+ fetchCollections,
39
+ resolveSearchHitPath,
40
+ searchPosts,
41
+ } from "@roottale/cms-client/server";
42
+
43
+ const [hits, collections] = await Promise.all([
44
+ searchPosts({
45
+ apiKey: process.env.ROOTTALE_API_KEY!,
46
+ baseUrl: process.env.ROOTTALE_API_BASE,
47
+ query,
48
+ type: "all",
49
+ locale,
50
+ limit: 20,
51
+ revalidate: 60,
52
+ }),
53
+ fetchCollections({
54
+ apiKey: process.env.ROOTTALE_API_KEY!,
55
+ baseUrl: process.env.ROOTTALE_API_BASE,
56
+ }).catch(() => []),
57
+ ]);
58
+
59
+ const results = hits.flatMap((hit) => {
60
+ const href = resolveSearchHitPath(hit, collections, locale);
61
+ return href ? [{ hit, href }] : [];
62
+ });
63
+ ```
64
+
65
+ 사이트 전체 검색은 `type: "all"`을 명시합니다. 이 값을 생략하면 하위 호환을
66
+ 위해 글(`post`)만 검색합니다. 다국어 사이트는 현재 경로의 `locale`을 검색 API와
67
+ `resolveSearchHitPath` 양쪽에 같은 값으로 전달합니다.
68
+
69
+ ## 결과 주소 계산
70
+
71
+ 검색 결과의 `slug`만 보고 `/blog/{slug}`를 직접 만들지 않습니다.
72
+ `resolveSearchHitPath`는 다음 규칙을 적용합니다.
73
+
74
+ | 콘텐츠 | 공개 주소 |
75
+ |---|---|
76
+ | 고정 페이지 | `/{slug}` |
77
+ | 기본 블로그 글 | `/blog/{slug}` |
78
+ | 콘텐츠 유형 글 | `/{collection.basePath}/{slug}` |
79
+ | 다국어 콘텐츠 | 위 주소 앞에 `/{locale}` 추가 |
80
+ | 상세 화면이 없는 콘텐츠 유형 | `null` — 검색 목록에서 제외 |
81
+
82
+ 따라서 `fetchCollections`가 실패해도 일반 글은 `/blog/{slug}`로 연결할 수 있지만,
83
+ 공지·자료실 같은 별도 콘텐츠 유형의 주소 정확도를 위해 정상 응답을 권장합니다.
84
+
85
+ ## 검색 범위와 정렬
86
+
87
+ - API 키에 연결된 tenant와 site 밖의 콘텐츠는 검색하지 않습니다.
88
+ - 요청한 locale의 `published` 콘텐츠만 반환합니다.
89
+ - 제목 완전일치 → 제목 부분일치 → 요약 → 본문 순으로 우선합니다.
90
+ - 같은 점수에서는 최근 발행 콘텐츠가 먼저 나옵니다.
91
+ - 응답은 카드용 슬림 결과이며 본문 전체는 포함하지 않습니다.
92
+
93
+ 현재 한 요청은 최대 50건입니다. 첫 버전에는 페이지네이션, 형태소 분석,
94
+ 오타 교정, 동의어 확장이 없습니다. 실제 검색 로그에서 필요성이 확인되면
95
+ 추가하는 범위입니다.
96
+
97
+ ## 캐시와 새 글 반영
98
+
99
+ `revalidate`를 지정하면 Next.js 서버 캐시에 검색 응답이 저장됩니다. 예를 들어
100
+ `revalidate: 60`이면 발행·수정 후 검색 결과가 최대 약 60초 늦게 바뀔 수 있습니다.
101
+ 항상 최신 결과가 필요하면 `revalidate: 0`을 사용하되 API 호출량 증가를 고려하세요.
102
+
103
+ 플랫폼 배포 순서는 **migration → API → SDK·고객 사이트**입니다. 검색용 생성 열과
104
+ GIN 색인이 먼저 준비되어야 새 API가 안전하게 조회할 수 있습니다.
105
+
106
+ ## 빈 결과와 장애 처리
107
+
108
+ - 빈 검색어는 API를 호출하지 않고 입력 안내를 표시합니다.
109
+ - 정상 응답이지만 결과가 없으면 검색어와 함께 `0건` 안내를 표시합니다.
110
+ - API 장애는 빈 결과와 구분해 “잠시 후 다시 시도” 안내를 표시합니다.
111
+ - `searchPosts`는 구 API의 `404`에 한해 빈 배열로 처리하고, 그 밖의 오류는
112
+ `CmsApiError`로 전달합니다.
113
+
114
+ 검색 입력은 `type="search"`, `name="q"`, 연결된 `<label>`을 사용하고 결과 수는
115
+ `aria-live="polite"`로 알립니다. 검색 결과 페이지는 보통 중복·저가치 URL이므로
116
+ `robots: { index: false, follow: true }`를 권장합니다.
117
+
118
+ ## HTTP API
119
+
120
+ JavaScript 이외의 서버에서는
121
+ `GET /v1/cms/public/search?q=...&type=all&locale=ko&limit=20`을 사용합니다.
122
+ 쿼리와 응답 필드는 [HTTP API 레퍼런스](./api-reference.md)를 참고하세요.
package/docs/seo.md CHANGED
@@ -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
  사이트 구조를 검색엔진에 전달하고 검색결과에 경로가 표시됩니다.
@@ -568,21 +591,37 @@ export default createPostOgImage(
568
591
 
569
592
  ## 공개 검색 (사이트 내 검색)
570
593
 
571
- `searchPosts` 로 발행 글 키워드 검색을 붙일 수 있습니다 (WP `?s=` 패리티):
594
+ `searchPosts` 로 발행된 글과 페이지의 통합 검색을 붙일 수 있습니다. API 키는
595
+ 브라우저에 보내지 않고 Server Component나 Route Handler에서만 사용합니다:
572
596
 
573
597
  ```tsx
574
598
  // app/search/page.tsx (Server Component)
575
- import { searchPosts } from "@roottale/cms-client/server";
599
+ import {
600
+ fetchCollections,
601
+ resolveSearchHitPath,
602
+ searchPosts,
603
+ } from "@roottale/cms-client/server";
576
604
 
577
- const hits = await searchPosts({
578
- apiKey: process.env.ROOTTALE_API_KEY!,
579
- query: q, // ?q= 쿼리
580
- limit: 20,
605
+ const [hits, collections] = await Promise.all([
606
+ searchPosts({
607
+ apiKey: process.env.ROOTTALE_API_KEY!,
608
+ query: q, // ?q= 쿼리
609
+ type: "all", // 글 + 페이지
610
+ locale: "ko",
611
+ limit: 20,
612
+ }),
613
+ fetchCollections({ apiKey: process.env.ROOTTALE_API_KEY! }),
614
+ ]);
615
+
616
+ const links = hits.flatMap((hit) => {
617
+ const href = resolveSearchHitPath(hit, collections);
618
+ return href ? [{ hit, href }] : [];
581
619
  });
582
620
  // hits: { id, title, slug, excerpt, featuredImageUrl, publishedAt }[]
583
621
  ```
584
622
 
585
- 본문은 미포함 슬림 hit 이므로 카드에서 `/blog/{slug}` 로 연결하세요.
623
+ 본문은 미포함 슬림 hit입니다. 주소를 `/blog/{slug}`로 직접 조립하면 공지·자료실
624
+ 등 콘텐츠 유형의 실제 주소를 놓칠 수 있으므로 `resolveSearchHitPath`를 사용하세요.
586
625
  검색결과 페이지는 위 체크리스트대로 **noindex** 처리를 잊지 마세요.
587
626
 
588
627
  ## JSON-LD 스키마 헬퍼
@@ -800,4 +839,3 @@ const violations = scanAssetHosts(
800
839
  `@import`는 1-depth만 보며(중첩 `@import`는 범위 밖), CSS escape 시퀀스
801
840
  (`\3a ` 등)까지는 디코드하지 않습니다. 런타임에 JS로 삽입되는 요청은 배포
802
841
  후 실제 네트워크 요청을 검사하는 E2E 프로브로 보완하세요.
803
-
@@ -1,9 +1,9 @@
1
1
  ---
2
- title: 테마·블로그 표시·분석 태그 설정
3
- description: 어드민에서 관리하는 디자인 토큰, 블로그 표시 옵션, 분석 태그를 사이트에서 조회
2
+ title: 테마·블로그 표시·ROOT-ANALYTICS 설정
3
+ description: 어드민에서 관리하는 디자인 토큰, 블로그 표시 옵션, ROOT-ANALYTICS 설정을 사이트에서 조회
4
4
  ---
5
5
 
6
- # 테마·블로그 표시·분석 태그 설정
6
+ # 테마·블로그 표시·ROOT-ANALYTICS 설정
7
7
 
8
8
  어드민에서 설정한 값을 공개 API로 조회해 사이트에 반영합니다. 모두
9
9
  `@roottale/cms-client/server`에서 제공하며 같은 API 키를 사용합니다.
@@ -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
 
@@ -165,10 +179,10 @@ if (business) {
165
179
  > 반환합니다. 전화·주소만 채우고 이름을 비워두면 **나머지 입력이 전부 무시**되니
166
180
  > 고객 안내 시 이름을 필수로 안내하세요.
167
181
 
168
- ## 분석 태그 — fetchAnalyticsConfig
182
+ ## ROOT-ANALYTICS 설정 — fetchAnalyticsConfig
169
183
 
170
- 어드민에서 등록한 외부 분석 태그(GA4, Microsoft Clarity, Meta Pixel, 네이버)
171
- 설정을 조회해 사이트에 주입합니다.
184
+ ROOT-ADMIN에서 등록한 외부 태그(GA4, Microsoft Clarity, Meta Pixel, 네이버)와
185
+ ROOT-ANALYTICS 사이트 ID를 조회해 고객 사이트에 연결합니다.
172
186
 
173
187
  ```ts
174
188
  import { fetchAnalyticsConfig } from "@roottale/cms-client/server";
@@ -183,9 +197,9 @@ const config = await fetchAnalyticsConfig({
183
197
  `enabled: true`인 태그만 렌더링하세요. 태그 ID는 어드민에서 변경될 수
184
198
  있으므로 하드코딩하지 말고 본 API로 조회하는 것을 권장합니다.
185
199
 
186
- ## 조회수 / first-party 비콘
200
+ ## ROOT-ANALYTICS 조회수·first-party 비콘
187
201
 
188
- RootTale 비콘은 쿠키리스 first-party 분석(방문수·클릭)과 **글별 조회수**를
202
+ ROOT-ANALYTICS 비콘은 쿠키리스 first-party 분석(페이지·클릭)과 **글별 조회수**를
189
203
  수집합니다. **API 키 하나로** 동작합니다 — 별도 사이트 ID 환경변수가 필요 없습니다.
190
204
  `fetchAnalyticsConfig`가 돌려주는 `siteId`를 비콘에 그대로 넘기세요.
191
205
 
@@ -226,10 +240,21 @@ export async function generateMetadata({ params }): Promise<Metadata> {
226
240
  > `@roottale/cms-client/server`의 `contentIdMeta(post.id)`가 같은 `<meta>` 태그
227
241
  > 문자열을 만들어 줍니다.
228
242
 
229
- 수집은 익명·쿠키리스이며 비콘은 클릭(`data-track`)과 pageview만 보냅니다. 봇
230
- 트래픽은 서버에서 제외됩니다. 공개 사이트에 "조회 N"을 표시하는 옵션은 어드민의
243
+ 수집은 익명·쿠키리스이며 비콘은 pageview와 명시한 행동 이벤트를 보냅니다. Next.js
244
+ SPA 전환과 섹션·스크롤·읽기·폼 감지는 `@roottale/analytics-runtime/next` 어댑터로
245
+ 연결합니다. 봇 트래픽은 서버에서 제외됩니다. 공개 사이트에 "조회 N"을 표시하는 옵션은 어드민의
231
246
  사이트 설정에서 켤 수 있습니다(켜면 글 응답에 `view_count`가 포함됩니다).
232
247
 
248
+ 저장 위치는 데이터 성격에 따라 나뉩니다.
249
+
250
+ | 데이터 | 저장 위치 |
251
+ |---|---|
252
+ | 페이지·클릭·섹션 이벤트 | Cloudflare Analytics Engine `cms_site_events` |
253
+ | 글 누적 조회수 | 사이트별 Durable Object SQLite, PostgreSQL `posts.view_count` 미러 |
254
+ | 첫·마지막 유입 | 브라우저 `localStorage._rt_attr`(30일) |
255
+ | 현재 방문 여정 | 브라우저 `sessionStorage._rt_journey`(최대 30건) |
256
+ | 문의에 귀속된 유입·여정 | PostgreSQL `inquiries.attribution`, `inquiries.journey` |
257
+
233
258
  ## 사이트 지식 — 브랜드 보이스 (AI 에이전트용)
234
259
 
235
260
  이 사이트의 **브랜드 보이스**(어조·톤·화자)와 **용어 규칙**(금지어·교정어)을
@@ -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 }}
@@ -0,0 +1,64 @@
1
+ // 사이트 통합 검색 — 고객 브라우저가 아니라 이 Server Component가 CMS API를 호출한다.
2
+ import type { Metadata } from "next";
3
+ import Link from "next/link";
4
+
5
+ import {
6
+ fetchCollections,
7
+ resolveSearchHitPath,
8
+ searchPosts,
9
+ } from "@roottale/cms-client/server";
10
+
11
+ export const metadata: Metadata = {
12
+ title: "사이트 검색",
13
+ robots: { index: false, follow: true },
14
+ };
15
+
16
+ interface Props {
17
+ searchParams: Promise<{ q?: string | string[] }>;
18
+ }
19
+
20
+ export default async function SearchPage({ searchParams }: Props) {
21
+ const { q = "" } = await searchParams;
22
+ const query = (typeof q === "string" ? q : q[0] ?? "").trim();
23
+ const apiKey = process.env.ROOTTALE_API_KEY!;
24
+ const baseUrl = process.env.ROOTTALE_API_BASE;
25
+ const [hits, collections] = query
26
+ ? await Promise.all([
27
+ searchPosts({ apiKey, baseUrl, query, type: "all", limit: 20 }),
28
+ fetchCollections({ apiKey, baseUrl }).catch(() => []),
29
+ ])
30
+ : [[], []];
31
+
32
+ return (
33
+ <main>
34
+ <h1>사이트 검색</h1>
35
+ <form action="/search" method="get" role="search">
36
+ <label htmlFor="site-search">검색어</label>
37
+ <input
38
+ defaultValue={query}
39
+ id="site-search"
40
+ maxLength={100}
41
+ name="q"
42
+ required
43
+ type="search"
44
+ />
45
+ <button type="submit">검색</button>
46
+ </form>
47
+ {query ? <p>검색 결과 {hits.length}건</p> : <p>검색어를 입력해 주세요.</p>}
48
+ <ul>
49
+ {hits.map((hit) => {
50
+ const href = resolveSearchHitPath(hit, collections);
51
+ if (!href) return null;
52
+ return (
53
+ <li key={hit.id}>
54
+ <Link href={href}>
55
+ <h2>{hit.title}</h2>
56
+ {hit.excerpt ? <p>{hit.excerpt}</p> : null}
57
+ </Link>
58
+ </li>
59
+ );
60
+ })}
61
+ </ul>
62
+ </main>
63
+ );
64
+ }
@@ -0,0 +1,13 @@
1
+ import { RootTaleExposureSlot } from "@roottale/cms-renderer-next/server";
2
+
3
+ export async function GlobalPopup({ path }: { path: string }) {
4
+ return (
5
+ <RootTaleExposureSlot
6
+ apiKey={process.env.ROOTTALE_API_KEY!}
7
+ slotKey="global-popup"
8
+ path={path}
9
+ allowedVariants={["card", "image-card"]}
10
+ revalidate={60}
11
+ />
12
+ );
13
+ }
@@ -2,7 +2,8 @@
2
2
  //
3
3
  // 글 슬러그 변경 자동 301 은 글 라우트에서 처리되지만(blog 예시 참고), 글이
4
4
  // 아닌 임의 경로(`/old-event → /promo`)는 라우팅 이전 단계인 미들웨어에서만
5
- // 가로챌 수 있다. 규칙은 자동 캐시되고, API 실패 시 트래픽을 막지 않는다.
5
+ // 가로챌 수 있다. 규칙은 자동 캐시되고, API 실패 시 stale 캐시를
6
+ // 우선 사용한다. 캐시가 없거나 순환 규칙이면 트래픽을 막지 않는다.
6
7
  import { NextResponse } from "next/server";
7
8
  import { createRedirectMiddleware } from "@roottale/cms-renderer-next/routes";
8
9
 
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.1",
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": {