@roottale/cms-client 0.51.1 → 0.52.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 CHANGED
@@ -1,5 +1,57 @@
1
1
  # @roottale/cms-client
2
2
 
3
+ ## 0.52.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 01f5534: 어드민에서 **설정**을 고치면 사이트에 즉시 반영된다 — 지금까지 글·디자인만 즉시였고 사업장 정보(전화·주소)·상단 메뉴·블로그 표시 설정·콘텐츠 유형은 최대 30분 걸렸다.
8
+
9
+ 원인은 두 개였다. ① 그 설정들을 읽는 조회 함수에 **캐시 이름표를 붙일 방법이 없었다**. ② 그래서 `revalidatePath` 로는 그 조회의 응답 캐시(Next.js Data Cache)를 지울 수 없었다 — 경로 캐시만 지워지고 옛 전화번호가 그대로 남았다.
10
+
11
+ **`@roottale/cms-client`**
12
+ - `fetchBusinessProfile` · `fetchMenu` · `fetchMenus` · `fetchBlogSettings` · `fetchCollections` 가 `tags?: string[]` 를 받는다(이미 있던 `fetchTheme` 와 같은 방식).
13
+ - 이름표 상수를 내보낸다: `BUSINESS_CACHE_TAG`("rt-business") · `MENUS_CACHE_TAG`("rt-menus") · `BLOG_SETTINGS_CACHE_TAG`("rt-blog-settings") · `COLLECTIONS_CACHE_TAG`("rt-collections"). 다섯 개(테마 포함)를 한 벌로 묶은 `SETTINGS_CACHE_TAGS` 배열도 함께.
14
+ - **`tags` 를 안 넘기면 동작이 예전과 한 글자도 다르지 않다** — `next` 키 자체를 만들지 않는다. 기존 코드는 그대로 두어도 된다.
15
+
16
+ **`@roottale/cms-renderer-next`** — `createRevalidateRoute` 의 설정 저장 신호(`theme.updated`) 처리
17
+ - 테마 이름표만이 아니라 위 다섯 개를 **함께** 지운다. 무엇이 바뀌었는지 웹훅 본문에 없기 때문이고, 쓰지 않는 이름표를 지우는 것은 아무 일도 일어나지 않으므로 해가 없다.
18
+ - `themeTag` 를 커스텀 이름으로 덮어썼다면 그 이름을 쓴다(기본 `rt-theme` 는 건드리지 않는다).
19
+ - **본문 `paths` 도 함께 갱신한다**(전에는 무시했다). 콘텐츠 유형 저장이 `/sitemap.xml`·`/feed.xml`·`/llms.txt` 를 보내는데, 이건 route handler 라서 레이아웃 무효화에 딸려 온다고 가정할 수 없다. 디자인 저장처럼 `paths` 가 비어 있으면 아무 일도 일어나지 않는다.
20
+ - **하나가 실패해도 나머지를 전부 시도한다.** 이름표 하나에서 멈추면 남은 이름표와 레이아웃 무효화까지 건너뛰기 때문이다. 하나라도 실패하면 성공으로 위장하지 않고 **500** 과 `failed[]`(무엇이·왜)를 돌려줘 플랫폼이 재시도하게 한다.
21
+ - `revalidateTag` 를 주입하지 않은 사이트의 동작은 **그대로**다(이름표 생략 + 레이아웃 재검증, 응답 200).
22
+ - 응답 본문이 늘었다: `revalidated` 에 `requestedTags: string[]`(콜백에 **넘긴** 이름표 — 실제로 지워졌는지는 Next 가 알려주지 않아 "요청한" 목록이다)와 `paths: string[]` 가 추가됐다. 기존 `tag` 필드는 테마 이름표를 담은 채 그대로 있다 — 로그를 파싱하던 쪽이 깨지지 않는다.
23
+
24
+ **사이트에서 할 일** — 두 군데를 함께 손봐야 즉시 반영이 된다. 한쪽만 하면 아무 오류 없이 "가끔 늦게 반영"으로만 보인다.
25
+
26
+ ```ts
27
+ // 1) app/api/revalidate/route.ts — 이름표를 지울 수 있게 주입
28
+ import { revalidatePath, revalidateTag } from "next/cache";
29
+ import { THEME_CACHE_TAG } from "@roottale/cms-client/server";
30
+ import { createRevalidateRoute } from "@roottale/cms-renderer-next/routes";
31
+
32
+ export const POST = createRevalidateRoute({
33
+ apiKey: process.env.ROOTTALE_API_KEY!,
34
+ // 2번째 인자(type)를 반드시 그대로 넘긴다 — `(path) => …` 로 감싸면
35
+ // 레이아웃 전역 무효화가 페이지 1개 무효화로 줄어든다.
36
+ revalidate: (path, type) => revalidatePath(path, type),
37
+ // { expire: 0 } = 즉시 만료. 다른 프로파일("max" 등)은 expire 가 0 이 아니라
38
+ // Next 가 stale-while-revalidate 로 취급해 다음 요청이 여전히 옛 값을 받는다
39
+ // ("max" 의 expire 는 1년). updateTag 는 서버 액션 전용이라 여기서 throw 한다.
40
+ revalidateTag: (tag: string) => revalidateTag(tag, { expire: 0 }),
41
+ themeTag: THEME_CACHE_TAG,
42
+ });
43
+
44
+ // 2) 설정 조회마다 이름표를 붙인다
45
+ const business = await fetchBusinessProfile({
46
+ apiKey,
47
+ tags: [BUSINESS_CACHE_TAG],
48
+ });
49
+ ```
50
+
51
+ 확인 방법: 웹훅 응답의 `revalidated.requestedTags` 가 비어 있으면 1번 주입이 빠진 것이다.
52
+
53
+ > **이벤트 변경 안내** — 어드민의 사업장 정보·메뉴·블로그 표시 설정·콘텐츠 유형 저장이 이제 `post.updated` 대신 `theme.updated` 를 보낸다(디자인·상단 메뉴·공지 배너·상담바는 원래 그랬다). `createRevalidateRoute` 를 쓰면 신경 쓸 것이 없다. 이벤트 이름으로 직접 분기하는 수신기를 손으로 짰다면 `theme.updated` 분기를 추가하라.
54
+
3
55
  ## 0.51.1
4
56
 
5
57
  ### Patch Changes
package/dist/server.d.ts CHANGED
@@ -465,6 +465,14 @@ interface RootTaleBlogSettings {
465
465
  */
466
466
  sitemap?: RootTaleSitemapSettings;
467
467
  }
468
+ /**
469
+ * 블로그 표시 설정 fetch 캐시 tag 의 정본 이름 (ADR-0096 Phase C).
470
+ * `fetchBlogSettings` 호출에 `tags: [BLOG_SETTINGS_CACHE_TAG]` 로 부착하고,
471
+ * revalidate route 가 `theme.updated` 수신 시
472
+ * `revalidateTag(BLOG_SETTINGS_CACHE_TAG)` 로 무효화한다. 양쪽이 이 상수를
473
+ * 공유해 drift 를 방지한다.
474
+ */
475
+ declare const BLOG_SETTINGS_CACHE_TAG = "rt-blog-settings";
468
476
  interface FetchBlogSettingsOptions {
469
477
  apiKey: string;
470
478
  baseUrl?: string;
@@ -472,6 +480,11 @@ interface FetchBlogSettingsOptions {
472
480
  signal?: AbortSignal;
473
481
  /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */
474
482
  revalidate?: number;
483
+ /**
484
+ * on-demand 무효화용 Next fetch cache tags(opt-in, ADR-0096 Phase C). 보통
485
+ * `[BLOG_SETTINGS_CACHE_TAG]`. 미지정 시 현행 동작 유지.
486
+ */
487
+ tags?: string[];
475
488
  }
476
489
  /**
477
490
  * tenant 의 블로그 표시 기본값을 가져온다. 네트워크/서버 에러는 throw —
@@ -498,6 +511,14 @@ interface CmsRouteCollection {
498
511
  /** 글별 상세 라우트 존재 여부 (기본 true — 하위 호환). false = 목록 전용 유형. */
499
512
  detail: boolean;
500
513
  }
514
+ /**
515
+ * 콘텐츠 유형(collections) fetch 캐시 tag 의 정본 이름 (ADR-0096 Phase C).
516
+ * `fetchCollections` 호출에 `tags: [COLLECTIONS_CACHE_TAG]` 로 부착하고,
517
+ * revalidate route 가 `theme.updated` 수신 시
518
+ * `revalidateTag(COLLECTIONS_CACHE_TAG)` 로 무효화한다. 양쪽이 이 상수를 공유해
519
+ * drift 를 방지한다.
520
+ */
521
+ declare const COLLECTIONS_CACHE_TAG = "rt-collections";
501
522
  interface FetchCollectionsOptions {
502
523
  apiKey: string;
503
524
  baseUrl?: string;
@@ -505,6 +526,11 @@ interface FetchCollectionsOptions {
505
526
  signal?: AbortSignal;
506
527
  /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */
507
528
  revalidate?: number;
529
+ /**
530
+ * on-demand 무효화용 Next fetch cache tags(opt-in, ADR-0096 Phase C). 보통
531
+ * `[COLLECTIONS_CACHE_TAG]`. 미지정 시 현행 동작 유지.
532
+ */
533
+ tags?: string[];
508
534
  }
509
535
  /**
510
536
  * 사이트의 콘텐츠 Collections (공지/블로그 등 스트림 분리)를 가져온다. 미설정이면
@@ -699,6 +725,16 @@ interface RootTaleBusinessProfile {
699
725
  naverBlog: string | null;
700
726
  };
701
727
  }
728
+ /**
729
+ * 사업장 정보 fetch 캐시 tag 의 정본 이름 (ADR-0096 Phase C). 사이트의
730
+ * `fetchBusinessProfile` 호출에 `tags: [BUSINESS_CACHE_TAG]` 로 부착하고,
731
+ * revalidate route 가 `theme.updated` 수신 시 `revalidateTag(BUSINESS_CACHE_TAG)`
732
+ * 로 무효화한다. 양쪽이 이 상수를 공유해 drift 를 방지한다.
733
+ *
734
+ * tag 를 붙이지 않으면 `revalidatePath` 로는 이 fetch 의 Data Cache 엔트리를
735
+ * 지울 수 없어 `revalidate` 초만큼 낡은 주소·전화가 남는다.
736
+ */
737
+ declare const BUSINESS_CACHE_TAG = "rt-business";
702
738
  interface FetchBusinessProfileOptions {
703
739
  apiKey: string;
704
740
  baseUrl?: string;
@@ -706,6 +742,11 @@ interface FetchBusinessProfileOptions {
706
742
  signal?: AbortSignal;
707
743
  /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */
708
744
  revalidate?: number;
745
+ /**
746
+ * on-demand 무효화용 Next fetch cache tags(opt-in, ADR-0096 Phase C). 보통
747
+ * `[BUSINESS_CACHE_TAG]`. 미지정 시 현행 동작 유지.
748
+ */
749
+ tags?: string[];
709
750
  }
710
751
  type BusinessProfileWire = {
711
752
  configured?: boolean;
@@ -801,6 +842,16 @@ interface RootTaleMenu {
801
842
  items: RootTaleMenuItem[];
802
843
  updatedAt: string | null;
803
844
  }
845
+ /**
846
+ * 메뉴(네비게이션) fetch 캐시 tag 의 정본 이름 (ADR-0096 Phase C).
847
+ * `fetchMenu`·`fetchMenus` 호출에 `tags: [MENUS_CACHE_TAG]` 로 부착하고,
848
+ * revalidate route 가 `theme.updated` 수신 시 `revalidateTag(MENUS_CACHE_TAG)`
849
+ * 로 무효화한다. 양쪽이 이 상수를 공유해 drift 를 방지한다.
850
+ *
851
+ * 메뉴 1개(`fetchMenu`)와 전체(`fetchMenus`)가 같은 tag 를 쓴다 — 어드민에서
852
+ * 메뉴를 저장하면 어느 쪽 캐시든 같이 낡기 때문이다.
853
+ */
854
+ declare const MENUS_CACHE_TAG = "rt-menus";
804
855
  interface FetchMenuOptions {
805
856
  apiKey: string;
806
857
  baseUrl?: string;
@@ -810,6 +861,11 @@ interface FetchMenuOptions {
810
861
  signal?: AbortSignal;
811
862
  /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */
812
863
  revalidate?: number;
864
+ /**
865
+ * on-demand 무효화용 Next fetch cache tags(opt-in, ADR-0096 Phase C). 보통
866
+ * `[MENUS_CACHE_TAG]`. 미지정 시 현행 동작 유지.
867
+ */
868
+ tags?: string[];
813
869
  }
814
870
  interface FetchMenusOptions {
815
871
  apiKey: string;
@@ -818,6 +874,11 @@ interface FetchMenusOptions {
818
874
  signal?: AbortSignal;
819
875
  /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */
820
876
  revalidate?: number;
877
+ /**
878
+ * on-demand 무효화용 Next fetch cache tags(opt-in, ADR-0096 Phase C). 보통
879
+ * `[MENUS_CACHE_TAG]`. 미지정 시 현행 동작 유지.
880
+ */
881
+ tags?: string[];
821
882
  }
822
883
  /**
823
884
  * 위치 핸들 slug 로 네비게이션 메뉴 1개를 가져온다. admin "디자인 > 메뉴" 의
@@ -922,6 +983,27 @@ declare function fetchSiteInquiries(options: FetchSiteInquiriesOptions): Promise
922
983
  declare function fetchSiteInquiry(options: FetchSiteInquiryOptions): Promise<SiteInquiryDetail | null>;
923
984
  declare function submitSiteInquiry(options: SubmitSiteInquiryOptions): Promise<SubmitSiteInquiryResult>;
924
985
 
986
+ /**
987
+ * 설정류 fetch 캐시 tag 한 벌 (ADR-0096 Phase C).
988
+ *
989
+ * 개별 tag 상수는 각자의 fetch 함수 옆에 산다 — `THEME_CACHE_TAG`
990
+ * (server-posts-theme) · `BUSINESS_CACHE_TAG`(server-business) ·
991
+ * `MENUS_CACHE_TAG`(server-discovery) · `BLOG_SETTINGS_CACHE_TAG` ·
992
+ * `COLLECTIONS_CACHE_TAG`(server-blog). 본 모듈은 그것들을 **묶기만** 한다:
993
+ * 어느 도메인 모듈에 배열을 두어도 나머지 셋을 import 해야 해서 모듈 방향이
994
+ * 꼬이기 때문이다(여기서 한 방향으로만 import 한다).
995
+ */
996
+ /**
997
+ * 어드민 "설정" 저장으로 낡는 fetch 캐시 tag 전부 — 테마·사업장 정보·메뉴·
998
+ * 블로그 표시 설정·콘텐츠 유형.
999
+ *
1000
+ * 수신측(`createRevalidateRoute`)이 `theme.updated` 한 번에 이 목록을 통째로
1001
+ * 무효화한다. 사이트가 이 중 일부 fetch 만 쓰더라도 안 쓰는 tag 의
1002
+ * `revalidateTag` 는 no-op 이므로 해가 없다 — 그래서 "설정을 저장했다" 신호
1003
+ * 하나로 설정류 전체를 지우는 것이 안전한 기본값이다.
1004
+ */
1005
+ declare const SETTINGS_CACHE_TAGS: readonly string[];
1006
+
925
1007
  interface RssItemInput {
926
1008
  title: string;
927
1009
  /** 글 절대 URL. */
@@ -1201,4 +1283,4 @@ declare function formatKoreanPhoneNumber(input: string): string;
1201
1283
  */
1202
1284
  declare function submitInquiry(options: SubmitInquiryOptions): Promise<SubmitInquiryResult>;
1203
1285
 
1204
- export { type ActionDockAction, type ActionDockKind, type ActionDockPosition, type ActionDockSettings, type AnalyticsTagConfig, type AnalyticsTagProvider, type AnnouncementItem, type AnnouncementSettings, type AnnouncementTone, type AnnouncementVariant, type ArticleSchemaInput, type BusinessDayCode, type BusinessOpeningHours, type BusinessType, CMS_CLIENT_VERSION, CmsApiError, type CmsArchiveRelatedGroup, type CmsArchiveRelatedPost, type CmsCategoryCount, type CmsCategoryCounts, type CmsFieldMetaEntry, type CmsPostContent, type CmsPostContentPage, type CmsPostTranslationRef, type CmsPostType, type CmsRouteCollection, type CmsSearchHit, type CmsTaxonomyKind, type CmsTermRef, DEFAULT_BLOG_SETTINGS, type FetchAnalyticsOptions, type FetchAuthorsOptions, type FetchBlogSettingsOptions, type FetchBusinessProfileOptions, type FetchCategoryCountsOptions, type FetchCollectionsOptions, type FetchMenuOptions, type FetchMenusOptions, type FetchPageBySlugOptions, type FetchPostOptions, type FetchPostsOptions, type FetchRedirectsOptions, type FetchSiteInquiriesOptions, type FetchSiteInquiryOptions, type FetchThemeOptions, INQUIRY_ERROR_MESSAGES, InquiryAttribution, type InquiryBoardSettings, type InquiryVertical, LOCAL_BUSINESS_MAX_SERVICES, LOCAL_BUSINESS_SERVICE_MAX_LENGTH, type LocalBusinessProfileInput, type ResolvedPostDisplay, type RootTaleAuthor, type RootTaleBlogPostCta, type RootTaleBlogSettings, type RootTaleBusinessProfile, type RootTaleMenu, type RootTaleMenuItem, type RootTaleRedirect, type RootTaleSiteProfile, type RootTaleSitemapSettings, type RootTaleTheme, type RssFeedInput, type RssItemInput, type SearchPostsOptions, type SiteAnalyticsTags, type SiteInquiryDetail, type SiteInquiryListItem, type SiteInquiryListPage, type SiteInquiryStatus, type SiteNavColumn, type SiteNavCta, type SiteNavGroup, type SiteNavLink, type SiteNavSettings, type SubmitInquiryFields, type SubmitInquiryOptions, type SubmitInquiryResult, type SubmitSiteInquiryFields, type SubmitSiteInquiryOptions, type SubmitSiteInquiryResult, THEME_CACHE_TAG, articleSchema, breadcrumbSchema, businessProfileFromWire, collectionPageSchema, contentIdMeta, faqSchema, fetchAnalyticsConfig, fetchAuthors, fetchBlogSettings, fetchBusinessProfile, fetchCategoryCounts, fetchCollections, fetchCollectionsSafeDefault, fetchMenu, fetchMenus, fetchPageBySlug, fetchPost, fetchPosts, fetchRedirects, fetchSiteInquiries, fetchSiteInquiry, fetchTheme, formatKoreanPhoneNumber, generateRssXml, inquiryErrorMessage, jsonLdJson, jsonLdScript, localBusinessSchema, organizationSchema, parseInquiryRedirect, profilePageSchema, resolvePostDisplay, rssItemsFromPosts, searchPosts, selectRelatedPosts, submitInquiry, submitSiteInquiry, websiteSchema };
1286
+ export { type ActionDockAction, type ActionDockKind, type ActionDockPosition, type ActionDockSettings, type AnalyticsTagConfig, type AnalyticsTagProvider, type AnnouncementItem, type AnnouncementSettings, type AnnouncementTone, type AnnouncementVariant, type ArticleSchemaInput, BLOG_SETTINGS_CACHE_TAG, BUSINESS_CACHE_TAG, type BusinessDayCode, type BusinessOpeningHours, type BusinessType, CMS_CLIENT_VERSION, COLLECTIONS_CACHE_TAG, CmsApiError, type CmsArchiveRelatedGroup, type CmsArchiveRelatedPost, type CmsCategoryCount, type CmsCategoryCounts, type CmsFieldMetaEntry, type CmsPostContent, type CmsPostContentPage, type CmsPostTranslationRef, type CmsPostType, type CmsRouteCollection, type CmsSearchHit, type CmsTaxonomyKind, type CmsTermRef, DEFAULT_BLOG_SETTINGS, type FetchAnalyticsOptions, type FetchAuthorsOptions, type FetchBlogSettingsOptions, type FetchBusinessProfileOptions, type FetchCategoryCountsOptions, type FetchCollectionsOptions, type FetchMenuOptions, type FetchMenusOptions, type FetchPageBySlugOptions, type FetchPostOptions, type FetchPostsOptions, type FetchRedirectsOptions, type FetchSiteInquiriesOptions, type FetchSiteInquiryOptions, type FetchThemeOptions, INQUIRY_ERROR_MESSAGES, InquiryAttribution, type InquiryBoardSettings, type InquiryVertical, LOCAL_BUSINESS_MAX_SERVICES, LOCAL_BUSINESS_SERVICE_MAX_LENGTH, type LocalBusinessProfileInput, MENUS_CACHE_TAG, type ResolvedPostDisplay, type RootTaleAuthor, type RootTaleBlogPostCta, type RootTaleBlogSettings, type RootTaleBusinessProfile, type RootTaleMenu, type RootTaleMenuItem, type RootTaleRedirect, type RootTaleSiteProfile, type RootTaleSitemapSettings, type RootTaleTheme, type RssFeedInput, type RssItemInput, SETTINGS_CACHE_TAGS, type SearchPostsOptions, type SiteAnalyticsTags, type SiteInquiryDetail, type SiteInquiryListItem, type SiteInquiryListPage, type SiteInquiryStatus, type SiteNavColumn, type SiteNavCta, type SiteNavGroup, type SiteNavLink, type SiteNavSettings, type SubmitInquiryFields, type SubmitInquiryOptions, type SubmitInquiryResult, type SubmitSiteInquiryFields, type SubmitSiteInquiryOptions, type SubmitSiteInquiryResult, THEME_CACHE_TAG, articleSchema, breadcrumbSchema, businessProfileFromWire, collectionPageSchema, contentIdMeta, faqSchema, fetchAnalyticsConfig, fetchAuthors, fetchBlogSettings, fetchBusinessProfile, fetchCategoryCounts, fetchCollections, fetchCollectionsSafeDefault, fetchMenu, fetchMenus, fetchPageBySlug, fetchPost, fetchPosts, fetchRedirects, fetchSiteInquiries, fetchSiteInquiry, fetchTheme, formatKoreanPhoneNumber, generateRssXml, inquiryErrorMessage, jsonLdJson, jsonLdScript, localBusinessSchema, organizationSchema, parseInquiryRedirect, profilePageSchema, resolvePostDisplay, rssItemsFromPosts, searchPosts, selectRelatedPosts, submitInquiry, submitSiteInquiry, websiteSchema };
package/dist/server.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
 
5
5
  // src/server-base.ts
6
6
  var DEFAULT_BASE_URL = "https://api.roottale.com";
7
- var CLIENT_VERSION = true ? "0.51.1" : "dev";
7
+ var CLIENT_VERSION = true ? "0.52.0" : "dev";
8
8
  var CMS_CLIENT_VERSION = CLIENT_VERSION;
9
9
  function authHeaders(apiKey) {
10
10
  return {
@@ -209,6 +209,7 @@ function fromWire(wire) {
209
209
  }
210
210
 
211
211
  // src/server-blog.ts
212
+ var BLOG_SETTINGS_CACHE_TAG = "rt-blog-settings";
212
213
  async function fetchBlogSettings(options) {
213
214
  assertServer();
214
215
  const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -268,6 +269,7 @@ async function fetchBlogSettings(options) {
268
269
  }
269
270
  };
270
271
  }
272
+ var COLLECTIONS_CACHE_TAG = "rt-collections";
271
273
  async function fetchCollections(options) {
272
274
  assertServer();
273
275
  const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -455,6 +457,7 @@ async function fetchCategoryCounts(options) {
455
457
  }
456
458
 
457
459
  // src/server-business.ts
460
+ var BUSINESS_CACHE_TAG = "rt-business";
458
461
  function businessProfileFromWire(json) {
459
462
  if (json.configured === false) return null;
460
463
  if (typeof json.name !== "string" || json.name.length === 0) return null;
@@ -537,6 +540,7 @@ async function searchPosts(options) {
537
540
  publishedAt: w.published_at
538
541
  }));
539
542
  }
543
+ var MENUS_CACHE_TAG = "rt-menus";
540
544
  function menuItemFromWire(w) {
541
545
  const item = { id: w.id, label: w.label, url: w.url };
542
546
  if (w.new_tab) item.newTab = true;
@@ -690,6 +694,15 @@ function fromDetailWire(item) {
690
694
  };
691
695
  }
692
696
 
697
+ // src/server-cache-tags.ts
698
+ var SETTINGS_CACHE_TAGS = [
699
+ THEME_CACHE_TAG,
700
+ BUSINESS_CACHE_TAG,
701
+ MENUS_CACHE_TAG,
702
+ BLOG_SETTINGS_CACHE_TAG,
703
+ COLLECTIONS_CACHE_TAG
704
+ ];
705
+
693
706
  // src/seo.ts
694
707
  function generateRssXml(input) {
695
708
  const lang = input.language ?? "ko-KR";
@@ -1108,12 +1121,17 @@ function assertServer2() {
1108
1121
  }
1109
1122
  }
1110
1123
  export {
1124
+ BLOG_SETTINGS_CACHE_TAG,
1125
+ BUSINESS_CACHE_TAG,
1111
1126
  CMS_CLIENT_VERSION,
1127
+ COLLECTIONS_CACHE_TAG,
1112
1128
  CmsApiError,
1113
1129
  DEFAULT_BLOG_SETTINGS,
1114
1130
  INQUIRY_ERROR_MESSAGES,
1115
1131
  LOCAL_BUSINESS_MAX_SERVICES,
1116
1132
  LOCAL_BUSINESS_SERVICE_MAX_LENGTH,
1133
+ MENUS_CACHE_TAG,
1134
+ SETTINGS_CACHE_TAGS,
1117
1135
  THEME_CACHE_TAG,
1118
1136
  articleSchema,
1119
1137
  breadcrumbSchema,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server-base.ts","../src/server-posts-theme.ts","../src/server-blog.ts","../src/server-categories.ts","../src/server-business.ts","../src/server-discovery.ts","../src/server-site-inquiries.ts","../src/seo.ts","../src/inquiry.ts"],"sourcesContent":["/**\n * `@roottale/cms-client/server` — RootTale CMS Public API server-side fetch client.\n *\n * 외부 고객사 사이트가 RootTale CMS 콘텐츠를 fetch 하기 위한 SSR-only client.\n * Bearer (`rtlk_cust_*`) 인증, 브라우저 직접 호출 금지 (ADR-0023 §5.1 #15).\n *\n * 사용 예 (Next.js server component):\n * ```ts\n * import { fetchPosts } from \"@roottale/cms-client/server\";\n *\n * const posts = await fetchPosts({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * limit: 10,\n * });\n * ```\n *\n * 데이터 모델 = ADR-0034 `posts` (WP 7.0-aligned, type=post|page).\n * Phase 1 scope: list + get. WP import/export, draft preview = 별도 PR.\n */\n\nexport type CmsPostType = \"post\" | \"page\";\n\nexport type CmsTaxonomyKind = \"category\" | \"tag\" | \"custom\";\n\nexport interface CmsTermRef {\n id: string;\n taxonomy: CmsTaxonomyKind;\n slug: string;\n name: string;\n}\n\nexport interface CmsArchiveRelatedPost {\n id: string;\n title: string;\n slug: string;\n excerpt: string | null;\n collectionKey: string;\n}\n\nexport interface CmsArchiveRelatedGroup {\n collectionKey: string;\n label: string;\n items: CmsArchiveRelatedPost[];\n}\n\n/** W5-2 — `fields` 표시 메타 1건 (서버 계약 `CmsFieldMeta` 대응). */\nexport interface CmsFieldMetaEntry {\n /** `fields` 의 key. */\n name: string;\n /** 필드 그룹에 정의된 사람용 라벨. */\n label: string;\n /** 필드 타입 (text/textarea/select/relationship/…). */\n type: string;\n /** select/radio 계열만 — 저장 값(value)→표시 라벨. */\n choices?: Array<{ value: string; label: string }>;\n}\n\n/** ADR-0052 A안 (W4-6 PR B) — 언어판 참조(locale + 그 언어판의 slug). */\nexport interface CmsPostTranslationRef {\n locale: string;\n slug: string;\n}\n\nexport interface CmsPostContent {\n /** UUIDv7. */\n id: string;\n /** Owning tenant id. */\n tenantId: string;\n /** Owning site id (ADR-0034 P4 dual-key). */\n siteId: string;\n /** WP post type. */\n type: CmsPostType;\n /**\n * ADR-0060 Amendment 1 — 글의 명시 섹션(content collection) key. 라우팅/스트림\n * 판정에 카테고리 파생보다 우선. 구 서버/미설정이면 null.\n */\n collectionKey: string | null;\n /** Lowercase kebab-case slug (1-120 chars). */\n slug: string;\n /**\n * ADR-0052 A안 — 이 글판의 BCP-47 locale. 단일 언어 사이트는 항상 'ko'.\n * 구 서버(마이그레이션 전) 는 미포함 — optional (하위호환).\n */\n locale?: string;\n /**\n * ADR-0052 A안 — 같은 글의 발행된 언어판 목록(이 글 자신 포함). 번역이 없으면\n * 자기 자신 1건만. 구 서버는 미포함 — optional (하위호환).\n */\n translations?: CmsPostTranslationRef[];\n title: string;\n excerpt: string | null;\n /** Media id (resolved by renderer / consumer to URL). */\n featuredMediaId: string | null;\n /**\n * featured 이미지 delivery URL (CF Images variant, md 우선). 서버가 `featured_media_id`\n * → `media.variants_json` 를 해석해 제공. 대표 이미지 미설정 시 null.\n */\n featuredImageUrl: string | null;\n authorId: string | null;\n /** Public display name for the post author. Email is not exposed. */\n authorName: string | null;\n /** Public profile image URL for the post author. */\n authorImageUrl: string | null;\n /** Public biography text for the post author. */\n authorBio: string | null;\n /**\n * Author archive slug (`/blog/author/{slug}`). null when the author has no\n * assigned slug. 구 서버는 미포함 — 매핑부에서 `?? null` 기본값.\n */\n authorSlug: string | null;\n /** Tiptap / Gutenberg-compatible Block JSON. Sanitization is renderer's responsibility. */\n bodyJson: Record<string, unknown>;\n /** Public API only exposes `published`. */\n status: \"published\";\n /** Per-post meta (SEO overrides, canonical, og, ...). */\n metaJson: Record<string, unknown>;\n /**\n * ADR-0041 — 포맷된 커스텀 필드(ACF) 값. 서버가 필드 정의(코드 ⊕ DB 그룹)로\n * `meta_json.acf` 를 포맷 — image/file/gallery 는 media variant URL 객체.\n * 해석된 그룹이 없거나 구 서버면 null.\n */\n fields: Record<string, unknown> | null;\n /**\n * W5-2 — `fields` 의 표시 메타(라벨·타입·choices), 필드 그룹 정의 순서.\n * 서버가 필드 정의로 계산해 동봉 — 렌더러가 vertical 지식 없이 양식 값을\n * 라벨과 함께 표시한다. 해석된 그룹 없거나 구 서버면 빈 배열.\n */\n fieldsMeta: CmsFieldMetaEntry[];\n /**\n * Attached taxonomy terms (categories + tags + custom). Server 가 category →\n * tag → custom 순서로 정렬해 보내며, 각 taxonomy 내부는 name asc. attach 가 0 개면\n * 빈 배열. category 만 추출하려면 `terms.filter(t => t.taxonomy === \"category\")`.\n */\n terms: CmsTermRef[];\n archiveRelated: CmsArchiveRelatedGroup[];\n /**\n * ADR-0065 — 누적 조회수. 어드민에서 \"공개 조회수\"를 켠 사이트에서만 내려온다\n * (꺼져 있으면 `undefined`). \"조회 N\" 표시에 사용. 비콘이 채우므로 신규 글은 0.\n */\n viewCount?: number;\n /** ISO 8601. */\n publishedAt: string;\n /** ISO 8601. */\n createdAt: string;\n /** ISO 8601. */\n updatedAt: string;\n}\n\nexport interface CmsPostContentPage {\n items: CmsPostContent[];\n nextCursor: string | null;\n hasMore: boolean;\n}\n\nexport const DEFAULT_BASE_URL = \"https://api.roottale.com\";\n\n// fleet 가시성 — 자기 버전을 자가 보고 (build-time 주입, tsup define).\n// api-core 가 사이트별 check-in 으로 기록 → internal `GET /v1/internal/sites`.\ndeclare const __CMS_CLIENT_VERSION__: string | undefined;\nconst CLIENT_VERSION =\n typeof __CMS_CLIENT_VERSION__ === \"string\" ? __CMS_CLIENT_VERSION__ : \"dev\";\n\n/** 설치된 cms-client 버전 (build-time 주입) — fleet well-known 라우트 등에서 사용. */\nexport const CMS_CLIENT_VERSION = CLIENT_VERSION;\n\nexport function authHeaders(apiKey: string): Record<string, string> {\n return {\n authorization: `Bearer ${apiKey}`,\n \"x-roottale-client\": `cms-client/${CLIENT_VERSION}`,\n };\n}\n\n/**\n * ISR 시간 기반 fallback(W0-9). 지정 시 fetch 에 Next 의 `next: { revalidate }`\n * 를 붙여 webhook 유실 시에도 이 주기(초) 내에 캐시가 자가 치유된다.\n * **미지정(undefined)이면 아무 것도 붙이지 않아 현행 동작을 그대로 유지**(파괴적\n * 변경 없음) — Next 기본 캐시/정적화 정책을 따른다. `0` 은 항상 재검증(동적).\n */\nexport interface RevalidatableFetchOptions {\n /** Optional AbortSignal. */\n signal?: AbortSignal;\n /**\n * ISR revalidate 초. opt-in — 미지정 시 현행 캐시 동작 유지.\n * (Next.js `next: { revalidate }` 로 전달.)\n */\n revalidate?: number;\n}\n\n/** Next `fetch` 확장 — `next.revalidate`/`next.tags` 지원(런타임 무해, non-Next 무시). */\ntype NextFetchInit = RequestInit & {\n next?: { revalidate?: number; tags?: string[] };\n};\n\n/**\n * GET fetch 용 RequestInit 빌더 — Bearer 헤더 + signal + opt-in ISR revalidate\n * + opt-in cache tags. cms-client 의 모든 공개 콘텐츠/테마 fetch 가 공유해\n * `next.revalidate`/`next.tags` 지원을 일관되게 얻는다.\n *\n * `tags` 는 on-demand 무효화 경로(W0-4): 지정 시 fetch 캐시 엔트리에 tag 를\n * 붙여, 수신 route 의 `revalidateTag(tag)` 가 해당 엔트리(+그 fetch 를 쓴 route)를\n * 무효화한다. 미지정 = 붙이지 않음(현행 동작 유지).\n */\nexport function buildGetInit(\n apiKey: string,\n opts: { signal?: AbortSignal; revalidate?: number; tags?: string[] },\n): NextFetchInit {\n const init: NextFetchInit = {\n method: \"GET\",\n headers: authHeaders(apiKey),\n signal: opts.signal,\n };\n if (opts.revalidate !== undefined || (opts.tags && opts.tags.length > 0)) {\n init.next = {\n ...(opts.revalidate !== undefined ? { revalidate: opts.revalidate } : {}),\n ...(opts.tags && opts.tags.length > 0 ? { tags: opts.tags } : {}),\n };\n }\n return init;\n}\n\nexport interface FetchPostsOptions {\n /** `rtlk_cust_*` API key. Required. */\n apiKey: string;\n /** Override the API base URL. Defaults to `https://api.roottale.com`. */\n baseUrl?: string;\n /** Page size, 1-100, default 20. */\n limit?: number;\n /** Opaque pagination cursor returned from previous call. */\n cursor?: string;\n /** Filter by post type (`post` or `page`). Default = both. */\n type?: CmsPostType;\n /** 작가 slug 필터 — 작가 아카이브(/blog/author/{slug})용. 지정 시 발행 글(post)만. */\n author?: string;\n /** Override the auto-resolved site id (Phase 1 = 1 site/tenant, usually omitted). */\n siteId?: string;\n /**\n * ADR-0060 — 특정 섹션(collection) 소속 글만(포지티브 스코프). `excludeCollections`\n * 와 동시 지정 불가(서버가 400).\n */\n collectionKey?: string;\n /**\n * 네거티브 스코프 — 어느 스트림에도 안 속한(\"미소속\") 글만\n * (`blog-list-collection-scope.md` §2②). `collectionKey` 와 동시 지정 불가.\n */\n excludeCollections?: boolean;\n /**\n * ADR-0052 A안 (W4-6 PR B) — BCP-47 소문자 locale 필터(예: \"en\"). 미지정 시\n * 서버가 사이트 기본 locale(`settingsJson.locales[0]`, 미설정이면 'ko') 만\n * 반환한다 — 하위호환(번역판이 생겨도 이 옵션을 안 쓰는 기존 호출부는 원어\n * 콘텐츠만 계속 받는다).\n */\n locale?: string;\n /** Optional AbortSignal. */\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\nexport interface FetchPostOptions {\n apiKey: string;\n baseUrl?: string;\n /** Slug (kebab-case) or UUIDv7 id. */\n slugOrId: string;\n /** Override the auto-resolved site id. */\n siteId?: string;\n /** ADR-0052 A안 — `FetchPostsOptions.locale` 과 동일 시맨틱. */\n locale?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\n\nexport class CmsApiError extends Error {\n readonly status: number;\n readonly code: string;\n constructor(message: string, status: number, code: string) {\n super(message);\n this.name = \"CmsApiError\";\n this.status = status;\n this.code = code;\n }\n}\n\nexport async function toApiError(response: Response): Promise<CmsApiError> {\n let code = \"request_failed\";\n let message = `RootTale CMS API error: ${response.status}`;\n try {\n const body = (await response.json()) as { code?: string; message?: string };\n if (body.code) code = body.code;\n if (body.message) message = body.message;\n } catch {\n /* ignore body parse */\n }\n return new CmsApiError(message, response.status, code);\n}\n\nexport function assertServer(): void {\n if (typeof window !== \"undefined\") {\n throw new Error(\n \"@roottale/cms-client/server must not run in the browser. \" +\n \"Move this call to a Server Component, getServerSideProps, or a Route Handler. \" +\n \"Shipping API keys to the browser breaks the security model (ADR-0023 §5.1 #15).\",\n );\n }\n}\n\n// 프레임워크 무관 SEO 헬퍼 (RSS 2.0 + Schema.org JSON-LD) 재노출.\n","import {\n type CmsPostType,\n type CmsPostContent,\n type CmsPostContentPage,\n type CmsTermRef,\n type FetchPostOptions,\n type FetchPostsOptions,\n DEFAULT_BASE_URL,\n assertServer,\n buildGetInit,\n toApiError,\n} from \"./server-base\";\n\n/**\n * 외부 사이트가 cms-renderer 에 주입할 RootTale 디자인 토큰.\n * admin /design (admin.roottale.com) 에서 저장한 값과 동일한 shape.\n * 미설정 필드는 응답에서 누락 → renderer 의 CSS fallback 이 사용된다.\n */\nexport interface RootTaleTheme {\n colors?: {\n primary?: string;\n primaryForeground?: string;\n foreground?: string;\n background?: string;\n secondary?: string;\n secondaryForeground?: string;\n accent?: string;\n accentForeground?: string;\n muted?: string;\n mutedForeground?: string;\n destructive?: string;\n destructiveForeground?: string;\n border?: string;\n input?: string;\n ring?: string;\n };\n fonts?: {\n body?: string;\n display?: string;\n };\n radius?: {\n sm?: string;\n md?: string;\n lg?: string;\n xl?: string;\n };\n /**\n * W6-3 — `sites.name`(DB SSoT, `rt init --name` 이 저장) 미러. 외부 사이트가\n * `<title>`/OG/헤더·푸터 브랜드명을 site.config.ts 정적 리터럴 대신 런타임\n * 반영하는 데 쓴다. 미설정이면 소비자가 site.config.ts 값으로 fallback.\n */\n siteName?: string;\n announcement?: AnnouncementSettings;\n actionDock?: ActionDockSettings;\n inquiryBoard?: InquiryBoardSettings;\n siteNav?: SiteNavSettings;\n}\n\nexport type AnnouncementVariant = \"single\" | \"rotating\" | \"cta\";\nexport type AnnouncementTone = \"default\" | \"primary\";\n\nexport interface AnnouncementItem {\n text: string;\n href?: string;\n ctaLabel?: string;\n}\n\nexport interface AnnouncementSettings {\n enabled: boolean;\n variant: AnnouncementVariant;\n tone: AnnouncementTone;\n items: AnnouncementItem[];\n startsAt?: string;\n endsAt?: string;\n}\n\nexport type ActionDockKind = \"tel\" | \"link\" | \"map\" | \"channel\";\nexport type ActionDockPosition = \"bottom\" | \"side\";\n\nexport interface ActionDockAction {\n label: string;\n href: string;\n kind: ActionDockKind;\n}\n\nexport interface ActionDockSettings {\n enabled: boolean;\n actions: ActionDockAction[];\n position: ActionDockPosition;\n collapseAt?: number;\n}\n\nexport interface InquiryBoardSettings {\n enabled: boolean;\n}\n\nexport interface SiteNavLink {\n label: string;\n href: string;\n description?: string;\n}\n\nexport interface SiteNavColumn {\n title?: string;\n links: SiteNavLink[];\n}\n\nexport interface SiteNavGroup {\n label: string;\n href?: string;\n columns?: SiteNavColumn[];\n}\n\nexport interface SiteNavCta {\n label: string;\n href: string;\n}\n\nexport interface SiteNavSettings {\n navGroups: SiteNavGroup[];\n cta?: SiteNavCta;\n utilityLinks?: SiteNavLink[];\n}\n\n/**\n * 테마 fetch 캐시 tag 의 정본 이름 (W0-4). starter layout 의 `fetchTheme` 호출에\n * `tags: [THEME_CACHE_TAG]` 로 부착하고, revalidate route 가 `theme.updated`\n * 수신 시 `revalidateTag(THEME_CACHE_TAG)` 로 무효화한다. 양쪽이 이 상수를 공유해\n * drift 를 방지한다.\n */\nexport const THEME_CACHE_TAG = \"rt-theme\";\n\nexport interface FetchThemeOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n /**\n * on-demand 무효화용 Next fetch cache tags(opt-in, W0-4). 보통\n * `[THEME_CACHE_TAG]`. 미지정 시 현행 동작 유지.\n */\n tags?: string[];\n}\n\n/**\n * tenant 의 RootTale 디자인 토큰을 가져온다. admin /design 의 마지막 저장값\n * (없으면 빈 객체) — renderer 가 이 값을 CSS 변수로 주입한다.\n *\n * 자동 fetch (renderer 가 theme prop 없을 때) 와 명시 호출 모두 지원.\n * 네트워크/서버 에러는 throw — 호출자가 fallback 처리.\n */\nexport async function fetchTheme(options: FetchThemeOptions): Promise<RootTaleTheme> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/theme${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n colors?: RootTaleTheme[\"colors\"];\n fonts?: RootTaleTheme[\"fonts\"];\n radius?: RootTaleTheme[\"radius\"];\n site_name?: string;\n announcement?: AnnouncementSettings;\n actionDock?: ActionDockSettings;\n inquiryBoard?: InquiryBoardSettings;\n siteNav?: SiteNavSettings;\n };\n const theme: RootTaleTheme = {};\n if (json.colors) theme.colors = json.colors;\n if (json.fonts) theme.fonts = json.fonts;\n if (json.radius) theme.radius = json.radius;\n if (json.site_name) theme.siteName = json.site_name;\n if (json.announcement) theme.announcement = json.announcement;\n if (json.actionDock) theme.actionDock = json.actionDock;\n if (json.inquiryBoard) theme.inquiryBoard = json.inquiryBoard;\n if (json.siteNav) theme.siteNav = json.siteNav;\n return theme;\n}\n\nexport type AnalyticsTagProvider = \"ga4\" | \"clarity\" | \"meta_pixel\" | \"naver\";\n\n/** analytics-runtime `TagConfig` 와 구조 호환 — `buildManifest` 입력. */\nexport interface AnalyticsTagConfig {\n provider: AnalyticsTagProvider;\n id: string;\n enabled: boolean;\n}\n\nexport interface SiteAnalyticsTags {\n siteId: string;\n tags: AnalyticsTagConfig[];\n}\n\nexport interface FetchAnalyticsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\n/**\n * ADR-0038 plane ① — 외부 사이트가 `<head>` 에 주입할 런타임 태그(GA4 measurement\n * id / Clarity project id 등, 비밀 아님). RootTale 관리용 default + 테넌트 고객용\n * 설정을 머지한 결과를 반환한다. analytics-runtime `buildManifest` 입력으로 쓰고,\n * 동의는 항상 fail-closed(런타임이 처리)이므로 키 노출 위험 없음(public id).\n */\nexport async function fetchAnalyticsConfig(\n options: FetchAnalyticsOptions,\n): Promise<SiteAnalyticsTags> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/analytics${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n site_id: string;\n tags?: AnalyticsTagConfig[];\n };\n return { siteId: json.site_id, tags: json.tags ?? [] };\n}\n\n\nexport async function fetchPosts(options: FetchPostsOptions): Promise<CmsPostContentPage> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.limit !== undefined) params.set(\"limit\", String(options.limit));\n if (options.cursor) params.set(\"cursor\", options.cursor);\n if (options.type) params.set(\"type\", options.type);\n if (options.author) params.set(\"author\", options.author);\n if (options.siteId) params.set(\"site_id\", options.siteId);\n if (options.collectionKey) params.set(\"collection_key\", options.collectionKey);\n if (options.excludeCollections) params.set(\"exclude_collections\", \"true\");\n if (options.locale) params.set(\"locale\", options.locale);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n items: CmsPostWire[];\n has_more: boolean;\n next_cursor: string | null;\n };\n return {\n items: json.items.map(fromWire),\n hasMore: json.has_more,\n nextCursor: json.next_cursor,\n };\n}\n\nexport async function fetchPost(options: FetchPostOptions): Promise<CmsPostContent | null> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n if (options.locale) params.set(\"locale\", options.locale);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts/${encodeURIComponent(options.slugOrId)}${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (response.status === 404) return null;\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as CmsPostWire;\n return fromWire(json);\n}\n\nexport interface FetchPageBySlugOptions {\n readonly apiKey: string;\n readonly baseUrl?: string;\n readonly slug: string;\n readonly siteId?: string;\n /** ADR-0052 A안 (W4-6 PR C1) — `FetchPostsOptions.locale` 과 동일 시맨틱. */\n readonly locale?: string;\n readonly signal?: AbortSignal;\n readonly revalidate?: number;\n}\n\nexport async function fetchPageBySlug(\n options: FetchPageBySlugOptions,\n): Promise<CmsPostContent | null> {\n let cursor: string | undefined;\n do {\n const page = await fetchPosts({\n apiKey: options.apiKey,\n baseUrl: options.baseUrl,\n limit: 100,\n ...(cursor ? { cursor } : {}),\n type: \"page\",\n siteId: options.siteId,\n ...(options.locale ? { locale: options.locale } : {}),\n signal: options.signal,\n revalidate: options.revalidate,\n });\n const match = page.items.find((item) => item.slug === options.slug);\n if (match) return match;\n cursor = page.nextCursor ?? undefined;\n } while (cursor);\n return null;\n}\n\ntype CmsPostWire = {\n id: string;\n tenant_id: string;\n site_id: string;\n type: CmsPostType;\n /** ADR-0060 Amendment 1 — 명시 섹션 key. 구 서버는 미포함 — optional. */\n collection_key?: string | null;\n slug: string;\n /** ADR-0052 A안 — 구 서버는 미포함 — optional(하위호환). */\n locale?: string;\n /** ADR-0052 A안 — 구 서버는 미포함 — optional(하위호환). */\n translations?: Array<{ locale: string; slug: string }>;\n title: string;\n excerpt: string | null;\n featured_media_id: string | null;\n /** featured 이미지 delivery URL. 구 서버는 미포함 — backward-compat optional. */\n featured_media_url?: string | null;\n author_id: string | null;\n author_name?: string | null;\n author_image_url?: string | null;\n author_bio?: string | null;\n author_slug?: string | null;\n body_json: Record<string, unknown>;\n status: \"published\";\n meta_json: Record<string, unknown>;\n /** ADR-0041 포맷 필드. 구 서버는 미포함 — backward-compat optional. */\n fields?: Record<string, unknown> | null;\n /** 0.2.0 이전 서버는 미포함 — backward-compat 차원에서 optional. */\n terms?: CmsTermRef[];\n archive_related?: Array<{\n collection_key: string;\n label: string;\n items: Array<{\n id: string;\n title: string;\n slug: string;\n excerpt: string | null;\n collection_key: string;\n }>;\n }>;\n /** W5-2 — fields 표시 메타. 해석된 그룹 없거나 구 서버는 미포함. */\n fields_meta?: Array<{\n name: string;\n label: string;\n type: string;\n choices?: Array<{ value: string; label: string }>;\n }>;\n /** ADR-0065 — 누적 조회수. 사이트가 공개 조회수를 켠 경우에만 포함. */\n view_count?: number;\n published_at: string;\n created_at: string;\n updated_at: string;\n};\n\nfunction fromWire(wire: CmsPostWire): CmsPostContent {\n return {\n id: wire.id,\n tenantId: wire.tenant_id,\n siteId: wire.site_id,\n type: wire.type,\n collectionKey: wire.collection_key ?? null,\n slug: wire.slug,\n ...(wire.locale !== undefined ? { locale: wire.locale } : {}),\n ...(wire.translations !== undefined ? { translations: wire.translations } : {}),\n title: wire.title,\n excerpt: wire.excerpt,\n featuredMediaId: wire.featured_media_id,\n featuredImageUrl: wire.featured_media_url ?? null,\n authorId: wire.author_id,\n authorName: wire.author_name ?? null,\n authorImageUrl: wire.author_image_url ?? null,\n authorBio: wire.author_bio ?? null,\n authorSlug: wire.author_slug ?? null,\n bodyJson: wire.body_json,\n status: wire.status,\n metaJson: wire.meta_json,\n fields: wire.fields ?? null,\n fieldsMeta: (wire.fields_meta ?? []).map((entry) => ({\n name: entry.name,\n label: entry.label,\n type: entry.type,\n ...(entry.choices\n ? {\n choices: entry.choices.map((c) => ({\n value: c.value,\n label: c.label,\n })),\n }\n : {}),\n })),\n terms: wire.terms ?? [],\n archiveRelated: (wire.archive_related ?? []).map((group) => ({\n collectionKey: group.collection_key,\n label: group.label,\n items: group.items.map((item) => ({\n id: item.id,\n title: item.title,\n slug: item.slug,\n excerpt: item.excerpt,\n collectionKey: item.collection_key,\n })),\n })),\n ...(typeof wire.view_count === \"number\" ? { viewCount: wire.view_count } : {}),\n publishedAt: wire.published_at,\n createdAt: wire.created_at,\n updatedAt: wire.updated_at,\n };\n}\n","import {\n DEFAULT_BASE_URL,\n assertServer,\n buildGetInit,\n toApiError,\n type CmsPostContent,\n} from \"./server-base\";\n\n/**\n * 블로그 표시 설정 — admin /design 의 \"블로그 표시\" 에서 저장한 사이트 기본값.\n * renderer 가 prop 미지정 시 이 값을 기본으로 적용 (글별 override 는 post 의\n * metaJson.display 가 우선).\n */\n/**\n * 글 하단 고정 CTA — admin 블로그 설정에서 활성화 시 모든 글 본문 끝에 그려진다.\n * null(미설정/비활성)이면 렌더러는 CTA 블록을 생략. buttonLabel/buttonHref 는\n * 객체가 존재하면 항상 non-empty(서버가 둘 다 있을 때만 객체를 내려줌).\n */\nexport interface RootTaleBlogPostCta {\n title: string | null;\n description: string | null;\n buttonLabel: string;\n buttonHref: string;\n}\n\n/**\n * 사이트 레벨 SEO 기본값 — 외부 사이트 `<head>`(설명·로고·파비콘)와 글별 OG\n * 미설정 시 공유 썸네일 폴백(defaultOgImageUrl)에 쓴다. 모두 nullable.\n */\nexport interface RootTaleSiteProfile {\n siteDescription: string | null;\n logoUrl: string | null;\n faviconUrl: string | null;\n /** 글별 og:image 미설정 시 폴백(1200×630 권장). */\n defaultOgImageUrl: string | null;\n}\n\n/**\n * 사이트맵 생성 정책 (admin 설정) — `createSitemapIndex` 가 소비해 무엇을 내보낼지\n * 결정한다. blanche식 인덱스 분리(static/blog/categories[/authors]) 위에 이미지·\n * 다국어 토글을 얹는다.\n */\nexport interface RootTaleSitemapSettings {\n /** 블로그 글 entry 에 대표 이미지(featuredImageUrl)를 `<image:image>` 로 포함. */\n blogImages: boolean;\n /** 다국어 hreflang locales (BCP-47). 빈 배열 = 단일 로케일(alternates 미emit). */\n locales: string[];\n /** authors 하위 사이트맵 포함 (작가 아카이브 라우트 필요). */\n authors: boolean;\n /**\n * 색인 위생(seo-geo-contract v3.1 §1) — \"검색에 이 분류 페이지 노출\" 을 켠\n * 카테고리 slug 목록. 아카이브는 기본 noindex 라서 여기 없는 카테고리의 모음\n * 페이지는 sitemap 에서 빠진다.\n *\n * **`undefined` = 승격 정보를 모름** (구버전 API 응답이거나 admin 설정 fetch 를\n * 생략한 경로). 이때 `createSitemapIndex` 는 정책을 적용하지 않고 기존 동작을\n * 유지한다 — 모른다는 이유로 이미 색인된 URL 을 지우지 않기 위해서다.\n *\n * 이 구분을 소비처가 직접 해석하지 말고 cms-renderer-next 의\n * `resolveCategoryPromotion(slugs, slug)` 에 그대로 넘길 것 — 사이트맵과\n * 아카이브 페이지가 같은 3-state(`promoted`/`not-promoted`/`unknown`)를 써야\n * 두 소스의 결론이 갈리지 않는다.\n */\n promotedCategorySlugs?: string[];\n}\n\nexport interface RootTaleBlogSettings {\n showTableOfContents: boolean;\n showAuthor: boolean;\n showDate: boolean;\n showAuthorCard: boolean;\n /** 목차 상단 제목. null = renderer 기본(\"목차\"). */\n tocTitle: string | null;\n /**\n * 목차 배치. \"inline\"=본문 위 접이식(기본), \"sidebar\"=넓은 화면에서 본문\n * 오른쪽 sticky 사이드바(좁은 화면은 자동 인라인 폴백).\n */\n tocPosition: \"inline\" | \"sidebar\";\n /** 작성자 카드 상단 라벨. null = renderer 기본(\"작성자\"). */\n authorCardEyebrow: string | null;\n /** 작성자 카드 설명. null = 설명 없음. */\n authorCardDescription: string | null;\n /** 작성자 프로필 이름. null = 글 작성자 이름 사용. */\n authorProfileName: string | null;\n /** 작성자 프로필 bio. null = 설명 없음. */\n authorProfileBio: string | null;\n /** 작성자 프로필 이미지 URL. null = 이니셜 아바타 사용. */\n authorProfileImageUrl: string | null;\n /** 작성자 프로필 이미지 모서리. */\n authorProfileImageRadius: \"circle\" | \"rounded\" | \"square\";\n /** 작성자 프로필 이미지 object-position x (%). */\n authorProfileImagePositionX: number;\n /** 작성자 프로필 이미지 object-position y (%). */\n authorProfileImagePositionY: number;\n /** 사이트 레벨 SEO 기본값(설명·로고·파비콘·기본 OG). */\n siteProfile: RootTaleSiteProfile;\n /** 글 하단 고정 CTA. null = 미설정/비활성. */\n postCta: RootTaleBlogPostCta | null;\n /**\n * 사이트맵 생성 정책 (admin 설정). createSitemapIndex 가 소비. optional —\n * 구버전 응답/직접 구성한 객체와의 하위호환(소비 시 기본값으로 fail-soft).\n */\n sitemap?: RootTaleSitemapSettings;\n}\n\nexport interface FetchBlogSettingsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\n/**\n * tenant 의 블로그 표시 기본값을 가져온다. 네트워크/서버 에러는 throw —\n * 호출자(renderer)가 안전 기본값으로 fallback.\n */\nexport async function fetchBlogSettings(\n options: FetchBlogSettingsOptions,\n): Promise<RootTaleBlogSettings> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/blog-settings${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n show_table_of_contents?: boolean;\n show_author?: boolean;\n show_date?: boolean;\n show_author_card?: boolean;\n toc_title?: string | null;\n toc_position?: string | null;\n author_card_eyebrow?: string | null;\n author_card_description?: string | null;\n author_profile_name?: string | null;\n author_profile_bio?: string | null;\n author_profile_image_url?: string | null;\n author_profile_image_radius?: string | null;\n author_profile_image_position_x?: number;\n author_profile_image_position_y?: number;\n site_profile?: {\n site_description?: string | null;\n logo_url?: string | null;\n favicon_url?: string | null;\n default_og_image_url?: string | null;\n } | null;\n post_cta?: {\n title?: string | null;\n description?: string | null;\n button_label?: string;\n button_href?: string;\n } | null;\n sitemap?: {\n blog_images?: boolean;\n locales?: string[];\n authors?: boolean;\n promoted_category_slugs?: string[];\n } | null;\n };\n const radius =\n json.author_profile_image_radius === \"rounded\" ||\n json.author_profile_image_radius === \"square\"\n ? json.author_profile_image_radius\n : \"circle\";\n const percent = (x: unknown): number =>\n typeof x === \"number\" && Number.isFinite(x)\n ? Math.max(0, Math.min(100, Math.round(x)))\n : 50;\n const nonEmpty = (x: unknown): string | null =>\n typeof x === \"string\" && x.length > 0 ? x : null;\n return {\n showTableOfContents: json.show_table_of_contents === true,\n showAuthor: json.show_author !== false,\n showDate: json.show_date !== false,\n showAuthorCard: json.show_author_card !== false,\n tocTitle:\n typeof json.toc_title === \"string\" && json.toc_title.length > 0\n ? json.toc_title\n : null,\n tocPosition: json.toc_position === \"sidebar\" ? \"sidebar\" : \"inline\",\n authorCardEyebrow:\n typeof json.author_card_eyebrow === \"string\" &&\n json.author_card_eyebrow.length > 0\n ? json.author_card_eyebrow\n : null,\n authorCardDescription:\n typeof json.author_card_description === \"string\" &&\n json.author_card_description.length > 0\n ? json.author_card_description\n : null,\n authorProfileName:\n typeof json.author_profile_name === \"string\" &&\n json.author_profile_name.length > 0\n ? json.author_profile_name\n : null,\n authorProfileBio:\n typeof json.author_profile_bio === \"string\" &&\n json.author_profile_bio.length > 0\n ? json.author_profile_bio\n : null,\n authorProfileImageUrl:\n typeof json.author_profile_image_url === \"string\" &&\n json.author_profile_image_url.length > 0\n ? json.author_profile_image_url\n : null,\n authorProfileImageRadius: radius,\n authorProfileImagePositionX: percent(json.author_profile_image_position_x),\n authorProfileImagePositionY: percent(json.author_profile_image_position_y),\n siteProfile: {\n siteDescription: nonEmpty(json.site_profile?.site_description),\n logoUrl: nonEmpty(json.site_profile?.logo_url),\n faviconUrl: nonEmpty(json.site_profile?.favicon_url),\n defaultOgImageUrl: nonEmpty(json.site_profile?.default_og_image_url),\n },\n postCta:\n json.post_cta &&\n typeof json.post_cta.button_label === \"string\" &&\n json.post_cta.button_label.length > 0 &&\n typeof json.post_cta.button_href === \"string\" &&\n json.post_cta.button_href.length > 0\n ? {\n title:\n typeof json.post_cta.title === \"string\" &&\n json.post_cta.title.length > 0\n ? json.post_cta.title\n : null,\n description:\n typeof json.post_cta.description === \"string\" &&\n json.post_cta.description.length > 0\n ? json.post_cta.description\n : null,\n buttonLabel: json.post_cta.button_label,\n buttonHref: json.post_cta.button_href,\n }\n : null,\n sitemap: {\n blogImages: json.sitemap?.blog_images !== false,\n locales: Array.isArray(json.sitemap?.locales)\n ? json.sitemap.locales.filter(\n (s): s is string => typeof s === \"string\" && s.length > 0,\n )\n : [],\n authors: json.sitemap?.authors === true,\n // 배열이 아니면(구버전 API 응답) 필드 자체를 생략 = \"승격 정보 모름\".\n // 빈 배열([])은 \"승격된 카테고리가 하나도 없음\" 이라는 확정 정보라 의미가\n // 다르다 — 이 구분이 사이트맵 필터의 fail-soft 근거다.\n ...(Array.isArray(json.sitemap?.promoted_category_slugs)\n ? {\n promotedCategorySlugs: json.sitemap.promoted_category_slugs.filter(\n (s): s is string => typeof s === \"string\" && s.length > 0,\n ),\n }\n : {}),\n },\n };\n}\n\n// ── 콘텐츠 Collections (ADR-0060) ────────────────────────────────────────────\n\n/**\n * 콘텐츠 스트림 1개 (camelCase). cms-core `RouteCollection` 과 구조 호환 —\n * `createSitemap`/`createFeedRoute` 의 `collections` 에 그대로 넘길 수 있다.\n */\nexport interface CmsRouteCollection {\n key: string;\n label: string;\n /** \"\" = 주소 없는 유형(라우트 없음) — 페이지 안에서 불러와 쓰는 콘텐츠. */\n basePath: string;\n layout: string;\n categories: string[];\n feed: boolean;\n archives: boolean;\n og: boolean;\n requireSubcategory: boolean;\n pinning: boolean;\n icon: string | null;\n /** 글별 상세 라우트 존재 여부 (기본 true — 하위 호환). false = 목록 전용 유형. */\n detail: boolean;\n}\n\nexport interface FetchCollectionsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\n/**\n * 사이트의 콘텐츠 Collections (공지/블로그 등 스트림 분리)를 가져온다. 미설정이면\n * 빈 배열(레거시 단일 /blog). 네트워크/서버 에러는 throw — 호출자가 빈 배열로\n * fail-soft 하도록 한다(렌더가 collections 때문에 죽지 않는 것이 계약).\n */\nexport async function fetchCollections(\n options: FetchCollectionsOptions,\n): Promise<CmsRouteCollection[]> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/collections${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n collections?: Array<Record<string, unknown>>;\n };\n const items = Array.isArray(json.collections) ? json.collections : [];\n return items.flatMap((o) => {\n const key = typeof o.key === \"string\" ? o.key : \"\";\n // \"\" = 주소 없는 유형(라우트/사이트맵/피드/아카이브 파생 없음, ADR-0060) — key\n // 만 있으면 유지한다. 소속 판정(`postsNotInAnyCollection` 방어선)의 시맨틱을\n // 서버(declaredKeys=key 기준)와 맞추기 위해 basePath 없다고 드롭하지 않는다.\n const basePath = typeof o.base_path === \"string\" ? o.base_path : \"\";\n if (!key) return [];\n return [\n {\n key,\n basePath,\n label: typeof o.label === \"string\" ? o.label : key,\n layout: typeof o.layout === \"string\" ? o.layout : \"grid\",\n categories: Array.isArray(o.categories)\n ? o.categories.filter((s): s is string => typeof s === \"string\")\n : [],\n feed: o.feed === true,\n archives: o.archives === true,\n og: o.og === true,\n requireSubcategory: o.require_subcategory === true,\n pinning: o.pinning === true,\n icon: typeof o.icon === \"string\" ? o.icon : null,\n // 기본 true(하위 호환) — 명시적으로 false 인 경우만 목록 전용.\n detail: o.detail !== false,\n },\n ];\n });\n}\n\n/**\n * fetch 실패/미설정 시 안전 기본값 — 빈 배열(레거시 단일 /blog). 사이트가\n * `fetchCollections().catch(() => fetchCollectionsSafeDefault())` 로 쓴다.\n */\nexport const fetchCollectionsSafeDefault: CmsRouteCollection[] = [];\n\n// ── 작가 아카이브 (blanche식 /blog/author/{slug}) ────────────────────────────\n\n/** 작가 1명 (camelCase) — author_slug 가 있고 발행 글이 1건 이상인 작가만. */\nexport interface RootTaleAuthor {\n /** 아카이브 URL slug (/blog/author/{slug}). */\n slug: string;\n /** 표시 이름 (author_display_name > user.name 폴백). */\n name: string;\n imageUrl: string | null;\n bio: string | null;\n /** 발행 글(type=post) 수 (≥1). */\n postCount: number;\n /** 작가의 최근 글 수정일 (사이트맵 lastmod). */\n lastUpdatedAt: string | null;\n}\n\nexport interface FetchAuthorsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\n/**\n * 사이트의 작가 목록(작가 아카이브·사이트맵 파생용)을 가져온다. author_slug 가 있고\n * 발행 글이 1건 이상인 작가만. 네트워크/서버 에러는 throw — 호출자(라우트/사이트맵\n * 팩토리)가 빈 배열로 fail-soft 한다.\n */\nexport async function fetchAuthors(\n options: FetchAuthorsOptions,\n): Promise<RootTaleAuthor[]> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/authors${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n authors?: Array<Record<string, unknown>>;\n };\n const items = Array.isArray(json.authors) ? json.authors : [];\n return items.flatMap((o) => {\n const slug = typeof o.slug === \"string\" ? o.slug : \"\";\n const name = typeof o.name === \"string\" ? o.name : \"\";\n if (!slug || !name) return [];\n return [\n {\n slug,\n name,\n imageUrl: typeof o.image_url === \"string\" ? o.image_url : null,\n bio: typeof o.bio === \"string\" ? o.bio : null,\n postCount: typeof o.post_count === \"number\" ? o.post_count : 0,\n lastUpdatedAt:\n typeof o.last_updated_at === \"string\" ? o.last_updated_at : null,\n },\n ];\n });\n}\n\n/** fetch 실패/미설정 시 사용할 안전 기본값 (기존 렌더러 동작). */\nexport const DEFAULT_BLOG_SETTINGS: RootTaleBlogSettings = {\n showTableOfContents: false,\n showAuthor: true,\n showDate: true,\n showAuthorCard: true,\n tocTitle: null,\n tocPosition: \"inline\",\n authorCardEyebrow: null,\n authorCardDescription: null,\n authorProfileName: null,\n authorProfileBio: null,\n authorProfileImageUrl: null,\n authorProfileImageRadius: \"circle\",\n authorProfileImagePositionX: 50,\n authorProfileImagePositionY: 50,\n siteProfile: {\n siteDescription: null,\n logoUrl: null,\n faviconUrl: null,\n defaultOgImageUrl: null,\n },\n postCta: null,\n sitemap: {\n blogImages: true,\n locales: [],\n authors: false,\n // 이 기본값은 **블로그 렌더링 폴백 전용**이다 (cms-renderer-next 의\n // `fetchBlogSettingsFailSoft` = `fetchBlogSettings(...).catch(() => DEFAULT_BLOG_SETTINGS)`,\n // 소비처는 `server/blog-list.tsx`·`server/blog-post.tsx` 뿐 — 둘 다 색인\n // 정책과 무관한 본문 표시 설정만 읽는다).\n //\n // **색인 정책 경로는 이 상수를 쓰지 않는다** — 사이트맵은\n // `resolveSitemapSettings`(cms-renderer-next/routes-sitemap-entries.ts)가\n // 자체 try/catch 로 `remote = {}` 를 쓰고, 아카이브 페이지는 조회 실패 시\n // 설정 객체 자체를 `undefined` 로 넘긴다. 그래서 두 경로 모두 fetch 실패가\n // `promotedCategorySlugs === undefined`(= 승격 정보 모름) 로 전달돼\n // `resolveCategoryPromotion` 이 `unknown` 을 만들고 기존 URL 이 보존된다.\n // 여기 `[]` 는 \"승격 0건\"(확정) 이지만 그 의미가 색인 정책까지 전달되는\n // 경로는 없다 — 새 소비처를 만들 때 이 상수를 승격 판정에 넘기지 말 것.\n promotedCategorySlugs: [],\n },\n};\n\n\nexport interface ResolvedPostDisplay {\n showTableOfContents: boolean;\n showAuthor: boolean;\n showDate: boolean;\n showAuthorCard: boolean;\n tocTitle: string;\n tocPosition: \"inline\" | \"sidebar\";\n authorCardEyebrow: string;\n authorCardDescription: string | null;\n authorProfileName: string | null;\n authorProfileBio: string | null;\n authorProfileImageUrl: string | null;\n authorProfileImageRadius: \"circle\" | \"rounded\" | \"square\";\n authorProfileImagePositionX: number;\n authorProfileImagePositionY: number;\n}\n\n/**\n * 글 1건의 실제 표시 여부 결정. 우선순위:\n * 명시 prop > 글별 override(post.metaJson.display) > 사이트 기본값(settings).\n * tocTitle: 명시 prop > settings.tocTitle > \"목차\".\n */\nexport function resolvePostDisplay(\n post: { metaJson?: Record<string, unknown> | null },\n settings: RootTaleBlogSettings,\n explicit?: { showTableOfContents?: boolean; tableOfContentsTitle?: string },\n): ResolvedPostDisplay {\n const display =\n post.metaJson && typeof post.metaJson === \"object\"\n ? ((post.metaJson as Record<string, unknown>).display as\n | Record<string, unknown>\n | undefined)\n : undefined;\n const ov = (key: string): boolean | undefined =>\n display && typeof display[key] === \"boolean\"\n ? (display[key] as boolean)\n : undefined;\n return {\n showTableOfContents:\n explicit?.showTableOfContents ?? ov(\"toc\") ?? settings.showTableOfContents,\n showAuthor: ov(\"author\") ?? settings.showAuthor,\n showDate: ov(\"date\") ?? settings.showDate,\n showAuthorCard: settings.showAuthorCard,\n tocTitle: explicit?.tableOfContentsTitle ?? settings.tocTitle ?? \"목차\",\n tocPosition: settings.tocPosition ?? \"inline\",\n authorCardEyebrow: settings.authorCardEyebrow ?? \"작성자\",\n authorCardDescription: settings.authorCardDescription,\n authorProfileName: settings.authorProfileName,\n authorProfileBio: settings.authorProfileBio,\n authorProfileImageUrl: settings.authorProfileImageUrl,\n authorProfileImageRadius: settings.authorProfileImageRadius,\n authorProfileImagePositionX: settings.authorProfileImagePositionX,\n authorProfileImagePositionY: settings.authorProfileImagePositionY,\n };\n}\n\n/**\n * 관련 글 선별 — 현재 글과 **같은 카테고리**를 1개 이상 공유하는 후보를\n * 발행일 내림차순으로 골라 최대 `limit` 개 반환 (자기 자신 제외).\n *\n * 순수 함수: 후보(`candidates`)는 호출부가 `fetchPosts` 로 미리 받아 전달한다\n * (서버에 카테고리 필터 파라미터가 없어 최근 글 배치에서 선별). 현재 글에\n * 카테고리가 없으면 빈 배열.\n */\nexport function selectRelatedPosts(\n current: CmsPostContent,\n candidates: CmsPostContent[],\n limit = 3,\n): CmsPostContent[] {\n if (limit <= 0) return [];\n const currentCats = new Set(\n current.terms\n .filter((t) => t.taxonomy === \"category\")\n .map((t) => t.id),\n );\n if (currentCats.size === 0) return [];\n return candidates\n .filter(\n (p) =>\n p.id !== current.id &&\n p.terms.some(\n (t) => t.taxonomy === \"category\" && currentCats.has(t.id),\n ),\n )\n .sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))\n .slice(0, limit);\n}\n","/**\n * 분류(category)별 발행 글 수 — **서버측 집계** 조회.\n *\n * 분류 아카이브의 \"0건 = 404\" 판정과 사이트맵 포함 여부(색인 위생)를 정확히\n * 계산하기 위한 값이다. 이 API 가 없던 시절 렌더러는 최근 글 배치를 받아 세었고,\n * 글이 많은 사이트에서는 배치 한도 밖에만 글이 있는 분류가 페이지·사이트맵에서\n * 함께 사라졌다. 소비처(`@roottale/cms-renderer-next`)는 이 함수를 먼저 쓰고,\n * 실패하면 예전 배치 파생으로 fail-soft 한다.\n */\n\nimport {\n CmsApiError,\n DEFAULT_BASE_URL,\n assertServer,\n buildGetInit,\n toApiError,\n} from \"./server-base\";\n\n/** 분류 1건 + 요청 스코프 안의 발행 글 수. */\nexport interface CmsCategoryCount {\n slug: string;\n name: string;\n /** 요청 스코프(미소속/특정 스트림) 안에서 이 분류에 걸린 발행 글 수. 0 가능. */\n publishedPostCount: number;\n /** 이 분류가 귀속된 콘텐츠 유형 key (ADR-0060). 미귀속·구버전 응답이면 null. */\n collectionKey: string | null;\n /**\n * 어드민 \"검색에 이 분류 페이지 노출\" 토글. **승격 판정 입력으로 쓰지 말 것** —\n * 렌더러의 3-state 승격은 `fetchBlogSettings().sitemap.promotedCategorySlugs`\n * 하나에서만 조립한다(두 소스를 섞으면 사이트맵과 페이지가 서로 다른 시점의\n * 값을 볼 수 있다). 진단·어드민 표시용 참고값이다.\n */\n hubPromoted: boolean;\n}\n\nexport interface CmsCategoryCounts {\n categories: CmsCategoryCount[];\n /** 분류 수가 서버 상한을 넘어 일부가 빠졌으면 true — 소비처는 경고를 남길 것. */\n truncated: boolean;\n}\n\nexport interface FetchCategoryCountsOptions {\n /** `rtlk_cust_*` API key. 블로그 조회와 같은 키. */\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n /**\n * 그 섹션(collection) 소속 글만 센다 — 스트림 아카이브용. `excludeCollections`\n * 와 동시 지정 불가(서버가 400).\n */\n collectionKey?: string;\n /** 어느 스트림에도 안 속한 글만 센다 — 레거시 `/blog` 계열 아카이브의 모집단. */\n excludeCollections?: boolean;\n /** BCP-47. 미지정이면 사이트 기본 로케일(포스트 목록과 같은 기본 동작). */\n locale?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\nfunction toNonNegativeInt(value: unknown): number {\n return typeof value === \"number\" && Number.isInteger(value) && value >= 0\n ? value\n : 0;\n}\n\n/**\n * 사이트의 모든 분류와 발행 글 수를 가져온다. 네트워크/서버 에러는 **throw** —\n * 호출자가 예전 파생 방식으로 fail-soft 할지, 화면을 죽일지 정한다.\n *\n * 이 엔드포인트가 없는 구버전 API 는 404 를 주고 그것도 `CmsApiError` 로 throw\n * 되므로, 호출자는 \"구버전 API\" 와 \"일시 장애\" 를 같은 catch 로 다루면 된다.\n * 응답 shape 이 예상과 다른 경우(`categories` 가 배열이 아님)도 throw 로 취급한다\n * — 빈 목록으로 오해하면 \"분류 0건\" 이라는 **확정 정보**가 되어 아카이브가 전부\n * 404 가 되기 때문이다.\n */\nexport async function fetchCategoryCounts(\n options: FetchCategoryCountsOptions,\n): Promise<CmsCategoryCounts> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n if (options.collectionKey) params.set(\"collection_key\", options.collectionKey);\n if (options.excludeCollections) params.set(\"exclude_collections\", \"true\");\n if (options.locale) params.set(\"locale\", options.locale);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/categories${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n categories?: Array<Record<string, unknown>>;\n truncated?: boolean;\n };\n if (!Array.isArray(json.categories)) {\n throw new CmsApiError(\n \"RootTale CMS API returned no `categories` array for /v1/cms/public/categories\",\n response.status,\n \"unexpected_response\",\n );\n }\n return {\n categories: json.categories.flatMap((o) => {\n const slug = typeof o.slug === \"string\" ? o.slug : \"\";\n const name = typeof o.name === \"string\" ? o.name : \"\";\n if (!slug || !name) return [];\n return [\n {\n slug,\n name,\n publishedPostCount: toNonNegativeInt(o.published_post_count),\n collectionKey:\n typeof o.collection_key === \"string\" ? o.collection_key : null,\n hubPromoted: o.hub_promoted === true,\n },\n ];\n }),\n truncated: json.truncated === true,\n };\n}\n","import { DEFAULT_BASE_URL, assertServer, buildGetInit, toApiError } from \"./server-base\";\n\n// ── 비즈니스 프로필 (로컬 SEO) ───────────────────────────────────────────────\n\n/** Schema.org LocalBusiness 하위 타입 (admin \"운영 > 비즈니스 프로필\" 의 업종). */\nexport type BusinessType =\n | \"LocalBusiness\"\n | \"ProfessionalService\"\n | \"AccountingService\"\n | \"LegalService\"\n | \"MedicalClinic\"\n | \"Dentist\"\n | \"RealEstateAgent\"\n | \"Restaurant\"\n | \"BeautySalon\";\n\n/** Schema.org openingHours 요일 약어. */\nexport type BusinessDayCode = \"Mo\" | \"Tu\" | \"We\" | \"Th\" | \"Fr\" | \"Sa\" | \"Su\";\n\nexport interface BusinessOpeningHours {\n days: BusinessDayCode[];\n /** HH:MM (24시간). */\n opens: string;\n /** HH:MM (24시간). */\n closes: string;\n}\n\n/**\n * 비즈니스 프로필 — admin \"운영 > 비즈니스 프로필\" 에서 저장한 사업장 정보.\n * `localBusinessSchema(profile, { url })` 로 LocalBusiness JSON-LD 를 만들어\n * layout 에 1회 렌더한다 (로컬 SEO).\n */\nexport interface RootTaleBusinessProfile {\n /** 사업장 이름 (간판명). */\n name: string;\n /** 사업자등록 상호 — 간판명과 다를 때만 설정. */\n legalName: string | null;\n /** 간판명의 표기 변형(띄어쓰기 등) — JSON-LD alternateName. */\n alternateName: string | null;\n businessType: BusinessType;\n telephone: string | null;\n /** 팩스번호 — JSON-LD faxNumber. */\n faxNumber: string | null;\n email: string | null;\n address: {\n streetAddress: string;\n addressLocality: string;\n addressRegion: string | null;\n postalCode: string | null;\n } | null;\n geo: { latitude: number; longitude: number } | null;\n openingHours: BusinessOpeningHours[];\n /** 가격대 표기 (예: \"₩₩\"). */\n priceRange: string | null;\n /** 서비스 지역 (예: \"서울 강남구\"). */\n areaServed: string[];\n /** 취급 업무 (예: \"기장·신고대리\") — JSON-LD hasOfferCatalog. */\n services: string[];\n /** 외부 프로필 URL — JSON-LD sameAs 로 들어가 동일 사업장임을 연결. */\n profiles: {\n naverPlace: string | null;\n googleBusiness: string | null;\n kakaoChannel: string | null;\n /** 지도 앱 딥링크 — naverPlace 가 없을 때 hasMap 대체값. */\n kakaoMap: string | null;\n instagram: string | null;\n naverBlog: string | null;\n };\n}\n\nexport interface FetchBusinessProfileOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\ntype BusinessProfileWire = {\n configured?: boolean;\n name?: string | null;\n legal_name?: string | null;\n alternate_name?: string | null;\n business_type?: string | null;\n telephone?: string | null;\n fax_number?: string | null;\n email?: string | null;\n address?: {\n street_address?: string;\n address_locality?: string;\n address_region?: string | null;\n postal_code?: string | null;\n } | null;\n geo?: { latitude?: number; longitude?: number } | null;\n opening_hours?: {\n days?: BusinessDayCode[];\n opens?: string;\n closes?: string;\n }[];\n price_range?: string | null;\n area_served?: string[];\n services?: string[];\n profiles?: {\n naver_place?: string | null;\n google_business?: string | null;\n kakao_channel?: string | null;\n kakao_map?: string | null;\n instagram?: string | null;\n naver_blog?: string | null;\n } | null;\n};\n\n/** wire(snake_case) → RootTaleBusinessProfile. 미설정이면 null (export for tests). */\nexport function businessProfileFromWire(\n json: BusinessProfileWire,\n): RootTaleBusinessProfile | null {\n // configured=false 또는 이름 없음 = 미설정 — 호출자는 JSON-LD 렌더를 건너뛴다.\n if (json.configured === false) return null;\n if (typeof json.name !== \"string\" || json.name.length === 0) return null;\n const address =\n json.address &&\n typeof json.address.street_address === \"string\" &&\n typeof json.address.address_locality === \"string\"\n ? {\n streetAddress: json.address.street_address,\n addressLocality: json.address.address_locality,\n addressRegion: json.address.address_region ?? null,\n postalCode: json.address.postal_code ?? null,\n }\n : null;\n const geo =\n json.geo &&\n typeof json.geo.latitude === \"number\" &&\n typeof json.geo.longitude === \"number\"\n ? { latitude: json.geo.latitude, longitude: json.geo.longitude }\n : null;\n const openingHours: BusinessOpeningHours[] = [];\n for (const row of json.opening_hours ?? []) {\n if (\n Array.isArray(row.days) &&\n row.days.length > 0 &&\n typeof row.opens === \"string\" &&\n typeof row.closes === \"string\"\n ) {\n openingHours.push({ days: row.days, opens: row.opens, closes: row.closes });\n }\n }\n return {\n name: json.name,\n legalName: json.legal_name ?? null,\n alternateName: json.alternate_name ?? null,\n businessType:\n typeof json.business_type === \"string\" && json.business_type.length > 0\n ? (json.business_type as BusinessType)\n : \"LocalBusiness\",\n telephone: json.telephone ?? null,\n faxNumber: json.fax_number ?? null,\n email: json.email ?? null,\n address,\n geo,\n openingHours,\n priceRange: json.price_range ?? null,\n areaServed: json.area_served ?? [],\n services: json.services ?? [],\n profiles: {\n naverPlace: json.profiles?.naver_place ?? null,\n googleBusiness: json.profiles?.google_business ?? null,\n kakaoChannel: json.profiles?.kakao_channel ?? null,\n kakaoMap: json.profiles?.kakao_map ?? null,\n instagram: json.profiles?.instagram ?? null,\n naverBlog: json.profiles?.naver_blog ?? null,\n },\n };\n}\n\n/**\n * tenant 의 비즈니스 프로필을 가져온다. 미설정이면 null — 호출자는 null 일 때\n * JSON-LD 렌더를 건너뛰면 된다. 네트워크/서버 에러는 throw — 호출자가\n * `.catch(() => null)` 등으로 fallback.\n */\nexport async function fetchBusinessProfile(\n options: FetchBusinessProfileOptions,\n): Promise<RootTaleBusinessProfile | null> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/business-profile${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n // 구 서버(라우트 미배포)는 404 — 미설정과 동일하게 null.\n if (response.status === 404) return null;\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as BusinessProfileWire;\n return businessProfileFromWire(json);\n}\n\n","import {\n type CmsPostType,\n DEFAULT_BASE_URL,\n assertServer,\n buildGetInit,\n toApiError,\n} from \"./server-base\";\n\n// ── 공개 검색 (WP `?s=` 패리티) ────────────────────────────────────────────\n\nexport interface SearchPostsOptions {\n apiKey: string;\n baseUrl?: string;\n /** 검색 키워드 (title·excerpt·본문 텍스트 매칭). */\n query: string;\n /** 결과 수 1-50, default 10. */\n limit?: number;\n /** 기본 = `post` 만. page 포함 검색은 명시. */\n type?: CmsPostType;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\n/** 검색 결과 카드용 슬림 hit — body 미포함. */\nexport interface CmsSearchHit {\n id: string;\n type: CmsPostType;\n title: string;\n slug: string;\n excerpt: string | null;\n featuredImageUrl: string | null;\n /** ISO 8601. */\n publishedAt: string;\n}\n\n/**\n * published 글 키워드 검색 (사이트 내 검색). 최신 발행순, body 미포함 슬림\n * hit — 상세는 slug 로 `fetchPost`. 구 서버(라우트 미배포)는 404 — 빈 배열.\n */\nexport async function searchPosts(\n options: SearchPostsOptions,\n): Promise<CmsSearchHit[]> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n params.set(\"q\", options.query);\n if (options.limit !== undefined) params.set(\"limit\", String(options.limit));\n if (options.type) params.set(\"type\", options.type);\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const url = `${baseUrl}/v1/cms/public/search?${params.toString()}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (response.status === 404) return [];\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n items: {\n id: string;\n type: CmsPostType;\n title: string;\n slug: string;\n excerpt: string | null;\n featured_media_url: string | null;\n published_at: string;\n }[];\n };\n return json.items.map((w) => ({\n id: w.id,\n type: w.type,\n title: w.title,\n slug: w.slug,\n excerpt: w.excerpt,\n featuredImageUrl: w.featured_media_url ?? null,\n publishedAt: w.published_at,\n }));\n}\n\n// ── 네비게이션 메뉴 (WP 외모>메뉴 패리티) ──────────────────────────────────\n\n/** 메뉴 항목 트리 노드 (깊이 ≤ 2). */\nexport interface RootTaleMenuItem {\n id: string;\n label: string;\n /** 상대 경로(`/about`) 또는 절대 http(s) URL — 서버가 sanitize. */\n url: string;\n /** true 면 `target=\"_blank\" rel=\"noopener\"` 로 렌더할 것. */\n newTab?: boolean;\n children?: RootTaleMenuItem[];\n}\n\nexport interface RootTaleMenu {\n id: string;\n /** 사람용 이름 — \"헤더 메뉴\". */\n name: string;\n /** 위치 핸들 — \"primary\" | \"footer\" 등. */\n slug: string;\n items: RootTaleMenuItem[];\n updatedAt: string | null;\n}\n\nexport interface FetchMenuOptions {\n apiKey: string;\n baseUrl?: string;\n /** 위치 핸들 — \"primary\" | \"footer\" 등. */\n slug: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\nexport interface FetchMenusOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\ntype CmsMenuItemWire = {\n id: string;\n label: string;\n url: string;\n new_tab?: boolean;\n children?: CmsMenuItemWire[];\n};\n\ntype CmsMenuWire = {\n id: string;\n name: string;\n slug: string;\n items: CmsMenuItemWire[];\n updated_at: string | null;\n};\n\nfunction menuItemFromWire(w: CmsMenuItemWire): RootTaleMenuItem {\n const item: RootTaleMenuItem = { id: w.id, label: w.label, url: w.url };\n if (w.new_tab) item.newTab = true;\n if (w.children && w.children.length > 0) {\n item.children = w.children.map(menuItemFromWire);\n }\n return item;\n}\n\nfunction menuFromWire(w: CmsMenuWire): RootTaleMenu {\n return {\n id: w.id,\n name: w.name,\n slug: w.slug,\n items: (w.items ?? []).map(menuItemFromWire),\n updatedAt: w.updated_at ?? null,\n };\n}\n\n/**\n * 위치 핸들 slug 로 네비게이션 메뉴 1개를 가져온다. admin \"디자인 > 메뉴\" 의\n * 저장값. 미설정/미존재(404)는 null — 사이트는 자체 fallback 네비를 렌더한다.\n * 구 서버(라우트 미배포)도 404 → null 로 동일하게 fail-soft.\n */\nexport async function fetchMenu(\n options: FetchMenuOptions,\n): Promise<RootTaleMenu | null> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/menus/${encodeURIComponent(options.slug)}${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (response.status === 404) return null;\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as CmsMenuWire;\n return menuFromWire(json);\n}\n\n/** 사이트의 네비게이션 메뉴 전체. 구 서버(라우트 미배포)는 404 — 빈 배열. */\nexport async function fetchMenus(\n options: FetchMenusOptions,\n): Promise<RootTaleMenu[]> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/menus${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (response.status === 404) return [];\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as { items: CmsMenuWire[] };\n return (json.items ?? []).map(menuFromWire);\n}\n\n// ── 커스텀 리다이렉트 (WP RankMath Redirections 패리티) ─────────────────────\n\nexport interface RootTaleRedirect {\n id: string;\n /** 정규화된 출발 경로 (앞 슬래시, 쿼리 제외). 예: `/old-event`. */\n fromPath: string;\n /** 도착지 — 사이트 내부 경로(`/promo`) 또는 절대 URL. */\n toTarget: string;\n /** 301 영구 / 302 임시. */\n statusCode: 301 | 302;\n}\n\nexport interface FetchRedirectsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\ntype CmsRedirectWire = {\n id: string;\n from_path: string;\n to_target: string;\n status_code: number;\n};\n\n/**\n * 사이트의 활성 커스텀 리다이렉트 규칙 전체. 고객 사이트 middleware\n * (`createRedirectMiddleware`)가 요청 경로 매칭에 사용한다. 구 서버(라우트\n * 미배포)는 404 — 빈 배열로 fail-soft (트래픽을 막지 않음).\n */\nexport async function fetchRedirects(\n options: FetchRedirectsOptions,\n): Promise<RootTaleRedirect[]> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/redirects${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (response.status === 404) return [];\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as { items: CmsRedirectWire[] };\n return (json.items ?? []).map((w) => ({\n id: w.id,\n fromPath: w.from_path,\n toTarget: w.to_target,\n statusCode: (w.status_code === 302 ? 302 : 301) as 301 | 302,\n }));\n}\n","import {\n DEFAULT_BASE_URL,\n assertServer,\n authHeaders,\n buildGetInit,\n toApiError,\n} from \"./server-base\";\n\nexport type SiteInquiryStatus = \"pending\" | \"answered\";\n\nexport interface SiteInquiryListItem {\n id: string;\n title: string;\n category: string | null;\n authorMasked: string | null;\n status: SiteInquiryStatus | null;\n isSecret: boolean;\n locked: boolean;\n createdAt: string | null;\n}\n\nexport interface SiteInquiryListPage {\n items: SiteInquiryListItem[];\n hasMore: boolean;\n nextCursor: string | null;\n}\n\nexport interface SiteInquiryDetail {\n id: string;\n title: string;\n category: string | null;\n authorMasked: string;\n status: SiteInquiryStatus;\n isSecret: boolean;\n body: string;\n answerBody: string | null;\n answeredAt: string | null;\n createdAt: string;\n}\n\nexport interface FetchSiteInquiriesOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n limit?: number;\n cursor?: string;\n signal?: AbortSignal;\n revalidate?: number;\n}\n\nexport interface FetchSiteInquiryOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n id: string;\n password?: string;\n signal?: AbortSignal;\n revalidate?: number;\n}\n\nexport interface SubmitSiteInquiryFields {\n siteId?: string;\n category?: string;\n title: string;\n body: string;\n authorName: string;\n contact?: string;\n isSecret: boolean;\n secretPassword?: string;\n privacyConsent: boolean;\n}\n\nexport interface SubmitSiteInquiryOptions {\n apiKey: string;\n baseUrl?: string;\n fields: SubmitSiteInquiryFields;\n signal?: AbortSignal;\n}\n\nexport interface SubmitSiteInquiryResult {\n id: string;\n status: SiteInquiryStatus;\n}\n\ntype SiteInquiryListItemWire = {\n id: string;\n title: string;\n category: string | null;\n author_masked: string | null;\n status: SiteInquiryStatus | null;\n is_secret: boolean;\n locked: boolean;\n created_at: string | null;\n};\n\ntype SiteInquiryDetailWire = {\n id: string;\n title: string;\n category: string | null;\n author_masked: string;\n status: SiteInquiryStatus;\n is_secret: boolean;\n body: string;\n answer_body: string | null;\n answered_at: string | null;\n created_at: string;\n};\n\nexport async function fetchSiteInquiries(\n options: FetchSiteInquiriesOptions,\n): Promise<SiteInquiryListPage> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.limit !== undefined) params.set(\"limit\", String(options.limit));\n if (options.cursor) params.set(\"cursor\", options.cursor);\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/inquiries${qs ? `?${qs}` : \"\"}`;\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n if (!response.ok) throw await toApiError(response);\n const json = (await response.json()) as {\n items: SiteInquiryListItemWire[];\n has_more: boolean;\n next_cursor: string | null;\n };\n return {\n items: json.items.map(fromListWire),\n hasMore: json.has_more,\n nextCursor: json.next_cursor,\n };\n}\n\nexport async function fetchSiteInquiry(\n options: FetchSiteInquiryOptions,\n): Promise<SiteInquiryDetail | null> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n if (options.password) params.set(\"password\", options.password);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/inquiries/${encodeURIComponent(options.id)}${qs ? `?${qs}` : \"\"}`;\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n if (response.status === 404) return null;\n if (!response.ok) throw await toApiError(response);\n return fromDetailWire((await response.json()) as SiteInquiryDetailWire);\n}\n\nexport async function submitSiteInquiry(\n options: SubmitSiteInquiryOptions,\n): Promise<SubmitSiteInquiryResult> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const fields = options.fields;\n const response = await fetch(`${baseUrl}/v1/cms/public/inquiries`, {\n method: \"POST\",\n headers: {\n ...authHeaders(options.apiKey),\n \"content-type\": \"application/json\",\n },\n body: JSON.stringify({\n site_id: fields.siteId,\n category: fields.category,\n title: fields.title,\n body: fields.body,\n author_name: fields.authorName,\n contact: fields.contact,\n is_secret: fields.isSecret,\n secret_password: fields.secretPassword,\n privacy_consent: fields.privacyConsent,\n }),\n signal: options.signal,\n });\n if (!response.ok) throw await toApiError(response);\n return (await response.json()) as SubmitSiteInquiryResult;\n}\n\nfunction fromListWire(item: SiteInquiryListItemWire): SiteInquiryListItem {\n return {\n id: item.id,\n title: item.title,\n category: item.category,\n authorMasked: item.author_masked,\n status: item.status,\n isSecret: item.is_secret,\n locked: item.locked,\n createdAt: item.created_at,\n };\n}\n\nfunction fromDetailWire(item: SiteInquiryDetailWire): SiteInquiryDetail {\n return {\n id: item.id,\n title: item.title,\n category: item.category,\n authorMasked: item.author_masked,\n status: item.status,\n isSecret: item.is_secret,\n body: item.body,\n answerBody: item.answer_body,\n answeredAt: item.answered_at,\n createdAt: item.created_at,\n };\n}\n","// 프레임워크 무관 SEO 헬퍼 — RSS 2.0 피드 + Schema.org JSON-LD 빌더.\n// 외부 사이트(Next route handler / Astro endpoint / RSC)가 fetchPosts 결과로\n// /feed.xml 응답이나 <head> JSON-LD 를 만들 때 사용. 순수 함수(네트워크 없음).\n\nimport type { CmsPostContent, RootTaleBusinessProfile } from \"./server\";\n\n// ── RSS 2.0 ────────────────────────────────────────────────────────────────\n\nexport interface RssItemInput {\n title: string;\n /** 글 절대 URL. */\n link: string;\n /** 고유 식별자. 미지정 시 link 사용. */\n guid?: string;\n description?: string | null;\n /** 발행 시각. */\n pubDate?: string | Date | null;\n}\n\nexport interface RssFeedInput {\n /** 사이트/피드 제목. */\n title: string;\n /** 사이트 홈 URL. */\n link: string;\n /** 이 피드 자체의 URL (atom:self). */\n feedUrl: string;\n description?: string;\n /** 기본 \"ko-KR\". */\n language?: string;\n items: RssItemInput[];\n}\n\n/** RSS 2.0 XML 문자열 생성. */\nexport function generateRssXml(input: RssFeedInput): string {\n const lang = input.language ?? \"ko-KR\";\n const lastBuild =\n input.items.length > 0 ? toRfc822(input.items[0]!.pubDate) : \"\";\n const lines: string[] = [];\n lines.push('<?xml version=\"1.0\" encoding=\"UTF-8\"?>');\n lines.push(\n '<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">',\n );\n lines.push(\"<channel>\");\n lines.push(`<title>${escapeXml(input.title)}</title>`);\n lines.push(`<link>${escapeXml(input.link)}</link>`);\n lines.push(\n `<atom:link href=\"${escapeXml(input.feedUrl)}\" rel=\"self\" type=\"application/rss+xml\" />`,\n );\n lines.push(\n `<description>${escapeXml(input.description ?? input.title)}</description>`,\n );\n lines.push(`<language>${escapeXml(lang)}</language>`);\n if (lastBuild) lines.push(`<lastBuildDate>${lastBuild}</lastBuildDate>`);\n for (const item of input.items) {\n const guid = item.guid ?? item.link;\n lines.push(\"<item>\");\n lines.push(`<title>${escapeXml(item.title)}</title>`);\n lines.push(`<link>${escapeXml(item.link)}</link>`);\n lines.push(`<guid isPermaLink=\"false\">${escapeXml(guid)}</guid>`);\n if (item.description) {\n lines.push(`<description>${cdata(item.description)}</description>`);\n }\n const pub = toRfc822(item.pubDate);\n if (pub) lines.push(`<pubDate>${pub}</pubDate>`);\n lines.push(\"</item>\");\n }\n lines.push(\"</channel>\");\n lines.push(\"</rss>\");\n return lines.join(\"\\n\");\n}\n\n/** CmsPostContent[] → RSS item[]. postUrl 로 글 절대 URL 생성. */\nexport function rssItemsFromPosts(\n posts: CmsPostContent[],\n opts: { postUrl: (post: CmsPostContent) => string },\n): RssItemInput[] {\n return posts.map((p) => ({\n title: p.title,\n link: opts.postUrl(p),\n guid: opts.postUrl(p),\n description: p.excerpt,\n pubDate: p.publishedAt,\n }));\n}\n\nfunction toRfc822(value: string | Date | null | undefined): string {\n if (!value) return \"\";\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? \"\" : d.toUTCString();\n}\n\n// ── Schema.org JSON-LD 빌더 ─────────────────────────────────────────────────\n\ntype JsonLd = Record<string, unknown>;\n\nexport interface ArticleSchemaInput {\n headline: string;\n url: string;\n description?: string | null;\n image?: string | null;\n datePublished?: string | Date | null;\n dateModified?: string | Date | null;\n authorName?: string | null;\n publisherName?: string;\n publisherLogo?: string;\n /** Schema.org 타입. 기본 \"Article\" (미지정 시 기존 출력 그대로). */\n type?: \"Article\" | \"BlogPosting\";\n /** 지정 시 `@id` emit (관례: `<url>#article`). */\n id?: string;\n /** 둘 다 있고 image 도 있으면 image 를 ImageObject 로 승격. */\n imageWidth?: number;\n imageHeight?: number;\n /** 저자 페이지 URL — 있으면 author 에 url + `@id`(`<authorUrl>#person`) 추가. */\n authorUrl?: string;\n /** 있으면 publisher 에 `@id` 추가 (name/logo 는 유지). */\n publisherId?: string;\n /** → articleSection. */\n section?: string;\n /** → inLanguage (기본값 없음, 지정 시만). */\n inLanguage?: string;\n}\n\nexport function articleSchema(input: ArticleSchemaInput): JsonLd {\n const schema: JsonLd = {\n \"@context\": \"https://schema.org\",\n \"@type\": input.type ?? \"Article\",\n headline: input.headline,\n mainEntityOfPage: { \"@type\": \"WebPage\", \"@id\": input.url },\n };\n if (input.id) schema[\"@id\"] = input.id;\n if (input.description) schema.description = input.description;\n if (input.image) {\n schema.image =\n input.imageWidth && input.imageHeight\n ? {\n \"@type\": \"ImageObject\",\n url: input.image,\n width: input.imageWidth,\n height: input.imageHeight,\n }\n : input.image;\n }\n const pub = toIso(input.datePublished);\n if (pub) schema.datePublished = pub;\n const mod = toIso(input.dateModified);\n if (mod) schema.dateModified = mod;\n if (input.authorName) {\n schema.author = {\n \"@type\": \"Person\",\n name: input.authorName,\n ...(input.authorUrl\n ? { \"@id\": `${input.authorUrl}#person`, url: input.authorUrl }\n : {}),\n };\n }\n if (input.publisherName) {\n schema.publisher = {\n \"@type\": \"Organization\",\n ...(input.publisherId ? { \"@id\": input.publisherId } : {}),\n name: input.publisherName,\n ...(input.publisherLogo\n ? { logo: { \"@type\": \"ImageObject\", url: input.publisherLogo } }\n : {}),\n };\n }\n if (input.section) schema.articleSection = input.section;\n if (input.inLanguage) schema.inLanguage = input.inLanguage;\n return schema;\n}\n\n/**\n * 저자 프로필 페이지용 ProfilePage JSON-LD.\n * `/blog/author/<slug>` 같은 저자 페이지 `<head>` 에서 사용.\n */\nexport function profilePageSchema(input: {\n url: string;\n name: string;\n image?: string | null;\n description?: string | null;\n}): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"ProfilePage\",\n mainEntity: {\n \"@type\": \"Person\",\n \"@id\": `${input.url}#person`,\n name: input.name,\n url: input.url,\n ...(input.image ? { image: input.image } : {}),\n ...(input.description ? { description: input.description } : {}),\n },\n };\n}\n\n/**\n * 글 목록·카테고리 허브 페이지용 CollectionPage JSON-LD.\n * `/blog`, `/blog/category/<slug>` 등에서 사용.\n */\nexport function collectionPageSchema(input: {\n url: string;\n name: string;\n description?: string | null;\n siteUrl?: string;\n}): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"CollectionPage\",\n \"@id\": `${input.url}#collection`,\n url: input.url,\n name: input.name,\n ...(input.description ? { description: input.description } : {}),\n ...(input.siteUrl\n ? { isPartOf: { \"@type\": \"WebSite\", url: input.siteUrl } }\n : {}),\n };\n}\n\nexport function breadcrumbSchema(\n items: { name: string; url: string }[],\n): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"BreadcrumbList\",\n itemListElement: items.map((it, i) => ({\n \"@type\": \"ListItem\",\n position: i + 1,\n name: it.name,\n item: it.url,\n })),\n };\n}\n\nexport function organizationSchema(input: {\n name: string;\n url: string;\n logo?: string;\n sameAs?: string[];\n}): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"Organization\",\n name: input.name,\n url: input.url,\n ...(input.logo ? { logo: input.logo } : {}),\n ...(input.sameAs && input.sameAs.length > 0 ? { sameAs: input.sameAs } : {}),\n };\n}\n\n// ── LocalBusiness (로컬 SEO) ────────────────────────────────────────────────\n\n/** Mo~Su 약어 → Schema.org dayOfWeek 전체 이름. */\nconst DAY_OF_WEEK: Record<string, string> = {\n Mo: \"Monday\",\n Tu: \"Tuesday\",\n We: \"Wednesday\",\n Th: \"Thursday\",\n Fr: \"Friday\",\n Sa: \"Saturday\",\n Su: \"Sunday\",\n};\n\n/**\n * `localBusinessSchema` 입력 — `fetchBusinessProfile` 의 반환값\n * (RootTaleBusinessProfile) 을 그대로 받도록 느슨하게 정의 (null/undefined 모두\n * 허용). 수동 구성도 가능.\n */\nexport interface LocalBusinessProfileInput {\n name: string;\n legalName?: string | null;\n /** 간판명의 표기 변형(띄어쓰기 등) — alternateName. */\n alternateName?: string | null;\n /** Schema.org LocalBusiness 하위 타입. 미지정 시 \"LocalBusiness\". */\n businessType?: string | null;\n telephone?: string | null;\n faxNumber?: string | null;\n email?: string | null;\n address?: {\n streetAddress: string;\n addressLocality: string;\n addressRegion?: string | null;\n postalCode?: string | null;\n } | null;\n geo?: { latitude: number; longitude: number } | null;\n openingHours?:\n | { days: string[]; opens: string; closes: string }[]\n | null;\n priceRange?: string | null;\n areaServed?: string[] | null;\n /** 취급 업무 — hasOfferCatalog 의 Offer 목록으로 들어간다. */\n services?: string[] | null;\n /** 외부 프로필 URL — 값들이 sameAs 로 들어간다 (네이버플레이스·구글 등). */\n profiles?: Record<string, string | null | undefined> | null;\n}\n\n/**\n * LocalBusiness JSON-LD — admin \"운영 > 비즈니스 프로필\" 의\n * `fetchBusinessProfile` 결과로 layout 에 1회 렌더한다 (로컬 SEO).\n *\n * - `@type` 은 `[businessType, \"LocalBusiness\"]` 중복 제거 배열.\n * - `profiles` 의 URL 들은 `sameAs` 로 — 검색엔진이 네이버플레이스/구글\n * 비즈니스 프로필과 동일 사업장임을 연결한다.\n */\n/** 취급 업무 상한 — api-contracts / api-core sanitize 와 같은 값이어야 한다. */\nexport const LOCAL_BUSINESS_MAX_SERVICES = 12;\nexport const LOCAL_BUSINESS_SERVICE_MAX_LENGTH = 80;\n\n/** hasMap 으로 빠지고 sameAs 에는 넣지 않는 지도 딥링크 키. */\nconst MAP_PROFILE_KEYS = new Set([\"kakaoMap\", \"kakao_map\"]);\n\nexport function localBusinessSchema(\n profile: LocalBusinessProfileInput | RootTaleBusinessProfile,\n opts: { url: string; image?: string | null },\n): JsonLd {\n const input = profile as LocalBusinessProfileInput;\n const types = Array.from(\n new Set([input.businessType || \"LocalBusiness\", \"LocalBusiness\"]),\n );\n const schema: JsonLd = {\n \"@context\": \"https://schema.org\",\n \"@type\": types,\n name: input.name,\n url: opts.url,\n };\n if (input.legalName) schema.legalName = input.legalName;\n if (input.alternateName) schema.alternateName = input.alternateName;\n if (opts.image) schema.image = opts.image;\n if (input.telephone) schema.telephone = input.telephone;\n if (input.faxNumber) schema.faxNumber = input.faxNumber;\n if (input.email) schema.email = input.email;\n if (input.address) {\n schema.address = {\n \"@type\": \"PostalAddress\",\n streetAddress: input.address.streetAddress,\n addressLocality: input.address.addressLocality,\n ...(input.address.addressRegion\n ? { addressRegion: input.address.addressRegion }\n : {}),\n ...(input.address.postalCode\n ? { postalCode: input.address.postalCode }\n : {}),\n addressCountry: \"KR\",\n };\n }\n if (input.geo) {\n schema.geo = {\n \"@type\": \"GeoCoordinates\",\n latitude: input.geo.latitude,\n longitude: input.geo.longitude,\n };\n }\n const openingHours = (input.openingHours ?? []).filter(\n (row) => row.days.length > 0 && row.opens && row.closes,\n );\n if (openingHours.length > 0) {\n schema.openingHoursSpecification = openingHours.map((row) => ({\n \"@type\": \"OpeningHoursSpecification\",\n dayOfWeek: row.days.map((d) => DAY_OF_WEEK[d] ?? d),\n opens: row.opens,\n closes: row.closes,\n }));\n }\n if (input.priceRange) schema.priceRange = input.priceRange;\n if (input.areaServed && input.areaServed.length > 0) {\n schema.areaServed = input.areaServed;\n }\n // 취급 업무 → OfferCatalog. \"무엇을 하는 곳인가\" 를 검색엔진에 명시하는 신호.\n //\n // 이 함수는 공개 API 라 admin/sanitize 를 거치지 않은 수동 입력도 들어온다.\n // 두 경로의 결과가 갈리지 않도록 여기서도 같은 정규화(공백 정리 → 길이 제한\n // → 중복 제거 → 개수 제한)를 적용한다.\n //\n // Service 단위 areaServed 는 넣지 않는다 — 서비스별 지역 데이터가 없어서\n // 첫 지역만 쓰면 \"이 서비스는 서울에서만 제공\" 이라는 없는 사실을 주장하게 된다.\n // 지역은 최상위 areaServed 가 이미 담고 있다.\n const services: string[] = [];\n for (const raw of input.services ?? []) {\n if (services.length >= LOCAL_BUSINESS_MAX_SERVICES) break;\n if (typeof raw !== \"string\") continue;\n const name = raw.trim().slice(0, LOCAL_BUSINESS_SERVICE_MAX_LENGTH);\n if (name && !services.includes(name)) services.push(name);\n }\n if (services.length > 0) {\n schema.hasOfferCatalog = {\n \"@type\": \"OfferCatalog\",\n name: `${input.name} 서비스`,\n itemListElement: services.map((name) => ({\n \"@type\": \"Offer\",\n itemOffered: { \"@type\": \"Service\", name },\n })),\n };\n }\n const profiles = input.profiles ?? {};\n // 지도 링크는 sameAs 가 아니라 hasMap 이 정확한 자리다. 네이버 플레이스 우선,\n // 없으면 지도 딥링크로 대체한다. `profiles` 는 수동 구성도 허용하는 느슨한\n // Record 라, 지도 키는 camelCase·snake_case 를 모두 인정한다.\n const pickUrl = (key: string): string | null => {\n const value = profiles[key];\n return typeof value === \"string\" && value.length > 0 ? value : null;\n };\n const mapUrl = pickUrl(\"naverPlace\") ?? pickUrl(\"kakaoMap\") ?? pickUrl(\"kakao_map\");\n if (mapUrl) schema.hasMap = mapUrl;\n // sameAs 는 \"같은 사업체를 가리키는 프로필 페이지\" 만 — 지도 딥링크는 프로필이\n // 아니라 위치 링크라 hasMap 으로만 내보내고 여기서는 뺀다.\n const sameAs = Object.entries(profiles)\n .filter(([key]) => !MAP_PROFILE_KEYS.has(key))\n .map(([, value]) => value)\n .filter((u): u is string => typeof u === \"string\" && u.length > 0);\n if (sameAs.length > 0) schema.sameAs = sameAs;\n return schema;\n}\n\nexport function websiteSchema(input: {\n name: string;\n url: string;\n searchUrl?: string;\n}): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"WebSite\",\n name: input.name,\n url: input.url,\n ...(input.searchUrl\n ? {\n potentialAction: {\n \"@type\": \"SearchAction\",\n target: `${input.searchUrl}{search_term_string}`,\n \"query-input\": \"required name=search_term_string\",\n },\n }\n : {}),\n };\n}\n\nexport function faqSchema(\n qa: { question: string; answer: string }[],\n): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"FAQPage\",\n mainEntity: qa.map((x) => ({\n \"@type\": \"Question\",\n name: x.question,\n acceptedAnswer: { \"@type\": \"Answer\", text: x.answer },\n })),\n };\n}\n\n/**\n * ADR-0065 — 글 조회수 식별자 meta 태그 HTML.\n *\n * 글 상세 페이지 `<head>` 에 렌더하면, RootTale 비콘이 이 값을 읽어 pageview 에\n * `post_id` 를 동봉한다 → URL/컬렉션 basePath 변경에 무관하게 조회수가 해당 글에\n * 정확히 집계된다. `post.id`(uuidv7) 를 그대로 넘기면 된다.\n *\n * Next App Router 는 `generateMetadata` 의 `other: { \"rt:content-id\": post.id }`\n * 로도 동일 태그를 낼 수 있다 — 그 경우 본 헬퍼 대신 metadata 를 쓴다.\n *\n * @example\n * // 프레임워크 무관(Astro/RSC set:html 등):\n * <head>{contentIdMeta(post.id)}</head>\n */\nexport function contentIdMeta(postId: string): string {\n return `<meta name=\"rt:content-id\" content=\"${escapeXml(postId)}\" />`;\n}\n\n/**\n * JSON-LD 객체 → XSS-safe JSON *문자열* (script 태그 없음). React 소비자가\n * `<script type=\"application/ld+json\" dangerouslySetInnerHTML={{ __html: ... }} />`\n * 로 직접 방출할 때 사용 — `</script>` 주입 방지 escape 의 단일 소스.\n */\nexport function jsonLdJson(schema: JsonLd | JsonLd[]): string {\n // `</script>` 주입 방지를 위해 `<` 이스케이프.\n return JSON.stringify(schema).replace(/</g, \"\\\\u003c\");\n}\n\n/** JSON-LD 객체 → `<script type=\"application/ld+json\">` HTML 문자열 (XSS-safe). */\nexport function jsonLdScript(schema: JsonLd | JsonLd[]): string {\n return `<script type=\"application/ld+json\">${jsonLdJson(schema)}</script>`;\n}\n\nfunction toIso(value: string | Date | null | undefined): string | null {\n if (!value) return null;\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? null : d.toISOString();\n}\n\nfunction escapeXml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n\nfunction cdata(value: string): string {\n return `<![CDATA[${value.replace(/]]>/g, \"]]]]><![CDATA[>\")}]]>`;\n}\n","/**\n * `@roottale/cms-client` — 리드(문의) 접수 server-side 헬퍼.\n *\n * 외부 고객사 사이트가 *블로그 조회(`fetchPosts`)와 동일한 API key 하나* 로 리드도\n * 접수하기 위한 SSR-only 클라이언트. 브라우저가 아니라 사이트 서버(Server Action /\n * Route Handler)에서 호출해야 한다 — API key 는 절대 브라우저로 노출 금지\n * (ADR-0023 §5.1 #15). 키가 tenant 를 권위 식별하므로 `tenant_slug` 불필요.\n *\n * 사용 예 (Next.js server action):\n * ```ts\n * \"use server\";\n * import { submitInquiry } from \"@roottale/cms-client/server\";\n *\n * const result = await submitInquiry({\n * apiKey: process.env.ROOTTALE_API_KEY!, // fetchPosts 와 같은 키\n * fields: {\n * vertical: \"tax\",\n * contactName: name,\n * businessName: company,\n * email,\n * phone,\n * message,\n * privacyConsent: true,\n * },\n * });\n * if (result.ok) showSuccess(); // 그 키의 tenant /crm 에 도착\n * else showError(result.message);\n * ```\n *\n * intake API 는 302 `?ok=1` / `?err=*` 로 응답 — 본 헬퍼가 파싱해 구조화 결과 반환.\n */\n\nimport type { InquiryAttribution } from \"./attribution\";\n\nconst DEFAULT_BASE_URL = \"https://api.roottale.com\";\n\nexport type InquiryVertical = \"consulting\" | \"medical\" | \"tax\" | \"legal\";\n\n/** intake API 의 `err` 코드 → 한국어 사용자 메시지. */\nexport const INQUIRY_ERROR_MESSAGES: Record<string, string> = {\n consent_privacy: \"개인정보 수집·이용 동의가 필요합니다.\",\n consent_overseas: \"의료 분야는 국외이전 동의가 필요합니다.\",\n invalid_vertical: \"업종 분류가 유효하지 않습니다.\",\n missing_fields: \"필수 항목이 비어 있습니다.\",\n invalid_email: \"이메일 형식이 올바르지 않습니다.\",\n invalid_api_key: \"연동 인증에 실패했습니다. API 키를 확인해주세요.\",\n rate_limited: \"요청이 너무 많습니다. 잠시 후 다시 시도해주세요.\",\n turnstile_failed: \"보안 확인에 실패했습니다. 새로고침 후 다시 시도해주세요.\",\n internal: \"전송에 실패했습니다. 잠시 후 다시 시도해주세요.\",\n};\n\nexport interface SubmitInquiryFields {\n vertical: InquiryVertical;\n contactName: string;\n businessName: string;\n email: string;\n phone: string;\n /** 자유 문의 내용 (선택). */\n message?: string;\n /** 상담 분야 라벨 등 부가 메타 (선택). */\n consultationField?: string;\n /** 현재 사이트 URL (선택). */\n currentSiteUrl?: string;\n /** 개인정보 수집·이용 동의 (필수 true). */\n privacyConsent: boolean;\n /** 의료 vertical 의 국외이전 동의 (medical 일 때 필수). */\n overseasTransferConsent?: boolean;\n /**\n * \"patient\"(고객사 방문자 문의, 기본) | \"sales\"(RootTale 자체 B2B 영업 리드).\n * sales 는 medical 국외이전 동의 게이트 면제.\n */\n leadKind?: \"patient\" | \"sales\";\n turnstileToken?: string;\n /**\n * 표준 필드 외에 폼이 받은 임의 항목(key→value)을 있는 그대로 전달. intake 가\n * 암호화 보관하고 CRM 상세에서 노출한다. 예약 키(표준 필드)·`_` 접두 키는 무시.\n * 주의: 개인정보가 담길 수 있으므로 폼의 동의 고지에 수집 항목을 반영할 것.\n */\n extras?: Record<string, string>;\n /**\n * 유입 first-touch 어트리뷰션 (ADR-0048 §2, 선택). 브라우저에서\n * `readAttribution()`(`@roottale/cms-client/attribution`)으로 읽어 Server\n * Action 인자로 넘긴 값을 그대로 전달하면 된다. CRM 에 \"이 문의가 어느\n * 글/링크/QR 에서 왔는지\"가 표시된다. null/undefined 면 미동반 제출.\n */\n attribution?: InquiryAttribution | null;\n /**\n * 방문 여정(journey) JSON 원문(설계 v2 §3, 선택) — `@roottale/analytics-runtime`\n * `readJourney()` 로 읽은 sessionStorage `_rt_journey` 링버퍼를 그대로\n * `JSON.stringify` 한 문자열. 파싱·검증은 서버(zod, medical/legal 마일스톤\n * 게이트 재강제)가 한다 — 여기서는 원문 그대로 `attr_journey` FormData 로\n * 전달만. null/undefined/빈 문자열이면 미동반 제출.\n */\n journey?: string;\n}\n\n/** submitInquiry 가 전용으로 매핑하는 키 — extras 에서 충돌 시 무시. */\nconst RESERVED_INQUIRY_KEYS = new Set([\n \"vertical\",\n \"contact_name\",\n \"business_name\",\n \"email\",\n \"phone\",\n \"message\",\n \"consultation_field\",\n \"current_site_url\",\n \"privacy_consent\",\n \"overseas_transfer_consent\",\n \"lead_kind\",\n \"cf-turnstile-response\",\n]);\n\nexport interface SubmitInquiryOptions {\n /** `rtlk_cust_*` API key — `fetchPosts` 와 동일한 키. Required. */\n apiKey: string;\n /** API base URL 오버라이드. 기본 `https://api.roottale.com`. */\n baseUrl?: string;\n fields: SubmitInquiryFields;\n signal?: AbortSignal;\n}\n\nexport type SubmitInquiryResult =\n | { ok: true }\n | { ok: false; code: string | null; message: string };\n\n/** `err` 코드를 사용자 메시지로 (미지의 코드는 generic). */\nexport function inquiryErrorMessage(code: string | null | undefined): string {\n return (code && INQUIRY_ERROR_MESSAGES[code]) || INQUIRY_ERROR_MESSAGES.internal;\n}\n\n/**\n * intake API 의 302 Location (`?ok=1` / `?err=*`) 을 구조화 결과로 파싱.\n * 외부 사이트가 ERROR_MESSAGES·파싱을 각자 재구현하지 않도록 공유.\n */\nexport function parseInquiryRedirect(\n location: string | null,\n base: string = DEFAULT_BASE_URL,\n): SubmitInquiryResult {\n if (!location) {\n return { ok: false, code: null, message: inquiryErrorMessage(null) };\n }\n let url: URL;\n try {\n url = new URL(location, base);\n } catch {\n return { ok: false, code: null, message: inquiryErrorMessage(null) };\n }\n if (url.searchParams.get(\"ok\") === \"1\") return { ok: true };\n const code = url.searchParams.get(\"err\");\n return { ok: false, code, message: inquiryErrorMessage(code) };\n}\n\nexport function formatKoreanPhoneNumber(input: string): string {\n let digits = input.replace(/\\D/g, \"\");\n if (digits.startsWith(\"82\") && digits.length >= 11) {\n digits = `0${digits.slice(2)}`;\n }\n if (digits.length === 11) {\n return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;\n }\n if (digits.length === 10) {\n if (digits.startsWith(\"02\")) {\n return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}`;\n }\n return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;\n }\n if (digits.length === 8) {\n return `${digits.slice(0, 4)}-${digits.slice(4)}`;\n }\n return input.trim();\n}\n\n/**\n * 리드를 RootTale intake API 로 제출 (server-to-server, Bearer 인증).\n * 키의 tenant 로 라우팅되어 admin(admin.roottale.com) /crm 에서 관리된다.\n */\nexport async function submitInquiry(\n options: SubmitInquiryOptions,\n): Promise<SubmitInquiryResult> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const f = options.fields;\n\n const body = new FormData();\n body.set(\"vertical\", f.vertical);\n body.set(\"contact_name\", f.contactName);\n body.set(\"business_name\", f.businessName);\n body.set(\"email\", f.email);\n body.set(\"phone\", formatKoreanPhoneNumber(f.phone));\n if (f.message) body.set(\"message\", f.message);\n if (f.consultationField) body.set(\"consultation_field\", f.consultationField);\n if (f.currentSiteUrl) body.set(\"current_site_url\", f.currentSiteUrl);\n // 사용자가 명시 동의한 경우에만 전달 (자동 부여 금지).\n if (f.privacyConsent) body.set(\"privacy_consent\", \"on\");\n if (f.overseasTransferConsent) body.set(\"overseas_transfer_consent\", \"on\");\n if (f.leadKind) body.set(\"lead_kind\", f.leadKind);\n if (f.turnstileToken) body.set(\"cf-turnstile-response\", f.turnstileToken);\n // 유입 어트리뷰션 → intake `attr_*` 필드 (허용 키만, 빈 값 생략).\n if (f.attribution) {\n for (const [key, value] of Object.entries(f.attribution)) {\n // ad_click(설계 v2 §2) 은 boolean \"존재 플래그\" — true 일 때만 \"1\" 로\n // 전송(false/미설정은 신호 없음과 동일하므로 생략).\n if (typeof value === \"boolean\") {\n if (value) body.set(`attr_${key}`, \"1\");\n continue;\n }\n if (typeof value !== \"string\" || !value.trim()) continue;\n body.set(`attr_${key}`, value.trim());\n }\n }\n // 방문 여정(설계 v2 §3) — attribution 과 별도 채널(`attr_journey`). 파싱은\n // 서버(zod)가 전담 — 여기서는 원문 그대로 전달.\n if (f.journey && f.journey.trim()) {\n body.set(\"attr_journey\", f.journey.trim());\n }\n // 표준 외 임의 항목 — 예약 키/`_`접두/빈 값/중복은 건너뛰고 원본 그대로 전달.\n if (f.extras) {\n for (const [rawKey, rawValue] of Object.entries(f.extras)) {\n const key = rawKey.trim();\n const value = String(rawValue ?? \"\").trim();\n if (!key || !value) continue;\n if (key.startsWith(\"_\") || RESERVED_INQUIRY_KEYS.has(key)) continue;\n if (key.startsWith(\"attr_\")) continue; // 어트리뷰션 채널과 충돌 방지\n\n if (body.has(key)) continue;\n body.set(key, value);\n }\n }\n\n let response: Response;\n try {\n response = await fetch(`${baseUrl}/v1/public/inquiries`, {\n method: \"POST\",\n headers: { authorization: `Bearer ${options.apiKey}` },\n body,\n // intake 는 302 로 결과 신호 — 자동 추적 금지하고 Location 직접 읽음.\n redirect: \"manual\",\n signal: options.signal,\n });\n } catch {\n return { ok: false, code: \"internal\", message: inquiryErrorMessage(\"internal\") };\n }\n\n // 잘못된/만료된 키 → 라우트가 401.\n if (response.status === 401) {\n return {\n ok: false,\n code: \"invalid_api_key\",\n message: inquiryErrorMessage(\"invalid_api_key\"),\n };\n }\n if (response.status === 429) {\n return {\n ok: false,\n code: \"rate_limited\",\n message: inquiryErrorMessage(\"rate_limited\"),\n };\n }\n\n const location = response.headers.get(\"location\");\n if (location) return parseInquiryRedirect(location, baseUrl);\n\n // redirect 없이 성공 응답이면 ok 로 간주, 그 외엔 generic 실패.\n if (response.ok) return { ok: true };\n return { ok: false, code: \"internal\", message: inquiryErrorMessage(\"internal\") };\n}\n\nfunction assertServer(): void {\n if (typeof window !== \"undefined\") {\n throw new Error(\n \"@roottale/cms-client submitInquiry must not run in the browser. \" +\n \"Call it from a Server Action / Route Handler. \" +\n \"Shipping the API key to the browser breaks the security model (ADR-0023 §5.1 #15).\",\n );\n }\n}\n"],"mappings":"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","names":["DEFAULT_BASE_URL","assertServer"]}
1
+ {"version":3,"sources":["../src/server-base.ts","../src/server-posts-theme.ts","../src/server-blog.ts","../src/server-categories.ts","../src/server-business.ts","../src/server-discovery.ts","../src/server-site-inquiries.ts","../src/server-cache-tags.ts","../src/seo.ts","../src/inquiry.ts"],"sourcesContent":["/**\n * `@roottale/cms-client/server` — RootTale CMS Public API server-side fetch client.\n *\n * 외부 고객사 사이트가 RootTale CMS 콘텐츠를 fetch 하기 위한 SSR-only client.\n * Bearer (`rtlk_cust_*`) 인증, 브라우저 직접 호출 금지 (ADR-0023 §5.1 #15).\n *\n * 사용 예 (Next.js server component):\n * ```ts\n * import { fetchPosts } from \"@roottale/cms-client/server\";\n *\n * const posts = await fetchPosts({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * limit: 10,\n * });\n * ```\n *\n * 데이터 모델 = ADR-0034 `posts` (WP 7.0-aligned, type=post|page).\n * Phase 1 scope: list + get. WP import/export, draft preview = 별도 PR.\n */\n\nexport type CmsPostType = \"post\" | \"page\";\n\nexport type CmsTaxonomyKind = \"category\" | \"tag\" | \"custom\";\n\nexport interface CmsTermRef {\n id: string;\n taxonomy: CmsTaxonomyKind;\n slug: string;\n name: string;\n}\n\nexport interface CmsArchiveRelatedPost {\n id: string;\n title: string;\n slug: string;\n excerpt: string | null;\n collectionKey: string;\n}\n\nexport interface CmsArchiveRelatedGroup {\n collectionKey: string;\n label: string;\n items: CmsArchiveRelatedPost[];\n}\n\n/** W5-2 — `fields` 표시 메타 1건 (서버 계약 `CmsFieldMeta` 대응). */\nexport interface CmsFieldMetaEntry {\n /** `fields` 의 key. */\n name: string;\n /** 필드 그룹에 정의된 사람용 라벨. */\n label: string;\n /** 필드 타입 (text/textarea/select/relationship/…). */\n type: string;\n /** select/radio 계열만 — 저장 값(value)→표시 라벨. */\n choices?: Array<{ value: string; label: string }>;\n}\n\n/** ADR-0052 A안 (W4-6 PR B) — 언어판 참조(locale + 그 언어판의 slug). */\nexport interface CmsPostTranslationRef {\n locale: string;\n slug: string;\n}\n\nexport interface CmsPostContent {\n /** UUIDv7. */\n id: string;\n /** Owning tenant id. */\n tenantId: string;\n /** Owning site id (ADR-0034 P4 dual-key). */\n siteId: string;\n /** WP post type. */\n type: CmsPostType;\n /**\n * ADR-0060 Amendment 1 — 글의 명시 섹션(content collection) key. 라우팅/스트림\n * 판정에 카테고리 파생보다 우선. 구 서버/미설정이면 null.\n */\n collectionKey: string | null;\n /** Lowercase kebab-case slug (1-120 chars). */\n slug: string;\n /**\n * ADR-0052 A안 — 이 글판의 BCP-47 locale. 단일 언어 사이트는 항상 'ko'.\n * 구 서버(마이그레이션 전) 는 미포함 — optional (하위호환).\n */\n locale?: string;\n /**\n * ADR-0052 A안 — 같은 글의 발행된 언어판 목록(이 글 자신 포함). 번역이 없으면\n * 자기 자신 1건만. 구 서버는 미포함 — optional (하위호환).\n */\n translations?: CmsPostTranslationRef[];\n title: string;\n excerpt: string | null;\n /** Media id (resolved by renderer / consumer to URL). */\n featuredMediaId: string | null;\n /**\n * featured 이미지 delivery URL (CF Images variant, md 우선). 서버가 `featured_media_id`\n * → `media.variants_json` 를 해석해 제공. 대표 이미지 미설정 시 null.\n */\n featuredImageUrl: string | null;\n authorId: string | null;\n /** Public display name for the post author. Email is not exposed. */\n authorName: string | null;\n /** Public profile image URL for the post author. */\n authorImageUrl: string | null;\n /** Public biography text for the post author. */\n authorBio: string | null;\n /**\n * Author archive slug (`/blog/author/{slug}`). null when the author has no\n * assigned slug. 구 서버는 미포함 — 매핑부에서 `?? null` 기본값.\n */\n authorSlug: string | null;\n /** Tiptap / Gutenberg-compatible Block JSON. Sanitization is renderer's responsibility. */\n bodyJson: Record<string, unknown>;\n /** Public API only exposes `published`. */\n status: \"published\";\n /** Per-post meta (SEO overrides, canonical, og, ...). */\n metaJson: Record<string, unknown>;\n /**\n * ADR-0041 — 포맷된 커스텀 필드(ACF) 값. 서버가 필드 정의(코드 ⊕ DB 그룹)로\n * `meta_json.acf` 를 포맷 — image/file/gallery 는 media variant URL 객체.\n * 해석된 그룹이 없거나 구 서버면 null.\n */\n fields: Record<string, unknown> | null;\n /**\n * W5-2 — `fields` 의 표시 메타(라벨·타입·choices), 필드 그룹 정의 순서.\n * 서버가 필드 정의로 계산해 동봉 — 렌더러가 vertical 지식 없이 양식 값을\n * 라벨과 함께 표시한다. 해석된 그룹 없거나 구 서버면 빈 배열.\n */\n fieldsMeta: CmsFieldMetaEntry[];\n /**\n * Attached taxonomy terms (categories + tags + custom). Server 가 category →\n * tag → custom 순서로 정렬해 보내며, 각 taxonomy 내부는 name asc. attach 가 0 개면\n * 빈 배열. category 만 추출하려면 `terms.filter(t => t.taxonomy === \"category\")`.\n */\n terms: CmsTermRef[];\n archiveRelated: CmsArchiveRelatedGroup[];\n /**\n * ADR-0065 — 누적 조회수. 어드민에서 \"공개 조회수\"를 켠 사이트에서만 내려온다\n * (꺼져 있으면 `undefined`). \"조회 N\" 표시에 사용. 비콘이 채우므로 신규 글은 0.\n */\n viewCount?: number;\n /** ISO 8601. */\n publishedAt: string;\n /** ISO 8601. */\n createdAt: string;\n /** ISO 8601. */\n updatedAt: string;\n}\n\nexport interface CmsPostContentPage {\n items: CmsPostContent[];\n nextCursor: string | null;\n hasMore: boolean;\n}\n\nexport const DEFAULT_BASE_URL = \"https://api.roottale.com\";\n\n// fleet 가시성 — 자기 버전을 자가 보고 (build-time 주입, tsup define).\n// api-core 가 사이트별 check-in 으로 기록 → internal `GET /v1/internal/sites`.\ndeclare const __CMS_CLIENT_VERSION__: string | undefined;\nconst CLIENT_VERSION =\n typeof __CMS_CLIENT_VERSION__ === \"string\" ? __CMS_CLIENT_VERSION__ : \"dev\";\n\n/** 설치된 cms-client 버전 (build-time 주입) — fleet well-known 라우트 등에서 사용. */\nexport const CMS_CLIENT_VERSION = CLIENT_VERSION;\n\nexport function authHeaders(apiKey: string): Record<string, string> {\n return {\n authorization: `Bearer ${apiKey}`,\n \"x-roottale-client\": `cms-client/${CLIENT_VERSION}`,\n };\n}\n\n/**\n * ISR 시간 기반 fallback(W0-9). 지정 시 fetch 에 Next 의 `next: { revalidate }`\n * 를 붙여 webhook 유실 시에도 이 주기(초) 내에 캐시가 자가 치유된다.\n * **미지정(undefined)이면 아무 것도 붙이지 않아 현행 동작을 그대로 유지**(파괴적\n * 변경 없음) — Next 기본 캐시/정적화 정책을 따른다. `0` 은 항상 재검증(동적).\n */\nexport interface RevalidatableFetchOptions {\n /** Optional AbortSignal. */\n signal?: AbortSignal;\n /**\n * ISR revalidate 초. opt-in — 미지정 시 현행 캐시 동작 유지.\n * (Next.js `next: { revalidate }` 로 전달.)\n */\n revalidate?: number;\n}\n\n/** Next `fetch` 확장 — `next.revalidate`/`next.tags` 지원(런타임 무해, non-Next 무시). */\ntype NextFetchInit = RequestInit & {\n next?: { revalidate?: number; tags?: string[] };\n};\n\n/**\n * GET fetch 용 RequestInit 빌더 — Bearer 헤더 + signal + opt-in ISR revalidate\n * + opt-in cache tags. cms-client 의 모든 공개 콘텐츠/테마 fetch 가 공유해\n * `next.revalidate`/`next.tags` 지원을 일관되게 얻는다.\n *\n * `tags` 는 on-demand 무효화 경로(W0-4): 지정 시 fetch 캐시 엔트리에 tag 를\n * 붙여, 수신 route 의 `revalidateTag(tag)` 가 해당 엔트리(+그 fetch 를 쓴 route)를\n * 무효화한다. 미지정 = 붙이지 않음(현행 동작 유지).\n */\nexport function buildGetInit(\n apiKey: string,\n opts: { signal?: AbortSignal; revalidate?: number; tags?: string[] },\n): NextFetchInit {\n const init: NextFetchInit = {\n method: \"GET\",\n headers: authHeaders(apiKey),\n signal: opts.signal,\n };\n if (opts.revalidate !== undefined || (opts.tags && opts.tags.length > 0)) {\n init.next = {\n ...(opts.revalidate !== undefined ? { revalidate: opts.revalidate } : {}),\n ...(opts.tags && opts.tags.length > 0 ? { tags: opts.tags } : {}),\n };\n }\n return init;\n}\n\nexport interface FetchPostsOptions {\n /** `rtlk_cust_*` API key. Required. */\n apiKey: string;\n /** Override the API base URL. Defaults to `https://api.roottale.com`. */\n baseUrl?: string;\n /** Page size, 1-100, default 20. */\n limit?: number;\n /** Opaque pagination cursor returned from previous call. */\n cursor?: string;\n /** Filter by post type (`post` or `page`). Default = both. */\n type?: CmsPostType;\n /** 작가 slug 필터 — 작가 아카이브(/blog/author/{slug})용. 지정 시 발행 글(post)만. */\n author?: string;\n /** Override the auto-resolved site id (Phase 1 = 1 site/tenant, usually omitted). */\n siteId?: string;\n /**\n * ADR-0060 — 특정 섹션(collection) 소속 글만(포지티브 스코프). `excludeCollections`\n * 와 동시 지정 불가(서버가 400).\n */\n collectionKey?: string;\n /**\n * 네거티브 스코프 — 어느 스트림에도 안 속한(\"미소속\") 글만\n * (`blog-list-collection-scope.md` §2②). `collectionKey` 와 동시 지정 불가.\n */\n excludeCollections?: boolean;\n /**\n * ADR-0052 A안 (W4-6 PR B) — BCP-47 소문자 locale 필터(예: \"en\"). 미지정 시\n * 서버가 사이트 기본 locale(`settingsJson.locales[0]`, 미설정이면 'ko') 만\n * 반환한다 — 하위호환(번역판이 생겨도 이 옵션을 안 쓰는 기존 호출부는 원어\n * 콘텐츠만 계속 받는다).\n */\n locale?: string;\n /** Optional AbortSignal. */\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\nexport interface FetchPostOptions {\n apiKey: string;\n baseUrl?: string;\n /** Slug (kebab-case) or UUIDv7 id. */\n slugOrId: string;\n /** Override the auto-resolved site id. */\n siteId?: string;\n /** ADR-0052 A안 — `FetchPostsOptions.locale` 과 동일 시맨틱. */\n locale?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\n\nexport class CmsApiError extends Error {\n readonly status: number;\n readonly code: string;\n constructor(message: string, status: number, code: string) {\n super(message);\n this.name = \"CmsApiError\";\n this.status = status;\n this.code = code;\n }\n}\n\nexport async function toApiError(response: Response): Promise<CmsApiError> {\n let code = \"request_failed\";\n let message = `RootTale CMS API error: ${response.status}`;\n try {\n const body = (await response.json()) as { code?: string; message?: string };\n if (body.code) code = body.code;\n if (body.message) message = body.message;\n } catch {\n /* ignore body parse */\n }\n return new CmsApiError(message, response.status, code);\n}\n\nexport function assertServer(): void {\n if (typeof window !== \"undefined\") {\n throw new Error(\n \"@roottale/cms-client/server must not run in the browser. \" +\n \"Move this call to a Server Component, getServerSideProps, or a Route Handler. \" +\n \"Shipping API keys to the browser breaks the security model (ADR-0023 §5.1 #15).\",\n );\n }\n}\n\n// 프레임워크 무관 SEO 헬퍼 (RSS 2.0 + Schema.org JSON-LD) 재노출.\n","import {\n type CmsPostType,\n type CmsPostContent,\n type CmsPostContentPage,\n type CmsTermRef,\n type FetchPostOptions,\n type FetchPostsOptions,\n DEFAULT_BASE_URL,\n assertServer,\n buildGetInit,\n toApiError,\n} from \"./server-base\";\n\n/**\n * 외부 사이트가 cms-renderer 에 주입할 RootTale 디자인 토큰.\n * admin /design (admin.roottale.com) 에서 저장한 값과 동일한 shape.\n * 미설정 필드는 응답에서 누락 → renderer 의 CSS fallback 이 사용된다.\n */\nexport interface RootTaleTheme {\n colors?: {\n primary?: string;\n primaryForeground?: string;\n foreground?: string;\n background?: string;\n secondary?: string;\n secondaryForeground?: string;\n accent?: string;\n accentForeground?: string;\n muted?: string;\n mutedForeground?: string;\n destructive?: string;\n destructiveForeground?: string;\n border?: string;\n input?: string;\n ring?: string;\n };\n fonts?: {\n body?: string;\n display?: string;\n };\n radius?: {\n sm?: string;\n md?: string;\n lg?: string;\n xl?: string;\n };\n /**\n * W6-3 — `sites.name`(DB SSoT, `rt init --name` 이 저장) 미러. 외부 사이트가\n * `<title>`/OG/헤더·푸터 브랜드명을 site.config.ts 정적 리터럴 대신 런타임\n * 반영하는 데 쓴다. 미설정이면 소비자가 site.config.ts 값으로 fallback.\n */\n siteName?: string;\n announcement?: AnnouncementSettings;\n actionDock?: ActionDockSettings;\n inquiryBoard?: InquiryBoardSettings;\n siteNav?: SiteNavSettings;\n}\n\nexport type AnnouncementVariant = \"single\" | \"rotating\" | \"cta\";\nexport type AnnouncementTone = \"default\" | \"primary\";\n\nexport interface AnnouncementItem {\n text: string;\n href?: string;\n ctaLabel?: string;\n}\n\nexport interface AnnouncementSettings {\n enabled: boolean;\n variant: AnnouncementVariant;\n tone: AnnouncementTone;\n items: AnnouncementItem[];\n startsAt?: string;\n endsAt?: string;\n}\n\nexport type ActionDockKind = \"tel\" | \"link\" | \"map\" | \"channel\";\nexport type ActionDockPosition = \"bottom\" | \"side\";\n\nexport interface ActionDockAction {\n label: string;\n href: string;\n kind: ActionDockKind;\n}\n\nexport interface ActionDockSettings {\n enabled: boolean;\n actions: ActionDockAction[];\n position: ActionDockPosition;\n collapseAt?: number;\n}\n\nexport interface InquiryBoardSettings {\n enabled: boolean;\n}\n\nexport interface SiteNavLink {\n label: string;\n href: string;\n description?: string;\n}\n\nexport interface SiteNavColumn {\n title?: string;\n links: SiteNavLink[];\n}\n\nexport interface SiteNavGroup {\n label: string;\n href?: string;\n columns?: SiteNavColumn[];\n}\n\nexport interface SiteNavCta {\n label: string;\n href: string;\n}\n\nexport interface SiteNavSettings {\n navGroups: SiteNavGroup[];\n cta?: SiteNavCta;\n utilityLinks?: SiteNavLink[];\n}\n\n/**\n * 테마 fetch 캐시 tag 의 정본 이름 (W0-4). starter layout 의 `fetchTheme` 호출에\n * `tags: [THEME_CACHE_TAG]` 로 부착하고, revalidate route 가 `theme.updated`\n * 수신 시 `revalidateTag(THEME_CACHE_TAG)` 로 무효화한다. 양쪽이 이 상수를 공유해\n * drift 를 방지한다.\n */\nexport const THEME_CACHE_TAG = \"rt-theme\";\n\nexport interface FetchThemeOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n /**\n * on-demand 무효화용 Next fetch cache tags(opt-in, W0-4). 보통\n * `[THEME_CACHE_TAG]`. 미지정 시 현행 동작 유지.\n */\n tags?: string[];\n}\n\n/**\n * tenant 의 RootTale 디자인 토큰을 가져온다. admin /design 의 마지막 저장값\n * (없으면 빈 객체) — renderer 가 이 값을 CSS 변수로 주입한다.\n *\n * 자동 fetch (renderer 가 theme prop 없을 때) 와 명시 호출 모두 지원.\n * 네트워크/서버 에러는 throw — 호출자가 fallback 처리.\n */\nexport async function fetchTheme(options: FetchThemeOptions): Promise<RootTaleTheme> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/theme${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n colors?: RootTaleTheme[\"colors\"];\n fonts?: RootTaleTheme[\"fonts\"];\n radius?: RootTaleTheme[\"radius\"];\n site_name?: string;\n announcement?: AnnouncementSettings;\n actionDock?: ActionDockSettings;\n inquiryBoard?: InquiryBoardSettings;\n siteNav?: SiteNavSettings;\n };\n const theme: RootTaleTheme = {};\n if (json.colors) theme.colors = json.colors;\n if (json.fonts) theme.fonts = json.fonts;\n if (json.radius) theme.radius = json.radius;\n if (json.site_name) theme.siteName = json.site_name;\n if (json.announcement) theme.announcement = json.announcement;\n if (json.actionDock) theme.actionDock = json.actionDock;\n if (json.inquiryBoard) theme.inquiryBoard = json.inquiryBoard;\n if (json.siteNav) theme.siteNav = json.siteNav;\n return theme;\n}\n\nexport type AnalyticsTagProvider = \"ga4\" | \"clarity\" | \"meta_pixel\" | \"naver\";\n\n/** analytics-runtime `TagConfig` 와 구조 호환 — `buildManifest` 입력. */\nexport interface AnalyticsTagConfig {\n provider: AnalyticsTagProvider;\n id: string;\n enabled: boolean;\n}\n\nexport interface SiteAnalyticsTags {\n siteId: string;\n tags: AnalyticsTagConfig[];\n}\n\nexport interface FetchAnalyticsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\n/**\n * ADR-0038 plane ① — 외부 사이트가 `<head>` 에 주입할 런타임 태그(GA4 measurement\n * id / Clarity project id 등, 비밀 아님). RootTale 관리용 default + 테넌트 고객용\n * 설정을 머지한 결과를 반환한다. analytics-runtime `buildManifest` 입력으로 쓰고,\n * 동의는 항상 fail-closed(런타임이 처리)이므로 키 노출 위험 없음(public id).\n */\nexport async function fetchAnalyticsConfig(\n options: FetchAnalyticsOptions,\n): Promise<SiteAnalyticsTags> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/analytics${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n site_id: string;\n tags?: AnalyticsTagConfig[];\n };\n return { siteId: json.site_id, tags: json.tags ?? [] };\n}\n\n\nexport async function fetchPosts(options: FetchPostsOptions): Promise<CmsPostContentPage> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.limit !== undefined) params.set(\"limit\", String(options.limit));\n if (options.cursor) params.set(\"cursor\", options.cursor);\n if (options.type) params.set(\"type\", options.type);\n if (options.author) params.set(\"author\", options.author);\n if (options.siteId) params.set(\"site_id\", options.siteId);\n if (options.collectionKey) params.set(\"collection_key\", options.collectionKey);\n if (options.excludeCollections) params.set(\"exclude_collections\", \"true\");\n if (options.locale) params.set(\"locale\", options.locale);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n items: CmsPostWire[];\n has_more: boolean;\n next_cursor: string | null;\n };\n return {\n items: json.items.map(fromWire),\n hasMore: json.has_more,\n nextCursor: json.next_cursor,\n };\n}\n\nexport async function fetchPost(options: FetchPostOptions): Promise<CmsPostContent | null> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n if (options.locale) params.set(\"locale\", options.locale);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts/${encodeURIComponent(options.slugOrId)}${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (response.status === 404) return null;\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as CmsPostWire;\n return fromWire(json);\n}\n\nexport interface FetchPageBySlugOptions {\n readonly apiKey: string;\n readonly baseUrl?: string;\n readonly slug: string;\n readonly siteId?: string;\n /** ADR-0052 A안 (W4-6 PR C1) — `FetchPostsOptions.locale` 과 동일 시맨틱. */\n readonly locale?: string;\n readonly signal?: AbortSignal;\n readonly revalidate?: number;\n}\n\nexport async function fetchPageBySlug(\n options: FetchPageBySlugOptions,\n): Promise<CmsPostContent | null> {\n let cursor: string | undefined;\n do {\n const page = await fetchPosts({\n apiKey: options.apiKey,\n baseUrl: options.baseUrl,\n limit: 100,\n ...(cursor ? { cursor } : {}),\n type: \"page\",\n siteId: options.siteId,\n ...(options.locale ? { locale: options.locale } : {}),\n signal: options.signal,\n revalidate: options.revalidate,\n });\n const match = page.items.find((item) => item.slug === options.slug);\n if (match) return match;\n cursor = page.nextCursor ?? undefined;\n } while (cursor);\n return null;\n}\n\ntype CmsPostWire = {\n id: string;\n tenant_id: string;\n site_id: string;\n type: CmsPostType;\n /** ADR-0060 Amendment 1 — 명시 섹션 key. 구 서버는 미포함 — optional. */\n collection_key?: string | null;\n slug: string;\n /** ADR-0052 A안 — 구 서버는 미포함 — optional(하위호환). */\n locale?: string;\n /** ADR-0052 A안 — 구 서버는 미포함 — optional(하위호환). */\n translations?: Array<{ locale: string; slug: string }>;\n title: string;\n excerpt: string | null;\n featured_media_id: string | null;\n /** featured 이미지 delivery URL. 구 서버는 미포함 — backward-compat optional. */\n featured_media_url?: string | null;\n author_id: string | null;\n author_name?: string | null;\n author_image_url?: string | null;\n author_bio?: string | null;\n author_slug?: string | null;\n body_json: Record<string, unknown>;\n status: \"published\";\n meta_json: Record<string, unknown>;\n /** ADR-0041 포맷 필드. 구 서버는 미포함 — backward-compat optional. */\n fields?: Record<string, unknown> | null;\n /** 0.2.0 이전 서버는 미포함 — backward-compat 차원에서 optional. */\n terms?: CmsTermRef[];\n archive_related?: Array<{\n collection_key: string;\n label: string;\n items: Array<{\n id: string;\n title: string;\n slug: string;\n excerpt: string | null;\n collection_key: string;\n }>;\n }>;\n /** W5-2 — fields 표시 메타. 해석된 그룹 없거나 구 서버는 미포함. */\n fields_meta?: Array<{\n name: string;\n label: string;\n type: string;\n choices?: Array<{ value: string; label: string }>;\n }>;\n /** ADR-0065 — 누적 조회수. 사이트가 공개 조회수를 켠 경우에만 포함. */\n view_count?: number;\n published_at: string;\n created_at: string;\n updated_at: string;\n};\n\nfunction fromWire(wire: CmsPostWire): CmsPostContent {\n return {\n id: wire.id,\n tenantId: wire.tenant_id,\n siteId: wire.site_id,\n type: wire.type,\n collectionKey: wire.collection_key ?? null,\n slug: wire.slug,\n ...(wire.locale !== undefined ? { locale: wire.locale } : {}),\n ...(wire.translations !== undefined ? { translations: wire.translations } : {}),\n title: wire.title,\n excerpt: wire.excerpt,\n featuredMediaId: wire.featured_media_id,\n featuredImageUrl: wire.featured_media_url ?? null,\n authorId: wire.author_id,\n authorName: wire.author_name ?? null,\n authorImageUrl: wire.author_image_url ?? null,\n authorBio: wire.author_bio ?? null,\n authorSlug: wire.author_slug ?? null,\n bodyJson: wire.body_json,\n status: wire.status,\n metaJson: wire.meta_json,\n fields: wire.fields ?? null,\n fieldsMeta: (wire.fields_meta ?? []).map((entry) => ({\n name: entry.name,\n label: entry.label,\n type: entry.type,\n ...(entry.choices\n ? {\n choices: entry.choices.map((c) => ({\n value: c.value,\n label: c.label,\n })),\n }\n : {}),\n })),\n terms: wire.terms ?? [],\n archiveRelated: (wire.archive_related ?? []).map((group) => ({\n collectionKey: group.collection_key,\n label: group.label,\n items: group.items.map((item) => ({\n id: item.id,\n title: item.title,\n slug: item.slug,\n excerpt: item.excerpt,\n collectionKey: item.collection_key,\n })),\n })),\n ...(typeof wire.view_count === \"number\" ? { viewCount: wire.view_count } : {}),\n publishedAt: wire.published_at,\n createdAt: wire.created_at,\n updatedAt: wire.updated_at,\n };\n}\n","import {\n DEFAULT_BASE_URL,\n assertServer,\n buildGetInit,\n toApiError,\n type CmsPostContent,\n} from \"./server-base\";\n\n/**\n * 블로그 표시 설정 — admin /design 의 \"블로그 표시\" 에서 저장한 사이트 기본값.\n * renderer 가 prop 미지정 시 이 값을 기본으로 적용 (글별 override 는 post 의\n * metaJson.display 가 우선).\n */\n/**\n * 글 하단 고정 CTA — admin 블로그 설정에서 활성화 시 모든 글 본문 끝에 그려진다.\n * null(미설정/비활성)이면 렌더러는 CTA 블록을 생략. buttonLabel/buttonHref 는\n * 객체가 존재하면 항상 non-empty(서버가 둘 다 있을 때만 객체를 내려줌).\n */\nexport interface RootTaleBlogPostCta {\n title: string | null;\n description: string | null;\n buttonLabel: string;\n buttonHref: string;\n}\n\n/**\n * 사이트 레벨 SEO 기본값 — 외부 사이트 `<head>`(설명·로고·파비콘)와 글별 OG\n * 미설정 시 공유 썸네일 폴백(defaultOgImageUrl)에 쓴다. 모두 nullable.\n */\nexport interface RootTaleSiteProfile {\n siteDescription: string | null;\n logoUrl: string | null;\n faviconUrl: string | null;\n /** 글별 og:image 미설정 시 폴백(1200×630 권장). */\n defaultOgImageUrl: string | null;\n}\n\n/**\n * 사이트맵 생성 정책 (admin 설정) — `createSitemapIndex` 가 소비해 무엇을 내보낼지\n * 결정한다. blanche식 인덱스 분리(static/blog/categories[/authors]) 위에 이미지·\n * 다국어 토글을 얹는다.\n */\nexport interface RootTaleSitemapSettings {\n /** 블로그 글 entry 에 대표 이미지(featuredImageUrl)를 `<image:image>` 로 포함. */\n blogImages: boolean;\n /** 다국어 hreflang locales (BCP-47). 빈 배열 = 단일 로케일(alternates 미emit). */\n locales: string[];\n /** authors 하위 사이트맵 포함 (작가 아카이브 라우트 필요). */\n authors: boolean;\n /**\n * 색인 위생(seo-geo-contract v3.1 §1) — \"검색에 이 분류 페이지 노출\" 을 켠\n * 카테고리 slug 목록. 아카이브는 기본 noindex 라서 여기 없는 카테고리의 모음\n * 페이지는 sitemap 에서 빠진다.\n *\n * **`undefined` = 승격 정보를 모름** (구버전 API 응답이거나 admin 설정 fetch 를\n * 생략한 경로). 이때 `createSitemapIndex` 는 정책을 적용하지 않고 기존 동작을\n * 유지한다 — 모른다는 이유로 이미 색인된 URL 을 지우지 않기 위해서다.\n *\n * 이 구분을 소비처가 직접 해석하지 말고 cms-renderer-next 의\n * `resolveCategoryPromotion(slugs, slug)` 에 그대로 넘길 것 — 사이트맵과\n * 아카이브 페이지가 같은 3-state(`promoted`/`not-promoted`/`unknown`)를 써야\n * 두 소스의 결론이 갈리지 않는다.\n */\n promotedCategorySlugs?: string[];\n}\n\nexport interface RootTaleBlogSettings {\n showTableOfContents: boolean;\n showAuthor: boolean;\n showDate: boolean;\n showAuthorCard: boolean;\n /** 목차 상단 제목. null = renderer 기본(\"목차\"). */\n tocTitle: string | null;\n /**\n * 목차 배치. \"inline\"=본문 위 접이식(기본), \"sidebar\"=넓은 화면에서 본문\n * 오른쪽 sticky 사이드바(좁은 화면은 자동 인라인 폴백).\n */\n tocPosition: \"inline\" | \"sidebar\";\n /** 작성자 카드 상단 라벨. null = renderer 기본(\"작성자\"). */\n authorCardEyebrow: string | null;\n /** 작성자 카드 설명. null = 설명 없음. */\n authorCardDescription: string | null;\n /** 작성자 프로필 이름. null = 글 작성자 이름 사용. */\n authorProfileName: string | null;\n /** 작성자 프로필 bio. null = 설명 없음. */\n authorProfileBio: string | null;\n /** 작성자 프로필 이미지 URL. null = 이니셜 아바타 사용. */\n authorProfileImageUrl: string | null;\n /** 작성자 프로필 이미지 모서리. */\n authorProfileImageRadius: \"circle\" | \"rounded\" | \"square\";\n /** 작성자 프로필 이미지 object-position x (%). */\n authorProfileImagePositionX: number;\n /** 작성자 프로필 이미지 object-position y (%). */\n authorProfileImagePositionY: number;\n /** 사이트 레벨 SEO 기본값(설명·로고·파비콘·기본 OG). */\n siteProfile: RootTaleSiteProfile;\n /** 글 하단 고정 CTA. null = 미설정/비활성. */\n postCta: RootTaleBlogPostCta | null;\n /**\n * 사이트맵 생성 정책 (admin 설정). createSitemapIndex 가 소비. optional —\n * 구버전 응답/직접 구성한 객체와의 하위호환(소비 시 기본값으로 fail-soft).\n */\n sitemap?: RootTaleSitemapSettings;\n}\n\n/**\n * 블로그 표시 설정 fetch 캐시 tag 의 정본 이름 (ADR-0096 Phase C).\n * `fetchBlogSettings` 호출에 `tags: [BLOG_SETTINGS_CACHE_TAG]` 로 부착하고,\n * revalidate route 가 `theme.updated` 수신 시\n * `revalidateTag(BLOG_SETTINGS_CACHE_TAG)` 로 무효화한다. 양쪽이 이 상수를\n * 공유해 drift 를 방지한다.\n */\nexport const BLOG_SETTINGS_CACHE_TAG = \"rt-blog-settings\";\n\nexport interface FetchBlogSettingsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n /**\n * on-demand 무효화용 Next fetch cache tags(opt-in, ADR-0096 Phase C). 보통\n * `[BLOG_SETTINGS_CACHE_TAG]`. 미지정 시 현행 동작 유지.\n */\n tags?: string[];\n}\n\n/**\n * tenant 의 블로그 표시 기본값을 가져온다. 네트워크/서버 에러는 throw —\n * 호출자(renderer)가 안전 기본값으로 fallback.\n */\nexport async function fetchBlogSettings(\n options: FetchBlogSettingsOptions,\n): Promise<RootTaleBlogSettings> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/blog-settings${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n show_table_of_contents?: boolean;\n show_author?: boolean;\n show_date?: boolean;\n show_author_card?: boolean;\n toc_title?: string | null;\n toc_position?: string | null;\n author_card_eyebrow?: string | null;\n author_card_description?: string | null;\n author_profile_name?: string | null;\n author_profile_bio?: string | null;\n author_profile_image_url?: string | null;\n author_profile_image_radius?: string | null;\n author_profile_image_position_x?: number;\n author_profile_image_position_y?: number;\n site_profile?: {\n site_description?: string | null;\n logo_url?: string | null;\n favicon_url?: string | null;\n default_og_image_url?: string | null;\n } | null;\n post_cta?: {\n title?: string | null;\n description?: string | null;\n button_label?: string;\n button_href?: string;\n } | null;\n sitemap?: {\n blog_images?: boolean;\n locales?: string[];\n authors?: boolean;\n promoted_category_slugs?: string[];\n } | null;\n };\n const radius =\n json.author_profile_image_radius === \"rounded\" ||\n json.author_profile_image_radius === \"square\"\n ? json.author_profile_image_radius\n : \"circle\";\n const percent = (x: unknown): number =>\n typeof x === \"number\" && Number.isFinite(x)\n ? Math.max(0, Math.min(100, Math.round(x)))\n : 50;\n const nonEmpty = (x: unknown): string | null =>\n typeof x === \"string\" && x.length > 0 ? x : null;\n return {\n showTableOfContents: json.show_table_of_contents === true,\n showAuthor: json.show_author !== false,\n showDate: json.show_date !== false,\n showAuthorCard: json.show_author_card !== false,\n tocTitle:\n typeof json.toc_title === \"string\" && json.toc_title.length > 0\n ? json.toc_title\n : null,\n tocPosition: json.toc_position === \"sidebar\" ? \"sidebar\" : \"inline\",\n authorCardEyebrow:\n typeof json.author_card_eyebrow === \"string\" &&\n json.author_card_eyebrow.length > 0\n ? json.author_card_eyebrow\n : null,\n authorCardDescription:\n typeof json.author_card_description === \"string\" &&\n json.author_card_description.length > 0\n ? json.author_card_description\n : null,\n authorProfileName:\n typeof json.author_profile_name === \"string\" &&\n json.author_profile_name.length > 0\n ? json.author_profile_name\n : null,\n authorProfileBio:\n typeof json.author_profile_bio === \"string\" &&\n json.author_profile_bio.length > 0\n ? json.author_profile_bio\n : null,\n authorProfileImageUrl:\n typeof json.author_profile_image_url === \"string\" &&\n json.author_profile_image_url.length > 0\n ? json.author_profile_image_url\n : null,\n authorProfileImageRadius: radius,\n authorProfileImagePositionX: percent(json.author_profile_image_position_x),\n authorProfileImagePositionY: percent(json.author_profile_image_position_y),\n siteProfile: {\n siteDescription: nonEmpty(json.site_profile?.site_description),\n logoUrl: nonEmpty(json.site_profile?.logo_url),\n faviconUrl: nonEmpty(json.site_profile?.favicon_url),\n defaultOgImageUrl: nonEmpty(json.site_profile?.default_og_image_url),\n },\n postCta:\n json.post_cta &&\n typeof json.post_cta.button_label === \"string\" &&\n json.post_cta.button_label.length > 0 &&\n typeof json.post_cta.button_href === \"string\" &&\n json.post_cta.button_href.length > 0\n ? {\n title:\n typeof json.post_cta.title === \"string\" &&\n json.post_cta.title.length > 0\n ? json.post_cta.title\n : null,\n description:\n typeof json.post_cta.description === \"string\" &&\n json.post_cta.description.length > 0\n ? json.post_cta.description\n : null,\n buttonLabel: json.post_cta.button_label,\n buttonHref: json.post_cta.button_href,\n }\n : null,\n sitemap: {\n blogImages: json.sitemap?.blog_images !== false,\n locales: Array.isArray(json.sitemap?.locales)\n ? json.sitemap.locales.filter(\n (s): s is string => typeof s === \"string\" && s.length > 0,\n )\n : [],\n authors: json.sitemap?.authors === true,\n // 배열이 아니면(구버전 API 응답) 필드 자체를 생략 = \"승격 정보 모름\".\n // 빈 배열([])은 \"승격된 카테고리가 하나도 없음\" 이라는 확정 정보라 의미가\n // 다르다 — 이 구분이 사이트맵 필터의 fail-soft 근거다.\n ...(Array.isArray(json.sitemap?.promoted_category_slugs)\n ? {\n promotedCategorySlugs: json.sitemap.promoted_category_slugs.filter(\n (s): s is string => typeof s === \"string\" && s.length > 0,\n ),\n }\n : {}),\n },\n };\n}\n\n// ── 콘텐츠 Collections (ADR-0060) ────────────────────────────────────────────\n\n/**\n * 콘텐츠 스트림 1개 (camelCase). cms-core `RouteCollection` 과 구조 호환 —\n * `createSitemap`/`createFeedRoute` 의 `collections` 에 그대로 넘길 수 있다.\n */\nexport interface CmsRouteCollection {\n key: string;\n label: string;\n /** \"\" = 주소 없는 유형(라우트 없음) — 페이지 안에서 불러와 쓰는 콘텐츠. */\n basePath: string;\n layout: string;\n categories: string[];\n feed: boolean;\n archives: boolean;\n og: boolean;\n requireSubcategory: boolean;\n pinning: boolean;\n icon: string | null;\n /** 글별 상세 라우트 존재 여부 (기본 true — 하위 호환). false = 목록 전용 유형. */\n detail: boolean;\n}\n\n/**\n * 콘텐츠 유형(collections) fetch 캐시 tag 의 정본 이름 (ADR-0096 Phase C).\n * `fetchCollections` 호출에 `tags: [COLLECTIONS_CACHE_TAG]` 로 부착하고,\n * revalidate route 가 `theme.updated` 수신 시\n * `revalidateTag(COLLECTIONS_CACHE_TAG)` 로 무효화한다. 양쪽이 이 상수를 공유해\n * drift 를 방지한다.\n */\nexport const COLLECTIONS_CACHE_TAG = \"rt-collections\";\n\nexport interface FetchCollectionsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n /**\n * on-demand 무효화용 Next fetch cache tags(opt-in, ADR-0096 Phase C). 보통\n * `[COLLECTIONS_CACHE_TAG]`. 미지정 시 현행 동작 유지.\n */\n tags?: string[];\n}\n\n/**\n * 사이트의 콘텐츠 Collections (공지/블로그 등 스트림 분리)를 가져온다. 미설정이면\n * 빈 배열(레거시 단일 /blog). 네트워크/서버 에러는 throw — 호출자가 빈 배열로\n * fail-soft 하도록 한다(렌더가 collections 때문에 죽지 않는 것이 계약).\n */\nexport async function fetchCollections(\n options: FetchCollectionsOptions,\n): Promise<CmsRouteCollection[]> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/collections${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n collections?: Array<Record<string, unknown>>;\n };\n const items = Array.isArray(json.collections) ? json.collections : [];\n return items.flatMap((o) => {\n const key = typeof o.key === \"string\" ? o.key : \"\";\n // \"\" = 주소 없는 유형(라우트/사이트맵/피드/아카이브 파생 없음, ADR-0060) — key\n // 만 있으면 유지한다. 소속 판정(`postsNotInAnyCollection` 방어선)의 시맨틱을\n // 서버(declaredKeys=key 기준)와 맞추기 위해 basePath 없다고 드롭하지 않는다.\n const basePath = typeof o.base_path === \"string\" ? o.base_path : \"\";\n if (!key) return [];\n return [\n {\n key,\n basePath,\n label: typeof o.label === \"string\" ? o.label : key,\n layout: typeof o.layout === \"string\" ? o.layout : \"grid\",\n categories: Array.isArray(o.categories)\n ? o.categories.filter((s): s is string => typeof s === \"string\")\n : [],\n feed: o.feed === true,\n archives: o.archives === true,\n og: o.og === true,\n requireSubcategory: o.require_subcategory === true,\n pinning: o.pinning === true,\n icon: typeof o.icon === \"string\" ? o.icon : null,\n // 기본 true(하위 호환) — 명시적으로 false 인 경우만 목록 전용.\n detail: o.detail !== false,\n },\n ];\n });\n}\n\n/**\n * fetch 실패/미설정 시 안전 기본값 — 빈 배열(레거시 단일 /blog). 사이트가\n * `fetchCollections().catch(() => fetchCollectionsSafeDefault())` 로 쓴다.\n */\nexport const fetchCollectionsSafeDefault: CmsRouteCollection[] = [];\n\n// ── 작가 아카이브 (blanche식 /blog/author/{slug}) ────────────────────────────\n\n/** 작가 1명 (camelCase) — author_slug 가 있고 발행 글이 1건 이상인 작가만. */\nexport interface RootTaleAuthor {\n /** 아카이브 URL slug (/blog/author/{slug}). */\n slug: string;\n /** 표시 이름 (author_display_name > user.name 폴백). */\n name: string;\n imageUrl: string | null;\n bio: string | null;\n /** 발행 글(type=post) 수 (≥1). */\n postCount: number;\n /** 작가의 최근 글 수정일 (사이트맵 lastmod). */\n lastUpdatedAt: string | null;\n}\n\nexport interface FetchAuthorsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\n/**\n * 사이트의 작가 목록(작가 아카이브·사이트맵 파생용)을 가져온다. author_slug 가 있고\n * 발행 글이 1건 이상인 작가만. 네트워크/서버 에러는 throw — 호출자(라우트/사이트맵\n * 팩토리)가 빈 배열로 fail-soft 한다.\n */\nexport async function fetchAuthors(\n options: FetchAuthorsOptions,\n): Promise<RootTaleAuthor[]> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/authors${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n authors?: Array<Record<string, unknown>>;\n };\n const items = Array.isArray(json.authors) ? json.authors : [];\n return items.flatMap((o) => {\n const slug = typeof o.slug === \"string\" ? o.slug : \"\";\n const name = typeof o.name === \"string\" ? o.name : \"\";\n if (!slug || !name) return [];\n return [\n {\n slug,\n name,\n imageUrl: typeof o.image_url === \"string\" ? o.image_url : null,\n bio: typeof o.bio === \"string\" ? o.bio : null,\n postCount: typeof o.post_count === \"number\" ? o.post_count : 0,\n lastUpdatedAt:\n typeof o.last_updated_at === \"string\" ? o.last_updated_at : null,\n },\n ];\n });\n}\n\n/** fetch 실패/미설정 시 사용할 안전 기본값 (기존 렌더러 동작). */\nexport const DEFAULT_BLOG_SETTINGS: RootTaleBlogSettings = {\n showTableOfContents: false,\n showAuthor: true,\n showDate: true,\n showAuthorCard: true,\n tocTitle: null,\n tocPosition: \"inline\",\n authorCardEyebrow: null,\n authorCardDescription: null,\n authorProfileName: null,\n authorProfileBio: null,\n authorProfileImageUrl: null,\n authorProfileImageRadius: \"circle\",\n authorProfileImagePositionX: 50,\n authorProfileImagePositionY: 50,\n siteProfile: {\n siteDescription: null,\n logoUrl: null,\n faviconUrl: null,\n defaultOgImageUrl: null,\n },\n postCta: null,\n sitemap: {\n blogImages: true,\n locales: [],\n authors: false,\n // 이 기본값은 **블로그 렌더링 폴백 전용**이다 (cms-renderer-next 의\n // `fetchBlogSettingsFailSoft` = `fetchBlogSettings(...).catch(() => DEFAULT_BLOG_SETTINGS)`,\n // 소비처는 `server/blog-list.tsx`·`server/blog-post.tsx` 뿐 — 둘 다 색인\n // 정책과 무관한 본문 표시 설정만 읽는다).\n //\n // **색인 정책 경로는 이 상수를 쓰지 않는다** — 사이트맵은\n // `resolveSitemapSettings`(cms-renderer-next/routes-sitemap-entries.ts)가\n // 자체 try/catch 로 `remote = {}` 를 쓰고, 아카이브 페이지는 조회 실패 시\n // 설정 객체 자체를 `undefined` 로 넘긴다. 그래서 두 경로 모두 fetch 실패가\n // `promotedCategorySlugs === undefined`(= 승격 정보 모름) 로 전달돼\n // `resolveCategoryPromotion` 이 `unknown` 을 만들고 기존 URL 이 보존된다.\n // 여기 `[]` 는 \"승격 0건\"(확정) 이지만 그 의미가 색인 정책까지 전달되는\n // 경로는 없다 — 새 소비처를 만들 때 이 상수를 승격 판정에 넘기지 말 것.\n promotedCategorySlugs: [],\n },\n};\n\n\nexport interface ResolvedPostDisplay {\n showTableOfContents: boolean;\n showAuthor: boolean;\n showDate: boolean;\n showAuthorCard: boolean;\n tocTitle: string;\n tocPosition: \"inline\" | \"sidebar\";\n authorCardEyebrow: string;\n authorCardDescription: string | null;\n authorProfileName: string | null;\n authorProfileBio: string | null;\n authorProfileImageUrl: string | null;\n authorProfileImageRadius: \"circle\" | \"rounded\" | \"square\";\n authorProfileImagePositionX: number;\n authorProfileImagePositionY: number;\n}\n\n/**\n * 글 1건의 실제 표시 여부 결정. 우선순위:\n * 명시 prop > 글별 override(post.metaJson.display) > 사이트 기본값(settings).\n * tocTitle: 명시 prop > settings.tocTitle > \"목차\".\n */\nexport function resolvePostDisplay(\n post: { metaJson?: Record<string, unknown> | null },\n settings: RootTaleBlogSettings,\n explicit?: { showTableOfContents?: boolean; tableOfContentsTitle?: string },\n): ResolvedPostDisplay {\n const display =\n post.metaJson && typeof post.metaJson === \"object\"\n ? ((post.metaJson as Record<string, unknown>).display as\n | Record<string, unknown>\n | undefined)\n : undefined;\n const ov = (key: string): boolean | undefined =>\n display && typeof display[key] === \"boolean\"\n ? (display[key] as boolean)\n : undefined;\n return {\n showTableOfContents:\n explicit?.showTableOfContents ?? ov(\"toc\") ?? settings.showTableOfContents,\n showAuthor: ov(\"author\") ?? settings.showAuthor,\n showDate: ov(\"date\") ?? settings.showDate,\n showAuthorCard: settings.showAuthorCard,\n tocTitle: explicit?.tableOfContentsTitle ?? settings.tocTitle ?? \"목차\",\n tocPosition: settings.tocPosition ?? \"inline\",\n authorCardEyebrow: settings.authorCardEyebrow ?? \"작성자\",\n authorCardDescription: settings.authorCardDescription,\n authorProfileName: settings.authorProfileName,\n authorProfileBio: settings.authorProfileBio,\n authorProfileImageUrl: settings.authorProfileImageUrl,\n authorProfileImageRadius: settings.authorProfileImageRadius,\n authorProfileImagePositionX: settings.authorProfileImagePositionX,\n authorProfileImagePositionY: settings.authorProfileImagePositionY,\n };\n}\n\n/**\n * 관련 글 선별 — 현재 글과 **같은 카테고리**를 1개 이상 공유하는 후보를\n * 발행일 내림차순으로 골라 최대 `limit` 개 반환 (자기 자신 제외).\n *\n * 순수 함수: 후보(`candidates`)는 호출부가 `fetchPosts` 로 미리 받아 전달한다\n * (서버에 카테고리 필터 파라미터가 없어 최근 글 배치에서 선별). 현재 글에\n * 카테고리가 없으면 빈 배열.\n */\nexport function selectRelatedPosts(\n current: CmsPostContent,\n candidates: CmsPostContent[],\n limit = 3,\n): CmsPostContent[] {\n if (limit <= 0) return [];\n const currentCats = new Set(\n current.terms\n .filter((t) => t.taxonomy === \"category\")\n .map((t) => t.id),\n );\n if (currentCats.size === 0) return [];\n return candidates\n .filter(\n (p) =>\n p.id !== current.id &&\n p.terms.some(\n (t) => t.taxonomy === \"category\" && currentCats.has(t.id),\n ),\n )\n .sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))\n .slice(0, limit);\n}\n","/**\n * 분류(category)별 발행 글 수 — **서버측 집계** 조회.\n *\n * 분류 아카이브의 \"0건 = 404\" 판정과 사이트맵 포함 여부(색인 위생)를 정확히\n * 계산하기 위한 값이다. 이 API 가 없던 시절 렌더러는 최근 글 배치를 받아 세었고,\n * 글이 많은 사이트에서는 배치 한도 밖에만 글이 있는 분류가 페이지·사이트맵에서\n * 함께 사라졌다. 소비처(`@roottale/cms-renderer-next`)는 이 함수를 먼저 쓰고,\n * 실패하면 예전 배치 파생으로 fail-soft 한다.\n */\n\nimport {\n CmsApiError,\n DEFAULT_BASE_URL,\n assertServer,\n buildGetInit,\n toApiError,\n} from \"./server-base\";\n\n/** 분류 1건 + 요청 스코프 안의 발행 글 수. */\nexport interface CmsCategoryCount {\n slug: string;\n name: string;\n /** 요청 스코프(미소속/특정 스트림) 안에서 이 분류에 걸린 발행 글 수. 0 가능. */\n publishedPostCount: number;\n /** 이 분류가 귀속된 콘텐츠 유형 key (ADR-0060). 미귀속·구버전 응답이면 null. */\n collectionKey: string | null;\n /**\n * 어드민 \"검색에 이 분류 페이지 노출\" 토글. **승격 판정 입력으로 쓰지 말 것** —\n * 렌더러의 3-state 승격은 `fetchBlogSettings().sitemap.promotedCategorySlugs`\n * 하나에서만 조립한다(두 소스를 섞으면 사이트맵과 페이지가 서로 다른 시점의\n * 값을 볼 수 있다). 진단·어드민 표시용 참고값이다.\n */\n hubPromoted: boolean;\n}\n\nexport interface CmsCategoryCounts {\n categories: CmsCategoryCount[];\n /** 분류 수가 서버 상한을 넘어 일부가 빠졌으면 true — 소비처는 경고를 남길 것. */\n truncated: boolean;\n}\n\nexport interface FetchCategoryCountsOptions {\n /** `rtlk_cust_*` API key. 블로그 조회와 같은 키. */\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n /**\n * 그 섹션(collection) 소속 글만 센다 — 스트림 아카이브용. `excludeCollections`\n * 와 동시 지정 불가(서버가 400).\n */\n collectionKey?: string;\n /** 어느 스트림에도 안 속한 글만 센다 — 레거시 `/blog` 계열 아카이브의 모집단. */\n excludeCollections?: boolean;\n /** BCP-47. 미지정이면 사이트 기본 로케일(포스트 목록과 같은 기본 동작). */\n locale?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\nfunction toNonNegativeInt(value: unknown): number {\n return typeof value === \"number\" && Number.isInteger(value) && value >= 0\n ? value\n : 0;\n}\n\n/**\n * 사이트의 모든 분류와 발행 글 수를 가져온다. 네트워크/서버 에러는 **throw** —\n * 호출자가 예전 파생 방식으로 fail-soft 할지, 화면을 죽일지 정한다.\n *\n * 이 엔드포인트가 없는 구버전 API 는 404 를 주고 그것도 `CmsApiError` 로 throw\n * 되므로, 호출자는 \"구버전 API\" 와 \"일시 장애\" 를 같은 catch 로 다루면 된다.\n * 응답 shape 이 예상과 다른 경우(`categories` 가 배열이 아님)도 throw 로 취급한다\n * — 빈 목록으로 오해하면 \"분류 0건\" 이라는 **확정 정보**가 되어 아카이브가 전부\n * 404 가 되기 때문이다.\n */\nexport async function fetchCategoryCounts(\n options: FetchCategoryCountsOptions,\n): Promise<CmsCategoryCounts> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n if (options.collectionKey) params.set(\"collection_key\", options.collectionKey);\n if (options.excludeCollections) params.set(\"exclude_collections\", \"true\");\n if (options.locale) params.set(\"locale\", options.locale);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/categories${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n categories?: Array<Record<string, unknown>>;\n truncated?: boolean;\n };\n if (!Array.isArray(json.categories)) {\n throw new CmsApiError(\n \"RootTale CMS API returned no `categories` array for /v1/cms/public/categories\",\n response.status,\n \"unexpected_response\",\n );\n }\n return {\n categories: json.categories.flatMap((o) => {\n const slug = typeof o.slug === \"string\" ? o.slug : \"\";\n const name = typeof o.name === \"string\" ? o.name : \"\";\n if (!slug || !name) return [];\n return [\n {\n slug,\n name,\n publishedPostCount: toNonNegativeInt(o.published_post_count),\n collectionKey:\n typeof o.collection_key === \"string\" ? o.collection_key : null,\n hubPromoted: o.hub_promoted === true,\n },\n ];\n }),\n truncated: json.truncated === true,\n };\n}\n","import { DEFAULT_BASE_URL, assertServer, buildGetInit, toApiError } from \"./server-base\";\n\n// ── 비즈니스 프로필 (로컬 SEO) ───────────────────────────────────────────────\n\n/** Schema.org LocalBusiness 하위 타입 (admin \"운영 > 비즈니스 프로필\" 의 업종). */\nexport type BusinessType =\n | \"LocalBusiness\"\n | \"ProfessionalService\"\n | \"AccountingService\"\n | \"LegalService\"\n | \"MedicalClinic\"\n | \"Dentist\"\n | \"RealEstateAgent\"\n | \"Restaurant\"\n | \"BeautySalon\";\n\n/** Schema.org openingHours 요일 약어. */\nexport type BusinessDayCode = \"Mo\" | \"Tu\" | \"We\" | \"Th\" | \"Fr\" | \"Sa\" | \"Su\";\n\nexport interface BusinessOpeningHours {\n days: BusinessDayCode[];\n /** HH:MM (24시간). */\n opens: string;\n /** HH:MM (24시간). */\n closes: string;\n}\n\n/**\n * 비즈니스 프로필 — admin \"운영 > 비즈니스 프로필\" 에서 저장한 사업장 정보.\n * `localBusinessSchema(profile, { url })` 로 LocalBusiness JSON-LD 를 만들어\n * layout 에 1회 렌더한다 (로컬 SEO).\n */\nexport interface RootTaleBusinessProfile {\n /** 사업장 이름 (간판명). */\n name: string;\n /** 사업자등록 상호 — 간판명과 다를 때만 설정. */\n legalName: string | null;\n /** 간판명의 표기 변형(띄어쓰기 등) — JSON-LD alternateName. */\n alternateName: string | null;\n businessType: BusinessType;\n telephone: string | null;\n /** 팩스번호 — JSON-LD faxNumber. */\n faxNumber: string | null;\n email: string | null;\n address: {\n streetAddress: string;\n addressLocality: string;\n addressRegion: string | null;\n postalCode: string | null;\n } | null;\n geo: { latitude: number; longitude: number } | null;\n openingHours: BusinessOpeningHours[];\n /** 가격대 표기 (예: \"₩₩\"). */\n priceRange: string | null;\n /** 서비스 지역 (예: \"서울 강남구\"). */\n areaServed: string[];\n /** 취급 업무 (예: \"기장·신고대리\") — JSON-LD hasOfferCatalog. */\n services: string[];\n /** 외부 프로필 URL — JSON-LD sameAs 로 들어가 동일 사업장임을 연결. */\n profiles: {\n naverPlace: string | null;\n googleBusiness: string | null;\n kakaoChannel: string | null;\n /** 지도 앱 딥링크 — naverPlace 가 없을 때 hasMap 대체값. */\n kakaoMap: string | null;\n instagram: string | null;\n naverBlog: string | null;\n };\n}\n\n/**\n * 사업장 정보 fetch 캐시 tag 의 정본 이름 (ADR-0096 Phase C). 사이트의\n * `fetchBusinessProfile` 호출에 `tags: [BUSINESS_CACHE_TAG]` 로 부착하고,\n * revalidate route 가 `theme.updated` 수신 시 `revalidateTag(BUSINESS_CACHE_TAG)`\n * 로 무효화한다. 양쪽이 이 상수를 공유해 drift 를 방지한다.\n *\n * tag 를 붙이지 않으면 `revalidatePath` 로는 이 fetch 의 Data Cache 엔트리를\n * 지울 수 없어 `revalidate` 초만큼 낡은 주소·전화가 남는다.\n */\nexport const BUSINESS_CACHE_TAG = \"rt-business\";\n\nexport interface FetchBusinessProfileOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n /**\n * on-demand 무효화용 Next fetch cache tags(opt-in, ADR-0096 Phase C). 보통\n * `[BUSINESS_CACHE_TAG]`. 미지정 시 현행 동작 유지.\n */\n tags?: string[];\n}\n\ntype BusinessProfileWire = {\n configured?: boolean;\n name?: string | null;\n legal_name?: string | null;\n alternate_name?: string | null;\n business_type?: string | null;\n telephone?: string | null;\n fax_number?: string | null;\n email?: string | null;\n address?: {\n street_address?: string;\n address_locality?: string;\n address_region?: string | null;\n postal_code?: string | null;\n } | null;\n geo?: { latitude?: number; longitude?: number } | null;\n opening_hours?: {\n days?: BusinessDayCode[];\n opens?: string;\n closes?: string;\n }[];\n price_range?: string | null;\n area_served?: string[];\n services?: string[];\n profiles?: {\n naver_place?: string | null;\n google_business?: string | null;\n kakao_channel?: string | null;\n kakao_map?: string | null;\n instagram?: string | null;\n naver_blog?: string | null;\n } | null;\n};\n\n/** wire(snake_case) → RootTaleBusinessProfile. 미설정이면 null (export for tests). */\nexport function businessProfileFromWire(\n json: BusinessProfileWire,\n): RootTaleBusinessProfile | null {\n // configured=false 또는 이름 없음 = 미설정 — 호출자는 JSON-LD 렌더를 건너뛴다.\n if (json.configured === false) return null;\n if (typeof json.name !== \"string\" || json.name.length === 0) return null;\n const address =\n json.address &&\n typeof json.address.street_address === \"string\" &&\n typeof json.address.address_locality === \"string\"\n ? {\n streetAddress: json.address.street_address,\n addressLocality: json.address.address_locality,\n addressRegion: json.address.address_region ?? null,\n postalCode: json.address.postal_code ?? null,\n }\n : null;\n const geo =\n json.geo &&\n typeof json.geo.latitude === \"number\" &&\n typeof json.geo.longitude === \"number\"\n ? { latitude: json.geo.latitude, longitude: json.geo.longitude }\n : null;\n const openingHours: BusinessOpeningHours[] = [];\n for (const row of json.opening_hours ?? []) {\n if (\n Array.isArray(row.days) &&\n row.days.length > 0 &&\n typeof row.opens === \"string\" &&\n typeof row.closes === \"string\"\n ) {\n openingHours.push({ days: row.days, opens: row.opens, closes: row.closes });\n }\n }\n return {\n name: json.name,\n legalName: json.legal_name ?? null,\n alternateName: json.alternate_name ?? null,\n businessType:\n typeof json.business_type === \"string\" && json.business_type.length > 0\n ? (json.business_type as BusinessType)\n : \"LocalBusiness\",\n telephone: json.telephone ?? null,\n faxNumber: json.fax_number ?? null,\n email: json.email ?? null,\n address,\n geo,\n openingHours,\n priceRange: json.price_range ?? null,\n areaServed: json.area_served ?? [],\n services: json.services ?? [],\n profiles: {\n naverPlace: json.profiles?.naver_place ?? null,\n googleBusiness: json.profiles?.google_business ?? null,\n kakaoChannel: json.profiles?.kakao_channel ?? null,\n kakaoMap: json.profiles?.kakao_map ?? null,\n instagram: json.profiles?.instagram ?? null,\n naverBlog: json.profiles?.naver_blog ?? null,\n },\n };\n}\n\n/**\n * tenant 의 비즈니스 프로필을 가져온다. 미설정이면 null — 호출자는 null 일 때\n * JSON-LD 렌더를 건너뛰면 된다. 네트워크/서버 에러는 throw — 호출자가\n * `.catch(() => null)` 등으로 fallback.\n */\nexport async function fetchBusinessProfile(\n options: FetchBusinessProfileOptions,\n): Promise<RootTaleBusinessProfile | null> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/business-profile${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n // 구 서버(라우트 미배포)는 404 — 미설정과 동일하게 null.\n if (response.status === 404) return null;\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as BusinessProfileWire;\n return businessProfileFromWire(json);\n}\n\n","import {\n type CmsPostType,\n DEFAULT_BASE_URL,\n assertServer,\n buildGetInit,\n toApiError,\n} from \"./server-base\";\n\n// ── 공개 검색 (WP `?s=` 패리티) ────────────────────────────────────────────\n\nexport interface SearchPostsOptions {\n apiKey: string;\n baseUrl?: string;\n /** 검색 키워드 (title·excerpt·본문 텍스트 매칭). */\n query: string;\n /** 결과 수 1-50, default 10. */\n limit?: number;\n /** 기본 = `post` 만. page 포함 검색은 명시. */\n type?: CmsPostType;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\n/** 검색 결과 카드용 슬림 hit — body 미포함. */\nexport interface CmsSearchHit {\n id: string;\n type: CmsPostType;\n title: string;\n slug: string;\n excerpt: string | null;\n featuredImageUrl: string | null;\n /** ISO 8601. */\n publishedAt: string;\n}\n\n/**\n * published 글 키워드 검색 (사이트 내 검색). 최신 발행순, body 미포함 슬림\n * hit — 상세는 slug 로 `fetchPost`. 구 서버(라우트 미배포)는 404 — 빈 배열.\n */\nexport async function searchPosts(\n options: SearchPostsOptions,\n): Promise<CmsSearchHit[]> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n params.set(\"q\", options.query);\n if (options.limit !== undefined) params.set(\"limit\", String(options.limit));\n if (options.type) params.set(\"type\", options.type);\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const url = `${baseUrl}/v1/cms/public/search?${params.toString()}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (response.status === 404) return [];\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as {\n items: {\n id: string;\n type: CmsPostType;\n title: string;\n slug: string;\n excerpt: string | null;\n featured_media_url: string | null;\n published_at: string;\n }[];\n };\n return json.items.map((w) => ({\n id: w.id,\n type: w.type,\n title: w.title,\n slug: w.slug,\n excerpt: w.excerpt,\n featuredImageUrl: w.featured_media_url ?? null,\n publishedAt: w.published_at,\n }));\n}\n\n// ── 네비게이션 메뉴 (WP 외모>메뉴 패리티) ──────────────────────────────────\n\n/** 메뉴 항목 트리 노드 (깊이 ≤ 2). */\nexport interface RootTaleMenuItem {\n id: string;\n label: string;\n /** 상대 경로(`/about`) 또는 절대 http(s) URL — 서버가 sanitize. */\n url: string;\n /** true 면 `target=\"_blank\" rel=\"noopener\"` 로 렌더할 것. */\n newTab?: boolean;\n children?: RootTaleMenuItem[];\n}\n\nexport interface RootTaleMenu {\n id: string;\n /** 사람용 이름 — \"헤더 메뉴\". */\n name: string;\n /** 위치 핸들 — \"primary\" | \"footer\" 등. */\n slug: string;\n items: RootTaleMenuItem[];\n updatedAt: string | null;\n}\n\n/**\n * 메뉴(네비게이션) fetch 캐시 tag 의 정본 이름 (ADR-0096 Phase C).\n * `fetchMenu`·`fetchMenus` 호출에 `tags: [MENUS_CACHE_TAG]` 로 부착하고,\n * revalidate route 가 `theme.updated` 수신 시 `revalidateTag(MENUS_CACHE_TAG)`\n * 로 무효화한다. 양쪽이 이 상수를 공유해 drift 를 방지한다.\n *\n * 메뉴 1개(`fetchMenu`)와 전체(`fetchMenus`)가 같은 tag 를 쓴다 — 어드민에서\n * 메뉴를 저장하면 어느 쪽 캐시든 같이 낡기 때문이다.\n */\nexport const MENUS_CACHE_TAG = \"rt-menus\";\n\nexport interface FetchMenuOptions {\n apiKey: string;\n baseUrl?: string;\n /** 위치 핸들 — \"primary\" | \"footer\" 등. */\n slug: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n /**\n * on-demand 무효화용 Next fetch cache tags(opt-in, ADR-0096 Phase C). 보통\n * `[MENUS_CACHE_TAG]`. 미지정 시 현행 동작 유지.\n */\n tags?: string[];\n}\n\nexport interface FetchMenusOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n /**\n * on-demand 무효화용 Next fetch cache tags(opt-in, ADR-0096 Phase C). 보통\n * `[MENUS_CACHE_TAG]`. 미지정 시 현행 동작 유지.\n */\n tags?: string[];\n}\n\ntype CmsMenuItemWire = {\n id: string;\n label: string;\n url: string;\n new_tab?: boolean;\n children?: CmsMenuItemWire[];\n};\n\ntype CmsMenuWire = {\n id: string;\n name: string;\n slug: string;\n items: CmsMenuItemWire[];\n updated_at: string | null;\n};\n\nfunction menuItemFromWire(w: CmsMenuItemWire): RootTaleMenuItem {\n const item: RootTaleMenuItem = { id: w.id, label: w.label, url: w.url };\n if (w.new_tab) item.newTab = true;\n if (w.children && w.children.length > 0) {\n item.children = w.children.map(menuItemFromWire);\n }\n return item;\n}\n\nfunction menuFromWire(w: CmsMenuWire): RootTaleMenu {\n return {\n id: w.id,\n name: w.name,\n slug: w.slug,\n items: (w.items ?? []).map(menuItemFromWire),\n updatedAt: w.updated_at ?? null,\n };\n}\n\n/**\n * 위치 핸들 slug 로 네비게이션 메뉴 1개를 가져온다. admin \"디자인 > 메뉴\" 의\n * 저장값. 미설정/미존재(404)는 null — 사이트는 자체 fallback 네비를 렌더한다.\n * 구 서버(라우트 미배포)도 404 → null 로 동일하게 fail-soft.\n */\nexport async function fetchMenu(\n options: FetchMenuOptions,\n): Promise<RootTaleMenu | null> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/menus/${encodeURIComponent(options.slug)}${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (response.status === 404) return null;\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as CmsMenuWire;\n return menuFromWire(json);\n}\n\n/** 사이트의 네비게이션 메뉴 전체. 구 서버(라우트 미배포)는 404 — 빈 배열. */\nexport async function fetchMenus(\n options: FetchMenusOptions,\n): Promise<RootTaleMenu[]> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/menus${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (response.status === 404) return [];\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as { items: CmsMenuWire[] };\n return (json.items ?? []).map(menuFromWire);\n}\n\n// ── 커스텀 리다이렉트 (WP RankMath Redirections 패리티) ─────────────────────\n\nexport interface RootTaleRedirect {\n id: string;\n /** 정규화된 출발 경로 (앞 슬래시, 쿼리 제외). 예: `/old-event`. */\n fromPath: string;\n /** 도착지 — 사이트 내부 경로(`/promo`) 또는 절대 URL. */\n toTarget: string;\n /** 301 영구 / 302 임시. */\n statusCode: 301 | 302;\n}\n\nexport interface FetchRedirectsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\n /** ISR revalidate 초(opt-in, W0-9). 미지정 시 현행 동작 유지. */\n revalidate?: number;\n}\n\ntype CmsRedirectWire = {\n id: string;\n from_path: string;\n to_target: string;\n status_code: number;\n};\n\n/**\n * 사이트의 활성 커스텀 리다이렉트 규칙 전체. 고객 사이트 middleware\n * (`createRedirectMiddleware`)가 요청 경로 매칭에 사용한다. 구 서버(라우트\n * 미배포)는 404 — 빈 배열로 fail-soft (트래픽을 막지 않음).\n */\nexport async function fetchRedirects(\n options: FetchRedirectsOptions,\n): Promise<RootTaleRedirect[]> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/redirects${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n\n if (response.status === 404) return [];\n if (!response.ok) {\n throw await toApiError(response);\n }\n\n const json = (await response.json()) as { items: CmsRedirectWire[] };\n return (json.items ?? []).map((w) => ({\n id: w.id,\n fromPath: w.from_path,\n toTarget: w.to_target,\n statusCode: (w.status_code === 302 ? 302 : 301) as 301 | 302,\n }));\n}\n","import {\n DEFAULT_BASE_URL,\n assertServer,\n authHeaders,\n buildGetInit,\n toApiError,\n} from \"./server-base\";\n\nexport type SiteInquiryStatus = \"pending\" | \"answered\";\n\nexport interface SiteInquiryListItem {\n id: string;\n title: string;\n category: string | null;\n authorMasked: string | null;\n status: SiteInquiryStatus | null;\n isSecret: boolean;\n locked: boolean;\n createdAt: string | null;\n}\n\nexport interface SiteInquiryListPage {\n items: SiteInquiryListItem[];\n hasMore: boolean;\n nextCursor: string | null;\n}\n\nexport interface SiteInquiryDetail {\n id: string;\n title: string;\n category: string | null;\n authorMasked: string;\n status: SiteInquiryStatus;\n isSecret: boolean;\n body: string;\n answerBody: string | null;\n answeredAt: string | null;\n createdAt: string;\n}\n\nexport interface FetchSiteInquiriesOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n limit?: number;\n cursor?: string;\n signal?: AbortSignal;\n revalidate?: number;\n}\n\nexport interface FetchSiteInquiryOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n id: string;\n password?: string;\n signal?: AbortSignal;\n revalidate?: number;\n}\n\nexport interface SubmitSiteInquiryFields {\n siteId?: string;\n category?: string;\n title: string;\n body: string;\n authorName: string;\n contact?: string;\n isSecret: boolean;\n secretPassword?: string;\n privacyConsent: boolean;\n}\n\nexport interface SubmitSiteInquiryOptions {\n apiKey: string;\n baseUrl?: string;\n fields: SubmitSiteInquiryFields;\n signal?: AbortSignal;\n}\n\nexport interface SubmitSiteInquiryResult {\n id: string;\n status: SiteInquiryStatus;\n}\n\ntype SiteInquiryListItemWire = {\n id: string;\n title: string;\n category: string | null;\n author_masked: string | null;\n status: SiteInquiryStatus | null;\n is_secret: boolean;\n locked: boolean;\n created_at: string | null;\n};\n\ntype SiteInquiryDetailWire = {\n id: string;\n title: string;\n category: string | null;\n author_masked: string;\n status: SiteInquiryStatus;\n is_secret: boolean;\n body: string;\n answer_body: string | null;\n answered_at: string | null;\n created_at: string;\n};\n\nexport async function fetchSiteInquiries(\n options: FetchSiteInquiriesOptions,\n): Promise<SiteInquiryListPage> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.limit !== undefined) params.set(\"limit\", String(options.limit));\n if (options.cursor) params.set(\"cursor\", options.cursor);\n if (options.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/inquiries${qs ? `?${qs}` : \"\"}`;\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n if (!response.ok) throw await toApiError(response);\n const json = (await response.json()) as {\n items: SiteInquiryListItemWire[];\n has_more: boolean;\n next_cursor: string | null;\n };\n return {\n items: json.items.map(fromListWire),\n hasMore: json.has_more,\n nextCursor: json.next_cursor,\n };\n}\n\nexport async function fetchSiteInquiry(\n options: FetchSiteInquiryOptions,\n): Promise<SiteInquiryDetail | null> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const params = new URLSearchParams();\n if (options.siteId) params.set(\"site_id\", options.siteId);\n if (options.password) params.set(\"password\", options.password);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/inquiries/${encodeURIComponent(options.id)}${qs ? `?${qs}` : \"\"}`;\n const response = await fetch(url, buildGetInit(options.apiKey, options));\n if (response.status === 404) return null;\n if (!response.ok) throw await toApiError(response);\n return fromDetailWire((await response.json()) as SiteInquiryDetailWire);\n}\n\nexport async function submitSiteInquiry(\n options: SubmitSiteInquiryOptions,\n): Promise<SubmitSiteInquiryResult> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const fields = options.fields;\n const response = await fetch(`${baseUrl}/v1/cms/public/inquiries`, {\n method: \"POST\",\n headers: {\n ...authHeaders(options.apiKey),\n \"content-type\": \"application/json\",\n },\n body: JSON.stringify({\n site_id: fields.siteId,\n category: fields.category,\n title: fields.title,\n body: fields.body,\n author_name: fields.authorName,\n contact: fields.contact,\n is_secret: fields.isSecret,\n secret_password: fields.secretPassword,\n privacy_consent: fields.privacyConsent,\n }),\n signal: options.signal,\n });\n if (!response.ok) throw await toApiError(response);\n return (await response.json()) as SubmitSiteInquiryResult;\n}\n\nfunction fromListWire(item: SiteInquiryListItemWire): SiteInquiryListItem {\n return {\n id: item.id,\n title: item.title,\n category: item.category,\n authorMasked: item.author_masked,\n status: item.status,\n isSecret: item.is_secret,\n locked: item.locked,\n createdAt: item.created_at,\n };\n}\n\nfunction fromDetailWire(item: SiteInquiryDetailWire): SiteInquiryDetail {\n return {\n id: item.id,\n title: item.title,\n category: item.category,\n authorMasked: item.author_masked,\n status: item.status,\n isSecret: item.is_secret,\n body: item.body,\n answerBody: item.answer_body,\n answeredAt: item.answered_at,\n createdAt: item.created_at,\n };\n}\n","/**\n * 설정류 fetch 캐시 tag 한 벌 (ADR-0096 Phase C).\n *\n * 개별 tag 상수는 각자의 fetch 함수 옆에 산다 — `THEME_CACHE_TAG`\n * (server-posts-theme) · `BUSINESS_CACHE_TAG`(server-business) ·\n * `MENUS_CACHE_TAG`(server-discovery) · `BLOG_SETTINGS_CACHE_TAG` ·\n * `COLLECTIONS_CACHE_TAG`(server-blog). 본 모듈은 그것들을 **묶기만** 한다:\n * 어느 도메인 모듈에 배열을 두어도 나머지 셋을 import 해야 해서 모듈 방향이\n * 꼬이기 때문이다(여기서 한 방향으로만 import 한다).\n */\n\nimport { BLOG_SETTINGS_CACHE_TAG, COLLECTIONS_CACHE_TAG } from \"./server-blog\";\nimport { BUSINESS_CACHE_TAG } from \"./server-business\";\nimport { MENUS_CACHE_TAG } from \"./server-discovery\";\nimport { THEME_CACHE_TAG } from \"./server-posts-theme\";\n\n/**\n * 어드민 \"설정\" 저장으로 낡는 fetch 캐시 tag 전부 — 테마·사업장 정보·메뉴·\n * 블로그 표시 설정·콘텐츠 유형.\n *\n * 수신측(`createRevalidateRoute`)이 `theme.updated` 한 번에 이 목록을 통째로\n * 무효화한다. 사이트가 이 중 일부 fetch 만 쓰더라도 안 쓰는 tag 의\n * `revalidateTag` 는 no-op 이므로 해가 없다 — 그래서 \"설정을 저장했다\" 신호\n * 하나로 설정류 전체를 지우는 것이 안전한 기본값이다.\n */\nexport const SETTINGS_CACHE_TAGS: readonly string[] = [\n THEME_CACHE_TAG,\n BUSINESS_CACHE_TAG,\n MENUS_CACHE_TAG,\n BLOG_SETTINGS_CACHE_TAG,\n COLLECTIONS_CACHE_TAG,\n];\n","// 프레임워크 무관 SEO 헬퍼 — RSS 2.0 피드 + Schema.org JSON-LD 빌더.\n// 외부 사이트(Next route handler / Astro endpoint / RSC)가 fetchPosts 결과로\n// /feed.xml 응답이나 <head> JSON-LD 를 만들 때 사용. 순수 함수(네트워크 없음).\n\nimport type { CmsPostContent, RootTaleBusinessProfile } from \"./server\";\n\n// ── RSS 2.0 ────────────────────────────────────────────────────────────────\n\nexport interface RssItemInput {\n title: string;\n /** 글 절대 URL. */\n link: string;\n /** 고유 식별자. 미지정 시 link 사용. */\n guid?: string;\n description?: string | null;\n /** 발행 시각. */\n pubDate?: string | Date | null;\n}\n\nexport interface RssFeedInput {\n /** 사이트/피드 제목. */\n title: string;\n /** 사이트 홈 URL. */\n link: string;\n /** 이 피드 자체의 URL (atom:self). */\n feedUrl: string;\n description?: string;\n /** 기본 \"ko-KR\". */\n language?: string;\n items: RssItemInput[];\n}\n\n/** RSS 2.0 XML 문자열 생성. */\nexport function generateRssXml(input: RssFeedInput): string {\n const lang = input.language ?? \"ko-KR\";\n const lastBuild =\n input.items.length > 0 ? toRfc822(input.items[0]!.pubDate) : \"\";\n const lines: string[] = [];\n lines.push('<?xml version=\"1.0\" encoding=\"UTF-8\"?>');\n lines.push(\n '<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">',\n );\n lines.push(\"<channel>\");\n lines.push(`<title>${escapeXml(input.title)}</title>`);\n lines.push(`<link>${escapeXml(input.link)}</link>`);\n lines.push(\n `<atom:link href=\"${escapeXml(input.feedUrl)}\" rel=\"self\" type=\"application/rss+xml\" />`,\n );\n lines.push(\n `<description>${escapeXml(input.description ?? input.title)}</description>`,\n );\n lines.push(`<language>${escapeXml(lang)}</language>`);\n if (lastBuild) lines.push(`<lastBuildDate>${lastBuild}</lastBuildDate>`);\n for (const item of input.items) {\n const guid = item.guid ?? item.link;\n lines.push(\"<item>\");\n lines.push(`<title>${escapeXml(item.title)}</title>`);\n lines.push(`<link>${escapeXml(item.link)}</link>`);\n lines.push(`<guid isPermaLink=\"false\">${escapeXml(guid)}</guid>`);\n if (item.description) {\n lines.push(`<description>${cdata(item.description)}</description>`);\n }\n const pub = toRfc822(item.pubDate);\n if (pub) lines.push(`<pubDate>${pub}</pubDate>`);\n lines.push(\"</item>\");\n }\n lines.push(\"</channel>\");\n lines.push(\"</rss>\");\n return lines.join(\"\\n\");\n}\n\n/** CmsPostContent[] → RSS item[]. postUrl 로 글 절대 URL 생성. */\nexport function rssItemsFromPosts(\n posts: CmsPostContent[],\n opts: { postUrl: (post: CmsPostContent) => string },\n): RssItemInput[] {\n return posts.map((p) => ({\n title: p.title,\n link: opts.postUrl(p),\n guid: opts.postUrl(p),\n description: p.excerpt,\n pubDate: p.publishedAt,\n }));\n}\n\nfunction toRfc822(value: string | Date | null | undefined): string {\n if (!value) return \"\";\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? \"\" : d.toUTCString();\n}\n\n// ── Schema.org JSON-LD 빌더 ─────────────────────────────────────────────────\n\ntype JsonLd = Record<string, unknown>;\n\nexport interface ArticleSchemaInput {\n headline: string;\n url: string;\n description?: string | null;\n image?: string | null;\n datePublished?: string | Date | null;\n dateModified?: string | Date | null;\n authorName?: string | null;\n publisherName?: string;\n publisherLogo?: string;\n /** Schema.org 타입. 기본 \"Article\" (미지정 시 기존 출력 그대로). */\n type?: \"Article\" | \"BlogPosting\";\n /** 지정 시 `@id` emit (관례: `<url>#article`). */\n id?: string;\n /** 둘 다 있고 image 도 있으면 image 를 ImageObject 로 승격. */\n imageWidth?: number;\n imageHeight?: number;\n /** 저자 페이지 URL — 있으면 author 에 url + `@id`(`<authorUrl>#person`) 추가. */\n authorUrl?: string;\n /** 있으면 publisher 에 `@id` 추가 (name/logo 는 유지). */\n publisherId?: string;\n /** → articleSection. */\n section?: string;\n /** → inLanguage (기본값 없음, 지정 시만). */\n inLanguage?: string;\n}\n\nexport function articleSchema(input: ArticleSchemaInput): JsonLd {\n const schema: JsonLd = {\n \"@context\": \"https://schema.org\",\n \"@type\": input.type ?? \"Article\",\n headline: input.headline,\n mainEntityOfPage: { \"@type\": \"WebPage\", \"@id\": input.url },\n };\n if (input.id) schema[\"@id\"] = input.id;\n if (input.description) schema.description = input.description;\n if (input.image) {\n schema.image =\n input.imageWidth && input.imageHeight\n ? {\n \"@type\": \"ImageObject\",\n url: input.image,\n width: input.imageWidth,\n height: input.imageHeight,\n }\n : input.image;\n }\n const pub = toIso(input.datePublished);\n if (pub) schema.datePublished = pub;\n const mod = toIso(input.dateModified);\n if (mod) schema.dateModified = mod;\n if (input.authorName) {\n schema.author = {\n \"@type\": \"Person\",\n name: input.authorName,\n ...(input.authorUrl\n ? { \"@id\": `${input.authorUrl}#person`, url: input.authorUrl }\n : {}),\n };\n }\n if (input.publisherName) {\n schema.publisher = {\n \"@type\": \"Organization\",\n ...(input.publisherId ? { \"@id\": input.publisherId } : {}),\n name: input.publisherName,\n ...(input.publisherLogo\n ? { logo: { \"@type\": \"ImageObject\", url: input.publisherLogo } }\n : {}),\n };\n }\n if (input.section) schema.articleSection = input.section;\n if (input.inLanguage) schema.inLanguage = input.inLanguage;\n return schema;\n}\n\n/**\n * 저자 프로필 페이지용 ProfilePage JSON-LD.\n * `/blog/author/<slug>` 같은 저자 페이지 `<head>` 에서 사용.\n */\nexport function profilePageSchema(input: {\n url: string;\n name: string;\n image?: string | null;\n description?: string | null;\n}): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"ProfilePage\",\n mainEntity: {\n \"@type\": \"Person\",\n \"@id\": `${input.url}#person`,\n name: input.name,\n url: input.url,\n ...(input.image ? { image: input.image } : {}),\n ...(input.description ? { description: input.description } : {}),\n },\n };\n}\n\n/**\n * 글 목록·카테고리 허브 페이지용 CollectionPage JSON-LD.\n * `/blog`, `/blog/category/<slug>` 등에서 사용.\n */\nexport function collectionPageSchema(input: {\n url: string;\n name: string;\n description?: string | null;\n siteUrl?: string;\n}): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"CollectionPage\",\n \"@id\": `${input.url}#collection`,\n url: input.url,\n name: input.name,\n ...(input.description ? { description: input.description } : {}),\n ...(input.siteUrl\n ? { isPartOf: { \"@type\": \"WebSite\", url: input.siteUrl } }\n : {}),\n };\n}\n\nexport function breadcrumbSchema(\n items: { name: string; url: string }[],\n): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"BreadcrumbList\",\n itemListElement: items.map((it, i) => ({\n \"@type\": \"ListItem\",\n position: i + 1,\n name: it.name,\n item: it.url,\n })),\n };\n}\n\nexport function organizationSchema(input: {\n name: string;\n url: string;\n logo?: string;\n sameAs?: string[];\n}): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"Organization\",\n name: input.name,\n url: input.url,\n ...(input.logo ? { logo: input.logo } : {}),\n ...(input.sameAs && input.sameAs.length > 0 ? { sameAs: input.sameAs } : {}),\n };\n}\n\n// ── LocalBusiness (로컬 SEO) ────────────────────────────────────────────────\n\n/** Mo~Su 약어 → Schema.org dayOfWeek 전체 이름. */\nconst DAY_OF_WEEK: Record<string, string> = {\n Mo: \"Monday\",\n Tu: \"Tuesday\",\n We: \"Wednesday\",\n Th: \"Thursday\",\n Fr: \"Friday\",\n Sa: \"Saturday\",\n Su: \"Sunday\",\n};\n\n/**\n * `localBusinessSchema` 입력 — `fetchBusinessProfile` 의 반환값\n * (RootTaleBusinessProfile) 을 그대로 받도록 느슨하게 정의 (null/undefined 모두\n * 허용). 수동 구성도 가능.\n */\nexport interface LocalBusinessProfileInput {\n name: string;\n legalName?: string | null;\n /** 간판명의 표기 변형(띄어쓰기 등) — alternateName. */\n alternateName?: string | null;\n /** Schema.org LocalBusiness 하위 타입. 미지정 시 \"LocalBusiness\". */\n businessType?: string | null;\n telephone?: string | null;\n faxNumber?: string | null;\n email?: string | null;\n address?: {\n streetAddress: string;\n addressLocality: string;\n addressRegion?: string | null;\n postalCode?: string | null;\n } | null;\n geo?: { latitude: number; longitude: number } | null;\n openingHours?:\n | { days: string[]; opens: string; closes: string }[]\n | null;\n priceRange?: string | null;\n areaServed?: string[] | null;\n /** 취급 업무 — hasOfferCatalog 의 Offer 목록으로 들어간다. */\n services?: string[] | null;\n /** 외부 프로필 URL — 값들이 sameAs 로 들어간다 (네이버플레이스·구글 등). */\n profiles?: Record<string, string | null | undefined> | null;\n}\n\n/**\n * LocalBusiness JSON-LD — admin \"운영 > 비즈니스 프로필\" 의\n * `fetchBusinessProfile` 결과로 layout 에 1회 렌더한다 (로컬 SEO).\n *\n * - `@type` 은 `[businessType, \"LocalBusiness\"]` 중복 제거 배열.\n * - `profiles` 의 URL 들은 `sameAs` 로 — 검색엔진이 네이버플레이스/구글\n * 비즈니스 프로필과 동일 사업장임을 연결한다.\n */\n/** 취급 업무 상한 — api-contracts / api-core sanitize 와 같은 값이어야 한다. */\nexport const LOCAL_BUSINESS_MAX_SERVICES = 12;\nexport const LOCAL_BUSINESS_SERVICE_MAX_LENGTH = 80;\n\n/** hasMap 으로 빠지고 sameAs 에는 넣지 않는 지도 딥링크 키. */\nconst MAP_PROFILE_KEYS = new Set([\"kakaoMap\", \"kakao_map\"]);\n\nexport function localBusinessSchema(\n profile: LocalBusinessProfileInput | RootTaleBusinessProfile,\n opts: { url: string; image?: string | null },\n): JsonLd {\n const input = profile as LocalBusinessProfileInput;\n const types = Array.from(\n new Set([input.businessType || \"LocalBusiness\", \"LocalBusiness\"]),\n );\n const schema: JsonLd = {\n \"@context\": \"https://schema.org\",\n \"@type\": types,\n name: input.name,\n url: opts.url,\n };\n if (input.legalName) schema.legalName = input.legalName;\n if (input.alternateName) schema.alternateName = input.alternateName;\n if (opts.image) schema.image = opts.image;\n if (input.telephone) schema.telephone = input.telephone;\n if (input.faxNumber) schema.faxNumber = input.faxNumber;\n if (input.email) schema.email = input.email;\n if (input.address) {\n schema.address = {\n \"@type\": \"PostalAddress\",\n streetAddress: input.address.streetAddress,\n addressLocality: input.address.addressLocality,\n ...(input.address.addressRegion\n ? { addressRegion: input.address.addressRegion }\n : {}),\n ...(input.address.postalCode\n ? { postalCode: input.address.postalCode }\n : {}),\n addressCountry: \"KR\",\n };\n }\n if (input.geo) {\n schema.geo = {\n \"@type\": \"GeoCoordinates\",\n latitude: input.geo.latitude,\n longitude: input.geo.longitude,\n };\n }\n const openingHours = (input.openingHours ?? []).filter(\n (row) => row.days.length > 0 && row.opens && row.closes,\n );\n if (openingHours.length > 0) {\n schema.openingHoursSpecification = openingHours.map((row) => ({\n \"@type\": \"OpeningHoursSpecification\",\n dayOfWeek: row.days.map((d) => DAY_OF_WEEK[d] ?? d),\n opens: row.opens,\n closes: row.closes,\n }));\n }\n if (input.priceRange) schema.priceRange = input.priceRange;\n if (input.areaServed && input.areaServed.length > 0) {\n schema.areaServed = input.areaServed;\n }\n // 취급 업무 → OfferCatalog. \"무엇을 하는 곳인가\" 를 검색엔진에 명시하는 신호.\n //\n // 이 함수는 공개 API 라 admin/sanitize 를 거치지 않은 수동 입력도 들어온다.\n // 두 경로의 결과가 갈리지 않도록 여기서도 같은 정규화(공백 정리 → 길이 제한\n // → 중복 제거 → 개수 제한)를 적용한다.\n //\n // Service 단위 areaServed 는 넣지 않는다 — 서비스별 지역 데이터가 없어서\n // 첫 지역만 쓰면 \"이 서비스는 서울에서만 제공\" 이라는 없는 사실을 주장하게 된다.\n // 지역은 최상위 areaServed 가 이미 담고 있다.\n const services: string[] = [];\n for (const raw of input.services ?? []) {\n if (services.length >= LOCAL_BUSINESS_MAX_SERVICES) break;\n if (typeof raw !== \"string\") continue;\n const name = raw.trim().slice(0, LOCAL_BUSINESS_SERVICE_MAX_LENGTH);\n if (name && !services.includes(name)) services.push(name);\n }\n if (services.length > 0) {\n schema.hasOfferCatalog = {\n \"@type\": \"OfferCatalog\",\n name: `${input.name} 서비스`,\n itemListElement: services.map((name) => ({\n \"@type\": \"Offer\",\n itemOffered: { \"@type\": \"Service\", name },\n })),\n };\n }\n const profiles = input.profiles ?? {};\n // 지도 링크는 sameAs 가 아니라 hasMap 이 정확한 자리다. 네이버 플레이스 우선,\n // 없으면 지도 딥링크로 대체한다. `profiles` 는 수동 구성도 허용하는 느슨한\n // Record 라, 지도 키는 camelCase·snake_case 를 모두 인정한다.\n const pickUrl = (key: string): string | null => {\n const value = profiles[key];\n return typeof value === \"string\" && value.length > 0 ? value : null;\n };\n const mapUrl = pickUrl(\"naverPlace\") ?? pickUrl(\"kakaoMap\") ?? pickUrl(\"kakao_map\");\n if (mapUrl) schema.hasMap = mapUrl;\n // sameAs 는 \"같은 사업체를 가리키는 프로필 페이지\" 만 — 지도 딥링크는 프로필이\n // 아니라 위치 링크라 hasMap 으로만 내보내고 여기서는 뺀다.\n const sameAs = Object.entries(profiles)\n .filter(([key]) => !MAP_PROFILE_KEYS.has(key))\n .map(([, value]) => value)\n .filter((u): u is string => typeof u === \"string\" && u.length > 0);\n if (sameAs.length > 0) schema.sameAs = sameAs;\n return schema;\n}\n\nexport function websiteSchema(input: {\n name: string;\n url: string;\n searchUrl?: string;\n}): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"WebSite\",\n name: input.name,\n url: input.url,\n ...(input.searchUrl\n ? {\n potentialAction: {\n \"@type\": \"SearchAction\",\n target: `${input.searchUrl}{search_term_string}`,\n \"query-input\": \"required name=search_term_string\",\n },\n }\n : {}),\n };\n}\n\nexport function faqSchema(\n qa: { question: string; answer: string }[],\n): JsonLd {\n return {\n \"@context\": \"https://schema.org\",\n \"@type\": \"FAQPage\",\n mainEntity: qa.map((x) => ({\n \"@type\": \"Question\",\n name: x.question,\n acceptedAnswer: { \"@type\": \"Answer\", text: x.answer },\n })),\n };\n}\n\n/**\n * ADR-0065 — 글 조회수 식별자 meta 태그 HTML.\n *\n * 글 상세 페이지 `<head>` 에 렌더하면, RootTale 비콘이 이 값을 읽어 pageview 에\n * `post_id` 를 동봉한다 → URL/컬렉션 basePath 변경에 무관하게 조회수가 해당 글에\n * 정확히 집계된다. `post.id`(uuidv7) 를 그대로 넘기면 된다.\n *\n * Next App Router 는 `generateMetadata` 의 `other: { \"rt:content-id\": post.id }`\n * 로도 동일 태그를 낼 수 있다 — 그 경우 본 헬퍼 대신 metadata 를 쓴다.\n *\n * @example\n * // 프레임워크 무관(Astro/RSC set:html 등):\n * <head>{contentIdMeta(post.id)}</head>\n */\nexport function contentIdMeta(postId: string): string {\n return `<meta name=\"rt:content-id\" content=\"${escapeXml(postId)}\" />`;\n}\n\n/**\n * JSON-LD 객체 → XSS-safe JSON *문자열* (script 태그 없음). React 소비자가\n * `<script type=\"application/ld+json\" dangerouslySetInnerHTML={{ __html: ... }} />`\n * 로 직접 방출할 때 사용 — `</script>` 주입 방지 escape 의 단일 소스.\n */\nexport function jsonLdJson(schema: JsonLd | JsonLd[]): string {\n // `</script>` 주입 방지를 위해 `<` 이스케이프.\n return JSON.stringify(schema).replace(/</g, \"\\\\u003c\");\n}\n\n/** JSON-LD 객체 → `<script type=\"application/ld+json\">` HTML 문자열 (XSS-safe). */\nexport function jsonLdScript(schema: JsonLd | JsonLd[]): string {\n return `<script type=\"application/ld+json\">${jsonLdJson(schema)}</script>`;\n}\n\nfunction toIso(value: string | Date | null | undefined): string | null {\n if (!value) return null;\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? null : d.toISOString();\n}\n\nfunction escapeXml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n\nfunction cdata(value: string): string {\n return `<![CDATA[${value.replace(/]]>/g, \"]]]]><![CDATA[>\")}]]>`;\n}\n","/**\n * `@roottale/cms-client` — 리드(문의) 접수 server-side 헬퍼.\n *\n * 외부 고객사 사이트가 *블로그 조회(`fetchPosts`)와 동일한 API key 하나* 로 리드도\n * 접수하기 위한 SSR-only 클라이언트. 브라우저가 아니라 사이트 서버(Server Action /\n * Route Handler)에서 호출해야 한다 — API key 는 절대 브라우저로 노출 금지\n * (ADR-0023 §5.1 #15). 키가 tenant 를 권위 식별하므로 `tenant_slug` 불필요.\n *\n * 사용 예 (Next.js server action):\n * ```ts\n * \"use server\";\n * import { submitInquiry } from \"@roottale/cms-client/server\";\n *\n * const result = await submitInquiry({\n * apiKey: process.env.ROOTTALE_API_KEY!, // fetchPosts 와 같은 키\n * fields: {\n * vertical: \"tax\",\n * contactName: name,\n * businessName: company,\n * email,\n * phone,\n * message,\n * privacyConsent: true,\n * },\n * });\n * if (result.ok) showSuccess(); // 그 키의 tenant /crm 에 도착\n * else showError(result.message);\n * ```\n *\n * intake API 는 302 `?ok=1` / `?err=*` 로 응답 — 본 헬퍼가 파싱해 구조화 결과 반환.\n */\n\nimport type { InquiryAttribution } from \"./attribution\";\n\nconst DEFAULT_BASE_URL = \"https://api.roottale.com\";\n\nexport type InquiryVertical = \"consulting\" | \"medical\" | \"tax\" | \"legal\";\n\n/** intake API 의 `err` 코드 → 한국어 사용자 메시지. */\nexport const INQUIRY_ERROR_MESSAGES: Record<string, string> = {\n consent_privacy: \"개인정보 수집·이용 동의가 필요합니다.\",\n consent_overseas: \"의료 분야는 국외이전 동의가 필요합니다.\",\n invalid_vertical: \"업종 분류가 유효하지 않습니다.\",\n missing_fields: \"필수 항목이 비어 있습니다.\",\n invalid_email: \"이메일 형식이 올바르지 않습니다.\",\n invalid_api_key: \"연동 인증에 실패했습니다. API 키를 확인해주세요.\",\n rate_limited: \"요청이 너무 많습니다. 잠시 후 다시 시도해주세요.\",\n turnstile_failed: \"보안 확인에 실패했습니다. 새로고침 후 다시 시도해주세요.\",\n internal: \"전송에 실패했습니다. 잠시 후 다시 시도해주세요.\",\n};\n\nexport interface SubmitInquiryFields {\n vertical: InquiryVertical;\n contactName: string;\n businessName: string;\n email: string;\n phone: string;\n /** 자유 문의 내용 (선택). */\n message?: string;\n /** 상담 분야 라벨 등 부가 메타 (선택). */\n consultationField?: string;\n /** 현재 사이트 URL (선택). */\n currentSiteUrl?: string;\n /** 개인정보 수집·이용 동의 (필수 true). */\n privacyConsent: boolean;\n /** 의료 vertical 의 국외이전 동의 (medical 일 때 필수). */\n overseasTransferConsent?: boolean;\n /**\n * \"patient\"(고객사 방문자 문의, 기본) | \"sales\"(RootTale 자체 B2B 영업 리드).\n * sales 는 medical 국외이전 동의 게이트 면제.\n */\n leadKind?: \"patient\" | \"sales\";\n turnstileToken?: string;\n /**\n * 표준 필드 외에 폼이 받은 임의 항목(key→value)을 있는 그대로 전달. intake 가\n * 암호화 보관하고 CRM 상세에서 노출한다. 예약 키(표준 필드)·`_` 접두 키는 무시.\n * 주의: 개인정보가 담길 수 있으므로 폼의 동의 고지에 수집 항목을 반영할 것.\n */\n extras?: Record<string, string>;\n /**\n * 유입 first-touch 어트리뷰션 (ADR-0048 §2, 선택). 브라우저에서\n * `readAttribution()`(`@roottale/cms-client/attribution`)으로 읽어 Server\n * Action 인자로 넘긴 값을 그대로 전달하면 된다. CRM 에 \"이 문의가 어느\n * 글/링크/QR 에서 왔는지\"가 표시된다. null/undefined 면 미동반 제출.\n */\n attribution?: InquiryAttribution | null;\n /**\n * 방문 여정(journey) JSON 원문(설계 v2 §3, 선택) — `@roottale/analytics-runtime`\n * `readJourney()` 로 읽은 sessionStorage `_rt_journey` 링버퍼를 그대로\n * `JSON.stringify` 한 문자열. 파싱·검증은 서버(zod, medical/legal 마일스톤\n * 게이트 재강제)가 한다 — 여기서는 원문 그대로 `attr_journey` FormData 로\n * 전달만. null/undefined/빈 문자열이면 미동반 제출.\n */\n journey?: string;\n}\n\n/** submitInquiry 가 전용으로 매핑하는 키 — extras 에서 충돌 시 무시. */\nconst RESERVED_INQUIRY_KEYS = new Set([\n \"vertical\",\n \"contact_name\",\n \"business_name\",\n \"email\",\n \"phone\",\n \"message\",\n \"consultation_field\",\n \"current_site_url\",\n \"privacy_consent\",\n \"overseas_transfer_consent\",\n \"lead_kind\",\n \"cf-turnstile-response\",\n]);\n\nexport interface SubmitInquiryOptions {\n /** `rtlk_cust_*` API key — `fetchPosts` 와 동일한 키. Required. */\n apiKey: string;\n /** API base URL 오버라이드. 기본 `https://api.roottale.com`. */\n baseUrl?: string;\n fields: SubmitInquiryFields;\n signal?: AbortSignal;\n}\n\nexport type SubmitInquiryResult =\n | { ok: true }\n | { ok: false; code: string | null; message: string };\n\n/** `err` 코드를 사용자 메시지로 (미지의 코드는 generic). */\nexport function inquiryErrorMessage(code: string | null | undefined): string {\n return (code && INQUIRY_ERROR_MESSAGES[code]) || INQUIRY_ERROR_MESSAGES.internal;\n}\n\n/**\n * intake API 의 302 Location (`?ok=1` / `?err=*`) 을 구조화 결과로 파싱.\n * 외부 사이트가 ERROR_MESSAGES·파싱을 각자 재구현하지 않도록 공유.\n */\nexport function parseInquiryRedirect(\n location: string | null,\n base: string = DEFAULT_BASE_URL,\n): SubmitInquiryResult {\n if (!location) {\n return { ok: false, code: null, message: inquiryErrorMessage(null) };\n }\n let url: URL;\n try {\n url = new URL(location, base);\n } catch {\n return { ok: false, code: null, message: inquiryErrorMessage(null) };\n }\n if (url.searchParams.get(\"ok\") === \"1\") return { ok: true };\n const code = url.searchParams.get(\"err\");\n return { ok: false, code, message: inquiryErrorMessage(code) };\n}\n\nexport function formatKoreanPhoneNumber(input: string): string {\n let digits = input.replace(/\\D/g, \"\");\n if (digits.startsWith(\"82\") && digits.length >= 11) {\n digits = `0${digits.slice(2)}`;\n }\n if (digits.length === 11) {\n return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;\n }\n if (digits.length === 10) {\n if (digits.startsWith(\"02\")) {\n return `${digits.slice(0, 2)}-${digits.slice(2, 6)}-${digits.slice(6)}`;\n }\n return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;\n }\n if (digits.length === 8) {\n return `${digits.slice(0, 4)}-${digits.slice(4)}`;\n }\n return input.trim();\n}\n\n/**\n * 리드를 RootTale intake API 로 제출 (server-to-server, Bearer 인증).\n * 키의 tenant 로 라우팅되어 admin(admin.roottale.com) /crm 에서 관리된다.\n */\nexport async function submitInquiry(\n options: SubmitInquiryOptions,\n): Promise<SubmitInquiryResult> {\n assertServer();\n const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, \"\");\n const f = options.fields;\n\n const body = new FormData();\n body.set(\"vertical\", f.vertical);\n body.set(\"contact_name\", f.contactName);\n body.set(\"business_name\", f.businessName);\n body.set(\"email\", f.email);\n body.set(\"phone\", formatKoreanPhoneNumber(f.phone));\n if (f.message) body.set(\"message\", f.message);\n if (f.consultationField) body.set(\"consultation_field\", f.consultationField);\n if (f.currentSiteUrl) body.set(\"current_site_url\", f.currentSiteUrl);\n // 사용자가 명시 동의한 경우에만 전달 (자동 부여 금지).\n if (f.privacyConsent) body.set(\"privacy_consent\", \"on\");\n if (f.overseasTransferConsent) body.set(\"overseas_transfer_consent\", \"on\");\n if (f.leadKind) body.set(\"lead_kind\", f.leadKind);\n if (f.turnstileToken) body.set(\"cf-turnstile-response\", f.turnstileToken);\n // 유입 어트리뷰션 → intake `attr_*` 필드 (허용 키만, 빈 값 생략).\n if (f.attribution) {\n for (const [key, value] of Object.entries(f.attribution)) {\n // ad_click(설계 v2 §2) 은 boolean \"존재 플래그\" — true 일 때만 \"1\" 로\n // 전송(false/미설정은 신호 없음과 동일하므로 생략).\n if (typeof value === \"boolean\") {\n if (value) body.set(`attr_${key}`, \"1\");\n continue;\n }\n if (typeof value !== \"string\" || !value.trim()) continue;\n body.set(`attr_${key}`, value.trim());\n }\n }\n // 방문 여정(설계 v2 §3) — attribution 과 별도 채널(`attr_journey`). 파싱은\n // 서버(zod)가 전담 — 여기서는 원문 그대로 전달.\n if (f.journey && f.journey.trim()) {\n body.set(\"attr_journey\", f.journey.trim());\n }\n // 표준 외 임의 항목 — 예약 키/`_`접두/빈 값/중복은 건너뛰고 원본 그대로 전달.\n if (f.extras) {\n for (const [rawKey, rawValue] of Object.entries(f.extras)) {\n const key = rawKey.trim();\n const value = String(rawValue ?? \"\").trim();\n if (!key || !value) continue;\n if (key.startsWith(\"_\") || RESERVED_INQUIRY_KEYS.has(key)) continue;\n if (key.startsWith(\"attr_\")) continue; // 어트리뷰션 채널과 충돌 방지\n\n if (body.has(key)) continue;\n body.set(key, value);\n }\n }\n\n let response: Response;\n try {\n response = await fetch(`${baseUrl}/v1/public/inquiries`, {\n method: \"POST\",\n headers: { authorization: `Bearer ${options.apiKey}` },\n body,\n // intake 는 302 로 결과 신호 — 자동 추적 금지하고 Location 직접 읽음.\n redirect: \"manual\",\n signal: options.signal,\n });\n } catch {\n return { ok: false, code: \"internal\", message: inquiryErrorMessage(\"internal\") };\n }\n\n // 잘못된/만료된 키 → 라우트가 401.\n if (response.status === 401) {\n return {\n ok: false,\n code: \"invalid_api_key\",\n message: inquiryErrorMessage(\"invalid_api_key\"),\n };\n }\n if (response.status === 429) {\n return {\n ok: false,\n code: \"rate_limited\",\n message: inquiryErrorMessage(\"rate_limited\"),\n };\n }\n\n const location = response.headers.get(\"location\");\n if (location) return parseInquiryRedirect(location, baseUrl);\n\n // redirect 없이 성공 응답이면 ok 로 간주, 그 외엔 generic 실패.\n if (response.ok) return { ok: true };\n return { ok: false, code: \"internal\", message: inquiryErrorMessage(\"internal\") };\n}\n\nfunction assertServer(): void {\n if (typeof window !== \"undefined\") {\n throw new Error(\n \"@roottale/cms-client submitInquiry must not run in the browser. \" +\n \"Call it from a Server Action / Route Handler. \" +\n \"Shipping the API key to the browser breaks the security model (ADR-0023 §5.1 #15).\",\n );\n }\n}\n"],"mappings":"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","names":["DEFAULT_BASE_URL","assertServer"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-client",
3
- "version": "0.51.1",
3
+ "version": "0.52.0",
4
4
  "type": "module",
5
5
  "description": "RootTale CMS Public API server-side fetch client (Bearer rtlk_cust_* auth). SSR-only — refuses to run in the browser to prevent key leak (ADR-0023 §5.1 #15). Pairs with @roottale/cms-renderer-next.",
6
6
  "main": "./dist/server.js",