@roottale/cms-mcp 0.55.0 → 0.57.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,61 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.57.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e222471: 본문 예약 내부 링크(`[[internal:키|문구]]`)를 공용 렌더러가 해석한다.
8
+ - `@roottale/cms-core`: 토큰 정규식·탐색(`findInternalContentLinkTokens`), 경로→키
9
+ (`internalContentKeyFromPath`), 발행 글 경로 색인(`internalContentPathIndex`, 옛 slug 포함),
10
+ 텍스트·정화된 HTML 안의 토큰을 링크로 바꾸는 `renderInternalContentLinksInText/InHtml`.
11
+ - `@roottale/cms-renderer-next`: `RenderTiptap`·`RootTaleBlogPost` 에 `internalLinks`
12
+ (Map·함수) 옵션. `RootTaleBlogPost` 는 기본 `"auto"` — 본문에 토큰이 있을 때만
13
+ `fetchInternalContentPathIndex` 로 발행 글 경로 색인을 만들어 링크로 렌더한다.
14
+ 대상이 없는 토큰은 문구만 남기고 `data-rt-internal-link-pending="키"` 를 단다.
15
+ `<code>`·`<pre>`·기존 링크 안은 건드리지 않는다.
16
+ - 문서: FRONT 가 토큰을 직접 해석하지 않아도 되는 경로를 안내한다.
17
+
18
+ - 92d0ad4: 공통 블록(Site Patterns) — 여러 글의 같은 자리(글 하단)에 붙는 재사용 본문.
19
+ - `@roottale/cms-client`: 글 응답 `patternSlots`(자리 key → 블록 key | null, 구 서버는 빈
20
+ 객체), `fetchSitePatterns`(발행 블록 목록, `GET /v1/cms/public/patterns`),
21
+ `selectSitePatternForSlot`, `SITE_PATTERNS_CACHE_TAG`(`SETTINGS_CACHE_TAGS` 에 포함 —
22
+ `theme.updated` 로 함께 갱신), `POST_FOOTER_PATTERN_SLOT`.
23
+ - `@roottale/cms-renderer-next`: `RootTaleBlogPost` 가 `patternSlots.post_footer` 블록을 본문
24
+ 바로 아래에 자동으로 그린다(목록 요청 실패는 블록만 생략). 자체 글 화면용
25
+ `RootTalePostPattern` 서버 컴포넌트와 `.rt-cms-post-pattern` 스타일 추가.
26
+ - `@roottale/cms-core`: 자리 선언·배치 규칙 정규화·`resolvePatternSlots` (플랫폼 서버·어드민과
27
+ 공유하는 순수 규칙).
28
+ - 문서: `theme-and-settings.md` "공통 블록", `api-reference.md` `GET /patterns`·`pattern_slots`.
29
+
30
+ ## 0.56.0
31
+
32
+ ### Patch Changes
33
+
34
+ - 18aaa9e: ADR-0105 Amendment 1 — 공개 글 응답에 저장된 정규 공개 경로 `path`(cms-client
35
+ `post.path`)를 additive 로 더합니다. FRONT 는 주소를 계산하지 않고 이 값을 링크·사이트맵·
36
+ 내부 링크 키에 그대로 씁니다.
37
+ - 422c388: ADR-0105 Amendment 1 — FRONT 가 글 주소를 다시 계산하지 않습니다. `RootTaleBlogList`·
38
+ `RootTaleBlogPost`(연관 글·빵부스러기)의 기본 글 링크와 `buildPostMetadata` 의 canonical 이
39
+ 플랫폼이 저장한 정규 공개 경로 `post.path` 를 먼저 읽고, 없을 때만 종전처럼 `collections`
40
+ 규칙 → `/blog/{slug}` 로 폴백합니다. `storedPostPath(post)` 헬퍼를 `routes` 에서 내보냅니다.
41
+ 문서(seo·collections·blog)와 Next.js 예제가 같은 규칙으로 갱신됐습니다.
42
+ - d303835: 공개 블로그 설정 응답 `site_profile` 에 메인 홈 전용 검색 제목·설명
43
+ `home_title`·`home_description` 을 additive 로 더합니다(어드민 "검색·공유 표시 >
44
+ 메인 홈 검색 노출"). `@roottale/cms-client` 는 `siteProfile.homeTitle` /
45
+ `homeDescription` 으로 노출하며, null 이면 사이트 이름·사이트 설명으로 폴백하는
46
+ 것이 계약입니다. cms-mcp 문서 `seo.md` 에 홈 `generateMetadata` 예시를 더했습니다.
47
+ - b13696c: ADR-0105 Amendment 1 — `GET /v1/cms/public/redirects` 가 글 공개 경로 이력(post_path_history)
48
+ 에서 옛 경로 → 현재 경로 301 을 자동으로 더합니다(`id` 접두사 `path-history:`). 운영자
49
+ 규칙이 같은 출발 경로면 운영자 규칙이 이깁니다.
50
+ - 86c5712: RootTale 표준 블로그 주소 `/blog/{category}/{slug}` 지원 — `RootTaleSiteConfig.categoryPath`
51
+ (`"direct"` 면 사이트맵 카테고리 허브가 `/blog/{category}`), collection 모드 사이트맵이 각 컬렉션의
52
+ `categoryPath` 를 따르며, `makeCategoryHref(basePath, categoryPath)` 를 `server`·`routes` 에서
53
+ 내보냅니다. 문서에 표준 규칙과 라우트 배치를 적었습니다.
54
+ - bd450bc: ADR-0105 — 콘텐츠 모델 `detail`·`category_tree` presentation 에 목록·피드 규칙 선택 필드
55
+ (`archives`, `feed`, `categoryPath`, `layout`)를 additive 로 더합니다. 예전 `collections`
56
+ 설정에만 있던 규칙을 모델이 소유하며, 공개 `content-models` 응답도 값이 있을 때 함께
57
+ 내보냅니다.
58
+
3
59
  ## 0.55.0
4
60
 
5
61
  ### Patch Changes
package/dist/index.js CHANGED
@@ -1559,7 +1559,7 @@ function registerTools(server) {
1559
1559
  }
1560
1560
 
1561
1561
  // src/server.ts
1562
- var VERSION = true ? "0.55.0" : "dev";
1562
+ var VERSION = true ? "0.57.0" : "dev";
1563
1563
  var SERVER_INSTRUCTIONS = `
1564
1564
  roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uACE0
1565
1565
  \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.
@@ -284,6 +284,7 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
284
284
  "author_profile_id": "site-author-id",
285
285
  "author_name": "공통 작성자", "author_slug": "writer",
286
286
  "terms": [{ "taxonomy": "category", "name": "…", "slug": "…" }],
287
+ "pattern_slots": { "post_footer": "clinic-guide" },
287
288
  "published_at": "…" } ],
288
289
  "has_more": false,
289
290
  "next_cursor": null
@@ -294,6 +295,11 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
294
295
  > 미설정이면 `null`. 카테고리(`terms`)는 섹션 안의 주제로 아카이브에만 쓰입니다 →
295
296
  > [콘텐츠 유형 (Collections)](./collections.md).
296
297
 
298
+ `pattern_slots` = 공통 블록 자리별 블록 key(자리 key → `GET /patterns` 의 `key` 또는
299
+ `null`). 어드민 배치 규칙을 서버가 이 글의 `collection_key`에 맞춰 계산한 결과이며,
300
+ 선언된 자리(현재 `post_footer` = 글 하단)는 항상 키로 존재합니다. 글 상세·미리보기
301
+ 응답에도 같은 필드가 붙습니다 → 아래 `GET /v1/cms/public/patterns` 참고.
302
+
297
303
  `author_profile_id`는 사이트 공통 공개 작성자 ID입니다. 이름·사진·소개·작가 주소는
298
304
  같은 원장의 `author_name`·`author_image_url`·`author_bio`·`author_slug`로
299
305
  제공됩니다. 기존 `author_id`는 하위 호환용이므로 새 연동에서는 공개 작성자
@@ -415,8 +421,9 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
415
421
  "show_author_card": true, "toc_title": null, "toc_position": "inline",
416
422
  "author_profile_name": null, "author_profile_bio": null,
417
423
  "author_profile_image_url": null, "author_profile_image_radius": "circle",
418
- "site_profile": { "site_description": null, "logo_url": null,
419
- "favicon_url": null, "default_og_image_url": null },
424
+ "site_profile": { "site_description": null, "home_title": null,
425
+ "home_description": null, "logo_url": null, "favicon_url": null,
426
+ "default_og_image_url": null },
420
427
  "post_cta": { "title": "상담이 필요하신가요?", "description": "첫 상담은 무료입니다.",
421
428
  "button_label": "상담 문의하기", "button_href": "/contact" },
422
429
  "updated_at": null }
@@ -434,6 +441,28 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
434
441
  `generateMetadata` 에서 `post.seo?.ogImage ?? post.featured_media_url ??
435
442
  settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
436
443
  `favicon_url`·`site_description` 은 사이트 `<head>` 에 적용합니다.
444
+ `home_title`·`home_description` 은 **메인 홈(`/`) 전용** 검색 제목·설명(어드민
445
+ "검색·공유 표시 > 메인 홈 검색 노출")으로, `null` 이면 사이트 이름(theme
446
+ `site_name`)·`site_description` 으로 폴백하세요(`seo.md` "메인 홈 메타데이터").
447
+
448
+ ## GET /v1/cms/public/patterns
449
+
450
+ 공통 블록(여러 글의 같은 자리에 붙는 재사용 본문) 중 **발행 상태** 목록. 어느 글의
451
+ 어느 자리에 놓을지는 글 응답의 `pattern_slots`가 이미 말해 주므로, 사이트는
452
+ `pattern_slots[slot]` 값으로 이 목록에서 `key`를 찾아 `body_json`(Tiptap 문서)을 글
453
+ 본문과 같은 렌더러·정화 경로로 그리면 됩니다. 목록이 비었거나 요청이 실패해도 글은
454
+ 정상 열려야 합니다(블록만 생략).
455
+
456
+ ```json
457
+ { "tenant_id": "…", "site_id": "…",
458
+ "patterns": [ { "id": "…", "key": "clinic-guide", "name": "병원 안내",
459
+ "description": "글 하단 공통 안내", "body_json": { "type": "doc", "content": [ … ] },
460
+ "status": "published", "updated_at": "…" } ] }
461
+ ```
462
+
463
+ - 자리 key: `post_footer`(글 하단, 본문 바로 아래). 새 자리는 문서와 함께 추가됩니다.
464
+ - 캐시: `Cache-Control: private, max-age=10`. 어드민에서 블록·배치 규칙을 저장하면
465
+ `theme.updated` 웹훅이 오므로 설정류 캐시와 함께 지우세요(`revalidation-webhooks.md`).
437
466
 
438
467
  ## GET /v1/cms/public/categories
439
468
 
package/docs/blog.md CHANGED
@@ -32,12 +32,18 @@ export default function BlogPage() {
32
32
  baseUrl={process.env.ROOTTALE_API_BASE}
33
33
  limit={20}
34
34
  showCategoryFilter
35
- postHref={(post) => `/blog/${post.slug}`}
36
35
  />
37
36
  );
38
37
  }
39
38
  ```
40
39
 
40
+ > **글 링크는 기본값에 맡기세요.** `postHref` 를 주지 않으면 카드·연관 글·빵부스러기의
41
+ > 글 링크는 플랫폼이 글마다 저장해 내려주는 정규 공개 경로 `post.path`(ADR-0105)를
42
+ > 그대로 쓰고, 없을 때(구 서버·주소 규칙 없는 유형)만 `collections` 규칙 →
43
+ > `/blog/{slug}` 순으로 폴백합니다. `postHref={(post) => "/blog/" + post.slug}` 처럼
44
+ > 하드코딩하면 관리자가 주소 규칙을 바꿔도 사이트가 옛 주소를 계속 그립니다 —
45
+ > 라우트가 정말 고정인 사이트에서만 쓰세요.
46
+
41
47
  > **카테고리 허브 라우트(`/blog/categories/{slug}`)를 구현하세요.**
42
48
  > `showCategoryFilter` 의 카테고리 칩, `RootTaleBlogCategories`, `RootTaleBlogPost`
43
49
  > 의 `breadcrumb` 카테고리 세그먼트가 모두 기본적으로 이 경로를 링크합니다
@@ -74,6 +80,14 @@ export default function BlogPage() {
74
80
 
75
81
  ### 상세 페이지
76
82
 
83
+ > **주소 규칙은 관리자 콘텐츠 모델(`blog`)의 presentation이 정합니다.** RootTale 표준
84
+ > 사이트는 `/blog/{category}/{slug}`(카테고리 허브 `/blog/{category}`)이고, 이 문서의
85
+ > 예시는 평면 `/blog/{slug}` 상세 규칙(`detail`)을 쓰는 사이트 기준입니다. 어느 쪽이든
86
+ > 글 링크·canonical·사이트맵은 공개 API `path`(`post.path`)를 그대로 읽으면 됩니다 —
87
+ > `content-models-and-exposures.md` "목록·피드 규칙도 모델이 소유합니다" 참고. 표준
88
+ > 사이트의 라우트 파일 배치는 카테고리 허브 `app/blog/[category]/page.tsx`(카테고리가
89
+ > 아니면 옛 글 주소로 보고 정본으로 301) + 글 `app/blog/[category]/[slug]/page.tsx` 입니다.
90
+
77
91
  ```tsx
78
92
  // app/blog/[slug]/page.tsx
79
93
  import { fetchPost } from "@roottale/cms-client/server";
@@ -406,6 +420,7 @@ export async function getPost(slug: string) {
406
420
  | `authorName` | 작성자 표시명 |
407
421
  | `authorSlug` | 작가 아카이브 slug(`/blog/author/{slug}`) — 미발급 작가는 `null` |
408
422
  | `metaJson` | 부가 메타 — `metaJson.seo`에 SEO 오버라이드 |
423
+ | `patternSlots` | 공통 블록 자리별 블록 key(`{ post_footer: "clinic-guide" }`) — `RootTaleBlogPost`가 자동 렌더, 자체 화면은 `fetchSitePatterns` + `selectSitePatternForSlot`(`theme-and-settings.md` "공통 블록") |
409
424
 
410
425
  ### 정적 경로 사전 생성 + 메타데이터
411
426
 
@@ -403,6 +403,10 @@ return buildPostMetadata(post, {
403
403
  });
404
404
  ```
405
405
 
406
+ 원본 post 를 넘기면 `post.path`(플랫폼이 저장한 정규 공개 경로, ADR-0105)가 먼저
407
+ canonical 이 되고 `collections` 는 저장 경로가 없는 글의 폴백입니다.
408
+ `RootTaleBlogList`·`RootTaleBlogPost`(연관 글·빵부스러기)의 글 링크도 같은 순서 —
409
+ `post.path` → `collections` 규칙 → `/blog/{slug}` — 로 정해집니다.
406
410
  자세한 동작은 `seo.md` 의 "글 메타데이터 → 공지·블로그 다중 스트림" 참고.
407
411
 
408
412
  ### revalidate
@@ -89,6 +89,55 @@ FAQ·도움말·문서처럼 목록 아래에 여러 단계의 분류 허브가
89
89
  }
90
90
  ```
91
91
 
92
+ ### 목록·피드 규칙도 모델이 소유합니다
93
+
94
+ 글 목록의 카테고리 모음, RSS 피드, 허브 주소 형식처럼 예전 `collections`
95
+ 설정에만 있던 규칙은 이제 `detail`·`category_tree` presentation의 선택 필드로
96
+ 선언합니다. 모두 생략 가능하며 기본값은 컬렉션이 없을 때의 동작과 같습니다.
97
+
98
+ | 필드 | 의미 | 기본값 |
99
+ |---|---|---|
100
+ | `archives` | 카테고리 모음 주소(`{목록}/categories/{slug}` 또는 direct)를 발행·갱신 | `false` |
101
+ | `feed` | RSS 피드에 포함 | `false` |
102
+ | `categoryPath` | 카테고리 허브 주소 형식 — `namespaced` = `{목록}/categories/{slug}`, `direct` = `{목록}/{slug}` | `namespaced` |
103
+ | `layout` | 목록 레이아웃 힌트(FRONT가 해석, 라우팅과 무관) | 없음 |
104
+
105
+ `detail`의 목록 주소는 `detailPath`에서 `/:slug`를 뗀 부모 경로이고,
106
+ `category_tree`는 `basePath`가 목록입니다. 예전 `collections` 응답은 호환을 위해
107
+ 유지되지만 새 사이트는 모델 presentation만 읽으면 됩니다.
108
+
109
+ **RootTale 표준 블로그 주소** — RootTale이 만드는 사이트(스타터·`roottale init` 시드)의
110
+ `blog` 모델은 아래 규칙 하나를 씁니다: 글 `/blog/{category}/{slug}`, 카테고리 허브
111
+ `/blog/{category}`, 목록 `/blog`. 1단계 `category_tree`이고 글마다 카테고리를 정확히
112
+ 하나 고릅니다(`categoryCardinality: "exactly-one"`). 옛 평면 주소 `/blog/{slug}`와
113
+ `/blog/categories/{slug}`는 FRONT가 정본으로 301 합니다.
114
+
115
+ ```json
116
+ {
117
+ "kind": "category_tree",
118
+ "basePath": "/blog",
119
+ "categoryDepth": 1,
120
+ "templateKey": "blog",
121
+ "categoryPath": "direct",
122
+ "categoryCardinality": "exactly-one",
123
+ "archives": true,
124
+ "feed": true
125
+ }
126
+ ```
127
+
128
+ 직접 만드는 사이트가 평면 상세 주소를 유지해도 됩니다 — 그때는 `detail` 규칙을
129
+ 선언하고 FRONT 라우트를 그 규칙에 맞추면 됩니다.
130
+
131
+ ```json
132
+ {
133
+ "kind": "detail",
134
+ "detailPath": "/blog/:slug",
135
+ "templateKey": "blog",
136
+ "archives": true,
137
+ "feed": true
138
+ }
139
+ ```
140
+
92
141
  공개 사이트는 `fetchCategories({ collectionKey: "faq" })`의 `id`와 `parentId`로
93
142
  루트부터 말단까지의 분류 사슬을 만들고, `fetchPosts({ modelKey: "faq" })`의 글마다
94
143
  말단 카테고리를 정확히 하나 연결합니다. 위 예시는 다음 주소를 표현합니다.
@@ -176,9 +225,38 @@ ROOT-ADMIN 편집기 도구 모음의 **내부 링크 삽입** 버튼이 이 표
176
225
  이 글을 연결할 때 쓰는 키가 복사 버튼과 함께 표시됩니다.
177
226
 
178
227
  이 표기는 본문 `body_json`의 일반 텍스트로 저장되며 공개 API도 그대로 내보냅니다.
179
- 해석은 고객 FRONT의 몫입니다 — 텍스트 구간에서 표기를 찾아, 현재 발행 원장의 글
180
- 경로를 같은 규칙으로 키로 바꿔 대조한 뒤 있으면 링크로, 없으면 표시 문구만
181
- 렌더링하세요. `code`·`pre`·이미 링크된 구간과 속성값은 변환하지 않는 것을 권장합니다.
228
+ 편집기 안에서는 칩으로 보여 주며(대상 글이 있으면 제목·발행 상태, 없으면 "아직 없는
229
+ 글"), 저장 형식은 바뀌지 않습니다.
230
+
231
+ **`@roottale/cms-renderer-next` 를 쓰면 따로 구현할 것이 없습니다.** `RootTaleBlogPost`
232
+ 는 본문에 표기가 있을 때만 발행 글 경로 색인을 만들어(`fetchInternalContentPathIndex`,
233
+ 옛 slug 포함) 링크로 렌더합니다. 대상이 없는 표기는 문구만 남기고
234
+ `<span class="rt-internal-link rt-internal-link--pending" data-rt-internal-link-pending="키">`
235
+ 로 표시하므로 배포 QA 에서 찾을 수 있습니다. 링크는 `<a class="rt-internal-link">` 입니다.
236
+ `code`·`pre`·이미 링크된 구간과 속성값은 변환하지 않습니다.
237
+
238
+ ```tsx
239
+ // 기본값 internalLinks="auto" — 필요할 때만 색인을 만든다.
240
+ <RootTaleBlogPost apiKey={apiKey} slugOrId={slug} />
241
+
242
+ // 여러 글을 한 페이지에서 그리는 등 색인을 직접 관리하려면:
243
+ import { fetchInternalContentPathIndex, RenderTiptap } from "@roottale/cms-renderer-next/server";
244
+ const internalLinks = await fetchInternalContentPathIndex({ apiKey, revalidate: 300 });
245
+ <RenderTiptap doc={post.bodyJson} internalLinks={internalLinks} />
246
+
247
+ // 끄려면 internalLinks={false} — 표기가 텍스트 그대로 나옵니다.
248
+ ```
249
+
250
+ 렌더러를 쓰지 않는 FRONT 는 직접 해석합니다 — 텍스트 구간에서 표기를 찾아, 현재 발행
251
+ 원장의 글 경로를 같은 규칙으로 키로 바꿔 대조한 뒤 있으면 링크로, 없으면 표시 문구만
252
+ 렌더링하세요(`@roottale/cms-core` 의 `findInternalContentLinkTokens`·
253
+ `internalContentPathIndex`·`renderInternalContentLinksInHtml` 을 그대로 쓸 수 있습니다).
254
+
255
+ **글의 주소는 계산하지 말고 읽으세요.** 공개 글 응답의 `path`(`@roottale/cms-client`
256
+ 에서는 `post.path`)가 플랫폼이 저장한 정규 공개 경로입니다(예 `/column/my-post`,
257
+ 상세 주소가 없는 글은 `null`). 링크·사이트맵·내부 링크 키에 이 값을 그대로 쓰면
258
+ 모델 규칙이 바뀌어도 FRONT 코드를 고칠 필요가 없습니다. 내부 링크 키는 이 경로의
259
+ 조각을 점으로 이은 값과 같습니다.
182
260
 
183
261
  **주소가 바뀐 글도 자동으로 따라가게 하려면** 공개 글 응답의 `previous_slugs`(옛 slug
184
262
  목록, 최신순 — `@roottale/cms-client`에서는 `previousSlugs`)를 함께 쓰세요. 현재 경로의
@@ -17,6 +17,11 @@ description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로
17
17
  - **커스텀 리다이렉트(이 문서)** — 글이 아닌 임의 경로를 옮깁니다. 라우팅
18
18
  *이전* 단계인 **미들웨어**에서만 가로챌 수 있어, 아래 설정이 필요합니다.
19
19
 
20
+ 글의 공개 주소가 바뀌면(slug 변경·분류 이동·모델 규칙 변경) 플랫폼이 옛 경로 → 현재
21
+ 경로 301 을 **자동으로** 이 목록에 더합니다(`id` 가 `path-history:` 로 시작). 운영자가
22
+ 같은 출발 경로 규칙을 만들었으면 운영자 규칙이 이깁니다. 미들웨어를 쓰고 있다면 별도
23
+ 작업 없이 옛 링크가 새 주소로 갑니다.
24
+
20
25
  규칙은 `GET /v1/cms/public/redirects` 로 내려오며 **활성** 규칙만 포함됩니다
21
26
  (`api-reference.md`). 출발 경로는 정규화된 사이트 내부 절대 경로, 도착지는
22
27
  내부 경로 또는 절대 URL, 상태는 `301`(영구) 또는 `302`(임시)입니다.
package/docs/overview.md CHANGED
@@ -41,6 +41,7 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
41
41
  | 상담문의(리드) 접수 | `submitInquiry` — 키가 테넌트를 식별 |
42
42
  | 테마·블로그 표시·ROOT-ANALYTICS 설정 조회 | `fetchTheme` / `fetchBlogSettings` / `fetchAnalyticsConfig` |
43
43
  | 사업장 정보·메뉴·콘텐츠 유형 조회 | `fetchBusinessProfile` / `fetchMenu`·`fetchMenus` / `fetchCollections` |
44
+ | 글 하단 공통 블록 조회 | `fetchSitePatterns` + `selectSitePatternForSlot` (`RootTaleBlogPost`는 자동) |
44
45
 
45
46
  키는 **서버 전용**입니다. 브라우저로 노출되면 안 됩니다(`NEXT_PUBLIC_*` 금지).
46
47
  `@roottale/cms-client`는 브라우저에서 import 시 의도적으로 throw 합니다.
@@ -66,5 +67,5 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
66
67
  | `inquiries.md` | 상담문의(리드) 폼 연동 |
67
68
  | `menus.md` | 메뉴(네비게이션) — 어드민 "디자인 > 메뉴" 트리를 헤더/푸터에 렌더 |
68
69
  | `seo.md` | RSS 피드, 사이트맵, JSON-LD, 동적 OG 이미지, 공개 검색, fleet 프로브 |
69
- | `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정, ROOT-ANALYTICS |
70
+ | `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정, 공통 블록(글 하단), ROOT-ANALYTICS |
70
71
  | `api-reference.md` | HTTP API 레퍼런스 (비 JS 스택용 raw 엔드포인트) |
@@ -12,7 +12,13 @@ description: 글 발행/수정 시 사이트 캐시를 near-real-time으로 갱
12
12
  - 별도 webhook secret을 보관할 필요가 없습니다 — 검증은 사이트 스코프 API
13
13
  키로 JWKS 공개키를 가져와 수행합니다.
14
14
  - ISR `revalidate = 1800` 같은 시간 기반 설정은 **fallback**입니다. 정상
15
- 경로는 웹훅입니다.
15
+ 경로는 웹훅입니다. 웹훅이 실패하면(타임아웃·5xx·연결 오류) 플랫폼이 변경을
16
+ outbox 에 남겨 1·2·4분… 간격(최대 6시간, 8회)으로 자동으로 다시 보내고,
17
+ 어드민 "발행 알림 기록" 화면에서 즉시 다시 보낼 수도 있습니다 — 사이트가
18
+ 잠시 내려가 있어도 변경은 유실되지 않습니다. 웹훅 배선이 끝난 사이트는 fallback 을 길게 잡아도
19
+ 됩니다(`sites/starter` 는 6시간, `21600`) — 짧게 잡으면 방문이 있는 페이지마다
20
+ 그 주기로 재생성이 일어나 함수 실행·ISR 쓰기·API 호출이 늘어날 뿐, 정상
21
+ 반영 속도는 웹훅이 정합니다.
16
22
 
17
23
  ## 1. revalidate 라우트 추가 (Next.js)
18
24
 
@@ -99,8 +105,9 @@ const menu = await fetchMenu({ apiKey, slug: "primary", tags: [MENUS_CACHE_TAG]
99
105
  | `fetchMenu` / `fetchMenus` | `MENUS_CACHE_TAG` | 디자인 > 메뉴 |
100
106
  | `fetchBlogSettings` | `BLOG_SETTINGS_CACHE_TAG` | 설정 > 블로그 |
101
107
  | `fetchCollections` | `COLLECTIONS_CACHE_TAG` | 설정 > 콘텐츠 유형 |
108
+ | `fetchSitePatterns` | `SITE_PATTERNS_CACHE_TAG` | 콘텐츠 도구 > 공통 블록 |
102
109
 
103
- 다섯 개를 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 있습니다. 수신 라우트는
110
+ 이들을 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 있습니다. 수신 라우트는
104
111
  설정 저장 신호(`theme.updated`) 한 번에 **이 목록 전체**를 지웁니다 — 무엇이
105
112
  바뀌었는지 웹훅 본문에 없기 때문이며, 쓰지 않는 이름표를 지우는 것은 아무 일도
106
113
  일어나지 않으므로 해가 없습니다.
package/docs/seo.md CHANGED
@@ -415,6 +415,59 @@ const crumbs = breadcrumbSchema([
415
415
  발행 웹훅의 `alsoRevalidate`에 `/feed.xml`, `/sitemap.xml`을 포함해 글 변경
416
416
  시 함께 갱신하세요 (`revalidation-webhooks.md` 참고).
417
417
 
418
+ ## 메인 홈 메타데이터 (홈 제목·홈 설명)
419
+
420
+ 어드민 **설정 > 검색·공유 표시 > 메인 홈 검색 노출**에서 저장한 홈 제목·홈
421
+ 설명은 `fetchBlogSettings().siteProfile.homeTitle` / `homeDescription` 으로
422
+ 내려옵니다(공개 API `site_profile.home_title` / `home_description`). 홈(`/`)
423
+ 라우트의 `generateMetadata` 에서 이 값을 우선 쓰고, 비어 있으면(null) 사이트
424
+ 이름(`fetchTheme().siteName`)·사이트 설명(`siteProfile.siteDescription`)으로
425
+ 폴백하세요. `title` 은 root layout 의 `%s | 사이트명` 템플릿을 타지 않도록
426
+ `{ absolute }` 로 넘깁니다. `openGraph` 는 page 값이 layout 값을 통째로
427
+ 대체하므로 `siteName`·`images` 까지 다시 채웁니다.
428
+
429
+ ```tsx
430
+ // app/page.tsx
431
+ import type { Metadata } from "next";
432
+ import {
433
+ BLOG_SETTINGS_CACHE_TAG,
434
+ fetchBlogSettings,
435
+ fetchTheme,
436
+ THEME_CACHE_TAG,
437
+ } from "@roottale/cms-client/server";
438
+
439
+ const apiKey = process.env.ROOTTALE_API_KEY!;
440
+
441
+ export async function generateMetadata(): Promise<Metadata> {
442
+ const [theme, settings] = await Promise.all([
443
+ fetchTheme({ apiKey, tags: [THEME_CACHE_TAG] }).catch(() => null),
444
+ fetchBlogSettings({ apiKey, tags: [BLOG_SETTINGS_CACHE_TAG] }).catch(() => null),
445
+ ]);
446
+ const siteName = theme?.siteName ?? "예시 사이트";
447
+ const profile = settings?.siteProfile;
448
+ const title = profile?.homeTitle ?? siteName;
449
+ const description = profile?.homeDescription ?? profile?.siteDescription ?? undefined;
450
+ const image = profile?.defaultOgImageUrl ?? undefined;
451
+ return {
452
+ title: { absolute: title },
453
+ description,
454
+ alternates: { canonical: "/" },
455
+ openGraph: {
456
+ type: "website",
457
+ siteName,
458
+ title,
459
+ description,
460
+ ...(image ? { images: [image] } : {}),
461
+ },
462
+ };
463
+ }
464
+ ```
465
+
466
+ 어드민에서 저장하면 서버가 `theme.updated` 알림을 보내므로 `revalidation-
467
+ webhooks.md` 의 태그 배선(`BLOG_SETTINGS_CACHE_TAG`)이 되어 있으면 홈
468
+ 제목·설명이 곧바로 반영됩니다. `sites/starter` 는 `buildHomeMetadata()`
469
+ (`components/SiteRootShell.tsx`)로 같은 동작이 이미 배선돼 있습니다.
470
+
418
471
  ## 글 메타데이터 (canonical·robots·OG)
419
472
 
420
473
  블로그 글 상세의 `generateMetadata` 에서 어드민 SEO 패널(`metaJson.seo`) 값을
@@ -473,6 +526,15 @@ return buildPostMetadata(post, {
473
526
  seo?: PostSeoOverrides }).seo` 로 넘기세요. `path` 는 redirect 후의 **현재
474
527
  slug**(`post.slug`) 기준으로 주세요(아래 301 참고).
475
528
 
529
+ **canonical 경로 우선순위(ADR-0105)** — `path` 옵션 → 글의 저장된 정규 공개 경로
530
+ (`post.path`, 원본 post 나 `path`·`collectionKey`·`modelKey` 를 함께 넘기면 자동) →
531
+ `collections` 계산. 플랫폼이 글마다 주소를 저장해 내려주므로 사이트는 주소 규칙을
532
+ 알 필요가 없습니다 — 권장 형태는 `path: post.path ?? "/blog/" + post.slug` 처럼
533
+ **원장을 먼저 읽고 이 라우트의 기본 주소로만 폴백**하는 것입니다(구 서버·주소 규칙이
534
+ 없는 유형이면 `post.path` 가 없거나 `null`). `storedPostPath(post)` 헬퍼
535
+ (`@roottale/cms-renderer-next/routes`)는 소속(모델·컬렉션)이 있는 글의 저장 경로만
536
+ 돌려주고, 어느 유형에도 속하지 않는 레거시 글의 `/blog` 폴백값은 무시합니다.
537
+
476
538
  ### 공지·블로그 다중 스트림 (ADR-0060)
477
539
 
478
540
  섹션을 나눈 사이트(공지 `/notice` + 블로그 `/blog`)는 `path` 를 직접 쓰지 말고
@@ -500,6 +562,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
500
562
  canonical 을 생략합니다(섹션 없는 글은 상세·sitemap에서 제외되는 규칙과 동일).
501
563
  단일 블로그 사이트는 기존처럼 `path: "/blog/" + post.slug` 만 주면 됩니다.
502
564
 
565
+ 원본 post 를 그대로 넘기면 `post.path`(플랫폼 저장 경로)가 `collections` 계산보다
566
+ 먼저 canonical 이 됩니다 — 컬렉션 규칙이 바뀌어도 사이트 코드를 고칠 필요가 없고,
567
+ `collections` 는 저장 경로가 없는 글(구 서버·주소 규칙 없는 유형)의 폴백으로만 쓰입니다.
568
+
503
569
  ### 다국어 hreflang (ADR-0052 A안, W4-6 PR C1)
504
570
 
505
571
  번역 사이트는 `translations`(글 응답의 `translations[]`)와 `locales` 옵션을
@@ -22,8 +22,9 @@ description: 어드민에서 관리하는 디자인 토큰, 블로그 표시 옵
22
22
  | `fetchMenu` / `fetchMenus` | `MENUS_CACHE_TAG` |
23
23
  | `fetchBlogSettings` | `BLOG_SETTINGS_CACHE_TAG` |
24
24
  | `fetchCollections` | `COLLECTIONS_CACHE_TAG` |
25
+ | `fetchSitePatterns` | `SITE_PATTERNS_CACHE_TAG` |
25
26
 
26
- 다섯 개를 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 내보냅니다. 이름표를 지우는
27
+ 이들을 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 내보냅니다. 이름표를 지우는
27
28
  쪽(웹훅 수신 라우트) 배선은 `revalidation-webhooks.md` §1 "설정 저장"을
28
29
  따르세요 — **양쪽을 다 해야** 즉시 반영이 됩니다.
29
30
 
@@ -126,6 +127,9 @@ const settings = await fetchBlogSettings({
126
127
  });
127
128
  // showTableOfContents, showAuthor, showDate, showAuthorCard,
128
129
  // tocTitle, authorProfileImageRadius / Position 등
130
+ // siteProfile: siteDescription · homeTitle · homeDescription(메인 홈 전용
131
+ // 검색 제목·설명, null 이면 사이트 이름·사이트 설명으로 폴백) · logoUrl ·
132
+ // faviconUrl · defaultOgImageUrl — 사이트 <head>/OG 폴백 (seo.md 참고)
129
133
 
130
134
  // 글 단위 오버라이드(metaJson)와 합성해 최종 표시값 계산
131
135
  const display = resolvePostDisplay(settings, post);
@@ -136,6 +140,48 @@ const display = resolvePostDisplay(settings, post);
136
140
  `authorProfileBio`·`authorProfileImageUrl`·`authorCardDescription`은 항상
137
141
  `null`이며 새 코드에서 사용하지 마세요.
138
142
 
143
+ ## 공통 블록 (글 하단) — fetchSitePatterns
144
+
145
+ 어드민 **콘텐츠 도구 > 공통 블록**에서 만든 재사용 본문(병원 안내·상담 CTA 등)을
146
+ 여러 글의 같은 자리에 붙입니다. "어느 글의 어느 자리에 어떤 블록"은 어드민의
147
+ 배치 규칙(콘텐츠 유형별)으로 정하고, **서버가 글 응답의 `patternSlots`(자리 →
148
+ 블록 key | null)로 계산해 내려주므로 사이트는 규칙을 몰라도 됩니다.** 현재
149
+ 자리는 글 하단 `post_footer` 하나입니다.
150
+
151
+ - `RootTaleBlogPost`(cms-renderer-next 0.57+)는 본문 바로 아래에 자동으로 그립니다
152
+ — 추가 코드가 필요 없습니다.
153
+ - 자체 글 화면을 만든 사이트는 `RootTalePostPattern` 서버 컴포넌트를 본문 아래에
154
+ 두거나, 아래처럼 직접 조회해 글 본문과 **같은 렌더러·정화 경로**로 그립니다.
155
+
156
+ ```ts
157
+ import {
158
+ SITE_PATTERNS_CACHE_TAG,
159
+ POST_FOOTER_PATTERN_SLOT,
160
+ fetchPost,
161
+ fetchSitePatterns,
162
+ selectSitePatternForSlot,
163
+ } from "@roottale/cms-client/server";
164
+
165
+ const post = await fetchPost({ apiKey, slugOrId });
166
+ // post.patternSlots → { post_footer: "clinic-guide" } 처럼 자리별 블록 key
167
+ const patterns = post?.patternSlots?.[POST_FOOTER_PATTERN_SLOT]
168
+ ? await fetchSitePatterns({ apiKey, tags: [SITE_PATTERNS_CACHE_TAG] }).catch(() => [])
169
+ : [];
170
+ const footer = selectSitePatternForSlot(post?.patternSlots, patterns);
171
+ // footer?.bodyJson 을 본문과 같은 Tiptap 렌더러로 그린다(없으면 아무것도 그리지 않음)
172
+ ```
173
+
174
+ ```tsx
175
+ // 자체 글 화면 + 공용 렌더러 조합
176
+ import { RootTalePostPattern } from "@roottale/cms-renderer-next/server";
177
+ <RootTalePostPattern apiKey={apiKey} post={post} />
178
+ ```
179
+
180
+ 블록 목록은 `rt-site-patterns` 캐시 이름표를 가지며, 어드민에서 블록·배치 규칙을
181
+ 저장하면 `theme.updated` 웹훅이 다른 설정과 함께 지웁니다(`createRevalidateRoute`
182
+ 에 `revalidateTag` 주입 필요 — 위 "저장 즉시 반영" 참고). 미발행·삭제된 블록은
183
+ `patternSlots` 에서 이미 `null` 이므로 사이트가 따로 걸러낼 필요가 없습니다.
184
+
139
185
  ## 비즈니스 프로필 (로컬 SEO) — fetchBusinessProfile
140
186
 
141
187
  어드민 **운영 > 비즈니스 프로필**에서 저장한 사업장 정보(이름·별칭·업종·
@@ -37,7 +37,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
37
37
  section: post.category || undefined,
38
38
  tags: post.tags.map((t) => t.name),
39
39
  },
40
- { siteUrl: SITE_URL, path: `/blog/${post.slug}` },
40
+ // ADR-0105 — 주소는 플랫폼 원장(post.path)을 읽고, 없을 때만 이 라우트 기본값.
41
+ { siteUrl: SITE_URL, path: post.path ?? `/blog/${post.slug}` },
41
42
  );
42
43
  }
43
44
 
@@ -49,7 +50,8 @@ export default async function PostPage({ params }: Props) {
49
50
  const redirect = postRedirectPath(post, slug);
50
51
  if (redirect) permanentRedirect(redirect);
51
52
 
52
- const url = `${SITE_URL}/blog/${post.slug}`;
53
+ // ADR-0105 — 정본 주소는 플랫폼 원장(post.path). 없을 때만 이 라우트 기본값.
54
+ const url = `${SITE_URL}${post.path ?? `/blog/${post.slug}`}`;
53
55
  // avcd 구조 — BlogPosting + 확장 옵션(전부 선택, 있는 값만 채우면 됩니다).
54
56
  const jsonLd = articleSchema({
55
57
  type: "BlogPosting",
@@ -80,7 +80,7 @@ export default async function CategoryArchivePage({ params }: Props) {
80
80
  <ul>
81
81
  {posts.map((post) => (
82
82
  <li key={post.id}>
83
- <Link href={`/blog/${post.slug}`}>{post.title}</Link>
83
+ <Link href={post.path ?? `/blog/${post.slug}`}>{post.title}</Link>
84
84
  </li>
85
85
  ))}
86
86
  </ul>
@@ -18,7 +18,7 @@ export default async function BlogPage() {
18
18
  <ul>
19
19
  {posts.map((post) => (
20
20
  <li key={post.id}>
21
- <Link href={`/blog/${post.slug}`}>
21
+ <Link href={post.path ?? `/blog/${post.slug}`}>
22
22
  {post.category && <span>{post.category}</span>}
23
23
  <h2>{post.title}</h2>
24
24
  <p>{post.description}</p>
@@ -11,6 +11,7 @@ import {
11
11
  type ArchivePromotion,
12
12
  fetchBlogArchivePosts,
13
13
  resolveCategoryPromotion,
14
+ storedPostPath,
14
15
  } from "@roottale/cms-renderer-next/routes";
15
16
 
16
17
  function getApiKey(): string {
@@ -24,6 +25,11 @@ const baseUrl = process.env.ROOTTALE_API_BASE;
24
25
  export interface BlogPostMeta {
25
26
  id: string;
26
27
  slug: string;
28
+ /**
29
+ * 플랫폼이 저장한 정규 공개 경로(ADR-0105, 예 `/blog/my-post`). 링크·canonical·
30
+ * JSON-LD 는 이 값을 읽고, 없을 때(구 서버·주소 규칙 없는 유형)만 `/blog/{slug}` 로 폴백.
31
+ */
32
+ path: string | null;
27
33
  title: string;
28
34
  description: string;
29
35
  date: string; // publishedAt ISO
@@ -51,6 +57,7 @@ function toMeta(post: CmsPostContent): BlogPostMeta {
51
57
  return {
52
58
  id: post.id,
53
59
  slug: post.slug,
60
+ path: storedPostPath(post),
54
61
  title: post.title,
55
62
  description: post.excerpt ?? "",
56
63
  date: post.publishedAt ?? "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.55.0",
3
+ "version": "0.57.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": {