@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,361 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findTemplate,
|
|
3
|
+
isInlineTemplate,
|
|
4
|
+
humanize,
|
|
5
|
+
shapeKindOf,
|
|
6
|
+
} from './templates.js'
|
|
7
|
+
import { fieldPropertyFor } from './form-rules.js'
|
|
8
|
+
|
|
9
|
+
export function buildForm({ pageRules, formRules: _formRules, data, doc }) {
|
|
10
|
+
const fragment = doc.createDocumentFragment()
|
|
11
|
+
const root = buildNode(pageRules, [], data, doc)
|
|
12
|
+
if (root) fragment.appendChild(root)
|
|
13
|
+
return fragment
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildItem({ shape, itemShape, pathArr, data, doc }) {
|
|
17
|
+
if (shape === 'object-array-item') {
|
|
18
|
+
return buildObjectArrayItem(itemShape, pathArr, data, doc)
|
|
19
|
+
}
|
|
20
|
+
if (shape === 'scalar-array-item') {
|
|
21
|
+
return buildScalarArrayItem(pathArr, data, doc)
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`hypercms: buildItem called with unknown shape "${shape}"`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildNode(rule, pathArr, data, doc) {
|
|
27
|
+
const kind = shapeKindOf(rule)
|
|
28
|
+
if (kind === 'scalar') return buildScalar(pathArr, data, doc)
|
|
29
|
+
if (kind === 'object') return buildObject(rule, pathArr, data, doc)
|
|
30
|
+
if (kind === 'object-array') return buildObjectArray(rule, pathArr, data, doc)
|
|
31
|
+
if (kind === 'scalar-array') return buildScalarArray(rule, pathArr, data, doc)
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildScalar(pathArr, data, doc) {
|
|
36
|
+
const tpl = resolveTemplate(pathArr, '@scalar', doc)
|
|
37
|
+
if (!tpl) throw new Error(`hypercms: missing template for scalar at "${pathArr.join('.')}"`)
|
|
38
|
+
const node = cloneTemplate(tpl, doc)
|
|
39
|
+
stampPath(node, pathArr)
|
|
40
|
+
// Stamp leaf input(s) FIRST so the container's data-hcms-field doesn't
|
|
41
|
+
// make stampScalarField early-return (which would leave the input without
|
|
42
|
+
// a key).
|
|
43
|
+
stampScalarField(node, lastKey(pathArr))
|
|
44
|
+
// Then stamp the wrapping container so engine selectors can scope by key.
|
|
45
|
+
stampContainerField(node, lastKey(pathArr))
|
|
46
|
+
setLabel(node, lastKey(pathArr))
|
|
47
|
+
populateScalarValue(node, data)
|
|
48
|
+
return node
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildObject(rule, pathArr, data, doc) {
|
|
52
|
+
const tpl = resolveTemplate(pathArr, '@object', doc)
|
|
53
|
+
if (!tpl) throw new Error(`hypercms: missing template for object at "${pathArr.join('.')}"`)
|
|
54
|
+
const node = cloneTemplate(tpl, doc)
|
|
55
|
+
stampPath(node, pathArr)
|
|
56
|
+
stampContainerField(node, lastKey(pathArr))
|
|
57
|
+
setLabel(node, lastKey(pathArr))
|
|
58
|
+
|
|
59
|
+
if (isInlineTemplate(tpl)) {
|
|
60
|
+
stampInlineFieldPaths(node, rule, pathArr)
|
|
61
|
+
populateInlineFields(node, rule, data)
|
|
62
|
+
return node
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const slot = requireSlot(node, '.hcms-object-fields', tpl, pathArr)
|
|
66
|
+
for (const [k, child] of Object.entries(rule)) {
|
|
67
|
+
const childData = data == null ? null : data[k]
|
|
68
|
+
const childNode = buildNode(child, [...pathArr, k], childData, doc)
|
|
69
|
+
if (childNode) slot.appendChild(childNode)
|
|
70
|
+
}
|
|
71
|
+
return node
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildObjectArray(rule, pathArr, data, doc) {
|
|
75
|
+
const tpl = resolveTemplate(pathArr, '@object-array', doc)
|
|
76
|
+
if (!tpl) throw new Error(`hypercms: missing template for object-array at "${pathArr.join('.')}"`)
|
|
77
|
+
const node = cloneTemplate(tpl, doc)
|
|
78
|
+
stampPath(node, pathArr)
|
|
79
|
+
stampContainerField(node, lastKey(pathArr))
|
|
80
|
+
setLabel(node, lastKey(pathArr))
|
|
81
|
+
copyArrayConstraintAttrs(node, tpl)
|
|
82
|
+
wireSortable(node, tpl, pathArr)
|
|
83
|
+
|
|
84
|
+
const slot = requireSlot(node, '.hcms-array-items', tpl, pathArr)
|
|
85
|
+
const [, itemShape] = rule
|
|
86
|
+
const items = Array.isArray(data) ? data : []
|
|
87
|
+
items.forEach((itemData, i) => {
|
|
88
|
+
const itemNode = buildObjectArrayItem(itemShape, [...pathArr, i], itemData, doc)
|
|
89
|
+
if (itemNode) slot.appendChild(itemNode)
|
|
90
|
+
})
|
|
91
|
+
applyConstraintVisibility(node)
|
|
92
|
+
return node
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildObjectArrayItem(itemShape, pathArr, data, doc) {
|
|
96
|
+
const tpl = resolveItemTemplate(pathArr, 'object-array-item', doc)
|
|
97
|
+
if (!tpl) throw new Error(`hypercms: missing item template for "${pathArr.join('.')}"`)
|
|
98
|
+
const node = cloneTemplate(tpl, doc)
|
|
99
|
+
node.setAttribute('data-hcms-card', '')
|
|
100
|
+
if (!node.classList.contains('hcms-card')) node.classList.add('hcms-card')
|
|
101
|
+
stampPath(node, pathArr)
|
|
102
|
+
|
|
103
|
+
if (isInlineTemplate(tpl)) {
|
|
104
|
+
if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
|
|
105
|
+
stampInlineFieldPaths(node, itemShape, pathArr)
|
|
106
|
+
populateInlineFields(node, itemShape, data)
|
|
107
|
+
}
|
|
108
|
+
return node
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const slot = requireSlot(node, '.hcms-card-fields', tpl, pathArr)
|
|
112
|
+
if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
|
|
113
|
+
for (const [k, child] of Object.entries(itemShape)) {
|
|
114
|
+
const childData = data == null ? null : data[k]
|
|
115
|
+
const childNode = buildNode(child, [...pathArr, k], childData, doc)
|
|
116
|
+
if (childNode) slot.appendChild(childNode)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return node
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildScalarArray(rule, pathArr, data, doc) {
|
|
123
|
+
const tpl = resolveTemplate(pathArr, '@scalar-array', doc)
|
|
124
|
+
if (!tpl) throw new Error(`hypercms: missing template for scalar-array at "${pathArr.join('.')}"`)
|
|
125
|
+
const node = cloneTemplate(tpl, doc)
|
|
126
|
+
stampPath(node, pathArr)
|
|
127
|
+
stampContainerField(node, lastKey(pathArr))
|
|
128
|
+
setLabel(node, lastKey(pathArr))
|
|
129
|
+
copyArrayConstraintAttrs(node, tpl)
|
|
130
|
+
wireSortable(node, tpl, pathArr)
|
|
131
|
+
|
|
132
|
+
const slot = requireSlot(node, '.hcms-array-items', tpl, pathArr)
|
|
133
|
+
const items = Array.isArray(data) ? data : []
|
|
134
|
+
items.forEach((itemValue, i) => {
|
|
135
|
+
const itemNode = buildScalarArrayItem([...pathArr, i], itemValue, doc)
|
|
136
|
+
if (itemNode) slot.appendChild(itemNode)
|
|
137
|
+
})
|
|
138
|
+
applyConstraintVisibility(node)
|
|
139
|
+
return node
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildScalarArrayItem(pathArr, value, doc) {
|
|
143
|
+
const tpl = resolveItemTemplate(pathArr, 'scalar-array-item', doc)
|
|
144
|
+
if (!tpl) throw new Error(`hypercms: missing item template for "${pathArr.join('.')}"`)
|
|
145
|
+
const node = cloneTemplate(tpl, doc)
|
|
146
|
+
node.setAttribute('data-hcms-array-item', '')
|
|
147
|
+
if (!node.classList.contains('hcms-array-item')) node.classList.add('hcms-array-item')
|
|
148
|
+
stampPath(node, pathArr)
|
|
149
|
+
|
|
150
|
+
populateScalarValue(node, value)
|
|
151
|
+
return node
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function resolveTemplate(pathArr, defaultKey, doc) {
|
|
155
|
+
const pathStr = pathArr.join('.')
|
|
156
|
+
const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
|
|
157
|
+
return (
|
|
158
|
+
(pathStr && findTemplate(doc, pathStr)) ||
|
|
159
|
+
(wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
|
|
160
|
+
findTemplate(doc, defaultKey)
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function resolveItemTemplate(pathArr, defaultShape, doc) {
|
|
165
|
+
const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
|
|
166
|
+
return findTemplate(doc, wildcardKey) || findTemplate(doc, '@' + defaultShape)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function cloneTemplate(tpl, doc) {
|
|
170
|
+
const content = tpl.content || tpl
|
|
171
|
+
const wrapper = doc.createElement('div')
|
|
172
|
+
wrapper.appendChild(content.cloneNode(true))
|
|
173
|
+
return wrapper.firstElementChild || wrapper
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function stampPath(node, pathArr) {
|
|
177
|
+
node.setAttribute('data-hcms-path', pathArr.join('.'))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function stampScalarField(node, key) {
|
|
181
|
+
const targetKey = key == null ? '' : String(key)
|
|
182
|
+
if (node.matches && node.matches('[data-hcms-field]')) {
|
|
183
|
+
if (!node.getAttribute('data-hcms-field')) node.setAttribute('data-hcms-field', targetKey)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
// Stamp every descendant input that's marked as a field but missing its key
|
|
187
|
+
// (radio groups have multiple inputs sharing the same key).
|
|
188
|
+
const fields = node.querySelectorAll ? node.querySelectorAll('[data-hcms-field]') : []
|
|
189
|
+
fields.forEach((field) => {
|
|
190
|
+
if (!field.getAttribute('data-hcms-field')) field.setAttribute('data-hcms-field', targetKey)
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Stamp data-hcms-field on the WRAPPING container so engine selectors can
|
|
195
|
+
// scope by key (e.g. nested arrays sharing a terminal name). The leaf input's
|
|
196
|
+
// data-hcms-field carries the same key — form-rules selectors use tag-specific
|
|
197
|
+
// qualifiers (`input[data-hcms-field=...]`) so the container doesn't shadow.
|
|
198
|
+
function stampContainerField(node, key) {
|
|
199
|
+
if (key == null || key === '' || !node.setAttribute) return
|
|
200
|
+
// Don't overwrite an existing data-hcms-field attribute (e.g. when the
|
|
201
|
+
// template author already stamped it deliberately).
|
|
202
|
+
if (node.hasAttribute?.('data-hcms-field')) return
|
|
203
|
+
node.setAttribute('data-hcms-field', String(key))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function setLabel(node, key) {
|
|
207
|
+
if (key == null || key === '') return
|
|
208
|
+
const labels = node.querySelectorAll
|
|
209
|
+
? node.querySelectorAll('[data-hcms-label]')
|
|
210
|
+
: []
|
|
211
|
+
labels.forEach((el) => {
|
|
212
|
+
const t = el.textContent || ''
|
|
213
|
+
if (t.trim() === '') el.textContent = humanize(String(key))
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function copyArrayConstraintAttrs(node, tpl) {
|
|
218
|
+
;['data-hcms-no-add', 'data-hcms-no-remove', 'data-hcms-no-reorder'].forEach((attr) => {
|
|
219
|
+
if (tpl.hasAttribute(attr)) node.setAttribute(attr, '')
|
|
220
|
+
})
|
|
221
|
+
;['data-hcms-min-items', 'data-hcms-max-items'].forEach((attr) => {
|
|
222
|
+
if (tpl.hasAttribute(attr)) node.setAttribute(attr, tpl.getAttribute(attr))
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function applyConstraintVisibility(arrayEl) {
|
|
227
|
+
const slot = arrayEl.querySelector ? arrayEl.querySelector('.hcms-array-items') : null
|
|
228
|
+
if (!slot) return
|
|
229
|
+
const items = Array.from(slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]'))
|
|
230
|
+
const count = items.length
|
|
231
|
+
const max = readIntAttr(arrayEl, 'data-hcms-max-items')
|
|
232
|
+
const min = readIntAttr(arrayEl, 'data-hcms-min-items')
|
|
233
|
+
const noAdd = arrayEl.hasAttribute('data-hcms-no-add')
|
|
234
|
+
const noRemove = arrayEl.hasAttribute('data-hcms-no-remove')
|
|
235
|
+
const noReorder = arrayEl.hasAttribute('data-hcms-no-reorder')
|
|
236
|
+
const addBtn = arrayEl.querySelector('[data-hcms-action="add"]')
|
|
237
|
+
if (addBtn) addBtn.hidden = noAdd || (max != null && count >= max)
|
|
238
|
+
items.forEach((item, i) => {
|
|
239
|
+
const rm = item.querySelector('[data-hcms-action="remove"]')
|
|
240
|
+
if (rm) rm.hidden = noRemove || (min != null && count <= min)
|
|
241
|
+
const up = item.querySelector('[data-hcms-action="move-up"]')
|
|
242
|
+
if (up) up.hidden = noReorder || i === 0
|
|
243
|
+
const dn = item.querySelector('[data-hcms-action="move-down"]')
|
|
244
|
+
if (dn) dn.hidden = noReorder || i === count - 1
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function readIntAttr(el, name) {
|
|
249
|
+
if (!el || !el.hasAttribute(name)) return null
|
|
250
|
+
const n = parseInt(el.getAttribute(name), 10)
|
|
251
|
+
return Number.isFinite(n) ? n : null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function wireSortable(node, tpl, pathArr) {
|
|
255
|
+
if (node.hasAttribute('data-hcms-no-reorder') || tpl.hasAttribute('data-hcms-no-reorder')) return
|
|
256
|
+
const slot = node.querySelector('.hcms-array-items')
|
|
257
|
+
if (!slot) return
|
|
258
|
+
const groupName = 'hcms-' + pathArr.join('.')
|
|
259
|
+
slot.setAttribute('sortable', groupName)
|
|
260
|
+
// hyperclayjs's [sortable] attribute compiles `onsorted` as a code body
|
|
261
|
+
// (new Function), so we invoke a top-level callable instead of pointing at
|
|
262
|
+
// a function reference. The callable is installed by hypercms.js open().
|
|
263
|
+
slot.setAttribute('onsorted', 'hypercmsCommit && hypercmsCommit()')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function lastKey(pathArr) {
|
|
267
|
+
if (!pathArr.length) return null
|
|
268
|
+
return pathArr[pathArr.length - 1]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function populateScalarValue(node, value) {
|
|
272
|
+
// Radio groups have multiple <input data-hcms-field>s; writeValue handles
|
|
273
|
+
// each radio individually (checks against its `value`). Write to ALL leaf
|
|
274
|
+
// fields under the wrapper, skipping the wrapper itself if it happens to
|
|
275
|
+
// carry data-hcms-field too.
|
|
276
|
+
const targets = collectLeafFields(node)
|
|
277
|
+
if (targets.length === 0) return
|
|
278
|
+
for (const t of targets) writeValue(t, value)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function collectLeafFields(node) {
|
|
282
|
+
if (!node) return []
|
|
283
|
+
const out = []
|
|
284
|
+
// The node itself is a leaf only if it's a recognized leaf tag (input,
|
|
285
|
+
// textarea, select, img, a, or contenteditable). Container shapes — even
|
|
286
|
+
// shape="scalar" wrappers — have child inputs we should target instead.
|
|
287
|
+
if (node.matches?.('[data-hcms-field]') && isLeafFieldEl(node)) {
|
|
288
|
+
out.push(node)
|
|
289
|
+
}
|
|
290
|
+
const descendants = node.querySelectorAll
|
|
291
|
+
? node.querySelectorAll('input[data-hcms-field], textarea[data-hcms-field], select[data-hcms-field], img[data-hcms-field], a[data-hcms-field], [contenteditable][data-hcms-field]')
|
|
292
|
+
: []
|
|
293
|
+
descendants.forEach((d) => out.push(d))
|
|
294
|
+
return out
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isLeafFieldEl(el) {
|
|
298
|
+
const tag = (el.tagName || '').toUpperCase()
|
|
299
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'IMG' || tag === 'A') return true
|
|
300
|
+
if (el.hasAttribute?.('contenteditable')) return true
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function populateInlineFields(node, shape, data) {
|
|
305
|
+
const fields = node.querySelectorAll ? node.querySelectorAll('[data-hcms-field]') : []
|
|
306
|
+
fields.forEach((el) => {
|
|
307
|
+
const key = el.getAttribute('data-hcms-field')
|
|
308
|
+
if (!key) return
|
|
309
|
+
if (!shape || typeof shape !== 'object' || !(key in shape)) {
|
|
310
|
+
console.warn(`[hypercms] inline template field "${key}" is not in the rule shape; ignoring`)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
const childValue = data == null ? null : data[key]
|
|
314
|
+
writeValue(el, childValue)
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function stampInlineFieldPaths(node, shape, parentPath) {
|
|
319
|
+
if (!node.querySelectorAll) return
|
|
320
|
+
const fields = node.querySelectorAll('[data-hcms-field]')
|
|
321
|
+
fields.forEach((el) => {
|
|
322
|
+
const key = el.getAttribute('data-hcms-field')
|
|
323
|
+
if (!key) return
|
|
324
|
+
if (shape && typeof shape === 'object' && !(key in shape)) return
|
|
325
|
+
const childPath = [...parentPath, key].join('.')
|
|
326
|
+
el.setAttribute('data-hcms-path', childPath)
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function requireSlot(node, slotSelector, tpl, pathArr) {
|
|
331
|
+
if (!node.querySelector) return node
|
|
332
|
+
const slot = node.querySelector(slotSelector)
|
|
333
|
+
if (slot) return slot
|
|
334
|
+
// Slotted templates must declare their slot. Inline-template detection has
|
|
335
|
+
// already short-circuited above, so missing slot here is a real authoring bug.
|
|
336
|
+
const id = tpl?.getAttribute?.('data-hcms-tpl') || pathArr.join('.')
|
|
337
|
+
throw new Error(
|
|
338
|
+
`hypercms: template "${id}" is in slotted mode but has no ${slotSelector} element`
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function writeValue(el, value) {
|
|
343
|
+
const prop = fieldPropertyFor(el)
|
|
344
|
+
const tag = (el.tagName || '').toUpperCase()
|
|
345
|
+
const type = (el.getAttribute('type') || '').toLowerCase()
|
|
346
|
+
if (tag === 'INPUT' && type === 'radio') {
|
|
347
|
+
el.checked = el.value != null && String(el.value) === String(value ?? '')
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
if (prop === 'checked') {
|
|
351
|
+
// Coerce strings "true"/"false" the same way coerceBooleans does, so even
|
|
352
|
+
// raw page-extract values (which engine stringifies) don't flip false → true.
|
|
353
|
+
el.checked = value === true || value === 'true'
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
if (prop) {
|
|
357
|
+
el[prop] = value == null ? '' : String(value)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
el.textContent = value == null ? '' : String(value)
|
|
361
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { shapeKindOf, findTemplate, isInlineTemplate } from './templates.js'
|
|
2
|
+
|
|
3
|
+
const TAG_PROP_MAP = {
|
|
4
|
+
IMG: 'src',
|
|
5
|
+
A: 'href',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function fieldPropertyFor(el) {
|
|
9
|
+
if (!el) return 'value'
|
|
10
|
+
const tag = (el.tagName || '').toUpperCase()
|
|
11
|
+
if (tag === 'INPUT') {
|
|
12
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase()
|
|
13
|
+
if (type === 'checkbox') return 'checked'
|
|
14
|
+
return 'value'
|
|
15
|
+
}
|
|
16
|
+
if (tag === 'TEXTAREA' || tag === 'SELECT') return 'value'
|
|
17
|
+
if (TAG_PROP_MAP[tag]) return TAG_PROP_MAP[tag]
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Tag-specific scalar field selector. Returns a selector that uniquely
|
|
22
|
+
// identifies the leaf input under data-hcms-field="key" — necessary because
|
|
23
|
+
// data-hcms-field is now stamped on every keyed node (containers + leaves),
|
|
24
|
+
// so a bare `[data-hcms-field="key"]` would match the wrapping container too.
|
|
25
|
+
export function fieldSelectorFor(el, key) {
|
|
26
|
+
const tag = (el.tagName || '').toUpperCase()
|
|
27
|
+
const type = (el.getAttribute && el.getAttribute('type') || '').toLowerCase()
|
|
28
|
+
const prop = fieldPropertyFor(el)
|
|
29
|
+
const tagSel = tagSelectorFor(tag, type)
|
|
30
|
+
const fieldSel = `${tagSel}[data-hcms-field="${cssEscape(key)}"]`
|
|
31
|
+
if (tag === 'INPUT' && type === 'radio') {
|
|
32
|
+
return `${fieldSel}:checked@value`
|
|
33
|
+
}
|
|
34
|
+
if (prop) return `${fieldSel}@${prop}`
|
|
35
|
+
return fieldSel
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Build a per-item leaf selector with no key qualifier — used by scalar-array
|
|
39
|
+
// rules where the leaf is scoped by the surrounding per-item context, so the
|
|
40
|
+
// data-hcms-field key isn't needed (and items don't have a stable key anyway).
|
|
41
|
+
function keylessFieldSelectorFor(el) {
|
|
42
|
+
const tag = (el.tagName || '').toUpperCase()
|
|
43
|
+
const type = (el.getAttribute && el.getAttribute('type') || '').toLowerCase()
|
|
44
|
+
const prop = fieldPropertyFor(el)
|
|
45
|
+
const tagSel = tagSelectorFor(tag, type)
|
|
46
|
+
const base = `${tagSel}[data-hcms-field]`
|
|
47
|
+
if (tag === 'INPUT' && type === 'radio') return `${base}:checked@value`
|
|
48
|
+
if (prop) return `${base}@${prop}`
|
|
49
|
+
return base
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tagSelectorFor(tag, type) {
|
|
53
|
+
if (tag === 'INPUT') {
|
|
54
|
+
if (type) return `input[type="${type}"]`
|
|
55
|
+
return 'input'
|
|
56
|
+
}
|
|
57
|
+
if (tag === 'TEXTAREA') return 'textarea'
|
|
58
|
+
if (tag === 'SELECT') return 'select'
|
|
59
|
+
if (tag === 'IMG') return 'img'
|
|
60
|
+
if (tag === 'A') return 'a'
|
|
61
|
+
// Custom/unknown element (e.g. contenteditable div) — match by attribute
|
|
62
|
+
// and exclude every container shape (scalar wrapper too) so the wrapping
|
|
63
|
+
// node never shadows the inner leaf. v0.3 stamps data-hcms-field on the
|
|
64
|
+
// scalar wrapper as well, so omitting `scalar` here would otherwise match
|
|
65
|
+
// the wrapper first and extract its full text instead of the leaf's.
|
|
66
|
+
return ':not([data-hcms-shape="scalar"]):not([data-hcms-shape="object"]):not([data-hcms-shape="object-array"]):not([data-hcms-shape="scalar-array"])'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cssEscape(value) {
|
|
70
|
+
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value)
|
|
71
|
+
return String(value).replace(/[^a-zA-Z0-9_\-.*]/g, (c) => '\\' + c)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
75
|
+
|
|
76
|
+
export function deriveFormRules(pageRules, doc) {
|
|
77
|
+
return derive(pageRules, [])
|
|
78
|
+
|
|
79
|
+
function derive(rule, pathArr) {
|
|
80
|
+
const kind = shapeKindOf(rule)
|
|
81
|
+
if (kind === 'scalar') return scalarRule(rule, pathArr)
|
|
82
|
+
if (kind === 'scalar-array') return scalarArrayRule(pathArr)
|
|
83
|
+
if (kind === 'object-array') return objectArrayRule(rule, pathArr)
|
|
84
|
+
if (kind === 'object') {
|
|
85
|
+
const out = Object.create(null)
|
|
86
|
+
for (const [k, child] of Object.entries(rule)) {
|
|
87
|
+
if (FORBIDDEN_KEYS.has(k)) {
|
|
88
|
+
throw new Error(`hypercms: rule key "${k}" is forbidden at "${pathArr.join('.') || '<root>'}"`)
|
|
89
|
+
}
|
|
90
|
+
out[k] = derive(child, [...pathArr, k])
|
|
91
|
+
}
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function scalarRule(rule, pathArr) {
|
|
98
|
+
const key = pathArr.length ? pathArr[pathArr.length - 1] : null
|
|
99
|
+
const fieldKey = typeof key === 'string' ? key : '__value'
|
|
100
|
+
const inlineEl = findInlineFieldEl(pathArr, fieldKey)
|
|
101
|
+
if (inlineEl) return fieldSelectorFor(inlineEl, fieldKey)
|
|
102
|
+
// No path-bound override: look up the actual @scalar template (which
|
|
103
|
+
// sites may override globally with e.g. <textarea>) and derive the
|
|
104
|
+
// selector from its leaf tag instead of assuming <input>.
|
|
105
|
+
const shapeEl = findShapeFieldEl('@scalar', fieldKey)
|
|
106
|
+
if (shapeEl) return fieldSelectorFor(shapeEl, fieldKey)
|
|
107
|
+
return `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scalarArrayRule(pathArr) {
|
|
111
|
+
// The engine's "selector[]" form is text-only; for an editable form we need
|
|
112
|
+
// per-item @value reads, so emit the [selector, shape] form with a scalar
|
|
113
|
+
// shape that targets the inner field's value property. If the site
|
|
114
|
+
// overrides @scalar-array-item with a non-<input> leaf (textarea, etc.),
|
|
115
|
+
// derive the inner-leaf selector from the override.
|
|
116
|
+
const shapeEl = findShapeFieldEl('@scalar-array-item', null)
|
|
117
|
+
const itemSel = shapeEl ? keylessFieldSelectorFor(shapeEl) : 'input[data-hcms-field]@value'
|
|
118
|
+
return [itemSelectorFor(pathArr, '[data-hcms-array-item]'), itemSel]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function objectArrayRule(rule, pathArr) {
|
|
122
|
+
const [, itemShape] = rule
|
|
123
|
+
const itemPath = [...pathArr, '*']
|
|
124
|
+
const itemSelector = itemSelectorFor(pathArr, '[data-hcms-card]')
|
|
125
|
+
if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
|
|
126
|
+
const obj = Object.create(null)
|
|
127
|
+
for (const [k, child] of Object.entries(itemShape)) {
|
|
128
|
+
if (FORBIDDEN_KEYS.has(k)) {
|
|
129
|
+
throw new Error(`hypercms: rule key "${k}" is forbidden at "${itemPath.join('.')}"`)
|
|
130
|
+
}
|
|
131
|
+
obj[k] = derive(child, [...itemPath, k])
|
|
132
|
+
}
|
|
133
|
+
return [itemSelector, obj]
|
|
134
|
+
}
|
|
135
|
+
return [itemSelector, derive(itemShape, [...itemPath, 0])]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Build a selector that scopes item matching to the right array container.
|
|
139
|
+
// Top-level paths use exact data-hcms-path match. Nested paths use the
|
|
140
|
+
// stable data-hcms-field key (which the form-builder stamps on every keyed
|
|
141
|
+
// container) — this disambiguates sibling arrays that share a terminal key
|
|
142
|
+
// (e.g. products.*.primary.variants vs products.*.secondary.variants would
|
|
143
|
+
// both have a `variants` array with the same last segment).
|
|
144
|
+
function itemSelectorFor(pathArr, itemTag) {
|
|
145
|
+
const lastKey = pathArr.length ? pathArr[pathArr.length - 1] : ''
|
|
146
|
+
const hasWildcard = pathArr.some((s) => s === '*')
|
|
147
|
+
const pathStr = pathArr.join('.')
|
|
148
|
+
const containerSel = hasWildcard
|
|
149
|
+
? `[data-hcms-field="${cssEscape(lastKey)}"]`
|
|
150
|
+
: `[data-hcms-path="${cssEscape(pathStr)}"]`
|
|
151
|
+
return `${containerSel} > .hcms-array-items > ${itemTag}`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Look up the field leaf inside a shape-default template (@scalar,
|
|
155
|
+
// @scalar-array-item, etc.). Sites can override these globally, so the
|
|
156
|
+
// selector emitted must reflect the actual leaf tag/type, not assume input.
|
|
157
|
+
// Returns null if the template isn't found or has no [data-hcms-field].
|
|
158
|
+
function findShapeFieldEl(shapeKey, fieldKey) {
|
|
159
|
+
if (!doc) return null
|
|
160
|
+
const tpl = findTemplate(doc, shapeKey)
|
|
161
|
+
if (!tpl) return null
|
|
162
|
+
const content = tpl.content || tpl
|
|
163
|
+
if (fieldKey) {
|
|
164
|
+
const keyed = content.querySelector(`[data-hcms-field="${cssEscape(fieldKey)}"]`)
|
|
165
|
+
if (keyed) return keyed
|
|
166
|
+
}
|
|
167
|
+
return content.querySelector('[data-hcms-field]')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function findInlineFieldEl(pathArr, fieldKey) {
|
|
171
|
+
if (!doc) return null
|
|
172
|
+
const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
|
|
173
|
+
const pathStr = pathArr.join('.')
|
|
174
|
+
const candidates = [pathStr, wildcardKey]
|
|
175
|
+
for (let i = pathArr.length - 1; i >= 0; i--) {
|
|
176
|
+
const slice = pathArr.slice(0, i).map((s) => (typeof s === 'number' ? '*' : s))
|
|
177
|
+
slice.push('*')
|
|
178
|
+
candidates.push(slice.join('.'))
|
|
179
|
+
}
|
|
180
|
+
for (const key of candidates) {
|
|
181
|
+
if (!key) continue
|
|
182
|
+
const tpl = findTemplate(doc, key)
|
|
183
|
+
if (!tpl || !isInlineTemplate(tpl)) continue
|
|
184
|
+
const content = tpl.content || tpl
|
|
185
|
+
const el =
|
|
186
|
+
content.querySelector(`[data-hcms-field="${cssEscape(fieldKey)}"]`) ||
|
|
187
|
+
content.querySelector('[data-hcms-field]')
|
|
188
|
+
if (el) return el
|
|
189
|
+
}
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|