@panphora/hyper-cms 0.4.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/hyper-cms",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Live edit-in-place CMS sidebar for HTML pages with a hyper-html-api rules tag",
5
5
  "keywords": [
6
6
  "HTML",
@@ -20,7 +20,8 @@
20
20
  "module": "src/hypercms.js",
21
21
  "exports": {
22
22
  ".": "./src/hypercms.js",
23
- "./styles.css": "./src/styles.css"
23
+ "./styles.css": "./src/theme.generated.css",
24
+ "./theme.css": "./src/theme.generated.css"
24
25
  },
25
26
  "files": [
26
27
  "LICENSE",
@@ -30,7 +31,8 @@
30
31
  "dist/*.js"
31
32
  ],
32
33
  "scripts": {
33
- "build": "npx esbuild src/hypercms-bundle.js --bundle --minify --format=iife --global-name=hypercms --loader:.css=text --outfile=dist/hypercms.min.js",
34
+ "build:theme": "node scripts/build-theme.js",
35
+ "build": "npm run build:theme && npx esbuild src/hypercms-bundle.js --bundle --minify --format=iife --global-name=hypercms --loader:.css=text --outfile=dist/hypercms.min.js",
34
36
  "prepublishOnly": "npm run build",
35
37
  "copy-to-hyperclayjs": "npm run build && node scripts/copy-to-hyperclayjs.js",
36
38
  "test:node": "node --test 'test-node/**/*.test.js'",
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
- import { fromString as pathFromString } from './path.js'
4
- import { buildItem } from './form-builder.js'
3
+ import { fromString as pathFromString, getRuleAtPath } from './path.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()
@@ -56,6 +56,11 @@ export function bindEvents(ctx) {
56
56
  if (!target || !target.closest) return
57
57
  if (!target.closest('[data-hcms-form-root]')) return
58
58
  if (!target.matches('input, textarea, select')) return
59
+ // The upload picker fires `input` too, but its .value is the OS fake path
60
+ // (C:\fakepath\…); it's handled on `change` via onUploadChange. The wrapper
61
+ // carries data-hcms-field, so without this guard the closest() below would
62
+ // route the fake path into onScalarChange.
63
+ if (target.matches('input[type="file"]')) return
59
64
  if (!target.closest('[data-hcms-field]') && !target.hasAttribute?.('data-hcms-field')) return
60
65
  onScalarChange(target, ctx)
61
66
  }
@@ -64,6 +69,10 @@ export function bindEvents(ctx) {
64
69
  const target = e.target
65
70
  if (!target || !target.closest) return
66
71
  if (!target.closest('[data-hcms-form-root]')) return
72
+ if (target.matches('input[type="file"][data-hcms-upload]')) {
73
+ onUploadChange(target, ctx)
74
+ return
75
+ }
67
76
  if (target.matches('input[type="checkbox"], input[type="radio"], select')) {
68
77
  onScalarChange(target, ctx)
69
78
  }
@@ -75,11 +84,11 @@ export function bindEvents(ctx) {
75
84
  const actionEl = target.closest('[data-hcms-action]')
76
85
  if (!actionEl) return
77
86
  const action = actionEl.getAttribute('data-hcms-action')
78
- // add/remove/move must live inside the form root; close/save inside the shell.
87
+ // add/remove/move must live inside the form root; close inside the shell.
79
88
  // Ignore stray data-hcms-action attributes elsewhere on the page.
80
- if (action === 'add' || action === 'remove' || action === 'move-up' || action === 'move-down') {
89
+ if (action === 'add' || action === 'remove' || action === 'move-up' || action === 'move-down' || action === 'clear-upload') {
81
90
  if (!actionEl.closest('[data-hcms-form-root]')) return
82
- } else if (action === 'close' || action === 'save') {
91
+ } else if (action === 'close') {
83
92
  if (!actionEl.closest('[data-hcms-shell]')) return
84
93
  }
85
94
  if (action === 'add') {
@@ -90,15 +99,15 @@ export function bindEvents(ctx) {
90
99
  } else if (action === 'remove') {
91
100
  const itemEl = actionEl.closest('[data-hcms-card], [data-hcms-array-item]')
92
101
  if (!itemEl) return
93
- onRemove(itemEl, ctx)
102
+ requestRemove(itemEl, ctx)
94
103
  } else if (action === 'move-up' || action === 'move-down') {
95
104
  const itemEl = actionEl.closest('[data-hcms-card], [data-hcms-array-item]')
96
105
  if (!itemEl) return
97
106
  onMove(itemEl, action === 'move-up' ? -1 : 1, ctx)
107
+ } else if (action === 'clear-upload') {
108
+ onClearUpload(actionEl, ctx)
98
109
  } else if (action === 'close') {
99
110
  ctx.onCloseRequested?.()
100
- } else if (action === 'save') {
101
- onSave(ctx)
102
111
  }
103
112
  }
104
113
 
@@ -115,10 +124,214 @@ export function bindEvents(ctx) {
115
124
  }
116
125
  }
117
126
 
127
+ // Element PROPERTIES a form field projects to that fire NO MutationRecord, so
128
+ // hyper-undo's observer can't see them (an input's live `value`, a checkbox's
129
+ // `checked`). Text projections ARE observed (characterData) and need nothing
130
+ // here. Kept narrow on purpose: the broader engine DOM_PROPERTIES_WRITE_SET
131
+ // includes observable writes (innerHTML/outerHTML) we must NOT double-record.
132
+ const UNOBSERVED_FIELD_PROPS = new Set(['value', 'checked'])
133
+
134
+ // Resolve the page element + property a scalar field projects to, but ONLY for
135
+ // the unobserved-property case above. `pathStr` is the field's FULL path (the
136
+ // scalar field element carries data-hcms-path = full path, e.g. "name" or
137
+ // "products.0.name"). Returns { el, prop, oldValue } captured from the live page
138
+ // BEFORE the apply, or null when the projection is text, targets an array item
139
+ // (the engine's ctx isn't pageRoot then), or can't be resolved.
140
+ function resolveUnobservedProjection(ctx, pathStr) {
141
+ if (!pathStr) return null
142
+ const fullPath = pathFromString(pathStr)
143
+ // Array-item fields project under a per-item engine ctx, not pageRoot. Resolving
144
+ // pageRoot.querySelector(selector) would grab the wrong item, so skip — the edit
145
+ // simply keeps today's no-undo behavior rather than recording a wrong revert.
146
+ if (fullPath.some((seg) => typeof seg === 'number' || seg === '*')) return null
147
+ const rule = getRuleAtPath(ctx.pageRules, fullPath)
148
+ if (typeof rule !== 'string') return null
149
+ const at = rule.lastIndexOf('@')
150
+ if (at === -1) return null // text projection — observed
151
+ const prop = rule.slice(at + 1)
152
+ if (!UNOBSERVED_FIELD_PROPS.has(prop)) return null
153
+ const selector = rule.slice(0, at)
154
+ const el = selector ? ctx.pageRoot.querySelector(selector) : ctx.pageRoot
155
+ if (!el) return null
156
+ return { el, prop, oldValue: el[prop] }
157
+ }
158
+
118
159
  export function onScalarChange(target, ctx) {
119
160
  const fieldEl = target.closest('[data-hcms-field]') || target
120
161
  const pathStr = fieldEl.closest('[data-hcms-path]')?.getAttribute('data-hcms-path') || ''
162
+ // Capture the projected page value BEFORE commit() overwrites it. Property
163
+ // writes (@value/@checked) leave no MutationRecord, so without recording them
164
+ // explicitly they produce no undo step (text projections are observed).
165
+ const proj = resolveUnobservedProjection(ctx, pathStr)
121
166
  commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
167
+ if (proj) {
168
+ const u = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
169
+ if (u && typeof u.recordValue === 'function') {
170
+ u.recordValue(proj.el, { prop: proj.prop, oldValue: proj.oldValue, newValue: proj.el[proj.prop] })
171
+ }
172
+ }
173
+ }
174
+
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
+ }
223
+ }
224
+
225
+ // A file was picked in an @file/@image widget. Optionally crop, upload via the
226
+ // host's uploadFileBasic (or fall back to a local object-URL preview), write the
227
+ // resulting URL into the bound img.src / a.href leaf, then commit once. The leaf
228
+ // attribute change is an observed page mutation, so undo gets a clean step with
229
+ // no commitWithUndo. Async; failures surface inline and reset the picker.
230
+ export async function onUploadChange(inputEl, ctx) {
231
+ const file = inputEl.files && inputEl.files[0]
232
+ if (!file) return
233
+ const fieldEl = inputEl.closest('[data-hcms-path]')
234
+ if (!fieldEl) return
235
+ const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
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
+
241
+ const cropped = await maybeCrop(file, fieldEl)
242
+ if (!cropped || ctx.closed) { resetPicker(inputEl); return }
243
+ const fileLike = cropped.file
244
+ const previewDataUrl = cropped.dataURL || null
245
+
246
+ const upload = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.uploadFileBasic) || null
247
+ let url = null
248
+ if (typeof upload === 'function') {
249
+ try {
250
+ const res = await upload(fileLike)
251
+ // uploadFileBasic resolves the full envelope { uploads:[{name,nodeId,url}], … };
252
+ // the served URL is uploads[0].url, NOT res.url.
253
+ url = res && res.uploads && res.uploads[0] && res.uploads[0].url
254
+ } catch (err) {
255
+ if (ctx.closed) { resetPicker(inputEl); return }
256
+ showFieldUploadError(fieldEl, (err && err.message) || 'Upload failed')
257
+ ctx.dispatch?.('hcms:error', { error: err, path: pathStr })
258
+ resetPicker(inputEl)
259
+ return
260
+ }
261
+ }
262
+ // The editor may have been closed while the upload was in flight; bail before
263
+ // writing the (now detached) form leaf or committing to the live page.
264
+ if (ctx.closed) { resetPicker(inputEl); return }
265
+ if (!url) {
266
+ // No host uploader (or it returned nothing): local preview only, which the
267
+ // commit persists into the page as a blob:/data: URL — non-persistent, by
268
+ // design (documented), so the widget still works in a bare page / demo.
269
+ url = previewDataUrl || makeLocalPreviewUrl(fileLike)
270
+ }
271
+ if (!url) { resetPicker(inputEl); return }
272
+
273
+ showFieldUploadError(fieldEl, null)
274
+ writeUploadLeaf(fieldEl, url, fileLike.name)
275
+ commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
276
+ resetPicker(inputEl)
277
+ }
278
+
279
+ // The × on an upload widget. Clears the bound leaf to empty and commits, so the
280
+ // :has()-driven chrome falls back to the empty state. Removes immediately (no
281
+ // consent gate) per the approved design; distinct from the "remove" action,
282
+ // which deletes a whole array item.
283
+ export function onClearUpload(actionEl, ctx) {
284
+ const fieldEl = actionEl.closest('[data-hcms-path]')
285
+ if (!fieldEl) return
286
+ const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
287
+ writeUploadLeaf(fieldEl, '', '')
288
+ const input = fieldEl.querySelector('input[type="file"][data-hcms-upload]')
289
+ if (input) resetPicker(input)
290
+ showFieldUploadError(fieldEl, null)
291
+ commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
292
+ }
293
+
294
+ function findUploadLeaf(fieldEl) {
295
+ return fieldEl.querySelector
296
+ ? fieldEl.querySelector('img[data-hcms-field], a[data-hcms-field]')
297
+ : null
298
+ }
299
+
300
+ // Write a URL into the widget's bound leaf: an <img>'s src, or an <a>'s href
301
+ // plus its visible filename text. Empty url clears to the empty state.
302
+ function writeUploadLeaf(fieldEl, url, fileName) {
303
+ const leaf = findUploadLeaf(fieldEl)
304
+ if (!leaf) return
305
+ const tag = (leaf.tagName || '').toUpperCase()
306
+ if (tag === 'IMG') {
307
+ leaf.src = url || ''
308
+ } else if (tag === 'A') {
309
+ leaf.href = url || ''
310
+ leaf.textContent = url ? (fileName || fileNameFromUrl(url)) : ''
311
+ }
312
+ }
313
+
314
+ function resetPicker(inputEl) {
315
+ // Clear the selection so re-picking the same file fires `change` again.
316
+ try { inputEl.value = '' } catch { /* some inputs reject programmatic clear */ }
317
+ }
318
+
319
+ function makeLocalPreviewUrl(file) {
320
+ const U = (typeof URL !== 'undefined' && URL.createObjectURL) ? URL : null
321
+ if (!U) return ''
322
+ try { return U.createObjectURL(file) } catch { return '' }
323
+ }
324
+
325
+ function showFieldUploadError(fieldEl, message) {
326
+ const slot = fieldEl.querySelector ? fieldEl.querySelector(':scope > .hcms-error') : null
327
+ if (!slot) return
328
+ if (message) {
329
+ slot.textContent = message
330
+ slot.hidden = false
331
+ } else {
332
+ slot.textContent = ''
333
+ slot.hidden = true
334
+ }
122
335
  }
123
336
 
124
337
  export function onAdd(arrayPath, ctx) {
@@ -148,6 +361,9 @@ export function onAdd(arrayPath, ctx) {
148
361
  pathArr: [...pathArr, nextIndex],
149
362
  data: itemData,
150
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,
151
367
  })
152
368
  slot.appendChild(itemNode)
153
369
  updateArrayButtonsVisibility(arrayEl)
@@ -186,6 +402,51 @@ export function onMove(itemEl, direction, ctx) {
186
402
  )
187
403
  }
188
404
 
405
+ const DEFAULT_CONFIRM_REMOVE = 'Delete this item?'
406
+
407
+ // Resolve whether removing an item from `arrayEl` should confirm first, and with
408
+ // what prompt. Most specific wins: a per-array `data-hcms-confirm-remove`
409
+ // attribute (a string overrides the prompt; "off"/"false"/"no"/"0" disables that
410
+ // one array), then the global `confirmRemove` open() option (a string overrides
411
+ // the prompt, true forces every list, false disables everything), then the
412
+ // built-in default. The default confirms card removals (object-array items hold
413
+ // several fields) and leaves scalar-array chips instant. Returns the prompt to
414
+ // confirm with, or null to remove without asking.
415
+ function resolveRemoveConfirm(arrayEl, ctx) {
416
+ const attr = arrayEl && arrayEl.getAttribute('data-hcms-confirm-remove')
417
+ if (attr != null) {
418
+ if (/^(off|false|no|0)$/i.test(attr.trim())) return null
419
+ return attr || DEFAULT_CONFIRM_REMOVE
420
+ }
421
+ const global = ctx && ctx.confirmRemove
422
+ if (global === false) return null
423
+ if (typeof global === 'string') return global || DEFAULT_CONFIRM_REMOVE
424
+ if (global === true) return DEFAULT_CONFIRM_REMOVE
425
+ return arrayEl && arrayEl.getAttribute('data-hcms-shape') === 'object-array'
426
+ ? DEFAULT_CONFIRM_REMOVE
427
+ : null
428
+ }
429
+
430
+ // Built-in delete confirmation: every list-item removal routes through here so
431
+ // the policy applies uniformly. Confirmation uses hyperclay's consent() (the
432
+ // styled modal) when the host page provides it, falls back to native confirm(),
433
+ // and proceeds when neither exists (non-browser). Programmatic api.removeItem()
434
+ // bypasses this by calling onRemove directly, the way undo treats deliberate
435
+ // actions.
436
+ export function requestRemove(itemEl, ctx) {
437
+ const arrayEl = itemEl.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]')
438
+ const message = resolveRemoveConfirm(arrayEl, ctx)
439
+ if (message == null) return onRemove(itemEl, ctx)
440
+ const consent = typeof window !== 'undefined' && (window.hyperclay?.consent || window.consent)
441
+ if (typeof consent === 'function') {
442
+ Promise.resolve(consent(message)).then(() => onRemove(itemEl, ctx), () => {})
443
+ } else if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
444
+ if (window.confirm(message)) onRemove(itemEl, ctx)
445
+ } else {
446
+ onRemove(itemEl, ctx)
447
+ }
448
+ }
449
+
189
450
  export function onRemove(itemEl, ctx) {
190
451
  const path = itemEl.getAttribute('data-hcms-path') || ''
191
452
  const parent = itemEl.parentElement
@@ -208,12 +469,6 @@ export function onRemove(itemEl, ctx) {
208
469
  )
209
470
  }
210
471
 
211
- export function onSave(ctx) {
212
- const data = extractFormData(ctx)
213
- ctx.dispatch?.('hcms:save', { data })
214
- ctx.onSave?.(data)
215
- }
216
-
217
472
  export function commit(newData, info, ctx) {
218
473
  const fingerprint = stableStringify(newData)
219
474
  if (fingerprint === ctx.lastFingerprint) return { ok: true, skipped: true }
@@ -412,6 +667,16 @@ export function restampAllSiblings(formRoot) {
412
667
  }
413
668
 
414
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
+ : []
415
680
  let i = 0
416
681
  for (const child of parent.children) {
417
682
  if (!child.matches?.('[data-hcms-card], [data-hcms-array-item]')) continue
@@ -423,6 +688,9 @@ function restampSiblingPaths(parent) {
423
688
  if (newPath !== path) restampSubtree(child, path, newPath)
424
689
  i++
425
690
  }
691
+ for (const [r, wasChecked] of radios) {
692
+ if (r.checked !== wasChecked) r.checked = wasChecked
693
+ }
426
694
  }
427
695
 
428
696
  function restampSubtree(root, oldPrefix, newPrefix) {
@@ -436,6 +704,22 @@ function restampSubtree(root, oldPrefix, newPrefix) {
436
704
  el.setAttribute('data-hcms-path', newPrefix + p.slice(oldPrefix.length))
437
705
  }
438
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
+ }
439
723
  }
440
724
 
441
725
  function stableStringify(value) {