@panphora/hyper-cms 0.4.2 → 0.5.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.5.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 } 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,169 @@ 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. A pass-through today; Slice 6 fills the body to route the
176
+ // file through window.hyperclay.quickcrop before upload when the field opts in
177
+ // via data-hcms-crop. Returns { file, dataURL? }, or null to abort (crop
178
+ // cancelled). Keeping ALL crop coupling here means no other upload code changes
179
+ // when crop lands.
180
+ async function maybeCrop(file /*, fieldEl */) {
181
+ return { file }
182
+ }
183
+
184
+ // A file was picked in an @file/@image widget. Optionally crop, upload via the
185
+ // host's uploadFileBasic (or fall back to a local object-URL preview), write the
186
+ // resulting URL into the bound img.src / a.href leaf, then commit once. The leaf
187
+ // attribute change is an observed page mutation, so undo gets a clean step with
188
+ // no commitWithUndo. Async; failures surface inline and reset the picker.
189
+ export async function onUploadChange(inputEl, ctx) {
190
+ const file = inputEl.files && inputEl.files[0]
191
+ if (!file) return
192
+ const fieldEl = inputEl.closest('[data-hcms-path]')
193
+ if (!fieldEl) return
194
+ const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
195
+
196
+ const cropped = await maybeCrop(file, fieldEl)
197
+ if (!cropped || ctx.closed) { resetPicker(inputEl); return }
198
+ const fileLike = cropped.file
199
+ const previewDataUrl = cropped.dataURL || null
200
+
201
+ const upload = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.uploadFileBasic) || null
202
+ let url = null
203
+ if (typeof upload === 'function') {
204
+ try {
205
+ const res = await upload(fileLike)
206
+ // uploadFileBasic resolves the full envelope { uploads:[{name,nodeId,url}], … };
207
+ // the served URL is uploads[0].url, NOT res.url.
208
+ url = res && res.uploads && res.uploads[0] && res.uploads[0].url
209
+ } catch (err) {
210
+ if (ctx.closed) { resetPicker(inputEl); return }
211
+ showFieldUploadError(fieldEl, (err && err.message) || 'Upload failed')
212
+ ctx.dispatch?.('hcms:error', { error: err, path: pathStr })
213
+ resetPicker(inputEl)
214
+ return
215
+ }
216
+ }
217
+ // The editor may have been closed while the upload was in flight; bail before
218
+ // writing the (now detached) form leaf or committing to the live page.
219
+ if (ctx.closed) { resetPicker(inputEl); return }
220
+ if (!url) {
221
+ // No host uploader (or it returned nothing): local preview only, which the
222
+ // commit persists into the page as a blob:/data: URL — non-persistent, by
223
+ // design (documented), so the widget still works in a bare page / demo.
224
+ url = previewDataUrl || makeLocalPreviewUrl(fileLike)
225
+ }
226
+ if (!url) { resetPicker(inputEl); return }
227
+
228
+ showFieldUploadError(fieldEl, null)
229
+ writeUploadLeaf(fieldEl, url, fileLike.name)
230
+ commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
231
+ resetPicker(inputEl)
232
+ }
233
+
234
+ // The × on an upload widget. Clears the bound leaf to empty and commits, so the
235
+ // :has()-driven chrome falls back to the empty state. Removes immediately (no
236
+ // consent gate) per the approved design; distinct from the "remove" action,
237
+ // which deletes a whole array item.
238
+ export function onClearUpload(actionEl, ctx) {
239
+ const fieldEl = actionEl.closest('[data-hcms-path]')
240
+ if (!fieldEl) return
241
+ const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
242
+ writeUploadLeaf(fieldEl, '', '')
243
+ const input = fieldEl.querySelector('input[type="file"][data-hcms-upload]')
244
+ if (input) resetPicker(input)
245
+ showFieldUploadError(fieldEl, null)
246
+ commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
247
+ }
248
+
249
+ function findUploadLeaf(fieldEl) {
250
+ return fieldEl.querySelector
251
+ ? fieldEl.querySelector('img[data-hcms-field], a[data-hcms-field]')
252
+ : null
253
+ }
254
+
255
+ // Write a URL into the widget's bound leaf: an <img>'s src, or an <a>'s href
256
+ // plus its visible filename text. Empty url clears to the empty state.
257
+ function writeUploadLeaf(fieldEl, url, fileName) {
258
+ const leaf = findUploadLeaf(fieldEl)
259
+ if (!leaf) return
260
+ const tag = (leaf.tagName || '').toUpperCase()
261
+ if (tag === 'IMG') {
262
+ leaf.src = url || ''
263
+ } else if (tag === 'A') {
264
+ leaf.href = url || ''
265
+ leaf.textContent = url ? (fileName || fileNameFromUrl(url)) : ''
266
+ }
267
+ }
268
+
269
+ function resetPicker(inputEl) {
270
+ // Clear the selection so re-picking the same file fires `change` again.
271
+ try { inputEl.value = '' } catch { /* some inputs reject programmatic clear */ }
272
+ }
273
+
274
+ function makeLocalPreviewUrl(file) {
275
+ const U = (typeof URL !== 'undefined' && URL.createObjectURL) ? URL : null
276
+ if (!U) return ''
277
+ try { return U.createObjectURL(file) } catch { return '' }
278
+ }
279
+
280
+ function showFieldUploadError(fieldEl, message) {
281
+ const slot = fieldEl.querySelector ? fieldEl.querySelector(':scope > .hcms-error') : null
282
+ if (!slot) return
283
+ if (message) {
284
+ slot.textContent = message
285
+ slot.hidden = false
286
+ } else {
287
+ slot.textContent = ''
288
+ slot.hidden = true
289
+ }
122
290
  }
123
291
 
124
292
  export function onAdd(arrayPath, ctx) {
@@ -186,6 +354,51 @@ export function onMove(itemEl, direction, ctx) {
186
354
  )
187
355
  }
188
356
 
357
+ const DEFAULT_CONFIRM_REMOVE = 'Delete this item?'
358
+
359
+ // Resolve whether removing an item from `arrayEl` should confirm first, and with
360
+ // what prompt. Most specific wins: a per-array `data-hcms-confirm-remove`
361
+ // attribute (a string overrides the prompt; "off"/"false"/"no"/"0" disables that
362
+ // one array), then the global `confirmRemove` open() option (a string overrides
363
+ // the prompt, true forces every list, false disables everything), then the
364
+ // built-in default. The default confirms card removals (object-array items hold
365
+ // several fields) and leaves scalar-array chips instant. Returns the prompt to
366
+ // confirm with, or null to remove without asking.
367
+ function resolveRemoveConfirm(arrayEl, ctx) {
368
+ const attr = arrayEl && arrayEl.getAttribute('data-hcms-confirm-remove')
369
+ if (attr != null) {
370
+ if (/^(off|false|no|0)$/i.test(attr.trim())) return null
371
+ return attr || DEFAULT_CONFIRM_REMOVE
372
+ }
373
+ const global = ctx && ctx.confirmRemove
374
+ if (global === false) return null
375
+ if (typeof global === 'string') return global || DEFAULT_CONFIRM_REMOVE
376
+ if (global === true) return DEFAULT_CONFIRM_REMOVE
377
+ return arrayEl && arrayEl.getAttribute('data-hcms-shape') === 'object-array'
378
+ ? DEFAULT_CONFIRM_REMOVE
379
+ : null
380
+ }
381
+
382
+ // Built-in delete confirmation: every list-item removal routes through here so
383
+ // the policy applies uniformly. Confirmation uses hyperclay's consent() (the
384
+ // styled modal) when the host page provides it, falls back to native confirm(),
385
+ // and proceeds when neither exists (non-browser). Programmatic api.removeItem()
386
+ // bypasses this by calling onRemove directly, the way undo treats deliberate
387
+ // actions.
388
+ export function requestRemove(itemEl, ctx) {
389
+ const arrayEl = itemEl.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]')
390
+ const message = resolveRemoveConfirm(arrayEl, ctx)
391
+ if (message == null) return onRemove(itemEl, ctx)
392
+ const consent = typeof window !== 'undefined' && (window.hyperclay?.consent || window.consent)
393
+ if (typeof consent === 'function') {
394
+ Promise.resolve(consent(message)).then(() => onRemove(itemEl, ctx), () => {})
395
+ } else if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
396
+ if (window.confirm(message)) onRemove(itemEl, ctx)
397
+ } else {
398
+ onRemove(itemEl, ctx)
399
+ }
400
+ }
401
+
189
402
  export function onRemove(itemEl, ctx) {
190
403
  const path = itemEl.getAttribute('data-hcms-path') || ''
191
404
  const parent = itemEl.parentElement
@@ -208,12 +421,6 @@ export function onRemove(itemEl, ctx) {
208
421
  )
209
422
  }
210
423
 
211
- export function onSave(ctx) {
212
- const data = extractFormData(ctx)
213
- ctx.dispatch?.('hcms:save', { data })
214
- ctx.onSave?.(data)
215
- }
216
-
217
424
  export function commit(newData, info, ctx) {
218
425
  const fingerprint = stableStringify(newData)
219
426
  if (fingerprint === ctx.lastFingerprint) return { ok: true, skipped: true }
@@ -3,9 +3,23 @@ import {
3
3
  isInlineTemplate,
4
4
  humanize,
5
5
  shapeKindOf,
6
+ componentForScalarRule,
6
7
  } from './templates.js'
7
8
  import { fieldPropertyFor } from './form-rules.js'
8
9
 
10
+ // The visible label for a file field is the basename of its URL value; the
11
+ // bound leaf still carries the full URL in href so it round-trips byte-stable.
12
+ export function fileNameFromUrl(url) {
13
+ if (!url) return ''
14
+ const clean = String(url).split(/[?#]/)[0]
15
+ const base = clean.split('/').pop() || clean
16
+ try {
17
+ return decodeURIComponent(base)
18
+ } catch {
19
+ return base
20
+ }
21
+ }
22
+
9
23
  export function buildForm({ pageRules, formRules: _formRules, data, doc }) {
10
24
  const fragment = doc.createDocumentFragment()
11
25
  const root = buildNode(pageRules, [], data, doc)
@@ -25,15 +39,19 @@ export function buildItem({ shape, itemShape, pathArr, data, doc }) {
25
39
 
26
40
  function buildNode(rule, pathArr, data, doc) {
27
41
  const kind = shapeKindOf(rule)
28
- if (kind === 'scalar') return buildScalar(pathArr, data, doc)
42
+ if (kind === 'scalar') return buildScalar(rule, pathArr, data, doc)
29
43
  if (kind === 'object') return buildObject(rule, pathArr, data, doc)
30
44
  if (kind === 'object-array') return buildObjectArray(rule, pathArr, data, doc)
31
45
  if (kind === 'scalar-array') return buildScalarArray(rule, pathArr, data, doc)
32
46
  return null
33
47
  }
34
48
 
35
- function buildScalar(pathArr, data, doc) {
36
- const tpl = resolveTemplate(pathArr, '@scalar', doc)
49
+ function buildScalar(rule, pathArr, data, doc) {
50
+ // Opt-in upload components select a richer template (@image/@file) off the
51
+ // bound rule's @prop suffix; plain scalars stay @scalar. A path-bound
52
+ // template override still wins inside resolveTemplate.
53
+ const componentKey = componentForScalarRule(rule, doc)
54
+ const tpl = resolveTemplate(pathArr, componentKey, doc)
37
55
  if (!tpl) throw new Error(`hypercms: missing template for scalar at "${pathArr.join('.')}"`)
38
56
  const node = cloneTemplate(tpl, doc)
39
57
  stampPath(node, pathArr)
@@ -45,9 +63,20 @@ function buildScalar(pathArr, data, doc) {
45
63
  stampContainerField(node, lastKey(pathArr))
46
64
  setLabel(node, lastKey(pathArr))
47
65
  populateScalarValue(node, data)
66
+ if (componentKey === '@file') syncFileLeafText(node)
48
67
  return node
49
68
  }
50
69
 
70
+ // The @file leaf is an <a> whose value is the href; populateScalarValue sets
71
+ // href but not the visible text, so derive the filename here. Empty href leaves
72
+ // the anchor empty, which the theme's :empty placeholder dresses as "No file
73
+ // chosen". Scoped to the default chrome's .mirk-file__name so author overrides
74
+ // keep their own text.
75
+ function syncFileLeafText(node) {
76
+ const a = node.querySelector ? node.querySelector('a.mirk-file__name[data-hcms-field]') : null
77
+ if (a) a.textContent = fileNameFromUrl(a.getAttribute('href'))
78
+ }
79
+
51
80
  function buildObject(rule, pathArr, data, doc) {
52
81
  const tpl = resolveTemplate(pathArr, '@object', doc)
53
82
  if (!tpl) throw new Error(`hypercms: missing template for object at "${pathArr.join('.')}"`)
package/src/form-rules.js CHANGED
@@ -1,4 +1,4 @@
1
- import { shapeKindOf, findTemplate, isInlineTemplate } from './templates.js'
1
+ import { shapeKindOf, findTemplate, isInlineTemplate, componentForScalarRule } from './templates.js'
2
2
 
3
3
  const TAG_PROP_MAP = {
4
4
  IMG: 'src',
@@ -99,10 +99,12 @@ export function deriveFormRules(pageRules, doc) {
99
99
  const fieldKey = typeof key === 'string' ? key : '__value'
100
100
  const inlineEl = findInlineFieldEl(pathArr, fieldKey)
101
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)
102
+ // No path-bound override: resolve the component this rule opts into
103
+ // (@scalar / @image / @file) and derive the selector from THAT template's
104
+ // leaf tag, so an @image field reads img@src and an @file field reads
105
+ // a@href, while a plain scalar still respects a global @scalar override
106
+ // (e.g. a <textarea>) instead of assuming <input>.
107
+ const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc), fieldKey)
106
108
  if (shapeEl) return fieldSelectorFor(shapeEl, fieldKey)
107
109
  return `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`
108
110
  }
@@ -1,7 +1,10 @@
1
1
  import * as core from './hypercms.js'
2
- import stylesText from './styles.css'
2
+ import themeText from './theme.generated.css'
3
+ // Install mirk's delegated component runtime (tags chips, number stepper, etc.)
4
+ // as a side effect. Idempotent + document-delegated, so safe to include once.
5
+ import './vendor/mirk.vendor.js'
3
6
 
4
- core.installStyles(stylesText)
7
+ core.installStyles(themeText)
5
8
 
6
9
  export const cms = core.cms
7
10
  export default { cms: core.cms }