@scaleflex/template-builder 0.1.1 → 0.4.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.
@@ -1,6 +1,7 @@
1
1
  import { LitElement, html, css, nothing, type PropertyValues } from 'lit'
2
2
  import { property, state } from 'lit/decorators.js'
3
3
  import {
4
+ BLANK_TEMPLATE_XML,
4
5
  BUILDER_CLOSE,
5
6
  BUILDER_CONTENT,
6
7
  BUILDER_CONTENT_REQUEST,
@@ -11,9 +12,11 @@ import {
11
12
  BUILDER_SAVE,
12
13
  EMBED_PARAMS,
13
14
  EMBED_ROUTE,
15
+ HOST_CONFIG,
14
16
  HOST_LOAD,
15
17
  HOST_SAVED,
16
18
  builderRoute,
19
+ type CustomMetadataField,
17
20
  type BuilderContentData,
18
21
  type BuilderContentMessage,
19
22
  type BuilderDirtyData,
@@ -24,6 +27,13 @@ import {
24
27
  type BuilderSaveMessage,
25
28
  type BuilderTheme,
26
29
  } from './protocol'
30
+ import {
31
+ storeTemplateInDam,
32
+ type DamStoreAuth,
33
+ type StoredTemplate,
34
+ } from './dam-store'
35
+
36
+ export type { StoredTemplate, DamStoreAuth } from './dam-store'
27
37
 
28
38
  export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error'
29
39
 
@@ -33,11 +43,14 @@ export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error'
33
43
  * - DAM-backed (default) — `BuilderSaveData`; the app uploaded the template and
34
44
  * reports the resulting `uuid`.
35
45
  * - `stateless` — `BuilderContentData`; nothing was stored, and `content` is
36
- * the edited template for the host to persist.
46
+ * the edited template for the host to persist. With `dam-store` the element
47
+ * uploads a rendering copy to Filerobot first, and the detail additionally
48
+ * carries `stored` (the copy's uuid + CDN URL) — or `storeError` when that
49
+ * copy could not be made; the raw `content` arrives either way.
37
50
  */
38
51
  export type TemplateBuilderSaveDetail =
39
52
  | BuilderSaveData
40
- | BuilderContentData
53
+ | (BuilderContentData & { stored?: StoredTemplate; storeError?: string })
41
54
  | undefined
42
55
 
43
56
  export interface TemplateBuilderEventMap {
@@ -74,7 +87,9 @@ export interface TemplateBuilderEventMap {
74
87
  * template into the editor over postMessage and `save` returns the edited
75
88
  * document; nothing is stored on the Scaleflex side, and `template-id` is
76
89
  * just an opaque string echoed back. Rendering, fonts and asset browsing
77
- * still use the session's Filerobot tenant.
90
+ * still use the session's Filerobot tenant. To start a template from
91
+ * scratch, set `new-template` (or call `createNew()`) instead of `content`
92
+ * — the widget supplies the blank document.
78
93
  *
79
94
  * `brand-color` and `theme` restyle the editor chrome to match the host page.
80
95
  * They do not touch the rendered template — its colours live in the document.
@@ -149,7 +164,8 @@ export class SfxTemplateBuilder extends LitElement {
149
164
  @property({ reflect: true }) mode: 'inline' | 'modal' = 'inline'
150
165
  /**
151
166
  * Hand the template in and take it back out instead of letting the app read
152
- * and write Filerobot. Requires `content`.
167
+ * and write Filerobot. Requires `content`, or `new-template` to start from
168
+ * scratch.
153
169
  */
154
170
  @property({ type: Boolean, reflect: true }) stateless = false
155
171
  /**
@@ -158,6 +174,21 @@ export class SfxTemplateBuilder extends LitElement {
158
174
  * Assigning a different value while open loads it into the running editor.
159
175
  */
160
176
  @property({ attribute: false }) content = ''
177
+ /**
178
+ * Stateless mode: open on a new, empty template rather than one of yours.
179
+ * The widget supplies the blank document ({@link BLANK_TEMPLATE_XML}), so
180
+ * nothing here needs to know the template format — the user picks the canvas
181
+ * size in the editor, and `save` hands back a complete document to store.
182
+ *
183
+ * `content` wins when both are set: a host with a real template to edit is
184
+ * not asking for a blank one.
185
+ *
186
+ * A flag rather than an inferred meaning for empty `content`, because empty
187
+ * already means "the host is still fetching" — the editor waits on a spinner
188
+ * for it, and blanking that case would flash an empty document in front of
189
+ * every host that mounts the builder before its request resolves.
190
+ */
191
+ @property({ type: Boolean, attribute: 'new-template' }) newTemplate = false
161
192
  /** Stateless mode: display name for the editor header. */
162
193
  @property({ attribute: 'template-name' }) templateName = ''
163
194
  /**
@@ -176,6 +207,81 @@ export class SfxTemplateBuilder extends LitElement {
176
207
  @property({ attribute: 'brand-color' }) brandColor = ''
177
208
  /** Colour scheme for the editor chrome. Empty leaves the app's own default. */
178
209
  @property() theme: BuilderTheme | '' = ''
210
+ /**
211
+ * Metadata model to offer in the editor as the **Custom metadata** value
212
+ * source: `[{ key, title?, group? }]`. Authors then bind a text variable to
213
+ * one of your field names instead of to a bare slug.
214
+ *
215
+ * Names only — no values travel with the model, and the app resolves nothing
216
+ * against it. The bound key is stored in the template as `custom_ckey`, and
217
+ * the variable renders like any free-text one: your pipeline puts
218
+ * `$slug=value` in the render query. Read the key back from the saved `.fdt`
219
+ * to know which of your fields each variable expects.
220
+ *
221
+ * Leave it empty and the source is not offered at all, so hosts that send
222
+ * nothing see the editor they always had. Its main use is `sec-template` /
223
+ * stateless embeds, where the Hub project model — and with it the "File
224
+ * metadata" source — is unavailable.
225
+ *
226
+ * Settable as a property (an array) or as a `custom-metadata` attribute
227
+ * holding that array as JSON. Neither is trusted to be well-formed:
228
+ * unparseable JSON is warned about, a value that is not an array is treated as
229
+ * no model, and individual fields the editor cannot use are dropped there —
230
+ * a config typo costs the field, not the editor.
231
+ *
232
+ * Lit compares by identity: assign a new array to change the model, don't
233
+ * mutate the one you passed.
234
+ */
235
+ @property({
236
+ attribute: 'custom-metadata',
237
+ converter: {
238
+ fromAttribute: (value: string | null): CustomMetadataField[] => {
239
+ if (!value) return []
240
+ try {
241
+ const parsed: unknown = JSON.parse(value)
242
+ return Array.isArray(parsed) ? (parsed as CustomMetadataField[]) : []
243
+ } catch {
244
+ console.warn('[sfx-template-builder] custom-metadata is not valid JSON — ignoring.')
245
+ return []
246
+ }
247
+ },
248
+ toAttribute: (value: CustomMetadataField[]): string => JSON.stringify(value ?? []),
249
+ },
250
+ })
251
+ customMetadata: CustomMetadataField[] = []
252
+ /**
253
+ * Display name for the custom-metadata value source in the editor's UI.
254
+ * Empty means the editor's default ("Custom metadata"); a host can rename
255
+ * it after its own domain — e.g. "External metadata". Pure wording: the
256
+ * stored template is unaffected.
257
+ */
258
+ @property({ attribute: 'custom-metadata-label' }) customMetadataLabel = ''
259
+ /**
260
+ * Stateless only: store each save in Filerobot too, so the CDN can render
261
+ * it. The `save` event then carries `stored: { uuid, url }` next to the raw
262
+ * `content` — or `storeError` when the copy failed (the raw data arrives
263
+ * either way; whether that fails the save is the host's call via the ack).
264
+ * Uses the element's own credentials; a security template needs a scope
265
+ * that allows uploads.
266
+ */
267
+ @property({ type: Boolean, attribute: 'dam-store' }) damStore = false
268
+ /**
269
+ * Folder new templates are stored into when `dam-store` is on and the
270
+ * template id names no existing DAM file (an existing file's own folder
271
+ * always wins, so same name + folder versions it in place).
272
+ */
273
+ @property({ attribute: 'store-folder' }) storeFolder = '/'
274
+ /**
275
+ * `dam-store`: the uuid of the rendering copy this document already has —
276
+ * the `stored.uuid` a previous session's save reported, passed back in by
277
+ * the host alongside the content. Without it the element only remembers
278
+ * copies it made itself, so after a reload an unchanged re-save cannot find
279
+ * its own file and reports a spurious `storeError`, and a changed one starts
280
+ * a fresh file instead of versioning the existing one. Set it when reopening
281
+ * a stored template; it belongs to the document, so hosts that swap
282
+ * documents must swap (or clear) it too — `load()` does this for you.
283
+ */
284
+ @property({ attribute: 'stored-uuid' }) storedUuid = ''
179
285
  /** Ms to wait for the app's ready signal before emitting `error`. 0 disables. */
180
286
  @property({ type: Number, attribute: 'ready-timeout' }) readyTimeout = 20000
181
287
 
@@ -197,6 +303,21 @@ export class SfxTemplateBuilder extends LitElement {
197
303
  * back on save.
198
304
  */
199
305
  private _sentKey?: string
306
+ /**
307
+ * Identity of the last SAVE the app handed back, in the same shape as
308
+ * `_sentKey`. A host echoing the full save detail into the props (content,
309
+ * name, templateQuery) matches this key rather than `_sentKey`, whose name
310
+ * and query still describe what the template was loaded with — without it
311
+ * the echo would post a HOST_LOAD that reloads the editor and wipes undo
312
+ * history on nearly every save (a save almost always changes the query).
313
+ */
314
+ private _sentSaveKey?: string
315
+ /**
316
+ * Identity of the config already delivered, so re-renders don't re-post it.
317
+ * Undefined means the app has not been told anything yet — set back to that
318
+ * whenever a new app instance loads.
319
+ */
320
+ private _sentConfigKey?: string
200
321
  /**
201
322
  * The `baseUrl` value already reported as unparseable. `_computeSrc()` runs
202
323
  * on every update cycle, so without this a bad URL re-emits `error` forever —
@@ -246,11 +367,13 @@ export class SfxTemplateBuilder extends LitElement {
246
367
  templateId,
247
368
  name,
248
369
  templateQuery,
370
+ storedUuid,
249
371
  }: {
250
372
  content: string
251
373
  templateId?: string
252
374
  name?: string
253
375
  templateQuery?: string
376
+ storedUuid?: string
254
377
  }): void {
255
378
  if (templateId !== undefined) this.templateId = templateId
256
379
  if (name !== undefined) this.templateName = name
@@ -258,10 +381,48 @@ export class SfxTemplateBuilder extends LitElement {
258
381
  // previous template's query in place while the new content goes out would
259
382
  // open the new document on the old layout and values.
260
383
  if (templateQuery !== undefined) this.templateQuery = templateQuery
384
+ // Omission clears rather than keeps: the seed names the DOCUMENT's stored
385
+ // copy, and carrying one document's uuid into the next would anchor its
386
+ // saves to the wrong file. "Unknown" is the safe reading of not saying.
387
+ this.storedUuid = storedUuid ?? ''
388
+ // A template of your own is not the blank one: clearing the flag lets a
389
+ // host alternate between `createNew()` and `load()` on one element.
390
+ this.newTemplate = false
261
391
  this.content = content
262
392
  this._open = true
263
393
  }
264
394
 
395
+ /**
396
+ * Stateless mode: open the editor on a new, empty template, opening it if
397
+ * needed. Equivalent to setting `new-template` and calling `open()`.
398
+ *
399
+ * The user chooses the canvas size from the editor's empty state; `save`
400
+ * then hands back a complete `.fdt` document — the first one you store for
401
+ * this record. Pass `templateId` if you have already allocated one; leave it
402
+ * out and the `save` payload simply comes back without an id.
403
+ *
404
+ * Calling it again on an editor that is already showing the blank document
405
+ * does nothing — resending would discard whatever the user has built since.
406
+ * To genuinely start over, `close()` and reopen.
407
+ */
408
+ createNew({
409
+ templateId,
410
+ name,
411
+ }: { templateId?: string; name?: string } = {}): void {
412
+ if (templateId !== undefined) this.templateId = templateId
413
+ if (name !== undefined) this.templateName = name
414
+ // A new document has no layouts and no variables, so there is no render
415
+ // for a query to select — and a leftover one would name layouts and
416
+ // variables of the previous template. Same for the stored-copy seed: a
417
+ // blank document has no copy, and a stale one would anchor the first save
418
+ // to the previous document's file.
419
+ this.templateQuery = ''
420
+ this.storedUuid = ''
421
+ this.newTemplate = true
422
+ this.content = ''
423
+ this._open = true
424
+ }
425
+
265
426
  /**
266
427
  * Stateless mode: report back whether a `save` was persisted on your side.
267
428
  *
@@ -297,6 +458,21 @@ export class SfxTemplateBuilder extends LitElement {
297
458
  super.disconnectedCallback()
298
459
  window.removeEventListener('message', this._onMessage)
299
460
  this._clearHandshakeTimer()
461
+ // Element-level listeners still fire on a detached element, so a vanilla
462
+ // host that removes the widget mid-upload still gets its raw save. (A
463
+ // framework wrapper unsubscribes its listeners before the node detaches,
464
+ // which is why the public `flushPendingSaves()` exists — the React wrapper
465
+ // calls it from its cleanup, while its listeners still hear the event.)
466
+ //
467
+ // Deferred a task: REPARENTING (appendChild into another container)
468
+ // detaches and reattaches synchronously, and flushing on the detach half
469
+ // would report a false storeError for an upload that lands fine. Only a
470
+ // detach that is still detached a tick later is a real removal.
471
+ setTimeout(() => {
472
+ if (!this.isConnected) {
473
+ this._flushPendingSaves('widget removed before the rendering copy completed')
474
+ }
475
+ }, 0)
300
476
  }
301
477
 
302
478
  protected willUpdate(changed: PropertyValues): void {
@@ -317,8 +493,14 @@ export class SfxTemplateBuilder extends LitElement {
317
493
  this._clearHandshakeTimer()
318
494
  // A new document means a new app instance: it has not asked for content
319
495
  // yet, and nothing has been delivered to it.
496
+ // Delivery state only — this is a new APP instance, not a new document.
497
+ // `_lastDocKey` / `_docEpoch` / `_storeMemory` describe the document and
498
+ // survive: resetting them here made every modal close or theme swap
499
+ // forget the stored copy and fork the file on the next save.
320
500
  this._contentRequested = false
321
501
  this._sentKey = undefined
502
+ this._sentSaveKey = undefined
503
+ this._sentConfigKey = undefined
322
504
  if (this._isDirty) {
323
505
  this._isDirty = false
324
506
  this._emit('dirtychange', { isDirty: false })
@@ -330,12 +512,23 @@ export class SfxTemplateBuilder extends LitElement {
330
512
  // templates must not leave the app saving under the previous id.
331
513
  if (
332
514
  changed.has('content') ||
515
+ changed.has('newTemplate') ||
333
516
  changed.has('templateId') ||
334
517
  changed.has('templateName') ||
335
518
  changed.has('templateQuery')
336
519
  ) {
337
520
  this._maybeSendContent()
338
521
  }
522
+ // Config is independent of the template: it also has to reach DAM-backed
523
+ // embeds, which never send a content request. Only once the app is ready,
524
+ // though — before that there is nothing listening, and the ready signal
525
+ // sends whatever the latest value is anyway.
526
+ if (
527
+ (changed.has('customMetadata') || changed.has('customMetadataLabel')) &&
528
+ this._status === 'ready'
529
+ ) {
530
+ this._maybeSendConfig()
531
+ }
339
532
  }
340
533
 
341
534
  render() {
@@ -477,6 +670,17 @@ export class SfxTemplateBuilder extends LitElement {
477
670
  this._status = 'ready'
478
671
  this._emit('ready')
479
672
  }
673
+ // Config rides the ready signal: the app has its listener attached by
674
+ // the time it announces, and a remount inside an unchanged iframe
675
+ // re-announces — so this is also the resend point after the editor
676
+ // reloads and forgets what it was told.
677
+ //
678
+ // Keyed on READY alone, because a single mount announces READY *and*
679
+ // OPEN and resetting on both would post the same config twice. OPEN
680
+ // still calls in, without the reset: after a READY that is a no-op, and
681
+ // it is the only signal an app deployment older than protocol v1 sends.
682
+ if (msg.type === BUILDER_READY) this._sentConfigKey = undefined
683
+ this._maybeSendConfig()
480
684
  if (msg.type === BUILDER_OPEN) this._emit('open')
481
685
  break
482
686
  case BUILDER_SAVE:
@@ -489,6 +693,7 @@ export class SfxTemplateBuilder extends LitElement {
489
693
  // iframe, so resend even if this content went out already — otherwise
490
694
  // the editor waits on a skeleton forever.
491
695
  this._sentKey = undefined
696
+ this._sentSaveKey = undefined
492
697
  this._maybeSendContent()
493
698
  break
494
699
  case BUILDER_CONTENT: {
@@ -496,11 +701,47 @@ export class SfxTemplateBuilder extends LitElement {
496
701
  // regardless of mode; the detail shape follows the mode they chose.
497
702
  const data = (msg as BuilderContentMessage).data
498
703
  // The saved document is what the editor now holds. A host that stores
499
- // it and echoes it back into `content` — the natural controlled
704
+ // it and echoes it back into the props — the natural controlled
500
705
  // pattern — must not trigger a HOST_LOAD reload that wipes the
501
- // editor's undo history behind a skeleton flash.
706
+ // editor's undo history behind a skeleton flash. TWO keys are
707
+ // recorded because hosts echo different subsets: the prop-based key
708
+ // covers "content only, other props untouched", and the detail-based
709
+ // key covers "the whole save detail" — whose name and templateQuery
710
+ // (a save nearly always changes the query) differ from the props the
711
+ // template was loaded with.
502
712
  this._sentKey = this._contentKey(data.content)
503
- this._emit('save', data)
713
+ this._sentSaveKey = this._detailKey(data)
714
+ if (this.damStore) {
715
+ // Serialized, not fired in parallel: the app discards a failure ack
716
+ // that has newer saves still outstanding as superseded, which is
717
+ // only sound while acks come back in post order — and ack order
718
+ // follows save-event order. Two racing uploads could swap it.
719
+ this._pendingSaves.add(data)
720
+ // Epoch, folder and credentials are captured now, not when the
721
+ // queued upload runs: the save belongs to the document — and the
722
+ // configuration — the app posted it under, and the host may have
723
+ // swapped both by the time the queue reaches it. Only the
724
+ // known-uuid seed stays live (validated against the epoch), because
725
+ // an earlier queued save of the SAME document must be able to hand
726
+ // its uuid to the next one.
727
+ const session = this._docEpoch
728
+ const job = {
729
+ auth: {
730
+ token: this.token,
731
+ sassKey: this.sassKey,
732
+ secTemplate: this.secTemplate,
733
+ sessionUuid: this.sessionUuid,
734
+ companyUuid: this.companyUuid,
735
+ projectUuid: this.projectUuid,
736
+ },
737
+ storeFolder: this.storeFolder || '/',
738
+ }
739
+ this._storeQueue = this._storeQueue.then(() =>
740
+ this._storeAndEmitSave(data, session, job),
741
+ )
742
+ } else {
743
+ this._emit('save', data)
744
+ }
504
745
  break
505
746
  }
506
747
  case BUILDER_DIRTY: {
@@ -510,6 +751,13 @@ export class SfxTemplateBuilder extends LitElement {
510
751
  break
511
752
  }
512
753
  case BUILDER_CLOSE:
754
+ // Pending `dam-store` uploads are deliberately NOT flushed here: the
755
+ // element outlives a close (inline keeps rendering, modal just drops
756
+ // its overlay), so a save still inside its upload window emits with
757
+ // its real outcome moments later — flushing would hand the host a
758
+ // `storeError` for a copy that lands fine. The paths where waiting
759
+ // genuinely loses the event — element removal — are covered by
760
+ // `disconnectedCallback` and the React wrapper's cleanup flush.
513
761
  this._emit('close')
514
762
  if (this.mode === 'modal') this._open = false
515
763
  break
@@ -519,25 +767,219 @@ export class SfxTemplateBuilder extends LitElement {
519
767
  }
520
768
  }
521
769
 
770
+ /**
771
+ * Pending `dam-store` uploads, chained so saves emit in the order the app
772
+ * posted them. `_storeAndEmitSave` never rejects (its catch emits
773
+ * `storeError`), so the chain cannot wedge.
774
+ */
775
+ private _storeQueue: Promise<void> = Promise.resolve()
776
+
777
+ /**
778
+ * Saves handed over by the app whose `save` event has not fired yet — the
779
+ * upload window. Insertion-ordered; membership is what makes a flush and a
780
+ * completing upload not double-emit the same save.
781
+ */
782
+ private _pendingSaves = new Set<BuilderContentData>()
783
+
784
+ /**
785
+ * Identity (id, content, name — not query) of the last document posted to
786
+ * the app — updated on every ship, and on an accepted save echo (the echoed
787
+ * document IS the current one; leaving the pre-save key here would make a
788
+ * later content-request redelivery look like a new document and wipe the
789
+ * store memory below).
790
+ */
791
+ private _lastDocKey?: string
792
+
793
+ /**
794
+ * Monotonic id of the current DOCUMENT. Bumped in exactly one place: when
795
+ * `_maybeSendContent` ships a genuinely different document (docKey change).
796
+ * Deliberately NOT bumped on iframe/src changes — a modal close, a theme
797
+ * swap or an in-place remount is the same document, and treating it as new
798
+ * forked the file on every such boundary.
799
+ */
800
+ private _docEpoch = 0
801
+
802
+ /**
803
+ * The copy this element last stored, tagged with the epoch of the document
804
+ * it belongs to. Written when an upload lands (with the SAVE's epoch, so a
805
+ * late-landing upload can never masquerade as another document's copy) and
806
+ * validated at read time — there is no eager reset to get wrong.
807
+ */
808
+ private _storeMemory?: { epoch: number; uuid: string }
809
+
810
+ /**
811
+ * Emit every not-yet-emitted `dam-store` save immediately, raw content with
812
+ * `storeError` in place of the links. Called when waiting any longer risks
813
+ * the event finding no listener; a still-running upload for a flushed save
814
+ * is left to finish (the copy usually lands) but will not emit again.
815
+ *
816
+ * Public for framework wrappers: one that unsubscribes its listeners before
817
+ * unmounting must call this first, while they are still attached — the
818
+ * element's own disconnect-time flush fires only after the wrapper has
819
+ * stopped listening, and the raw save would be lost. The React wrapper does
820
+ * this; a vanilla host never needs to call it.
821
+ */
822
+ flushPendingSaves(
823
+ reason = 'widget removed before the rendering copy completed',
824
+ ): void {
825
+ this._flushPendingSaves(reason)
826
+ }
827
+
828
+ private _flushPendingSaves(reason: string): void {
829
+ for (const data of this._pendingSaves) {
830
+ this._pendingSaves.delete(data)
831
+ this._emit('save', { ...data, storeError: reason })
832
+ }
833
+ }
834
+
835
+ /**
836
+ * The `dam-store` save path: upload the edited template to Filerobot, then
837
+ * emit `save` with the stored copy's links on the detail. The upload is the
838
+ * render side of the save — the CDN renders only stored files — while the
839
+ * raw `content` stays the host's copy exactly as without the flag.
840
+ *
841
+ * A failed upload still emits `save` (the raw data must reach the host
842
+ * either way), with `storeError` in place of `stored`; whether a save
843
+ * without a rendering copy counts as saved is the host's decision, made
844
+ * where it always is — the save ack.
845
+ */
846
+ private async _storeAndEmitSave(
847
+ data: BuilderContentData,
848
+ session: number,
849
+ job: { auth: DamStoreAuth; storeFolder: string },
850
+ ): Promise<void> {
851
+ if (!this._pendingSaves.has(data)) return
852
+ // The seeds are validated against the save's own document epoch. The
853
+ // element's memory carries the epoch it was recorded under; the host's
854
+ // `storedUuid` property is live and describes the CURRENT document, so it
855
+ // only applies while the save's epoch is still the current one — anchoring
856
+ // a stale save's upload to it would put the old document into the new
857
+ // one's file. Folder and credentials come from the job captured at
858
+ // enqueue time, for the same reason.
859
+ const knownUuid =
860
+ (this._storeMemory?.epoch === session
861
+ ? this._storeMemory.uuid
862
+ : undefined) ||
863
+ (session === this._docEpoch ? this.storedUuid || undefined : undefined)
864
+ let stored: StoredTemplate | undefined
865
+ let storeError: string | undefined
866
+ try {
867
+ stored = await storeTemplateInDam(data, job.auth, job.storeFolder, knownUuid)
868
+ } catch (err) {
869
+ storeError = err instanceof Error ? err.message : String(err)
870
+ }
871
+ // A flush mid-upload already delivered this save; the copy (if it landed)
872
+ // is simply not reported. Emitting again would double the host's write —
873
+ // and recording the uuid would poison the session memory of whatever
874
+ // document has loaded since (the flush usually precedes a swap), pointing
875
+ // its next save at this document's file.
876
+ if (!this._pendingSaves.delete(data)) return
877
+ // Recorded under the SAVE's epoch, unconditionally: a copy always belongs
878
+ // to the document it was saved from. A later document's saves carry a
879
+ // higher epoch and simply never match this record — no current-vs-then
880
+ // comparison to get wrong. (Uploads complete in queue order, so the last
881
+ // write is always the newest save.)
882
+ if (stored) this._storeMemory = { epoch: session, uuid: stored.uuid }
883
+ this._emit('save', stored ? { ...data, stored } : { ...data, storeError })
884
+ }
885
+
522
886
  /**
523
887
  * Deliver `content` to the app once both sides are ready: it has asked, and
524
888
  * we have something new to give it. Skips a re-send of identical content so
525
889
  * an unrelated re-render can't discard the user's in-progress edits.
526
890
  */
527
891
  private _maybeSendContent(): void {
528
- if (!this.stateless || !this._contentRequested || !this.content) return
892
+ if (!this.stateless || !this._contentRequested) return
893
+
894
+ // Empty `content` means the host has nothing to give yet — usually a fetch
895
+ // in flight — so the editor keeps waiting. Only `new-template` turns that
896
+ // into a document, and only until the host does supply one.
897
+ const content =
898
+ this.content || (this.newTemplate ? BLANK_TEMPLATE_XML : '')
899
+ if (!content) return
529
900
 
530
901
  const data = {
531
902
  templateId: this.templateId || undefined,
532
- content: this.content,
903
+ content,
533
904
  name: this.templateName || undefined,
534
905
  templateQuery: this.templateQuery || undefined,
535
906
  }
536
- const key = this._contentKey(this.content)
537
- if (key === this._sentKey) return
907
+ const key = this._contentKey(content)
908
+ // The document's identity — id, content, name; NOT the query. A
909
+ // query-only resend ("same XML, different render") and a content-request
910
+ // redelivery after an in-place remount are the same document, and
911
+ // treating them as new would orphan the stored copy.
912
+ const docKey = JSON.stringify({
913
+ templateId: this.templateId || undefined,
914
+ content,
915
+ name: this.templateName || undefined,
916
+ })
917
+ // `_sentSaveKey` counts as delivered too: matching it means the host has
918
+ // echoed the last save back into the props, and the editor already holds
919
+ // exactly that document — reloading would wipe its undo history. The doc
920
+ // key is still refreshed: the echoed document IS the current one, and
921
+ // leaving the pre-save key in place would make the next redelivery of
922
+ // this same content look like a document change and orphan the copy.
923
+ if (key === this._sentKey || key === this._sentSaveKey) {
924
+ this._lastDocKey = docKey
925
+ return
926
+ }
538
927
 
539
928
  if (!this._postToApp({ type: HOST_LOAD, data })) return
929
+ // A genuinely different document starts a new epoch — the file the
930
+ // previous one made must not resolve this one's folder or unchanged
931
+ // re-saves.
932
+ if (docKey !== this._lastDocKey) this._docEpoch++
933
+ this._lastDocKey = docKey
540
934
  this._sentKey = key
935
+ // A new document shipped: the previous save's echo key no longer names
936
+ // what the editor holds, and matching it later would wrongly skip a load.
937
+ this._sentSaveKey = undefined
938
+ }
939
+
940
+ /**
941
+ * Deliver host config to the app. Unlike content this is not requested — the
942
+ * app has no way to know a host means to send any — so it goes out on the
943
+ * ready signal and on every later change.
944
+ *
945
+ * Sending an empty model is meaningful: it is how a host clears one it set
946
+ * before. What is skipped is only a *repeat* of what the app already holds,
947
+ * and the very first send when there was never anything to say.
948
+ */
949
+ private _maybeSendConfig(): void {
950
+ // Hosts are plain JS as often as not, and the property has no converter to
951
+ // vet what lands on it the way the attribute does. Anything that is not a
952
+ // list of fields is treated as no model rather than posted onward as
953
+ // something the app would have to make sense of.
954
+ const customMetadata = Array.isArray(this.customMetadata)
955
+ ? this.customMetadata
956
+ : []
957
+ const customMetadataLabel =
958
+ typeof this.customMetadataLabel === 'string'
959
+ ? this.customMetadataLabel.trim()
960
+ : ''
961
+ const key = JSON.stringify({ customMetadata, customMetadataLabel })
962
+ if (key === this._sentConfigKey) return
963
+ if (
964
+ this._sentConfigKey === undefined &&
965
+ customMetadata.length === 0 &&
966
+ customMetadataLabel === ''
967
+ ) {
968
+ return
969
+ }
970
+
971
+ if (
972
+ !this._postToApp({
973
+ type: HOST_CONFIG,
974
+ data: {
975
+ customMetadata,
976
+ ...(customMetadataLabel ? { customMetadataLabel } : {}),
977
+ },
978
+ })
979
+ ) {
980
+ return
981
+ }
982
+ this._sentConfigKey = key
541
983
  }
542
984
 
543
985
  /** Identity of a delivered template, as compared against `_sentKey`. */
@@ -550,6 +992,20 @@ export class SfxTemplateBuilder extends LitElement {
550
992
  })
551
993
  }
552
994
 
995
+ /**
996
+ * Identity of a save the app handed back — the same shape as
997
+ * `_contentKey`, but built from the save detail rather than the props, so
998
+ * a host echoing the detail (whose name/query the save changed) matches.
999
+ */
1000
+ private _detailKey(data: BuilderContentData): string {
1001
+ return JSON.stringify({
1002
+ templateId: data.templateId || undefined,
1003
+ content: data.content,
1004
+ name: data.name || undefined,
1005
+ templateQuery: data.templateQuery || undefined,
1006
+ })
1007
+ }
1008
+
553
1009
  /** Post into the iframe, targeted at the app origin. False if not mounted. */
554
1010
  private _postToApp(message: unknown): boolean {
555
1011
  const target = this.shadowRoot?.querySelector('iframe')?.contentWindow