@roottale/cms-mcp 0.50.0 → 0.52.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,46 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.52.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 01f5534: 연동 문서·예시가 **설정 저장 즉시 반영** 배선을 담게 됐다. 지금까지 예시의 revalidate 라우트는 `revalidatePath` 만 넣었고, 그 예시를 그대로 따른 사이트는 어드민에서 전화·주소·메뉴를 고쳐도 최대 30분 옛 값을 보여 줬다 — 예시를 복사해 쓰는 것이 정상 경로인데 그 예시에 구멍이 있었다.
8
+ - `revalidation-webhooks.md` — 예시에 `revalidateTag` 주입 추가. "설정 저장을 즉시 반영하려면 — 캐시 이름표(`tags`)" 절 신설(조회 함수 ↔ 이름표 상수 표, 빠뜨렸을 때의 증상, 자기 진단법). 이벤트 목록에 `theme.updated` 추가 + 저수준(`verifyRootTaleWebhook`) 예시에도 그 분기를 넣었다. 트러블슈팅에 "글은 즉시인데 설정만 늦음" 행 추가.
9
+ - **두 가지 함정을 문서·예시 전체에서 바로잡았다.** ① 이름표를 지울 때는 `revalidateTag(tag, { expire: 0 })` 여야 한다 — `"max"` 같은 프로파일은 `expire` 가 0 이 아니어서 Next 가 stale-while-revalidate 로 취급하고, 다음 요청이 여전히 옛 값을 받는다(`"max"` 의 `expire` 는 1년). `updateTag` 는 즉시지만 서버 액션 전용이라 웹훅 라우트에서 오류가 난다. ② `revalidate` 를 콜백으로 감쌀 때 2번째 인자(`type`)를 그대로 넘겨야 한다 — 안 넘기면 레이아웃 전역 무효화가 페이지 1개 무효화로 줄어 정적 페이지가 안 바뀐다.
10
+ - `theme-and-settings.md` — 맨 앞에 이름표 표를 두고, `fetchTheme`·`fetchBusinessProfile`·`fetchBlogSettings` 예시에 `tags` 를 넣었다.
11
+ - `menus.md` · `collections.md` — `fetchMenu`·`fetchCollections` 예시에 이름표, collections 의 revalidate 예시에 `revalidateTag` 주입.
12
+ - `overview.md` · `getting-started.md` — 사업장 정보·메뉴·콘텐츠 유형 조회 함수를 표에 넣고, "다음 단계"에서 설정 반영 문서를 가리킨다.
13
+ - `examples/nextjs` — revalidate 라우트에 `revalidateTag`, layout 의 사업장 정보 조회에 `BUSINESS_CACHE_TAG`, 헤더 메뉴에 `MENUS_CACHE_TAG`, 블로그 설정 조회에 `BLOG_SETTINGS_CACHE_TAG`.
14
+
15
+ 이름표 상수와 수신측 동작은 `@roottale/cms-client` · `@roottale/cms-renderer-next` 쪽 변경 사항을 참고.
16
+
17
+ - f3aef69: 공개 API `/v1/cms/public/theme` 의 `siteNav` 상한이 **좁아진다** — 그룹당 열이 4 → 3, 열당 링크가 8 → 6. 그룹 7개·유틸리티 링크 4개는 그대로다.
18
+
19
+ 지금까지 `siteNav` 를 저장하는 유일한 경로가 어드민 **설정 > 사이트 > 상단 메뉴** 폼이었고 그 폼은 이미 3열·6링크까지만 입력칸을 제공했다. 그래서 상한을 넘는 값은 만들어질 수 없었고(프로덕션 실측: `siteNav` 를 가진 사이트 0곳), 이번 변경으로 잘려 나가는 데이터는 없다. 저장 스키마·어드민 폼·공개 계약이 각자 다른 숫자를 들고 있던 것을 하나로 맞추는 정리다.
20
+
21
+ `@roottale/cms-client` 의 `SiteNavSettings` 타입과 `fetchTheme` 시그니처는 그대로다 — 소비 코드 변경은 필요 없다.
22
+
23
+ 다만 **상한을 넘는 값이 응답에서 어떻게 되는지**를 이번에 문서화했다: 개별 항목을 잘라내는 것이 아니라 `siteNav` 전체가 빠진다(`undefined`). 손으로 DB 를 고쳐 4열짜리 메뉴를 넣어 둔 사이트가 있다면 메뉴가 통째로 사라지므로, 사이트에는 항상 폴백 메뉴를 두는 편이 안전하다. 모양·상한·폴백 안내는 `docs/theme-and-settings.md` 의 "상단 메뉴 — theme.siteNav" 절에 추가했다.
24
+
25
+ - cfd78e4: 발행 웹훅의 `paths` 가 글이 속한 **콘텐츠 유형의 주소**를 담는다는 점을 문서에
26
+ 명시했다. 지금까지는 공지 유형(`/notice`) 글을 고쳐도 어드민이 `/blog` 주소만
27
+ 보내서, 정작 바뀐 목록이 갱신되지 않았다. 어드민 쪽 발송 로직을 고치면서 문서도
28
+ 사실에 맞췄다 — 유형별 목록·상세 주소, 카테고리 모음은 그 유형에서 켰을 때만,
29
+ 유형을 옮긴 글은 이전·이후 주소가 함께 온다는 것, 그리고 주소 없는 유형·목록 전용
30
+ 유형은 상세 주소가 빠진다는 것.
31
+
32
+ **수신 측 코드 변경은 필요 없다.** `createRevalidateRoute` 는 예전과 똑같이 모든
33
+ 서명 이벤트에서 블로그 목록·피드·사이트맵을 함께 갱신하고, `collections` 를 넘긴
34
+ 사이트는 각 유형의 목록 주소도 이미 자동으로 갱신하고 있었다. 공개 API·타입·
35
+ 시그니처는 그대로라 patch 로 올린다(문서 문구만 바뀐다).
36
+
37
+ - 66ad45d: 발행 웹훅 문서에 "주소가 리다이렉트되면 실패한다" 와 Cloudflare SSL 모드
38
+ Flexible 함정을 명시했다. 둘 다 화면에는 아무 오류가 안 뜨고 발행 반영만
39
+ 조용히 늦어지는 유형이라, 실제로 자사 사이트와 고객사 한 곳이 각각 이 이유로
40
+ 웹훅이 죽어 있었다(2026-07-27 실측).
41
+
42
+ ## 0.51.1
43
+
3
44
  ## 0.50.0
4
45
 
5
46
  ### 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.50.0" : "dev";
947
+ var VERSION = true ? "0.52.0" : "dev";
948
948
  var SERVER_INSTRUCTIONS = `
949
949
  roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uACE0
950
950
  \uAE00\xB7\uC378\uB124\uC77C\xB7\uBCF8\uBB38 \uC774\uBBF8\uC9C0\uB97C \uC790\uB3D9\uD654\uD558\uAE30 \uC704\uD55C \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7API tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
@@ -205,13 +205,19 @@ export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: COLLEC
205
205
  함수**로 넘깁니다. 운영자가 어드민에서 섹션을 바꾸면 사이트가 따라갑니다.
206
206
 
207
207
  ```ts
208
- import { fetchCollections } from "@roottale/cms-client/server";
208
+ import {
209
+ COLLECTIONS_CACHE_TAG,
210
+ fetchCollections,
211
+ } from "@roottale/cms-client/server";
209
212
 
210
213
  const DEFAULT: RouteCollection[] = [ /* 위와 동일 — fail-soft 기본값 */ ];
211
214
 
212
215
  async function getCollections(): Promise<RouteCollection[]> {
213
216
  try {
214
- const c = await fetchCollections({ apiKey: process.env.ROOTTALE_API_KEY! });
217
+ const c = await fetchCollections({
218
+ apiKey: process.env.ROOTTALE_API_KEY!,
219
+ tags: [COLLECTIONS_CACHE_TAG], // 어드민 저장 시 즉시 반영 (아래 revalidate 절)
220
+ });
215
221
  return c.length ? c : DEFAULT;
216
222
  } catch {
217
223
  return DEFAULT; // API 미설정/실패 시 기본값
@@ -345,14 +351,32 @@ return buildPostMetadata(post, {
345
351
  ### revalidate
346
352
 
347
353
  ```ts
354
+ import { revalidatePath, revalidateTag } from "next/cache";
355
+ import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
348
356
  import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
349
- export const POST = createRevalidateRoute({ apiKey, revalidate, collections: COLLECTIONS });
357
+
358
+ export const POST = createRevalidateRoute({
359
+ apiKey,
360
+ // 2번째 인자(type)를 반드시 그대로 넘긴다 — 안 넘기면 레이아웃 전역 무효화가
361
+ // 페이지 1개 무효화로 줄어든다.
362
+ revalidate: (path, type) => revalidatePath(path, type),
363
+ collections: COLLECTIONS,
364
+ // 어드민에서 콘텐츠 유형·메뉴·사업장 정보 등 **설정**을 저장했을 때 그 값을
365
+ // 담은 fetch 캐시를 지운다. `{ expire: 0 }` 이어야 즉시 만료다
366
+ // (revalidation-webhooks.md §1 "설정 저장").
367
+ revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
368
+ themeTag: THEME_CACHE_TAG,
369
+ });
350
370
  ```
351
371
 
352
372
  각 섹션 basePath(+ `archives`면 `/categories`)와 `/feed.xml`·`/sitemap.xml`·`/llms.txt`를
353
373
  자동 무효화합니다. resolver가 실패하면 잘못된 경로를 추측하지 않고 불변 경로만 갱신하며
354
374
  응답에 `warning`을 노출합니다.
355
375
 
376
+ `fetchCollections`로 유형을 동적 로드한다면 그 조회에도
377
+ `tags: [COLLECTIONS_CACHE_TAG]`를 붙이세요 — 어드민에서 유형을 추가·수정했을 때
378
+ 사이트가 옛 목록을 붙들고 있지 않게 됩니다.
379
+
356
380
  ### 동적 OG 이미지
357
381
 
358
382
  ```tsx
@@ -150,4 +150,7 @@ npx -y @roottale/cms-mcp cli media upload --help
150
150
 
151
151
  - 블로그 페이지 구현 → `blog.md`
152
152
  - 발행 즉시 사이트 반영 → `revalidation-webhooks.md`
153
+ - 설정(디자인·메뉴·사업장 정보) 저장 즉시 반영 → `revalidation-webhooks.md` §1
154
+ "설정 저장" + `theme-and-settings.md` (수신 라우트의 `revalidateTag` 주입과
155
+ 조회의 `tags` 를 **둘 다** 해야 합니다)
153
156
  - HTTP 자동화 엔드포인트 → `api-reference.md`
package/docs/menus.md CHANGED
@@ -21,12 +21,15 @@ description: 어드민 "디자인 > 메뉴"에서 관리하는 네비게이션
21
21
  ```tsx
22
22
  // components/site-nav.tsx (Server Component)
23
23
  import Link from "next/link";
24
- import { fetchMenu } from "@roottale/cms-client/server";
24
+ import { MENUS_CACHE_TAG, fetchMenu } from "@roottale/cms-client/server";
25
25
 
26
26
  export async function SiteNav() {
27
27
  const menu = await fetchMenu({
28
28
  apiKey: process.env.ROOTTALE_API_KEY!,
29
29
  slug: "primary",
30
+ // 캐시 이름표 — 어드민에서 메뉴를 저장하면 웹훅 수신 라우트가 이 이름표로
31
+ // 이 조회의 캐시를 지웁니다 (revalidation-webhooks.md §1 "설정 저장").
32
+ tags: [MENUS_CACHE_TAG],
30
33
  });
31
34
 
32
35
  // 메뉴 미설정(null)이면 자체 fallback 네비를 렌더하세요.
@@ -67,7 +70,9 @@ export async function SiteNav() {
67
70
  ```
68
71
 
69
72
  `fetchMenu` 는 미존재/구 서버의 404 를 `null` 로 돌려주므로(fail-soft) 항상
70
- fallback 분기를 두세요. 전체 메뉴 목록이 필요하면 `fetchMenus({ apiKey })`.
73
+ fallback 분기를 두세요. 전체 메뉴 목록이 필요하면
74
+ `fetchMenus({ apiKey, tags: [MENUS_CACHE_TAG] })` — 두 함수가 같은 이름표를
75
+ 씁니다(메뉴를 저장하면 어느 쪽 캐시든 함께 낡기 때문).
71
76
 
72
77
  ## 타입
73
78
 
package/docs/overview.md CHANGED
@@ -34,9 +34,10 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
34
34
  | 기능 | 같은 키 하나로 |
35
35
  |---|---|
36
36
  | 블로그 글 목록/상세 조회 | `fetchPosts` / `fetchPost` |
37
- | 발행 웹훅 서명 검증 | `createRevalidateRoute` (JWKS 공개키 — 별도 secret 보관 불필요) |
37
+ | 발행 웹훅 서명 검증 + 캐시 갱신 | `createRevalidateRoute` (JWKS 공개키 — 별도 secret 보관 불필요). 설정 저장을 즉시 반영하려면 `revalidateTag` 주입 필수 — `revalidation-webhooks.md` §1 |
38
38
  | 상담문의(리드) 접수 | `submitInquiry` — 키가 테넌트를 식별 |
39
39
  | 테마·블로그 표시·분석 태그 설정 조회 | `fetchTheme` / `fetchBlogSettings` / `fetchAnalyticsConfig` |
40
+ | 사업장 정보·메뉴·콘텐츠 유형 조회 | `fetchBusinessProfile` / `fetchMenu`·`fetchMenus` / `fetchCollections` |
40
41
 
41
42
  키는 **서버 전용**입니다. 브라우저로 노출되면 안 됩니다(`NEXT_PUBLIC_*` 금지).
42
43
  `@roottale/cms-client`는 브라우저에서 import 시 의도적으로 throw 합니다.
@@ -18,13 +18,19 @@ description: 글 발행/수정 시 사이트 캐시를 near-real-time으로 갱
18
18
 
19
19
  ```ts
20
20
  // app/api/revalidate/route.ts
21
- import { revalidatePath } from "next/cache";
21
+ import { revalidatePath, revalidateTag } from "next/cache";
22
+ import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
22
23
  import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
23
24
 
24
25
  export const POST = createRevalidateRoute({
25
26
  apiKey: process.env.ROOTTALE_API_KEY!,
26
27
  apiBase: process.env.ROOTTALE_API_BASE,
27
28
  revalidate: revalidatePath,
29
+ // 설정(디자인 토큰·상단 메뉴·사업장 정보·블로그 표시 설정) 저장을 즉시
30
+ // 반영하려면 **반드시 넣으세요**. `{ expire: 0 }` 이어야 즉시 만료입니다.
31
+ // 아래 §설정 저장 참고.
32
+ revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
33
+ themeTag: THEME_CACHE_TAG,
28
34
  // 글 변경 시 함께 갱신할 추가 경로 (기본: /feed.xml, /sitemap.xml, /blog)
29
35
  alsoRevalidate: ["/feed.xml", "/sitemap.xml", "/blog", "/"],
30
36
  });
@@ -36,16 +42,74 @@ export function GET(): Response {
36
42
 
37
43
  블로그가 `/blog`가 아닌 경로면 `blogBasePath: "/insights"` 옵션을 추가하세요.
38
44
 
39
- 카테고리/태그 인덱스 같은 동적 경로가 있다면 `revalidate` 콜백을 확장합니다:
45
+ 카테고리/태그 인덱스 같은 동적 경로가 있다면 `revalidate` 콜백을 확장합니다.
46
+ **2번째 인자(`type`)를 반드시 그대로 넘기세요** — 설정 저장 시 팩토리가
47
+ `revalidate("/", "layout")` 으로 루트 레이아웃 전역 무효화를 요청하는데,
48
+ `(path)` 만 받는 콜백은 그 `"layout"` 을 버려서 정적 페이지가 안 바뀝니다.
40
49
 
41
50
  ```ts
42
- function revalidateBlogPath(path: string): void {
43
- revalidatePath(path);
51
+ function revalidateBlogPath(path: string, type?: "layout" | "page"): void {
52
+ revalidatePath(path, type); // ← type 을 빠뜨리지 마세요
44
53
  if (path === "/blog/categories") revalidatePath("/blog/categories/[category]", "page");
45
54
  if (path === "/blog/tags") revalidatePath("/blog/tags/[tag]", "page");
46
55
  }
47
56
  ```
48
57
 
58
+ ### 설정 저장을 즉시 반영하려면 — 캐시 이름표(`tags`)
59
+
60
+ 글은 **경로**에 담겨 있어서 `revalidatePath`로 지워집니다. 반면 디자인 토큰·
61
+ 상단 메뉴·사업장 정보 같은 **설정**은 경로가 아니라 `fetch` 응답 캐시(Next.js
62
+ Data Cache)에 들어 있어서, `revalidatePath`로는 지워지지 않습니다. 그래서 설정
63
+ 조회에는 **캐시 이름표(`tags`)** 를 붙이고, 웹훅 수신 시 그 이름표를 지웁니다.
64
+
65
+ 두 곳을 함께 해 주세요.
66
+
67
+ 1. **수신 라우트**에 `revalidateTag`를 주입한다 (위 §1 예시)
68
+ 2. **설정 조회**마다 이름표를 붙인다
69
+
70
+ ```ts
71
+ import {
72
+ BUSINESS_CACHE_TAG,
73
+ MENUS_CACHE_TAG,
74
+ THEME_CACHE_TAG,
75
+ fetchBusinessProfile,
76
+ fetchMenu,
77
+ fetchTheme,
78
+ } from "@roottale/cms-client/server";
79
+
80
+ const theme = await fetchTheme({ apiKey, tags: [THEME_CACHE_TAG] });
81
+ const business = await fetchBusinessProfile({ apiKey, tags: [BUSINESS_CACHE_TAG] });
82
+ const menu = await fetchMenu({ apiKey, slug: "primary", tags: [MENUS_CACHE_TAG] });
83
+ ```
84
+
85
+ 이름표 상수는 `@roottale/cms-client/server`가 내보냅니다 — 문자열을 직접 쓰지
86
+ 말고 상수를 쓰세요(양쪽 이름이 어긋나면 조용히 안 지워집니다).
87
+
88
+ > **두 번째 인자는 `{ expire: 0 }` 이어야 합니다.** Next는 `expire`가 0이 아닌
89
+ > 프로파일을 stale-while-revalidate 업데이트로 취급해서, 이름표를 지워도 다음
90
+ > 요청이 여전히 옛 값을 받습니다 — `"max"` 프로파일의 `expire`는 **1년**이라
91
+ > "즉시 반영"이 되지 않습니다. `updateTag`는 즉시 만료이지만 **서버 액션
92
+ > 전용**이라 웹훅 라우트(Route Handler)에서 호출하면 오류가 납니다. 이 경로에서
93
+ > 쓸 수 있는 것은 `revalidateTag(tag, { expire: 0 })` 하나뿐입니다.
94
+
95
+ | 조회 함수 | 이름표 상수 | 어드민 화면 |
96
+ |---|---|---|
97
+ | `fetchTheme` (`theme.siteNav` 포함) | `THEME_CACHE_TAG` | 디자인 · 설정 > 사이트 > 상단 메뉴 |
98
+ | `fetchBusinessProfile` | `BUSINESS_CACHE_TAG` | 운영 > 비즈니스 프로필 |
99
+ | `fetchMenu` / `fetchMenus` | `MENUS_CACHE_TAG` | 디자인 > 메뉴 |
100
+ | `fetchBlogSettings` | `BLOG_SETTINGS_CACHE_TAG` | 설정 > 블로그 |
101
+ | `fetchCollections` | `COLLECTIONS_CACHE_TAG` | 설정 > 콘텐츠 유형 |
102
+
103
+ 다섯 개를 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 있습니다. 수신 라우트는
104
+ 설정 저장 신호(`theme.updated`) 한 번에 **이 목록 전체**를 지웁니다 — 무엇이
105
+ 바뀌었는지 웹훅 본문에 없기 때문이며, 쓰지 않는 이름표를 지우는 것은 아무 일도
106
+ 일어나지 않으므로 해가 없습니다.
107
+
108
+ > **`revalidateTag`를 주입하지 않으면** 설정 변경이 그 조회의 `revalidate`
109
+ > 초만큼(설정에 따라 30분까지) 늦게 반영됩니다. 라우트는 정상 200을 돌려주고
110
+ > 어드민에도 오류가 안 뜨기 때문에, 이 누락은 "가끔 늦게 반영된다"로만 보입니다.
111
+ > 응답 본문의 `revalidated.requestedTags`가 빈 배열이면 주입이 빠진 것입니다.
112
+
49
113
  ## 2. 어드민에 웹훅 URL 등록
50
114
 
51
115
  1. 어드민 **내 사이트 > (사이트 선택)** 페이지로 이동
@@ -56,15 +120,51 @@ function revalidateBlogPath(path: string): void {
56
120
 
57
121
  URL을 비우고 저장하면 웹훅이 비활성화됩니다.
58
122
 
123
+ ### 주소는 "실제로 서비스되는 주소" 여야 합니다
124
+
125
+ 웹훅은 **리다이렉트를 따라가지 않습니다**(서명이 본문에 묶여 있어 따라가면
126
+ 안 됩니다). 그래서 다른 주소로 넘어가는 주소를 등록하면 3xx 응답을 받고
127
+ 그대로 실패합니다 — 화면에는 아무 오류가 안 뜨고, 발행한 글만 조용히 늦게
128
+ 반영됩니다.
129
+
130
+ - 사이트 도메인을 바꿨다면 **이 URL도 같이 바꾸세요**. 옛 주소가 새 주소로
131
+ 넘어가도록 해 뒀더라도 웹훅에는 소용이 없습니다.
132
+ - `example.com` 이 `www.example.com` 으로 넘어가는 구성이라면 **넘어간 뒤의
133
+ 주소**(`https://www.example.com/api/revalidate`)를 등록하세요.
134
+
135
+ ### Cloudflare를 쓴다면 SSL 모드가 Flexible이면 안 됩니다
136
+
137
+ 도메인이 Cloudflare에 있고 SSL/TLS 모드가 **Flexible**이면, Cloudflare가
138
+ 원본 서버에 평문 HTTP로 붙습니다. Vercel·Netlify 등 대부분의 호스팅은 HTTP
139
+ 요청을 HTTPS로 되돌리므로 **자기 자신으로 무한히 넘어가는 308**이 되고,
140
+ 웹훅은 매번 실패합니다.
141
+
142
+ 브라우저로는 멀쩡해 보일 수 있습니다(DNS가 Cloudflare를 거치지 않는 설정이면
143
+ 사람이 접속할 때는 이 경로를 안 타기 때문입니다). SSL/TLS 모드를 **Full
144
+ (strict)** 로 두세요 — 보안상으로도 그쪽이 맞습니다.
145
+
146
+ 확인 방법: 아래 응답이 `308`이고 이동 주소가 **요청한 주소와 같으면** 이
147
+ 경우입니다.
148
+
149
+ ```bash
150
+ curl -sI -X POST https://<사이트 도메인>/api/revalidate
151
+ ```
152
+
59
153
  ## 웹훅 발송 트리거
60
154
 
61
155
  - 게시물 생성/발행/수정/삭제/발행 취소
62
156
  - 게시물의 카테고리·태그 변경 (블로그 카드의 카테고리 라벨이 바뀌므로)
63
157
  - 분류(taxonomy) 용어 생성/수정/순서 변경/삭제
64
- - 블로그 표시 설정 변경 (TOC, 작성자/발행일, 작성자 카드)
65
- - 디자인 토큰 변경
158
+ - 디자인 토큰 변경 → `theme.updated`
159
+ - 상단 메뉴·헤더/푸터 메뉴·공지 배너·상담바·문의 게시판 설정 변경 → `theme.updated`
160
+ - 사업장 정보(비즈니스 프로필) 변경 → `theme.updated`
161
+ - 블로그 표시 설정 변경 (TOC, 작성자/발행일, 작성자 카드) → `theme.updated`
162
+ - 콘텐츠 유형(스트림) 변경 → `theme.updated`
66
163
  - 수동 revalidation API 호출
67
164
 
165
+ 설정 저장은 **전부 `theme.updated` 하나**로 옵니다 — 무엇이 바뀌었는지는 본문에
166
+ 없습니다. 그래서 수신 측은 이 이벤트에서 설정 이름표를 통째로 지웁니다.
167
+
68
168
  ## 캐시 무효화 규칙
69
169
 
70
170
  수신 측은 다음을 보장해야 합니다 (`createRevalidateRoute`가 기본 처리):
@@ -73,18 +173,51 @@ URL을 비우고 저장하면 웹훅이 비활성화됩니다.
73
173
  바뀌어도 카드 메타(카테고리 라벨 등)가 바뀔 수 있음
74
174
  - 상세 페이지는 현재 slug + payload의 `paths` 힌트 경로 모두 revalidate
75
175
  - 홈에 최신 글 섹션이 있으면 `alsoRevalidate`에 `/` 포함
176
+ - `theme.updated`(설정 저장)는 **캐시 이름표 전체 + `revalidatePath("/",
177
+ "layout")` + 본문 `paths`** 로 처리 — 설정은 모든 페이지에 깔리므로 경로만
178
+ 열거하면 정적 페이지가 빠지고, 반대로 `/sitemap.xml`·`/feed.xml` 같은 route
179
+ handler 는 레이아웃 무효화에 딸려 온다고 보장할 수 없어 `paths` 도 함께 씁니다
76
180
 
77
181
  분류 변경은 `taxonomy.updated` 이벤트 **한 번**으로 전달됩니다. payload의
78
182
  `paths`에는 영향을 받는 글 상세 경로와 카테고리 모음 경로가 중복 없이 들어갑니다.
79
183
  연결된 글마다 웹훅을 따로 보내지 않으므로, 카테고리 하나를 바꿔도 수십 번 재검증되는
80
184
  문제가 없습니다.
81
185
 
186
+ ## 글 웹훅의 `paths` — 콘텐츠 유형 기준
187
+
188
+ 글 관련 이벤트(`post.published`·`post.updated`·`post.deleted`)의 `paths`는 **그 글이
189
+ 속한 콘텐츠 유형(스트림)의 주소**를 담습니다. 공지 유형(`/notice`) 글이면 `/notice`와
190
+ `/notice/{slug}`가 오고, `/blog`는 오지 않습니다.
191
+
192
+ 한 글의 `paths`에 들어가는 것:
193
+
194
+ - 유형의 목록 주소 (예: `/notice`)
195
+ - 유형의 글 상세 주소 (예: `/notice/my-post`). 한글 주소는 인코딩한 값과 원문을
196
+ 함께 보냅니다
197
+ - 유형의 **카테고리 모음 켬** 설정이 켜져 있을 때만 `{유형 주소}/categories`와
198
+ `{유형 주소}/categories/{카테고리}`
199
+ - **유형을 옮긴 글**은 옮기기 전·후 주소가 함께 옵니다 — 옮기기 전 목록에서도
200
+ 글이 빠져야 하기 때문입니다
201
+
202
+ 주소가 안 나오는 경우도 있습니다.
203
+
204
+ - **주소 없는 유형**(페이지 안에서 불러 쓰는 강사·후기 같은 콘텐츠)이나 **목록
205
+ 전용 유형**(글마다 상세 주소가 없는 유형)은 상세 주소가 없으므로 그만큼 빠집니다.
206
+ 유형 자체에 주소가 없으면 `paths`가 비어 올 수 있습니다
207
+ - 글에 유형이 없거나(미분류) 사이트가 아직 콘텐츠 유형을 안 쓰면 예전처럼
208
+ `/blog`·`/blog/{slug}`·`/blog/categories*`로 옵니다
209
+
210
+ `paths`가 비거나 유형 주소만 와도 걱정할 필요는 없습니다. 위 §캐시 무효화 규칙대로
211
+ 수신 측은 모든 서명 이벤트에서 블로그 목록과 피드·사이트맵을 함께 갱신하고,
212
+ `collections`를 넘긴 사이트는 각 유형의 목록 주소도 자동으로 갱신합니다.
213
+
82
214
  ## 저수준 검증 — verifyRootTaleWebhook
83
215
 
84
216
  `createRevalidateRoute`를 못 쓰는 환경(다른 프레임워크 등)은
85
217
  `@roottale/cms-client/webhook`으로 직접 검증합니다:
86
218
 
87
219
  ```ts
220
+ import { SETTINGS_CACHE_TAGS } from "@roottale/cms-client/server";
88
221
  import { verifyRootTaleWebhook } from "@roottale/cms-client/webhook";
89
222
 
90
223
  export async function POST(request: Request) {
@@ -101,7 +234,22 @@ export async function POST(request: Request) {
101
234
  const paths = Array.isArray(payload.paths)
102
235
  ? payload.paths.filter((path): path is string => typeof path === "string")
103
236
  : [];
104
- // result.event: "post.published" | "post.updated" | "post.deleted" | "taxonomy.updated"
237
+ // result.event:
238
+ // "post.published" | "post.updated" | "post.deleted" | "taxonomy.updated"
239
+ // | "theme.updated" ← 설정 저장(디자인 토큰·상단 메뉴·사업장 정보 등)
240
+ //
241
+ // "theme.updated"는 캐시 이름표 + 레이아웃 무효화가 본체이고, 본문 paths 는
242
+ // 그 위에 더합니다 — 콘텐츠 유형 저장은 /sitemap.xml·/feed.xml·/llms.txt 를
243
+ // 보내는데 이건 route handler 라 레이아웃 무효화로 덮인다고 볼 수 없습니다.
244
+ // (위 §1 "설정 저장" 참고)
245
+ if (result.event === "theme.updated") {
246
+ // { expire: 0 } = 즉시 만료. "max" 등 다른 프로파일은 SWR 업데이트라 다음
247
+ // 요청이 옛 값을 받습니다. updateTag 는 서버 액션 전용이라 여기서 throw.
248
+ for (const tag of SETTINGS_CACHE_TAGS) revalidateTag(tag, { expire: 0 });
249
+ revalidatePath("/", "layout"); // 정적 페이지까지 반영
250
+ for (const path of paths) revalidatePath(path); // 콘텐츠 유형 저장의 sitemap·feed
251
+ return Response.json({ ok: true });
252
+ }
105
253
  for (const path of paths) revalidatePath(path);
106
254
  return Response.json({ ok: true });
107
255
  }
@@ -133,6 +281,8 @@ Content-Type: application/json
133
281
  | 증상 | 확인 |
134
282
  |---|---|
135
283
  | 발행해도 사이트 미반영 | 어드민의 자동 갱신 URL·활성화 체크, 배포 도메인 일치 여부 |
284
+ | 전송 기록이 `308`·`301` | 등록한 주소가 다른 주소로 넘어가고 있습니다. 넘어간 뒤의 주소를 등록하세요. 이동 주소가 요청 주소와 같으면 Cloudflare SSL 모드가 Flexible입니다(위 §2 참고) |
136
285
  | 401 `invalid_signature` | `ROOTTALE_API_KEY`가 해당 사이트 스코프 키인지 |
137
286
  | 401 `timestamp_out_of_window` | 서버 시계 동기화 (NTP) |
138
287
  | 일부 페이지만 갱신 | `alsoRevalidate`·동적 경로 콜백 누락 |
288
+ | 글은 즉시인데 **설정(전화·주소·메뉴·디자인)만 늦게** 반영 | `revalidateTag` 주입과 조회의 `tags` 누락 (§1 "설정 저장") — 응답의 `revalidated.requestedTags`가 비어 있으면 주입이 빠진 것 |
@@ -8,18 +8,93 @@ description: 어드민에서 관리하는 디자인 토큰, 블로그 표시 옵
8
8
  어드민에서 설정한 값을 공개 API로 조회해 사이트에 반영합니다. 모두
9
9
  `@roottale/cms-client/server`에서 제공하며 같은 API 키를 사용합니다.
10
10
 
11
+ ## 저장 즉시 반영 — 캐시 이름표(`tags`)
12
+
13
+ 이 문서의 모든 조회 함수는 `tags?: string[]` 옵션을 받습니다. **설정 조회에는
14
+ 이름표를 붙이세요.** 붙이지 않으면 어드민에서 값을 고쳐도 그 조회의
15
+ `revalidate` 초만큼(설정에 따라 30분까지) 옛 값이 사이트에 남습니다 —
16
+ `revalidatePath`는 경로 캐시만 지우고 `fetch` 응답 캐시는 못 지웁니다.
17
+
18
+ | 조회 함수 | 이름표 상수 |
19
+ |---|---|
20
+ | `fetchTheme` (`theme.siteNav` 포함) | `THEME_CACHE_TAG` |
21
+ | `fetchBusinessProfile` | `BUSINESS_CACHE_TAG` |
22
+ | `fetchMenu` / `fetchMenus` | `MENUS_CACHE_TAG` |
23
+ | `fetchBlogSettings` | `BLOG_SETTINGS_CACHE_TAG` |
24
+ | `fetchCollections` | `COLLECTIONS_CACHE_TAG` |
25
+
26
+ 다섯 개를 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 내보냅니다. 이름표를 지우는
27
+ 쪽(웹훅 수신 라우트) 배선은 `revalidation-webhooks.md` §1 "설정 저장"을
28
+ 따르세요 — **양쪽을 다 해야** 즉시 반영이 됩니다.
29
+
11
30
  ## 디자인 토큰 — fetchTheme
12
31
 
13
32
  ```ts
14
- import { fetchTheme } from "@roottale/cms-client/server";
33
+ import { THEME_CACHE_TAG, fetchTheme } from "@roottale/cms-client/server";
15
34
 
16
- const theme = await fetchTheme({ apiKey: process.env.ROOTTALE_API_KEY! });
35
+ const theme = await fetchTheme({
36
+ apiKey: process.env.ROOTTALE_API_KEY!,
37
+ tags: [THEME_CACHE_TAG],
38
+ });
17
39
  // theme.colors / theme.fonts / theme.radius — 어드민에서 설정한 토큰만 포함
18
40
  ```
19
41
 
20
42
  토큰을 CSS 변수로 매핑해 렌더러 스타일과 사이트 스타일을 일치시킬 수
21
43
  있습니다. 토큰 변경 시에도 발행 웹훅이 발송되어 캐시가 갱신됩니다.
22
44
 
45
+ ## 상단 메뉴 — theme.siteNav
46
+
47
+ 같은 `fetchTheme` 응답에 어드민 **설정 > 사이트 > 상단 메뉴**에서 저장한 GNB
48
+ 구조가 함께 담깁니다. 별도 호출이 없고 테마와 같은 캐시 태그로 무효화됩니다.
49
+
50
+ ```ts
51
+ const theme = await fetchTheme({
52
+ apiKey: process.env.ROOTTALE_API_KEY!,
53
+ tags: [THEME_CACHE_TAG],
54
+ });
55
+
56
+ // 미설정이면 undefined — 사이트가 자체 기본 메뉴로 폴백합니다.
57
+ const navGroups = theme.siteNav?.navGroups ?? FALLBACK_NAV;
58
+ ```
59
+
60
+ ```jsonc
61
+ {
62
+ "navGroups": [
63
+ { "label": "오시는 길", "href": "/visit" },
64
+ {
65
+ "label": "진료과목",
66
+ "columns": [
67
+ {
68
+ "title": "척추",
69
+ "links": [
70
+ { "label": "허리 통증", "href": "/spine/back-pain", "description": "증상과 진료 흐름" }
71
+ ]
72
+ }
73
+ ]
74
+ }
75
+ ],
76
+ "cta": { "label": "예약", "href": "/reserve" },
77
+ "utilityLinks": [{ "label": "블로그", "href": "/blog" }]
78
+ }
79
+ ```
80
+
81
+ 각 그룹은 `href`(단일 링크) 또는 `columns`(드롭다운) 중 **최소 하나**를 가져야
82
+ 합니다. 모든 `href` 는 `#anchor` · `/path` · `http(s)://` 형식만 허용됩니다.
83
+
84
+ 개수 상한:
85
+
86
+ | 항목 | 최대 |
87
+ |---|---|
88
+ | `navGroups` | 7 |
89
+ | 그룹당 `columns` | 3 |
90
+ | 열당 `links` | 6 |
91
+ | `utilityLinks` | 4 |
92
+
93
+ > **주의**: 상한을 넘거나 형식이 어긋나면 개별 항목만 잘라내는 것이 아니라
94
+ > **`siteNav` 전체가 응답에서 빠집니다**(`undefined`). 메뉴가 통째로 사라지지
95
+ > 않도록 사이트에는 항상 폴백 메뉴를 두세요. 어드민 폼은 위 상한까지만 입력칸을
96
+ > 제공하므로 어드민으로 저장한 값은 이 조건을 이미 만족합니다.
97
+
23
98
  ## 블로그 표시 설정 — fetchBlogSettings
24
99
 
25
100
  어드민의 블로그 표시 옵션(TOC 노출, 작성자/발행일 표시, 작성자 카드, 저자
@@ -27,6 +102,7 @@ const theme = await fetchTheme({ apiKey: process.env.ROOTTALE_API_KEY! });
27
102
 
28
103
  ```ts
29
104
  import {
105
+ BLOG_SETTINGS_CACHE_TAG,
30
106
  fetchBlogSettings,
31
107
  resolvePostDisplay,
32
108
  DEFAULT_BLOG_SETTINGS,
@@ -34,6 +110,7 @@ import {
34
110
 
35
111
  const settings = await fetchBlogSettings({
36
112
  apiKey: process.env.ROOTTALE_API_KEY!,
113
+ tags: [BLOG_SETTINGS_CACHE_TAG],
37
114
  });
38
115
  // showTableOfContents, showAuthor, showDate, showAuthorCard,
39
116
  // tocTitle, authorProfileName / Bio / ImageUrl 등
@@ -53,12 +130,14 @@ const display = resolvePostDisplay(settings, post);
53
130
 
54
131
  ```ts
55
132
  import {
133
+ BUSINESS_CACHE_TAG,
56
134
  fetchBusinessProfile,
57
135
  localBusinessSchema,
58
136
  } from "@roottale/cms-client/server";
59
137
 
60
138
  const business = await fetchBusinessProfile({
61
139
  apiKey: process.env.ROOTTALE_API_KEY!,
140
+ tags: [BUSINESS_CACHE_TAG],
62
141
  });
63
142
  // 미설정이면 null. 설정돼 있으면 name, alternateName, businessType, telephone,
64
143
  // faxNumber, address, geo, openingHours, priceRange, areaServed, services,
@@ -1,10 +1,14 @@
1
1
  // RootTale 발행 웹훅 수신 — ES256 서명 검증 후 Next.js 캐시 revalidate.
2
2
  // 어드민 "내 사이트 > 배포"에 https://<도메인>/api/revalidate 로 등록한다.
3
- import { revalidatePath } from "next/cache";
3
+ import { revalidatePath, revalidateTag } from "next/cache";
4
+ import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
4
5
  import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
5
6
 
6
- function revalidateBlogPath(path: string): void {
7
- revalidatePath(path);
7
+ // 2번째 인자(type)를 반드시 그대로 넘긴다 — 팩토리가 설정 저장 시
8
+ // `revalidate("/", "layout")` 을 부르는데, 콜백이 path 만 받으면 "layout" 이
9
+ // 사라져 루트 레이아웃 전역 무효화가 페이지 1개 무효화로 줄어든다.
10
+ function revalidateBlogPath(path: string, type?: "layout" | "page"): void {
11
+ revalidatePath(path, type);
8
12
  if (path === "/blog/categories") {
9
13
  revalidatePath("/blog/categories/[category]", "page");
10
14
  }
@@ -14,6 +18,17 @@ export const POST = createRevalidateRoute({
14
18
  apiKey: process.env.ROOTTALE_API_KEY!,
15
19
  apiBase: process.env.ROOTTALE_API_BASE,
16
20
  revalidate: revalidateBlogPath,
21
+ // 설정(디자인 토큰·상단 메뉴·사업장 정보·블로그 표시 설정) 저장 시 그 값을
22
+ // 담고 있는 fetch 캐시를 지운다. **이 주입이 없으면** revalidatePath 만으로는
23
+ // fetch 캐시(Data Cache)가 안 지워져서 설정 변경이 최대 `revalidate` 초만큼
24
+ // 늦게 반영된다. 어떤 fetch 에 어떤 이름표를 붙이는지는 theme-and-settings.md.
25
+ //
26
+ // `{ expire: 0 }` = 즉시 만료. 다른 프로파일("max" 등)은 expire 가 0 이 아니라
27
+ // Next 가 stale-while-revalidate 업데이트로 취급해 다음 요청이 여전히 옛 값을
28
+ // 받는다("max" 의 expire 는 1년이다). `updateTag` 는 Server Action 전용이라
29
+ // route handler 에서 throw 하므로 쓰지 않는다.
30
+ revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
31
+ themeTag: THEME_CACHE_TAG,
17
32
  // 사이트맵 인덱스 + 분리된 하위 사이트맵(static/blog/categories)을 모두 무효화.
18
33
  // 홈에 최신 글 섹션이 있으면 "/" 포함 — 글 변경 시 홈도 함께 갱신.
19
34
  alsoRevalidate: [
@@ -2,6 +2,7 @@
2
2
  import "@roottale/cms-renderer-next/styles";
3
3
 
4
4
  import {
5
+ BUSINESS_CACHE_TAG,
5
6
  fetchBusinessProfile,
6
7
  localBusinessSchema,
7
8
  } from "@roottale/cms-client/server";
@@ -20,8 +21,13 @@ export default async function RootLayout({
20
21
  }) {
21
22
  // 로컬 SEO — 어드민(운영 > 비즈니스 프로필)에서 사업장 정보를 저장하면
22
23
  // LocalBusiness JSON-LD 를 자동 렌더. 미설정/실패 시 null → 렌더 건너뜀 (seo.md).
24
+ //
25
+ // `tags` 는 캐시 이름표다. 이걸 붙여야 어드민에서 전화·주소를 고친 순간
26
+ // revalidate 라우트가 이 fetch 캐시를 지울 수 있다(안 붙이면 최대 `revalidate`
27
+ // 초만큼 옛 값이 남는다).
23
28
  const business = await fetchBusinessProfile({
24
29
  apiKey: process.env.ROOTTALE_API_KEY!,
30
+ tags: [BUSINESS_CACHE_TAG],
25
31
  }).catch(() => null);
26
32
 
27
33
  return (
@@ -2,13 +2,16 @@
2
2
  // 메뉴 미설정(null)이면 fallback 네비 — 항상 fallback 분기를 두세요.
3
3
  import Link from "next/link";
4
4
 
5
- import { fetchMenu } from "@roottale/cms-client/server";
5
+ import { MENUS_CACHE_TAG, fetchMenu } from "@roottale/cms-client/server";
6
6
 
7
7
  export async function SiteNav() {
8
+ // `tags` = 캐시 이름표. 어드민에서 메뉴를 저장하면 revalidate 라우트가 이
9
+ // 이름표로 이 fetch 캐시를 지운다(안 붙이면 옛 메뉴가 한동안 남는다).
8
10
  const menu = await fetchMenu({
9
11
  apiKey: process.env.ROOTTALE_API_KEY!,
10
12
  baseUrl: process.env.ROOTTALE_API_BASE,
11
13
  slug: "primary",
14
+ tags: [MENUS_CACHE_TAG],
12
15
  }).catch(() => null);
13
16
 
14
17
  if (!menu) {
@@ -1,6 +1,7 @@
1
1
  // RootTale CMS 블로그 데이터 레이어 — 서버 전용.
2
2
  // 사이트 UI에 맞는 메타 형태로 변환하는 wrapper. API 키는 env에서만 읽는다.
3
3
  import {
4
+ BLOG_SETTINGS_CACHE_TAG,
4
5
  fetchBlogSettings,
5
6
  fetchPost,
6
7
  type CmsPostContent,
@@ -107,7 +108,13 @@ export async function getCategoryPromotion(
107
108
  categorySlug: string,
108
109
  ): Promise<ArchivePromotion> {
109
110
  try {
110
- const settings = await fetchBlogSettings({ apiKey: getApiKey(), baseUrl });
111
+ // `tags` = 캐시 이름표. 어드민에서 블로그 표시 설정을 저장하면 revalidate
112
+ // 라우트가 이 이름표로 이 fetch 캐시를 지운다.
113
+ const settings = await fetchBlogSettings({
114
+ apiKey: getApiKey(),
115
+ baseUrl,
116
+ tags: [BLOG_SETTINGS_CACHE_TAG],
117
+ });
111
118
  return resolveCategoryPromotion(
112
119
  settings.sitemap?.promotedCategorySlugs,
113
120
  categorySlug,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "type": "module",
5
5
  "description": "RootTale CMS MCP server and CLI for post publishing, media uploads, integration docs, and public API access.",
6
6
  "bin": {