hyper-html-api 0.1.0 → 0.3.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 +22 -8
- package/dist/hyper-html-api.engine.min.js +1 -0
- package/dist/hyper-html-api.min.js +3 -2
- package/package.json +11 -5
- package/src/adapters/cheerio.js +47 -7
- package/src/adapters/dom.js +31 -6
- package/src/cms/form-builder.js +431 -0
- package/src/cms/index.js +4 -0
- package/src/cms/morph.js +44 -0
- package/src/cms/path.js +105 -0
- package/src/cms/scaffold.js +24 -0
- package/src/cms/widget-handles.js +17 -0
- package/src/data.js +10 -0
- package/src/dom-api.js +60 -0
- package/src/engine/apply.js +63 -27
- package/src/engine/diff.js +54 -16
- package/src/engine/dom-properties.js +53 -6
- package/src/engine/errors.js +11 -1
- package/src/engine/extract.js +16 -16
- package/src/engine/index.js +34 -0
- package/src/engine/rules-tag.js +28 -6
- package/src/engine/rules.js +24 -5
- package/src/hyper-html-api.js +13 -17
|
@@ -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
|
+
}
|
package/src/cms/index.js
ADDED
package/src/cms/morph.js
ADDED
|
@@ -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
|
+
}
|
package/src/cms/path.js
ADDED
|
@@ -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()
|
package/src/data.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Lean data entry: the DOM-bound engine + the data sugar, WITHOUT cms/upgrade.
|
|
2
|
+
// This is what `hyper-html-api/data` exports and what hyperclayjs vendors, so
|
|
3
|
+
// the `window.hyperclay.extractData/applyData` feature stays small (data
|
|
4
|
+
// extract/apply is a core primitive, not a cms one).
|
|
5
|
+
import { engine, extractData, applyData } from './dom-api.js'
|
|
6
|
+
|
|
7
|
+
export { engine, extractData, applyData }
|
|
8
|
+
|
|
9
|
+
const HyperHtmlApiData = { engine, extractData, applyData }
|
|
10
|
+
export default HyperHtmlApiData
|