@scaleflex/template-builder
v0.5.0
Published
Embeddable web component (<sfx-template-builder>) for the Filerobot design-templates builder
Keywords
Readme
Table of Contents
- Overview
- Features
- Requirements
- Installation
- Quick Start
- Modes
- Configuration
- Public Methods
- Events
- React API
- Theming
- Browser Support
- Claude Code Integration
- Development
- Release
- License
Overview
<sfx-template-builder> embeds the Scaleflex design-templates builder in a page
on any origin. The element loads the editor from a design-templates-app
deployment, hands over the credentials, and surfaces everything the editor does
as ordinary DOM CustomEvents — so from your side it behaves like any other
component.
All the heavy lifting — server-side text rendering, font resolution, asset browsing, template XML — stays inside the app deployment. The widget is a protocol adapter.
Features
- Framework-agnostic — a custom element; works in vanilla JS, React, Vue, Angular, Svelte. A thin React wrapper ships in the box.
- Two storage modes — let Scaleflex own the template, or keep the document entirely on your side (Stateless) and never map your users onto Scaleflex identities.
- Two ways in — a Hub session, or a Scaleflex security template when you have no Hub account to hand over per user.
- Inline or modal — fill a box in your layout, or cover the viewport.
- New templates without the format —
new-templatestarts an empty document for the user to build; you only ever store what comes back. - Themeable — one brand colour drives the editor's whole accent ramp; light, dark, or follow the OS.
- Origin-checked both ways — the widget only accepts messages from the app origin; the app only accepts a template from the origin pinned when the session was handed over.
- Diagnosable failures — auth, blocked cookies, bad content and handshake
timeouts all surface as an
errorevent instead of a stuck editor.
Requirements
- A Scaleflex account
and a running
design-templates-appdeployment to pointbase-urlat - Credentials minted server-side — either a Hub session (
session-uuid+ SASS key + Scaleflex token) or a security template + token for guest access; see Authentication - Your page's origin registered in the deployment's embedding allowlist — see Origin registration
- Modern browser with Custom Elements v1 support (see Browser Support)
Installation
npm / yarn / pnpm
npm i @scaleflex/template-builderCDN
<script type="module" src="https://cdn.cloudimage.io/template-builder/0.5.0/template-builder.min.js"></script>The CDN bundle is self-registering — it defines <sfx-template-builder> on
load, with Lit bundled in. Pin the major version.
Package exports
| Entry | Contents |
|---|---|
| @scaleflex/template-builder | The element class and every protocol constant / type. Does not register the tag. |
| @scaleflex/template-builder/define | Side-effect import that registers <sfx-template-builder>. |
| @scaleflex/template-builder/react | The <TemplateBuilder> React wrapper (also registers the tag). |
Client-only: the element extends
HTMLElement, so importing.,./define, or./reactin a server-rendered module will throw. In SSR frameworks, import dynamically on the client (e.g. Next.jsdynamic(..., { ssr: false })or auseEffectimport).
Protocol constants: there is also a
./protocolsubpath, but it resolves to TypeScript source and exists for thedesign-templates-appworkspace, which compiles it. External consumers should take the same constants from the package root, which is compiled.
Quick Start
You need two things from your Scaleflex project: its token, and a security template key — a named, permission-scoped credential you define once, the same guest-auth mechanism the other Scaleflex widgets use. No Hub account, and no user of yours ever needs a Scaleflex identity. See Authentication for how to scope one.
The template document stays on your side: you hand the widget its XML, and the edit comes back to you on save. That is stateless mode.
Vanilla JS / Web Component
<script type="module">
import '@scaleflex/template-builder/define' // registers <sfx-template-builder>
</script>
<sfx-template-builder
base-url="https://<your-design-templates-deployment>"
token="PROJECT_TOKEN"
sec-template="SEC_TEMPLATE_KEY"
stateless
style="display:block;height:800px"
></sfx-template-builder>
<script>
const builder = document.querySelector('sfx-template-builder')
// 1 — hand it the template to edit
const { content, name } = await fetch(`/api/templates/${id}`).then((r) => r.json())
builder.load({ templateId: id, name, content })
// 2 — take the edit back and store it
builder.addEventListener('save', async (e) => {
const { templateId, content, name, templateQuery } = e.detail
const ok = await saveToYourApi(templateId, { content, name, templateQuery })
builder.confirmSave(ok) // false → the editor keeps its unsaved-changes warning
})
builder.addEventListener('error', (e) => console.error(e.detail)) // { code, message }
</script>Size the element yourself — in inline mode it fills the box you give it.
React
import { TemplateBuilder } from '@scaleflex/template-builder/react'
<TemplateBuilder
stateless
baseUrl="https://<deployment>"
token={projectToken}
secTemplate={secTemplateKey}
templateId={id}
name={name}
content={xml}
style={{ height: 800 }}
onSave={async (data) => (await saveToYourApi(data)).ok}
/>Hub session (internal)
Scaleflex-side embeds inside the Hub authenticate with a session instead of a security template, which unlocks DAM-backed storage and Hub-project features. Mint the session server-side; never put a long-lived credential in client code.
<sfx-template-builder
base-url="https://<your-design-templates-deployment>"
token="PROJECT_TOKEN"
sass-key="SASS_KEY"
session-uuid="SESSION_UUID"
template-id="TEMPLATE_UUID"
style="display:block;height:800px"
></sfx-template-builder><TemplateBuilder
baseUrl="https://<deployment>"
token={token}
sassKey={sassKey}
sessionUuid={sessionUuid}
templateId={templateId}
style={{ height: 800 }}
onSave={(data) => console.log(data)}
/>Modes
DAM-backed (default)
template-id is a DAM file uuid. The app loads the template itself, and
Save uploads a new version and reports the resulting uuid on the save event.
Leave template-id empty to open the new-template flow.
Stateless
Set stateless to keep the document entirely on your side: you pass the
template in, the user edits it, and you get the edited template back. Nothing is
stored on the Scaleflex side (unless you opt into
dam-store for a rendering copy), so
your app keeps its own storage, versioning,
tenancy and access control, and template-id becomes an opaque string that is
echoed back untouched.
The template comes from your API
The whole flow is three steps: fetch the XML from your endpoint, pass it in, take the edited XML back out. Nothing is stored on the Scaleflex side at any point.
<sfx-template-builder
stateless
base-url="https://<deployment>"
token="PROJECT_TOKEN"
sec-template="SEC_TEMPLATE_KEY"
style="display:block;height:800px"
></sfx-template-builder>
<script type="module">
const builder = document.querySelector('sfx-template-builder')
const id = 'your-own-id-42'
// 1 — get the template XML from your API.
const res = await fetch(`/api/templates/${id}`)
const { content, name, templateQuery } = await res.json()
// 2 — pass it in. `content` is a property, never an attribute: templates
// routinely exceed what fits in markup or a URL, so the widget hands the
// document to the editor directly rather than through either.
// `templateQuery` is what you stored on the last save; it reopens the
// template on the same layout and variable values.
builder.load({ templateId: id, name, content, templateQuery })
// 3 — take the edited template back out and store it yourself.
builder.addEventListener('save', async (e) => {
const { templateId, content, name, templateQuery } = e.detail
const ok = await fetch(`/api/templates/${templateId}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content, name, templateQuery }),
}).then((r) => r.ok)
// Tell the editor whether it landed — see Reporting a failed save.
builder.confirmSave(ok)
})
// Loading a different template discards unsaved edits — check before you do.
builder.addEventListener('dirtychange', (e) => {
unsavedBanner.hidden = !e.detail.isDirty
})
</script>templateId is your identifier, not a DAM uuid. The app never
resolves it against anything — it is carried alongside the content and handed
straight back on save, so use whatever key maps to your own record.
The demo page runs exactly this against a real HTTP endpoint and logs every call, so you can watch the XML cross the boundary in both directions.
React — content is a prop, and the outcome of onSave is reported back
automatically:
function TemplateEditor({ id }: { id: string }) {
const [tpl, setTpl] = useState<{ content: string; name: string } | null>(null)
// 1 — get the template XML from your API.
useEffect(() => {
fetch(`/api/templates/${id}`).then((r) => r.json()).then(setTpl)
}, [id])
if (!tpl) return null
return (
<TemplateBuilder
stateless
baseUrl="https://<deployment>"
token={projectToken}
secTemplate={secTemplateKey}
// 2 — pass it in.
templateId={id}
content={tpl.content}
templateName={tpl.name}
style={{ height: 800 }}
// 3 — take it back out. Returning false (or throwing) tells the editor
// the save failed, and it restores its unsaved-changes flag.
onSave={async (data) => {
const res = await fetch(`/api/templates/${data.templateId}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(data),
})
return res.ok
}}
onDirtyChange={({ isDirty }) => setHasUnsavedEdits(isDirty)}
/>
)
}About templateQuery
save returns a templateQuery next to the content. It is the query string
that renders the template at its defaults — layout, variable values, locale —
and you append it to the template's CDN URL when you want an image out:
https://<tenant>.filerobot.com/<path>/<template>.fdt?<templateQuery>&force_format=pngPersist it alongside content. DAM-backed saves store it as file metadata;
a stateless host that drops it gets renders that fall back to whatever the XML
alone implies.
Pass it back in. load() takes a templateQuery too, so the query you
stored reopens the template on the render it was left at:
builder.load({ templateId, name, content, templateQuery })Omit it and the editor falls back to the default= attributes in the XML —
a different render whenever the query overrode any of them, which is the usual
case for a template driven by metadata or URL variables.
It is applied as display state, not as an edit: it selects the layout and fills variable values without marking the document dirty, so opening a template and closing it again is not an unsaved change. Entries naming a variable or layout the document no longer defines are ignored rather than treated as an error, so a stale query still opens the template.
Known limits on the way in — all of them cases where the editor's preview can differ from what the same query renders on the CDN:
$localeis not applied. Regional selection is seeded from Hub project info, which a guest embed never receives.- An explicitly empty value (
$headline=) does not clear a variable. The editor resolvesvalue || default, so it falls back to the XML default, while the backend treats explicit-empty as empty. Omit the key instead of sending it blank. - Metadata-sourced variables behave differently by session. Under a guest (security-template) session the query's value stands. Under a Hub session the editor re-resolves them from the linked asset shortly after load, overwriting it — that automation is the DAM behaviour and takes precedence.
$layout_coloris ignored; the layout's own colour is used. Queries this editor produced always agree, so this only bites a hand-built one.layout,localeandforce_formatare reserved. A variable whose slug is one of those cannot be addressed by a query.
Swapping templates. Assigning a new content, templateId, templateName
or templateQuery reloads the editor and discards unsaved edits without
prompting — the host is treated as authoritative. Watch dirtychange (or read
the isDirty property) and ask the user first. Re-assigning an identical
template is a no-op, so a host re-render can't destroy work by accident. Note
that the id is part of that identity check: moving between two records whose
content is byte-identical does reload, so a save can never land on the record
you navigated away from.
What stateless does not remove
Statelessness applies to the document, not to the infrastructure. The editor still needs an authenticated Scaleflex tenant for:
- text rendering — text and shape layers are rasterized server-side (see the render round-trip below),
- fonts — custom fonts are served from the tenant's
.studio/fonts/, - asset browsing and upload — image layers are picked from the DAM,
- metadata variables and regional settings.
What it does let you avoid is mapping your users onto Scaleflex identities: point every embed at one service tenant with a single machine credential minted server-side, and do all per-user permission work yourself.
Images referenced by a template may live on your own CDN, but the render
service only fetches from allowlisted hosts — add yours to the deployment's
RENDER_ALLOWED_HOSTS_EXTRA.
Why the editor still calls a server
This is the reason a stateless embed still needs a credential, so it is worth being concrete about.
A .fdt template is not an image. The image only exists once someone requests
the template's CDN URL, and it is Scaleflex that renders it there — server-side,
with ImageMagick:
https://<tenant>.filerobot.com/<path>/<template>.fdt?<templateQuery>&force_format=pngThe editor's contract is that what you see while editing is what that URL will
return. That rules out drawing the text in the browser. Line breaking, kerning,
letter spacing, baseline placement, shrink-to-fit and antialiasing are FreeType
and ImageMagick behaviours; canvas fillText and DOM text go through the
browser's own shaping and hinting instead, so the same layer lands differently
in Chrome, Safari and Firefox — and differently from the export in all three. A
few pixels of drift is enough to move a headline off a product shot. There is
no JS library that reimplements that layout either: the only faithful
implementation of ImageMagick's text rendering is ImageMagick.
So the editor does not approximate the export — it runs the same engine.
Text and shape layers are rasterized by an ImageMagick 7 build hosted in your
base-url deployment (the export pipeline drives ImageMagick 7 from PHP; the
editor drives a WebAssembly build of it), and each layer comes back as a
transparent PNG that the canvas positions with CSS:
browser — the widget app deployment (base-url)
──────────────────── ─────────────────────────
edit a text layer
│ batched across layers, debounced
├────── POST /api/render-layers ─────▶ ImageMagick (WASM)
│ layers + fonts + variables ├─ resolve fonts: bundled,
│ │ then tenant /.studio/fonts
│ ├─ draw at 3×, downscale
◀────── transparent PNG per layer ─────┘
│
└─ position / rotate / fade with CSS — no round tripConsequences you can observe from the outside:
- Content edits cost a round trip; placement edits do not. Text, font, weight, colour, alignment, letter spacing and box size re-render. Dragging, rotating and opacity are CSS transforms on the PNG already in the page, so they stay at pointer speed.
- Bursts collapse. Requests are debounced (~300 ms) and batched across layers, and an in-flight batch is aborted when you keep typing — so a sentence typed at speed costs one render, not one per keystroke.
- Layers are drawn at 3× and downscaled, so preview antialiasing matches the export rather than the browser's rasterizer.
- Fonts are resolved server-side against the tenant's
/.studio/fontsfolder and cached there, so the page never downloads a rendering engine or a font binary per weight. Image layers are the exception — they are plain<img>elements, drawn by the browser.
That endpoint is authenticated, and it has to be. It fetches fonts and
images by URL on the server's behalf, so it is not open to anonymous callers:
POST /api/render-layers requires the credential the widget handed over, and
without a valid one it answers 401 and text and shape layers simply never
appear. The same credential authorizes the font list/upload calls and the asset
picker.
In DAM-backed mode a Hub session covers that. A stateless embed has no Hub session to hand over — and that is exactly the gap a security template fills: a permission-scoped, project-level guest credential that authorizes rendering, fonts and asset browsing without authenticating any particular user, and without your users existing in Scaleflex at all.
Storing a rendering copy (dam-store)
The raw XML in the save event is yours to keep — but the CDN renders only
files it stores, so previews and production banners need a copy in Filerobot
too. Set dam-store and the element makes that copy itself on every stateless
save, with the same multipart upload the DAM-backed editor uses, before the
save event fires. The detail then carries the links next to the raw data:
builder.damStore = true
builder.addEventListener('save', (e) => {
const { content, templateQuery, stored, storeError } = e.detail
// stored = { uuid, url } — the DAM copy. `url` is the .fdt's CDN URL with
// its current ?vh= cache key; append templateQuery and it IS a render:
// <img src=`${stored.url}&${templateQuery}`>
// (`url` can rarely come back empty — the record read-back after the
// upload failed; the file is stored regardless, under stored.uuid. And
// join with '?' instead of '&' if your URL happens to carry no query.)
})Added after 0.3.0 — if your pinned CDN bundle predates it, the attribute is silently ignored; check the changelog.
What to know:
- The raw
contentarrives either way. A failed upload replacesstoredwithstoreError(a message) — whether a save without a rendering copy counts as saved is your call, made where it always is: the save ack (confirmSave(false)/ returningfalsefrom React'sonSave). - The raw content never dies with the element. Closing the editor changes
nothing: the element outlives a close, so a save still inside its upload
window simply emits moments later with its real outcome — possibly after
the
closeevent. Only removing the element mid-upload flushes the pendingsaveimmediately, raw content with astoreErrornote (the copy, if it lands, goes unreported); the React wrapper does the same from its unmount cleanup. And rapid saves are serialized, sosaveevents always arrive in the order the editor posted them. - Same name + folder versions the file in place. When
template-idnames an existing DAM file, its own folder is reused; otherwise new templates land instore-folder(default/). Under a VERSION conflict policy each save is its own file row —stored.uuidis always the current one, so persist it (don't echo it intotemplate-id: an id change deliberately reloads the editor). The element remembers the copy it last made, so follow-up saves — including unchanged re-saves, which resolve to that copy instead of erroring — keep working against it even while your own id stays opaque. That memory lives in the element instance: to carry it across a page reload or remount, pass the persisted uuid back in asstored-uuid/storedUuid(or thestoredUuidfield ofload()) alongside the content. - The stored
template_queryis record-agnostic. Values of custom-metadata-bound variables are stripped before the query is attached as file metadata, so one record's data never becomes the template's own default. - The credential needs upload rights. A
security template must have a scope that
allows uploads, or every save reports
storeError.
Starting a template from scratch
A template your user has not created yet has no XML to pass in, and you should
not have to author one. In stateless mode, set new-template
instead of content and the widget supplies the empty document itself.
(DAM-backed mode has its own new-template flow — leave template-id empty.)
<sfx-template-builder
stateless
new-template
template-name="Untitled"
base-url="https://<deployment>"
token="PROJECT_TOKEN"
sec-template="SEC_TEMPLATE_KEY"
style="display:block;height:800px"
></sfx-template-builder>// Or imperatively, on an element that is already showing something else.
builder.createNew({ templateId: 'your-own-id-43', name: 'Untitled' })<TemplateBuilder stateless newTemplate templateName="Untitled" … />The editor opens on its empty state — "No layouts yet. Click + Add to create
one." — and the user picks the canvas size, background and preset there. Save
is refused until at least one layout exists, so the first save you receive
already carries a complete, well-formed .fdt document; store it as content
and every later open is the ordinary load flow.
templateIdis optional. Pass one if your record already exists and you want it echoed back; otherwise thesavepayload simply arrives without an id and you allocate one when you store it.templateQuerystays empty. A new document has no layouts and no variables, so there is no render for a query to select. You get one back on the first save — persist it then.contentwins when both are set, so a host that renders one element for both cases can simply pass the XML when it has one.- Empty
contenton its own does not start a blank template. It means "the host has nothing yet" — the editor keeps waiting, which is what lets you mount the builder while your fetch is still in flight. Onlynew-templateturns that wait into a document.
Calling createNew() again while the blank template is already open does
nothing: resending would discard whatever the user has built since. Close and
reopen the editor to genuinely start over.
If you would rather ship your own starting point — a house style, a standard
canvas size, a locked logo layer — pass it as ordinary content. A starter
template is just a template, and BLANK_TEMPLATE_XML is exported from
@scaleflex/template-builder/protocol if you want the empty document as a base.
Reporting a failed save
The editor clears its unsaved-changes state as soon as it emits save —
receiving the event says nothing about whether you stored anything. Tell it when
you didn't, and it restores the dirty flag and warns the user instead of showing
a failed write as saved:
builder.addEventListener('save', async (e) => {
try {
await yourApi.saveTemplateXml(e.detail.templateId, e.detail.content)
builder.confirmSave(true)
} catch (err) {
builder.confirmSave(false, 'Could not save — please try again.')
}
})The React wrapper does this for you: return (or resolve to) false from
onSave, or throw, and the failure is reported automatically.
Acking is optional. A host that never calls confirmSave keeps the optimistic
behaviour, so this is additive — but only ok: false carries information, and
without it a failed write is invisible to the user.
Configuration
Attributes & properties
| Attribute / property | Required | Description |
| --- | --- | --- |
| base-url / baseUrl | yes | Origin of the design-templates-app deployment |
| token | yes | Scaleflex token (ftoken) |
| sass-key / sassKey | session auth | Project sass key |
| session-uuid / sessionUuid | session auth | Hub session uuid |
| sec-template / secTemplate | guest auth | Scaleflex security-template key, instead of sass-key + session-uuid. Stateless only — see Authentication |
| company-uuid, project-uuid | no | Company / project scoping (session auth only) |
| template-id / templateId | no | DAM-backed: the file uuid to edit, empty opens the new-template flow. Stateless: opaque id echoed back on save |
| mode | no | inline (default; size the element) or modal (fullscreen overlay, starts closed — call open()) |
| stateless | no | Pass the template in and take it back out instead of using the DAM (see Stateless). Requires content, or new-template |
| content (property only) | stateless | The template to edit, as .fdt XML. Assigning a new value loads it into a running editor |
| 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 |
| template-name / templateName | no | Stateless: header title |
| template-query / templateQuery | no | Stateless: the render to open on — the templateQuery from the last save. Empty uses the XML's default= values. |
| custom-metadata / customMetadata | no | Metadata model offered as the Custom metadata source type: [{ key, title?, group? }], as an array (property) or JSON (attribute). See Custom metadata fields |
| custom-metadata-label / customMetadataLabel | no | Renames the Custom metadata source type in the editor's UI (e.g. External metadata). Wording only — the stored template is unaffected. Empty uses the default |
| 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 |
| 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 |
| 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 |
| brand-color / brandColor | no | Accent colour for the editor chrome, #rgb / #rrggbb |
| theme | no | light, dark or auto |
| ready-timeout / readyTimeout | no | Ms to wait for the app handshake before error (default 20000, 0 disables) |
Authentication
Two credentials get you in. Both go into the page from your server; neither is something to hardcode in a public bundle.
| | Hub session | Security template |
| --- | --- | --- |
| Attributes | token + sass-key + session-uuid | token + sec-template |
| Needs a Hub account per embed | yes | no |
| Storage modes | DAM-backed and stateless | stateless only |
| Metadata fields, regional variants, project branding | yes | empty |
| Rendering, fonts, asset picker | yes | yes, within the template's scope |
Hub session
Mint the Hub session server-side and inject session-uuid / sass-key /
token into your page. Issue short-lived per-user sessions; never embed a
long-lived master credential in client-side code.
Security template (guest auth)
A security template is a named, permission-scoped credential you define once in your Scaleflex project — the same guest-auth mechanism the other Scaleflex widgets use. Hand one to the widget and no Hub session is involved at all.
It is what makes a stateless embed work without Hub accounts. Even when the document never leaves your side, the editor rasterizes every text and shape layer on the server to stay pixel-identical to the CDN render, and resolves fonts and assets from your tenant — all of it authenticated. See why the editor still calls a server for what those calls are.
<sfx-template-builder
base-url="https://templates.example.com"
token="PROJECT_TOKEN"
sec-template="SEC_TEMPLATE_KEY"
stateless
></sfx-template-builder><TemplateBuilder
stateless
baseUrl="https://templates.example.com"
token={projectToken}
secTemplate={secTemplateKey}
templateId={id}
content={xml}
onSave={async (data) => (await saveToYourApi(data)).ok}
/>The app exchanges the key for a short-lived access key itself and renews it when it expires, so the embed does not die mid-session.
Scoping the template. Grant it LIST on the folders you want browsable,
plus LIST + UPLOAD on /.studio/fonts* if users are to see or add custom
fonts — anything the template cannot reach simply isn't there. Prefer a short
TTL: the app re-exchanges the key when it expires, so a short-lived template
costs you nothing and limits the blast radius if one leaks.
If the key is rejected — revoked, wrong project token, typo — the widget emits
error with code auth.
What it costs. A security template authenticates nobody in particular: no user identity, no Hub project behind it. That has consequences worth knowing before you pick it:
- Stateless only. Setting
sec-templatewithoutstatelessis a config error — the widget reportserrorwith codeinvalid-configand never mounts the editor. The dashboard and the DAM-backed editor keep requiring a session. - Hub-project features come back empty — metadata fields, regional variants
and dynamic fields have no model to read, and project branding does not apply
(theme the chrome with
brand-color/themeinstead).
Custom metadata fields
A text variable normally takes its value from the render query (Manual),
or from the source asset's DAM metadata (Asset metadata, which needs a
Hub session — it is unavailable under sec-template). custom-metadata adds a
third source type: your own field names, so an author binds a variable to sku
instead of having to remember which slug happens to mean the SKU.
<sfx-template-builder
base-url="https://templates.example.com"
token="…"
sec-template="…"
stateless
custom-metadata='[
{ "key": "sku", "title": "SKU", "group": "Product" },
{ "key": "price", "title": "Price", "group": "Product" },
{ "key": "campaign", "title": "Campaign name" }
]'
></sfx-template-builder>// Or as a property, which is nicer for anything built at runtime:
builder.customMetadata = fields.map((f) => ({ key: f.id, title: f.label }))Fields appear in the editor's picker in the order you declare them, grouped
under group where present, with title (or the bare key) as the label. Set
no model and the source is not offered at all.
The source type is called Custom metadata in the editor by default;
custom-metadata-label renames it to fit your domain — External metadata,
Product attributes, whatever your authors know it as. Wording only: the saved
template carries the same custom_ckey either way. (The label is another
post-0.3.0 addition — an older pinned bundle ignores it.)
Names only — no values travel with the model, and the editor resolves nothing
against it. The binding is stored in the saved .fdt on the variable as
custom_ckey, and the variable renders exactly like a free-text one: your
pipeline substitutes the value by putting $slug=value in the render query.
<variable annotation_id="text_1" display="Product code" name="code"
type="text_placeholder" source="URL" custom_ckey="sku"
default="AB-1234" />So the round trip is: read custom_ckey back from the template you stored, look
up that field in your own data, and render with $code=<that value>. Until you
do, the editor and any render show the variable's default value.
A few consequences worth knowing:
- The model can change between sessions. A variable bound to a key your current model does not list keeps its binding — the editor shows it read-only and says so — so opening a template with a narrower model never silently rewrites it.
- Nothing is validated against the model at render time. A key you stop sending simply stops being substituted, and the default shows instead.
- Send it whenever you like. The widget delivers the model as soon as the editor is ready and re-sends it whenever you change it; assigning an equal model is a no-op. It applies to DAM-backed embeds as well as stateless ones.
- Malformed fields are dropped, not fatal. An entry with no
key, a duplicatekey(first one wins), or akeycontaining a character XML cannot carry — a C0 control, an unpaired surrogate — is skipped; the rest of the model still works. Ordinary text, punctuation, accents and emoji are all fine.
Origin registration
Your page's origin must be in the deployment's embedding allowlist
(NEXT_PUBLIC_TRUSTED_HUB_ORIGINS), otherwise the browser refuses to load the
editor on your page — Chrome shows "refused to connect" in its place, and the
widget reports handshake-timeout.
A deployment allows 'self', https://*.scaleflex.com,
https://*.filerobot.com and http://localhost:5173 (the demo's dev server)
out of the box, plus whatever its NEXT_PUBLIC_TRUSTED_HUB_ORIGINS names. Your
own domain has to be added there — the list is baked in at build time, so it
takes a rebuild of the app, not just a restart.
Cookies
The app stores auth in partitioned (CHIPS) cookies scoped to your site. Browsers
without CHIPS support that block third-party cookies will fail with auth or
handshake-timeout.
Public Methods
| Method | Description |
| --- | --- |
| open(templateId?) | Open the editor, loading it if it isn't loaded yet. Optionally switch template first. |
| close() | Close the editor and unload it. Does not emit close. |
| 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. storedUuid seeds the dam-store memory; omitting it clears the seed. |
| 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. |
| createNew({ templateId?, name? }) | Stateless: open on a new, empty template — no XML needed. See Starting a template from scratch. |
| confirmSave(ok, message?) | Stateless: report whether you persisted the content. See Reporting a failed save. |
Read-only properties: status (idle | loading | ready | error),
isDirty (stateless; unsaved edits pending).
In React these are reached through a ref — see React API.
Events
All events are CustomEvents; the payload is in detail.
| Event | detail | Fired when |
| --- | --- | --- |
| ready | — | The editor mounted and auth validated. Clears the handshake timeout. |
| open | — | The editor UI opened. |
| save | { uuid, name } (DAM-backed) or { templateId, content, name, templateQuery } (stateless; under dam-store also stored: { uuid, url } or storeError) | The user saved. |
| dirtychange | { isDirty } | Stateless: the unsaved-changes flag flipped. |
| close | — | The user left the editor, or it unmounted. |
| error | { code, message? } | See below. |
error codes:
| Code | Means |
| --- | --- |
| auth | The app could not authenticate — blocked cookies, an expired session, or a security template the Scaleflex API refused. |
| invalid-content | Stateless: the content you sent is not a parseable .fdt document. |
| invalid-config | Attributes that contradict each other, e.g. sec-template without stateless. The editor never mounts. |
| handshake-timeout | No ready signal in time — usually a missing entry in the deployment's embedding allowlist, or blocked third-party cookies. |
| invalid-base-url | base-url is not a URL. |
| unknown | Anything the app reported that this version does not name. |
close fires when the user leaves the editor as well as when it unmounts. In
modal mode the element tears its overlay down; in inline mode it is yours to
act on — the editor never navigates itself anywhere.
Under dam-store, a save whose upload
is still in flight emits after close, with its real outcome. Keep your
save listener attached until it arrives — or, if your close handler tears the
element down anyway, removing it from the DOM flushes the pending save to your
still-attached listeners; call flushPendingSaves() yourself only if you
detach listeners without removing the element.
React API
Props mirror the attributes in camelCase, plus className and style.
Callbacks: onReady, onOpen, onSave, onDirtyChange, onClose, onError.
The credential props are a discriminated union, so the two
auth modes are enforced at compile time: sassKey +
sessionUuid, or secTemplate with stateless — mixing them, or passing
secTemplate without stateless, is a type error rather than a runtime one.
onSave may return false or a promise; see
Reporting a failed save.
customMetadata is an array prop, compared by identity like the rest — hoist it
to module scope or memoise it, or every render counts as a change (harmless; the
element de-dupes by value before it says anything to the editor).
The component forwards a ref to the underlying element, which is how you reach
the imperative API — required for mode="modal", which
renders nothing until open() is called:
import { useRef } from 'react'
import { TemplateBuilder } from '@scaleflex/template-builder/react'
import type { SfxTemplateBuilder } from '@scaleflex/template-builder'
const builder = useRef<SfxTemplateBuilder>(null)
<TemplateBuilder ref={builder} mode="modal" baseUrl={...} {...auth} />
<button onClick={() => builder.current?.open('tpl-1')}>Edit template</button>react and react-dom (>= 18) are optional peer dependencies — the package
works without React installed.
Theming
Brand Color
brand-color restyles the editor chrome — buttons, focus rings, highlights,
selected states — from a single accent colour.
<sfx-template-builder brand-color="#FF6600" ...></sfx-template-builder>- Must be
#rgbor#rrggbb. Anything else is rejected by the app and the default Scaleflex accent is kept — the value ends up inside a stylesheet, so the shape is enforced rather than escaped. - Text drawn on top of the brand colour (primary button labels) is chosen for you, white or near-black, by contrast ratio. A pale brand colour gets dark labels rather than invisible ones.
- Surfaces, borders and body text keep the design system's neutrals; only the accent ramp follows your colour.
Pick a colour that is readable on white. The design system uses one accent token for both filled surfaces and link text, so a very pale brand colour gives you a good-looking button and low-contrast links. Tinted accents are darkened automatically where they are unambiguously text, but a link rendered in the accent colour itself cannot be — darkening it would mean not showing your brand colour on the button either. Mid-tone colours (roughly, anything that passes 4.5:1 on white) avoid the trade-off entirely.
Colour scheme
theme is light, dark or auto (follows the viewer's OS setting). It
overrides the user's own stored preference, which in an embed lives in
partitioned storage your page cannot reach.
Theming applies to the editor UI, not the template. Colours in the design itself live in the document and are edited through the builder — a brand colour never changes what gets rendered or exported.
Browser Support
| Browser | Minimum version | |---|---| | Chrome | 114+ | | Firefox | 131+ | | Safari | 18.4+ | | Edge (Chromium) | 114+ |
Requires native support for Custom Elements v1, Shadow DOM, and ES2020+. Internet Explorer is not supported.
The floors are higher than a plain web component would need because cross-site
embedding depends on partitioned cookies (CHIPS). On an older browser that
blocks third-party cookies the editor cannot authenticate and the widget reports
auth / handshake-timeout. Same-site embeds work further back.
Development
yarn dev:demo # demo site (defaults to the deployed app; point Base URL at
# http://localhost:3000 to drive a local one)
yarn test # vitest
yarn typecheck # tsc --noEmit
yarn build # dist/ — npm artifact (ESM + CJS + types)
yarn build:cdn # dist-cdn/template-builder.min.js — self-registering bundle
yarn build:demo # demo-dist/ — the static demo site
yarn preview:demo # serve demo-dist/ as a client would
yarn build:all # build + build:cdnFrom the repo root, yarn widget <script> runs any of these, and the root
yarn test / yarn typecheck include this package.
The demo site
demo/ is two pages: index.html, the live widget with a configuration panel
and a wire log, and docs.html, this README. scripts/vite-plugin-docs.mjs
renders the markdown at build time — the shipped page is static HTML with no
markdown runtime, and this file stays the only place the documentation is
written. Headings get GitHub's anchor slugs so the links above keep resolving,
the "Table of Contents" section is replaced by a generated sidebar, and editing
the README reloads the dev server.
yarn build:demo emits the pair to demo-dist/ with relative asset paths, so
the folder can be zipped and handed to a client, or dropped behind any static
host at any path — GitHub Pages, S3, a subdirectory of an existing site. The
only thing it needs at runtime is a reachable design-templates-app for the
demo page's Base URL field to point at; it defaults to
https://design-templates.scaleflex.com.
That default loads successfully only from an origin that deployment allows —
'self', *.scaleflex.com, *.filerobot.com and http://localhost:5173,
which covers both the demo as published to the CDN and yarn dev:demo. Serving
it anywhere else — another port (yarn preview:demo uses 4173), or a copy on
your own domain — gets "refused to connect" until that origin joins the
deployment's allowlist, which is baked in at build time and so needs a rebuild
(see Origin registration). Driving a locally running
app has the same requirement in reverse: 'self' does not cover a page on
localhost:5173 embedding localhost:3000, but the default list now does.
The demo page takes its XML from a URL, from a paste, or from a picker listing
the .fdt files in the project its credentials point at, which fills the id,
name and template query from the file you choose. Both auth modes list: a
security template is exchanged for a sass key first, so the listing sees
whatever that template's scope allows. The picker is demo scaffolding standing
in for a host's own template store — the widget itself only ever sees the XML
the page hands it.
It also plays the host half of
custom metadata. The panel carries a switch and an
editable JSON model — sample fields to start from, and add whatever of your own
you like. Each field takes an optional value, which never reaches the editor:
the page substitutes it into the render query for any variable bound to that
field, on the way in and again on the way out, and logs each substitution. That
is exactly the work a real integration does, so binding a variable to sku and
reloading shows the host's SKU on the canvas rather than the variable's default.
End-to-end cover for the embed boundary lives in the app repo at
e2e/embed-widget.spec.ts — a cross-origin host page loads the built CDN
bundle, pulls template XML from its own API and gets the edit back. It needs the
app running with the fixture origin allowlisted:
yarn build:widget
export NEXT_PUBLIC_TRUSTED_HUB_ORIGINS=http://127.0.0.1:4321
yarn build && yarn start
yarn test:e2e:embedThe host↔app protocol lives in src/protocol.ts and is shared with the app
via the ./protocol export, so the two sides cannot drift. Message values are
wire format: never change an existing string, only add new messages, so an older
widget keeps working against a newer app deployment and vice versa.
Release
yarn release # patch bump (0.1.0 → 0.1.1)
yarn release -- minor # minor bump (0.1.0 → 0.2.0)
yarn release -- major # major bump (0.1.0 → 1.0.0)This handles the full pipeline: version bump, CDN build + upload, library build,
npm publish, git commit + tag + push. It needs a .env.local in this package:
FILEROBOT_CDN_TOKEN=scaleflex
FILEROBOT_CDN_SECU=<secu key>
FILEROBOT_CDN_FOLDER=/plugins/cloudimage/template-builder/{version}/Update CHANGELOG.md before releasing.
yarn release:demo publishes the demo site into <that folder>/demo/, next to
the bundle it demonstrates. yarn release:demo:probe sends a single file first,
reporting the content-type the CDN serves it as. The pages are built as
self-contained HTML with their JS and CSS inlined, because the CDN project
refuses .js uploads.
Note the CDN caches for 24h: re-uploading over a path that has already been fetched keeps serving the old copy until it expires. Version folders are the way around it — don't overwrite a published one.
Claude Code Integration
If you use Claude Code, this package ships a ready-made skill that walks Claude through adding the builder to your project — choosing DAM-backed vs stateless storage, wiring the save round trip, theming, and registering your embedding origin.
Option 1: Project-level (recommended)
Copy the skill into your project so everyone on the team gets it:
mkdir -p .claude/skills/integrate-template-builder
cp node_modules/@scaleflex/template-builder/.claude/skills/integrate-template-builder/SKILL.md \
.claude/skills/integrate-template-builder/SKILL.mdCommit the .claude/skills/ directory to version control.
Option 2: Global (personal)
Install it once for all your projects:
mkdir -p ~/.claude/skills/integrate-template-builder
cp node_modules/@scaleflex/template-builder/.claude/skills/integrate-template-builder/SKILL.md \
~/.claude/skills/integrate-template-builder/SKILL.mdUsage
Type /integrate-template-builder in Claude Code and it will take you through
the whole integration, tailored to your stack.
License
PROPRIETARY — All Rights Reserved.
Copyright © 2025 Scaleflex SAS.
This software and associated documentation are the exclusive property of Scaleflex SAS. No part of this software may be copied, modified, distributed, sublicensed, sold, or otherwise made available to any third party without prior written permission from Scaleflex SAS.
This package is distributed via npm solely for the convenience of licensed customers. Installing or using this package does not grant any licence to use the software. Use is permitted only under a separate written licence agreement with Scaleflex SAS.
For licensing enquiries, contact [email protected].
