@better-i18n/sdk 0.2.0 → 0.4.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/README.md +126 -0
- package/package.json +1 -1
- package/src/client.ts +12 -6
- package/src/content-api.ts +42 -26
- package/src/index.ts +3 -0
- package/src/types.ts +46 -14
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# @better-i18n/sdk
|
|
2
|
+
|
|
3
|
+
Content SDK for [Better i18n](https://better-i18n.com). A lightweight, typed client for fetching content models and entries from the headless CMS.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 📦 **Zero Dependencies** — Runs anywhere with `fetch()`
|
|
8
|
+
- 🔒 **Type-Safe** — Full TypeScript types with generic custom fields
|
|
9
|
+
- 🌍 **Language-Aware** — Fetch localized content by language code
|
|
10
|
+
- 📄 **Pagination Built-in** — Paginated listing with total count and `hasMore`
|
|
11
|
+
- 🔍 **Filtering & Sorting** — Filter by status, sort by date or title
|
|
12
|
+
- ⚡ **Lightweight** — Thin wrapper over REST API
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @better-i18n/sdk
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick Start
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { createClient } from "@better-i18n/sdk";
|
|
24
|
+
|
|
25
|
+
const client = createClient({
|
|
26
|
+
project: "acme/web-app",
|
|
27
|
+
apiKey: process.env.BETTER_I18N_API_KEY!,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// List content models
|
|
31
|
+
const models = await client.getModels();
|
|
32
|
+
|
|
33
|
+
// List published blog posts
|
|
34
|
+
const { items, total, hasMore } = await client.getEntries("blog-posts", {
|
|
35
|
+
status: "published",
|
|
36
|
+
sort: "publishedAt",
|
|
37
|
+
order: "desc",
|
|
38
|
+
language: "en",
|
|
39
|
+
limit: 10,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Get a single entry with localized content
|
|
43
|
+
const post = await client.getEntry("blog-posts", "hello-world", {
|
|
44
|
+
language: "fr",
|
|
45
|
+
});
|
|
46
|
+
console.log(post.title, post.bodyMarkdown);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
| Method | Description |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `getModels()` | List all content models with entry counts |
|
|
54
|
+
| `getEntries(modelSlug, options?)` | Paginated list of entries for a model |
|
|
55
|
+
| `getEntry(modelSlug, entrySlug, options?)` | Full content entry with all fields |
|
|
56
|
+
|
|
57
|
+
### `getEntries` Options
|
|
58
|
+
|
|
59
|
+
| Option | Type | Default | Description |
|
|
60
|
+
| --- | --- | --- | --- |
|
|
61
|
+
| `language` | `string` | source language | Language code for localized content |
|
|
62
|
+
| `status` | `"draft" \| "published" \| "archived"` | all | Filter by entry status |
|
|
63
|
+
| `sort` | `"publishedAt" \| "createdAt" \| "updatedAt" \| "title"` | `"updatedAt"` | Sort field |
|
|
64
|
+
| `order` | `"asc" \| "desc"` | `"desc"` | Sort direction |
|
|
65
|
+
| `page` | `number` | `1` | Page number (1-based) |
|
|
66
|
+
| `limit` | `number` | `50` | Entries per page (1-100) |
|
|
67
|
+
|
|
68
|
+
### `getEntry` Options
|
|
69
|
+
|
|
70
|
+
| Option | Type | Default | Description |
|
|
71
|
+
| --- | --- | --- | --- |
|
|
72
|
+
| `language` | `string` | source language | Language code for localized content |
|
|
73
|
+
|
|
74
|
+
### Response Types
|
|
75
|
+
|
|
76
|
+
**`getEntries` returns `PaginatedResponse<ContentEntryListItem>`:**
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
{
|
|
80
|
+
items: ContentEntryListItem[]; // slug, title, excerpt, publishedAt, tags, author
|
|
81
|
+
total: number; // total matching entries
|
|
82
|
+
hasMore: boolean; // more pages available
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**`getEntry` returns `ContentEntry<CF>`:**
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
{
|
|
90
|
+
id, slug, status, publishedAt, sourceLanguage, availableLanguages,
|
|
91
|
+
featuredImage, tags, author, customFields,
|
|
92
|
+
title, excerpt, body, bodyHtml, bodyMarkdown,
|
|
93
|
+
metaTitle, metaDescription
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Typed Custom Fields
|
|
98
|
+
|
|
99
|
+
Use the generic type parameter for type-safe custom fields:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
interface BlogFields {
|
|
103
|
+
readingTime: string | null;
|
|
104
|
+
category: string | null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const post = await client.getEntry<BlogFields>("blog-posts", "hello-world");
|
|
108
|
+
post.customFields.readingTime; // string | null (typed!)
|
|
109
|
+
post.customFields.category; // string | null (typed!)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
| Option | Required | Description |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `project` | Yes | Project identifier in `org/project` format (e.g., `"acme/web-app"`) |
|
|
117
|
+
| `apiKey` | Yes | API key from [dashboard](https://dash.better-i18n.com) |
|
|
118
|
+
| `apiBase` | No | API base URL (default: `https://dash.better-i18n.com`) |
|
|
119
|
+
|
|
120
|
+
## Documentation
|
|
121
|
+
|
|
122
|
+
Full documentation at [docs.better-i18n.com/sdk](https://docs.better-i18n.com/sdk)
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT © [Better i18n](https://better-i18n.com)
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ClientConfig, ContentClient } from "./types";
|
|
2
2
|
import { createContentAPIClient } from "./content-api";
|
|
3
3
|
|
|
4
|
-
const DEFAULT_API_BASE = "https://
|
|
4
|
+
const DEFAULT_API_BASE = "https://dash.better-i18n.com";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Creates a Better i18n content client.
|
|
@@ -12,8 +12,7 @@ const DEFAULT_API_BASE = "https://api.better-i18n.com";
|
|
|
12
12
|
* @example
|
|
13
13
|
* ```typescript
|
|
14
14
|
* const client = createClient({
|
|
15
|
-
*
|
|
16
|
-
* project: "web-app",
|
|
15
|
+
* project: "acme/web-app",
|
|
17
16
|
* apiKey: "bi18n_...",
|
|
18
17
|
* });
|
|
19
18
|
*
|
|
@@ -23,16 +22,23 @@ const DEFAULT_API_BASE = "https://api.better-i18n.com";
|
|
|
23
22
|
* ```
|
|
24
23
|
*/
|
|
25
24
|
export function createClient(config: ClientConfig): ContentClient {
|
|
26
|
-
const { org, project } = config;
|
|
27
25
|
const apiBase = (config.apiBase || DEFAULT_API_BASE).replace(/\/$/, "");
|
|
28
26
|
|
|
27
|
+
const parts = config.project.split("/");
|
|
28
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Invalid project format "${config.project}". Expected "org/project" (e.g., "acme/web-app").`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const [org, project] = parts;
|
|
34
|
+
|
|
29
35
|
if (!config.apiKey) {
|
|
30
36
|
throw new Error(
|
|
31
37
|
"API key is required for content API access.\n" +
|
|
32
38
|
"Set apiKey in your client config:\n\n" +
|
|
33
|
-
' createClient({
|
|
39
|
+
' createClient({ project: "acme/web-app", apiKey: "bi18n_..." })',
|
|
34
40
|
);
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
return createContentAPIClient(apiBase, org, project, config.apiKey);
|
|
43
|
+
return createContentAPIClient(apiBase, org, project, config.apiKey, config.debug);
|
|
38
44
|
}
|
package/src/content-api.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ContentModel,
|
|
6
6
|
ListEntriesOptions,
|
|
7
7
|
GetEntryOptions,
|
|
8
|
+
PaginatedResponse,
|
|
8
9
|
} from "./types";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -23,6 +24,7 @@ export function createContentAPIClient(
|
|
|
23
24
|
org: string,
|
|
24
25
|
project: string,
|
|
25
26
|
apiKey: string,
|
|
27
|
+
debug = false,
|
|
26
28
|
): ContentClient {
|
|
27
29
|
const base = `${apiBase}/api/v1/content/${org}/${project}`;
|
|
28
30
|
const headers: Record<string, string> = {
|
|
@@ -30,58 +32,72 @@ export function createContentAPIClient(
|
|
|
30
32
|
"content-type": "application/json",
|
|
31
33
|
};
|
|
32
34
|
|
|
35
|
+
const log = debug
|
|
36
|
+
? (...args: unknown[]) => console.log("[better-i18n]", ...args)
|
|
37
|
+
: () => {};
|
|
38
|
+
|
|
39
|
+
if (debug) {
|
|
40
|
+
log("Client initialized", { apiBase, org, project, base });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function request<T>(url: string, label: string): Promise<{ res: Response; data: T }> {
|
|
44
|
+
log(`→ ${label}`, url);
|
|
45
|
+
const res = await fetch(url, { headers });
|
|
46
|
+
log(`← ${res.status} ${res.statusText}`);
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
const body = await res.text().catch(() => "");
|
|
49
|
+
log(" Error body:", body);
|
|
50
|
+
throw new Error(`API error ${label}: ${res.status} ${body}`);
|
|
51
|
+
}
|
|
52
|
+
const data = await res.json() as T;
|
|
53
|
+
log(" Response:", JSON.stringify(data).slice(0, 500));
|
|
54
|
+
return { res, data };
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
return {
|
|
34
58
|
async getModels(): Promise<ContentModel[]> {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
throw new Error(`API error fetching models: ${res.status}`);
|
|
38
|
-
}
|
|
39
|
-
return res.json();
|
|
59
|
+
const { data } = await request<ContentModel[]>(`${base}/models`, "getModels");
|
|
60
|
+
return data;
|
|
40
61
|
},
|
|
41
62
|
|
|
42
63
|
async getEntries(
|
|
43
64
|
modelSlug: string,
|
|
44
65
|
options?: ListEntriesOptions,
|
|
45
|
-
): Promise<ContentEntryListItem
|
|
66
|
+
): Promise<PaginatedResponse<ContentEntryListItem>> {
|
|
46
67
|
const params = new URLSearchParams();
|
|
47
68
|
if (options?.language) params.set("language", options.language);
|
|
69
|
+
if (options?.status) params.set("status", options.status);
|
|
70
|
+
if (options?.sort) params.set("sort", options.sort);
|
|
71
|
+
if (options?.order) params.set("order", options.order);
|
|
48
72
|
if (options?.page) params.set("page", String(options.page));
|
|
49
73
|
if (options?.limit) params.set("limit", String(options.limit));
|
|
50
74
|
const qs = params.toString() ? `?${params}` : "";
|
|
51
75
|
|
|
52
|
-
const
|
|
76
|
+
const { data } = await request<{ items: ContentEntryListItem[]; total: number; hasMore: boolean }>(
|
|
53
77
|
`${base}/models/${modelSlug}/entries${qs}`,
|
|
54
|
-
{
|
|
78
|
+
`getEntries(${modelSlug})`,
|
|
55
79
|
);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
const data: { items?: ContentEntryListItem[] } | ContentEntryListItem[] =
|
|
62
|
-
await res.json();
|
|
63
|
-
return Array.isArray(data) ? data : (data.items ?? []);
|
|
80
|
+
return {
|
|
81
|
+
items: data.items,
|
|
82
|
+
total: data.total,
|
|
83
|
+
hasMore: data.hasMore,
|
|
84
|
+
};
|
|
64
85
|
},
|
|
65
86
|
|
|
66
|
-
async getEntry(
|
|
87
|
+
async getEntry<CF extends Record<string, string | null> = Record<string, string | null>>(
|
|
67
88
|
modelSlug: string,
|
|
68
89
|
entrySlug: string,
|
|
69
90
|
options?: GetEntryOptions,
|
|
70
|
-
): Promise<ContentEntry
|
|
91
|
+
): Promise<ContentEntry<CF>> {
|
|
71
92
|
const params = new URLSearchParams();
|
|
72
93
|
if (options?.language) params.set("language", options.language);
|
|
73
94
|
const qs = params.toString() ? `?${params}` : "";
|
|
74
95
|
|
|
75
|
-
const
|
|
96
|
+
const { data } = await request<ContentEntry<CF>>(
|
|
76
97
|
`${base}/models/${modelSlug}/entries/${entrySlug}${qs}`,
|
|
77
|
-
{
|
|
98
|
+
`getEntry(${modelSlug}/${entrySlug})`,
|
|
78
99
|
);
|
|
79
|
-
|
|
80
|
-
throw new Error(
|
|
81
|
-
`API error fetching entry ${modelSlug}/${entrySlug}: ${res.status}`,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
return res.json();
|
|
100
|
+
return data;
|
|
85
101
|
},
|
|
86
102
|
};
|
|
87
103
|
}
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -2,40 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
/** Configuration for creating a Better i18n content client. */
|
|
4
4
|
export interface ClientConfig {
|
|
5
|
-
/**
|
|
6
|
-
org: string;
|
|
7
|
-
/** Project slug (e.g., "web-app"). */
|
|
5
|
+
/** Project identifier in `org/project` format (e.g., "acme-corp/web-app"). Same as the dashboard URL path. */
|
|
8
6
|
project: string;
|
|
9
7
|
/** API key for authenticating content requests. Required. */
|
|
10
8
|
apiKey: string;
|
|
11
|
-
/** REST API base URL. Defaults to `https://
|
|
9
|
+
/** REST API base URL. Defaults to `https://dash.better-i18n.com`. */
|
|
12
10
|
apiBase?: string;
|
|
11
|
+
/** Enable debug logging to see request URLs, headers, and responses. */
|
|
12
|
+
debug?: boolean;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
// ─── Content Types ───────────────────────────────────────────────────
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* A full content entry with all localized fields.
|
|
19
|
+
*
|
|
20
|
+
* @typeParam CF - Custom fields shape. Defaults to `Record<string, string | null>`.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* // Typed custom fields
|
|
25
|
+
* interface BlogFields { readingTime: string | null; category: string | null }
|
|
26
|
+
* const post = await client.getEntry<BlogFields>("blog", "hello-world");
|
|
27
|
+
* post.customFields.readingTime; // string | null (typed!)
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export interface ContentEntry<CF extends Record<string, string | null> = Record<string, string | null>> {
|
|
19
31
|
id: string;
|
|
20
32
|
slug: string;
|
|
21
|
-
status:
|
|
33
|
+
status: "draft" | "published" | "archived";
|
|
22
34
|
publishedAt: string | null;
|
|
23
35
|
sourceLanguage: string;
|
|
24
36
|
availableLanguages: string[];
|
|
25
37
|
featuredImage: string | null;
|
|
26
38
|
tags: string[];
|
|
27
39
|
author: { name: string; image: string | null } | null;
|
|
28
|
-
customFields:
|
|
40
|
+
customFields: CF;
|
|
29
41
|
// Localized content
|
|
30
42
|
title: string;
|
|
31
43
|
excerpt: string | null;
|
|
32
|
-
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
body: Record<string, any> | null;
|
|
33
46
|
bodyHtml: string | null;
|
|
34
47
|
bodyMarkdown: string | null;
|
|
35
48
|
metaTitle: string | null;
|
|
36
49
|
metaDescription: string | null;
|
|
37
50
|
}
|
|
38
51
|
|
|
52
|
+
/** Entry status filter values. */
|
|
53
|
+
export type ContentEntryStatus = "draft" | "published" | "archived";
|
|
54
|
+
|
|
39
55
|
/** A summary item for content entry lists. */
|
|
40
56
|
export interface ContentEntryListItem {
|
|
41
57
|
slug: string;
|
|
@@ -47,6 +63,13 @@ export interface ContentEntryListItem {
|
|
|
47
63
|
author: { name: string; image: string | null } | null;
|
|
48
64
|
}
|
|
49
65
|
|
|
66
|
+
/** Paginated response wrapper. */
|
|
67
|
+
export interface PaginatedResponse<T> {
|
|
68
|
+
items: T[];
|
|
69
|
+
total: number;
|
|
70
|
+
hasMore: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
/** A content model definition. */
|
|
51
74
|
export interface ContentModel {
|
|
52
75
|
slug: string;
|
|
@@ -58,13 +81,22 @@ export interface ContentModel {
|
|
|
58
81
|
|
|
59
82
|
// ─── Client Interface ───────────────────────────────────────────────
|
|
60
83
|
|
|
84
|
+
/** Sortable fields for content entries. */
|
|
85
|
+
export type ContentEntrySortField = "publishedAt" | "createdAt" | "updatedAt" | "title";
|
|
86
|
+
|
|
61
87
|
/** Options for listing content entries. */
|
|
62
88
|
export interface ListEntriesOptions {
|
|
63
89
|
/** Language code for localized content. Defaults to source language. */
|
|
64
90
|
language?: string;
|
|
91
|
+
/** Filter by entry status. */
|
|
92
|
+
status?: ContentEntryStatus;
|
|
93
|
+
/** Field to sort by. Defaults to `"updatedAt"`. */
|
|
94
|
+
sort?: ContentEntrySortField;
|
|
95
|
+
/** Sort direction. Defaults to `"desc"`. */
|
|
96
|
+
order?: "asc" | "desc";
|
|
65
97
|
/** Page number (1-based). */
|
|
66
98
|
page?: number;
|
|
67
|
-
/** Max entries per page. */
|
|
99
|
+
/** Max entries per page (1-100). Defaults to 50. */
|
|
68
100
|
limit?: number;
|
|
69
101
|
}
|
|
70
102
|
|
|
@@ -78,15 +110,15 @@ export interface GetEntryOptions {
|
|
|
78
110
|
export interface ContentClient {
|
|
79
111
|
/** List all content models in the project. */
|
|
80
112
|
getModels(): Promise<ContentModel[]>;
|
|
81
|
-
/** List entries for a content model. */
|
|
113
|
+
/** List entries for a content model with pagination. */
|
|
82
114
|
getEntries(
|
|
83
115
|
modelSlug: string,
|
|
84
116
|
options?: ListEntriesOptions,
|
|
85
|
-
): Promise<ContentEntryListItem
|
|
117
|
+
): Promise<PaginatedResponse<ContentEntryListItem>>;
|
|
86
118
|
/** Fetch a single content entry by slug. */
|
|
87
|
-
getEntry(
|
|
119
|
+
getEntry<CF extends Record<string, string | null> = Record<string, string | null>>(
|
|
88
120
|
modelSlug: string,
|
|
89
121
|
entrySlug: string,
|
|
90
122
|
options?: GetEntryOptions,
|
|
91
|
-
): Promise<ContentEntry
|
|
123
|
+
): Promise<ContentEntry<CF>>;
|
|
92
124
|
}
|