@syntaxcircus/cmsify-client 0.0.0-bootstrap.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Syntax Circus LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # `@syntaxcircus/cmsify-client`
2
+
3
+ The first-party TypeScript client provides a typed, server/edge delivery facade for Cmsify content, templates, and media. It targets Node 20+ and modern edge runtimes; do not import it into browser bundles or expose its API token to client-side code.
4
+
5
+ For management, authentication, or any endpoint beyond the curated delivery facade, use the exported generated fetch client instead.
6
+
7
+ ## Install and build locally
8
+
9
+ ```powershell
10
+ Set-Location sdk/typescript
11
+ npm ci
12
+ npm run typecheck
13
+ npm test
14
+ npm run build
15
+ npm run test:consumer
16
+ ```
17
+
18
+ `test:consumer` packs the SDK, installs it into an empty temporary consumer, and typechecks that consumer and the checked-in Next.js, Astro, and SvelteKit server examples through only public exports. CI runs it on Node 20 and 22.
19
+
20
+ ## Configure a server-side delivery client
21
+
22
+ ```ts
23
+ import { CmsifyClient } from "@syntaxcircus/cmsify-client";
24
+
25
+ const cms = new CmsifyClient({
26
+ baseUrl: process.env.CMSIFY_API_URL!,
27
+ apiToken: process.env.CMSIFY_API_TOKEN!,
28
+ workspaceId: process.env.CMSIFY_WORKSPACE_ID!, // GUID only; slugs are rejected
29
+ timeoutMs: 5_000,
30
+ });
31
+
32
+ const posts = await cms.content.list({
33
+ status: "Published",
34
+ tags: "featured",
35
+ sortBy: "publishedAt",
36
+ pageSize: 10,
37
+ });
38
+
39
+ const post = await cms.content.bySlug("my-first-post");
40
+ ```
41
+
42
+ List operations return `{ items, totalCount, page, pageSize, totalPages }`, including `content.translations`. Consume every page with `content.listAll`:
43
+
44
+ ```ts
45
+ for await (const post of cms.content.listAll({ status: "Published" })) {
46
+ console.log(post.slug);
47
+ }
48
+ ```
49
+
50
+ Keep `CMSIFY_API_TOKEN` in a server-only secret store. The checked-in Next.js App Router, Astro, and SvelteKit examples use server/private environment mechanisms. API tokens are opaque bearer credentials; applications must not parse or reconstruct them.
51
+
52
+ ## Generated raw client
53
+
54
+ The complete generated OpenAPI surface remains available without weakening the delivery facade:
55
+
56
+ ```ts
57
+ import { createCmsifyFetchClient, type paths } from "@syntaxcircus/cmsify-client";
58
+
59
+ const raw = createCmsifyFetchClient(process.env.CMSIFY_API_URL!);
60
+ const response = await raw.GET("/api/v1/workspaces/{workspaceId}/content", {
61
+ params: { path: { workspaceId: process.env.CMSIFY_WORKSPACE_ID! } },
62
+ });
63
+ ```
64
+
65
+ `generated`, `paths`, and `components` are also exported for generated schema access. Generated files under `src/generated` are not handwritten API surface and must be regenerated only through the OpenAPI workflow.
66
+
67
+ ## Errors, retries, cancellation, and concurrency
68
+
69
+ Failures are `CmsifyApiError` instances carrying RFC 7807 fields (`type`, `title`, `status`, `detail`, `errors`, `extensions`, and `traceId`) plus the server correlation ID. `CmsifyTimeoutError` identifies an expired SDK timeout budget.
70
+
71
+ By default the client retries `429`, transient `5xx`, and transport faults up to three attempts for idempotent methods only. `Retry-After` supports both delta-seconds and HTTP-date values. A non-idempotent request is retried only when `RequestOptions.idempotencyKey` is supplied. Pass `retry: false`, `timeoutMs`, or a caller `signal` in `RequestOptions` to control one request; timeout budgets include retries and a caller abort is never retried.
72
+
73
+ ETags from read responses are tracked and used as `If-Match` on later mutations of the same URL. An explicit `ifMatch` overrides the tracked ETag. Successful empty and `204 No Content` responses return `undefined`.
74
+
75
+ ## OpenAPI generation
76
+
77
+ Generated files are under `src/generated` and should not be edited by hand. After an API contract change:
78
+
79
+ ```powershell
80
+ Set-Location ../..
81
+ dotnet restore Cmsify.slnx --locked-mode
82
+ node scripts/openapi.mjs update
83
+ Set-Location sdk/typescript
84
+ npm run generate:check
85
+ npm run typecheck
86
+ npm test
87
+ npm run build
88
+ npm run test:consumer
89
+ ```
90
+
91
+ `update` is the only command allowed to modify the checked-in OpenAPI snapshot or generated TypeScript files. `generate:check` is non-mutating: it exports the live document and generates into a temporary directory before checking live-to-snapshot and generated-to-tracked drift. Both commands build `Cmsify.Api` with `--no-restore`, so complete the applicable public or approved ignored-feed locked solution restore from the repository root first.
package/dist/index.cjs ADDED
@@ -0,0 +1,333 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ CmsifyApiError: () => CmsifyApiError,
34
+ CmsifyClient: () => CmsifyClient,
35
+ CmsifyTimeoutError: () => CmsifyTimeoutError,
36
+ ETagStore: () => ETagStore,
37
+ TEXT_FORMAT_HINTS: () => TEXT_FORMAT_HINTS,
38
+ createCmsifyFetchClient: () => createCmsifyFetchClient,
39
+ generated: () => schema_exports,
40
+ getFormatHint: () => getFormatHint,
41
+ isProseHint: () => isProseHint,
42
+ listAll: () => listAll,
43
+ toMimeType: () => toMimeType
44
+ });
45
+ module.exports = __toCommonJS(index_exports);
46
+
47
+ // src/etag.ts
48
+ var ETagStore = class {
49
+ values = /* @__PURE__ */ new Map();
50
+ get(key) {
51
+ return this.values.get(key);
52
+ }
53
+ set(key, etag) {
54
+ if (etag) {
55
+ this.values.set(key, etag);
56
+ }
57
+ }
58
+ };
59
+
60
+ // src/errors.ts
61
+ var CmsifyApiError = class extends Error {
62
+ problem;
63
+ status;
64
+ traceId;
65
+ correlationId;
66
+ constructor(problem, correlationId) {
67
+ super(problem.detail ?? problem.title ?? `Cmsify API request failed with ${problem.status ?? "unknown status"}`);
68
+ this.name = "CmsifyApiError";
69
+ this.problem = problem;
70
+ this.status = problem.status ?? 0;
71
+ this.traceId = typeof problem.traceId === "string" ? problem.traceId : void 0;
72
+ this.correlationId = correlationId;
73
+ }
74
+ };
75
+ var CmsifyTimeoutError = class extends Error {
76
+ constructor(timeoutMs) {
77
+ super(`Cmsify API request exceeded its ${timeoutMs}ms timeout budget.`);
78
+ this.name = "CmsifyTimeoutError";
79
+ }
80
+ };
81
+ var isProblemDetails = (value) => typeof value === "object" && value !== null && ("status" in value || "title" in value || "type" in value);
82
+
83
+ // src/pagination.ts
84
+ async function* listAll(loader) {
85
+ for (let page = 1; ; page += 1) {
86
+ const result = await loader(page);
87
+ for (const item of result.items) {
88
+ yield item;
89
+ }
90
+ if (page >= result.totalPages || result.items.length === 0) {
91
+ return;
92
+ }
93
+ }
94
+ }
95
+
96
+ // src/client.ts
97
+ var CmsifyClient = class {
98
+ content = {
99
+ list: (options = {}) => this.request(this.workspacePath("/content", contentQuery(options, true))),
100
+ listAll: (options = {}) => listAll((page) => this.content.list({ ...options, page })),
101
+ get: (id, options = {}) => this.request(this.workspacePath(`/content/${encodeURIComponent(id)}`, detailQuery(options))),
102
+ bySlug: (slug, options = {}) => this.request(this.workspacePath(`/content/by-slug/${encodeURIComponent(slug)}`, detailQuery(options, false))),
103
+ translations: (id, options = {}) => this.request(this.workspacePath(`/content/${encodeURIComponent(id)}/translations`, pageQuery(options)))
104
+ };
105
+ templates = {
106
+ list: (options = {}) => this.request(this.workspacePath("/templates", pageQuery(options))),
107
+ get: (id) => this.request(this.workspacePath(`/templates/${encodeURIComponent(id)}`))
108
+ };
109
+ media = {
110
+ list: (options = {}) => this.request(this.workspacePath("/media", pageQuery(options))),
111
+ get: (id) => this.request(this.workspacePath(`/media/${encodeURIComponent(id)}`)),
112
+ download: (id) => this.request(this.workspacePath(`/media/${encodeURIComponent(id)}/file`), {}, {}, "blob")
113
+ };
114
+ baseUrl;
115
+ baseOrigin;
116
+ workspaceId;
117
+ token;
118
+ fetchImpl;
119
+ retryByDefault;
120
+ timeoutMs;
121
+ now;
122
+ delay;
123
+ etags = new ETagStore();
124
+ constructor(options) {
125
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
126
+ this.baseOrigin = new URL(this.baseUrl).origin;
127
+ this.workspaceId = options.workspaceId;
128
+ this.token = options.apiToken;
129
+ this.fetchImpl = options.fetch ?? fetch;
130
+ this.retryByDefault = options.retry !== false;
131
+ this.timeoutMs = options.timeoutMs;
132
+ this.now = options.now ?? Date.now;
133
+ this.delay = options.delay ?? delay;
134
+ }
135
+ async request(path, init = {}, options = {}, responseType = "json") {
136
+ const url = this.requestUrl(path);
137
+ const headers = new Headers(init.headers);
138
+ headers.set("Accept", responseType === "blob" ? "*/*" : "application/json");
139
+ headers.set("X-Correlation-Id", createCorrelationId());
140
+ if (this.token) headers.set("Authorization", `Bearer ${this.token}`);
141
+ if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
142
+ if (options.idempotencyKey) headers.set("Idempotency-Key", options.idempotencyKey);
143
+ const method = (init.method ?? "GET").toUpperCase();
144
+ const trackedEtag = options.ifMatch ?? this.etags.get(url);
145
+ if (trackedEtag && isMutation(method)) headers.set("If-Match", trackedEtag);
146
+ const retry = (options.retry ?? this.retryByDefault) && (isIdempotent(method) || Boolean(options.idempotencyKey)) && isReplayableBody(init.body);
147
+ const response = await this.fetchWithRetry(url, { ...init, headers }, retry, options.signal, options.timeoutMs ?? this.timeoutMs);
148
+ this.etags.set(url, response.headers.get("ETag"));
149
+ if (!response.ok) {
150
+ const correlationId = response.headers.get("X-Correlation-Id") ?? headers.get("X-Correlation-Id") ?? void 0;
151
+ const body = await this.safeReadJson(response);
152
+ const problem = isProblemDetails(body) ? { ...body, status: response.status } : { status: response.status, title: response.statusText };
153
+ throw new CmsifyApiError(problem, correlationId);
154
+ }
155
+ if (hasEmptySuccessBody(response)) return void 0;
156
+ if (responseType === "blob") return await response.blob();
157
+ return await this.safeReadJson(response);
158
+ }
159
+ workspacePath(path, query) {
160
+ if (!isGuid(this.workspaceId)) throw new Error("workspaceId must be a GUID.");
161
+ const search = new URLSearchParams();
162
+ for (const [key, value] of Object.entries(query ?? {})) if (value !== void 0) search.set(key, String(value));
163
+ const suffix = search.size > 0 ? `?${search}` : "";
164
+ return `/api/v1/workspaces/${encodeURIComponent(this.workspaceId)}${path}${suffix}`;
165
+ }
166
+ requestUrl(path) {
167
+ const url = new URL(path, `${this.baseUrl}/`);
168
+ if (url.origin !== this.baseOrigin) throw new Error("CmsifyClient requests must target the configured Cmsify origin.");
169
+ return url.toString();
170
+ }
171
+ async fetchWithRetry(url, init, retry, callerSignal, timeoutMs) {
172
+ const deadline = timeoutMs === void 0 ? void 0 : this.now() + Math.max(0, timeoutMs);
173
+ for (let attempt = 1; ; attempt += 1) {
174
+ const controller = new AbortController();
175
+ const timeout = timeoutMs === void 0 ? void 0 : Math.max(0, (deadline ?? this.now()) - this.now());
176
+ const cleanup = connectAbortSignals(controller, callerSignal, timeout, timeoutMs);
177
+ try {
178
+ const response = await this.fetchImpl(url, { ...init, signal: controller.signal });
179
+ if (!retry || attempt >= 3 || !isRetryableResponse(response)) return response;
180
+ await this.delay(retryDelay(response.headers.get("Retry-After"), attempt, this.now), controller.signal);
181
+ } catch (error) {
182
+ if (controller.signal.aborted) throw controller.signal.reason ?? error;
183
+ if (!retry || attempt >= 3 || !isTransportFault(error)) throw error;
184
+ await this.delay(100 * 2 ** (attempt - 1), controller.signal);
185
+ } finally {
186
+ cleanup();
187
+ }
188
+ }
189
+ }
190
+ async safeReadJson(response) {
191
+ const text = await response.text();
192
+ if (!text) return void 0;
193
+ try {
194
+ return JSON.parse(text);
195
+ } catch {
196
+ return { status: response.status, title: response.statusText };
197
+ }
198
+ }
199
+ };
200
+ var contentQuery = (options, resolve) => ({
201
+ Q: options.q,
202
+ TemplateVersionId: options.templateVersionId,
203
+ TemplateId: options.templateId,
204
+ Status: options.status,
205
+ LocaleCode: options.localeCode,
206
+ TranslationGroupId: options.translationGroupId,
207
+ Slug: options.slug,
208
+ Tags: options.tags,
209
+ CreatedAfter: asIso(options.createdAfter),
210
+ CreatedBefore: asIso(options.createdBefore),
211
+ PublishedAfter: asIso(options.publishedAfter),
212
+ PublishedBefore: asIso(options.publishedBefore),
213
+ Resolve: resolve,
214
+ AsOf: asIso(options.asOf),
215
+ SortBy: options.sortBy,
216
+ SortDesc: options.sortDesc,
217
+ page: options.page,
218
+ pageSize: options.pageSize
219
+ });
220
+ var detailQuery = (options, resolve = true) => options.asOf === void 0 ? {} : { ...resolve ? { resolve: true } : {}, asOf: asIso(options.asOf) };
221
+ var pageQuery = (options) => ({ page: options.page, pageSize: options.pageSize });
222
+ var asIso = (value) => value instanceof Date ? value.toISOString() : value;
223
+ var isGuid = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
224
+ var isIdempotent = (method) => ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"].includes(method);
225
+ var isMutation = (method) => !["GET", "HEAD", "OPTIONS"].includes(method);
226
+ var isRetryableResponse = (response) => response.status === 429 || response.status >= 500;
227
+ var isTransportFault = (error) => error instanceof TypeError || error instanceof Error && error.name === "NetworkError";
228
+ var isReplayableBody = (body) => !(typeof ReadableStream !== "undefined" && body instanceof ReadableStream);
229
+ var hasEmptySuccessBody = (response) => response.status === 204 || response.headers.get("Content-Length") === "0";
230
+ var retryDelay = (header, attempt, now) => retryAfterDelay(header, now) ?? 100 * 2 ** (attempt - 1);
231
+ var retryAfterDelay = (header, now) => {
232
+ if (header === null) return void 0;
233
+ const seconds = Number(header);
234
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
235
+ const date = Date.parse(header);
236
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - now());
237
+ };
238
+ var connectAbortSignals = (controller, callerSignal, timeout, timeoutBudget) => {
239
+ const abortForCaller = () => controller.abort(callerSignal?.reason);
240
+ if (callerSignal?.aborted) abortForCaller();
241
+ else callerSignal?.addEventListener("abort", abortForCaller, { once: true });
242
+ const timer = timeout === void 0 ? void 0 : setTimeout(() => controller.abort(new CmsifyTimeoutError(timeoutBudget ?? timeout)), timeout);
243
+ return () => {
244
+ callerSignal?.removeEventListener("abort", abortForCaller);
245
+ if (timer !== void 0) clearTimeout(timer);
246
+ };
247
+ };
248
+ var delay = (milliseconds, signal) => new Promise((resolve, reject) => {
249
+ if (signal.aborted) {
250
+ reject(signal.reason);
251
+ return;
252
+ }
253
+ const timer = setTimeout(resolve, milliseconds);
254
+ signal.addEventListener("abort", () => {
255
+ clearTimeout(timer);
256
+ reject(signal.reason);
257
+ }, { once: true });
258
+ });
259
+ var createCorrelationId = () => globalThis.crypto?.randomUUID?.() ?? `cmsify-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
260
+
261
+ // src/formatting.ts
262
+ var TEXT_FORMAT_HINTS = [
263
+ "plaintext",
264
+ "html",
265
+ "markdown",
266
+ "json",
267
+ "xml",
268
+ "yaml",
269
+ "csv",
270
+ "toml",
271
+ "sql",
272
+ "code",
273
+ "url",
274
+ "email",
275
+ "regex"
276
+ ];
277
+ var MIME_BY_HINT = {
278
+ plaintext: "text/plain",
279
+ html: "text/html",
280
+ markdown: "text/markdown",
281
+ json: "application/json",
282
+ xml: "application/xml",
283
+ yaml: "application/yaml",
284
+ csv: "text/csv",
285
+ toml: "application/toml",
286
+ sql: "application/sql",
287
+ code: "text/plain",
288
+ url: "text/uri-list",
289
+ email: "text/plain",
290
+ regex: "text/plain"
291
+ };
292
+ function toMimeType(hint) {
293
+ if (hint && hint in MIME_BY_HINT) {
294
+ return MIME_BY_HINT[hint];
295
+ }
296
+ return "text/plain";
297
+ }
298
+ function getFormatHint(fieldConfig) {
299
+ if (!fieldConfig || typeof fieldConfig !== "object") {
300
+ return "plaintext";
301
+ }
302
+ const raw = fieldConfig.formatHint;
303
+ if (typeof raw !== "string") {
304
+ return "plaintext";
305
+ }
306
+ const normalized = raw.toLowerCase();
307
+ return TEXT_FORMAT_HINTS.includes(normalized) ? normalized : "plaintext";
308
+ }
309
+ function isProseHint(hint) {
310
+ return hint === "plaintext" || hint === "markdown" || hint === "html";
311
+ }
312
+
313
+ // src/generated/schema.ts
314
+ var schema_exports = {};
315
+
316
+ // src/generated/client.ts
317
+ var import_openapi_fetch = __toESM(require("openapi-fetch"), 1);
318
+ var createCmsifyFetchClient = (baseUrl, fetchImpl) => fetchImpl ? (0, import_openapi_fetch.default)({ baseUrl, fetch: fetchImpl }) : (0, import_openapi_fetch.default)({ baseUrl });
319
+ // Annotate the CommonJS export names for ESM import in node:
320
+ 0 && (module.exports = {
321
+ CmsifyApiError,
322
+ CmsifyClient,
323
+ CmsifyTimeoutError,
324
+ ETagStore,
325
+ TEXT_FORMAT_HINTS,
326
+ createCmsifyFetchClient,
327
+ generated,
328
+ getFormatHint,
329
+ isProseHint,
330
+ listAll,
331
+ toMimeType
332
+ });
333
+ //# sourceMappingURL=index.cjs.map