@roottale/cms-mcp 0.24.0 → 0.32.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 +59 -0
- package/dist/index.js +1 -1
- package/docs/api-reference.md +67 -0
- package/docs/blog.md +41 -2
- package/docs/collections.md +211 -0
- package/docs/menus.md +100 -0
- package/docs/overview.md +4 -2
- package/docs/seo.md +233 -0
- package/docs/theme-and-settings.md +25 -0
- package/examples/nextjs/app/blog/[slug]/opengraph-image.tsx +30 -0
- package/examples/nextjs/app/blog/[slug]/page.tsx +9 -2
- package/examples/nextjs/app/layout.tsx +27 -2
- package/examples/nextjs/app/llms.txt/route.ts +30 -0
- package/examples/nextjs/components/site-nav.tsx +46 -0
- package/examples/nextjs/lib/blog.ts +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# @roottale/cms-mcp
|
|
2
2
|
|
|
3
|
+
## 0.32.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- dfe7814: docs: 콘텐츠 유형(Collections) 전용 문서 추가 (ADR-0060)
|
|
8
|
+
|
|
9
|
+
신규 `docs/collections.md` — 공지·블로그 다중 스트림의 URL 구조, 어드민 "콘텐츠 유형"
|
|
10
|
+
설정, 연동 코드(코드 상수 vs `fetchCollections` 동적 로드), 가드·catch-all·slug 301·동적
|
|
11
|
+
OG·동적 basePath·트러블슈팅을 한 곳에 정리. `docs/seo.md` 의 collections 섹션은 이 문서로
|
|
12
|
+
포인터 처리(드리프트 방지). 공개 엔드포인트 `GET /v1/cms/public/collections` 명시.
|
|
13
|
+
|
|
14
|
+
MCP `listDocs()` 는 docs/ 자동 발견이라 새 문서가 바로 노출되고, roottale-web 문서 사이트는
|
|
15
|
+
`PREFERRED_ORDER` 에 `collections` 추가로 노출된다.
|
|
16
|
+
|
|
17
|
+
## 0.25.0
|
|
18
|
+
|
|
19
|
+
### Minor Changes
|
|
20
|
+
|
|
21
|
+
- 0dcb931: 비즈니스 프로필 (로컬 SEO) — 어드민 "운영 > 비즈니스 프로필" 에서 저장한
|
|
22
|
+
사업장 정보(이름·업종·주소·좌표·영업시간·네이버플레이스/구글 비즈니스
|
|
23
|
+
프로필 URL)를 외부 사이트가 조회해 LocalBusiness JSON-LD 를 자동 렌더.
|
|
24
|
+
- cms-client: `fetchBusinessProfile()` (미설정/구 서버 404 = null) +
|
|
25
|
+
`localBusinessSchema(profile, { url, image? })` — @type 중복 제거,
|
|
26
|
+
PostalAddress/GeoCoordinates/openingHoursSpecification/sameAs.
|
|
27
|
+
- API: `GET /v1/cms/public/business-profile` (cms:read).
|
|
28
|
+
- cms-mcp docs: seo.md 로컬 SEO 섹션, theme-and-settings.md
|
|
29
|
+
fetchBusinessProfile, api-reference.md 엔드포인트, examples layout 적용.
|
|
30
|
+
|
|
31
|
+
- c81991a: CLS(Core Web Vitals) 대책: 본문 이미지에 width/height 출력. 에디터가
|
|
32
|
+
삽입 시 원본 치수를 보존하고(CmsImage), 렌더러(Next·Astro)가 `width`/
|
|
33
|
+
`height` 속성으로 출력 — 기존 CSS(max-inline-size:100%; block-size:auto)와
|
|
34
|
+
조합돼 반응형 유지하면서 레이아웃 시프트 제거. WP import 의 width/height 도
|
|
35
|
+
자동 보존. cms-mcp docs 에 robots.txt·브레드크럼·페이지네이션 가이드 추가.
|
|
36
|
+
- 4e7f9f8: llms.txt 라우트 팩토리 (AEO/GEO): `createLlmsTxtRoute(config)` 추가 —
|
|
37
|
+
llms.txt 스펙(https://llmstxt.org)의 마크다운 인덱스로 AI 검색·생성엔진
|
|
38
|
+
크롤러에 사이트 구조·발행 글(최대 100개, excerpt 1줄)을 노출. fetch 실패도
|
|
39
|
+
항상 200(fail-soft). `createRevalidateRoute` 기본 `alsoRevalidate` 에
|
|
40
|
+
`/llms.txt` 포함 — 글 변경 시 AI 크롤러 인덱스 자동 갱신. docs/examples 반영.
|
|
41
|
+
- 50b6df0: 메뉴(네비게이션) 연동: `fetchMenu({ apiKey, slug })` / `fetchMenus({ apiKey })`
|
|
42
|
+
— 어드민 "디자인 > 메뉴" 트리(`GET /v1/cms/public/menus`)를 헤더/푸터 네비로
|
|
43
|
+
렌더. 깊이 2, 미존재 404 → null/빈 배열 fail-soft. 새 문서 `menus.md` +
|
|
44
|
+
예시 `components/site-nav.tsx`.
|
|
45
|
+
- e84a46c: slug 변경 301 (WP permalink 패리티): public API 가 옛 slug 로도 글을 찾아
|
|
46
|
+
현재 slug 로 응답하고(post_slug_history fallback), `cms-renderer-next/routes`
|
|
47
|
+
에 `postRedirectPath(post, requestedSlug, blogBasePath?)` 헬퍼를 추가 —
|
|
48
|
+
페이지에서 `permanentRedirect()` 로 301 처리. docs/examples 에 패턴 반영.
|
|
49
|
+
- 6262fe7: 동적 OG 이미지: `createPostOgImage(config, { ImageResponse })` +
|
|
50
|
+
`OG_IMAGE_SIZE`/`OG_IMAGE_CONTENT_TYPE` — `app/blog/[slug]/opengraph-image.tsx`
|
|
51
|
+
에서 글별 1200×630 소셜 카드 생성. 한글은 Noto Sans KR 부분셋 런타임 로딩,
|
|
52
|
+
글/폰트 fetch 실패 시 fail-soft. `next` 직접 의존 없음(ImageResponse 주입).
|
|
53
|
+
- 50b6df0: 공개 검색 API: `searchPosts({ apiKey, query, limit?, type? })` — 발행 글
|
|
54
|
+
키워드 검색 (WP `?s=` 패리티, `GET /v1/cms/public/search`). title·excerpt·
|
|
55
|
+
본문 텍스트 부분일치, 본문 미포함 슬림 hit. 구 서버 404 는 빈 배열 fail-soft.
|
|
56
|
+
|
|
57
|
+
### Patch Changes
|
|
58
|
+
|
|
59
|
+
- e7e0849: per-post nofollow 제어 문서화 — 어드민 SEO 패널의 noindex 옆에 nofollow
|
|
60
|
+
토글이 추가됨. `metaJson.seo.nofollow` → Next `robots.follow` 매핑 예시 갱신.
|
|
61
|
+
|
|
3
62
|
## 0.24.0
|
|
4
63
|
|
|
5
64
|
### Minor Changes
|
package/dist/index.js
CHANGED
|
@@ -256,7 +256,7 @@ function registerTools(server2) {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
// src/server.ts
|
|
259
|
-
var VERSION = true ? "0.
|
|
259
|
+
var VERSION = true ? "0.32.0" : "dev";
|
|
260
260
|
var SERVER_INSTRUCTIONS = `
|
|
261
261
|
roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uAE30 \uC704\uD55C
|
|
262
262
|
\uD1B5\uD569 \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7\uACF5\uAC1C API \uC870\uD68C tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
|
package/docs/api-reference.md
CHANGED
|
@@ -36,6 +36,48 @@ JS/TS 스택은 raw 호출 대신 `@roottale/cms-client`를 사용하세요 —
|
|
|
36
36
|
|
|
37
37
|
글 1개 — `identifier`는 slug 또는 UUID. 미발행/없는 글은 `404`.
|
|
38
38
|
|
|
39
|
+
## GET /v1/cms/public/search
|
|
40
|
+
|
|
41
|
+
발행된 글 키워드 검색 (사이트 내 검색, WP `?s=` 패리티). title·excerpt·본문
|
|
42
|
+
텍스트의 case-insensitive 부분일치, 최신 발행순. 응답은 카드 렌더용 슬림
|
|
43
|
+
hit — 본문(`body_json`)은 미포함이므로 상세는 slug 로 글 1개 API를 호출하세요.
|
|
44
|
+
|
|
45
|
+
| 쿼리 | 설명 |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `q` | 검색 키워드 (필수, 1~100자) |
|
|
48
|
+
| `limit` | 결과 수 1~50, 기본 10 |
|
|
49
|
+
| `type` | `post`(기본) \| `page` |
|
|
50
|
+
| `site_id` | 멀티 사이트 키일 때만 |
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{ "tenant_id": "…", "site_id": "…", "query": "세무",
|
|
54
|
+
"items": [ { "id": "…", "type": "post", "title": "…", "slug": "…",
|
|
55
|
+
"excerpt": "…", "featured_media_url": "…",
|
|
56
|
+
"published_at": "…" } ] }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
JS/TS 는 `@roottale/cms-client/server` 의 `searchPosts({ apiKey, query })` 를
|
|
60
|
+
사용하세요 — 구 서버(라우트 미배포)의 404 를 빈 배열로 처리합니다.
|
|
61
|
+
|
|
62
|
+
## GET /v1/cms/public/menus
|
|
63
|
+
|
|
64
|
+
네비게이션 메뉴 전체 — 어드민 "디자인 > 메뉴" 저장값. 항목은 깊이 2 트리.
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{ "tenant_id": "…", "site_id": "…",
|
|
68
|
+
"items": [ { "id": "…", "name": "헤더 메뉴", "slug": "primary",
|
|
69
|
+
"items": [ { "id": "…", "label": "회사 소개", "url": "/about",
|
|
70
|
+
"children": [ { "id": "…", "label": "오시는 길",
|
|
71
|
+
"url": "/about/location" } ] },
|
|
72
|
+
{ "id": "…", "label": "블로그", "url": "/blog" } ],
|
|
73
|
+
"updated_at": "…" } ] }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## GET /v1/cms/public/menus/{slug}
|
|
77
|
+
|
|
78
|
+
위치 핸들(`primary`, `footer` 등)로 메뉴 1개. 없으면 `404` — 사이트는 자체
|
|
79
|
+
fallback 네비를 렌더하세요 (`menus.md` 참고).
|
|
80
|
+
|
|
39
81
|
## GET /v1/cms/public/theme
|
|
40
82
|
|
|
41
83
|
어드민에서 설정한 디자인 토큰. 설정된 그룹만 포함됩니다.
|
|
@@ -57,6 +99,31 @@ JS/TS 스택은 raw 호출 대신 `@roottale/cms-client`를 사용하세요 —
|
|
|
57
99
|
"updated_at": null }
|
|
58
100
|
```
|
|
59
101
|
|
|
102
|
+
## GET /v1/cms/public/business-profile
|
|
103
|
+
|
|
104
|
+
비즈니스 프로필 (로컬 SEO) — 어드민 "운영 > 비즈니스 프로필" 저장값.
|
|
105
|
+
미설정이면 `configured: false` + 필드 `null`.
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{ "tenant_id": "…", "site_id": "…", "configured": true,
|
|
109
|
+
"name": "길동세무회계", "legal_name": null,
|
|
110
|
+
"business_type": "AccountingService",
|
|
111
|
+
"telephone": "02-1234-5678", "email": null,
|
|
112
|
+
"address": { "street_address": "테헤란로 123", "address_locality": "강남구",
|
|
113
|
+
"address_region": "서울특별시", "postal_code": "06234" },
|
|
114
|
+
"geo": { "latitude": 37.5006, "longitude": 127.0364 },
|
|
115
|
+
"opening_hours": [ { "days": ["Mo","Tu","We","Th","Fr"],
|
|
116
|
+
"opens": "09:00", "closes": "18:00" } ],
|
|
117
|
+
"price_range": "₩₩", "area_served": ["서울 강남구"],
|
|
118
|
+
"profiles": { "naver_place": "https://…", "google_business": "https://…",
|
|
119
|
+
"kakao_channel": null, "instagram": null, "naver_blog": null },
|
|
120
|
+
"updated_at": "…" }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`business_type`: `LocalBusiness` | `ProfessionalService` |
|
|
124
|
+
`AccountingService` | `LegalService` | `MedicalClinic` | `Dentist` |
|
|
125
|
+
`RealEstateAgent` | `Restaurant` | `BeautySalon`.
|
|
126
|
+
|
|
60
127
|
## GET /v1/cms/public/analytics
|
|
61
128
|
|
|
62
129
|
분석 태그 설정.
|
package/docs/blog.md
CHANGED
|
@@ -67,6 +67,18 @@ export default async function PostPage({
|
|
|
67
67
|
목차(ToC)·작성자 카드·발행일 표시는 어드민의 블로그 표시 설정으로도 제어됩니다
|
|
68
68
|
(`theme-and-settings.md` 참고).
|
|
69
69
|
|
|
70
|
+
#### 목차 블록 (본문 임의 위치)
|
|
71
|
+
|
|
72
|
+
`showTableOfContents` 는 본문 **상단**에 목차를 자동으로 붙입니다. 글 안의
|
|
73
|
+
원하는 위치(예: 인트로 문단 다음)에 목차를 넣고 싶다면, 어드민 에디터에서
|
|
74
|
+
슬래시 메뉴 `/목차` 로 **목차 블록**(`roottale/table-of-contents`)을 삽입하세요.
|
|
75
|
+
렌더러가 그 위치에 문서의 h2–h4 제목으로 목차(`<nav class="rt-cms-toc">`)를
|
|
76
|
+
생성하고 각 제목에 앵커 id 를 부여합니다 — 상단 자동 ToC 와 동일 마크업·클래스라
|
|
77
|
+
스타일은 그대로 적용됩니다. 헤딩이 하나도 없으면 아무것도 렌더되지 않습니다.
|
|
78
|
+
|
|
79
|
+
블록을 본문에 직접 배치할 때는 상단 자동 ToC 와 중복되지 않도록
|
|
80
|
+
`showTableOfContents` 를 생략(기본 `false`)하는 것을 권장합니다.
|
|
81
|
+
|
|
70
82
|
### 고정 페이지 (회사소개 등)
|
|
71
83
|
|
|
72
84
|
어드민의 고정 페이지(`type: "page"`)는 `RootTalePage`로 렌더링합니다 — 블로그
|
|
@@ -155,12 +167,39 @@ export async function generateMetadata({
|
|
|
155
167
|
return {
|
|
156
168
|
title: (seo?.title as string) || post.title,
|
|
157
169
|
description: (seo?.description as string) || post.excerpt,
|
|
158
|
-
...(seo?.noindex
|
|
170
|
+
...(seo?.noindex || seo?.nofollow
|
|
171
|
+
? { robots: { index: !seo?.noindex, follow: !seo?.nofollow } }
|
|
172
|
+
: {}),
|
|
159
173
|
};
|
|
160
174
|
}
|
|
161
175
|
```
|
|
162
176
|
|
|
163
|
-
SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`,
|
|
177
|
+
SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`,
|
|
178
|
+
`noindex`, `nofollow`.
|
|
179
|
+
|
|
180
|
+
### slug 변경 시 301 리다이렉트 (필수 권장)
|
|
181
|
+
|
|
182
|
+
어드민에서 글 slug를 바꿔도 API는 **옛 slug로 글을 찾아 현재 slug로
|
|
183
|
+
응답**합니다(slug history fallback). 페이지에서 요청 slug와 응답 slug가
|
|
184
|
+
다르면 301로 보내야 검색엔진 순위가 새 URL로 승계됩니다:
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
// app/blog/[slug]/page.tsx
|
|
188
|
+
import { notFound, permanentRedirect } from "next/navigation";
|
|
189
|
+
import { postRedirectPath } from "@roottale/cms-renderer-next/routes";
|
|
190
|
+
|
|
191
|
+
export default async function PostPage({ params }: Props) {
|
|
192
|
+
const { slug } = await params;
|
|
193
|
+
const post = await getPost(slug);
|
|
194
|
+
if (!post) notFound();
|
|
195
|
+
const redirect = postRedirectPath(post, slug); // 기본 basePath "/blog"
|
|
196
|
+
if (redirect) permanentRedirect(redirect);
|
|
197
|
+
// ... 렌더
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`generateMetadata`는 redirect 페이지에서 실행돼도 무방하지만, canonical을
|
|
202
|
+
직접 계산한다면 `post.slug`(현재 slug) 기준으로 계산하세요.
|
|
164
203
|
|
|
165
204
|
## 캐싱 전략
|
|
166
205
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 콘텐츠 유형 (Collections) — 공지·블로그 URL 분리
|
|
3
|
+
description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등 여러 스트림으로 나누는 법. URL 구조, 어드민 설정, 연동 코드, slug·301·OG.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 콘텐츠 유형 (Collections)
|
|
7
|
+
|
|
8
|
+
하나의 글 목록을 **여러 스트림**으로 나눠 서로 다른 URL·레이아웃으로 보여주는 기능입니다.
|
|
9
|
+
가장 흔한 형태는 **공지 게시판(`/notice`) + 블로그(`/blog`)** 분리입니다. 어느 글이 어느
|
|
10
|
+
스트림에 속할지는 그 글의 **카테고리**로 정해집니다.
|
|
11
|
+
|
|
12
|
+
## URL이 어떻게 정해지나
|
|
13
|
+
|
|
14
|
+
> **글의 URL = 그 글이 속한 스트림의 `basePath` + `/{slug}`**
|
|
15
|
+
|
|
16
|
+
| 글의 카테고리 | 속하는 스트림 | URL |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `notice` | 공지 (basePath `/notice`) | `/notice/{slug}` |
|
|
19
|
+
| `column` · `news` | 블로그 (basePath `/blog`) | `/blog/{slug}` |
|
|
20
|
+
|
|
21
|
+
예: `notice` 카테고리 글 "개강안내" → `https://내사이트/notice/개강안내`
|
|
22
|
+
`column` 카테고리 글 "비문학공부법" → `https://내사이트/blog/비문학공부법`
|
|
23
|
+
|
|
24
|
+
스트림별로 함께 만들어지는 경로:
|
|
25
|
+
|
|
26
|
+
| 경로 | 설명 |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `{basePath}` | 스트림 목록 (예: `/notice`, `/blog`) |
|
|
29
|
+
| `{basePath}/{slug}` | 글 상세 |
|
|
30
|
+
| `{basePath}/categories/{slug}` | 카테고리 아카이브 (`archives` 켠 스트림만) |
|
|
31
|
+
| `/feed.xml` | RSS — `feed` 켠 스트림들의 통합 피드 |
|
|
32
|
+
| `/sitemap.xml` | 글마다 **소속 스트림 basePath로** 정확히 매핑 |
|
|
33
|
+
| `{basePath}/{slug}/opengraph-image` | 글별 동적 OG 카드 (배선 시) |
|
|
34
|
+
|
|
35
|
+
규칙:
|
|
36
|
+
|
|
37
|
+
- **slug은 한글 그대로** 됩니다(예: `/blog/비문학독해`). 내부적으로 percent-encoding.
|
|
38
|
+
- **같은 글이 두 스트림에 안 뜸**: 공지 글을 `/blog/개강안내`로 열면 404, 반대도 404(가드).
|
|
39
|
+
- **글이 어느 스트림에도 안 속하면**(분류 전용 카테고리 등) sitemap·feed에서 제외됩니다.
|
|
40
|
+
- 한 글이 여러 스트림 카테고리를 동시에 가지면 **선언 순서가 빠른 스트림**이 이깁니다(first-wins).
|
|
41
|
+
|
|
42
|
+
## 어드민에서 설정 (`mysite.roottale.com`)
|
|
43
|
+
|
|
44
|
+
**설정 > 콘텐츠 유형** 에서 스트림을 정의합니다. 각 스트림은:
|
|
45
|
+
|
|
46
|
+
| 항목 | 의미 |
|
|
47
|
+
|---|---|
|
|
48
|
+
| key | 안정 식별자 (`notice`, `blog`) |
|
|
49
|
+
| 라벨 | 메뉴·작성 화면 표시 이름 (공지/블로그) |
|
|
50
|
+
| basePath | URL 앞부분 (`/notice`, `/blog`) |
|
|
51
|
+
| 카테고리 | 이 스트림에 속하는 카테고리 slug들. **비우면 catch-all**(나머지 전부) |
|
|
52
|
+
| feed / archives / og | RSS 포함 / 카테고리 아카이브 / 동적 OG |
|
|
53
|
+
| 순서 | 위에서부터 우선순위 |
|
|
54
|
+
|
|
55
|
+
**비우면 단일 블로그(`/blog`)** 로 동작합니다(설정 전 기본값).
|
|
56
|
+
|
|
57
|
+
### ⚠️ basePath는 "라우트가 있어야" 동작합니다 (데이터=DB, 라우트=코드)
|
|
58
|
+
|
|
59
|
+
basePath·라벨·카테고리·플래그는 어드민에서 바꾸면 sitemap·feed·라우팅이 즉시 따라갑니다.
|
|
60
|
+
**단 basePath에 해당하는 페이지 파일이 사이트에 있어야** 실제로 열립니다:
|
|
61
|
+
|
|
62
|
+
- `/notice`·`/blog`처럼 **이미 라우트가 있는 경로**는 어드민만으로 자유롭게 편집 → 동작.
|
|
63
|
+
- basePath를 **완전히 새 경로**(예: `/news`)로 바꾸면 sitemap엔 `/news/{slug}`가 나가지만
|
|
64
|
+
사이트에 `app/news/[slug]` 라우트가 없으면 **404**. 이 경우 개발자가 라우트를 먼저 추가해야
|
|
65
|
+
합니다. (새 basePath도 코드 수정 없이 동작시키려면 catch-all 동적 라우트를 쓰면 됩니다 — 아래.)
|
|
66
|
+
|
|
67
|
+
### 카테고리 만들기
|
|
68
|
+
|
|
69
|
+
각 스트림의 카테고리(`notice`, `column`, `news` 등)는 **설정 > 카테고리**(taxonomy)에서
|
|
70
|
+
만들고, 글 작성 화면에서 글에 붙입니다. 작성 화면에는 *"이 글은 → /notice 에 게시됩니다"*
|
|
71
|
+
표시가 떠서 어느 스트림으로 가는지 바로 확인됩니다.
|
|
72
|
+
|
|
73
|
+
## 사이트 연동 코드
|
|
74
|
+
|
|
75
|
+
스트림 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지
|
|
76
|
+
방식이 있습니다.
|
|
77
|
+
|
|
78
|
+
### 방식 A — 코드 상수 (간단, 고정)
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import type { RouteCollection } from "@roottale/cms-renderer-next/routes";
|
|
82
|
+
|
|
83
|
+
export const COLLECTIONS: RouteCollection[] = [
|
|
84
|
+
{ key: "notice", basePath: "/notice", categories: ["notice"] },
|
|
85
|
+
{
|
|
86
|
+
key: "blog",
|
|
87
|
+
basePath: "/blog",
|
|
88
|
+
categories: ["column", "news"], // 또는 [] = catch-all(공지 외 전부)
|
|
89
|
+
feed: true,
|
|
90
|
+
archives: true,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// app/sitemap.ts
|
|
97
|
+
import { createSitemap } from "@roottale/cms-renderer-next/routes";
|
|
98
|
+
export default createSitemap({ apiKey, siteUrl, title, collections: COLLECTIONS }, [
|
|
99
|
+
/* 정적 경로 */
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
// app/feed.xml/route.ts
|
|
103
|
+
export const dynamic = "force-dynamic";
|
|
104
|
+
export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: COLLECTIONS });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### 방식 B — 어드민에서 동적 로드 (운영자가 직접 편집)
|
|
108
|
+
|
|
109
|
+
상수 대신 **어드민 "콘텐츠 유형"** 값을 `fetchCollections()`로 가져와 팩토리에 **resolver
|
|
110
|
+
함수**로 넘깁니다. 운영자가 어드민에서 스트림을 바꾸면 사이트가 따라갑니다.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { fetchCollections } from "@roottale/cms-client/server";
|
|
114
|
+
|
|
115
|
+
const DEFAULT: RouteCollection[] = [ /* 위와 동일 — fail-soft 기본값 */ ];
|
|
116
|
+
|
|
117
|
+
async function getCollections(): Promise<RouteCollection[]> {
|
|
118
|
+
try {
|
|
119
|
+
const c = await fetchCollections({ apiKey: process.env.ROOTTALE_API_KEY! });
|
|
120
|
+
return c.length ? c : DEFAULT;
|
|
121
|
+
} catch {
|
|
122
|
+
return DEFAULT; // API 미설정/실패 시 기본값
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default createSitemap({ apiKey, siteUrl, title, collections: getCollections }, [ ]);
|
|
127
|
+
export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: getCollections });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
공개 엔드포인트: `GET /v1/cms/public/collections` (블로그 조회와 같은 API 키).
|
|
131
|
+
응답은 `RouteCollection`과 구조 호환이라 그대로 넘길 수 있습니다. 매 요청 fetch를 피하려면
|
|
132
|
+
사이트 경계에서 캐시하세요(예: Next `fetch(url, { next: { revalidate: 300 } })`).
|
|
133
|
+
|
|
134
|
+
### 상세 페이지 가드 (스트림 누출 차단)
|
|
135
|
+
|
|
136
|
+
상세 라우트는 글이 그 스트림 소속인지 확인해 다른 스트림 글이 새는 것을 막습니다.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { resolvePostCollection } from "@roottale/cms-renderer-next/routes";
|
|
140
|
+
// app/blog/[slug]/page.tsx
|
|
141
|
+
const post = await getPost(slug);
|
|
142
|
+
if (!post || resolvePostCollection(post, COLLECTIONS)?.key !== "blog") notFound();
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### revalidate
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
|
|
149
|
+
export const POST = createRevalidateRoute({ apiKey, revalidate, collections: COLLECTIONS });
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
각 스트림 basePath(+ `archives`면 `/categories`)와 `/feed.xml`·`/sitemap.xml`·`/llms.txt`를
|
|
153
|
+
자동 무효화합니다. resolver가 실패하면 잘못된 경로를 추측하지 않고 불변 경로만 갱신하며
|
|
154
|
+
응답에 `warning`을 노출합니다.
|
|
155
|
+
|
|
156
|
+
### 동적 OG 이미지
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
// app/blog/[slug]/opengraph-image.tsx
|
|
160
|
+
import { ImageResponse } from "next/og";
|
|
161
|
+
import {
|
|
162
|
+
createPostOgImage, OG_IMAGE_SIZE, OG_IMAGE_CONTENT_TYPE,
|
|
163
|
+
type ImageResponseLike,
|
|
164
|
+
} from "@roottale/cms-renderer-next/routes";
|
|
165
|
+
|
|
166
|
+
export const size = OG_IMAGE_SIZE;
|
|
167
|
+
export const contentType = OG_IMAGE_CONTENT_TYPE;
|
|
168
|
+
export default createPostOgImage(
|
|
169
|
+
{ apiKey, siteUrl, title, brandLabel: "내 사이트" },
|
|
170
|
+
{ ImageResponse: ImageResponse as unknown as ImageResponseLike }, // Next 16 타입 cast
|
|
171
|
+
);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
> Astro 사이트는 `@roottale/cms-renderer-astro`에서 동일한 `resolvePostCollection`/
|
|
175
|
+
> `resolvePostPath`/`RouteCollection`을 import 하고 `renderBlogList({ collections })`로
|
|
176
|
+
> 링크를 스트림별로 라우팅합니다(동등 surface).
|
|
177
|
+
|
|
178
|
+
## catch-all 스트림
|
|
179
|
+
|
|
180
|
+
블로그 카테고리가 계속 늘어나는 사이트는, 블로그를 **`categories: []`(빈 배열) = catch-all**로
|
|
181
|
+
두면 됩니다 — 공지로 분류되지 않은 글을 전부 흡수합니다. catch-all은 **맨 뒤**에 두세요
|
|
182
|
+
(선언 순서가 우선순위라, 뒤에 둔 스트림은 도달 못 합니다).
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
[
|
|
186
|
+
{ key: "notice", basePath: "/notice", categories: ["notice"] },
|
|
187
|
+
{ key: "blog", basePath: "/blog", categories: [], feed: true }, // 나머지 전부
|
|
188
|
+
]
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## slug 변경과 301
|
|
192
|
+
|
|
193
|
+
글의 slug(`/{slug}` 부분)는 글 편집 화면에서 바꿉니다. 바꾼 뒤 옛 slug로 들어오면 `postRedirectPath`로
|
|
194
|
+
**301 리다이렉트**되어 새 slug로 넘어갑니다(검색 순위 보존).
|
|
195
|
+
|
|
196
|
+
## 동적 basePath (advanced)
|
|
197
|
+
|
|
198
|
+
basePath를 **코드 수정 없이 어드민에서 자유롭게** 바꾸고 싶으면, 정적 `app/notice/[slug]`
|
|
199
|
+
대신 **catch-all 동적 라우트** `app/[stream]/[slug]/page.tsx`(또는 `app/[...path]`)를 두고,
|
|
200
|
+
그 안에서 collections를 읽어 요청 경로가 어떤 스트림 basePath인지 판정해 렌더합니다. 그러면
|
|
201
|
+
어드민에서 basePath를 `/news`로 바꿔도 동작합니다. 트레이드오프: 정적 라우트보다 캐시·타입
|
|
202
|
+
안전성이 떨어지므로, 스트림 구조가 자주 바뀌는 사이트에만 권장합니다.
|
|
203
|
+
|
|
204
|
+
## 자주 막히는 곳
|
|
205
|
+
|
|
206
|
+
- **글이 안 보여요** → 그 글에 스트림 카테고리(`notice`/`column`/`news` 등)가 붙어 있는지 확인.
|
|
207
|
+
어드민 콘텐츠 유형이 비어 있으면 사이트는 코드 기본값으로만 동작합니다.
|
|
208
|
+
- **404가 떠요** → 공지 글을 `/blog/...`로(또는 그 반대로) 열면 가드가 막습니다. 올바른 스트림
|
|
209
|
+
basePath로 접근하세요. basePath를 바꿨다면 사이트에 그 라우트 파일이 있는지 확인.
|
|
210
|
+
- **sitemap에 글이 빠졌어요** → 그 글이 어느 스트림에도 안 속하면(상세 라우트 없는 분류 전용)
|
|
211
|
+
의도적으로 제외됩니다.
|
package/docs/menus.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 메뉴 (네비게이션) 연동
|
|
3
|
+
description: 어드민 "디자인 > 메뉴"에서 관리하는 네비게이션을 헤더/푸터에 렌더
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 메뉴 (네비게이션) 연동
|
|
7
|
+
|
|
8
|
+
어드민(mysite.roottale.com)의 **디자인 > 메뉴**에서 저장한 네비게이션 트리를
|
|
9
|
+
사이트의 헤더·푸터에 렌더합니다. 고객이 직접 메뉴 항목(이름·주소·순서·하위
|
|
10
|
+
항목)을 바꿀 수 있어 코드 수정 없이 네비가 갱신됩니다.
|
|
11
|
+
|
|
12
|
+
- 메뉴는 **위치 핸들(slug)** 로 구분합니다 — 관례: `primary`(헤더), `footer`(푸터).
|
|
13
|
+
- 항목은 깊이 2 트리 (상위 + 드롭다운 1단).
|
|
14
|
+
- `url` 은 상대 경로(`/about`) 또는 절대 `http(s)` URL — 서버가 위험 스킴을
|
|
15
|
+
제거한 안전한 값만 내려줍니다.
|
|
16
|
+
- 메뉴 저장 시 발행 웹훅(`post.updated`, paths: `["/"]`)이 발송되므로
|
|
17
|
+
웹훅 수신 라우트(`revalidation-webhooks.md`)를 설정했다면 몇 초 내 반영됩니다.
|
|
18
|
+
|
|
19
|
+
## 서버 컴포넌트에서 메뉴 가져오기
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
// components/site-nav.tsx (Server Component)
|
|
23
|
+
import Link from "next/link";
|
|
24
|
+
import { fetchMenu } from "@roottale/cms-client/server";
|
|
25
|
+
|
|
26
|
+
export async function SiteNav() {
|
|
27
|
+
const menu = await fetchMenu({
|
|
28
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
29
|
+
slug: "primary",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// 메뉴 미설정(null)이면 자체 fallback 네비를 렌더하세요.
|
|
33
|
+
if (!menu) {
|
|
34
|
+
return (
|
|
35
|
+
<nav>
|
|
36
|
+
<Link href="/blog">블로그</Link>
|
|
37
|
+
</nav>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<nav>
|
|
43
|
+
{menu.items.map((item) => (
|
|
44
|
+
<div key={item.id}>
|
|
45
|
+
<Link
|
|
46
|
+
href={item.url}
|
|
47
|
+
{...(item.newTab
|
|
48
|
+
? { target: "_blank", rel: "noopener" }
|
|
49
|
+
: {})}
|
|
50
|
+
>
|
|
51
|
+
{item.label}
|
|
52
|
+
</Link>
|
|
53
|
+
{item.children?.length ? (
|
|
54
|
+
<div>
|
|
55
|
+
{item.children.map((child) => (
|
|
56
|
+
<Link key={child.id} href={child.url}>
|
|
57
|
+
{child.label}
|
|
58
|
+
</Link>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
) : null}
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</nav>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`fetchMenu` 는 미존재/구 서버의 404 를 `null` 로 돌려주므로(fail-soft) 항상
|
|
70
|
+
fallback 분기를 두세요. 전체 메뉴 목록이 필요하면 `fetchMenus({ apiKey })`.
|
|
71
|
+
|
|
72
|
+
## 타입
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
interface RootTaleMenu {
|
|
76
|
+
id: string;
|
|
77
|
+
name: string; // "헤더 메뉴"
|
|
78
|
+
slug: string; // "primary" | "footer" | ...
|
|
79
|
+
items: RootTaleMenuItem[];
|
|
80
|
+
updatedAt: string | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface RootTaleMenuItem {
|
|
84
|
+
id: string;
|
|
85
|
+
label: string;
|
|
86
|
+
url: string; // "/about" 또는 "https://..."
|
|
87
|
+
newTab?: boolean; // target="_blank" rel="noopener" 로 렌더
|
|
88
|
+
children?: RootTaleMenuItem[];
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 캐싱
|
|
93
|
+
|
|
94
|
+
메뉴 응답은 5분 캐시(+SWR)로 내려갑니다. 페이지가 ISR 이라면 발행 웹훅이
|
|
95
|
+
`/` 를 revalidate 할 때 함께 갱신되므로 별도 처리 없이 near-real-time 입니다.
|
|
96
|
+
|
|
97
|
+
## raw HTTP
|
|
98
|
+
|
|
99
|
+
JS 외 스택은 `GET /v1/cms/public/menus/{slug}` 를 직접 호출하세요 —
|
|
100
|
+
`api-reference.md` 참고.
|
package/docs/overview.md
CHANGED
|
@@ -50,7 +50,8 @@ RootTale CMS는 어드민(`mysite.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
50
50
|
| `blog.md` | 블로그 목록/상세 페이지 구현 (컴포넌트 또는 직접 fetch) |
|
|
51
51
|
| `revalidation-webhooks.md` | 발행 웹훅으로 near-real-time 캐시 갱신 |
|
|
52
52
|
| `inquiries.md` | 상담문의(리드) 폼 연동 |
|
|
53
|
-
| `
|
|
53
|
+
| `menus.md` | 메뉴(네비게이션) — 어드민 "디자인 > 메뉴" 트리를 헤더/푸터에 렌더 |
|
|
54
|
+
| `seo.md` | RSS 피드, 사이트맵, JSON-LD, 동적 OG 이미지, 공개 검색, fleet 프로브 |
|
|
54
55
|
| `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정, 분석 태그 |
|
|
55
56
|
| `api-reference.md` | HTTP API 레퍼런스 (비 JS 스택용 raw 엔드포인트) |
|
|
56
57
|
|
|
@@ -59,5 +60,6 @@ RootTale CMS는 어드민(`mysite.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
59
60
|
1. `getting-started.md` — 키 발급 + 환경 설정
|
|
60
61
|
2. `blog.md` — `/blog` 목록·상세 페이지
|
|
61
62
|
3. `revalidation-webhooks.md` — 웹훅 등록 (발행 → 즉시 반영)
|
|
62
|
-
4. `seo.md` — RSS
|
|
63
|
+
4. `seo.md` — RSS·사이트맵·동적 OG 이미지
|
|
63
64
|
5. `inquiries.md` — 상담문의 폼 (선택)
|
|
65
|
+
6. `menus.md` — 어드민 관리 네비게이션 (선택)
|
package/docs/seo.md
CHANGED
|
@@ -51,11 +51,145 @@ export default createSitemap(
|
|
|
51
51
|
);
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
## 다중 스트림 (collections) — 공지·블로그 분리
|
|
55
|
+
|
|
56
|
+
같은 글 풀을 공지 게시판(`/notice`) + 블로그(`/blog`) 등 여러 스트림으로 나눠 서로 다른
|
|
57
|
+
URL·레이아웃으로 보여줄 수 있습니다. URL 구조·어드민 설정·연동 코드(코드 상수 vs 어드민
|
|
58
|
+
fetch)·가드·catch-all·동적 basePath는 별도 문서 [콘텐츠 유형 (Collections)](./collections.md)
|
|
59
|
+
에 정리되어 있습니다. 여기 sitemap/feed 예시도 `collections`를 넘기면 스트림별로 파생됩니다.
|
|
60
|
+
|
|
61
|
+
## robots.txt
|
|
62
|
+
|
|
63
|
+
크롤링 제어의 기본. sitemap 위치를 알려주고, 크롤링이 무의미한 경로만
|
|
64
|
+
차단합니다. **CSS/JS/이미지 경로를 차단하지 마세요** — 구글이 페이지를
|
|
65
|
+
렌더링하지 못해 평가가 깨집니다. 색인 제외가 목적이면 robots.txt 차단이
|
|
66
|
+
아니라 페이지의 noindex 를 쓰세요 (외부 링크가 있으면 차단해도 색인될 수
|
|
67
|
+
있습니다).
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
// app/robots.ts
|
|
71
|
+
import type { MetadataRoute } from "next";
|
|
72
|
+
|
|
73
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
|
|
74
|
+
|
|
75
|
+
export default function robots(): MetadataRoute.Robots {
|
|
76
|
+
return {
|
|
77
|
+
rules: [{ userAgent: "*", allow: "/", disallow: ["/api/"] }],
|
|
78
|
+
sitemap: `${SITE_URL}/sitemap.xml`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 브레드크럼 (BreadcrumbList)
|
|
84
|
+
|
|
85
|
+
사이트 구조를 검색엔진에 전달하고 검색결과에 경로가 표시됩니다. 블로그 글
|
|
86
|
+
상세에서 `breadcrumbSchema` 로 렌더하세요 (UI 브레드크럼과 구조 일치 권장):
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { breadcrumbSchema } from "@roottale/cms-client/server";
|
|
90
|
+
|
|
91
|
+
const category = post.terms.find((t) => t.taxonomy === "category");
|
|
92
|
+
const crumbs = breadcrumbSchema([
|
|
93
|
+
{ name: "홈", url: SITE_URL },
|
|
94
|
+
{ name: "블로그", url: `${SITE_URL}/blog` },
|
|
95
|
+
...(category
|
|
96
|
+
? [{ name: category.name, url: `${SITE_URL}/blog/categories/${category.slug}` }]
|
|
97
|
+
: []),
|
|
98
|
+
{ name: post.title, url: `${SITE_URL}/blog/${post.slug}` },
|
|
99
|
+
]);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 블로그 목록 페이지네이션 주의
|
|
103
|
+
|
|
104
|
+
- 마지막 페이지에 다음(next) 링크를 렌더하지 마세요 — 같은 페이지가 반복
|
|
105
|
+
노출되면 크롤 낭비·중복 신호가 됩니다.
|
|
106
|
+
- 필터·정렬로 내용이 바뀌면 URL(쿼리)도 함께 바뀌어야 하고, canonical 은
|
|
107
|
+
필터 없는 기본 목록을 가리키게 하세요.
|
|
108
|
+
- 검색결과(사이트 내 검색) 페이지는 noindex 처리하세요 — 특히 결과 0건
|
|
109
|
+
페이지가 색인되면 저품질(소프트 404) 신호가 됩니다.
|
|
110
|
+
|
|
54
111
|
## RSS/사이트맵과 웹훅
|
|
55
112
|
|
|
56
113
|
발행 웹훅의 `alsoRevalidate`에 `/feed.xml`, `/sitemap.xml`을 포함해 글 변경
|
|
57
114
|
시 함께 갱신하세요 (`revalidation-webhooks.md` 참고).
|
|
58
115
|
|
|
116
|
+
## slug 변경 시 301 리다이렉트
|
|
117
|
+
|
|
118
|
+
글 slug를 바꿔도 옛 URL이 깨지지 않습니다. API가 slug history로 글을 찾아
|
|
119
|
+
**현재 slug로 응답**하므로, 페이지에서 요청 slug와 비교해 301을 보내세요:
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
import { notFound, permanentRedirect } from "next/navigation";
|
|
123
|
+
import { postRedirectPath } from "@roottale/cms-renderer-next/routes";
|
|
124
|
+
|
|
125
|
+
const post = await getPost(slug);
|
|
126
|
+
if (!post) notFound();
|
|
127
|
+
const redirect = postRedirectPath(post, slug);
|
|
128
|
+
if (redirect) permanentRedirect(redirect);
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
301이어야 기존 URL의 검색 순위·백링크가 새 URL로 승계됩니다 (사이트맵·RSS는
|
|
132
|
+
항상 현재 slug만 포함).
|
|
133
|
+
|
|
134
|
+
## 동적 OG 이미지 (글별 1200×630)
|
|
135
|
+
|
|
136
|
+
글마다 제목·날짜가 들어간 소셜 공유 카드를 자동 생성합니다 — 대표 이미지를
|
|
137
|
+
일일이 만들지 않아도 카카오톡·페이스북·X 공유 시 글 제목이 보이는 카드가
|
|
138
|
+
나갑니다. 한글 제목은 Noto Sans KR 부분셋을 런타임에 받아 렌더합니다.
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
// app/blog/[slug]/opengraph-image.tsx
|
|
142
|
+
import { ImageResponse } from "next/og";
|
|
143
|
+
import {
|
|
144
|
+
createPostOgImage,
|
|
145
|
+
OG_IMAGE_SIZE,
|
|
146
|
+
OG_IMAGE_CONTENT_TYPE,
|
|
147
|
+
type ImageResponseLike,
|
|
148
|
+
} from "@roottale/cms-renderer-next/routes";
|
|
149
|
+
|
|
150
|
+
export const size = OG_IMAGE_SIZE; // { width: 1200, height: 630 }
|
|
151
|
+
export const contentType = OG_IMAGE_CONTENT_TYPE; // "image/png"
|
|
152
|
+
|
|
153
|
+
export default createPostOgImage(
|
|
154
|
+
{
|
|
155
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
156
|
+
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
157
|
+
title: "예시 블로그",
|
|
158
|
+
// 선택 — 브랜드 색 커스텀:
|
|
159
|
+
// backgroundColor: "#10172a", accentColor: "#38bdf8", brandLabel: "예시",
|
|
160
|
+
},
|
|
161
|
+
// Next 16 의 ImageResponse 타입은 패키지 ImageResponseLike 와 미묘하게 달라
|
|
162
|
+
// cast 가 필요하다(패키지는 next 비의존이라 구조적 타입만 안다).
|
|
163
|
+
{ ImageResponse: ImageResponse as unknown as ImageResponseLike },
|
|
164
|
+
);
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `opengraph-image.tsx` 파일 컨벤션이라 별도 meta 태그 없이 Next 가
|
|
168
|
+
`og:image` 를 자동 주입합니다 (`twitter-image.tsx` 로 복제하면 X 카드도).
|
|
169
|
+
- 글이 없거나 API 실패 시 사이트 제목으로 fail-soft 렌더 — 빈 카드가
|
|
170
|
+
나가지 않습니다.
|
|
171
|
+
- `ImageResponse` 는 호출부에서 주입합니다 — 본 패키지는 `next` 에 직접
|
|
172
|
+
의존하지 않습니다.
|
|
173
|
+
|
|
174
|
+
## 공개 검색 (사이트 내 검색)
|
|
175
|
+
|
|
176
|
+
`searchPosts` 로 발행 글 키워드 검색을 붙일 수 있습니다 (WP `?s=` 패리티):
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
// app/search/page.tsx (Server Component)
|
|
180
|
+
import { searchPosts } from "@roottale/cms-client/server";
|
|
181
|
+
|
|
182
|
+
const hits = await searchPosts({
|
|
183
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
184
|
+
query: q, // ?q= 쿼리
|
|
185
|
+
limit: 20,
|
|
186
|
+
});
|
|
187
|
+
// hits: { id, title, slug, excerpt, featuredImageUrl, publishedAt }[]
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
본문은 미포함 슬림 hit 이므로 카드에서 `/blog/{slug}` 로 연결하세요.
|
|
191
|
+
검색결과 페이지는 위 체크리스트대로 **noindex** 처리를 잊지 마세요.
|
|
192
|
+
|
|
59
193
|
## JSON-LD 스키마 헬퍼
|
|
60
194
|
|
|
61
195
|
`@roottale/cms-client/server`에서 제공:
|
|
@@ -65,6 +199,7 @@ export default createSitemap(
|
|
|
65
199
|
| `articleSchema(input)` | 블로그 글 상세 페이지 Article |
|
|
66
200
|
| `breadcrumbSchema(items)` | 빵부스러기 |
|
|
67
201
|
| `organizationSchema(input)` | 조직/사업체 |
|
|
202
|
+
| `localBusinessSchema(profile, opts)` | 사업장 LocalBusiness (로컬 SEO — 아래 섹션) |
|
|
68
203
|
| `websiteSchema(input)` | 웹사이트 |
|
|
69
204
|
| `faqSchema(items)` | FAQ |
|
|
70
205
|
|
|
@@ -88,6 +223,63 @@ const jsonLd = articleSchema({
|
|
|
88
223
|
저수준 RSS가 필요하면 `generateRssXml` / `rssItemsFromPosts`를 직접 사용할 수
|
|
89
224
|
있습니다.
|
|
90
225
|
|
|
226
|
+
## 로컬 SEO (네이버플레이스·구글 비즈니스)
|
|
227
|
+
|
|
228
|
+
어드민 **운영 > 비즈니스 프로필**에서 사업장 정보(이름·업종·주소·좌표·
|
|
229
|
+
영업시간·외부 프로필 URL)를 저장하면, 사이트가 `fetchBusinessProfile`로
|
|
230
|
+
조회해 LocalBusiness JSON-LD를 자동 렌더할 수 있습니다 — 주소·영업시간을
|
|
231
|
+
사이트 코드에 하드코딩할 필요가 없습니다.
|
|
232
|
+
|
|
233
|
+
layout에 1회 렌더하면 충분합니다:
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
// app/layout.tsx
|
|
237
|
+
import {
|
|
238
|
+
fetchBusinessProfile,
|
|
239
|
+
localBusinessSchema,
|
|
240
|
+
} from "@roottale/cms-client/server";
|
|
241
|
+
|
|
242
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
|
|
243
|
+
|
|
244
|
+
export default async function RootLayout({ children }) {
|
|
245
|
+
// 어드민에서 미설정이면 null — 렌더를 건너뛴다.
|
|
246
|
+
const business = await fetchBusinessProfile({
|
|
247
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
248
|
+
}).catch(() => null);
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<html lang="ko">
|
|
252
|
+
<body>
|
|
253
|
+
{business ? (
|
|
254
|
+
<script
|
|
255
|
+
type="application/ld+json"
|
|
256
|
+
dangerouslySetInnerHTML={{
|
|
257
|
+
__html: JSON.stringify(
|
|
258
|
+
localBusinessSchema(business, { url: SITE_URL }),
|
|
259
|
+
),
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
) : null}
|
|
263
|
+
{children}
|
|
264
|
+
</body>
|
|
265
|
+
</html>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`localBusinessSchema`가 만드는 것:
|
|
271
|
+
|
|
272
|
+
- `@type`: `[업종, "LocalBusiness"]` (중복 제거) — 어드민에서 고른 업종
|
|
273
|
+
(세무·회계 = `AccountingService`, 병원·의원 = `MedicalClinic` 등)
|
|
274
|
+
- `address`(PostalAddress) / `geo`(GeoCoordinates) /
|
|
275
|
+
`openingHoursSpecification` / `priceRange` / `areaServed`
|
|
276
|
+
- `sameAs`: 어드민에 입력한 네이버플레이스·구글 비즈니스 프로필·카카오 채널
|
|
277
|
+
등의 URL — 검색엔진이 동일 사업장임을 연결합니다.
|
|
278
|
+
|
|
279
|
+
네이버플레이스(new.smartplace.naver.com)와 구글 비즈니스 프로필
|
|
280
|
+
(business.google.com) 등록 자체는 공개 API가 없어 사장님이 직접 해야 하며,
|
|
281
|
+
어드민 화면에 등록 안내와 프로필 URL 입력란이 있습니다.
|
|
282
|
+
|
|
91
283
|
## Fleet 프로브 (운영 가시성)
|
|
92
284
|
|
|
93
285
|
RootTale 운영 측이 배포 버전·헬스를 확인할 수 있는 well-known 라우트:
|
|
@@ -101,3 +293,44 @@ export const GET = createFleetInfoRoute({ site: "example" });
|
|
|
101
293
|
|
|
102
294
|
`site`에는 사이트 식별용 슬러그를 넣습니다. 필수는 아니지만 운영 지원을
|
|
103
295
|
받으려면 추가를 권장합니다.
|
|
296
|
+
|
|
297
|
+
## AEO/GEO — llms.txt
|
|
298
|
+
|
|
299
|
+
AI 검색·생성엔진(ChatGPT·Claude·Perplexity 등)의 크롤러는
|
|
300
|
+
[llms.txt](https://llmstxt.org) 마크다운 인덱스로 사이트 구조와 콘텐츠를
|
|
301
|
+
빠르게 파악합니다. 발행 글 목록(최대 100개)을 제목·요약과 함께 자동
|
|
302
|
+
포함하므로, AI가 관련 질문에 답할 때 내 사이트의 글이 출처로 인용될
|
|
303
|
+
가능성을 높입니다(AEO/GEO).
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
// app/llms.txt/route.ts
|
|
307
|
+
import { createLlmsTxtRoute } from "@roottale/cms-renderer-next/routes";
|
|
308
|
+
|
|
309
|
+
export const dynamic = "force-dynamic";
|
|
310
|
+
|
|
311
|
+
export const GET = createLlmsTxtRoute({
|
|
312
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
313
|
+
apiBase: process.env.ROOTTALE_API_BASE,
|
|
314
|
+
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
315
|
+
title: "예시 사이트",
|
|
316
|
+
description: "예시 사이트 설명",
|
|
317
|
+
sections: [
|
|
318
|
+
// (선택) 서비스 소개 등 정적 페이지 링크 그룹 — 블로그 목록 앞에 출력
|
|
319
|
+
{
|
|
320
|
+
title: "주요 페이지",
|
|
321
|
+
links: [
|
|
322
|
+
{
|
|
323
|
+
title: "서비스 소개",
|
|
324
|
+
url: "https://example.com/services",
|
|
325
|
+
note: "제공 서비스 안내",
|
|
326
|
+
},
|
|
327
|
+
{ title: "상담 문의", url: "https://example.com/contact" },
|
|
328
|
+
],
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
});
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
API 조회가 실패해도 항상 200으로 헤더 부분을 반환합니다(빌드 사고 방지).
|
|
335
|
+
발행 웹훅의 기본 `alsoRevalidate`에 `/llms.txt`가 포함되어 있어, 글을
|
|
336
|
+
발행·수정하면 AI 크롤러용 인덱스도 자동으로 갱신됩니다.
|
|
@@ -45,6 +45,31 @@ const display = resolvePostDisplay(settings, post);
|
|
|
45
45
|
`RootTaleBlogPost` 컴포넌트를 쓰면 이 설정이 자동 반영됩니다 — 커스텀 UI를
|
|
46
46
|
만들 때만 직접 조회하면 됩니다.
|
|
47
47
|
|
|
48
|
+
## 비즈니스 프로필 (로컬 SEO) — fetchBusinessProfile
|
|
49
|
+
|
|
50
|
+
어드민 **운영 > 비즈니스 프로필**에서 저장한 사업장 정보(이름·업종·주소·
|
|
51
|
+
좌표·영업시간·네이버플레이스/구글 비즈니스 프로필 URL)를 조회합니다.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import {
|
|
55
|
+
fetchBusinessProfile,
|
|
56
|
+
localBusinessSchema,
|
|
57
|
+
} from "@roottale/cms-client/server";
|
|
58
|
+
|
|
59
|
+
const business = await fetchBusinessProfile({
|
|
60
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
61
|
+
});
|
|
62
|
+
// 미설정이면 null. 설정돼 있으면 name, businessType, address, geo,
|
|
63
|
+
// openingHours, priceRange, areaServed, profiles(sameAs용 URL) 포함.
|
|
64
|
+
|
|
65
|
+
if (business) {
|
|
66
|
+
const jsonLd = localBusinessSchema(business, {
|
|
67
|
+
url: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
68
|
+
});
|
|
69
|
+
// layout에 <script type="application/ld+json">으로 1회 렌더 (seo.md 참고)
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
48
73
|
## 분석 태그 — fetchAnalyticsConfig
|
|
49
74
|
|
|
50
75
|
어드민에서 등록한 외부 분석 태그(GA4, Microsoft Clarity, Meta Pixel, 네이버)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// 동적 OG 이미지 — 글별 1200×630 소셜 공유 카드 (제목·날짜·브랜드).
|
|
2
|
+
// 파일 컨벤션(opengraph-image.tsx)이라 Next 가 og:image meta 를 자동 주입.
|
|
3
|
+
// X(트위터) 카드도 원하면 같은 내용으로 twitter-image.tsx 를 복제.
|
|
4
|
+
import { ImageResponse } from "next/og";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createPostOgImage,
|
|
8
|
+
OG_IMAGE_CONTENT_TYPE,
|
|
9
|
+
OG_IMAGE_SIZE,
|
|
10
|
+
} from "@roottale/cms-renderer-next/routes";
|
|
11
|
+
|
|
12
|
+
export const size = OG_IMAGE_SIZE;
|
|
13
|
+
export const contentType = OG_IMAGE_CONTENT_TYPE;
|
|
14
|
+
|
|
15
|
+
const SITE_URL =
|
|
16
|
+
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") || "https://example.com";
|
|
17
|
+
|
|
18
|
+
export default createPostOgImage(
|
|
19
|
+
{
|
|
20
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
21
|
+
apiBase: process.env.ROOTTALE_API_BASE,
|
|
22
|
+
siteUrl: SITE_URL,
|
|
23
|
+
title: "예시 사이트",
|
|
24
|
+
// 브랜드 색 커스텀 (선택):
|
|
25
|
+
// backgroundColor: "#10172a",
|
|
26
|
+
// accentColor: "#38bdf8",
|
|
27
|
+
// brandLabel: "예시",
|
|
28
|
+
},
|
|
29
|
+
{ ImageResponse },
|
|
30
|
+
);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// 블로그 상세 페이지 — 본문은 RootTaleBlogPost(블록 JSON 렌더러)에 위임,
|
|
2
2
|
// 메타데이터는 어드민 SEO 패널(metaJson.seo) 값을 우선 적용.
|
|
3
|
+
// slug 변경 시: API가 옛 slug로도 글을 찾아 현재 slug로 응답 → 301 redirect.
|
|
3
4
|
import type { Metadata } from "next";
|
|
4
|
-
import { notFound } from "next/navigation";
|
|
5
|
+
import { notFound, permanentRedirect } from "next/navigation";
|
|
5
6
|
import { RootTaleBlogPost } from "@roottale/cms-renderer-next/server";
|
|
7
|
+
import { postRedirectPath } from "@roottale/cms-renderer-next/routes";
|
|
6
8
|
|
|
7
9
|
import { getAllPosts, getPost } from "@/lib/blog";
|
|
8
10
|
|
|
@@ -26,7 +28,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
26
28
|
title,
|
|
27
29
|
description,
|
|
28
30
|
...(seo?.canonical ? { alternates: { canonical: seo.canonical } } : {}),
|
|
29
|
-
...(seo?.noindex
|
|
31
|
+
...(seo?.noindex || seo?.nofollow
|
|
32
|
+
? { robots: { index: !seo?.noindex, follow: !seo?.nofollow } }
|
|
33
|
+
: {}),
|
|
30
34
|
openGraph: {
|
|
31
35
|
type: "article",
|
|
32
36
|
title,
|
|
@@ -41,6 +45,9 @@ export default async function PostPage({ params }: Props) {
|
|
|
41
45
|
const { slug } = await params;
|
|
42
46
|
const post = await getPost(slug);
|
|
43
47
|
if (!post) notFound();
|
|
48
|
+
// 옛 slug 로 들어온 요청 → 현재 slug 로 301 (검색 순위 승계).
|
|
49
|
+
const redirect = postRedirectPath(post, slug);
|
|
50
|
+
if (redirect) permanentRedirect(redirect);
|
|
44
51
|
|
|
45
52
|
return (
|
|
46
53
|
<main>
|
|
@@ -1,19 +1,44 @@
|
|
|
1
1
|
// root layout — 렌더러 스타일은 여기서 1회 import (getting-started.md).
|
|
2
2
|
import "@roottale/cms-renderer-next/styles";
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
fetchBusinessProfile,
|
|
6
|
+
localBusinessSchema,
|
|
7
|
+
} from "@roottale/cms-client/server";
|
|
8
|
+
|
|
9
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com";
|
|
10
|
+
|
|
4
11
|
export const metadata = {
|
|
5
12
|
title: "예시 사이트",
|
|
6
13
|
description: "RootTale CMS 연동 예시",
|
|
7
14
|
};
|
|
8
15
|
|
|
9
|
-
export default function RootLayout({
|
|
16
|
+
export default async function RootLayout({
|
|
10
17
|
children,
|
|
11
18
|
}: {
|
|
12
19
|
children: React.ReactNode;
|
|
13
20
|
}) {
|
|
21
|
+
// 로컬 SEO — 어드민(운영 > 비즈니스 프로필)에서 사업장 정보를 저장하면
|
|
22
|
+
// LocalBusiness JSON-LD 를 자동 렌더. 미설정/실패 시 null → 렌더 건너뜀 (seo.md).
|
|
23
|
+
const business = await fetchBusinessProfile({
|
|
24
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
25
|
+
}).catch(() => null);
|
|
26
|
+
|
|
14
27
|
return (
|
|
15
28
|
<html lang="ko">
|
|
16
|
-
<body>
|
|
29
|
+
<body>
|
|
30
|
+
{business ? (
|
|
31
|
+
<script
|
|
32
|
+
type="application/ld+json"
|
|
33
|
+
dangerouslySetInnerHTML={{
|
|
34
|
+
__html: JSON.stringify(
|
|
35
|
+
localBusinessSchema(business, { url: SITE_URL }),
|
|
36
|
+
),
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
) : null}
|
|
40
|
+
{children}
|
|
41
|
+
</body>
|
|
17
42
|
</html>
|
|
18
43
|
);
|
|
19
44
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// llms.txt — AEO/GEO(AI 검색·생성엔진 최적화) 마크다운 인덱스.
|
|
2
|
+
// AI 크롤러(ChatGPT/Claude/Perplexity 등)가 사이트 구조·발행 글을 빠르게 파악.
|
|
3
|
+
import { createLlmsTxtRoute } from "@roottale/cms-renderer-next/routes";
|
|
4
|
+
|
|
5
|
+
export const dynamic = "force-dynamic";
|
|
6
|
+
|
|
7
|
+
const SITE_URL =
|
|
8
|
+
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") || "https://example.com";
|
|
9
|
+
|
|
10
|
+
export const GET = createLlmsTxtRoute({
|
|
11
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
12
|
+
apiBase: process.env.ROOTTALE_API_BASE,
|
|
13
|
+
siteUrl: SITE_URL,
|
|
14
|
+
title: "예시 사이트",
|
|
15
|
+
description: "최신 소식과 인사이트",
|
|
16
|
+
sections: [
|
|
17
|
+
// (선택) 정적 페이지 링크 그룹 — 발행 글 목록("## 블로그")은 자동 포함됨.
|
|
18
|
+
{
|
|
19
|
+
title: "주요 페이지",
|
|
20
|
+
links: [
|
|
21
|
+
{
|
|
22
|
+
title: "서비스 소개",
|
|
23
|
+
url: `${SITE_URL}/services`,
|
|
24
|
+
note: "제공 서비스 안내",
|
|
25
|
+
},
|
|
26
|
+
{ title: "상담 문의", url: `${SITE_URL}/contact` },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// 헤더 네비게이션 — 어드민 "디자인 > 메뉴"(위치 핸들 primary)를 렌더.
|
|
2
|
+
// 메뉴 미설정(null)이면 fallback 네비 — 항상 fallback 분기를 두세요.
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
|
|
5
|
+
import { fetchMenu } from "@roottale/cms-client/server";
|
|
6
|
+
|
|
7
|
+
export async function SiteNav() {
|
|
8
|
+
const menu = await fetchMenu({
|
|
9
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
10
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
11
|
+
slug: "primary",
|
|
12
|
+
}).catch(() => null);
|
|
13
|
+
|
|
14
|
+
if (!menu) {
|
|
15
|
+
return (
|
|
16
|
+
<nav className="flex gap-4">
|
|
17
|
+
<Link href="/blog">블로그</Link>
|
|
18
|
+
<Link href="/contact">상담 문의</Link>
|
|
19
|
+
</nav>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<nav className="flex gap-4">
|
|
25
|
+
{menu.items.map((item) => (
|
|
26
|
+
<div key={item.id} className="group relative">
|
|
27
|
+
<Link
|
|
28
|
+
href={item.url}
|
|
29
|
+
{...(item.newTab ? { target: "_blank", rel: "noopener" } : {})}
|
|
30
|
+
>
|
|
31
|
+
{item.label}
|
|
32
|
+
</Link>
|
|
33
|
+
{item.children?.length ? (
|
|
34
|
+
<div className="absolute hidden flex-col gap-2 bg-white p-3 shadow group-hover:flex">
|
|
35
|
+
{item.children.map((child) => (
|
|
36
|
+
<Link key={child.id} href={child.url}>
|
|
37
|
+
{child.label}
|
|
38
|
+
</Link>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
) : null}
|
|
42
|
+
</div>
|
|
43
|
+
))}
|
|
44
|
+
</nav>
|
|
45
|
+
);
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "RootTale CMS integration MCP server — bundled integration docs, Next.js example code, and public API lookup tools. Run with: npx @roottale/cms-mcp",
|
|
6
6
|
"bin": {
|