@roottale/cms-mcp 0.41.0 → 0.42.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,17 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.42.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 1cdc365: 어드민 도메인 변경 반영 — 문서·예시의 `mysite.roottale.com` 을 `admin.roottale.com` 으로 갱신 (키 발급·웹훅 등록 메뉴 경로 안내 포함). 기존 주소는 308 redirect 로 계속 동작.
8
+
9
+ ## 0.41.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 3810879: Add Turnstile-aware inquiry submission fields, rate-limit error handling, and docs for protected lead intake.
14
+
3
15
  ## 0.41.0
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -30,7 +30,7 @@ RootTale CMS 연동 MCP 서버 — AI 코딩 에이전트(Claude Code, Cursor
30
30
 
31
31
  `ROOTTALE_API_KEY`는 선택입니다 — 문서·예시 코드 tool은 키 없이 동작하고,
32
32
  `listPublishedPosts` / `getPublishedPost` (연동 검증) tool만 키가 필요합니다.
33
- 키 발급: 어드민(`mysite.roottale.com`) **설정 > 사이트 연결 키**.
33
+ 키 발급: 어드민(`admin.roottale.com`) **설정 > 사이트 연결 키**.
34
34
 
35
35
  Claude Code:
36
36
 
package/dist/index.js CHANGED
@@ -279,7 +279,7 @@ function registerTools(server2) {
279
279
  }
280
280
 
281
281
  // src/server.ts
282
- var VERSION = true ? "0.41.0" : "dev";
282
+ var VERSION = true ? "0.42.0" : "dev";
283
283
  var SERVER_INSTRUCTIONS = `
284
284
  roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uAE30 \uC704\uD55C
285
285
  \uD1B5\uD569 \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7\uACF5\uAC1C API \uC870\uD68C tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
@@ -207,12 +207,14 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
207
207
  | `message`, `consultation_field`, `current_site_url` | | |
208
208
  | `lead_kind` | | `patient`(기본) \| `sales` |
209
209
  | `_redirect_url` | | 완료 후 redirect base (allowlist 검증) |
210
+ | `cf-turnstile-response` | | Cloudflare Turnstile 토큰. tenant-api에 `LEAD_INTAKE_TURNSTILE_SECRET_KEY`가 설정된 경우 필수 |
210
211
  | `attr_landing_path`, `attr_rt_src`, `attr_utm_source`, `attr_utm_medium`, `attr_utm_campaign`, `attr_referrer`, `attr_first_touch_at` | | 유입 어트리뷰션 — CRM에 유입 경로 표시 (`inquiries.md` 참고). 그 외 `attr_*` 키는 무시 |
211
212
  | 기타 임의 필드 | | 최대 50개, 암호화 보관, CRM 상세 노출 |
212
213
 
213
214
  응답: `302` redirect — 성공 `?ok=1`, 실패 `?err=<code>`
214
215
  (`consent_privacy`, `consent_overseas`, `invalid_vertical`, `missing_fields`,
215
- `invalid_email`, `internal`). 잘못된 키는 `401`.
216
+ `invalid_email`, `turnstile_failed`, `internal`). 잘못된 키는 `401`.
217
+ IP/tenant rate limit 초과 시 `429 rate_limited`를 반환합니다.
216
218
 
217
219
  서버-서버 호출 시 redirect를 따라가지 말고(`redirect: "manual"`) `Location`
218
220
  헤더를 파싱하세요 — `@roottale/cms-client`의 `submitInquiry`가 이를 대신합니다.
package/docs/blog.md CHANGED
@@ -52,7 +52,16 @@ export default function BlogPage() {
52
52
  > <RootTaleBlogList apiKey={apiKey} collection="blog" collections={COLLECTIONS} showCategoryFilter />
53
53
  > ```
54
54
  >
55
- > 자세한 내용은 `collections.md` 참고.
55
+ > **`/blog` 가 선언 섹션이 아니라 "나머지 전부"인 사이트**(증상/질환/치료 같은
56
+ > 아카이브 스트림만 선언)는 반대로 `collections`+**`excludeCollections`** 로 어느
57
+ > 스트림에도 안 속한 글만 렌더하세요:
58
+ >
59
+ > ```tsx
60
+ > <RootTaleBlogList apiKey={apiKey} collections={ARCHIVE_COLLECTIONS} excludeCollections showCategoryFilter />
61
+ > ```
62
+ >
63
+ > 자세한 내용과 `excludeCollections` 오적용 주의(`blog` 자체가 선언 섹션인 사이트에
64
+ > 쓰면 목록이 빈다)는 `collections.md` 참고.
56
65
 
57
66
  ### 상세 페이지
58
67
 
@@ -120,6 +129,40 @@ export default async function PostPage({
120
129
  경우 `@roottale/cms-core` 의 `extractFaqEntries(doc)` / `faqPageJsonLd(entries)`
121
130
  로 동일한 JSON-LD 를 생성할 수 있습니다.
122
131
 
132
+ ### 다국어 (ADR-0052 A안, W4-6 PR C1/C2)
133
+
134
+ 번역 사이트(어드민에서 "번역 추가"로 언어판을 만든 글)는 `RootTaleBlogList`/
135
+ `RootTaleBlogPost`/`RootTalePage` 에 `locale` prop 을 넘기면 그 언어판만
136
+ fetch 합니다(`fetchPosts`/`fetchPost` 의 `locale` 쿼리 그대로 전달). 미지정
137
+ 시 서버가 사이트 기본 locale 만 반환합니다(하위호환).
138
+
139
+ ```tsx
140
+ // app/[locale]/blog/[slug]/page.tsx — starter 참조 구현 패턴
141
+ const { locale, slug } = await params;
142
+ <RootTaleBlogPost
143
+ apiKey={process.env.ROOTTALE_API_KEY!}
144
+ slugOrId={slug}
145
+ locale={locale}
146
+ showTableOfContents
147
+ />;
148
+ ```
149
+
150
+ - **URL 전략**(권장, starter 참조 구현) — 기본 locale 은 prefix 없는 기존
151
+ 경로(`/blog/{slug}`) 그대로 두고, 나머지 locale 만 `app/[locale]/...` 로
152
+ 별도 트리를 둡니다(`app/[locale]/blog/`·`app/[locale]/blog/[slug]/`·
153
+ `app/[locale]/[slug]/`). 사이트 활성 locale 목록은 별도 API 없이
154
+ `fetchBlogSettings().sitemap.locales`(SSoT = `sites.settingsJson.locales`)
155
+ 로 얻습니다 — 첫 원소가 기본 locale.
156
+ - `<head>` hreflang · 사이트맵 hreflang 은 "SEO 연동" 문서(`seo.md`)의
157
+ "다국어 (hreflang)" 절 참고.
158
+ - **UI 문자열**(cms-i18n, ADR-0034 §3) — `RootTaleBlogList.emptyMessage`/
159
+ `allCategoriesLabel`, `RootTaleBlogPost.relatedPostsTitle`/미발견 안내,
160
+ `RootTalePage` 미발견 안내, `RootTaleTableOfContents.title` 은 명시 prop
161
+ 을 안 주면 `locale` prop 기준으로 자동 번역됩니다(`@roottale/cms-i18n`
162
+ ko/en 사전, `ja`/`zh-hans` 등 사전 없는 locale 은 ko 로 fallback). 글
163
+ 본문·admin UI 는 이 자동 번역 대상이 아닙니다(사람이 직접 작성/ADR-0052
164
+ 범위 밖).
165
+
123
166
  ### 고정 페이지 (회사소개 등)
124
167
 
125
168
  어드민의 고정 페이지(`type: "page"`)는 `RootTalePage`로 렌더링합니다 — 블로그
@@ -223,7 +266,7 @@ export async function generateMetadata({
223
266
 
224
267
  입력은 `{ title, description?, date?, image?, seo? }` 형태면 되고
225
268
  (`getPost` 의 `BlogPostMeta` 가 그대로 호환), 원본 post 를 쓸 땐
226
- `seo: (post.metaJson as { seo?: ... }).seo` 로 넘기세요.
269
+ `seo`로 넘길 값은 `(post.metaJson as { seo?: ... }).seo`입니다.
227
270
 
228
271
  SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`,
229
272
  `noindex`, `nofollow`.
@@ -1,5 +1,5 @@
1
1
  ---
2
- title: 콘텐츠 유형 (Collections) — 공지·블로그 URL 분리
2
+ title: 콘텐츠 유형 (Collections)
3
3
  description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등 여러 섹션으로 나누는 법. 섹션=글의 collection_key, 카테고리=섹션 안의 주제. URL 구조, 어드민 설정, 연동 코드, slug·301·OG.
4
4
  ---
5
5
 
@@ -55,7 +55,7 @@ description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등
55
55
  > 이전 버전은 *카테고리로 섹션을 추론*했지만, 지금은 섹션이 글에 **명시**됩니다.
56
56
  > 카테고리는 더 이상 어느 섹션에 속하는지를 결정하지 않습니다(주제 아카이브 전용).
57
57
 
58
- ## 어드민에서 설정 (`mysite.roottale.com`)
58
+ ## 어드민에서 설정 (`admin.roottale.com`)
59
59
 
60
60
  **설정 > 콘텐츠 유형** 에서 섹션을 정의합니다. 빈 상태의 "공지 + 블로그 한 번에 만들기"
61
61
  버튼으로 표준 두 섹션을 한 번에 만들 수 있습니다. 각 섹션은:
@@ -198,6 +198,41 @@ export default function BlogPage() {
198
198
  `RootTaleBlogCategories` 도 같은 `collection`+`collections` 를 받아 그 섹션의 주제만
199
199
  집계합니다. 카테고리 칩/사이드바를 섹션별로 나눌 때 쓰세요.
200
200
 
201
+ #### `/blog` 가 선언 섹션이 *아닌* 사이트 — `excludeCollections` (네거티브 스코프)
202
+
203
+ 증상/질환/치료처럼 **아카이브 전용 스트림만 선언하고, 나머지("미소속") 글을
204
+ `/blog` 로 보여주는** 사이트도 있습니다. 이런 사이트는 `blog` 라는 섹션 자체가
205
+ 없으므로 `collection="blog"` 로 스코프할 수 없습니다 — 대신 `collections`+
206
+ **`excludeCollections`** 로 "어느 스트림에도 안 속한 글만" 골라냅니다.
207
+
208
+ ```tsx
209
+ // app/blog/page.tsx — 증상/질환/치료 스트림은 따로, 나머지는 블로그
210
+ import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";
211
+ import { ARCHIVE_COLLECTIONS } from "@/lib/collections"; // symptoms/conditions/treatments
212
+
213
+ export default function BlogPage() {
214
+ return (
215
+ <RootTaleBlogList
216
+ apiKey={process.env.ROOTTALE_API_KEY!}
217
+ collections={ARCHIVE_COLLECTIONS}
218
+ excludeCollections
219
+ showCategoryFilter
220
+ />
221
+ );
222
+ }
223
+ ```
224
+
225
+ ⚠️ **`blog` 자체가 선언 섹션인 사이트에는 쓰지 마세요.** `collections` 목록에
226
+ `{ key: "blog", ... }` 가 있고 글의 `collectionKey === "blog"` 라면, 그 글은
227
+ "소속 글"로 판정되어 `excludeCollections` 가 오히려 **전부 제외**해 버립니다(빈
228
+ 목록). 그런 사이트는 위 "섹션 목록 페이지" 예시처럼 포지티브 `collection="blog"`
229
+ 가 정경로입니다.
230
+
231
+ 규칙:
232
+
233
+ - `collection` 과 `excludeCollections` 동시 지정 = **throw**(포지티브/네거티브 배타).
234
+ - `collections` 없이 `excludeCollections` 만 지정 = **throw**(조용한 no-op 금지).
235
+
201
236
  > 동적 basePath(어드민에서 자유 편집)나 catch-all 라우트를 쓰면 `collection` 값을
202
237
  > 요청 경로에서 판정해 넘기세요(아래 "동적 basePath" 참고).
203
238
 
@@ -5,7 +5,7 @@ description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로
5
5
 
6
6
  # 주소 이동 (커스텀 리다이렉트) 연동
7
7
 
8
- 어드민(mysite.roottale.com)의 **설정 > 주소 이동**에서 운영자가 정의한 임의
8
+ 어드민(admin.roottale.com)의 **설정 > 주소 이동**에서 운영자가 정의한 임의
9
9
  경로 이동 규칙(`/old-event → /promo`)을 사이트 미들웨어로 적용합니다. 코드
10
10
  수정 없이 고객이 직접 규칙을 추가·수정·삭제할 수 있습니다.
11
11
 
@@ -7,7 +7,7 @@ description: API 키 발급, 환경변수 설정, 패키지 설치, 첫 콘텐
7
7
 
8
8
  ## 1. API 키 발급
9
9
 
10
- 1. 어드민(`mysite.roottale.com`) 로그인
10
+ 1. 어드민(`admin.roottale.com`) 로그인
11
11
  2. **설정 > 사이트 연결 키** 메뉴로 이동
12
12
  3. 새 키 발급 — 권한 선택:
13
13
  - **read** (기본): 발행된 콘텐츠 조회만. 외부 사이트 연동은 이걸로 충분
package/docs/inquiries.md CHANGED
@@ -31,6 +31,8 @@ export async function submitContact(
31
31
  const name = (formData.get("name") as string | null)?.trim() ?? "";
32
32
  const phone = (formData.get("phone") as string | null)?.trim() ?? "";
33
33
  const message = (formData.get("message") as string | null)?.trim() ?? "";
34
+ const turnstileToken =
35
+ (formData.get("cf-turnstile-response") as string | null)?.trim() ?? "";
34
36
  const privacyConsent = formData.get("privacyConsent") !== null;
35
37
 
36
38
  const errors: ContactState["errors"] = {};
@@ -51,6 +53,7 @@ export async function submitContact(
51
53
  email: `noemail-${name}@example.invalid`, // 이메일 미수집 폼이면 placeholder
52
54
  phone, // 자동으로 010-1234-5678 형태 포맷됨
53
55
  message: message || undefined,
56
+ turnstileToken: turnstileToken || undefined,
54
57
  privacyConsent: true, // 사용자가 명시 동의한 경우에만 true
55
58
  },
56
59
  });
@@ -80,6 +83,7 @@ export async function submitContact(
80
83
  | `currentSiteUrl` | | 현재 사이트 URL |
81
84
  | `overseasTransferConsent` | medical 시 ✅ | 국외이전 동의 |
82
85
  | `leadKind` | | `patient`(기본) \| `sales` |
86
+ | `turnstileToken` | | Cloudflare Turnstile 토큰. tenant-api secret 검증을 켠 경우 전달 |
83
87
  | `extras` | | 임의 추가 항목 (최대 50개, 암호화 보관, CRM 상세에 노출) |
84
88
  | `attribution` | | 유입 first-touch — 아래 [유입 어트리뷰션](#유입-어트리뷰션-attribution) 참고 |
85
89
 
@@ -154,6 +158,8 @@ type SubmitInquiryResult =
154
158
  | `missing_fields` | 필수 필드 누락 |
155
159
  | `invalid_email` | 이메일 형식 오류 |
156
160
  | `invalid_vertical` | 허용되지 않는 vertical |
161
+ | `turnstile_failed` | Turnstile 검증 실패 |
162
+ | `rate_limited` | 같은 tenant/IP에서 요청 과다 |
157
163
  | `invalid_api_key` | 키 인증 실패 |
158
164
  | `internal` | 서버/네트워크 오류 |
159
165
 
@@ -163,5 +169,8 @@ type SubmitInquiryResult =
163
169
  `RootTaleLeadForm`(RSC, HTML form)을 사용할 수 있습니다. 디자인·검증을
164
170
  통제하려면 위의 Server Action 방식을 권장합니다.
165
171
 
172
+ `RootTaleLeadForm`에 `turnstileSiteKey`를 넘기면 Cloudflare Turnstile 위젯과
173
+ `cf-turnstile-response` 필드가 함께 렌더링됩니다.
174
+
166
175
  raw HTTP로 직접 연동(비 JS 스택)하려면 `api-reference.md`의
167
176
  `POST /v1/public/inquiries`를 참고하세요.
package/docs/menus.md CHANGED
@@ -5,7 +5,7 @@ description: 어드민 "디자인 > 메뉴"에서 관리하는 네비게이션
5
5
 
6
6
  # 메뉴 (네비게이션) 연동
7
7
 
8
- 어드민(mysite.roottale.com)의 **디자인 > 메뉴**에서 저장한 네비게이션 트리를
8
+ 어드민(admin.roottale.com)의 **디자인 > 메뉴**에서 저장한 네비게이션 트리를
9
9
  사이트의 헤더·푸터에 렌더합니다. 고객이 직접 메뉴 항목(이름·주소·순서·하위
10
10
  항목)을 바꿀 수 있어 코드 수정 없이 네비가 갱신됩니다.
11
11
 
package/docs/overview.md CHANGED
@@ -5,12 +5,12 @@ description: 연동 아키텍처, 단일 API 키 모델, 패키지 구성, 문
5
5
 
6
6
  # RootTale CMS 연동 개요
7
7
 
8
- RootTale CMS는 어드민(`mysite.roottale.com`)에서 콘텐츠를 작성·발행하고, 외부
8
+ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발행하고, 외부
9
9
  고객 사이트(자체 도메인의 Next.js/Astro 등)가 공개 API(`api.roottale.com`)로
10
10
  콘텐츠를 가져가는 헤드리스 구조입니다.
11
11
 
12
12
  ```
13
- 어드민 (mysite.roottale.com) 고객 사이트 (예: example.com)
13
+ 어드민 (admin.roottale.com) 고객 사이트 (예: example.com)
14
14
  글 작성·발행 ──────────┐
15
15
  ▼
16
16
  api.roottale.com ◀── Bearer rtlk_cust_* ── 콘텐츠/설정 조회
package/docs/seo.md CHANGED
@@ -105,19 +105,33 @@ export default async function AuthorArchive({
105
105
  - `RootTaleBlogList`에 `author={slug}` 를 주면 그 작가의 글만 가져옵니다
106
106
  (`GET /v1/cms/public/posts?author={slug}`).
107
107
 
108
- ### 다국어 (hreflang)
109
-
110
- 번역 페이지가 있는 사이트는 어드민 **설정 > 블로그 > 사이트맵**의 "다국어 로케일"에
111
- 언어 코드를 입력하면(예: `ko, ja, en`), 사이트맵의 모든 항목에 `hreflang` 대체 언어
112
- 링크(`<xhtml:link rel="alternate">`)가 붙습니다.
113
-
114
- - **첫 번째** 로케일이 기본 언어(현재 URL 그대로) + `x-default`.
115
- - 나머지는 **경로 접두사**로 매핑됩니다 — `ko`가 기본이면 `ja`는
116
- `/ja/blog/{slug}`, `en`은 `/en/blog/{slug}`.
117
- - 같은 slug에 언어 접두사만 붙는 1:1 구조를 가정합니다. **실제 번역 페이지를
118
- 그 경로(`/ja/...`)에 서빙하는 것은 사이트 쪽 책임**입니다 — 사이트맵은 검색엔진에
119
- 대체 언어를 알릴 뿐입니다.
120
- - 비워두면 단일 언어로 동작합니다(기본).
108
+ ### 다국어 (hreflang) — ADR-0052 A안, 콘텐츠 다국어
109
+
110
+ 번역 글이 있는 사이트는 어드민 **설정 > 블로그 > 사이트맵**의 "다국어 로케일"에
111
+ 언어 코드를 입력합니다(예: `ko, ja, en`, 첫 번째 = 기본 locale). 사이트맵의
112
+ `blog` 섹션(`GET /sitemap/blog.xml`)이 **글 단위 실제 번역 데이터**로 hreflang
113
+ 대체 언어 링크(`<xhtml:link rel="alternate">`)를 계산합니다:
114
+
115
+ - 공개 API(`GET /v1/cms/public/posts`)가 글마다 `translations`(발행된 언어판
116
+ 목록, 자기 자신 포함)를 내려줍니다. 언어판이 없는 글(자기 자신 1건뿐)은
117
+ hreflang alternates 자체가 생략됩니다 — 존재하지 않는 URL 을 검색엔진에
118
+ 알리는 사고를 원천 차단합니다(옛 slug 1:1 path-prefix 가정 제거).
119
+ - **기본 locale** = prefix 없는 경로 + `x-default`. 나머지는 **경로 접두사**
120
+ (`ko`가 기본이면 `ja` 언어판은 `/ja/blog/{그-언어판-실제-slug}`) — 언어판마다
121
+ **독립된 slug**를 가질 수 있습니다(동일 slug 강제 아님).
122
+ - **실제 번역 라우트를 그 경로(`/ja/...`)에 서빙하는 것은 사이트 쪽 책임**
123
+ 입니다 — starter 참조 구현은 `app/[locale]/blog/`·`app/[locale]/blog/[slug]/`·
124
+ `app/[locale]/[slug]/`(기본 locale 은 prefix 없는 기존 경로 그대로).
125
+ - **`static`/`categories`/`authors` 섹션은 hreflang alternates 를 붙이지
126
+ 않습니다** — v1 은 블로그 글 + CMS `[slug]` 페이지만 번역 대상이라(nav 정적
127
+ 경로·카테고리 아카이브·작가 아카이브는 locale 라우트 자체가 없음), 옛 방식대로
128
+ path-prefix 를 씌우면 존재하지 않는 URL 의 hreflang 이 됩니다.
129
+ - 로케일을 비워두면(또는 기본 locale 조차 발행 글 0건이면) 단일 언어로
130
+ 동작합니다(alternates 미부착, fail-closed).
131
+
132
+ 글 상세 `<head>` hreflang 은 `buildPostMetadata`(아래 "글 메타데이터" 절 참고)의
133
+ `locales`/`translations` 옵션으로 계산합니다 — 사이트맵과 같은 원본 데이터
134
+ (`translations[]`)를 씁니다.
121
135
 
122
136
  ## 다중 스트림 (collections) — 공지·블로그 분리
123
137
 
@@ -244,6 +258,37 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
244
258
  canonical 을 생략합니다(섹션 없는 글은 상세·sitemap에서 제외되는 규칙과 동일).
245
259
  단일 블로그 사이트는 기존처럼 `path: "/blog/" + post.slug` 만 주면 됩니다.
246
260
 
261
+ ### 다국어 hreflang (ADR-0052 A안, W4-6 PR C1)
262
+
263
+ 번역 사이트는 `translations`(글 응답의 `translations[]`)와 `locales` 옵션을
264
+ 넘기면 `<head>` 에 양방향 hreflang(+ `x-default`)이 자동으로 붙습니다:
265
+
266
+ ```tsx
267
+ import { buildPostMetadata } from "@roottale/cms-renderer-next/routes";
268
+
269
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
270
+ const { locale, slug } = await params; // app/[locale]/blog/[slug]/page.tsx
271
+ const post = await getPost(slug, locale);
272
+ if (!post) return {};
273
+ return buildPostMetadata(
274
+ { ...post, translations: post.translations },
275
+ {
276
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
277
+ path: `/${locale}/blog/${post.slug}`,
278
+ locales: { defaultLocale: "ko" }, // 사이트 활성 locale 의 첫 원소
279
+ },
280
+ );
281
+ }
282
+ ```
283
+
284
+ - `pathFor` 를 안 주면 기본값 `기본locale → /blog/{slug}`, `그 외 → /{locale}/blog/{slug}`.
285
+ 블로그가 아닌 라우트(CMS `[slug]` 페이지 등)는 `pathFor` override 필수 —
286
+ `buildHreflangLanguages(translations, siteUrl, { defaultLocale, pathFor })`
287
+ 를 직접 호출하면 OG `type:"article"` 없이 plain `alternates` 만 조립할 수
288
+ 있습니다.
289
+ - `translations` 가 없거나(구 서버) 자기 자신 1건뿐(번역 없음)이면 `languages`
290
+ 자체가 생략됩니다 — 단일 언어 사이트는 기존 canonical-only 동작 그대로.
291
+
247
292
  ## slug 변경 시 301 리다이렉트
248
293
 
249
294
  글 slug를 바꿔도 옛 URL이 깨지지 않습니다. API가 slug history로 글을 찾아
@@ -407,9 +452,10 @@ export default async function RootLayout({ children }) {
407
452
  - `sameAs`: 어드민에 입력한 네이버플레이스·구글 비즈니스 프로필·카카오 채널
408
453
  등의 URL — 검색엔진이 동일 사업장임을 연결합니다.
409
454
 
410
- 네이버플레이스(new.smartplace.naver.com)와 구글 비즈니스 프로필
411
- (business.google.com) 등록 자체는 공개 API가 없어 사장님이 직접 해야 하며,
412
- 어드민 화면에 등록 안내와 프로필 URL 입력란이 있습니다.
455
+ 네이버플레이스([new.smartplace.naver.com](https://new.smartplace.naver.com))와
456
+ 구글 비즈니스 프로필([business.google.com](https://business.google.com)) 등록
457
+ 자체는 공개 API가 없어 사장님이 직접 해야 하며, 어드민 화면에 등록 안내와
458
+ 프로필 URL 입력란이 있습니다.
413
459
 
414
460
  ## Fleet 프로브 (운영 가시성)
415
461
 
@@ -18,6 +18,8 @@ export async function submitContact(
18
18
  const phone = (formData.get("phone") as string | null)?.trim() ?? "";
19
19
  const field = (formData.get("field") as string | null)?.trim() ?? "";
20
20
  const message = (formData.get("message") as string | null)?.trim() ?? "";
21
+ const turnstileToken =
22
+ (formData.get("cf-turnstile-response") as string | null)?.trim() ?? "";
21
23
  const privacyConsent = formData.get("privacyConsent") !== null;
22
24
  // 유입 어트리뷰션 (선택) — 클라이언트 폼이 readAttribution() 값을 hidden
23
25
  // input(name="attribution")에 JSON 으로 실어 보내면 CRM 에 유입 경로가 표시된다.
@@ -42,6 +44,7 @@ export async function submitContact(
42
44
  phone,
43
45
  consultationField: field || undefined,
44
46
  message: message || undefined,
47
+ turnstileToken: turnstileToken || undefined,
45
48
  privacyConsent: true, // 사용자가 명시 동의한 경우에만
46
49
  attribution,
47
50
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "description": "RootTale CMS integration MCP server — bundled integration docs, Next.js example code, and public API lookup tools. Run with: npx @roottale/cms-mcp",
6
6
  "bin": {