@createcms/core 0.1.1

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.
Files changed (83) hide show
  1. package/README.md +169 -0
  2. package/dist/ab-edge/index.cjs +214 -0
  3. package/dist/ab-edge/index.d.cts +121 -0
  4. package/dist/ab-edge/index.d.ts +121 -0
  5. package/dist/ab-edge/index.js +205 -0
  6. package/dist/bin/createcms.js +3082 -0
  7. package/dist/db.cjs +496 -0
  8. package/dist/db.d.cts +128 -0
  9. package/dist/db.d.ts +128 -0
  10. package/dist/db.js +488 -0
  11. package/dist/index.cjs +13789 -0
  12. package/dist/index.d.cts +10277 -0
  13. package/dist/index.d.ts +10277 -0
  14. package/dist/index.js +13737 -0
  15. package/dist/nanoid.cjs +50 -0
  16. package/dist/nanoid.d.cts +29 -0
  17. package/dist/nanoid.d.ts +29 -0
  18. package/dist/nanoid.js +47 -0
  19. package/dist/next/index.cjs +60 -0
  20. package/dist/next/index.d.cts +141 -0
  21. package/dist/next/index.d.ts +141 -0
  22. package/dist/next/index.js +58 -0
  23. package/dist/next/middleware.cjs +113 -0
  24. package/dist/next/middleware.d.cts +77 -0
  25. package/dist/next/middleware.d.ts +77 -0
  26. package/dist/next/middleware.js +111 -0
  27. package/dist/plugins/ab-test/analytics/upstash.cjs +345 -0
  28. package/dist/plugins/ab-test/analytics/upstash.d.cts +193 -0
  29. package/dist/plugins/ab-test/analytics/upstash.d.ts +193 -0
  30. package/dist/plugins/ab-test/analytics/upstash.js +343 -0
  31. package/dist/plugins/ab-test/client.cjs +686 -0
  32. package/dist/plugins/ab-test/client.d.cts +233 -0
  33. package/dist/plugins/ab-test/client.d.ts +233 -0
  34. package/dist/plugins/ab-test/client.js +684 -0
  35. package/dist/plugins/ab-test/index.cjs +3400 -0
  36. package/dist/plugins/ab-test/index.d.cts +1131 -0
  37. package/dist/plugins/ab-test/index.d.ts +1131 -0
  38. package/dist/plugins/ab-test/index.js +3367 -0
  39. package/dist/plugins/client.cjs +20 -0
  40. package/dist/plugins/client.d.cts +3 -0
  41. package/dist/plugins/client.d.ts +3 -0
  42. package/dist/plugins/client.js +3 -0
  43. package/dist/plugins/consent/client.cjs +315 -0
  44. package/dist/plugins/consent/client.d.cts +145 -0
  45. package/dist/plugins/consent/client.d.ts +145 -0
  46. package/dist/plugins/consent/client.js +313 -0
  47. package/dist/plugins/consent/index.cjs +267 -0
  48. package/dist/plugins/consent/index.d.cts +618 -0
  49. package/dist/plugins/consent/index.d.ts +618 -0
  50. package/dist/plugins/consent/index.js +258 -0
  51. package/dist/plugins/i18n/index.cjs +2177 -0
  52. package/dist/plugins/i18n/index.d.cts +562 -0
  53. package/dist/plugins/i18n/index.d.ts +562 -0
  54. package/dist/plugins/i18n/index.js +2150 -0
  55. package/dist/plugins/media-optimize/index.cjs +315 -0
  56. package/dist/plugins/media-optimize/index.d.cts +144 -0
  57. package/dist/plugins/media-optimize/index.d.ts +144 -0
  58. package/dist/plugins/media-optimize/index.js +311 -0
  59. package/dist/plugins/multi-tenant/index.cjs +210 -0
  60. package/dist/plugins/multi-tenant/index.d.cts +431 -0
  61. package/dist/plugins/multi-tenant/index.d.ts +431 -0
  62. package/dist/plugins/multi-tenant/index.js +207 -0
  63. package/dist/plugins/server.cjs +24 -0
  64. package/dist/plugins/server.d.cts +3 -0
  65. package/dist/plugins/server.d.ts +3 -0
  66. package/dist/plugins/server.js +3 -0
  67. package/dist/react/blocks.cjs +233 -0
  68. package/dist/react/blocks.d.cts +320 -0
  69. package/dist/react/blocks.d.ts +320 -0
  70. package/dist/react/blocks.js +226 -0
  71. package/dist/react/index.cjs +901 -0
  72. package/dist/react/index.d.cts +992 -0
  73. package/dist/react/index.d.ts +992 -0
  74. package/dist/react/index.js +872 -0
  75. package/dist/react/tracking.cjs +243 -0
  76. package/dist/react/tracking.d.cts +364 -0
  77. package/dist/react/tracking.d.ts +364 -0
  78. package/dist/react/tracking.js +216 -0
  79. package/dist/react/variant.cjs +59 -0
  80. package/dist/react/variant.d.cts +26 -0
  81. package/dist/react/variant.d.ts +26 -0
  82. package/dist/react/variant.js +57 -0
  83. package/package.json +303 -0
@@ -0,0 +1,872 @@
1
+ import { createClient } from 'better-call/client';
2
+ import { atom, onMount } from 'nanostores';
3
+ import 'better-call';
4
+ import { useRef, useCallback, useSyncExternalStore } from 'react';
5
+ export { BlocksRenderer, createBlocksMap, createBlocksRenderer, createContentRenderer, extractBlockEvents } from './blocks.js';
6
+ export { pickVariant } from './variant.js';
7
+
8
+ /**
9
+ * Wire contract for the `withUser` / `withRoot` query flags, shared by the HTTP
10
+ * client (encode side, `client/proxy.ts`) and the server endpoint wrapper
11
+ * (decode side, `core/endpoint.ts`) so the two halves can never drift.
12
+ *
13
+ * Two transports carry these flags and both must round-trip:
14
+ * - **HTTP** — the proxy encodes values as strings (objects → JSON, booleans →
15
+ * `String()`); the endpoint decodes the strings back.
16
+ * - **In-process** (`cms.api.*`) — callers pass raw values (objects / real
17
+ * booleans) straight through, bypassing the proxy, so decode must accept the
18
+ * untransformed forms as well.
19
+ *
20
+ * This module is intentionally dependency-free (no drizzle, no better-call) so
21
+ * importing it from the client bundle stays cheap.
22
+ */ const WITH_USER_KEY = 'withUser';
23
+ const WITH_ROOT_KEY = 'withRoot';
24
+ /**
25
+ * Encode the flag query for HTTP transport. Pure and copy-on-write: never
26
+ * mutates its input, and returns the *same* reference when nothing needed
27
+ * encoding (so an absent query stays absent, preserving GET-without-query).
28
+ */ function encodeFlagQuery(query) {
29
+ if (!query) return query;
30
+ let out = query;
31
+ const rawWithUser = query[WITH_USER_KEY];
32
+ if (rawWithUser && typeof rawWithUser === 'object') {
33
+ out = {
34
+ ...out,
35
+ [WITH_USER_KEY]: JSON.stringify(rawWithUser)
36
+ };
37
+ }
38
+ const rawWithRoot = query[WITH_ROOT_KEY];
39
+ if (rawWithRoot !== undefined) {
40
+ out = {
41
+ ...out,
42
+ [WITH_ROOT_KEY]: String(rawWithRoot)
43
+ };
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * Creates a Proxy-based client that maps property access to API calls.
50
+ *
51
+ * - `client.pages.listRoots()` -> `$fetch("/pages/listRoots", ...)`
52
+ * - Direct properties on `routes` (plugin actions, atom hooks, $fetch, $store)
53
+ * are returned as-is.
54
+ * - On successful API calls, matching `atomListeners` are triggered to
55
+ * invalidate dependent query atoms.
56
+ */ function createDynamicPathProxy(routes, $fetch, pathMethods, atoms, atomListeners) {
57
+ return new Proxy(routes, {
58
+ get (target, prop) {
59
+ if (prop in target) {
60
+ const value = target[prop];
61
+ if (typeof value === 'object' && value !== null) {
62
+ return createNamespaceProxy(prop, value, $fetch, pathMethods, atoms, atomListeners);
63
+ }
64
+ return value;
65
+ }
66
+ return createNamespaceProxy(prop, {}, $fetch, pathMethods, atoms, atomListeners);
67
+ }
68
+ });
69
+ }
70
+ function createNamespaceProxy(namespace, routes, $fetch, pathMethods, atoms, atomListeners) {
71
+ return new Proxy(routes, {
72
+ get (target, method) {
73
+ if (method in target) return target[method];
74
+ return (opts)=>{
75
+ const routePath = `/${namespace}/${method}`;
76
+ const httpMethod = pathMethods[routePath] ?? (opts?.body !== undefined ? 'POST' : 'GET');
77
+ const query = encodeFlagQuery(opts?.query);
78
+ return $fetch(routePath, {
79
+ method: httpMethod,
80
+ ...opts,
81
+ ...query ? {
82
+ query
83
+ } : {}
84
+ }).then((data)=>{
85
+ triggerListeners(routePath, atoms, atomListeners);
86
+ return data;
87
+ });
88
+ };
89
+ }
90
+ });
91
+ }
92
+ function triggerListeners(routePath, atoms, atomListeners) {
93
+ const matches = atomListeners.filter((l)=>l.matcher(routePath));
94
+ if (!matches.length) return;
95
+ const visited = new Set();
96
+ for (const match of matches){
97
+ const signal = atoms[match.signal];
98
+ if (!signal || visited.has(match.signal)) continue;
99
+ visited.add(match.signal);
100
+ // Defer to a microtask so the toggle runs after the current call settles,
101
+ // and read the CURRENT value at set-time (not a value captured earlier) so
102
+ // rapid successive mutations can't cancel each other out and drop an
103
+ // invalidation.
104
+ setTimeout(()=>{
105
+ const current = signal.get();
106
+ signal.set(typeof current === 'boolean' ? !current : current);
107
+ }, 0);
108
+ match.callback?.(routePath);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Shared client assembly for both the vanilla and React entrypoints. The only
114
+ * thing that differs between them is how `media.useUploadAssets` is exposed:
115
+ * - vanilla passes the raw `uploadAssets` nanostores atom (consumers call
116
+ * `.get()` / `.subscribe()` themselves);
117
+ * - React passes a `() => useStore(uploadAssets)` hook thunk.
118
+ *
119
+ * That value is constructed by the caller and handed in as `unknown` so this
120
+ * module never imports React — keeping it out of the vanilla bundle. The
121
+ * `as CMSClientInstance` cast (present in both original builders) is the single
122
+ * intentional escape hatch that normalizes the loosely-typed routes object to
123
+ * the inferred public client type (`WithMedia` mandates
124
+ * `useUploadAssets: () => MediaUploadState`).
125
+ */ function buildClient(config, useUploadAssets) {
126
+ const { $fetch, $store, pluginsActions, pluginsAtoms, pluginPathMethods, atomListeners, $ERROR_CODES } = config;
127
+ return createDynamicPathProxy({
128
+ media: {
129
+ useUploadAssets
130
+ },
131
+ ...pluginsActions,
132
+ $fetch,
133
+ $store,
134
+ $ERROR_CODES
135
+ }, $fetch, pluginPathMethods, pluginsAtoms, atomListeners);
136
+ }
137
+
138
+ const CMS_ERRORS = {
139
+ BRANCH_NOT_FOUND: {
140
+ status: 404,
141
+ message: 'Branch not found'
142
+ },
143
+ BLOCK_NOT_FOUND: {
144
+ status: 404,
145
+ message: 'Block not found in snapshot'
146
+ },
147
+ PARENT_NOT_FOUND: {
148
+ status: 404,
149
+ message: 'Parent block not found'
150
+ },
151
+ ROOT_NOT_FOUND: {
152
+ status: 404,
153
+ message: 'Root block not found in snapshot'
154
+ },
155
+ ROOT_HAS_CHILDREN: {
156
+ status: 400,
157
+ message: 'Cannot delete a page that has child pages; archive or move the children first'
158
+ },
159
+ ROOT_IN_USE: {
160
+ status: 409,
161
+ message: 'Cannot delete: this root is embedded as a reusable block on live pages; remove those references first'
162
+ },
163
+ COMMIT_NOT_FOUND: {
164
+ status: 404,
165
+ message: 'Commit not found'
166
+ },
167
+ FOLDER_NOT_FOUND: {
168
+ status: 404,
169
+ message: 'Folder not found'
170
+ },
171
+ FOLDER_HAS_CONTENT: {
172
+ status: 400,
173
+ message: 'Cannot delete folder that contains assets or subfolders'
174
+ },
175
+ EMPTY_SNAPSHOT: {
176
+ status: 400,
177
+ message: 'Empty snapshot — no versions found'
178
+ },
179
+ BLOCK_ALREADY_DELETED: {
180
+ status: 400,
181
+ message: 'Block is already deleted'
182
+ },
183
+ TYPE_MISMATCH: {
184
+ status: 400,
185
+ message: 'Block type does not match the expected type'
186
+ },
187
+ USER_ID_REQUIRED: {
188
+ status: 400,
189
+ message: 'userId is required for this route when neither the request nor middleware provides one'
190
+ },
191
+ CANNOT_MOVE_ROOT: {
192
+ status: 400,
193
+ message: 'Cannot move the root block'
194
+ },
195
+ CANNOT_MOVE_INTO_SELF: {
196
+ status: 400,
197
+ message: 'Cannot move an item into itself'
198
+ },
199
+ CANNOT_MOVE_INTO_DESCENDANT: {
200
+ status: 400,
201
+ message: 'Cannot move an item into its own descendant'
202
+ },
203
+ MISSING_TARGET_PROPERTIES: {
204
+ status: 400,
205
+ message: 'targetProperties is required when duplicating a root'
206
+ },
207
+ BRANCH_NAME_ALREADY_EXISTS: {
208
+ status: 400,
209
+ message: 'A branch with this name already exists for this root'
210
+ },
211
+ CANNOT_RENAME_MAIN_BRANCH: {
212
+ status: 400,
213
+ message: 'The main branch cannot be renamed'
214
+ },
215
+ CANNOT_DELETE_MAIN_BRANCH: {
216
+ status: 400,
217
+ message: 'The main branch cannot be deleted'
218
+ },
219
+ BRANCH_HAS_PUBLICATIONS: {
220
+ status: 400,
221
+ message: 'Cannot delete a branch that has active publications'
222
+ },
223
+ BRANCH_HAS_OPEN_MERGE_REQUESTS: {
224
+ status: 400,
225
+ message: 'Cannot delete a branch that is part of open merge requests'
226
+ },
227
+ NO_COMMON_ANCESTOR: {
228
+ status: 400,
229
+ message: 'The two branches share no common ancestor'
230
+ },
231
+ MERGE_REQUEST_NOT_FOUND: {
232
+ status: 404,
233
+ message: 'Merge request not found'
234
+ },
235
+ MERGE_REQUEST_NOT_OPEN: {
236
+ status: 400,
237
+ message: 'Merge request is not open'
238
+ },
239
+ MERGE_REQUEST_NOT_CLOSED: {
240
+ status: 400,
241
+ message: 'Merge request is not closed'
242
+ },
243
+ MERGE_REQUEST_ALREADY_MERGED: {
244
+ status: 400,
245
+ message: 'Merge request has already been merged and cannot be reopened'
246
+ },
247
+ MERGE_REQUEST_ALREADY_EXISTS: {
248
+ status: 400,
249
+ message: 'An open merge request already exists for this source and target branch'
250
+ },
251
+ MERGE_REQUEST_OUTDATED: {
252
+ status: 400,
253
+ message: 'Merge request is outdated because the source branch changed after it was opened'
254
+ },
255
+ UNRESOLVED_CONFLICTS: {
256
+ status: 400,
257
+ message: 'Cannot merge: there are unresolved conflicts'
258
+ },
259
+ CONFLICT_NOT_FOUND: {
260
+ status: 404,
261
+ message: 'Merge conflict not found'
262
+ },
263
+ RESOLVED_VERSION_NOT_FOUND: {
264
+ status: 404,
265
+ message: 'The provided resolvedVersionId does not reference an existing block version'
266
+ },
267
+ APPROVAL_NOT_FOUND: {
268
+ status: 404,
269
+ message: 'Approval not found'
270
+ },
271
+ APPROVAL_ALREADY_REQUESTED: {
272
+ status: 400,
273
+ message: 'An approval has already been requested from this reviewer'
274
+ },
275
+ APPROVAL_NOT_PENDING: {
276
+ status: 400,
277
+ message: 'Approval is not pending'
278
+ },
279
+ APPROVAL_REVIEWER_MISMATCH: {
280
+ status: 403,
281
+ message: 'Only the requested reviewer can approve or reject this request'
282
+ },
283
+ APPROVAL_STALE: {
284
+ status: 400,
285
+ message: 'Approval is stale: the branch has advanced past the approved commit'
286
+ },
287
+ MERGE_APPROVAL_REQUIRED: {
288
+ status: 400,
289
+ message: 'Cannot merge: approval is required before execution'
290
+ },
291
+ PUBLICATION_APPROVAL_REQUIRED: {
292
+ status: 400,
293
+ message: 'Cannot publish: approval is required before publication'
294
+ },
295
+ APPROVALS_NOT_FULLY_APPROVED: {
296
+ status: 400,
297
+ message: 'Cannot proceed: not all requested approvals are approved'
298
+ },
299
+ BRANCHES_NOT_SAME_ROOT: {
300
+ status: 400,
301
+ message: 'Source and target branches must belong to the same root'
302
+ },
303
+ PUBLICATION_NOT_FOUND: {
304
+ status: 404,
305
+ message: 'Publication not found for this branch'
306
+ },
307
+ PUBLISHED_CONTENT_NOT_FOUND: {
308
+ status: 404,
309
+ message: 'No published content found'
310
+ },
311
+ AMBIGUOUS_SLUG: {
312
+ status: 400,
313
+ message: 'Multiple roots match this slug — use rootId for an unambiguous lookup'
314
+ },
315
+ DATA_RETENTION_NOT_CONFIGURED: {
316
+ status: 400,
317
+ message: 'dataRetention is not configured for this CMS instance'
318
+ },
319
+ MISSING_REQUIRED_S3_PARAMETERS: {
320
+ status: 400,
321
+ message: 'Missing required S3 parameters: hostname, accessKeyId, or secretAccessKey'
322
+ },
323
+ UNKNOWN_S3_PROVIDER: {
324
+ status: 400,
325
+ message: 'Unknown S3 provider specified'
326
+ },
327
+ SLUG_GENERATION_FAILED: {
328
+ status: 500,
329
+ message: 'Failed to generate a unique slug after maximum attempts'
330
+ },
331
+ TOO_MANY_FILES: {
332
+ status: 400,
333
+ message: 'Too many files in upload batch'
334
+ },
335
+ FILE_TOO_LARGE: {
336
+ status: 400,
337
+ message: 'One or more files exceed the maximum allowed size'
338
+ },
339
+ INVALID_FILE_TYPE: {
340
+ status: 400,
341
+ message: 'One or more files have a disallowed MIME type'
342
+ },
343
+ UPLOAD_FAILED: {
344
+ status: 500,
345
+ message: 'Server-side upload to S3 failed'
346
+ },
347
+ SLUG_ALREADY_EXISTS: {
348
+ status: 409,
349
+ message: 'A root with this slug on this collection with this parentRootId already exists'
350
+ },
351
+ SLUG_NOT_ENABLED: {
352
+ status: 400,
353
+ message: 'This collection does not have slugs enabled'
354
+ },
355
+ REDIRECT_NOT_FOUND: {
356
+ status: 404,
357
+ message: 'Redirect not found'
358
+ },
359
+ REDIRECT_INVALID: {
360
+ status: 400,
361
+ message: 'A redirect endpoint must be a page (rootId) or a path, matching its type'
362
+ },
363
+ REDIRECT_SOURCE_EXISTS: {
364
+ status: 409,
365
+ message: 'An active redirect already exists for this source'
366
+ },
367
+ SLUG_EMPTY_NOT_ALLOWED: {
368
+ status: 400,
369
+ message: 'Empty slug is not allowed for this collection (allowRoot is false)'
370
+ },
371
+ NESTING_NOT_ENABLED: {
372
+ status: 400,
373
+ message: 'parentRootId is not allowed — this collection does not have nested pages enabled'
374
+ },
375
+ CIRCULAR_REFERENCE: {
376
+ status: 400,
377
+ message: 'Cannot move a page under itself or one of its descendants'
378
+ },
379
+ PARENT_ROOT_NOT_FOUND: {
380
+ status: 404,
381
+ message: 'Parent root not found in this collection'
382
+ },
383
+ REFERENCE_DEPTH_EXCEEDED: {
384
+ status: 422,
385
+ message: 'Reference nesting is too deep (a reusable block embeds others past the limit)'
386
+ },
387
+ ASSET_NOT_FOUND: {
388
+ status: 404,
389
+ message: 'Asset not found'
390
+ },
391
+ VARIABLE_NOT_FOUND: {
392
+ status: 404,
393
+ message: 'Variable not found'
394
+ },
395
+ VARIABLE_KEY_EXISTS: {
396
+ status: 409,
397
+ message: 'A variable with this key already exists'
398
+ },
399
+ VARIABLE_IN_USE: {
400
+ status: 409,
401
+ message: 'Cannot delete variable: it is still in use'
402
+ },
403
+ TEMPLATE_NOT_FOUND: {
404
+ status: 404,
405
+ message: 'Template not found'
406
+ },
407
+ TEMPLATE_KEY_EXISTS: {
408
+ status: 409,
409
+ message: 'A template for this collection/block/property combination already exists'
410
+ },
411
+ ASSET_ACCESS_DENIED: {
412
+ status: 403,
413
+ message: 'This asset is private and requires authentication'
414
+ },
415
+ COMMENT_THREAD_NOT_FOUND: {
416
+ status: 404,
417
+ message: 'Comment thread not found'
418
+ },
419
+ COMMENT_THREAD_ALREADY_RESOLVED: {
420
+ status: 400,
421
+ message: 'Comment thread is already resolved'
422
+ },
423
+ COMMENT_THREAD_NOT_RESOLVED: {
424
+ status: 400,
425
+ message: 'Comment thread is not resolved'
426
+ },
427
+ COMMENT_MESSAGE_NOT_FOUND: {
428
+ status: 404,
429
+ message: 'Comment message not found'
430
+ },
431
+ COMMENT_MESSAGE_DELETED: {
432
+ status: 400,
433
+ message: 'Comment message has been deleted'
434
+ },
435
+ COMMENT_BODY_REQUIRED: {
436
+ status: 400,
437
+ message: 'Body is required for comment messages'
438
+ },
439
+ COMMENT_AUTHOR_MISMATCH: {
440
+ status: 403,
441
+ message: 'Only the author can edit or delete this message'
442
+ },
443
+ NOTIFICATION_NOT_FOUND: {
444
+ status: 404,
445
+ message: 'Notification not found'
446
+ },
447
+ NOTIFICATION_RECIPIENT_MISMATCH: {
448
+ status: 403,
449
+ message: 'You can only access your own notifications'
450
+ }
451
+ };
452
+
453
+ /**
454
+ * Client-side CMS error thrown by `$fetch` when the server returns an error
455
+ * response. Unlike the server-side `CMSError` (which extends better-call's
456
+ * `APIError`), this is a plain `Error` subclass that works in the browser.
457
+ */ class CMSClientError extends Error {
458
+ constructor(errorBody){
459
+ super(errorBody.message ?? errorBody.statusText ?? 'Unknown CMS error');
460
+ this.name = 'CMSClientError';
461
+ this.status = errorBody.status ?? 500;
462
+ this.statusText = errorBody.statusText ?? '';
463
+ this.code = errorBody.code;
464
+ }
465
+ get cmsCode() {
466
+ if (this.code && this.code in CMS_ERRORS) {
467
+ return this.code;
468
+ }
469
+ return undefined;
470
+ }
471
+ }
472
+
473
+ // ============================================================================
474
+ // Constants
475
+ // ============================================================================
476
+ const FORBIDDEN_XHR_HEADERS = new Set([
477
+ 'content-length',
478
+ 'host',
479
+ 'connection',
480
+ 'user-agent',
481
+ 'referer',
482
+ 'origin'
483
+ ]);
484
+ // ============================================================================
485
+ // XHR Upload
486
+ // ============================================================================
487
+ function uploadWithXHR(url, file, headers, onProgress, signal) {
488
+ return new Promise((resolve, reject)=>{
489
+ if (signal?.aborted) {
490
+ reject(new DOMException('Upload aborted', 'AbortError'));
491
+ return;
492
+ }
493
+ const xhr = new XMLHttpRequest();
494
+ xhr.open('PUT', url, true);
495
+ for (const [key, value] of Object.entries(headers)){
496
+ if (!FORBIDDEN_XHR_HEADERS.has(key.toLowerCase())) {
497
+ xhr.setRequestHeader(key, value);
498
+ }
499
+ }
500
+ if (signal) {
501
+ signal.addEventListener('abort', ()=>{
502
+ xhr.abort();
503
+ reject(new DOMException('Upload aborted', 'AbortError'));
504
+ }, {
505
+ once: true
506
+ });
507
+ }
508
+ xhr.upload.onprogress = (event)=>{
509
+ if (event.lengthComputable) {
510
+ onProgress(event.loaded, event.total);
511
+ }
512
+ };
513
+ xhr.onload = ()=>{
514
+ if (xhr.status >= 200 && xhr.status < 300) {
515
+ resolve();
516
+ } else {
517
+ reject(new Error(`Upload failed with status ${xhr.status}`));
518
+ }
519
+ };
520
+ xhr.onerror = ()=>reject(new Error('Network error during upload'));
521
+ xhr.onabort = ()=>reject(new DOMException('Upload aborted', 'AbortError'));
522
+ xhr.send(file);
523
+ });
524
+ }
525
+ // ============================================================================
526
+ // Helpers
527
+ // ============================================================================
528
+ function computeTotalProgress(files) {
529
+ if (files.length === 0) return 0;
530
+ const sum = files.reduce((acc, f)=>acc + f.progress, 0);
531
+ return Math.round(sum / files.length);
532
+ }
533
+ // ============================================================================
534
+ // Atom Factory
535
+ // ============================================================================
536
+ const INITIAL_STATE = {
537
+ isUploading: false,
538
+ isAborted: false,
539
+ files: [],
540
+ totalProgress: 0,
541
+ error: null,
542
+ upload: ()=>Promise.resolve(),
543
+ abort: ()=>{},
544
+ reset: ()=>{}
545
+ };
546
+ /**
547
+ * Creates a nanostores atom that manages media upload state.
548
+ *
549
+ * Pipeline: sign -> upload primary files.
550
+ *
551
+ * Server-side validation (file size, count, MIME types) is handled by the
552
+ * `createSignedUpload` endpoint.
553
+ */ function createMediaUploadAtom($fetch) {
554
+ const store = atom({
555
+ ...INITIAL_STATE
556
+ });
557
+ let controller = null;
558
+ function updateFileState(index, patch) {
559
+ const current = store.get();
560
+ const files = [
561
+ ...current.files
562
+ ];
563
+ files[index] = {
564
+ ...files[index],
565
+ ...patch
566
+ };
567
+ store.set({
568
+ ...current,
569
+ files,
570
+ totalProgress: computeTotalProgress(files)
571
+ });
572
+ }
573
+ function abort() {
574
+ controller?.abort();
575
+ controller = null;
576
+ const current = store.get();
577
+ store.set({
578
+ ...current,
579
+ isUploading: false,
580
+ isAborted: true
581
+ });
582
+ }
583
+ function reset() {
584
+ controller?.abort();
585
+ controller = null;
586
+ store.set({
587
+ ...INITIAL_STATE,
588
+ upload,
589
+ abort,
590
+ reset
591
+ });
592
+ }
593
+ async function upload(files, options) {
594
+ if (files.length === 0) return;
595
+ controller = new AbortController();
596
+ const { signal } = controller;
597
+ store.set({
598
+ ...store.get(),
599
+ isUploading: true,
600
+ isAborted: false,
601
+ files: files.map((f)=>({
602
+ name: f.name,
603
+ progress: 0,
604
+ status: 'pending'
605
+ })),
606
+ totalProgress: 0,
607
+ error: null
608
+ });
609
+ try {
610
+ if (signal.aborted) return;
611
+ // 1. Sign files
612
+ const signResponse = await $fetch('/media/createSignedUpload', {
613
+ method: 'POST',
614
+ body: {
615
+ files: files.map((f)=>({
616
+ name: f.name,
617
+ size: f.size,
618
+ type: f.type
619
+ })),
620
+ folderId: options?.folderId
621
+ }
622
+ });
623
+ if (signal.aborted) return;
624
+ // 2. Upload files to S3
625
+ const uploadPromises = signResponse.assets.map(async (asset, index)=>{
626
+ updateFileState(index, {
627
+ status: 'uploading'
628
+ });
629
+ try {
630
+ await uploadWithXHR(asset.signedUrl, files[index], asset.headers, (loaded, total)=>{
631
+ updateFileState(index, {
632
+ progress: Math.round(loaded / total * 100)
633
+ });
634
+ }, signal);
635
+ updateFileState(index, {
636
+ progress: 100,
637
+ status: 'done',
638
+ result: {
639
+ id: asset.id,
640
+ slug: asset.slug,
641
+ objectKey: asset.objectKey
642
+ }
643
+ });
644
+ } catch (err) {
645
+ const isAbortErr = err instanceof DOMException && err.name === 'AbortError';
646
+ updateFileState(index, {
647
+ status: 'error',
648
+ error: isAbortErr ? 'Upload aborted.' : err instanceof Error ? err.message : 'Upload failed'
649
+ });
650
+ }
651
+ });
652
+ await Promise.allSettled(uploadPromises);
653
+ // 3. Finalize
654
+ const finalState = store.get();
655
+ const hasErrors = finalState.files.some((f)=>f.status === 'error');
656
+ store.set({
657
+ ...finalState,
658
+ isUploading: false,
659
+ totalProgress: computeTotalProgress(finalState.files),
660
+ error: hasErrors ? 'Some files failed to upload' : null
661
+ });
662
+ } catch (err) {
663
+ store.set({
664
+ ...store.get(),
665
+ isUploading: false,
666
+ error: err instanceof Error ? err.message : err
667
+ });
668
+ }
669
+ }
670
+ store.set({
671
+ ...INITIAL_STATE,
672
+ upload,
673
+ abort,
674
+ reset
675
+ });
676
+ return store;
677
+ }
678
+
679
+ /**
680
+ * Creates a `CMSClientStore` from a map of atoms.
681
+ * Provides `notify` (toggle a signal) and `listen` (subscribe to a signal).
682
+ */ function createStore(atoms) {
683
+ return {
684
+ notify (signal) {
685
+ const atom = atoms[signal];
686
+ if (atom) {
687
+ const current = atom.get();
688
+ atom.set(typeof current === 'boolean' ? !current : current);
689
+ }
690
+ },
691
+ listen (signal, listener) {
692
+ const atom = atoms[signal];
693
+ if (atom) atom.subscribe(listener);
694
+ },
695
+ atoms
696
+ };
697
+ }
698
+
699
+ /**
700
+ * Builds the client config synchronously. Atoms, actions, listeners and
701
+ * error codes are available immediately so React hooks work from the
702
+ * first render. Plugin `init` (async) is NOT called here — use
703
+ * `runPluginInit` afterwards.
704
+ */ function getClientConfigSync(options) {
705
+ const plugins = options.plugins ?? [];
706
+ const betterCallClient = createClient({
707
+ baseURL: options.baseURL
708
+ });
709
+ const $fetch = async (path, opts)=>{
710
+ const res = await betterCallClient(path, opts);
711
+ if (res.error) throw new CMSClientError(res.error);
712
+ return res.data;
713
+ };
714
+ const pluginsAtoms = {
715
+ $mediaSignal: atom(false),
716
+ uploadAssets: createMediaUploadAtom($fetch)
717
+ };
718
+ const pluginPathMethods = {};
719
+ const atomListeners = [
720
+ {
721
+ matcher: (path)=>path.startsWith('/media/'),
722
+ signal: '$mediaSignal'
723
+ }
724
+ ];
725
+ const $store = createStore(pluginsAtoms);
726
+ for (const plugin of plugins){
727
+ if (plugin.pathMethods) {
728
+ Object.assign(pluginPathMethods, plugin.pathMethods);
729
+ }
730
+ if (plugin.atomListeners) {
731
+ atomListeners.push(...plugin.atomListeners);
732
+ }
733
+ }
734
+ let pluginsActions = {};
735
+ for (const plugin of plugins){
736
+ if (plugin.getActions) {
737
+ pluginsActions = {
738
+ ...pluginsActions,
739
+ ...plugin.getActions($fetch, $store, options.baseURL)
740
+ };
741
+ }
742
+ }
743
+ const $ERROR_CODES = {};
744
+ for (const plugin of plugins){
745
+ if (plugin.$ERROR_CODES) {
746
+ Object.assign($ERROR_CODES, plugin.$ERROR_CODES);
747
+ }
748
+ }
749
+ return {
750
+ $fetch,
751
+ $store,
752
+ pluginsActions,
753
+ pluginsAtoms,
754
+ pluginPathMethods,
755
+ atomListeners,
756
+ $ERROR_CODES
757
+ };
758
+ }
759
+ /**
760
+ * Runs async plugin `init` functions sequentially. Call this after
761
+ * `getClientConfigSync` — the config is already usable before init
762
+ * completes, so React hooks work immediately.
763
+ */ async function runPluginInit(options, config) {
764
+ const plugins = options.plugins ?? [];
765
+ for (const plugin of plugins){
766
+ // init runs for its side effects. (Client plugins surface state via
767
+ // getActions/atoms, which already close over their own config — there is
768
+ // no shared client context to populate, so any returned `context` is
769
+ // intentionally not collected.)
770
+ await plugin.init?.(config.$fetch, config.$store);
771
+ }
772
+ }
773
+
774
+ /**
775
+ * React hook that subscribes to a nanostores atom via `useSyncExternalStore`.
776
+ * Re-renders the component when the atom value changes.
777
+ */ function useStore(store) {
778
+ const snapshotRef = useRef(store.get());
779
+ const subscribe = useCallback((onChange)=>{
780
+ const emitChange = (value)=>{
781
+ if (snapshotRef.current === value) return;
782
+ snapshotRef.current = value;
783
+ onChange();
784
+ };
785
+ emitChange(store.get());
786
+ return store.listen(emitChange);
787
+ }, [
788
+ store
789
+ ]);
790
+ const getSnapshot = ()=>snapshotRef.current;
791
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
792
+ }
793
+
794
+ function createCMSClient(options) {
795
+ if (options) {
796
+ const config = getClientConfigSync(options);
797
+ runPluginInit(options, config);
798
+ return buildClient(config, ()=>useStore(config.pluginsAtoms.uploadAssets));
799
+ }
800
+ return (opts)=>{
801
+ const config = getClientConfigSync(opts);
802
+ runPluginInit(opts, config);
803
+ return buildClient(config, ()=>useStore(config.pluginsAtoms.uploadAssets));
804
+ };
805
+ }
806
+
807
+ const isServer = ()=>typeof window === 'undefined';
808
+ /**
809
+ * Creates a reactive query atom that fetches data from a CMS endpoint.
810
+ * Subscribes to one or more signal atoms and refetches when they toggle.
811
+ *
812
+ * Returns a nanostores `WritableAtom` — framework-agnostic, not a React hook.
813
+ */ function createCMSQuery(signals, path, $fetch, options) {
814
+ const value = atom({
815
+ data: null,
816
+ error: null,
817
+ isPending: true,
818
+ isRefetching: false,
819
+ refetch: ()=>fetchData()
820
+ });
821
+ const fetchData = async ()=>{
822
+ const current = value.get();
823
+ value.set({
824
+ ...current,
825
+ isPending: current.data === null,
826
+ isRefetching: current.data !== null,
827
+ error: null
828
+ });
829
+ try {
830
+ const opts = typeof options === 'function' ? options() : options;
831
+ const data = await $fetch(path, {
832
+ method: opts?.method ?? 'GET',
833
+ query: opts?.query
834
+ });
835
+ value.set({
836
+ data,
837
+ error: null,
838
+ isPending: false,
839
+ isRefetching: false,
840
+ refetch: ()=>fetchData()
841
+ });
842
+ } catch (error) {
843
+ value.set({
844
+ ...value.get(),
845
+ error,
846
+ isPending: false,
847
+ isRefetching: false
848
+ });
849
+ }
850
+ };
851
+ const signalList = Array.isArray(signals) ? signals : [
852
+ signals
853
+ ];
854
+ // Activate only while the query atom has subscribers. On mount, do an initial
855
+ // fetch and listen to each signal for changes; on unmount, call the
856
+ // per-listener unsubscribers. We deliberately use the unsubscribe functions
857
+ // returned by `listen` rather than `atom.off()` — `off()` removes EVERY
858
+ // listener on the (often shared) signal atom, which would break other queries
859
+ // subscribed to the same signal.
860
+ onMount(value, ()=>{
861
+ if (!isServer()) void fetchData();
862
+ const unsubscribers = signalList.map((signal)=>signal.listen(()=>{
863
+ if (!isServer()) void fetchData();
864
+ }));
865
+ return ()=>{
866
+ for (const unsubscribe of unsubscribers)unsubscribe();
867
+ };
868
+ });
869
+ return value;
870
+ }
871
+
872
+ export { CMSClientError, createCMSClient, createCMSQuery, useStore };