@roottale/cms-client 0.1.0 → 0.2.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,89 @@
1
1
  # @roottale/cms-client
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a7444e2: Add `terms` field to `CmsPostContent` (categories · tags · custom taxonomy refs).
8
+
9
+ 외부 사이트 (예: kjmtax) 가 admin 에서 분류한 카테고리·태그를 `metaJson` 우회
10
+ 없이 `post.terms` 로 바로 받을 수 있다. 응답은 category → tag → custom 순,
11
+ 각 taxonomy 내부 name asc.
12
+
13
+ Wire schema 의 `terms` 는 optional 로 받아 구 서버 응답에 대해 backward-compat
14
+ (빈 배열 fallback).
15
+
16
+ 신규 타입:
17
+
18
+ - `CmsTermRef { id, taxonomy, slug, name }`
19
+ - `CmsTaxonomyKind = "category" | "tag" | "custom"`
20
+
21
+ - 1e738fd: Add design token auto-application for external sites.
22
+
23
+ admin `mysite.roottale.com → 디자인` 에서 저장한 색·글꼴·둥글기를 외부 사이트의
24
+ RootTale 블로그 컴포넌트가 자동으로 가져와 적용한다.
25
+
26
+ **`@roottale/cms-client`** — 신규 `fetchTheme()` + `RootTaleTheme` 타입 export.
27
+ `GET /v1/cms/public/theme` 엔드포인트 호출 (Bearer rtlk*cust*\*).
28
+
29
+ **`@roottale/cms-renderer-next`** — `<RootTaleBlogList>` / `<RootTaleBlogPost>` 가
30
+ 신규 `theme` prop 지원. 동시에 `<RootTaleThemeProvider>` 컴포넌트로 부모에서 1회
31
+ fetch 한 뒤 자식 컴포넌트가 변수를 상속하는 패턴도 지원.
32
+
33
+ ```tsx
34
+ // 자동 fetch (기본) — 별도 설정 없이 admin 토큰 적용
35
+ <RootTaleBlogList apiKey={process.env.ROOTTALE_API_KEY!} />
36
+
37
+ // 명시 override
38
+ <RootTaleBlogList
39
+ apiKey={process.env.ROOTTALE_API_KEY!}
40
+ theme={{ colors: { primary: "#0070f3" } }}
41
+ />
42
+
43
+ // 부모 1회 fetch → 자식들 상속 (다중 컴포넌트 페이지에서 호출 1회로 축소)
44
+ const theme = await fetchTheme({ apiKey });
45
+ <RootTaleThemeProvider theme={theme}>
46
+ <RootTaleBlogList apiKey={apiKey} theme={null} />
47
+ <RootTaleBlogPost apiKey={apiKey} slugOrId="..." theme={null} />
48
+ </RootTaleThemeProvider>
49
+ ```
50
+
51
+ **`@roottale/cms-renderer-astro`** — `renderBlogList` / `renderBlogPost` 에 동일
52
+ 의미의 `theme` 옵션. style="..." 속성으로 CSS 변수 주입 (서버 측 escape 보강).
53
+
54
+ `theme` prop semantics:
55
+
56
+ - `undefined` → 자동 fetch
57
+ - `RootTaleTheme` → 호출자 override
58
+ - `null` → 자동 fetch 건너뜀 (부모 `RootTaleThemeProvider` 활용 시)
59
+
60
+ fetch 실패는 비치명적 — CSS fallback 으로 graceful degrade.
61
+
62
+ ## 0.1.1
63
+
64
+ ### Patch Changes
65
+
66
+ - dist build 도입 — `.ts` 소스 직접 publish 폐기.
67
+
68
+ 기존 (0.1.0/0.2.0): `exports` 가 `./src/server.ts` 가리킴 → 외부 Next.js
69
+ Turbopack/Webpack 이 npm 모듈에서 TS 자동 컴파일 안 해 build 실패 (customer
70
+ site 가 `transpilePackages` 명시해야 했음).
71
+
72
+ 수정: `tsup` 으로 `dist/*.js` + `dist/*.d.ts` (ESM) 출력. `exports` 가 dist
73
+ 가리킴. customer site 측 `transpilePackages` 불필요.
74
+
75
+ - `cms-client@0.1.1` — ESM `dist/server.js` + types
76
+ - `cms-core@0.2.1` — ESM `dist/index.js` + types
77
+ - `cms-renderer-next@0.2.1` — ESM `dist/{server,index}.js` + types + `dist/cms-public.css`
78
+ - `server.tsx` 의 `import "./styles/cms-public.css"` 제거 — customer 가
79
+ `@roottale/cms-renderer-next/styles` 로 명시 import (README 정합).
80
+ - `cms-renderer-astro@0.2.1` — ESM `dist/index.js` + types
81
+
82
+ 후속 (customer site PR):
83
+
84
+ - roottale-web / kjmtax / theoneulsan 의 `next.config` 의 `transpilePackages`
85
+ 에서 `@roottale/cms-*` 제거. `pnpm update @roottale/cms-client @roottale/cms-renderer-next` 로 patch 적용.
86
+
3
87
  ## 0.1.0
4
88
 
5
89
  ### Minor Changes
@@ -9,6 +93,7 @@
9
93
  ADR-0029 §0 amend (publish-only dormant) 는 design system 5 패키지 (tokens, ui-css, ui-react, ui-astro, ui-admin) 에 한정. cms-\* 는 별도 정책 — 실행 로직 + 5-20 외부 customer site 직접 의존 + schema 호환 + 보안 경계. Codex consult verdict (session `019e6703…`) 정합.
10
94
 
11
95
  변경:
96
+
12
97
  - 4 패키지 `private: true` 해제 + `publishConfig.access: "public"`
13
98
  - `@roottale/cms-renderer-next` 에서 `@roottale/tokens/tokens.css` import 제거 — 모든 `--rt-*` 변수에 static fallback 으로 self-contained. tokens dormant 와 무관하게 동작.
14
99
  - `RootTaleLeadForm` RSC 추가 — 외부 사이트 진단 폼 (`vertical`/`redirectUrl` props, medical 국외이전 동의 자동).
@@ -17,5 +102,6 @@
17
102
  - `cms-renderer-astro` 도 동등 surface 유지를 위해 동시 publish
18
103
 
19
104
  후속:
105
+
20
106
  - ADR 신규 — cms-\* publish 정책 (별 PR)
21
107
  - Astro 측 LeadForm 컴포넌트 (현재 `@roottale/ui-astro` 위치, `cms-renderer-astro` 로 이동 검토)
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `@roottale/cms-client/server` — RootTale CMS Public API server-side fetch client.
3
+ *
4
+ * 외부 고객사 사이트가 RootTale CMS 콘텐츠를 fetch 하기 위한 SSR-only client.
5
+ * Bearer (`rtlk_cust_*`) 인증, 브라우저 직접 호출 금지 (ADR-0023 §5.1 #15).
6
+ *
7
+ * 사용 예 (Next.js server component):
8
+ * ```ts
9
+ * import { fetchPosts } from "@roottale/cms-client/server";
10
+ *
11
+ * const posts = await fetchPosts({
12
+ * apiKey: process.env.ROOTTALE_API_KEY!,
13
+ * limit: 10,
14
+ * });
15
+ * ```
16
+ *
17
+ * 데이터 모델 = ADR-0034 `posts` (WP 7.0-aligned, type=post|page).
18
+ * Phase 1 scope: list + get. WP import/export, draft preview = 별도 PR.
19
+ */
20
+ type CmsPostType = "post" | "page";
21
+ type CmsTaxonomyKind = "category" | "tag" | "custom";
22
+ interface CmsTermRef {
23
+ id: string;
24
+ taxonomy: CmsTaxonomyKind;
25
+ slug: string;
26
+ name: string;
27
+ }
28
+ interface CmsPostContent {
29
+ /** UUIDv7. */
30
+ id: string;
31
+ /** Owning tenant id. */
32
+ tenantId: string;
33
+ /** Owning site id (ADR-0034 P4 dual-key). */
34
+ siteId: string;
35
+ /** WP post type. */
36
+ type: CmsPostType;
37
+ /** Lowercase kebab-case slug (1-120 chars). */
38
+ slug: string;
39
+ title: string;
40
+ excerpt: string | null;
41
+ /** Media id (resolved by renderer / consumer to URL). */
42
+ featuredMediaId: string | null;
43
+ authorId: string | null;
44
+ /** Tiptap / Gutenberg-compatible Block JSON. Sanitization is renderer's responsibility. */
45
+ bodyJson: Record<string, unknown>;
46
+ /** Public API only exposes `published`. */
47
+ status: "published";
48
+ /** Per-post meta (SEO overrides, canonical, og, ...). */
49
+ metaJson: Record<string, unknown>;
50
+ /**
51
+ * Attached taxonomy terms (categories + tags + custom). Server 가 category →
52
+ * tag → custom 순서로 정렬해 보내며, 각 taxonomy 내부는 name asc. attach 가 0 개면
53
+ * 빈 배열. category 만 추출하려면 `terms.filter(t => t.taxonomy === "category")`.
54
+ */
55
+ terms: CmsTermRef[];
56
+ /** ISO 8601. */
57
+ publishedAt: string;
58
+ /** ISO 8601. */
59
+ createdAt: string;
60
+ /** ISO 8601. */
61
+ updatedAt: string;
62
+ }
63
+ interface CmsPostContentPage {
64
+ items: CmsPostContent[];
65
+ nextCursor: string | null;
66
+ hasMore: boolean;
67
+ }
68
+ interface FetchPostsOptions {
69
+ /** `rtlk_cust_*` API key. Required. */
70
+ apiKey: string;
71
+ /** Override the API base URL. Defaults to `https://api.roottale.com`. */
72
+ baseUrl?: string;
73
+ /** Page size, 1-100, default 20. */
74
+ limit?: number;
75
+ /** Opaque pagination cursor returned from previous call. */
76
+ cursor?: string;
77
+ /** Filter by post type (`post` or `page`). Default = both. */
78
+ type?: CmsPostType;
79
+ /** Override the auto-resolved site id (Phase 1 = 1 site/tenant, usually omitted). */
80
+ siteId?: string;
81
+ /** Optional AbortSignal. */
82
+ signal?: AbortSignal;
83
+ }
84
+ interface FetchPostOptions {
85
+ apiKey: string;
86
+ baseUrl?: string;
87
+ /** Slug (kebab-case) or UUIDv7 id. */
88
+ slugOrId: string;
89
+ /** Override the auto-resolved site id. */
90
+ siteId?: string;
91
+ signal?: AbortSignal;
92
+ }
93
+ /**
94
+ * 외부 사이트가 cms-renderer 에 주입할 RootTale 디자인 토큰.
95
+ * admin /design (mysite.roottale.com) 에서 저장한 값과 동일한 shape.
96
+ * 미설정 필드는 응답에서 누락 → renderer 의 CSS fallback 이 사용된다.
97
+ */
98
+ interface RootTaleTheme {
99
+ colors?: {
100
+ primary?: string;
101
+ primaryForeground?: string;
102
+ foreground?: string;
103
+ background?: string;
104
+ muted?: string;
105
+ mutedForeground?: string;
106
+ border?: string;
107
+ };
108
+ fonts?: {
109
+ body?: string;
110
+ display?: string;
111
+ };
112
+ radius?: {
113
+ md?: string;
114
+ };
115
+ }
116
+ interface FetchThemeOptions {
117
+ apiKey: string;
118
+ baseUrl?: string;
119
+ siteId?: string;
120
+ signal?: AbortSignal;
121
+ }
122
+ /**
123
+ * tenant 의 RootTale 디자인 토큰을 가져온다. admin /design 의 마지막 저장값
124
+ * (없으면 빈 객체) — renderer 가 이 값을 CSS 변수로 주입한다.
125
+ *
126
+ * 자동 fetch (renderer 가 theme prop 없을 때) 와 명시 호출 모두 지원.
127
+ * 네트워크/서버 에러는 throw — 호출자가 fallback 처리.
128
+ */
129
+ declare function fetchTheme(options: FetchThemeOptions): Promise<RootTaleTheme>;
130
+ declare class CmsApiError extends Error {
131
+ readonly status: number;
132
+ readonly code: string;
133
+ constructor(message: string, status: number, code: string);
134
+ }
135
+ declare function fetchPosts(options: FetchPostsOptions): Promise<CmsPostContentPage>;
136
+ declare function fetchPost(options: FetchPostOptions): Promise<CmsPostContent | null>;
137
+
138
+ export { CmsApiError, type CmsPostContent, type CmsPostContentPage, type CmsPostType, type CmsTaxonomyKind, type CmsTermRef, type FetchPostOptions, type FetchPostsOptions, type FetchThemeOptions, type RootTaleTheme, fetchPost, fetchPosts, fetchTheme };
package/dist/server.js ADDED
@@ -0,0 +1,123 @@
1
+ // src/server.ts
2
+ var DEFAULT_BASE_URL = "https://api.roottale.com";
3
+ async function fetchTheme(options) {
4
+ assertServer();
5
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
6
+ const params = new URLSearchParams();
7
+ if (options.siteId) params.set("site_id", options.siteId);
8
+ const qs = params.toString();
9
+ const url = `${baseUrl}/v1/cms/public/theme${qs ? `?${qs}` : ""}`;
10
+ const response = await fetch(url, {
11
+ method: "GET",
12
+ headers: { authorization: `Bearer ${options.apiKey}` },
13
+ signal: options.signal
14
+ });
15
+ if (!response.ok) {
16
+ throw await toApiError(response);
17
+ }
18
+ const json = await response.json();
19
+ const theme = {};
20
+ if (json.colors) theme.colors = json.colors;
21
+ if (json.fonts) theme.fonts = json.fonts;
22
+ if (json.radius) theme.radius = json.radius;
23
+ return theme;
24
+ }
25
+ var CmsApiError = class extends Error {
26
+ status;
27
+ code;
28
+ constructor(message, status, code) {
29
+ super(message);
30
+ this.name = "CmsApiError";
31
+ this.status = status;
32
+ this.code = code;
33
+ }
34
+ };
35
+ async function fetchPosts(options) {
36
+ assertServer();
37
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
38
+ const params = new URLSearchParams();
39
+ if (options.limit !== void 0) params.set("limit", String(options.limit));
40
+ if (options.cursor) params.set("cursor", options.cursor);
41
+ if (options.type) params.set("type", options.type);
42
+ if (options.siteId) params.set("site_id", options.siteId);
43
+ const qs = params.toString();
44
+ const url = `${baseUrl}/v1/cms/public/posts${qs ? `?${qs}` : ""}`;
45
+ const response = await fetch(url, {
46
+ method: "GET",
47
+ headers: { authorization: `Bearer ${options.apiKey}` },
48
+ signal: options.signal
49
+ });
50
+ if (!response.ok) {
51
+ throw await toApiError(response);
52
+ }
53
+ const json = await response.json();
54
+ return {
55
+ items: json.items.map(fromWire),
56
+ hasMore: json.has_more,
57
+ nextCursor: json.next_cursor
58
+ };
59
+ }
60
+ async function fetchPost(options) {
61
+ assertServer();
62
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
63
+ const params = new URLSearchParams();
64
+ if (options.siteId) params.set("site_id", options.siteId);
65
+ const qs = params.toString();
66
+ const url = `${baseUrl}/v1/cms/public/posts/${encodeURIComponent(options.slugOrId)}${qs ? `?${qs}` : ""}`;
67
+ const response = await fetch(url, {
68
+ method: "GET",
69
+ headers: { authorization: `Bearer ${options.apiKey}` },
70
+ signal: options.signal
71
+ });
72
+ if (response.status === 404) return null;
73
+ if (!response.ok) {
74
+ throw await toApiError(response);
75
+ }
76
+ const json = await response.json();
77
+ return fromWire(json);
78
+ }
79
+ function fromWire(wire) {
80
+ return {
81
+ id: wire.id,
82
+ tenantId: wire.tenant_id,
83
+ siteId: wire.site_id,
84
+ type: wire.type,
85
+ slug: wire.slug,
86
+ title: wire.title,
87
+ excerpt: wire.excerpt,
88
+ featuredMediaId: wire.featured_media_id,
89
+ authorId: wire.author_id,
90
+ bodyJson: wire.body_json,
91
+ status: wire.status,
92
+ metaJson: wire.meta_json,
93
+ terms: wire.terms ?? [],
94
+ publishedAt: wire.published_at,
95
+ createdAt: wire.created_at,
96
+ updatedAt: wire.updated_at
97
+ };
98
+ }
99
+ async function toApiError(response) {
100
+ let code = "request_failed";
101
+ let message = `RootTale CMS API error: ${response.status}`;
102
+ try {
103
+ const body = await response.json();
104
+ if (body.code) code = body.code;
105
+ if (body.message) message = body.message;
106
+ } catch {
107
+ }
108
+ return new CmsApiError(message, response.status, code);
109
+ }
110
+ function assertServer() {
111
+ if (typeof window !== "undefined") {
112
+ throw new Error(
113
+ "@roottale/cms-client/server must not run in the browser. Move this call to a Server Component, getServerSideProps, or a Route Handler. Shipping API keys to the browser breaks the security model (ADR-0023 \xA75.1 #15)."
114
+ );
115
+ }
116
+ }
117
+ export {
118
+ CmsApiError,
119
+ fetchPost,
120
+ fetchPosts,
121
+ fetchTheme
122
+ };
123
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/server.ts"],"sourcesContent":["/**\n * `@roottale/cms-client/server` — RootTale CMS Public API server-side fetch client.\n *\n * 외부 고객사 사이트가 RootTale CMS 콘텐츠를 fetch 하기 위한 SSR-only client.\n * Bearer (`rtlk_cust_*`) 인증, 브라우저 직접 호출 금지 (ADR-0023 §5.1 #15).\n *\n * 사용 예 (Next.js server component):\n * ```ts\n * import { fetchPosts } from \"@roottale/cms-client/server\";\n *\n * const posts = await fetchPosts({\n * apiKey: process.env.ROOTTALE_API_KEY!,\n * limit: 10,\n * });\n * ```\n *\n * 데이터 모델 = ADR-0034 `posts` (WP 7.0-aligned, type=post|page).\n * Phase 1 scope: list + get. WP import/export, draft preview = 별도 PR.\n */\n\nexport type CmsPostType = \"post\" | \"page\";\n\nexport type CmsTaxonomyKind = \"category\" | \"tag\" | \"custom\";\n\nexport interface CmsTermRef {\n id: string;\n taxonomy: CmsTaxonomyKind;\n slug: string;\n name: string;\n}\n\nexport interface 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 authorId: 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 * 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\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 muted?: string;\n mutedForeground?: string;\n border?: string;\n };\n fonts?: {\n body?: string;\n display?: string;\n };\n radius?: {\n md?: 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: { authorization: `Bearer ${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 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: { authorization: `Bearer ${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: { authorization: `Bearer ${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 author_id: string | null;\n body_json: Record<string, unknown>;\n status: \"published\";\n meta_json: Record<string, unknown>;\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 authorId: wire.author_id,\n bodyJson: wire.body_json,\n status: wire.status,\n metaJson: wire.meta_json,\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"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,15 +1,20 @@
1
1
  {
2
2
  "name": "@roottale/cms-client",
3
- "version": "0.1.0",
3
+ "version": "0.2.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
- "main": "./src/server.ts",
6
+ "main": "./dist/server.js",
7
+ "types": "./dist/server.d.ts",
7
8
  "exports": {
8
- "./server": "./src/server.ts",
9
+ "./server": {
10
+ "types": "./dist/server.d.ts",
11
+ "import": "./dist/server.js",
12
+ "default": "./dist/server.js"
13
+ },
9
14
  "./package.json": "./package.json"
10
15
  },
11
16
  "files": [
12
- "src/",
17
+ "dist/",
13
18
  "README.md",
14
19
  "CHANGELOG.md"
15
20
  ],
@@ -37,7 +42,7 @@
37
42
  "directory": "packages/cms-client"
38
43
  },
39
44
  "scripts": {
40
- "build": "tsc --noEmit",
45
+ "build": "tsup",
41
46
  "type-check": "tsc --noEmit",
42
47
  "test": "vitest run --passWithNoTests"
43
48
  }
package/src/server.ts DELETED
@@ -1,214 +0,0 @@
1
- /**
2
- * `@roottale/cms-client/server` — RootTale CMS Public API server-side fetch client.
3
- *
4
- * 외부 고객사 사이트가 RootTale CMS 콘텐츠를 fetch 하기 위한 SSR-only client.
5
- * Bearer (`rtlk_cust_*`) 인증, 브라우저 직접 호출 금지 (ADR-0023 §5.1 #15).
6
- *
7
- * 사용 예 (Next.js server component):
8
- * ```ts
9
- * import { fetchPosts } from "@roottale/cms-client/server";
10
- *
11
- * const posts = await fetchPosts({
12
- * apiKey: process.env.ROOTTALE_API_KEY!,
13
- * limit: 10,
14
- * });
15
- * ```
16
- *
17
- * 데이터 모델 = ADR-0034 `posts` (WP 7.0-aligned, type=post|page).
18
- * Phase 1 scope: list + get. WP import/export, draft preview = 별도 PR.
19
- */
20
-
21
- export type CmsPostType = "post" | "page";
22
-
23
- export interface CmsPostContent {
24
- /** UUIDv7. */
25
- id: string;
26
- /** Owning tenant id. */
27
- tenantId: string;
28
- /** Owning site id (ADR-0034 P4 dual-key). */
29
- siteId: string;
30
- /** WP post type. */
31
- type: CmsPostType;
32
- /** Lowercase kebab-case slug (1-120 chars). */
33
- slug: string;
34
- title: string;
35
- excerpt: string | null;
36
- /** Media id (resolved by renderer / consumer to URL). */
37
- featuredMediaId: string | null;
38
- authorId: string | null;
39
- /** Tiptap / Gutenberg-compatible Block JSON. Sanitization is renderer's responsibility. */
40
- bodyJson: Record<string, unknown>;
41
- /** Public API only exposes `published`. */
42
- status: "published";
43
- /** Per-post meta (SEO overrides, canonical, og, ...). */
44
- metaJson: Record<string, unknown>;
45
- /** ISO 8601. */
46
- publishedAt: string;
47
- /** ISO 8601. */
48
- createdAt: string;
49
- /** ISO 8601. */
50
- updatedAt: string;
51
- }
52
-
53
- export interface CmsPostContentPage {
54
- items: CmsPostContent[];
55
- nextCursor: string | null;
56
- hasMore: boolean;
57
- }
58
-
59
- const DEFAULT_BASE_URL = "https://api.roottale.com";
60
-
61
- export interface FetchPostsOptions {
62
- /** `rtlk_cust_*` API key. Required. */
63
- apiKey: string;
64
- /** Override the API base URL. Defaults to `https://api.roottale.com`. */
65
- baseUrl?: string;
66
- /** Page size, 1-100, default 20. */
67
- limit?: number;
68
- /** Opaque pagination cursor returned from previous call. */
69
- cursor?: string;
70
- /** Filter by post type (`post` or `page`). Default = both. */
71
- type?: CmsPostType;
72
- /** Override the auto-resolved site id (Phase 1 = 1 site/tenant, usually omitted). */
73
- siteId?: string;
74
- /** Optional AbortSignal. */
75
- signal?: AbortSignal;
76
- }
77
-
78
- export interface FetchPostOptions {
79
- apiKey: string;
80
- baseUrl?: string;
81
- /** Slug (kebab-case) or UUIDv7 id. */
82
- slugOrId: string;
83
- /** Override the auto-resolved site id. */
84
- siteId?: string;
85
- signal?: AbortSignal;
86
- }
87
-
88
- export class CmsApiError extends Error {
89
- readonly status: number;
90
- readonly code: string;
91
- constructor(message: string, status: number, code: string) {
92
- super(message);
93
- this.name = "CmsApiError";
94
- this.status = status;
95
- this.code = code;
96
- }
97
- }
98
-
99
- export async function fetchPosts(options: FetchPostsOptions): Promise<CmsPostContentPage> {
100
- assertServer();
101
- const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
102
- const params = new URLSearchParams();
103
- if (options.limit !== undefined) params.set("limit", String(options.limit));
104
- if (options.cursor) params.set("cursor", options.cursor);
105
- if (options.type) params.set("type", options.type);
106
- if (options.siteId) params.set("site_id", options.siteId);
107
- const qs = params.toString();
108
- const url = `${baseUrl}/v1/cms/public/posts${qs ? `?${qs}` : ""}`;
109
-
110
- const response = await fetch(url, {
111
- method: "GET",
112
- headers: { authorization: `Bearer ${options.apiKey}` },
113
- signal: options.signal,
114
- });
115
-
116
- if (!response.ok) {
117
- throw await toApiError(response);
118
- }
119
-
120
- const json = (await response.json()) as {
121
- items: CmsPostWire[];
122
- has_more: boolean;
123
- next_cursor: string | null;
124
- };
125
- return {
126
- items: json.items.map(fromWire),
127
- hasMore: json.has_more,
128
- nextCursor: json.next_cursor,
129
- };
130
- }
131
-
132
- export async function fetchPost(options: FetchPostOptions): Promise<CmsPostContent | null> {
133
- assertServer();
134
- const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
135
- const params = new URLSearchParams();
136
- if (options.siteId) params.set("site_id", options.siteId);
137
- const qs = params.toString();
138
- const url = `${baseUrl}/v1/cms/public/posts/${encodeURIComponent(options.slugOrId)}${qs ? `?${qs}` : ""}`;
139
-
140
- const response = await fetch(url, {
141
- method: "GET",
142
- headers: { authorization: `Bearer ${options.apiKey}` },
143
- signal: options.signal,
144
- });
145
-
146
- if (response.status === 404) return null;
147
- if (!response.ok) {
148
- throw await toApiError(response);
149
- }
150
-
151
- const json = (await response.json()) as CmsPostWire;
152
- return fromWire(json);
153
- }
154
-
155
- type CmsPostWire = {
156
- id: string;
157
- tenant_id: string;
158
- site_id: string;
159
- type: CmsPostType;
160
- slug: string;
161
- title: string;
162
- excerpt: string | null;
163
- featured_media_id: string | null;
164
- author_id: string | null;
165
- body_json: Record<string, unknown>;
166
- status: "published";
167
- meta_json: Record<string, unknown>;
168
- published_at: string;
169
- created_at: string;
170
- updated_at: string;
171
- };
172
-
173
- function fromWire(wire: CmsPostWire): CmsPostContent {
174
- return {
175
- id: wire.id,
176
- tenantId: wire.tenant_id,
177
- siteId: wire.site_id,
178
- type: wire.type,
179
- slug: wire.slug,
180
- title: wire.title,
181
- excerpt: wire.excerpt,
182
- featuredMediaId: wire.featured_media_id,
183
- authorId: wire.author_id,
184
- bodyJson: wire.body_json,
185
- status: wire.status,
186
- metaJson: wire.meta_json,
187
- publishedAt: wire.published_at,
188
- createdAt: wire.created_at,
189
- updatedAt: wire.updated_at,
190
- };
191
- }
192
-
193
- async function toApiError(response: Response): Promise<CmsApiError> {
194
- let code = "request_failed";
195
- let message = `RootTale CMS API error: ${response.status}`;
196
- try {
197
- const body = (await response.json()) as { code?: string; message?: string };
198
- if (body.code) code = body.code;
199
- if (body.message) message = body.message;
200
- } catch {
201
- /* ignore body parse */
202
- }
203
- return new CmsApiError(message, response.status, code);
204
- }
205
-
206
- function assertServer(): void {
207
- if (typeof window !== "undefined") {
208
- throw new Error(
209
- "@roottale/cms-client/server must not run in the browser. " +
210
- "Move this call to a Server Component, getServerSideProps, or a Route Handler. " +
211
- "Shipping API keys to the browser breaks the security model (ADR-0023 §5.1 #15).",
212
- );
213
- }
214
- }