@roottale/cms-mcp 0.24.0 → 0.25.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 +45 -0
- package/dist/index.js +1 -1
- package/docs/api-reference.md +67 -0
- package/docs/blog.md +29 -2
- package/docs/menus.md +100 -0
- package/docs/overview.md +4 -2
- package/docs/seo.md +223 -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,50 @@
|
|
|
1
1
|
# @roottale/cms-mcp
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0dcb931: 비즈니스 프로필 (로컬 SEO) — 어드민 "운영 > 비즈니스 프로필" 에서 저장한
|
|
8
|
+
사업장 정보(이름·업종·주소·좌표·영업시간·네이버플레이스/구글 비즈니스
|
|
9
|
+
프로필 URL)를 외부 사이트가 조회해 LocalBusiness JSON-LD 를 자동 렌더.
|
|
10
|
+
- cms-client: `fetchBusinessProfile()` (미설정/구 서버 404 = null) +
|
|
11
|
+
`localBusinessSchema(profile, { url, image? })` — @type 중복 제거,
|
|
12
|
+
PostalAddress/GeoCoordinates/openingHoursSpecification/sameAs.
|
|
13
|
+
- API: `GET /v1/cms/public/business-profile` (cms:read).
|
|
14
|
+
- cms-mcp docs: seo.md 로컬 SEO 섹션, theme-and-settings.md
|
|
15
|
+
fetchBusinessProfile, api-reference.md 엔드포인트, examples layout 적용.
|
|
16
|
+
|
|
17
|
+
- c81991a: CLS(Core Web Vitals) 대책: 본문 이미지에 width/height 출력. 에디터가
|
|
18
|
+
삽입 시 원본 치수를 보존하고(CmsImage), 렌더러(Next·Astro)가 `width`/
|
|
19
|
+
`height` 속성으로 출력 — 기존 CSS(max-inline-size:100%; block-size:auto)와
|
|
20
|
+
조합돼 반응형 유지하면서 레이아웃 시프트 제거. WP import 의 width/height 도
|
|
21
|
+
자동 보존. cms-mcp docs 에 robots.txt·브레드크럼·페이지네이션 가이드 추가.
|
|
22
|
+
- 4e7f9f8: llms.txt 라우트 팩토리 (AEO/GEO): `createLlmsTxtRoute(config)` 추가 —
|
|
23
|
+
llms.txt 스펙(https://llmstxt.org)의 마크다운 인덱스로 AI 검색·생성엔진
|
|
24
|
+
크롤러에 사이트 구조·발행 글(최대 100개, excerpt 1줄)을 노출. fetch 실패도
|
|
25
|
+
항상 200(fail-soft). `createRevalidateRoute` 기본 `alsoRevalidate` 에
|
|
26
|
+
`/llms.txt` 포함 — 글 변경 시 AI 크롤러 인덱스 자동 갱신. docs/examples 반영.
|
|
27
|
+
- 50b6df0: 메뉴(네비게이션) 연동: `fetchMenu({ apiKey, slug })` / `fetchMenus({ apiKey })`
|
|
28
|
+
— 어드민 "디자인 > 메뉴" 트리(`GET /v1/cms/public/menus`)를 헤더/푸터 네비로
|
|
29
|
+
렌더. 깊이 2, 미존재 404 → null/빈 배열 fail-soft. 새 문서 `menus.md` +
|
|
30
|
+
예시 `components/site-nav.tsx`.
|
|
31
|
+
- e84a46c: slug 변경 301 (WP permalink 패리티): public API 가 옛 slug 로도 글을 찾아
|
|
32
|
+
현재 slug 로 응답하고(post_slug_history fallback), `cms-renderer-next/routes`
|
|
33
|
+
에 `postRedirectPath(post, requestedSlug, blogBasePath?)` 헬퍼를 추가 —
|
|
34
|
+
페이지에서 `permanentRedirect()` 로 301 처리. docs/examples 에 패턴 반영.
|
|
35
|
+
- 6262fe7: 동적 OG 이미지: `createPostOgImage(config, { ImageResponse })` +
|
|
36
|
+
`OG_IMAGE_SIZE`/`OG_IMAGE_CONTENT_TYPE` — `app/blog/[slug]/opengraph-image.tsx`
|
|
37
|
+
에서 글별 1200×630 소셜 카드 생성. 한글은 Noto Sans KR 부분셋 런타임 로딩,
|
|
38
|
+
글/폰트 fetch 실패 시 fail-soft. `next` 직접 의존 없음(ImageResponse 주입).
|
|
39
|
+
- 50b6df0: 공개 검색 API: `searchPosts({ apiKey, query, limit?, type? })` — 발행 글
|
|
40
|
+
키워드 검색 (WP `?s=` 패리티, `GET /v1/cms/public/search`). title·excerpt·
|
|
41
|
+
본문 텍스트 부분일치, 본문 미포함 슬림 hit. 구 서버 404 는 빈 배열 fail-soft.
|
|
42
|
+
|
|
43
|
+
### Patch Changes
|
|
44
|
+
|
|
45
|
+
- e7e0849: per-post nofollow 제어 문서화 — 어드민 SEO 패널의 noindex 옆에 nofollow
|
|
46
|
+
토글이 추가됨. `metaJson.seo.nofollow` → Next `robots.follow` 매핑 예시 갱신.
|
|
47
|
+
|
|
3
48
|
## 0.24.0
|
|
4
49
|
|
|
5
50
|
### 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.25.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
|
@@ -155,12 +155,39 @@ export async function generateMetadata({
|
|
|
155
155
|
return {
|
|
156
156
|
title: (seo?.title as string) || post.title,
|
|
157
157
|
description: (seo?.description as string) || post.excerpt,
|
|
158
|
-
...(seo?.noindex
|
|
158
|
+
...(seo?.noindex || seo?.nofollow
|
|
159
|
+
? { robots: { index: !seo?.noindex, follow: !seo?.nofollow } }
|
|
160
|
+
: {}),
|
|
159
161
|
};
|
|
160
162
|
}
|
|
161
163
|
```
|
|
162
164
|
|
|
163
|
-
SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`,
|
|
165
|
+
SEO 오버라이드 필드: `title`, `description`, `canonical`, `ogImage`,
|
|
166
|
+
`noindex`, `nofollow`.
|
|
167
|
+
|
|
168
|
+
### slug 변경 시 301 리다이렉트 (필수 권장)
|
|
169
|
+
|
|
170
|
+
어드민에서 글 slug를 바꿔도 API는 **옛 slug로 글을 찾아 현재 slug로
|
|
171
|
+
응답**합니다(slug history fallback). 페이지에서 요청 slug와 응답 slug가
|
|
172
|
+
다르면 301로 보내야 검색엔진 순위가 새 URL로 승계됩니다:
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
// app/blog/[slug]/page.tsx
|
|
176
|
+
import { notFound, permanentRedirect } from "next/navigation";
|
|
177
|
+
import { postRedirectPath } from "@roottale/cms-renderer-next/routes";
|
|
178
|
+
|
|
179
|
+
export default async function PostPage({ params }: Props) {
|
|
180
|
+
const { slug } = await params;
|
|
181
|
+
const post = await getPost(slug);
|
|
182
|
+
if (!post) notFound();
|
|
183
|
+
const redirect = postRedirectPath(post, slug); // 기본 basePath "/blog"
|
|
184
|
+
if (redirect) permanentRedirect(redirect);
|
|
185
|
+
// ... 렌더
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
`generateMetadata`는 redirect 페이지에서 실행돼도 무방하지만, canonical을
|
|
190
|
+
직접 계산한다면 `post.slug`(현재 slug) 기준으로 계산하세요.
|
|
164
191
|
|
|
165
192
|
## 캐싱 전략
|
|
166
193
|
|
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,135 @@ export default createSitemap(
|
|
|
51
51
|
);
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
+
## robots.txt
|
|
55
|
+
|
|
56
|
+
크롤링 제어의 기본. sitemap 위치를 알려주고, 크롤링이 무의미한 경로만
|
|
57
|
+
차단합니다. **CSS/JS/이미지 경로를 차단하지 마세요** — 구글이 페이지를
|
|
58
|
+
렌더링하지 못해 평가가 깨집니다. 색인 제외가 목적이면 robots.txt 차단이
|
|
59
|
+
아니라 페이지의 noindex 를 쓰세요 (외부 링크가 있으면 차단해도 색인될 수
|
|
60
|
+
있습니다).
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// app/robots.ts
|
|
64
|
+
import type { MetadataRoute } from "next";
|
|
65
|
+
|
|
66
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
|
|
67
|
+
|
|
68
|
+
export default function robots(): MetadataRoute.Robots {
|
|
69
|
+
return {
|
|
70
|
+
rules: [{ userAgent: "*", allow: "/", disallow: ["/api/"] }],
|
|
71
|
+
sitemap: `${SITE_URL}/sitemap.xml`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 브레드크럼 (BreadcrumbList)
|
|
77
|
+
|
|
78
|
+
사이트 구조를 검색엔진에 전달하고 검색결과에 경로가 표시됩니다. 블로그 글
|
|
79
|
+
상세에서 `breadcrumbSchema` 로 렌더하세요 (UI 브레드크럼과 구조 일치 권장):
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import { breadcrumbSchema } from "@roottale/cms-client/server";
|
|
83
|
+
|
|
84
|
+
const category = post.terms.find((t) => t.taxonomy === "category");
|
|
85
|
+
const crumbs = breadcrumbSchema([
|
|
86
|
+
{ name: "홈", url: SITE_URL },
|
|
87
|
+
{ name: "블로그", url: `${SITE_URL}/blog` },
|
|
88
|
+
...(category
|
|
89
|
+
? [{ name: category.name, url: `${SITE_URL}/blog/categories/${category.slug}` }]
|
|
90
|
+
: []),
|
|
91
|
+
{ name: post.title, url: `${SITE_URL}/blog/${post.slug}` },
|
|
92
|
+
]);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## 블로그 목록 페이지네이션 주의
|
|
96
|
+
|
|
97
|
+
- 마지막 페이지에 다음(next) 링크를 렌더하지 마세요 — 같은 페이지가 반복
|
|
98
|
+
노출되면 크롤 낭비·중복 신호가 됩니다.
|
|
99
|
+
- 필터·정렬로 내용이 바뀌면 URL(쿼리)도 함께 바뀌어야 하고, canonical 은
|
|
100
|
+
필터 없는 기본 목록을 가리키게 하세요.
|
|
101
|
+
- 검색결과(사이트 내 검색) 페이지는 noindex 처리하세요 — 특히 결과 0건
|
|
102
|
+
페이지가 색인되면 저품질(소프트 404) 신호가 됩니다.
|
|
103
|
+
|
|
54
104
|
## RSS/사이트맵과 웹훅
|
|
55
105
|
|
|
56
106
|
발행 웹훅의 `alsoRevalidate`에 `/feed.xml`, `/sitemap.xml`을 포함해 글 변경
|
|
57
107
|
시 함께 갱신하세요 (`revalidation-webhooks.md` 참고).
|
|
58
108
|
|
|
109
|
+
## slug 변경 시 301 리다이렉트
|
|
110
|
+
|
|
111
|
+
글 slug를 바꿔도 옛 URL이 깨지지 않습니다. API가 slug history로 글을 찾아
|
|
112
|
+
**현재 slug로 응답**하므로, 페이지에서 요청 slug와 비교해 301을 보내세요:
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
import { notFound, permanentRedirect } from "next/navigation";
|
|
116
|
+
import { postRedirectPath } from "@roottale/cms-renderer-next/routes";
|
|
117
|
+
|
|
118
|
+
const post = await getPost(slug);
|
|
119
|
+
if (!post) notFound();
|
|
120
|
+
const redirect = postRedirectPath(post, slug);
|
|
121
|
+
if (redirect) permanentRedirect(redirect);
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
301이어야 기존 URL의 검색 순위·백링크가 새 URL로 승계됩니다 (사이트맵·RSS는
|
|
125
|
+
항상 현재 slug만 포함).
|
|
126
|
+
|
|
127
|
+
## 동적 OG 이미지 (글별 1200×630)
|
|
128
|
+
|
|
129
|
+
글마다 제목·날짜가 들어간 소셜 공유 카드를 자동 생성합니다 — 대표 이미지를
|
|
130
|
+
일일이 만들지 않아도 카카오톡·페이스북·X 공유 시 글 제목이 보이는 카드가
|
|
131
|
+
나갑니다. 한글 제목은 Noto Sans KR 부분셋을 런타임에 받아 렌더합니다.
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
// app/blog/[slug]/opengraph-image.tsx
|
|
135
|
+
import { ImageResponse } from "next/og";
|
|
136
|
+
import {
|
|
137
|
+
createPostOgImage,
|
|
138
|
+
OG_IMAGE_SIZE,
|
|
139
|
+
OG_IMAGE_CONTENT_TYPE,
|
|
140
|
+
} from "@roottale/cms-renderer-next/routes";
|
|
141
|
+
|
|
142
|
+
export const size = OG_IMAGE_SIZE; // { width: 1200, height: 630 }
|
|
143
|
+
export const contentType = OG_IMAGE_CONTENT_TYPE; // "image/png"
|
|
144
|
+
|
|
145
|
+
export default createPostOgImage(
|
|
146
|
+
{
|
|
147
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
148
|
+
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
149
|
+
title: "예시 블로그",
|
|
150
|
+
// 선택 — 브랜드 색 커스텀:
|
|
151
|
+
// backgroundColor: "#10172a", accentColor: "#38bdf8", brandLabel: "예시",
|
|
152
|
+
},
|
|
153
|
+
{ ImageResponse },
|
|
154
|
+
);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- `opengraph-image.tsx` 파일 컨벤션이라 별도 meta 태그 없이 Next 가
|
|
158
|
+
`og:image` 를 자동 주입합니다 (`twitter-image.tsx` 로 복제하면 X 카드도).
|
|
159
|
+
- 글이 없거나 API 실패 시 사이트 제목으로 fail-soft 렌더 — 빈 카드가
|
|
160
|
+
나가지 않습니다.
|
|
161
|
+
- `ImageResponse` 는 호출부에서 주입합니다 — 본 패키지는 `next` 에 직접
|
|
162
|
+
의존하지 않습니다.
|
|
163
|
+
|
|
164
|
+
## 공개 검색 (사이트 내 검색)
|
|
165
|
+
|
|
166
|
+
`searchPosts` 로 발행 글 키워드 검색을 붙일 수 있습니다 (WP `?s=` 패리티):
|
|
167
|
+
|
|
168
|
+
```tsx
|
|
169
|
+
// app/search/page.tsx (Server Component)
|
|
170
|
+
import { searchPosts } from "@roottale/cms-client/server";
|
|
171
|
+
|
|
172
|
+
const hits = await searchPosts({
|
|
173
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
174
|
+
query: q, // ?q= 쿼리
|
|
175
|
+
limit: 20,
|
|
176
|
+
});
|
|
177
|
+
// hits: { id, title, slug, excerpt, featuredImageUrl, publishedAt }[]
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
본문은 미포함 슬림 hit 이므로 카드에서 `/blog/{slug}` 로 연결하세요.
|
|
181
|
+
검색결과 페이지는 위 체크리스트대로 **noindex** 처리를 잊지 마세요.
|
|
182
|
+
|
|
59
183
|
## JSON-LD 스키마 헬퍼
|
|
60
184
|
|
|
61
185
|
`@roottale/cms-client/server`에서 제공:
|
|
@@ -65,6 +189,7 @@ export default createSitemap(
|
|
|
65
189
|
| `articleSchema(input)` | 블로그 글 상세 페이지 Article |
|
|
66
190
|
| `breadcrumbSchema(items)` | 빵부스러기 |
|
|
67
191
|
| `organizationSchema(input)` | 조직/사업체 |
|
|
192
|
+
| `localBusinessSchema(profile, opts)` | 사업장 LocalBusiness (로컬 SEO — 아래 섹션) |
|
|
68
193
|
| `websiteSchema(input)` | 웹사이트 |
|
|
69
194
|
| `faqSchema(items)` | FAQ |
|
|
70
195
|
|
|
@@ -88,6 +213,63 @@ const jsonLd = articleSchema({
|
|
|
88
213
|
저수준 RSS가 필요하면 `generateRssXml` / `rssItemsFromPosts`를 직접 사용할 수
|
|
89
214
|
있습니다.
|
|
90
215
|
|
|
216
|
+
## 로컬 SEO (네이버플레이스·구글 비즈니스)
|
|
217
|
+
|
|
218
|
+
어드민 **운영 > 비즈니스 프로필**에서 사업장 정보(이름·업종·주소·좌표·
|
|
219
|
+
영업시간·외부 프로필 URL)를 저장하면, 사이트가 `fetchBusinessProfile`로
|
|
220
|
+
조회해 LocalBusiness JSON-LD를 자동 렌더할 수 있습니다 — 주소·영업시간을
|
|
221
|
+
사이트 코드에 하드코딩할 필요가 없습니다.
|
|
222
|
+
|
|
223
|
+
layout에 1회 렌더하면 충분합니다:
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
// app/layout.tsx
|
|
227
|
+
import {
|
|
228
|
+
fetchBusinessProfile,
|
|
229
|
+
localBusinessSchema,
|
|
230
|
+
} from "@roottale/cms-client/server";
|
|
231
|
+
|
|
232
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
|
|
233
|
+
|
|
234
|
+
export default async function RootLayout({ children }) {
|
|
235
|
+
// 어드민에서 미설정이면 null — 렌더를 건너뛴다.
|
|
236
|
+
const business = await fetchBusinessProfile({
|
|
237
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
238
|
+
}).catch(() => null);
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<html lang="ko">
|
|
242
|
+
<body>
|
|
243
|
+
{business ? (
|
|
244
|
+
<script
|
|
245
|
+
type="application/ld+json"
|
|
246
|
+
dangerouslySetInnerHTML={{
|
|
247
|
+
__html: JSON.stringify(
|
|
248
|
+
localBusinessSchema(business, { url: SITE_URL }),
|
|
249
|
+
),
|
|
250
|
+
}}
|
|
251
|
+
/>
|
|
252
|
+
) : null}
|
|
253
|
+
{children}
|
|
254
|
+
</body>
|
|
255
|
+
</html>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`localBusinessSchema`가 만드는 것:
|
|
261
|
+
|
|
262
|
+
- `@type`: `[업종, "LocalBusiness"]` (중복 제거) — 어드민에서 고른 업종
|
|
263
|
+
(세무·회계 = `AccountingService`, 병원·의원 = `MedicalClinic` 등)
|
|
264
|
+
- `address`(PostalAddress) / `geo`(GeoCoordinates) /
|
|
265
|
+
`openingHoursSpecification` / `priceRange` / `areaServed`
|
|
266
|
+
- `sameAs`: 어드민에 입력한 네이버플레이스·구글 비즈니스 프로필·카카오 채널
|
|
267
|
+
등의 URL — 검색엔진이 동일 사업장임을 연결합니다.
|
|
268
|
+
|
|
269
|
+
네이버플레이스(new.smartplace.naver.com)와 구글 비즈니스 프로필
|
|
270
|
+
(business.google.com) 등록 자체는 공개 API가 없어 사장님이 직접 해야 하며,
|
|
271
|
+
어드민 화면에 등록 안내와 프로필 URL 입력란이 있습니다.
|
|
272
|
+
|
|
91
273
|
## Fleet 프로브 (운영 가시성)
|
|
92
274
|
|
|
93
275
|
RootTale 운영 측이 배포 버전·헬스를 확인할 수 있는 well-known 라우트:
|
|
@@ -101,3 +283,44 @@ export const GET = createFleetInfoRoute({ site: "example" });
|
|
|
101
283
|
|
|
102
284
|
`site`에는 사이트 식별용 슬러그를 넣습니다. 필수는 아니지만 운영 지원을
|
|
103
285
|
받으려면 추가를 권장합니다.
|
|
286
|
+
|
|
287
|
+
## AEO/GEO — llms.txt
|
|
288
|
+
|
|
289
|
+
AI 검색·생성엔진(ChatGPT·Claude·Perplexity 등)의 크롤러는
|
|
290
|
+
[llms.txt](https://llmstxt.org) 마크다운 인덱스로 사이트 구조와 콘텐츠를
|
|
291
|
+
빠르게 파악합니다. 발행 글 목록(최대 100개)을 제목·요약과 함께 자동
|
|
292
|
+
포함하므로, AI가 관련 질문에 답할 때 내 사이트의 글이 출처로 인용될
|
|
293
|
+
가능성을 높입니다(AEO/GEO).
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
// app/llms.txt/route.ts
|
|
297
|
+
import { createLlmsTxtRoute } from "@roottale/cms-renderer-next/routes";
|
|
298
|
+
|
|
299
|
+
export const dynamic = "force-dynamic";
|
|
300
|
+
|
|
301
|
+
export const GET = createLlmsTxtRoute({
|
|
302
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
303
|
+
apiBase: process.env.ROOTTALE_API_BASE,
|
|
304
|
+
siteUrl: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
305
|
+
title: "예시 사이트",
|
|
306
|
+
description: "예시 사이트 설명",
|
|
307
|
+
sections: [
|
|
308
|
+
// (선택) 서비스 소개 등 정적 페이지 링크 그룹 — 블로그 목록 앞에 출력
|
|
309
|
+
{
|
|
310
|
+
title: "주요 페이지",
|
|
311
|
+
links: [
|
|
312
|
+
{
|
|
313
|
+
title: "서비스 소개",
|
|
314
|
+
url: "https://example.com/services",
|
|
315
|
+
note: "제공 서비스 안내",
|
|
316
|
+
},
|
|
317
|
+
{ title: "상담 문의", url: "https://example.com/contact" },
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
});
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
API 조회가 실패해도 항상 200으로 헤더 부분을 반환합니다(빌드 사고 방지).
|
|
325
|
+
발행 웹훅의 기본 `alsoRevalidate`에 `/llms.txt`가 포함되어 있어, 글을
|
|
326
|
+
발행·수정하면 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.25.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": {
|