@roottale/cms-mcp 0.46.0 → 0.48.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,14 @@
1
1
  # @roottale/cms-mcp
2
2
 
3
+ ## 0.48.0
4
+
5
+ ### Patch Changes
6
+
7
+ - a4c7070: 문서 갱신 — `blog.md`/`seo.md`에 `author_slug` 가산, 저자 링크·브레드크럼
8
+ (avcd 구조), 카테고리 허브 라우트(`/blog/categories/{slug}`) 필수화 안내,
9
+ avcd 확장 메타데이터(RSS alternate·Twitter Card·og article 확장)를 반영.
10
+ 예시 코드에 브레드크럼·BlogPosting·카테고리 허브 라우트 사용법 추가.
11
+
3
12
  ## 0.46.0
4
13
 
5
14
  ### Minor Changes
package/dist/index.js CHANGED
@@ -801,7 +801,7 @@ function registerTools(server) {
801
801
  }
802
802
 
803
803
  // src/server.ts
804
- var VERSION = true ? "0.46.0" : "dev";
804
+ var VERSION = true ? "0.48.0" : "dev";
805
805
  var SERVER_INSTRUCTIONS = `
806
806
  roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uACE0
807
807
  \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.
@@ -332,7 +332,8 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
332
332
  | `lead_kind` | | `patient`(기본) \| `sales` |
333
333
  | `_redirect_url` | | 완료 후 redirect base (allowlist 검증) |
334
334
  | `cf-turnstile-response` | | Cloudflare Turnstile 토큰. tenant-api에 `LEAD_INTAKE_TURNSTILE_SECRET_KEY`가 설정된 경우 필수 |
335
- | `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_*` 키는 무시 |
335
+ | `attr_landing_path`, `attr_rt_src`, `attr_utm_source`, `attr_utm_medium`, `attr_utm_campaign`, `attr_utm_term`, `attr_utm_content`, `attr_ad_click`, `attr_referrer`, `attr_first_touch_at`, `attr_last_touch_at`, `attr_last_touch_channel`, `attr_last_touch_source`, `attr_last_touch_medium`, `attr_last_touch_campaign`, `attr_last_touch_referrer` | | 유입 어트리뷰션 — CRM에 유입 경로 표시 (`inquiries.md` 참고). 그 외 `attr_*` 키는 무시 |
336
+ | `attr_journey` | | 방문 여정 JSON 원문(서버 zod 검증, 최대 30항목) — CRM "방문 여정" 패널에 표시 (`inquiries.md` 참고) |
336
337
  | 기타 임의 필드 | | 최대 50개, 암호화 보관, CRM 상세 노출 |
337
338
 
338
339
  응답: `302` redirect — 성공 `?ok=1`, 실패 `?err=<code>`
package/docs/blog.md CHANGED
@@ -38,6 +38,15 @@ export default function BlogPage() {
38
38
  }
39
39
  ```
40
40
 
41
+ > **카테고리 허브 라우트(`/blog/categories/{slug}`)를 구현하세요.**
42
+ > `showCategoryFilter` 의 카테고리 칩, `RootTaleBlogCategories`, `RootTaleBlogPost`
43
+ > 의 `breadcrumb` 카테고리 세그먼트가 모두 기본적으로 이 경로를 링크합니다
44
+ > (`defaultCategoryHref`). **단일 블로그 사이트도 예외가 아닙니다** — 이 라우트가
45
+ > 없으면 카테고리 칩·브레드크럼을 클릭했을 때 404 가 됩니다. 공지·블로그처럼
46
+ > 섹션을 나눈 사이트는 `{basePath}/categories/{slug}` 패턴(`collections.md`
47
+ > "URL이 어떻게 정해지나" 절)을 대신 씁니다. 참조 구현은 예시 코드
48
+ > `app/blog/categories/[slug]/page.tsx`(MCP tool `readRootTaleNextjsExampleCode`).
49
+
41
50
  > **공지·블로그를 나눈 사이트(ADR-0060)는 목록을 반드시 섹션으로 스코프하세요.**
42
51
  > `RootTaleBlogList` 는 기본적으로 **모든 글**을 렌더하므로, 섹션을 나눴는데
43
52
  > `collection` 을 안 주면 공지 글이 `/blog` 목록에 섞여 나옵니다. 각 목록 페이지에서
@@ -134,6 +143,46 @@ export default async function PostPage({
134
143
  경우 `@roottale/cms-core` 의 `extractFaqEntries(doc)` / `faqPageJsonLd(entries)`
135
144
  로 동일한 JSON-LD 를 생성할 수 있습니다.
136
145
 
146
+ #### 저자 링크 + 브레드크럼 (avcd 구조)
147
+
148
+ 발행 글에 `author_slug`(작가 아카이브 slug)가 있으면 `RootTaleBlogPost` 가
149
+ 헤더 메타·저자 카드의 작성자 이름을 자동으로 `<a href="/blog/author/{slug}">`
150
+ 로 감쌉니다. slug 가 없는 작가는 지금처럼 plain text 로 렌더됩니다. 링크
151
+ 대상을 바꾸려면 `authorHref`:
152
+
153
+ ```tsx
154
+ <RootTaleBlogPost
155
+ apiKey={apiKey}
156
+ slugOrId={slug}
157
+ authorHref={(slug) => `/authors/${slug}`} // 기본 /blog/author/{slug}
158
+ />
159
+ ```
160
+
161
+ `breadcrumb` prop(opt-in, 기본 off)을 주면 시각 브레드크럼(홈 › 블로그 ›
162
+ 카테고리 › 글 제목)을 본문 위에 렌더하고, `siteUrl` 을 함께 주면
163
+ `BreadcrumbList` JSON-LD 도 같이 emit합니다:
164
+
165
+ ```tsx
166
+ <RootTaleBlogPost
167
+ apiKey={apiKey}
168
+ slugOrId={slug}
169
+ breadcrumb={{ siteUrl: process.env.NEXT_PUBLIC_SITE_URL }}
170
+ // collections 를 함께 주면 "블로그" 세그먼트가 글의 소속 섹션 basePath로
171
+ // 자동 해석됩니다(공지 글 → /notice). showCategory:false 로 카테고리
172
+ // 세그먼트 생략 가능, categoryHref로 링크 override 가능.
173
+ />
174
+ ```
175
+
176
+ 마지막 항목(카테고리 링크, `showCategory` 기본 `true`)은 `defaultCategoryHref`
177
+ (`/blog/categories/{slug}`)를 씁니다 — **이 라우트를 구현해야 브레드크럼·
178
+ 카테고리 칩 클릭이 404 가 나지 않습니다**(아래 "카테고리 허브 라우트" 참고).
179
+
180
+ > **마크업 변경 주의** — 헤더 발행일이 `<span data-rt-cms-meta="date">` 에서
181
+ > `<time dateTime="…" data-rt-cms-meta="date">` 로 바뀌었습니다. `class`·
182
+ > `data-rt-cms-meta` 속성은 그대로라 속성 선택자로 스타일링했다면 영향 없지만,
183
+ > 태그명(`span`)을 직접 선택자로 쓴 고객 CSS/스크립트가 있다면 `time` 으로
184
+ > 갱신하세요.
185
+
137
186
  ### 다국어 (ADR-0052 A안, W4-6 PR C1/C2)
138
187
 
139
188
  번역 사이트(어드민에서 "번역 추가"로 언어판을 만든 글)는 `RootTaleBlogList`/
@@ -229,6 +278,7 @@ export async function getPost(slug: string) {
229
278
  | `terms` | 분류 용어 배열 (`taxonomy: "category" \| "tag"`, `name`, `slug`) |
230
279
  | `featuredImageUrl` | 대표 이미지 |
231
280
  | `authorName` | 작성자 표시명 |
281
+ | `authorSlug` | 작가 아카이브 slug(`/blog/author/{slug}`) — 미발급 작가는 `null` |
232
282
  | `metaJson` | 부가 메타 — `metaJson.seo`에 SEO 오버라이드 |
233
283
 
234
284
  ### 정적 경로 사전 생성 + 메타데이터
package/docs/inquiries.md CHANGED
@@ -85,9 +85,12 @@ export async function submitContact(
85
85
  | `leadKind` | | `patient`(기본) \| `sales` |
86
86
  | `turnstileToken` | | Cloudflare Turnstile 토큰. tenant-api secret 검증을 켠 경우 전달 |
87
87
  | `extras` | | 임의 추가 항목 (최대 50개, 암호화 보관, CRM 상세에 노출) |
88
- | `attribution` | | 유입 first-touch — 아래 [유입 어트리뷰션](#유입-어트리뷰션-attribution) 참고 |
88
+ | `attribution` | | 유입 first-touch/last-touch — 아래 [유입 어트리뷰션](#유입-어트리뷰션-attribution) 참고 |
89
+ | `journey` | | 방문 여정 JSON 원문 — 아래 [방문 여정](#방문-여정-journey) 참고 |
89
90
 
90
91
  `extras`에 개인정보가 담길 수 있으므로 폼의 동의 고지에 수집 항목을 반영하세요.
92
+ `journey`를 함께 보낼 때는 개인정보 수집·이용 동의 문구에 "사이트 이용 기록
93
+ (방문 페이지·상호작용)"을 추가하세요.
91
94
 
92
95
  ## 유입 어트리뷰션 (`attribution`)
93
96
 
@@ -134,12 +137,33 @@ const result = await submitInquiry({
134
137
  | `landing_path` | 첫 방문 landing pathname |
135
138
  | `rt_src` | 단축링크/QR 토큰 (`roottale.link` 경유 시) |
136
139
  | `utm_source` / `utm_medium` / `utm_campaign` | UTM 파라미터 |
140
+ | `utm_term` / `utm_content` | UTM 파라미터(세부) |
141
+ | `ad_click` | 광고 클릭 유입 존재 플래그(boolean, true일 때만 존재) — gclid/fbclid 원값은 저장하지 않습니다 |
137
142
  | `referrer` | 외부 referrer **호스트명** (raw URL 아님) |
138
143
  | `first_touch_at` | first-touch 시각 (ISO 8601) |
144
+ | `last_touch_at` | 가장 최근 non-direct 방문 시각(ISO 8601) — first-touch와 별개로 갱신 |
145
+ | `last_touch_channel` | last-touch를 유발한 채널 — `rs`(단축링크/QR) \| `utm` \| `gcl` \| `rf` |
146
+ | `last_touch_source` / `last_touch_medium` / `last_touch_campaign` / `last_touch_referrer` | last-touch 시점의 UTM/referrer |
139
147
 
140
148
  직접 HTTP 연동 시에는 같은 값을 `attr_landing_path`, `attr_rt_src`,
141
- `attr_utm_source`, `attr_utm_medium`, `attr_utm_campaign`, `attr_referrer`,
142
- `attr_first_touch_at` 폼 필드로 보내면 됩니다.
149
+ `attr_utm_source`, `attr_utm_medium`, `attr_utm_campaign`, `attr_utm_term`,
150
+ `attr_utm_content`, `attr_ad_click`, `attr_referrer`, `attr_first_touch_at`,
151
+ `attr_last_touch_at`, `attr_last_touch_channel`, `attr_last_touch_source`,
152
+ `attr_last_touch_medium`, `attr_last_touch_campaign`,
153
+ `attr_last_touch_referrer` 폼 필드로 보내면 됩니다.
154
+
155
+ ## 방문 여정 (`journey`)
156
+
157
+ 문의 제출 직전까지의 방문 경로(페이지 이동·주요 전환 이벤트)를 CRM 상세의
158
+ "방문 여정" 패널에서 시간순으로 확인할 수 있습니다.
159
+ `@roottale/analytics-runtime`의 `readJourney()`로 읽은 sessionStorage 링버퍼를
160
+ `JSON.stringify`한 문자열을 그대로 `journey` 필드(직접 HTTP는 `attr_journey`
161
+ 폼 필드)로 보내면 됩니다 — 파싱·검증은 서버가 담당합니다.
162
+
163
+ 의료(`medical`)·법률(`legal`) 업종 문의는 서버가 자동으로 "마일스톤
164
+ 모드"(이벤트 유형·시각만, 방문 페이지 정보 제거)로 강제 전환합니다 —
165
+ 증상·질환 관심사가 노출되지 않도록 하는 안전장치이므로 별도 설정이 필요
166
+ 없습니다.
143
167
 
144
168
  ## 에러 처리
145
169
 
package/docs/seo.md CHANGED
@@ -73,12 +73,14 @@ export default sitemap;
73
73
  작가 사이트맵(`/sitemap/authors.xml`)을 만들 수 있습니다. 어드민 **설정 > 블로그 >
74
74
  사이트맵**에서 "작가 사이트맵"을 켜면 인덱스에 `authors` 섹션이 추가됩니다.
75
75
 
76
- ```ts
76
+ ```tsx
77
77
  // app/blog/author/[slug]/page.tsx
78
78
  import { notFound } from "next/navigation";
79
- import { fetchAuthors } from "@roottale/cms-client/server";
79
+ import { fetchAuthors, profilePageSchema } from "@roottale/cms-client/server";
80
80
  import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";
81
81
 
82
+ const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
83
+
82
84
  export default async function AuthorArchive({
83
85
  params,
84
86
  }: {
@@ -89,8 +91,19 @@ export default async function AuthorArchive({
89
91
  const author = authors.find((a) => a.slug === slug);
90
92
  if (!author) notFound();
91
93
 
94
+ const jsonLd = profilePageSchema({
95
+ url: `${SITE_URL}/blog/author/${slug}`,
96
+ name: author.name,
97
+ image: author.imageUrl,
98
+ description: author.bio,
99
+ });
100
+
92
101
  return (
93
102
  <>
103
+ <script
104
+ type="application/ld+json"
105
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
106
+ />
94
107
  <h1>{author.name}</h1>
95
108
  {author.bio ? <p>{author.bio}</p> : null}
96
109
  {/* author= 로 그 작가의 발행 글만 렌더 */}
@@ -104,6 +117,31 @@ export default async function AuthorArchive({
104
117
  (`GET /v1/cms/public/authors`).
105
118
  - `RootTaleBlogList`에 `author={slug}` 를 주면 그 작가의 글만 가져옵니다
106
119
  (`GET /v1/cms/public/posts?author={slug}`).
120
+ - 글 상세의 `articleSchema` 에 `authorUrl: ${SITE_URL}/blog/author/${post.authorSlug}`
121
+ 를 넘기면 글의 author 엔티티가 이 작가 페이지의 `ProfilePage.mainEntity`
122
+ 와 같은 `@id`(`{authorUrl}#person`)로 연결됩니다.
123
+
124
+ ### 카테고리 허브 (`/blog/categories/{slug}`)
125
+
126
+ `RootTaleBlogList`의 카테고리 칩, `RootTaleBlogCategories`, `RootTaleBlogPost`
127
+ 의 `breadcrumb` 카테고리 세그먼트가 모두 기본적으로 `/blog/categories/{slug}`
128
+ 를 링크합니다(`defaultCategoryHref`) — **단일 블로그 사이트도 이 라우트를
129
+ 구현해야** 링크가 404 나지 않습니다. 참조 구현은 예시 코드
130
+ `app/blog/categories/[slug]/page.tsx`(MCP tool `readRootTaleNextjsExampleCode`).
131
+ 공지·블로그처럼 섹션을 나눈 사이트는 `{basePath}/categories/{slug}` 패턴
132
+ (`collections.md` "URL이 어떻게 정해지나" 절)을 대신 씁니다.
133
+
134
+ 목록 페이지용 JSON-LD는 `collectionPageSchema`:
135
+
136
+ ```tsx
137
+ import { collectionPageSchema } from "@roottale/cms-client/server";
138
+
139
+ const jsonLd = collectionPageSchema({
140
+ url: `${SITE_URL}/blog/categories/${category.slug}`,
141
+ name: `${category.name} 글 모음`,
142
+ siteUrl: SITE_URL,
143
+ });
144
+ ```
107
145
 
108
146
  ### 다국어 (hreflang) — ADR-0052 A안, 콘텐츠 다국어
109
147
 
@@ -165,8 +203,27 @@ export default function robots(): MetadataRoute.Robots {
165
203
 
166
204
  ## 브레드크럼 (BreadcrumbList)
167
205
 
168
- 사이트 구조를 검색엔진에 전달하고 검색결과에 경로가 표시됩니다. 블로그 글
169
- 상세에서 `breadcrumbSchema` 로 렌더하세요 (UI 브레드크럼과 구조 일치 권장):
206
+ 사이트 구조를 검색엔진에 전달하고 검색결과에 경로가 표시됩니다.
207
+
208
+ **빠른 경로** — `RootTaleBlogPost` 를 쓰면 `breadcrumb` prop 하나로 시각
209
+ 브레드크럼 + `BreadcrumbList` JSON-LD 를 함께 얻습니다(opt-in, 기본 off):
210
+
211
+ ```tsx
212
+ <RootTaleBlogPost
213
+ apiKey={apiKey}
214
+ slugOrId={slug}
215
+ breadcrumb={{ siteUrl: SITE_URL }} // siteUrl 없으면 시각 브레드크럼만
216
+ />
217
+ ```
218
+
219
+ 카테고리 세그먼트는 글의 첫 category term 을 자동으로 쓰고
220
+ (`showCategory: false` 로 생략 가능), 링크는 `defaultCategoryHref`
221
+ (`/blog/categories/{slug}`)를 씁니다 — `blog.md` "카테고리 허브 라우트" 참고.
222
+ 자세한 옵션(`homeLabel`/`listHref`/`categoryHref`/`collections` 연동)은
223
+ `blog.md` "저자 링크 + 브레드크럼" 절 참고.
224
+
225
+ **커스텀 경로** — 직접 마크업하는 경우 `breadcrumbSchema` 로 JSON-LD 만
226
+ 생성하세요(UI 브레드크럼과 구조 일치 권장):
170
227
 
171
228
  ```tsx
172
229
  import { breadcrumbSchema } from "@roottale/cms-client/server";
@@ -225,6 +282,29 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
225
282
  `siteUrl`/`path` 를 안 넘기면 override 가 있을 때만 canonical 출력.
226
283
  - `seo.noindex`/`seo.nofollow` 중 하나라도 켜지면 `robots` 출력
227
284
  - `openGraph`(type:`article`·`publishedTime`·`images`) 자동 구성
285
+ - **avcd 구조** — `siteUrl` 이 있으면 canonical 해석 성공 여부와 무관하게
286
+ `alternates.types["application/rss+xml"]`(기본 `/feed.xml`, `feedPath`
287
+ 옵션으로 override)을 항상 emit합니다. `twitter`(`summary_large_image`)도
288
+ og 와 같은 소스(seo override → 글 값)로 항상 채워집니다 — `card`+`title`
289
+ 은 og 이미지가 없어도 emit, `images` 는 og 이미지가 있을 때만.
290
+ - **avcd 구조 — og article 확장** — 입력에 `modified`(→ `openGraph.modifiedTime`)·
291
+ `section`(→ `openGraph.section`)·`tags`(문자열 배열 → `openGraph.tags`)·
292
+ `authors`(저자 프로필 URL 배열 → `openGraph.authors`)를 넘기면 그대로
293
+ 추가됩니다. 전부 선택 — 미지정/빈 배열은 생략(기존 호출부 diff 0).
294
+
295
+ ```tsx
296
+ return buildPostMetadata(post, {
297
+ siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
298
+ path: `/blog/${post.slug}`,
299
+ modified: post.modified, // updatedAt ISO
300
+ section: post.category || undefined,
301
+ tags: post.tags.map((t) => t.name),
302
+ authors: post.authorSlug
303
+ ? [`${SITE_URL}/blog/author/${post.authorSlug}`]
304
+ : undefined,
305
+ feedPath: "/feed.xml", // 기본값 — 다중 스트림이면 섹션별 피드로 override
306
+ });
307
+ ```
228
308
 
229
309
  입력은 `{ title, description?, date?, image?, seo? }` 구조면 됩니다(`getPost`
230
310
  의 `BlogPostMeta` 호환). 원본 post 를 쓸 땐 `seo: (post.metaJson as {
@@ -372,12 +452,14 @@ const hits = await searchPosts({
372
452
 
373
453
  | 함수 | 용도 |
374
454
  |---|---|
375
- | `articleSchema(input)` | 블로그 글 상세 페이지 Article |
455
+ | `articleSchema(input)` | 블로그 글 상세 페이지 Article/BlogPosting |
376
456
  | `breadcrumbSchema(items)` | 빵부스러기 |
377
457
  | `organizationSchema(input)` | 조직/사업체 |
378
458
  | `localBusinessSchema(profile, opts)` | 사업장 LocalBusiness (로컬 SEO — 아래 섹션) |
379
459
  | `websiteSchema(input)` | 웹사이트 |
380
460
  | `faqSchema(items)` | FAQ |
461
+ | `profilePageSchema(input)` | 작가 프로필 페이지 (`/blog/author/{slug}`) |
462
+ | `collectionPageSchema(input)` | 글 목록/카테고리 허브 (`/blog`, `/blog/categories/{slug}`) |
381
463
 
382
464
  ```tsx
383
465
  import { articleSchema } from "@roottale/cms-client/server";
@@ -396,6 +478,29 @@ const jsonLd = articleSchema({
396
478
  />;
397
479
  ```
398
480
 
481
+ **avcd 구조 — 확장 옵션(전부 선택)**:
482
+
483
+ ```tsx
484
+ const jsonLd = articleSchema({
485
+ type: "BlogPosting", // 기본 "Article" — 미지정 시 기존 출력 그대로
486
+ id: `${url}#article`, // 지정 시 @id emit
487
+ headline: post.title,
488
+ url,
489
+ image: post.featuredImageUrl ?? undefined,
490
+ imageWidth: 1200, // 둘 다 있으면 image 를 ImageObject 로 승격
491
+ imageHeight: 630,
492
+ authorName: post.authorName,
493
+ authorUrl: `${SITE_URL}/blog/author/${post.authorSlug}`, // author.@id 연결
494
+ publisherName: "예시 사이트",
495
+ publisherId: `${SITE_URL}#organization`,
496
+ section: category?.name, // → articleSection
497
+ inLanguage: "ko",
498
+ });
499
+ ```
500
+
501
+ `profilePageSchema`/`collectionPageSchema` 사용법은 각각 위 "작가 아카이브"·
502
+ "카테고리 허브" 절 참고.
503
+
399
504
  저수준 RSS가 필요하면 `generateRssXml` / `rssItemsFromPosts`를 직접 사용할 수
400
505
  있습니다.
401
506
 
@@ -8,9 +8,12 @@ import {
8
8
  buildPostMetadata,
9
9
  postRedirectPath,
10
10
  } from "@roottale/cms-renderer-next/routes";
11
+ import { articleSchema } from "@roottale/cms-client/server";
11
12
 
12
13
  import { getAllPosts, getPost } from "@/lib/blog";
13
14
 
15
+ const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
16
+
14
17
  export const revalidate = 1800;
15
18
 
16
19
  type Props = { params: Promise<{ slug: string }> };
@@ -24,10 +27,15 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
24
27
  const { slug } = await params;
25
28
  const post = await getPost(slug);
26
29
  if (!post) return {};
27
- // 어드민 SEO 패널(metaJson.seo) override + self-canonical 을 1줄로.
30
+ // 어드민 SEO 패널(metaJson.seo) override + self-canonical + avcd 구조(RSS
31
+ // alternate·Twitter Card·og article 확장)까지 1줄로. modified/section/tags 는
32
+ // 있는 만큼만 넘기면 됩니다 — 없으면 해당 필드만 생략.
28
33
  return buildPostMetadata(post, {
29
- siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
34
+ siteUrl: SITE_URL,
30
35
  path: `/blog/${post.slug}`,
36
+ modified: post.modified,
37
+ section: post.category || undefined,
38
+ tags: post.tags.map((t) => t.name),
31
39
  });
32
40
  }
33
41
 
@@ -39,14 +47,41 @@ export default async function PostPage({ params }: Props) {
39
47
  const redirect = postRedirectPath(post, slug);
40
48
  if (redirect) permanentRedirect(redirect);
41
49
 
50
+ const url = `${SITE_URL}/blog/${post.slug}`;
51
+ // avcd 구조 — BlogPosting + 확장 옵션(전부 선택, 있는 값만 채우면 됩니다).
52
+ const jsonLd = articleSchema({
53
+ type: "BlogPosting",
54
+ id: `${url}#article`,
55
+ headline: post.title,
56
+ description: post.description,
57
+ url,
58
+ image: post.image ?? undefined,
59
+ datePublished: post.date,
60
+ dateModified: post.modified,
61
+ authorName: post.authorName,
62
+ authorUrl: post.authorSlug
63
+ ? `${SITE_URL}/blog/author/${post.authorSlug}`
64
+ : undefined,
65
+ publisherName: "예시 사이트",
66
+ section: post.category || undefined,
67
+ inLanguage: "ko",
68
+ });
69
+
42
70
  return (
43
71
  <main>
72
+ <script
73
+ type="application/ld+json"
74
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
75
+ />
44
76
  <h1>{post.title}</h1>
45
77
  <RootTaleBlogPost
46
78
  apiKey={process.env.ROOTTALE_API_KEY!}
47
79
  baseUrl={process.env.ROOTTALE_API_BASE}
48
80
  slugOrId={post.id}
49
81
  showTitle={false}
82
+ // opt-in — 시각 브레드크럼 + BreadcrumbList JSON-LD. siteUrl 없으면
83
+ // 시각 브레드크럼만(JSON-LD 미emit).
84
+ breadcrumb={{ siteUrl: SITE_URL }}
50
85
  />
51
86
  </main>
52
87
  );
@@ -0,0 +1,60 @@
1
+ // 카테고리 허브 — RootTaleBlogList/RootTaleBlogCategories(카테고리 칩·목록)와
2
+ // RootTaleBlogPost의 breadcrumb prop(카테고리 세그먼트) 기본 링크가 모두
3
+ // `/blog/categories/{slug}`를 가리킵니다(defaultCategoryHref). 이 라우트가
4
+ // 없으면 그 링크들을 클릭했을 때 404가 됩니다 — 단일 블로그 사이트도 예외
5
+ // 아닙니다. 공지·블로그를 나눈 섹션형 사이트는 `{basePath}/categories/{slug}`
6
+ // 패턴(collections.md 참고)을 대신 쓰세요.
7
+ import type { Metadata } from "next";
8
+ import Link from "next/link";
9
+ import { notFound } from "next/navigation";
10
+ import { collectionPageSchema } from "@roottale/cms-client/server";
11
+
12
+ import { getPostsByCategory } from "@/lib/blog";
13
+
14
+ const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
15
+
16
+ type Props = { params: Promise<{ slug: string }> };
17
+
18
+ export const revalidate = 1800;
19
+
20
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
21
+ const { slug } = await params;
22
+ const posts = await getPostsByCategory(slug);
23
+ if (posts.length === 0) return { title: "카테고리" };
24
+ return {
25
+ title: `${posts[0]!.category} 글 모음`,
26
+ alternates: { canonical: `/blog/categories/${slug}` },
27
+ };
28
+ }
29
+
30
+ export default async function CategoryArchivePage({ params }: Props) {
31
+ const { slug } = await params;
32
+ const posts = await getPostsByCategory(slug);
33
+ if (posts.length === 0) notFound();
34
+
35
+ const categoryName = posts[0]!.category;
36
+ const url = `${SITE_URL}/blog/categories/${slug}`;
37
+ // 글 목록 페이지용 CollectionPage JSON-LD (articleSchema는 글 상세 전용).
38
+ const jsonLd = collectionPageSchema({
39
+ url,
40
+ name: `${categoryName} 글 모음`,
41
+ siteUrl: SITE_URL,
42
+ });
43
+
44
+ return (
45
+ <main>
46
+ <script
47
+ type="application/ld+json"
48
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
49
+ />
50
+ <h1>{categoryName}</h1>
51
+ <ul>
52
+ {posts.map((post) => (
53
+ <li key={post.id}>
54
+ <Link href={`/blog/${post.slug}`}>{post.title}</Link>
55
+ </li>
56
+ ))}
57
+ </ul>
58
+ </main>
59
+ );
60
+ }
@@ -20,10 +20,13 @@ export interface BlogPostMeta {
20
20
  title: string;
21
21
  description: string;
22
22
  date: string; // publishedAt ISO
23
+ modified: string; // updatedAt ISO — avcd 구조 og:article:modified_time·dateModified
23
24
  category: string;
25
+ categorySlug: string; // "" = 카테고리 미지정 — /blog/categories/{slug} 허브 필터링용
24
26
  tags: { name: string; slug: string }[];
25
27
  image: string | null;
26
28
  authorName?: string;
29
+ authorSlug?: string | null; // /blog/author/{slug} — 미발급 작가는 null
27
30
  seo?: {
28
31
  title?: string;
29
32
  description?: string;
@@ -43,13 +46,16 @@ function toMeta(post: CmsPostContent): BlogPostMeta {
43
46
  title: post.title,
44
47
  description: post.excerpt ?? "",
45
48
  date: post.publishedAt ?? "",
49
+ modified: post.updatedAt,
46
50
  category: category?.name ?? "",
51
+ categorySlug: category?.slug ?? "",
47
52
  tags:
48
53
  post.terms
49
54
  ?.filter((t) => t.taxonomy === "tag")
50
55
  .map((t) => ({ name: t.name, slug: t.slug })) ?? [],
51
56
  image: post.featuredImageUrl ?? null,
52
57
  authorName: post.authorName ?? undefined,
58
+ authorSlug: post.authorSlug,
53
59
  seo: meta.seo as BlogPostMeta["seo"],
54
60
  };
55
61
  }
@@ -71,3 +77,14 @@ export async function getPost(
71
77
  if (!post) return null;
72
78
  return { ...toMeta(post), bodyJson: post.bodyJson };
73
79
  }
80
+
81
+ // 카테고리 허브(`/blog/categories/{slug}`)용 — RootTaleBlogList/RootTaleBlogCategories
82
+ // 의 기본 카테고리 링크가 이 경로를 가리키므로(defaultCategoryHref), 사이트는 이
83
+ // 라우트를 반드시 구현해야 합니다(안 하면 카테고리 칩·브레드크럼 클릭이 404).
84
+ // `/v1/cms/public/posts` 는 카테고리 서버 필터가 없어 클라이언트에서 거릅니다.
85
+ export async function getPostsByCategory(
86
+ categorySlug: string,
87
+ ): Promise<BlogPostMeta[]> {
88
+ const posts = await getAllPosts();
89
+ return posts.filter((p) => p.categorySlug === categorySlug);
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-mcp",
3
- "version": "0.46.0",
3
+ "version": "0.48.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": {