@roottale/cms-mcp 0.53.0 → 0.53.1
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 +10 -0
- package/dist/index.js +1 -1
- package/docs/api-reference.md +15 -7
- package/docs/collections.md +27 -0
- package/docs/content-models-and-exposures.md +63 -0
- package/docs/custom-redirects.md +9 -1
- package/docs/inquiries.md +1 -1
- package/docs/overview.md +11 -6
- package/docs/search.md +122 -0
- package/docs/seo.md +23 -7
- package/docs/theme-and-settings.md +21 -10
- package/examples/nextjs/app/search/page.tsx +64 -0
- package/examples/nextjs/components/global-popup.tsx +13 -0
- package/examples/nextjs/middleware.ts +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @roottale/cms-mcp
|
|
2
2
|
|
|
3
|
+
## 0.53.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6df15b6: 고객 사이트의 서버에서 발행 글과 페이지를 함께 검색할 수 있도록 `searchPosts`에 `type: "all"`과 언어 필터를 추가합니다. 검색 결과에는 콘텐츠 유형과 언어를 포함하고, `resolveSearchHitPath`가 블로그·고정 페이지·콘텐츠 유형·다국어 주소를 안전하게 계산합니다. Next.js 통합 검색 예시와 공개 API 문서도 함께 제공합니다.
|
|
8
|
+
- 1efecbb: 페이지·글·정보의 콘텐츠 모델 공개 계약과 `modelKey` 조회를 추가합니다. 개발자가 선언한 슬롯에서만 배너·팝업을 조회하고 접근성 있게 렌더하는 API, client, renderer, 예제 문서를 함께 제공합니다. 기존 collections와 collectionKey 계약은 유지합니다.
|
|
9
|
+
- a490db2: 사이트 통합 검색의 서버 전용 연동 구조, 실제 콘텐츠 주소 계산, 캐시·장애 처리, 배포 순서와 현재 제약을 설명하는 전용 문서를 추가합니다.
|
|
10
|
+
- 866f4ff: ROOT-ADMIN 필드 그룹을 `collection_key` 기준으로 콘텐츠 유형에 연결하는 방법과 공개 `fields` 연동법을 문서화합니다. 의료 vertical의 `doctors` 유형은 의료진 프로필·반복 이력·진료 시간표·관계 필드를 같은 계약으로 제공합니다.
|
|
11
|
+
- 5cd0e3b: 주소 이동 middleware가 API 장애 시 stale 규칙을 계속 사용하고, 순환 규칙은 브라우저를 왕복시키지 않도록 통과처리합니다. Site Materializer 납품물에 middleware와 회귀 검증 파일을 필수로 포함합니다.
|
|
12
|
+
|
|
3
13
|
## 0.53.0
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/dist/index.js
CHANGED
|
@@ -944,7 +944,7 @@ function registerTools(server) {
|
|
|
944
944
|
}
|
|
945
945
|
|
|
946
946
|
// src/server.ts
|
|
947
|
-
var VERSION = true ? "0.53.
|
|
947
|
+
var VERSION = true ? "0.53.1" : "dev";
|
|
948
948
|
var SERVER_INSTRUCTIONS = `
|
|
949
949
|
roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uACE0
|
|
950
950
|
\uAE00\xB7\uC378\uB124\uC77C\xB7\uBCF8\uBB38 \uC774\uBBF8\uC9C0\uB97C \uC790\uB3D9\uD654\uD558\uAE30 \uC704\uD55C \uBB38\uC11C\xB7\uC608\uC2DC \uCF54\uB4DC\xB7API tool\uC744 \uC81C\uACF5\uD569\uB2C8\uB2E4.
|
package/docs/api-reference.md
CHANGED
|
@@ -146,6 +146,9 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
|
|
|
146
146
|
권한으로 발급한 키가 필요합니다** (`settings:write`). 글쓰기 키(`read_write`)로
|
|
147
147
|
호출하면 `403 insufficient_scope` 입니다.
|
|
148
148
|
|
|
149
|
+
페이지·글·정보 모델과 배너·팝업 슬롯 연동은
|
|
150
|
+
[콘텐츠 모델과 노출 슬롯](./content-models-and-exposures.md)을 참고하세요.
|
|
151
|
+
|
|
149
152
|
세 가지를 먼저 알아 두세요.
|
|
150
153
|
|
|
151
154
|
1. **두 엔드포인트 모두 "그 설정 블록 전체 교체"입니다.** 메서드는 `PATCH`
|
|
@@ -281,26 +284,31 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
|
|
|
281
284
|
|
|
282
285
|
## GET /v1/cms/public/search
|
|
283
286
|
|
|
284
|
-
발행된
|
|
285
|
-
|
|
286
|
-
hit
|
|
287
|
+
발행된 콘텐츠 키워드 검색입니다. 제목 완전일치, 제목 부분일치, 요약, 본문
|
|
288
|
+
순으로 관련도를 계산하고 같은 점수에서는 최신 발행순으로 정렬합니다. 응답은
|
|
289
|
+
카드 렌더용 슬림 hit이며 본문(`body_json`)은 포함하지 않습니다.
|
|
287
290
|
|
|
288
291
|
| 쿼리 | 설명 |
|
|
289
292
|
|---|---|
|
|
290
293
|
| `q` | 검색 키워드 (필수, 1~100자) |
|
|
291
294
|
| `limit` | 결과 수 1~50, 기본 10 |
|
|
292
|
-
| `type` | `post`(
|
|
295
|
+
| `type` | `post`(기본, 하위 호환) \| `page` \| `all`(글+페이지 통합) |
|
|
296
|
+
| `locale` | BCP-47 언어 코드. 생략하면 사이트 기본 언어 |
|
|
293
297
|
| `site_id` | 멀티 사이트 키일 때만 |
|
|
294
298
|
|
|
295
299
|
```json
|
|
296
300
|
{ "tenant_id": "…", "site_id": "…", "query": "세무",
|
|
297
|
-
"items": [ { "id": "…", "type": "post", "
|
|
301
|
+
"items": [ { "id": "…", "type": "post", "collection_key": "notice",
|
|
302
|
+
"locale": "ko", "title": "…", "slug": "…",
|
|
298
303
|
"excerpt": "…", "featured_media_url": "…",
|
|
299
304
|
"published_at": "…" } ] }
|
|
300
305
|
```
|
|
301
306
|
|
|
302
307
|
JS/TS 는 `@roottale/cms-client/server` 의 `searchPosts({ apiKey, query })` 를
|
|
303
|
-
|
|
308
|
+
사용하세요. 사이트 전체 검색은 `type: "all"`을 지정합니다. 결과 링크는
|
|
309
|
+
`resolveSearchHitPath(hit, collections, locale?)`로 계산해야 콘텐츠 유형의
|
|
310
|
+
`basePath`와 다국어 경로를 그대로 따릅니다. `ROOTTALE_API_KEY`는 브라우저에
|
|
311
|
+
노출하지 말고 Server Component·Route Handler에서만 사용하세요.
|
|
304
312
|
|
|
305
313
|
## GET /v1/cms/public/menus
|
|
306
314
|
|
|
@@ -481,7 +489,7 @@ const { categories, truncated } = await fetchCategoryCounts({
|
|
|
481
489
|
|
|
482
490
|
## GET /v1/cms/public/analytics
|
|
483
491
|
|
|
484
|
-
|
|
492
|
+
ROOT-ANALYTICS 사이트 ID와 외부 태그 설정.
|
|
485
493
|
|
|
486
494
|
```json
|
|
487
495
|
{ "tags": [ { "provider": "ga4", "id": "G-XXXXXXX", "enabled": true } ] }
|
package/docs/collections.md
CHANGED
|
@@ -158,6 +158,33 @@ basePath·라벨·플래그는 어드민에서 바꾸면 sitemap·feed·라우
|
|
|
158
158
|
(블로그면 칼럼·소식 등). 주제는 선택이며, **글 > 분류 > 카테고리**에서 미리 만들어
|
|
159
159
|
콘텐츠 유형에 연결합니다.
|
|
160
160
|
|
|
161
|
+
### 콘텐츠 유형별 커스텀 필드 (ACF 방식)
|
|
162
|
+
|
|
163
|
+
**글 > 필드 그룹**에서 콘텐츠 유형마다 별도 입력칸을 만들 수 있습니다. 새 필드
|
|
164
|
+
그룹의 적용 기준을 **콘텐츠 유형**으로 고른 뒤 `doctors`, `reviews` 같은 유형을
|
|
165
|
+
선택하면, 그 유형의 글 편집 화면에만 해당 입력칸이 나타납니다. 카테고리를 임시로
|
|
166
|
+
붙일 필요가 없습니다.
|
|
167
|
+
|
|
168
|
+
필드 값은 글의 `meta_json.acf`에 저장되며 공개 글 API에서는 정의에 맞게 변환된
|
|
169
|
+
`fields`와 표시용 `fields_meta`로 제공됩니다. 이미지·관계 필드는 raw ID가 아니라
|
|
170
|
+
공개 URL과 안전한 참조 객체로 해석됩니다.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const doctors = await fetchPosts({
|
|
174
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
175
|
+
collectionKey: "doctors",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
for (const doctor of doctors.items) {
|
|
179
|
+
// 예: { name, specialty, photo, education, schedule, ... }
|
|
180
|
+
console.log(doctor.fields);
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
ROOT-ADMIN의 코드 정의 필드 그룹도 같은 규칙을 씁니다. 의료 vertical의 기본
|
|
185
|
+
`의료진 정보` 그룹은 `doctors` 콘텐츠 유형에 연결되며 이름·전문분야·사진·대체
|
|
186
|
+
텍스트·학력/경력 반복 목록·진료 시간표·담당 치료·검수 책임자를 제공합니다.
|
|
187
|
+
|
|
161
188
|
## 사이트 연동 코드
|
|
162
189
|
|
|
163
190
|
섹션 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 콘텐츠 모델과 노출 슬롯
|
|
3
|
+
description: 페이지·글·정보 모델과 배너·팝업을 고객 FRONT에 연결하는 방법
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 콘텐츠 모델과 노출 슬롯
|
|
7
|
+
|
|
8
|
+
RootTale은 운영 화면의 콘텐츠를 세 가지로 나눕니다.
|
|
9
|
+
|
|
10
|
+
- 페이지: 개발자가 주소와 화면을 만들고 운영자가 내용을 수정합니다.
|
|
11
|
+
- 글: 운영자가 항목을 만들며 목록·상세 주소가 있습니다.
|
|
12
|
+
- 정보: 인물·서비스·후기처럼 구조화된 값을 운영자가 만들고, 화면은 개발자가 정합니다.
|
|
13
|
+
|
|
14
|
+
고객 FRONT는 `fetchContentModels()`로 활성 모델을 읽고, `fetchPosts({ modelKey })`로
|
|
15
|
+
특정 모델의 항목만 가져올 수 있습니다. 기존 `fetchCollections()`와
|
|
16
|
+
`collectionKey`는 지원 기간 동안 그대로 동작합니다.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { fetchContentModels, fetchPosts } from "@roottale/cms-client/server";
|
|
20
|
+
|
|
21
|
+
const models = await fetchContentModels({ apiKey: process.env.ROOTTALE_API_KEY! });
|
|
22
|
+
const people = await fetchPosts({
|
|
23
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
24
|
+
modelKey: "people",
|
|
25
|
+
limit: 20,
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 배너·팝업
|
|
30
|
+
|
|
31
|
+
운영자는 임의 위치에 팝업을 만들 수 없습니다. 개발자가 사이트 콘텐츠 계약에
|
|
32
|
+
등록한 슬롯 안에서만 내용·기간·대상 경로·우선순위를 관리합니다. FRONT는 슬롯을
|
|
33
|
+
코드에 명시하고 `RootTaleExposureSlot`을 놓습니다.
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { RootTaleExposureSlot } from "@roottale/cms-renderer-next/server";
|
|
37
|
+
|
|
38
|
+
export default async function Layout({ children }: { children: React.ReactNode }) {
|
|
39
|
+
return <>
|
|
40
|
+
{children}
|
|
41
|
+
<RootTaleExposureSlot
|
|
42
|
+
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
43
|
+
slotKey="global-popup"
|
|
44
|
+
path="/"
|
|
45
|
+
allowedVariants={["card", "image-card"]}
|
|
46
|
+
revalidate={60}
|
|
47
|
+
/>
|
|
48
|
+
</>;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
알 수 없는 variant, 계약에서 제거된 슬롯, 발행 기간 밖 캠페인은 렌더하지 않습니다.
|
|
53
|
+
팝업은 닫기 버튼과 Escape 닫기를 제공하고 `always|session|day|never` 재노출 정책을
|
|
54
|
+
적용합니다. `@roottale/cms-renderer-next/styles`를 root layout에서 한 번 불러오세요.
|
|
55
|
+
|
|
56
|
+
## Raw API
|
|
57
|
+
|
|
58
|
+
- `GET /v1/cms/public/content-models`
|
|
59
|
+
- `GET /v1/cms/public/posts?model_key=people`
|
|
60
|
+
- `GET /v1/cms/public/exposures?slot_key=global-popup&path=%2Fabout`
|
|
61
|
+
|
|
62
|
+
모두 고객용 API key와 `cms:read` 범위가 필요합니다. 노출 API는 선택된 공개 내용만
|
|
63
|
+
반환하며 초안, 보관함, 내부 일정·타기팅 원문은 반환하지 않습니다.
|
package/docs/custom-redirects.md
CHANGED
|
@@ -25,7 +25,8 @@ description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로
|
|
|
25
25
|
|
|
26
26
|
`@roottale/cms-renderer-next` 의 `createRedirectMiddleware` 를 프로젝트 루트
|
|
27
27
|
`middleware.ts` 에 마운트합니다. 규칙을 자동 캐시(기본 60초)하며, API 실패 시
|
|
28
|
-
|
|
28
|
+
기존 캐시가 있으면 오래된 규칙을 우선 사용하고(stale-first), 캐시가
|
|
29
|
+
없으면 트래픽을 막지 않고 통과시킵니다(fail-soft).
|
|
29
30
|
|
|
30
31
|
```ts
|
|
31
32
|
// middleware.ts
|
|
@@ -56,8 +57,15 @@ export const config = {
|
|
|
56
57
|
- 매칭은 **정확 경로 일치**입니다(와일드카드 없음). 출발 경로의 앞/뒤 슬래시와
|
|
57
58
|
한글 percent-encoding 차이는 자동 정규화해 비교합니다.
|
|
58
59
|
- 도착지가 내부 경로면 요청 origin 기준 절대 URL 로 변환해 리다이렉트합니다.
|
|
60
|
+
- 내부 경로 체인은 최종 도착지로 평탄화하고, 순환이 발견되면 브라우저
|
|
61
|
+
왕복을 막기 위해 해당 요청을 통과시킵니다.
|
|
59
62
|
- 매칭이 없으면 `null` 을 반환하므로 `NextResponse.next()` 로 통과시키세요.
|
|
60
63
|
|
|
64
|
+
Site Materializer로 새 사이트를 만들면 `middleware.ts`와
|
|
65
|
+
`tests/redirect-middleware.test.ts`가 필수 산출물로 포함됩니다. 두 파일은
|
|
66
|
+
내부 Materializer 영수증에도 기록되므로, 설치 여부를 추측하지 않고
|
|
67
|
+
실제 납품 산출물로 확인할 수 있습니다.
|
|
68
|
+
|
|
61
69
|
## 캐시와 즉시성
|
|
62
70
|
|
|
63
71
|
규칙은 미들웨어가 TTL(기본 60초) 동안 캐시합니다. 운영자가 규칙을 바꾸면
|
package/docs/inquiries.md
CHANGED
|
@@ -95,7 +95,7 @@ export async function submitContact(
|
|
|
95
95
|
## 유입 어트리뷰션 (`attribution`)
|
|
96
96
|
|
|
97
97
|
문의가 **어느 글·검색·단축링크/QR에서 왔는지**를 CRM에 표시하려면 두 줄만
|
|
98
|
-
추가하면 됩니다.
|
|
98
|
+
추가하면 됩니다. ROOT-ANALYTICS 비콘이 방문자의 first-touch(처음 도착한
|
|
99
99
|
경로·`rt_src` 토큰·utm·외부 referrer 호스트명)를 30일간 기억하며,
|
|
100
100
|
`readAttribution()`(브라우저 전용, `@roottale/cms-client/attribution`)으로
|
|
101
101
|
읽습니다. 식별자가 아니므로 개인정보가 아닙니다.
|
package/docs/overview.md
CHANGED
|
@@ -13,10 +13,12 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
13
13
|
|
|
14
14
|
1. `getting-started.md` — 키 발급 + 환경 설정
|
|
15
15
|
2. `blog.md` — `/blog` 목록·상세 페이지
|
|
16
|
-
3. `
|
|
17
|
-
4. `
|
|
18
|
-
5. `
|
|
19
|
-
6. `
|
|
16
|
+
3. `search.md` — 글·페이지 통합 검색 (선택)
|
|
17
|
+
4. `revalidation-webhooks.md` — 웹훅 등록 (발행 → 즉시 반영)
|
|
18
|
+
5. `seo.md` — RSS·사이트맵·동적 OG 이미지
|
|
19
|
+
6. `theme-and-settings.md` — ROOT-ANALYTICS 연결 (권장)
|
|
20
|
+
7. `inquiries.md` — 상담문의 폼과 유입·여정 저장 (선택)
|
|
21
|
+
8. `menus.md` — 어드민 관리 네비게이션 (선택)
|
|
20
22
|
|
|
21
23
|
```
|
|
22
24
|
어드민 (admin.roottale.com) 고객 사이트 (예: example.com)
|
|
@@ -34,9 +36,10 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
34
36
|
| 기능 | 같은 키 하나로 |
|
|
35
37
|
|---|---|
|
|
36
38
|
| 블로그 글 목록/상세 조회 | `fetchPosts` / `fetchPost` |
|
|
39
|
+
| 발행 글·페이지 검색 | `searchPosts` / `resolveSearchHitPath` |
|
|
37
40
|
| 발행 웹훅 서명 검증 + 캐시 갱신 | `createRevalidateRoute` (JWKS 공개키 — 별도 secret 보관 불필요). 설정 저장을 즉시 반영하려면 `revalidateTag` 주입 필수 — `revalidation-webhooks.md` §1 |
|
|
38
41
|
| 상담문의(리드) 접수 | `submitInquiry` — 키가 테넌트를 식별 |
|
|
39
|
-
| 테마·블로그
|
|
42
|
+
| 테마·블로그 표시·ROOT-ANALYTICS 설정 조회 | `fetchTheme` / `fetchBlogSettings` / `fetchAnalyticsConfig` |
|
|
40
43
|
| 사업장 정보·메뉴·콘텐츠 유형 조회 | `fetchBusinessProfile` / `fetchMenu`·`fetchMenus` / `fetchCollections` |
|
|
41
44
|
|
|
42
45
|
키는 **서버 전용**입니다. 브라우저로 노출되면 안 됩니다(`NEXT_PUBLIC_*` 금지).
|
|
@@ -49,6 +52,7 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
49
52
|
| [`@roottale/cms-client`](https://www.npmjs.com/package/@roottale/cms-client) | 서버 전용 fetch 클라이언트 — 글/테마/설정 조회, 문의 접수, 웹훅 검증 (raw) |
|
|
50
53
|
| [`@roottale/cms-renderer-next`](https://www.npmjs.com/package/@roottale/cms-renderer-next) | Next.js(RSC) 렌더러 — 블로그 컴포넌트, revalidate/RSS/sitemap 라우트 팩토리 |
|
|
51
54
|
| [`@roottale/cms-core`](https://www.npmjs.com/package/@roottale/cms-core) | 블록 JSON 공통 코어 (렌더러가 의존) |
|
|
55
|
+
| [`@roottale/analytics-runtime`](https://www.npmjs.com/package/@roottale/analytics-runtime) | ROOT-ANALYTICS 비콘·동의·Next.js SPA 추적 런타임 |
|
|
52
56
|
| `@roottale/cms-mcp` | 본 MCP 서버 — 통합 문서·예시 코드·API 조회 tool |
|
|
53
57
|
|
|
54
58
|
## 문서 맵
|
|
@@ -57,9 +61,10 @@ RootTale CMS는 어드민(`admin.roottale.com`)에서 콘텐츠를 작성·발
|
|
|
57
61
|
|---|---|
|
|
58
62
|
| `getting-started.md` | API 키 발급, 환경변수, 패키지 설치, 첫 조회 |
|
|
59
63
|
| `blog.md` | 블로그 목록/상세 페이지 구현 (컴포넌트 또는 직접 fetch) |
|
|
64
|
+
| `search.md` | 글·페이지 통합 검색, 실제 공개 주소 계산, 보안·캐시·장애 처리 |
|
|
60
65
|
| `revalidation-webhooks.md` | 발행 웹훅으로 near-real-time 캐시 갱신 |
|
|
61
66
|
| `inquiries.md` | 상담문의(리드) 폼 연동 |
|
|
62
67
|
| `menus.md` | 메뉴(네비게이션) — 어드민 "디자인 > 메뉴" 트리를 헤더/푸터에 렌더 |
|
|
63
68
|
| `seo.md` | RSS 피드, 사이트맵, JSON-LD, 동적 OG 이미지, 공개 검색, fleet 프로브 |
|
|
64
|
-
| `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정,
|
|
69
|
+
| `theme-and-settings.md` | 디자인 토큰, 블로그 표시 설정, ROOT-ANALYTICS |
|
|
65
70
|
| `api-reference.md` | HTTP API 레퍼런스 (비 JS 스택용 raw 엔드포인트) |
|
package/docs/search.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 사이트 검색 연동
|
|
3
|
+
description: 발행된 글과 페이지를 서버에서 안전하게 검색하고 실제 공개 주소로 연결
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 사이트 검색 연동
|
|
7
|
+
|
|
8
|
+
RootTale 검색은 고객 사이트의 **서버**가 공개 CMS API를 호출하는 방식입니다.
|
|
9
|
+
검색창은 일반 GET 폼으로 만들되, `ROOTTALE_API_KEY`는 Server Component나 Route
|
|
10
|
+
Handler 안에서만 사용합니다. 브라우저가 RootTale API를 직접 호출하지 않습니다.
|
|
11
|
+
|
|
12
|
+
## 권장 구조
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
방문자 브라우저
|
|
16
|
+
GET /search?q=상담
|
|
17
|
+
│
|
|
18
|
+
▼
|
|
19
|
+
고객 사이트 Server Component
|
|
20
|
+
searchPosts({ type: "all", locale })
|
|
21
|
+
│ Authorization: Bearer rtlk_cust_*
|
|
22
|
+
▼
|
|
23
|
+
RootTale 공개 검색 API
|
|
24
|
+
tenant + site + locale + published 범위 검색
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
이 구조는 API 키를 숨기고, 검색 결과 페이지를 서버 렌더링하며, 고객사와 사이트
|
|
28
|
+
경계를 API에서 강제합니다. `NEXT_PUBLIC_ROOTTALE_API_KEY`처럼 공개 환경변수에
|
|
29
|
+
키를 넣으면 안 됩니다.
|
|
30
|
+
|
|
31
|
+
## Next.js 구현
|
|
32
|
+
|
|
33
|
+
전체 예시는 `examples/nextjs/app/search/page.tsx`에 있습니다. 핵심 흐름은 다음과
|
|
34
|
+
같습니다.
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import {
|
|
38
|
+
fetchCollections,
|
|
39
|
+
resolveSearchHitPath,
|
|
40
|
+
searchPosts,
|
|
41
|
+
} from "@roottale/cms-client/server";
|
|
42
|
+
|
|
43
|
+
const [hits, collections] = await Promise.all([
|
|
44
|
+
searchPosts({
|
|
45
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
46
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
47
|
+
query,
|
|
48
|
+
type: "all",
|
|
49
|
+
locale,
|
|
50
|
+
limit: 20,
|
|
51
|
+
revalidate: 60,
|
|
52
|
+
}),
|
|
53
|
+
fetchCollections({
|
|
54
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
55
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
56
|
+
}).catch(() => []),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const results = hits.flatMap((hit) => {
|
|
60
|
+
const href = resolveSearchHitPath(hit, collections, locale);
|
|
61
|
+
return href ? [{ hit, href }] : [];
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
사이트 전체 검색은 `type: "all"`을 명시합니다. 이 값을 생략하면 하위 호환을
|
|
66
|
+
위해 글(`post`)만 검색합니다. 다국어 사이트는 현재 경로의 `locale`을 검색 API와
|
|
67
|
+
`resolveSearchHitPath` 양쪽에 같은 값으로 전달합니다.
|
|
68
|
+
|
|
69
|
+
## 결과 주소 계산
|
|
70
|
+
|
|
71
|
+
검색 결과의 `slug`만 보고 `/blog/{slug}`를 직접 만들지 않습니다.
|
|
72
|
+
`resolveSearchHitPath`는 다음 규칙을 적용합니다.
|
|
73
|
+
|
|
74
|
+
| 콘텐츠 | 공개 주소 |
|
|
75
|
+
|---|---|
|
|
76
|
+
| 고정 페이지 | `/{slug}` |
|
|
77
|
+
| 기본 블로그 글 | `/blog/{slug}` |
|
|
78
|
+
| 콘텐츠 유형 글 | `/{collection.basePath}/{slug}` |
|
|
79
|
+
| 다국어 콘텐츠 | 위 주소 앞에 `/{locale}` 추가 |
|
|
80
|
+
| 상세 화면이 없는 콘텐츠 유형 | `null` — 검색 목록에서 제외 |
|
|
81
|
+
|
|
82
|
+
따라서 `fetchCollections`가 실패해도 일반 글은 `/blog/{slug}`로 연결할 수 있지만,
|
|
83
|
+
공지·자료실 같은 별도 콘텐츠 유형의 주소 정확도를 위해 정상 응답을 권장합니다.
|
|
84
|
+
|
|
85
|
+
## 검색 범위와 정렬
|
|
86
|
+
|
|
87
|
+
- API 키에 연결된 tenant와 site 밖의 콘텐츠는 검색하지 않습니다.
|
|
88
|
+
- 요청한 locale의 `published` 콘텐츠만 반환합니다.
|
|
89
|
+
- 제목 완전일치 → 제목 부분일치 → 요약 → 본문 순으로 우선합니다.
|
|
90
|
+
- 같은 점수에서는 최근 발행 콘텐츠가 먼저 나옵니다.
|
|
91
|
+
- 응답은 카드용 슬림 결과이며 본문 전체는 포함하지 않습니다.
|
|
92
|
+
|
|
93
|
+
현재 한 요청은 최대 50건입니다. 첫 버전에는 페이지네이션, 형태소 분석,
|
|
94
|
+
오타 교정, 동의어 확장이 없습니다. 실제 검색 로그에서 필요성이 확인되면
|
|
95
|
+
추가하는 범위입니다.
|
|
96
|
+
|
|
97
|
+
## 캐시와 새 글 반영
|
|
98
|
+
|
|
99
|
+
`revalidate`를 지정하면 Next.js 서버 캐시에 검색 응답이 저장됩니다. 예를 들어
|
|
100
|
+
`revalidate: 60`이면 발행·수정 후 검색 결과가 최대 약 60초 늦게 바뀔 수 있습니다.
|
|
101
|
+
항상 최신 결과가 필요하면 `revalidate: 0`을 사용하되 API 호출량 증가를 고려하세요.
|
|
102
|
+
|
|
103
|
+
플랫폼 배포 순서는 **migration → API → SDK·고객 사이트**입니다. 검색용 생성 열과
|
|
104
|
+
GIN 색인이 먼저 준비되어야 새 API가 안전하게 조회할 수 있습니다.
|
|
105
|
+
|
|
106
|
+
## 빈 결과와 장애 처리
|
|
107
|
+
|
|
108
|
+
- 빈 검색어는 API를 호출하지 않고 입력 안내를 표시합니다.
|
|
109
|
+
- 정상 응답이지만 결과가 없으면 검색어와 함께 `0건` 안내를 표시합니다.
|
|
110
|
+
- API 장애는 빈 결과와 구분해 “잠시 후 다시 시도” 안내를 표시합니다.
|
|
111
|
+
- `searchPosts`는 구 API의 `404`에 한해 빈 배열로 처리하고, 그 밖의 오류는
|
|
112
|
+
`CmsApiError`로 전달합니다.
|
|
113
|
+
|
|
114
|
+
검색 입력은 `type="search"`, `name="q"`, 연결된 `<label>`을 사용하고 결과 수는
|
|
115
|
+
`aria-live="polite"`로 알립니다. 검색 결과 페이지는 보통 중복·저가치 URL이므로
|
|
116
|
+
`robots: { index: false, follow: true }`를 권장합니다.
|
|
117
|
+
|
|
118
|
+
## HTTP API
|
|
119
|
+
|
|
120
|
+
JavaScript 이외의 서버에서는
|
|
121
|
+
`GET /v1/cms/public/search?q=...&type=all&locale=ko&limit=20`을 사용합니다.
|
|
122
|
+
쿼리와 응답 필드는 [HTTP API 레퍼런스](./api-reference.md)를 참고하세요.
|
package/docs/seo.md
CHANGED
|
@@ -591,21 +591,37 @@ export default createPostOgImage(
|
|
|
591
591
|
|
|
592
592
|
## 공개 검색 (사이트 내 검색)
|
|
593
593
|
|
|
594
|
-
`searchPosts` 로
|
|
594
|
+
`searchPosts` 로 발행된 글과 페이지의 통합 검색을 붙일 수 있습니다. API 키는
|
|
595
|
+
브라우저에 보내지 않고 Server Component나 Route Handler에서만 사용합니다:
|
|
595
596
|
|
|
596
597
|
```tsx
|
|
597
598
|
// app/search/page.tsx (Server Component)
|
|
598
|
-
import {
|
|
599
|
+
import {
|
|
600
|
+
fetchCollections,
|
|
601
|
+
resolveSearchHitPath,
|
|
602
|
+
searchPosts,
|
|
603
|
+
} from "@roottale/cms-client/server";
|
|
599
604
|
|
|
600
|
-
const hits = await
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
605
|
+
const [hits, collections] = await Promise.all([
|
|
606
|
+
searchPosts({
|
|
607
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
608
|
+
query: q, // ?q= 쿼리
|
|
609
|
+
type: "all", // 글 + 페이지
|
|
610
|
+
locale: "ko",
|
|
611
|
+
limit: 20,
|
|
612
|
+
}),
|
|
613
|
+
fetchCollections({ apiKey: process.env.ROOTTALE_API_KEY! }),
|
|
614
|
+
]);
|
|
615
|
+
|
|
616
|
+
const links = hits.flatMap((hit) => {
|
|
617
|
+
const href = resolveSearchHitPath(hit, collections);
|
|
618
|
+
return href ? [{ hit, href }] : [];
|
|
604
619
|
});
|
|
605
620
|
// hits: { id, title, slug, excerpt, featuredImageUrl, publishedAt }[]
|
|
606
621
|
```
|
|
607
622
|
|
|
608
|
-
본문은 미포함 슬림 hit
|
|
623
|
+
본문은 미포함 슬림 hit입니다. 주소를 `/blog/{slug}`로 직접 조립하면 공지·자료실
|
|
624
|
+
등 콘텐츠 유형의 실제 주소를 놓칠 수 있으므로 `resolveSearchHitPath`를 사용하세요.
|
|
609
625
|
검색결과 페이지는 위 체크리스트대로 **noindex** 처리를 잊지 마세요.
|
|
610
626
|
|
|
611
627
|
## JSON-LD 스키마 헬퍼
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: 테마·블로그
|
|
3
|
-
description: 어드민에서 관리하는 디자인 토큰, 블로그 표시 옵션,
|
|
2
|
+
title: 테마·블로그 표시·ROOT-ANALYTICS 설정
|
|
3
|
+
description: 어드민에서 관리하는 디자인 토큰, 블로그 표시 옵션, ROOT-ANALYTICS 설정을 사이트에서 조회
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# 테마·블로그
|
|
6
|
+
# 테마·블로그 표시·ROOT-ANALYTICS 설정
|
|
7
7
|
|
|
8
8
|
어드민에서 설정한 값을 공개 API로 조회해 사이트에 반영합니다. 모두
|
|
9
9
|
`@roottale/cms-client/server`에서 제공하며 같은 API 키를 사용합니다.
|
|
@@ -179,10 +179,10 @@ if (business) {
|
|
|
179
179
|
> 반환합니다. 전화·주소만 채우고 이름을 비워두면 **나머지 입력이 전부 무시**되니
|
|
180
180
|
> 고객 안내 시 이름을 필수로 안내하세요.
|
|
181
181
|
|
|
182
|
-
##
|
|
182
|
+
## ROOT-ANALYTICS 설정 — fetchAnalyticsConfig
|
|
183
183
|
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
ROOT-ADMIN에서 등록한 외부 태그(GA4, Microsoft Clarity, Meta Pixel, 네이버)와
|
|
185
|
+
ROOT-ANALYTICS 사이트 ID를 조회해 고객 사이트에 연결합니다.
|
|
186
186
|
|
|
187
187
|
```ts
|
|
188
188
|
import { fetchAnalyticsConfig } from "@roottale/cms-client/server";
|
|
@@ -197,9 +197,9 @@ const config = await fetchAnalyticsConfig({
|
|
|
197
197
|
`enabled: true`인 태그만 렌더링하세요. 태그 ID는 어드민에서 변경될 수
|
|
198
198
|
있으므로 하드코딩하지 말고 본 API로 조회하는 것을 권장합니다.
|
|
199
199
|
|
|
200
|
-
##
|
|
200
|
+
## ROOT-ANALYTICS 조회수·first-party 비콘
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
ROOT-ANALYTICS 비콘은 쿠키리스 first-party 분석(페이지·클릭)과 **글별 조회수**를
|
|
203
203
|
수집합니다. **API 키 하나로** 동작합니다 — 별도 사이트 ID 환경변수가 필요 없습니다.
|
|
204
204
|
`fetchAnalyticsConfig`가 돌려주는 `siteId`를 비콘에 그대로 넘기세요.
|
|
205
205
|
|
|
@@ -240,10 +240,21 @@ export async function generateMetadata({ params }): Promise<Metadata> {
|
|
|
240
240
|
> `@roottale/cms-client/server`의 `contentIdMeta(post.id)`가 같은 `<meta>` 태그
|
|
241
241
|
> 문자열을 만들어 줍니다.
|
|
242
242
|
|
|
243
|
-
수집은 익명·쿠키리스이며 비콘은
|
|
244
|
-
|
|
243
|
+
수집은 익명·쿠키리스이며 비콘은 pageview와 명시한 행동 이벤트를 보냅니다. Next.js
|
|
244
|
+
SPA 전환과 섹션·스크롤·읽기·폼 감지는 `@roottale/analytics-runtime/next` 어댑터로
|
|
245
|
+
연결합니다. 봇 트래픽은 서버에서 제외됩니다. 공개 사이트에 "조회 N"을 표시하는 옵션은 어드민의
|
|
245
246
|
사이트 설정에서 켤 수 있습니다(켜면 글 응답에 `view_count`가 포함됩니다).
|
|
246
247
|
|
|
248
|
+
저장 위치는 데이터 성격에 따라 나뉩니다.
|
|
249
|
+
|
|
250
|
+
| 데이터 | 저장 위치 |
|
|
251
|
+
|---|---|
|
|
252
|
+
| 페이지·클릭·섹션 이벤트 | Cloudflare Analytics Engine `cms_site_events` |
|
|
253
|
+
| 글 누적 조회수 | 사이트별 Durable Object SQLite, PostgreSQL `posts.view_count` 미러 |
|
|
254
|
+
| 첫·마지막 유입 | 브라우저 `localStorage._rt_attr`(30일) |
|
|
255
|
+
| 현재 방문 여정 | 브라우저 `sessionStorage._rt_journey`(최대 30건) |
|
|
256
|
+
| 문의에 귀속된 유입·여정 | PostgreSQL `inquiries.attribution`, `inquiries.journey` |
|
|
257
|
+
|
|
247
258
|
## 사이트 지식 — 브랜드 보이스 (AI 에이전트용)
|
|
248
259
|
|
|
249
260
|
이 사이트의 **브랜드 보이스**(어조·톤·화자)와 **용어 규칙**(금지어·교정어)을
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// 사이트 통합 검색 — 고객 브라우저가 아니라 이 Server Component가 CMS API를 호출한다.
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
fetchCollections,
|
|
7
|
+
resolveSearchHitPath,
|
|
8
|
+
searchPosts,
|
|
9
|
+
} from "@roottale/cms-client/server";
|
|
10
|
+
|
|
11
|
+
export const metadata: Metadata = {
|
|
12
|
+
title: "사이트 검색",
|
|
13
|
+
robots: { index: false, follow: true },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
searchParams: Promise<{ q?: string | string[] }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default async function SearchPage({ searchParams }: Props) {
|
|
21
|
+
const { q = "" } = await searchParams;
|
|
22
|
+
const query = (typeof q === "string" ? q : q[0] ?? "").trim();
|
|
23
|
+
const apiKey = process.env.ROOTTALE_API_KEY!;
|
|
24
|
+
const baseUrl = process.env.ROOTTALE_API_BASE;
|
|
25
|
+
const [hits, collections] = query
|
|
26
|
+
? await Promise.all([
|
|
27
|
+
searchPosts({ apiKey, baseUrl, query, type: "all", limit: 20 }),
|
|
28
|
+
fetchCollections({ apiKey, baseUrl }).catch(() => []),
|
|
29
|
+
])
|
|
30
|
+
: [[], []];
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<main>
|
|
34
|
+
<h1>사이트 검색</h1>
|
|
35
|
+
<form action="/search" method="get" role="search">
|
|
36
|
+
<label htmlFor="site-search">검색어</label>
|
|
37
|
+
<input
|
|
38
|
+
defaultValue={query}
|
|
39
|
+
id="site-search"
|
|
40
|
+
maxLength={100}
|
|
41
|
+
name="q"
|
|
42
|
+
required
|
|
43
|
+
type="search"
|
|
44
|
+
/>
|
|
45
|
+
<button type="submit">검색</button>
|
|
46
|
+
</form>
|
|
47
|
+
{query ? <p>검색 결과 {hits.length}건</p> : <p>검색어를 입력해 주세요.</p>}
|
|
48
|
+
<ul>
|
|
49
|
+
{hits.map((hit) => {
|
|
50
|
+
const href = resolveSearchHitPath(hit, collections);
|
|
51
|
+
if (!href) return null;
|
|
52
|
+
return (
|
|
53
|
+
<li key={hit.id}>
|
|
54
|
+
<Link href={href}>
|
|
55
|
+
<h2>{hit.title}</h2>
|
|
56
|
+
{hit.excerpt ? <p>{hit.excerpt}</p> : null}
|
|
57
|
+
</Link>
|
|
58
|
+
</li>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</ul>
|
|
62
|
+
</main>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RootTaleExposureSlot } from "@roottale/cms-renderer-next/server";
|
|
2
|
+
|
|
3
|
+
export async function GlobalPopup({ path }: { path: string }) {
|
|
4
|
+
return (
|
|
5
|
+
<RootTaleExposureSlot
|
|
6
|
+
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
7
|
+
slotKey="global-popup"
|
|
8
|
+
path={path}
|
|
9
|
+
allowedVariants={["card", "image-card"]}
|
|
10
|
+
revalidate={60}
|
|
11
|
+
/>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 글 슬러그 변경 자동 301 은 글 라우트에서 처리되지만(blog 예시 참고), 글이
|
|
4
4
|
// 아닌 임의 경로(`/old-event → /promo`)는 라우팅 이전 단계인 미들웨어에서만
|
|
5
|
-
// 가로챌 수 있다. 규칙은 자동 캐시되고, API 실패 시
|
|
5
|
+
// 가로챌 수 있다. 규칙은 자동 캐시되고, API 실패 시 stale 캐시를
|
|
6
|
+
// 우선 사용한다. 캐시가 없거나 순환 규칙이면 트래픽을 막지 않는다.
|
|
6
7
|
import { NextResponse } from "next/server";
|
|
7
8
|
import { createRedirectMiddleware } from "@roottale/cms-renderer-next/routes";
|
|
8
9
|
|