@scaleflex/template-builder 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.
@@ -0,0 +1,599 @@
1
+ import { LitElement, html, css, nothing, type PropertyValues } from 'lit'
2
+ import { property, state } from 'lit/decorators.js'
3
+ import {
4
+ BUILDER_CLOSE,
5
+ BUILDER_CONTENT,
6
+ BUILDER_CONTENT_REQUEST,
7
+ BUILDER_DIRTY,
8
+ BUILDER_ERROR,
9
+ BUILDER_OPEN,
10
+ BUILDER_READY,
11
+ BUILDER_SAVE,
12
+ EMBED_PARAMS,
13
+ EMBED_ROUTE,
14
+ HOST_LOAD,
15
+ HOST_SAVED,
16
+ builderRoute,
17
+ type BuilderContentData,
18
+ type BuilderContentMessage,
19
+ type BuilderDirtyData,
20
+ type BuilderDirtyMessage,
21
+ type BuilderErrorData,
22
+ type BuilderErrorMessage,
23
+ type BuilderSaveData,
24
+ type BuilderSaveMessage,
25
+ type BuilderTheme,
26
+ } from './protocol'
27
+
28
+ export type TemplateBuilderStatus = 'idle' | 'loading' | 'ready' | 'error'
29
+
30
+ /**
31
+ * `save` payload. Which variant arrives follows the mode the element was
32
+ * configured in:
33
+ * - DAM-backed (default) — `BuilderSaveData`; the app uploaded the template and
34
+ * reports the resulting `uuid`.
35
+ * - `stateless` — `BuilderContentData`; nothing was stored, and `content` is
36
+ * the edited template for the host to persist.
37
+ */
38
+ export type TemplateBuilderSaveDetail =
39
+ | BuilderSaveData
40
+ | BuilderContentData
41
+ | undefined
42
+
43
+ export interface TemplateBuilderEventMap {
44
+ ready: CustomEvent<void>
45
+ open: CustomEvent<void>
46
+ close: CustomEvent<void>
47
+ save: CustomEvent<TemplateBuilderSaveDetail>
48
+ error: CustomEvent<BuilderErrorData>
49
+ dirtychange: CustomEvent<BuilderDirtyData>
50
+ }
51
+
52
+ /**
53
+ * `<sfx-template-builder>` — embeds the Filerobot design-templates builder.
54
+ *
55
+ * The element owns an iframe pointed at a design-templates-app deployment,
56
+ * passes auth via URL params (converted to cookies by the app's proxy), and
57
+ * translates the app's postMessage protocol into DOM CustomEvents:
58
+ * `ready`, `open`, `close`, `save`, `error`.
59
+ *
60
+ * Required: `base-url`, `token`, and one of two credentials:
61
+ * - `sass-key` + `session-uuid` — a Hub session. Full features.
62
+ * - `sec-template` — a Filerobot security-template key. No Hub session needed,
63
+ * but it only works with `stateless`, and Hub-project features (metadata
64
+ * fields, regional variants, project branding) come back empty.
65
+ *
66
+ * In `inline` mode the editor loads as soon as config is complete and fills
67
+ * the host element (size it explicitly). In `modal` mode nothing renders
68
+ * until `open()` is called; the editor then covers the viewport.
69
+ *
70
+ * Two ways to supply the template:
71
+ * - **DAM-backed** (default) — set `template-id` to a Filerobot file uuid. The
72
+ * app loads and saves it itself, and `save` reports the new uuid.
73
+ * - **Stateless** — set `stateless` and assign `content`. The element sends the
74
+ * template into the editor over postMessage and `save` returns the edited
75
+ * document; nothing is stored on the Scaleflex side, and `template-id` is
76
+ * just an opaque string echoed back. Rendering, fonts and asset browsing
77
+ * still use the session's Filerobot tenant.
78
+ *
79
+ * `brand-color` and `theme` restyle the editor chrome to match the host page.
80
+ * They do not touch the rendered template — its colours live in the document.
81
+ */
82
+ export class SfxTemplateBuilder extends LitElement {
83
+ static styles = css`
84
+ :host {
85
+ display: block;
86
+ position: relative;
87
+ }
88
+ :host([mode='modal']) {
89
+ display: contents;
90
+ }
91
+ .overlay {
92
+ position: fixed;
93
+ inset: 0;
94
+ z-index: 2147483000;
95
+ background: rgba(0, 0, 0, 0.55);
96
+ display: flex;
97
+ }
98
+ .stage {
99
+ position: relative;
100
+ flex: 1;
101
+ display: flex;
102
+ }
103
+ iframe {
104
+ border: 0;
105
+ flex: 1;
106
+ width: 100%;
107
+ height: 100%;
108
+ }
109
+ .spinner {
110
+ position: absolute;
111
+ inset: 0;
112
+ margin: auto;
113
+ width: 32px;
114
+ height: 32px;
115
+ border: 3px solid rgba(128, 128, 128, 0.3);
116
+ border-top-color: currentColor;
117
+ border-radius: 50%;
118
+ animation: sfx-tb-spin 0.8s linear infinite;
119
+ pointer-events: none;
120
+ }
121
+ @keyframes sfx-tb-spin {
122
+ to {
123
+ transform: rotate(360deg);
124
+ }
125
+ }
126
+ `
127
+
128
+ /** Origin + optional path prefix of the design-templates-app deployment. */
129
+ @property({ attribute: 'base-url' }) baseUrl = ''
130
+ /** Filerobot token (`ftoken`). */
131
+ @property() token = ''
132
+ @property({ attribute: 'sass-key' }) sassKey = ''
133
+ @property({ attribute: 'session-uuid' }) sessionUuid = ''
134
+ /**
135
+ * Filerobot security-template key — the alternative to `sass-key` +
136
+ * `session-uuid` for hosts with no Hub session to hand over. Requires
137
+ * `stateless`, and degrades the features that come from the Hub project
138
+ * model (metadata fields, regional variants, project branding). When set it
139
+ * wins: neither `sass-key` nor `session-uuid` is passed to the app.
140
+ */
141
+ @property({ attribute: 'sec-template' }) secTemplate = ''
142
+ @property({ attribute: 'company-uuid' }) companyUuid = ''
143
+ @property({ attribute: 'project-uuid' }) projectUuid = ''
144
+ /**
145
+ * DAM-backed mode: the Filerobot uuid to load; empty opens the new-template
146
+ * flow. Stateless mode: an opaque host id, echoed back on `save`.
147
+ */
148
+ @property({ attribute: 'template-id' }) templateId = ''
149
+ @property({ reflect: true }) mode: 'inline' | 'modal' = 'inline'
150
+ /**
151
+ * Hand the template in and take it back out instead of letting the app read
152
+ * and write Filerobot. Requires `content`.
153
+ */
154
+ @property({ type: Boolean, reflect: true }) stateless = false
155
+ /**
156
+ * Stateless mode: the template to edit, as `.fdt` XML. Property only — templates
157
+ * routinely exceed practical attribute/URL sizes, so it is never reflected.
158
+ * Assigning a different value while open loads it into the running editor.
159
+ */
160
+ @property({ attribute: false }) content = ''
161
+ /** Stateless mode: display name for the editor header. */
162
+ @property({ attribute: 'template-name' }) templateName = ''
163
+ /**
164
+ * Stateless mode: the `template_query` to open on — the value handed back in
165
+ * the `save` payload. Pass back what you stored and the editor reopens on the
166
+ * same layout and variable values; leave it empty and the render falls back
167
+ * to the XML's own `default=` attributes.
168
+ */
169
+ @property({ attribute: 'template-query' }) templateQuery = ''
170
+ /**
171
+ * Accent colour for the editor chrome, as `#rgb` / `#rrggbb`. The app derives
172
+ * buttons, focus rings and highlights from it. Empty keeps the Scaleflex
173
+ * default. Themes the editor UI only — never the rendered template, whose
174
+ * colours live in the document.
175
+ */
176
+ @property({ attribute: 'brand-color' }) brandColor = ''
177
+ /** Colour scheme for the editor chrome. Empty leaves the app's own default. */
178
+ @property() theme: BuilderTheme | '' = ''
179
+ /** Ms to wait for the app's ready signal before emitting `error`. 0 disables. */
180
+ @property({ type: Number, attribute: 'ready-timeout' }) readyTimeout = 20000
181
+
182
+ @state() private _status: TemplateBuilderStatus = 'idle'
183
+ @state() private _open = false
184
+ @state() private _src = ''
185
+
186
+ private _handshakeTimer?: number
187
+ /**
188
+ * The app asked for content. Tracked because the request and the `content`
189
+ * assignment race: whichever lands second triggers the send.
190
+ */
191
+ private _contentRequested = false
192
+ /**
193
+ * Identity of the template already delivered, so an unrelated re-render does
194
+ * not resend it and discard the user's edits. Covers the id and name too, not
195
+ * just the content: two host records can hold byte-identical templates, and
196
+ * resending only on content change would leave the app echoing a stale id
197
+ * back on save.
198
+ */
199
+ private _sentKey?: string
200
+ /**
201
+ * The `baseUrl` value already reported as unparseable. `_computeSrc()` runs
202
+ * on every update cycle, so without this a bad URL re-emits `error` forever —
203
+ * once per render, since the error status it sets is already in place after
204
+ * the first.
205
+ */
206
+ private _reportedBadBaseUrl?: string
207
+ /**
208
+ * Whether the sec-template-without-stateless mistake has been reported. Same
209
+ * reason as `_reportedBadBaseUrl`: `_computeSrc()` runs every update cycle
210
+ * and the error status it sets is already in place after the first pass.
211
+ */
212
+ private _reportedStatelessRequired = false
213
+
214
+ @state() private _isDirty = false
215
+
216
+ get status(): TemplateBuilderStatus {
217
+ return this._status
218
+ }
219
+
220
+ /**
221
+ * Stateless mode: whether the editor holds edits that have not been handed
222
+ * back yet. Check this before calling `load()` — a swap discards them.
223
+ * Always false in DAM-backed mode, where the app owns saving.
224
+ */
225
+ get isDirty(): boolean {
226
+ return this._isDirty
227
+ }
228
+
229
+ /** Open the editor (loads the iframe). Optionally switch template first. */
230
+ open(templateId?: string): void {
231
+ if (templateId !== undefined) this.templateId = templateId
232
+ this._open = true
233
+ }
234
+
235
+ /** Close the editor and unload the iframe. Does not emit `close`. */
236
+ close(): void {
237
+ this._open = false
238
+ }
239
+
240
+ /**
241
+ * Stateless mode: load a template, opening the editor if needed. Equivalent
242
+ * to assigning `templateId` / `content` / `templateName` and calling `open()`.
243
+ */
244
+ load({
245
+ content,
246
+ templateId,
247
+ name,
248
+ templateQuery,
249
+ }: {
250
+ content: string
251
+ templateId?: string
252
+ name?: string
253
+ templateQuery?: string
254
+ }): void {
255
+ if (templateId !== undefined) this.templateId = templateId
256
+ if (name !== undefined) this.templateName = name
257
+ // Assigned before `content`: all four ship as one HOST_LOAD, and leaving a
258
+ // previous template's query in place while the new content goes out would
259
+ // open the new document on the old layout and values.
260
+ if (templateQuery !== undefined) this.templateQuery = templateQuery
261
+ this.content = content
262
+ this._open = true
263
+ }
264
+
265
+ /**
266
+ * Stateless mode: report back whether a `save` was persisted on your side.
267
+ *
268
+ * Optional. The editor clears its unsaved-changes flag as soon as it hands
269
+ * the content over, so not calling this leaves the previous behaviour intact.
270
+ * Calling it with `false` is what earns something: the editor restores the
271
+ * dirty flag and tells the user, rather than showing a failed write as saved.
272
+ *
273
+ * No-op outside stateless mode, where the app did the saving and has nothing
274
+ * to hear back about.
275
+ */
276
+ confirmSave(ok: boolean, message?: string): void {
277
+ if (!this.stateless) return
278
+ this._postToApp({ type: HOST_SAVED, data: { ok, message } })
279
+ }
280
+
281
+ /**
282
+ * Whether the inline-implies-open decision has been made. It cannot be made
283
+ * in `connectedCallback`: frameworks insert the element first and assign
284
+ * properties afterwards in the same task (the React wrapper does), so at
285
+ * connect time `mode` may still hold its `'inline'` default — deciding there
286
+ * flashes a modal's full-viewport overlay open on mount. By the first update
287
+ * cycle the real value has settled.
288
+ */
289
+ private _autoOpenDecided = false
290
+
291
+ connectedCallback(): void {
292
+ super.connectedCallback()
293
+ window.addEventListener('message', this._onMessage)
294
+ }
295
+
296
+ disconnectedCallback(): void {
297
+ super.disconnectedCallback()
298
+ window.removeEventListener('message', this._onMessage)
299
+ this._clearHandshakeTimer()
300
+ }
301
+
302
+ protected willUpdate(changed: PropertyValues): void {
303
+ super.willUpdate(changed)
304
+ if (!this._autoOpenDecided) {
305
+ this._autoOpenDecided = true
306
+ if (this.mode === 'inline') this._open = true
307
+ }
308
+ const src = this._computeSrc()
309
+ if (src !== this._src) {
310
+ this._src = src
311
+ this._status = src ? 'loading' : 'idle'
312
+ }
313
+ }
314
+
315
+ protected updated(changed: PropertyValues): void {
316
+ if (changed.has('_src')) {
317
+ this._clearHandshakeTimer()
318
+ // A new document means a new app instance: it has not asked for content
319
+ // yet, and nothing has been delivered to it.
320
+ this._contentRequested = false
321
+ this._sentKey = undefined
322
+ if (this._isDirty) {
323
+ this._isDirty = false
324
+ this._emit('dirtychange', { isDirty: false })
325
+ }
326
+ if (this._src) this._startHandshakeTimer()
327
+ }
328
+ // Swapping any part of the template on a running editor reloads it. The id
329
+ // matters as much as the content: a host moving between two identical
330
+ // templates must not leave the app saving under the previous id.
331
+ if (
332
+ changed.has('content') ||
333
+ changed.has('templateId') ||
334
+ changed.has('templateName') ||
335
+ changed.has('templateQuery')
336
+ ) {
337
+ this._maybeSendContent()
338
+ }
339
+ }
340
+
341
+ render() {
342
+ const frame = this._src
343
+ ? html`<iframe
344
+ part="iframe"
345
+ title="Template builder"
346
+ src=${this._src}
347
+ allow="clipboard-read; clipboard-write"
348
+ ></iframe>`
349
+ : nothing
350
+ const spinner =
351
+ this._status === 'loading'
352
+ ? html`<div class="spinner" part="spinner"></div>`
353
+ : nothing
354
+
355
+ if (this.mode === 'modal') {
356
+ return this._open
357
+ ? html`<div class="overlay" part="overlay">
358
+ <div class="stage">${frame}${spinner}</div>
359
+ </div>`
360
+ : nothing
361
+ }
362
+ return html`${frame}${spinner}`
363
+ }
364
+
365
+ private _computeSrc(): string {
366
+ if (!this._open) return ''
367
+ if (!this.baseUrl || !this.token) return ''
368
+ if (this.secTemplate) {
369
+ // A security template is a guest credential with no user identity behind
370
+ // it, so the app takes it on the stateless route only. Saying so here
371
+ // turns a config mistake into a message instead of a login redirect the
372
+ // host sees as `handshake-timeout`.
373
+ if (!this.stateless) {
374
+ if (!this._reportedStatelessRequired) {
375
+ this._reportedStatelessRequired = true
376
+ queueMicrotask(() =>
377
+ this._fail({
378
+ code: 'invalid-config',
379
+ message:
380
+ 'sec-template requires stateless mode — the app accepts a ' +
381
+ 'security template on the stateless embed route only.',
382
+ }),
383
+ )
384
+ }
385
+ return ''
386
+ }
387
+ // Cleared on a valid pass so a host that fixes the combination and later
388
+ // breaks it again is told again, matching `_reportedBadBaseUrl`.
389
+ this._reportedStatelessRequired = false
390
+ } else if (!this.sassKey || !this.sessionUuid) {
391
+ return ''
392
+ }
393
+ let url: URL
394
+ try {
395
+ // Stateless mode keeps the id out of the URL — it is a host-side value
396
+ // the app never resolves, and the content arrives by postMessage.
397
+ const route = this.stateless
398
+ ? EMBED_ROUTE
399
+ : builderRoute(this.templateId || undefined)
400
+ // Resolve relative to the base, not the origin: routes are absolute
401
+ // paths, and `new URL('/x', 'https://host/app')` would silently drop
402
+ // the documented path prefix, 404 on subpath deployments, and surface
403
+ // only as a handshake-timeout.
404
+ const base = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`
405
+ url = new URL(route.replace(/^\//, ''), base)
406
+ } catch {
407
+ // Report each bad value once. This runs on every update cycle, and
408
+ // `_fail` sets a status that is already 'error' by the second pass, so
409
+ // there is no state change to fall out of the loop on.
410
+ if (this._reportedBadBaseUrl !== this.baseUrl) {
411
+ this._reportedBadBaseUrl = this.baseUrl
412
+ // Emitted from a state-compute path; defer so consumers attached after
413
+ // this update cycle still receive it.
414
+ queueMicrotask(() =>
415
+ this._fail({
416
+ code: 'invalid-base-url',
417
+ message: `base-url is not a valid URL: ${this.baseUrl}`,
418
+ }),
419
+ )
420
+ }
421
+ return ''
422
+ }
423
+ this._reportedBadBaseUrl = undefined
424
+ url.searchParams.set(EMBED_PARAMS.FILEROBOT_TOKEN, this.token)
425
+ if (this.secTemplate) {
426
+ // Exclusive with the session credentials: the app reads the mode off
427
+ // which of the two arrived, and the Hub uuids below name a project it
428
+ // cannot look up without a session anyway.
429
+ url.searchParams.set(EMBED_PARAMS.SEC_TEMPLATE, this.secTemplate)
430
+ } else {
431
+ url.searchParams.set(EMBED_PARAMS.SASS_KEY, this.sassKey)
432
+ url.searchParams.set(EMBED_PARAMS.SESSION_UUID, this.sessionUuid)
433
+ if (this.companyUuid) {
434
+ url.searchParams.set(EMBED_PARAMS.COMPANY_UUID, this.companyUuid)
435
+ }
436
+ if (this.projectUuid) {
437
+ url.searchParams.set(EMBED_PARAMS.PROJECT_UUID, this.projectUuid)
438
+ }
439
+ }
440
+ if (this.brandColor) {
441
+ url.searchParams.set(EMBED_PARAMS.BRAND_COLOR, this.brandColor)
442
+ }
443
+ if (this.theme) {
444
+ url.searchParams.set(EMBED_PARAMS.THEME, this.theme)
445
+ }
446
+ url.searchParams.set(EMBED_PARAMS.IFRAME, '1')
447
+ url.searchParams.set(EMBED_PARAMS.EMBED_ORIGIN, window.location.origin)
448
+ return url.toString()
449
+ }
450
+
451
+ private get _appOrigin(): string | null {
452
+ try {
453
+ return new URL(this.baseUrl).origin
454
+ } catch {
455
+ return null
456
+ }
457
+ }
458
+
459
+ private _onMessage = (event: MessageEvent): void => {
460
+ if (!this._open) return
461
+ if (!event.origin || event.origin !== this._appOrigin) return
462
+ const iframe = this.shadowRoot?.querySelector('iframe')
463
+ if (!iframe) return
464
+ // Ignore messages from other frames of the same app origin. Strict: a
465
+ // missing source is not given the benefit of the doubt — the app side
466
+ // (`readHostLoadMessage`) applies the same rule.
467
+ if (event.source !== iframe.contentWindow) return
468
+
469
+ const msg = event.data as { type?: unknown } | null
470
+ if (!msg || typeof msg.type !== 'string') return
471
+
472
+ switch (msg.type) {
473
+ case BUILDER_READY:
474
+ case BUILDER_OPEN:
475
+ this._clearHandshakeTimer()
476
+ if (this._status !== 'ready') {
477
+ this._status = 'ready'
478
+ this._emit('ready')
479
+ }
480
+ if (msg.type === BUILDER_OPEN) this._emit('open')
481
+ break
482
+ case BUILDER_SAVE:
483
+ this._emit('save', (msg as BuilderSaveMessage).data)
484
+ break
485
+ case BUILDER_CONTENT_REQUEST:
486
+ this._contentRequested = true
487
+ // A fresh request is authoritative: the app is saying it holds no
488
+ // template. It may have remounted or reloaded inside an unchanged
489
+ // iframe, so resend even if this content went out already — otherwise
490
+ // the editor waits on a skeleton forever.
491
+ this._sentKey = undefined
492
+ this._maybeSendContent()
493
+ break
494
+ case BUILDER_CONTENT: {
495
+ // Stateless save. Surfaced as `save` so hosts have one event to bind
496
+ // regardless of mode; the detail shape follows the mode they chose.
497
+ const data = (msg as BuilderContentMessage).data
498
+ // The saved document is what the editor now holds. A host that stores
499
+ // it and echoes it back into `content` — the natural controlled
500
+ // pattern — must not trigger a HOST_LOAD reload that wipes the
501
+ // editor's undo history behind a skeleton flash.
502
+ this._sentKey = this._contentKey(data.content)
503
+ this._emit('save', data)
504
+ break
505
+ }
506
+ case BUILDER_DIRTY: {
507
+ const data = (msg as BuilderDirtyMessage).data
508
+ this._isDirty = !!data?.isDirty
509
+ this._emit('dirtychange', { isDirty: this._isDirty })
510
+ break
511
+ }
512
+ case BUILDER_CLOSE:
513
+ this._emit('close')
514
+ if (this.mode === 'modal') this._open = false
515
+ break
516
+ case BUILDER_ERROR:
517
+ this._fail((msg as BuilderErrorMessage).data ?? { code: 'unknown' })
518
+ break
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Deliver `content` to the app once both sides are ready: it has asked, and
524
+ * we have something new to give it. Skips a re-send of identical content so
525
+ * an unrelated re-render can't discard the user's in-progress edits.
526
+ */
527
+ private _maybeSendContent(): void {
528
+ if (!this.stateless || !this._contentRequested || !this.content) return
529
+
530
+ const data = {
531
+ templateId: this.templateId || undefined,
532
+ content: this.content,
533
+ name: this.templateName || undefined,
534
+ templateQuery: this.templateQuery || undefined,
535
+ }
536
+ const key = this._contentKey(this.content)
537
+ if (key === this._sentKey) return
538
+
539
+ if (!this._postToApp({ type: HOST_LOAD, data })) return
540
+ this._sentKey = key
541
+ }
542
+
543
+ /** Identity of a delivered template, as compared against `_sentKey`. */
544
+ private _contentKey(content: string): string {
545
+ return JSON.stringify({
546
+ templateId: this.templateId || undefined,
547
+ content,
548
+ name: this.templateName || undefined,
549
+ templateQuery: this.templateQuery || undefined,
550
+ })
551
+ }
552
+
553
+ /** Post into the iframe, targeted at the app origin. False if not mounted. */
554
+ private _postToApp(message: unknown): boolean {
555
+ const target = this.shadowRoot?.querySelector('iframe')?.contentWindow
556
+ const appOrigin = this._appOrigin
557
+ if (!target || !appOrigin) return false
558
+ target.postMessage(message, appOrigin)
559
+ return true
560
+ }
561
+
562
+ private _startHandshakeTimer(): void {
563
+ if (this.readyTimeout <= 0) return
564
+ this._handshakeTimer = window.setTimeout(() => {
565
+ this._fail({
566
+ code: 'handshake-timeout',
567
+ message:
568
+ `No ready signal from ${this.baseUrl} within ${this.readyTimeout}ms. ` +
569
+ 'Check that this origin is in the app\'s frame-ancestors allowlist ' +
570
+ 'and that third-party cookies are not blocked.',
571
+ })
572
+ }, this.readyTimeout)
573
+ }
574
+
575
+ private _clearHandshakeTimer(): void {
576
+ if (this._handshakeTimer !== undefined) {
577
+ window.clearTimeout(this._handshakeTimer)
578
+ this._handshakeTimer = undefined
579
+ }
580
+ }
581
+
582
+ private _fail(data: BuilderErrorData): void {
583
+ this._clearHandshakeTimer()
584
+ this._status = 'error'
585
+ this._emit('error', data)
586
+ }
587
+
588
+ private _emit<T>(name: string, detail?: T): void {
589
+ this.dispatchEvent(
590
+ new CustomEvent(name, { detail, bubbles: true, composed: true }),
591
+ )
592
+ }
593
+ }
594
+
595
+ declare global {
596
+ interface HTMLElementTagNameMap {
597
+ 'sfx-template-builder': SfxTemplateBuilder
598
+ }
599
+ }