@roottale/cms-mcp 0.65.0 → 0.66.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.66.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7532c0f: 글을 여러 개 담는 활성 `data_only` 모델(`page` 프리셋 제외)은 저장할 때 `presentation.listPaths`가
8
+ 1개 이상 있어야 합니다(관리 API `400 bad_request`, `details.reason: "list_paths_required"`).
9
+ 이런 모델의 글 변경 웹훅은 더 이상 예전 `/blog` 경로로 떨어지지 않고 `listPaths`만 보냅니다.
10
+ 공개 반영 확인은 `listPaths[0]`을 기준 화면으로 보고, 상세 모델이 적은 추가 화면도 확인합니다.
11
+ - 716dc9b: 콘텐츠 모델 presentation에 `listPaths`(글을 목록으로 보여 주는 고정 화면)를 추가합니다.
12
+ `data_only`·`detail`·`category_tree`에서 쓸 수 있고, 글 변경 웹훅 `paths`에 함께 실립니다.
13
+ 상세 주소가 없는 `data_only` 모델은 이 화면만 갱신하고, ROOT-ADMIN은 그 화면에서 현재
14
+ 제목으로 공개 반영을 확인합니다. 공개 콘텐츠 모델 API 응답에도 `listPaths`가 포함됩니다.
15
+ - bb7b516: 검색엔진 소유 확인 코드를 공개 테마 응답 `siteVerification`으로 내보냅니다.
16
+ 고객이 ROOT-ADMIN 설정 → 연동에서 Google Search Console·네이버 서치어드바이저 코드를
17
+ 붙여 넣으면 `theme.updated` 웹훅으로 바로 반영되고, 사이트는
18
+ `siteVerificationMetadata(theme.siteVerification)`를 루트 metadata `verification`에 넣어
19
+ 메타태그로 출력합니다.
20
+
21
+ ### Patch Changes
22
+
23
+ - da23c9e: AVCD 본문 기준으로 기본 읽기 폭을 720px, 행간을 1.7로 맞추고 제목 위계와 문단 간격을 정리합니다. 가져온 HTML 내부 문단에도 24px 간격을 적용하며 모바일 제목 크기를 조정합니다. 기존 테마 변수 재정의는 유지합니다.
24
+
25
+ 글머리표·번호는 li에 적용되는 CSS reset에서도 유지됩니다. 강조·기울임·인용·코드·이미지·표까지 AVCD의 서식 체계를 적용하며 독립 본문과 가져온 HTML에도 적용합니다. 번호 목록의 시작 번호도 보존합니다. 모바일 제목은 AVCD 실측값인 22/20/18px입니다.
26
+
27
+ Pretendard(SIL OFL)를 포함하고 헤더 높이를 고려한 sticky 목차 오프셋과 hover/focus 링크 밑줄을 적용합니다. 제목 내부 고정 크기 마크도 반응형 제목 크기를 따릅니다.
28
+
29
+ 글 제목은 데스크톱 44px/700/1.3, 모바일 28px/600/1.3으로 통일하고 제목 아래 12px 간격을 적용합니다. 문단에 저장된 고정 글자 크기·행간도 모바일 본문과 소제목 크기를 따르게 합니다. 데스크톱의 문단 서식과 모바일 색상·강조는 유지합니다. 모바일 목차는 닫힌 높이를 52px로 줄입니다.
30
+
31
+ 본문 이미지를 읽기 폭까지 크게 표시하고 클릭·Enter·Space로 확대할 수 있습니다. 확대 화면은 2배 확대, Esc·닫기, 포커스 복귀를 지원합니다. 독립 본문용 `image-lightbox` 진입점도 제공합니다.
32
+
33
+ - 1d2be30: 빈 분류 변경도 전체 분류 캐시와 레이아웃을 갱신합니다. 미디어 삭제의 CDN 정리·자동 재시도와 삭제 완료 응답 계약을 문서화합니다.
34
+ - 8c6d6c1: 외부 문의 원장의 전체 스냅샷을 사이트별 전용 권한으로 동기화한다. 원본 접수일·유입·상태를 보존하고 중복·알림 재발송을 막으며, 원본에서 삭제된 문의를 사본에서도 제거한다. 관리자에서는 연결된 문의를 읽기 전용으로 제공한다.
35
+ - e3ac05b: CMS 관리 API(`/v1/cms/posts…`, 분류 연결·삭제, 관련 콘텐츠)로 쓴 글 변경도 어드민 저장과 같은 발송 대기열을 거친다고 문서에 적었습니다. 웹훅 이벤트·`paths`는 어드민과 같고, 실패하면 자동 재시도하며 발행 알림 기록과 공개 화면 반영 확인에 남습니다. 관리 API의 분류 삭제는 이제 연결된 글마다 `post.updated`를 보내지 않고 `taxonomy.updated`를 한 번 보냅니다.
36
+ - 5d52bbf: 글 미리보기 응답(`fetchPostPreview`)이 저장 전 커스텀 필드와 대표 이미지도 편집 중인 값으로 담는다는 점과, 편집기의 '공유 링크 복사'가 없어지고 '사이트 화면 미리보기' 하나만 남았다는 점을 문서에 반영합니다.
37
+
38
+ ## 0.65.1
39
+
40
+ ### Patch Changes
41
+
42
+ - 1b2d623: 작성자 변경 웹훅이 실제 공개 주소를 갱신하며, 공개 글이 없거나 경로 상한을 넘으면 theme.updated로 전역 갱신하는 계약을 문서와 Next.js 예제에 반영합니다.
43
+ - febb0f2: 유입 신호가 없는 방문을 출처 정보 없음으로 표시한다. 브라우저 방문 시작을 익명으로
44
+ 첫 확인·재방문으로 분류해 수집하고, ROOT-ADMIN의 방문 유형 통계와 연동한다.
45
+ 문서 새로고침·뒤로/앞으로 이동은 새 외부 유입에서 제외한다.
46
+ - 0d9ee66: 유입 출처와 첫 도착 페이지를 동봉한 클릭 수집 계약을 추가하고 ROOT-ADMIN에 출처·캠페인별 버튼 클릭 흐름을 표시한다. 기존 클릭은 출처를 추정하지 않으며 외부 유입 집계는 pageview로 유지한다.
47
+ - 973032a: 웹훅 실패 원인 확인, 재시도와 수동 복구의 운영 계약을 안내합니다.
48
+
3
49
  ## 0.65.0
4
50
 
5
51
  ## 0.64.0
package/dist/index.js CHANGED
@@ -1735,7 +1735,7 @@ function registerTools(server) {
1735
1735
  }
1736
1736
 
1737
1737
  // src/server.ts
1738
- var VERSION = true ? "0.65.0" : "dev";
1738
+ var VERSION = true ? "0.66.0" : "dev";
1739
1739
  var SERVER_INSTRUCTIONS = `
1740
1740
  roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uACE0
1741
1741
  \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.
@@ -176,12 +176,16 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
176
176
  |---|---|
177
177
  | `GET /v1/cms/media` | 최신순 목록. `limit`, `offset`, `site_id` 지원 |
178
178
  | `PATCH /v1/cms/media/{media_id}` | `alt`, `caption` 수정 |
179
- | `DELETE /v1/cms/media/{media_id}` | R2 객체와 미디어 행 삭제 |
179
+ | `DELETE /v1/cms/media/{media_id}` | 미디어 행·R2 원본·변형 이미지·CDN 캐시 삭제 |
180
180
 
181
181
  미디어 삭제 전 해당 URL을 쓰는 본문과 `featured_media_id` 연결을 먼저
182
182
  교체하세요. 삭제 후 기존 공개 URL은 더 이상 유효하지 않습니다.
183
183
  파일 보존이 활성화된 환경에서 원본 보존 또는 삭제 기록 저장에 실패하면
184
184
  `503`을 반환하며 삭제를 완료하지 않습니다.
185
+ 외부 파일이나 CDN 정리가 지연되면 목록에서 삭제된 뒤에도 `503`을 받을 수 있습니다.
186
+ 이때 오류 메시지에 정리 대기와 자동 재시도를 알리며, 서버가 남은 작업을 계속 처리합니다.
187
+ `200 { deleted: true }`는 공개 파일 정리까지 완료된 경우입니다. 미디어 목록의 `404`만으로
188
+ CDN 정리 완료를 판단하지 마세요. 이미 방문자의 브라우저에 저장된 파일은 원격으로 지울 수 없습니다.
185
189
 
186
190
  ## 설정 쓰기 API
187
191
 
@@ -363,7 +367,7 @@ published_at }` 로 카드 하나를 그릴 만큼만 담습니다. 목록·상
363
367
 
364
368
  | 쿼리 | 설명 |
365
369
  |---|---|
366
- | `token` | 편집기 '미리보기'·'공유 링크'가 URL `?token=`으로 넘긴 값 (글 1건 전용, 1시간) |
370
+ | `token` | 편집기 '사이트 화면 미리보기'가 URL `?token=`으로 넘긴 값 (글 1건 전용, 1시간) |
367
371
  | `site_id` | 멀티 사이트 키일 때만 |
368
372
 
369
373
  - 응답은 `GET /posts/{identifier}`와 같은 형식이며 `preview` 블록이 추가됩니다:
package/docs/blog.md CHANGED
@@ -272,12 +272,12 @@ export default async function PostPage({
272
272
 
273
273
  ### 미리보기 페이지 — 관리자 미리보기 = 발행 결과
274
274
 
275
- 관리자(ROOT-ADMIN) 편집기의 **미리보기**·**공유 링크**는 기본으로 관리자가
275
+ 관리자(ROOT-ADMIN) 편집기의 **사이트 화면 미리보기**는 기본으로 관리자가
276
276
  RootTale 기본 테마로 그린 화면을 엽니다. 사이트 고유 폰트·본문 너비·목차와는
277
277
  다를 수 있어서, 사이트에 아래 라우트를 두면 **사이트의 실제 글 템플릿**으로
278
278
  미리보기를 그리게 할 수 있습니다. 라우트를 배포한 뒤 관리자 **설정 › 외부
279
279
  연결(Webhook) › 글 미리보기 여는 곳**에서 "홈페이지에서 열기"를 켜면 편집기의
280
- 미리보기·공유 링크가 `https://{사이트}/preview/post/{글 ID}?token=…` 으로 열립니다.
280
+ 미리보기가 `https://{사이트}/preview/post/{글 ID}?token=…` 으로 열립니다.
281
281
 
282
282
  ```tsx
283
283
  // app/preview/post/[id]/page.tsx
@@ -360,6 +360,9 @@ export default async function PostPreviewPage({ params, searchParams }: Props) {
360
360
  - 커스텀 템플릿(직접 fetch)이라면 `fetchPostPreview`가 돌려주는 값이
361
361
  `fetchPost`와 같은 `CmsPostContent` 형식(+`preview`)이므로 상세 렌더 함수를
362
362
  그대로 재사용하면 됩니다.
363
+ - 응답에는 저장 전 제목·요약·본문과 함께 커스텀 필드(`fields`)·대표 이미지
364
+ (`featuredImageUrl`)도 편집 중인 값으로 담깁니다. 자기 주소가 없는 정보
365
+ 항목(면허·연혁 등)은 그 항목이 들어가는 화면을 그려 보여 주면 됩니다.
363
366
 
364
367
  전체 예시는 `examples/nextjs/app/preview/post/[id]/page.tsx` 에 있습니다.
365
368
 
@@ -89,6 +89,22 @@ FAQ·도움말·문서처럼 목록 아래에 여러 단계의 분류 허브가
89
89
  }
90
90
  ```
91
91
 
92
+ 기본은 `categoryDepth` 단계의 말단 분류에만 글을 붙입니다(위 예는
93
+ `/faq/{영역}/{질환}/{slug}`). 하위 분류가 필요한 영역만 나누고 나머지 영역에는 바로
94
+ 글을 쓰려면 `"entryCategory": "leaf"`를 둡니다. 그러면 `categoryDepth`는 최대
95
+ 단계가 되고, 하위 분류가 없는 분류면 어느 단계든 글을 붙일 수 있습니다.
96
+
97
+ | `entryCategory` | 글을 붙일 수 있는 분류 | FAQ 주소 예 |
98
+ |---|---|---|
99
+ | 생략·`"deepest"` | `categoryDepth` 단계 말단만 | `/faq/{영역}/{질환}/{slug}` |
100
+ | `"leaf"` | 하위가 없는 분류(최대 `categoryDepth` 단계) | `/faq/{영역}/{slug}`, `/faq/{영역}/{질환}/{slug}` |
101
+
102
+ `leaf` 모델에서 글이 붙은 분류에 나중에 하위 분류가 생겨도 이미 붙은 글의 주소는
103
+ 유지됩니다. 새로 고르거나 다시 발행할 때는 하위가 없는 분류를 골라야 합니다. 같은
104
+ 상위 아래에서 하위 분류 slug와 글 slug는 겹칠 수 없습니다. FRONT는 공개 글의
105
+ `path`를 그대로 쓰고, 분류 허브 화면에서 하위 분류와 그 분류에 바로 붙은 글을 함께
106
+ 보여 주면 됩니다.
107
+
92
108
  ### 목록·피드 규칙도 모델이 소유합니다
93
109
 
94
110
  글 목록의 카테고리 모음, RSS 피드, 허브 주소 형식처럼 예전 `collections`
@@ -106,6 +122,35 @@ FAQ·도움말·문서처럼 목록 아래에 여러 단계의 분류 허브가
106
122
  `category_tree`는 `basePath`가 목록입니다. 예전 `collections` 응답은 호환을 위해
107
123
  유지되지만 새 사이트는 모델 presentation만 읽으면 됩니다.
108
124
 
125
+ ### 목록 화면(`listPaths`)
126
+
127
+ 글을 목록으로 보여 주는 고정 화면이 따로 있으면 `listPaths`에 적습니다. 홈의
128
+ 최근 사례나 회사소개의 인증서 갤러리처럼 모델 주소 밖에 있는 화면이 여기에 해당합니다.
129
+ `data_only`·`detail`·`category_tree`에서 쓸 수 있습니다.
130
+
131
+ - 글을 바꾸면 웹훅 `paths`에 이 화면들이 함께 실립니다. 상세 주소의 부모 목록은
132
+ 자동으로 들어가므로 적지 않습니다.
133
+ - 상세 주소가 없는 `data_only` 모델은 이 화면들만 갱신 대상이 되며, 글을 여러 개 담는
134
+ 활성 모델(`page` 프리셋 제외)은 **`listPaths`를 1개 이상 적어야 저장됩니다**(없으면
135
+ `400 bad_request`, `details.reason: "list_paths_required"`).
136
+ - **첫 화면이 기준 화면입니다.** 그 모델의 모든 글이 보이는 화면(예: `/experts`)을 먼저
137
+ 적으세요. ROOT-ADMIN은 기준 화면의 소스(서버가 보낸 화면 데이터 포함)에 현재 제목이
138
+ 있는지로 공개 반영을 확인하고, 나머지 화면(홈 등 일부만 보이는 화면)은 제목이 없으면
139
+ 판정을 보류합니다.
140
+ - 상세 모델의 추가 화면은 글 링크 기준으로 확인하고, 링크가 없으면 판정을 보류합니다.
141
+ - 고정 경로만 쓸 수 있습니다(`:slug` 같은 변수 불가, 최대 20개).
142
+
143
+ ```json
144
+ {
145
+ "key": "certificate",
146
+ "preset": "entity",
147
+ "presentation": {
148
+ "kind": "data_only",
149
+ "listPaths": ["/company/credentials", "/company"]
150
+ }
151
+ }
152
+ ```
153
+
109
154
  **RootTale 표준 블로그 주소** — RootTale이 만드는 사이트(스타터·`roottale init` 시드)의
110
155
  `blog` 모델은 아래 규칙 하나를 씁니다: 글 `/blog/{category}/{slug}`, 카테고리 허브
111
156
  `/blog/{category}`, 목록 `/blog`. 1단계 `category_tree`이고 글마다 카테고리를 정확히
@@ -110,6 +110,41 @@ pnpm add @roottale/cms-client @roottale/cms-renderer-next
110
110
  import "@roottale/cms-renderer-next/styles";
111
111
  ```
112
112
 
113
+ 기본 본문은 720px 폭, 18px/1.7이며 768px 미만에서는 17px입니다.
114
+ 글 제목은 데스크톱 44px/700/1.3, 모바일 28px/600/1.3입니다.
115
+ 본문 H2/H3/H4는 데스크톱 32/24/20px, 모바일 22/20/18px입니다.
116
+ 제목 아래 메타 정보 간격은 12px이며, H2 위 110px·아래 20px(모바일 16px),
117
+ H3 위 32px·아래 12px, H4 위 24px·아래 8px를 사용합니다.
118
+ Pretendard 가변 글꼴을 스타일 패키지에 포함합니다. 고객 글꼴은 `--rt-font-body`,
119
+ 본문 폭은 `--rt-cms-measure`, 크기는 `--rt-cms-body-size`로 조정할 수 있습니다.
120
+ 독립 본문은 `<div data-roottale-cms="body">`로 감싸면 같은 서식을 적용합니다.
121
+ 목록의 `li` reset도 복원하며, 제목 안의 고정 크기 마크는 제목의 반응형 크기를
122
+ 따릅니다. 일반 문단의 인라인 크기·행간 설정은 데스크톱에서 유지하고,
123
+ 모바일에서는 본문과 소제목의 크기·행간을 따릅니다. 색상·강조는 유지됩니다.
124
+ 본문 링크의 밑줄은 hover·active·키보드 focus 때 표시되고, 동작 줄이기 설정을 존중합니다.
125
+
126
+ 본문 이미지는 읽기 폭까지 표시하며 `RootTaleBlogPost`에서는 클릭·Enter·Space로 확대됩니다.
127
+ 확대 화면은 2배 확대, Esc·닫기, 원래 이미지로 포커스 복귀를 지원합니다.
128
+ 이미 링크나 버튼인 이미지는 기존 동작을 유지하며, `data-no-zoom`으로 제외할 수 있습니다.
129
+ `data-fullsize-src`가 있으면 확대 이미지의 주소로 사용합니다.
130
+ 독립 HTML·`RenderTiptap`에는 다음 클라이언트 경계를 감싸 사용합니다.
131
+
132
+ ```tsx
133
+ import { RootTaleImageLightbox } from "@roottale/cms-renderer-next/image-lightbox";
134
+
135
+ <div data-roottale-cms="body">
136
+ <RootTaleImageLightbox>
137
+ <RenderTiptap doc={doc} />
138
+ </RootTaleImageLightbox>
139
+ </div>
140
+ ```
141
+
142
+ `tocPosition="sidebar"` 목차는 넓은 화면에서 고정됩니다.
143
+ 모바일 목차는 접었을 때 기본 높이가 52px입니다.
144
+ `--rt-cms-scroll-offset`을 헤더 높이보다 크게 지정하세요. 조상 요소의
145
+ `overflow: hidden/auto`는 별도 스크롤 기준을 만들므로 화면 기준 sticky가 필요하면
146
+ 불필요한 overflow를 제거하거나 `overflow: clip`을 사용하세요.
147
+
113
148
  예시 코드는 `@/lib/blog` 형태의 import를 사용합니다 — `create-next-app` 기본
114
149
  설정이면 그대로 동작하고, tsconfig를 직접 구성했다면 paths alias가 필요합니다:
115
150
 
package/docs/inquiries.md CHANGED
@@ -371,3 +371,30 @@ type SubmitInquiryResult =
371
371
 
372
372
  raw HTTP로 직접 연동(비 JS 스택)하려면 `api-reference.md`의
373
373
  `POST /v1/public/inquiries`를 참고하세요.
374
+
375
+ ## 기존 문의 원장 동기화
376
+
377
+ 이미 다른 시스템에서 접수·처리하는 문의는 서버에서 `POST /v1/inquiries/sync`로
378
+ 연결합니다. **사이트에 바인딩된 `inquiries:sync:write` 전용 키**가 필요합니다.
379
+ CMS 읽기 키와 공개 폼 접수 키에는 이 권한을 추가하지 마세요.
380
+ 예제는 `examples/nextjs/lib/sync-external-inquiries.ts`입니다.
381
+
382
+ 요청은 `{ source, observed_at, items }`인 **해당 원장의 전체 스냅샷**입니다.
383
+ `source`는 변하지 않는 시스템 식별자, `observed_at`은 일관된 DB 읽기를 시작한
384
+ 시각입니다. 각 항목은 `external_id`, `received_at`, `name`, `phone`, `email`,
385
+ `message`, `status`가 필요하며, `extras`와 `attribution`은 선택입니다.
386
+ 상태는 `new`, `contacting`, `contacted`, `follow_up`, `converted`, `closed`입니다.
387
+ 실제 성공 계약이 확인된 경우만 `converted`를 보냅니다.
388
+
389
+ - 같은 사이트·원장·외부 ID는 같은 문의를 갱신합니다. 원본 접수일로 성과에 집계합니다.
390
+ - 전체 스냅샷에서 빠진 문의는 해당 원장의 사본에서 삭제됩니다. 페이지 한 장이나
391
+ 조회 실패를 빈 배열로 보내면 안 됩니다. 원본 읽기 실패 시 요청 자체를 중단하세요.
392
+ - 이전 시각의 요청과 같은 시각에 내용이 다른 요청은 `409`입니다. 재시도에는
393
+ 같은 스냅샷을 사용하거나 최신 전체 스냅샷을 다시 읽습니다.
394
+ - ROOT-ADMIN에서는 조회만 제공합니다. 수정·처리·삭제는 원본에서 관리합니다.
395
+ 원본 동의 기록을 유지하며 동기화 API가 새로운 동의를 생성하지 않습니다.
396
+ - 연락처·본문·추가 항목은 암호화하고 요청 본문은 API 감사 로그에 남기지 않습니다.
397
+ 유입에는 query 없는 `landing_path`, hostname인 `referrer`, UTM, `rt_src`,
398
+ `ad_click: true`만 보냅니다. 방문 여정과 광고 클릭 ID 원문은 받지 않습니다.
399
+ - 응답은 `{ inserted, updated, removed, total }`입니다. 알림은 다시 발송하지 않습니다.
400
+ 요청 상한은 1,000건·2 MiB이며 초과 시 부분 전송 없이 중단해야 합니다.
@@ -166,8 +166,16 @@ curl -sI -X POST https://<사이트 도메인>/api/revalidate
166
166
 
167
167
  ## 웹훅 발송 트리거
168
168
 
169
+ 어드민에서 저장하든 CMS 관리 API(`/v1/cms/posts…`, MCP 도구 포함)로 쓰든 글 변경은
170
+ 같은 발송 대기열을 거칩니다. 그래서 이벤트·`paths`·자동 재시도·실패 기록·공개 화면
171
+ 반영 확인이 같습니다. 관리 API는 대기열에 적고 곧바로 전달을 요청한 뒤 응답합니다.
172
+ 그 전달이 실패하면 약 2분 안에 다시 보냅니다.
173
+
169
174
  - 게시물 생성/발행/수정/삭제/발행 취소
170
175
  - 게시물의 카테고리·태그 변경 (블로그 카드의 카테고리 라벨이 바뀌므로)
176
+ - 작성자 정보 변경 → 해당 작성자의 공개 글과 상위 목록 주소를 담은 `post.updated`.
177
+ 주소는 저장된 공개 경로를 사용하므로 `/reviews`·계층형 `/faq` 등도 포함됩니다.
178
+ 공개 글이 없거나 조회·경로 상한을 넘으면 `theme.updated`로 전체 캐시를 갱신합니다.
171
179
  - 분류(taxonomy) 용어 생성/수정/순서 변경/삭제
172
180
  - 디자인 토큰 변경 → `theme.updated`
173
181
  - 상단 메뉴·헤더/푸터 메뉴·공지 배너·상담바·문의 게시판 설정 변경 → `theme.updated`
@@ -195,6 +203,10 @@ curl -sI -X POST https://<사이트 도메인>/api/revalidate
195
203
 
196
204
  분류 변경은 `taxonomy.updated` 이벤트 **한 번**으로 전달됩니다. payload의
197
205
  `paths`에는 영향을 받는 글 상세 경로와 카테고리 모음 경로가 중복 없이 들어갑니다.
206
+ 연결된 글이 없는 분류의 생성·삭제도 이벤트를 보냅니다. 이때 `paths`는 빈 배열일 수
207
+ 있습니다. 수신기는 경로 유무와 무관하게 분류 캐시와 루트 레이아웃을 갱신해야 합니다.
208
+ `createRevalidateRoute`는 설정 태그·루트 layout·사이트맵/피드 및 `alsoRevalidate`를
209
+ 함께 갱신합니다. 직접 만든 수신기도 같은 규칙을 적용하세요.
198
210
  연결된 글마다 웹훅을 따로 보내지 않으므로, 카테고리 하나를 바꿔도 수십 번 재검증되는
199
211
  문제가 없습니다.
200
212
 
@@ -345,3 +357,18 @@ Content-Type: application/json
345
357
  | 401 `timestamp_out_of_window` | 서버 시계 동기화 (NTP) |
346
358
  | 일부 페이지만 갱신 | `alsoRevalidate`·동적 경로 콜백 누락 |
347
359
  | 글은 즉시인데 **설정(전화·주소·메뉴·디자인)만 늦게** 반영 | `revalidateTag` 주입과 조회의 `tags` 누락 (§1 "설정 저장") — 응답의 `revalidated.requestedTags`가 비어 있으면 주입이 빠진 것 |
360
+
361
+ ### 실패한 갱신 요청 관리
362
+
363
+ ROOT-ADMIN의 사이트 설정 → 발행 알림 기록에서 미전달 건수, 가장 오래된 변경과
364
+ 조치가 필요한 원인을 확인할 수 있습니다. 일시적인 네트워크 오류·408·429·5xx는
365
+ 자동 재시도합니다. `Retry-After`가 있으면 지정한 대기 시간을 적용합니다(최대 24시간).
366
+ 422·인증·주소 오류는 원인을 수정한 뒤 **지금 다시 보내기**를 실행하세요.
367
+ 이미 성공한 목적지는 유지하고 실패한 목적지의 시도 횟수만 새로 시작합니다.
368
+
369
+ CMS 관리 API로 쓴 글 변경도 같은 기록에 남고 같은 방식으로 재시도됩니다.
370
+
371
+ 수신측의 알려진 `reason`은 `http_422:model_path_mismatch`처럼 전송 기록에 남습니다.
372
+ 응답 본문이나 고객 콘텐츠는 기록하지 않습니다. 전달 성공은 무효화 요청이 접수됐다는
373
+ 뜻이므로, 실제 화면 반영이 의심되면 해당 공개 페이지도 확인해야 합니다. 수신 라우트는
374
+ 같은 요청이 다시 와도 안전하게 캐시를 무효화해야 합니다.
@@ -18,7 +18,7 @@ description: 고객 운영 정보와 사이트 코드의 디자인 설정을 분
18
18
 
19
19
  | 조회 함수 | 이름표 상수 |
20
20
  |---|---|
21
- | `fetchTheme` (`theme.siteNav` 포함) | `THEME_CACHE_TAG` |
21
+ | `fetchTheme` (`theme.siteNav`·`theme.siteVerification` 포함) | `THEME_CACHE_TAG` |
22
22
  | `fetchBusinessProfile` | `BUSINESS_CACHE_TAG` |
23
23
  | `fetchMenu` / `fetchMenus` | `MENUS_CACHE_TAG` |
24
24
  | `fetchBlogSettings` | `BLOG_SETTINGS_CACHE_TAG` |
@@ -116,6 +116,41 @@ const navGroups = theme.siteNav?.navGroups ?? FALLBACK_NAV;
116
116
  저장에 성공하면 서버가 `theme.updated` 알림을 보내므로, 위 이름표 배선이
117
117
  되어 있으면 사이트에 곧바로 반영됩니다.
118
118
 
119
+ ## 검색엔진 소유 확인 — theme.siteVerification
120
+
121
+ 고객이 ROOT-ADMIN **설정 → 연동**에서 Google Search Console·네이버
122
+ 서치어드바이저 확인 코드를 붙여 넣으면 같은 `fetchTheme` 응답의
123
+ `siteVerification`에 담깁니다. `siteVerificationMetadata`로 Next metadata
124
+ `verification` 모양으로 바꿔 **루트 layout**에 출력하세요. 저장하면 `theme.updated`
125
+ 웹훅이 발송되므로 `THEME_CACHE_TAG`를 붙인 조회는 재배포 없이 바로 반영됩니다.
126
+
127
+ ```ts
128
+ // app/layout.tsx
129
+ import type { Metadata } from "next";
130
+ import {
131
+ THEME_CACHE_TAG,
132
+ fetchTheme,
133
+ siteVerificationMetadata,
134
+ } from "@roottale/cms-client/server";
135
+
136
+ export async function generateMetadata(): Promise<Metadata> {
137
+ const theme = await fetchTheme({
138
+ apiKey: process.env.ROOTTALE_API_KEY!,
139
+ tags: [THEME_CACHE_TAG],
140
+ }).catch(() => null);
141
+ const verification = siteVerificationMetadata(theme?.siteVerification);
142
+ return {
143
+ title: "예시 사이트",
144
+ ...(verification ? { verification } : {}),
145
+ };
146
+ }
147
+ ```
148
+
149
+ 엔진마다 코드를 3개까지 받습니다(고객 계정과 제작사 계정이 같은 사이트를 각각
150
+ 확인하는 경우). 코드가 없으면 `siteVerificationMetadata`가 `undefined`를 돌려주므로
151
+ 태그가 출력되지 않습니다. 확인 요청은 홈(`/`)의 `<head>`를 읽으므로 홈이 루트
152
+ layout metadata를 이어받는지 확인하세요.
153
+
119
154
  ## 블로그 표시 설정 — fetchBlogSettings
120
155
 
121
156
  어드민의 블로그 표시 옵션(TOC 노출, 작성자/발행일 표시, 작성자 카드 표시
@@ -355,6 +390,41 @@ SPA 전환과 섹션·스크롤·읽기·폼 감지는 `@roottale/analytics-runt
355
390
  | 현재 방문 여정 | 브라우저 `sessionStorage._rt_journey`(최대 30건) |
356
391
  | 문의에 귀속된 유입·여정 | PostgreSQL `inquiries.attribution`, `inquiries.journey` |
357
392
 
393
+ ### 유입 경로에서 버튼 클릭까지
394
+
395
+ 유입과 버튼 클릭을 연결하려면 같은 방문의 출처와 최초 도착 pathname을 클릭 이벤트에
396
+ 동봉합니다. `/v1/collect`는 `phone_click`, `email_click`, `chat_click`, `booking_click`,
397
+ `cta_click`에 `attr: 1`이 있을 때 `rh`, `air`, `us`, `um`, `uc`, `rs`, `gc`, `ref`와
398
+ `lp`를 받습니다. `lp`는 첫 도착 pathname이며 query·hash는 제거됩니다.
399
+ 기존 `data-track` 위임은 `data-track-attr="1"`, `data-track-lp`와 같은 속성을
400
+ 사용합니다. 수집기는 `trackAttr`·`trackLp`·`trackUs` 등 dataset 키도 같은 계약으로
401
+ 정규화하며, 기존 배너·서비스 링크 클릭 이벤트에도 적용합니다.
402
+
403
+ 출처는 현재 탭에서 유지하고 30분 동안 행동이 없으면 새 방문으로 시작합니다. 내부
404
+ 페이지 이동의 pageview에는 출처를 다시 붙이지 않습니다. 그래야 외부 유입 횟수가
405
+ 페이지 조회마다 늘어나지 않습니다. 클릭에는 저장한 출처를 붙여 보냅니다.
406
+ [Next.js 클릭 예제](../examples/nextjs/lib/analytics-click.ts)는 전송 계약을 보여줍니다.
407
+ SDK `data-track` 위임과 직접 호출을 한 버튼에 동시에 적용하면 중복 집계됩니다.
408
+
409
+ ROOT-ADMIN의 통계 > 페이지·문의 성과에는 출처·캠페인 → 첫 도착 페이지 → 클릭한
410
+ 페이지·버튼을 묶은 표가 표시됩니다. 반복 클릭을 포함하며 전화 연결·예약 완료를
411
+ 의미하지 않습니다. 기존 기록에 출처를 추정해 붙이지 않습니다. 원본 referrer 경로,
412
+ 검색 입력값, 광고 클릭 ID는 보내지 말고 호스트·캠페인 라벨·광고 클릭 여부만 사용합니다.
413
+
414
+ Analytics Engine의 blob10~15는 해당 클릭의 유입 출처를, blob20은 첫 도착 pathname을
415
+ 저장합니다. 페이지 유입 보고서는 계속 `pageview`만 읽습니다. 현재 20개 blob 슬롯을
416
+ 모두 사용하므로 추가 차원은 새 저장 설계를 검토해야 합니다.
417
+
418
+ 비콘은 사이트별 브라우저 저장소에 마지막 방문 시각만 두고, 30분 동안 움직임이 없으면
419
+ 다음 pageview를 새 방문으로 분류합니다. 최근 30일 기록이 없으면 `vst: 1`(첫 확인),
420
+ 기록이 있으면 `vst: 2`(재방문)를 **방문 시작 pageview 한 건에만** 보냅니다.
421
+ 저장소 접근이 막히면 이 필드를 보내지 않습니다. 수집 API는 검증된 값만 Analytics
422
+ Engine `double2`에 기록합니다. ROOT-ADMIN은 이를 방문 횟수로 표시하며 사람 수로
423
+ 해석하지 않습니다. 기존 사이트 어댑터는 별도 ID나 `vst`를 만들 필요가 없습니다.
424
+ 첫 문서 조회가 새로고침이나 뒤로/앞으로 이동이면 비콘은 `nv: 1|2`를 함께 보내고,
425
+ 수집 API는 해당 조회를 새 외부 유입에서 제외합니다. SPA 이동에는 `nv`를 보내지
426
+ 않으며 페이지 조회수는 그대로 기록합니다.
427
+
358
428
  ## 사이트 지식 — 브랜드 보이스 (AI 에이전트용)
359
429
 
360
430
  이 사이트의 **브랜드 보이스**(어조·톤·화자)와 **용어 규칙**(금지어·교정어)을
@@ -1,3 +1,7 @@
1
+ // 갱신 요청은 재전송될 수 있으므로 캐시 무효화를 멱등으로 유지한다.
2
+ // 수신 오류의 공개 reason 코드는 관리자 전송 기록에서 확인할 수 있다.
3
+ // 빈 분류도 taxonomy.updated(paths: [])가 온다. 팩토리가 tag·layout을 전역 갱신하므로
4
+ // 호출 전에 paths.length === 0 조건으로 이벤트를 건너뛰지 않는다.
1
5
  // RootTale 발행 웹훅 수신 — ES256 서명 검증 후 Next.js 캐시 revalidate.
2
6
  // 어드민 "내 사이트 > 배포"에 https://<도메인>/api/revalidate 로 등록한다.
3
7
  import { revalidatePath, revalidateTag } from "next/cache";
@@ -18,6 +22,8 @@ export const POST = createRevalidateRoute({
18
22
  apiKey: process.env.ROOTTALE_API_KEY!,
19
23
  apiBase: process.env.ROOTTALE_API_BASE,
20
24
  revalidate: revalidateBlogPath,
25
+ // theme.updated는 설정 저장 외에 작성자 변경의 전역 갱신에도 쓰인다.
26
+ // 특정 글이나 modelKey가 없더라도 캐시 이름표와 루트 layout을 갱신해야 한다.
21
27
  // 설정(디자인 토큰·상단 메뉴·사업장 정보·블로그 표시 설정) 저장 시 그 값을
22
28
  // 담고 있는 fetch 캐시를 지운다. **이 주입이 없으면** revalidatePath 만으로는
23
29
  // fetch 캐시(Data Cache)가 안 지워져서 설정 변경이 최대 `revalidate` 초만큼
@@ -1,19 +1,37 @@
1
- // root layout — 렌더러 스타일은 여기서 1회 import (getting-started.md).
1
+ // root layout — 렌더러 스타일(Pretendard 포함)은 여기서 1회 import (getting-started.md).
2
+ // 고정 헤더가 있으면 사이트 CSS에서 --rt-cms-scroll-offset을 헤더 높이+여백으로 지정한다.
3
+ // 독립 RenderTiptap 본문은 data-roottale-cms="body"로 감싸 같은 조판을 적용한다.
4
+ // 이미지 확대는 @roottale/cms-renderer-next/image-lightbox의 RootTaleImageLightbox로 감싼다.
5
+ // RootTaleBlogPost에는 기본 포함되어 있으므로 중복으로 감싸지 않는다.
2
6
  import "@roottale/cms-renderer-next/styles";
7
+ import type { Metadata } from "next";
3
8
  import { SiteExposures } from "../components/site-exposures";
4
9
 
5
10
  import {
6
11
  BUSINESS_CACHE_TAG,
12
+ THEME_CACHE_TAG,
7
13
  fetchBusinessProfile,
14
+ fetchTheme,
8
15
  localBusinessSchema,
16
+ siteVerificationMetadata,
9
17
  } from "@roottale/cms-client/server";
10
18
 
11
19
  const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com";
12
20
 
13
- export const metadata = {
14
- title: "예시 사이트",
15
- description: "RootTale CMS 연동 예시",
16
- };
21
+ // 검색엔진 소유 확인 — 어드민(설정 > 연동)에 붙여 넣은 Google·네이버 코드를
22
+ // 메타태그로 출력한다. 미설정/실패 시 태그 없이 렌더 (theme-and-settings.md).
23
+ export async function generateMetadata(): Promise<Metadata> {
24
+ const theme = await fetchTheme({
25
+ apiKey: process.env.ROOTTALE_API_KEY!,
26
+ tags: [THEME_CACHE_TAG],
27
+ }).catch(() => null);
28
+ const verification = siteVerificationMetadata(theme?.siteVerification);
29
+ return {
30
+ title: "예시 사이트",
31
+ description: "RootTale CMS 연동 예시",
32
+ ...(verification ? { verification } : {}),
33
+ };
34
+ }
17
35
 
18
36
  export default async function RootLayout({
19
37
  children,
@@ -1,4 +1,4 @@
1
- // 글 미리보기 — ROOT-ADMIN 편집기의 '미리보기'·'공유 링크'가 여는 주소.
1
+ // 글 미리보기 — ROOT-ADMIN 편집기의 '사이트 화면 미리보기'가 여는 주소.
2
2
  // 관리자 발급 토큰(1시간, 글 1건 전용)으로 편집 중인 내용을 받아 발행 글과
3
3
  // **같은 템플릿**(RootTaleBlogPost)으로 그린다 → 미리보기 = 발행 결과.
4
4
  // 라우트를 배포한 뒤 관리자 설정 › 외부 연결 › "글 미리보기 여는 곳"에서
@@ -0,0 +1,26 @@
1
+ import { trackCustomEvent } from "@roottale/analytics-runtime";
2
+
3
+ /** 사이트 어댑터가 최초 진입에서 수집해 현재 탭에 유지한 출처. */
4
+ export type VisitEntry = {
5
+ lp: string;
6
+ ref: 0 | 1;
7
+ rh?: string;
8
+ air?: string;
9
+ us?: string;
10
+ um?: string;
11
+ uc?: string;
12
+ rs?: string;
13
+ gc?: 1;
14
+ };
15
+
16
+ /** data-track 자동 위임을 쓰지 않는 예약 버튼의 onClick 예제. */
17
+ // 방문 유형(vst)은 비콘이 방문 시작 pageview에만 자동으로 보냅니다.
18
+ // 클릭 이벤트에는 붙이지 않아 방문 횟수가 중복되지 않습니다.
19
+ export function trackBookingFromEntry(entry: VisitEntry, placement: string) {
20
+ trackCustomEvent("booking_click", {
21
+ ...entry,
22
+ attr: 1,
23
+ trackProvider: "naver",
24
+ trackPlacement: placement,
25
+ });
26
+ }
@@ -0,0 +1,23 @@
1
+ import "server-only";
2
+
3
+ /** Read a complete, consistent ledger snapshot before calling; never send a partial page. */
4
+ export async function syncExternalInquiries(snapshot: {
5
+ source: string;
6
+ observed_at: string;
7
+ items: Array<{
8
+ external_id: string; received_at: string; name: string; phone: string;
9
+ email: string; message: string;
10
+ status: "new" | "contacting" | "contacted" | "follow_up" | "converted" | "closed";
11
+ extras?: Record<string, string>;
12
+ }>;
13
+ }) {
14
+ const key = process.env.ROOTTALE_INQUIRY_SYNC_API_KEY;
15
+ if (!key) throw new Error("Inquiry sync key is missing");
16
+ const response = await fetch("https://api.roottale.com/v1/inquiries/sync", {
17
+ method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
18
+ body: JSON.stringify(snapshot), redirect: "error", signal: AbortSignal.timeout(45_000),
19
+ });
20
+ // Never log the customer request or a remote error body.
21
+ if (!response.ok) throw new Error(`Inquiry sync HTTP ${response.status}`);
22
+ return response.json();
23
+ }
@@ -26,3 +26,8 @@ if (!completed.ok) throw new Error("미디어 등록에 실패했습니다.");
26
26
  const media = await completed.json(); // 신규201 또는 멱등 재시도200
27
27
  // 글 연결에는 media.id, 본문 이미지에는 media.url을 사용합니다.
28
28
  ```
29
+
30
+ 삭제는 서버에서 `DELETE /v1/cms/media/{media_id}`로 요청합니다. `200`일 때만
31
+ 공개 파일 정리 완료로 안내하세요. `503`이고 메시지가 공개 파일 정리 대기를 알리면
32
+ 관리 목록에서는 이미 사라졌어도 서버가 원본·변형 이미지·CDN 캐시를 자동으로 재정리합니다.
33
+ 이 경우 같은 파일을 다시 업로드하거나 목록에서 사라졌다는 이유로 완료 안내를 보내지 않습니다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.65.0",
3
+ "version": "0.66.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": {