@panphora/hyper-cms 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.
@@ -0,0 +1,161 @@
1
+ import { engine } from 'hyper-html-api'
2
+ import { fromString as pathFromString, getRuleAtPath } from './path.js'
3
+
4
+ const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
5
+
6
+ // Engine reads/writes against pageRoot pass skip + templateAttr so the
7
+ // engine never traverses into the form's own DOM, and so [cms-template]
8
+ // nodes are treated as seed templates (not real data). Scalar applies skip
9
+ // snapshot — string user input can't produce a ShapeMismatch and the focused
10
+ // input must survive every keystroke.
11
+ //
12
+ // Structural applies (add/remove/reorder) can hit EmptyListInsert and other
13
+ // engine errors. We snapshot just the affected array container's slot so
14
+ // rollback restores only the failing subtree; listeners and state elsewhere
15
+ // on the page survive.
16
+ export function applyWithRollback(pageRoot, pageRules, newData, options = {}) {
17
+ const { observerHandle, shellRoot, structural, structuralPath } = options
18
+ observerHandle?.pause?.()
19
+ try {
20
+ if (!structural) {
21
+ try {
22
+ engine.apply(pageRoot, pageRules, newData, ENGINE_OPTS)
23
+ return { ok: true }
24
+ } catch (err) {
25
+ return { ok: false, error: err }
26
+ }
27
+ }
28
+
29
+ // Structural path: snapshot the smallest container that the change
30
+ // touches, falling back to non-shell page content if we can't resolve
31
+ // a specific container.
32
+ const target = resolveStructuralTarget(pageRoot, pageRules, structuralPath)
33
+ const subtreeSnapshot = target ? captureChildren(target) : null
34
+ const pageSnapshot = target ? null : captureNonShellSnapshot(pageRoot, shellRoot)
35
+ try {
36
+ engine.apply(pageRoot, pageRules, newData, ENGINE_OPTS)
37
+ return { ok: true }
38
+ } catch (err) {
39
+ if (subtreeSnapshot) {
40
+ restoreChildren(target, subtreeSnapshot)
41
+ } else if (pageSnapshot) {
42
+ restoreNonShellSnapshot(pageRoot, shellRoot, pageSnapshot)
43
+ }
44
+ return { ok: false, error: err }
45
+ }
46
+ } finally {
47
+ observerHandle?.resume?.()
48
+ }
49
+ }
50
+
51
+ // Walk pageRules along structuralPath to find the array rule that the change
52
+ // targets, then ask the engine to locate its matching container in pageRoot.
53
+ // Returns the live container element (so its child cards can be snapshotted),
54
+ // or null if the path doesn't resolve to a single array container.
55
+ function resolveStructuralTarget(pageRoot, pageRules, structuralPath) {
56
+ if (!structuralPath || !pageRoot) return null
57
+ const segs = pathFromString(structuralPath)
58
+ // Walk to the array rule. Add/remove paths can be either the array itself
59
+ // ("products") or an item path ("products.0"); both resolve to the same
60
+ // array container.
61
+ let arrPath = []
62
+ let node = pageRules
63
+ for (const seg of segs) {
64
+ if (typeof node === 'string' || node == null) break
65
+ if (Array.isArray(node)) {
66
+ // We're at an array — this segment is the index; stop walking, we have
67
+ // the container.
68
+ break
69
+ }
70
+ if (typeof node === 'object' && seg in node) {
71
+ arrPath.push(seg)
72
+ node = node[seg]
73
+ if (Array.isArray(node) || (typeof node === 'string' && node.endsWith('[]'))) break
74
+ } else {
75
+ return null
76
+ }
77
+ }
78
+ if (!Array.isArray(node) && !(typeof node === 'string' && node.endsWith('[]'))) return null
79
+ // arrPath is the prefix that leads to the array. Walk pageRoot via the
80
+ // engine using a stripped pageRules so we land on the container.
81
+ return resolveContainerByPath(pageRoot, pageRules, arrPath)
82
+ }
83
+
84
+ // Walk pageRoot by following each parent rule's selector until we reach the
85
+ // array container. For paths with no parent (root-level array), use pageRoot
86
+ // itself; otherwise descend through each named rule.
87
+ function resolveContainerByPath(pageRoot, pageRules, arrPath) {
88
+ if (arrPath.length === 0) return null
89
+ let ctx = pageRoot
90
+ let rule = pageRules
91
+ for (let i = 0; i < arrPath.length; i++) {
92
+ const key = arrPath[i]
93
+ if (!rule || typeof rule !== 'object' || Array.isArray(rule)) return null
94
+ const sub = rule[key]
95
+ if (sub == null) return null
96
+ if (i === arrPath.length - 1) {
97
+ // Final step: the array's selector resolves to ITEMS, not the
98
+ // container. Walk to the items' shared parent so the snapshot covers
99
+ // the whole list (cards/scalars siblings). Without this, an object
100
+ // array's selector like `.product` returns the first item, and
101
+ // rollback restores only that item's children — leaving any earlier
102
+ // list-level insert/reorder mutated.
103
+ if (Array.isArray(sub)) {
104
+ const [selector] = sub
105
+ const first = ctx.querySelector?.(selector)
106
+ return first?.parentElement || null
107
+ }
108
+ if (typeof sub === 'string' && sub.endsWith('[]')) {
109
+ const selector = sub.slice(0, -2)
110
+ const first = ctx.querySelector?.(selector)
111
+ return first?.parentElement || null
112
+ }
113
+ return null
114
+ }
115
+ // Intermediate: must be an object subrule. Engine extract treats objects
116
+ // as same-ctx, so we don't narrow ctx.
117
+ rule = sub
118
+ }
119
+ return null
120
+ }
121
+
122
+ function captureChildren(parent) {
123
+ const clones = []
124
+ for (const child of Array.from(parent.childNodes)) {
125
+ clones.push(child.cloneNode(true))
126
+ }
127
+ return clones
128
+ }
129
+
130
+ function restoreChildren(parent, clones) {
131
+ while (parent.firstChild) parent.removeChild(parent.firstChild)
132
+ for (const clone of clones) parent.appendChild(clone)
133
+ }
134
+
135
+ function captureNonShellSnapshot(pageRoot, shellRoot) {
136
+ const clones = []
137
+ for (const child of Array.from(pageRoot.childNodes)) {
138
+ if (child === shellRoot || (shellRoot && child.contains?.(shellRoot))) continue
139
+ clones.push(child.cloneNode(true))
140
+ }
141
+ return clones
142
+ }
143
+
144
+ function restoreNonShellSnapshot(pageRoot, shellRoot, clones) {
145
+ for (const child of Array.from(pageRoot.childNodes)) {
146
+ if (child === shellRoot || (shellRoot && child.contains?.(shellRoot))) continue
147
+ pageRoot.removeChild(child)
148
+ }
149
+ const host = findShellHostChild(pageRoot, shellRoot)
150
+ for (const clone of clones) {
151
+ pageRoot.insertBefore(clone, host || null)
152
+ }
153
+ }
154
+
155
+ function findShellHostChild(pageRoot, shellRoot) {
156
+ if (!shellRoot) return null
157
+ for (const child of Array.from(pageRoot.childNodes)) {
158
+ if (child === shellRoot || child.contains?.(shellRoot)) return child
159
+ }
160
+ return null
161
+ }
@@ -0,0 +1,44 @@
1
+ import { shapeKindOf } from './templates.js'
2
+
3
+ const PREFIX = '[hypercms]'
4
+
5
+ export function warnUnmatchedTemplates(doc, pageRules) {
6
+ if (!doc || !doc.querySelectorAll || !pageRules) return
7
+ const validKeys = collectValidKeys(pageRules)
8
+ const tpls = doc.querySelectorAll('template[data-hcms-tpl]')
9
+ tpls.forEach((tpl) => {
10
+ const key = tpl.getAttribute('data-hcms-tpl')
11
+ if (!key) return
12
+ if (key.startsWith('@')) return
13
+ if (!validKeys.has(key)) {
14
+ console.warn(`${PREFIX} template "${key}" doesn't match any rule path; ignored`)
15
+ }
16
+ })
17
+ }
18
+
19
+ function collectValidKeys(pageRules) {
20
+ const keys = new Set()
21
+ walk([], pageRules)
22
+ return keys
23
+
24
+ function walk(pathArr, rule) {
25
+ const pathStr = pathArr.join('.')
26
+ const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
27
+ if (pathStr) keys.add(pathStr)
28
+ if (wildcardKey) keys.add(wildcardKey)
29
+ const shape = shapeKindOf(rule)
30
+ if (shape === 'object') {
31
+ for (const [k, child] of Object.entries(rule)) walk([...pathArr, k], child)
32
+ } else if (shape === 'object-array' || shape === 'scalar-array') {
33
+ const itemPath = [...pathArr, '*']
34
+ const itemWildcardKey = itemPath.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
35
+ keys.add(itemWildcardKey)
36
+ if (shape === 'object-array') {
37
+ const itemRule = rule[1]
38
+ if (itemRule && typeof itemRule === 'object' && !Array.isArray(itemRule)) {
39
+ for (const [k, child] of Object.entries(itemRule)) walk([...itemPath, k], child)
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
package/src/events.js ADDED
@@ -0,0 +1,457 @@
1
+ import { engine } from 'hyper-html-api'
2
+ import { applyWithRollback } from './apply-loop.js'
3
+ import { fromString as pathFromString } from './path.js'
4
+ import { buildItem } from './form-builder.js'
5
+ import { scaffold } from './scaffold.js'
6
+
7
+ const BOUND = new WeakSet()
8
+
9
+ // Wraps a structural apply in the pause-before / commit-on-success pattern.
10
+ // Pauses the undo recorder, runs fn, then either commits the captured
11
+ // records (if the apply succeeded) or discards them (if it failed). Either
12
+ // way, the undo stack only ever contains successful applies — never an
13
+ // apply+rollback no-op pair.
14
+ //
15
+ // No-op pass-through when window.hyperclay.undo isn't loaded.
16
+ export function commitWithUndo(label, fn) {
17
+ const u = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
18
+ if (!u) return fn()
19
+ u.pause()
20
+ try {
21
+ const result = fn()
22
+ if (result && result.ok) {
23
+ u.commitCaptured(label)
24
+ } else {
25
+ u.discardCaptured()
26
+ }
27
+ return result
28
+ } finally {
29
+ u.resume()
30
+ }
31
+ }
32
+
33
+ // Runs fn with the undo recorder paused and its captured records discarded, so
34
+ // editor-chrome-only DOM mutations (e.g. the body class toggles the shell adds
35
+ // on mount/unmount) never enter the undo stack. No-op pass-through when undo
36
+ // isn't loaded.
37
+ export function suppressUndo(fn) {
38
+ const u = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
39
+ if (!u) return fn()
40
+ u.pause()
41
+ try {
42
+ return fn()
43
+ } finally {
44
+ u.discardCaptured()
45
+ u.resume()
46
+ }
47
+ }
48
+
49
+ export function bindEvents(ctx) {
50
+ const { formRoot } = ctx
51
+ if (!formRoot || BOUND.has(formRoot)) return
52
+ BOUND.add(formRoot)
53
+
54
+ const onInput = (e) => {
55
+ const target = e.target
56
+ if (!target || !target.closest) return
57
+ if (!target.closest('[data-hcms-form-root]')) return
58
+ if (!target.matches('input, textarea, select')) return
59
+ if (!target.closest('[data-hcms-field]') && !target.hasAttribute?.('data-hcms-field')) return
60
+ onScalarChange(target, ctx)
61
+ }
62
+
63
+ const onChange = (e) => {
64
+ const target = e.target
65
+ if (!target || !target.closest) return
66
+ if (!target.closest('[data-hcms-form-root]')) return
67
+ if (target.matches('input[type="checkbox"], input[type="radio"], select')) {
68
+ onScalarChange(target, ctx)
69
+ }
70
+ }
71
+
72
+ const onClick = (e) => {
73
+ const target = e.target
74
+ if (!target || !target.closest) return
75
+ const actionEl = target.closest('[data-hcms-action]')
76
+ if (!actionEl) return
77
+ const action = actionEl.getAttribute('data-hcms-action')
78
+ // add/remove/move must live inside the form root; close/save inside the shell.
79
+ // Ignore stray data-hcms-action attributes elsewhere on the page.
80
+ if (action === 'add' || action === 'remove' || action === 'move-up' || action === 'move-down') {
81
+ if (!actionEl.closest('[data-hcms-form-root]')) return
82
+ } else if (action === 'close' || action === 'save') {
83
+ if (!actionEl.closest('[data-hcms-shell]')) return
84
+ }
85
+ if (action === 'add') {
86
+ const arrayEl = actionEl.closest('[data-hcms-path]')
87
+ if (!arrayEl) return
88
+ const arrayPath = arrayEl.getAttribute('data-hcms-path')
89
+ onAdd(arrayPath, ctx)
90
+ } else if (action === 'remove') {
91
+ const itemEl = actionEl.closest('[data-hcms-card], [data-hcms-array-item]')
92
+ if (!itemEl) return
93
+ onRemove(itemEl, ctx)
94
+ } else if (action === 'move-up' || action === 'move-down') {
95
+ const itemEl = actionEl.closest('[data-hcms-card], [data-hcms-array-item]')
96
+ if (!itemEl) return
97
+ onMove(itemEl, action === 'move-up' ? -1 : 1, ctx)
98
+ } else if (action === 'close') {
99
+ ctx.onCloseRequested?.()
100
+ } else if (action === 'save') {
101
+ onSave(ctx)
102
+ }
103
+ }
104
+
105
+ const doc = formRoot.ownerDocument
106
+ doc.addEventListener('input', onInput, true)
107
+ doc.addEventListener('change', onChange, true)
108
+ doc.addEventListener('click', onClick, true)
109
+
110
+ ctx.detachEvents = () => {
111
+ doc.removeEventListener('input', onInput, true)
112
+ doc.removeEventListener('change', onChange, true)
113
+ doc.removeEventListener('click', onClick, true)
114
+ BOUND.delete(formRoot)
115
+ }
116
+ }
117
+
118
+ export function onScalarChange(target, ctx) {
119
+ const fieldEl = target.closest('[data-hcms-field]') || target
120
+ const pathStr = fieldEl.closest('[data-hcms-path]')?.getAttribute('data-hcms-path') || ''
121
+ commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
122
+ }
123
+
124
+ export function onAdd(arrayPath, ctx) {
125
+ const { formRoot, pageRules } = ctx
126
+ const arrayEl = formRoot.querySelector(`[data-hcms-path="${cssEscape(arrayPath)}"]`)
127
+ if (!arrayEl) throw new Error(`hypercms: no element at path "${arrayPath}"`)
128
+ const slot = arrayEl.querySelector('.hcms-array-items')
129
+ if (!slot) throw new Error(`hypercms: array container missing .hcms-array-items at "${arrayPath}"`)
130
+
131
+ const pathArr = pathFromString(arrayPath)
132
+ const ruleAtPath = ruleAt(pageRules, pathArr)
133
+ const isObjectArray = Array.isArray(ruleAtPath)
134
+ const isScalarArray = typeof ruleAtPath === 'string' && ruleAtPath.endsWith('[]')
135
+ if (!isObjectArray && !isScalarArray) throw new Error(`hypercms: path "${arrayPath}" is not an array`)
136
+
137
+ const maxItems = readIntAttr(arrayEl, 'data-hcms-max-items')
138
+ const existingItems = slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]')
139
+ if (arrayEl.hasAttribute('data-hcms-no-add')) return
140
+ if (maxItems != null && existingItems.length >= maxItems) return
141
+ const nextIndex = existingItems.length
142
+
143
+ const itemShape = isObjectArray ? ruleAtPath[1] : ruleAtPath.replace(/\[\]$/, '')
144
+ const itemData = scaffold(isObjectArray ? itemShape : 'string')
145
+ const itemNode = buildItem({
146
+ shape: isObjectArray ? 'object-array-item' : 'scalar-array-item',
147
+ itemShape,
148
+ pathArr: [...pathArr, nextIndex],
149
+ data: itemData,
150
+ doc: ctx.doc,
151
+ })
152
+ slot.appendChild(itemNode)
153
+ updateArrayButtonsVisibility(arrayEl)
154
+ return commitWithUndo(`Add ${arrayPath}`, () =>
155
+ commit(extractFormData(ctx), { path: arrayPath, structural: true }, ctx)
156
+ )
157
+ }
158
+
159
+ export function onMove(itemEl, direction, ctx) {
160
+ const arrayEl = itemEl.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]')
161
+ if (!arrayEl) return
162
+ if (arrayEl.hasAttribute('data-hcms-no-reorder')) return
163
+ const slot = arrayEl.querySelector('.hcms-array-items')
164
+ if (!slot) return
165
+ const siblings = Array.from(slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]'))
166
+ const idx = siblings.indexOf(itemEl)
167
+ if (idx < 0) return
168
+ const targetIdx = idx + direction
169
+ if (targetIdx < 0 || targetIdx >= siblings.length) return
170
+ const moveAction = itemEl.querySelector(`[data-hcms-action="${direction < 0 ? 'move-up' : 'move-down'}"]`)
171
+ if (direction < 0) {
172
+ slot.insertBefore(itemEl, siblings[targetIdx])
173
+ } else {
174
+ slot.insertBefore(itemEl, siblings[targetIdx].nextSibling)
175
+ }
176
+ restampSiblingPaths(slot)
177
+ updateArrayButtonsVisibility(arrayEl)
178
+ // Restore focus to the moved button so keyboard users can continue pressing
179
+ // the same button (now landed in its new position).
180
+ if (moveAction && typeof moveAction.focus === 'function') {
181
+ const newAction = itemEl.querySelector(`[data-hcms-action="${direction < 0 ? 'move-up' : 'move-down'}"]`)
182
+ newAction?.focus?.()
183
+ }
184
+ return commitWithUndo(`Reorder ${arrayEl.getAttribute('data-hcms-path') || ''}`, () =>
185
+ commit(extractFormData(ctx), { path: arrayEl.getAttribute('data-hcms-path') || '', structural: true }, ctx)
186
+ )
187
+ }
188
+
189
+ export function onRemove(itemEl, ctx) {
190
+ const path = itemEl.getAttribute('data-hcms-path') || ''
191
+ const parent = itemEl.parentElement
192
+ const arrayEl = itemEl.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]')
193
+ if (arrayEl?.hasAttribute('data-hcms-no-remove')) return
194
+ if (arrayEl) {
195
+ const minItems = readIntAttr(arrayEl, 'data-hcms-min-items')
196
+ const slot = arrayEl.querySelector('.hcms-array-items')
197
+ const count = slot
198
+ ? slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]').length
199
+ : 0
200
+ if (minItems != null && count <= minItems) return
201
+ }
202
+ itemEl.remove()
203
+ // After removal, re-stamp sibling paths to keep indices contiguous.
204
+ if (parent) restampSiblingPaths(parent)
205
+ if (arrayEl) updateArrayButtonsVisibility(arrayEl)
206
+ return commitWithUndo(`Remove ${path}`, () =>
207
+ commit(extractFormData(ctx), { path, structural: true }, ctx)
208
+ )
209
+ }
210
+
211
+ export function onSave(ctx) {
212
+ const data = extractFormData(ctx)
213
+ ctx.dispatch?.('hcms:save', { data })
214
+ ctx.onSave?.(data)
215
+ }
216
+
217
+ export function commit(newData, info, ctx) {
218
+ const fingerprint = stableStringify(newData)
219
+ if (fingerprint === ctx.lastFingerprint) return { ok: true, skipped: true }
220
+
221
+ const result = applyWithRollback(ctx.pageRoot, ctx.pageRules, newData, {
222
+ observerHandle: ctx.observerHandle,
223
+ shellRoot: ctx.shellRoot,
224
+ structural: !!info.structural,
225
+ structuralPath: info.path || null,
226
+ })
227
+ if (result.ok) {
228
+ ctx.lastFingerprint = fingerprint
229
+ ctx.lastData = newData
230
+ setError(ctx, null)
231
+ ctx.dispatch?.('hcms:change', { data: newData, path: info.path, structural: !!info.structural })
232
+ ctx.onChange?.(newData, info)
233
+ } else {
234
+ setError(ctx, formatError(result.error, info.path))
235
+ ctx.dispatch?.('hcms:error', { error: result.error, attemptedData: newData })
236
+ ctx.onError?.(result.error)
237
+ }
238
+ return result
239
+ }
240
+
241
+ export function extractFormData(ctx) {
242
+ const raw = engine.extract(ctx.formRoot, ctx.formRules)
243
+ return coerceBooleans(raw, ctx.formRules)
244
+ }
245
+
246
+ // Walks either pageRules OR formRules (both use the same `endsWith('@checked')`
247
+ // semantics) and coerces leaf strings "true" / "false" to booleans. Engine
248
+ // extract stringifies @checked properties; the form layer wants real booleans
249
+ // so writeValue's Boolean(value) doesn't flip "false" → true on hydration.
250
+ export function coerceBooleans(data, rules) {
251
+ if (rules == null || data == null) return data
252
+ if (typeof rules === 'string') {
253
+ if (rules.endsWith('@checked')) {
254
+ return data === true || data === 'true'
255
+ }
256
+ return data
257
+ }
258
+ if (Array.isArray(rules)) {
259
+ if (!Array.isArray(data)) return data
260
+ const [, itemShape] = rules
261
+ return data.map((item) => coerceBooleans(item, itemShape))
262
+ }
263
+ if (typeof rules === 'object') {
264
+ if (typeof data !== 'object' || Array.isArray(data)) return data
265
+ const out = {}
266
+ for (const [k, child] of Object.entries(rules)) {
267
+ out[k] = coerceBooleans(data[k], child)
268
+ }
269
+ return out
270
+ }
271
+ return data
272
+ }
273
+
274
+ export { stableStringify }
275
+
276
+ // Errors render inline when we can match a path to a slot in the form (a `.hcms-error`
277
+ // direct child of the path's container — added by the default scalar / array templates).
278
+ // A single commit can produce multiple mismatches (ShapeMismatch carries an array); each one
279
+ // tries to land at its own path. Custom templates that omit the slot, and path-less errors,
280
+ // fall back to the global banner (joined when there's more than one).
281
+ function setError(ctx, errors) {
282
+ ctx.lastErrors = errors && errors.length ? errors : null
283
+ applyErrorState(ctx)
284
+ }
285
+
286
+ // Re-apply ctx.lastErrors to the DOM. Exported so refreshForm can call this
287
+ // after morphForm rebuilds the form — the morph wipes inline error slots
288
+ // (they're inside the form), so we restore them post-morph.
289
+ export function applyErrorState(ctx) {
290
+ clearInlineErrors(ctx)
291
+ if (ctx.errorEl) {
292
+ ctx.errorEl.textContent = ''
293
+ ctx.errorEl.hidden = true
294
+ }
295
+ if (!ctx.lastErrors) return
296
+
297
+ const unplaced = []
298
+ for (const { message, path } of ctx.lastErrors) {
299
+ if (path != null && path !== '') {
300
+ const target = findInlineErrorSlot(ctx.formRoot, path)
301
+ if (target) {
302
+ // Two errors at the same path concatenate into one slot, separated by a newline.
303
+ target.textContent = target.textContent ? `${target.textContent}\n${message}` : message
304
+ target.hidden = false
305
+ continue
306
+ }
307
+ }
308
+ unplaced.push(message)
309
+ }
310
+
311
+ if (unplaced.length && ctx.errorEl) {
312
+ ctx.errorEl.textContent = unplaced.join('\n')
313
+ ctx.errorEl.hidden = false
314
+ }
315
+ }
316
+
317
+ function clearInlineErrors(ctx) {
318
+ if (!ctx.formRoot) return
319
+ for (const el of ctx.formRoot.querySelectorAll('.hcms-error')) {
320
+ el.textContent = ''
321
+ el.hidden = true
322
+ }
323
+ }
324
+
325
+ function findInlineErrorSlot(formRoot, path) {
326
+ if (!formRoot) return null
327
+ // Walk up the path: try the exact match first, then trim the last segment, etc.
328
+ // Lets deep-path errors land at the nearest ancestor that has a slot — useful when
329
+ // engine paths go deeper than the form's stamped structure (e.g., migration data).
330
+ const segs = path.split('.')
331
+ while (segs.length > 0) {
332
+ const candidate = segs.join('.')
333
+ const esc = (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(candidate) : candidate.replace(/[^a-zA-Z0-9_\-.*]/g, (c) => '\\' + c)
334
+ const container = formRoot.querySelector(`[data-hcms-path="${esc}"]`)
335
+ if (container) {
336
+ for (const child of container.children) {
337
+ if (child.classList && child.classList.contains('hcms-error')) return child
338
+ }
339
+ }
340
+ segs.pop()
341
+ }
342
+ return null
343
+ }
344
+
345
+ function formatError(err, fallbackPath) {
346
+ if (!err) return [{ message: 'unknown error', path: fallbackPath }]
347
+ if (err.name === 'EmptyListInsert') {
348
+ return [{ message: 'Add a seed item in HTML first.', path: fallbackPath }]
349
+ }
350
+ if (err.name === 'ShapeMismatch' && Array.isArray(err.mismatches) && err.mismatches.length) {
351
+ return err.mismatches.map((m) => ({
352
+ message: `Shape mismatch: expected ${m.expected}, got ${m.got}`,
353
+ path: m.path,
354
+ }))
355
+ }
356
+ return [{ message: err.message || String(err), path: fallbackPath }]
357
+ }
358
+
359
+ function ruleAt(rules, pathArr) {
360
+ let node = rules
361
+ for (const seg of pathArr) {
362
+ if (node == null) return undefined
363
+ if (typeof node === 'string') return undefined
364
+ if (Array.isArray(node)) {
365
+ if (typeof seg !== 'number' && seg !== '*') return undefined
366
+ node = node[1]
367
+ continue
368
+ }
369
+ if (typeof node === 'object') {
370
+ if (typeof seg === 'number') return undefined
371
+ if (!(seg in node)) return undefined
372
+ node = node[seg]
373
+ continue
374
+ }
375
+ return undefined
376
+ }
377
+ return node
378
+ }
379
+
380
+ function readIntAttr(el, name) {
381
+ if (!el || !el.hasAttribute(name)) return null
382
+ const n = parseInt(el.getAttribute(name), 10)
383
+ return Number.isFinite(n) ? n : null
384
+ }
385
+
386
+ export function updateArrayButtonsVisibility(arrayEl) {
387
+ if (!arrayEl) return
388
+ const slot = arrayEl.querySelector('.hcms-array-items')
389
+ if (!slot) return
390
+ const items = Array.from(slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]'))
391
+ const count = items.length
392
+ const max = readIntAttr(arrayEl, 'data-hcms-max-items')
393
+ const min = readIntAttr(arrayEl, 'data-hcms-min-items')
394
+ const noAdd = arrayEl.hasAttribute('data-hcms-no-add')
395
+ const noRemove = arrayEl.hasAttribute('data-hcms-no-remove')
396
+ const noReorder = arrayEl.hasAttribute('data-hcms-no-reorder')
397
+ const addBtn = arrayEl.querySelector(':scope > .hcms-add, :scope > * > .hcms-add, :scope > [data-hcms-action="add"]')
398
+ if (addBtn) addBtn.hidden = noAdd || (max != null && count >= max)
399
+ items.forEach((item, i) => {
400
+ const rm = item.querySelector('[data-hcms-action="remove"]')
401
+ if (rm) rm.hidden = noRemove || (min != null && count <= min)
402
+ const up = item.querySelector('[data-hcms-action="move-up"]')
403
+ if (up) up.hidden = noReorder || i === 0
404
+ const dn = item.querySelector('[data-hcms-action="move-down"]')
405
+ if (dn) dn.hidden = noReorder || i === count - 1
406
+ })
407
+ }
408
+
409
+ export function restampAllSiblings(formRoot) {
410
+ if (!formRoot || !formRoot.querySelectorAll) return
411
+ formRoot.querySelectorAll('.hcms-array-items').forEach((slot) => restampSiblingPaths(slot))
412
+ }
413
+
414
+ function restampSiblingPaths(parent) {
415
+ let i = 0
416
+ for (const child of parent.children) {
417
+ if (!child.matches?.('[data-hcms-card], [data-hcms-array-item]')) continue
418
+ const path = child.getAttribute('data-hcms-path')
419
+ if (!path) continue
420
+ const segs = path.split('.')
421
+ segs[segs.length - 1] = String(i)
422
+ const newPath = segs.join('.')
423
+ if (newPath !== path) restampSubtree(child, path, newPath)
424
+ i++
425
+ }
426
+ }
427
+
428
+ function restampSubtree(root, oldPrefix, newPrefix) {
429
+ const all = root.querySelectorAll('[data-hcms-path]')
430
+ root.setAttribute('data-hcms-path', newPrefix)
431
+ for (const el of all) {
432
+ const p = el.getAttribute('data-hcms-path')
433
+ if (p === oldPrefix) {
434
+ el.setAttribute('data-hcms-path', newPrefix)
435
+ } else if (p && p.startsWith(oldPrefix + '.')) {
436
+ el.setAttribute('data-hcms-path', newPrefix + p.slice(oldPrefix.length))
437
+ }
438
+ }
439
+ }
440
+
441
+ function stableStringify(value) {
442
+ return JSON.stringify(value, (_, v) => {
443
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
444
+ // Use a null-prototype container so `__proto__` keys survive sorting and
445
+ // serialize as data rather than mutating object prototype.
446
+ const sorted = Object.create(null)
447
+ for (const k of Object.keys(v).sort()) sorted[k] = v[k]
448
+ return sorted
449
+ }
450
+ return v
451
+ })
452
+ }
453
+
454
+ function cssEscape(value) {
455
+ if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value)
456
+ return String(value).replace(/[^a-zA-Z0-9_\-.*]/g, (c) => '\\' + c)
457
+ }