@roottale/cms-renderer-next 0.47.0 → 0.51.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/blog-filter-BKCGM0qd.d.ts +29 -0
  3. package/dist/{chunk-6MFHA555.js → chunk-27RT6B3O.js} +1 -1
  4. package/dist/chunk-27RT6B3O.js.map +1 -0
  5. package/dist/chunk-2QU4JU46.js +3927 -0
  6. package/dist/chunk-2QU4JU46.js.map +1 -0
  7. package/dist/chunk-4RQ3SEDL.js +106 -0
  8. package/dist/chunk-4RQ3SEDL.js.map +1 -0
  9. package/dist/{chunk-OMXEU4LS.js → chunk-QYNEOFL7.js} +140 -136
  10. package/dist/chunk-QYNEOFL7.js.map +1 -0
  11. package/dist/{chunk-FUA5H6JE.js → chunk-U2IM6OCQ.js} +46 -2
  12. package/dist/chunk-U2IM6OCQ.js.map +1 -0
  13. package/dist/cms-public.css +137 -18
  14. package/dist/concerns-client.d.ts +1 -1
  15. package/dist/expertise-client.d.ts +1 -1
  16. package/dist/expertise-client.js +1 -1
  17. package/dist/hero-carousel-client.js +1 -1
  18. package/dist/routes.d.ts +377 -21
  19. package/dist/routes.js +273 -106
  20. package/dist/routes.js.map +1 -1
  21. package/dist/seo-asset-hosts.d.ts +36 -0
  22. package/dist/seo-asset-hosts.js +391 -0
  23. package/dist/seo-asset-hosts.js.map +1 -0
  24. package/dist/server.d.ts +34 -216
  25. package/dist/server.js +7 -7
  26. package/dist/{site-config-Dicv57oY.d.ts → site-config-DNpvlqA5.d.ts} +201 -201
  27. package/dist/spaces-client.d.ts +1 -1
  28. package/dist/team-expanding-stage-client.d.ts +1 -1
  29. package/dist/team-expanding-stage-client.js +1 -1
  30. package/dist/templates.d.ts +64 -25
  31. package/dist/templates.js +14 -7
  32. package/dist/templates.js.map +1 -1
  33. package/package.json +7 -2
  34. package/dist/chunk-6MFHA555.js.map +0 -1
  35. package/dist/chunk-FUA5H6JE.js.map +0 -1
  36. package/dist/chunk-FZK3EXYZ.js +0 -103
  37. package/dist/chunk-FZK3EXYZ.js.map +0 -1
  38. package/dist/chunk-OMXEU4LS.js.map +0 -1
  39. package/dist/chunk-ZHXYCABN.js +0 -3584
  40. package/dist/chunk-ZHXYCABN.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
- * `app/feed.xml/route.ts` 용 GET 핸들러. RSS 2.0.
56
- *
57
- * ```ts
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
- declare function createFeedRoute(config: RootTaleSiteConfig): () => Promise<Response>;
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 토글을 지원한다. 본 함수는 하위호환용으로
@@ -426,6 +443,20 @@ interface PostMetadataInput {
426
443
  * `alternates.languages` 를 계산한다. 없거나 자기 자신 1건뿐이면 languages 생략.
427
444
  */
428
445
  translations?: readonly CmsPostTranslationRef[];
446
+ /**
447
+ * avcd 구조(og article 확장) — 최근 수정 ISO 일시 → `openGraph.modifiedTime`.
448
+ * `CmsPostContent.updatedAt` 등을 그대로 넘기면 된다. 미지정 시 생략.
449
+ */
450
+ modified?: string | null;
451
+ /** avcd 구조 — 글 섹션/카테고리명 → `openGraph.section`. 미지정 시 생략. */
452
+ section?: string | null;
453
+ /** avcd 구조 — 글 태그명 목록 → `openGraph.tags`. 빈 배열/미지정 시 생략. */
454
+ tags?: readonly string[] | null;
455
+ /**
456
+ * avcd 구조 — 저자 프로필 URL 목록 → `openGraph.authors` (Next `Metadata`
457
+ * article 확장). 빈 배열/미지정 시 생략.
458
+ */
459
+ authors?: readonly string[] | null;
429
460
  }
430
461
  interface BuildPostMetadataOptions {
431
462
  /**
@@ -452,6 +483,12 @@ interface BuildPostMetadataOptions {
452
483
  * (opt-in — 명시 안 한 기존 소비자는 canonical-only 로 diff 0).
453
484
  */
454
485
  locales?: PostLocaleOptions;
486
+ /**
487
+ * avcd 구조 — RSS 피드 절대 경로 → `alternates.types["application/rss+xml"]`.
488
+ * 기본 `"/feed.xml"`. `siteUrl` 이 있으면 canonical 유무와 무관하게 항상 emit
489
+ * (RSS 발견성은 개별 글의 canonical 해석 성공 여부와 무관한 사이트 차원 신호).
490
+ */
491
+ feedPath?: string;
455
492
  }
456
493
  /**
457
494
  * ADR-0052 A안 (W4-6 PR C1) — hreflang 경로 계산 옵션. `buildPostMetadata` 와
@@ -467,29 +504,62 @@ interface PostLocaleOptions {
467
504
  */
468
505
  pathFor?: (locale: string, slug: string) => string;
469
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;
470
524
  /** Next `Metadata` 의 구조적 부분집합 — 소비자는 그대로 return 가능. */
471
525
  interface PostMetadata {
472
526
  title: string;
473
527
  description?: string;
474
528
  alternates?: {
475
- canonical: string;
529
+ /** `types` 만 emit 되는 경우(예: siteUrl 만 있고 path/collections 미해석)도 있어 optional. */
530
+ canonical?: string;
476
531
  languages?: Record<string, string>;
532
+ /** avcd 구조 — RSS 등 대체 표현. Next `Metadata.alternates.types` 그대로. */
533
+ types?: Record<string, string>;
477
534
  };
478
- robots?: {
479
- index: boolean;
480
- follow: boolean;
481
- };
535
+ robots?: RobotsMeta;
482
536
  openGraph: {
483
537
  type: "article";
484
538
  title: string;
485
539
  description?: string;
486
540
  url?: string;
487
541
  publishedTime?: string;
542
+ /** avcd 구조 — og article 확장. */
543
+ modifiedTime?: string;
544
+ section?: string;
545
+ tags?: string[];
546
+ authors?: string[];
488
547
  images?: {
489
548
  url: string;
490
549
  alt: string;
491
550
  }[];
492
551
  };
552
+ /**
553
+ * avcd 구조 — Twitter Card. og 와 동일 소스(seo override → 글 값)에서 title/
554
+ * description/images 를 채운다. `card`+`title` 은 항상 emit(og 이미지 유무와
555
+ * 무관), `images` 는 og 이미지가 있을 때만.
556
+ */
557
+ twitter?: {
558
+ card: "summary_large_image";
559
+ title: string;
560
+ description?: string;
561
+ images?: string[];
562
+ };
493
563
  /** ADR-0065 — `<meta name="rt:content-id">` 등 임의 head meta (조회수 식별자). */
494
564
  other?: Record<string, string>;
495
565
  }
@@ -502,6 +572,9 @@ interface PostMetadata {
502
572
  * → 없으면 생략. 모든 글이 자기 자신을 가리키는 canonical 을 갖는 게 권장이므로
503
573
  * `siteUrl` 과 함께 `path` 또는 `collections` 전달을 권장한다.
504
574
  * - `noindex`/`nofollow` 중 하나라도 켜지면 `robots` 출력.
575
+ * - avcd 구조 — `siteUrl` 이 있으면 `alternates.types["application/rss+xml"]`
576
+ * (기본 `/feed.xml`, `feedPath` 로 override)을 canonical 유무와 무관하게 emit.
577
+ * Twitter Card(`summary_large_image`)는 og 와 같은 소스로 항상 emit.
505
578
  *
506
579
  * ```ts
507
580
  * // 단일 블로그(레거시): 라우트가 곧 섹션이므로 path 를 직접 준다.
@@ -535,6 +608,289 @@ declare function buildPostMetadata(post: PostMetadataInput, options?: BuildPostM
535
608
  */
536
609
  declare function buildHreflangLanguages(translations: readonly CmsPostTranslationRef[] | undefined, siteUrl: string, locales: PostLocaleOptions): Record<string, string> | undefined;
537
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
+
538
894
  /**
539
895
  * `@roottale/cms-renderer-next/routes` — Next.js App Router 통합 라우트 팩토리.
540
896
  *
@@ -594,4 +950,4 @@ interface FleetInfoRouteConfig {
594
950
  */
595
951
  declare function createFleetInfoRoute(config?: FleetInfoRouteConfig): () => Promise<Response>;
596
952
 
597
- 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 };