@bettercms-ai/sdk 1.32.0 → 1.34.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.
@@ -0,0 +1,393 @@
1
+ import { BetterCMSErrorCode, Perspective, DeliveryEntry, DeliveryList, DeliveryPage, DeliveryComponent, DeliveredLayout } from '@bettercms-ai/types';
2
+
3
+ /** Semantic error codes used by the SDK. */
4
+ declare const ErrorCodes: {
5
+ readonly CONTENT_NOT_FOUND: "CONTENT_NOT_FOUND";
6
+ readonly UNAUTHORIZED: "UNAUTHORIZED";
7
+ readonly FORBIDDEN: "FORBIDDEN";
8
+ readonly RATE_LIMITED: "RATE_LIMITED";
9
+ readonly INTERNAL_ERROR: "INTERNAL_ERROR";
10
+ readonly VALIDATION_ERROR: "VALIDATION_ERROR";
11
+ readonly NETWORK_ERROR: "NETWORK_ERROR";
12
+ readonly ADMIN_NOT_IMPLEMENTED: "ADMIN_NOT_IMPLEMENTED";
13
+ };
14
+ /**
15
+ * BetterCMSError — normalized SDK error with a semantic code.
16
+ */
17
+ declare class BetterCMSError extends Error {
18
+ readonly status: number;
19
+ readonly code: BetterCMSErrorCode;
20
+ /**
21
+ * Raw machine-readable `code` from the response body (e.g. "PROJECT_DELETED"),
22
+ * when the API supplies one. Distinct from `code` (which is derived from the
23
+ * HTTP status), this lets callers branch on a specific server condition that
24
+ * shares a status with others (e.g. 409 slug-conflict vs 409 project-deleted).
25
+ */
26
+ readonly bodyCode?: string;
27
+ /**
28
+ * Per-field validation messages from a 400 `{ errors: { field: message } }` body.
29
+ * The backend returns validation failures in this shape (not `message`/`error`), so
30
+ * without this the SDK collapsed every validation 400 to a bare "Bad Request" with no
31
+ * hint which field or rule failed (FLO-474). Mirrors the forms client's `fieldErrors`.
32
+ */
33
+ readonly fieldErrors?: Record<string, string>;
34
+ /**
35
+ * The parsed response body, when there was one.
36
+ *
37
+ * Several refusals carry STRUCTURED detail that neither `message` nor `fieldErrors` can hold,
38
+ * and which is the whole point of the refusal: a component publish 409s with the full
39
+ * `readiness` tuple (what evidence is missing, whether a human still has to approve it) and
40
+ * 422s with the `issues` naming the Layouts it would break. Collapsing those to a sentence
41
+ * leaves a caller able to report that something failed and not what to do about it.
42
+ */
43
+ readonly body?: unknown;
44
+ constructor(message: string, status: number, code: BetterCMSErrorCode, bodyCode?: string, fieldErrors?: Record<string, string>, body?: unknown);
45
+ toJSON(): {
46
+ name: string;
47
+ message: string;
48
+ status: number;
49
+ code: BetterCMSErrorCode;
50
+ bodyCode: string | undefined;
51
+ fieldErrors: Record<string, string> | undefined;
52
+ };
53
+ /**
54
+ * Factory — creates a BetterCMSError from a failed fetch Response. Reads the body's
55
+ * `message` (or `error`) for the human message and `code` for a machine-readable
56
+ * condition surfaced as `bodyCode`. Validation failures arrive as `{ errors: {...} }`
57
+ * (no `message`/`error`), so fall back to the first field error for the message and
58
+ * expose the full map as `fieldErrors` (FLO-474).
59
+ */
60
+ static from(res: Response): Promise<BetterCMSError>;
61
+ }
62
+
63
+ /**
64
+ * Public forms — the headless integration surface for sites built on or imported
65
+ * into BetterCMS (the "render it yourself" model, like Sanity's delivery API).
66
+ *
67
+ * Form definitions ship in the build-time content snapshot (`bcms-content.json` →
68
+ * `forms` / `turnstileSiteKey`) and from `GET /api/v1/delivery/:workspace/forms`.
69
+ * A site renders them with its own components (see `@bettercms-ai/next`'s <BcmsForm>)
70
+ * and posts here. No API key — the endpoint is Turnstile-gated (Webflow model).
71
+ *
72
+ * This module is the single source of truth for the delivered form shape + the
73
+ * pure logic (conditional visibility, initial values) the renderers share, so the
74
+ * vanilla embed and the React component never drift.
75
+ */
76
+
77
+ /**
78
+ * Field types the delivery API serializes. Must stay equal to the authoring
79
+ * vocabulary in `packages/db/src/schema/forms.ts` — this list is what every site
80
+ * renderer switches on, so a type missing HERE renders as a plain text input on
81
+ * every published site while the builder happily offers it. `radio`/`checkboxes`
82
+ * were exactly that gap.
83
+ */
84
+ type DeliveryFormFieldType = "text" | "email" | "phone" | "url" | "number" | "date" | "textarea" | "select" | "radio" | "checkboxes" | "checkbox" | "consent" | "hidden" | "step";
85
+ interface DeliveryFormField {
86
+ key: string;
87
+ label: string;
88
+ type: DeliveryFormFieldType;
89
+ placeholder?: string;
90
+ /** Hint rendered under the control. */
91
+ helpText?: string;
92
+ required?: boolean;
93
+ options?: string[];
94
+ defaultValue?: string;
95
+ /** Conditional visibility — render this field only when another field equals `equals`. */
96
+ showIf?: {
97
+ field: string;
98
+ equals: string;
99
+ };
100
+ /**
101
+ * Per-field rules the API enforces on submit. Renderers should mirror them onto the
102
+ * native control (`min`/`max`/`pattern`) so the browser reports them before the round
103
+ * trip — the server check is the one that counts.
104
+ */
105
+ validation?: DeliveryFormFieldValidation;
106
+ /**
107
+ * Presentation options the renderer honours. Unlike `validation`, the API does NOT enforce
108
+ * these on submit — they change what the visitor is shown, not what is accepted.
109
+ */
110
+ ui?: DeliveryFormFieldUi;
111
+ }
112
+ /**
113
+ * @see FormFieldUi in @bettercms-ai/types — same shape, delivered to the site.
114
+ *
115
+ * ── The phone composition contract every renderer implements ──
116
+ *
117
+ * A picker-enabled phone field is rendered as a country control PLUS a `tel` input, but it
118
+ * submits ONE value under the field's own key, in E.164. Whatever a renderer uses to hold the
119
+ * country (a React state, a `{key}__country` select) is scaffolding: it must never reach the
120
+ * POST body.
121
+ *
122
+ * Composition, from the visible input's raw text:
123
+ * - empty → `""`. NEVER `"+CC"`, and the reason is worse than "the server rejects it". The
124
+ * server's loose phone shape is `{3,32}` chars, so the outcome depends on the calling
125
+ * code's length: `"+1"` is REFUSED (an optional untouched field starts 400ing), while
126
+ * `"+91"` is ACCEPTED and stored as a real-looking submission that is only a country code.
127
+ * The silent case is the dangerous one. Pinned in `__tests__/lib/forms-validate.test.ts`.
128
+ * - starts with `"+"` → `"+" + digits(raw)`. The visitor typed an international number; the
129
+ * country control is ignored rather than fighting them.
130
+ * - otherwise → `"+" + callingCode + digits(raw).replace(/^0/, "")`, dropping ONE trunk zero.
131
+ * Known exception: Italian landlines keep their trunk zero, so this loses it. The value is
132
+ * still accepted (shape-only check) — it is a formatting loss, not a refusal.
133
+ *
134
+ * `ui` changes what the visitor SEES. It never changes what the API accepts: enforcing a format
135
+ * is `validation.phoneFormat`'s job and the two are independent.
136
+ *
137
+ * Renderer parity is behavioural, not implementational. FIVE lanes render this field, and they
138
+ * agree on the VALUE, not on the implementation:
139
+ *
140
+ * | Lane | Control | As-you-type formatting |
141
+ * |-------------------------------|---------------------------------|------------------------|
142
+ * | dashboard form preview | searchable picker + formatter | yes |
143
+ * | @bettercms-ai/next <BcmsForm> | native select + formatter | yes (lazy chunk) |
144
+ * | hosted sites (render-page) | native select + tel input | NO |
145
+ * | embed snippet | native select + tel input | NO |
146
+ * | @bettercms-ai/astro BcmsForm | native select + tel input | NO |
147
+ *
148
+ * The missing as-you-type formatting on the three vanilla lanes is a NAMED degradation, not an
149
+ * oversight: it is what keeps libphonenumber off a visitor's page entirely. `packages/next`
150
+ * pays for it only on pages that actually render a picker, via a separate chunk.
151
+ *
152
+ * When a lane builds that option list, never pass a libphonenumber helper as a bare callback:
153
+ * `getCountries().filter(isSupportedCountry)` hands it the array INDEX as its `metadata`
154
+ * argument and throws. Always wrap — `.filter((iso) => isSupportedCountry(iso))`.
155
+ *
156
+ * And when a lane ships the composition rule inside a JS TEMPLATE LITERAL (the embed snippet
157
+ * does), `\D` collapses to `D` and the regex silently becomes `/D/g` — stripping the letter D
158
+ * and leaving every other separator in place. Write `\\D` there. Raw HTML `<script>` text (the
159
+ * hosted renderer, the Astro inline script) takes a single backslash. Nothing throws either
160
+ * way; numbers just arrive malformed, so each lane pins it with an executed-script test.
161
+ */
162
+ interface DeliveryFormFieldUi {
163
+ /** `phone` — render a country picker beside the number. The submitted value is E.164. */
164
+ countryPicker?: boolean;
165
+ /** `phone` — uppercase ISO 3166-1 alpha-2 the picker starts on. */
166
+ defaultCountry?: string;
167
+ /**
168
+ * `phone` — show the country's flag beside the dialling code.
169
+ *
170
+ * Honoured only by a renderer with a custom picker. The native `<select>` the site lanes use
171
+ * cannot hold an SVG, and an emoji flag renders as two grey letters on Windows, so those
172
+ * lanes show the country NAME instead — the same information, legibly, everywhere.
173
+ */
174
+ showFlags?: boolean;
175
+ }
176
+ /** @see FormFieldValidation in @bettercms-ai/types — same shape, delivered to the site. */
177
+ interface DeliveryFormFieldValidation {
178
+ emailPolicy?: "any" | "business";
179
+ min?: number;
180
+ max?: number;
181
+ phoneFormat?: "any" | "e164";
182
+ pattern?: string;
183
+ }
184
+ /** The pick-many type, whose value is an array rather than a scalar. */
185
+ declare function isMultiValueField(type: DeliveryFormFieldType): boolean;
186
+ /** A form as delivered to a published/imported site. Mirrors the delivery `/forms` payload. */
187
+ interface DeliveryForm {
188
+ id: string;
189
+ name: string;
190
+ /** The project the form belongs to. */
191
+ projectId?: string | null;
192
+ /**
193
+ * A `draft` form refuses every submission: render it as unavailable rather than as a form that
194
+ * silently drops what a visitor sends. Delivered for exactly that reason, and missing from this
195
+ * type until the contract test held the two together.
196
+ */
197
+ status?: "draft" | "published" | (string & {});
198
+ /** Set on a form the site's owner told BetterCMS to leave alone (ISO time); such a form is not wired. */
199
+ ignoredAt?: string | null;
200
+ fields: DeliveryFormField[];
201
+ submitLabel?: string;
202
+ successMessage?: string;
203
+ redirectUrl?: string;
204
+ turnstileEnabled?: boolean;
205
+ honeypotField?: string | null;
206
+ }
207
+ /** `string[]` is the `checkboxes` (pick-many) value; every other type is scalar. */
208
+ type FormValue = string | boolean | string[];
209
+ type FormValues = Record<string, FormValue>;
210
+ /** Read a `checkboxes` value defensively — stored data may predate the array shape. */
211
+ declare function asStringArray(value: FormValue | undefined): string[];
212
+ /** Add/remove `option` from a `checkboxes` value, preserving the field's option order. */
213
+ declare function toggleOption(value: FormValue | undefined, option: string, on: boolean): string[];
214
+ /**
215
+ * Conditional visibility. A `showIf` field is shown only when its trigger field's
216
+ * current value equals the target; non-conditional fields are always shown. The
217
+ * renderer must skip hidden fields when collecting/validating so a hidden required
218
+ * field never blocks submit.
219
+ */
220
+ declare function shouldShowField(field: DeliveryFormField, values: FormValues): boolean;
221
+ /**
222
+ * Build the initial value map: checkbox/consent default to false, checkboxes to an
223
+ * empty array, every other field to its `defaultValue`, with `prefill` (e.g. URL
224
+ * query params) taking precedence.
225
+ */
226
+ declare function formInitialValues(form: DeliveryForm, prefill?: Record<string, string>): FormValues;
227
+ interface SubmitFormOptions {
228
+ formId: string;
229
+ data: FormValues;
230
+ /** Cloudflare Turnstile token; required when the form has `turnstileEnabled`. */
231
+ turnstileToken?: string;
232
+ /** API origin. Defaults to https://api.bettercms.ai. */
233
+ baseUrl?: string;
234
+ /** Injectable fetch for tests / non-browser runtimes. */
235
+ fetchImpl?: typeof fetch;
236
+ }
237
+ interface SubmitFormResult {
238
+ id: string;
239
+ }
240
+ /** A submit failure that carries per-field validation messages from the API (HTTP 400). */
241
+ type FormSubmitError = BetterCMSError & {
242
+ fieldErrors?: Record<string, string>;
243
+ };
244
+ /**
245
+ * Submit a public form. Resolves with the new submission id, or throws a
246
+ * BetterCMSError — field-level validation errors are attached as `.fieldErrors`.
247
+ */
248
+ declare function submitForm(opts: SubmitFormOptions): Promise<SubmitFormResult>;
249
+
250
+ /**
251
+ * createClient — framework-agnostic typed read client for the Delivery API.
252
+ *
253
+ * Shared by @bettercms-ai/astro and @bettercms-ai/next so the fetch/typing/retry
254
+ * logic lives in one place. Read-only; for schema authoring use the
255
+ * Management client, for sessions use the Admin client.
256
+ *
257
+ * const client = createClient({ apiUrl, workspace, apiKey });
258
+ * await client.getPage("home");
259
+ * await client.listEntries({ model: "post", perPage: 50 });
260
+ */
261
+
262
+ interface CreateClientOptions {
263
+ /** Backend base, e.g. `https://api.bettercms.ai` (no trailing slash needed). */
264
+ apiUrl: string;
265
+ /** Workspace slug. */
266
+ workspace: string;
267
+ /** Delivery API key. Use a `content:read:draft` key for drafts. */
268
+ apiKey?: string;
269
+ /**
270
+ * The project every read is addressed to. A workspace holds many projects whose slugs
271
+ * collide (each has a `blog` model and a `home` page), so a site names its own; published
272
+ * reads need no key for it. BetterCMS hosting supplies `PUBLIC_BCMS_PROJECT_ID`.
273
+ */
274
+ projectId?: string;
275
+ /** `published` (default) or `drafts`. */
276
+ perspective?: Perspective;
277
+ /**
278
+ * Preview-token JWT for token-based single-document draft reads via the
279
+ * `/preview/:slug` endpoint. Only used when `perspective === "drafts"`.
280
+ */
281
+ previewToken?: string;
282
+ /**
283
+ * Embed invisible visual-editing provenance in draft reads, so a preview build can
284
+ * deep-link each rendered value back to its editor. Requires `perspective: "drafts"`,
285
+ * and then either a `previewToken` or a `content:read:draft` key. Ignored on published
286
+ * reads — the payload must never reach a production build.
287
+ *
288
+ * **Defaults to `true` on `perspective: "drafts"`** (since 1.15.0). Provenance is the whole
289
+ * reason the drafts perspective exists in a preview build, and an option nobody knew to pass
290
+ * meant every such build rendered anonymous strings: the editor could bind them only by
291
+ * guessing at their text. It stays OFF on every other perspective, unconditionally, so a
292
+ * published build cannot acquire a payload by accident — pass `stega: false` to opt a draft
293
+ * read out (a diff or a checksum over draft content is the case that wants it).
294
+ */
295
+ stega?: boolean;
296
+ /** Per-attempt timeout in ms. Defaults to 10000. */
297
+ timeout?: number;
298
+ /**
299
+ * Collapse identical GETs to one network round-trip for the life of this client.
300
+ * Default false.
301
+ *
302
+ * Only correct where the content is a POINT-IN-TIME SNAPSHOT — i.e. a static build,
303
+ * where every route renders from the same published dataset. `@bettercms-ai/astro`
304
+ * turns this on automatically when Astro's `command === "build"` and never for `dev`,
305
+ * SSR or the drafts perspective, where a repeat read must see the current row.
306
+ *
307
+ * A static build without it re-fetches the same nav/settings/collection once PER ROUTE.
308
+ * That is what made a large site exhaust the delivery rate limit: hundreds of identical
309
+ * requests, and `getPage()` re-scanning every page of the paginated list each time it is
310
+ * called.
311
+ */
312
+ cache?: boolean;
313
+ }
314
+ interface ListEntriesOptions {
315
+ /** Filter by content-model slug. */
316
+ model?: string;
317
+ page?: number;
318
+ perPage?: number;
319
+ /** Reference hydration depth (lists cap at 1). */
320
+ depth?: 0 | 1;
321
+ /** Projection — field names to return. */
322
+ select?: string[];
323
+ }
324
+ interface GetEntryOptions {
325
+ depth?: 0 | 1 | 2;
326
+ select?: string[];
327
+ }
328
+ interface ListPagesOptions {
329
+ page?: number;
330
+ perPage?: number;
331
+ }
332
+ interface BetterCMSReadClient {
333
+ readonly perspective: Perspective;
334
+ getEntry<T = Record<string, unknown>>(slug: string, opts?: GetEntryOptions): Promise<DeliveryEntry<T>>;
335
+ listEntries<T = Record<string, unknown>>(opts?: ListEntriesOptions): Promise<DeliveryList<DeliveryEntry<T>>>;
336
+ getPage(slug: string): Promise<DeliveryPage | null>;
337
+ listPages(opts?: ListPagesOptions): Promise<DeliveryList<DeliveryPage>>;
338
+ /**
339
+ * The workspace's reusable component definitions, for resolving `component` blocks.
340
+ * Under `perspective: "drafts"` this is the DRAFT definition — what makes an SSR render
341
+ * of a draft page look like the page the editor is showing.
342
+ */
343
+ listComponents(): Promise<DeliveryComponent[]>;
344
+ /** Fetch the project-scoped Global Layout for the active perspective. */
345
+ getLayout(): Promise<DeliveredLayout | null>;
346
+ listForms(opts?: {
347
+ projectId?: string;
348
+ }): Promise<{
349
+ items: DeliveryForm[];
350
+ turnstileSiteKey: string | null;
351
+ }>;
352
+ /** Build a deterministic Next/cache tag for a resource. */
353
+ tag(resource: "entry" | "page" | "forms", id?: string): string;
354
+ }
355
+ declare function createClient(options: CreateClientOptions): BetterCMSReadClient;
356
+
357
+ /**
358
+ * Public site search — the headless surface for the visitor-facing search box.
359
+ *
360
+ * Queries `GET /api/v1/delivery/search?project=…&q=…`, the same key-less public
361
+ * endpoint the embeddable widget uses. Powers <BcmsSearch> in @bettercms-ai/next
362
+ * and @bettercms-ai/astro, so the component and any custom UI never drift.
363
+ *
364
+ * `snippet` is HTML-safe: the server escapes the field content and keeps only the
365
+ * <mark> highlight wrappers, so it can be rendered with dangerouslySetInnerHTML.
366
+ */
367
+ interface SearchHit {
368
+ title: string;
369
+ slug: string;
370
+ type: "page" | "entry";
371
+ /** HTML-safe snippet with <mark> highlights around matched terms. */
372
+ snippet: string;
373
+ /** Site-relative URL, e.g. "/about". */
374
+ url: string;
375
+ }
376
+ interface SearchOptions {
377
+ /** The project id (from the dashboard's Site Search settings). */
378
+ project: string;
379
+ /** The query string. Calls with fewer than 1 char resolve to []. */
380
+ q: string;
381
+ /** Max hits, 1–20 (default 8). */
382
+ limit?: number;
383
+ /** API origin. Defaults to https://api.bettercms.ai. */
384
+ baseUrl?: string;
385
+ /** Injectable fetch for tests / non-browser runtimes. */
386
+ fetchImpl?: typeof fetch;
387
+ /** AbortSignal so callers can cancel a stale in-flight search. */
388
+ signal?: AbortSignal;
389
+ }
390
+ /** Search a published site. Resolves with hits, or [] on an empty query / network error. */
391
+ declare function search(opts: SearchOptions): Promise<SearchHit[]>;
392
+
393
+ export { BetterCMSError as B, type CreateClientOptions as C, type DeliveryFormField as D, ErrorCodes as E, type FormSubmitError as F, type GetEntryOptions as G, type ListEntriesOptions as L, type SearchHit as S, type BetterCMSReadClient as a, type DeliveryForm as b, type DeliveryFormFieldType as c, type FormValue as d, type FormValues as e, type ListPagesOptions as f, type SearchOptions as g, type SubmitFormOptions as h, type SubmitFormResult as i, asStringArray as j, createClient as k, formInitialValues as l, isMultiValueField as m, shouldShowField as n, submitForm as o, search as s, toggleOption as t };