cmskite 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/LICENSE +21 -0
- package/README.md +462 -0
- package/dist/analytics-B0zzt0nW.d.cts +91 -0
- package/dist/analytics-B0zzt0nW.d.ts +91 -0
- package/dist/browser.cjs +152 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +30 -0
- package/dist/browser.d.ts +30 -0
- package/dist/browser.js +12 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-DT3V5CH2.js +145 -0
- package/dist/chunk-DT3V5CH2.js.map +1 -0
- package/dist/chunk-QXWHDCMJ.js +143 -0
- package/dist/chunk-QXWHDCMJ.js.map +1 -0
- package/dist/index.cjs +348 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +278 -0
- package/dist/index.d.ts +278 -0
- package/dist/index.js +204 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +175 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +45 -0
- package/dist/react.d.ts +45 -0
- package/dist/react.js +32 -0
- package/dist/react.js.map +1 -0
- package/package.json +54 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes CMSKite answers with.
|
|
3
|
+
*
|
|
4
|
+
* These mirror the API's own response schemas rather than its database, and
|
|
5
|
+
* that distinction is the SDK's main promise to a customer: a column added,
|
|
6
|
+
* renamed or moved behind the scenes is not their problem. What is documented
|
|
7
|
+
* here is what will keep working.
|
|
8
|
+
*
|
|
9
|
+
* Every interface is open to new optional fields. A minor release may add one;
|
|
10
|
+
* none will remove one or change the meaning of one.
|
|
11
|
+
*/
|
|
12
|
+
type BodyFormat = 'markdown' | 'html' | 'text';
|
|
13
|
+
type PostStatus = 'draft' | 'published' | 'scheduled';
|
|
14
|
+
interface Author {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
bio: string | null;
|
|
19
|
+
avatarUrl: string | null;
|
|
20
|
+
}
|
|
21
|
+
interface Category {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
/** `engineering/databases` — the full path, so two "Guides" can be told apart. */
|
|
26
|
+
path: string;
|
|
27
|
+
depth: number;
|
|
28
|
+
}
|
|
29
|
+
interface Tag {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
slug: string;
|
|
33
|
+
}
|
|
34
|
+
interface Media {
|
|
35
|
+
id: string;
|
|
36
|
+
url: string;
|
|
37
|
+
alt: string | null;
|
|
38
|
+
width: number | null;
|
|
39
|
+
height: number | null;
|
|
40
|
+
}
|
|
41
|
+
interface Seo {
|
|
42
|
+
title?: string | null;
|
|
43
|
+
description?: string | null;
|
|
44
|
+
canonicalUrl?: string | null;
|
|
45
|
+
ogImageUrl?: string | null;
|
|
46
|
+
noindex?: boolean;
|
|
47
|
+
}
|
|
48
|
+
interface Post {
|
|
49
|
+
id: string;
|
|
50
|
+
title: string;
|
|
51
|
+
slug: string;
|
|
52
|
+
excerpt: string | null;
|
|
53
|
+
/** Empty when `bodyOmitted` is true, which is how a list stays small. */
|
|
54
|
+
body: string;
|
|
55
|
+
/**
|
|
56
|
+
* Whether the body was left out of this response.
|
|
57
|
+
*
|
|
58
|
+
* List endpoints omit it: twenty posts with full bodies is a payload nobody
|
|
59
|
+
* asked for. Fetching one post always includes it.
|
|
60
|
+
*/
|
|
61
|
+
bodyOmitted: boolean;
|
|
62
|
+
bodyFormat: BodyFormat;
|
|
63
|
+
status: PostStatus;
|
|
64
|
+
author: Author | null;
|
|
65
|
+
category: Category | null;
|
|
66
|
+
tags: Tag[];
|
|
67
|
+
featuredMedia: Media | null;
|
|
68
|
+
publishedAt: string | null;
|
|
69
|
+
scheduledAt: string | null;
|
|
70
|
+
seo: Seo;
|
|
71
|
+
/** Addresses this post used to live at. Useful for issuing redirects. */
|
|
72
|
+
previousSlugs: string[];
|
|
73
|
+
/** Set when the post was reached by one of its old slugs. */
|
|
74
|
+
slugRedirectedFrom: string | null;
|
|
75
|
+
wordCount: number;
|
|
76
|
+
readingMinutes: number;
|
|
77
|
+
createdAt: string;
|
|
78
|
+
updatedAt: string;
|
|
79
|
+
}
|
|
80
|
+
interface Pagination {
|
|
81
|
+
hasNext: boolean;
|
|
82
|
+
/** Opaque. Pass it back as `cursor`; never parse it. */
|
|
83
|
+
nextCursor: string | null;
|
|
84
|
+
limit: number;
|
|
85
|
+
/** Only present when the request asked for it. Counting is not free. */
|
|
86
|
+
total?: number;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A page of results.
|
|
90
|
+
*
|
|
91
|
+
* `items` rather than `data`, because `data` is what the wire calls it and the
|
|
92
|
+
* wire is an implementation detail. Iterating a page directly is the common
|
|
93
|
+
* case, so the array is the first thing you reach.
|
|
94
|
+
*/
|
|
95
|
+
interface Page<T> {
|
|
96
|
+
items: T[];
|
|
97
|
+
pagination: Pagination;
|
|
98
|
+
}
|
|
99
|
+
type PostSort = 'publishedAt' | '-publishedAt' | 'updatedAt' | '-updatedAt' | 'createdAt' | '-createdAt' | 'title' | '-title';
|
|
100
|
+
interface ListPostsQuery {
|
|
101
|
+
/** Default 20, maximum 100. */
|
|
102
|
+
limit?: number;
|
|
103
|
+
/** From a previous page's `pagination.nextCursor`. */
|
|
104
|
+
cursor?: string;
|
|
105
|
+
status?: PostStatus;
|
|
106
|
+
/** Category slug or id. */
|
|
107
|
+
category?: string;
|
|
108
|
+
/** Tag slug or id. */
|
|
109
|
+
tag?: string;
|
|
110
|
+
author?: string;
|
|
111
|
+
sort?: PostSort;
|
|
112
|
+
/** Free text across title and body. */
|
|
113
|
+
search?: string;
|
|
114
|
+
publishedAfter?: string;
|
|
115
|
+
publishedBefore?: string;
|
|
116
|
+
/** Ask for `pagination.total`. Costs a count; off by default. */
|
|
117
|
+
withTotal?: boolean;
|
|
118
|
+
}
|
|
119
|
+
interface ListQuery {
|
|
120
|
+
limit?: number;
|
|
121
|
+
cursor?: string;
|
|
122
|
+
}
|
|
123
|
+
interface RequestOptions {
|
|
124
|
+
/** Cancels the request. Every method takes one. */
|
|
125
|
+
signal?: AbortSignal;
|
|
126
|
+
/** Overrides the client's default for this call only. */
|
|
127
|
+
timeoutMs?: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface TransportConfig {
|
|
131
|
+
apiKey: string;
|
|
132
|
+
baseUrl: string;
|
|
133
|
+
timeoutMs: number;
|
|
134
|
+
/** Extra headers on every request. For a proxy or a trace id. */
|
|
135
|
+
headers: Record<string, string>;
|
|
136
|
+
fetch: typeof globalThis.fetch;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface CMSKiteOptions {
|
|
140
|
+
/**
|
|
141
|
+
* A CMSKite API key. Read-only, and scoped to one project.
|
|
142
|
+
*
|
|
143
|
+
* Safe in a browser bundle: the only scope a key can hold is `blog:read`, it
|
|
144
|
+
* cannot write or delete anything, and a project can restrict which origins
|
|
145
|
+
* may use it. See the security section of the README.
|
|
146
|
+
*/
|
|
147
|
+
apiKey: string;
|
|
148
|
+
/** Only for a self-hosted deployment or a local API. */
|
|
149
|
+
baseUrl?: string;
|
|
150
|
+
/** How long to wait for an answer. Default 10 seconds. */
|
|
151
|
+
timeoutMs?: number;
|
|
152
|
+
/** Extra headers on every request, for a proxy or a trace id. */
|
|
153
|
+
headers?: Record<string, string>;
|
|
154
|
+
/** Supply your own, for a test double or a fetch with retries built in. */
|
|
155
|
+
fetch?: typeof globalThis.fetch;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The CMSKite client.
|
|
159
|
+
*
|
|
160
|
+
* Created once and reused. It holds no connection and no mutable state beyond
|
|
161
|
+
* its configuration, so it is safe to build at module scope and share.
|
|
162
|
+
*
|
|
163
|
+
* const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })
|
|
164
|
+
* const { items } = await cms.posts.list({ limit: 10 })
|
|
165
|
+
*
|
|
166
|
+
* Every method takes an optional `{ signal }`, so a request can be cancelled
|
|
167
|
+
* when a component unmounts or a newer search supersedes an older one.
|
|
168
|
+
*/
|
|
169
|
+
declare class CMSKite {
|
|
170
|
+
readonly posts: Posts;
|
|
171
|
+
readonly categories: Categories;
|
|
172
|
+
readonly tags: Tags;
|
|
173
|
+
readonly authors: Authors;
|
|
174
|
+
/** @internal Not part of the public surface; the shape here may change. */
|
|
175
|
+
private readonly config;
|
|
176
|
+
constructor(options: CMSKiteOptions);
|
|
177
|
+
}
|
|
178
|
+
declare class Posts {
|
|
179
|
+
private readonly config;
|
|
180
|
+
constructor(config: TransportConfig);
|
|
181
|
+
/** A page of posts. Published only, newest first, unless you say otherwise. */
|
|
182
|
+
list(query?: ListPostsQuery, options?: RequestOptions): Promise<Page<Post>>;
|
|
183
|
+
/** One post by its id. The body is always included. */
|
|
184
|
+
get(id: string, options?: RequestOptions): Promise<Post>;
|
|
185
|
+
/**
|
|
186
|
+
* One post by its address.
|
|
187
|
+
*
|
|
188
|
+
* This is what a page at `/blog/[slug]` wants. An old slug still resolves,
|
|
189
|
+
* and the answer carries `slugRedirectedFrom` so the page can issue a 301
|
|
190
|
+
* rather than quietly serving two addresses for one post.
|
|
191
|
+
*/
|
|
192
|
+
getBySlug(slug: string, options?: RequestOptions): Promise<Post>;
|
|
193
|
+
/**
|
|
194
|
+
* The same as `getBySlug`, but a missing post is `null` rather than a throw.
|
|
195
|
+
*
|
|
196
|
+
* For a page that renders its own not-found state, which is most of them.
|
|
197
|
+
*/
|
|
198
|
+
findBySlug(slug: string, options?: RequestOptions): Promise<Post | null>;
|
|
199
|
+
/** Posts like this one, chosen by shared tags and category. */
|
|
200
|
+
related(id: string, query?: ListQuery, options?: RequestOptions): Promise<Page<Post>>;
|
|
201
|
+
/** Full-text search across titles and bodies. */
|
|
202
|
+
search(q: string, query?: ListQuery, options?: RequestOptions): Promise<Page<Post>>;
|
|
203
|
+
/**
|
|
204
|
+
* Every post, a page at a time, without writing the cursor loop.
|
|
205
|
+
*
|
|
206
|
+
* An async iterator rather than an array, so a site with four thousand posts
|
|
207
|
+
* does not hold four thousand posts in memory to generate a sitemap.
|
|
208
|
+
*
|
|
209
|
+
* for await (const post of cms.posts.all()) { … }
|
|
210
|
+
*/
|
|
211
|
+
all(query?: Omit<ListPostsQuery, 'cursor'>, options?: RequestOptions): AsyncGenerator<Post, void, undefined>;
|
|
212
|
+
}
|
|
213
|
+
declare class Categories {
|
|
214
|
+
private readonly config;
|
|
215
|
+
constructor(config: TransportConfig);
|
|
216
|
+
list(query?: ListQuery, options?: RequestOptions): Promise<Page<Category>>;
|
|
217
|
+
get(id: string, options?: RequestOptions): Promise<Category>;
|
|
218
|
+
}
|
|
219
|
+
declare class Tags {
|
|
220
|
+
private readonly config;
|
|
221
|
+
constructor(config: TransportConfig);
|
|
222
|
+
list(query?: ListQuery, options?: RequestOptions): Promise<Page<Tag>>;
|
|
223
|
+
get(id: string, options?: RequestOptions): Promise<Tag>;
|
|
224
|
+
}
|
|
225
|
+
declare class Authors {
|
|
226
|
+
private readonly config;
|
|
227
|
+
constructor(config: TransportConfig);
|
|
228
|
+
list(query?: ListQuery, options?: RequestOptions): Promise<Page<Author>>;
|
|
229
|
+
get(id: string, options?: RequestOptions): Promise<Author>;
|
|
230
|
+
}
|
|
231
|
+
/** Creates a client. The function, rather than `new`, is the documented way in. */
|
|
232
|
+
declare function createCMSKite(options: CMSKiteOptions): CMSKite;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* One error type, so a caller writes one catch.
|
|
236
|
+
*
|
|
237
|
+
* Everything that can go wrong on the way to an answer arrives as a
|
|
238
|
+
* `CMSKiteError`: a refusal from the API, a dropped connection, a timeout, a
|
|
239
|
+
* response that was not JSON. Without that, a `catch` has to tell a `TypeError`
|
|
240
|
+
* from a `SyntaxError` from whatever the API said, and the ones that forget
|
|
241
|
+
* show somebody "Failed to fetch" -- a sentence about our code rather than
|
|
242
|
+
* about their connection.
|
|
243
|
+
*/
|
|
244
|
+
declare class CMSKiteError extends Error {
|
|
245
|
+
/** HTTP status, or 0 when no response arrived at all. */
|
|
246
|
+
readonly status: number;
|
|
247
|
+
/** A stable machine-readable reason. Safe to branch on; see the constants. */
|
|
248
|
+
readonly code: string;
|
|
249
|
+
/** Whatever the API attached. Field-level validation problems live here. */
|
|
250
|
+
readonly details: Record<string, unknown>;
|
|
251
|
+
/** Quote this when asking us about a request. */
|
|
252
|
+
readonly requestId: string | null;
|
|
253
|
+
constructor(status: number, code: string, message: string, details?: Record<string, unknown>, requestId?: string | null);
|
|
254
|
+
/** Nothing you send differently will change the answer. */
|
|
255
|
+
get isClientError(): boolean;
|
|
256
|
+
/** Worth trying again. The client already retried once. */
|
|
257
|
+
get isRetryable(): boolean;
|
|
258
|
+
get isNotFound(): boolean;
|
|
259
|
+
/** The key is missing, wrong, or has been revoked. */
|
|
260
|
+
get isAuthError(): boolean;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The codes worth branching on, as values rather than as strings scattered
|
|
264
|
+
* through a codebase.
|
|
265
|
+
*/
|
|
266
|
+
declare const ErrorCode: {
|
|
267
|
+
readonly NETWORK: "NETWORK";
|
|
268
|
+
readonly TIMEOUT: "TIMEOUT";
|
|
269
|
+
readonly BAD_RESPONSE: "BAD_RESPONSE";
|
|
270
|
+
readonly UNAUTHENTICATED: "UNAUTHENTICATED";
|
|
271
|
+
readonly FORBIDDEN: "FORBIDDEN";
|
|
272
|
+
readonly NOT_FOUND: "NOT_FOUND";
|
|
273
|
+
readonly RATE_LIMITED: "RATE_LIMITED";
|
|
274
|
+
readonly INVALID_REQUEST: "INVALID_REQUEST";
|
|
275
|
+
};
|
|
276
|
+
type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
277
|
+
|
|
278
|
+
export { type Author, type BodyFormat, CMSKite, CMSKiteError, type CMSKiteOptions, type Category, ErrorCode, type ErrorCodeValue, type ListPostsQuery, type ListQuery, type Media, type Page, type Pagination, type Post, type PostSort, type PostStatus, type RequestOptions, type Seo, type Tag, createCMSKite };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { CMSKiteError, ErrorCode, DEFAULT_BASE_URL, request } from './chunk-DT3V5CH2.js';
|
|
2
|
+
export { CMSKiteError, ErrorCode } from './chunk-DT3V5CH2.js';
|
|
3
|
+
|
|
4
|
+
// src/client.ts
|
|
5
|
+
var CMSKite = class {
|
|
6
|
+
posts;
|
|
7
|
+
categories;
|
|
8
|
+
tags;
|
|
9
|
+
authors;
|
|
10
|
+
/** @internal Not part of the public surface; the shape here may change. */
|
|
11
|
+
config;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
if (!options?.apiKey) {
|
|
14
|
+
throw new CMSKiteError(
|
|
15
|
+
0,
|
|
16
|
+
ErrorCode.INVALID_REQUEST,
|
|
17
|
+
"createCMSKite needs an apiKey. Create one under Project \u2192 API keys."
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
this.config = {
|
|
21
|
+
apiKey: options.apiKey,
|
|
22
|
+
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
|
|
23
|
+
timeoutMs: options.timeoutMs ?? 1e4,
|
|
24
|
+
headers: options.headers ?? {},
|
|
25
|
+
// Bound, because an unbound `fetch` throws "Illegal invocation" in a
|
|
26
|
+
// browser -- a failure that only appears in the environment that matters
|
|
27
|
+
// most and reads as nothing to do with us.
|
|
28
|
+
fetch: (options.fetch ?? globalThis.fetch).bind(globalThis)
|
|
29
|
+
};
|
|
30
|
+
this.posts = new Posts(this.config);
|
|
31
|
+
this.categories = new Categories(this.config);
|
|
32
|
+
this.tags = new Tags(this.config);
|
|
33
|
+
this.authors = new Authors(this.config);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
function page(result) {
|
|
37
|
+
return {
|
|
38
|
+
items: result.data ?? [],
|
|
39
|
+
pagination: result.pagination ?? { hasNext: false, nextCursor: null, limit: 0 }
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
var Posts = class {
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this.config = config;
|
|
45
|
+
}
|
|
46
|
+
config;
|
|
47
|
+
/** A page of posts. Published only, newest first, unless you say otherwise. */
|
|
48
|
+
async list(query = {}, options) {
|
|
49
|
+
const result = await request(this.config, "GET", "v1/blog/posts", {
|
|
50
|
+
query: { ...query, withTotal: query.withTotal ? "true" : void 0 },
|
|
51
|
+
...options ? { options } : {}
|
|
52
|
+
});
|
|
53
|
+
return page(result);
|
|
54
|
+
}
|
|
55
|
+
/** One post by its id. The body is always included. */
|
|
56
|
+
async get(id, options) {
|
|
57
|
+
const result = await request(this.config, "GET", `v1/blog/posts/${encodeURIComponent(id)}`, {
|
|
58
|
+
...options ? { options } : {}
|
|
59
|
+
});
|
|
60
|
+
return result.data;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One post by its address.
|
|
64
|
+
*
|
|
65
|
+
* This is what a page at `/blog/[slug]` wants. An old slug still resolves,
|
|
66
|
+
* and the answer carries `slugRedirectedFrom` so the page can issue a 301
|
|
67
|
+
* rather than quietly serving two addresses for one post.
|
|
68
|
+
*/
|
|
69
|
+
async getBySlug(slug, options) {
|
|
70
|
+
const result = await request(
|
|
71
|
+
this.config,
|
|
72
|
+
"GET",
|
|
73
|
+
`v1/blog/posts/slug/${encodeURIComponent(slug)}`,
|
|
74
|
+
{ ...options ? { options } : {} }
|
|
75
|
+
);
|
|
76
|
+
return result.data;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The same as `getBySlug`, but a missing post is `null` rather than a throw.
|
|
80
|
+
*
|
|
81
|
+
* For a page that renders its own not-found state, which is most of them.
|
|
82
|
+
*/
|
|
83
|
+
async findBySlug(slug, options) {
|
|
84
|
+
try {
|
|
85
|
+
return await this.getBySlug(slug, options);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof CMSKiteError && error.isNotFound) return null;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Posts like this one, chosen by shared tags and category. */
|
|
92
|
+
async related(id, query = {}, options) {
|
|
93
|
+
const result = await request(
|
|
94
|
+
this.config,
|
|
95
|
+
"GET",
|
|
96
|
+
`v1/blog/posts/${encodeURIComponent(id)}/related`,
|
|
97
|
+
{ query, ...options ? { options } : {} }
|
|
98
|
+
);
|
|
99
|
+
return page(result);
|
|
100
|
+
}
|
|
101
|
+
/** Full-text search across titles and bodies. */
|
|
102
|
+
async search(q, query = {}, options) {
|
|
103
|
+
const result = await request(this.config, "GET", "v1/blog/search", {
|
|
104
|
+
query: { q, ...query },
|
|
105
|
+
...options ? { options } : {}
|
|
106
|
+
});
|
|
107
|
+
return page(result);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Every post, a page at a time, without writing the cursor loop.
|
|
111
|
+
*
|
|
112
|
+
* An async iterator rather than an array, so a site with four thousand posts
|
|
113
|
+
* does not hold four thousand posts in memory to generate a sitemap.
|
|
114
|
+
*
|
|
115
|
+
* for await (const post of cms.posts.all()) { … }
|
|
116
|
+
*/
|
|
117
|
+
async *all(query = {}, options) {
|
|
118
|
+
let cursor;
|
|
119
|
+
do {
|
|
120
|
+
const result = await this.list(
|
|
121
|
+
{ ...query, ...cursor ? { cursor } : {} },
|
|
122
|
+
options
|
|
123
|
+
);
|
|
124
|
+
for (const item of result.items) yield item;
|
|
125
|
+
cursor = result.pagination.nextCursor ?? void 0;
|
|
126
|
+
} while (cursor);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
var Categories = class {
|
|
130
|
+
constructor(config) {
|
|
131
|
+
this.config = config;
|
|
132
|
+
}
|
|
133
|
+
config;
|
|
134
|
+
async list(query = {}, options) {
|
|
135
|
+
return page(
|
|
136
|
+
await request(this.config, "GET", "v1/blog/categories", {
|
|
137
|
+
query,
|
|
138
|
+
...options ? { options } : {}
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
async get(id, options) {
|
|
143
|
+
const result = await request(
|
|
144
|
+
this.config,
|
|
145
|
+
"GET",
|
|
146
|
+
`v1/blog/categories/${encodeURIComponent(id)}`,
|
|
147
|
+
{ ...options ? { options } : {} }
|
|
148
|
+
);
|
|
149
|
+
return result.data;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
var Tags = class {
|
|
153
|
+
constructor(config) {
|
|
154
|
+
this.config = config;
|
|
155
|
+
}
|
|
156
|
+
config;
|
|
157
|
+
async list(query = {}, options) {
|
|
158
|
+
return page(
|
|
159
|
+
await request(this.config, "GET", "v1/blog/tags", {
|
|
160
|
+
query,
|
|
161
|
+
...options ? { options } : {}
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
async get(id, options) {
|
|
166
|
+
const result = await request(
|
|
167
|
+
this.config,
|
|
168
|
+
"GET",
|
|
169
|
+
`v1/blog/tags/${encodeURIComponent(id)}`,
|
|
170
|
+
{ ...options ? { options } : {} }
|
|
171
|
+
);
|
|
172
|
+
return result.data;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var Authors = class {
|
|
176
|
+
constructor(config) {
|
|
177
|
+
this.config = config;
|
|
178
|
+
}
|
|
179
|
+
config;
|
|
180
|
+
async list(query = {}, options) {
|
|
181
|
+
return page(
|
|
182
|
+
await request(this.config, "GET", "v1/blog/authors", {
|
|
183
|
+
query,
|
|
184
|
+
...options ? { options } : {}
|
|
185
|
+
})
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
async get(id, options) {
|
|
189
|
+
const result = await request(
|
|
190
|
+
this.config,
|
|
191
|
+
"GET",
|
|
192
|
+
`v1/blog/authors/${encodeURIComponent(id)}`,
|
|
193
|
+
{ ...options ? { options } : {} }
|
|
194
|
+
);
|
|
195
|
+
return result.data;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
function createCMSKite(options) {
|
|
199
|
+
return new CMSKite(options);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export { CMSKite, createCMSKite };
|
|
203
|
+
//# sourceMappingURL=index.js.map
|
|
204
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { CMSKiteError, ErrorCode } from './errors.js'\nimport { DEFAULT_BASE_URL, request, type TransportConfig } from './transport.js'\nimport type {\n Author,\n Category,\n ListPostsQuery,\n ListQuery,\n Page,\n Post,\n RequestOptions,\n Tag,\n} from './types.js'\n\nexport interface CMSKiteOptions {\n /**\n * A CMSKite API key. Read-only, and scoped to one project.\n *\n * Safe in a browser bundle: the only scope a key can hold is `blog:read`, it\n * cannot write or delete anything, and a project can restrict which origins\n * may use it. See the security section of the README.\n */\n apiKey: string\n /** Only for a self-hosted deployment or a local API. */\n baseUrl?: string\n /** How long to wait for an answer. Default 10 seconds. */\n timeoutMs?: number\n /** Extra headers on every request, for a proxy or a trace id. */\n headers?: Record<string, string>\n /** Supply your own, for a test double or a fetch with retries built in. */\n fetch?: typeof globalThis.fetch\n}\n\n/**\n * The CMSKite client.\n *\n * Created once and reused. It holds no connection and no mutable state beyond\n * its configuration, so it is safe to build at module scope and share.\n *\n * const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })\n * const { items } = await cms.posts.list({ limit: 10 })\n *\n * Every method takes an optional `{ signal }`, so a request can be cancelled\n * when a component unmounts or a newer search supersedes an older one.\n */\nexport class CMSKite {\n readonly posts: Posts\n readonly categories: Categories\n readonly tags: Tags\n readonly authors: Authors\n /** @internal Not part of the public surface; the shape here may change. */\n private readonly config: TransportConfig\n\n constructor(options: CMSKiteOptions) {\n if (!options?.apiKey) {\n throw new CMSKiteError(\n 0,\n ErrorCode.INVALID_REQUEST,\n 'createCMSKite needs an apiKey. Create one under Project → API keys.',\n )\n }\n\n this.config = {\n apiKey: options.apiKey,\n baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,\n timeoutMs: options.timeoutMs ?? 10_000,\n headers: options.headers ?? {},\n // Bound, because an unbound `fetch` throws \"Illegal invocation\" in a\n // browser -- a failure that only appears in the environment that matters\n // most and reads as nothing to do with us.\n fetch: (options.fetch ?? globalThis.fetch).bind(globalThis),\n }\n\n this.posts = new Posts(this.config)\n this.categories = new Categories(this.config)\n this.tags = new Tags(this.config)\n this.authors = new Authors(this.config)\n }\n}\n\n/**\n * The shape a list endpoint answers with, normalised.\n *\n * The wire says `{ data, pagination }`. This says `{ items, pagination }`,\n * because a caller writing `posts.items.map(...)` should not have to know that\n * our envelope calls it `data`.\n */\nfunction page<T>(result: { data: T[]; pagination?: Page<T>['pagination'] }): Page<T> {\n return {\n items: result.data ?? [],\n pagination: result.pagination ?? { hasNext: false, nextCursor: null, limit: 0 },\n }\n}\n\nclass Posts {\n constructor(private readonly config: TransportConfig) {}\n\n /** A page of posts. Published only, newest first, unless you say otherwise. */\n async list(query: ListPostsQuery = {}, options?: RequestOptions): Promise<Page<Post>> {\n const result = await request<Post[]>(this.config, 'GET', 'v1/blog/posts', {\n query: { ...query, withTotal: query.withTotal ? 'true' : undefined },\n ...(options ? { options } : {}),\n })\n return page(result)\n }\n\n /** One post by its id. The body is always included. */\n async get(id: string, options?: RequestOptions): Promise<Post> {\n const result = await request<Post>(this.config, 'GET', `v1/blog/posts/${encodeURIComponent(id)}`, {\n ...(options ? { options } : {}),\n })\n return result.data\n }\n\n /**\n * One post by its address.\n *\n * This is what a page at `/blog/[slug]` wants. An old slug still resolves,\n * and the answer carries `slugRedirectedFrom` so the page can issue a 301\n * rather than quietly serving two addresses for one post.\n */\n async getBySlug(slug: string, options?: RequestOptions): Promise<Post> {\n const result = await request<Post>(\n this.config,\n 'GET',\n `v1/blog/posts/slug/${encodeURIComponent(slug)}`,\n { ...(options ? { options } : {}) },\n )\n return result.data\n }\n\n /**\n * The same as `getBySlug`, but a missing post is `null` rather than a throw.\n *\n * For a page that renders its own not-found state, which is most of them.\n */\n async findBySlug(slug: string, options?: RequestOptions): Promise<Post | null> {\n try {\n return await this.getBySlug(slug, options)\n } catch (error) {\n if (error instanceof CMSKiteError && error.isNotFound) return null\n throw error\n }\n }\n\n /** Posts like this one, chosen by shared tags and category. */\n async related(id: string, query: ListQuery = {}, options?: RequestOptions): Promise<Page<Post>> {\n const result = await request<Post[]>(\n this.config,\n 'GET',\n `v1/blog/posts/${encodeURIComponent(id)}/related`,\n { query, ...(options ? { options } : {}) },\n )\n return page(result)\n }\n\n /** Full-text search across titles and bodies. */\n async search(\n q: string,\n query: ListQuery = {},\n options?: RequestOptions,\n ): Promise<Page<Post>> {\n const result = await request<Post[]>(this.config, 'GET', 'v1/blog/search', {\n query: { q, ...query },\n ...(options ? { options } : {}),\n })\n return page(result)\n }\n\n /**\n * Every post, a page at a time, without writing the cursor loop.\n *\n * An async iterator rather than an array, so a site with four thousand posts\n * does not hold four thousand posts in memory to generate a sitemap.\n *\n * for await (const post of cms.posts.all()) { … }\n */\n async *all(\n query: Omit<ListPostsQuery, 'cursor'> = {},\n options?: RequestOptions,\n ): AsyncGenerator<Post, void, undefined> {\n let cursor: string | undefined\n do {\n const result: Page<Post> = await this.list(\n { ...query, ...(cursor ? { cursor } : {}) },\n options,\n )\n for (const item of result.items) yield item\n cursor = result.pagination.nextCursor ?? undefined\n } while (cursor)\n }\n}\n\nclass Categories {\n constructor(private readonly config: TransportConfig) {}\n\n async list(query: ListQuery = {}, options?: RequestOptions): Promise<Page<Category>> {\n return page(\n await request<Category[]>(this.config, 'GET', 'v1/blog/categories', {\n query,\n ...(options ? { options } : {}),\n }),\n )\n }\n\n async get(id: string, options?: RequestOptions): Promise<Category> {\n const result = await request<Category>(\n this.config,\n 'GET',\n `v1/blog/categories/${encodeURIComponent(id)}`,\n { ...(options ? { options } : {}) },\n )\n return result.data\n }\n}\n\nclass Tags {\n constructor(private readonly config: TransportConfig) {}\n\n async list(query: ListQuery = {}, options?: RequestOptions): Promise<Page<Tag>> {\n return page(\n await request<Tag[]>(this.config, 'GET', 'v1/blog/tags', {\n query,\n ...(options ? { options } : {}),\n }),\n )\n }\n\n async get(id: string, options?: RequestOptions): Promise<Tag> {\n const result = await request<Tag>(\n this.config,\n 'GET',\n `v1/blog/tags/${encodeURIComponent(id)}`,\n { ...(options ? { options } : {}) },\n )\n return result.data\n }\n}\n\nclass Authors {\n constructor(private readonly config: TransportConfig) {}\n\n async list(query: ListQuery = {}, options?: RequestOptions): Promise<Page<Author>> {\n return page(\n await request<Author[]>(this.config, 'GET', 'v1/blog/authors', {\n query,\n ...(options ? { options } : {}),\n }),\n )\n }\n\n async get(id: string, options?: RequestOptions): Promise<Author> {\n const result = await request<Author>(\n this.config,\n 'GET',\n `v1/blog/authors/${encodeURIComponent(id)}`,\n { ...(options ? { options } : {}) },\n )\n return result.data\n }\n}\n\n/** Creates a client. The function, rather than `new`, is the documented way in. */\nexport function createCMSKite(options: CMSKiteOptions): CMSKite {\n return new CMSKite(options)\n}\n"]}
|
package/dist/react.cjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var react = require('react');
|
|
4
|
+
|
|
5
|
+
// src/transport.ts
|
|
6
|
+
var DEFAULT_BASE_URL = "https://api.cmskite.com";
|
|
7
|
+
|
|
8
|
+
// src/analytics.ts
|
|
9
|
+
var MAX_BATCH = 50;
|
|
10
|
+
var STORAGE_PREFIX = "cmskite:v:";
|
|
11
|
+
var CMSKiteAnalytics = class {
|
|
12
|
+
endpoint;
|
|
13
|
+
apiKey;
|
|
14
|
+
enabled;
|
|
15
|
+
flushIntervalMs;
|
|
16
|
+
fetchImpl;
|
|
17
|
+
queue = [];
|
|
18
|
+
timer = null;
|
|
19
|
+
listening = false;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.apiKey = options.apiKey;
|
|
22
|
+
this.endpoint = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "")}/v1/blog/events`;
|
|
23
|
+
this.enabled = options.enabled !== false;
|
|
24
|
+
this.flushIntervalMs = options.flushIntervalMs ?? 1e3;
|
|
25
|
+
this.fetchImpl = options.fetch;
|
|
26
|
+
this.listenForUnload();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* One reader seeing one post.
|
|
30
|
+
*
|
|
31
|
+
* Call it when the post is rendered. Calling it again for the same post in
|
|
32
|
+
* the same tab does nothing, which is what makes it safe to put in a React
|
|
33
|
+
* effect that runs on every render.
|
|
34
|
+
*/
|
|
35
|
+
trackView(postId, path) {
|
|
36
|
+
if (!this.enabled || !postId) return;
|
|
37
|
+
if (this.alreadySeen(postId)) return;
|
|
38
|
+
this.remember(postId);
|
|
39
|
+
this.push({ type: "view", postId, ...path ? { path } : { path: currentPath() } });
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A link press.
|
|
43
|
+
*
|
|
44
|
+
* Not deduplicated: pressing the same link twice is two clicks, and the nonce
|
|
45
|
+
* is what tells the server so.
|
|
46
|
+
*/
|
|
47
|
+
trackClick(postId, target, path) {
|
|
48
|
+
if (!this.enabled || !postId) return;
|
|
49
|
+
this.push({
|
|
50
|
+
type: "click",
|
|
51
|
+
postId,
|
|
52
|
+
...target ? { target: target.slice(0, 300) } : {},
|
|
53
|
+
...path ? { path } : { path: currentPath() },
|
|
54
|
+
nonce: nonce()
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Sends whatever is queued now. Called for you on page hide. */
|
|
58
|
+
flush() {
|
|
59
|
+
if (this.queue.length === 0) return;
|
|
60
|
+
const batch = this.queue.splice(0, MAX_BATCH);
|
|
61
|
+
this.clearTimer();
|
|
62
|
+
this.send(batch);
|
|
63
|
+
}
|
|
64
|
+
/** Stops the timer and the listeners. For a test, or a single-page teardown. */
|
|
65
|
+
destroy() {
|
|
66
|
+
this.flush();
|
|
67
|
+
this.clearTimer();
|
|
68
|
+
}
|
|
69
|
+
// --- internals ------------------------------------------------------------
|
|
70
|
+
push(event) {
|
|
71
|
+
this.queue.push(event);
|
|
72
|
+
if (this.queue.length >= MAX_BATCH) return this.flush();
|
|
73
|
+
if (!this.timer) {
|
|
74
|
+
this.timer = setTimeout(() => this.flush(), this.flushIntervalMs);
|
|
75
|
+
this.timer.unref?.();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
send(events) {
|
|
79
|
+
const body = JSON.stringify({ events });
|
|
80
|
+
if (!this.fetchImpl) {
|
|
81
|
+
try {
|
|
82
|
+
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
|
83
|
+
const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`;
|
|
84
|
+
const blob = new Blob([body], { type: "application/json" });
|
|
85
|
+
if (navigator.sendBeacon(url, blob)) return;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
void (this.fetchImpl ?? fetch)(this.endpoint, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` },
|
|
94
|
+
body,
|
|
95
|
+
// Survives the navigation that triggered it, like sendBeacon does.
|
|
96
|
+
keepalive: true
|
|
97
|
+
}).catch(() => {
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* What this tab has already reported.
|
|
104
|
+
*
|
|
105
|
+
* `sessionStorage`, not `localStorage`: a view should be counted again
|
|
106
|
+
* tomorrow, and a session is the unit the server deduplicates on too. A
|
|
107
|
+
* browser that refuses storage -- private mode, blocked site data -- falls
|
|
108
|
+
* back to counting the view, which is the right way to be wrong.
|
|
109
|
+
*/
|
|
110
|
+
alreadySeen(postId) {
|
|
111
|
+
try {
|
|
112
|
+
return sessionStorage.getItem(STORAGE_PREFIX + postId) !== null;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
remember(postId) {
|
|
118
|
+
try {
|
|
119
|
+
sessionStorage.setItem(STORAGE_PREFIX + postId, "1");
|
|
120
|
+
} catch {
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
listenForUnload() {
|
|
124
|
+
if (this.listening || typeof document === "undefined") return;
|
|
125
|
+
this.listening = true;
|
|
126
|
+
document.addEventListener("visibilitychange", () => {
|
|
127
|
+
if (document.visibilityState === "hidden") this.flush();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
clearTimer() {
|
|
131
|
+
if (this.timer) clearTimeout(this.timer);
|
|
132
|
+
this.timer = null;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
function currentPath() {
|
|
136
|
+
try {
|
|
137
|
+
return window.location.pathname;
|
|
138
|
+
} catch {
|
|
139
|
+
return "/";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function nonce() {
|
|
143
|
+
return Math.random().toString(36).slice(2, 10);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/react.ts
|
|
147
|
+
function useTrackView(postId, options) {
|
|
148
|
+
const tracker = useTracker(options);
|
|
149
|
+
const reported = react.useRef(null);
|
|
150
|
+
react.useEffect(() => {
|
|
151
|
+
if (!postId || reported.current === postId) return;
|
|
152
|
+
reported.current = postId;
|
|
153
|
+
tracker.trackView(postId);
|
|
154
|
+
}, [postId, tracker]);
|
|
155
|
+
}
|
|
156
|
+
function useCMSKiteAnalytics(options) {
|
|
157
|
+
return useTracker(options);
|
|
158
|
+
}
|
|
159
|
+
function useTracker(options) {
|
|
160
|
+
const { apiKey, baseUrl, enabled, flushIntervalMs } = options;
|
|
161
|
+
return react.useMemo(
|
|
162
|
+
() => new CMSKiteAnalytics({
|
|
163
|
+
apiKey,
|
|
164
|
+
...baseUrl === void 0 ? {} : { baseUrl },
|
|
165
|
+
...enabled === void 0 ? {} : { enabled },
|
|
166
|
+
...flushIntervalMs === void 0 ? {} : { flushIntervalMs }
|
|
167
|
+
}),
|
|
168
|
+
[apiKey, baseUrl, enabled, flushIntervalMs]
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
exports.useCMSKiteAnalytics = useCMSKiteAnalytics;
|
|
173
|
+
exports.useTrackView = useTrackView;
|
|
174
|
+
//# sourceMappingURL=react.cjs.map
|
|
175
|
+
//# sourceMappingURL=react.cjs.map
|