@templatical/core 0.26.3 → 0.27.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.
@@ -1,6 +1,27 @@
1
- import { SdkError, createDefaultTemplateContent } from "@templatical/types";
2
- import { computed, onScopeDispose, reactive, readonly, ref, watch } from "vue";
1
+ import { SdkError } from "@templatical/types";
2
+ import { computed, onScopeDispose, ref, watch } from "vue";
3
3
  //#region src/cloud/auth.ts
4
+ /**
5
+ * Whether a token-refresh failure is worth interrupting the user over.
6
+ *
7
+ * **Fatal** means the endpoint actively rejected the credentials — a `4xx`. No
8
+ * amount of retrying fixes that: every subsequent request will fail the same
9
+ * way, so the session really is over and the editor should say so.
10
+ *
11
+ * **Everything else is transient**: a network blip, a `5xx`, a timeout, a
12
+ * malformed response. Those resolve on their own, and blanking the editor over
13
+ * one interrupts someone mid-edit on a template that is very likely unsaved.
14
+ * `AuthManager` reports every failure to `onError` and re-throws regardless, so
15
+ * a caller that genuinely cannot proceed still finds out; this only decides
16
+ * whether the *overlay* goes up.
17
+ *
18
+ * `404` is treated as fatal deliberately: a refresh URL that does not exist is a
19
+ * misconfiguration, and retrying a typo forever is worse than saying so.
20
+ */
21
+ function isFatalAuthError(error) {
22
+ const status = error instanceof SdkError ? error.statusCode : void 0;
23
+ return status !== void 0 && status >= 400 && status < 500;
24
+ }
4
25
  var AuthManager = class AuthManager {
5
26
  static DEFAULT_BASE_URL = "https://templatical.com";
6
27
  accessToken = null;
@@ -172,10 +193,10 @@ const API_ROUTES = {
172
193
  "templates.export": `${TEMPLATE}/export`,
173
194
  "templates.importFromBeefree": `${BASE}/templates/import/from-beefree`,
174
195
  "templates.sendTestEmail": `${TEMPLATE}/send-test-email`,
175
- "snapshots.index": `${TEMPLATE}/snapshots`,
176
- "snapshots.store": `${TEMPLATE}/snapshots`,
177
- "snapshots.show": `${TEMPLATE}/snapshots/{snapshot}`,
178
- "snapshots.restore": `${TEMPLATE}/snapshots/{snapshot}/restore`,
196
+ "versions.index": `${TEMPLATE}/versions`,
197
+ "versions.store": `${TEMPLATE}/versions`,
198
+ "versions.show": `${TEMPLATE}/versions/{version}`,
199
+ "versions.restore": `${TEMPLATE}/versions/{version}/restore`,
179
200
  "comments.index": `${TEMPLATE}/comments`,
180
201
  "comments.store": `${TEMPLATE}/comments`,
181
202
  "comments.update": `${TEMPLATE}/comments/{comment}`,
@@ -249,10 +270,13 @@ var ApiClient = class {
249
270
  }
250
271
  return error.message;
251
272
  }
252
- async createTemplate(content) {
273
+ async createTemplate(content, name) {
253
274
  return this.request(buildUrl(API_ROUTES["templates.store"], this.baseParams), {
254
275
  method: "POST",
255
- body: JSON.stringify({ content })
276
+ body: JSON.stringify(name !== void 0 ? {
277
+ content,
278
+ name
279
+ } : { content })
256
280
  });
257
281
  }
258
282
  async getTemplate(id) {
@@ -261,22 +285,31 @@ var ApiClient = class {
261
285
  template: id
262
286
  }));
263
287
  }
264
- async updateTemplate(id, content) {
288
+ /**
289
+ * Apply a partial update. Takes a patch rather than bare content so a rename
290
+ * can travel without content and vice versa — the shape `TemplatePatch`
291
+ * defines, which is what `createCloudTemplatesProvider` forwards verbatim. Only
292
+ * the keys present are sent.
293
+ */
294
+ async updateTemplate(id, patch) {
265
295
  return this.request(buildUrl(API_ROUTES["templates.update"], {
266
296
  ...this.baseParams,
267
297
  template: id
268
298
  }), {
269
299
  method: "PUT",
270
- body: JSON.stringify({ content })
300
+ body: JSON.stringify(patch)
271
301
  });
272
302
  }
273
- async createSnapshot(templateId, content) {
274
- return this.request(buildUrl(API_ROUTES["snapshots.store"], {
303
+ async createVersion(templateId, content, label) {
304
+ return this.request(buildUrl(API_ROUTES["versions.store"], {
275
305
  ...this.baseParams,
276
306
  template: templateId
277
307
  }), {
278
308
  method: "POST",
279
- body: JSON.stringify({ content })
309
+ body: JSON.stringify(label !== void 0 ? {
310
+ content,
311
+ label
312
+ } : { content })
280
313
  });
281
314
  }
282
315
  async deleteTemplate(id) {
@@ -285,17 +318,24 @@ var ApiClient = class {
285
318
  template: id
286
319
  }), { method: "DELETE" });
287
320
  }
288
- async getSnapshots(templateId) {
289
- return this.request(buildUrl(API_ROUTES["snapshots.index"], {
321
+ async getVersions(templateId) {
322
+ return this.request(buildUrl(API_ROUTES["versions.index"], {
290
323
  ...this.baseParams,
291
324
  template: templateId
292
325
  }));
293
326
  }
294
- async restoreSnapshot(templateId, snapshotId) {
295
- return this.request(buildUrl(API_ROUTES["snapshots.restore"], {
327
+ async getVersion(templateId, versionId) {
328
+ return this.request(buildUrl(API_ROUTES["versions.show"], {
296
329
  ...this.baseParams,
297
330
  template: templateId,
298
- snapshot: snapshotId
331
+ version: versionId
332
+ }));
333
+ }
334
+ async restoreVersion(templateId, versionId) {
335
+ return this.request(buildUrl(API_ROUTES["versions.restore"], {
336
+ ...this.baseParams,
337
+ template: templateId,
338
+ version: versionId
299
339
  }), { method: "POST" });
300
340
  }
301
341
  async exportTemplate(templateId, fontsPayload) {
@@ -424,7 +464,7 @@ var WebSocketClient = class {
424
464
  try {
425
465
  ({default: Pusher} = await import("pusher-js"));
426
466
  } catch {
427
- throw new Error("Cloud features require the optional peer dependency 'pusher-js'. Install it with: npm install pusher-js");
467
+ throw new Error("[Templatical] Cloud features require the optional peer dependency 'pusher-js'. Please install it.");
428
468
  }
429
469
  const { host, port, appKey } = this.config;
430
470
  const authEndpoint = this.authManager.resolveUrl(buildUrl(API_ROUTES["broadcasting.auth"], {
@@ -507,251 +547,6 @@ function handleOperation(editor, payload) {
507
547
  }
508
548
  }
509
549
  //#endregion
510
- //#region src/cloud/editor.ts
511
- function getColumnCount(layout) {
512
- if (layout === "1") return 1;
513
- if (layout === "3") return 3;
514
- return 2;
515
- }
516
- function useEditor(options) {
517
- const api = new ApiClient(options.authManager);
518
- const state = reactive({
519
- template: null,
520
- content: createDefaultTemplateContent(options.defaultFontFamily, options.templateDefaults),
521
- selectedBlockId: null,
522
- viewport: "desktop",
523
- darkMode: false,
524
- previewMode: false,
525
- isDirty: false,
526
- isSaving: false,
527
- isLoading: false,
528
- uiTheme: "auto"
529
- });
530
- const content = computed({
531
- get: () => state.content,
532
- set: (value) => {
533
- state.content = value;
534
- state.isDirty = true;
535
- }
536
- });
537
- const selectedBlock = computed(() => {
538
- if (!state.selectedBlockId) return null;
539
- return findBlockById(state.content.blocks, state.selectedBlockId);
540
- });
541
- const savedBlockIds = computed(() => {
542
- const ids = /* @__PURE__ */ new Set();
543
- const blocks = state.template?.content?.blocks;
544
- if (!blocks) return ids;
545
- for (const block of blocks) {
546
- ids.add(block.id);
547
- if (block.type === "section") for (const column of block.children) for (const child of column) ids.add(child.id);
548
- }
549
- return ids;
550
- });
551
- function findBlockById(blocks, id) {
552
- for (const block of blocks) {
553
- if (block.id === id) return block;
554
- if (block.type === "section") for (const column of block.children) {
555
- const found = findBlockById(column, id);
556
- if (found) return found;
557
- }
558
- }
559
- return null;
560
- }
561
- function findBlockParent(blocks, id, parent = { blocks }) {
562
- for (let i = 0; i < blocks.length; i++) {
563
- const block = blocks[i];
564
- if (block.id === id) return parent;
565
- if (block.type === "section") for (let colIdx = 0; colIdx < block.children.length; colIdx++) {
566
- const result = findBlockParent(block.children[colIdx], id, {
567
- blocks: block.children[colIdx],
568
- sectionId: block.id,
569
- columnIndex: colIdx
570
- });
571
- if (result) return result;
572
- }
573
- }
574
- return null;
575
- }
576
- function isBlockLocked(blockId) {
577
- return options.lockedBlocks?.value.has(blockId) ?? false;
578
- }
579
- function setContent(newContent, markDirty = true) {
580
- state.content = newContent;
581
- if (markDirty) state.isDirty = true;
582
- }
583
- function selectBlock(blockId) {
584
- if (blockId && isBlockLocked(blockId)) return;
585
- state.selectedBlockId = blockId;
586
- }
587
- function setViewport(viewport) {
588
- state.viewport = viewport;
589
- }
590
- function setDarkMode(darkMode) {
591
- state.darkMode = darkMode;
592
- }
593
- function setUiTheme(theme) {
594
- state.uiTheme = theme;
595
- }
596
- function setPreviewMode(previewMode) {
597
- state.previewMode = previewMode;
598
- if (previewMode) state.selectedBlockId = null;
599
- }
600
- function updateBlock(blockId, updates) {
601
- if (isBlockLocked(blockId)) return;
602
- const block = findBlockById(state.content.blocks, blockId);
603
- if (block) {
604
- Object.assign(block, updates);
605
- state.isDirty = true;
606
- }
607
- }
608
- function updateSettings(updates) {
609
- state.content.settings = {
610
- ...state.content.settings,
611
- ...updates
612
- };
613
- state.isDirty = true;
614
- }
615
- function addBlock(block, targetSectionId, columnIndex = 0, index) {
616
- if (targetSectionId) {
617
- const section = findBlockById(state.content.blocks, targetSectionId);
618
- if (section && section.type === "section") {
619
- section.children[columnIndex] = section.children[columnIndex] || [];
620
- const targetArray = section.children[columnIndex];
621
- if (index !== void 0 && index < targetArray.length) targetArray.splice(index, 0, block);
622
- else targetArray.push(block);
623
- }
624
- } else if (index !== void 0 && index < state.content.blocks.length) state.content.blocks.splice(index, 0, block);
625
- else state.content.blocks.push(block);
626
- state.isDirty = true;
627
- }
628
- function removeBlock(blockId) {
629
- if (isBlockLocked(blockId)) return;
630
- const parent = findBlockParent(state.content.blocks, blockId);
631
- if (parent) {
632
- const index = parent.blocks.findIndex((b) => b.id === blockId);
633
- if (index !== -1) {
634
- parent.blocks.splice(index, 1);
635
- if (state.selectedBlockId === blockId) state.selectedBlockId = null;
636
- state.isDirty = true;
637
- }
638
- }
639
- }
640
- function moveBlock(blockId, newIndex, targetSectionId, columnIndex = 0) {
641
- const parent = findBlockParent(state.content.blocks, blockId);
642
- if (!parent) return;
643
- const oldIndex = parent.blocks.findIndex((b) => b.id === blockId);
644
- if (oldIndex === -1) return;
645
- let targetArray;
646
- if (targetSectionId) {
647
- const section = findBlockById(state.content.blocks, targetSectionId);
648
- if (!section || section.type !== "section") return;
649
- if (columnIndex < 0 || columnIndex >= getColumnCount(section.columns)) return;
650
- section.children[columnIndex] = section.children[columnIndex] || [];
651
- targetArray = section.children[columnIndex];
652
- } else targetArray = state.content.blocks;
653
- const [block] = parent.blocks.splice(oldIndex, 1);
654
- targetArray.splice(newIndex, 0, block);
655
- state.isDirty = true;
656
- }
657
- async function create(content) {
658
- state.isLoading = true;
659
- try {
660
- if (content) state.content = content;
661
- const template = await api.createTemplate(state.content);
662
- state.template = template;
663
- state.isDirty = false;
664
- return template;
665
- } catch (error) {
666
- options.onError?.(error);
667
- throw error;
668
- } finally {
669
- state.isLoading = false;
670
- }
671
- }
672
- async function load(templateId) {
673
- state.isLoading = true;
674
- try {
675
- const template = await api.getTemplate(templateId);
676
- state.template = template;
677
- state.content = template.content;
678
- state.isDirty = false;
679
- return template;
680
- } catch (error) {
681
- options.onError?.(error);
682
- throw error;
683
- } finally {
684
- state.isLoading = false;
685
- }
686
- }
687
- async function save() {
688
- if (!state.template?.id) throw new SdkError("No template loaded. Call create() or load() before saving.");
689
- state.isSaving = true;
690
- try {
691
- const template = await api.updateTemplate(state.template.id, state.content);
692
- state.template = template;
693
- state.isDirty = false;
694
- return template;
695
- } catch (error) {
696
- options.onError?.(error);
697
- throw error;
698
- } finally {
699
- state.isSaving = false;
700
- }
701
- }
702
- async function createSnapshot() {
703
- if (!state.template?.id) return;
704
- try {
705
- await api.createSnapshot(state.template.id, state.content);
706
- } catch (error) {
707
- options.onError?.(error);
708
- throw error;
709
- }
710
- }
711
- function hasTemplate() {
712
- return state.template?.id !== void 0;
713
- }
714
- function markDirty() {
715
- state.isDirty = true;
716
- }
717
- function findBlockLocation(blockId) {
718
- const parent = findBlockParent(state.content.blocks, blockId);
719
- if (!parent) return null;
720
- const index = parent.blocks.findIndex((b) => b.id === blockId);
721
- if (index === -1) return null;
722
- return {
723
- targetSectionId: parent.sectionId,
724
- columnIndex: parent.columnIndex,
725
- index
726
- };
727
- }
728
- return {
729
- state: readonly(state),
730
- content,
731
- selectedBlock,
732
- savedBlockIds,
733
- isBlockLocked,
734
- findBlockLocation,
735
- setContent,
736
- selectBlock,
737
- setViewport,
738
- setDarkMode,
739
- setUiTheme,
740
- setPreviewMode,
741
- updateBlock,
742
- updateSettings,
743
- addBlock,
744
- removeBlock,
745
- moveBlock,
746
- create,
747
- load,
748
- save,
749
- createSnapshot,
750
- hasTemplate,
751
- markDirty
752
- };
753
- }
754
- //#endregion
755
550
  //#region src/cloud/ai-chat.ts
756
551
  let messageIdCounter = 0;
757
552
  function generateMessageId() {
@@ -1370,32 +1165,63 @@ function useDesignReference(options) {
1370
1165
  };
1371
1166
  }
1372
1167
  //#endregion
1373
- //#region src/cloud/comments.ts
1374
- function useComments(options) {
1375
- const { authManager, getTemplateId, getSocketId, onComment, onError, hasCommentingFeature } = options;
1168
+ //#region src/cloud/comments-provider.ts
1169
+ /** The Pusher event Cloud's backend fans comment writes out on. */
1170
+ const BROADCAST_EVENT = "comment-broadcast";
1171
+ /**
1172
+ * Cloud's wire shape → the contract shape. The whole job of this adapter, plus
1173
+ * auth: snake_case to camelCase, and the two flat `author_*` / `resolved_by_*`
1174
+ * pairs into {@link CommentAuthor} objects.
1175
+ */
1176
+ function toComment(record) {
1177
+ const comment = {
1178
+ id: record.id,
1179
+ body: record.body,
1180
+ author: {
1181
+ id: record.author_identifier,
1182
+ name: record.author_name
1183
+ },
1184
+ createdAt: record.created_at,
1185
+ blockId: record.block_id,
1186
+ parentId: record.parent_id,
1187
+ resolvedAt: record.resolved_at
1188
+ };
1189
+ if (record.updated_at !== record.created_at) comment.updatedAt = record.updated_at;
1190
+ if (record.resolved_by_identifier) comment.resolvedBy = {
1191
+ id: record.resolved_by_identifier,
1192
+ name: record.resolved_by_name ?? ""
1193
+ };
1194
+ if (record.replies?.length) comment.replies = record.replies.map(toComment);
1195
+ return comment;
1196
+ }
1197
+ /**
1198
+ * Cloud-backed {@link CommentsProvider} — the Templatical Cloud adapter for the
1199
+ * same review contract consumers implement themselves.
1200
+ *
1201
+ * All four mutations are enabled: comment storage and its realtime fan-out are
1202
+ * what the `commenting` plan feature pays for, so there is no Cloud tier that can
1203
+ * read a thread but not reply to it. A consumer who wants a read-only review
1204
+ * supplies their own provider with the mutations set to `false`.
1205
+ *
1206
+ * Two things about the write payloads are Cloud's alone and stay on this side of
1207
+ * the seam:
1208
+ *
1209
+ * - **The author is signed.** `user_id` / `user_name` / `user_signature` come from
1210
+ * the JWT, not from the editor's `user` config, so a browser cannot attribute a
1211
+ * comment to someone else. The editor's `user` key still gates the feature and
1212
+ * drives "you wrote this" in the UI; the two agree because `initCloud()` fills
1213
+ * `user` from the same JWT.
1214
+ * - **`X-Socket-ID`.** Cloud excludes the originating socket from the broadcast,
1215
+ * which is why a local write and its echo can't double-post. Every implementation
1216
+ * is nonetheless echo-safe (`upsert` replaces by id), so a backend without that
1217
+ * header is fine too.
1218
+ */
1219
+ function createCloudCommentsProvider(options) {
1220
+ const { authManager, channel, getSocketId } = options;
1376
1221
  const api = new ApiClient(authManager);
1377
- const comments = ref([]);
1378
- const isLoading = ref(false);
1379
- const isSubmitting = ref(false);
1380
- const isEnabled = computed(() => {
1381
- return (hasCommentingFeature?.() ?? false) && authManager.userConfig !== null;
1382
- });
1383
- const totalCount = computed(() => {
1384
- let count = 0;
1385
- for (const thread of comments.value) count += 1 + (thread.replies?.length ?? 0);
1386
- return count;
1387
- });
1388
- const unresolvedCount = computed(() => {
1389
- return comments.value.filter((c) => !c.resolved_at).length;
1390
- });
1391
- const commentCountByBlock = computed(() => {
1392
- const map = /* @__PURE__ */ new Map();
1393
- for (const thread of comments.value) if (thread.block_id) map.set(thread.block_id, (map.get(thread.block_id) ?? 0) + 1 + (thread.replies?.length ?? 0));
1394
- return map;
1395
- });
1396
- function getUserPayload() {
1222
+ function userPayload() {
1397
1223
  const user = authManager.userConfig;
1398
- if (!user) throw new Error("User config not available");
1224
+ if (!user) throw new Error("[Templatical] Cloud comments need a signed user in the auth token. The project's token endpoint must include a `user` claim.");
1399
1225
  return {
1400
1226
  user_id: user.id,
1401
1227
  user_name: user.name,
@@ -1404,226 +1230,70 @@ function useComments(options) {
1404
1230
  }
1405
1231
  function socketHeaders() {
1406
1232
  const socketId = getSocketId?.();
1407
- if (!socketId) return;
1408
- return { "X-Socket-ID": socketId };
1409
- }
1410
- function emitEvent(type, comment) {
1411
- onComment?.({
1412
- type,
1413
- comment
1414
- });
1415
- }
1416
- function findComment(commentId) {
1417
- for (const thread of comments.value) {
1418
- if (thread.id === commentId) return thread;
1419
- for (const reply of thread.replies ?? []) if (reply.id === commentId) return reply;
1420
- }
1421
- return null;
1422
- }
1423
- async function loadComments() {
1424
- const templateId = getTemplateId();
1425
- if (!templateId) return;
1426
- isLoading.value = true;
1427
- try {
1428
- comments.value = await api.getComments(templateId);
1429
- } catch (err) {
1430
- const wrappedError = err instanceof Error ? err : new Error("Failed to load comments", { cause: err });
1431
- onError?.(wrappedError);
1432
- } finally {
1433
- isLoading.value = false;
1434
- }
1435
- }
1436
- async function addComment(body, blockId, parentId) {
1437
- const templateId = getTemplateId();
1438
- if (!templateId) return null;
1439
- isSubmitting.value = true;
1440
- try {
1441
- const comment = await api.createComment(templateId, {
1442
- body,
1443
- block_id: blockId,
1444
- parent_id: parentId,
1445
- ...getUserPayload()
1446
- }, socketHeaders());
1447
- if (parentId) {
1448
- const parent = findComment(parentId);
1449
- if (parent) parent.replies = [...parent.replies ?? [], comment];
1450
- } else comments.value = [...comments.value, comment];
1451
- emitEvent("created", comment);
1452
- return comment;
1453
- } catch (err) {
1454
- const wrappedError = err instanceof Error ? err : new Error("Failed to create comment", { cause: err });
1455
- onError?.(wrappedError);
1456
- return null;
1457
- } finally {
1458
- isSubmitting.value = false;
1459
- }
1460
- }
1461
- async function editComment(commentId, body) {
1462
- const templateId = getTemplateId();
1463
- if (!templateId) return null;
1464
- isSubmitting.value = true;
1465
- try {
1466
- const updated = await api.updateComment(templateId, commentId, {
1467
- body,
1468
- ...getUserPayload()
1469
- }, socketHeaders());
1470
- updateCommentInState(commentId, updated);
1471
- emitEvent("updated", updated);
1472
- return updated;
1473
- } catch (err) {
1474
- const wrappedError = err instanceof Error ? err : new Error("Failed to update comment", { cause: err });
1475
- onError?.(wrappedError);
1476
- return null;
1477
- } finally {
1478
- isSubmitting.value = false;
1479
- }
1480
- }
1481
- async function removeComment(commentId) {
1482
- const templateId = getTemplateId();
1483
- if (!templateId) return false;
1484
- const comment = findComment(commentId);
1485
- if (!comment) return false;
1486
- const commentSnapshot = {
1487
- ...comment,
1488
- replies: [...comment.replies ?? []]
1489
- };
1490
- isSubmitting.value = true;
1491
- try {
1492
- await api.deleteComment(templateId, commentId, getUserPayload(), socketHeaders());
1493
- if (comment.parent_id) {
1494
- const parent = findComment(comment.parent_id);
1495
- if (parent) parent.replies = (parent.replies ?? []).filter((r) => r.id !== commentId);
1496
- } else comments.value = comments.value.filter((c) => c.id !== commentId);
1497
- emitEvent("deleted", commentSnapshot);
1498
- return true;
1499
- } catch (err) {
1500
- const wrappedError = err instanceof Error ? err : new Error("Failed to delete comment", { cause: err });
1501
- onError?.(wrappedError);
1502
- return false;
1503
- } finally {
1504
- isSubmitting.value = false;
1505
- }
1506
- }
1507
- async function toggleResolve(commentId) {
1508
- const templateId = getTemplateId();
1509
- if (!templateId) return null;
1510
- isSubmitting.value = true;
1511
- try {
1512
- const updated = await api.resolveComment(templateId, commentId, getUserPayload(), socketHeaders());
1513
- updateCommentInState(commentId, updated);
1514
- emitEvent(updated.resolved_at ? "resolved" : "unresolved", updated);
1515
- return updated;
1516
- } catch (err) {
1517
- const wrappedError = err instanceof Error ? err : new Error("Failed to toggle comment resolution", { cause: err });
1518
- onError?.(wrappedError);
1519
- return null;
1520
- } finally {
1521
- isSubmitting.value = false;
1522
- }
1233
+ return socketId ? { "X-Socket-ID": socketId } : void 0;
1523
1234
  }
1524
- function applyRemoteCreate(comment) {
1525
- if (comment.parent_id) {
1526
- const parent = findComment(comment.parent_id);
1527
- if (parent) parent.replies = [...parent.replies ?? [], comment];
1528
- } else comments.value = [...comments.value, comment];
1529
- emitEvent("created", comment);
1530
- }
1531
- function applyRemoteUpdate(comment) {
1532
- updateCommentInState(comment.id, comment);
1533
- emitEvent("updated", comment);
1534
- }
1535
- function applyRemoteDelete(commentId, parentId) {
1536
- const comment = findComment(commentId);
1537
- const snapshot = comment ? {
1538
- ...comment,
1539
- replies: [...comment.replies ?? []]
1540
- } : null;
1541
- if (parentId) {
1542
- const parent = findComment(parentId);
1543
- if (parent) parent.replies = (parent.replies ?? []).filter((r) => r.id !== commentId);
1544
- } else comments.value = comments.value.filter((c) => c.id !== commentId);
1545
- if (snapshot) emitEvent("deleted", snapshot);
1546
- }
1547
- function updateCommentInState(commentId, updated) {
1548
- for (let i = 0; i < comments.value.length; i++) {
1549
- if (comments.value[i].id === commentId) {
1550
- comments.value = [
1551
- ...comments.value.slice(0, i),
1552
- {
1553
- ...updated,
1554
- replies: comments.value[i].replies
1555
- },
1556
- ...comments.value.slice(i + 1)
1557
- ];
1558
- return;
1559
- }
1560
- const replies = comments.value[i].replies ?? [];
1561
- for (let j = 0; j < replies.length; j++) if (replies[j].id === commentId) {
1562
- const newReplies = [
1563
- ...replies.slice(0, j),
1564
- updated,
1565
- ...replies.slice(j + 1)
1566
- ];
1567
- comments.value = [
1568
- ...comments.value.slice(0, i),
1569
- {
1570
- ...comments.value[i],
1571
- replies: newReplies
1572
- },
1573
- ...comments.value.slice(i + 1)
1574
- ];
1575
- return;
1235
+ return {
1236
+ async list(templateId) {
1237
+ return (await api.getComments(templateId)).map(toComment);
1238
+ },
1239
+ async create(templateId, input) {
1240
+ return toComment(await api.createComment(templateId, {
1241
+ body: input.body,
1242
+ block_id: input.blockId,
1243
+ parent_id: input.parentId,
1244
+ ...userPayload()
1245
+ }, socketHeaders()));
1246
+ },
1247
+ async update(templateId, commentId, patch) {
1248
+ if (patch.body === void 0) throw new Error("[Templatical] Cloud comments: update() needs a `body` in the patch.");
1249
+ return toComment(await api.updateComment(templateId, commentId, {
1250
+ body: patch.body,
1251
+ ...userPayload()
1252
+ }, socketHeaders()));
1253
+ },
1254
+ async delete(templateId, commentId) {
1255
+ await api.deleteComment(templateId, commentId, userPayload(), socketHeaders());
1256
+ },
1257
+ async setResolved(templateId, commentId, _resolved) {
1258
+ return toComment(await api.resolveComment(templateId, commentId, userPayload(), socketHeaders()));
1259
+ },
1260
+ subscribe(_templateId, onChange) {
1261
+ function handle(payload) {
1262
+ const comment = toComment(payload.comment);
1263
+ switch (payload.action) {
1264
+ case "comment_created":
1265
+ onChange({
1266
+ type: "created",
1267
+ comment
1268
+ });
1269
+ break;
1270
+ case "comment_deleted":
1271
+ onChange({
1272
+ type: "deleted",
1273
+ commentId: comment.id,
1274
+ parentId: comment.parentId
1275
+ });
1276
+ break;
1277
+ case "comment_updated":
1278
+ case "comment_resolved":
1279
+ case "comment_unresolved": onChange({
1280
+ type: "updated",
1281
+ comment
1282
+ });
1283
+ }
1576
1284
  }
1285
+ const stopWatching = watch(channel, (next, previous) => {
1286
+ previous?.unbind(BROADCAST_EVENT, handle);
1287
+ next?.bind(BROADCAST_EVENT, handle);
1288
+ }, { immediate: true });
1289
+ return () => {
1290
+ stopWatching();
1291
+ channel.value?.unbind(BROADCAST_EVENT, handle);
1292
+ };
1577
1293
  }
1578
- }
1579
- return {
1580
- comments,
1581
- isLoading,
1582
- isSubmitting,
1583
- isEnabled,
1584
- commentCountByBlock,
1585
- totalCount,
1586
- unresolvedCount,
1587
- loadComments,
1588
- addComment,
1589
- editComment,
1590
- removeComment,
1591
- toggleResolve,
1592
- applyRemoteCreate,
1593
- applyRemoteUpdate,
1594
- applyRemoteDelete
1595
1294
  };
1596
1295
  }
1597
1296
  //#endregion
1598
- //#region src/cloud/comment-listener.ts
1599
- function useCommentListener(options) {
1600
- const { comments, channel } = options;
1601
- watch(channel, (newChannel, oldChannel) => {
1602
- if (oldChannel) oldChannel.unbind("comment-broadcast");
1603
- if (newChannel) newChannel.bind("comment-broadcast", (payload) => {
1604
- handleCommentBroadcast(comments, payload);
1605
- });
1606
- });
1607
- onScopeDispose(() => {
1608
- channel.value?.unbind("comment-broadcast");
1609
- });
1610
- }
1611
- function handleCommentBroadcast(comments, payload) {
1612
- switch (payload.action) {
1613
- case "comment_created":
1614
- comments.applyRemoteCreate(payload.comment);
1615
- break;
1616
- case "comment_updated":
1617
- comments.applyRemoteUpdate(payload.comment);
1618
- break;
1619
- case "comment_deleted":
1620
- comments.applyRemoteDelete(payload.comment.id, payload.comment.parent_id);
1621
- break;
1622
- case "comment_resolved":
1623
- case "comment_unresolved": comments.applyRemoteUpdate(payload.comment);
1624
- }
1625
- }
1626
- //#endregion
1627
1297
  //#region src/cloud/collaboration.ts
1628
1298
  const COLLAB_EVENTS = [
1629
1299
  "pusher:member_added",
@@ -1922,8 +1592,9 @@ function useWebSocket(options) {
1922
1592
  * editor UI never see them. Plan gating is applied by the caller before this
1923
1593
  * provider is constructed.
1924
1594
  *
1925
- * Wraps the existing `ApiClient` saved-module endpoints, so the REST contract
1926
- * is unchanged from before saved blocks became an open-source feature.
1595
+ * Wraps the `ApiClient` saved-module endpoints. The REST contract keeps the
1596
+ * `savedModules.*` route names even though the type is `SavedBlock`, so the
1597
+ * names deliberately differ from the contract's.
1927
1598
  *
1928
1599
  * Rows pass straight through, unmapped: the Cloud endpoints speak the same
1929
1600
  * `SavedBlock` shape the contract defines, so this adapter only supplies auth
@@ -1950,42 +1621,217 @@ function createCloudSavedBlocksProvider(authManager) {
1950
1621
  };
1951
1622
  }
1952
1623
  //#endregion
1953
- //#region src/cloud/snapshots.ts
1954
- function useSnapshotHistory(options) {
1955
- const api = new ApiClient(options.authManager);
1956
- const snapshots = ref([]);
1957
- const isLoading = ref(false);
1958
- const isRestoring = ref(false);
1959
- async function loadSnapshots() {
1960
- isLoading.value = true;
1961
- try {
1962
- snapshots.value = await api.getSnapshots(options.templateId);
1963
- } catch (error) {
1964
- options.onError?.(error);
1965
- throw error;
1966
- } finally {
1967
- isLoading.value = false;
1968
- }
1624
+ //#region src/cloud/templates-provider.ts
1625
+ /**
1626
+ * How often a save may also record an automatic version. Cloud's storage, Cloud's
1627
+ * number — an editor-side default would be guessing on someone else's behalf.
1628
+ */
1629
+ const AUTO_VERSION_INTERVAL_MS = 6e4;
1630
+ /**
1631
+ * Cloud-backed {@link TemplatesProvider} — the Templatical Cloud adapter for the
1632
+ * same save/load contract consumers implement themselves.
1633
+ *
1634
+ * Auth (JWT via {@link AuthManager}) and project/tenant scoping live entirely on
1635
+ * this side of the seam; the editor's header, Cmd+S, autosave and
1636
+ * unsaved-changes guard never see them.
1637
+ *
1638
+ * All three methods are enabled: Cloud's template storage is what the plan pays
1639
+ * for, so there is no tier in which a Cloud session can load but not save. A
1640
+ * consumer who wants read-only supplies their own provider with `save: false`.
1641
+ *
1642
+ * **This is also where automatic versions are recorded.** The editor never
1643
+ * creates one on its own — whoever implements `save` decides whether a save also
1644
+ * records a version, which keeps throttling and retention with the side that
1645
+ * pays for the storage. Cloud throttles to one automatic version per
1646
+ * {@link AUTO_VERSION_INTERVAL_MS}, so an autosave firing every few seconds does
1647
+ * not turn history into a keystroke log. Keep this on the provider side: an
1648
+ * editor-side timer would put Cloud's retention policy in the editor.
1649
+ *
1650
+ * Note `initCloud()` **rejects** a consumer-supplied templates provider, unlike
1651
+ * `savedBlocks` and `testEmail`. Those are inert — nothing keys off them. The
1652
+ * template id is the join key for collaboration, version history, comments, AI
1653
+ * rewrite, scoring and the server-side export, so an id Cloud never issued would
1654
+ * degrade all six silently.
1655
+ */
1656
+ function createCloudTemplatesProvider(authManager) {
1657
+ const api = new ApiClient(authManager);
1658
+ let lastAutoVersionAt = 0;
1659
+ /**
1660
+ * Fire-and-forget: a version is a convenience on top of a save that already
1661
+ * succeeded, so a failing history endpoint must not turn a successful save
1662
+ * into a failed one. The clock advances before the request, so a slow endpoint
1663
+ * can't let a burst of saves queue several at once.
1664
+ *
1665
+ * Logged rather than swallowed: history quietly not filling up is exactly the
1666
+ * kind of failure nobody notices until they need a version that was never
1667
+ * recorded.
1668
+ */
1669
+ function maybeRecordVersion(templateId, content) {
1670
+ const now = Date.now();
1671
+ if (now - lastAutoVersionAt < AUTO_VERSION_INTERVAL_MS) return;
1672
+ lastAutoVersionAt = now;
1673
+ api.createVersion(templateId, content).catch((error) => {
1674
+ console.warn("[Templatical] Automatic version not recorded — the save itself succeeded:", error);
1675
+ });
1969
1676
  }
1970
- async function restoreSnapshot(snapshotId) {
1971
- isRestoring.value = true;
1972
- try {
1973
- const template = await api.restoreSnapshot(options.templateId, snapshotId);
1974
- options.onRestore?.(template);
1677
+ return {
1678
+ load(id) {
1679
+ return api.getTemplate(id);
1680
+ },
1681
+ create(input) {
1682
+ return api.createTemplate(input.content, input.name);
1683
+ },
1684
+ async save(id, patch) {
1685
+ const template = await api.updateTemplate(id, patch);
1686
+ if (patch.content) maybeRecordVersion(id, patch.content);
1975
1687
  return template;
1976
- } catch (error) {
1977
- options.onError?.(error);
1978
- throw error;
1979
- } finally {
1980
- isRestoring.value = false;
1981
1688
  }
1689
+ };
1690
+ }
1691
+ //#endregion
1692
+ //#region src/cloud/export.ts
1693
+ /**
1694
+ * Flatten a {@link FontsConfig} into the export payload.
1695
+ *
1696
+ * Unconditional: gating fonts by entitlement meters no resource Cloud buys, and
1697
+ * would only make the paid tier render fewer fonts than the free editor.
1698
+ */
1699
+ function resolveExportFonts(fonts) {
1700
+ return {
1701
+ customFonts: fonts?.customFonts ?? [],
1702
+ defaultFallback: fonts?.defaultFallback ?? "Arial, sans-serif"
1703
+ };
1704
+ }
1705
+ /**
1706
+ * Cloud's server-side export endpoint, as a plain API wrapper.
1707
+ *
1708
+ * Both calls render from the **stored** template, so callers save first when the
1709
+ * canvas may have moved on — see `createCloudRenderProvider`, which is where that
1710
+ * policy lives.
1711
+ */
1712
+ function useExport(options) {
1713
+ const api = new ApiClient(options.authManager);
1714
+ async function exportHtml(templateId, fonts) {
1715
+ const result = await api.exportTemplate(templateId, fonts);
1716
+ return {
1717
+ html: result.html,
1718
+ mjml: result.mjml
1719
+ };
1720
+ }
1721
+ async function getMjmlSource(templateId, fonts) {
1722
+ return (await api.exportTemplate(templateId, fonts)).mjml;
1982
1723
  }
1983
1724
  return {
1984
- snapshots,
1985
- isLoading,
1986
- isRestoring,
1987
- loadSnapshots,
1988
- restoreSnapshot
1725
+ exportHtml,
1726
+ getMjmlSource
1727
+ };
1728
+ }
1729
+ //#endregion
1730
+ //#region src/cloud/render-provider.ts
1731
+ /**
1732
+ * Templatical Cloud's renderer, shaped as a {@link RenderProvider} so it plugs
1733
+ * into the same editor seam a consumer's own backend would.
1734
+ *
1735
+ * **Why Cloud renders server-side at all**, rather than running the bundled
1736
+ * renderer like an OSS consumer: its output is a deliberate *superset* that a
1737
+ * browser cannot produce. A countdown block resolves to a URL serving a live,
1738
+ * on-demand animated GIF; a video block gets a composited play button. Both are
1739
+ * injected into the published renderer through `blockRenderers` in Cloud's Node
1740
+ * sidecar, so the delta is two functions and parity on the other twelve block
1741
+ * types holds by construction.
1742
+ *
1743
+ * Two consequences of the endpoint rendering the **stored** template:
1744
+ *
1745
+ * - **Every render saves first.** The same trade the test-email adapter makes, for
1746
+ * the same reason: exporting a stale version of what is on screen is worse than
1747
+ * a write the caller did not ask for. It also means `toMjml()` needs a template
1748
+ * to exist — a Cloud session that never created one gets a clear rejection
1749
+ * rather than an export of nothing.
1750
+ * - **`payload.content` is ignored**, along with the custom-block `renderedHtml`
1751
+ * the editor pre-rendered into it. Cloud's `save()` persists that same content
1752
+ * immediately before, so the server reads it from storage rather than trusting an
1753
+ * echo. `payload.fonts` *is* read, because it is the only place the editor's
1754
+ * effective font set is expressed. Don't "fix" the content branch.
1755
+ *
1756
+ * `compileMjml` is deliberately absent: `toMjml` and `toHtml` are both whole-
1757
+ * pipeline calls here, so there is no MJML the editor would hand back for
1758
+ * compiling.
1759
+ */
1760
+ function createCloudRenderProvider(options) {
1761
+ const exporter = useExport({ authManager: options.authManager });
1762
+ function exportFonts(payload) {
1763
+ return {
1764
+ customFonts: payload.fonts?.customFonts ?? [],
1765
+ defaultFallback: payload.fonts?.defaultFallback ?? "Arial, sans-serif"
1766
+ };
1767
+ }
1768
+ async function saveThenResolveId() {
1769
+ if (options.getTemplateId() === null) throw new Error("[Templatical] Cloud renders from the saved template, so one must exist first. Call create() or load() before toMjml() / toHtml().");
1770
+ return (await options.save()).id;
1771
+ }
1772
+ return {
1773
+ async toMjml(payload) {
1774
+ const fonts = exportFonts(payload);
1775
+ const templateId = await saveThenResolveId();
1776
+ return exporter.getMjmlSource(templateId, fonts);
1777
+ },
1778
+ async toHtml(payload) {
1779
+ const fonts = exportFonts(payload);
1780
+ const templateId = await saveThenResolveId();
1781
+ const { html } = await exporter.exportHtml(templateId, fonts);
1782
+ return html;
1783
+ }
1784
+ };
1785
+ }
1786
+ //#endregion
1787
+ //#region src/cloud/version-history-provider.ts
1788
+ /**
1789
+ * Cloud's wire shape → the contract shape. The only real work this adapter does:
1790
+ * snake_case to camelCase, and `is_autosave` to `isAutomatic`.
1791
+ *
1792
+ * `content` is carried through deliberately — Cloud's index endpoint already
1793
+ * returns it, so every listed version arrives hydrated and the editor never
1794
+ * calls `get` while scrubbing. See {@link TemplateVersion.content}: that is the
1795
+ * hint working as intended, not a shortcut. `get` is implemented anyway, because
1796
+ * it is the contract's operation and Cloud is one implementation of it like any
1797
+ * other.
1798
+ */
1799
+ function toVersion(record) {
1800
+ return {
1801
+ id: record.id,
1802
+ createdAt: record.created_at,
1803
+ isAutomatic: record.is_autosave,
1804
+ content: record.content
1805
+ };
1806
+ }
1807
+ /**
1808
+ * Cloud-backed {@link VersionHistoryProvider} — the Templatical Cloud adapter for
1809
+ * the same contract consumers implement themselves.
1810
+ *
1811
+ * Both mutations are enabled: version storage is what the plan pays for, so
1812
+ * there is no Cloud tier that can list history but not restore it. A consumer
1813
+ * who wants read-only history supplies their own provider with `restore: false`.
1814
+ *
1815
+ * **Automatic versions are not created here.** They are recorded by
1816
+ * `createCloudTemplatesProvider`'s `save`, because the contract puts that
1817
+ * decision on whoever implements `save` — the side that knows the storage cost.
1818
+ * `create` here is for a version a person asked for.
1819
+ */
1820
+ function createCloudVersionHistoryProvider(authManager) {
1821
+ const api = new ApiClient(authManager);
1822
+ return {
1823
+ async list(templateId) {
1824
+ return { versions: (await api.getVersions(templateId)).map(toVersion) };
1825
+ },
1826
+ async get(templateId, versionId) {
1827
+ return (await api.getVersion(templateId, versionId)).content;
1828
+ },
1829
+ async create(templateId, content, meta) {
1830
+ return toVersion(await api.createVersion(templateId, content, meta?.label));
1831
+ },
1832
+ restore(templateId, versionId) {
1833
+ return api.restoreVersion(templateId, versionId);
1834
+ }
1989
1835
  };
1990
1836
  }
1991
1837
  //#endregion
@@ -2055,35 +1901,6 @@ function createCloudTestEmailProvider(options) {
2055
1901
  };
2056
1902
  }
2057
1903
  //#endregion
2058
- //#region src/cloud/export.ts
2059
- function useExport(options) {
2060
- const { authManager, getFontsConfig, canUseCustomFonts } = options;
2061
- const api = new ApiClient(authManager);
2062
- function getExportFontsPayload() {
2063
- const fontsConfig = getFontsConfig?.();
2064
- return {
2065
- customFonts: (canUseCustomFonts?.() ?? true) && fontsConfig?.customFonts ? fontsConfig.customFonts : [],
2066
- defaultFallback: fontsConfig?.defaultFallback ?? "Arial, sans-serif"
2067
- };
2068
- }
2069
- async function exportHtml(templateId) {
2070
- const fontsPayload = getExportFontsPayload();
2071
- const result = await api.exportTemplate(templateId, fontsPayload);
2072
- return {
2073
- html: result.html,
2074
- mjml: result.mjml
2075
- };
2076
- }
2077
- async function getMjmlSource(templateId) {
2078
- const fontsPayload = getExportFontsPayload();
2079
- return (await api.exportTemplate(templateId, fontsPayload)).mjml;
2080
- }
2081
- return {
2082
- exportHtml,
2083
- getMjmlSource
2084
- };
2085
- }
2086
- //#endregion
2087
1904
  //#region src/cloud/plan-config.ts
2088
1905
  function usePlanConfig(options) {
2089
1906
  const { authManager, onError } = options;
@@ -2239,6 +2056,6 @@ function useMcpListener(options) {
2239
2056
  });
2240
2057
  }
2241
2058
  //#endregion
2242
- export { API_ROUTES, ApiClient, AuthManager, WebSocketClient, buildUrl, createCloudSavedBlocksProvider, createCloudTestEmailProvider, createSdkAuthManager, handleOperation, performHealthCheck, resolveWebSocketConfig, useAiChat, useAiConfig, useAiRewrite, useCollaboration, useCollaborationBroadcast, useCommentListener, useComments, useDesignReference, useEditor, useExport, useMcpListener, usePlanConfig, useSnapshotHistory, useTemplateScoring, useTestEmail, useWebSocket };
2059
+ export { API_ROUTES, ApiClient, AuthManager, WebSocketClient, buildUrl, createCloudCommentsProvider, createCloudRenderProvider, createCloudSavedBlocksProvider, createCloudTemplatesProvider, createCloudTestEmailProvider, createCloudVersionHistoryProvider, createSdkAuthManager, handleOperation, isFatalAuthError, performHealthCheck, resolveExportFonts, resolveWebSocketConfig, useAiChat, useAiConfig, useAiRewrite, useCollaboration, useCollaborationBroadcast, useDesignReference, useExport, useMcpListener, usePlanConfig, useTemplateScoring, useTestEmail, useWebSocket };
2243
2060
 
2244
2061
  //# sourceMappingURL=index.js.map