@roottale/cms-mcp 0.53.1 → 0.56.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 +74 -0
- package/README.md +17 -9
- package/dist/index.js +660 -45
- package/dist/index.js.map +1 -1
- package/docs/api-reference.md +70 -9
- package/docs/blog.md +108 -1
- package/docs/collections.md +35 -1
- package/docs/content-models-and-exposures.md +329 -32
- package/docs/custom-redirects.md +5 -0
- package/docs/getting-started.md +20 -10
- package/docs/revalidation-webhooks.md +7 -1
- package/docs/seo.md +66 -0
- package/docs/theme-and-settings.md +3 -0
- package/examples/nextjs/app/blog/[slug]/page.tsx +4 -2
- package/examples/nextjs/app/blog/categories/[slug]/page.tsx +1 -1
- package/examples/nextjs/app/blog/page.tsx +1 -1
- package/examples/nextjs/app/preview/post/[id]/page.tsx +70 -0
- package/examples/nextjs/lib/blog.ts +29 -0
- package/examples/nextjs/lib/content-models.ts +12 -0
- package/package.json +2 -2
package/docs/api-reference.md
CHANGED
|
@@ -9,10 +9,11 @@ description: 공개 API raw 엔드포인트 — JS 외 스택이나 저수준
|
|
|
9
9
|
인증: 모든 요청에 `Authorization: Bearer rtlk_cust_...` 헤더.
|
|
10
10
|
|
|
11
11
|
공개 콘텐츠 조회를 JS/TS로 연동할 때는 raw 호출 대신
|
|
12
|
-
`@roottale/cms-client`를 사용하세요.
|
|
12
|
+
`@roottale/cms-client`를 사용하세요. 전체 CMS 관리는 아래 HTTP API,
|
|
13
13
|
MCP tool 또는 공개 CLI를 사용합니다.
|
|
14
14
|
|
|
15
|
-
글쓰기·미디어 자동화는 `read_write` API 키가 필요합니다.
|
|
15
|
+
글쓰기·미디어 자동화는 `read_write` API 키가 필요합니다. 모델·필드·노출까지
|
|
16
|
+
관리하려면 `full_management` 키를 권장합니다. `read` 키는 공개
|
|
16
17
|
콘텐츠뿐 아니라 관리 API의 초안·예약·비공개 글과 미디어 목록도 조회할 수
|
|
17
18
|
있습니다. 다만 **완전한 읽기 전용은 아닙니다** — 상담 게시판 글 작성
|
|
18
19
|
(`POST /v1/cms/public/inquiries`)이 `cms:read`로 허용됩니다. 기존 글·미디어·
|
|
@@ -26,6 +27,9 @@ MCP tool 또는 공개 CLI를 사용합니다.
|
|
|
26
27
|
관리 API는 초안 작성 → 미디어 연결 → 발행을 분리합니다. 자동화가 실수로
|
|
27
28
|
미완성 글을 공개하지 않도록 글 생성의 기본 상태는 `draft`입니다.
|
|
28
29
|
|
|
30
|
+
전체 리소스 경로와 권한은
|
|
31
|
+
[콘텐츠 모델과 노출 관리](./content-models-and-exposures.md)에 정리되어 있습니다.
|
|
32
|
+
|
|
29
33
|
### POST /v1/cms/posts
|
|
30
34
|
|
|
31
35
|
Tiptap JSON 본문으로 글 또는 페이지를 만듭니다.
|
|
@@ -40,6 +44,7 @@ curl https://api.roottale.com/v1/cms/posts \
|
|
|
40
44
|
"title": "자동화로 작성한 글",
|
|
41
45
|
"slug": "automated-post",
|
|
42
46
|
"body_json": {"type":"doc","content":[]},
|
|
47
|
+
"author_profile_id": "site-author-id",
|
|
43
48
|
"featured_media_id": null,
|
|
44
49
|
"category_ids": [],
|
|
45
50
|
"tag_ids": []
|
|
@@ -51,20 +56,25 @@ curl https://api.roottale.com/v1/cms/posts \
|
|
|
51
56
|
| 필드 | 설명 |
|
|
52
57
|
|---|---|
|
|
53
58
|
| `body_json` | Tiptap 문서 JSON 객체 |
|
|
59
|
+
| `author_profile_id` | 사이트 공통 공개 작성자 ID. 생략한 사용자 키는 계정별 기본값, `null`은 미지정 |
|
|
54
60
|
| `featured_media_id` | 미디어 업로드 완료 응답의 `id`; `null`이면 썸네일 없음 |
|
|
55
61
|
| `publish` | `true`면 즉시 발행. 생략하면 초안 |
|
|
56
62
|
| `scheduled_at` | 미래 ISO 8601 시각. `publish`와 동시 사용 불가 |
|
|
57
63
|
| `category_ids`, `tag_ids` | 생성과 동시에 연결할 term ID 배열 |
|
|
64
|
+
| `model_key` | 페이지·글·정보 모델의 안정 key. 모델이 여러 개면 필수 |
|
|
65
|
+
| `field_values` | 모델 필드 정의로 검증할 편집 원문 객체 |
|
|
58
66
|
|
|
59
67
|
### PATCH /v1/cms/posts/{post_id}
|
|
60
68
|
|
|
61
69
|
보낸 필드만 수정합니다. `featured_media_id: null`이면 썸네일을 해제하고,
|
|
62
|
-
`scheduled_at: null`이면 예약을 해제합니다.
|
|
70
|
+
`scheduled_at: null`이면 예약을 해제합니다. `author_profile_id`를 생략하면 기존
|
|
71
|
+
공개 작성자를 유지하고, `null`이면 초안의 공개 작성자를 비웁니다.
|
|
63
72
|
|
|
64
73
|
### POST /v1/cms/posts/{post_id}/publish
|
|
65
74
|
|
|
66
75
|
초안 또는 예약 글을 즉시 발행하고 등록된 revalidate 웹훅을 전송합니다.
|
|
67
|
-
본문은 `{}` 또는 `{"site_id":"..."}`입니다.
|
|
76
|
+
본문은 `{}` 또는 `{"site_id":"..."}`입니다. 글(`type: "post"`)은 현재
|
|
77
|
+
사이트의 활성 공통 작성자가 지정돼 있어야 발행·예약할 수 있습니다.
|
|
68
78
|
|
|
69
79
|
### POST /v1/cms/posts/{post_id}/unpublish
|
|
70
80
|
|
|
@@ -81,7 +91,11 @@ curl https://api.roottale.com/v1/cms/posts \
|
|
|
81
91
|
### GET /v1/cms/posts
|
|
82
92
|
|
|
83
93
|
초안·예약·비공개·발행 글을 조회합니다. 쿼리는 `limit`, `cursor`, `type`,
|
|
84
|
-
`status`, `site_id`를 지원합니다.
|
|
94
|
+
`status`, `site_id`, `model_key`를 지원합니다.
|
|
95
|
+
|
|
96
|
+
관리 응답은 `owner_user_id`(편집 소유 계정)와
|
|
97
|
+
`author_profile_id`(사이트 공통 공개 작성자)를 별도로 반환합니다. 기존
|
|
98
|
+
`author_id`는 하위 호환용이며 새 연동에서는 두 분리 필드를 사용하세요.
|
|
85
99
|
|
|
86
100
|
## 미디어 업로드 API
|
|
87
101
|
|
|
@@ -267,6 +281,8 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
|
|
|
267
281
|
{
|
|
268
282
|
"items": [ { "id": "…", "slug": "…", "title": "…", "body_json": {…},
|
|
269
283
|
"collection_key": "blog",
|
|
284
|
+
"author_profile_id": "site-author-id",
|
|
285
|
+
"author_name": "공통 작성자", "author_slug": "writer",
|
|
270
286
|
"terms": [{ "taxonomy": "category", "name": "…", "slug": "…" }],
|
|
271
287
|
"published_at": "…" } ],
|
|
272
288
|
"has_more": false,
|
|
@@ -278,10 +294,38 @@ tenant/site 경로, 크기, 형식을 검증한 뒤 미디어를 등록합니다
|
|
|
278
294
|
> 미설정이면 `null`. 카테고리(`terms`)는 섹션 안의 주제로 아카이브에만 쓰입니다 →
|
|
279
295
|
> [콘텐츠 유형 (Collections)](./collections.md).
|
|
280
296
|
|
|
297
|
+
`author_profile_id`는 사이트 공통 공개 작성자 ID입니다. 이름·사진·소개·작가 주소는
|
|
298
|
+
같은 원장의 `author_name`·`author_image_url`·`author_bio`·`author_slug`로
|
|
299
|
+
제공됩니다. 기존 `author_id`는 하위 호환용이므로 새 연동에서는 공개 작성자
|
|
300
|
+
식별자로 사용하지 마세요.
|
|
301
|
+
|
|
281
302
|
## GET /v1/cms/public/posts/{identifier}
|
|
282
303
|
|
|
283
304
|
글 1개 — `identifier`는 slug 또는 UUID. 미발행/없는 글은 `404`.
|
|
284
305
|
|
|
306
|
+
## GET /v1/cms/public/posts/preview
|
|
307
|
+
|
|
308
|
+
관리자 편집기가 발급한 **미리보기 토큰**으로 초안(미발행 포함)을 **발행 글과
|
|
309
|
+
같은 모양**으로 받습니다. 사이트가 자기 글 템플릿으로 미리보기를 그리게 하는
|
|
310
|
+
용도입니다 — 관리자 미리보기와 발행 결과가 다르던 문제의 해법(`blog.md`
|
|
311
|
+
"미리보기 페이지" 참고).
|
|
312
|
+
|
|
313
|
+
| 쿼리 | 설명 |
|
|
314
|
+
|---|---|
|
|
315
|
+
| `token` | 편집기 '미리보기'·'공유 링크'가 URL `?token=`으로 넘긴 값 (글 1건 전용, 1시간) |
|
|
316
|
+
| `site_id` | 멀티 사이트 키일 때만 |
|
|
317
|
+
|
|
318
|
+
- 응답은 `GET /posts/{identifier}`와 같은 형식이며 `preview` 블록이 추가됩니다:
|
|
319
|
+
`{ "expires_at": "…", "source_status": "draft" }`. `status`는 템플릿 재사용을
|
|
320
|
+
위해 항상 `published`, `title`·`slug`·`excerpt`·`body_json`은 토큰 발급
|
|
321
|
+
시점의 편집기 내용, `body_html`은 항상 `null`, 발행 전이면 `published_at`은
|
|
322
|
+
토큰 발급 시각.
|
|
323
|
+
- 토큰이 없거나 다른 사이트의 것이면 `404`, 만료면 `410 preview_expired`.
|
|
324
|
+
- 응답 헤더 `cache-control: no-store` — 사이트도 절대 캐시하지 마세요.
|
|
325
|
+
|
|
326
|
+
JS/TS 는 `@roottale/cms-client/server` 의 `fetchPostPreview({ apiKey, token })`
|
|
327
|
+
와 `isPreviewExpiredError(error)` 를 사용하세요.
|
|
328
|
+
|
|
285
329
|
## GET /v1/cms/public/search
|
|
286
330
|
|
|
287
331
|
발행된 콘텐츠 키워드 검색입니다. 제목 완전일치, 제목 부분일치, 요약, 본문
|
|
@@ -371,8 +415,9 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
|
|
|
371
415
|
"show_author_card": true, "toc_title": null, "toc_position": "inline",
|
|
372
416
|
"author_profile_name": null, "author_profile_bio": null,
|
|
373
417
|
"author_profile_image_url": null, "author_profile_image_radius": "circle",
|
|
374
|
-
"site_profile": { "site_description": null, "
|
|
375
|
-
"
|
|
418
|
+
"site_profile": { "site_description": null, "home_title": null,
|
|
419
|
+
"home_description": null, "logo_url": null, "favicon_url": null,
|
|
420
|
+
"default_og_image_url": null },
|
|
376
421
|
"post_cta": { "title": "상담이 필요하신가요?", "description": "첫 상담은 무료입니다.",
|
|
377
422
|
"button_label": "상담 문의하기", "button_href": "/contact" },
|
|
378
423
|
"updated_at": null }
|
|
@@ -390,6 +435,9 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
|
|
|
390
435
|
`generateMetadata` 에서 `post.seo?.ogImage ?? post.featured_media_url ??
|
|
391
436
|
settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
|
|
392
437
|
`favicon_url`·`site_description` 은 사이트 `<head>` 에 적용합니다.
|
|
438
|
+
`home_title`·`home_description` 은 **메인 홈(`/`) 전용** 검색 제목·설명(어드민
|
|
439
|
+
"검색·공유 표시 > 메인 홈 검색 노출")으로, `null` 이면 사이트 이름(theme
|
|
440
|
+
`site_name`)·`site_description` 으로 폴백하세요(`seo.md` "메인 홈 메타데이터").
|
|
393
441
|
|
|
394
442
|
## GET /v1/cms/public/categories
|
|
395
443
|
|
|
@@ -411,7 +459,12 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
|
|
|
411
459
|
{ "tenant_id": "…", "site_id": "…",
|
|
412
460
|
"categories": [
|
|
413
461
|
{ "slug": "tax", "name": "세무", "published_post_count": 412,
|
|
414
|
-
"collection_key":
|
|
462
|
+
"collection_key": "column", "hub_promoted": true,
|
|
463
|
+
"description": "세무 칼럼", "seo_title": "세무 칼럼 모음",
|
|
464
|
+
"seo_description": "세무 칼럼을 주제별로 확인하세요.",
|
|
465
|
+
"image_media_id": "019…", "image_url": "https://imagedelivery.net/…/md",
|
|
466
|
+
"image_alt": "세무 자료와 계산기",
|
|
467
|
+
"path": "/column/tax" },
|
|
415
468
|
{ "slug": "law", "name": "법률", "published_post_count": 0,
|
|
416
469
|
"collection_key": null, "hub_promoted": false }
|
|
417
470
|
] }
|
|
@@ -419,7 +472,15 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
|
|
|
419
472
|
|
|
420
473
|
- 글이 0건인 분류도 그대로 내려옵니다(`published_post_count: 0`) — "그런 분류가
|
|
421
474
|
없다"와 "분류는 있는데 이 범위에 글이 없다"를 구분할 수 있게.
|
|
422
|
-
- `
|
|
475
|
+
- `description`·`seo_title`·`seo_description`은 ROOT-ADMIN 카테고리 편집값입니다.
|
|
476
|
+
검색 화면에서는 `seo_title → "{name} 글 모음"`, `seo_description → description
|
|
477
|
+
→ 사이트별 기본 소개문` 순으로 대체값을 적용하세요.
|
|
478
|
+
- `image_media_id`는 대표 이미지의 안정 ID, `image_url`은 바로 표시할 수 있는 URL,
|
|
479
|
+
`image_alt`는 대체 텍스트입니다. 세 값은 이미지 미설정 또는 구버전 API에서
|
|
480
|
+
`null`이거나 생략될 수 있습니다.
|
|
481
|
+
`path`는 콘텐츠 유형의 `category_path` 정책까지 적용한 정규 상대 경로이며, 해당
|
|
482
|
+
유형에 카테고리 아카이브가 없으면 `null`입니다.
|
|
483
|
+
- `hub_promoted` 는 어드민의 "이 카테고리 모음 페이지를 검색에 공개" 값이지만, **검색 노출
|
|
423
484
|
판정에는 쓰지 마세요**. 그 판정의 단일 소스는 `/blog-settings` 의
|
|
424
485
|
`sitemap.promoted_category_slugs` 입니다(두 소스를 섞으면 사이트맵과 페이지가
|
|
425
486
|
서로 다른 시점의 값을 볼 수 있습니다). 여기 값은 진단·표시용입니다.
|
package/docs/blog.md
CHANGED
|
@@ -32,12 +32,18 @@ export default function BlogPage() {
|
|
|
32
32
|
baseUrl={process.env.ROOTTALE_API_BASE}
|
|
33
33
|
limit={20}
|
|
34
34
|
showCategoryFilter
|
|
35
|
-
postHref={(post) => `/blog/${post.slug}`}
|
|
36
35
|
/>
|
|
37
36
|
);
|
|
38
37
|
}
|
|
39
38
|
```
|
|
40
39
|
|
|
40
|
+
> **글 링크는 기본값에 맡기세요.** `postHref` 를 주지 않으면 카드·연관 글·빵부스러기의
|
|
41
|
+
> 글 링크는 플랫폼이 글마다 저장해 내려주는 정규 공개 경로 `post.path`(ADR-0105)를
|
|
42
|
+
> 그대로 쓰고, 없을 때(구 서버·주소 규칙 없는 유형)만 `collections` 규칙 →
|
|
43
|
+
> `/blog/{slug}` 순으로 폴백합니다. `postHref={(post) => "/blog/" + post.slug}` 처럼
|
|
44
|
+
> 하드코딩하면 관리자가 주소 규칙을 바꿔도 사이트가 옛 주소를 계속 그립니다 —
|
|
45
|
+
> 라우트가 정말 고정인 사이트에서만 쓰세요.
|
|
46
|
+
|
|
41
47
|
> **카테고리 허브 라우트(`/blog/categories/{slug}`)를 구현하세요.**
|
|
42
48
|
> `showCategoryFilter` 의 카테고리 칩, `RootTaleBlogCategories`, `RootTaleBlogPost`
|
|
43
49
|
> 의 `breadcrumb` 카테고리 세그먼트가 모두 기본적으로 이 경로를 링크합니다
|
|
@@ -74,6 +80,14 @@ export default function BlogPage() {
|
|
|
74
80
|
|
|
75
81
|
### 상세 페이지
|
|
76
82
|
|
|
83
|
+
> **주소 규칙은 관리자 콘텐츠 모델(`blog`)의 presentation이 정합니다.** RootTale 표준
|
|
84
|
+
> 사이트는 `/blog/{category}/{slug}`(카테고리 허브 `/blog/{category}`)이고, 이 문서의
|
|
85
|
+
> 예시는 평면 `/blog/{slug}` 상세 규칙(`detail`)을 쓰는 사이트 기준입니다. 어느 쪽이든
|
|
86
|
+
> 글 링크·canonical·사이트맵은 공개 API `path`(`post.path`)를 그대로 읽으면 됩니다 —
|
|
87
|
+
> `content-models-and-exposures.md` "목록·피드 규칙도 모델이 소유합니다" 참고. 표준
|
|
88
|
+
> 사이트의 라우트 파일 배치는 카테고리 허브 `app/blog/[category]/page.tsx`(카테고리가
|
|
89
|
+
> 아니면 옛 글 주소로 보고 정본으로 301) + 글 `app/blog/[category]/[slug]/page.tsx` 입니다.
|
|
90
|
+
|
|
77
91
|
```tsx
|
|
78
92
|
// app/blog/[slug]/page.tsx
|
|
79
93
|
import { fetchPost } from "@roottale/cms-client/server";
|
|
@@ -216,6 +230,99 @@ export default async function PostPage({
|
|
|
216
230
|
> 태그명(`span`)을 직접 선택자로 쓴 고객 CSS/스크립트가 있다면 `time` 으로
|
|
217
231
|
> 갱신하세요.
|
|
218
232
|
|
|
233
|
+
### 미리보기 페이지 — 관리자 미리보기 = 발행 결과
|
|
234
|
+
|
|
235
|
+
관리자(ROOT-ADMIN) 편집기의 **미리보기**·**공유 링크**는 기본으로 관리자가
|
|
236
|
+
RootTale 기본 테마로 그린 화면을 엽니다. 사이트 고유 폰트·본문 너비·목차와는
|
|
237
|
+
다를 수 있어서, 사이트에 아래 라우트를 두면 **사이트의 실제 글 템플릿**으로
|
|
238
|
+
미리보기를 그리게 할 수 있습니다. 라우트를 배포한 뒤 관리자 **설정 › 외부
|
|
239
|
+
연결(Webhook) › 글 미리보기 여는 곳**에서 "홈페이지에서 열기"를 켜면 편집기의
|
|
240
|
+
미리보기·공유 링크가 `https://{사이트}/preview/post/{글 ID}?token=…` 으로 열립니다.
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
// app/preview/post/[id]/page.tsx
|
|
244
|
+
import type { Metadata } from "next";
|
|
245
|
+
import { notFound } from "next/navigation";
|
|
246
|
+
import {
|
|
247
|
+
fetchPostPreview,
|
|
248
|
+
isPreviewExpiredError,
|
|
249
|
+
} from "@roottale/cms-client/server";
|
|
250
|
+
import {
|
|
251
|
+
RootTaleBlogPost,
|
|
252
|
+
RootTalePreviewNotice,
|
|
253
|
+
} from "@roottale/cms-renderer-next/server";
|
|
254
|
+
import { buildPreviewMetadata } from "@roottale/cms-renderer-next/routes";
|
|
255
|
+
|
|
256
|
+
// 초안이 ISR·CDN 에 남으면 안 된다 — 항상 동적 렌더.
|
|
257
|
+
export const dynamic = "force-dynamic";
|
|
258
|
+
export const revalidate = 0;
|
|
259
|
+
|
|
260
|
+
interface Props {
|
|
261
|
+
params: Promise<{ id: string }>;
|
|
262
|
+
searchParams: Promise<{ token?: string }>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
|
|
266
|
+
const { token } = await searchParams;
|
|
267
|
+
const post = token
|
|
268
|
+
? await fetchPostPreview({
|
|
269
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
270
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
271
|
+
token,
|
|
272
|
+
}).catch(() => null)
|
|
273
|
+
: null;
|
|
274
|
+
return buildPreviewMetadata({ title: post?.title }); // 항상 noindex/nofollow
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export default async function PostPreviewPage({ params, searchParams }: Props) {
|
|
278
|
+
const { id } = await params;
|
|
279
|
+
const { token } = await searchParams;
|
|
280
|
+
if (!token) notFound();
|
|
281
|
+
|
|
282
|
+
let expiresAt: string | undefined;
|
|
283
|
+
try {
|
|
284
|
+
const post = await fetchPostPreview({
|
|
285
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
286
|
+
baseUrl: process.env.ROOTTALE_API_BASE,
|
|
287
|
+
token,
|
|
288
|
+
});
|
|
289
|
+
if (!post || post.id !== id) notFound(); // 토큰은 글 1건 전용
|
|
290
|
+
expiresAt = post.preview.expiresAt;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (!isPreviewExpiredError(error)) throw error; // 만료는 컴포넌트가 안내
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<>
|
|
297
|
+
<RootTalePreviewNotice expiresAt={expiresAt} />
|
|
298
|
+
{/* 발행 글 상세와 같은 props 를 쓰되 slugOrId 대신 previewToken 만 넘긴다. */}
|
|
299
|
+
<RootTaleBlogPost
|
|
300
|
+
apiKey={process.env.ROOTTALE_API_KEY!}
|
|
301
|
+
baseUrl={process.env.ROOTTALE_API_BASE}
|
|
302
|
+
previewToken={token}
|
|
303
|
+
relatedPostsCount={3}
|
|
304
|
+
/>
|
|
305
|
+
</>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
지켜야 할 것:
|
|
311
|
+
|
|
312
|
+
- `RootTaleBlogPost`에 `previewToken`을 주면 `slugOrId` 대신 미리보기 조회
|
|
313
|
+
(`fetchPostPreview`)를 쓰고 **같은 템플릿**으로 그립니다. 만료(410)면
|
|
314
|
+
`previewExpiredElement`(기본 안내 문구), 없으면(404) `notFoundElement`.
|
|
315
|
+
- 항상 `dynamic = "force-dynamic"` — 응답이 `no-store` 라도 페이지 자체가 정적
|
|
316
|
+
생성되면 안 됩니다.
|
|
317
|
+
- `buildPreviewMetadata`는 항상 `noindex, nofollow` 이며 canonical·og 를 내지
|
|
318
|
+
않습니다. `robots.ts`에도 `disallow: ["/preview/"]`를 더하세요.
|
|
319
|
+
- 토큰 없이 접근하면 `notFound()` — 주소만으로는 아무것도 보이지 않습니다.
|
|
320
|
+
- 커스텀 템플릿(직접 fetch)이라면 `fetchPostPreview`가 돌려주는 값이
|
|
321
|
+
`fetchPost`와 같은 `CmsPostContent` 형식(+`preview`)이므로 상세 렌더 함수를
|
|
322
|
+
그대로 재사용하면 됩니다.
|
|
323
|
+
|
|
324
|
+
전체 예시는 `examples/nextjs/app/preview/post/[id]/page.tsx` 에 있습니다.
|
|
325
|
+
|
|
219
326
|
### 다국어 (ADR-0052 A안, W4-6 PR C1/C2)
|
|
220
327
|
|
|
221
328
|
번역 사이트(어드민에서 "번역 추가"로 언어판을 만든 글)는 `RootTaleBlogList`/
|
package/docs/collections.md
CHANGED
|
@@ -42,6 +42,33 @@ description: 같은 글 풀을 공지 게시판(/notice)·블로그(/blog) 등
|
|
|
42
42
|
- **같은 글이 두 섹션에 안 뜸**: 공지 글을 `/blog/개강안내`로 열면 404, 반대도 404(가드).
|
|
43
43
|
- **섹션(`collection_key`)이 없는 글**은 sitemap·상세 라우트에서 제외됩니다.
|
|
44
44
|
|
|
45
|
+
### 카테고리를 주소에 넣는 유형
|
|
46
|
+
|
|
47
|
+
칼럼처럼 카테고리가 정보 구조의 일부라면 유형별로 아래 정책을 켤 수 있습니다.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const COLUMN: RouteCollection = {
|
|
51
|
+
key: "column",
|
|
52
|
+
basePath: "/column",
|
|
53
|
+
categories: ["headache", "dizziness"],
|
|
54
|
+
archives: true,
|
|
55
|
+
detailPath: "category",
|
|
56
|
+
categoryPath: "direct",
|
|
57
|
+
categoryCardinality: "exactly-one",
|
|
58
|
+
};
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
이 설정은 카테고리 허브를 `/column/{categorySlug}`, 글 상세를
|
|
62
|
+
`/column/{categorySlug}/{postSlug}`로 만듭니다. `categoryPath`를 생략하면 기존
|
|
63
|
+
형식인 `/column/categories/{categorySlug}`를 유지합니다. `detailPath`를 생략하면
|
|
64
|
+
글 상세도 기존의 `/column/{postSlug}`를 유지합니다.
|
|
65
|
+
|
|
66
|
+
`detailPath: "category"`는 `archives: true`와
|
|
67
|
+
`categoryCardinality: "exactly-one"`이 반드시 필요합니다. ROOT-ADMIN은 공개·예약
|
|
68
|
+
글의 카테고리가 정확히 하나인지 서버에서 검증하며, 기존 공개·예약 글에 누락이 있으면
|
|
69
|
+
이 정책 자체를 저장하지 않습니다. 이미 주소에 쓰이는 카테고리의 slug·소속·삭제도
|
|
70
|
+
URL 이전 계획 없이 바로 바꿀 수 없습니다.
|
|
71
|
+
|
|
45
72
|
## 주소 없는 유형 (`base_path: ""`) — 페이지 안에서만 쓰는 콘텐츠
|
|
46
73
|
|
|
47
74
|
강사·리뷰처럼 **독립된 URL이 없고 홈페이지의 다른 페이지 안에서 불러와 쓰는**
|
|
@@ -111,7 +138,7 @@ export const COLLECTIONS: RouteCollection[] = [
|
|
|
111
138
|
| | 섹션 (collection) | 주제 (category) |
|
|
112
139
|
|---|---|---|
|
|
113
140
|
| 무엇 | 글이 사는 곳 (공지 / 블로그) | 섹션 안의 세부 분류 (칼럼 / 소식) |
|
|
114
|
-
| 글당 | **딱 하나** (배타적) | 0개
|
|
141
|
+
| 글당 | **딱 하나** (배타적) | 기본 0개 이상, 유형 정책으로 정확히 1개 가능 |
|
|
115
142
|
| 정하는 곳 | 글쓰기 "어디에 올릴까요?" | 글 > 분류 > 카테고리 / 글쓰기 주제 칩 |
|
|
116
143
|
| 저장 | `post.collection_key` | 글의 category terms |
|
|
117
144
|
| 라우팅 | basePath 결정 (`/notice`) | 아카이브만 (`/blog/categories/칼럼`) |
|
|
@@ -132,6 +159,9 @@ export const COLLECTIONS: RouteCollection[] = [
|
|
|
132
159
|
| basePath | URL 앞부분 (`/notice`, `/blog`). **비워두면 주소 없는 유형** — 아래 "주소 없는 유형" 참고 |
|
|
133
160
|
| feed / archives / og | RSS 포함 / 주제 아카이브 / 동적 OG |
|
|
134
161
|
| 글별 상세 페이지 (`detail`) | 기본 켜짐. 끄면 목록 전용(상세 URL 없음) — 아래 "목록 전용 유형" 참고 |
|
|
162
|
+
| 상세 주소 (`detail_path`) | `flat`(기본) 또는 `category` — 카테고리를 상세 주소에 포함 |
|
|
163
|
+
| 카테고리 주소 (`category_path`) | `namespaced`(기본 `/categories/{slug}`) 또는 `direct`(`/{slug}`) |
|
|
164
|
+
| 카테고리 개수 (`category_cardinality`) | `multiple`(기본) 또는 `exactly-one` |
|
|
135
165
|
| 순서 | 메뉴·표시 순서 |
|
|
136
166
|
|
|
137
167
|
**섹션을 하나도 안 만들면 단일 블로그(`/blog`)** 로 동작합니다(설정 전 기본값).
|
|
@@ -373,6 +403,10 @@ return buildPostMetadata(post, {
|
|
|
373
403
|
});
|
|
374
404
|
```
|
|
375
405
|
|
|
406
|
+
원본 post 를 넘기면 `post.path`(플랫폼이 저장한 정규 공개 경로, ADR-0105)가 먼저
|
|
407
|
+
canonical 이 되고 `collections` 는 저장 경로가 없는 글의 폴백입니다.
|
|
408
|
+
`RootTaleBlogList`·`RootTaleBlogPost`(연관 글·빵부스러기)의 글 링크도 같은 순서 —
|
|
409
|
+
`post.path` → `collections` 규칙 → `/blog/{slug}` — 로 정해집니다.
|
|
376
410
|
자세한 동작은 `seo.md` 의 "글 메타데이터 → 공지·블로그 다중 스트림" 참고.
|
|
377
411
|
|
|
378
412
|
### revalidate
|