@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/.claude/skills/integrate-template-builder/SKILL.md +79 -24
- package/CHANGELOG.md +190 -4
- package/README.md +452 -66
- package/dist/dam-store.d.ts +92 -0
- package/dist/define.cjs +1 -1
- package/dist/define.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +16 -14
- package/dist/protocol.d.ts +84 -2
- package/dist/react.cjs +1 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.ts +41 -1
- package/dist/react.js +45 -29
- package/dist/react.js.map +1 -1
- package/dist/template-builder-B9Cwo_Q-.js +651 -0
- package/dist/template-builder-B9Cwo_Q-.js.map +1 -0
- package/dist/template-builder-Byqg1q93.cjs +53 -0
- package/dist/template-builder-Byqg1q93.cjs.map +1 -0
- package/dist/template-builder.d.ts +201 -6
- package/package.json +3 -1
- package/src/dam-store.ts +388 -0
- package/src/index.ts +2 -0
- package/src/protocol.ts +92 -2
- package/src/react.ts +120 -27
- package/src/template-builder.ts +467 -11
- package/dist/template-builder-CSyPZni9.cjs +0 -52
- package/dist/template-builder-CSyPZni9.cjs.map +0 -1
- package/dist/template-builder-S33H_d5T.js +0 -354
- package/dist/template-builder-S33H_d5T.js.map +0 -1
package/src/index.ts
CHANGED
package/src/protocol.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// so older widgets keep working against newer app deployments and vice versa.
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
|
-
export const PROTOCOL_VERSION =
|
|
12
|
+
export const PROTOCOL_VERSION = 3
|
|
13
13
|
|
|
14
14
|
// App → embedder ------------------------------------------------------------
|
|
15
15
|
|
|
@@ -195,6 +195,96 @@ export interface HostLoadMessage {
|
|
|
195
195
|
data: HostLoadData
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* An empty `.fdt` document: no layouts, no layers, no variables. What the
|
|
200
|
+
* widget sends as `HOST_LOAD` content when the host asked for a new template
|
|
201
|
+
* (`new-template`) instead of supplying one, so starting from scratch costs a
|
|
202
|
+
* host no knowledge of the template format.
|
|
203
|
+
*
|
|
204
|
+
* The editor opens on its empty state — "No layouts yet. Click + Add to create
|
|
205
|
+
* one." — and the user picks the canvas size there. Save is refused until a
|
|
206
|
+
* layout exists, and hands back a fully-formed document serialized by the app,
|
|
207
|
+
* not this skeleton.
|
|
208
|
+
*
|
|
209
|
+
* Sent as ordinary `HOST_LOAD` content rather than a new message so it works
|
|
210
|
+
* against app deployments that predate this widget version: the document is
|
|
211
|
+
* the whole signal, and every app that can parse a template can parse this.
|
|
212
|
+
*
|
|
213
|
+
* `version` tracks the app's `TEMPLATE_VERSION` for the benefit of whoever
|
|
214
|
+
* reads this next: nothing consumes it. The parser never looks at it, and the
|
|
215
|
+
* backend never sees this document — the app refuses to save a template with
|
|
216
|
+
* no layouts, so what reaches the render pipeline was re-serialized by the app
|
|
217
|
+
* with a layout present. A widget lagging the app by a version still loads.
|
|
218
|
+
* `design-templates` pins the pair in
|
|
219
|
+
* `src/lib/xml/__tests__/blank-template.test.ts`.
|
|
220
|
+
*/
|
|
221
|
+
export const BLANK_TEMPLATE_XML =
|
|
222
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
223
|
+
'<template><templateInfo><version>0.3</version></templateInfo>' +
|
|
224
|
+
'<rootStack/><layouts/><variables/></template>'
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Host-supplied editor configuration (protocol v3). Sent once the app reports
|
|
228
|
+
* `BUILDER_READY`, and again whenever the host changes it.
|
|
229
|
+
*
|
|
230
|
+
* Separate from `HOST_LOAD` because it is not per-template and because it must
|
|
231
|
+
* also reach DAM-backed embeds, which never receive a `HOST_LOAD` at all — the
|
|
232
|
+
* app loads those templates itself.
|
|
233
|
+
*
|
|
234
|
+
* Purely additive: an app deployment that predates this message ignores it and
|
|
235
|
+
* behaves exactly as before, so a newer widget stays compatible with an older
|
|
236
|
+
* app. Held to the same origin bar as `HOST_LOAD`.
|
|
237
|
+
*/
|
|
238
|
+
export const HOST_CONFIG = 'design-templates:host:config'
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* One field of a host-supplied metadata model, offered in the editor as the
|
|
242
|
+
* "Custom metadata" value source.
|
|
243
|
+
*
|
|
244
|
+
* The model is a vocabulary, not data: it names the fields the host can fill at
|
|
245
|
+
* render time, so an author can bind a variable to `sku` rather than having to
|
|
246
|
+
* remember that the variable's slug happens to mean the SKU. No value travels
|
|
247
|
+
* with it — the host substitutes one by putting `$slug=value` in the render
|
|
248
|
+
* query, exactly as it would for a free-text variable.
|
|
249
|
+
*
|
|
250
|
+
* This is what makes named fields workable in `secTemplate` / stateless embeds,
|
|
251
|
+
* where the Hub project model (and with it the "File metadata" source) is
|
|
252
|
+
* unavailable.
|
|
253
|
+
*/
|
|
254
|
+
export interface CustomMetadataField {
|
|
255
|
+
/**
|
|
256
|
+
* Stable identifier stored in the template as `custom_ckey`. The host's own
|
|
257
|
+
* key for the field — the app never resolves it against anything.
|
|
258
|
+
*/
|
|
259
|
+
key: string
|
|
260
|
+
/** Label shown in the editor's field picker. Falls back to `key` when empty. */
|
|
261
|
+
title?: string
|
|
262
|
+
/** Optional section header, used to group fields in the picker. */
|
|
263
|
+
group?: string
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface HostConfigData {
|
|
267
|
+
/**
|
|
268
|
+
* Metadata model offered as the "Custom metadata" value source. Omitted or
|
|
269
|
+
* empty hides that source in the editor, so a host that sends nothing sees
|
|
270
|
+
* the two sources it always had.
|
|
271
|
+
*/
|
|
272
|
+
customMetadata?: CustomMetadataField[]
|
|
273
|
+
/**
|
|
274
|
+
* Display name for the custom-metadata value source in the editor's UI
|
|
275
|
+
* (source dropdowns, properties-panel section). Defaults to "Custom
|
|
276
|
+
* metadata"; a host can rename it after its own domain — e.g. "External
|
|
277
|
+
* metadata" or "Product attributes". Pure wording: the stored template is
|
|
278
|
+
* unaffected.
|
|
279
|
+
*/
|
|
280
|
+
customMetadataLabel?: string
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface HostConfigMessage {
|
|
284
|
+
type: typeof HOST_CONFIG
|
|
285
|
+
data: HostConfigData
|
|
286
|
+
}
|
|
287
|
+
|
|
198
288
|
/**
|
|
199
289
|
* Stateless mode only (protocol v2). Reports whether the host managed to
|
|
200
290
|
* persist the content it received in `BUILDER_CONTENT`.
|
|
@@ -219,7 +309,7 @@ export interface HostSavedMessage {
|
|
|
219
309
|
data: HostSavedData
|
|
220
310
|
}
|
|
221
311
|
|
|
222
|
-
export type HostMessage = HostLoadMessage | HostSavedMessage
|
|
312
|
+
export type HostMessage = HostLoadMessage | HostSavedMessage | HostConfigMessage
|
|
223
313
|
|
|
224
314
|
// Embed URL contract ----------------------------------------------------------
|
|
225
315
|
|
package/src/react.ts
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
import './define'
|
|
11
11
|
import type { SfxTemplateBuilder } from './template-builder'
|
|
12
12
|
import type { TemplateBuilderSaveDetail } from './template-builder'
|
|
13
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
BuilderDirtyData,
|
|
15
|
+
BuilderErrorData,
|
|
16
|
+
BuilderTheme,
|
|
17
|
+
CustomMetadataField,
|
|
18
|
+
} from './protocol'
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* Hub session — the full-featured credential.
|
|
@@ -50,6 +55,13 @@ export interface TemplateBuilderBaseProps {
|
|
|
50
55
|
stateless?: boolean
|
|
51
56
|
/** Stateless mode: the template to edit, as `.fdt` XML. */
|
|
52
57
|
content?: string
|
|
58
|
+
/**
|
|
59
|
+
* Stateless mode: open on a new, empty template instead of supplying
|
|
60
|
+
* `content`. The widget provides the blank document, the user picks the
|
|
61
|
+
* canvas size in the editor, and `onSave` receives a complete `.fdt` to
|
|
62
|
+
* store. Ignored when `content` is set.
|
|
63
|
+
*/
|
|
64
|
+
newTemplate?: boolean
|
|
53
65
|
/** Stateless mode: display name for the editor header. */
|
|
54
66
|
templateName?: string
|
|
55
67
|
/**
|
|
@@ -62,6 +74,39 @@ export interface TemplateBuilderBaseProps {
|
|
|
62
74
|
brandColor?: string
|
|
63
75
|
/** Colour scheme for the editor chrome. */
|
|
64
76
|
theme?: BuilderTheme
|
|
77
|
+
/**
|
|
78
|
+
* Metadata model offered in the editor as the "Custom metadata" value source
|
|
79
|
+
* — names only, no values. Omit it and the source is not offered.
|
|
80
|
+
*
|
|
81
|
+
* Compared by identity, like every other prop here, so a freshly built array
|
|
82
|
+
* counts as a change. Nothing is re-sent to the editor over it — the element
|
|
83
|
+
* de-dupes by value — but hoisting or memoising the array avoids the churn.
|
|
84
|
+
*/
|
|
85
|
+
customMetadata?: CustomMetadataField[]
|
|
86
|
+
/**
|
|
87
|
+
* Display name for the custom-metadata value source in the editor's UI —
|
|
88
|
+
* e.g. "External metadata". Wording only: the stored template is unaffected.
|
|
89
|
+
* Empty uses the editor's default, "Custom metadata".
|
|
90
|
+
*/
|
|
91
|
+
customMetadataLabel?: string
|
|
92
|
+
/**
|
|
93
|
+
* Stateless only: store each save in Filerobot too, so the CDN can render
|
|
94
|
+
* it. `onSave`'s detail then carries `stored: { uuid, url }` next to the raw
|
|
95
|
+
* `content` — or `storeError` when the copy failed.
|
|
96
|
+
*/
|
|
97
|
+
damStore?: boolean
|
|
98
|
+
/**
|
|
99
|
+
* Folder new templates land in under `damStore` when the template id names
|
|
100
|
+
* no existing DAM file (an existing file's own folder always wins).
|
|
101
|
+
*/
|
|
102
|
+
storeFolder?: string
|
|
103
|
+
/**
|
|
104
|
+
* `damStore`: the `stored.uuid` a previous session's save reported for THIS
|
|
105
|
+
* document, so re-saves after a reload resolve to (and version) the copy
|
|
106
|
+
* that already exists instead of erroring on unchanged content or starting
|
|
107
|
+
* a fresh file. Per-document — pass it with the content it belongs to.
|
|
108
|
+
*/
|
|
109
|
+
storedUuid?: string
|
|
65
110
|
readyTimeout?: number
|
|
66
111
|
className?: string
|
|
67
112
|
style?: CSSProperties
|
|
@@ -147,6 +192,12 @@ export const TemplateBuilder = forwardRef<
|
|
|
147
192
|
el.templateQuery = config.templateQuery ?? ''
|
|
148
193
|
el.brandColor = config.brandColor ?? ''
|
|
149
194
|
el.theme = config.theme ?? ''
|
|
195
|
+
el.newTemplate = config.newTemplate ?? false
|
|
196
|
+
el.customMetadata = config.customMetadata ?? []
|
|
197
|
+
el.customMetadataLabel = config.customMetadataLabel ?? ''
|
|
198
|
+
el.damStore = config.damStore ?? false
|
|
199
|
+
el.storeFolder = config.storeFolder ?? '/'
|
|
200
|
+
el.storedUuid = config.storedUuid ?? ''
|
|
150
201
|
// Assigned last: the element sends content to the app as soon as it has
|
|
151
202
|
// both a request and a value, so the id, name and query must already be
|
|
152
203
|
// set — all four ship as one message.
|
|
@@ -164,13 +215,37 @@ export const TemplateBuilder = forwardRef<
|
|
|
164
215
|
config.mode,
|
|
165
216
|
config.stateless,
|
|
166
217
|
config.content,
|
|
218
|
+
config.newTemplate,
|
|
167
219
|
config.templateName,
|
|
168
220
|
config.templateQuery,
|
|
169
221
|
config.brandColor,
|
|
170
222
|
config.theme,
|
|
223
|
+
config.customMetadata,
|
|
224
|
+
config.customMetadataLabel,
|
|
225
|
+
config.damStore,
|
|
226
|
+
config.storeFolder,
|
|
227
|
+
config.storedUuid,
|
|
171
228
|
config.readyTimeout,
|
|
172
229
|
])
|
|
173
230
|
|
|
231
|
+
// The callbacks the listeners read at event time. A ref rather than effect
|
|
232
|
+
// dependencies: listeners are attached once per element (below), so a parent
|
|
233
|
+
// re-render swapping handler identities costs nothing — and, decisively, the
|
|
234
|
+
// listeners are still attached during the element's disconnect-time flush of
|
|
235
|
+
// pending dam-store saves, which a resubscribe-per-change cleanup would have
|
|
236
|
+
// already torn down.
|
|
237
|
+
const handlers = useRef({
|
|
238
|
+
onReady,
|
|
239
|
+
onOpen,
|
|
240
|
+
onClose,
|
|
241
|
+
onSave,
|
|
242
|
+
onError,
|
|
243
|
+
onDirtyChange,
|
|
244
|
+
})
|
|
245
|
+
useLayoutEffect(() => {
|
|
246
|
+
handlers.current = { onReady, onOpen, onClose, onSave, onError, onDirtyChange }
|
|
247
|
+
})
|
|
248
|
+
|
|
174
249
|
// Layout effect, not passive: the element reports config errors (e.g.
|
|
175
250
|
// `invalid-base-url`) in a microtask queued during this same commit, and a
|
|
176
251
|
// passive effect would subscribe only after that microtask has fired —
|
|
@@ -180,43 +255,61 @@ export const TemplateBuilder = forwardRef<
|
|
|
180
255
|
const el = ref.current
|
|
181
256
|
if (!el) return
|
|
182
257
|
const subs: Array<[string, EventListener]> = []
|
|
183
|
-
const on = (
|
|
184
|
-
|
|
185
|
-
|
|
258
|
+
const on = (
|
|
259
|
+
name: string,
|
|
260
|
+
pick: (h: typeof handlers.current) => ((detail: never) => void) | undefined,
|
|
261
|
+
) => {
|
|
262
|
+
const listener = ((e: CustomEvent) =>
|
|
263
|
+
pick(handlers.current)?.(e.detail as never)) as EventListener
|
|
186
264
|
el.addEventListener(name, listener)
|
|
187
265
|
subs.push([name, listener])
|
|
188
266
|
}
|
|
189
|
-
on('ready', onReady)
|
|
190
|
-
on('open', onOpen)
|
|
191
|
-
on('close', onClose)
|
|
192
|
-
on('error', onError)
|
|
193
|
-
on('dirtychange', onDirtyChange)
|
|
267
|
+
on('ready', (h) => h.onReady)
|
|
268
|
+
on('open', (h) => h.onOpen)
|
|
269
|
+
on('close', (h) => h.onClose)
|
|
270
|
+
on('error', (h) => h.onError)
|
|
271
|
+
on('dirtychange', (h) => h.onDirtyChange)
|
|
194
272
|
|
|
195
273
|
// `save` is not just re-emitted: in stateless mode the handler's outcome
|
|
196
274
|
// is acked back, so a failed write on the host side doesn't leave the
|
|
197
275
|
// editor showing the template as saved. `confirmSave` no-ops in DAM mode.
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
276
|
+
const saveListener = ((e: CustomEvent<TemplateBuilderSaveDetail>) => {
|
|
277
|
+
const onSaveNow = handlers.current.onSave
|
|
278
|
+
if (!onSaveNow) return
|
|
279
|
+
// Wrapped in a promise so a synchronous throw is handled like a
|
|
280
|
+
// rejection, and a sync `false` like a resolved one.
|
|
281
|
+
Promise.resolve()
|
|
282
|
+
.then(() => onSaveNow(e.detail))
|
|
283
|
+
.then((result) => el.confirmSave(result !== false))
|
|
284
|
+
.catch((err) => {
|
|
285
|
+
// No message: an internal error string is not something to put in
|
|
286
|
+
// front of the end user. The editor uses its own wording.
|
|
287
|
+
console.error('[sfx-template-builder] onSave failed:', err)
|
|
288
|
+
el.confirmSave(false)
|
|
289
|
+
})
|
|
290
|
+
}) as EventListener
|
|
291
|
+
el.addEventListener('save', saveListener)
|
|
292
|
+
subs.push(['save', saveListener])
|
|
215
293
|
|
|
216
294
|
return () => {
|
|
295
|
+
// React runs this cleanup BEFORE it detaches the node, so the element's
|
|
296
|
+
// own disconnect-time flush of in-flight dam-store saves would fire
|
|
297
|
+
// after every listener is gone — and the raw save would be silently
|
|
298
|
+
// lost. Flushing here, while the listeners are still attached, hands
|
|
299
|
+
// those saves (with `storeError` in place of the links) to `onSave`
|
|
300
|
+
// first. A no-op when nothing is pending, including StrictMode's
|
|
301
|
+
// simulated unmount at mount time.
|
|
302
|
+
//
|
|
303
|
+
// Guarded: when an older CDN bundle registered the tag first, `el` is
|
|
304
|
+
// that bundle's class and lacks the method — every other new-API use
|
|
305
|
+
// degrades silently via property assignment, and unmount must not be
|
|
306
|
+
// the one path that throws.
|
|
307
|
+
if (typeof el.flushPendingSaves === 'function') {
|
|
308
|
+
el.flushPendingSaves('widget removed before the rendering copy completed')
|
|
309
|
+
}
|
|
217
310
|
for (const [name, listener] of subs) el.removeEventListener(name, listener)
|
|
218
311
|
}
|
|
219
|
-
}, [
|
|
312
|
+
}, [])
|
|
220
313
|
|
|
221
314
|
// eslint-disable-next-line react-hooks/refs -- ref is forwarded as a prop, not read during render
|
|
222
315
|
return createElement('sfx-template-builder', { ref, class: className, style })
|