@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,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
+ }
@@ -0,0 +1,7 @@
1
+ import * as core from './hypercms.js'
2
+ import stylesText from './styles.css'
3
+
4
+ core.installStyles(stylesText)
5
+
6
+ export const cms = core.cms
7
+ export default { cms: core.cms }