@roottale/cms-mcp 0.49.0 → 0.50.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 +32 -0
- package/dist/index.js +1 -1
- package/docs/api-reference.md +61 -3
- package/docs/seo.md +186 -1
- package/docs/theme-and-settings.md +20 -4
- package/examples/nextjs/app/blog/categories/[slug]/page.tsx +38 -9
- package/examples/nextjs/components/attribution-field.tsx +1 -0
- package/examples/nextjs/lib/blog.ts +34 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @roottale/cms-mcp
|
|
2
2
|
|
|
3
|
+
## 0.50.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ebd8fa1: `docs/seo.md` 에 "색인 위생 — 모음 페이지는 기본 '검색 노출 안 함'" 절을 추가하고, 예시 코드 `app/blog/categories/[slug]/page.tsx`·`lib/blog.ts` 를 `decideArchiveIndex`·`buildArchiveMetadata`·`resolveCategoryPromotion` 사용으로 갱신한다. 사이트맵 절에도 "분류·작가 모음 페이지는 기본으로 빠진다" 를 명시했다.
|
|
8
|
+
|
|
9
|
+
문서 예시가 **존재하지 않는 옵션**(`fetchPosts({ category })`)을 쓰던 오류를 고쳤다 — 공개 API 에는 분류별 서버 필터가 없어 최근 글을 `ARCHIVE_POST_COUNT_LIMIT` 만큼 받아 분류별로 세는 것이 정답이다. 그대로 따라 하면 TypeScript 는 컴파일 오류, JavaScript 는 옵션이 조용히 무시돼 전체 최근 글 수를 그 분류의 글 수로 오판했다.
|
|
10
|
+
|
|
11
|
+
승격 상태를 boolean 이 아니라 3-state(`promoted`/`not-promoted`/`unknown`)로 다루도록 예시와 정책 표를 바꿨다 — 설정 조회 실패를 "안 켬" 으로 바꿔 넘기면 이미 검색에 올라가 있던 주소에 noindex 가 붙는다. 글 수 모집단 한도를 사이트맵과 공유해야 하는 이유와 그 한계도 문서화했다.
|
|
12
|
+
|
|
13
|
+
이 문서는 고객 측 AI 에이전트가 읽으므로 렌더러 변경과 같이 재발행해야 드리프트가 없다.
|
|
14
|
+
|
|
15
|
+
- ef72e0a: 분류별 글 수를 서버가 센다 — 오래된 글에만 달린 분류가 검색에서 사라지지 않게
|
|
16
|
+
|
|
17
|
+
분류 모음 페이지의 "글 0건이면 404" 판정과 사이트맵 포함 여부는 분류별 글 수에서
|
|
18
|
+
나옵니다. 지금까지는 최근 200건을 받아 세었기 때문에, 글이 많은 사이트에서 그
|
|
19
|
+
범위 밖에만 글이 있는 분류가 **모음 페이지(404)와 사이트맵 양쪽에서** 함께
|
|
20
|
+
빠졌습니다.
|
|
21
|
+
- 공개 API에 `GET /v1/cms/public/categories` 추가 — 모든 분류와 발행 글 수를
|
|
22
|
+
서버가 SQL로 집계해 내려줍니다. 집계 범위는 글 목록 API와 같은 어휘
|
|
23
|
+
(`exclude_collections` / `collection_key` / `locale`)로 지정합니다.
|
|
24
|
+
- `@roottale/cms-client` 에 `fetchCategoryCounts()` 추가.
|
|
25
|
+
- `fetchBlogArchiveCategories`(cms-renderer-next)가 이 API를 먼저 씁니다. 못 받으면
|
|
26
|
+
(구버전 API·일시 장애) 예전 방식으로 자동 복귀하므로 기존 동작이 깨지지 않고,
|
|
27
|
+
사이트맵과 모음 페이지는 어느 경로에서든 같은 값을 봅니다.
|
|
28
|
+
|
|
29
|
+
사이트 코드 변경은 필요 없습니다 — 패키지를 올리면 적용됩니다. 다만 사이트가
|
|
30
|
+
바라보는 API가 아직 이 엔드포인트를 배포하지 않았다면 예전 동작이 그대로
|
|
31
|
+
유지됩니다.
|
|
32
|
+
|
|
33
|
+
- 1787ac0: `docs/seo.md`의 에셋 도메인 정합 스캔 섹션을 `scanAssetHosts` 하드닝(base/iframe/embed/object/track/audio/input[type=image] 스캔 대상 확장, `<link rel>` 필터링, `imagesrcset`/`image-set()` 지원, HTML 엔티티 디코드 + fail-closed 파싱)에 맞춰 갱신한다. 이 문서는 npm 패키지에 번들되어 고객 측 AI 에이전트가 읽으므로 renderer 변경과 함께 재발행해야 드리프트가 없다.
|
|
34
|
+
|
|
3
35
|
## 0.49.0
|
|
4
36
|
|
|
5
37
|
### Minor 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.
|
|
947
|
+
var VERSION = true ? "0.50.0" : "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
|
@@ -262,6 +262,51 @@ fallback 네비를 렌더하세요 (`menus.md` 참고).
|
|
|
262
262
|
settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
|
|
263
263
|
`favicon_url`·`site_description` 은 사이트 `<head>` 에 적용합니다.
|
|
264
264
|
|
|
265
|
+
## GET /v1/cms/public/categories
|
|
266
|
+
|
|
267
|
+
분류(카테고리)와 **분류별 발행 글 수**. 글 수는 서버가 SQL로 세므로 글이 몇 건이든
|
|
268
|
+
정확합니다 — 분류 모음 페이지의 "글 0건이면 404" 판정과 사이트맵 포함 여부(색인
|
|
269
|
+
위생)를 계산하는 데 씁니다. `@roottale/cms-renderer-next` 의
|
|
270
|
+
`fetchBlogArchiveCategories` 가 이 API를 자동으로 씁니다(직접 부를 일은 드뭅니다).
|
|
271
|
+
|
|
272
|
+
쿼리(선택):
|
|
273
|
+
|
|
274
|
+
| 이름 | 값 | 설명 |
|
|
275
|
+
| --- | --- | --- |
|
|
276
|
+
| `exclude_collections` | `true` | 어느 콘텐츠 유형에도 속하지 않은 글만 셉니다 — `/blog` 계열 모음 페이지가 다루는 범위와 같습니다. |
|
|
277
|
+
| `collection_key` | 유형 key | 그 유형에 속한 글만 셉니다. `exclude_collections` 와 동시 지정 시 400. |
|
|
278
|
+
| `locale` | BCP-47 | 미지정이면 사이트 기본 로케일(글 목록과 같은 기본값). |
|
|
279
|
+
| `site_id` | 사이트 id | 보통 생략. |
|
|
280
|
+
|
|
281
|
+
```json
|
|
282
|
+
{ "tenant_id": "…", "site_id": "…",
|
|
283
|
+
"categories": [
|
|
284
|
+
{ "slug": "tax", "name": "세무", "published_post_count": 412,
|
|
285
|
+
"collection_key": null, "hub_promoted": true },
|
|
286
|
+
{ "slug": "law", "name": "법률", "published_post_count": 0,
|
|
287
|
+
"collection_key": null, "hub_promoted": false }
|
|
288
|
+
] }
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- 글이 0건인 분류도 그대로 내려옵니다(`published_post_count: 0`) — "그런 분류가
|
|
292
|
+
없다"와 "분류는 있는데 이 범위에 글이 없다"를 구분할 수 있게.
|
|
293
|
+
- `hub_promoted` 는 어드민의 "검색에 이 분류 페이지 노출" 값이지만, **검색 노출
|
|
294
|
+
판정에는 쓰지 마세요**. 그 판정의 단일 소스는 `/blog-settings` 의
|
|
295
|
+
`sitemap.promoted_category_slugs` 입니다(두 소스를 섞으면 사이트맵과 페이지가
|
|
296
|
+
서로 다른 시점의 값을 볼 수 있습니다). 여기 값은 진단·표시용입니다.
|
|
297
|
+
- 분류가 서버 상한(500개)을 넘으면 응답에 `"truncated": true` 가 붙습니다.
|
|
298
|
+
|
|
299
|
+
`@roottale/cms-client` 로 직접 부를 때:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
import { fetchCategoryCounts } from "@roottale/cms-client/server";
|
|
303
|
+
|
|
304
|
+
const { categories, truncated } = await fetchCategoryCounts({
|
|
305
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
306
|
+
excludeCollections: true,
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
265
310
|
## GET /v1/cms/public/site-knowledge
|
|
266
311
|
|
|
267
312
|
사이트 지식 — 브랜드 보이스(어조·톤·화자) + 용어 규칙(금지어·교정어). AI
|
|
@@ -282,17 +327,19 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
|
|
|
282
327
|
|
|
283
328
|
```json
|
|
284
329
|
{ "tenant_id": "…", "site_id": "…", "configured": true,
|
|
285
|
-
"name": "길동세무회계", "legal_name": null,
|
|
330
|
+
"name": "길동세무회계", "legal_name": null, "alternate_name": "길동 세무회계",
|
|
286
331
|
"business_type": "AccountingService",
|
|
287
|
-
"telephone": "02-1234-5678", "email": null,
|
|
332
|
+
"telephone": "02-1234-5678", "fax_number": "02-1234-5679", "email": null,
|
|
288
333
|
"address": { "street_address": "테헤란로 123", "address_locality": "강남구",
|
|
289
334
|
"address_region": "서울특별시", "postal_code": "06234" },
|
|
290
335
|
"geo": { "latitude": 37.5006, "longitude": 127.0364 },
|
|
291
336
|
"opening_hours": [ { "days": ["Mo","Tu","We","Th","Fr"],
|
|
292
337
|
"opens": "09:00", "closes": "18:00" } ],
|
|
293
338
|
"price_range": "₩₩", "area_served": ["서울 강남구"],
|
|
339
|
+
"services": ["기장·신고대리", "세무고문"],
|
|
294
340
|
"profiles": { "naver_place": "https://…", "google_business": "https://…",
|
|
295
|
-
"kakao_channel": null, "
|
|
341
|
+
"kakao_channel": null, "kakao_map": "https://…",
|
|
342
|
+
"instagram": null, "naver_blog": null },
|
|
296
343
|
"updated_at": "…" }
|
|
297
344
|
```
|
|
298
345
|
|
|
@@ -300,6 +347,17 @@ settings.siteProfile.defaultOgImageUrl` 순으로 우선합니다. `logo_url`·
|
|
|
300
347
|
`AccountingService` | `LegalService` | `MedicalClinic` | `Dentist` |
|
|
301
348
|
`RealEstateAgent` | `Restaurant` | `BeautySalon`.
|
|
302
349
|
|
|
350
|
+
| 필드 | JSON-LD 매핑 |
|
|
351
|
+
|---|---|
|
|
352
|
+
| `alternate_name` | `alternateName` — 띄어쓰기 변형 등 같은 사업장의 다른 표기 |
|
|
353
|
+
| `fax_number` | `faxNumber` |
|
|
354
|
+
| `services` | `hasOfferCatalog` 의 `Offer.itemOffered` 목록 (최대 12개) |
|
|
355
|
+
| `profiles.kakao_map` | `hasMap` — `naver_place` 가 있으면 그쪽이 우선. **`sameAs` 에는 들어가지 않는다**(프로필이 아니라 위치 링크) |
|
|
356
|
+
|
|
357
|
+
> **주의**: `name`(사업장 이름)이 비어 있으면 `configured: false` 로 내려가고
|
|
358
|
+
> `@roottale/cms-client` 의 `fetchBusinessProfile` 은 `null` 을 반환한다 —
|
|
359
|
+
> 전화·주소만 채우고 이름을 비우면 나머지 입력이 전부 무시된다.
|
|
360
|
+
|
|
303
361
|
## GET /v1/cms/public/analytics
|
|
304
362
|
|
|
305
363
|
분석 태그 설정.
|
package/docs/seo.md
CHANGED
|
@@ -62,6 +62,8 @@ export default sitemap;
|
|
|
62
62
|
|
|
63
63
|
- **블로그 이미지 색인** — 글에 대표 이미지가 있으면 `<image:image>`로 함께 색인합니다.
|
|
64
64
|
어드민 **설정 > 블로그 > 사이트맵**에서 켜고 끌 수 있어요(기본 켜짐).
|
|
65
|
+
- **분류·작가 모음 페이지는 기본으로 사이트맵에서 빠집니다** — 어드민에서 "검색에
|
|
66
|
+
노출"을 켠 것만 들어갑니다. 아래 "색인 위생" 절을 보세요.
|
|
65
67
|
- `changeFrequency`/`priority`는 Google이 무시하므로 더 이상 내보내지 않습니다(`loc` +
|
|
66
68
|
`lastmod` + 이미지만).
|
|
67
69
|
- 단일 평면 사이트맵이 필요하면 레거시 `createSitemap`(default export 하나)도 그대로
|
|
@@ -143,6 +145,143 @@ const jsonLd = collectionPageSchema({
|
|
|
143
145
|
});
|
|
144
146
|
```
|
|
145
147
|
|
|
148
|
+
### 색인 위생 — 모음 페이지는 기본 "검색 노출 안 함"
|
|
149
|
+
|
|
150
|
+
분류·태그·작가·검색 결과처럼 **글을 모아 보여주는 페이지**는 내용이 겹치기 쉬워,
|
|
151
|
+
전부 검색에 올리면 오히려 사이트 평가가 내려갑니다. 그래서 RootTale은 모음 페이지를
|
|
152
|
+
**기본으로 검색에서 빼고**, 소개 글을 채워 대표로 삼은 분류만 올립니다.
|
|
153
|
+
|
|
154
|
+
- 켜는 곳: 어드민 **내 사이트 > 카테고리**에서 분류를 펼치고 "검색에 이 분류 페이지
|
|
155
|
+
노출"을 체크합니다. 켠 분류만 사이트맵에 들어가고 `robots`도 색인 허용이 됩니다.
|
|
156
|
+
- 작가 모음은 **설정 > 블로그 > 사이트맵**의 "작가별 글 모음도 검색엔진에 알리기"가
|
|
157
|
+
같은 역할을 합니다.
|
|
158
|
+
- 글 자체는 영향을 받지 않습니다 — 모음 페이지만 빠집니다.
|
|
159
|
+
|
|
160
|
+
판정은 `decideArchiveIndex` 하나에서 나옵니다. 사이트맵 필터와 페이지의 `robots`가
|
|
161
|
+
**같은 함수**를 보므로 "검색에서 빼기로 해놓고 사이트맵에는 넣는" 어긋남이 생기지
|
|
162
|
+
않습니다. 단, 같은 함수를 쓰는 것만으로는 부족하고 **판정에 넣는 값도 같아야**
|
|
163
|
+
합니다. 그래서 입력을 만드는 세 가지도 함께 제공합니다:
|
|
164
|
+
|
|
165
|
+
- `resolveCategoryPromotion(slugs, slug)` — 노출 설정을 3단계
|
|
166
|
+
(`promoted` / `not-promoted` / `unknown`)로 바꿉니다. 설정을 못 읽었을 때
|
|
167
|
+
`unknown` 을 그대로 넘기면 정책이 **기존 상태를 보존**합니다. 여기서 임의로
|
|
168
|
+
"안 켬"으로 바꾸면, 설정 조회가 한 번 실패했다는 이유만으로 이미 검색에 올라가
|
|
169
|
+
있던 주소에 noindex 가 붙습니다.
|
|
170
|
+
- `fetchBlogArchiveCategories(config)` — `/blog` 분류 모음 페이지가 **다루는 글**
|
|
171
|
+
에서 분류와 글 수를 뽑습니다. 사이트맵도 같은 함수를 씁니다. 직접 글을 받아
|
|
172
|
+
세지 마세요 — 세는 글 집합이 갈리면 "사이트맵엔 주소가 있는데 페이지는 404" 가
|
|
173
|
+
생깁니다.
|
|
174
|
+
- `ARCHIVE_POST_COUNT_LIMIT` — 아래 폴백 경로가 쓰는 글 개수 한도. 직접 셀 때도
|
|
175
|
+
같은 상수를 쓰세요.
|
|
176
|
+
|
|
177
|
+
글 수는 `GET /v1/cms/public/categories` 가 **서버에서 세어** 내려줍니다. 글이
|
|
178
|
+
몇 건이든 모든 분류의 정확한 개수가 나오므로, 오래된 글에만 달린 분류가 검색에서
|
|
179
|
+
사라지지 않습니다. `fetchBlogArchiveCategories` 가 이 API를 먼저 쓰고, 못 받으면
|
|
180
|
+
(구버전 API이거나 일시 장애) 예전처럼 최근 글 `ARCHIVE_POST_COUNT_LIMIT` 건을 받아
|
|
181
|
+
세는 방식으로 되돌아갑니다 — 사이트맵과 페이지가 여전히 같은 함수를 쓰므로 어느
|
|
182
|
+
경로든 두 곳의 결론은 같습니다.
|
|
183
|
+
|
|
184
|
+
> **어떤 글을 세나요**: `/blog` 계열 모음 페이지는 "콘텐츠 유형(스트림)에 속하지
|
|
185
|
+
> 않은 글"만 다룹니다. 증상·질환 같은 별도 유형을 쓰는 사이트에서 그 유형 글에만
|
|
186
|
+
> 달린 분류는 `/blog/categories/{slug}` 로 볼 수 없으므로(그 유형은 자기 주소가
|
|
187
|
+
> 따로 있습니다) 사이트맵에서도 빠집니다. `fetchBlogArchiveCategories` 가 이
|
|
188
|
+
> 범위를 서버에 물어보므로(`exclude_collections`), 어떤 유형을 만들었든 두 곳이
|
|
189
|
+
> 항상 같은 글을 셉니다. 유형별 모음 페이지(`{유형주소}/categories/{slug}`)는
|
|
190
|
+
> `collectCollectionCategories(posts, collections, key)` 가 짝입니다.
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
// app/blog/categories/[slug]/page.tsx
|
|
194
|
+
import { notFound } from "next/navigation";
|
|
195
|
+
import {
|
|
196
|
+
buildArchiveMetadata,
|
|
197
|
+
decideArchiveIndex,
|
|
198
|
+
fetchBlogArchiveCategories,
|
|
199
|
+
resolveCategoryPromotion,
|
|
200
|
+
} from "@roottale/cms-renderer-next/routes";
|
|
201
|
+
import { fetchBlogSettings } from "@roottale/cms-client/server";
|
|
202
|
+
|
|
203
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
|
|
204
|
+
const apiKey = process.env.ROOTTALE_API_KEY!;
|
|
205
|
+
|
|
206
|
+
async function archiveState(slug: string) {
|
|
207
|
+
const [settings, categories] = await Promise.all([
|
|
208
|
+
// 설정 조회 실패는 "안 켬"이 아니라 "모름"입니다 — undefined 로 넘깁니다.
|
|
209
|
+
fetchBlogSettings({ apiKey }).catch(() => null),
|
|
210
|
+
// 사이트맵과 같은 함수 = 같은 글 집합, 같은 한도.
|
|
211
|
+
fetchBlogArchiveCategories({ apiKey }),
|
|
212
|
+
]);
|
|
213
|
+
return {
|
|
214
|
+
promotion: resolveCategoryPromotion(
|
|
215
|
+
settings?.sitemap?.promotedCategorySlugs,
|
|
216
|
+
slug,
|
|
217
|
+
),
|
|
218
|
+
// 이 라우트가 모르는 분류면 0건 → 아래에서 404.
|
|
219
|
+
postCount: categories.find((c) => c.slug === slug)?.count ?? 0,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function generateMetadata({ params }) {
|
|
224
|
+
const { slug } = await params;
|
|
225
|
+
const { promotion, postCount } = await archiveState(slug);
|
|
226
|
+
return buildArchiveMetadata(
|
|
227
|
+
{ kind: "category", promotion, postCount, title: `${slug} 글 모음` },
|
|
228
|
+
{ siteUrl: SITE_URL, path: `/blog/categories/${slug}` },
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export default async function CategoryArchive({ params }) {
|
|
233
|
+
const { slug } = await params;
|
|
234
|
+
const { promotion, postCount } = await archiveState(slug);
|
|
235
|
+
// 글이 하나도 없는 모음 페이지는 만들지 않습니다(404).
|
|
236
|
+
if (decideArchiveIndex({ kind: "category", promotion, postCount }).render === "notFound") {
|
|
237
|
+
notFound();
|
|
238
|
+
}
|
|
239
|
+
// ... 목록 렌더
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
정책 요약(`decideArchiveIndex`):
|
|
244
|
+
|
|
245
|
+
| 상황 | 화면 | 검색 노출 | 사이트맵 |
|
|
246
|
+
| --- | --- | --- | --- |
|
|
247
|
+
| 글 0건 (검색 결과 제외) | 404 | ✕ | ✕ |
|
|
248
|
+
| 2페이지 이상 | 정상 | ✕ | ✕ |
|
|
249
|
+
| 태그·날짜·사이트 내 검색 | 정상 | ✕ | ✕ |
|
|
250
|
+
| 분류·작가 — 노출 켬(`promoted`) | 정상 | ○ | ○ |
|
|
251
|
+
| 분류·작가 — 노출 끔(`not-promoted`, 기본) | 정상 | ✕ | ✕ |
|
|
252
|
+
| 분류·작가 — 설정 모름(`unknown`) | 정상 | ○ | ○ |
|
|
253
|
+
|
|
254
|
+
승격 목록은 `GET /v1/cms/public/blog-settings` 응답의
|
|
255
|
+
`sitemap.promoted_category_slugs`(cms-client에서는 `sitemap.promotedCategorySlugs`)
|
|
256
|
+
로 내려옵니다. 이 필드가 아예 없는 구버전 API에 붙어 있거나 설정 조회에 실패하면
|
|
257
|
+
`resolveCategoryPromotion` 이 `unknown` 을 돌려주고, 렌더러는 정책을 적용하지 않고
|
|
258
|
+
기존처럼 모든 분류를 사이트맵에 넣습니다(모른다고 이미 색인된 주소를 지우지 않기
|
|
259
|
+
위해서입니다). 빈 배열 `[]` 은 "승격 0건"이라는 **확정 정보**라 의미가 다릅니다.
|
|
260
|
+
|
|
261
|
+
**구버전 클라이언트 호환**: `sitemap.promoted_category_slugs` 는 응답에 새로 생긴
|
|
262
|
+
키입니다. `@roottale/cms-client` 는 응답에서 아는 필드만 골라 쓰고 모르는 키는
|
|
263
|
+
무시하므로(스키마 전체 검증을 하지 않습니다) 구버전 사이트를 그대로 두어도 이 키
|
|
264
|
+
때문에 깨지지 않습니다 — 사이트를 올리지 않으면 이 절의 색인 정책이 적용되지 않을
|
|
265
|
+
뿐입니다.
|
|
266
|
+
|
|
267
|
+
> **한도 안내**: 분류별 글 수는 `GET /v1/cms/public/categories` 가 서버에서 세므로
|
|
268
|
+
> 글이 아무리 많아도 누락되지 않습니다. 다만 그 API를 못 받아 폴백으로 도는 동안
|
|
269
|
+
> (구버전 API·일시 장애)에는 최근 `ARCHIVE_POST_COUNT_LIMIT` 건만 보게 되어, 한도
|
|
270
|
+
> 밖에만 글이 있는 분류가 사이트맵과 페이지 **양쪽에서** 빠집니다(페이지는 404).
|
|
271
|
+
> 양쪽이 같은 기준으로 판단하므로 색인 어긋남은 이때도 생기지 않습니다.
|
|
272
|
+
>
|
|
273
|
+
> 유형별 모음 페이지(`{유형주소}/categories/{slug}`)를 만드는
|
|
274
|
+
> `collectCollectionCategories` 는 아직 최근 글 배치만 씁니다. 서버 집계 API는
|
|
275
|
+
> `collection_key` 로 유형 범위를 지정할 수 있으므로 같은 방식으로 옮길 예정입니다.
|
|
276
|
+
|
|
277
|
+
> **어긋남이 잠깐 보일 수 있는 경우**: 사이트맵과 모음 페이지는 서로 다른 요청에서
|
|
278
|
+
> 각자 설정을 읽습니다. 한쪽만 조회에 실패하면 그 사이에는 "검색에서 뺐는데
|
|
279
|
+
> 사이트맵엔 남아 있는" 상태가 생길 수 있습니다. 사이트맵이 다시 만들어질 때
|
|
280
|
+
> 자동으로 맞춰지며, **걸리는 시간의 상한 = 사이트맵의 `revalidate` 값**입니다
|
|
281
|
+
> (Site Kit 기본 60초). 조회 실패 때 주소를 빼지 않고 남기는 쪽을 택한 이유는,
|
|
282
|
+
> 일시적인 장애 한 번으로 이미 검색에 올라간 주소를 사이트맵에서 지우는 손실이 더
|
|
283
|
+
> 크기 때문입니다.
|
|
284
|
+
|
|
146
285
|
### 다국어 (hreflang) — ADR-0052 A안, 콘텐츠 다국어
|
|
147
286
|
|
|
148
287
|
번역 글이 있는 사이트는 어드민 **설정 > 블로그 > 사이트맵**의 "다국어 로케일"에
|
|
@@ -554,8 +693,14 @@ export default async function RootLayout({ children }) {
|
|
|
554
693
|
(세무·회계 = `AccountingService`, 병원·의원 = `MedicalClinic` 등)
|
|
555
694
|
- `address`(PostalAddress) / `geo`(GeoCoordinates) /
|
|
556
695
|
`openingHoursSpecification` / `priceRange` / `areaServed`
|
|
696
|
+
- `alternateName`: 띄어쓰기 변형처럼 같은 사업장을 다르게 부르는 이름
|
|
697
|
+
- `faxNumber`: 팩스를 쓰는 업종(세무·법률 등)의 연락처
|
|
698
|
+
- `hasOfferCatalog`: 어드민 "취급 업무" 목록 — "무엇을 하는 곳인가"를 명시하는
|
|
699
|
+
신호입니다. 명함·간판에 적힌 업무를 그대로 옮기면 됩니다.
|
|
700
|
+
- `hasMap`: 네이버플레이스 주소(없으면 카카오맵 주소)
|
|
557
701
|
- `sameAs`: 어드민에 입력한 네이버플레이스·구글 비즈니스 프로필·카카오 채널
|
|
558
|
-
등의 URL — 검색엔진이 동일 사업장임을 연결합니다.
|
|
702
|
+
등의 URL — 검색엔진이 동일 사업장임을 연결합니다. 지도 딥링크(카카오맵)는
|
|
703
|
+
프로필 페이지가 아니므로 `hasMap` 으로만 나가고 여기엔 포함되지 않습니다.
|
|
559
704
|
|
|
560
705
|
네이버플레이스([new.smartplace.naver.com](https://new.smartplace.naver.com))와
|
|
561
706
|
구글 비즈니스 프로필([business.google.com](https://business.google.com)) 등록
|
|
@@ -616,3 +761,43 @@ export const GET = createLlmsTxtRoute({
|
|
|
616
761
|
API 조회가 실패해도 항상 200으로 헤더 부분을 반환합니다(빌드 사고 방지).
|
|
617
762
|
발행 웹훅의 기본 `alsoRevalidate`에 `/llms.txt`가 포함되어 있어, 글을
|
|
618
763
|
발행·수정하면 AI 크롤러용 인덱스도 자동으로 갱신됩니다.
|
|
764
|
+
|
|
765
|
+
## 에셋 도메인 정합 스캔 (스테이징 잔류 방지)
|
|
766
|
+
|
|
767
|
+
빌드 산출물의 이미지·스크립트·스타일시트가 스테이징/프리뷰 호스트(예:
|
|
768
|
+
`*.vercel.app`, `staging.example.com`)를 그대로 참조한 채 배포되면 운영
|
|
769
|
+
사이트가 죽은 링크나 깨진 자산을 서빙하게 됩니다. `scanAssetHosts`는 렌더된
|
|
770
|
+
HTML(+ CSS)에서 절대 URL 에셋 호스트를 추출해 허용목록 밖의 호스트, 그리고
|
|
771
|
+
허용목록에 있어도 스테이징 패턴이면 무조건 위반으로 보고합니다.
|
|
772
|
+
|
|
773
|
+
```ts
|
|
774
|
+
import { scanAssetHosts } from "@roottale/cms-renderer-next/seo-asset-hosts";
|
|
775
|
+
|
|
776
|
+
const violations = scanAssetHosts(
|
|
777
|
+
html, // 렌더된 페이지 HTML — 인라인 <style> 블록도 함께 스캔됨
|
|
778
|
+
{ siteHost: "www.example.com", cdnHosts: [".cloudfront.net"] },
|
|
779
|
+
[externalCssString], // 선택: 별도로 로드되는 CSS 문자열(@import 1-depth)
|
|
780
|
+
);
|
|
781
|
+
// violations: { url, host, source, staging? }[] — 0건이면 통과
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
- 스캔 대상: `<img src>`/`<img srcset>`, `<source src>`(media)/`<source
|
|
785
|
+
srcset>`, `<script src>`, `<video poster>`/`<video src>`, `<audio src>`,
|
|
786
|
+
`<track src>`, `<base href>`(가장 위험 — 이후 모든 상대 URL이 이 호스트
|
|
787
|
+
기준으로 요청됨), `<iframe src>`, `<embed src>`/`<object data>`, `<input
|
|
788
|
+
type="image" src>`, `<link href>`(단 `rel`이 실제로 에셋을 로드하는
|
|
789
|
+
값일 때만 — `stylesheet`/`preload`/`modulepreload`/`prefetch`/
|
|
790
|
+
`preconnect`/`dns-prefetch`/`icon`류/`manifest`. `canonical`/`alternate`/
|
|
791
|
+
`author`/`me` 등은 검사하지 않음), `<link imagesrcset>`(srcset 문법), 인라인·
|
|
792
|
+
외부 CSS의 `url()`/`@import`/`image-set()`(`-webkit-image-set()` 포함).
|
|
793
|
+
- 절대 URL(`https://`/`http://`/`//host/...`)만 판정합니다 — 상대경로·
|
|
794
|
+
`data:`/`blob:`는 통과. 속성값의 HTML 엔티티(`&`, `&#NN;` 등)는 URL
|
|
795
|
+
파싱 전에 디코드하고, URL 안의 백슬래시는 브라우저와 동일하게 슬래시로
|
|
796
|
+
정규화합니다. `new URL()` 파싱이 실패하는 절대 URL은 호스트를 추측하지
|
|
797
|
+
않고 그 자체로 위반 처리합니다(fail-closed, `host: "(unparseable)"`).
|
|
798
|
+
- **한계**: HTML 태그는 quote-aware 토크나이저로 스캔하지만 완전한 DOM
|
|
799
|
+
파서는 아니라 스크립트 문자열 리터럴 안의 가짜 태그는 걸러내지 못하고, CSS
|
|
800
|
+
`@import`는 1-depth만 보며(중첩 `@import`는 범위 밖), CSS escape 시퀀스
|
|
801
|
+
(`\3a ` 등)까지는 디코드하지 않습니다. 런타임에 JS로 삽입되는 요청은 배포
|
|
802
|
+
후 실제 네트워크 요청을 검사하는 E2E 프로브로 보완하세요.
|
|
803
|
+
|
|
@@ -47,8 +47,9 @@ const display = resolvePostDisplay(settings, post);
|
|
|
47
47
|
|
|
48
48
|
## 비즈니스 프로필 (로컬 SEO) — fetchBusinessProfile
|
|
49
49
|
|
|
50
|
-
어드민 **운영 > 비즈니스 프로필**에서 저장한 사업장 정보(
|
|
51
|
-
|
|
50
|
+
어드민 **운영 > 비즈니스 프로필**에서 저장한 사업장 정보(이름·별칭·업종·
|
|
51
|
+
연락처·팩스·주소·좌표·영업시간·취급 업무·네이버플레이스/구글/카카오 URL)를
|
|
52
|
+
조회합니다.
|
|
52
53
|
|
|
53
54
|
```ts
|
|
54
55
|
import {
|
|
@@ -59,8 +60,9 @@ import {
|
|
|
59
60
|
const business = await fetchBusinessProfile({
|
|
60
61
|
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
61
62
|
});
|
|
62
|
-
// 미설정이면 null. 설정돼 있으면 name,
|
|
63
|
-
// openingHours, priceRange, areaServed,
|
|
63
|
+
// 미설정이면 null. 설정돼 있으면 name, alternateName, businessType, telephone,
|
|
64
|
+
// faxNumber, address, geo, openingHours, priceRange, areaServed, services,
|
|
65
|
+
// profiles 포함.
|
|
64
66
|
|
|
65
67
|
if (business) {
|
|
66
68
|
const jsonLd = localBusinessSchema(business, {
|
|
@@ -70,6 +72,20 @@ if (business) {
|
|
|
70
72
|
}
|
|
71
73
|
```
|
|
72
74
|
|
|
75
|
+
`localBusinessSchema` 가 만들어 주는 매핑:
|
|
76
|
+
|
|
77
|
+
| 프로필 값 | JSON-LD |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `alternateName` | `alternateName` — 띄어쓰기 변형 등 같은 사업장의 다른 표기 |
|
|
80
|
+
| `faxNumber` | `faxNumber` |
|
|
81
|
+
| `services` | `hasOfferCatalog` (각 항목이 `Offer` → `Service`) |
|
|
82
|
+
| `profiles.naverPlace` \| `profiles.kakaoMap` | `hasMap` (네이버 플레이스 우선) |
|
|
83
|
+
| 그 밖의 `profiles` 값 | `sameAs` — 지도 딥링크(`kakaoMap`)는 제외 |
|
|
84
|
+
|
|
85
|
+
> **주의**: `name`(사업장 이름)이 비어 있으면 `fetchBusinessProfile` 이 `null` 을
|
|
86
|
+
> 반환합니다. 전화·주소만 채우고 이름을 비워두면 **나머지 입력이 전부 무시**되니
|
|
87
|
+
> 고객 안내 시 이름을 필수로 안내하세요.
|
|
88
|
+
|
|
73
89
|
## 분석 태그 — fetchAnalyticsConfig
|
|
74
90
|
|
|
75
91
|
어드민에서 등록한 외부 분석 태그(GA4, Microsoft Clarity, Meta Pixel, 네이버)
|
|
@@ -8,8 +8,12 @@ import type { Metadata } from "next";
|
|
|
8
8
|
import Link from "next/link";
|
|
9
9
|
import { notFound } from "next/navigation";
|
|
10
10
|
import { collectionPageSchema } from "@roottale/cms-client/server";
|
|
11
|
+
import {
|
|
12
|
+
buildArchiveMetadata,
|
|
13
|
+
decideArchiveIndex,
|
|
14
|
+
} from "@roottale/cms-renderer-next/routes";
|
|
11
15
|
|
|
12
|
-
import { getPostsByCategory } from "@/lib/blog";
|
|
16
|
+
import { getCategoryPromotion, getPostsByCategory } from "@/lib/blog";
|
|
13
17
|
|
|
14
18
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
|
|
15
19
|
|
|
@@ -17,20 +21,45 @@ type Props = { params: Promise<{ slug: string }> };
|
|
|
17
21
|
|
|
18
22
|
export const revalidate = 1800;
|
|
19
23
|
|
|
24
|
+
// 색인 위생(seo.md "색인 위생") — 사이트맵 포함 여부와 robots 를 같은
|
|
25
|
+
// decideArchiveIndex 판정에서 파생합니다. 어드민에서 "검색에 이 분류 페이지
|
|
26
|
+
// 노출" 을 켠 분류만 색인되고, 나머지는 화면은 정상이되 noindex 입니다.
|
|
27
|
+
//
|
|
28
|
+
// 판정 입력(승격 상태·글 수)은 **사이트맵과 같은 소스·같은 한도**에서 얻어야
|
|
29
|
+
// 합니다. 여기서 갈리면 "사이트맵엔 있는데 페이지는 404" 가 생깁니다.
|
|
30
|
+
async function loadArchive(slug: string) {
|
|
31
|
+
const [posts, promotion] = await Promise.all([
|
|
32
|
+
getPostsByCategory(slug),
|
|
33
|
+
getCategoryPromotion(slug),
|
|
34
|
+
]);
|
|
35
|
+
return { posts, promotion, postCount: posts.length };
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
21
39
|
const { slug } = await params;
|
|
22
|
-
const posts = await
|
|
23
|
-
if (
|
|
24
|
-
return
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
const { posts, promotion, postCount } = await loadArchive(slug);
|
|
41
|
+
if (postCount === 0) return { title: "카테고리" };
|
|
42
|
+
return buildArchiveMetadata(
|
|
43
|
+
{
|
|
44
|
+
kind: "category",
|
|
45
|
+
promotion,
|
|
46
|
+
postCount,
|
|
47
|
+
title: `${posts[0]!.category} 글 모음`,
|
|
48
|
+
},
|
|
49
|
+
{ siteUrl: SITE_URL, path: `/blog/categories/${slug}` },
|
|
50
|
+
);
|
|
28
51
|
}
|
|
29
52
|
|
|
30
53
|
export default async function CategoryArchivePage({ params }: Props) {
|
|
31
54
|
const { slug } = await params;
|
|
32
|
-
const posts = await
|
|
33
|
-
|
|
55
|
+
const { posts, promotion, postCount } = await loadArchive(slug);
|
|
56
|
+
// 글 0건 모음 페이지는 만들지 않습니다(404) — 정책이 판정합니다.
|
|
57
|
+
if (
|
|
58
|
+
decideArchiveIndex({ kind: "category", promotion, postCount }).render ===
|
|
59
|
+
"notFound"
|
|
60
|
+
) {
|
|
61
|
+
notFound();
|
|
62
|
+
}
|
|
34
63
|
|
|
35
64
|
const categoryName = posts[0]!.category;
|
|
36
65
|
const url = `${SITE_URL}/blog/categories/${slug}`;
|
|
@@ -13,6 +13,7 @@ export function AttributionField() {
|
|
|
13
13
|
const [value, setValue] = useState("");
|
|
14
14
|
useEffect(() => {
|
|
15
15
|
const attribution = readAttribution();
|
|
16
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- 어트리뷰션은 브라우저 저장소에서만 읽을 수 있어 마운트 후에야 알 수 있다.
|
|
16
17
|
if (attribution) setValue(JSON.stringify(attribution));
|
|
17
18
|
}, []);
|
|
18
19
|
return <input type="hidden" name="attribution" value={value} />;
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// RootTale CMS 블로그 데이터 레이어 — 서버 전용.
|
|
2
2
|
// 사이트 UI에 맞는 메타 형태로 변환하는 wrapper. API 키는 env에서만 읽는다.
|
|
3
3
|
import {
|
|
4
|
-
|
|
4
|
+
fetchBlogSettings,
|
|
5
5
|
fetchPost,
|
|
6
6
|
type CmsPostContent,
|
|
7
7
|
} from "@roottale/cms-client/server";
|
|
8
|
+
import {
|
|
9
|
+
type ArchivePromotion,
|
|
10
|
+
fetchBlogArchivePosts,
|
|
11
|
+
resolveCategoryPromotion,
|
|
12
|
+
} from "@roottale/cms-renderer-next/routes";
|
|
8
13
|
|
|
9
14
|
function getApiKey(): string {
|
|
10
15
|
const key = process.env.ROOTTALE_API_KEY;
|
|
@@ -60,14 +65,15 @@ function toMeta(post: CmsPostContent): BlogPostMeta {
|
|
|
60
65
|
};
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
// `/blog` 계열 표면(목록·분류 모음)이 다루는 글 = 콘텐츠 유형(스트림)에 속하지
|
|
69
|
+
// 않은 글. 사이트맵도 **같은 함수**로 같은 글을 세므로 "사이트맵엔 주소가 있는데
|
|
70
|
+
// 페이지는 404" 가 생기지 않습니다(한도 상수도 함께 따라옵니다).
|
|
63
71
|
export async function getAllPosts(): Promise<BlogPostMeta[]> {
|
|
64
|
-
const
|
|
72
|
+
const posts = await fetchBlogArchivePosts({
|
|
65
73
|
apiKey: getApiKey(),
|
|
66
|
-
baseUrl,
|
|
67
|
-
type: "post",
|
|
68
|
-
limit: 100,
|
|
74
|
+
apiBase: baseUrl,
|
|
69
75
|
});
|
|
70
|
-
return
|
|
76
|
+
return posts.map(toMeta);
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
export async function getPost(
|
|
@@ -88,3 +94,25 @@ export async function getPostsByCategory(
|
|
|
88
94
|
const posts = await getAllPosts();
|
|
89
95
|
return posts.filter((p) => p.categorySlug === categorySlug);
|
|
90
96
|
}
|
|
97
|
+
|
|
98
|
+
// 색인 위생 — 어드민 "내 사이트 > 카테고리 > 검색에 이 분류 페이지 노출" 을 켠
|
|
99
|
+
// 분류인지. 켜지 않은 분류의 모음 페이지는 noindex + 사이트맵 제외가 됩니다
|
|
100
|
+
// (seo.md "색인 위생" 절).
|
|
101
|
+
//
|
|
102
|
+
// 반환은 boolean 이 아니라 3-state(`promoted` / `not-promoted` / `unknown`)입니다.
|
|
103
|
+
// 설정을 못 읽었을 때(구버전 API·조회 실패) `not-promoted` 로 바꿔 넘기면, 설정
|
|
104
|
+
// 조회가 한 번 실패했다는 이유만으로 이미 검색에 올라가 있던 주소에 noindex 가
|
|
105
|
+
// 붙습니다. `unknown` 을 그대로 넘기면 정책이 알아서 기존 상태를 보존합니다.
|
|
106
|
+
export async function getCategoryPromotion(
|
|
107
|
+
categorySlug: string,
|
|
108
|
+
): Promise<ArchivePromotion> {
|
|
109
|
+
try {
|
|
110
|
+
const settings = await fetchBlogSettings({ apiKey: getApiKey(), baseUrl });
|
|
111
|
+
return resolveCategoryPromotion(
|
|
112
|
+
settings.sitemap?.promotedCategorySlugs,
|
|
113
|
+
categorySlug,
|
|
114
|
+
);
|
|
115
|
+
} catch {
|
|
116
|
+
return "unknown";
|
|
117
|
+
}
|
|
118
|
+
}
|