@roottale/cms-renderer-next 0.48.0 → 0.51.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/dist/blog-filter-BKCGM0qd.d.ts +29 -0
- package/dist/{chunk-6MFHA555.js → chunk-27RT6B3O.js} +1 -1
- package/dist/chunk-27RT6B3O.js.map +1 -0
- package/dist/chunk-2QU4JU46.js +3927 -0
- package/dist/chunk-2QU4JU46.js.map +1 -0
- package/dist/chunk-4RQ3SEDL.js +106 -0
- package/dist/chunk-4RQ3SEDL.js.map +1 -0
- package/dist/{chunk-OMXEU4LS.js → chunk-QYNEOFL7.js} +140 -136
- package/dist/chunk-QYNEOFL7.js.map +1 -0
- package/dist/concerns-client.d.ts +1 -1
- package/dist/expertise-client.d.ts +1 -1
- package/dist/expertise-client.js +1 -1
- package/dist/hero-carousel-client.js +1 -1
- package/dist/routes.d.ts +334 -20
- package/dist/routes.js +249 -68
- package/dist/routes.js.map +1 -1
- package/dist/seo-asset-hosts.d.ts +36 -0
- package/dist/seo-asset-hosts.js +391 -0
- package/dist/seo-asset-hosts.js.map +1 -0
- package/dist/server.d.ts +23 -241
- package/dist/server.js +6 -6
- package/dist/{site-config-Dicv57oY.d.ts → site-config-BvUyDx_f.d.ts} +411 -411
- package/dist/spaces-client.d.ts +1 -1
- package/dist/team-expanding-stage-client.d.ts +1 -1
- package/dist/team-expanding-stage-client.js +1 -1
- package/dist/templates.d.ts +64 -25
- package/dist/templates.js +13 -6
- package/dist/templates.js.map +1 -1
- package/package.json +7 -2
- package/dist/chunk-6MFHA555.js.map +0 -1
- package/dist/chunk-FZK3EXYZ.js +0 -103
- package/dist/chunk-FZK3EXYZ.js.map +0 -1
- package/dist/chunk-LIBSVC4M.js +0 -3642
- package/dist/chunk-LIBSVC4M.js.map +0 -1
- package/dist/chunk-OMXEU4LS.js.map +0 -1
package/dist/routes.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CmsPostContent, RootTaleRedirect, CmsPostTranslationRef } from '@roottale/cms-client/server';
|
|
2
2
|
import { RouteCollection } from '@roottale/cms-core';
|
|
3
3
|
export { RouteCollection, resolvePostCollection, resolvePostUrl } from '@roottale/cms-core';
|
|
4
|
+
import { B as BlogCategoryWithCount } from './blog-filter-BKCGM0qd.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* `routes` 공통 — config 타입, collections resolve, fetch 헬퍼. feed/sitemap/llms/
|
|
@@ -49,23 +50,12 @@ declare function resolveCollections(input: CollectionsInput | undefined): Promis
|
|
|
49
50
|
declare function trimSlash(u: string): string;
|
|
50
51
|
declare function safeFetchPosts(config: RootTaleSiteConfig, limit: number): Promise<CmsPostContent[]>;
|
|
51
52
|
|
|
52
|
-
/** `routes` — RSS feed + sitemap 팩토리. collection 모드/레거시 양쪽. */
|
|
53
|
-
|
|
54
53
|
/**
|
|
55
|
-
* `
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* import { createFeedRoute } from "@roottale/cms-renderer-next/routes";
|
|
59
|
-
* export const dynamic = "force-dynamic";
|
|
60
|
-
* export const GET = createFeedRoute({
|
|
61
|
-
* apiKey: process.env.ROOTTALE_API_KEY!,
|
|
62
|
-
* siteUrl: "https://example.com",
|
|
63
|
-
* title: "Example 블로그",
|
|
64
|
-
* description: "...",
|
|
65
|
-
* });
|
|
66
|
-
* ```
|
|
54
|
+
* 사이트맵 entry 조립 — `createSitemap`·`createSitemapIndex` 가 공유하는 내부
|
|
55
|
+
* 빌더. 라우트 팩토리(`routes-feed-sitemap.ts`)와 분리해 "무엇을 내보낼지"(정책·
|
|
56
|
+
* 조립)와 "어떤 라우트로 내보낼지"(Next 계약)를 나눈다. 공개 표면은 `routes.ts` 배럴.
|
|
67
57
|
*/
|
|
68
|
-
|
|
58
|
+
|
|
69
59
|
/** Next `MetadataRoute.Sitemap[number]` 와 구조 호환(타입 결합 회피). */
|
|
70
60
|
interface SitemapEntry {
|
|
71
61
|
url: string;
|
|
@@ -89,11 +79,38 @@ interface SitemapIndexOptions {
|
|
|
89
79
|
locales?: string[];
|
|
90
80
|
/** admin 의 `authors` 토글을 덮어쓴다 (작가 사이트맵). */
|
|
91
81
|
authors?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* admin 의 "검색에 이 분류 페이지 노출" 집계를 덮어쓴다 — 색인 정책상 여기
|
|
84
|
+
* 없는 카테고리의 아카이브는 sitemap 에서 빠진다. `skipRemoteSettings` 와 함께
|
|
85
|
+
* 쓰면 네트워크 없이 정책을 켤 수 있다(빌드 타임 사이트맵).
|
|
86
|
+
*/
|
|
87
|
+
promotedCategorySlugs?: string[];
|
|
92
88
|
/** 작가 아카이브 base path. 기본 "/blog/author" (→ /blog/author/{slug}). */
|
|
93
89
|
authorBasePath?: string;
|
|
94
90
|
/** true 면 admin 설정 fetch 생략(opts/기본값만). 빌드 시 네트워크 회피용. */
|
|
95
91
|
skipRemoteSettings?: boolean;
|
|
96
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `routes` — RSS feed + sitemap 라우트 팩토리. collection 모드/레거시 양쪽.
|
|
96
|
+
* 사이트맵 entry 조립(정책·설정 해석)은 `routes-sitemap-entries.ts` 가 맡는다.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `app/feed.xml/route.ts` 용 GET 핸들러. RSS 2.0.
|
|
101
|
+
*
|
|
102
|
+
* ```ts
|
|
103
|
+
* import { createFeedRoute } from "@roottale/cms-renderer-next/routes";
|
|
104
|
+
* export const dynamic = "force-dynamic";
|
|
105
|
+
* export const GET = createFeedRoute({
|
|
106
|
+
* apiKey: process.env.ROOTTALE_API_KEY!,
|
|
107
|
+
* siteUrl: "https://example.com",
|
|
108
|
+
* title: "Example 블로그",
|
|
109
|
+
* description: "...",
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
declare function createFeedRoute(config: RootTaleSiteConfig): () => Promise<Response>;
|
|
97
114
|
/**
|
|
98
115
|
* @deprecated `createSitemapIndex` 를 쓰세요 — 단일 평면 사이트맵 대신 인덱스 분리
|
|
99
116
|
* (static/blog/categories) + 이미지 + admin 토글을 지원한다. 본 함수는 하위호환용으로
|
|
@@ -487,6 +504,23 @@ interface PostLocaleOptions {
|
|
|
487
504
|
*/
|
|
488
505
|
pathFor?: (locale: string, slug: string) => string;
|
|
489
506
|
}
|
|
507
|
+
/** robots 지시자 — Next `Metadata.robots` 의 구조적 부분집합. */
|
|
508
|
+
interface RobotsMeta {
|
|
509
|
+
index: boolean;
|
|
510
|
+
follow: boolean;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* noindex/nofollow 플래그 → `Metadata.robots`. **글과 아카이브가 공유하는 단
|
|
514
|
+
* 하나의 robots 표현** — 둘 중 한쪽만 형태가 바뀌면 색인 정책이 갈리므로
|
|
515
|
+
* `buildPostMetadata` 와 `buildArchiveMetadata` 가 이 함수만 쓴다.
|
|
516
|
+
*
|
|
517
|
+
* 둘 다 꺼져 있으면 `undefined` — robots 를 아예 emit 하지 않아 검색엔진 기본값
|
|
518
|
+
* (index, follow)을 그대로 둔다.
|
|
519
|
+
*/
|
|
520
|
+
declare function resolveRobotsMeta(flags: {
|
|
521
|
+
noindex?: boolean;
|
|
522
|
+
nofollow?: boolean;
|
|
523
|
+
}): RobotsMeta | undefined;
|
|
490
524
|
/** Next `Metadata` 의 구조적 부분집합 — 소비자는 그대로 return 가능. */
|
|
491
525
|
interface PostMetadata {
|
|
492
526
|
title: string;
|
|
@@ -498,10 +532,7 @@ interface PostMetadata {
|
|
|
498
532
|
/** avcd 구조 — RSS 등 대체 표현. Next `Metadata.alternates.types` 그대로. */
|
|
499
533
|
types?: Record<string, string>;
|
|
500
534
|
};
|
|
501
|
-
robots?:
|
|
502
|
-
index: boolean;
|
|
503
|
-
follow: boolean;
|
|
504
|
-
};
|
|
535
|
+
robots?: RobotsMeta;
|
|
505
536
|
openGraph: {
|
|
506
537
|
type: "article";
|
|
507
538
|
title: string;
|
|
@@ -577,6 +608,289 @@ declare function buildPostMetadata(post: PostMetadataInput, options?: BuildPostM
|
|
|
577
608
|
*/
|
|
578
609
|
declare function buildHreflangLanguages(translations: readonly CmsPostTranslationRef[] | undefined, siteUrl: string, locales: PostLocaleOptions): Record<string, string> | undefined;
|
|
579
610
|
|
|
611
|
+
/**
|
|
612
|
+
* 아카이브(모음 페이지) 색인 정책 — **단일 SSoT**.
|
|
613
|
+
*
|
|
614
|
+
* seo-geo-contract v3.1 §1 "색인 위생": robots 메타(noindex)와 sitemap 포함은
|
|
615
|
+
* **한 소스에서 파생**해야 한다. 둘이 갈리면 "noindex 인데 sitemap 에 제출된 URL"
|
|
616
|
+
* 이 생기고, 검색엔진은 이를 크롤 예산 낭비 + 품질 신호 하락으로 취급한다.
|
|
617
|
+
*
|
|
618
|
+
* 그래서 이 파일의 `decideArchiveIndex` 하나가
|
|
619
|
+
* - `routes-sitemap-entries.ts` 의 sitemap entry 필터(`inSitemap`)
|
|
620
|
+
* - 아카이브 페이지 `generateMetadata` 의 robots(`buildArchiveMetadata` → `index`)
|
|
621
|
+
* - 0건 아카이브의 404 처리(`render`)
|
|
622
|
+
* 를 **동시에** 결정한다. 소비처가 따로 판단하지 않는다.
|
|
623
|
+
*
|
|
624
|
+
* 정책 요지: 아카이브는 **기본 noindex**. 고유한 소개 글을 채워 "허브"로 승격한
|
|
625
|
+
* 분류·작가만 색인 대상이 된다(`promotion`).
|
|
626
|
+
*
|
|
627
|
+
* **판정 입력도 이 파일에서 만든다**(`resolveCategoryPromotion`·
|
|
628
|
+
* `resolveAuthorPromotion`). 판정 함수만 공유하고 입력 조립을 소비처가 따로
|
|
629
|
+
* 하면 "sitemap 은 모름을 통과로, 페이지는 모름을 미승격으로" 읽는 식으로
|
|
630
|
+
* 결론이 갈린다 — 실제로 그렇게 갈렸던 것이 이 3-state 도입의 계기다.
|
|
631
|
+
* (같은 이유로 **모집단**도 한 곳에서 정한다 — `archive-categories.ts`.)
|
|
632
|
+
*
|
|
633
|
+
* ## 불변식의 범위 — 정상 상태(steady state) 보장이다
|
|
634
|
+
*
|
|
635
|
+
* "noindex 인데 sitemap 포함" 이 없다는 계약은 **두 요청이 같은 설정을 봤을 때**
|
|
636
|
+
* 성립한다. sitemap 과 아카이브 페이지는 서로 다른 요청에서 각자 설정을 조회하고
|
|
637
|
+
* (sitemap 은 ISR 캐시, 페이지는 요청 시 렌더), 한쪽만 실패할 수 있다. 예를 들어
|
|
638
|
+
* 페이지 조회는 성공(`promotedCategorySlugs: []` → `not-promoted` → noindex)인데
|
|
639
|
+
* sitemap 조회는 실패(`undefined` → `unknown` → 포함)면 그 창 동안 두 결론이
|
|
640
|
+
* 어긋난다. 이건 코드로 없앨 수 없다 — 두 표면이 같은 시점의 설정을 본다고
|
|
641
|
+
* 보장할 방법이 없기 때문이다(공유 캐시를 둬도 만료 시점이 갈린다).
|
|
642
|
+
*
|
|
643
|
+
* **자가 치유 시간 = 사이트맵 revalidate 주기.** 다음 재생성 때 설정을 정상
|
|
644
|
+
* 조회하면 어긋남이 사라진다. Site Kit(`sites/starter/app/sitemap.ts`)은
|
|
645
|
+
* `export const revalidate = 60` 이므로 **상한 60초**다(아카이브 페이지도 60초).
|
|
646
|
+
* 고객 사이트가 값을 바꿨으면 그 값이 곧 상한이다.
|
|
647
|
+
*
|
|
648
|
+
* **왜 이 방향인가(sitemap 조회 실패 시 포함 유지)**: 반대로 fail-closed 하면
|
|
649
|
+
* 일시적 API 장애 한 번에 이미 색인된 URL 이 sitemap 에서 통째로 빠진다 —
|
|
650
|
+
* 재발견까지 걸리는 노출 손실이 "잠깐 noindex 인 URL 이 sitemap 에 남는" 비용보다
|
|
651
|
+
* 크다. 후자는 크롤러가 해당 URL 을 방문해 noindex 를 읽고 색인에서 빼는, 되돌릴
|
|
652
|
+
* 수 있는 낭비다. (작가 **entry** 쪽만 예외로 fail-closed 인데, 이유는
|
|
653
|
+
* `routes-sitemap-entries.ts` 의 `authorEntries` 주석 참조 — 라우트 자체가 없는
|
|
654
|
+
* 사이트에 존재하지 않는 URL 을 제출할 위험이 더 크기 때문이다.)
|
|
655
|
+
*
|
|
656
|
+
* ## 정책 대상에서 뺀 것 (스펙 예외)
|
|
657
|
+
*
|
|
658
|
+
* 분류 **목록** 허브(`/blog/categories`, collection 모드의
|
|
659
|
+
* `{basePath}/categories`)는 이 정책을 적용하지 않고 항상 sitemap 에 넣는다.
|
|
660
|
+
* 개별 아카이브가 아니라 "이 사이트에 어떤 분류가 있는지" 를 훑는 **내비게이션
|
|
661
|
+
* 허브**라, 중복 콘텐츠 위험(같은 글이 여러 모음에 중복 노출)이 없고 크롤러가
|
|
662
|
+
* 분류 구조를 발견하는 진입점 역할을 하기 때문이다. 정본 스펙(seo-geo-contract
|
|
663
|
+
* v3.1 §2)에는 이 예외가 아직 명문화돼 있지 않다 — 스펙 개정이 필요하다.
|
|
664
|
+
*/
|
|
665
|
+
|
|
666
|
+
/** 승격("허브로 올리기") 개념이 있는 아카이브 종류. */
|
|
667
|
+
type PromotableArchiveKind = "category" | "author";
|
|
668
|
+
/** 종류만으로 항상 noindex 인 아카이브 — 승격 개념이 없다. */
|
|
669
|
+
type AlwaysNoindexArchiveKind = "tag" | "date" | "search" | "paginated";
|
|
670
|
+
/** 아카이브 종류. `paginated` 는 종류 불명의 2페이지 이상 목록. */
|
|
671
|
+
type ArchiveKind = PromotableArchiveKind | AlwaysNoindexArchiveKind;
|
|
672
|
+
/**
|
|
673
|
+
* 허브 승격 상태 — **boolean 이 아니라 3-state 다**.
|
|
674
|
+
*
|
|
675
|
+
* - `promoted` — 어드민에서 노출을 켰다(확정).
|
|
676
|
+
* - `not-promoted` — 켜지 않았다(확정). 아카이브 기본값.
|
|
677
|
+
* - `unknown` — 승격 설정을 **읽지 못했다**. 구버전 API 응답이라 필드가 없거나,
|
|
678
|
+
* 설정 fetch 가 실패했거나, 빌드 타임 사이트맵이 설정 조회를 생략한 경우.
|
|
679
|
+
*
|
|
680
|
+
* `unknown` 을 `not-promoted` 로 뭉개면 "설정을 못 읽었다" 는 이유만으로 이미
|
|
681
|
+
* 색인된 URL 에 noindex 를 붙이고 sitemap 에서 지우게 된다 — 근거 없는 노출
|
|
682
|
+
* 손실이다. 그래서 `unknown` 은 **정책 미적용 = 기존 동작 보존**(색인 + sitemap
|
|
683
|
+
* 포함)으로 fail-soft 한다. 이 결정을 소비처가 아니라 정책 함수 안에 두는 이유는
|
|
684
|
+
* 위 모듈 주석 참조.
|
|
685
|
+
*/
|
|
686
|
+
type ArchivePromotion = "promoted" | "not-promoted" | "unknown";
|
|
687
|
+
/**
|
|
688
|
+
* 아카이브 정책이 `postCount` 를 세는 **폴백 모집단 한도**. sitemap 과 아카이브
|
|
689
|
+
* 페이지가 이 상수를 함께 import 해 같은 크기의 최근 글 배치에서 개수를 파생한다.
|
|
690
|
+
* 소비처마다 N 이 다르면 같은 카테고리에 대해 sitemap 은 "글 있음", 페이지는
|
|
691
|
+
* "0건 → 404" 라는 상충이 생긴다(정확히 이 상수를 만든 이유다).
|
|
692
|
+
*
|
|
693
|
+
* **이제는 폴백 경로에서만 쓰인다**: 카테고리 `postCount` 의 1순위 소스는 서버측
|
|
694
|
+
* 집계 API(`GET /v1/cms/public/categories`)이고, 그쪽은 발행 글 수와 무관하게
|
|
695
|
+
* 모든 분류의 정확한 개수를 준다. 이 배치 파생은 그 API 를 못 받았을 때
|
|
696
|
+
* (구버전 API·일시 장애) 기존 동작을 보존하는 fail-soft 경로다 —
|
|
697
|
+
* `archive-categories.ts` 참조.
|
|
698
|
+
*
|
|
699
|
+
* **폴백 경로의 알려진 한계(그대로 유지)**: 발행 글이 이 한도를 넘는 사이트에서는
|
|
700
|
+
* 한도 밖에만 글이 있는 카테고리가 sitemap·페이지 **양쪽 모두에서** 누락된다
|
|
701
|
+
* (페이지는 404). 양쪽이 같은 방식으로 틀리므로 색인 위생 계약(noindex ↔ sitemap
|
|
702
|
+
* 불일치)은 깨지지 않는다.
|
|
703
|
+
*
|
|
704
|
+
* collection 모드 스트림 아카이브(`collectCollectionCategories`)는 아직 이 배치
|
|
705
|
+
* 파생만 쓴다 — 집계 API 는 `collection_key` 스코프를 이미 지원하므로 배선만
|
|
706
|
+
* 남았다(후속).
|
|
707
|
+
*/
|
|
708
|
+
declare const ARCHIVE_POST_COUNT_LIMIT = 200;
|
|
709
|
+
/** 개수·페이지 입력 — 종류와 무관한 공통 부분. */
|
|
710
|
+
interface ArchiveScopeInput {
|
|
711
|
+
/**
|
|
712
|
+
* 이 아카이브에 실제로 걸린 발행 글 수. 0 = 빈 아카이브.
|
|
713
|
+
* 유한한 0 이상 정수여야 한다(아래 "입력 경계" 참조).
|
|
714
|
+
*/
|
|
715
|
+
postCount: number;
|
|
716
|
+
/** 1-base 페이지 번호. 미지정 = 1페이지. 유한한 1 이상 정수여야 한다. */
|
|
717
|
+
page?: number;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* `decideArchiveIndex` 입력. 종류에 따라 갈리는 판별 유니온이라,
|
|
721
|
+
* **승격 상태는 의미가 있는 종류에서만 요구되고 나머지에서는 아예 받지 않는다** —
|
|
722
|
+
* 태그 아카이브에 `promotion` 을 넘겨놓고 반영되길 기대하는 오해를 타입이 막는다.
|
|
723
|
+
*/
|
|
724
|
+
type ArchiveIndexInput = (ArchiveScopeInput & {
|
|
725
|
+
kind: PromotableArchiveKind;
|
|
726
|
+
promotion: ArchivePromotion;
|
|
727
|
+
}) | (ArchiveScopeInput & {
|
|
728
|
+
kind: AlwaysNoindexArchiveKind;
|
|
729
|
+
});
|
|
730
|
+
interface ArchiveIndexDecision {
|
|
731
|
+
/** `notFound` 면 페이지가 404 를 반환해야 한다(빈 아카이브 = 색인 쓰레기). */
|
|
732
|
+
render: "ok" | "notFound";
|
|
733
|
+
/** robots 색인 허용 여부. `buildArchiveMetadata` 가 이 값으로 robots 를 만든다. */
|
|
734
|
+
index: boolean;
|
|
735
|
+
/** sitemap 에 이 URL 을 넣을지. `index` 가 false 면 반드시 false. */
|
|
736
|
+
inSitemap: boolean;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* 카테고리 승격 상태. 소스는 공개 projection
|
|
740
|
+
* `blog-settings.sitemap.promotedCategorySlugs`(어드민 카테고리 편집의
|
|
741
|
+
* "검색에 이 분류 페이지 노출" 토글 집계).
|
|
742
|
+
*
|
|
743
|
+
* 목록 자체가 `undefined` 면 `unknown` — 빈 배열 `[]`("승격 0건" 확정)과 의미가
|
|
744
|
+
* 다르다. cms-client `fetchBlogSettings` 가 구버전 응답에서 필드를 **생략**하고,
|
|
745
|
+
* 설정 fetch 실패 경로도 목록을 만들지 않으므로 이 구분이 그대로 전달된다.
|
|
746
|
+
*/
|
|
747
|
+
declare function resolveCategoryPromotion(promotedSlugs: readonly string[] | undefined, slug: string): ArchivePromotion;
|
|
748
|
+
/**
|
|
749
|
+
* 작가 승격 상태. 카테고리와 달리 작가는 개별 승격 UI 가 없고 **사이트 단위 토글**
|
|
750
|
+
* (`blog-settings.sitemap.authors`, "작가별 글 모음도 검색엔진에 알리기")이 곧
|
|
751
|
+
* 승격 신호다. 토글 값을 못 읽었으면 `undefined` 를 넘겨 `unknown` 을 받는다.
|
|
752
|
+
*/
|
|
753
|
+
declare function resolveAuthorPromotion(authorsEnabled: boolean | undefined): ArchivePromotion;
|
|
754
|
+
/**
|
|
755
|
+
* 아카이브 URL 하나의 색인·sitemap·렌더 판정. **판정 순서가 곧 정책이다**:
|
|
756
|
+
*
|
|
757
|
+
* 0. **입력 경계** — `postCount` 가 유한한 0 이상 정수가 아니거나 `page` 가 유한한
|
|
758
|
+
* 1 이상 정수가 아니면 noindex + sitemap 제외로 중립 처리한다. 개수를 못 세는
|
|
759
|
+
* 상태에서 "좋은 모음 페이지" 라고 주장할 근거가 없고, 그렇다고 404 로 화면을
|
|
760
|
+
* 없애는 것은 과한 파괴라서 **화면은 살리고 색인만 보류**한다.
|
|
761
|
+
* 1. **0건** (검색 제외) → 404. 빈 모음 페이지는 애초에 존재하면 안 된다.
|
|
762
|
+
* 검색 결과는 0건이어도 정상 화면이라 404 로 만들지 않는다.
|
|
763
|
+
* 2. **2페이지 이상** 이거나 **검색·날짜·태그** → noindex + sitemap 제외.
|
|
764
|
+
* (승격 여부와 무관 — 승격된 카테고리의 2페이지도 noindex 다.)
|
|
765
|
+
* 3. **카테고리·작가** → 승격 상태 3-state 로 결정. `promoted` 면 색인 + sitemap,
|
|
766
|
+
* `not-promoted` 면 noindex + 제외, `unknown` 이면 정책 미적용(기존 동작 보존).
|
|
767
|
+
*/
|
|
768
|
+
declare function decideArchiveIndex(input: ArchiveIndexInput): ArchiveIndexDecision;
|
|
769
|
+
interface ArchiveMetadataFields {
|
|
770
|
+
/** 아카이브 화면 제목 (예: "세무 | 블로그"). */
|
|
771
|
+
title: string;
|
|
772
|
+
/** 메타 설명 — 분류 소개 글 등. 비면 생략. */
|
|
773
|
+
description?: string | null;
|
|
774
|
+
/** 링크 추적 제외. 색인과 별개 축이라 정책이 건드리지 않는다. */
|
|
775
|
+
nofollow?: boolean;
|
|
776
|
+
}
|
|
777
|
+
type ArchiveMetadataInput = ArchiveIndexInput & ArchiveMetadataFields;
|
|
778
|
+
interface BuildArchiveMetadataOptions {
|
|
779
|
+
/** self-canonical origin (예: `https://example.com`). `path` 와 함께. */
|
|
780
|
+
siteUrl?: string;
|
|
781
|
+
/** self-canonical 경로 (예: `/blog/categories/tax`). `siteUrl` 과 함께. */
|
|
782
|
+
path?: string;
|
|
783
|
+
}
|
|
784
|
+
/** Next `Metadata` 의 구조적 부분집합 — 아카이브 `generateMetadata` 가 그대로 return. */
|
|
785
|
+
interface ArchiveMetadata {
|
|
786
|
+
title: string;
|
|
787
|
+
description?: string;
|
|
788
|
+
alternates?: {
|
|
789
|
+
canonical: string;
|
|
790
|
+
};
|
|
791
|
+
robots?: RobotsMeta;
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* 아카이브 페이지 metadata. **robots 는 `decideArchiveIndex` 의 `index` 에서만**
|
|
795
|
+
* 파생된다 — sitemap 필터와 같은 판정이라 "noindex 인데 sitemap 에 있음" 이
|
|
796
|
+
* 구조적으로 불가능하다.
|
|
797
|
+
*
|
|
798
|
+
* `render === "notFound"` 판정(0건 아카이브)은 metadata 로 표현할 수 없으므로
|
|
799
|
+
* 페이지가 `decideArchiveIndex` 를 직접 호출해 `notFound()` 를 부른다:
|
|
800
|
+
*
|
|
801
|
+
* ```ts
|
|
802
|
+
* const promotion = resolveCategoryPromotion(settings.sitemap?.promotedCategorySlugs, slug);
|
|
803
|
+
* const decision = decideArchiveIndex({ kind: "category", promotion, postCount });
|
|
804
|
+
* if (decision.render === "notFound") notFound();
|
|
805
|
+
* // generateMetadata 에서:
|
|
806
|
+
* return buildArchiveMetadata(
|
|
807
|
+
* { kind: "category", promotion, postCount, title: `${name} | 블로그` },
|
|
808
|
+
* { siteUrl, path: `/blog/categories/${slug}` },
|
|
809
|
+
* );
|
|
810
|
+
* ```
|
|
811
|
+
*/
|
|
812
|
+
declare function buildArchiveMetadata(input: ArchiveMetadataInput, options?: BuildArchiveMetadataOptions): ArchiveMetadata;
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* 아카이브 카테고리 **모집단** — "이 아카이브 라우트가 실제로 다루는 글 집합" 을
|
|
816
|
+
* 정하는 단일 지점. 사이트맵과 아카이브 페이지가 **같은 함수**를 호출한다.
|
|
817
|
+
*
|
|
818
|
+
* 판정 함수(`decideArchiveIndex`)와 승격 상태 조립(`resolveCategoryPromotion`)을
|
|
819
|
+
* 공유해도 **세는 글 집합이 다르면** 결론이 갈린다. 실제로 갈렸다:
|
|
820
|
+
*
|
|
821
|
+
* - 사이트맵은 발행 글 **전체**에서 카테고리를 셌고,
|
|
822
|
+
* - `/blog` 계열 아카이브 페이지는 스트림(collection) 소속 글을 뺀 **"미소속 글"**
|
|
823
|
+
* 만 다룬다(`blog-list-collection-scope.md` §2① — "/blog 계열 표면은 전부
|
|
824
|
+
* 미소속 글 = 블로그로 통일", `RootTaleBlogList` 의 `excludeCollections`).
|
|
825
|
+
*
|
|
826
|
+
* 그래서 스트림 글에만 달린 카테고리(예: `symptoms` 스트림 글 1건뿐인 `symptom`)가
|
|
827
|
+
* **사이트맵엔 URL 이 있는데 페이지는 404** 인 상충을 만들었다.
|
|
828
|
+
*
|
|
829
|
+
* ## 어느 쪽으로 맞췄나 — 사이트맵을 페이지에 맞춰 **좁혔다**
|
|
830
|
+
*
|
|
831
|
+
* 반대(페이지를 넓히기)는 카테고리는 유효하다고 인정하면서 목록은
|
|
832
|
+
* `excludeCollections` 라 **비어 있는 화면**이 되거나, 그 prop 을 떼면 `/blog` 에
|
|
833
|
+
* 스트림 글이 새어 들어온다 — §2① 을 되돌리는 셈이라 택하지 않았다. 페이지가
|
|
834
|
+
* 실제로 서빙하는 집합이 이 라우트의 진실이고, 사이트맵은 거기에 따른다.
|
|
835
|
+
*
|
|
836
|
+
* ## "미소속" 판정은 서버가 한다
|
|
837
|
+
*
|
|
838
|
+
* `exclude_collections=true`(공개 posts API)는 **사이트가 선언한
|
|
839
|
+
* `content_collections`** 기준으로 미소속 글을 고른다 — 판정 정의가 DB 한 곳에
|
|
840
|
+
* 있다. 사이트맵 팩토리는 라우팅용 `collections` 를 모른 채 도는 경우가 많은데
|
|
841
|
+
* (레거시 모드 = `config.collections` 미전달), 이 서버 필터는 그 지식 없이도
|
|
842
|
+
* 페이지와 같은 집합을 준다. 클라이언트에서 각자 collections 목록을 조립해 거르면
|
|
843
|
+
* "어느 목록을 봤느냐" 에 따라 또 갈린다 — 그게 이 결함의 원인이었다.
|
|
844
|
+
*
|
|
845
|
+
* collection 모드 스트림 아카이브(`{basePath}/categories/{slug}`)의 모집단은
|
|
846
|
+
* 대칭적으로 `collectCollectionCategories`(포지티브 스코프: 그 스트림에 귀속된 글
|
|
847
|
+
* ∩ 스트림의 주제 allowlist) 하나다 — 사이트맵과 스트림 아카이브 페이지가 역시
|
|
848
|
+
* 같은 함수를 쓴다.
|
|
849
|
+
*/
|
|
850
|
+
|
|
851
|
+
/** 모집단 조회에 필요한 최소 설정 — `RootTaleSiteConfig` 가 구조적으로 만족한다. */
|
|
852
|
+
interface ArchiveCategoriesConfig {
|
|
853
|
+
/** `rtlk_cust_*` — 블로그 조회와 같은 키. 서버에서만. */
|
|
854
|
+
apiKey: string;
|
|
855
|
+
/** API base 오버라이드. 기본 `https://api.roottale.com`. */
|
|
856
|
+
apiBase?: string;
|
|
857
|
+
}
|
|
858
|
+
interface ArchiveCategoriesOptions {
|
|
859
|
+
/** Next fetch 재검증 주기(초). 미지정 = cms-client 기본. */
|
|
860
|
+
revalidate?: number;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* 레거시 `/blog` 계열 아카이브의 **모집단** — 어느 스트림에도 안 속한 발행 글.
|
|
864
|
+
*
|
|
865
|
+
* 한도는 `ARCHIVE_POST_COUNT_LIMIT`. 소비처마다 한도가 갈리면 같은 카테고리를 두고
|
|
866
|
+
* 사이트맵은 "글 있음", 페이지는 "0건 → 404" 가 된다(그래서 상수를 공유한다).
|
|
867
|
+
* 실패는 **throw** 한다 — 사이트맵은 always-200 이라 호출부에서 삼키고, 페이지는
|
|
868
|
+
* API 장애를 404 로 둔갑시키면 안 되므로 그대로 올린다(에러 정책이 서로 다르므로
|
|
869
|
+
* 여기서 임의로 fail-soft 하지 않는다).
|
|
870
|
+
*/
|
|
871
|
+
declare function fetchBlogArchivePosts(config: ArchiveCategoriesConfig, options?: ArchiveCategoriesOptions): Promise<CmsPostContent[]>;
|
|
872
|
+
/**
|
|
873
|
+
* 레거시 `/blog` 계열 아카이브 라우트가 인정하는 카테고리 + 글 수.
|
|
874
|
+
*
|
|
875
|
+
* 사이트맵의 `/blog/categories/{slug}` 필터와 아카이브 페이지의 slug 유효성·
|
|
876
|
+
* `postCount` 가 **이 함수 하나**에서 나온다.
|
|
877
|
+
*
|
|
878
|
+
* ## 우선순위 — 서버 집계 → (실패 시) 최근 글 배치 파생
|
|
879
|
+
*
|
|
880
|
+
* 1순위는 서버측 집계 API 다. 발행 글 수와 무관하게 **모든** 분류의 정확한 글 수를
|
|
881
|
+
* 주므로, 최근 `ARCHIVE_POST_COUNT_LIMIT` 건 밖에만 글이 있는 분류가 페이지(404)·
|
|
882
|
+
* 사이트맵에서 함께 사라지던 한계가 없어진다.
|
|
883
|
+
*
|
|
884
|
+
* 서버 집계를 못 받으면(구버전 API·일시 장애) **옛 배치 파생으로 fail-soft** 한다 —
|
|
885
|
+
* 새 API 가 없다는 이유로 아카이브를 통째로 죽이지 않는다. 이때의 동작은 이 변경
|
|
886
|
+
* 이전과 정확히 같다(한도 안 분류만 인정). 배치 조회까지 실패하면 그때는 throw 가
|
|
887
|
+
* 그대로 올라간다(`fetchBlogArchivePosts` 주석 — API 장애를 404 로 둔갑시키지 않음).
|
|
888
|
+
*
|
|
889
|
+
* 두 경로 모두 이 함수 **하나**를 통과하므로, 사이트맵과 아카이브 페이지는 어느
|
|
890
|
+
* 경로가 쓰이든 같은 값을 본다(그 계약이 이 모듈의 존재 이유다).
|
|
891
|
+
*/
|
|
892
|
+
declare function fetchBlogArchiveCategories(config: ArchiveCategoriesConfig, options?: ArchiveCategoriesOptions): Promise<BlogCategoryWithCount[]>;
|
|
893
|
+
|
|
580
894
|
/**
|
|
581
895
|
* `@roottale/cms-renderer-next/routes` — Next.js App Router 통합 라우트 팩토리.
|
|
582
896
|
*
|
|
@@ -636,4 +950,4 @@ interface FleetInfoRouteConfig {
|
|
|
636
950
|
*/
|
|
637
951
|
declare function createFleetInfoRoute(config?: FleetInfoRouteConfig): () => Promise<Response>;
|
|
638
952
|
|
|
639
|
-
export { type BuildPostMetadataOptions, type CollectionsInput, type FleetInfoRouteConfig, type ImageResponseLike, type LlmsTxtLink, type LlmsTxtRouteConfig, type LlmsTxtSection, OG_IMAGE_CONTENT_TYPE, OG_IMAGE_SIZE, type OgImageRouteConfig, type PostLocaleOptions, type PostMetadata, type PostMetadataInput, type PostSeoOverrides, type RedirectMiddlewareConfig, type RevalidateRouteConfig, type RootTaleSiteConfig, type SitemapEntry, type SitemapIndexOptions, type SitemapSectionId, buildHreflangLanguages, buildPostMetadata, createFeedRoute, createFleetInfoRoute, createLlmsTxtRoute, createPostOgImage, createRedirectMiddleware, createRevalidateRoute, createSitemap, createSitemapIndex, matchRedirect, normalizeRedirectPath, postRedirectPath, resolveCollections, resolveCollectionsResult, safeFetchPosts, trimSlash };
|
|
953
|
+
export { ARCHIVE_POST_COUNT_LIMIT, type AlwaysNoindexArchiveKind, type ArchiveCategoriesConfig, type ArchiveCategoriesOptions, type ArchiveIndexDecision, type ArchiveIndexInput, type ArchiveKind, type ArchiveMetadata, type ArchiveMetadataFields, type ArchiveMetadataInput, type ArchivePromotion, type BuildArchiveMetadataOptions, type BuildPostMetadataOptions, type CollectionsInput, type FleetInfoRouteConfig, type ImageResponseLike, type LlmsTxtLink, type LlmsTxtRouteConfig, type LlmsTxtSection, OG_IMAGE_CONTENT_TYPE, OG_IMAGE_SIZE, type OgImageRouteConfig, type PostLocaleOptions, type PostMetadata, type PostMetadataInput, type PostSeoOverrides, type PromotableArchiveKind, type RedirectMiddlewareConfig, type RevalidateRouteConfig, type RobotsMeta, type RootTaleSiteConfig, type SitemapEntry, type SitemapIndexOptions, type SitemapSectionId, buildArchiveMetadata, buildHreflangLanguages, buildPostMetadata, createFeedRoute, createFleetInfoRoute, createLlmsTxtRoute, createPostOgImage, createRedirectMiddleware, createRevalidateRoute, createSitemap, createSitemapIndex, decideArchiveIndex, fetchBlogArchiveCategories, fetchBlogArchivePosts, matchRedirect, normalizeRedirectPath, postRedirectPath, resolveAuthorPromotion, resolveCategoryPromotion, resolveCollections, resolveCollectionsResult, resolveRobotsMeta, safeFetchPosts, trimSlash };
|