hyper-html-api 0.1.0 → 0.2.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.
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Walks a (possibly merged) rules tree and renders a form DOM subtree.
3
+ *
4
+ * Dispatch on rule shape:
5
+ * "selector" → scalar widget (inferred from rendered app)
6
+ * "selector[]" → scalar-array repeater
7
+ * [selector, shape] → object-array repeater (cards)
8
+ * { key: rule, ... } → labeled section; recurses
9
+ *
10
+ * Rendered DOM is purely declarative — no event listeners are attached
11
+ * during build. Events are dispatched via `bindFormEvents(formRoot, ...)`,
12
+ * which installs ONE delegated handler on the form root and routes by
13
+ * data-attribute. Delegation is required because hyper-morph's createNode
14
+ * path inserts new nodes via `document.importNode`, which does NOT
15
+ * transfer addEventListener-style listeners — so any handler attached
16
+ * during build would be silently lost the first time the morph inserts a
17
+ * new card/row.
18
+ *
19
+ * Widget call sites use the locked object-arg signature (cms-plan.md §
20
+ * "Widget registry"). Phase 1 dispatches inline on widget type; phase 3
21
+ * swaps in the registry without changing call sites.
22
+ */
23
+
24
+ import {
25
+ fromString as pathFromString,
26
+ getRuleAtPath,
27
+ getValueAtPath,
28
+ setAtPath,
29
+ toString as pathToString,
30
+ } from './path.js'
31
+ import { scaffold } from './scaffold.js'
32
+ import { widgetHandles } from './widget-handles.js'
33
+
34
+ const BOOL_PROPS = new Set(['checked', 'selected', 'disabled', 'readOnly', 'paused'])
35
+
36
+ /** Public entry. Returns a DocumentFragment so the caller decides where to mount. */
37
+ export function buildForm({ rules, data, appRoot, cmsRoot }) {
38
+ const frag = document.createDocumentFragment()
39
+ const ctx = { rules, appRoot, cmsRoot }
40
+ frag.appendChild(buildNode({ rule: rules, value: data, path: [], ctx }))
41
+ return frag
42
+ }
43
+
44
+ /**
45
+ * Register a form root for event delegation, and ensure the global
46
+ * document-level dispatcher is installed.
47
+ *
48
+ * formRoot — element hosting the form output (typically dom.form). Tagged
49
+ * with `data-hha-form-root` so the global dispatcher can find
50
+ * the nearest registered root from any event target.
51
+ * rules — the merged rule tree used to resolve object-array shapes
52
+ * for "+ add" actions.
53
+ * getData — function returning the current data tree (must reflect
54
+ * the latest state, not a snapshot).
55
+ * onChange — (newData, { structural }) => void.
56
+ *
57
+ * Idempotent: calling twice on the same root overwrites its config. Document
58
+ * listeners are attached once globally and never removed (no leakage —
59
+ * inactive roots simply don't match the closest-ancestor lookup).
60
+ *
61
+ * Why delegate on `document` rather than on each form root:
62
+ * - One handler regardless of how many CMS forms coexist on the page.
63
+ * - The CMS form can be wrapped, moved, or re-parented without re-binding.
64
+ * - Authors can host the form inside their own delegated handlers without
65
+ * coordinating with us (events bubble past, we filter by data attribute).
66
+ */
67
+ export function bindFormEvents(formRoot, { rules, getData, onChange }) {
68
+ formRoot.setAttribute('data-hha-form-root', '')
69
+ formRoots.set(formRoot, { rules, getData, onChange })
70
+ installDocumentListeners()
71
+ }
72
+
73
+ const formRoots = new WeakMap()
74
+ let listenersInstalled = false
75
+
76
+ function installDocumentListeners() {
77
+ if (listenersInstalled) return
78
+ document.addEventListener('input', handleValueEvent, true)
79
+ document.addEventListener('change', handleValueEvent, true)
80
+ document.addEventListener('click', handleClick, true)
81
+ listenersInstalled = true
82
+ }
83
+
84
+ function lookupConfig(target) {
85
+ if (!(target instanceof Element)) return null
86
+ const root = target.closest('[data-hha-form-root]')
87
+ return root ? formRoots.get(root) || null : null
88
+ }
89
+
90
+ // ─ delegated handlers ──────────────────────────────────────────────
91
+
92
+ function handleClick(e) {
93
+ if (!(e.target instanceof Element)) return
94
+ const btn = e.target.closest('[data-hha-action]')
95
+ if (!btn) return
96
+ if (e.defaultPrevented) return
97
+ const config = lookupConfig(btn)
98
+ if (!config) return
99
+ const { rules, getData, onChange } = config
100
+ const action = btn.getAttribute('data-hha-action')
101
+ const path = pathFromString(btn.getAttribute('data-hha-path') || '')
102
+
103
+ if (action === 'scalar-array-add') {
104
+ const current = getValueAtPath(getData(), path) || []
105
+ onChange(setAtPath(getData(), path, [...current, '']), { structural: true })
106
+ return
107
+ }
108
+ if (action === 'object-array-add') {
109
+ const rule = getRuleAtPath(rules, path)
110
+ if (!Array.isArray(rule)) return
111
+ const current = getValueAtPath(getData(), path) || []
112
+ onChange(setAtPath(getData(), path, [...current, scaffold(rule[1])]), {
113
+ structural: true,
114
+ })
115
+ return
116
+ }
117
+ if (action === 'array-remove') {
118
+ if (path.length === 0) return
119
+ const parentPath = path.slice(0, -1)
120
+ const index = path[path.length - 1]
121
+ const current = getValueAtPath(getData(), parentPath) || []
122
+ if (typeof index !== 'number') return
123
+ const next = current.filter((_, i) => i !== index)
124
+ onChange(setAtPath(getData(), parentPath, next), { structural: true })
125
+ }
126
+ }
127
+
128
+ function handleValueEvent(e) {
129
+ const target = e.target
130
+ if (!target?.matches?.('input, textarea, select')) return
131
+ const config = lookupConfig(target)
132
+ if (!config) return
133
+ const { getData, onChange } = config
134
+ const row = target.closest('[data-hha-path]')
135
+ if (!row) return
136
+ const path = pathFromString(row.getAttribute('data-hha-path') || '')
137
+ const value = target.type === 'checkbox' ? target.checked : target.value
138
+ onChange(setAtPath(getData(), path, value), { structural: false })
139
+ }
140
+
141
+ // ─ dispatch ─────────────────────────────────────────────────────────
142
+
143
+ function buildNode({ rule, value, path, ctx }) {
144
+ if (typeof rule === 'string') {
145
+ if (rule.endsWith('[]')) return buildScalarArray({ rule, value, path, ctx })
146
+ return buildScalarField({ rule, value, path, ctx })
147
+ }
148
+ if (Array.isArray(rule)) return buildObjectArray({ rule, value, path, ctx })
149
+ if (typeof rule === 'object' && rule !== null) {
150
+ return buildObject({ rule, value, path, ctx })
151
+ }
152
+ const span = document.createElement('span')
153
+ span.textContent = '(unsupported rule)'
154
+ return span
155
+ }
156
+
157
+ // ─ object ───────────────────────────────────────────────────────────
158
+
159
+ function buildObject({ rule, value, path, ctx }) {
160
+ const wrap = document.createElement('div')
161
+ wrap.className = 'form-section'
162
+ wrap.setAttribute('data-hha-path', pathToString(path))
163
+
164
+ if (path.length > 0) {
165
+ const label = document.createElement('div')
166
+ label.className = 'form-section-label'
167
+ label.textContent = humanize(path[path.length - 1])
168
+ wrap.appendChild(label)
169
+ }
170
+
171
+ for (const [key, sub] of Object.entries(rule)) {
172
+ const subPath = [...path, key]
173
+ const subValue = value == null ? undefined : value[key]
174
+ wrap.appendChild(buildNode({ rule: sub, value: subValue, path: subPath, ctx }))
175
+ }
176
+
177
+ return wrap
178
+ }
179
+
180
+ // ─ scalar field ─────────────────────────────────────────────────────
181
+
182
+ function buildScalarField({ rule, value, path, ctx }) {
183
+ const row = document.createElement('div')
184
+ row.className = 'field-row'
185
+ row.setAttribute('data-hha-path', pathToString(path))
186
+
187
+ const body = document.createElement('div')
188
+ body.className = 'field-body'
189
+
190
+ const label = document.createElement('label')
191
+ label.className = 'field-label'
192
+ label.textContent = humanize(path[path.length - 1] || '.') + ruleHint(rule)
193
+
194
+ const widget = widgetFor(rule, ctx.appRoot)
195
+ const handle = invokeWidget(widget, {
196
+ value,
197
+ rule,
198
+ path,
199
+ appRoot: ctx.appRoot,
200
+ })
201
+
202
+ const input = handle.el
203
+ input.classList.add(widget.type === 'textarea' ? 'field-textarea' : 'field-input')
204
+
205
+ body.appendChild(label)
206
+ body.appendChild(input)
207
+ row.appendChild(body)
208
+
209
+ widgetHandles.set(row, handle)
210
+ return row
211
+ }
212
+
213
+ // ─ scalar array ─────────────────────────────────────────────────────
214
+
215
+ function buildScalarArray({ rule, value, path, ctx }) {
216
+ const wrap = document.createElement('div')
217
+ wrap.className = 'form-section'
218
+ wrap.setAttribute('data-hha-path', pathToString(path))
219
+
220
+ const label = document.createElement('div')
221
+ label.className = 'form-section-label'
222
+ label.textContent = humanize(path[path.length - 1])
223
+ wrap.appendChild(label)
224
+
225
+ const items = Array.isArray(value) ? value : []
226
+ const list = document.createElement('div')
227
+ wrap.appendChild(list)
228
+
229
+ items.forEach((item, i) => {
230
+ const itemPath = [...path, i]
231
+ const row = document.createElement('div')
232
+ row.className = 'scalar-array-row'
233
+ row.setAttribute('data-hha-path', pathToString(itemPath))
234
+
235
+ const input = document.createElement('input')
236
+ input.type = 'text'
237
+ input.className = 'field-input'
238
+ input.value = item == null ? '' : String(item)
239
+
240
+ const remove = document.createElement('button')
241
+ remove.type = 'button'
242
+ remove.className = 'array-remove'
243
+ remove.textContent = '×'
244
+ remove.title = 'remove'
245
+ remove.setAttribute('data-hha-action', 'array-remove')
246
+ remove.setAttribute('data-hha-path', pathToString(itemPath))
247
+
248
+ row.appendChild(input)
249
+ row.appendChild(remove)
250
+ list.appendChild(row)
251
+ })
252
+
253
+ const add = document.createElement('button')
254
+ add.type = 'button'
255
+ add.className = 'array-add'
256
+ add.textContent = '+ add'
257
+ add.setAttribute('data-hha-action', 'scalar-array-add')
258
+ add.setAttribute('data-hha-path', pathToString(path))
259
+ wrap.appendChild(add)
260
+
261
+ return wrap
262
+ }
263
+
264
+ // ─ object array ─────────────────────────────────────────────────────
265
+
266
+ function buildObjectArray({ rule, value, path, ctx }) {
267
+ const [, shape] = rule
268
+ const items = Array.isArray(value) ? value : []
269
+
270
+ const wrap = document.createElement('div')
271
+ wrap.className = 'form-section'
272
+ wrap.setAttribute('data-hha-path', pathToString(path))
273
+
274
+ const label = document.createElement('div')
275
+ label.className = 'form-section-label'
276
+ label.textContent = humanize(path[path.length - 1])
277
+ wrap.appendChild(label)
278
+
279
+ items.forEach((item, i) => {
280
+ const itemPath = [...path, i]
281
+ const card = document.createElement('div')
282
+ card.className = 'array-card'
283
+ card.setAttribute('data-hha-path', pathToString(itemPath))
284
+
285
+ const remove = document.createElement('button')
286
+ remove.type = 'button'
287
+ remove.className = 'array-remove'
288
+ remove.textContent = '×'
289
+ remove.title = 'remove item'
290
+ remove.setAttribute('data-hha-action', 'array-remove')
291
+ remove.setAttribute('data-hha-path', pathToString(itemPath))
292
+ card.appendChild(remove)
293
+
294
+ const inner = buildNode({
295
+ rule: shape,
296
+ value: item,
297
+ path: itemPath,
298
+ ctx,
299
+ })
300
+ // The nested object renders its own section-label from its key; strip it
301
+ // here since the card is already the container and the key is an index.
302
+ inner.querySelector('.form-section-label')?.remove()
303
+ card.appendChild(inner)
304
+ wrap.appendChild(card)
305
+ })
306
+
307
+ const add = document.createElement('button')
308
+ add.type = 'button'
309
+ add.className = 'array-add'
310
+ add.textContent = '+ add'
311
+ add.setAttribute('data-hha-action', 'object-array-add')
312
+ add.setAttribute('data-hha-path', pathToString(path))
313
+ wrap.appendChild(add)
314
+
315
+ return wrap
316
+ }
317
+
318
+ // ─ widget invocation (phase 1: inline; phase 3: registry) ──────────
319
+
320
+ /**
321
+ * Build the widget element with the locked object-arg signature and
322
+ * render its initial value. Returns a normalized handle so the morph
323
+ * hook can unconditionally call destroy?.() at removal time.
324
+ *
325
+ * No event listeners are wired here — events flow through the form
326
+ * root's delegated handler (see bindFormEvents above).
327
+ *
328
+ * Phase 3 will move type-specific builders into src/cms/registry.js and
329
+ * dispatch via `registry.get(name)(ctx)` — the call site stays put.
330
+ */
331
+ function invokeWidget(widget, ctx) {
332
+ const el = makeInput(widget)
333
+
334
+ // Values are set via PROPERTY assignment. morphForm passes
335
+ // `formStateSync: 'property'` to hyper-morph (see src/cms/morph.js), so
336
+ // attribute-driven value sync is disabled. Callers that need
337
+ // attribute-driven sync (e.g. hyperclay-livesync) are unaffected; this is
338
+ // an opt-out exposed by hyper-morph 0.3.0.
339
+ if (widget.type === 'select' && widget.options) {
340
+ for (const opt of widget.options) {
341
+ const o = document.createElement('option')
342
+ o.value = opt.value
343
+ o.textContent = opt.text
344
+ if (String(ctx.value) === opt.value) o.selected = true
345
+ el.appendChild(o)
346
+ }
347
+ } else if (widget.type === 'checkbox') {
348
+ el.checked = ctx.value === true || ctx.value === 'true' || ctx.value === 'checked'
349
+ } else if (widget.type === 'textarea') {
350
+ el.value = ctx.value == null ? '' : String(ctx.value)
351
+ } else {
352
+ el.value = ctx.value == null ? '' : String(ctx.value)
353
+ }
354
+
355
+ return {
356
+ el,
357
+ destroy() {},
358
+ focus() {
359
+ el.focus()
360
+ },
361
+ validate() {},
362
+ }
363
+ }
364
+
365
+ // ─ widget inference ─────────────────────────────────────────────────
366
+
367
+ function widgetFor(rule, appRoot) {
368
+ let propName = null
369
+ if (rule.startsWith('@')) propName = rule.slice(1)
370
+ else if (rule.includes('@')) propName = rule.split('@')[1] || null
371
+
372
+ if (propName && BOOL_PROPS.has(propName)) return { type: 'checkbox' }
373
+ if (propName === 'innerHTML') return { type: 'textarea' }
374
+
375
+ const target = queryTarget(rule, appRoot)
376
+ if (!target) return { type: 'text' }
377
+
378
+ const tag = (target.tagName || '').toLowerCase()
379
+ if (tag === 'textarea') return { type: 'textarea' }
380
+ if (tag === 'select') {
381
+ const options = Array.from(target.querySelectorAll('option')).map((o) => ({
382
+ value: o.getAttribute('value') || o.textContent,
383
+ text: o.textContent.trim() || o.getAttribute('value') || '',
384
+ }))
385
+ return { type: 'select', options }
386
+ }
387
+ if (tag === 'input') {
388
+ const inputType = (target.getAttribute('type') || 'text').toLowerCase()
389
+ if (inputType === 'checkbox' || inputType === 'radio') return { type: 'checkbox' }
390
+ if (inputType === 'number') return { type: 'number' }
391
+ }
392
+ const txt = (target.textContent || '').trim()
393
+ if (txt.length > 60 || txt.includes('\n')) return { type: 'textarea' }
394
+
395
+ return { type: 'text' }
396
+ }
397
+
398
+ function queryTarget(rule, appRoot) {
399
+ if (!appRoot) return null
400
+ if (rule === '.' || rule === '') return null
401
+ if (rule.startsWith('@')) return null
402
+ const selector = rule.includes('@') ? rule.split('@')[0] : rule
403
+ if (!selector) return null
404
+ try {
405
+ return appRoot.querySelector(selector)
406
+ } catch {
407
+ return null
408
+ }
409
+ }
410
+
411
+ function makeInput(widget) {
412
+ if (widget.type === 'textarea') return document.createElement('textarea')
413
+ if (widget.type === 'select') return document.createElement('select')
414
+ const el = document.createElement('input')
415
+ el.type = widget.type === 'checkbox' ? 'checkbox' : widget.type === 'number' ? 'number' : 'text'
416
+ return el
417
+ }
418
+
419
+ // ─ misc ─────────────────────────────────────────────────────────────
420
+
421
+ function humanize(key) {
422
+ if (typeof key === 'number') return `#${key + 1}`
423
+ return String(key)
424
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
425
+ .replace(/[_-]+/g, ' ')
426
+ .replace(/^\w/, (c) => c.toUpperCase())
427
+ }
428
+
429
+ function ruleHint(rule) {
430
+ return ` · ${rule}`
431
+ }
@@ -0,0 +1,4 @@
1
+ export { buildForm, bindFormEvents } from './form-builder.js'
2
+ export { scaffold } from './scaffold.js'
3
+ export { morphForm } from './morph.js'
4
+ export * as path from './path.js'
@@ -0,0 +1,44 @@
1
+ import HyperMorph from 'hyper-morph'
2
+ import { widgetHandles } from './widget-handles.js'
3
+
4
+ /**
5
+ * Morphs the contents of `formRoot` to match `newFragment`, preserving
6
+ * focus, caret, and live widget instances where possible.
7
+ *
8
+ * The morph wraps HyperMorph with:
9
+ * morphStyle: 'innerHTML' — children of formRoot map to children
10
+ * of newFragment
11
+ * ignoreActiveValue: true — never overwrite a value the user is mid-typing
12
+ * restoreFocus: true — restore focus by id after the morph
13
+ * formStateSync: 'property' — the form-builder authors values via
14
+ * property assignment (`el.value = …`), not via the
15
+ * `value` attribute. The default `'attribute'` mode
16
+ * exists for hyperclay-livesync, where every
17
+ * keystroke is mirrored into the `value` attribute so
18
+ * cloneNode/outerHTML captures it; that's not how the
19
+ * CMS form authors its DOM, so we ask hyper-morph to
20
+ * treat the property as authoritative instead.
21
+ * beforeNodeRemoved: looks up the removed node in the shared
22
+ * widgetHandles WeakMap and runs destroy() if present,
23
+ * then invokes the optional caller hook
24
+ *
25
+ * Note: hyper-morph moves persistent / hyper-matched nodes to a "pantry"
26
+ * instead of calling beforeNodeRemoved. A field row that survives the
27
+ * structural change does NOT fire destroy — which is correct, the widget
28
+ * instance persists with it.
29
+ */
30
+ export function morphForm(formRoot, newFragment, { onWidgetRemoved } = {}) {
31
+ HyperMorph.morph(formRoot, newFragment, {
32
+ morphStyle: 'innerHTML',
33
+ ignoreActiveValue: true,
34
+ restoreFocus: true,
35
+ formStateSync: 'property',
36
+ callbacks: {
37
+ beforeNodeRemoved(node) {
38
+ const handle = widgetHandles.get(node)
39
+ if (handle?.destroy) handle.destroy()
40
+ onWidgetRemoved?.(node)
41
+ },
42
+ },
43
+ })
44
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Canonical path representation for the CMS layer.
3
+ *
4
+ * One source of truth used by form-builder traversal, sidecar config keys
5
+ * (phase 2), data-hha-path attributes, and the apply-loop's snapshot scope
6
+ * (phase 4). Three serialized forms:
7
+ *
8
+ * internal Array<string|number> ["products", 0, "name"]
9
+ * stringified dot-separated literal "products.0.name"
10
+ * wildcard numbers replaced with * "products.*.name"
11
+ *
12
+ * Numbers represent array indices; strings represent object keys. The
13
+ * stringified form is what we write to data-hha-path; the wildcard form is
14
+ * what sidecar config keys use.
15
+ */
16
+
17
+ export function toString(path) {
18
+ return path.map(String).join('.')
19
+ }
20
+
21
+ export function toConfigKey(path) {
22
+ return path.map((seg) => (typeof seg === 'number' ? '*' : String(seg))).join('.')
23
+ }
24
+
25
+ export function parseConfigKey(str) {
26
+ if (str === '') return []
27
+ return str.split('.')
28
+ }
29
+
30
+ /**
31
+ * Inverse of `toString`: parses the dot-separated stringified form back to
32
+ * the internal array, converting digit-only segments to numbers. Used by
33
+ * the form's delegated event handler to recover the path from
34
+ * `data-hha-path` attributes.
35
+ */
36
+ export function fromString(str) {
37
+ if (str === '') return []
38
+ return str.split('.').map((seg) => (/^\d+$/.test(seg) ? Number(seg) : seg))
39
+ }
40
+
41
+ /**
42
+ * Walks a (possibly merged) rule tree along the given path.
43
+ *
44
+ * Rule grammar (see cms-plan.md § Form rendering):
45
+ * string scalar or scalar-array ("...[]") rule — leaf
46
+ * [selector, shape] object-array rule; numeric and "*" segments
47
+ * recurse into shape (every item shares it)
48
+ * { key: rule, ... } object rule; string segment indexes
49
+ *
50
+ * Returns the rule at the path, or undefined if the path doesn't address
51
+ * a node.
52
+ */
53
+ export function getRuleAtPath(rules, path) {
54
+ let node = rules
55
+ for (const seg of path) {
56
+ if (node == null) return undefined
57
+ if (typeof node === 'string') return undefined
58
+ if (Array.isArray(node)) {
59
+ if (typeof seg !== 'number' && seg !== '*') return undefined
60
+ node = node[1]
61
+ continue
62
+ }
63
+ if (typeof node === 'object') {
64
+ if (typeof seg === 'number') return undefined
65
+ if (!(seg in node)) return undefined
66
+ node = node[seg]
67
+ continue
68
+ }
69
+ return undefined
70
+ }
71
+ return node
72
+ }
73
+
74
+ /**
75
+ * Walks a data tree, tolerating missing intermediate nodes.
76
+ */
77
+ export function getValueAtPath(data, path) {
78
+ let node = data
79
+ for (const seg of path) {
80
+ if (node == null) return undefined
81
+ node = node[seg]
82
+ }
83
+ return node
84
+ }
85
+
86
+ /**
87
+ * Immutable splice: returns a new tree with `value` placed at `path`.
88
+ * Numeric leading segments copy-on-write the surrounding array; string
89
+ * segments copy-on-write the surrounding object. Missing intermediate
90
+ * nodes are created (object literal for string segments, sparse array for
91
+ * numeric segments) to match the existing prototype semantics.
92
+ */
93
+ export function setAtPath(obj, path, value) {
94
+ if (path.length === 0) return value
95
+ const [k, ...rest] = path
96
+ if (typeof k === 'number') {
97
+ const next = Array.isArray(obj) ? [...obj] : []
98
+ next[k] = setAtPath(next[k], rest, value)
99
+ return next
100
+ }
101
+ return {
102
+ ...(obj && typeof obj === 'object' ? obj : {}),
103
+ [k]: setAtPath((obj || {})[k], rest, value),
104
+ }
105
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Produce an empty value matching a rule shape.
3
+ *
4
+ * Mirrors the shape with empty leaves so apply's list diff has something
5
+ * to write into the cloned template when an author clicks "+ add" on a
6
+ * list.
7
+ *
8
+ * "title" → ""
9
+ * "li[]" → []
10
+ * ["sel", { ... }] → []
11
+ * { name: "h1", ... } → { name: "" , ... }
12
+ *
13
+ * Anything else falls through to the scalar default ("").
14
+ */
15
+ export function scaffold(shape) {
16
+ if (typeof shape === 'string') return shape.endsWith('[]') ? [] : ''
17
+ if (Array.isArray(shape)) return []
18
+ if (typeof shape === 'object' && shape !== null) {
19
+ const out = {}
20
+ for (const [k, v] of Object.entries(shape)) out[k] = scaffold(v)
21
+ return out
22
+ }
23
+ return ''
24
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Module-level WeakMap<HTMLElement, WidgetHandle> shared by form-builder
3
+ * (writes), morphForm (reads on remove), and phase 5's shell-close logic
4
+ * (reads on close).
5
+ *
6
+ * Internal-only — not re-exported from src/cms/index.js. We avoid stashing
7
+ * handles as properties on the DOM element so author CSS/JS can't collide
8
+ * with our internals, and entries clean up automatically when the row is
9
+ * garbage-collected.
10
+ *
11
+ * A WidgetHandle is the normalized shape returned by every widget invocation:
12
+ * { el, destroy(), focus(), validate() }
13
+ * Built-in widgets that don't need cleanup register a handle with a no-op
14
+ * destroy so the morph hook can unconditionally call destroy?.() without
15
+ * special-casing.
16
+ */
17
+ export const widgetHandles = new WeakMap()