@roottale/cms-mcp 0.55.0 → 0.56.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,34 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.56.0
4
+
5
+ ### Patch Changes
6
+
7
+ - 18aaa9e: ADR-0105 Amendment 1 — 공개 글 응답에 저장된 정규 공개 경로 `path`(cms-client
8
+ `post.path`)를 additive 로 더합니다. FRONT 는 주소를 계산하지 않고 이 값을 링크·사이트맵·
9
+ 내부 링크 키에 그대로 씁니다.
10
+ - 422c388: ADR-0105 Amendment 1 — FRONT 가 글 주소를 다시 계산하지 않습니다. `RootTaleBlogList`·
11
+ `RootTaleBlogPost`(연관 글·빵부스러기)의 기본 글 링크와 `buildPostMetadata` 의 canonical 이
12
+ 플랫폼이 저장한 정규 공개 경로 `post.path` 를 먼저 읽고, 없을 때만 종전처럼 `collections`
13
+ 규칙 → `/blog/{slug}` 로 폴백합니다. `storedPostPath(post)` 헬퍼를 `routes` 에서 내보냅니다.
14
+ 문서(seo·collections·blog)와 Next.js 예제가 같은 규칙으로 갱신됐습니다.
15
+ - d303835: 공개 블로그 설정 응답 `site_profile` 에 메인 홈 전용 검색 제목·설명
16
+ `home_title`·`home_description` 을 additive 로 더합니다(어드민 "검색·공유 표시 >
17
+ 메인 홈 검색 노출"). `@roottale/cms-client` 는 `siteProfile.homeTitle` /
18
+ `homeDescription` 으로 노출하며, null 이면 사이트 이름·사이트 설명으로 폴백하는
19
+ 것이 계약입니다. cms-mcp 문서 `seo.md` 에 홈 `generateMetadata` 예시를 더했습니다.
20
+ - b13696c: ADR-0105 Amendment 1 — `GET /v1/cms/public/redirects` 가 글 공개 경로 이력(post_path_history)
21
+ 에서 옛 경로 → 현재 경로 301 을 자동으로 더합니다(`id` 접두사 `path-history:`). 운영자
22
+ 규칙이 같은 출발 경로면 운영자 규칙이 이깁니다.
23
+ - 86c5712: RootTale 표준 블로그 주소 `/blog/{category}/{slug}` 지원 — `RootTaleSiteConfig.categoryPath`
24
+ (`"direct"` 면 사이트맵 카테고리 허브가 `/blog/{category}`), collection 모드 사이트맵이 각 컬렉션의
25
+ `categoryPath` 를 따르며, `makeCategoryHref(basePath, categoryPath)` 를 `server`·`routes` 에서
26
+ 내보냅니다. 문서에 표준 규칙과 라우트 배치를 적었습니다.
27
+ - bd450bc: ADR-0105 — 콘텐츠 모델 `detail`·`category_tree` presentation 에 목록·피드 규칙 선택 필드
28
+ (`archives`, `feed`, `categoryPath`, `layout`)를 additive 로 더합니다. 예전 `collections`
29
+ 설정에만 있던 규칙을 모델이 소유하며, 공개 `content-models` 응답도 값이 있을 때 함께
30
+ 내보냅니다.
31
+
3
32
  ## 0.55.0
4
33
 
5
34
  ### 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.56.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.
@@ -415,8 +415,9 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
415
415
  "show_author_card": true, "toc_title": null, "toc_position": "inline",
416
416
  "author_profile_name": null, "author_profile_bio": null,
417
417
  "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 },
418
+ "site_profile": { "site_description": null, "home_title": null,
419
+ "home_description": null, "logo_url": null, "favicon_url": null,
420
+ "default_og_image_url": null },
420
421
  "post_cta": { "title": "상담이 필요하신가요?", "description": "첫 상담은 무료입니다.",
421
422
  "button_label": "상담 문의하기", "button_href": "/contact" },
422
423
  "updated_at": null }
@@ -434,6 +435,9 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
434
435
  `generateMetadata` 에서 `post.seo?.ogImage ?? post.featured_media_url ??
435
436
  settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
436
437
  `favicon_url`·`site_description` 은 사이트 `<head>` 에 적용합니다.
438
+ `home_title`·`home_description` 은 **메인 홈(`/`) 전용** 검색 제목·설명(어드민
439
+ "검색·공유 표시 > 메인 홈 검색 노출")으로, `null` 이면 사이트 이름(theme
440
+ `site_name`)·`site_description` 으로 폴백하세요(`seo.md` "메인 홈 메타데이터").
437
441
 
438
442
  ## GET /v1/cms/public/categories
439
443
 
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";
@@ -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
  말단 카테고리를 정확히 하나 연결합니다. 위 예시는 다음 주소를 표현합니다.
@@ -180,6 +229,12 @@ ROOT-ADMIN 편집기 도구 모음의 **내부 링크 삽입** 버튼이 이 표
180
229
  경로를 같은 규칙으로 키로 바꿔 대조한 뒤 있으면 링크로, 없으면 표시 문구만
181
230
  렌더링하세요. `code`·`pre`·이미 링크된 구간과 속성값은 변환하지 않는 것을 권장합니다.
182
231
 
232
+ **글의 주소는 계산하지 말고 읽으세요.** 공개 글 응답의 `path`(`@roottale/cms-client`
233
+ 에서는 `post.path`)가 플랫폼이 저장한 정규 공개 경로입니다(예 `/column/my-post`,
234
+ 상세 주소가 없는 글은 `null`). 링크·사이트맵·내부 링크 키에 이 값을 그대로 쓰면
235
+ 모델 규칙이 바뀌어도 FRONT 코드를 고칠 필요가 없습니다. 내부 링크 키는 이 경로의
236
+ 조각을 점으로 이은 값과 같습니다.
237
+
183
238
  **주소가 바뀐 글도 자동으로 따라가게 하려면** 공개 글 응답의 `previous_slugs`(옛 slug
184
239
  목록, 최신순 — `@roottale/cms-client`에서는 `previousSlugs`)를 함께 쓰세요. 현재 경로의
185
240
  마지막 조각을 옛 slug로 바꾼 경로도 같은 글의 키로 등록하면, `[[internal:blog.old-slug|…]]`
@@ -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`(임시)입니다.
@@ -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
 
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` 옵션을
@@ -126,6 +126,9 @@ const settings = await fetchBlogSettings({
126
126
  });
127
127
  // showTableOfContents, showAuthor, showDate, showAuthorCard,
128
128
  // tocTitle, authorProfileImageRadius / Position 등
129
+ // siteProfile: siteDescription · homeTitle · homeDescription(메인 홈 전용
130
+ // 검색 제목·설명, null 이면 사이트 이름·사이트 설명으로 폴백) · logoUrl ·
131
+ // faviconUrl · defaultOgImageUrl — 사이트 <head>/OG 폴백 (seo.md 참고)
129
132
 
130
133
  // 글 단위 오버라이드(metaJson)와 합성해 최종 표시값 계산
131
134
  const display = resolvePostDisplay(settings, post);
@@ -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.56.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": {