@panphora/hyper-cms 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -1
- package/dist/hypercms.min.js +187 -23
- package/package.json +1 -1
- package/src/events.js +85 -8
- package/src/form-builder.js +143 -20
- package/src/form-rules.js +18 -6
- package/src/hypercms.js +9 -3
- package/src/refresh.js +2 -2
- package/src/shell.js +13 -2
- package/src/templates.js +294 -29
- package/src/theme/pixel-quiet.overrides.css +9 -0
- package/src/theme.generated.css +112 -11
- package/src/vendor/mirk.vendor.js +2 -2
package/package.json
CHANGED
package/src/events.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { engine } from 'hyper-html-api'
|
|
2
2
|
import { applyWithRollback } from './apply-loop.js'
|
|
3
3
|
import { fromString as pathFromString, getRuleAtPath } from './path.js'
|
|
4
|
-
import { buildItem, fileNameFromUrl } from './form-builder.js'
|
|
4
|
+
import { buildItem, fileNameFromUrl, radioGroupName } from './form-builder.js'
|
|
5
5
|
import { scaffold } from './scaffold.js'
|
|
6
6
|
|
|
7
7
|
const BOUND = new WeakSet()
|
|
@@ -172,13 +172,54 @@ export function onScalarChange(target, ctx) {
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
// Crop adapter seam
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
175
|
+
// Crop adapter seam: ALL crop coupling lives here, between the file pick and
|
|
176
|
+
// the upload. A field opts in via data-hcms-crop on the page element (copied
|
|
177
|
+
// onto the built @image field by the form-builder); absent attr or absent
|
|
178
|
+
// quickcrop (standalone page) uploads raw. Returns { file, dataURL? }, or null
|
|
179
|
+
// to abort (crop cancelled / failed) — the caller resets the picker and skips
|
|
180
|
+
// the commit. quickcrop resolves null on cancel (it does NOT reject), so the
|
|
181
|
+
// cancel branch is the null check, not the catch.
|
|
182
|
+
const CROP_OUTPUT = { type: 'image/webp', quality: 0.85, maxWidth: 2048, maxHeight: 2048 }
|
|
183
|
+
|
|
184
|
+
async function maybeCrop(file, fieldEl) {
|
|
185
|
+
const attr = fieldEl && fieldEl.getAttribute ? fieldEl.getAttribute('data-hcms-crop') : null
|
|
186
|
+
if (attr == null) return { file }
|
|
187
|
+
const crop = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.quickcrop) || null
|
|
188
|
+
if (typeof crop !== 'function') return { file }
|
|
189
|
+
try {
|
|
190
|
+
const r = await crop(file, { aspect: parseCropAspect(attr), ...CROP_OUTPUT })
|
|
191
|
+
if (r === null) return null
|
|
192
|
+
return { file: blobToFile(r.blob, file.name), dataURL: r.dataURL }
|
|
193
|
+
} catch (err) {
|
|
194
|
+
showFieldUploadError(fieldEl, (err && err.message) || 'Crop failed')
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// "1:1" → 1, "16:9" → 16/9, "free" / "" / unparseable → null (freeform crop).
|
|
200
|
+
// Exported for tests.
|
|
201
|
+
export function parseCropAspect(value) {
|
|
202
|
+
const v = String(value ?? '').trim().toLowerCase()
|
|
203
|
+
if (v === '' || v === 'free') return null
|
|
204
|
+
const m = v.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/)
|
|
205
|
+
if (!m) return null
|
|
206
|
+
const w = parseFloat(m[1])
|
|
207
|
+
const h = parseFloat(m[2])
|
|
208
|
+
if (!w || !h) return null
|
|
209
|
+
return w / h
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// quickcrop returns a nameless Blob; uploadFileBasic rejects raw Blobs and the
|
|
213
|
+
// server keys content-type off the filename extension, so wrap it as a File
|
|
214
|
+
// whose extension matches the encoded mime. Exported for tests.
|
|
215
|
+
export function blobToFile(blob, originalName) {
|
|
216
|
+
const ext = blob.type === 'image/webp' ? '.webp' : blob.type === 'image/jpeg' ? '.jpg' : '.png'
|
|
217
|
+
const base = String(originalName || 'image').replace(/\.[^.]+$/, '')
|
|
218
|
+
try {
|
|
219
|
+
return new File([blob], base + ext, { type: blob.type })
|
|
220
|
+
} catch {
|
|
221
|
+
return blob
|
|
222
|
+
}
|
|
182
223
|
}
|
|
183
224
|
|
|
184
225
|
// A file was picked in an @file/@image widget. Optionally crop, upload via the
|
|
@@ -193,6 +234,10 @@ export async function onUploadChange(inputEl, ctx) {
|
|
|
193
234
|
if (!fieldEl) return
|
|
194
235
|
const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
|
|
195
236
|
|
|
237
|
+
// A fresh pick is a fresh attempt: clear any stale inline error (e.g. a
|
|
238
|
+
// crop failure from a previous attempt) before running the new flow.
|
|
239
|
+
showFieldUploadError(fieldEl, null)
|
|
240
|
+
|
|
196
241
|
const cropped = await maybeCrop(file, fieldEl)
|
|
197
242
|
if (!cropped || ctx.closed) { resetPicker(inputEl); return }
|
|
198
243
|
const fileLike = cropped.file
|
|
@@ -316,6 +361,9 @@ export function onAdd(arrayPath, ctx) {
|
|
|
316
361
|
pathArr: [...pathArr, nextIndex],
|
|
317
362
|
data: itemData,
|
|
318
363
|
doc: ctx.doc,
|
|
364
|
+
// Component arrays (chips) record their item-template key at build time so
|
|
365
|
+
// added items keep the same chrome.
|
|
366
|
+
itemKey: arrayEl.getAttribute('data-hcms-item-tpl') || null,
|
|
319
367
|
})
|
|
320
368
|
slot.appendChild(itemNode)
|
|
321
369
|
updateArrayButtonsVisibility(arrayEl)
|
|
@@ -619,6 +667,16 @@ export function restampAllSiblings(formRoot) {
|
|
|
619
667
|
}
|
|
620
668
|
|
|
621
669
|
function restampSiblingPaths(parent) {
|
|
670
|
+
// Radio names are rewritten one item at a time below, so during a move or
|
|
671
|
+
// drag a just-renamed radio can transiently share a name with a
|
|
672
|
+
// not-yet-renamed sibling — and the browser enforces single-selection per
|
|
673
|
+
// group AT THAT INSTANT, permanently unchecking one of them (null extract →
|
|
674
|
+
// the whole-form commit clobbers that field's page value). Snapshot every
|
|
675
|
+
// radio's checked state and re-assert it once all names are final (and
|
|
676
|
+
// unique per field) again.
|
|
677
|
+
const radios = parent.querySelectorAll
|
|
678
|
+
? Array.from(parent.querySelectorAll('input[type="radio"][data-hcms-field]'), (r) => [r, r.checked])
|
|
679
|
+
: []
|
|
622
680
|
let i = 0
|
|
623
681
|
for (const child of parent.children) {
|
|
624
682
|
if (!child.matches?.('[data-hcms-card], [data-hcms-array-item]')) continue
|
|
@@ -630,6 +688,9 @@ function restampSiblingPaths(parent) {
|
|
|
630
688
|
if (newPath !== path) restampSubtree(child, path, newPath)
|
|
631
689
|
i++
|
|
632
690
|
}
|
|
691
|
+
for (const [r, wasChecked] of radios) {
|
|
692
|
+
if (r.checked !== wasChecked) r.checked = wasChecked
|
|
693
|
+
}
|
|
633
694
|
}
|
|
634
695
|
|
|
635
696
|
function restampSubtree(root, oldPrefix, newPrefix) {
|
|
@@ -643,6 +704,22 @@ function restampSubtree(root, oldPrefix, newPrefix) {
|
|
|
643
704
|
el.setAttribute('data-hcms-path', newPrefix + p.slice(oldPrefix.length))
|
|
644
705
|
}
|
|
645
706
|
}
|
|
707
|
+
syncRadioGroupNames(root)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Radio group names derive from the field path at build time (populateOptions),
|
|
711
|
+
// so a restamp that moves paths must recompute them. Otherwise a removed-then-
|
|
712
|
+
// re-added sibling gets a fresh name that collides with this item's stale one:
|
|
713
|
+
// the two fields share a browser radio group, checking one unchecks the other,
|
|
714
|
+
// and the whole-form commit clobbers the unchecked field's page value. Only
|
|
715
|
+
// names the builder minted ('hcms-' prefix) are touched — radios in inline or
|
|
716
|
+
// custom templates keep whatever name the author gave them.
|
|
717
|
+
function syncRadioGroupNames(root) {
|
|
718
|
+
for (const input of root.querySelectorAll('input[type="radio"][data-hcms-field]')) {
|
|
719
|
+
if (!input.name || !input.name.startsWith('hcms-')) continue
|
|
720
|
+
const host = input.closest('[data-hcms-path]')
|
|
721
|
+
if (host) input.name = radioGroupName(host.getAttribute('data-hcms-path'))
|
|
722
|
+
}
|
|
646
723
|
}
|
|
647
724
|
|
|
648
725
|
function stableStringify(value) {
|
package/src/form-builder.js
CHANGED
|
@@ -4,6 +4,12 @@ import {
|
|
|
4
4
|
humanize,
|
|
5
5
|
shapeKindOf,
|
|
6
6
|
componentForScalarRule,
|
|
7
|
+
componentForScalarArrayRule,
|
|
8
|
+
winningScalarArrayComponent,
|
|
9
|
+
resolveTemplate,
|
|
10
|
+
declaredComponentKey,
|
|
11
|
+
readOptionsOverride,
|
|
12
|
+
readCropOverride,
|
|
7
13
|
} from './templates.js'
|
|
8
14
|
import { fieldPropertyFor } from './form-rules.js'
|
|
9
15
|
|
|
@@ -27,12 +33,12 @@ export function buildForm({ pageRules, formRules: _formRules, data, doc }) {
|
|
|
27
33
|
return fragment
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
export function buildItem({ shape, itemShape, pathArr, data, doc }) {
|
|
36
|
+
export function buildItem({ shape, itemShape, pathArr, data, doc, itemKey }) {
|
|
31
37
|
if (shape === 'object-array-item') {
|
|
32
38
|
return buildObjectArrayItem(itemShape, pathArr, data, doc)
|
|
33
39
|
}
|
|
34
40
|
if (shape === 'scalar-array-item') {
|
|
35
|
-
return buildScalarArrayItem(pathArr, data, doc)
|
|
41
|
+
return buildScalarArrayItem(pathArr, data, doc, itemKey || null)
|
|
36
42
|
}
|
|
37
43
|
throw new Error(`hypercms: buildItem called with unknown shape "${shape}"`)
|
|
38
44
|
}
|
|
@@ -47,14 +53,40 @@ function buildNode(rule, pathArr, data, doc) {
|
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
function buildScalar(rule, pathArr, data, doc) {
|
|
50
|
-
// Opt-in
|
|
51
|
-
//
|
|
52
|
-
// template override still wins inside resolveTemplate.
|
|
53
|
-
const componentKey = componentForScalarRule(rule, doc)
|
|
56
|
+
// Opt-in components select a richer template off the bound rule (explicit
|
|
57
|
+
// data-hcms-component, then @prop inference); plain scalars stay @scalar.
|
|
58
|
+
// A path-bound template override still wins inside resolveTemplate.
|
|
59
|
+
const componentKey = componentForScalarRule(rule, doc, pathArr)
|
|
54
60
|
const tpl = resolveTemplate(pathArr, componentKey, doc)
|
|
55
61
|
if (!tpl) throw new Error(`hypercms: missing template for scalar at "${pathArr.join('.')}"`)
|
|
62
|
+
const declaredKey = declaredComponentKey(rule, doc)
|
|
63
|
+
// A value-guard fell back to text: say so, or "where did my number input /
|
|
64
|
+
// checkbox go" has no answer.
|
|
65
|
+
if (declaredKey === '@number' && componentKey === '@scalar') {
|
|
66
|
+
console.info(
|
|
67
|
+
`[hypercms] field "${pathArr.join('.')}" declares component "@number" but its value isn't a plain number; rendering a text input so the value is preserved`
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
if ((declaredKey === '@checkbox' || declaredKey === '@toggle') && componentKey === '@scalar') {
|
|
71
|
+
console.info(
|
|
72
|
+
`[hypercms] field "${pathArr.join('.')}" declares component "${declaredKey}" but its value isn't true/false; rendering a text input so the value is preserved`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
noteShadowedComponent(tpl, declaredKey === componentKey ? declaredKey : null, pathArr)
|
|
56
76
|
const node = cloneTemplate(tpl, doc)
|
|
57
77
|
stampPath(node, pathArr)
|
|
78
|
+
// Component-specific setup runs only when the component's own template won —
|
|
79
|
+
// a custom path/wildcard template that shadows it is the author's UI, and
|
|
80
|
+
// injecting options, dropping radio rows, or stamping crop into it would
|
|
81
|
+
// corrupt that UI.
|
|
82
|
+
const winnerKey = tpl.getAttribute?.('data-hcms-tpl')
|
|
83
|
+
if ((componentKey === '@select' || componentKey === '@radio') && winnerKey === componentKey) {
|
|
84
|
+
populateOptions(node, rule, pathArr, data, doc, componentKey)
|
|
85
|
+
}
|
|
86
|
+
if (componentKey === '@image' && winnerKey === '@image') {
|
|
87
|
+
const crop = readCropOverride(rule, doc)
|
|
88
|
+
if (crop != null && !node.hasAttribute('data-hcms-crop')) node.setAttribute('data-hcms-crop', crop)
|
|
89
|
+
}
|
|
58
90
|
// Stamp leaf input(s) FIRST so the container's data-hcms-field doesn't
|
|
59
91
|
// make stampScalarField early-return (which would leave the input without
|
|
60
92
|
// a key).
|
|
@@ -67,6 +99,89 @@ function buildScalar(rule, pathArr, data, doc) {
|
|
|
67
99
|
return node
|
|
68
100
|
}
|
|
69
101
|
|
|
102
|
+
// A custom template (exact or wildcard path) outranks a named component, by
|
|
103
|
+
// design: the author's template is the stronger signal. Surface the shadowing
|
|
104
|
+
// so "my data-hcms-component does nothing" has a visible answer — but only for
|
|
105
|
+
// EXPLICITLY declared components (declaredKey non-null): a custom template
|
|
106
|
+
// quietly outranking @src/@checked inference is unremarkable.
|
|
107
|
+
function noteShadowedComponent(tpl, declaredKey, pathArr) {
|
|
108
|
+
if (!declaredKey) return
|
|
109
|
+
const winner = tpl.getAttribute?.('data-hcms-tpl')
|
|
110
|
+
if (winner && winner !== declaredKey) {
|
|
111
|
+
console.info(
|
|
112
|
+
`[hypercms] field "${pathArr.join('.')}" declares component "${declaredKey}" but custom template "${winner}" wins`
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fill a @select's <select> with <option>s, or clone a @radio's prototype row
|
|
118
|
+
// per option, from the page element's data-hcms-options tokens. The current
|
|
119
|
+
// page value is prepended when it isn't in the list, so opening the editor
|
|
120
|
+
// never silently changes data. Radio inputs get a per-field name derived from
|
|
121
|
+
// the path so sibling fields (and array items) never cross-group.
|
|
122
|
+
function populateOptions(node, rule, pathArr, data, doc, componentKey) {
|
|
123
|
+
const provided = readOptionsOverride(rule, doc)
|
|
124
|
+
const values = provided ? [...provided] : []
|
|
125
|
+
const current = data == null ? '' : String(data)
|
|
126
|
+
if (current !== '' && !values.includes(current)) values.unshift(current)
|
|
127
|
+
if (!provided) {
|
|
128
|
+
// Missing data-hcms-options is an authoring error: surface it ALWAYS, not
|
|
129
|
+
// just when the page value is empty too. When a value exists, fall through
|
|
130
|
+
// and render it as the single option so the data still round-trips while
|
|
131
|
+
// the attribute is missing.
|
|
132
|
+
setFieldError(node, 'data-hcms-options required (space-separated values)')
|
|
133
|
+
if (values.length === 0) {
|
|
134
|
+
// No options AND no value: drop the radio prototype so no phantom empty
|
|
135
|
+
// row renders. The null/'' extract this leaves behind applies as ''
|
|
136
|
+
// (writePropOrAttr), matching the already empty page value, so no data
|
|
137
|
+
// moves.
|
|
138
|
+
node.querySelector('.mirk-radio')?.remove()
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (componentKey === '@select') {
|
|
143
|
+
const select = node.querySelector('select[data-hcms-field]')
|
|
144
|
+
if (!select) return
|
|
145
|
+
for (const v of values) {
|
|
146
|
+
const opt = doc.createElement('option')
|
|
147
|
+
opt.value = v
|
|
148
|
+
opt.textContent = humanize(v)
|
|
149
|
+
select.appendChild(opt)
|
|
150
|
+
}
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const proto = node.querySelector('.mirk-radio')
|
|
154
|
+
if (!proto || !proto.parentNode) return
|
|
155
|
+
const name = radioGroupName(pathArr.join('.'))
|
|
156
|
+
for (const v of values) {
|
|
157
|
+
const row = proto.cloneNode(true)
|
|
158
|
+
const input = row.querySelector('input[type="radio"]')
|
|
159
|
+
if (input) {
|
|
160
|
+
input.value = v
|
|
161
|
+
input.name = name
|
|
162
|
+
}
|
|
163
|
+
const label = row.querySelector('.mirk-radio__label')
|
|
164
|
+
if (label) label.textContent = humanize(v)
|
|
165
|
+
proto.parentNode.insertBefore(row, proto)
|
|
166
|
+
}
|
|
167
|
+
proto.remove()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The radio group name a field at this dot-path gets. Shared with the restamp
|
|
171
|
+
// machinery in events.js: names derive from paths, so when a remove/move
|
|
172
|
+
// restamps paths, the names must be recomputed with the same formula or
|
|
173
|
+
// sibling items end up sharing a group.
|
|
174
|
+
export function radioGroupName(pathStr) {
|
|
175
|
+
return 'hcms-' + String(pathStr).replace(/[^A-Za-z0-9_-]/g, '-')
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function setFieldError(node, message) {
|
|
179
|
+
const slot = node.querySelector ? node.querySelector('.hcms-error') : null
|
|
180
|
+
if (!slot) return
|
|
181
|
+
slot.textContent = message
|
|
182
|
+
slot.hidden = false
|
|
183
|
+
}
|
|
184
|
+
|
|
70
185
|
// The @file leaf is an <a> whose value is the href; populateScalarValue sets
|
|
71
186
|
// href but not the visible text, so derive the filename here. Empty href leaves
|
|
72
187
|
// the anchor empty, which the theme's :empty placeholder dresses as "No file
|
|
@@ -149,27 +264,41 @@ function buildObjectArrayItem(itemShape, pathArr, data, doc) {
|
|
|
149
264
|
}
|
|
150
265
|
|
|
151
266
|
function buildScalarArray(rule, pathArr, data, doc) {
|
|
152
|
-
|
|
267
|
+
// The chips component swaps the scalar-array chrome (data-hcms-component=
|
|
268
|
+
// "chips" on the list container or an item ancestor). A path-bound template
|
|
269
|
+
// still wins inside resolveTemplate — and when it does, `comp` is null
|
|
270
|
+
// (winningScalarArrayComponent shares that verdict with deriveFormRules, so
|
|
271
|
+
// the item chrome and the derived item selector can never diverge) and the
|
|
272
|
+
// custom template's own UI is left untouched.
|
|
273
|
+
const declared = componentForScalarArrayRule(rule, doc)
|
|
274
|
+
const comp = winningScalarArrayComponent(rule, pathArr, doc)
|
|
275
|
+
const defaultKey = declared ? declared.array : '@scalar-array'
|
|
276
|
+
const tpl = resolveTemplate(pathArr, defaultKey, doc)
|
|
153
277
|
if (!tpl) throw new Error(`hypercms: missing template for scalar-array at "${pathArr.join('.')}"`)
|
|
278
|
+
// chips is always an explicit declaration (attr-driven), so always notable.
|
|
279
|
+
noteShadowedComponent(tpl, declared ? declared.array : null, pathArr)
|
|
154
280
|
const node = cloneTemplate(tpl, doc)
|
|
155
281
|
stampPath(node, pathArr)
|
|
156
282
|
stampContainerField(node, lastKey(pathArr))
|
|
157
283
|
setLabel(node, lastKey(pathArr))
|
|
158
284
|
copyArrayConstraintAttrs(node, tpl)
|
|
159
285
|
wireSortable(node, tpl, pathArr)
|
|
286
|
+
// Record the resolved item-template key so onAdd (which has no rule context)
|
|
287
|
+
// builds new items with the same component chrome.
|
|
288
|
+
if (comp) node.setAttribute('data-hcms-item-tpl', comp.item)
|
|
160
289
|
|
|
161
290
|
const slot = requireSlot(node, '.hcms-array-items', tpl, pathArr)
|
|
162
291
|
const items = Array.isArray(data) ? data : []
|
|
163
292
|
items.forEach((itemValue, i) => {
|
|
164
|
-
const itemNode = buildScalarArrayItem([...pathArr, i], itemValue, doc)
|
|
293
|
+
const itemNode = buildScalarArrayItem([...pathArr, i], itemValue, doc, comp ? comp.item : null)
|
|
165
294
|
if (itemNode) slot.appendChild(itemNode)
|
|
166
295
|
})
|
|
167
296
|
applyConstraintVisibility(node)
|
|
168
297
|
return node
|
|
169
298
|
}
|
|
170
299
|
|
|
171
|
-
function buildScalarArrayItem(pathArr, value, doc) {
|
|
172
|
-
const tpl = resolveItemTemplate(pathArr, 'scalar-array-item', doc)
|
|
300
|
+
function buildScalarArrayItem(pathArr, value, doc, itemKey) {
|
|
301
|
+
const tpl = resolveItemTemplate(pathArr, 'scalar-array-item', doc, itemKey)
|
|
173
302
|
if (!tpl) throw new Error(`hypercms: missing item template for "${pathArr.join('.')}"`)
|
|
174
303
|
const node = cloneTemplate(tpl, doc)
|
|
175
304
|
node.setAttribute('data-hcms-array-item', '')
|
|
@@ -180,21 +309,15 @@ function buildScalarArrayItem(pathArr, value, doc) {
|
|
|
180
309
|
return node
|
|
181
310
|
}
|
|
182
311
|
|
|
183
|
-
function
|
|
184
|
-
const pathStr = pathArr.join('.')
|
|
312
|
+
function resolveItemTemplate(pathArr, defaultShape, doc, itemKey) {
|
|
185
313
|
const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
|
|
186
314
|
return (
|
|
187
|
-
|
|
188
|
-
(
|
|
189
|
-
findTemplate(doc,
|
|
315
|
+
findTemplate(doc, wildcardKey) ||
|
|
316
|
+
(itemKey && findTemplate(doc, itemKey)) ||
|
|
317
|
+
findTemplate(doc, '@' + defaultShape)
|
|
190
318
|
)
|
|
191
319
|
}
|
|
192
320
|
|
|
193
|
-
function resolveItemTemplate(pathArr, defaultShape, doc) {
|
|
194
|
-
const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
|
|
195
|
-
return findTemplate(doc, wildcardKey) || findTemplate(doc, '@' + defaultShape)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
321
|
function cloneTemplate(tpl, doc) {
|
|
199
322
|
const content = tpl.content || tpl
|
|
200
323
|
const wrapper = doc.createElement('div')
|
package/src/form-rules.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
shapeKindOf,
|
|
3
|
+
findTemplate,
|
|
4
|
+
isInlineTemplate,
|
|
5
|
+
componentForScalarRule,
|
|
6
|
+
winningScalarArrayComponent,
|
|
7
|
+
} from './templates.js'
|
|
2
8
|
|
|
3
9
|
const TAG_PROP_MAP = {
|
|
4
10
|
IMG: 'src',
|
|
@@ -79,7 +85,7 @@ export function deriveFormRules(pageRules, doc) {
|
|
|
79
85
|
function derive(rule, pathArr) {
|
|
80
86
|
const kind = shapeKindOf(rule)
|
|
81
87
|
if (kind === 'scalar') return scalarRule(rule, pathArr)
|
|
82
|
-
if (kind === 'scalar-array') return scalarArrayRule(pathArr)
|
|
88
|
+
if (kind === 'scalar-array') return scalarArrayRule(rule, pathArr)
|
|
83
89
|
if (kind === 'object-array') return objectArrayRule(rule, pathArr)
|
|
84
90
|
if (kind === 'object') {
|
|
85
91
|
const out = Object.create(null)
|
|
@@ -104,18 +110,24 @@ export function deriveFormRules(pageRules, doc) {
|
|
|
104
110
|
// leaf tag, so an @image field reads img@src and an @file field reads
|
|
105
111
|
// a@href, while a plain scalar still respects a global @scalar override
|
|
106
112
|
// (e.g. a <textarea>) instead of assuming <input>.
|
|
107
|
-
const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc), fieldKey)
|
|
113
|
+
const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc, pathArr), fieldKey)
|
|
108
114
|
if (shapeEl) return fieldSelectorFor(shapeEl, fieldKey)
|
|
109
115
|
return `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`
|
|
110
116
|
}
|
|
111
117
|
|
|
112
|
-
function scalarArrayRule(pathArr) {
|
|
118
|
+
function scalarArrayRule(rule, pathArr) {
|
|
113
119
|
// The engine's "selector[]" form is text-only; for an editable form we need
|
|
114
120
|
// per-item @value reads, so emit the [selector, shape] form with a scalar
|
|
115
121
|
// shape that targets the inner field's value property. If the site
|
|
116
122
|
// overrides @scalar-array-item with a non-<input> leaf (textarea, etc.),
|
|
117
|
-
// derive the inner-leaf selector from the override.
|
|
118
|
-
|
|
123
|
+
// derive the inner-leaf selector from the override. A component array
|
|
124
|
+
// (chips) derives from its own item template first — but only when the
|
|
125
|
+
// chips template actually wins at this path (winningScalarArrayComponent
|
|
126
|
+
// is the same verdict buildScalarArray uses, so the derived item selector
|
|
127
|
+
// always matches the rendered item leaf).
|
|
128
|
+
const comp = winningScalarArrayComponent(rule, pathArr, doc)
|
|
129
|
+
const shapeEl =
|
|
130
|
+
(comp && findShapeFieldEl(comp.item, null)) || findShapeFieldEl('@scalar-array-item', null)
|
|
119
131
|
const itemSel = shapeEl ? keylessFieldSelectorFor(shapeEl) : 'input[data-hcms-field]@value'
|
|
120
132
|
return [itemSelectorFor(pathArr, '[data-hcms-array-item]'), itemSel]
|
|
121
133
|
}
|
package/src/hypercms.js
CHANGED
|
@@ -2,7 +2,7 @@ import { engine } from 'hyper-html-api'
|
|
|
2
2
|
import * as pathUtil from './path.js'
|
|
3
3
|
import { scaffold } from './scaffold.js'
|
|
4
4
|
import { morphForm } from './morph.js'
|
|
5
|
-
import { injectDefaults,
|
|
5
|
+
import { injectDefaults, injectComponents } from './templates.js'
|
|
6
6
|
import { deriveFormRules } from './form-rules.js'
|
|
7
7
|
import { buildForm } from './form-builder.js'
|
|
8
8
|
import {
|
|
@@ -104,7 +104,7 @@ export function open(opts = {}) {
|
|
|
104
104
|
const rulesTagNode = found.tagNode
|
|
105
105
|
|
|
106
106
|
injectDefaults(doc)
|
|
107
|
-
|
|
107
|
+
injectComponents(doc, pageRules)
|
|
108
108
|
warnUnmatchedTemplates(doc, pageRules)
|
|
109
109
|
const formRules = deriveFormRules(pageRules, doc)
|
|
110
110
|
const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
|
|
@@ -511,7 +511,13 @@ function autoOpenReady() {
|
|
|
511
511
|
// deadline is hit (warn, never throw).
|
|
512
512
|
function whenReady(fn) {
|
|
513
513
|
if (autoOpenReady()) {
|
|
514
|
-
|
|
514
|
+
// Defer one microtask. On the bundled entry (hypercms-bundle.js), installStyles()
|
|
515
|
+
// runs AFTER this module evaluates, so firing open() synchronously here would
|
|
516
|
+
// mount the shell with cssText still empty (an unstyled sidebar). A microtask
|
|
517
|
+
// runs only after the synchronous bundle evaluation completes, by which point
|
|
518
|
+
// styles are installed. The event/backstop paths below already fire post-eval,
|
|
519
|
+
// so only this fast path needed deferring.
|
|
520
|
+
queueMicrotask(fn)
|
|
515
521
|
return
|
|
516
522
|
}
|
|
517
523
|
const deadline = Date.now() + AUTO_OPEN_DEADLINE_MS
|
package/src/refresh.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { engine } from 'hyper-html-api'
|
|
2
2
|
import { buildForm } from './form-builder.js'
|
|
3
3
|
import { morphForm } from './morph.js'
|
|
4
|
-
import { injectDefaults,
|
|
4
|
+
import { injectDefaults, injectComponents } from './templates.js'
|
|
5
5
|
import { deriveFormRules } from './form-rules.js'
|
|
6
6
|
import { coerceBooleans, applyErrorState } from './events.js'
|
|
7
7
|
|
|
@@ -18,7 +18,7 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
|
|
|
18
18
|
}
|
|
19
19
|
// Re-derive formRules so schema changes (rule shape, template overrides) flow through.
|
|
20
20
|
injectDefaults(ctx.doc)
|
|
21
|
-
|
|
21
|
+
injectComponents(ctx.doc, ctx.pageRules)
|
|
22
22
|
ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
|
|
23
23
|
|
|
24
24
|
const newData = coerceBooleans(
|
package/src/shell.js
CHANGED
|
@@ -155,6 +155,10 @@ function ensureStyles(doc) {
|
|
|
155
155
|
const style = doc.createElement('style')
|
|
156
156
|
style.id = SHELL_STYLE_ID
|
|
157
157
|
style.setAttribute('save-remove', '')
|
|
158
|
+
// save-ignore mirrors the shell root so a full-document morph (e.g. a live-sync
|
|
159
|
+
// head-merge) preserves the stylesheet instead of stripping it; without it the
|
|
160
|
+
// style survives only via restoreChrome's reactive re-injection.
|
|
161
|
+
style.setAttribute('save-ignore', '')
|
|
158
162
|
style.textContent = cssText
|
|
159
163
|
;(doc.head || doc.documentElement).appendChild(style)
|
|
160
164
|
styledDocs.add(doc)
|
|
@@ -170,12 +174,19 @@ function ensureStyles(doc) {
|
|
|
170
174
|
link.rel = 'stylesheet'
|
|
171
175
|
link.id = SHELL_STYLE_ID
|
|
172
176
|
link.setAttribute('save-remove', '')
|
|
177
|
+
link.setAttribute('save-ignore', '')
|
|
173
178
|
link.href = href
|
|
174
179
|
;(doc.head || doc.documentElement).appendChild(link)
|
|
175
180
|
styledDocs.add(doc)
|
|
176
181
|
} catch (_) {
|
|
177
|
-
//
|
|
178
|
-
//
|
|
182
|
+
// No bundled CSS and no resolvable co-located theme (e.g. the IIFE bundle,
|
|
183
|
+
// where import.meta is undefined). Warn loudly rather than mount a silently
|
|
184
|
+
// unstyled shell — the host must call installStyles(themeText) before opening.
|
|
185
|
+
console.warn(
|
|
186
|
+
'hypercms: shell stylesheet not applied — cssText is empty and the ' +
|
|
187
|
+
'co-located theme fallback is unavailable. Call installStyles(themeText) ' +
|
|
188
|
+
'before opening the CMS.'
|
|
189
|
+
)
|
|
179
190
|
}
|
|
180
191
|
}
|
|
181
192
|
|