@roottale/cms-mcp 0.65.1 → 0.67.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.
@@ -28,6 +28,18 @@ MCP tool 또는 공개 CLI를 사용합니다.
28
28
 
29
29
  ## 관리 API 빠른 흐름
30
30
 
31
+ 공통 메타데이터는 `GET /v1/cms/metadata`로 검색·cursor 조회하고,
32
+ `GET /v1/cms/metadata/{kind}/{key}`로 현재 버전과 지원 필드를 확인합니다.
33
+ `kind`는 `route | post | term`입니다. `PATCH`는
34
+ `{ "expected_version": "조회한 version", "patch": { "title": null, "noindex": false } }`
35
+ 형식이며 수정은 Pro/Premium과 대상별 쓰기 scope가 모두 필요합니다.
36
+ `site_id`·`locale`은 선택입니다. 본문만 수정하면 저장된 SEO를 보존합니다.
37
+ 고정 경로 공개 조회는 `GET /v1/cms/public/metadata?route_key=home&locale=ko`이며
38
+ `{ "site_id": "…", "metadata": null }` 또는 camelCase `PublicRouteMetadata`를 반환합니다.
39
+ 공개 응답의 `metadata`에는 `routeKey`, `locale`, `path`, `version`, `overrides`,
40
+ `defaults`, `effective`, `supportedFields`, 선택적 `fallback`과 `robots` 강제 정책이 있습니다.
41
+ 공개 읽기는 등급과 관계없이 기존 설정을 사용합니다. 자세한 연동은 [공통 메타데이터](seo.md#공통-메타데이터)를 참고하세요.
42
+
31
43
  관리 API는 초안 작성 → 미디어 연결 → 발행을 분리합니다. 자동화가 실수로
32
44
  미완성 글을 공개하지 않도록 글 생성의 기본 상태는 `draft`입니다.
33
45
 
@@ -167,6 +179,9 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
167
179
  완료 응답을 받지 못했거나 `503`을 받으면 같은 `r2_key`와 요청 본문으로 완료
168
180
  요청을 재시도하세요. 새 등록은 `201`, 이미 완료된 업로드의 재시도는 같은
169
181
  미디어 ID와 `200`을 반환합니다. 삭제된 업로드는 다시 완료할 수 없습니다.
182
+ 서버의 보존 조회는 일시적인 timeout·429·5xx를 제한 재시도하지만, 원본 대조에
183
+ 성공하기 전에는 완료로 응답하지 않습니다. 자동 조회 재시도 후에도 `503`이면
184
+ 위와 같이 같은 업로드의 완료 요청을 재시도합니다.
170
185
  응답의 `id`는 글의 `featured_media_id`에, `url`은 Tiptap image 노드의
171
186
  `attrs.src`에 사용합니다.
172
187
 
@@ -176,12 +191,16 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
176
191
  |---|---|
177
192
  | `GET /v1/cms/media` | 최신순 목록. `limit`, `offset`, `site_id` 지원 |
178
193
  | `PATCH /v1/cms/media/{media_id}` | `alt`, `caption` 수정 |
179
- | `DELETE /v1/cms/media/{media_id}` | R2 객체와 미디어 행 삭제 |
194
+ | `DELETE /v1/cms/media/{media_id}` | 미디어 행·R2 원본·변형 이미지·CDN 캐시 삭제 |
180
195
 
181
196
  미디어 삭제 전 해당 URL을 쓰는 본문과 `featured_media_id` 연결을 먼저
182
197
  교체하세요. 삭제 후 기존 공개 URL은 더 이상 유효하지 않습니다.
183
198
  파일 보존이 활성화된 환경에서 원본 보존 또는 삭제 기록 저장에 실패하면
184
199
  `503`을 반환하며 삭제를 완료하지 않습니다.
200
+ 외부 파일이나 CDN 정리가 지연되면 목록에서 삭제된 뒤에도 `503`을 받을 수 있습니다.
201
+ 이때 오류 메시지에 정리 대기와 자동 재시도를 알리며, 서버가 남은 작업을 계속 처리합니다.
202
+ `200 { deleted: true }`는 공개 파일 정리까지 완료된 경우입니다. 미디어 목록의 `404`만으로
203
+ CDN 정리 완료를 판단하지 마세요. 이미 방문자의 브라우저에 저장된 파일은 원격으로 지울 수 없습니다.
185
204
 
186
205
  ## 설정 쓰기 API
187
206
 
@@ -363,7 +382,7 @@ published_at }` 로 카드 하나를 그릴 만큼만 담습니다. 목록·상
363
382
 
364
383
  | 쿼리 | 설명 |
365
384
  |---|---|
366
- | `token` | 편집기 '미리보기'·'공유 링크'가 URL `?token=`으로 넘긴 값 (글 1건 전용, 1시간) |
385
+ | `token` | 편집기 '사이트 화면 미리보기'가 URL `?token=`으로 넘긴 값 (글 1건 전용, 1시간) |
367
386
  | `site_id` | 멀티 사이트 키일 때만 |
368
387
 
369
388
  - 응답은 `GET /posts/{identifier}`와 같은 형식이며 `preview` 블록이 추가됩니다:
@@ -487,9 +506,12 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
487
506
  `generateMetadata` 에서 `post.seo?.ogImage ?? post.featured_media_url ??
488
507
  settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
489
508
  `favicon_url`·`site_description` 은 사이트 `<head>` 에 적용합니다.
490
- `home_title`·`home_description` 은 **메인 홈(`/`) 전용** 검색 제목·설명(어드민
491
- "검색·공유 표시 > 메인 홈 검색 노출")으로, `null` 이면 사이트 이름(theme
492
- `site_name`)·`site_description` 으로 폴백하세요(`seo.md` "메인 홈 메타데이터").
509
+ `home_title`·`home_description`은 **메인 홈(`/`) 전용** 호환 필드입니다.
510
+ 계약 등록만으로 기존 홈 설정을 바꾸지 않습니다. 초기값 보존 또는 실제 설치 확인에서
511
+ 홈 전환을 완료한 뒤 해당 경로의 `effective.title`·`effective.description`을 투영합니다.
512
+ 전환 뒤에는 초기화·연결 해제·계약 제거로 예전 홈 설정을 되살리지 않습니다.
513
+ 연결된 CMS 홈이 초안이거나 소유 경로가 제거되면 `null`을 반환합니다.
514
+ 새 연동은 `fetchRouteMetadata`와 공통 빌더를 사용하세요(`seo.md` 참고).
493
515
 
494
516
  ## GET /v1/cms/public/patterns
495
517
 
@@ -560,6 +582,11 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
560
582
  판정에는 쓰지 마세요**. 그 판정의 단일 소스는 `/blog-settings` 의
561
583
  `sitemap.promoted_category_slugs` 입니다(두 소스를 섞으면 사이트맵과 페이지가
562
584
  서로 다른 시점의 값을 볼 수 있습니다). 여기 값은 진단·표시용입니다.
585
+ - `category_index_activated`는 사이트의 분류 색인 정책 전환이 끝났는지 알려 줍니다.
586
+ 사이트 계약에 `terms.categoryIndexActivation: "required"`를 선언한 사이트만 사용합니다.
587
+ 이 값이 `true`일 때만 `hub_promoted: false`인 분류를 `noindex`와 사이트맵 제외로
588
+ 처리하고, `false`이거나 값이 없으면 기존처럼 모든 공개 분류를 색인하세요. 선언하지
589
+ 않은 사이트는 이 값을 읽지 않습니다.
563
590
  - 분류가 서버 상한(500개)을 넘으면 응답에 `"truncated": true` 가 붙습니다.
564
591
 
565
592
  `@roottale/cms-client` 로 직접 부를 때:
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`이고 글마다 카테고리를 정확히
@@ -9,6 +9,11 @@ description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로
9
9
  경로 이동 규칙(`/old-event → /promo`)을 사이트 Proxy로 적용합니다. 코드
10
10
  수정 없이 고객이 직접 규칙을 추가·수정·삭제할 수 있습니다.
11
11
 
12
+ 수동 등록·수정·삭제·CSV 적용·되돌리기는 **Pro/Premium**에서 사용할 수 있습니다.
13
+ 기본 요금제로 내려가도 기존 규칙은 계속 적용되고 읽기·내보내기를 유지합니다.
14
+ 일반 글 주소 변경에 따른 자동 301은 기본 기능입니다. 공개 조회와 Proxy에
15
+ 요금제 검사를 추가하면 기존 주소가 끊어지므로 읽기에는 등급 제한을 두지 마세요.
16
+
12
17
  두 종류의 주소 이동이 있습니다.
13
18
 
14
19
  - **글 주소 변경 자동 301** — 블로그 글의 슬러그를 바꾸면 자동으로 옛 주소가
@@ -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
@@ -28,7 +28,7 @@ API 키는 블로그 조회와 같은 `rtlk_cust_*` 키를 사이트 서버에
28
28
 
29
29
  ## 같은 사이트에 견적·AS 폼 연결하기
30
30
 
31
- 1. ROOT-ADMIN에서 사이트의 **문의 폼**을 열어 견적과 AS 폼을 각각 만듭니다.
31
+ 1. RootTale 내부 개발자가 ROOT-ADMIN에서 해당 사이트를 선택한 뒤 **루트테일 관리자 → 문의 폼**을 열어 견적과 AS 폼을 각각 만듭니다. 고객 계정은 폼 설정을 편집하지 않고 **받은 문의**에서 접수 내용을 관리합니다.
32
32
  2. 견적에는 `sales`, AS에는 `service` 업무 유형을 지정합니다. 항목 키·타입·필수
33
33
  조건·개인정보 동의 문구를 저장하고 두 폼의 ID를 서버 설정에 넣습니다.
34
34
  3. 각 폼을 `fetchInquiryForm`으로 읽어 해당 `version`과 동의 문구를 보여줍니다.
@@ -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이며 초과 시 부분 전송 없이 중단해야 합니다.
@@ -110,6 +110,7 @@ const menu = await fetchMenu({ apiKey, slug: "primary", tags: [MENUS_CACHE_TAG]
110
110
  | `fetchBlogSettings` | `BLOG_SETTINGS_CACHE_TAG` | 설정 > 블로그 |
111
111
  | `fetchCollections` | `COLLECTIONS_CACHE_TAG` | 설정 > 콘텐츠 유형 |
112
112
  | `fetchSitePatterns` | `SITE_PATTERNS_CACHE_TAG` | 콘텐츠 도구 > 공통 블록 |
113
+ | `fetchRouteMetadata` | `METADATA_CACHE_TAG` · `SITE_METADATA_DEFAULTS_CACHE_TAG` · 경로별 태그(자동 부착) | 설정 > 검색·공유 표시 |
113
114
 
114
115
  이들을 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 있습니다. 수신 라우트는
115
116
  설정 저장 신호(`theme.updated`) 한 번에 **이 목록 전체**를 지웁니다 — 무엇이
@@ -166,6 +167,11 @@ curl -sI -X POST https://<사이트 도메인>/api/revalidate
166
167
 
167
168
  ## 웹훅 발송 트리거
168
169
 
170
+ 어드민에서 저장하든 CMS 관리 API(`/v1/cms/posts…`, MCP 도구 포함)로 쓰든 글 변경은
171
+ 같은 발송 대기열을 거칩니다. 그래서 이벤트·`paths`·자동 재시도·실패 기록·공개 화면
172
+ 반영 확인이 같습니다. 관리 API는 대기열에 적고 곧바로 전달을 요청한 뒤 응답합니다.
173
+ 그 전달이 실패하면 약 2분 안에 다시 보냅니다.
174
+
169
175
  - 게시물 생성/발행/수정/삭제/발행 취소
170
176
  - 게시물의 카테고리·태그 변경 (블로그 카드의 카테고리 라벨이 바뀌므로)
171
177
  - 작성자 정보 변경 → 해당 작성자의 공개 글과 상위 목록 주소를 담은 `post.updated`.
@@ -198,6 +204,10 @@ curl -sI -X POST https://<사이트 도메인>/api/revalidate
198
204
 
199
205
  분류 변경은 `taxonomy.updated` 이벤트 **한 번**으로 전달됩니다. payload의
200
206
  `paths`에는 영향을 받는 글 상세 경로와 카테고리 모음 경로가 중복 없이 들어갑니다.
207
+ 연결된 글이 없는 분류의 생성·삭제도 이벤트를 보냅니다. 이때 `paths`는 빈 배열일 수
208
+ 있습니다. 수신기는 경로 유무와 무관하게 분류 캐시와 루트 레이아웃을 갱신해야 합니다.
209
+ `createRevalidateRoute`는 설정 태그·루트 layout·사이트맵/피드 및 `alsoRevalidate`를
210
+ 함께 갱신합니다. 직접 만든 수신기도 같은 규칙을 적용하세요.
201
211
  연결된 글마다 웹훅을 따로 보내지 않으므로, 카테고리 하나를 바꿔도 수십 번 재검증되는
202
212
  문제가 없습니다.
203
213
 
@@ -357,6 +367,8 @@ ROOT-ADMIN의 사이트 설정 → 발행 알림 기록에서 미전달 건수,
357
367
  422·인증·주소 오류는 원인을 수정한 뒤 **지금 다시 보내기**를 실행하세요.
358
368
  이미 성공한 목적지는 유지하고 실패한 목적지의 시도 횟수만 새로 시작합니다.
359
369
 
370
+ CMS 관리 API로 쓴 글 변경도 같은 기록에 남고 같은 방식으로 재시도됩니다.
371
+
360
372
  수신측의 알려진 `reason`은 `http_422:model_path_mismatch`처럼 전송 기록에 남습니다.
361
373
  응답 본문이나 고객 콘텐츠는 기록하지 않습니다. 전달 성공은 무효화 요청이 접수됐다는
362
374
  뜻이므로, 실제 화면 반영이 의심되면 해당 공개 페이지도 확인해야 합니다. 수신 라우트는
package/docs/seo.md CHANGED
@@ -8,6 +8,8 @@ description: RSS 피드, 사이트맵, JSON-LD 스키마, fleet 프로브 라우
8
8
  `@roottale/cms-renderer-next/routes`의 팩토리로 RSS·사이트맵을 한 줄에
9
9
  구성하고, `@roottale/cms-client/server`의 헬퍼로 JSON-LD를 생성합니다.
10
10
 
11
+ 고정 페이지의 검색·공유 메타데이터는 아래 [공통 메타데이터](#공통-메타데이터)를 참고하세요.
12
+
11
13
  ## RSS 피드
12
14
 
13
15
  ```ts
@@ -27,6 +29,56 @@ export const GET = createFeedRoute({
27
29
 
28
30
  발행된 글이 자동 포함된 RSS 2.0 XML을 반환합니다.
29
31
 
32
+ ## 공통 메타데이터
33
+
34
+ ROOT-ADMIN **설정 → 검색·공유 표시**에서 홈·코드 페이지·목록 첫 화면·글·분류를 관리합니다. 제목·설명·공유 이미지·이미지 대체 텍스트·`noindex`·`nofollow`를 사이트가 지원하는 범위에서 편집합니다. 등록·수정·초기화는 **Pro/Premium**에서 가능합니다. 기본 요금제로 내려가도 기존 설정의 공개 적용과 읽기·내보내기는 유지됩니다. 일반 본문 편집과 대표 이미지 변경은 이 제한과 별개입니다.
35
+
36
+ 본문이 없는 페이지는 콘텐츠 계약 `metadata.routes`에 안정적인 `routeKey`를 등록합니다. 코드 화면은 `source: { kind: "code", path: "/about" }`, 기존 본문 페이지는 `{ kind: "page", pageKey: "about" }`, 모델 목록은 `{ kind: "model-list", modelKey: "column" }`으로 선언합니다. CMS 페이지를 메타데이터 저장 용도로 새로 만들 필요가 없습니다. 주소를 바꿔도 같은 페이지의 `routeKey`는 유지합니다. SDK 설치만으로 편집을 열지는 않으며 실제 배포 계약·연동 증거가 필요합니다.
37
+
38
+ ```ts
39
+ import { fetchRouteMetadata } from "@roottale/cms-client/server";
40
+ import { buildPageMetadata } from "@roottale/cms-renderer-next/routes";
41
+
42
+ export async function generateMetadata() {
43
+ const route = await fetchRouteMetadata({
44
+ apiKey: process.env.ROOTTALE_API_KEY!, routeKey: "about", locale: "ko",
45
+ });
46
+ return buildPageMetadata({
47
+ ...route,
48
+ fallback: { title: "소개 | 예시 사이트", description: "사이트 소개" },
49
+ }, {
50
+ canonical: "https://example.com/about",
51
+ verification: route ? {
52
+ routeKey: route.routeKey, locale: route.locale, version: route.version,
53
+ // 사이트가 배포한 콘텐츠 계약 체크섬도 함께 전달합니다.
54
+ contractChecksum: process.env.ROOTTALE_CONTENT_CONTRACT_CHECKSUM,
55
+ } : undefined,
56
+ });
57
+ }
58
+ ```
59
+
60
+ `fetchRouteMetadata`는 공개 API를 고유 키로 한 번 조회합니다. `null`은 등록·연결된 경로가 없다는 정상 응답이고, 연결됐지만 직접 설정이 없으면 `overrides: {}`인 객체입니다. HTTP·네트워크·잘못된 응답은 예외입니다. 예외를 `null`로 바꿔 장기 캐시하지 마세요. 오류를 기록하고 코드 기본값으로 화면을 유지하는 예시는 `lib/route-metadata.ts`에 있습니다.
61
+
62
+ 해석 순서는 필드별 **직접 설정 → 콘텐츠/코드 기본값 → 사이트 기본값 → 코드 fallback**입니다. `false`는 명시값이며 강제 미리보기·비공개 제한이 우선합니다. `buildPageMetadata`는 최종 제목을 `title.absolute`로 내보내고 OG·X에 같은 문구와 이미지 대체 텍스트를 적용합니다. 기존 Googlebot 제한은 `googleBot` 옵션으로 전달합니다. canonical은 사이트 코드가 `canonical` 또는 `siteUrl`+`path`로 정하며 `canonical: null`이면 생략합니다. 기존 `buildPostMetadata`의 `seo.canonical`은 기본 유지하고, 코드 소유 사이트는 `allowCanonicalOverride: false`로 고정합니다.
63
+
64
+ 글은 공개 응답의 `metaJson.seo`, 분류는 `seoTitle/seoDescription/seoOgImage/seoOgImageAlt/seoNofollow`를 같은 빌더의 `overrides`에 연결합니다. 분류 색인 여부는 기존 `hubPromoted` 정책을 유지합니다. 글마다 경로 메타데이터 API를 추가 호출하지 않습니다. 본문이 있는 CMS 페이지는 `lib/page-metadata.ts` 예시처럼 기존 글 원본을 사용합니다.
65
+
66
+ 조회에는 `METADATA_CACHE_TAG`, `SITE_METADATA_DEFAULTS_CACHE_TAG`, `routeMetadataCacheTag(routeKey, locale)`가 자동 부착됩니다. 기본 재조회 주기는 300초이며 `revalidate`로 조정할 수 있습니다. 기존 서명 `theme.updated`와 `createRevalidateRoute`가 공통 메타데이터 태그·루트 layout·영향 경로를 무효화합니다. 수신부에 `(tag) => revalidateTag(tag, { expire: 0 })`를 전달해야 합니다. 공개 읽기·SSR·Proxy에는 요금제 조건을 넣지 마세요.
67
+
68
+ 사이트맵도 같은 최종 `noindex`·대표 경로 정책을 사용하고 활성 리다이렉트 출발 주소를 제외해야 합니다. 목록의 페이지 번호·필터별 정책은 사이트 라우트에서 계산합니다. 메타데이터 저장 성공·웹훅 수신 성공·실제 HTML 반영 확인은 서로 다른 상태입니다.
69
+
70
+ ### 관리 API·MCP
71
+
72
+ CLI도 같은 관리 API를 사용합니다. `rt cms metadata list --site-id <site-id> --kind route --state custom`, `rt cms metadata get route home --site-id <site-id>`로 조회합니다. 수정은 `rt cms metadata update route home --site-id <site-id> --input-file patch.json`이며 파일에는 `{ "expected_version": "직전 조회값", "patch": { "title": null } }`처럼 실제 변경만 넣습니다. 인증키를 명령 인자에 넣지 마세요.
73
+
74
+ 코드에 있는 최후 기본 공유 이미지는 경로 계약의 `fallback.ogImage`로 선언합니다. 페이지 자체 이미지에 해당하는 `defaults.ogImage`와 구별하면 사이트 공통 공유 이미지가 먼저 적용되고, 공통 이미지까지 없을 때 코드 fallback을 사용합니다. 공개 경로 응답의 `fallback`도 빌더에 그대로 전달합니다.
75
+
76
+ 설치 확인 표식의 체크섬은 `metadataContractChecksum(normalizedContract)`로 계산할 수 있습니다(`@roottale/cms-client/server`). 이 함수는 배포 도구가 정규화해 등록한 manifest를 입력받아 재귀 키 정렬과 SHA-256만 수행합니다. 스키마 검사나 생략된 기본값 채우기는 하지 않습니다. 등록 API와 사이트가 같은 정규화된 계약을 사용해야 하며 입력 JSON이 다르면 설치 확인이 실패합니다. 첫 설치에서 경로 설정이 아직 없으면 `verification.version`은 생략하고 `routeKey`·`locale`·`contractChecksum`을 전달합니다.
77
+
78
+ `filterMetadataSitemapEntries(entries, policyFor, { redirects })`는 이미 읽은 메타데이터와 `fetchRedirects`의 활성 규칙을 사용해 사이트맵을 거릅니다. `policyFor(entry)`는 `{ metadata: SSR에_사용한_입력, canonical: 최종_대표주소, published: true }`를 반환합니다. 미확인 설정은 기존 포함을 유지하며, 최종 `noindex`·비공개·다른 대표 주소·활성 이동 출발 주소를 제외합니다. 함수가 네트워크를 대신 조회하지는 않습니다.
79
+
80
+ `listCmsMetadata` → `getCmsMetadata` → `updateCmsMetadata` 순서로 지원 필드와 현재 `version`을 확인하고 변경합니다. PATCH는 `expected_version`과 `patch`를 받습니다. 필드 미전송은 유지, `null`은 자동값 복귀, 불리언 `false`는 그대로 저장됩니다. 오래된 버전은 409로 거절합니다. 임의 canonical·HTML 입력은 제공하지 않습니다. 역할·API scope·Pro·연결 검사는 서버가 수행하며 다른 API 경로로 우회할 수 없습니다.
81
+
30
82
  ## 사이트맵
31
83
 
32
84
  `createSitemapIndex`는 **사이트맵 인덱스**(`/sitemap.xml`)와 섹션별 하위 사이트맵
@@ -422,10 +474,16 @@ const crumbs = breadcrumbSchema([
422
474
 
423
475
  ## 메인 홈 메타데이터 (홈 제목·홈 설명)
424
476
 
425
- 어드민 **설정 > 검색·공유 표시 > 메인 홈 검색 노출**에서 저장한 홈 제목·홈
426
- 설명은 `fetchBlogSettings().siteProfile.homeTitle` / `homeDescription` 으로
427
- 내려옵니다(공개 API `site_profile.home_title` / `home_description`). 홈(`/`)
428
- 라우트의 `generateMetadata` 에서 이 값을 우선 쓰고, 비어 있으면(null) 사이트
477
+ 새 연동은 홈도 위의 `fetchRouteMetadata`와 `buildPageMetadata`를 사용합니다.
478
+ 기존 연동의 `fetchBlogSettings().siteProfile.homeTitle` / `homeDescription`
479
+ (공개 API `site_profile.home_title` / `home_description`)은 호환을 위해 유지합니다.
480
+ 계약 등록만으로 전환하지 않고, 초기값 보존 또는 실제 설치 확인에서 홈 전환을
481
+ 완료할 때까지 기존 홈 설정을 반환합니다. 전환 뒤에는 현재 소유 경로의 `effective`
482
+ 제목·설명을 반환하며 초기화·연결 해제·계약 제거로 예전 홈 설정이 다시 나타나지 않습니다.
483
+ CMS 홈이 초안이거나 소유 경로가 제거되면 `null`을 반환합니다.
484
+
485
+ 아래는 기존 연동용 예시입니다. 홈(`/`) 라우트의 `generateMetadata`에서
486
+ 이 값을 우선 쓰고, 비어 있으면(null) 사이트
429
487
  이름(`fetchTheme().siteName`)·사이트 설명(`siteProfile.siteDescription`)으로
430
488
  폴백하세요. `title` 은 root layout 의 `%s | 사이트명` 템플릿을 타지 않도록
431
489
  `{ absolute }` 로 넘깁니다. `openGraph` 는 page 값이 layout 값을 통째로
@@ -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 노출, 작성자/발행일 표시, 작성자 카드 표시
@@ -165,6 +200,15 @@ const tocPosition = "inline";
165
200
  블록 key | null)로 계산해 내려주므로 사이트는 규칙을 몰라도 됩니다.** 현재
166
201
  자리는 글 하단 `post_footer` 하나입니다.
167
202
 
203
+ 사이트 코드에서 특정 key의 사진을 직접 조회하는 연동은 제작 계약의 `fixedPatterns`로
204
+ 표시합니다. 이 경우 공통 블록 화면은 연결된 사진 미리보기·사용 위치·사진 편집·숨김만
205
+ 제공하고 배치표·새 블록 만들기·삭제를 숨깁니다. 기존 블록이나 배치 데이터를 삭제하지
206
+ 않습니다. 선언이 없는 사이트는 기존 배치 화면을 그대로 사용합니다.
207
+ `examples/nextjs/cms/fixed-patterns.example.json`은 기존 계약에 병합할 필드 예시입니다.
208
+ 관리자와 API Worker 배포 후 현재 계약의 모델·노출 슬롯을 보존하고 version을 올려
209
+ 연결해야 합니다. 선언 자체가 사이트 출력을 만들지는 않으며, 소비 코드는
210
+ `fetchSitePatterns`에서 선언된 key를 찾고 저장 웹훅으로 캐시를 갱신해야 합니다.
211
+
168
212
  - `RootTaleBlogPost`(cms-renderer-next 0.57+)는 본문 바로 아래에 자동으로 그립니다
169
213
  — 추가 코드가 필요 없습니다.
170
214
  - 자체 글 화면을 만든 사이트는 `RootTalePostPattern` 서버 컴포넌트를 본문 아래에
@@ -1,5 +1,7 @@
1
1
  // 갱신 요청은 재전송될 수 있으므로 캐시 무효화를 멱등으로 유지한다.
2
2
  // 수신 오류의 공개 reason 코드는 관리자 전송 기록에서 확인할 수 있다.
3
+ // 빈 분류도 taxonomy.updated(paths: [])가 온다. 팩토리가 tag·layout을 전역 갱신하므로
4
+ // 호출 전에 paths.length === 0 조건으로 이벤트를 건너뛰지 않는다.
3
5
  // RootTale 발행 웹훅 수신 — ES256 서명 검증 후 Next.js 캐시 revalidate.
4
6
  // 어드민 "내 사이트 > 배포"에 https://<도메인>/api/revalidate 로 등록한다.
5
7
  import { revalidatePath, revalidateTag } from "next/cache";
@@ -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,9 @@
1
+ {
2
+ "fixedPatterns": [
3
+ {
4
+ "key": "article-footer-image",
5
+ "label": "글 하단 이미지",
6
+ "location": "모든 블로그 글의 목록 링크 바로 위에 표시됩니다."
7
+ }
8
+ ]
9
+ }
@@ -0,0 +1,18 @@
1
+ import type { Metadata } from "next";
2
+ import { readMetadataValues, type CmsPostContent } from "@roottale/cms-client/server";
3
+ import { buildPageMetadata } from "@roottale/cms-renderer-next/routes";
4
+
5
+ /** 본문이 있는 CMS 페이지는 기존 글 SEO를 사용한다. 코드 경로는 route-metadata.ts 참고. */
6
+ export function pageMetadata(
7
+ page: CmsPostContent | null,
8
+ defaults: { title: string; description: string; canonical: string; imageUrl: string },
9
+ ): Metadata {
10
+ return buildPageMetadata({
11
+ overrides: readMetadataValues(page?.metaJson.seo),
12
+ defaults: {
13
+ title: page?.title || defaults.title,
14
+ description: page?.excerpt || defaults.description,
15
+ ogImage: page?.featuredImageUrl || defaults.imageUrl,
16
+ },
17
+ }, { canonical: defaults.canonical });
18
+ }
@@ -0,0 +1,31 @@
1
+ import type { Metadata } from "next";
2
+ import { fetchRouteMetadata, type MetadataValues, type PublicRouteMetadata } from "@roottale/cms-client/server";
3
+ import { buildPageMetadata } from "@roottale/cms-renderer-next/routes";
4
+
5
+ /** fallback은 계약 manifest와 같은 사이트 정의에서 가져온다. API key는 서버 전용이다. */
6
+ export async function routeMetadata(
7
+ routeKey: string,
8
+ options: { canonical: string; fallback: MetadataValues; contractChecksum: string; forceNoindex?: boolean },
9
+ ): Promise<Metadata> {
10
+ let route: PublicRouteMetadata | null = null;
11
+ try {
12
+ route = await fetchRouteMetadata({
13
+ apiKey: process.env.ROOTTALE_API_KEY!, baseUrl: process.env.ROOTTALE_API_BASE,
14
+ routeKey, locale: "ko", revalidate: 300,
15
+ });
16
+ } catch (error) {
17
+ // 오류를 "설정 없음"으로 저장하는 별도 캐시를 만들지 않는다.
18
+ console.error("메타데이터 조회 실패", { routeKey, error: error instanceof Error ? error.name : "unknown" });
19
+ }
20
+ return buildPageMetadata({
21
+ ...route,
22
+ fallback: options.fallback,
23
+ robots: { ...route?.robots, forceNoindex: options.forceNoindex || route?.robots?.forceNoindex },
24
+ }, {
25
+ canonical: options.canonical,
26
+ verification: {
27
+ routeKey, locale: route?.locale ?? "ko", version: route?.version,
28
+ contractChecksum: options.contractChecksum,
29
+ },
30
+ });
31
+ }
@@ -1,4 +1,6 @@
1
1
  // 서버 전용. 하나의 사이트에 등록한 두 폼은 서로 다른 ID와 발행 버전을 갖는다.
2
+ // 폼 ID는 RootTale 내부 개발자가 해당 사이트의 루트테일 관리자 → 문의 폼에서 등록한다.
3
+ // 고객은 받은 문의에서 두 폼의 접수 내용을 함께 관리한다.
2
4
  import { fetchInquiryForm } from "@roottale/cms-client/server";
3
5
 
4
6
  export type SiteFormKey = "quote" | "service";
@@ -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
+ }