@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.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  <h1 align="center">@scaleflex/template-builder</h1>
8
8
 
9
9
  <p align="center">
10
- Framework-agnostic Design Template Builder Web Component for <a href="https://www.scaleflex.com/page/digital-asset-management">Filerobot</a>.<br>
10
+ Framework-agnostic Design Template Builder Web Component for <a href="https://www.scaleflex.com/page/digital-asset-management">Scaleflex</a>.<br>
11
11
  Drop a full multi-layout template editor into any page, in any frontend stack.
12
12
  </p>
13
13
 
@@ -30,13 +30,17 @@
30
30
  - [Quick Start](#quick-start)
31
31
  - [Vanilla JS / Web Component](#vanilla-js--web-component)
32
32
  - [React](#react)
33
+ - [Hub session (internal)](#hub-session-internal)
33
34
  - [Modes](#modes)
34
35
  - [DAM-backed](#dam-backed-default)
35
36
  - [Stateless](#stateless)
37
+ - [Storing a rendering copy (dam-store)](#storing-a-rendering-copy-dam-store)
38
+ - [Starting a template from scratch](#starting-a-template-from-scratch)
36
39
  - [Reporting a failed save](#reporting-a-failed-save)
37
40
  - [Configuration](#configuration)
38
41
  - [Attributes & properties](#attributes--properties)
39
42
  - [Authentication](#authentication)
43
+ - [Custom metadata fields](#custom-metadata-fields)
40
44
  - [Origin registration](#origin-registration)
41
45
  - [Cookies](#cookies)
42
46
  - [Public Methods](#public-methods)
@@ -56,11 +60,11 @@
56
60
 
57
61
  ## Overview
58
62
 
59
- `<sfx-template-builder>` embeds the Filerobot design-templates builder in a page
60
- on any origin. The element owns an iframe pointed at a `design-templates-app`
61
- deployment, hands over the credentials, and translates the app's
62
- postMessage protocol into ordinary DOM `CustomEvent`s — so from your side it
63
- behaves like any other component.
63
+ `<sfx-template-builder>` embeds the Scaleflex design-templates builder in a page
64
+ on any origin. The element loads the editor from a `design-templates-app`
65
+ deployment, hands over the credentials, and surfaces everything the editor does
66
+ as ordinary DOM `CustomEvent`s — so from your side it behaves like any other
67
+ component.
64
68
 
65
69
  All the heavy lifting — server-side text rendering, font resolution, asset
66
70
  browsing, template XML — stays inside the app deployment. The widget is a
@@ -70,30 +74,33 @@ protocol adapter.
70
74
 
71
75
  - **Framework-agnostic** — a custom element; works in vanilla JS, React, Vue,
72
76
  Angular, Svelte. A thin React wrapper ships in the box.
73
- - **Two storage modes** — let Filerobot own the template, or keep the document
77
+ - **Two storage modes** — let Scaleflex own the template, or keep the document
74
78
  entirely on your side ([Stateless](#stateless)) and never map your users onto
75
79
  Scaleflex identities.
76
- - **Two ways in** — a Hub session, or a Filerobot [security
80
+ - **Two ways in** — a Hub session, or a Scaleflex [security
77
81
  template](#security-template-guest-auth) when you have no Hub account to hand
78
82
  over per user.
79
83
  - **Inline or modal** — fill a box in your layout, or cover the viewport.
84
+ - **New templates without the format** — [`new-template`](#starting-a-template-from-scratch)
85
+ starts an empty document for the user to build; you only ever store what
86
+ comes back.
80
87
  - **Themeable** — one [brand colour](#brand-color) drives the editor's whole
81
88
  accent ramp; light, dark, or follow the OS.
82
89
  - **Origin-checked both ways** — the widget only accepts messages from the app
83
90
  origin; the app only accepts a template from the origin pinned when the
84
91
  session was handed over.
85
92
  - **Diagnosable failures** — auth, blocked cookies, bad content and handshake
86
- timeouts all surface as an `error` event instead of a stuck iframe.
93
+ timeouts all surface as an `error` event instead of a stuck editor.
87
94
 
88
95
  ## Requirements
89
96
 
90
- - A [Filerobot](https://www.scaleflex.com/page/digital-asset-management) account
97
+ - A [Scaleflex](https://www.scaleflex.com/page/digital-asset-management) account
91
98
  and a running `design-templates-app` deployment to point `base-url` at
92
99
  - Credentials minted server-side — either a Hub session (`session-uuid` +
93
- **SASS key** + **Filerobot token**) or a **security template** + token for
100
+ **SASS key** + **Scaleflex token**) or a **security template** + token for
94
101
  guest access; see [Authentication](#authentication)
95
- - Your page's origin registered in the deployment's `frame-ancestors` allowlist
96
- — see [Origin registration](#origin-registration)
102
+ - Your page's origin registered in the deployment's embedding allowlist — see
103
+ [Origin registration](#origin-registration)
97
104
  - Modern browser with Custom Elements v1 support (see
98
105
  [Browser Support](#browser-support))
99
106
 
@@ -108,7 +115,7 @@ npm i @scaleflex/template-builder
108
115
  ### CDN
109
116
 
110
117
  ```html
111
- <script type="module" src="https://cdn.scaleflex.com/design-template-builder/0.1.1/template-builder.min.js"></script>
118
+ <script type="module" src="https://cdn.cloudimage.io/template-builder/0.4.0/template-builder.min.js"></script>
112
119
  ```
113
120
 
114
121
  The CDN bundle is self-registering — it defines `<sfx-template-builder>` on
@@ -134,6 +141,15 @@ load, with Lit bundled in. Pin the major version.
134
141
 
135
142
  ## Quick Start
136
143
 
144
+ You need two things from your Scaleflex project: its **token**, and a
145
+ **security template** key — a named, permission-scoped credential you define
146
+ once, the same guest-auth mechanism the other Scaleflex widgets use. No Hub
147
+ account, and no user of yours ever needs a Scaleflex identity. See
148
+ [Authentication](#authentication) for how to scope one.
149
+
150
+ The template document stays on your side: you hand the widget its XML, and the
151
+ edit comes back to you on save. That is [stateless](#stateless) mode.
152
+
137
153
  ### Vanilla JS / Web Component
138
154
 
139
155
  ```html
@@ -143,16 +159,26 @@ load, with Lit bundled in. Pin the major version.
143
159
 
144
160
  <sfx-template-builder
145
161
  base-url="https://<your-design-templates-deployment>"
146
- token="FILEROBOT_TOKEN"
147
- sass-key="SASS_KEY"
148
- session-uuid="SESSION_UUID"
149
- template-id="TEMPLATE_UUID"
162
+ token="PROJECT_TOKEN"
163
+ sec-template="SEC_TEMPLATE_KEY"
164
+ stateless
150
165
  style="display:block;height:800px"
151
166
  ></sfx-template-builder>
152
167
 
153
168
  <script>
154
169
  const builder = document.querySelector('sfx-template-builder')
155
- builder.addEventListener('save', (e) => console.log('saved', e.detail)) // { uuid, name }
170
+
171
+ // 1 — hand it the template to edit
172
+ const { content, name } = await fetch(`/api/templates/${id}`).then((r) => r.json())
173
+ builder.load({ templateId: id, name, content })
174
+
175
+ // 2 — take the edit back and store it
176
+ builder.addEventListener('save', async (e) => {
177
+ const { templateId, content, name, templateQuery } = e.detail
178
+ const ok = await saveToYourApi(templateId, { content, name, templateQuery })
179
+ builder.confirmSave(ok) // false → the editor keeps its unsaved-changes warning
180
+ })
181
+
156
182
  builder.addEventListener('error', (e) => console.error(e.detail)) // { code, message }
157
183
  </script>
158
184
  ```
@@ -164,6 +190,38 @@ Size the element yourself — in `inline` mode it fills the box you give it.
164
190
  ```tsx
165
191
  import { TemplateBuilder } from '@scaleflex/template-builder/react'
166
192
 
193
+ <TemplateBuilder
194
+ stateless
195
+ baseUrl="https://<deployment>"
196
+ token={projectToken}
197
+ secTemplate={secTemplateKey}
198
+ templateId={id}
199
+ name={name}
200
+ content={xml}
201
+ style={{ height: 800 }}
202
+ onSave={async (data) => (await saveToYourApi(data)).ok}
203
+ />
204
+ ```
205
+
206
+ <!-- internal:start -->
207
+ ### Hub session (internal)
208
+
209
+ Scaleflex-side embeds inside the Hub authenticate with a session instead of a
210
+ security template, which unlocks DAM-backed storage and Hub-project features.
211
+ Mint the session server-side; never put a long-lived credential in client code.
212
+
213
+ ```html
214
+ <sfx-template-builder
215
+ base-url="https://<your-design-templates-deployment>"
216
+ token="PROJECT_TOKEN"
217
+ sass-key="SASS_KEY"
218
+ session-uuid="SESSION_UUID"
219
+ template-id="TEMPLATE_UUID"
220
+ style="display:block;height:800px"
221
+ ></sfx-template-builder>
222
+ ```
223
+
224
+ ```tsx
167
225
  <TemplateBuilder
168
226
  baseUrl="https://<deployment>"
169
227
  token={token}
@@ -174,6 +232,7 @@ import { TemplateBuilder } from '@scaleflex/template-builder/react'
174
232
  onSave={(data) => console.log(data)}
175
233
  />
176
234
  ```
235
+ <!-- internal:end -->
177
236
 
178
237
  ---
179
238
 
@@ -181,7 +240,7 @@ import { TemplateBuilder } from '@scaleflex/template-builder/react'
181
240
 
182
241
  ### DAM-backed (default)
183
242
 
184
- `template-id` is a Filerobot file uuid. The app loads the template itself, and
243
+ `template-id` is a DAM file uuid. The app loads the template itself, and
185
244
  Save uploads a new version and reports the resulting uuid on the `save` event.
186
245
  Leave `template-id` empty to open the new-template flow.
187
246
 
@@ -189,7 +248,9 @@ Leave `template-id` empty to open the new-template flow.
189
248
 
190
249
  Set `stateless` to keep the document entirely on your side: you pass the
191
250
  template in, the user edits it, and you get the edited template back. Nothing is
192
- stored on the Scaleflex side, so your app keeps its own storage, versioning,
251
+ stored on the Scaleflex side (unless you opt into
252
+ [`dam-store`](#storing-a-rendering-copy-dam-store) for a rendering copy), so
253
+ your app keeps its own storage, versioning,
193
254
  tenancy and access control, and `template-id` becomes an opaque string that is
194
255
  echoed back untouched.
195
256
 
@@ -203,9 +264,8 @@ point.
203
264
  <sfx-template-builder
204
265
  stateless
205
266
  base-url="https://<deployment>"
206
- token="FILEROBOT_TOKEN"
207
- sass-key="SASS_KEY"
208
- session-uuid="SESSION_UUID"
267
+ token="PROJECT_TOKEN"
268
+ sec-template="SEC_TEMPLATE_KEY"
209
269
  style="display:block;height:800px"
210
270
  ></sfx-template-builder>
211
271
 
@@ -218,8 +278,8 @@ point.
218
278
  const { content, name, templateQuery } = await res.json()
219
279
 
220
280
  // 2 — pass it in. `content` is a property, never an attribute: templates
221
- // routinely exceed what fits in markup or a URL, which is also why the
222
- // widget delivers it over postMessage rather than the iframe src.
281
+ // routinely exceed what fits in markup or a URL, so the widget hands the
282
+ // document to the editor directly rather than through either.
223
283
  // `templateQuery` is what you stored on the last save; it reopens the
224
284
  // template on the same layout and variable values.
225
285
  builder.load({ templateId: id, name, content, templateQuery })
@@ -245,7 +305,7 @@ point.
245
305
  </script>
246
306
  ```
247
307
 
248
- `templateId` is **your** identifier, not a Filerobot uuid. The app never
308
+ `templateId` is **your** identifier, not a DAM uuid. The app never
249
309
  resolves it against anything — it is carried alongside the content and handed
250
310
  straight back on save, so use whatever key maps to your own record.
251
311
 
@@ -270,9 +330,8 @@ function TemplateEditor({ id }: { id: string }) {
270
330
  <TemplateBuilder
271
331
  stateless
272
332
  baseUrl="https://<deployment>"
273
- token={token}
274
- sassKey={sassKey}
275
- sessionUuid={sessionUuid}
333
+ token={projectToken}
334
+ secTemplate={secTemplateKey}
276
335
  // 2 — pass it in.
277
336
  templateId={id}
278
337
  content={tpl.content}
@@ -355,9 +414,10 @@ you navigated away from.
355
414
  #### What stateless does *not* remove
356
415
 
357
416
  Statelessness applies to the **document**, not to the infrastructure. The editor
358
- still needs an authenticated Filerobot tenant for:
417
+ still needs an authenticated Scaleflex tenant for:
359
418
 
360
- - **text rendering** — text layers are rasterized server-side,
419
+ - **text rendering** — text and shape layers are rasterized server-side (see
420
+ [the render round-trip](#why-the-editor-still-calls-a-server) below),
361
421
  - **fonts** — custom fonts are served from the tenant's `.studio/fonts/`,
362
422
  - **asset browsing and upload** — image layers are picked from the DAM,
363
423
  - **metadata variables and regional settings**.
@@ -370,12 +430,198 @@ Images referenced by a template may live on your own CDN, but the render
370
430
  service only fetches from allowlisted hosts — add yours to the deployment's
371
431
  `RENDER_ALLOWED_HOSTS_EXTRA`.
372
432
 
433
+ #### Why the editor still calls a server
434
+
435
+ This is the reason a stateless embed still needs a credential, so it is worth
436
+ being concrete about.
437
+
438
+ A `.fdt` template is not an image. The image only exists once someone requests
439
+ the template's CDN URL, and it is Scaleflex that renders it there — server-side,
440
+ with ImageMagick:
441
+
442
+ ```
443
+ https://<tenant>.filerobot.com/<path>/<template>.fdt?<templateQuery>&force_format=png
444
+ ```
445
+
446
+ The editor's contract is that what you see while editing is what that URL will
447
+ return. That rules out drawing the text in the browser. Line breaking, kerning,
448
+ letter spacing, baseline placement, shrink-to-fit and antialiasing are FreeType
449
+ and ImageMagick behaviours; canvas `fillText` and DOM text go through the
450
+ browser's own shaping and hinting instead, so the same layer lands differently
451
+ in Chrome, Safari and Firefox — and differently from the export in all three. A
452
+ few pixels of drift is enough to move a headline off a product shot. There is
453
+ no JS library that reimplements that layout either: the only faithful
454
+ implementation of ImageMagick's text rendering is ImageMagick.
455
+
456
+ So the editor does not approximate the export — it runs the same engine.
457
+ Text and shape layers are rasterized by an ImageMagick 7 build hosted in your
458
+ `base-url` deployment (the export pipeline drives ImageMagick 7 from PHP; the
459
+ editor drives a WebAssembly build of it), and each layer comes back as a
460
+ transparent PNG that the canvas positions with CSS:
461
+
462
+ ```
463
+ browser — the widget app deployment (base-url)
464
+ ──────────────────── ─────────────────────────
465
+ edit a text layer
466
+ │ batched across layers, debounced
467
+ ├────── POST /api/render-layers ─────▶ ImageMagick (WASM)
468
+ │ layers + fonts + variables ├─ resolve fonts: bundled,
469
+ │ │ then tenant /.studio/fonts
470
+ │ ├─ draw at 3×, downscale
471
+ ◀────── transparent PNG per layer ─────┘
472
+
473
+ └─ position / rotate / fade with CSS — no round trip
474
+ ```
475
+
476
+ Consequences you can observe from the outside:
477
+
478
+ - **Content edits cost a round trip; placement edits do not.** Text, font,
479
+ weight, colour, alignment, letter spacing and box size re-render. Dragging,
480
+ rotating and opacity are CSS transforms on the PNG already in the page, so
481
+ they stay at pointer speed.
482
+ - **Bursts collapse.** Requests are debounced (~300 ms) and batched across
483
+ layers, and an in-flight batch is aborted when you keep typing — so a
484
+ sentence typed at speed costs one render, not one per keystroke.
485
+ - **Layers are drawn at 3× and downscaled**, so preview antialiasing matches
486
+ the export rather than the browser's rasterizer.
487
+ - **Fonts are resolved server-side** against the tenant's `/.studio/fonts`
488
+ folder and cached there, so the page never downloads a rendering engine or a
489
+ font binary per weight. Image layers are the exception — they are plain
490
+ `<img>` elements, drawn by the browser.
491
+
492
+ **That endpoint is authenticated, and it has to be.** It fetches fonts and
493
+ images by URL on the server's behalf, so it is not open to anonymous callers:
494
+ `POST /api/render-layers` requires the credential the widget handed over, and
495
+ without a valid one it answers `401` and text and shape layers simply never
496
+ appear. The same credential authorizes the font list/upload calls and the asset
497
+ picker.
498
+
499
+ In DAM-backed mode a Hub session covers that. A stateless embed has no Hub
500
+ session to hand over — and that is exactly the gap a
501
+ [security template](#security-template-guest-auth) fills: a permission-scoped,
502
+ project-level guest credential that authorizes rendering, fonts and asset
503
+ browsing without authenticating any particular user, and without your users
504
+ existing in Scaleflex at all.
505
+
506
+ ### Storing a rendering copy (dam-store)
507
+
508
+ The raw XML in the `save` event is yours to keep — but the CDN renders only
509
+ files it stores, so previews and production banners need a copy in Filerobot
510
+ too. Set `dam-store` and the element makes that copy itself on every stateless
511
+ save, with the same multipart upload the DAM-backed editor uses, before the
512
+ `save` event fires. The detail then carries the links next to the raw data:
513
+
514
+ ```ts
515
+ builder.damStore = true
516
+ builder.addEventListener('save', (e) => {
517
+ const { content, templateQuery, stored, storeError } = e.detail
518
+ // stored = { uuid, url } — the DAM copy. `url` is the .fdt's CDN URL with
519
+ // its current ?vh= cache key; append templateQuery and it IS a render:
520
+ // <img src=`${stored.url}&${templateQuery}`>
521
+ // (`url` can rarely come back empty — the record read-back after the
522
+ // upload failed; the file is stored regardless, under stored.uuid. And
523
+ // join with '?' instead of '&' if your URL happens to carry no query.)
524
+ })
525
+ ```
526
+
527
+ Added after 0.3.0 — if your pinned CDN bundle predates it, the attribute is
528
+ silently ignored; check the [changelog](./CHANGELOG.md).
529
+
530
+ What to know:
531
+
532
+ - **The raw `content` arrives either way.** A failed upload replaces `stored`
533
+ with `storeError` (a message) — whether a save without a rendering copy
534
+ counts as saved is your call, made where it always is: the save ack
535
+ (`confirmSave(false)` / returning `false` from React's `onSave`).
536
+ - **The raw content never dies with the element.** Closing the editor changes
537
+ nothing: the element outlives a close, so a save still inside its upload
538
+ window simply emits moments later with its real outcome — possibly after
539
+ the `close` event. Only *removing* the element mid-upload flushes the
540
+ pending `save` immediately, raw content with a `storeError` note (the copy,
541
+ if it lands, goes unreported); the React wrapper does the same from its
542
+ unmount cleanup. And rapid saves are serialized, so `save` events always
543
+ arrive in the order the editor posted them.
544
+ - **Same name + folder versions the file in place.** When `template-id` names
545
+ an existing DAM file, its own folder is reused; otherwise new templates land
546
+ in `store-folder` (default `/`). Under a VERSION conflict policy each save is
547
+ its own file row — `stored.uuid` is always the current one, so persist it
548
+ (don't echo it into `template-id`: an id change deliberately reloads the
549
+ editor). The element remembers the copy it last made, so follow-up saves —
550
+ including unchanged re-saves, which resolve to that copy instead of
551
+ erroring — keep working against it even while your own id stays opaque.
552
+ That memory lives in the element instance: to carry it across a page
553
+ reload or remount, pass the persisted uuid back in as `stored-uuid` /
554
+ `storedUuid` (or the `storedUuid` field of `load()`) alongside the content.
555
+ - **The stored `template_query` is record-agnostic.** Values of
556
+ [custom-metadata-bound](#custom-metadata-fields) variables are stripped
557
+ before the query is attached as file metadata, so one record's data never
558
+ becomes the template's own default.
559
+ - **The credential needs upload rights.** A
560
+ [security template](#security-template-guest-auth) must have a scope that
561
+ allows uploads, or every save reports `storeError`.
562
+
563
+ ### Starting a template from scratch
564
+
565
+ A template your user has not created yet has no XML to pass in, and you should
566
+ not have to author one. In [stateless](#stateless) mode, set `new-template`
567
+ instead of `content` and the widget supplies the empty document itself.
568
+ (DAM-backed mode has its own new-template flow — leave `template-id` empty.)
569
+
570
+ ```html
571
+ <sfx-template-builder
572
+ stateless
573
+ new-template
574
+ template-name="Untitled"
575
+ base-url="https://<deployment>"
576
+ token="PROJECT_TOKEN"
577
+ sec-template="SEC_TEMPLATE_KEY"
578
+ style="display:block;height:800px"
579
+ ></sfx-template-builder>
580
+ ```
581
+
582
+ ```js
583
+ // Or imperatively, on an element that is already showing something else.
584
+ builder.createNew({ templateId: 'your-own-id-43', name: 'Untitled' })
585
+ ```
586
+
587
+ ```tsx
588
+ <TemplateBuilder stateless newTemplate templateName="Untitled" … />
589
+ ```
590
+
591
+ The editor opens on its empty state — *"No layouts yet. Click + Add to create
592
+ one."* — and the user picks the canvas size, background and preset there. Save
593
+ is refused until at least one layout exists, so the first `save` you receive
594
+ already carries a complete, well-formed `.fdt` document; store it as `content`
595
+ and every later open is the ordinary [load flow](#the-template-comes-from-your-api).
596
+
597
+ - **`templateId` is optional.** Pass one if your record already exists and you
598
+ want it echoed back; otherwise the `save` payload simply arrives without an
599
+ id and you allocate one when you store it.
600
+ - **`templateQuery` stays empty.** A new document has no layouts and no
601
+ variables, so there is no render for a query to select. You get one back on
602
+ the first save — persist it then.
603
+ - **`content` wins when both are set,** so a host that renders one element for
604
+ both cases can simply pass the XML when it has one.
605
+ - **Empty `content` on its own does not start a blank template.** It means "the
606
+ host has nothing yet" — the editor keeps waiting, which is what lets you
607
+ mount the builder while your fetch is still in flight. Only `new-template`
608
+ turns that wait into a document.
609
+
610
+ Calling `createNew()` again while the blank template is already open does
611
+ nothing: resending would discard whatever the user has built since. Close and
612
+ reopen the editor to genuinely start over.
613
+
614
+ If you would rather ship your own starting point — a house style, a standard
615
+ canvas size, a locked logo layer — pass it as ordinary `content`. A starter
616
+ template is just a template, and `BLANK_TEMPLATE_XML` is exported from
617
+ `@scaleflex/template-builder/protocol` if you want the empty document as a base.
618
+
373
619
  ### Reporting a failed save
374
620
 
375
- The editor clears its unsaved-changes state as soon as it posts `save` —
376
- delivery of a postMessage says nothing about whether you stored anything. Tell
377
- it when you didn't, and it restores the dirty flag and warns the user instead of
378
- showing a failed write as saved:
621
+ The editor clears its unsaved-changes state as soon as it emits `save` —
622
+ receiving the event says nothing about whether you stored anything. Tell it when
623
+ you didn't, and it restores the dirty flag and warns the user instead of showing
624
+ a failed write as saved:
379
625
 
380
626
  ```js
381
627
  builder.addEventListener('save', async (e) => {
@@ -404,17 +650,23 @@ without it a failed write is invisible to the user.
404
650
  | Attribute / property | Required | Description |
405
651
  | --- | --- | --- |
406
652
  | `base-url` / `baseUrl` | yes | Origin of the design-templates-app deployment |
407
- | `token` | yes | Filerobot token (`ftoken`) |
653
+ | `token` | yes | Scaleflex token (`ftoken`) |
408
654
  | `sass-key` / `sassKey` | session auth | Project sass key |
409
655
  | `session-uuid` / `sessionUuid` | session auth | Hub session uuid |
410
- | `sec-template` / `secTemplate` | guest auth | Filerobot security-template key, *instead of* `sass-key` + `session-uuid`. Stateless only — see [Authentication](#authentication) |
656
+ | `sec-template` / `secTemplate` | guest auth | Scaleflex security-template key, *instead of* `sass-key` + `session-uuid`. Stateless only — see [Authentication](#authentication) |
411
657
  | `company-uuid`, `project-uuid` | no | Company / project scoping (session auth only) |
412
- | `template-id` / `templateId` | no | DAM-backed: Filerobot uuid to edit, empty opens the new-template flow. Stateless: opaque id echoed back on `save` |
658
+ | `template-id` / `templateId` | no | DAM-backed: the file uuid to edit, empty opens the new-template flow. Stateless: opaque id echoed back on `save` |
413
659
  | `mode` | no | `inline` (default; size the element) or `modal` (fullscreen overlay, starts closed — call `open()`) |
414
- | `stateless` | no | Pass the template in and take it back out instead of using the DAM (see [Stateless](#stateless)). Requires `content` |
660
+ | `stateless` | no | Pass the template in and take it back out instead of using the DAM (see [Stateless](#stateless)). Requires `content`, or `new-template` |
415
661
  | `content` (property only) | stateless | The template to edit, as `.fdt` XML. Assigning a new value loads it into a running editor |
662
+ | `new-template` / `newTemplate` | no | Stateless: open on a new, empty template instead of supplying `content` — the widget provides the blank document. Ignored when `content` is set. See [Starting a template from scratch](#starting-a-template-from-scratch) |
416
663
  | `template-name` / `templateName` | no | Stateless: header title |
417
664
  | `template-query` / `templateQuery` | no | Stateless: the render to open on — the `templateQuery` from the last save. Empty uses the XML's `default=` values. |
665
+ | `custom-metadata` / `customMetadata` | no | Metadata model offered as the **Custom metadata** value source: `[{ key, title?, group? }]`, as an array (property) or JSON (attribute). See [Custom metadata fields](#custom-metadata-fields) |
666
+ | `custom-metadata-label` / `customMetadataLabel` | no | Renames the **Custom metadata** value source in the editor's UI (e.g. `External metadata`). Wording only — the stored template is unaffected. Empty uses the default |
667
+ | `dam-store` / `damStore` | no | Stateless only: the element stores each save in Filerobot too, and `save`'s detail carries `stored: { uuid, url }` (or `storeError`) next to the raw `content`. See [Storing a rendering copy](#storing-a-rendering-copy-dam-store) |
668
+ | `store-folder` / `storeFolder` | no | `dam-store`: folder for templates whose id names no existing DAM file (default `/`); an existing file's own folder always wins |
669
+ | `stored-uuid` / `storedUuid` | no | `dam-store`: the `stored.uuid` you persisted for this document, passed back in so re-saves after a reload resolve to (and version) the existing copy. Per-document — `load()` clears it when omitted |
418
670
  | `brand-color` / `brandColor` | no | Accent colour for the editor chrome, `#rgb` / `#rrggbb` |
419
671
  | `theme` | no | `light`, `dark` or `auto` |
420
672
  | `ready-timeout` / `readyTimeout` | no | Ms to wait for the app handshake before `error` (default 20000, 0 disables) |
@@ -432,18 +684,27 @@ something to hardcode in a public bundle.
432
684
  | Metadata fields, regional variants, project branding | yes | **empty** |
433
685
  | Rendering, fonts, asset picker | yes | yes, within the template's scope |
434
686
 
687
+ <!-- internal:start -->
435
688
  #### Hub session
436
689
 
437
690
  Mint the Hub session **server-side** and inject `session-uuid` / `sass-key` /
438
691
  `token` into your page. Issue short-lived per-user sessions; never embed a
439
692
  long-lived master credential in client-side code.
693
+ <!-- internal:end -->
440
694
 
441
695
  #### Security template (guest auth)
442
696
 
443
- A Filerobot **security template** is a named, permission-scoped credential you
444
- define once in your Filerobot project — the same guest-auth mechanism the other
445
- Scaleflex widgets use. Hand one to the widget and no Hub session is involved at
446
- all:
697
+ A **security template** is a named, permission-scoped credential you define once
698
+ in your Scaleflex project — the same guest-auth mechanism the other Scaleflex
699
+ widgets use. Hand one to the widget and no Hub session is involved at
700
+ all.
701
+
702
+ It is what makes a stateless embed work without Hub accounts. Even when the
703
+ document never leaves your side, the editor rasterizes every text and shape
704
+ layer on the server to stay pixel-identical to the CDN render, and resolves
705
+ fonts and assets from your tenant — all of it authenticated. See
706
+ [why the editor still calls a server](#why-the-editor-still-calls-a-server) for
707
+ what those calls are.
447
708
 
448
709
  ```html
449
710
  <sfx-template-builder
@@ -469,30 +730,108 @@ all:
469
730
  The app exchanges the key for a short-lived access key itself and renews it when
470
731
  it expires, so the embed does not die mid-session.
471
732
 
733
+ **Scoping the template.** Grant it `LIST` on the folders you want browsable,
734
+ plus `LIST` + `UPLOAD` on `/.studio/fonts*` if users are to see or add custom
735
+ fonts — anything the template cannot reach simply isn't there. Prefer a short
736
+ TTL: the app re-exchanges the key when it expires, so a short-lived template
737
+ costs you nothing and limits the blast radius if one leaks.
738
+
739
+ If the key is rejected — revoked, wrong project token, typo — the widget emits
740
+ `error` with code `auth`.
741
+
742
+ <!-- internal:start -->
472
743
  **What it costs.** A security template authenticates *nobody in particular*: no
473
744
  user identity, no Hub project behind it. That has consequences worth knowing
474
745
  before you pick it:
475
746
 
476
747
  - **Stateless only.** Setting `sec-template` without `stateless` is a config
477
748
  error — the widget reports `error` with code `invalid-config` and never mounts
478
- the iframe. The dashboard and the DAM-backed editor keep requiring a session.
749
+ the editor. The dashboard and the DAM-backed editor keep requiring a session.
479
750
  - **Hub-project features come back empty** — metadata fields, regional variants
480
751
  and dynamic fields have no model to read, and project branding does not apply
481
752
  (theme the chrome with `brand-color` / `theme` instead).
482
- - **Its scope is the app's scope.** Grant the template `LIST` on the folders you
483
- want browsable, plus `LIST` + `UPLOAD` on `/.studio/fonts*` if users are to
484
- see or add custom fonts. Anything it cannot reach simply isn't there.
485
- - **Prefer short TTLs.** The app re-exchanges on expiry, so a short-lived
486
- template costs you nothing but limits the blast radius of a leaked key.
753
+ <!-- internal:end -->
487
754
 
488
- If the key is rejected — revoked, wrong project token, typo — the widget emits
489
- `error` with code `auth`.
755
+ ### Custom metadata fields
756
+
757
+ A text variable normally takes its value from the render query (**Free text**),
758
+ or from the source asset's DAM metadata (**File metadata**, which needs a
759
+ Hub session — it is unavailable under `sec-template`). `custom-metadata` adds a
760
+ third source: your own field names, so an author binds a variable to `sku`
761
+ instead of having to remember which slug happens to mean the SKU.
762
+
763
+ ```html
764
+ <sfx-template-builder
765
+ base-url="https://templates.example.com"
766
+ token="…"
767
+ sec-template="…"
768
+ stateless
769
+ custom-metadata='[
770
+ { "key": "sku", "title": "SKU", "group": "Product" },
771
+ { "key": "price", "title": "Price", "group": "Product" },
772
+ { "key": "campaign", "title": "Campaign name" }
773
+ ]'
774
+ ></sfx-template-builder>
775
+ ```
776
+
777
+ ```ts
778
+ // Or as a property, which is nicer for anything built at runtime:
779
+ builder.customMetadata = fields.map((f) => ({ key: f.id, title: f.label }))
780
+ ```
781
+
782
+ Fields appear in the editor's picker in the order you declare them, grouped
783
+ under `group` where present, with `title` (or the bare `key`) as the label. Set
784
+ no model and the source is not offered at all.
785
+
786
+ The source is called **Custom metadata** in the editor by default;
787
+ `custom-metadata-label` renames it to fit your domain — `External metadata`,
788
+ `Product attributes`, whatever your authors know it as. Wording only: the saved
789
+ template carries the same `custom_ckey` either way. (The label is another
790
+ post-0.3.0 addition — an older pinned bundle ignores it.)
791
+
792
+ **Names only — no values travel with the model, and the editor resolves nothing
793
+ against it.** The binding is stored in the saved `.fdt` on the variable as
794
+ `custom_ckey`, and the variable renders exactly like a free-text one: your
795
+ pipeline substitutes the value by putting `$slug=value` in the render query.
796
+
797
+ ```xml
798
+ <variable annotation_id="text_1" display="Product code" name="code"
799
+ type="text_placeholder" source="URL" custom_ckey="sku"
800
+ default="AB-1234" />
801
+ ```
802
+
803
+ So the round trip is: read `custom_ckey` back from the template you stored, look
804
+ up that field in your own data, and render with `$code=<that value>`. Until you
805
+ do, the editor and any render show the variable's default value.
806
+
807
+ A few consequences worth knowing:
808
+
809
+ - **The model can change between sessions.** A variable bound to a key your
810
+ current model does not list keeps its binding — the editor shows it read-only
811
+ and says so — so opening a template with a narrower model never silently
812
+ rewrites it.
813
+ - **Nothing is validated against the model at render time.** A key you stop
814
+ sending simply stops being substituted, and the default shows instead.
815
+ - **Send it whenever you like.** The widget delivers the model as soon as the
816
+ editor is ready and re-sends it whenever you change it; assigning an equal
817
+ model is a no-op. It applies to DAM-backed embeds as well as stateless ones.
818
+ - **Malformed fields are dropped, not fatal.** An entry with no `key`, a
819
+ duplicate `key` (first one wins), or a `key` containing a character XML cannot
820
+ carry — a C0 control, an unpaired surrogate — is skipped; the rest of the model
821
+ still works. Ordinary text, punctuation, accents and emoji are all fine.
490
822
 
491
823
  ### Origin registration
492
824
 
493
- Your page's origin must be in the deployment's `frame-ancestors` allowlist
494
- (`NEXT_PUBLIC_TRUSTED_HUB_ORIGINS`), otherwise the browser refuses to render the
495
- iframe and the widget reports `handshake-timeout`.
825
+ Your page's origin must be in the deployment's embedding allowlist
826
+ (`NEXT_PUBLIC_TRUSTED_HUB_ORIGINS`), otherwise the browser refuses to load the
827
+ editor on your page Chrome shows "refused to connect" in its place, and the
828
+ widget reports `handshake-timeout`.
829
+
830
+ A deployment allows `'self'`, `https://*.scaleflex.com`,
831
+ `https://*.filerobot.com` and `http://localhost:5173` (the demo's dev server)
832
+ out of the box, plus whatever its `NEXT_PUBLIC_TRUSTED_HUB_ORIGINS` names. Your
833
+ own domain has to be added there — the list is baked in at build time, so it
834
+ takes a rebuild of the app, not just a restart.
496
835
 
497
836
  ### Cookies
498
837
 
@@ -506,9 +845,11 @@ without CHIPS support that block third-party cookies will fail with `auth` or
506
845
 
507
846
  | Method | Description |
508
847
  | --- | --- |
509
- | `open(templateId?)` | Open the editor, loading the iframe. Optionally switch template first. |
510
- | `close()` | Close the editor and unload the iframe. Does not emit `close`. |
511
- | `load({ content, templateId?, name?, templateQuery? })` | Stateless: load a template, opening the editor if needed. `templateQuery` picks the render to open on — see [About `templateQuery`](#about-templatequery). |
848
+ | `open(templateId?)` | Open the editor, loading it if it isn't loaded yet. Optionally switch template first. |
849
+ | `close()` | Close the editor and unload it. Does not emit `close`. |
850
+ | `load({ content, templateId?, name?, templateQuery?, storedUuid? })` | Stateless: load a template, opening the editor if needed. `templateQuery` picks the render to open on — see [About `templateQuery`](#about-templatequery). `storedUuid` seeds the [dam-store](#storing-a-rendering-copy-dam-store) memory; omitting it clears the seed. |
851
+ | `flushPendingSaves(reason?)` | Emit any `dam-store` saves still waiting on their upload, raw content with `storeError` in place of the links. Only for framework wrappers that unsubscribe listeners before removing the element — the React wrapper calls it for you. |
852
+ | `createNew({ templateId?, name? })` | Stateless: open on a new, empty template — no XML needed. See [Starting a template from scratch](#starting-a-template-from-scratch). |
512
853
  | `confirmSave(ok, message?)` | Stateless: report whether you persisted the content. See [Reporting a failed save](#reporting-a-failed-save). |
513
854
 
514
855
  **Read-only properties:** `status` (`idle` \| `loading` \| `ready` \| `error`),
@@ -524,7 +865,7 @@ All events are `CustomEvent`s; the payload is in `detail`.
524
865
  | --- | --- | --- |
525
866
  | `ready` | — | The editor mounted and auth validated. Clears the handshake timeout. |
526
867
  | `open` | — | The editor UI opened. |
527
- | `save` | `{ uuid, name }` (DAM-backed) or `{ templateId, content, name, templateQuery }` (stateless) | The user saved. |
868
+ | `save` | `{ uuid, name }` (DAM-backed) or `{ templateId, content, name, templateQuery }` (stateless; under [`dam-store`](#storing-a-rendering-copy-dam-store) also `stored: { uuid, url }` or `storeError`) | The user saved. |
528
869
  | `dirtychange` | `{ isDirty }` | Stateless: the unsaved-changes flag flipped. |
529
870
  | `close` | — | The user left the editor, or it unmounted. |
530
871
  | `error` | `{ code, message? }` | See below. |
@@ -533,10 +874,10 @@ All events are `CustomEvent`s; the payload is in `detail`.
533
874
 
534
875
  | Code | Means |
535
876
  | --- | --- |
536
- | `auth` | The app could not authenticate — blocked cookies, an expired session, or a security template the Filerobot API refused. |
877
+ | `auth` | The app could not authenticate — blocked cookies, an expired session, or a security template the Scaleflex API refused. |
537
878
  | `invalid-content` | Stateless: the `content` you sent is not a parseable `.fdt` document. |
538
- | `invalid-config` | Attributes that contradict each other, e.g. `sec-template` without `stateless`. The iframe never mounts. |
539
- | `handshake-timeout` | No ready signal in time — usually a missing `frame-ancestors` entry or blocked third-party cookies. |
879
+ | `invalid-config` | Attributes that contradict each other, e.g. `sec-template` without `stateless`. The editor never mounts. |
880
+ | `handshake-timeout` | No ready signal in time — usually a missing entry in the deployment's embedding allowlist, or blocked third-party cookies. |
540
881
  | `invalid-base-url` | `base-url` is not a URL. |
541
882
  | `unknown` | Anything the app reported that this version does not name. |
542
883
 
@@ -544,6 +885,13 @@ All events are `CustomEvent`s; the payload is in `detail`.
544
885
  `modal` mode the element tears its overlay down; in `inline` mode it is yours to
545
886
  act on — the editor never navigates itself anywhere.
546
887
 
888
+ Under [`dam-store`](#storing-a-rendering-copy-dam-store), a save whose upload
889
+ is still in flight emits **after** `close`, with its real outcome. Keep your
890
+ `save` listener attached until it arrives — or, if your close handler tears the
891
+ element down anyway, removing it from the DOM flushes the pending save to your
892
+ still-attached listeners; call `flushPendingSaves()` yourself only if you
893
+ detach listeners without removing the element.
894
+
547
895
  ## React API
548
896
 
549
897
  Props mirror the attributes in camelCase, plus `className` and `style`.
@@ -557,6 +905,10 @@ The credential props are a discriminated union, so the two
557
905
  `onSave` may return `false` or a promise; see
558
906
  [Reporting a failed save](#reporting-a-failed-save).
559
907
 
908
+ `customMetadata` is an array prop, compared by identity like the rest — hoist it
909
+ to module scope or memoise it, or every render counts as a change (harmless; the
910
+ element de-dupes by value before it says anything to the editor).
911
+
560
912
  The component forwards a ref to the underlying element, which is how you reach
561
913
  the [imperative API](#public-methods) — required for `mode="modal"`, which
562
914
  renders nothing until `open()` is called:
@@ -636,10 +988,12 @@ blocks third-party cookies the editor cannot authenticate and the widget reports
636
988
 
637
989
  ---
638
990
 
991
+ <!-- internal:start -->
639
992
  ## Development
640
993
 
641
994
  ```bash
642
- yarn dev:demo # demo site (expects the app on http://localhost:3000)
995
+ yarn dev:demo # demo site (defaults to the deployed app; point Base URL at
996
+ # http://localhost:3000 to drive a local one)
643
997
  yarn test # vitest
644
998
  yarn typecheck # tsc --noEmit
645
999
  yarn build # dist/ — npm artifact (ESM + CJS + types)
@@ -666,7 +1020,18 @@ the README reloads the dev server.
666
1020
  the folder can be zipped and handed to a client, or dropped behind any static
667
1021
  host at any path — GitHub Pages, S3, a subdirectory of an existing site. The
668
1022
  only thing it needs at runtime is a reachable `design-templates-app` for the
669
- demo page's Base URL field to point at.
1023
+ demo page's Base URL field to point at; it defaults to
1024
+ `https://design-templates.scaleflex.com`.
1025
+
1026
+ That default loads successfully only from an origin that deployment allows —
1027
+ `'self'`, `*.scaleflex.com`, `*.filerobot.com` and `http://localhost:5173`,
1028
+ which covers both the demo as published to the CDN and `yarn dev:demo`. Serving
1029
+ it anywhere else — another port (`yarn preview:demo` uses 4173), or a copy on
1030
+ your own domain — gets "refused to connect" until that origin joins the
1031
+ deployment's allowlist, which is baked in at build time and so needs a rebuild
1032
+ (see [Origin registration](#origin-registration)). Driving a *locally* running
1033
+ app has the same requirement in reverse: `'self'` does not cover a page on
1034
+ `localhost:5173` embedding `localhost:3000`, but the default list now does.
670
1035
 
671
1036
  The demo page takes its XML from a URL, from a paste, or from a picker listing
672
1037
  the `.fdt` files in the project its credentials point at, which fills the id,
@@ -676,6 +1041,15 @@ whatever that template's scope allows. The picker is demo scaffolding standing
676
1041
  in for a host's own template store — the widget itself only ever sees the XML
677
1042
  the page hands it.
678
1043
 
1044
+ It also plays the host half of
1045
+ [custom metadata](#custom-metadata-fields). The panel carries a switch and an
1046
+ editable JSON model — sample fields to start from, and add whatever of your own
1047
+ you like. Each field takes an optional `value`, which never reaches the editor:
1048
+ the page substitutes it into the render query for any variable bound to that
1049
+ field, on the way in and again on the way out, and logs each substitution. That
1050
+ is exactly the work a real integration does, so binding a variable to `sku` and
1051
+ reloading shows the host's SKU on the canvas rather than the variable's default.
1052
+
679
1053
  End-to-end cover for the embed boundary lives in the app repo at
680
1054
  `e2e/embed-widget.spec.ts` — a cross-origin host page loads the built CDN
681
1055
  bundle, pulls template XML from its own API and gets the edit back. It needs the
@@ -688,7 +1062,7 @@ yarn build && yarn start
688
1062
  yarn test:e2e:embed
689
1063
  ```
690
1064
 
691
- The postMessage protocol lives in `src/protocol.ts` and is shared with the app
1065
+ The host↔app protocol lives in `src/protocol.ts` and is shared with the app
692
1066
  via the `./protocol` export, so the two sides cannot drift. Message *values* are
693
1067
  wire format: never change an existing string, only add new messages, so an older
694
1068
  widget keeps working against a newer app deployment and vice versa.
@@ -707,11 +1081,23 @@ npm publish, git commit + tag + push. It needs a `.env.local` in this package:
707
1081
  ```env
708
1082
  FILEROBOT_CDN_TOKEN=scaleflex
709
1083
  FILEROBOT_CDN_SECU=<secu key>
710
- FILEROBOT_CDN_FOLDER=/plugins/scaleflex/template-builder/{version}/
1084
+ FILEROBOT_CDN_FOLDER=/plugins/cloudimage/template-builder/{version}/
711
1085
  ```
712
1086
 
713
1087
  Update [CHANGELOG.md](./CHANGELOG.md) before releasing.
714
1088
 
1089
+ `yarn release:demo` publishes the demo site into `<that folder>/demo/`, next to
1090
+ the bundle it demonstrates. `yarn release:demo:probe` sends a single file first,
1091
+ reporting the content-type the CDN serves it as. The pages are built as
1092
+ self-contained HTML with their JS and CSS inlined, because the CDN project
1093
+ refuses `.js` uploads.
1094
+
1095
+ Note the CDN caches for 24h: re-uploading over a path that has already been
1096
+ fetched keeps serving the old copy until it expires. Version folders are the
1097
+ way around it — don't overwrite a published one.
1098
+
1099
+ <!-- internal:end -->
1100
+
715
1101
  ---
716
1102
 
717
1103
  ## Claude Code Integration