@roottale/cms-client 0.47.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # @roottale/cms-client
2
2
 
3
+ ## 0.51.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ca4b1c7: 비즈니스 프로필에 `alternateName` · `faxNumber` · `services[]` · `profiles.kakaoMap` 이 추가된다. 어드민 "운영 > 비즈니스 프로필" 에서 입력하며, `localBusinessSchema` 가 각각 `alternateName` · `faxNumber` · `hasOfferCatalog` · `hasMap` 으로 내보낸다. 그동안 고객 사이트가 코드 상수로 들고 있던 항목들이라, 이 버전부터는 어드민에서 고칠 수 있다.
8
+
9
+ `hasMap` 은 네이버 플레이스가 있으면 그쪽을 우선 쓰고 없을 때만 지도 딥링크로 대체한다. **지도 딥링크는 `sameAs` 에서 빠진다** — 지도는 "같은 사업체를 가리키는 프로필 페이지" 가 아니라 위치 링크라 `sameAs` 가 틀린 자리였다. 네이버 플레이스를 이미 입력해 둔 사이트는 이번 버전부터 `hasMap` 이 새로 붙는다(기존 `sameAs` 는 그대로).
10
+
11
+ `services` 는 `localBusinessSchema` 단에서도 공백 정리 → 80자 → 중복 제거 → 12개 상한을 적용한다. 어드민·공개 API 를 거치지 않고 직접 구성해 넘기는 호출자와 결과가 갈리지 않게 하기 위함이다. `Service` 단위 `areaServed` 는 넣지 않는다 — 서비스별 지역 데이터가 없어 첫 지역만 쓰면 없는 사실을 주장하게 되고, 지역은 최상위 `areaServed` 가 이미 담는다.
12
+
13
+ `RootTaleBusinessProfile` 에 새 속성이 **필수**로 추가된다. `fetchBusinessProfile` 의 반환값은 항상 채워지므로 소비 코드는 그대로지만, 이 타입으로 목 객체·픽스처를 직접 구성하던 곳은 네 필드를 채워야 컴파일된다. 수동 구성에는 모든 필드가 optional 인 `LocalBusinessProfileInput` 을 쓰는 편이 낫다.
14
+
15
+ ## 0.50.0
16
+
17
+ ### Minor Changes
18
+
19
+ - ebd8fa1: `fetchBlogSettings` 응답의 `sitemap` 에 `promotedCategorySlugs` 를 추가한다 — 어드민에서 "검색에 이 분류 페이지 노출" 을 켠 카테고리 slug 목록으로, 렌더러의 아카이브 색인 정책이 소비한다. 구버전 API 응답(필드 없음)에서는 이 값을 **생략**해 "승격 0건(`[]`)" 과 "모름(`undefined`)" 을 구분한다 — 후자에서는 렌더러가 정책을 적용하지 않는다.
20
+
21
+ 이 구분은 직접 해석하지 말고 `@roottale/cms-renderer-next/routes` 의 `resolveCategoryPromotion(slugs, slug)` 에 그대로 넘길 것 — 사이트맵과 아카이브 페이지가 같은 3-state 를 써야 두 소스의 결론이 갈리지 않는다. `DEFAULT_BLOG_SETTINGS` 의 `promotedCategorySlugs: []` 는 **블로그 본문 렌더링 폴백 전용**이며 색인 판정에 넘기면 안 된다(조회 실패가 "승격 0건 확정" 으로 뭉개진다).
22
+
23
+ - ef72e0a: 분류별 글 수를 서버가 센다 — 오래된 글에만 달린 분류가 검색에서 사라지지 않게
24
+
25
+ 분류 모음 페이지의 "글 0건이면 404" 판정과 사이트맵 포함 여부는 분류별 글 수에서
26
+ 나옵니다. 지금까지는 최근 200건을 받아 세었기 때문에, 글이 많은 사이트에서 그
27
+ 범위 밖에만 글이 있는 분류가 **모음 페이지(404)와 사이트맵 양쪽에서** 함께
28
+ 빠졌습니다.
29
+ - 공개 API에 `GET /v1/cms/public/categories` 추가 — 모든 분류와 발행 글 수를
30
+ 서버가 SQL로 집계해 내려줍니다. 집계 범위는 글 목록 API와 같은 어휘
31
+ (`exclude_collections` / `collection_key` / `locale`)로 지정합니다.
32
+ - `@roottale/cms-client` 에 `fetchCategoryCounts()` 추가.
33
+ - `fetchBlogArchiveCategories`(cms-renderer-next)가 이 API를 먼저 씁니다. 못 받으면
34
+ (구버전 API·일시 장애) 예전 방식으로 자동 복귀하므로 기존 동작이 깨지지 않고,
35
+ 사이트맵과 모음 페이지는 어느 경로에서든 같은 값을 봅니다.
36
+
37
+ 사이트 코드 변경은 필요 없습니다 — 패키지를 올리면 적용됩니다. 다만 사이트가
38
+ 바라보는 API가 아직 이 엔드포인트를 배포하지 않았다면 예전 동작이 그대로
39
+ 유지됩니다.
40
+
41
+ ## 0.48.0
42
+
43
+ ### Minor Changes
44
+
45
+ - a4c7070: avcd 구조 SEO 확장 — `articleSchema`에 `type`(Article/BlogPosting)·`id`·
46
+ `imageWidth`/`imageHeight`(ImageObject 승격)·`authorUrl`·`publisherId`·
47
+ `section`·`inLanguage` 옵션 추가(기본 출력 불변). 신규 `profilePageSchema`(작가
48
+ 프로필 페이지)·`collectionPageSchema`(글 목록/카테고리 허브) 헬퍼. 공개 posts
49
+ 응답에 `author_slug` 가산 → `CmsPostContent.authorSlug`.
50
+
3
51
  ## 0.47.0
4
52
 
5
53
  ### Minor Changes
package/dist/server.d.ts CHANGED
@@ -101,6 +101,11 @@ interface CmsPostContent {
101
101
  authorImageUrl: string | null;
102
102
  /** Public biography text for the post author. */
103
103
  authorBio: string | null;
104
+ /**
105
+ * Author archive slug (`/blog/author/{slug}`). null when the author has no
106
+ * assigned slug. 구 서버는 미포함 — 매핑부에서 `?? null` 기본값.
107
+ */
108
+ authorSlug: string | null;
104
109
  /** Tiptap / Gutenberg-compatible Block JSON. Sanitization is renderer's responsibility. */
105
110
  bodyJson: Record<string, unknown>;
106
111
  /** Public API only exposes `published`. */
@@ -406,6 +411,21 @@ interface RootTaleSitemapSettings {
406
411
  locales: string[];
407
412
  /** authors 하위 사이트맵 포함 (작가 아카이브 라우트 필요). */
408
413
  authors: boolean;
414
+ /**
415
+ * 색인 위생(seo-geo-contract v3.1 §1) — "검색에 이 분류 페이지 노출" 을 켠
416
+ * 카테고리 slug 목록. 아카이브는 기본 noindex 라서 여기 없는 카테고리의 모음
417
+ * 페이지는 sitemap 에서 빠진다.
418
+ *
419
+ * **`undefined` = 승격 정보를 모름** (구버전 API 응답이거나 admin 설정 fetch 를
420
+ * 생략한 경로). 이때 `createSitemapIndex` 는 정책을 적용하지 않고 기존 동작을
421
+ * 유지한다 — 모른다는 이유로 이미 색인된 URL 을 지우지 않기 위해서다.
422
+ *
423
+ * 이 구분을 소비처가 직접 해석하지 말고 cms-renderer-next 의
424
+ * `resolveCategoryPromotion(slugs, slug)` 에 그대로 넘길 것 — 사이트맵과
425
+ * 아카이브 페이지가 같은 3-state(`promoted`/`not-promoted`/`unknown`)를 써야
426
+ * 두 소스의 결론이 갈리지 않는다.
427
+ */
428
+ promotedCategorySlugs?: string[];
409
429
  }
410
430
  interface RootTaleBlogSettings {
411
431
  showTableOfContents: boolean;
@@ -563,6 +583,66 @@ declare function resolvePostDisplay(post: {
563
583
  */
564
584
  declare function selectRelatedPosts(current: CmsPostContent, candidates: CmsPostContent[], limit?: number): CmsPostContent[];
565
585
 
586
+ /**
587
+ * 분류(category)별 발행 글 수 — **서버측 집계** 조회.
588
+ *
589
+ * 분류 아카이브의 "0건 = 404" 판정과 사이트맵 포함 여부(색인 위생)를 정확히
590
+ * 계산하기 위한 값이다. 이 API 가 없던 시절 렌더러는 최근 글 배치를 받아 세었고,
591
+ * 글이 많은 사이트에서는 배치 한도 밖에만 글이 있는 분류가 페이지·사이트맵에서
592
+ * 함께 사라졌다. 소비처(`@roottale/cms-renderer-next`)는 이 함수를 먼저 쓰고,
593
+ * 실패하면 예전 배치 파생으로 fail-soft 한다.
594
+ */
595
+ /** 분류 1건 + 요청 스코프 안의 발행 글 수. */
596
+ interface CmsCategoryCount {
597
+ slug: string;
598
+ name: string;
599
+ /** 요청 스코프(미소속/특정 스트림) 안에서 이 분류에 걸린 발행 글 수. 0 가능. */
600
+ publishedPostCount: number;
601
+ /** 이 분류가 귀속된 콘텐츠 유형 key (ADR-0060). 미귀속·구버전 응답이면 null. */
602
+ collectionKey: string | null;
603
+ /**
604
+ * 어드민 "검색에 이 분류 페이지 노출" 토글. **승격 판정 입력으로 쓰지 말 것** —
605
+ * 렌더러의 3-state 승격은 `fetchBlogSettings().sitemap.promotedCategorySlugs`
606
+ * 하나에서만 조립한다(두 소스를 섞으면 사이트맵과 페이지가 서로 다른 시점의
607
+ * 값을 볼 수 있다). 진단·어드민 표시용 참고값이다.
608
+ */
609
+ hubPromoted: boolean;
610
+ }
611
+ interface CmsCategoryCounts {
612
+ categories: CmsCategoryCount[];
613
+ /** 분류 수가 서버 상한을 넘어 일부가 빠졌으면 true — 소비처는 경고를 남길 것. */
614
+ truncated: boolean;
615
+ }
616
+ interface FetchCategoryCountsOptions {
617
+ /** `rtlk_cust_*` API key. 블로그 조회와 같은 키. */
618
+ apiKey: string;
619
+ baseUrl?: string;
620
+ siteId?: string;
621
+ /**
622
+ * 그 섹션(collection) 소속 글만 센다 — 스트림 아카이브용. `excludeCollections`
623
+ * 와 동시 지정 불가(서버가 400).
624
+ */
625
+ collectionKey?: string;
626
+ /** 어느 스트림에도 안 속한 글만 센다 — 레거시 `/blog` 계열 아카이브의 모집단. */
627
+ excludeCollections?: boolean;
628
+ /** BCP-47. 미지정이면 사이트 기본 로케일(포스트 목록과 같은 기본 동작). */
629
+ locale?: string;
630
+ signal?: AbortSignal;
631
+ /** ISR revalidate 초(opt-in). 미지정 시 현행 동작 유지. */
632
+ revalidate?: number;
633
+ }
634
+ /**
635
+ * 사이트의 모든 분류와 발행 글 수를 가져온다. 네트워크/서버 에러는 **throw** —
636
+ * 호출자가 예전 파생 방식으로 fail-soft 할지, 화면을 죽일지 정한다.
637
+ *
638
+ * 이 엔드포인트가 없는 구버전 API 는 404 를 주고 그것도 `CmsApiError` 로 throw
639
+ * 되므로, 호출자는 "구버전 API" 와 "일시 장애" 를 같은 catch 로 다루면 된다.
640
+ * 응답 shape 이 예상과 다른 경우(`categories` 가 배열이 아님)도 throw 로 취급한다
641
+ * — 빈 목록으로 오해하면 "분류 0건" 이라는 **확정 정보**가 되어 아카이브가 전부
642
+ * 404 가 되기 때문이다.
643
+ */
644
+ declare function fetchCategoryCounts(options: FetchCategoryCountsOptions): Promise<CmsCategoryCounts>;
645
+
566
646
  /** Schema.org LocalBusiness 하위 타입 (admin "운영 > 비즈니스 프로필" 의 업종). */
567
647
  type BusinessType = "LocalBusiness" | "ProfessionalService" | "AccountingService" | "LegalService" | "MedicalClinic" | "Dentist" | "RealEstateAgent" | "Restaurant" | "BeautySalon";
568
648
  /** Schema.org openingHours 요일 약어. */
@@ -584,8 +664,12 @@ interface RootTaleBusinessProfile {
584
664
  name: string;
585
665
  /** 사업자등록 상호 — 간판명과 다를 때만 설정. */
586
666
  legalName: string | null;
667
+ /** 간판명의 표기 변형(띄어쓰기 등) — JSON-LD alternateName. */
668
+ alternateName: string | null;
587
669
  businessType: BusinessType;
588
670
  telephone: string | null;
671
+ /** 팩스번호 — JSON-LD faxNumber. */
672
+ faxNumber: string | null;
589
673
  email: string | null;
590
674
  address: {
591
675
  streetAddress: string;
@@ -602,11 +686,15 @@ interface RootTaleBusinessProfile {
602
686
  priceRange: string | null;
603
687
  /** 서비스 지역 (예: "서울 강남구"). */
604
688
  areaServed: string[];
689
+ /** 취급 업무 (예: "기장·신고대리") — JSON-LD hasOfferCatalog. */
690
+ services: string[];
605
691
  /** 외부 프로필 URL — JSON-LD sameAs 로 들어가 동일 사업장임을 연결. */
606
692
  profiles: {
607
693
  naverPlace: string | null;
608
694
  googleBusiness: string | null;
609
695
  kakaoChannel: string | null;
696
+ /** 지도 앱 딥링크 — naverPlace 가 없을 때 hasMap 대체값. */
697
+ kakaoMap: string | null;
610
698
  instagram: string | null;
611
699
  naverBlog: string | null;
612
700
  };
@@ -623,8 +711,10 @@ type BusinessProfileWire = {
623
711
  configured?: boolean;
624
712
  name?: string | null;
625
713
  legal_name?: string | null;
714
+ alternate_name?: string | null;
626
715
  business_type?: string | null;
627
716
  telephone?: string | null;
717
+ fax_number?: string | null;
628
718
  email?: string | null;
629
719
  address?: {
630
720
  street_address?: string;
@@ -643,10 +733,12 @@ type BusinessProfileWire = {
643
733
  }[];
644
734
  price_range?: string | null;
645
735
  area_served?: string[];
736
+ services?: string[];
646
737
  profiles?: {
647
738
  naver_place?: string | null;
648
739
  google_business?: string | null;
649
740
  kakao_channel?: string | null;
741
+ kakao_map?: string | null;
650
742
  instagram?: string | null;
651
743
  naver_blog?: string | null;
652
744
  } | null;
@@ -869,8 +961,43 @@ interface ArticleSchemaInput {
869
961
  authorName?: string | null;
870
962
  publisherName?: string;
871
963
  publisherLogo?: string;
964
+ /** Schema.org 타입. 기본 "Article" (미지정 시 기존 출력 그대로). */
965
+ type?: "Article" | "BlogPosting";
966
+ /** 지정 시 `@id` emit (관례: `<url>#article`). */
967
+ id?: string;
968
+ /** 둘 다 있고 image 도 있으면 image 를 ImageObject 로 승격. */
969
+ imageWidth?: number;
970
+ imageHeight?: number;
971
+ /** 저자 페이지 URL — 있으면 author 에 url + `@id`(`<authorUrl>#person`) 추가. */
972
+ authorUrl?: string;
973
+ /** 있으면 publisher 에 `@id` 추가 (name/logo 는 유지). */
974
+ publisherId?: string;
975
+ /** → articleSection. */
976
+ section?: string;
977
+ /** → inLanguage (기본값 없음, 지정 시만). */
978
+ inLanguage?: string;
872
979
  }
873
980
  declare function articleSchema(input: ArticleSchemaInput): JsonLd;
981
+ /**
982
+ * 저자 프로필 페이지용 ProfilePage JSON-LD.
983
+ * `/blog/author/<slug>` 같은 저자 페이지 `<head>` 에서 사용.
984
+ */
985
+ declare function profilePageSchema(input: {
986
+ url: string;
987
+ name: string;
988
+ image?: string | null;
989
+ description?: string | null;
990
+ }): JsonLd;
991
+ /**
992
+ * 글 목록·카테고리 허브 페이지용 CollectionPage JSON-LD.
993
+ * `/blog`, `/blog/category/<slug>` 등에서 사용.
994
+ */
995
+ declare function collectionPageSchema(input: {
996
+ url: string;
997
+ name: string;
998
+ description?: string | null;
999
+ siteUrl?: string;
1000
+ }): JsonLd;
874
1001
  declare function breadcrumbSchema(items: {
875
1002
  name: string;
876
1003
  url: string;
@@ -889,9 +1016,12 @@ declare function organizationSchema(input: {
889
1016
  interface LocalBusinessProfileInput {
890
1017
  name: string;
891
1018
  legalName?: string | null;
1019
+ /** 간판명의 표기 변형(띄어쓰기 등) — alternateName. */
1020
+ alternateName?: string | null;
892
1021
  /** Schema.org LocalBusiness 하위 타입. 미지정 시 "LocalBusiness". */
893
1022
  businessType?: string | null;
894
1023
  telephone?: string | null;
1024
+ faxNumber?: string | null;
895
1025
  email?: string | null;
896
1026
  address?: {
897
1027
  streetAddress: string;
@@ -910,6 +1040,8 @@ interface LocalBusinessProfileInput {
910
1040
  }[] | null;
911
1041
  priceRange?: string | null;
912
1042
  areaServed?: string[] | null;
1043
+ /** 취급 업무 — hasOfferCatalog 의 Offer 목록으로 들어간다. */
1044
+ services?: string[] | null;
913
1045
  /** 외부 프로필 URL — 값들이 sameAs 로 들어간다 (네이버플레이스·구글 등). */
914
1046
  profiles?: Record<string, string | null | undefined> | null;
915
1047
  }
@@ -921,6 +1053,9 @@ interface LocalBusinessProfileInput {
921
1053
  * - `profiles` 의 URL 들은 `sameAs` 로 — 검색엔진이 네이버플레이스/구글
922
1054
  * 비즈니스 프로필과 동일 사업장임을 연결한다.
923
1055
  */
1056
+ /** 취급 업무 상한 — api-contracts / api-core sanitize 와 같은 값이어야 한다. */
1057
+ declare const LOCAL_BUSINESS_MAX_SERVICES = 12;
1058
+ declare const LOCAL_BUSINESS_SERVICE_MAX_LENGTH = 80;
924
1059
  declare function localBusinessSchema(profile: LocalBusinessProfileInput | RootTaleBusinessProfile, opts: {
925
1060
  url: string;
926
1061
  image?: string | null;
@@ -1066,4 +1201,4 @@ declare function formatKoreanPhoneNumber(input: string): string;
1066
1201
  */
1067
1202
  declare function submitInquiry(options: SubmitInquiryOptions): Promise<SubmitInquiryResult>;
1068
1203
 
1069
- 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 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 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, 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, contentIdMeta, faqSchema, fetchAnalyticsConfig, fetchAuthors, fetchBlogSettings, fetchBusinessProfile, fetchCollections, fetchCollectionsSafeDefault, fetchMenu, fetchMenus, fetchPageBySlug, fetchPost, fetchPosts, fetchRedirects, fetchSiteInquiries, fetchSiteInquiry, fetchTheme, formatKoreanPhoneNumber, generateRssXml, inquiryErrorMessage, jsonLdJson, jsonLdScript, localBusinessSchema, organizationSchema, parseInquiryRedirect, resolvePostDisplay, rssItemsFromPosts, searchPosts, selectRelatedPosts, submitInquiry, submitSiteInquiry, websiteSchema };
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 };
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.47.0" : "dev";
7
+ var CLIENT_VERSION = true ? "0.51.0" : "dev";
8
8
  var CMS_CLIENT_VERSION = CLIENT_VERSION;
9
9
  function authHeaders(apiKey) {
10
10
  return {
@@ -173,6 +173,7 @@ function fromWire(wire) {
173
173
  authorName: wire.author_name ?? null,
174
174
  authorImageUrl: wire.author_image_url ?? null,
175
175
  authorBio: wire.author_bio ?? null,
176
+ authorSlug: wire.author_slug ?? null,
176
177
  bodyJson: wire.body_json,
177
178
  status: wire.status,
178
179
  metaJson: wire.meta_json,
@@ -255,7 +256,15 @@ async function fetchBlogSettings(options) {
255
256
  locales: Array.isArray(json.sitemap?.locales) ? json.sitemap.locales.filter(
256
257
  (s) => typeof s === "string" && s.length > 0
257
258
  ) : [],
258
- authors: json.sitemap?.authors === true
259
+ authors: json.sitemap?.authors === true,
260
+ // 배열이 아니면(구버전 API 응답) 필드 자체를 생략 = "승격 정보 모름".
261
+ // 빈 배열([])은 "승격된 카테고리가 하나도 없음" 이라는 확정 정보라 의미가
262
+ // 다르다 — 이 구분이 사이트맵 필터의 fail-soft 근거다.
263
+ ...Array.isArray(json.sitemap?.promoted_category_slugs) ? {
264
+ promotedCategorySlugs: json.sitemap.promoted_category_slugs.filter(
265
+ (s) => typeof s === "string" && s.length > 0
266
+ )
267
+ } : {}
259
268
  }
260
269
  };
261
270
  }
@@ -350,7 +359,21 @@ var DEFAULT_BLOG_SETTINGS = {
350
359
  sitemap: {
351
360
  blogImages: true,
352
361
  locales: [],
353
- authors: false
362
+ authors: false,
363
+ // 이 기본값은 **블로그 렌더링 폴백 전용**이다 (cms-renderer-next 의
364
+ // `fetchBlogSettingsFailSoft` = `fetchBlogSettings(...).catch(() => DEFAULT_BLOG_SETTINGS)`,
365
+ // 소비처는 `server/blog-list.tsx`·`server/blog-post.tsx` 뿐 — 둘 다 색인
366
+ // 정책과 무관한 본문 표시 설정만 읽는다).
367
+ //
368
+ // **색인 정책 경로는 이 상수를 쓰지 않는다** — 사이트맵은
369
+ // `resolveSitemapSettings`(cms-renderer-next/routes-sitemap-entries.ts)가
370
+ // 자체 try/catch 로 `remote = {}` 를 쓰고, 아카이브 페이지는 조회 실패 시
371
+ // 설정 객체 자체를 `undefined` 로 넘긴다. 그래서 두 경로 모두 fetch 실패가
372
+ // `promotedCategorySlugs === undefined`(= 승격 정보 모름) 로 전달돼
373
+ // `resolveCategoryPromotion` 이 `unknown` 을 만들고 기존 URL 이 보존된다.
374
+ // 여기 `[]` 는 "승격 0건"(확정) 이지만 그 의미가 색인 정책까지 전달되는
375
+ // 경로는 없다 — 새 소비처를 만들 때 이 상수를 승격 판정에 넘기지 말 것.
376
+ promotedCategorySlugs: []
354
377
  }
355
378
  };
356
379
  function resolvePostDisplay(post, settings, explicit) {
@@ -386,6 +409,51 @@ function selectRelatedPosts(current, candidates, limit = 3) {
386
409
  ).sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)).slice(0, limit);
387
410
  }
388
411
 
412
+ // src/server-categories.ts
413
+ function toNonNegativeInt(value) {
414
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : 0;
415
+ }
416
+ async function fetchCategoryCounts(options) {
417
+ assertServer();
418
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
419
+ const params = new URLSearchParams();
420
+ if (options.siteId) params.set("site_id", options.siteId);
421
+ if (options.collectionKey) params.set("collection_key", options.collectionKey);
422
+ if (options.excludeCollections) params.set("exclude_collections", "true");
423
+ if (options.locale) params.set("locale", options.locale);
424
+ const qs = params.toString();
425
+ const url = `${baseUrl}/v1/cms/public/categories${qs ? `?${qs}` : ""}`;
426
+ const response = await fetch(url, buildGetInit(options.apiKey, options));
427
+ if (!response.ok) {
428
+ throw await toApiError(response);
429
+ }
430
+ const json = await response.json();
431
+ if (!Array.isArray(json.categories)) {
432
+ throw new CmsApiError(
433
+ "RootTale CMS API returned no `categories` array for /v1/cms/public/categories",
434
+ response.status,
435
+ "unexpected_response"
436
+ );
437
+ }
438
+ return {
439
+ categories: json.categories.flatMap((o) => {
440
+ const slug = typeof o.slug === "string" ? o.slug : "";
441
+ const name = typeof o.name === "string" ? o.name : "";
442
+ if (!slug || !name) return [];
443
+ return [
444
+ {
445
+ slug,
446
+ name,
447
+ publishedPostCount: toNonNegativeInt(o.published_post_count),
448
+ collectionKey: typeof o.collection_key === "string" ? o.collection_key : null,
449
+ hubPromoted: o.hub_promoted === true
450
+ }
451
+ ];
452
+ }),
453
+ truncated: json.truncated === true
454
+ };
455
+ }
456
+
389
457
  // src/server-business.ts
390
458
  function businessProfileFromWire(json) {
391
459
  if (json.configured === false) return null;
@@ -406,18 +474,22 @@ function businessProfileFromWire(json) {
406
474
  return {
407
475
  name: json.name,
408
476
  legalName: json.legal_name ?? null,
477
+ alternateName: json.alternate_name ?? null,
409
478
  businessType: typeof json.business_type === "string" && json.business_type.length > 0 ? json.business_type : "LocalBusiness",
410
479
  telephone: json.telephone ?? null,
480
+ faxNumber: json.fax_number ?? null,
411
481
  email: json.email ?? null,
412
482
  address,
413
483
  geo,
414
484
  openingHours,
415
485
  priceRange: json.price_range ?? null,
416
486
  areaServed: json.area_served ?? [],
487
+ services: json.services ?? [],
417
488
  profiles: {
418
489
  naverPlace: json.profiles?.naver_place ?? null,
419
490
  googleBusiness: json.profiles?.google_business ?? null,
420
491
  kakaoChannel: json.profiles?.kakao_channel ?? null,
492
+ kakaoMap: json.profiles?.kakao_map ?? null,
421
493
  instagram: json.profiles?.instagram ?? null,
422
494
  naverBlog: json.profiles?.naver_blog ?? null
423
495
  }
@@ -672,27 +744,68 @@ function toRfc822(value) {
672
744
  function articleSchema(input) {
673
745
  const schema = {
674
746
  "@context": "https://schema.org",
675
- "@type": "Article",
747
+ "@type": input.type ?? "Article",
676
748
  headline: input.headline,
677
749
  mainEntityOfPage: { "@type": "WebPage", "@id": input.url }
678
750
  };
751
+ if (input.id) schema["@id"] = input.id;
679
752
  if (input.description) schema.description = input.description;
680
- if (input.image) schema.image = input.image;
753
+ if (input.image) {
754
+ schema.image = input.imageWidth && input.imageHeight ? {
755
+ "@type": "ImageObject",
756
+ url: input.image,
757
+ width: input.imageWidth,
758
+ height: input.imageHeight
759
+ } : input.image;
760
+ }
681
761
  const pub = toIso(input.datePublished);
682
762
  if (pub) schema.datePublished = pub;
683
763
  const mod = toIso(input.dateModified);
684
764
  if (mod) schema.dateModified = mod;
685
- if (input.authorName)
686
- schema.author = { "@type": "Person", name: input.authorName };
765
+ if (input.authorName) {
766
+ schema.author = {
767
+ "@type": "Person",
768
+ name: input.authorName,
769
+ ...input.authorUrl ? { "@id": `${input.authorUrl}#person`, url: input.authorUrl } : {}
770
+ };
771
+ }
687
772
  if (input.publisherName) {
688
773
  schema.publisher = {
689
774
  "@type": "Organization",
775
+ ...input.publisherId ? { "@id": input.publisherId } : {},
690
776
  name: input.publisherName,
691
777
  ...input.publisherLogo ? { logo: { "@type": "ImageObject", url: input.publisherLogo } } : {}
692
778
  };
693
779
  }
780
+ if (input.section) schema.articleSection = input.section;
781
+ if (input.inLanguage) schema.inLanguage = input.inLanguage;
694
782
  return schema;
695
783
  }
784
+ function profilePageSchema(input) {
785
+ return {
786
+ "@context": "https://schema.org",
787
+ "@type": "ProfilePage",
788
+ mainEntity: {
789
+ "@type": "Person",
790
+ "@id": `${input.url}#person`,
791
+ name: input.name,
792
+ url: input.url,
793
+ ...input.image ? { image: input.image } : {},
794
+ ...input.description ? { description: input.description } : {}
795
+ }
796
+ };
797
+ }
798
+ function collectionPageSchema(input) {
799
+ return {
800
+ "@context": "https://schema.org",
801
+ "@type": "CollectionPage",
802
+ "@id": `${input.url}#collection`,
803
+ url: input.url,
804
+ name: input.name,
805
+ ...input.description ? { description: input.description } : {},
806
+ ...input.siteUrl ? { isPartOf: { "@type": "WebSite", url: input.siteUrl } } : {}
807
+ };
808
+ }
696
809
  function breadcrumbSchema(items) {
697
810
  return {
698
811
  "@context": "https://schema.org",
@@ -724,6 +837,9 @@ var DAY_OF_WEEK = {
724
837
  Sa: "Saturday",
725
838
  Su: "Sunday"
726
839
  };
840
+ var LOCAL_BUSINESS_MAX_SERVICES = 12;
841
+ var LOCAL_BUSINESS_SERVICE_MAX_LENGTH = 80;
842
+ var MAP_PROFILE_KEYS = /* @__PURE__ */ new Set(["kakaoMap", "kakao_map"]);
727
843
  function localBusinessSchema(profile, opts) {
728
844
  const input = profile;
729
845
  const types = Array.from(
@@ -736,8 +852,10 @@ function localBusinessSchema(profile, opts) {
736
852
  url: opts.url
737
853
  };
738
854
  if (input.legalName) schema.legalName = input.legalName;
855
+ if (input.alternateName) schema.alternateName = input.alternateName;
739
856
  if (opts.image) schema.image = opts.image;
740
857
  if (input.telephone) schema.telephone = input.telephone;
858
+ if (input.faxNumber) schema.faxNumber = input.faxNumber;
741
859
  if (input.email) schema.email = input.email;
742
860
  if (input.address) {
743
861
  schema.address = {
@@ -771,9 +889,31 @@ function localBusinessSchema(profile, opts) {
771
889
  if (input.areaServed && input.areaServed.length > 0) {
772
890
  schema.areaServed = input.areaServed;
773
891
  }
774
- const sameAs = Object.values(input.profiles ?? {}).filter(
775
- (u) => typeof u === "string" && u.length > 0
776
- );
892
+ const services = [];
893
+ for (const raw of input.services ?? []) {
894
+ if (services.length >= LOCAL_BUSINESS_MAX_SERVICES) break;
895
+ if (typeof raw !== "string") continue;
896
+ const name = raw.trim().slice(0, LOCAL_BUSINESS_SERVICE_MAX_LENGTH);
897
+ if (name && !services.includes(name)) services.push(name);
898
+ }
899
+ if (services.length > 0) {
900
+ schema.hasOfferCatalog = {
901
+ "@type": "OfferCatalog",
902
+ name: `${input.name} \uC11C\uBE44\uC2A4`,
903
+ itemListElement: services.map((name) => ({
904
+ "@type": "Offer",
905
+ itemOffered: { "@type": "Service", name }
906
+ }))
907
+ };
908
+ }
909
+ const profiles = input.profiles ?? {};
910
+ const pickUrl = (key) => {
911
+ const value = profiles[key];
912
+ return typeof value === "string" && value.length > 0 ? value : null;
913
+ };
914
+ const mapUrl = pickUrl("naverPlace") ?? pickUrl("kakaoMap") ?? pickUrl("kakao_map");
915
+ if (mapUrl) schema.hasMap = mapUrl;
916
+ const sameAs = Object.entries(profiles).filter(([key]) => !MAP_PROFILE_KEYS.has(key)).map(([, value]) => value).filter((u) => typeof u === "string" && u.length > 0);
777
917
  if (sameAs.length > 0) schema.sameAs = sameAs;
778
918
  return schema;
779
919
  }
@@ -972,16 +1112,20 @@ export {
972
1112
  CmsApiError,
973
1113
  DEFAULT_BLOG_SETTINGS,
974
1114
  INQUIRY_ERROR_MESSAGES,
1115
+ LOCAL_BUSINESS_MAX_SERVICES,
1116
+ LOCAL_BUSINESS_SERVICE_MAX_LENGTH,
975
1117
  THEME_CACHE_TAG,
976
1118
  articleSchema,
977
1119
  breadcrumbSchema,
978
1120
  businessProfileFromWire,
1121
+ collectionPageSchema,
979
1122
  contentIdMeta,
980
1123
  faqSchema,
981
1124
  fetchAnalyticsConfig,
982
1125
  fetchAuthors,
983
1126
  fetchBlogSettings,
984
1127
  fetchBusinessProfile,
1128
+ fetchCategoryCounts,
985
1129
  fetchCollections,
986
1130
  fetchCollectionsSafeDefault,
987
1131
  fetchMenu,
@@ -1002,6 +1146,7 @@ export {
1002
1146
  organizationSchema,
1003
1147
  parseAttributionJson,
1004
1148
  parseInquiryRedirect,
1149
+ profilePageSchema,
1005
1150
  resolvePostDisplay,
1006
1151
  rssItemsFromPosts,
1007
1152
  searchPosts,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server-base.ts","../src/server-posts-theme.ts","../src/server-blog.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 /** 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 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 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\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 } | 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 },\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 },\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","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 businessType: BusinessType;\n telephone: 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 /** 외부 프로필 URL — JSON-LD sameAs 로 들어가 동일 사업장임을 연결. */\n profiles: {\n naverPlace: string | null;\n googleBusiness: string | null;\n kakaoChannel: 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 business_type?: string | null;\n telephone?: 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 profiles?: {\n naver_place?: string | null;\n google_business?: string | null;\n kakao_channel?: 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 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 email: json.email ?? null,\n address,\n geo,\n openingHours,\n priceRange: json.price_range ?? null,\n areaServed: json.area_served ?? [],\n profiles: {\n naverPlace: json.profiles?.naver_place ?? null,\n googleBusiness: json.profiles?.google_business ?? null,\n kakaoChannel: json.profiles?.kakao_channel ?? 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}\n\nexport function articleSchema(input: ArticleSchemaInput): JsonLd {\n const schema: JsonLd = {\n \"@context\": \"https://schema.org\",\n \"@type\": \"Article\",\n headline: input.headline,\n mainEntityOfPage: { \"@type\": \"WebPage\", \"@id\": input.url },\n };\n if (input.description) schema.description = input.description;\n if (input.image) schema.image = input.image;\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 = { \"@type\": \"Person\", name: input.authorName };\n if (input.publisherName) {\n schema.publisher = {\n \"@type\": \"Organization\",\n name: input.publisherName,\n ...(input.publisherLogo\n ? { logo: { \"@type\": \"ImageObject\", url: input.publisherLogo } }\n : {}),\n };\n }\n return schema;\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 /** Schema.org LocalBusiness 하위 타입. 미지정 시 \"LocalBusiness\". */\n businessType?: string | null;\n telephone?: 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 /** 외부 프로필 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 */\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 (opts.image) schema.image = opts.image;\n if (input.telephone) schema.telephone = input.telephone;\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 const sameAs = Object.values(input.profiles ?? {}).filter(\n (u): u is string => typeof u === \"string\" && u.length > 0,\n );\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/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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roottale/cms-client",
3
- "version": "0.47.0",
3
+ "version": "0.51.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",