@roottale/cms-client 0.1.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 +21 -0
- package/README.md +85 -0
- package/package.json +44 -0
- package/src/server.ts +214 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @roottale/cms-client
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d1b5d35: cms-\* public renderer 패키지 npm public publish — Phase 1 dogfood.
|
|
8
|
+
|
|
9
|
+
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
|
+
|
|
11
|
+
변경:
|
|
12
|
+
- 4 패키지 `private: true` 해제 + `publishConfig.access: "public"`
|
|
13
|
+
- `@roottale/cms-renderer-next` 에서 `@roottale/tokens/tokens.css` import 제거 — 모든 `--rt-*` 변수에 static fallback 으로 self-contained. tokens dormant 와 무관하게 동작.
|
|
14
|
+
- `RootTaleLeadForm` RSC 추가 — 외부 사이트 진단 폼 (`vertical`/`redirectUrl` props, medical 국외이전 동의 자동).
|
|
15
|
+
- README + repository / homepage / keywords 메타 정비
|
|
16
|
+
- `peerDependencies.react: ^19` 명시 (cms-renderer-next)
|
|
17
|
+
- `cms-renderer-astro` 도 동등 surface 유지를 위해 동시 publish
|
|
18
|
+
|
|
19
|
+
후속:
|
|
20
|
+
- ADR 신규 — cms-\* publish 정책 (별 PR)
|
|
21
|
+
- Astro 측 LeadForm 컴포넌트 (현재 `@roottale/ui-astro` 위치, `cms-renderer-astro` 로 이동 검토)
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @roottale/cms-client
|
|
2
|
+
|
|
3
|
+
RootTale CMS public API fetch client — **server-only**. Bearer auth (`rtlk_cust_*`), refuses to run in the browser.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @roottale/cms-client
|
|
11
|
+
# or
|
|
12
|
+
pnpm add @roottale/cms-client
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Zero runtime dependencies. Uses global `fetch` (node ≥18.18).
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# .env.local — SERVER ONLY, never NEXT_PUBLIC_*
|
|
21
|
+
ROOTTALE_API_KEY=rtlk_cust_xxxxxxxxxxxxxxxxxx
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { fetchPosts, fetchPost } from "@roottale/cms-client/server";
|
|
28
|
+
|
|
29
|
+
// List published posts
|
|
30
|
+
const page = await fetchPosts({
|
|
31
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
32
|
+
limit: 10,
|
|
33
|
+
type: "post", // or "page"
|
|
34
|
+
});
|
|
35
|
+
// page.items: CmsPostContent[]
|
|
36
|
+
// page.hasMore: boolean
|
|
37
|
+
// page.nextCursor: string | null
|
|
38
|
+
|
|
39
|
+
// Get one post by slug or id
|
|
40
|
+
const post = await fetchPost({
|
|
41
|
+
apiKey: process.env.ROOTTALE_API_KEY!,
|
|
42
|
+
slugOrId: "hello-world",
|
|
43
|
+
});
|
|
44
|
+
// post: CmsPostContent | null (null = 404)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`CmsPostContent.bodyJson` is Tiptap-compatible Block JSON. Render it with `@roottale/cms-renderer-next` or your own renderer.
|
|
48
|
+
|
|
49
|
+
## Errors
|
|
50
|
+
|
|
51
|
+
`CmsApiError` (thrown on non-2xx, except 404 which returns `null`):
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { CmsApiError } from "@roottale/cms-client/server";
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
await fetchPosts({ apiKey });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err instanceof CmsApiError) {
|
|
60
|
+
err.status; // HTTP status (e.g. 401, 429)
|
|
61
|
+
err.code; // API error code (e.g. "invalid_key", "rate_limited")
|
|
62
|
+
err.message;
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Security boundary
|
|
69
|
+
|
|
70
|
+
`assertServer()` throws if you import this in the browser. Shipping `rtlk_cust_*` to the client breaks the security model (ADR-0023 §5.1 #15). Keep all calls inside Server Components, Route Handlers, `getServerSideProps`, or build-time scripts.
|
|
71
|
+
|
|
72
|
+
## API base URL
|
|
73
|
+
|
|
74
|
+
Defaults to `https://api.roottale.com`. Override via `baseUrl` option for staging/preview:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
await fetchPosts({
|
|
78
|
+
apiKey,
|
|
79
|
+
baseUrl: process.env.ROOTTALE_API_BASE ?? "https://api.roottale.com",
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
Proprietary (UNLICENSED). Issued under the RootTale customer contract.
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@roottale/cms-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
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",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./server": "./src/server.ts",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"CHANGELOG.md"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"typescript": "^5.7.0",
|
|
20
|
+
"vitest": "^2.1.0"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"roottale",
|
|
27
|
+
"cms",
|
|
28
|
+
"client",
|
|
29
|
+
"api",
|
|
30
|
+
"ssr"
|
|
31
|
+
],
|
|
32
|
+
"license": "UNLICENSED",
|
|
33
|
+
"homepage": "https://github.com/RootTale/roottale-platform/tree/main/packages/cms-client",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/RootTale/roottale-platform.git",
|
|
37
|
+
"directory": "packages/cms-client"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc --noEmit",
|
|
41
|
+
"type-check": "tsc --noEmit",
|
|
42
|
+
"test": "vitest run --passWithNoTests"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
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
|
+
}
|