@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.
- package/LICENSE +14 -0
- package/README.md +247 -0
- package/dist/hypercms.min.js +362 -0
- package/package.json +50 -0
- package/src/apply-loop.js +161 -0
- package/src/diagnostics.js +44 -0
- package/src/events.js +457 -0
- package/src/form-builder.js +361 -0
- package/src/form-rules.js +192 -0
- package/src/hypercms-bundle.js +7 -0
- package/src/hypercms.js +321 -0
- package/src/morph.js +10 -0
- package/src/path.js +61 -0
- package/src/refresh.js +57 -0
- package/src/scaffold.js +10 -0
- package/src/shell.js +132 -0
- package/src/styles.css +298 -0
- package/src/templates.js +184 -0
|
@@ -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
|
+
}
|