@roottale/cms-mcp 0.22.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 +15 -0
- package/README.md +59 -0
- package/dist/index.js +285 -0
- package/dist/index.js.map +1 -0
- package/docs/api-reference.md +117 -0
- package/docs/blog.md +172 -0
- package/docs/getting-started.md +78 -0
- package/docs/inquiries.md +114 -0
- package/docs/overview.md +63 -0
- package/docs/revalidation-webhooks.md +129 -0
- package/docs/seo.md +103 -0
- package/docs/theme-and-settings.md +64 -0
- package/examples/nextjs/app/.well-known/roottale.json/route.ts +4 -0
- package/examples/nextjs/app/api/revalidate/route.ts +22 -0
- package/examples/nextjs/app/blog/[slug]/page.tsx +54 -0
- package/examples/nextjs/app/blog/page.tsx +34 -0
- package/examples/nextjs/app/feed.xml/route.ts +15 -0
- package/examples/nextjs/app/sitemap.ts +19 -0
- package/examples/nextjs/lib/actions/submit-contact.ts +50 -0
- package/examples/nextjs/lib/blog.ts +72 -0
- package/package.json +50 -0
package/docs/blog.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 블로그 연동
|
|
3
|
+
description: 블로그 목록/상세 페이지 구현 — 컴포넌트 빠른 경로와 직접 fetch 커스텀 경로
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 블로그 연동
|
|
7
|
+
|
|
8
|
+
두 가지 경로가 있습니다:
|
|
9
|
+
|
|
10
|
+
- **빠른 경로** — `@roottale/cms-renderer-next`의 RSC 컴포넌트 사용. 데이터
|
|
11
|
+
fetch + 렌더링까지 한 번에.
|
|
12
|
+
- **커스텀 경로** — `@roottale/cms-client`로 raw 데이터를 가져와 자체 UI로
|
|
13
|
+
렌더링. 디자인을 완전히 통제할 때.
|
|
14
|
+
|
|
15
|
+
본문 렌더링은 두 경로 모두 `RootTaleBlogPost`(블록 JSON → React)를 쓰는 것을
|
|
16
|
+
권장합니다. 본문 JSON 스키마를 직접 파싱하지 마세요.
|
|
17
|
+
|
|
18
|
+
## 빠른 경로 — 컴포넌트
|
|
19
|
+
|
|
20
|
+
### 목록 페이지
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
// app/blog/page.tsx
|
|
24
|
+
import { RootTaleBlogList } from "@roottale/cms-renderer-next/server";
|
|
25
|
+
|
|
26
|
+
export const revalidate = 1800; // 30분 fallback — 실시간 갱신은 웹훅이 담당
|
|
27
|
+
|
|
28
|
+
export default function BlogPage() {
|
|
29
|
+
return (
|
|
30
|
+
<RootTaleBlogList
|
|
31
|
+
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
32
|
+
baseUrl={process.env.ROOTTALE_API_BASE}
|
|
33
|
+
limit={20}
|
|
34
|
+
showCategoryFilter
|
|
35
|
+
postHref={(post) => `/blog/${post.slug}`}
|
|
36
|
+
/>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 상세 페이지
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
// app/blog/[slug]/page.tsx
|
|
45
|
+
import { RootTaleBlogPost } from "@roottale/cms-renderer-next/server";
|
|
46
|
+
|
|
47
|
+
export const revalidate = 1800;
|
|
48
|
+
|
|
49
|
+
export default async function PostPage({
|
|
50
|
+
params,
|
|
51
|
+
}: {
|
|
52
|
+
params: Promise<{ slug: string }>;
|
|
53
|
+
}) {
|
|
54
|
+
const { slug } = await params; // Next.js 15+ async params
|
|
55
|
+
return (
|
|
56
|
+
<RootTaleBlogPost
|
|
57
|
+
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
58
|
+
baseUrl={process.env.ROOTTALE_API_BASE}
|
|
59
|
+
slugOrId={slug}
|
|
60
|
+
showTableOfContents
|
|
61
|
+
tableOfContentsTitle="목차"
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
목차(ToC)·작성자 카드·발행일 표시는 어드민의 블로그 표시 설정으로도 제어됩니다
|
|
68
|
+
(`theme-and-settings.md` 참고).
|
|
69
|
+
|
|
70
|
+
### 고정 페이지 (회사소개 등)
|
|
71
|
+
|
|
72
|
+
어드민의 고정 페이지(`type: "page"`)는 `RootTalePage`로 렌더링합니다 — 블로그
|
|
73
|
+
크롬(날짜·작성자·작성자 카드) 없이 제목+본문만 출력합니다 (renderer-next
|
|
74
|
+
0.22.0+):
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// app/about/page.tsx
|
|
78
|
+
import { RootTalePage } from "@roottale/cms-renderer-next/server";
|
|
79
|
+
|
|
80
|
+
export const revalidate = 1800;
|
|
81
|
+
|
|
82
|
+
export default function AboutPage() {
|
|
83
|
+
return (
|
|
84
|
+
<RootTalePage
|
|
85
|
+
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
86
|
+
baseUrl={process.env.ROOTTALE_API_BASE}
|
|
87
|
+
slugOrId="about"
|
|
88
|
+
// showTitle={false} — 페이지 제목을 직접 마크업할 때
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 커스텀 경로 — 직접 fetch
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// lib/blog.ts
|
|
98
|
+
import { fetchPosts, fetchPost, type CmsPostContent } from "@roottale/cms-client/server";
|
|
99
|
+
|
|
100
|
+
export async function getAllPosts() {
|
|
101
|
+
const page = await fetchPosts({
|
|
102
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
103
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
104
|
+
type: "post",
|
|
105
|
+
limit: 100,
|
|
106
|
+
});
|
|
107
|
+
return page.items; // CmsPostContent[]
|
|
108
|
+
// page.hasMore / page.nextCursor 로 커서 페이지네이션
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function getPost(slug: string) {
|
|
112
|
+
return fetchPost({
|
|
113
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
114
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
115
|
+
slugOrId: slug, // slug 또는 UUID — 404면 null 반환
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`CmsPostContent` 주요 필드:
|
|
121
|
+
|
|
122
|
+
| 필드 | 설명 |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `id`, `slug`, `title` | 식별자·제목 |
|
|
125
|
+
| `excerpt` | 요약 (목록 카드용) |
|
|
126
|
+
| `publishedAt` | 발행 시각 (ISO) |
|
|
127
|
+
| `bodyJson` | 본문 블록 JSON — `RootTaleBlogPost` 또는 `renderBlocks`로 렌더 |
|
|
128
|
+
| `terms` | 분류 용어 배열 (`taxonomy: "category" \| "tag"`, `name`, `slug`) |
|
|
129
|
+
| `featuredImageUrl` | 대표 이미지 |
|
|
130
|
+
| `authorName` | 작성자 표시명 |
|
|
131
|
+
| `metaJson` | 부가 메타 — `metaJson.seo`에 SEO 오버라이드 |
|
|
132
|
+
|
|
133
|
+
### 정적 경로 사전 생성 + 메타데이터
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
// app/blog/[slug]/page.tsx (커스텀 UI 버전)
|
|
137
|
+
import type { Metadata } from "next";
|
|
138
|
+
import { getAllPosts, getPost } from "@/lib/blog";
|
|
139
|
+
|
|
140
|
+
export async function generateStaticParams() {
|
|
141
|
+
const posts = await getAllPosts();
|
|
142
|
+
return posts.map((p) => ({ slug: p.slug }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function generateMetadata({
|
|
146
|
+
params,
|
|
147
|
+
}: {
|
|
148
|
+
params: Promise<{ slug: string }>;
|
|
149
|
+
}): Promise<Metadata> {
|
|
150
|
+
const { slug } = await params;
|
|
151
|
+
const post = await getPost(slug);
|
|
152
|
+
if (!post) return {};
|
|
153
|
+
// 어드민 글 에디터의 SEO 패널 값(metaJson.seo)을 우선 적용
|
|
154
|
+
const seo = (post.metaJson as { seo?: Record<string, string | boolean> })?.seo;
|
|
155
|
+
return {
|
|
156
|
+
title: (seo?.title as string) || post.title,
|
|
157
|
+
description: (seo?.description as string) || post.excerpt,
|
|
158
|
+
...(seo?.noindex ? { robots: { index: false, follow: true } } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`, `noindex`.
|
|
164
|
+
|
|
165
|
+
## 캐싱 전략
|
|
166
|
+
|
|
167
|
+
- 페이지에 `export const revalidate = 1800` (30분) — **fallback일 뿐**입니다.
|
|
168
|
+
- 정상 동작은 발행 웹훅이 즉시 revalidate 하는 것 → `revalidation-webhooks.md`
|
|
169
|
+
를 반드시 함께 설정하세요.
|
|
170
|
+
- 홈 화면에 최신 글 섹션을 둔다면 홈도 웹훅의 `alsoRevalidate`에 포함하세요.
|
|
171
|
+
|
|
172
|
+
완전한 동작 예시는 MCP tool `readRootTaleNextjsExampleCode`로 확인할 수 있습니다.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 시작하기
|
|
3
|
+
description: API 키 발급, 환경변수 설정, 패키지 설치, 첫 콘텐츠 조회
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 시작하기
|
|
7
|
+
|
|
8
|
+
## 1. API 키 발급
|
|
9
|
+
|
|
10
|
+
1. 어드민(`mysite.roottale.com`) 로그인
|
|
11
|
+
2. **설정 > 사이트 연결 키** 메뉴로 이동
|
|
12
|
+
3. 새 키 발급 — 권한 선택:
|
|
13
|
+
- **read** (기본): 발행된 콘텐츠 조회만. 외부 사이트 연동은 이걸로 충분
|
|
14
|
+
- **read_write**: 쓰기 포함 (자동화 서버용 — 일반 연동에는 불필요)
|
|
15
|
+
4. 발급된 키(`rtlk_cust_` + 24자)는 **발급 직후 1회만 평문 표시**됩니다. 바로
|
|
16
|
+
복사해 환경변수에 저장하세요.
|
|
17
|
+
|
|
18
|
+
키는 사이트 단위로 스코프되어(site-scoped) 해당 사이트의 콘텐츠·웹훅 검증에만
|
|
19
|
+
사용됩니다.
|
|
20
|
+
|
|
21
|
+
## 2. 환경변수
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# .env.local (Next.js) 또는 배포 플랫폼의 환경변수 — 반드시 서버 전용
|
|
25
|
+
ROOTTALE_API_KEY=rtlk_cust_xxxxxxxxxxxxxxxxxxxxxxxx
|
|
26
|
+
|
|
27
|
+
# (선택) API 베이스 오버라이드 — 기본값 https://api.roottale.com 이면 생략
|
|
28
|
+
# ROOTTALE_API_BASE=https://api.roottale.com
|
|
29
|
+
|
|
30
|
+
# 사이트 정식 도메인 (RSS/sitemap/canonical 생성용)
|
|
31
|
+
NEXT_PUBLIC_SITE_URL=https://example.com
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**중요**: `ROOTTALE_API_KEY`는 절대 `NEXT_PUBLIC_*` 접두를 붙이지 마세요.
|
|
35
|
+
브라우저로 노출되면 누구나 그 키로 API를 호출할 수 있습니다.
|
|
36
|
+
`@roottale/cms-client`는 브라우저에서 실행되면 의도적으로 에러를 던집니다.
|
|
37
|
+
|
|
38
|
+
## 3. 패키지 설치
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Next.js 사이트
|
|
42
|
+
npm install @roottale/cms-client @roottale/cms-renderer-next
|
|
43
|
+
# 또는
|
|
44
|
+
pnpm add @roottale/cms-client @roottale/cms-renderer-next
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
요구사항: Node ≥ 18.18, React 19, Next.js 14+ (renderer-next 사용 시).
|
|
48
|
+
|
|
49
|
+
렌더러 스타일은 root layout에서 1회 import:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// app/layout.tsx
|
|
53
|
+
import "@roottale/cms-renderer-next/styles";
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 4. 첫 조회 — 동작 확인
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// 서버 컴포넌트, Route Handler, 또는 빌드 스크립트에서
|
|
60
|
+
import { fetchPosts } from "@roottale/cms-client/server";
|
|
61
|
+
|
|
62
|
+
const page = await fetchPosts({
|
|
63
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
64
|
+
limit: 5,
|
|
65
|
+
type: "post",
|
|
66
|
+
});
|
|
67
|
+
console.log(page.items.map((p) => p.slug));
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
응답이 비어 있다면 어드민에서 글이 **발행(published)** 상태인지 확인하세요.
|
|
71
|
+
공개 API는 발행된 콘텐츠만 반환합니다.
|
|
72
|
+
|
|
73
|
+
`401` 에러(`invalid_key`)면 키 값/환경변수 로딩을 확인하세요.
|
|
74
|
+
|
|
75
|
+
## 다음 단계
|
|
76
|
+
|
|
77
|
+
- 블로그 페이지 구현 → `blog.md`
|
|
78
|
+
- 발행 즉시 사이트 반영 → `revalidation-webhooks.md`
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 상담문의(리드) 연동
|
|
3
|
+
description: submitInquiry 서버 액션으로 문의 폼을 어드민 CRM에 연결
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 상담문의(리드) 연동
|
|
7
|
+
|
|
8
|
+
사이트의 문의 폼 제출을 RootTale로 보내면 어드민 **CRM(받은문의)** 에서
|
|
9
|
+
관리됩니다. 블로그 조회와 **같은 API 키 하나**로 동작하며, 키가 테넌트를
|
|
10
|
+
식별하므로 별도 식별자가 필요 없습니다. 개인정보(이름·연락처 등)는 서버에서
|
|
11
|
+
암호화 저장됩니다.
|
|
12
|
+
|
|
13
|
+
## 권장 — Next.js Server Action
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// lib/actions/submit-contact.ts
|
|
17
|
+
"use server";
|
|
18
|
+
|
|
19
|
+
import { submitInquiry } from "@roottale/cms-client/server";
|
|
20
|
+
|
|
21
|
+
export interface ContactState {
|
|
22
|
+
status: "idle" | "success" | "error";
|
|
23
|
+
message?: string;
|
|
24
|
+
errors?: Partial<Record<"name" | "phone" | "privacyConsent", string>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function submitContact(
|
|
28
|
+
_prev: ContactState,
|
|
29
|
+
formData: FormData,
|
|
30
|
+
): Promise<ContactState> {
|
|
31
|
+
const name = (formData.get("name") as string | null)?.trim() ?? "";
|
|
32
|
+
const phone = (formData.get("phone") as string | null)?.trim() ?? "";
|
|
33
|
+
const message = (formData.get("message") as string | null)?.trim() ?? "";
|
|
34
|
+
const privacyConsent = formData.get("privacyConsent") !== null;
|
|
35
|
+
|
|
36
|
+
const errors: ContactState["errors"] = {};
|
|
37
|
+
if (!name) errors.name = "이름을 입력해주세요.";
|
|
38
|
+
if (!phone || phone.length < 7) errors.phone = "연락처를 입력해주세요.";
|
|
39
|
+
if (!privacyConsent) errors.privacyConsent = "개인정보 수집·이용에 동의해주세요.";
|
|
40
|
+
if (Object.keys(errors).length > 0) {
|
|
41
|
+
return { status: "error", message: "필수 항목을 입력해주세요.", errors };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const result = await submitInquiry({
|
|
45
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
46
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
47
|
+
fields: {
|
|
48
|
+
vertical: "tax", // consulting | medical | tax | legal
|
|
49
|
+
contactName: name,
|
|
50
|
+
businessName: name, // 사업체명 미수집 폼이면 이름으로 대체
|
|
51
|
+
email: `noemail-${name}@example.invalid`, // 이메일 미수집 폼이면 placeholder
|
|
52
|
+
phone, // 자동으로 010-1234-5678 형태 포맷됨
|
|
53
|
+
message: message || undefined,
|
|
54
|
+
privacyConsent: true, // 사용자가 명시 동의한 경우에만 true
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (result.ok) {
|
|
59
|
+
return { status: "success", message: "상담 문의가 접수되었습니다." };
|
|
60
|
+
}
|
|
61
|
+
return { status: "error", message: result.message };
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
클라이언트 폼에서는 `useActionState(submitContact, { status: "idle" })`로
|
|
66
|
+
연결합니다.
|
|
67
|
+
|
|
68
|
+
## 필드 레퍼런스 (`SubmitInquiryFields`)
|
|
69
|
+
|
|
70
|
+
| 필드 | 필수 | 설명 |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `vertical` | ✅ | `consulting` \| `medical` \| `tax` \| `legal` |
|
|
73
|
+
| `contactName` | ✅ | 이름 |
|
|
74
|
+
| `businessName` | ✅ | 사업체명 (미수집 시 이름으로 대체) |
|
|
75
|
+
| `email` | ✅ | 이메일 (`.+@.+\..+`) |
|
|
76
|
+
| `phone` | ✅ | 전화번호 (자동 한국식 포맷) |
|
|
77
|
+
| `privacyConsent` | ✅ | 개인정보 수집·이용 동의 — 반드시 사용자 명시 동의 |
|
|
78
|
+
| `message` | | 문의 내용 |
|
|
79
|
+
| `consultationField` | | 상담 분야 라벨 |
|
|
80
|
+
| `currentSiteUrl` | | 현재 사이트 URL |
|
|
81
|
+
| `overseasTransferConsent` | medical 시 ✅ | 국외이전 동의 |
|
|
82
|
+
| `leadKind` | | `patient`(기본) \| `sales` |
|
|
83
|
+
| `extras` | | 임의 추가 항목 (최대 50개, 암호화 보관, CRM 상세에 노출) |
|
|
84
|
+
|
|
85
|
+
`extras`에 개인정보가 담길 수 있으므로 폼의 동의 고지에 수집 항목을 반영하세요.
|
|
86
|
+
|
|
87
|
+
## 에러 처리
|
|
88
|
+
|
|
89
|
+
`submitInquiry`는 throw 하지 않고 구조화된 결과를 반환합니다:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
type SubmitInquiryResult =
|
|
93
|
+
| { ok: true }
|
|
94
|
+
| { ok: false; code: string | null; message: string }; // message = 한국어 사용자 메시지
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
| `code` | 의미 |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `consent_privacy` | 개인정보 동의 누락 |
|
|
100
|
+
| `consent_overseas` | medical인데 국외이전 동의 누락 |
|
|
101
|
+
| `missing_fields` | 필수 필드 누락 |
|
|
102
|
+
| `invalid_email` | 이메일 형식 오류 |
|
|
103
|
+
| `invalid_vertical` | 허용되지 않는 vertical |
|
|
104
|
+
| `invalid_api_key` | 키 인증 실패 |
|
|
105
|
+
| `internal` | 서버/네트워크 오류 |
|
|
106
|
+
|
|
107
|
+
## 대안 — RootTaleLeadForm 컴포넌트
|
|
108
|
+
|
|
109
|
+
자체 폼 없이 빠르게 붙일 때는 `@roottale/cms-renderer-next`의
|
|
110
|
+
`RootTaleLeadForm`(RSC, HTML form)을 사용할 수 있습니다. 디자인·검증을
|
|
111
|
+
통제하려면 위의 Server Action 방식을 권장합니다.
|
|
112
|
+
|
|
113
|
+
raw HTTP로 직접 연동(비 JS 스택)하려면 `api-reference.md`의
|
|
114
|
+
`POST /v1/public/inquiries`를 참고하세요.
|
package/docs/overview.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: RootTale CMS 연동 개요
|
|
3
|
+
description: 연동 아키텍처, 단일 API 키 모델, 패키지 구성, 문서 맵
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# RootTale CMS 연동 개요
|
|
7
|
+
|
|
8
|
+
RootTale CMS는 어드민(`mysite.roottale.com`)에서 콘텐츠를 작성·발행하고, 외부
|
|
9
|
+
고객 사이트(자체 도메인의 Next.js/Astro 등)가 공개 API(`api.roottale.com`)로
|
|
10
|
+
콘텐츠를 가져가는 헤드리스 구조입니다.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
어드민 (mysite.roottale.com) 고객 사이트 (예: example.com)
|
|
14
|
+
글 작성·발행 ──────────┐
|
|
15
|
+
▼
|
|
16
|
+
api.roottale.com ◀── Bearer rtlk_cust_* ── 콘텐츠/설정 조회
|
|
17
|
+
│
|
|
18
|
+
발행 웹훅 (ES256 서명) ─┴──────────▶ POST /api/revalidate → 캐시 즉시 갱신
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 단일 API 키 모델
|
|
22
|
+
|
|
23
|
+
연동 전체가 **API 키 하나(`rtlk_cust_*`)** 로 동작합니다:
|
|
24
|
+
|
|
25
|
+
| 기능 | 같은 키 하나로 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| 블로그 글 목록/상세 조회 | `fetchPosts` / `fetchPost` |
|
|
28
|
+
| 발행 웹훅 서명 검증 | `createRevalidateRoute` (JWKS 공개키 — 별도 secret 보관 불필요) |
|
|
29
|
+
| 상담문의(리드) 접수 | `submitInquiry` — 키가 테넌트를 식별 |
|
|
30
|
+
| 테마·블로그 표시·분석 태그 설정 조회 | `fetchTheme` / `fetchBlogSettings` / `fetchAnalyticsConfig` |
|
|
31
|
+
|
|
32
|
+
키는 **서버 전용**입니다. 브라우저로 노출되면 안 됩니다(`NEXT_PUBLIC_*` 금지).
|
|
33
|
+
`@roottale/cms-client`는 브라우저에서 import 시 의도적으로 throw 합니다.
|
|
34
|
+
|
|
35
|
+
## 패키지 구성 (npm public)
|
|
36
|
+
|
|
37
|
+
| 패키지 | 역할 |
|
|
38
|
+
|---|---|
|
|
39
|
+
| [`@roottale/cms-client`](https://www.npmjs.com/package/@roottale/cms-client) | 서버 전용 fetch 클라이언트 — 글/테마/설정 조회, 문의 접수, 웹훅 검증 (raw) |
|
|
40
|
+
| [`@roottale/cms-renderer-next`](https://www.npmjs.com/package/@roottale/cms-renderer-next) | Next.js(RSC) 렌더러 — 블로그 컴포넌트, revalidate/RSS/sitemap 라우트 팩토리 |
|
|
41
|
+
| [`@roottale/cms-renderer-astro`](https://www.npmjs.com/package/@roottale/cms-renderer-astro) | Astro 렌더러 |
|
|
42
|
+
| [`@roottale/cms-core`](https://www.npmjs.com/package/@roottale/cms-core) | 블록 JSON 공통 코어 (렌더러들이 의존) |
|
|
43
|
+
| `@roottale/cms-mcp` | 본 MCP 서버 — 통합 문서·예시 코드·API 조회 tool |
|
|
44
|
+
|
|
45
|
+
## 문서 맵
|
|
46
|
+
|
|
47
|
+
| 문서 | 내용 |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `getting-started.md` | API 키 발급, 환경변수, 패키지 설치, 첫 조회 |
|
|
50
|
+
| `blog.md` | 블로그 목록/상세 페이지 구현 (컴포넌트 또는 직접 fetch) |
|
|
51
|
+
| `revalidation-webhooks.md` | 발행 웹훅으로 near-real-time 캐시 갱신 |
|
|
52
|
+
| `inquiries.md` | 상담문의(리드) 폼 연동 |
|
|
53
|
+
| `seo.md` | RSS 피드, 사이트맵, JSON-LD, fleet 프로브 |
|
|
54
|
+
| `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정, 분석 태그 |
|
|
55
|
+
| `api-reference.md` | HTTP API 레퍼런스 (비 JS 스택용 raw 엔드포인트) |
|
|
56
|
+
|
|
57
|
+
## 권장 연동 순서
|
|
58
|
+
|
|
59
|
+
1. `getting-started.md` — 키 발급 + 환경 설정
|
|
60
|
+
2. `blog.md` — `/blog` 목록·상세 페이지
|
|
61
|
+
3. `revalidation-webhooks.md` — 웹훅 등록 (발행 → 즉시 반영)
|
|
62
|
+
4. `seo.md` — RSS·사이트맵
|
|
63
|
+
5. `inquiries.md` — 상담문의 폼 (선택)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 발행 웹훅 (캐시 자동 갱신)
|
|
3
|
+
description: 글 발행/수정 시 사이트 캐시를 near-real-time으로 갱신하는 웹훅 설정
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 발행 웹훅 — 캐시 자동 갱신
|
|
7
|
+
|
|
8
|
+
어드민에서 글을 발행/수정/삭제하면 RootTale이 고객 사이트의 revalidate
|
|
9
|
+
엔드포인트로 **ES256 서명된 웹훅**을 보냅니다. 사이트는 서명을 검증하고
|
|
10
|
+
`revalidatePath`를 호출해 즉시 갱신합니다.
|
|
11
|
+
|
|
12
|
+
- 별도 webhook secret을 보관할 필요가 없습니다 — 검증은 사이트 스코프 API
|
|
13
|
+
키로 JWKS 공개키를 가져와 수행합니다.
|
|
14
|
+
- ISR `revalidate = 1800` 같은 시간 기반 설정은 **fallback**입니다. 정상
|
|
15
|
+
경로는 웹훅입니다.
|
|
16
|
+
|
|
17
|
+
## 1. revalidate 라우트 추가 (Next.js)
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// app/api/revalidate/route.ts
|
|
21
|
+
import { revalidatePath } from "next/cache";
|
|
22
|
+
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
|
|
23
|
+
|
|
24
|
+
export const POST = createRevalidateRoute({
|
|
25
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
26
|
+
apiBase: process.env.ROOTTALE_API_BASE,
|
|
27
|
+
revalidate: revalidatePath,
|
|
28
|
+
// 글 변경 시 함께 갱신할 추가 경로 (기본: /feed.xml, /sitemap.xml, /blog)
|
|
29
|
+
alsoRevalidate: ["/feed.xml", "/sitemap.xml", "/blog", "/"],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function GET(): Response {
|
|
33
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
블로그가 `/blog`가 아닌 경로면 `blogBasePath: "/insights"` 옵션을 추가하세요.
|
|
38
|
+
|
|
39
|
+
카테고리/태그 인덱스 같은 동적 경로가 있다면 `revalidate` 콜백을 확장합니다:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
function revalidateBlogPath(path: string): void {
|
|
43
|
+
revalidatePath(path);
|
|
44
|
+
if (path === "/blog/categories") revalidatePath("/blog/categories/[category]", "page");
|
|
45
|
+
if (path === "/blog/tags") revalidatePath("/blog/tags/[tag]", "page");
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 2. 어드민에 웹훅 URL 등록
|
|
50
|
+
|
|
51
|
+
1. 어드민 **내 사이트 > (사이트 선택)** 페이지로 이동
|
|
52
|
+
2. "글 발행 후 사이트 자동 갱신" 카드에서:
|
|
53
|
+
- **자동 갱신 URL**: `https://<사이트 도메인>/api/revalidate`
|
|
54
|
+
- **활성화** 체크
|
|
55
|
+
3. 저장 — ES256 키페어가 자동 발급됩니다 (고객 측 보관 항목 없음)
|
|
56
|
+
|
|
57
|
+
URL을 비우고 저장하면 웹훅이 비활성화됩니다.
|
|
58
|
+
|
|
59
|
+
## 웹훅 발송 트리거
|
|
60
|
+
|
|
61
|
+
- 게시물 생성/발행/수정/삭제/발행 취소
|
|
62
|
+
- 게시물의 카테고리·태그 변경 (블로그 카드의 카테고리 라벨이 바뀌므로)
|
|
63
|
+
- 분류(taxonomy) 용어 삭제 (해당 용어를 참조하던 발행 글 전부)
|
|
64
|
+
- 블로그 표시 설정 변경 (TOC, 작성자/발행일, 작성자 카드)
|
|
65
|
+
- 디자인 토큰 변경
|
|
66
|
+
- 수동 revalidation API 호출
|
|
67
|
+
|
|
68
|
+
## 캐시 무효화 규칙
|
|
69
|
+
|
|
70
|
+
수신 측은 다음을 보장해야 합니다 (`createRevalidateRoute`가 기본 처리):
|
|
71
|
+
|
|
72
|
+
- 모든 서명된 이벤트에서 블로그 목록(`/blog`) revalidate — 글 본문이 안
|
|
73
|
+
바뀌어도 카드 메타(카테고리 라벨 등)가 바뀔 수 있음
|
|
74
|
+
- 상세 페이지는 현재 slug + payload의 `paths` 힌트 경로 모두 revalidate
|
|
75
|
+
- 홈에 최신 글 섹션이 있으면 `alsoRevalidate`에 `/` 포함
|
|
76
|
+
|
|
77
|
+
## 저수준 검증 — verifyRootTaleWebhook
|
|
78
|
+
|
|
79
|
+
`createRevalidateRoute`를 못 쓰는 환경(다른 프레임워크 등)은
|
|
80
|
+
`@roottale/cms-client/webhook`으로 직접 검증합니다:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { verifyRootTaleWebhook } from "@roottale/cms-client/webhook";
|
|
84
|
+
|
|
85
|
+
export async function POST(request: Request) {
|
|
86
|
+
const rawBody = await request.text(); // 반드시 파싱 전 raw로 검증
|
|
87
|
+
const result = await verifyRootTaleWebhook({
|
|
88
|
+
rawBody,
|
|
89
|
+
headers: request.headers,
|
|
90
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
91
|
+
});
|
|
92
|
+
if (!result.ok) {
|
|
93
|
+
return Response.json({ reason: result.reason }, { status: 401 });
|
|
94
|
+
}
|
|
95
|
+
// result.event: "post.published" | "post.updated" | "post.deleted"
|
|
96
|
+
// result.payload.paths: 갱신할 root-relative 경로 배열
|
|
97
|
+
return Response.json({ ok: true });
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
실패 `reason` 값: `missing_signature`, `invalid_signature`, `expired`,
|
|
102
|
+
`body_hash_mismatch`, `timestamp_out_of_window`, `replay_seen` 등.
|
|
103
|
+
옵션으로 `expectedSiteId`(멀티 사이트 하드닝), `consumeJti`(replay 방지 저장소),
|
|
104
|
+
`timestampWindowSec`(기본 300초)을 지정할 수 있습니다.
|
|
105
|
+
|
|
106
|
+
## 수동 revalidation API
|
|
107
|
+
|
|
108
|
+
배포 직후 등 강제 갱신이 필요할 때:
|
|
109
|
+
|
|
110
|
+
```http
|
|
111
|
+
POST https://api.roottale.com/v1/cms/revalidate
|
|
112
|
+
Authorization: Bearer rtlk_cust_...
|
|
113
|
+
Content-Type: application/json
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
"event": "post.updated",
|
|
117
|
+
"paths": ["/blog", "/blog/my-post"],
|
|
118
|
+
"slug": "my-post"
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## 트러블슈팅
|
|
123
|
+
|
|
124
|
+
| 증상 | 확인 |
|
|
125
|
+
|---|---|
|
|
126
|
+
| 발행해도 사이트 미반영 | 어드민의 자동 갱신 URL·활성화 체크, 배포 도메인 일치 여부 |
|
|
127
|
+
| 401 `invalid_signature` | `ROOTTALE_API_KEY`가 해당 사이트 스코프 키인지 |
|
|
128
|
+
| 401 `timestamp_out_of_window` | 서버 시계 동기화 (NTP) |
|
|
129
|
+
| 일부 페이지만 갱신 | `alsoRevalidate`·동적 경로 콜백 누락 |
|
package/docs/seo.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: SEO (RSS·사이트맵·JSON-LD)
|
|
3
|
+
description: RSS 피드, 사이트맵, JSON-LD 스키마, fleet 프로브 라우트
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# SEO — RSS·사이트맵·JSON-LD
|
|
7
|
+
|
|
8
|
+
`@roottale/cms-renderer-next/routes`의 팩토리로 RSS·사이트맵을 한 줄에
|
|
9
|
+
구성하고, `@roottale/cms-client/server`의 헬퍼로 JSON-LD를 생성합니다.
|
|
10
|
+
|
|
11
|
+
## RSS 피드
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// app/feed.xml/route.ts
|
|
15
|
+
import { createFeedRoute } from "@roottale/cms-renderer-next/routes";
|
|
16
|
+
|
|
17
|
+
export const dynamic = "force-dynamic";
|
|
18
|
+
|
|
19
|
+
export const GET = createFeedRoute({
|
|
20
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
21
|
+
apiBase: process.env.ROOTTALE_API_BASE,
|
|
22
|
+
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
23
|
+
title: "예시 블로그",
|
|
24
|
+
description: "예시 블로그 설명",
|
|
25
|
+
});
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
발행된 글이 자동 포함된 RSS 2.0 XML을 반환합니다.
|
|
29
|
+
|
|
30
|
+
## 사이트맵
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// app/sitemap.ts
|
|
34
|
+
import { createSitemap } from "@roottale/cms-renderer-next/routes";
|
|
35
|
+
|
|
36
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
|
|
37
|
+
|
|
38
|
+
export default createSitemap(
|
|
39
|
+
{
|
|
40
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
41
|
+
apiBase: process.env.ROOTTALE_API_BASE,
|
|
42
|
+
siteUrl: SITE_URL,
|
|
43
|
+
title: "예시 사이트",
|
|
44
|
+
},
|
|
45
|
+
[
|
|
46
|
+
// 정적 경로 — 발행 글 URL은 자동 추가됨
|
|
47
|
+
{ url: SITE_URL, changeFrequency: "weekly", priority: 1.0 },
|
|
48
|
+
{ url: `${SITE_URL}/blog`, changeFrequency: "weekly", priority: 0.7 },
|
|
49
|
+
{ url: `${SITE_URL}/contact`, changeFrequency: "monthly", priority: 0.9 },
|
|
50
|
+
],
|
|
51
|
+
);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## RSS/사이트맵과 웹훅
|
|
55
|
+
|
|
56
|
+
발행 웹훅의 `alsoRevalidate`에 `/feed.xml`, `/sitemap.xml`을 포함해 글 변경
|
|
57
|
+
시 함께 갱신하세요 (`revalidation-webhooks.md` 참고).
|
|
58
|
+
|
|
59
|
+
## JSON-LD 스키마 헬퍼
|
|
60
|
+
|
|
61
|
+
`@roottale/cms-client/server`에서 제공:
|
|
62
|
+
|
|
63
|
+
| 함수 | 용도 |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `articleSchema(input)` | 블로그 글 상세 페이지 Article |
|
|
66
|
+
| `breadcrumbSchema(items)` | 빵부스러기 |
|
|
67
|
+
| `organizationSchema(input)` | 조직/사업체 |
|
|
68
|
+
| `websiteSchema(input)` | 웹사이트 |
|
|
69
|
+
| `faqSchema(items)` | FAQ |
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { articleSchema } from "@roottale/cms-client/server";
|
|
73
|
+
|
|
74
|
+
const jsonLd = articleSchema({
|
|
75
|
+
title: post.title,
|
|
76
|
+
description: post.excerpt,
|
|
77
|
+
url: `${SITE_URL}/blog/${post.slug}`,
|
|
78
|
+
datePublished: post.publishedAt,
|
|
79
|
+
image: post.featuredImageUrl ?? undefined,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
<script
|
|
83
|
+
type="application/ld+json"
|
|
84
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
85
|
+
/>;
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
저수준 RSS가 필요하면 `generateRssXml` / `rssItemsFromPosts`를 직접 사용할 수
|
|
89
|
+
있습니다.
|
|
90
|
+
|
|
91
|
+
## Fleet 프로브 (운영 가시성)
|
|
92
|
+
|
|
93
|
+
RootTale 운영 측이 배포 버전·헬스를 확인할 수 있는 well-known 라우트:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// app/.well-known/roottale.json/route.ts
|
|
97
|
+
import { createFleetInfoRoute } from "@roottale/cms-renderer-next/routes";
|
|
98
|
+
|
|
99
|
+
export const GET = createFleetInfoRoute({ site: "example" });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`site`에는 사이트 식별용 슬러그를 넣습니다. 필수는 아니지만 운영 지원을
|
|
103
|
+
받으려면 추가를 권장합니다.
|