@roottale/cms-mcp 0.68.0 → 0.70.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 +24 -0
- package/dist/index.js +1 -1
- package/docs/blog.md +15 -0
- package/docs/content-models-and-exposures.md +66 -0
- package/docs/getting-started.md +8 -0
- package/docs/revalidation-webhooks.md +44 -0
- package/docs/seo.md +164 -0
- package/examples/nextjs/app/api/revalidate/route.ts +4 -0
- package/examples/nextjs/app/blog/[slug]/page.tsx +2 -0
- package/examples/nextjs/lib/content-metadata.ts +9 -0
- package/examples/nextjs/lib/content-models.ts +1 -1
- package/examples/nextjs/lib/content-policy.ts +118 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @roottale/cms-mcp
|
|
2
2
|
|
|
3
|
+
## 0.70.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 49145e4: 콘텐츠 공개·색인 정책의 기존 단일 when 조건을 유지하면서, 공개 필드가 모두 일치하는 all과 모두 일치할 때만 검사를 제외하는 unlessAll을 추가합니다. 각 그룹은 equality 조건 1~4개만 허용하고 중첩하지 않으며, 기존 공개 필드 참조·검사 개수 한도에 포함합니다.
|
|
8
|
+
|
|
9
|
+
명시적인 방송 기록 조건이 모두 충족된 경우에만 검수·출연자 조건의 예외를 적용하고, 출처 필수와 강제 검색 제외를 함께 선언하는 공개 예제를 제공합니다. 누락 값과 null은 false로 바꾸지 않으며 관리자 projection·웹훅·FRONT가 같은 정책을 사용합니다.
|
|
10
|
+
|
|
11
|
+
글의 공개 영상 필드를 `videoThumbnail: { source: "youtube", field, when? }`로 연결합니다. 선언한 모델에만 안전한 대표 이미지 다음으로 정확한 YouTube ID의 썸네일을 사용하며, 수동 SEO와 기존 무옵션 모델의 이미지 의미는 유지합니다. 같은 공개 필드와 조건을 관리자 자동값·버전·검증과 FRONT 카드/메타데이터에 재사용할 수 있습니다.
|
|
12
|
+
|
|
13
|
+
## 0.69.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 30b830f: 분류 기반 경로에서 선언한 첫 분류 선택, 미분류 대체 경로와 예약 경로를 공통 계산기로 처리합니다. 기존 분류 하나 필수 규칙은 그대로 유지합니다.
|
|
18
|
+
|
|
19
|
+
공개 필드와 본문에 기반한 콘텐츠 공개·검색 색인 정책 및 목록 선택 계약을 추가합니다. FRONT의 본문, 목록, sitemap과 관리자 메타데이터가 같은 평가기를 사용하며 강제 검색 제외 사유와 정책 전용 웹훅 캐시 무효화를 제공합니다. 정책을 선언하지 않은 연동의 동작은 유지됩니다.
|
|
20
|
+
|
|
21
|
+
글 조회에 캐시 태그 옵션을 공개하고 RootTaleBlogPost에 독립 categoryHref 속성을 추가해 별도 breadcrumb가 있는 사이트도 분류 링크를 정확히 연결할 수 있습니다. 정책 적용 예제와 공개 문서를 함께 제공합니다.
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- ca5ef33: 공개 분류 API의 주소를 활성 모델 presentation과 같은 분류 계층에서 계산합니다. 레거시 null/blog 분류 범위는 보존하고, 모델이 없는 분류만 기존 컬렉션 설정을 사용합니다. 모델 기반 분류 경로와 완전한 분류 조회를 소비하는 공개 예제를 보완합니다.
|
|
26
|
+
|
|
3
27
|
## 0.68.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/dist/index.js
CHANGED
|
@@ -1855,7 +1855,7 @@ function registerTools(server) {
|
|
|
1855
1855
|
}
|
|
1856
1856
|
|
|
1857
1857
|
// src/server.ts
|
|
1858
|
-
var VERSION = true ? "0.
|
|
1858
|
+
var VERSION = true ? "0.70.0" : "dev";
|
|
1859
1859
|
var SERVER_INSTRUCTIONS = `
|
|
1860
1860
|
roottale-cms-mcp\uB294 RootTale CMS\uB97C \uC678\uBD80 \uC0AC\uC774\uD2B8(\uC8FC\uB85C Next.js)\uC5D0 \uC5F0\uB3D9\uD558\uACE0
|
|
1861
1861
|
\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/blog.md
CHANGED
|
@@ -159,6 +159,21 @@ export default async function PostPage({
|
|
|
159
159
|
카테고리가 있으면 H1 위에 링크로 표시됩니다. 메타 줄에는 발행일이 명시되고,
|
|
160
160
|
수정일의 달력 날짜가 발행일과 다를 때만 수정일을 따로 표시합니다.
|
|
161
161
|
|
|
162
|
+
SDK 0.69.0부터 `categoryHref`로 H1 위 카테고리 링크를 별도로 지정할 수 있습니다.
|
|
163
|
+
`breadcrumb` 없이도 동작하며, 우선순위는 독립 `categoryHref` →
|
|
164
|
+
`breadcrumb.categoryHref` → 기본 `/blog/categories/{slug}`입니다.
|
|
165
|
+
브레드크럼 링크도 바꾸려면 `breadcrumb.categoryHref`에 같은 함수를 전달하세요.
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
<RootTaleBlogPost
|
|
169
|
+
apiKey={apiKey}
|
|
170
|
+
slugOrId={post.id}
|
|
171
|
+
categoryHref={(slug) => `/insights/${encodeURIComponent(slug)}`}
|
|
172
|
+
/>
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
위 `/insights/{slug}`는 해당 사이트에 실제로 구현한 분류 허브 주소여야 합니다.
|
|
176
|
+
|
|
162
177
|
편집자가 어드민 "관련 콘텐츠"에서 글을 골라 두면(유형 무관, 최대 10개) `RootTaleBlogPost` 는
|
|
163
178
|
`relatedPostsCount` 와 무관하게 **그 글들을 고른 순서대로** 글 하단에 그립니다(발행 글만, 저장된
|
|
164
179
|
공개 주소로 링크). 고른 것이 없을 때만 아래 자동 추천이 동작합니다.
|
|
@@ -105,6 +105,72 @@ FAQ·도움말·문서처럼 목록 아래에 여러 단계의 분류 허브가
|
|
|
105
105
|
`path`를 그대로 쓰고, 분류 허브 화면에서 하위 분류와 그 분류에 바로 붙은 글을 함께
|
|
106
106
|
보여 주면 됩니다.
|
|
107
107
|
|
|
108
|
+
### 여러 분류 중 대표 분류 선택
|
|
109
|
+
|
|
110
|
+
SDK 0.69.0부터 `categorySelection: "first"`로 여러 분류를 붙인 글의 대표 분류를
|
|
111
|
+
선택할 수 있습니다. 생략하면 기존의 한 분류 경로 규칙을 유지합니다.
|
|
112
|
+
`first`는 **`termOrder` 오름차순 → 같은 순서이면 ID 오름차순**으로 고릅니다.
|
|
113
|
+
공개 응답의 `terms` 배열 순서는 바꾸지 않으므로 `terms.find(...)`나 이름 정렬로
|
|
114
|
+
대표 분류를 대신 고르지 마세요. `categoryCardinality: "exactly-one"`과는 함께
|
|
115
|
+
선언할 수 없습니다.
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"key": "blog",
|
|
120
|
+
"cardinality": "collection",
|
|
121
|
+
"preset": "article",
|
|
122
|
+
"compatibilityCollectionKey": null,
|
|
123
|
+
"presentation": {
|
|
124
|
+
"kind": "category_tree",
|
|
125
|
+
"basePath": "/insights",
|
|
126
|
+
"categoryDepth": 1,
|
|
127
|
+
"categorySelection": "first",
|
|
128
|
+
"categoryCardinality": "multiple",
|
|
129
|
+
"categoryFallbackSlug": "general",
|
|
130
|
+
"reservedCategorySlugs": ["notices"],
|
|
131
|
+
"categoryPath": "direct",
|
|
132
|
+
"templateKey": "blog"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
이 예에서 분류를 하나도 붙이지 않은 글은 `/insights/general/{slug}`를 사용합니다.
|
|
138
|
+
`categoryFallbackSlug`는 `first`인 1단계 모델에서만 지원하며, 실제 분류가 없을
|
|
139
|
+
때의 경로입니다. 잘못되거나 다른 모델에 속한 분류 참조를 대체하지 않습니다.
|
|
140
|
+
`general`이라는 분류나 메타데이터 고정 경로를 만들 필요가 없습니다.
|
|
141
|
+
fallback slug와 `reservedCategorySlugs`는 실제 분류 slug로 쓸 수 없고 서로 겹칠
|
|
142
|
+
수도 없습니다. 예약 주소의 페이지는 사이트가 별도로 구현합니다.
|
|
143
|
+
|
|
144
|
+
`compatibilityCollectionKey: null`은 레거시 컬렉션에 연결하지 않겠다는 명시값입니다.
|
|
145
|
+
모델 key로 바꾸지 마세요. 글 조회에는 `modelKey: "blog"`를 사용하고, 링크에는
|
|
146
|
+
공개 응답의 `post.path`를 사용합니다.
|
|
147
|
+
|
|
148
|
+
분류 주소도 `fetchCategoryCounts()`의 `category.path`를 사용합니다. 서버는 활성
|
|
149
|
+
모델의 presentation과 해당 모델의 분류 계층으로 주소를 계산합니다. 모델이 없는
|
|
150
|
+
분류만 이전 `content_collections` 설정으로 계산하며, 예약 주소나 잘못된 계층의
|
|
151
|
+
`path: null`을 임의 URL로 대체하지 않습니다. 발행 글이 0건인 실제 분류도 응답에
|
|
152
|
+
포함되며 화면 공개·검색 색인 여부는 사이트의 동일한 정책으로 판단합니다.
|
|
153
|
+
|
|
154
|
+
분류 응답의 `collectionKey`는 원본 값을 보존합니다. 서버의 모델 선택은 정확한
|
|
155
|
+
모델 key를 우선하며 호환 컬렉션 key도 지원합니다. 위처럼 호환 key가 `null`인
|
|
156
|
+
`blog` 모델의 분류 범위에는 레거시 `null`과 `"blog"`가 모두 들어갑니다.
|
|
157
|
+
이 예시에서는 `(category.collectionKey ?? "blog") === "blog"`로 범위를 선택합니다.
|
|
158
|
+
이 범위와 `category.path`를 canonical·sitemap에서 함께 사용하고, `truncated: true`인
|
|
159
|
+
응답을 완전한 분류 계층으로 간주하지 마세요. 실행 예제는
|
|
160
|
+
`lib/content-policy.ts`의 `fetchBlogCategoryGraph`를 참고하세요.
|
|
161
|
+
|
|
162
|
+
분류 표시·브레드크럼에도 같은 선택이 필요하면 공개 core 헬퍼를 사용합니다.
|
|
163
|
+
`modelCategories`에는 글에 붙은 분류만이 아닌 **해당 모델의 전체 분류**를 전달합니다.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
import { resolveCategoryTreeEntryCategory } from "@roottale/cms-core";
|
|
167
|
+
|
|
168
|
+
const selected = resolveCategoryTreeEntryCategory(presentation, post, modelCategories);
|
|
169
|
+
// selected.kind === "category": selected.category와 selected.chain 사용
|
|
170
|
+
// selected.kind === "fallback": selected.slug 사용(실제 분류 ID는 없음)
|
|
171
|
+
// selected === null: 유효한 분류 경로가 없음
|
|
172
|
+
```
|
|
173
|
+
|
|
108
174
|
### 목록·피드 규칙도 모델이 소유합니다
|
|
109
175
|
|
|
110
176
|
글 목록의 카테고리 모음, RSS 피드, 허브 주소 형식처럼 예전 `collections`
|
package/docs/getting-started.md
CHANGED
|
@@ -103,6 +103,14 @@ pnpm add @roottale/cms-client @roottale/cms-renderer-next
|
|
|
103
103
|
|
|
104
104
|
요구사항: Node ≥ 18.18, React 19, Next.js 14+ (renderer-next 사용 시).
|
|
105
105
|
|
|
106
|
+
분류 선택·공개 콘텐츠 정책 API는 SDK **0.69.0부터** 제공합니다. 이 API를 직접
|
|
107
|
+
사용하는 사이트는 `@roottale/cms-core`도 직접 의존성으로 설치하고 세 패키지의
|
|
108
|
+
버전을 맞추세요.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pnpm add @roottale/cms-client@0.69.0 @roottale/cms-renderer-next@0.69.0 @roottale/cms-core@0.69.0
|
|
112
|
+
```
|
|
113
|
+
|
|
106
114
|
렌더러 스타일은 root layout에서 1회 import:
|
|
107
115
|
|
|
108
116
|
```ts
|
|
@@ -122,6 +122,50 @@ const menu = await fetchMenu({ apiKey, slug: "primary", tags: [MENUS_CACHE_TAG]
|
|
|
122
122
|
> 어드민에도 오류가 안 뜨기 때문에, 이 누락은 "가끔 늦게 반영된다"로만 보입니다.
|
|
123
123
|
> 응답 본문의 `revalidated.requestedTags`가 빈 배열이면 주입이 빠진 것입니다.
|
|
124
124
|
|
|
125
|
+
### 공개 정책에 사용하는 데이터 갱신
|
|
126
|
+
|
|
127
|
+
SDK 0.69.0부터 공개 콘텐츠 정책을 쓰는 사이트는 수신 라우트에
|
|
128
|
+
`contentPolicies`를 명시합니다. 값은 **실제 배포 계약의
|
|
129
|
+
`metadata.posts.contentPolicies`**이며 상세·목록·사이트맵이 사용하는 정책과
|
|
130
|
+
같아야 합니다. 이 옵션을 주면 빈 객체여도 `revalidateTag`가 필수입니다.
|
|
131
|
+
누락하면 팩토리 생성 시 오류가 납니다.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { registeredContentPolicies } from "@/lib/content-policy";
|
|
135
|
+
|
|
136
|
+
export const POST = createRevalidateRoute({
|
|
137
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
138
|
+
revalidate: revalidatePath,
|
|
139
|
+
revalidateTag: (tag) => revalidateTag(tag, { expire: 0 }),
|
|
140
|
+
contentPolicies: registeredContentPolicies,
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Next.js 예제의 `registeredContentPolicies`는 미등록 상태인 `undefined`입니다.
|
|
145
|
+
실제 사이트 계약에서 가져온 값으로 바꾸고 정책을 모든 소비 화면에 연결하면
|
|
146
|
+
활성화됩니다. 정책을 선언하지 않은 기존 연동의 태그 동작은 유지됩니다.
|
|
147
|
+
|
|
148
|
+
정책 평가에 사용하는 글·전체 목록·분류·모델·컬렉션 조회에는
|
|
149
|
+
`CONTENT_POLICY_CACHE_TAG`를 붙입니다. `fetchPost`·`fetchPosts`도 `tags`를
|
|
150
|
+
지원합니다. 이미 쓰는 태그가 있다면 덮어쓰지 말고 함께 전달하세요.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { CONTENT_POLICY_CACHE_TAG, fetchPosts } from "@roottale/cms-client/server";
|
|
154
|
+
|
|
155
|
+
const firstPage = await fetchPosts({
|
|
156
|
+
apiKey, modelKey: "blog", limit: 100,
|
|
157
|
+
tags: [CONTENT_POLICY_CACHE_TAG],
|
|
158
|
+
});
|
|
159
|
+
// 정책 쿼리는 firstPage에서 끝내지 않고 hasMore/nextCursor로 끝까지 수집합니다.
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
정책 옵션이 있으면 서명이 검증된 `post.*`·`media.*`·`metadata.updated` 이벤트에서
|
|
163
|
+
`CONTENT_POLICY_CACHE_TAG`와 `METADATA_CACHE_TAG`를 자동으로 무효화합니다.
|
|
164
|
+
`theme.updated`·`taxonomy.updated`에서는 기존 설정 태그와 공개 정책 태그를 지우고
|
|
165
|
+
루트 layout을 갱신합니다. 관련 경로를 계산하기 전에 조회 캐시를 갱신하므로 변경된
|
|
166
|
+
현재 값으로 공개·색인 여부를 다시 평가할 수 있습니다. `fetchRouteMetadata`의
|
|
167
|
+
메타데이터 태그는 자동 부착되며 소비자가 같은 무효화를 중복 구현할 필요가 없습니다.
|
|
168
|
+
|
|
125
169
|
## 2. 어드민에서 알림 보낼 주소 확인
|
|
126
170
|
|
|
127
171
|
웹훅 주소는 따로 적지 않습니다. 어드민 **설정 > 사이트 정보**의 **공개 도메인**과
|
package/docs/seo.md
CHANGED
|
@@ -69,6 +69,24 @@ export async function generateMetadata() {
|
|
|
69
69
|
|
|
70
70
|
공유 이미지 자동값은 대표 이미지 → 선언한 본문 첫 이미지·이전 이미지 순입니다. `useFeaturedImage: false` 또는 분류의 `useTermImage: false`로 기존 화면에서 사용하지 않던 콘텐츠 이미지를 제외할 수 있습니다. `defaultRules.defaults`는 콘텐츠 값이 없을 때 사용할 코드 기본값으로, 사이트 공통값보다 먼저 적용됩니다. 글에 고정 공유 이미지를 쓰려면 `useFeaturedImage: false`와 `defaults.ogImage`를 함께 선언하고 본문·이전 이미지 규칙은 켜지 않습니다. `defaultRules.fallback`은 사이트 공통값 뒤에 적용됩니다.
|
|
71
71
|
|
|
72
|
+
SDK 0.70.0부터 글의 공개 영상 필드를 이미지 자동값으로 연결할 수 있습니다.
|
|
73
|
+
`videoThumbnail: { source: "youtube", field: "video_url", when: { field: "content_format", equals: "media" } }`를
|
|
74
|
+
등록하고 FRONT에도 같은 규칙과 `fields: post.fields`를 전달합니다. 이 옵션을 선언한
|
|
75
|
+
모델은 안전한 절대 HTTPS 대표 이미지 → 조건에 맞는 YouTube 썸네일 → 기존
|
|
76
|
+
본문·이전 이미지 → 코드·사이트 기본값 순으로 계산합니다. 대표 이미지가 HTTP·상대
|
|
77
|
+
주소·인증정보 포함 URL이면 영상 후보를 사용하며, 수동 SEO 이미지에는 이 변환을
|
|
78
|
+
적용하지 않습니다. 옵션 없는 모델의 기존 이미지 처리는 유지됩니다.
|
|
79
|
+
|
|
80
|
+
YouTube 입력은 인증정보·공백·제어문자·역슬래시 없는 HTTPS URL만 받습니다.
|
|
81
|
+
`youtu.be/<id>` 또는 `youtube.com`·`www.youtube.com`·`m.youtube.com`의
|
|
82
|
+
`watch?v=<id>`·`shorts/<id>`·`embed/<id>`에서 영문·숫자·`_`·`-`로 된 11자 ID만
|
|
83
|
+
읽고 `https://i.ytimg.com/vi/<id>/hqdefault.jpg`를 만듭니다. 시작 시각은 이미지에
|
|
84
|
+
영향을 주지 않습니다. 임의 호스트·이미지 URL 형식은 선언할 수 없습니다.
|
|
85
|
+
카드에서도 같은 자동 썸네일이 필요하면 core의
|
|
86
|
+
`metadataVideoThumbnail(rule.videoThumbnail, post.fields)`를 사용합니다.
|
|
87
|
+
영상·조건·같이 선언한 텍스트 토큰은 공개 필드 투영을 사용하며 비공개 필드나
|
|
88
|
+
원본 ACF를 넘기지 않습니다. 글 자동값 전용 옵션이며 분류에는 선언하지 않습니다.
|
|
89
|
+
|
|
72
90
|
검색 차단을 유지해야 하는 동적 모델은 `defaultRules[modelKey].robots: { forceNoindex: true, forceNofollow: true }`를 선언하고 같은 `robots`를 `resolveMetadata`나 페이지 빌더에 전달합니다. 직접 입력한 `false`로 이 제한을 해제할 수 없습니다. 기존 모델 소속과 실제 FRONT 분류 경로가 다른 경우 `metadata.terms.archivePaths: { notice: "/blog/:slug" }`처럼 등록 모델별 내부 경로를 선언합니다. 이 값은 메타데이터 조회·편집·재검증 주소만 맞추며 공개 라우트나 콘텐츠 소속을 변경하지 않습니다. 마지막 `:slug` 하나만 지원합니다.
|
|
73
91
|
|
|
74
92
|
`fetchCategoryCounts`는 API의 `category_index_activated`를 `categoryIndexActivated`로 보존합니다. 색인 전환을 요구하는 계약에서는 이 값이 `true`일 때만 `hubPromoted` 기반 정책을 적용하고 이전 정책은 그때까지 유지합니다. 분류 메타데이터 조회에도 `tags: [METADATA_CACHE_TAG]`를 전달할 수 있습니다.
|
|
@@ -77,6 +95,152 @@ export async function generateMetadata() {
|
|
|
77
95
|
|
|
78
96
|
사이트맵도 같은 최종 `noindex`·대표 경로 정책을 사용하고 활성 리다이렉트 출발 주소를 제외해야 합니다. 목록의 페이지 번호·필터별 정책은 사이트 라우트에서 계산합니다. 메타데이터 저장 성공·웹훅 수신 성공·실제 HTML 반영 확인은 서로 다른 상태입니다.
|
|
79
97
|
|
|
98
|
+
### 동적 콘텐츠의 공개·색인 정책
|
|
99
|
+
|
|
100
|
+
SDK 0.69.0의 `ContentPolicy`·`ContentQuery`는 `@roottale/cms-core`에서 제공합니다.
|
|
101
|
+
배포 계약의 `metadata.posts.contentPolicies[modelKey]`와 FRONT가 같은 선언을
|
|
102
|
+
사용하면, 현재 공개 필드·본문·경로를 기준으로 글과 목록을 다시 평가합니다.
|
|
103
|
+
`visibility`를 통과한 글만 화면에 표시하고, `indexing`까지 통과한 글만 검색에
|
|
104
|
+
허용합니다. 결과의 `visible`과 `indexable`은 별개이며 `robots`를 메타데이터
|
|
105
|
+
빌더에 전달해야 직접 설정한 `noindex: false`가 강제 제한을 풀지 못합니다.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import type { ContentPolicy, ContentQuery } from "@roottale/cms-core";
|
|
109
|
+
|
|
110
|
+
// 아래 필드는 활성 공개 필드 그룹에 선언되어 있어야 합니다.
|
|
111
|
+
export const contentPolicies = {
|
|
112
|
+
blog: {
|
|
113
|
+
visibility: [
|
|
114
|
+
{ field: "active", op: "notEquals", value: false },
|
|
115
|
+
{ field: "content_format", op: "oneOf", values: ["article"], allowEmpty: true },
|
|
116
|
+
{ op: "bodyContent", allowExcerpt: true },
|
|
117
|
+
],
|
|
118
|
+
indexing: [
|
|
119
|
+
{ op: "bodyText" },
|
|
120
|
+
{ field: "reviewer_name", op: "text" },
|
|
121
|
+
{ field: "reviewed_at", op: "date" },
|
|
122
|
+
{ field: "sources", op: "some", checks: [
|
|
123
|
+
{ field: "label", op: "text" },
|
|
124
|
+
{ field: "url", op: "safeHttps" },
|
|
125
|
+
] },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
} satisfies Record<string, ContentPolicy>;
|
|
129
|
+
|
|
130
|
+
export const blogQuery = {
|
|
131
|
+
modelKeys: ["blog"], pinnedField: "pinned", pageSize: 12,
|
|
132
|
+
} satisfies ContentQuery;
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
등록 위치는 글 정책이 `metadata.posts.contentPolicies`, 고정 목록이
|
|
136
|
+
`metadata.routes[].contentQuery`, 분류별 목록이 `metadata.terms.contentQueries[modelKey]`입니다.
|
|
137
|
+
쿼리가 참조하는 모든 모델에는 글 정책이 있어야 하며, 선언한 `posts.modelKeys`와
|
|
138
|
+
`terms.modelKeys`의 범위를 지킵니다. 본문 페이지(`source.kind: "page"`)에는
|
|
139
|
+
목록 쿼리를 붙이지 않습니다. 기존 정책·쿼리 없는 계약의 동작은 유지됩니다.
|
|
140
|
+
|
|
141
|
+
필드 검사는 `equals`·`notEquals`·`oneOf`·`text`·`date`·`safeHttps`·`allowedPath`와
|
|
142
|
+
한 단계 repeater의 `some`만 지원합니다. `when: { field, equals }`로 특정 값일 때만
|
|
143
|
+
검사할 수 있습니다. SDK 0.70.0부터는 `when: { all: [...], unlessAll: [...] }`도
|
|
144
|
+
지원합니다. `all`은 조건이 모두 일치해야 하고, `unlessAll`은 조건이 모두 일치할
|
|
145
|
+
때만 검사를 생략합니다. 두 그룹 중 하나 이상을 선언하며 각 그룹은 equality 조건
|
|
146
|
+
1~4개만 받습니다. 빈 그룹·중첩·단일 조건과 그룹의 혼합은 허용하지 않습니다.
|
|
147
|
+
조건의 필드도 공개 필드 64개 한도에 포함합니다. `visibility`·`indexing` 각각의
|
|
148
|
+
검사 24개 한도는 그대로입니다. `all`과 `unlessAll`을 함께 쓰면 두 제한을 모두
|
|
149
|
+
충족해야 합니다. 누락·`null`은 명시적인 `false`와 같지 않습니다.
|
|
150
|
+
임의 함수·정규식·중첩 repeater·관계형 값은 정책 입력이 아닙니다.
|
|
151
|
+
`post.fields`의 공개 값만 사용하고 비공개 ACF를 전달하지 마세요. 누락 값은
|
|
152
|
+
`undefined`이며 `notEquals: false`는 누락도 통과합니다. `oneOf`의
|
|
153
|
+
`allowEmpty: true`는 누락·`null`·빈 문자열·`false`·`0`도 허용하므로 필수 필드에는
|
|
154
|
+
해당 옵션을 사용하지 않습니다.
|
|
155
|
+
|
|
156
|
+
예를 들어 방송 기록을 `content_format === "media"`·`broadcast_record === true`·
|
|
157
|
+
`medical_content === false`인 경우로 한정한다면, 다음 조건들을 **같은 전체 정책**의
|
|
158
|
+
검수·출연자·출처 검사에 연결합니다. 예외일 때만 일부 검사를 생략하고, 출처 필수와
|
|
159
|
+
강제 검색 제외는 유지합니다. 이 조건 선언만으로 글을 공개하지는 않습니다.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
// 실행 예제 lib/content-policy.ts가 같은 조건을 제공합니다.
|
|
163
|
+
const record = [
|
|
164
|
+
{ field: "content_format", equals: "media" },
|
|
165
|
+
{ field: "broadcast_record", equals: true },
|
|
166
|
+
{ field: "medical_content", equals: false },
|
|
167
|
+
];
|
|
168
|
+
const reviewWhen = { unlessAll: record };
|
|
169
|
+
const performerWhen = {
|
|
170
|
+
all: [{ field: "content_format", equals: "media" }], unlessAll: record,
|
|
171
|
+
};
|
|
172
|
+
const recordSourcesWhen = { all: record };
|
|
173
|
+
// indexing에 추가하면 예외 방송 기록은 수동 noindex:false로 풀 수 없습니다.
|
|
174
|
+
const recordNoindexCheck = {
|
|
175
|
+
field: "broadcast_record", op: "notEquals", value: true,
|
|
176
|
+
when: { all: [
|
|
177
|
+
{ field: "content_format", equals: "media" },
|
|
178
|
+
{ field: "medical_content", equals: false },
|
|
179
|
+
] },
|
|
180
|
+
};
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`bodyContent`는 표시 가능한 HTML 또는 JSON 텍스트를 확인하고, 명시한 경우에만
|
|
184
|
+
요약·대표 이미지·`additionalUrl`을 대체 콘텐츠로 허용합니다. `bodyText`는 사용할
|
|
185
|
+
HTML이 있으면 그 텍스트를, 없으면 JSON의 `text` 노드를 읽습니다. 이미지뿐인
|
|
186
|
+
본문은 텍스트 검사를 통과하지 않습니다. 본문 소스 선택은 core의
|
|
187
|
+
`resolvePostBodySource`로 공유하며 기존 renderer/server의 같은 export도 유지됩니다.
|
|
188
|
+
|
|
189
|
+
`evaluateContentPolicy`를 직접 부를 때는 **`expectedPath`에
|
|
190
|
+
`resolvePostPublicPath(post, modelContext)` 결과를 반드시 전달**합니다. `null`도
|
|
191
|
+
그대로 전달하며 `post.path` 자체를 넣지 않습니다. `modelContext`에는 현재 모델의
|
|
192
|
+
presentation·호환 컬렉션·전체 분류가 필요합니다. 발행 상태·발행일·대표 경로와
|
|
193
|
+
모델 경로가 맞는지도 평가하므로, 임의로 만든 경로로 잘못된 글을 통과시키지 않습니다.
|
|
194
|
+
|
|
195
|
+
실행 예제 `examples/nextjs/lib/content-policy.ts`는 모든 커서를 수집하고 같은
|
|
196
|
+
평가 결과를 재사용합니다. 다음 코드의 `contextFor`에는 위 모델 정보를 돌려주는
|
|
197
|
+
사이트 함수를 전달합니다.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { evaluateContentQuery, type PublicPathContext } from "@roottale/cms-core";
|
|
201
|
+
import { fetchAllContentPolicyPosts, evaluateContentPolicyPosts,
|
|
202
|
+
type PolicyPost } from "@/lib/content-policy";
|
|
203
|
+
// 위 contentPolicies와 blogQuery를 같은 사이트 모듈에서 가져옵니다.
|
|
204
|
+
|
|
205
|
+
async function loadBlogPage(
|
|
206
|
+
apiKey: string,
|
|
207
|
+
page: number,
|
|
208
|
+
contextFor: (post: PolicyPost) => PublicPathContext,
|
|
209
|
+
) {
|
|
210
|
+
const posts = (await Promise.all(blogQuery.modelKeys.map((modelKey) =>
|
|
211
|
+
fetchAllContentPolicyPosts({ apiKey, modelKey, locale: "ko" }),
|
|
212
|
+
))).flat();
|
|
213
|
+
const candidates = evaluateContentPolicyPosts(posts, contentPolicies, contextFor);
|
|
214
|
+
const listing = evaluateContentQuery(blogQuery, candidates, { complete: true, page });
|
|
215
|
+
return { listing, candidates };
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
첫 100건만 읽은 결과에 `complete: true`를 붙이지 마세요. 모든 모델의 마지막
|
|
220
|
+
커서까지 성공한 뒤에만 완전한 입력입니다. `complete: false`는 예외이며 네트워크
|
|
221
|
+
실패·커서 누락도 빈 목록이나 `noindex` 성공으로 바꾸지 않습니다.
|
|
222
|
+
|
|
223
|
+
쿼리는 공개 가능한 글 → 모델·필터·범위 선택 → `pinnedField === true` 우선 →
|
|
224
|
+
발행일 내림차순·ID 오름차순 → 현재 페이지 순으로 계산합니다. 목록의 색인 여부는
|
|
225
|
+
**현재 페이지 안에** 색인 가능한 글이 있는지로 정합니다. 본문은 `listing.items`,
|
|
226
|
+
메타데이터는 같은 `listing.robots`를 사용하세요. `filters`는
|
|
227
|
+
`equals`·`notEquals`·`oneOf`, `scope`는 `currentTerm`(분류 ID 필수) 또는
|
|
228
|
+
`currentPath`(현재 목록 경로 필수)만 지원합니다. 여러 목록을 평가할 때에는 필요한
|
|
229
|
+
모델의 합집합을 한 번 수집하고 `candidates`를 재사용할 수 있습니다.
|
|
230
|
+
|
|
231
|
+
빈 분류가 실제로 404인 사이트만 분류 쿼리에 `requireItems: true`를 선언합니다.
|
|
232
|
+
일반 목록의 빈 첫 페이지는 `visible: true`, `indexable: false`이므로 200 화면을
|
|
233
|
+
유지합니다. 범위를 벗어난 페이지는 `visible: false`입니다. 고정 목록에는
|
|
234
|
+
`requireItems: true`와 `scope: "currentTerm"`을 선언할 수 없습니다. 관리자 목록
|
|
235
|
+
메타데이터와 사이트맵은 첫 페이지, FRONT는 요청받은 페이지 번호로 평가합니다.
|
|
236
|
+
|
|
237
|
+
사이트맵도 `candidates`의 글 정책과 같은 목록 쿼리를 사용하고 최종 수동 SEO·강제
|
|
238
|
+
robots·canonical·리다이렉트 제외 규칙을 함께 적용합니다. 기존 `createSitemapIndex`
|
|
239
|
+
팩토리가 등록 정책을 자동으로 가져오지는 않으므로 정책을 쓰는 사이트의 사이트맵
|
|
240
|
+
수집 코드에도 연결하세요. `selectContentQuery`는 글 정책과 목록 평가를 한 번에
|
|
241
|
+
호출하는 편의 API이며, 여러 화면에서 재사용할 때는 위처럼 두 평가를 분리합니다.
|
|
242
|
+
캐시 연결은 [발행 웹훅 문서](revalidation-webhooks.md)의 공개 정책 절을 따릅니다.
|
|
243
|
+
|
|
80
244
|
### 관리 API·MCP
|
|
81
245
|
|
|
82
246
|
CLI도 같은 관리 API를 사용합니다. `rt cms metadata list --site-id <site-id> --kind route --state custom`, `rt cms metadata get route home --site-id <site-id>`로 조회합니다. 수정은 `rt cms metadata update route home --site-id <site-id> --input-file patch.json`이며 파일에는 `{ "expected_version": "직전 조회값", "patch": { "title": null } }`처럼 실제 변경만 넣습니다. 인증키를 명령 인자에 넣지 마세요.
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { revalidatePath, revalidateTag } from "next/cache";
|
|
8
8
|
import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
|
|
9
9
|
import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
|
|
10
|
+
import { registeredContentPolicies } from "@/lib/content-policy";
|
|
10
11
|
|
|
11
12
|
// 2번째 인자(type)를 반드시 그대로 넘긴다 — 팩토리가 설정 저장 시
|
|
12
13
|
// `revalidate("/", "layout")` 을 부르는데, 콜백이 path 만 받으면 "layout" 이
|
|
@@ -34,6 +35,9 @@ export const POST = createRevalidateRoute({
|
|
|
34
35
|
// 받는다("max" 의 expire 는 1년이다). `updateTag` 는 Server Action 전용이라
|
|
35
36
|
// route handler 에서 throw 하므로 쓰지 않는다.
|
|
36
37
|
revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
|
|
38
|
+
// 정책을 쓰는 사이트는 배포 계약 metadata.posts.contentPolicies를 연결합니다.
|
|
39
|
+
// 예제의 undefined는 opt-in 전 상태입니다. 설정하면 revalidateTag가 필수입니다.
|
|
40
|
+
contentPolicies: registeredContentPolicies,
|
|
37
41
|
themeTag: THEME_CACHE_TAG,
|
|
38
42
|
// 사이트맵 인덱스 + 분리된 하위 사이트맵(static/blog/categories)을 모두 무효화.
|
|
39
43
|
// 홈에 최신 글 섹션이 있으면 "/" 포함 — 글 변경 시 홈도 함께 갱신.
|
|
@@ -89,6 +89,8 @@ export default async function PostPage({ params }: Props) {
|
|
|
89
89
|
theme={null}
|
|
90
90
|
footerPatternPresentation={null}
|
|
91
91
|
relatedPostsCount={3}
|
|
92
|
+
// H1 위 카테고리 링크. breadcrumb 없이도 독립적으로 사용할 수 있습니다.
|
|
93
|
+
categoryHref={(category) => `/blog/categories/${encodeURIComponent(category)}`}
|
|
92
94
|
// opt-in — 시각 브레드크럼 + BreadcrumbList JSON-LD. siteUrl 없으면
|
|
93
95
|
// 시각 브레드크럼만(JSON-LD 미emit).
|
|
94
96
|
breadcrumb={{ siteUrl: SITE_URL }}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type MetadataDefaultsDeclaration,
|
|
8
8
|
type MetadataValues,
|
|
9
9
|
} from "@roottale/cms-client/server";
|
|
10
|
+
import { contentPolicySafeHttpsUrl, metadataVideoThumbnail } from "@roottale/cms-core";
|
|
10
11
|
|
|
11
12
|
/** Register this same declaration in the deployed site's metadata.posts contract. */
|
|
12
13
|
export const contentMetadataDefaults: MetadataDefaultsDeclaration = {
|
|
@@ -20,6 +21,7 @@ export const contentMetadataDefaults: MetadataDefaultsDeclaration = {
|
|
|
20
21
|
defaults: { ogImage: "/images/logo.png", ogImageAlt: "사무소 로고" },
|
|
21
22
|
},
|
|
22
23
|
blog: {
|
|
24
|
+
videoThumbnail: { source: "youtube", field: "video_url", when: { field: "content_format", equals: "media" } },
|
|
23
25
|
description: {
|
|
24
26
|
normalizeWhitespace: true, maxLength: 160, lengthUnit: "code-points", ellipsis: "…",
|
|
25
27
|
bodyFallback: {}, fallback: "%s에 관한 안내입니다.",
|
|
@@ -29,6 +31,13 @@ export const contentMetadataDefaults: MetadataDefaultsDeclaration = {
|
|
|
29
31
|
},
|
|
30
32
|
};
|
|
31
33
|
|
|
34
|
+
/** Card image uses the same declared public field; it does not copy site SEO defaults. */
|
|
35
|
+
export function contentCardImage(post: CmsPostContent): string | undefined {
|
|
36
|
+
const modelKey = post.modelKey ?? post.collectionKey ?? "blog";
|
|
37
|
+
const rule = contentMetadataDefaults.posts?.defaultRules?.[modelKey];
|
|
38
|
+
return contentPolicySafeHttpsUrl(post.featuredImageUrl) ?? metadataVideoThumbnail(rule?.videoThumbnail, post.fields);
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
/** Metadata cleanup applies to automatic values only. Stored SEO and canonical remain separate. */
|
|
33
42
|
export function contentMetadata(post: CmsPostContent, siteUrl: string, siteDefaults: MetadataValues): MetadataValues {
|
|
34
43
|
const modelKey = post.modelKey ?? post.collectionKey ?? "blog";
|
|
@@ -4,7 +4,7 @@ const apiKey = process.env.ROOTTALE_API_KEY!;
|
|
|
4
4
|
|
|
5
5
|
export async function getTeamMembers() {
|
|
6
6
|
const models = await fetchContentModels({ apiKey });
|
|
7
|
-
const model = models.
|
|
7
|
+
const model = models.find((candidate) => candidate.key === "team-member");
|
|
8
8
|
if (!model || model.preset !== "entity") return [];
|
|
9
9
|
|
|
10
10
|
const response = await fetchPosts({ apiKey, modelKey: model.key, limit: 100 });
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @roottale/cms-core를 사이트의 직접 의존성에 추가합니다(SDK 0.70.0+).
|
|
2
|
+
import {
|
|
3
|
+
ContentPolicyWhenSchema,
|
|
4
|
+
evaluateContentPolicy,
|
|
5
|
+
resolvePostPublicPath,
|
|
6
|
+
type ContentPolicy,
|
|
7
|
+
type ContentPolicyCheck,
|
|
8
|
+
type ContentQueryCandidate,
|
|
9
|
+
type MetadataRobotsPolicy,
|
|
10
|
+
type PublicPathContext,
|
|
11
|
+
} from "@roottale/cms-core";
|
|
12
|
+
import {
|
|
13
|
+
CONTENT_POLICY_CACHE_TAG,
|
|
14
|
+
fetchCategoryCounts,
|
|
15
|
+
fetchPosts,
|
|
16
|
+
type CmsPostContent,
|
|
17
|
+
type FetchCategoryCountsOptions,
|
|
18
|
+
type FetchPostsOptions,
|
|
19
|
+
} from "@roottale/cms-client/server";
|
|
20
|
+
|
|
21
|
+
// 이 예제에는 등록된 사이트 콘텐츠 계약이 없습니다. 정책을 사용하는 사이트는
|
|
22
|
+
// 배포 계약 metadata.posts.contentPolicies를 여기서 import하여 그대로 내보냅니다.
|
|
23
|
+
// 상세·목록·사이트맵에 같은 정책을 연결한 뒤 이 값을 설정하세요.
|
|
24
|
+
export const registeredContentPolicies:
|
|
25
|
+
| Readonly<Record<string, ContentPolicy>>
|
|
26
|
+
| undefined = undefined;
|
|
27
|
+
|
|
28
|
+
export type PolicyPost = CmsPostContent & { path: string | null };
|
|
29
|
+
|
|
30
|
+
// 예외 조건도 등록한 정책 안에 둡니다. 이 상수만으로 글을 공개하지 않습니다.
|
|
31
|
+
// 아래 세 값이 정확히 일치할 때만 방송 기록이며 누락/null은 false가 아닙니다.
|
|
32
|
+
const broadcastRecordFields = [
|
|
33
|
+
{ field: "content_format", equals: "media" },
|
|
34
|
+
{ field: "broadcast_record", equals: true },
|
|
35
|
+
{ field: "medical_content", equals: false },
|
|
36
|
+
];
|
|
37
|
+
export const broadcastRecordWhen = ContentPolicyWhenSchema.parse({ all: broadcastRecordFields });
|
|
38
|
+
export const reviewExceptBroadcastRecordWhen = ContentPolicyWhenSchema.parse({ unlessAll: broadcastRecordFields });
|
|
39
|
+
export const performerExceptBroadcastRecordWhen = ContentPolicyWhenSchema.parse({
|
|
40
|
+
all: [{ field: "content_format", equals: "media" }],
|
|
41
|
+
unlessAll: broadcastRecordFields,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Add to the full indexing policy; direct noindex:false cannot override a failed check. */
|
|
45
|
+
export const broadcastRecordNoindexCheck: ContentPolicyCheck = {
|
|
46
|
+
field: "broadcast_record", op: "notEquals", value: true,
|
|
47
|
+
when: { all: [
|
|
48
|
+
{ field: "content_format", equals: "media" },
|
|
49
|
+
{ field: "medical_content", equals: false },
|
|
50
|
+
] },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// 문서의 compatibilityCollectionKey: null인 blog 모델 예제입니다.
|
|
54
|
+
// 경로 계산에는 글에 붙은 분류만이 아닌 모델의 전체 graph가 필요합니다.
|
|
55
|
+
// null collectionKey는 기존 blog 범위이며 응답 원본은 재작성하지 않습니다.
|
|
56
|
+
export async function fetchBlogCategoryGraph(
|
|
57
|
+
options: Omit<FetchCategoryCountsOptions, "collectionKey" | "excludeCollections">,
|
|
58
|
+
) {
|
|
59
|
+
const result = await fetchCategoryCounts({
|
|
60
|
+
...options, tags: [...new Set([...(options.tags ?? []), CONTENT_POLICY_CACHE_TAG])],
|
|
61
|
+
});
|
|
62
|
+
if (result.truncated) throw new Error("모델의 전체 분류를 가져오지 못했습니다");
|
|
63
|
+
return result.categories.filter((category) => (category.collectionKey ?? "blog") === "blog").map((category) => {
|
|
64
|
+
if (!category.id) throw new Error("분류의 안정 ID가 없습니다");
|
|
65
|
+
return { ...category, id: category.id };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 첫 100건은 완전한 목록이 아닙니다. query.modelKeys의 각 모델에 대해 호출하고,
|
|
70
|
+
// 모든 호출이 성공한 뒤에만 evaluateContentQuery에 complete: true를 전달합니다.
|
|
71
|
+
export async function fetchAllContentPolicyPosts(
|
|
72
|
+
options: Omit<FetchPostsOptions, "cursor" | "limit">,
|
|
73
|
+
): Promise<PolicyPost[]> {
|
|
74
|
+
const posts: PolicyPost[] = [];
|
|
75
|
+
const cursors = new Set<string>();
|
|
76
|
+
const tags = [...new Set([...(options.tags ?? []), CONTENT_POLICY_CACHE_TAG])];
|
|
77
|
+
let cursor: string | undefined;
|
|
78
|
+
while (true) {
|
|
79
|
+
const page = await fetchPosts({ ...options, tags, limit: 100, cursor });
|
|
80
|
+
posts.push(...page.items.map((post) => ({ ...post, path: post.path ?? null })));
|
|
81
|
+
if (!page.hasMore) return posts;
|
|
82
|
+
if (!page.nextCursor || cursors.has(page.nextCursor)) {
|
|
83
|
+
throw new Error("공개 콘텐츠의 다음 커서가 없거나 반복됩니다");
|
|
84
|
+
}
|
|
85
|
+
cursors.add(page.nextCursor);
|
|
86
|
+
cursor = page.nextCursor;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 한 요청에서 이 결과를 목록 본문·메타데이터·사이트맵에 재사용합니다.
|
|
91
|
+
// contextFor는 해당 모델의 presentation/호환 컬렉션/전체 분류를 반환해야 합니다.
|
|
92
|
+
// 분류·모델·컬렉션을 fetch할 때도 CONTENT_POLICY_CACHE_TAG를 붙입니다.
|
|
93
|
+
export function evaluateContentPolicyPosts(
|
|
94
|
+
posts: readonly PolicyPost[],
|
|
95
|
+
policies: Readonly<Record<string, ContentPolicy>>,
|
|
96
|
+
contextFor: (post: PolicyPost) => PublicPathContext,
|
|
97
|
+
options: {
|
|
98
|
+
now?: number;
|
|
99
|
+
robotsFor?: (post: PolicyPost) => MetadataRobotsPolicy | undefined;
|
|
100
|
+
} = {},
|
|
101
|
+
): ContentQueryCandidate<PolicyPost>[] {
|
|
102
|
+
const now = options.now ?? Date.now();
|
|
103
|
+
return posts.map((post) => {
|
|
104
|
+
const modelKey = post.modelKey ?? post.collectionKey ?? "";
|
|
105
|
+
const policy = Object.hasOwn(policies, modelKey) ? policies[modelKey] : undefined;
|
|
106
|
+
if (!policy) throw new Error(`모델 ${modelKey}의 공개 정책이 없습니다`);
|
|
107
|
+
return {
|
|
108
|
+
post,
|
|
109
|
+
policy: evaluateContentPolicy(policy, post, {
|
|
110
|
+
now,
|
|
111
|
+
// null도 그대로 전달합니다. post.path 자체를 expectedPath로 쓰면
|
|
112
|
+
// 모델의 실제 경로와 다른 글을 걸러낼 수 없습니다.
|
|
113
|
+
expectedPath: resolvePostPublicPath(post, contextFor(post)),
|
|
114
|
+
robots: options.robotsFor?.(post),
|
|
115
|
+
}),
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
package/package.json
CHANGED