@roottale/cms-mcp 0.34.0 → 0.35.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 +26 -0
- package/dist/index.js +1 -1
- package/docs/api-reference.md +24 -0
- package/docs/collections.md +70 -80
- package/docs/custom-redirects.md +83 -0
- package/docs/seo.md +5 -4
- package/examples/nextjs/middleware.ts +23 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @roottale/cms-mcp
|
|
2
2
|
|
|
3
|
+
## 0.35.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- cfddfc7: 커스텀 리다이렉트 (WP RankMath Redirections 패리티) — 운영자가 어드민 "설정 >
|
|
8
|
+
주소 이동"에서 글이 아닌 임의 경로 이동 규칙(`/old-event → /promo`, 301/302)을
|
|
9
|
+
정의하고, 사이트 미들웨어가 적용한다.
|
|
10
|
+
- `@roottale/cms-client`: `fetchRedirects()` 추가 — `GET /v1/cms/public/redirects`
|
|
11
|
+
의 활성 규칙 목록을 가져온다(404 → 빈 배열 fail-soft).
|
|
12
|
+
- `@roottale/cms-renderer-next`: `createRedirectMiddleware()` 추가(+ 순수 함수
|
|
13
|
+
`matchRedirect`/`normalizeRedirectPath`), `/routes` 서브패스로 노출. 규칙을
|
|
14
|
+
TTL 캐시하고 API 실패 시 트래픽을 막지 않는다.
|
|
15
|
+
- `@roottale/cms-mcp`: `custom-redirects.md` 연동 가이드 + api-reference 의
|
|
16
|
+
`/redirects` 항목 + nextjs `middleware.ts` 예시.
|
|
17
|
+
|
|
18
|
+
## 0.34.1
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- ff6eec1: docs: 콘텐츠 유형(Collections) 문서를 M4 최종 모델로 갱신
|
|
23
|
+
|
|
24
|
+
섹션은 글의 `collection_key`로 정해지고 카테고리는 섹션 안의 주제(아카이브)라는
|
|
25
|
+
최종 모델 반영. catch-all·first-wins·"카테고리로 섹션 추론" 등 제거된 동작 설명을
|
|
26
|
+
삭제. collections.md 전면 개정 + api-reference 에 `collection_key` 필드 + seo.md
|
|
27
|
+
교차링크 정정.
|
|
28
|
+
|
|
3
29
|
## 0.34.0
|
|
4
30
|
|
|
5
31
|
### Patch Changes
|
package/dist/index.js
CHANGED
|
@@ -279,7 +279,7 @@ function registerTools(server2) {
|
|
|
279
279
|
}
|
|
280
280
|
|
|
281
281
|
// src/server.ts
|
|
282
|
-
var VERSION = true ? "0.
|
|
282
|
+
var VERSION = true ? "0.35.0" : "dev";
|
|
283
283
|
var SERVER_INSTRUCTIONS = `
|
|
284
284
|
roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uAE30 \uC704\uD55C
|
|
285
285
|
\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
|
@@ -25,6 +25,7 @@ JS/TS 스택은 raw 호출 대신 `@roottale/cms-client`를 사용하세요 —
|
|
|
25
25
|
```json
|
|
26
26
|
{
|
|
27
27
|
"items": [ { "id": "…", "slug": "…", "title": "…", "body_json": {…},
|
|
28
|
+
"collection_key": "blog",
|
|
28
29
|
"terms": [{ "taxonomy": "category", "name": "…", "slug": "…" }],
|
|
29
30
|
"published_at": "…" } ],
|
|
30
31
|
"has_more": false,
|
|
@@ -32,6 +33,10 @@ JS/TS 스택은 raw 호출 대신 `@roottale/cms-client`를 사용하세요 —
|
|
|
32
33
|
}
|
|
33
34
|
```
|
|
34
35
|
|
|
36
|
+
> `collection_key` = 글이 속한 섹션(콘텐츠 유형). 섹션 라우팅(`/notice`·`/blog`)의 근거이며,
|
|
37
|
+
> 미설정이면 `null`. 카테고리(`terms`)는 섹션 안의 주제로 아카이브에만 쓰입니다 →
|
|
38
|
+
> [콘텐츠 유형 (Collections)](./collections.md).
|
|
39
|
+
|
|
35
40
|
## GET /v1/cms/public/posts/{identifier}
|
|
36
41
|
|
|
37
42
|
글 1개 — `identifier`는 slug 또는 UUID. 미발행/없는 글은 `404`.
|
|
@@ -78,6 +83,25 @@ JS/TS 는 `@roottale/cms-client/server` 의 `searchPosts({ apiKey, query })` 를
|
|
|
78
83
|
위치 핸들(`primary`, `footer` 등)로 메뉴 1개. 없으면 `404` — 사이트는 자체
|
|
79
84
|
fallback 네비를 렌더하세요 (`menus.md` 참고).
|
|
80
85
|
|
|
86
|
+
## GET /v1/cms/public/redirects
|
|
87
|
+
|
|
88
|
+
어드민 "설정 > 주소 이동"에서 정의한 **활성** 커스텀 리다이렉트 규칙 전체.
|
|
89
|
+
사이트 미들웨어가 요청 경로를 매칭해 301/302 처리합니다. 비활성 규칙과 운영자
|
|
90
|
+
메모는 응답에 포함되지 않습니다. 라우트 미배포(구 서버)는 `404` — 빈 목록으로
|
|
91
|
+
처리하세요. 연동은 `custom-redirects.md` 참고.
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{ "tenant_id": "…", "site_id": "…",
|
|
95
|
+
"items": [ { "id": "…", "from_path": "/old-event",
|
|
96
|
+
"to_target": "/promo", "status_code": 301 } ] }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| 필드 | 설명 |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `from_path` | 출발 경로 — 정규화된 사이트 내부 절대 경로(앞 슬래시, 쿼리 제외). |
|
|
102
|
+
| `to_target` | 도착지 — 내부 경로(`/promo`) 또는 절대 URL(`https://…`). |
|
|
103
|
+
| `status_code` | `301`(영구) 또는 `302`(임시). |
|
|
104
|
+
|
|
81
105
|
## GET /v1/cms/public/theme
|
|
82
106
|
|
|
83
107
|
어드민에서 설정한 디자인 토큰. 설정된 그룹만 포함됩니다.
|
package/docs/collections.md
CHANGED
|
@@ -1,78 +1,95 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: 콘텐츠 유형 (Collections) — 공지·블로그 URL 분리
|
|
3
|
-
description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등 여러
|
|
3
|
+
description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등 여러 섹션으로 나누는 법. 섹션=글의 collection_key, 카테고리=섹션 안의 주제. URL 구조, 어드민 설정, 연동 코드, slug·301·OG.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 콘텐츠 유형 (Collections)
|
|
7
7
|
|
|
8
|
-
하나의 글 목록을 **여러
|
|
9
|
-
가장 흔한 형태는 **공지 게시판(`/notice`) + 블로그(`/blog`)** 분리입니다.
|
|
10
|
-
|
|
8
|
+
하나의 글 목록을 **여러 섹션(stream)**으로 나눠 서로 다른 URL·레이아웃으로 보여주는
|
|
9
|
+
기능입니다. 가장 흔한 형태는 **공지 게시판(`/notice`) + 블로그(`/blog`)** 분리입니다.
|
|
10
|
+
|
|
11
|
+
> **핵심 모델 (2024 ADR-0060 Amendment 1):**
|
|
12
|
+
> 글은 **하나의 섹션**에 속하고(`collection_key`), 그 안에서 여러 **주제**(category)를
|
|
13
|
+
> 가질 수 있습니다. 섹션은 글쓰기 화면의 **"어디에 올릴까요?"**에서 고르고, 주제는
|
|
14
|
+
> 섹션 안의 세부 분류(예: 블로그의 *칼럼*·*소식*)입니다.
|
|
11
15
|
|
|
12
16
|
## URL이 어떻게 정해지나
|
|
13
17
|
|
|
14
|
-
> **글의 URL = 그 글이 속한
|
|
18
|
+
> **글의 URL = 그 글이 속한 섹션의 `basePath` + `/{slug}`** — 섹션은 글의 `collection_key`로 정해집니다.
|
|
15
19
|
|
|
16
|
-
| 글의
|
|
20
|
+
| 글의 섹션 (`collection_key`) | basePath | URL |
|
|
17
21
|
|---|---|---|
|
|
18
|
-
| `notice`
|
|
19
|
-
| `
|
|
22
|
+
| `notice` (공지) | `/notice` | `/notice/{slug}` |
|
|
23
|
+
| `blog` (블로그) | `/blog` | `/blog/{slug}` |
|
|
20
24
|
|
|
21
|
-
예: `notice
|
|
22
|
-
`
|
|
25
|
+
예: 섹션이 `notice`인 글 "개강안내" → `https://내사이트/notice/개강안내`
|
|
26
|
+
섹션이 `blog`인 글 "비문학공부법" → `https://내사이트/blog/비문학공부법`
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
섹션별로 함께 만들어지는 경로:
|
|
25
29
|
|
|
26
30
|
| 경로 | 설명 |
|
|
27
31
|
|---|---|
|
|
28
|
-
| `{basePath}` |
|
|
32
|
+
| `{basePath}` | 섹션 목록 (예: `/notice`, `/blog`) |
|
|
29
33
|
| `{basePath}/{slug}` | 글 상세 |
|
|
30
|
-
| `{basePath}/categories/{slug}` |
|
|
31
|
-
| `/feed.xml` | RSS — `feed` 켠
|
|
32
|
-
| `/sitemap.xml` | 글마다 **소속
|
|
34
|
+
| `{basePath}/categories/{slug}` | **주제** 아카이브 (`archives` 켠 섹션만) |
|
|
35
|
+
| `/feed.xml` | RSS — `feed` 켠 섹션들의 통합 피드 |
|
|
36
|
+
| `/sitemap.xml` | 글마다 **소속 섹션 basePath로** 정확히 매핑 |
|
|
33
37
|
| `{basePath}/{slug}/opengraph-image` | 글별 동적 OG 카드 (배선 시) |
|
|
34
38
|
|
|
35
39
|
규칙:
|
|
36
40
|
|
|
37
41
|
- **slug은 한글 그대로** 됩니다(예: `/blog/비문학독해`). 내부적으로 percent-encoding.
|
|
38
|
-
- **같은 글이 두
|
|
39
|
-
-
|
|
40
|
-
|
|
42
|
+
- **같은 글이 두 섹션에 안 뜸**: 공지 글을 `/blog/개강안내`로 열면 404, 반대도 404(가드).
|
|
43
|
+
- **섹션(`collection_key`)이 없는 글**은 sitemap·상세 라우트에서 제외됩니다.
|
|
44
|
+
|
|
45
|
+
## 섹션 vs 주제 (자주 헷갈리는 부분)
|
|
46
|
+
|
|
47
|
+
| | 섹션 (collection) | 주제 (category) |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| 무엇 | 글이 사는 곳 (공지 / 블로그) | 섹션 안의 세부 분류 (칼럼 / 소식) |
|
|
50
|
+
| 글당 | **딱 하나** (배타적) | 0개 이상 (선택·복수) |
|
|
51
|
+
| 정하는 곳 | 글쓰기 "어디에 올릴까요?" | 글쓰기 주제 칩 / 설정 > 분류 |
|
|
52
|
+
| 저장 | `post.collection_key` | 글의 category terms |
|
|
53
|
+
| 라우팅 | basePath 결정 (`/notice`) | 아카이브만 (`/blog/categories/칼럼`) |
|
|
54
|
+
|
|
55
|
+
> 이전 버전은 *카테고리로 섹션을 추론*했지만, 지금은 섹션이 글에 **명시**됩니다.
|
|
56
|
+
> 카테고리는 더 이상 어느 섹션에 속하는지를 결정하지 않습니다(주제 아카이브 전용).
|
|
41
57
|
|
|
42
58
|
## 어드민에서 설정 (`mysite.roottale.com`)
|
|
43
59
|
|
|
44
|
-
**설정 > 콘텐츠 유형** 에서
|
|
60
|
+
**설정 > 콘텐츠 유형** 에서 섹션을 정의합니다. 빈 상태의 "공지 + 블로그 한 번에 만들기"
|
|
61
|
+
버튼으로 표준 두 섹션을 한 번에 만들 수 있습니다. 각 섹션은:
|
|
45
62
|
|
|
46
63
|
| 항목 | 의미 |
|
|
47
64
|
|---|---|
|
|
48
|
-
| key | 안정 식별자 (`notice`, `blog`) |
|
|
65
|
+
| key | 안정 식별자 (`notice`, `blog`) — 사이트 코드와 맞물리므로 보통 고정 |
|
|
49
66
|
| 라벨 | 메뉴·작성 화면 표시 이름 (공지/블로그) |
|
|
50
67
|
| basePath | URL 앞부분 (`/notice`, `/blog`) |
|
|
51
|
-
|
|
|
52
|
-
| feed / archives / og | RSS 포함 /
|
|
53
|
-
| 순서 |
|
|
68
|
+
| 주제 | 이 섹션이 제공하는 카테고리(아카이브 범위). 비우면 주제 없음 |
|
|
69
|
+
| feed / archives / og | RSS 포함 / 주제 아카이브 / 동적 OG |
|
|
70
|
+
| 순서 | 메뉴·표시 순서 |
|
|
54
71
|
|
|
55
|
-
|
|
72
|
+
**섹션을 하나도 안 만들면 단일 블로그(`/blog`)** 로 동작합니다(설정 전 기본값).
|
|
56
73
|
|
|
57
74
|
### ⚠️ basePath는 "라우트가 있어야" 동작합니다 (데이터=DB, 라우트=코드)
|
|
58
75
|
|
|
59
|
-
basePath
|
|
76
|
+
basePath·라벨·주제·플래그는 어드민에서 바꾸면 sitemap·feed·라우팅이 즉시 따라갑니다.
|
|
60
77
|
**단 basePath에 해당하는 페이지 파일이 사이트에 있어야** 실제로 열립니다:
|
|
61
78
|
|
|
62
79
|
- `/notice`·`/blog`처럼 **이미 라우트가 있는 경로**는 어드민만으로 자유롭게 편집 → 동작.
|
|
63
80
|
- basePath를 **완전히 새 경로**(예: `/news`)로 바꾸면 sitemap엔 `/news/{slug}`가 나가지만
|
|
64
|
-
사이트에 `app/news/[slug]` 라우트가 없으면 **404**.
|
|
65
|
-
합니다. (새 basePath도 코드 수정 없이 동작시키려면 catch-all 동적
|
|
81
|
+
사이트에 `app/news/[slug]` 라우트가 없으면 **404**. 개발자가 라우트를 먼저 추가해야
|
|
82
|
+
합니다. (새 basePath도 코드 수정 없이 동작시키려면 catch-all 동적 라우트 — 아래.)
|
|
66
83
|
|
|
67
|
-
###
|
|
84
|
+
### 글을 섹션에 올리기
|
|
68
85
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
86
|
+
글쓰기 화면 맨 위 **"어디에 올릴까요?"**에서 공지/블로그 카드를 고릅니다 — 이게 글의
|
|
87
|
+
섹션(`collection_key`)이 됩니다. 그 아래 **주제**는 고른 섹션의 카테고리만 보입니다
|
|
88
|
+
(블로그면 칼럼·소식 등). 주제는 선택이며, **설정 > 분류**(taxonomy)에서 미리 만들어 둡니다.
|
|
72
89
|
|
|
73
90
|
## 사이트 연동 코드
|
|
74
91
|
|
|
75
|
-
|
|
92
|
+
섹션 선언을 route 팩토리에 넘기면 sitemap·feed·revalidate가 거기서 파생됩니다. 두 가지
|
|
76
93
|
방식이 있습니다.
|
|
77
94
|
|
|
78
95
|
### 방식 A — 코드 상수 (간단, 고정)
|
|
@@ -81,17 +98,21 @@ basePath·라벨·카테고리·플래그는 어드민에서 바꾸면 sitemap·
|
|
|
81
98
|
import type { RouteCollection } from "@roottale/cms-renderer-next/routes";
|
|
82
99
|
|
|
83
100
|
export const COLLECTIONS: RouteCollection[] = [
|
|
84
|
-
{ key: "notice", basePath: "/notice", categories: [
|
|
101
|
+
{ key: "notice", basePath: "/notice", categories: [] }, // 주제 없는 게시판
|
|
85
102
|
{
|
|
86
103
|
key: "blog",
|
|
87
104
|
basePath: "/blog",
|
|
88
|
-
categories: ["column", "news"], //
|
|
105
|
+
categories: ["column", "news"], // 이 섹션의 주제(아카이브 범위)
|
|
89
106
|
feed: true,
|
|
90
107
|
archives: true,
|
|
91
108
|
},
|
|
92
109
|
];
|
|
93
110
|
```
|
|
94
111
|
|
|
112
|
+
> `categories`는 이제 **그 섹션이 제공하는 주제 목록**(아카이브 `/blog/categories/{slug}`
|
|
113
|
+
> 생성 범위)입니다. 섹션 소속은 글의 `collection_key`로 정해지므로, 이 배열은 라우팅
|
|
114
|
+
> 소유권이 아닙니다.
|
|
115
|
+
|
|
95
116
|
```ts
|
|
96
117
|
// app/sitemap.ts
|
|
97
118
|
import { createSitemap } from "@roottale/cms-renderer-next/routes";
|
|
@@ -107,7 +128,7 @@ export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: COLLEC
|
|
|
107
128
|
### 방식 B — 어드민에서 동적 로드 (운영자가 직접 편집)
|
|
108
129
|
|
|
109
130
|
상수 대신 **어드민 "콘텐츠 유형"** 값을 `fetchCollections()`로 가져와 팩토리에 **resolver
|
|
110
|
-
함수**로 넘깁니다. 운영자가 어드민에서
|
|
131
|
+
함수**로 넘깁니다. 운영자가 어드민에서 섹션을 바꾸면 사이트가 따라갑니다.
|
|
111
132
|
|
|
112
133
|
```ts
|
|
113
134
|
import { fetchCollections } from "@roottale/cms-client/server";
|
|
@@ -131,9 +152,11 @@ export const GET = createFeedRoute({ apiKey, siteUrl, title, collections: getCol
|
|
|
131
152
|
응답은 `RouteCollection`과 구조 호환이라 그대로 넘길 수 있습니다. 매 요청 fetch를 피하려면
|
|
132
153
|
사이트 경계에서 캐시하세요(예: Next `fetch(url, { next: { revalidate: 300 } })`).
|
|
133
154
|
|
|
134
|
-
### 상세 페이지 가드 (
|
|
155
|
+
### 상세 페이지 가드 (섹션 누출 차단)
|
|
135
156
|
|
|
136
|
-
상세 라우트는 글이 그
|
|
157
|
+
상세 라우트는 글이 그 섹션 소속인지 확인해 다른 섹션 글이 새는 것을 막습니다.
|
|
158
|
+
`resolvePostCollection`은 글의 `collectionKey`로 섹션을 해석합니다(공개 API가 글마다
|
|
159
|
+
`collection_key`를 내려줍니다 → `cms-client`의 `CmsPostContent.collectionKey`).
|
|
137
160
|
|
|
138
161
|
```ts
|
|
139
162
|
import { resolvePostCollection } from "@roottale/cms-renderer-next/routes";
|
|
@@ -149,7 +172,7 @@ import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
|
|
|
149
172
|
export const POST = createRevalidateRoute({ apiKey, revalidate, collections: COLLECTIONS });
|
|
150
173
|
```
|
|
151
174
|
|
|
152
|
-
각
|
|
175
|
+
각 섹션 basePath(+ `archives`면 `/categories`)와 `/feed.xml`·`/sitemap.xml`·`/llms.txt`를
|
|
153
176
|
자동 무효화합니다. resolver가 실패하면 잘못된 경로를 추측하지 않고 불변 경로만 갱신하며
|
|
154
177
|
응답에 `warning`을 노출합니다.
|
|
155
178
|
|
|
@@ -173,41 +196,7 @@ export default createPostOgImage(
|
|
|
173
196
|
|
|
174
197
|
> Astro 사이트는 `@roottale/cms-renderer-astro`에서 동일한 `resolvePostCollection`/
|
|
175
198
|
> `resolvePostPath`/`RouteCollection`을 import 하고 `renderBlogList({ collections })`로
|
|
176
|
-
> 링크를
|
|
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
|
-
## 명시 섹션 — `collection_key` (권장)
|
|
192
|
-
|
|
193
|
-
공개 API의 글(post) 응답에는 글이 속한 섹션을 직접 가리키는 **`collection_key`**
|
|
194
|
-
필드가 포함됩니다. 어드민 글쓰기에서 "어디에 올릴까요?"로 고른 섹션이 이 값으로
|
|
195
|
-
저장됩니다. `cms-client`의 `CmsPostContent.collectionKey`로 받습니다.
|
|
196
|
-
|
|
197
|
-
`resolvePostCollection(post, collections)`는 **`collectionKey`가 있으면 그것을
|
|
198
|
-
우선** 사용하고(일치하는 섹션이 있을 때), 없으면 기존처럼 글의 카테고리 slug로
|
|
199
|
-
섹션을 파생합니다. 즉:
|
|
200
|
-
|
|
201
|
-
- 신규 글: `collectionKey`로 섹션이 명확히 결정됩니다(카테고리는 순수 "주제"로만 쓰임).
|
|
202
|
-
- 구 글/구 서버: `collection_key`가 없으므로 카테고리 파생으로 **그대로 동작**(하위호환).
|
|
203
|
-
|
|
204
|
-
```ts
|
|
205
|
-
// 소비자 코드는 동일 — resolver가 collectionKey 를 우선 사용.
|
|
206
|
-
const c = resolvePostCollection(post, collections);
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
> 마이그레이션 중에는 두 방식이 공존합니다. `collection_key`가 채워진 글은 카테고리와
|
|
210
|
-
> 무관하게 그 섹션으로 라우팅되고, 비어 있는 글은 카테고리로 판정됩니다.
|
|
199
|
+
> 링크를 섹션별로 라우팅합니다(동등 surface).
|
|
211
200
|
|
|
212
201
|
## slug 변경과 301
|
|
213
202
|
|
|
@@ -218,15 +207,16 @@ const c = resolvePostCollection(post, collections);
|
|
|
218
207
|
|
|
219
208
|
basePath를 **코드 수정 없이 어드민에서 자유롭게** 바꾸고 싶으면, 정적 `app/notice/[slug]`
|
|
220
209
|
대신 **catch-all 동적 라우트** `app/[stream]/[slug]/page.tsx`(또는 `app/[...path]`)를 두고,
|
|
221
|
-
그 안에서 collections를 읽어 요청 경로가 어떤
|
|
210
|
+
그 안에서 collections를 읽어 요청 경로가 어떤 섹션 basePath인지 판정해 렌더합니다. 그러면
|
|
222
211
|
어드민에서 basePath를 `/news`로 바꿔도 동작합니다. 트레이드오프: 정적 라우트보다 캐시·타입
|
|
223
|
-
안전성이 떨어지므로,
|
|
212
|
+
안전성이 떨어지므로, 섹션 구조가 자주 바뀌는 사이트에만 권장합니다.
|
|
224
213
|
|
|
225
214
|
## 자주 막히는 곳
|
|
226
215
|
|
|
227
|
-
- **글이 안 보여요** →
|
|
228
|
-
|
|
229
|
-
- **404가 떠요** → 공지 글을 `/blog/...`로(또는 그 반대로) 열면 가드가 막습니다. 올바른
|
|
216
|
+
- **글이 안 보여요** → 글에 **섹션(`collection_key`)**이 지정됐는지 확인. 글쓰기 "어디에
|
|
217
|
+
올릴까요?"에서 섹션을 골라야 합니다. (섹션 없는 글은 sitemap·상세에서 제외.)
|
|
218
|
+
- **404가 떠요** → 공지 글을 `/blog/...`로(또는 그 반대로) 열면 가드가 막습니다. 올바른 섹션
|
|
230
219
|
basePath로 접근하세요. basePath를 바꿨다면 사이트에 그 라우트 파일이 있는지 확인.
|
|
231
|
-
-
|
|
232
|
-
|
|
220
|
+
- **주제 아카이브가 비어요** → 그 주제(카테고리)가 섹션의 `categories`(주제 목록)에 있고
|
|
221
|
+
`archives`가 켜져 있는지 확인.
|
|
222
|
+
- **sitemap에 글이 빠졌어요** → 그 글에 섹션이 없으면 의도적으로 제외됩니다.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 주소 이동 (커스텀 리다이렉트) 연동
|
|
3
|
+
description: 어드민 "설정 > 주소 이동"에서 정의한 임의 경로 리다이렉트를 사이트 미들웨어로 적용
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 주소 이동 (커스텀 리다이렉트) 연동
|
|
7
|
+
|
|
8
|
+
어드민(mysite.roottale.com)의 **설정 > 주소 이동**에서 운영자가 정의한 임의
|
|
9
|
+
경로 이동 규칙(`/old-event → /promo`)을 사이트 미들웨어로 적용합니다. 코드
|
|
10
|
+
수정 없이 고객이 직접 규칙을 추가·수정·삭제할 수 있습니다.
|
|
11
|
+
|
|
12
|
+
두 종류의 주소 이동이 있습니다.
|
|
13
|
+
|
|
14
|
+
- **글 주소 변경 자동 301** — 블로그 글의 슬러그를 바꾸면 자동으로 옛 주소가
|
|
15
|
+
새 주소로 이어집니다. 글 라우트에서 처리되며 별도 설정이 필요 없습니다
|
|
16
|
+
(`blog.md` 의 `postRedirectPath` 참고).
|
|
17
|
+
- **커스텀 리다이렉트(이 문서)** — 글이 아닌 임의 경로를 옮깁니다. 라우팅
|
|
18
|
+
*이전* 단계인 **미들웨어**에서만 가로챌 수 있어, 아래 설정이 필요합니다.
|
|
19
|
+
|
|
20
|
+
규칙은 `GET /v1/cms/public/redirects` 로 내려오며 **활성** 규칙만 포함됩니다
|
|
21
|
+
(`api-reference.md`). 출발 경로는 정규화된 사이트 내부 절대 경로, 도착지는
|
|
22
|
+
내부 경로 또는 절대 URL, 상태는 `301`(영구) 또는 `302`(임시)입니다.
|
|
23
|
+
|
|
24
|
+
## 미들웨어 설정
|
|
25
|
+
|
|
26
|
+
`@roottale/cms-renderer-next` 의 `createRedirectMiddleware` 를 프로젝트 루트
|
|
27
|
+
`middleware.ts` 에 마운트합니다. 규칙을 자동 캐시(기본 60초)하며, API 실패 시
|
|
28
|
+
트래픽을 막지 않고 통과시킵니다(fail-soft).
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// middleware.ts
|
|
32
|
+
import { NextResponse } from "next/server";
|
|
33
|
+
import { createRedirectMiddleware } from "@roottale/cms-renderer-next/routes";
|
|
34
|
+
|
|
35
|
+
const redirects = createRedirectMiddleware({
|
|
36
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
37
|
+
// apiBase, siteId, cacheTtlMs 는 선택.
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export async function middleware(req: Request) {
|
|
41
|
+
return (await redirects(req)) ?? NextResponse.next();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Next 내부·API·알려진 정적 자산만 제외합니다. 점 포함 경로를 전부 막으면
|
|
45
|
+
// `/old.html`·`/foo.php` 같은 레거시 마이그레이션 리다이렉트가 동작하지
|
|
46
|
+
// 않으므로, 자산 확장자만 명시적으로 제외합니다.
|
|
47
|
+
export const config = {
|
|
48
|
+
matcher: [
|
|
49
|
+
"/((?!_next/|api/|.*\\.(?:ico|png|jpg|jpeg|gif|svg|webp|css|js|txt|xml|json|woff2?|map)$).*)",
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `ROOTTALE_API_KEY` 는 블로그 조회와 **같은** 키입니다. 서버 전용 — 절대
|
|
55
|
+
브라우저에 노출하지 마세요.
|
|
56
|
+
- 매칭은 **정확 경로 일치**입니다(와일드카드 없음). 출발 경로의 앞/뒤 슬래시와
|
|
57
|
+
한글 percent-encoding 차이는 자동 정규화해 비교합니다.
|
|
58
|
+
- 도착지가 내부 경로면 요청 origin 기준 절대 URL 로 변환해 리다이렉트합니다.
|
|
59
|
+
- 매칭이 없으면 `null` 을 반환하므로 `NextResponse.next()` 로 통과시키세요.
|
|
60
|
+
|
|
61
|
+
## 캐시와 즉시성
|
|
62
|
+
|
|
63
|
+
규칙은 미들웨어가 TTL(기본 60초) 동안 캐시합니다. 운영자가 규칙을 바꾸면
|
|
64
|
+
최대 TTL 만큼 뒤 반영됩니다. 더 빠른 반영이 필요하면 `cacheTtlMs` 를 줄이세요
|
|
65
|
+
(요청당 API 호출이 늘어납니다).
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const redirects = createRedirectMiddleware({
|
|
69
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
70
|
+
cacheTtlMs: 10_000, // 10초
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 직접 호출 (미들웨어 없이)
|
|
75
|
+
|
|
76
|
+
규칙 목록만 필요하면 `fetchRedirects` 로 직접 가져올 수 있습니다.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { fetchRedirects } from "@roottale/cms-client/server";
|
|
80
|
+
|
|
81
|
+
const rules = await fetchRedirects({ apiKey: process.env.ROOTTALE_API_KEY! });
|
|
82
|
+
// [{ id, fromPath, toTarget, statusCode }]
|
|
83
|
+
```
|
package/docs/seo.md
CHANGED
|
@@ -53,10 +53,11 @@ export default createSitemap(
|
|
|
53
53
|
|
|
54
54
|
## 다중 스트림 (collections) — 공지·블로그 분리
|
|
55
55
|
|
|
56
|
-
같은 글 풀을 공지 게시판(`/notice`) + 블로그(`/blog`) 등 여러
|
|
57
|
-
URL·레이아웃으로 보여줄 수 있습니다.
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
같은 글 풀을 공지 게시판(`/notice`) + 블로그(`/blog`) 등 여러 섹션으로 나눠 서로 다른
|
|
57
|
+
URL·레이아웃으로 보여줄 수 있습니다. 섹션은 글의 `collection_key`로 정해지고, 카테고리는
|
|
58
|
+
섹션 안의 주제(아카이브)입니다. URL 구조·어드민 설정·연동 코드(코드 상수 vs 어드민
|
|
59
|
+
fetch)·가드·동적 basePath는 별도 문서 [콘텐츠 유형 (Collections)](./collections.md)
|
|
60
|
+
에 정리되어 있습니다. 여기 sitemap/feed 예시도 `collections`를 넘기면 섹션별로 파생됩니다.
|
|
60
61
|
|
|
61
62
|
## robots.txt
|
|
62
63
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// middleware.ts — 커스텀 리다이렉트(어드민 "설정 > 주소 이동") 적용.
|
|
2
|
+
//
|
|
3
|
+
// 글 슬러그 변경 자동 301 은 글 라우트에서 처리되지만(blog 예시 참고), 글이
|
|
4
|
+
// 아닌 임의 경로(`/old-event → /promo`)는 라우팅 이전 단계인 미들웨어에서만
|
|
5
|
+
// 가로챌 수 있다. 규칙은 자동 캐시되고, API 실패 시 트래픽을 막지 않는다.
|
|
6
|
+
import { NextResponse } from "next/server";
|
|
7
|
+
import { createRedirectMiddleware } from "@roottale/cms-renderer-next/routes";
|
|
8
|
+
|
|
9
|
+
const redirects = createRedirectMiddleware({
|
|
10
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export async function middleware(req: Request) {
|
|
14
|
+
return (await redirects(req)) ?? NextResponse.next();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Next 내부·API·알려진 정적 자산만 제외. 점 포함 경로를 전부 막으면
|
|
18
|
+
// `/old.html`·`/foo.php` 같은 레거시 마이그레이션 리다이렉트가 동작하지 않는다.
|
|
19
|
+
export const config = {
|
|
20
|
+
matcher: [
|
|
21
|
+
"/((?!_next/|api/|.*\\.(?:ico|png|jpg|jpeg|gif|svg|webp|css|js|txt|xml|json|woff2?|map)$).*)",
|
|
22
|
+
],
|
|
23
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roottale/cms-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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": {
|