@roottale/cms-client 0.20.0 → 0.25.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,33 @@
1
1
  # @roottale/cms-client
2
2
 
3
+ ## 0.25.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0dcb931: 비즈니스 프로필 (로컬 SEO) — 어드민 "운영 > 비즈니스 프로필" 에서 저장한
8
+ 사업장 정보(이름·업종·주소·좌표·영업시간·네이버플레이스/구글 비즈니스
9
+ 프로필 URL)를 외부 사이트가 조회해 LocalBusiness JSON-LD 를 자동 렌더.
10
+ - cms-client: `fetchBusinessProfile()` (미설정/구 서버 404 = null) +
11
+ `localBusinessSchema(profile, { url, image? })` — @type 중복 제거,
12
+ PostalAddress/GeoCoordinates/openingHoursSpecification/sameAs.
13
+ - API: `GET /v1/cms/public/business-profile` (cms:read).
14
+ - cms-mcp docs: seo.md 로컬 SEO 섹션, theme-and-settings.md
15
+ fetchBusinessProfile, api-reference.md 엔드포인트, examples layout 적용.
16
+
17
+ - 50b6df0: 메뉴(네비게이션) 연동: `fetchMenu({ apiKey, slug })` / `fetchMenus({ apiKey })`
18
+ — 어드민 "디자인 > 메뉴" 트리(`GET /v1/cms/public/menus`)를 헤더/푸터 네비로
19
+ 렌더. 깊이 2, 미존재 404 → null/빈 배열 fail-soft. 새 문서 `menus.md` +
20
+ 예시 `components/site-nav.tsx`.
21
+ - 50b6df0: 공개 검색 API: `searchPosts({ apiKey, query, limit?, type? })` — 발행 글
22
+ 키워드 검색 (WP `?s=` 패리티, `GET /v1/cms/public/search`). title·excerpt·
23
+ 본문 텍스트 부분일치, 본문 미포함 슬림 hit. 구 서버 404 는 빈 배열 fail-soft.
24
+
25
+ ## 0.24.0
26
+
27
+ ### Minor Changes
28
+
29
+ - 299c9b2: 문의 유입 어트리뷰션 (ADR-0048 §2). `submitInquiry` 에 `attribution` 필드 추가 — 비콘이 기억한 first-touch(처음 도착한 페이지·단축링크/QR 토큰·utm·외부 referrer 호스트명)를 문의와 함께 보내면 어드민 CRM 에 유입 경로가 표시된다. 신규: `@roottale/cms-client/attribution` 서브패스의 `readAttribution()`(브라우저 전용), 서버 엔트리의 `parseAttributionJson()`, intake API `attr_*` 폼 필드. cms-mcp docs(inquiries/api-reference) 및 예제(AttributionField) 동반 갱신.
30
+
3
31
  ## 0.20.0
4
32
 
5
33
  ### Minor Changes
package/README.md CHANGED
@@ -4,6 +4,8 @@ RootTale CMS public API fetch client — **server-only**. Bearer auth (`rtlk_cus
4
4
 
5
5
  Companion of [`@roottale/cms-renderer-next`](https://www.npmjs.com/package/@roottale/cms-renderer-next) (React RSC) and [`@roottale/cms-renderer-astro`](https://www.npmjs.com/package/@roottale/cms-renderer-astro). Use this directly if you want raw data; use the renderer packages if you want HTML/React out.
6
6
 
7
+ > AI 코딩 에이전트로 연동한다면 [`@roottale/cms-mcp`](https://www.npmjs.com/package/@roottale/cms-mcp)를 MCP 서버로 등록하세요 — 통합 문서·예시 코드를 tool로 제공합니다: `npx -y @roottale/cms-mcp`
8
+
7
9
  ## Install
8
10
 
9
11
  ```bash
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `@roottale/cms-client/attribution` — 유입 first-touch 판독 (브라우저 전용).
3
+ *
4
+ * RootTale 사이트에 주입되는 비콘(`@roottale/analytics-runtime`, ADR-0048 §1)이
5
+ * 방문자의 first-touch(landing path · 단축링크/QR 토큰 · utm · 외부 referrer
6
+ * hostname)를 `localStorage["_rt_attr"]` 에 저장한다. 본 모듈은 그 값을 문의
7
+ * 폼 제출에 동반시킬 표준형으로 변환한다 — 클라이언트 컴포넌트에서 읽어
8
+ * Server Action 으로 넘기고, 서버에서 `submitInquiry({ fields: { attribution } })`
9
+ * 로 전달하면 된다. 식별자가 아니므로 PII 아님.
10
+ *
11
+ * 사용 예 (Next.js 폼 클라이언트 컴포넌트):
12
+ * ```tsx
13
+ * "use client";
14
+ * import { readAttribution } from "@roottale/cms-client/attribution";
15
+ *
16
+ * function onSubmit(values: FormValues) {
17
+ * return submitContactAction({ ...values, attribution: readAttribution() });
18
+ * }
19
+ * ```
20
+ *
21
+ * 저장 키·형태는 analytics-runtime 비콘과의 공유 계약 — 변경 시 양쪽 동시 갱신.
22
+ */
23
+ /** 비콘이 first-touch 를 저장하는 localStorage 키. */
24
+ declare const ATTRIBUTION_STORAGE_KEY = "_rt_attr";
25
+ /** 문의에 동반되는 유입 어트리뷰션 (intake 의 `attr_*` 필드로 매핑). */
26
+ interface InquiryAttribution {
27
+ /** 첫 방문 landing pathname (query 제외). */
28
+ landing_path?: string;
29
+ /** 단축링크/QR 토큰 (`rt_src`). */
30
+ rt_src?: string;
31
+ utm_source?: string;
32
+ utm_medium?: string;
33
+ utm_campaign?: string;
34
+ /** 외부 referrer hostname (raw URL 아님). */
35
+ referrer?: string;
36
+ /** first-touch 시각 (ISO 8601). */
37
+ first_touch_at?: string;
38
+ }
39
+ /**
40
+ * 폼 hidden input 등으로 넘어온 attribution JSON 을 안전하게 파싱 — 허용 키만,
41
+ * 문자열만, 길이 상한 적용. 깨진 값은 null (throw 금지). 서버에서도 사용 가능.
42
+ *
43
+ * 사용 예 (FormData 기반 Server Action):
44
+ * ```ts
45
+ * const attribution = parseAttributionJson(formData.get("attribution"));
46
+ * await submitInquiry({ apiKey, fields: { ...rest, attribution } });
47
+ * ```
48
+ */
49
+ declare function parseAttributionJson(value: unknown): InquiryAttribution | null;
50
+ /**
51
+ * 비콘이 기록한 first-touch 를 판독. 브라우저가 아니거나, 값이 없거나, 형태가
52
+ * 깨졌거나, TTL(30일)이 지났으면 null — 어떤 경우에도 throw 하지 않는다.
53
+ */
54
+ declare function readAttribution(): InquiryAttribution | null;
55
+
56
+ export { ATTRIBUTION_STORAGE_KEY, type InquiryAttribution, parseAttributionJson, readAttribution };
@@ -0,0 +1,11 @@
1
+ import {
2
+ ATTRIBUTION_STORAGE_KEY,
3
+ parseAttributionJson,
4
+ readAttribution
5
+ } from "./chunk-FRCLSU55.js";
6
+ export {
7
+ ATTRIBUTION_STORAGE_KEY,
8
+ parseAttributionJson,
9
+ readAttribution
10
+ };
11
+ //# sourceMappingURL=attribution.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,75 @@
1
+ // src/attribution.ts
2
+ var ATTRIBUTION_STORAGE_KEY = "_rt_attr";
3
+ var ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
4
+ function pick(value, maxLen) {
5
+ if (typeof value !== "string") return void 0;
6
+ const trimmed = value.trim();
7
+ return trimmed ? trimmed.slice(0, maxLen) : void 0;
8
+ }
9
+ function parseAttributionJson(value) {
10
+ let parsed = value;
11
+ if (typeof value === "string") {
12
+ if (!value.trim()) return null;
13
+ try {
14
+ parsed = JSON.parse(value);
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
20
+ const record = parsed;
21
+ const out = {};
22
+ const landingPath = pick(record.landing_path, 512);
23
+ if (landingPath) out.landing_path = landingPath;
24
+ const rtSrc = pick(record.rt_src, 32);
25
+ if (rtSrc) out.rt_src = rtSrc;
26
+ const utmSource = pick(record.utm_source, 100);
27
+ if (utmSource) out.utm_source = utmSource;
28
+ const utmMedium = pick(record.utm_medium, 100);
29
+ if (utmMedium) out.utm_medium = utmMedium;
30
+ const utmCampaign = pick(record.utm_campaign, 100);
31
+ if (utmCampaign) out.utm_campaign = utmCampaign;
32
+ const referrer = pick(record.referrer, 253);
33
+ if (referrer) out.referrer = referrer;
34
+ const firstTouchAt = pick(record.first_touch_at, 40);
35
+ if (firstTouchAt) out.first_touch_at = firstTouchAt;
36
+ return Object.keys(out).length > 0 ? out : null;
37
+ }
38
+ function readAttribution() {
39
+ if (typeof window === "undefined" || !("localStorage" in window)) return null;
40
+ let stored = null;
41
+ try {
42
+ const raw = window.localStorage.getItem(ATTRIBUTION_STORAGE_KEY);
43
+ if (!raw) return null;
44
+ stored = JSON.parse(raw);
45
+ } catch {
46
+ return null;
47
+ }
48
+ if (!stored || typeof stored !== "object") return null;
49
+ if (typeof stored.ts !== "number" || Date.now() - stored.ts > ATTRIBUTION_TTL_MS) {
50
+ return null;
51
+ }
52
+ const out = {
53
+ first_touch_at: new Date(stored.ts).toISOString()
54
+ };
55
+ const landingPath = pick(stored.lp, 512);
56
+ if (landingPath) out.landing_path = landingPath;
57
+ const rtSrc = pick(stored.rs, 32);
58
+ if (rtSrc) out.rt_src = rtSrc;
59
+ const utmSource = pick(stored.us, 100);
60
+ if (utmSource) out.utm_source = utmSource;
61
+ const utmMedium = pick(stored.um, 100);
62
+ if (utmMedium) out.utm_medium = utmMedium;
63
+ const utmCampaign = pick(stored.uc, 100);
64
+ if (utmCampaign) out.utm_campaign = utmCampaign;
65
+ const referrer = pick(stored.rf, 253);
66
+ if (referrer) out.referrer = referrer;
67
+ return out;
68
+ }
69
+
70
+ export {
71
+ ATTRIBUTION_STORAGE_KEY,
72
+ parseAttributionJson,
73
+ readAttribution
74
+ };
75
+ //# sourceMappingURL=chunk-FRCLSU55.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/attribution.ts"],"sourcesContent":["/**\n * `@roottale/cms-client/attribution` — 유입 first-touch 판독 (브라우저 전용).\n *\n * RootTale 사이트에 주입되는 비콘(`@roottale/analytics-runtime`, ADR-0048 §1)이\n * 방문자의 first-touch(landing path · 단축링크/QR 토큰 · utm · 외부 referrer\n * hostname)를 `localStorage[\"_rt_attr\"]` 에 저장한다. 본 모듈은 그 값을 문의\n * 폼 제출에 동반시킬 표준형으로 변환한다 — 클라이언트 컴포넌트에서 읽어\n * Server Action 으로 넘기고, 서버에서 `submitInquiry({ fields: { attribution } })`\n * 로 전달하면 된다. 식별자가 아니므로 PII 아님.\n *\n * 사용 예 (Next.js 폼 클라이언트 컴포넌트):\n * ```tsx\n * \"use client\";\n * import { readAttribution } from \"@roottale/cms-client/attribution\";\n *\n * function onSubmit(values: FormValues) {\n * return submitContactAction({ ...values, attribution: readAttribution() });\n * }\n * ```\n *\n * 저장 키·형태는 analytics-runtime 비콘과의 공유 계약 — 변경 시 양쪽 동시 갱신.\n */\n\n/** 비콘이 first-touch 를 저장하는 localStorage 키. */\nexport const ATTRIBUTION_STORAGE_KEY = \"_rt_attr\";\n\n/** first-touch 보존 기간 (30일, ms) — 비콘과 동일. 경과분은 stale 로 무시. */\nconst ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\n/** 문의에 동반되는 유입 어트리뷰션 (intake 의 `attr_*` 필드로 매핑). */\nexport interface InquiryAttribution {\n /** 첫 방문 landing pathname (query 제외). */\n landing_path?: string;\n /** 단축링크/QR 토큰 (`rt_src`). */\n rt_src?: string;\n utm_source?: string;\n utm_medium?: string;\n utm_campaign?: string;\n /** 외부 referrer hostname (raw URL 아님). */\n referrer?: string;\n /** first-touch 시각 (ISO 8601). */\n first_touch_at?: string;\n}\n\n/** 비콘이 저장하는 압축형 — 내부 파싱용. */\ninterface StoredFirstTouch {\n v?: number;\n lp?: unknown;\n ts?: unknown;\n rs?: unknown;\n us?: unknown;\n um?: unknown;\n uc?: unknown;\n rf?: unknown;\n}\n\nfunction pick(value: unknown, maxLen: number): string | undefined {\n if (typeof value !== \"string\") return undefined;\n const trimmed = value.trim();\n return trimmed ? trimmed.slice(0, maxLen) : undefined;\n}\n\n/**\n * 폼 hidden input 등으로 넘어온 attribution JSON 을 안전하게 파싱 — 허용 키만,\n * 문자열만, 길이 상한 적용. 깨진 값은 null (throw 금지). 서버에서도 사용 가능.\n *\n * 사용 예 (FormData 기반 Server Action):\n * ```ts\n * const attribution = parseAttributionJson(formData.get(\"attribution\"));\n * await submitInquiry({ apiKey, fields: { ...rest, attribution } });\n * ```\n */\nexport function parseAttributionJson(value: unknown): InquiryAttribution | null {\n let parsed: unknown = value;\n if (typeof value === \"string\") {\n if (!value.trim()) return null;\n try {\n parsed = JSON.parse(value);\n } catch {\n return null;\n }\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n const record = parsed as Record<string, unknown>;\n const out: InquiryAttribution = {};\n const landingPath = pick(record.landing_path, 512);\n if (landingPath) out.landing_path = landingPath;\n const rtSrc = pick(record.rt_src, 32);\n if (rtSrc) out.rt_src = rtSrc;\n const utmSource = pick(record.utm_source, 100);\n if (utmSource) out.utm_source = utmSource;\n const utmMedium = pick(record.utm_medium, 100);\n if (utmMedium) out.utm_medium = utmMedium;\n const utmCampaign = pick(record.utm_campaign, 100);\n if (utmCampaign) out.utm_campaign = utmCampaign;\n const referrer = pick(record.referrer, 253);\n if (referrer) out.referrer = referrer;\n const firstTouchAt = pick(record.first_touch_at, 40);\n if (firstTouchAt) out.first_touch_at = firstTouchAt;\n return Object.keys(out).length > 0 ? out : null;\n}\n\n/**\n * 비콘이 기록한 first-touch 를 판독. 브라우저가 아니거나, 값이 없거나, 형태가\n * 깨졌거나, TTL(30일)이 지났으면 null — 어떤 경우에도 throw 하지 않는다.\n */\nexport function readAttribution(): InquiryAttribution | null {\n if (typeof window === \"undefined\" || !(\"localStorage\" in window)) return null;\n let stored: StoredFirstTouch | null = null;\n try {\n const raw = window.localStorage.getItem(ATTRIBUTION_STORAGE_KEY);\n if (!raw) return null;\n stored = JSON.parse(raw) as StoredFirstTouch;\n } catch {\n return null;\n }\n if (!stored || typeof stored !== \"object\") return null;\n if (typeof stored.ts !== \"number\" || Date.now() - stored.ts > ATTRIBUTION_TTL_MS) {\n return null;\n }\n\n const out: InquiryAttribution = {\n first_touch_at: new Date(stored.ts).toISOString(),\n };\n const landingPath = pick(stored.lp, 512);\n if (landingPath) out.landing_path = landingPath;\n const rtSrc = pick(stored.rs, 32);\n if (rtSrc) out.rt_src = rtSrc;\n const utmSource = pick(stored.us, 100);\n if (utmSource) out.utm_source = utmSource;\n const utmMedium = pick(stored.um, 100);\n if (utmMedium) out.utm_medium = utmMedium;\n const utmCampaign = pick(stored.uc, 100);\n if (utmCampaign) out.utm_campaign = utmCampaign;\n const referrer = pick(stored.rf, 253);\n if (referrer) out.referrer = referrer;\n return out;\n}\n"],"mappings":";AAwBO,IAAM,0BAA0B;AAGvC,IAAM,qBAAqB,KAAK,KAAK,KAAK,KAAK;AA6B/C,SAAS,KAAK,OAAgB,QAAoC;AAChE,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,UAAU,MAAM,KAAK;AAC3B,SAAO,UAAU,QAAQ,MAAM,GAAG,MAAM,IAAI;AAC9C;AAYO,SAAS,qBAAqB,OAA2C;AAC9E,MAAI,SAAkB;AACtB,MAAI,OAAO,UAAU,UAAU;AAC7B,QAAI,CAAC,MAAM,KAAK,EAAG,QAAO;AAC1B,QAAI;AACF,eAAS,KAAK,MAAM,KAAK;AAAA,IAC3B,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,MAAI,CAAC,UAAU,OAAO,WAAW,YAAY,MAAM,QAAQ,MAAM,EAAG,QAAO;AAC3E,QAAM,SAAS;AACf,QAAM,MAA0B,CAAC;AACjC,QAAM,cAAc,KAAK,OAAO,cAAc,GAAG;AACjD,MAAI,YAAa,KAAI,eAAe;AACpC,QAAM,QAAQ,KAAK,OAAO,QAAQ,EAAE;AACpC,MAAI,MAAO,KAAI,SAAS;AACxB,QAAM,YAAY,KAAK,OAAO,YAAY,GAAG;AAC7C,MAAI,UAAW,KAAI,aAAa;AAChC,QAAM,YAAY,KAAK,OAAO,YAAY,GAAG;AAC7C,MAAI,UAAW,KAAI,aAAa;AAChC,QAAM,cAAc,KAAK,OAAO,cAAc,GAAG;AACjD,MAAI,YAAa,KAAI,eAAe;AACpC,QAAM,WAAW,KAAK,OAAO,UAAU,GAAG;AAC1C,MAAI,SAAU,KAAI,WAAW;AAC7B,QAAM,eAAe,KAAK,OAAO,gBAAgB,EAAE;AACnD,MAAI,aAAc,KAAI,iBAAiB;AACvC,SAAO,OAAO,KAAK,GAAG,EAAE,SAAS,IAAI,MAAM;AAC7C;AAMO,SAAS,kBAA6C;AAC3D,MAAI,OAAO,WAAW,eAAe,EAAE,kBAAkB,QAAS,QAAO;AACzE,MAAI,SAAkC;AACtC,MAAI;AACF,UAAM,MAAM,OAAO,aAAa,QAAQ,uBAAuB;AAC/D,QAAI,CAAC,IAAK,QAAO;AACjB,aAAS,KAAK,MAAM,GAAG;AAAA,EACzB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAClD,MAAI,OAAO,OAAO,OAAO,YAAY,KAAK,IAAI,IAAI,OAAO,KAAK,oBAAoB;AAChF,WAAO;AAAA,EACT;AAEA,QAAM,MAA0B;AAAA,IAC9B,gBAAgB,IAAI,KAAK,OAAO,EAAE,EAAE,YAAY;AAAA,EAClD;AACA,QAAM,cAAc,KAAK,OAAO,IAAI,GAAG;AACvC,MAAI,YAAa,KAAI,eAAe;AACpC,QAAM,QAAQ,KAAK,OAAO,IAAI,EAAE;AAChC,MAAI,MAAO,KAAI,SAAS;AACxB,QAAM,YAAY,KAAK,OAAO,IAAI,GAAG;AACrC,MAAI,UAAW,KAAI,aAAa;AAChC,QAAM,YAAY,KAAK,OAAO,IAAI,GAAG;AACrC,MAAI,UAAW,KAAI,aAAa;AAChC,QAAM,cAAc,KAAK,OAAO,IAAI,GAAG;AACvC,MAAI,YAAa,KAAI,eAAe;AACpC,QAAM,WAAW,KAAK,OAAO,IAAI,GAAG;AACpC,MAAI,SAAU,KAAI,WAAW;AAC7B,SAAO;AACT;","names":[]}
package/dist/server.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { InquiryAttribution } from './attribution.js';
2
+ export { parseAttributionJson } from './attribution.js';
3
+
1
4
  interface RssItemInput {
2
5
  title: string;
3
6
  /** 글 절대 URL. */
@@ -49,6 +52,50 @@ declare function organizationSchema(input: {
49
52
  logo?: string;
50
53
  sameAs?: string[];
51
54
  }): JsonLd;
55
+ /**
56
+ * `localBusinessSchema` 입력 — `fetchBusinessProfile` 의 반환값
57
+ * (RootTaleBusinessProfile) 을 그대로 받도록 느슨하게 정의 (null/undefined 모두
58
+ * 허용). 수동 구성도 가능.
59
+ */
60
+ interface LocalBusinessProfileInput {
61
+ name: string;
62
+ legalName?: string | null;
63
+ /** Schema.org LocalBusiness 하위 타입. 미지정 시 "LocalBusiness". */
64
+ businessType?: string | null;
65
+ telephone?: string | null;
66
+ email?: string | null;
67
+ address?: {
68
+ streetAddress: string;
69
+ addressLocality: string;
70
+ addressRegion?: string | null;
71
+ postalCode?: string | null;
72
+ } | null;
73
+ geo?: {
74
+ latitude: number;
75
+ longitude: number;
76
+ } | null;
77
+ openingHours?: {
78
+ days: string[];
79
+ opens: string;
80
+ closes: string;
81
+ }[] | null;
82
+ priceRange?: string | null;
83
+ areaServed?: string[] | null;
84
+ /** 외부 프로필 URL — 값들이 sameAs 로 들어간다 (네이버플레이스·구글 등). */
85
+ profiles?: Record<string, string | null | undefined> | null;
86
+ }
87
+ /**
88
+ * LocalBusiness JSON-LD — admin "운영 > 비즈니스 프로필" 의
89
+ * `fetchBusinessProfile` 결과로 layout 에 1회 렌더한다 (로컬 SEO).
90
+ *
91
+ * - `@type` 은 `[businessType, "LocalBusiness"]` 중복 제거 배열.
92
+ * - `profiles` 의 URL 들은 `sameAs` 로 — 검색엔진이 네이버플레이스/구글
93
+ * 비즈니스 프로필과 동일 사업장임을 연결한다.
94
+ */
95
+ declare function localBusinessSchema(profile: LocalBusinessProfileInput | RootTaleBusinessProfile, opts: {
96
+ url: string;
97
+ image?: string | null;
98
+ }): JsonLd;
52
99
  declare function websiteSchema(input: {
53
100
  name: string;
54
101
  url: string;
@@ -92,6 +139,7 @@ declare function jsonLdScript(schema: JsonLd | JsonLd[]): string;
92
139
  *
93
140
  * intake API 는 302 `?ok=1` / `?err=*` 로 응답 — 본 헬퍼가 파싱해 구조화 결과 반환.
94
141
  */
142
+
95
143
  type InquiryVertical = "consulting" | "medical" | "tax" | "legal";
96
144
  /** intake API 의 `err` 코드 → 한국어 사용자 메시지. */
97
145
  declare const INQUIRY_ERROR_MESSAGES: Record<string, string>;
@@ -122,6 +170,13 @@ interface SubmitInquiryFields {
122
170
  * 주의: 개인정보가 담길 수 있으므로 폼의 동의 고지에 수집 항목을 반영할 것.
123
171
  */
124
172
  extras?: Record<string, string>;
173
+ /**
174
+ * 유입 first-touch 어트리뷰션 (ADR-0048 §2, 선택). 브라우저에서
175
+ * `readAttribution()`(`@roottale/cms-client/attribution`)으로 읽어 Server
176
+ * Action 인자로 넘긴 값을 그대로 전달하면 된다. CRM 에 "이 문의가 어느
177
+ * 글/링크/QR 에서 왔는지"가 표시된다. null/undefined 면 미동반 제출.
178
+ */
179
+ attribution?: InquiryAttribution | null;
125
180
  }
126
181
  interface SubmitInquiryOptions {
127
182
  /** `rtlk_cust_*` API key — `fetchPosts` 와 동일한 키. Required. */
@@ -377,6 +432,100 @@ interface FetchBlogSettingsOptions {
377
432
  declare function fetchBlogSettings(options: FetchBlogSettingsOptions): Promise<RootTaleBlogSettings>;
378
433
  /** fetch 실패/미설정 시 사용할 안전 기본값 (기존 렌더러 동작). */
379
434
  declare const DEFAULT_BLOG_SETTINGS: RootTaleBlogSettings;
435
+ /** Schema.org LocalBusiness 하위 타입 (admin "운영 > 비즈니스 프로필" 의 업종). */
436
+ type BusinessType = "LocalBusiness" | "ProfessionalService" | "AccountingService" | "LegalService" | "MedicalClinic" | "Dentist" | "RealEstateAgent" | "Restaurant" | "BeautySalon";
437
+ /** Schema.org openingHours 요일 약어. */
438
+ type BusinessDayCode = "Mo" | "Tu" | "We" | "Th" | "Fr" | "Sa" | "Su";
439
+ interface BusinessOpeningHours {
440
+ days: BusinessDayCode[];
441
+ /** HH:MM (24시간). */
442
+ opens: string;
443
+ /** HH:MM (24시간). */
444
+ closes: string;
445
+ }
446
+ /**
447
+ * 비즈니스 프로필 — admin "운영 > 비즈니스 프로필" 에서 저장한 사업장 정보.
448
+ * `localBusinessSchema(profile, { url })` 로 LocalBusiness JSON-LD 를 만들어
449
+ * layout 에 1회 렌더한다 (로컬 SEO).
450
+ */
451
+ interface RootTaleBusinessProfile {
452
+ /** 사업장 이름 (간판명). */
453
+ name: string;
454
+ /** 사업자등록 상호 — 간판명과 다를 때만 설정. */
455
+ legalName: string | null;
456
+ businessType: BusinessType;
457
+ telephone: string | null;
458
+ email: string | null;
459
+ address: {
460
+ streetAddress: string;
461
+ addressLocality: string;
462
+ addressRegion: string | null;
463
+ postalCode: string | null;
464
+ } | null;
465
+ geo: {
466
+ latitude: number;
467
+ longitude: number;
468
+ } | null;
469
+ openingHours: BusinessOpeningHours[];
470
+ /** 가격대 표기 (예: "₩₩"). */
471
+ priceRange: string | null;
472
+ /** 서비스 지역 (예: "서울 강남구"). */
473
+ areaServed: string[];
474
+ /** 외부 프로필 URL — JSON-LD sameAs 로 들어가 동일 사업장임을 연결. */
475
+ profiles: {
476
+ naverPlace: string | null;
477
+ googleBusiness: string | null;
478
+ kakaoChannel: string | null;
479
+ instagram: string | null;
480
+ naverBlog: string | null;
481
+ };
482
+ }
483
+ interface FetchBusinessProfileOptions {
484
+ apiKey: string;
485
+ baseUrl?: string;
486
+ siteId?: string;
487
+ signal?: AbortSignal;
488
+ }
489
+ type BusinessProfileWire = {
490
+ configured?: boolean;
491
+ name?: string | null;
492
+ legal_name?: string | null;
493
+ business_type?: string | null;
494
+ telephone?: string | null;
495
+ email?: string | null;
496
+ address?: {
497
+ street_address?: string;
498
+ address_locality?: string;
499
+ address_region?: string | null;
500
+ postal_code?: string | null;
501
+ } | null;
502
+ geo?: {
503
+ latitude?: number;
504
+ longitude?: number;
505
+ } | null;
506
+ opening_hours?: {
507
+ days?: BusinessDayCode[];
508
+ opens?: string;
509
+ closes?: string;
510
+ }[];
511
+ price_range?: string | null;
512
+ area_served?: string[];
513
+ profiles?: {
514
+ naver_place?: string | null;
515
+ google_business?: string | null;
516
+ kakao_channel?: string | null;
517
+ instagram?: string | null;
518
+ naver_blog?: string | null;
519
+ } | null;
520
+ };
521
+ /** wire(snake_case) → RootTaleBusinessProfile. 미설정이면 null (export for tests). */
522
+ declare function businessProfileFromWire(json: BusinessProfileWire): RootTaleBusinessProfile | null;
523
+ /**
524
+ * tenant 의 비즈니스 프로필을 가져온다. 미설정이면 null — 호출자는 null 일 때
525
+ * JSON-LD 렌더를 건너뛰면 된다. 네트워크/서버 에러는 throw — 호출자가
526
+ * `.catch(() => null)` 등으로 fallback.
527
+ */
528
+ declare function fetchBusinessProfile(options: FetchBusinessProfileOptions): Promise<RootTaleBusinessProfile | null>;
380
529
  interface ResolvedPostDisplay {
381
530
  showTableOfContents: boolean;
382
531
  showAuthor: boolean;
@@ -410,5 +559,74 @@ declare class CmsApiError extends Error {
410
559
  }
411
560
  declare function fetchPosts(options: FetchPostsOptions): Promise<CmsPostContentPage>;
412
561
  declare function fetchPost(options: FetchPostOptions): Promise<CmsPostContent | null>;
562
+ interface SearchPostsOptions {
563
+ apiKey: string;
564
+ baseUrl?: string;
565
+ /** 검색 키워드 (title·excerpt·본문 텍스트 매칭). */
566
+ query: string;
567
+ /** 결과 수 1-50, default 10. */
568
+ limit?: number;
569
+ /** 기본 = `post` 만. page 포함 검색은 명시. */
570
+ type?: CmsPostType;
571
+ siteId?: string;
572
+ signal?: AbortSignal;
573
+ }
574
+ /** 검색 결과 카드용 슬림 hit — body 미포함. */
575
+ interface CmsSearchHit {
576
+ id: string;
577
+ type: CmsPostType;
578
+ title: string;
579
+ slug: string;
580
+ excerpt: string | null;
581
+ featuredImageUrl: string | null;
582
+ /** ISO 8601. */
583
+ publishedAt: string;
584
+ }
585
+ /**
586
+ * published 글 키워드 검색 (사이트 내 검색). 최신 발행순, body 미포함 슬림
587
+ * hit — 상세는 slug 로 `fetchPost`. 구 서버(라우트 미배포)는 404 — 빈 배열.
588
+ */
589
+ declare function searchPosts(options: SearchPostsOptions): Promise<CmsSearchHit[]>;
590
+ /** 메뉴 항목 트리 노드 (깊이 ≤ 2). */
591
+ interface RootTaleMenuItem {
592
+ id: string;
593
+ label: string;
594
+ /** 상대 경로(`/about`) 또는 절대 http(s) URL — 서버가 sanitize. */
595
+ url: string;
596
+ /** true 면 `target="_blank" rel="noopener"` 로 렌더할 것. */
597
+ newTab?: boolean;
598
+ children?: RootTaleMenuItem[];
599
+ }
600
+ interface RootTaleMenu {
601
+ id: string;
602
+ /** 사람용 이름 — "헤더 메뉴". */
603
+ name: string;
604
+ /** 위치 핸들 — "primary" | "footer" 등. */
605
+ slug: string;
606
+ items: RootTaleMenuItem[];
607
+ updatedAt: string | null;
608
+ }
609
+ interface FetchMenuOptions {
610
+ apiKey: string;
611
+ baseUrl?: string;
612
+ /** 위치 핸들 — "primary" | "footer" 등. */
613
+ slug: string;
614
+ siteId?: string;
615
+ signal?: AbortSignal;
616
+ }
617
+ interface FetchMenusOptions {
618
+ apiKey: string;
619
+ baseUrl?: string;
620
+ siteId?: string;
621
+ signal?: AbortSignal;
622
+ }
623
+ /**
624
+ * 위치 핸들 slug 로 네비게이션 메뉴 1개를 가져온다. admin "디자인 > 메뉴" 의
625
+ * 저장값. 미설정/미존재(404)는 null — 사이트는 자체 fallback 네비를 렌더한다.
626
+ * 구 서버(라우트 미배포)도 404 → null 로 동일하게 fail-soft.
627
+ */
628
+ declare function fetchMenu(options: FetchMenuOptions): Promise<RootTaleMenu | null>;
629
+ /** 사이트의 네비게이션 메뉴 전체. 구 서버(라우트 미배포)는 404 — 빈 배열. */
630
+ declare function fetchMenus(options: FetchMenusOptions): Promise<RootTaleMenu[]>;
413
631
 
414
- export { type AnalyticsTagConfig, type AnalyticsTagProvider, type ArticleSchemaInput, CMS_CLIENT_VERSION, CmsApiError, type CmsPostContent, type CmsPostContentPage, type CmsPostType, type CmsTaxonomyKind, type CmsTermRef, DEFAULT_BLOG_SETTINGS, type FetchAnalyticsOptions, type FetchBlogSettingsOptions, type FetchPostOptions, type FetchPostsOptions, type FetchThemeOptions, INQUIRY_ERROR_MESSAGES, type InquiryVertical, type ResolvedPostDisplay, type RootTaleBlogSettings, type RootTaleTheme, type RssFeedInput, type RssItemInput, type SiteAnalyticsTags, type SubmitInquiryFields, type SubmitInquiryOptions, type SubmitInquiryResult, articleSchema, breadcrumbSchema, faqSchema, fetchAnalyticsConfig, fetchBlogSettings, fetchPost, fetchPosts, fetchTheme, formatKoreanPhoneNumber, generateRssXml, inquiryErrorMessage, jsonLdScript, organizationSchema, parseInquiryRedirect, resolvePostDisplay, rssItemsFromPosts, submitInquiry, websiteSchema };
632
+ export { type AnalyticsTagConfig, type AnalyticsTagProvider, type ArticleSchemaInput, type BusinessDayCode, type BusinessOpeningHours, type BusinessType, CMS_CLIENT_VERSION, CmsApiError, type CmsPostContent, type CmsPostContentPage, type CmsPostType, type CmsSearchHit, type CmsTaxonomyKind, type CmsTermRef, DEFAULT_BLOG_SETTINGS, type FetchAnalyticsOptions, type FetchBlogSettingsOptions, type FetchBusinessProfileOptions, type FetchMenuOptions, type FetchMenusOptions, type FetchPostOptions, type FetchPostsOptions, type FetchThemeOptions, INQUIRY_ERROR_MESSAGES, InquiryAttribution, type InquiryVertical, type LocalBusinessProfileInput, type ResolvedPostDisplay, type RootTaleBlogSettings, type RootTaleBusinessProfile, type RootTaleMenu, type RootTaleMenuItem, type RootTaleTheme, type RssFeedInput, type RssItemInput, type SearchPostsOptions, type SiteAnalyticsTags, type SubmitInquiryFields, type SubmitInquiryOptions, type SubmitInquiryResult, articleSchema, breadcrumbSchema, businessProfileFromWire, faqSchema, fetchAnalyticsConfig, fetchBlogSettings, fetchBusinessProfile, fetchMenu, fetchMenus, fetchPost, fetchPosts, fetchTheme, formatKoreanPhoneNumber, generateRssXml, inquiryErrorMessage, jsonLdScript, localBusinessSchema, organizationSchema, parseInquiryRedirect, resolvePostDisplay, rssItemsFromPosts, searchPosts, submitInquiry, websiteSchema };
package/dist/server.js CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ parseAttributionJson
3
+ } from "./chunk-FRCLSU55.js";
4
+
1
5
  // src/seo.ts
2
6
  function generateRssXml(input) {
3
7
  const lang = input.language ?? "ko-KR";
@@ -95,6 +99,68 @@ function organizationSchema(input) {
95
99
  ...input.sameAs && input.sameAs.length > 0 ? { sameAs: input.sameAs } : {}
96
100
  };
97
101
  }
102
+ var DAY_OF_WEEK = {
103
+ Mo: "Monday",
104
+ Tu: "Tuesday",
105
+ We: "Wednesday",
106
+ Th: "Thursday",
107
+ Fr: "Friday",
108
+ Sa: "Saturday",
109
+ Su: "Sunday"
110
+ };
111
+ function localBusinessSchema(profile, opts) {
112
+ const input = profile;
113
+ const types = Array.from(
114
+ /* @__PURE__ */ new Set([input.businessType || "LocalBusiness", "LocalBusiness"])
115
+ );
116
+ const schema = {
117
+ "@context": "https://schema.org",
118
+ "@type": types,
119
+ name: input.name,
120
+ url: opts.url
121
+ };
122
+ if (input.legalName) schema.legalName = input.legalName;
123
+ if (opts.image) schema.image = opts.image;
124
+ if (input.telephone) schema.telephone = input.telephone;
125
+ if (input.email) schema.email = input.email;
126
+ if (input.address) {
127
+ schema.address = {
128
+ "@type": "PostalAddress",
129
+ streetAddress: input.address.streetAddress,
130
+ addressLocality: input.address.addressLocality,
131
+ ...input.address.addressRegion ? { addressRegion: input.address.addressRegion } : {},
132
+ ...input.address.postalCode ? { postalCode: input.address.postalCode } : {},
133
+ addressCountry: "KR"
134
+ };
135
+ }
136
+ if (input.geo) {
137
+ schema.geo = {
138
+ "@type": "GeoCoordinates",
139
+ latitude: input.geo.latitude,
140
+ longitude: input.geo.longitude
141
+ };
142
+ }
143
+ const openingHours = (input.openingHours ?? []).filter(
144
+ (row) => row.days.length > 0 && row.opens && row.closes
145
+ );
146
+ if (openingHours.length > 0) {
147
+ schema.openingHoursSpecification = openingHours.map((row) => ({
148
+ "@type": "OpeningHoursSpecification",
149
+ dayOfWeek: row.days.map((d) => DAY_OF_WEEK[d] ?? d),
150
+ opens: row.opens,
151
+ closes: row.closes
152
+ }));
153
+ }
154
+ if (input.priceRange) schema.priceRange = input.priceRange;
155
+ if (input.areaServed && input.areaServed.length > 0) {
156
+ schema.areaServed = input.areaServed;
157
+ }
158
+ const sameAs = Object.values(input.profiles ?? {}).filter(
159
+ (u) => typeof u === "string" && u.length > 0
160
+ );
161
+ if (sameAs.length > 0) schema.sameAs = sameAs;
162
+ return schema;
163
+ }
98
164
  function websiteSchema(input) {
99
165
  return {
100
166
  "@context": "https://schema.org",
@@ -213,12 +279,19 @@ async function submitInquiry(options) {
213
279
  if (f.privacyConsent) body.set("privacy_consent", "on");
214
280
  if (f.overseasTransferConsent) body.set("overseas_transfer_consent", "on");
215
281
  if (f.leadKind) body.set("lead_kind", f.leadKind);
282
+ if (f.attribution) {
283
+ for (const [key, value] of Object.entries(f.attribution)) {
284
+ if (typeof value !== "string" || !value.trim()) continue;
285
+ body.set(`attr_${key}`, value.trim());
286
+ }
287
+ }
216
288
  if (f.extras) {
217
289
  for (const [rawKey, rawValue] of Object.entries(f.extras)) {
218
290
  const key = rawKey.trim();
219
291
  const value = String(rawValue ?? "").trim();
220
292
  if (!key || !value) continue;
221
293
  if (key.startsWith("_") || RESERVED_INQUIRY_KEYS.has(key)) continue;
294
+ if (key.startsWith("attr_")) continue;
222
295
  if (body.has(key)) continue;
223
296
  body.set(key, value);
224
297
  }
@@ -258,7 +331,7 @@ function assertServer() {
258
331
 
259
332
  // src/server.ts
260
333
  var DEFAULT_BASE_URL2 = "https://api.roottale.com";
261
- var CLIENT_VERSION = true ? "0.20.0" : "dev";
334
+ var CLIENT_VERSION = true ? "0.25.0" : "dev";
262
335
  var CMS_CLIENT_VERSION = CLIENT_VERSION;
263
336
  function authHeaders(apiKey) {
264
337
  return {
@@ -355,6 +428,61 @@ var DEFAULT_BLOG_SETTINGS = {
355
428
  authorProfileImagePositionX: 50,
356
429
  authorProfileImagePositionY: 50
357
430
  };
431
+ function businessProfileFromWire(json) {
432
+ if (json.configured === false) return null;
433
+ if (typeof json.name !== "string" || json.name.length === 0) return null;
434
+ const address = json.address && typeof json.address.street_address === "string" && typeof json.address.address_locality === "string" ? {
435
+ streetAddress: json.address.street_address,
436
+ addressLocality: json.address.address_locality,
437
+ addressRegion: json.address.address_region ?? null,
438
+ postalCode: json.address.postal_code ?? null
439
+ } : null;
440
+ const geo = json.geo && typeof json.geo.latitude === "number" && typeof json.geo.longitude === "number" ? { latitude: json.geo.latitude, longitude: json.geo.longitude } : null;
441
+ const openingHours = [];
442
+ for (const row of json.opening_hours ?? []) {
443
+ if (Array.isArray(row.days) && row.days.length > 0 && typeof row.opens === "string" && typeof row.closes === "string") {
444
+ openingHours.push({ days: row.days, opens: row.opens, closes: row.closes });
445
+ }
446
+ }
447
+ return {
448
+ name: json.name,
449
+ legalName: json.legal_name ?? null,
450
+ businessType: typeof json.business_type === "string" && json.business_type.length > 0 ? json.business_type : "LocalBusiness",
451
+ telephone: json.telephone ?? null,
452
+ email: json.email ?? null,
453
+ address,
454
+ geo,
455
+ openingHours,
456
+ priceRange: json.price_range ?? null,
457
+ areaServed: json.area_served ?? [],
458
+ profiles: {
459
+ naverPlace: json.profiles?.naver_place ?? null,
460
+ googleBusiness: json.profiles?.google_business ?? null,
461
+ kakaoChannel: json.profiles?.kakao_channel ?? null,
462
+ instagram: json.profiles?.instagram ?? null,
463
+ naverBlog: json.profiles?.naver_blog ?? null
464
+ }
465
+ };
466
+ }
467
+ async function fetchBusinessProfile(options) {
468
+ assertServer2();
469
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL2).replace(/\/+$/, "");
470
+ const params = new URLSearchParams();
471
+ if (options.siteId) params.set("site_id", options.siteId);
472
+ const qs = params.toString();
473
+ const url = `${baseUrl}/v1/cms/public/business-profile${qs ? `?${qs}` : ""}`;
474
+ const response = await fetch(url, {
475
+ method: "GET",
476
+ headers: authHeaders(options.apiKey),
477
+ signal: options.signal
478
+ });
479
+ if (response.status === 404) return null;
480
+ if (!response.ok) {
481
+ throw await toApiError(response);
482
+ }
483
+ const json = await response.json();
484
+ return businessProfileFromWire(json);
485
+ }
358
486
  function resolvePostDisplay(post, settings, explicit) {
359
487
  const display = post.metaJson && typeof post.metaJson === "object" ? post.metaJson.display : void 0;
360
488
  const ov = (key) => display && typeof display[key] === "boolean" ? display[key] : void 0;
@@ -428,6 +556,90 @@ async function fetchPost(options) {
428
556
  const json = await response.json();
429
557
  return fromWire(json);
430
558
  }
559
+ async function searchPosts(options) {
560
+ assertServer2();
561
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL2).replace(/\/+$/, "");
562
+ const params = new URLSearchParams();
563
+ params.set("q", options.query);
564
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
565
+ if (options.type) params.set("type", options.type);
566
+ if (options.siteId) params.set("site_id", options.siteId);
567
+ const url = `${baseUrl}/v1/cms/public/search?${params.toString()}`;
568
+ const response = await fetch(url, {
569
+ method: "GET",
570
+ headers: authHeaders(options.apiKey),
571
+ signal: options.signal
572
+ });
573
+ if (response.status === 404) return [];
574
+ if (!response.ok) {
575
+ throw await toApiError(response);
576
+ }
577
+ const json = await response.json();
578
+ return json.items.map((w) => ({
579
+ id: w.id,
580
+ type: w.type,
581
+ title: w.title,
582
+ slug: w.slug,
583
+ excerpt: w.excerpt,
584
+ featuredImageUrl: w.featured_media_url ?? null,
585
+ publishedAt: w.published_at
586
+ }));
587
+ }
588
+ function menuItemFromWire(w) {
589
+ const item = { id: w.id, label: w.label, url: w.url };
590
+ if (w.new_tab) item.newTab = true;
591
+ if (w.children && w.children.length > 0) {
592
+ item.children = w.children.map(menuItemFromWire);
593
+ }
594
+ return item;
595
+ }
596
+ function menuFromWire(w) {
597
+ return {
598
+ id: w.id,
599
+ name: w.name,
600
+ slug: w.slug,
601
+ items: (w.items ?? []).map(menuItemFromWire),
602
+ updatedAt: w.updated_at ?? null
603
+ };
604
+ }
605
+ async function fetchMenu(options) {
606
+ assertServer2();
607
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL2).replace(/\/+$/, "");
608
+ const params = new URLSearchParams();
609
+ if (options.siteId) params.set("site_id", options.siteId);
610
+ const qs = params.toString();
611
+ const url = `${baseUrl}/v1/cms/public/menus/${encodeURIComponent(options.slug)}${qs ? `?${qs}` : ""}`;
612
+ const response = await fetch(url, {
613
+ method: "GET",
614
+ headers: authHeaders(options.apiKey),
615
+ signal: options.signal
616
+ });
617
+ if (response.status === 404) return null;
618
+ if (!response.ok) {
619
+ throw await toApiError(response);
620
+ }
621
+ const json = await response.json();
622
+ return menuFromWire(json);
623
+ }
624
+ async function fetchMenus(options) {
625
+ assertServer2();
626
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL2).replace(/\/+$/, "");
627
+ const params = new URLSearchParams();
628
+ if (options.siteId) params.set("site_id", options.siteId);
629
+ const qs = params.toString();
630
+ const url = `${baseUrl}/v1/cms/public/menus${qs ? `?${qs}` : ""}`;
631
+ const response = await fetch(url, {
632
+ method: "GET",
633
+ headers: authHeaders(options.apiKey),
634
+ signal: options.signal
635
+ });
636
+ if (response.status === 404) return [];
637
+ if (!response.ok) {
638
+ throw await toApiError(response);
639
+ }
640
+ const json = await response.json();
641
+ return (json.items ?? []).map(menuFromWire);
642
+ }
431
643
  function fromWire(wire) {
432
644
  return {
433
645
  id: wire.id,
@@ -478,9 +690,13 @@ export {
478
690
  INQUIRY_ERROR_MESSAGES,
479
691
  articleSchema,
480
692
  breadcrumbSchema,
693
+ businessProfileFromWire,
481
694
  faqSchema,
482
695
  fetchAnalyticsConfig,
483
696
  fetchBlogSettings,
697
+ fetchBusinessProfile,
698
+ fetchMenu,
699
+ fetchMenus,
484
700
  fetchPost,
485
701
  fetchPosts,
486
702
  fetchTheme,
@@ -488,10 +704,13 @@ export {
488
704
  generateRssXml,
489
705
  inquiryErrorMessage,
490
706
  jsonLdScript,
707
+ localBusinessSchema,
491
708
  organizationSchema,
709
+ parseAttributionJson,
492
710
  parseInquiryRedirect,
493
711
  resolvePostDisplay,
494
712
  rssItemsFromPosts,
713
+ searchPosts,
495
714
  submitInquiry,
496
715
  websiteSchema
497
716
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/seo.ts","../src/inquiry.ts","../src/server.ts"],"sourcesContent":["// 프레임워크 무관 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 } 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\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/** JSON-LD 객체 → `<script type=\"application/ld+json\">` HTML 문자열 (XSS-safe). */\nexport function jsonLdScript(schema: JsonLd | JsonLd[]): string {\n // `</script>` 주입 방지를 위해 `<` 이스케이프.\n const json = JSON.stringify(schema).replace(/</g, \"\\\\u003c\");\n return `<script type=\"application/ld+json\">${json}</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\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 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 /**\n * 표준 필드 외에 폼이 받은 임의 항목(key→value)을 있는 그대로 전달. intake 가\n * 암호화 보관하고 CRM 상세에서 노출한다. 예약 키(표준 필드)·`_` 접두 키는 무시.\n * 주의: 개인정보가 담길 수 있으므로 폼의 동의 고지에 수집 항목을 반영할 것.\n */\n extras?: Record<string, 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]);\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(mysite.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 // 표준 외 임의 항목 — 예약 키/`_`접두/빈 값/중복은 건너뛰고 원본 그대로 전달.\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 (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\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","/**\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 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 /** Lowercase kebab-case slug (1-120 chars). */\n slug: string;\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 * 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 /** 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\nconst 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\nfunction authHeaders(apiKey: string): Record<string, string> {\n return {\n authorization: `Bearer ${apiKey}`,\n \"x-roottale-client\": `cms-client/${CLIENT_VERSION}`,\n };\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 /** Override the auto-resolved site id (Phase 1 = 1 site/tenant, usually omitted). */\n siteId?: string;\n /** Optional AbortSignal. */\n signal?: AbortSignal;\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 signal?: AbortSignal;\n}\n\n/**\n * 외부 사이트가 cms-renderer 에 주입할 RootTale 디자인 토큰.\n * admin /design (mysite.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\nexport interface FetchThemeOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\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, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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 };\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 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}\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, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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/**\n * 블로그 표시 설정 — admin /design 의 \"블로그 표시\" 에서 저장한 사이트 기본값.\n * renderer 가 prop 미지정 시 이 값을 기본으로 적용 (글별 override 는 post 의\n * metaJson.display 가 우선).\n */\nexport interface RootTaleBlogSettings {\n showTableOfContents: boolean;\n showAuthor: boolean;\n showDate: boolean;\n showAuthorCard: boolean;\n /** 목차 상단 제목. null = renderer 기본(\"목차\"). */\n tocTitle: string | null;\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}\n\nexport interface FetchBlogSettingsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\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, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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 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 };\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 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 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 };\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 authorCardEyebrow: null,\n authorCardDescription: null,\n authorProfileName: null,\n authorProfileBio: null,\n authorProfileImageUrl: null,\n authorProfileImageRadius: \"circle\",\n authorProfileImagePositionX: 50,\n authorProfileImagePositionY: 50,\n};\n\nexport interface ResolvedPostDisplay {\n showTableOfContents: boolean;\n showAuthor: boolean;\n showDate: boolean;\n showAuthorCard: boolean;\n tocTitle: string;\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 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\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 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.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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 const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts/${encodeURIComponent(options.slugOrId)}${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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\ntype CmsPostWire = {\n id: string;\n tenant_id: string;\n site_id: string;\n type: CmsPostType;\n 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 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 slug: wire.slug,\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 terms: wire.terms ?? [],\n publishedAt: wire.published_at,\n createdAt: wire.created_at,\n updatedAt: wire.updated_at,\n };\n}\n\nasync 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\nfunction 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) 재노출.\nexport * from \"./seo\";\n\n// 리드 접수 — 블로그 조회와 동일한 API key 하나로 server-side 제출 (submitInquiry).\nexport * from \"./inquiry\";\n"],"mappings":"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","names":["DEFAULT_BASE_URL","assertServer"]}
1
+ {"version":3,"sources":["../src/seo.ts","../src/inquiry.ts","../src/server.ts"],"sourcesContent":["// 프레임워크 무관 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/** JSON-LD 객체 → `<script type=\"application/ld+json\">` HTML 문자열 (XSS-safe). */\nexport function jsonLdScript(schema: JsonLd | JsonLd[]): string {\n // `</script>` 주입 방지를 위해 `<` 이스케이프.\n const json = JSON.stringify(schema).replace(/</g, \"\\\\u003c\");\n return `<script type=\"application/ld+json\">${json}</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 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 /**\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\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]);\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(mysite.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 // 유입 어트리뷰션 → intake `attr_*` 필드 (허용 키만, 빈 값 생략).\n if (f.attribution) {\n for (const [key, value] of Object.entries(f.attribution)) {\n if (typeof value !== \"string\" || !value.trim()) continue;\n body.set(`attr_${key}`, value.trim());\n }\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\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","/**\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 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 /** Lowercase kebab-case slug (1-120 chars). */\n slug: string;\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 * 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 /** 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\nconst 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\nfunction authHeaders(apiKey: string): Record<string, string> {\n return {\n authorization: `Bearer ${apiKey}`,\n \"x-roottale-client\": `cms-client/${CLIENT_VERSION}`,\n };\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 /** Override the auto-resolved site id (Phase 1 = 1 site/tenant, usually omitted). */\n siteId?: string;\n /** Optional AbortSignal. */\n signal?: AbortSignal;\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 signal?: AbortSignal;\n}\n\n/**\n * 외부 사이트가 cms-renderer 에 주입할 RootTale 디자인 토큰.\n * admin /design (mysite.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\nexport interface FetchThemeOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\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, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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 };\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 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}\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, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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/**\n * 블로그 표시 설정 — admin /design 의 \"블로그 표시\" 에서 저장한 사이트 기본값.\n * renderer 가 prop 미지정 시 이 값을 기본으로 적용 (글별 override 는 post 의\n * metaJson.display 가 우선).\n */\nexport interface RootTaleBlogSettings {\n showTableOfContents: boolean;\n showAuthor: boolean;\n showDate: boolean;\n showAuthorCard: boolean;\n /** 목차 상단 제목. null = renderer 기본(\"목차\"). */\n tocTitle: string | null;\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}\n\nexport interface FetchBlogSettingsOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\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, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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 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 };\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 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 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 };\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 authorCardEyebrow: null,\n authorCardDescription: null,\n authorProfileName: null,\n authorProfileBio: null,\n authorProfileImageUrl: null,\n authorProfileImageRadius: \"circle\",\n authorProfileImagePositionX: 50,\n authorProfileImagePositionY: 50,\n};\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}\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, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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\nexport interface ResolvedPostDisplay {\n showTableOfContents: boolean;\n showAuthor: boolean;\n showDate: boolean;\n showAuthorCard: boolean;\n tocTitle: string;\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 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\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 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.siteId) params.set(\"site_id\", options.siteId);\n const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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 const qs = params.toString();\n const url = `${baseUrl}/v1/cms/public/posts/${encodeURIComponent(options.slugOrId)}${qs ? `?${qs}` : \"\"}`;\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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\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}\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, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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}\n\nexport interface FetchMenusOptions {\n apiKey: string;\n baseUrl?: string;\n siteId?: string;\n signal?: AbortSignal;\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, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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, {\n method: \"GET\",\n headers: authHeaders(options.apiKey),\n signal: options.signal,\n });\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\ntype CmsPostWire = {\n id: string;\n tenant_id: string;\n site_id: string;\n type: CmsPostType;\n 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 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 slug: wire.slug,\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 terms: wire.terms ?? [],\n publishedAt: wire.published_at,\n createdAt: wire.created_at,\n updatedAt: wire.updated_at,\n };\n}\n\nasync 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\nfunction 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) 재노출.\nexport * from \"./seo\";\n\n// 리드 접수 — 블로그 조회와 동일한 API key 하나로 server-side 제출 (submitInquiry).\nexport * from \"./inquiry\";\n\n// 유입 어트리뷰션 (ADR-0048 §2) — readAttribution() 은 브라우저 전용이므로\n// `@roottale/cms-client/attribution` 서브패스에서 import 할 것. JSON 파서는\n// FormData 기반 Server Action 편의를 위해 서버 엔트리에서도 재노출.\nexport { parseAttributionJson } from \"./attribution\";\nexport type { InquiryAttribution } from \"./attribution\";\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.20.0",
3
+ "version": "0.25.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 / @roottale/cms-renderer-astro.",
6
6
  "main": "./dist/server.js",
@@ -11,6 +11,11 @@
11
11
  "import": "./dist/server.js",
12
12
  "default": "./dist/server.js"
13
13
  },
14
+ "./attribution": {
15
+ "types": "./dist/attribution.d.ts",
16
+ "import": "./dist/attribution.js",
17
+ "default": "./dist/attribution.js"
18
+ },
14
19
  "./webhook": {
15
20
  "types": "./dist/webhook.d.ts",
16
21
  "import": "./dist/webhook.js",