@panphora/hyper-cms 0.5.0 → 0.6.1

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.5.0",
3
+ "version": "0.6.1",
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",
package/src/enhance.js ADDED
@@ -0,0 +1,97 @@
1
+ // Post-mount field enhancements + the rich-text rule upgrade.
2
+ //
3
+ // Bare scalar rules bind textContent, which flattens inline HTML (links,
4
+ // bold, code) on extract and destroys it on the first apply. When the bound
5
+ // page element actually contains child elements, the rule is upgraded to
6
+ // @innerHTML so the round-trip preserves markup and the form renders the
7
+ // @richtext component (a contenteditable surface, richclay-powered when the
8
+ // host page ships richclay). Explicit @prop rules, array rules, and rules
9
+ // whose element holds only text are left untouched. Hosts opt out with
10
+ // cms.open({ richText: false }).
11
+
12
+ export function upgradeRichTextRules(rules, pageRoot) {
13
+ if (!pageRoot || rules == null) return rules
14
+ return walk(rules)
15
+
16
+ function walk(rule) {
17
+ if (typeof rule === 'string') {
18
+ if (rule.endsWith('[]')) return rule
19
+ if (rule.lastIndexOf('@') >= 0) return rule
20
+ let el = null
21
+ try {
22
+ el = pageRoot.querySelector(rule)
23
+ } catch (_) {
24
+ return rule
25
+ }
26
+ return el && el.children.length > 0 ? rule + '@innerHTML' : rule
27
+ }
28
+ // Array rules ([itemSelector, shape]) scope their inner selectors per
29
+ // item; a document-level querySelector can't answer "does THIS item's
30
+ // element hold markup", so array subtrees are never upgraded.
31
+ if (Array.isArray(rule)) return rule
32
+ if (rule && typeof rule === 'object') {
33
+ const out = Object.create(null)
34
+ for (const [k, v] of Object.entries(rule)) out[k] = walk(v)
35
+ return out
36
+ }
37
+ return rule
38
+ }
39
+ }
40
+
41
+ // Grow a form textarea to fit its content. Browsers with `field-sizing:
42
+ // content` handle this in CSS (the theme sets it); everywhere else the
43
+ // height is pinned to scrollHeight on populate and on every input.
44
+ export function autosizeTextarea(el) {
45
+ if (!el || el.tagName !== 'TEXTAREA') return
46
+ const win = el.ownerDocument.defaultView || (typeof window !== 'undefined' ? window : null)
47
+ if (win && win.CSS && win.CSS.supports && win.CSS.supports('field-sizing: content')) return
48
+ el.style.height = 'auto'
49
+ el.style.height = el.scrollHeight + 'px'
50
+ }
51
+
52
+ // Runs after any form (or form fragment) lands in the live DOM: sizes the
53
+ // autosize fallback and mounts richclay on rich-text surfaces when the host
54
+ // page ships it (window.richclay / window.hyperclay.RichClay). Without
55
+ // richclay the surface stays a plain contenteditable bound via @innerHTML,
56
+ // which still round-trips markup — richclay only adds the editing chrome.
57
+ export function enhanceFields(root, doc) {
58
+ if (!root || !root.querySelectorAll) return
59
+ root.querySelectorAll('textarea[data-hcms-field]').forEach(autosizeTextarea)
60
+
61
+ const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null)
62
+ const RichClay =
63
+ (win && win.richclay && win.richclay.RichClay) ||
64
+ (win && win.hyperclay && win.hyperclay.RichClay) ||
65
+ (win && typeof win.RichClay === 'function' ? win.RichClay : null)
66
+ if (!RichClay) return
67
+
68
+ root.querySelectorAll('[contenteditable][data-hcms-field]').forEach((el) => {
69
+ if (el.__hcmsRichclay) return
70
+ let editor
71
+ try {
72
+ // hyperclay:false keeps the form field out of the page's save/undo
73
+ // machinery (page writes go through commit) and activates the editor
74
+ // regardless of the host's edit-mode signal.
75
+ editor = new RichClay(el, {
76
+ inline: true,
77
+ hyperclay: false,
78
+ toolbar: ['bold', 'italic', 'link', 'undo', 'redo'],
79
+ })
80
+ } catch (err) {
81
+ console.warn('[hypercms] richclay activation failed; field stays plain contenteditable', err)
82
+ return
83
+ }
84
+ el.__hcmsRichclay = editor
85
+ // Toolbar commands mutate the DOM through squire, which doesn't always
86
+ // fire a native input event; mirror squire's own input signal so the
87
+ // form's delegated listener commits the change. Duplicate events are
88
+ // harmless — commits are fingerprinted.
89
+ const squire = editor && editor.squire
90
+ if (squire && typeof squire.addEventListener === 'function') {
91
+ squire.addEventListener('input', () => {
92
+ const Ev = (win && win.Event) || Event
93
+ el.dispatchEvent(new Ev('input', { bubbles: true }))
94
+ })
95
+ }
96
+ })
97
+ }
package/src/events.js CHANGED
@@ -1,8 +1,9 @@
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
+ import { autosizeTextarea, enhanceFields } from './enhance.js'
6
7
 
7
8
  const BOUND = new WeakSet()
8
9
 
@@ -55,7 +56,8 @@ export function bindEvents(ctx) {
55
56
  const target = e.target
56
57
  if (!target || !target.closest) return
57
58
  if (!target.closest('[data-hcms-form-root]')) return
58
- if (!target.matches('input, textarea, select')) return
59
+ if (!target.matches('input, textarea, select, [contenteditable][data-hcms-field]')) return
60
+ if (target.tagName === 'TEXTAREA') autosizeTextarea(target)
59
61
  // The upload picker fires `input` too, but its .value is the OS fake path
60
62
  // (C:\fakepath\…); it's handled on `change` via onUploadChange. The wrapper
61
63
  // carries data-hcms-field, so without this guard the closest() below would
@@ -172,13 +174,54 @@ export function onScalarChange(target, ctx) {
172
174
  }
173
175
  }
174
176
 
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 }
177
+ // Crop adapter seam: ALL crop coupling lives here, between the file pick and
178
+ // the upload. A field opts in via data-hcms-crop on the page element (copied
179
+ // onto the built @image field by the form-builder); absent attr or absent
180
+ // quickcrop (standalone page) uploads raw. Returns { file, dataURL? }, or null
181
+ // to abort (crop cancelled / failed) — the caller resets the picker and skips
182
+ // the commit. quickcrop resolves null on cancel (it does NOT reject), so the
183
+ // cancel branch is the null check, not the catch.
184
+ const CROP_OUTPUT = { type: 'image/webp', quality: 0.85, maxWidth: 2048, maxHeight: 2048 }
185
+
186
+ async function maybeCrop(file, fieldEl) {
187
+ const attr = fieldEl && fieldEl.getAttribute ? fieldEl.getAttribute('data-hcms-crop') : null
188
+ if (attr == null) return { file }
189
+ const crop = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.quickcrop) || null
190
+ if (typeof crop !== 'function') return { file }
191
+ try {
192
+ const r = await crop(file, { aspect: parseCropAspect(attr), ...CROP_OUTPUT })
193
+ if (r === null) return null
194
+ return { file: blobToFile(r.blob, file.name), dataURL: r.dataURL }
195
+ } catch (err) {
196
+ showFieldUploadError(fieldEl, (err && err.message) || 'Crop failed')
197
+ return null
198
+ }
199
+ }
200
+
201
+ // "1:1" → 1, "16:9" → 16/9, "free" / "" / unparseable → null (freeform crop).
202
+ // Exported for tests.
203
+ export function parseCropAspect(value) {
204
+ const v = String(value ?? '').trim().toLowerCase()
205
+ if (v === '' || v === 'free') return null
206
+ const m = v.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/)
207
+ if (!m) return null
208
+ const w = parseFloat(m[1])
209
+ const h = parseFloat(m[2])
210
+ if (!w || !h) return null
211
+ return w / h
212
+ }
213
+
214
+ // quickcrop returns a nameless Blob; uploadFileBasic rejects raw Blobs and the
215
+ // server keys content-type off the filename extension, so wrap it as a File
216
+ // whose extension matches the encoded mime. Exported for tests.
217
+ export function blobToFile(blob, originalName) {
218
+ const ext = blob.type === 'image/webp' ? '.webp' : blob.type === 'image/jpeg' ? '.jpg' : '.png'
219
+ const base = String(originalName || 'image').replace(/\.[^.]+$/, '')
220
+ try {
221
+ return new File([blob], base + ext, { type: blob.type })
222
+ } catch {
223
+ return blob
224
+ }
182
225
  }
183
226
 
184
227
  // A file was picked in an @file/@image widget. Optionally crop, upload via the
@@ -193,6 +236,10 @@ export async function onUploadChange(inputEl, ctx) {
193
236
  if (!fieldEl) return
194
237
  const pathStr = fieldEl.getAttribute('data-hcms-path') || ''
195
238
 
239
+ // A fresh pick is a fresh attempt: clear any stale inline error (e.g. a
240
+ // crop failure from a previous attempt) before running the new flow.
241
+ showFieldUploadError(fieldEl, null)
242
+
196
243
  const cropped = await maybeCrop(file, fieldEl)
197
244
  if (!cropped || ctx.closed) { resetPicker(inputEl); return }
198
245
  const fileLike = cropped.file
@@ -316,8 +363,12 @@ export function onAdd(arrayPath, ctx) {
316
363
  pathArr: [...pathArr, nextIndex],
317
364
  data: itemData,
318
365
  doc: ctx.doc,
366
+ // Component arrays (chips) record their item-template key at build time so
367
+ // added items keep the same chrome.
368
+ itemKey: arrayEl.getAttribute('data-hcms-item-tpl') || null,
319
369
  })
320
370
  slot.appendChild(itemNode)
371
+ enhanceFields(itemNode, ctx.doc)
321
372
  updateArrayButtonsVisibility(arrayEl)
322
373
  return commitWithUndo(`Add ${arrayPath}`, () =>
323
374
  commit(extractFormData(ctx), { path: arrayPath, structural: true }, ctx)
@@ -619,6 +670,16 @@ export function restampAllSiblings(formRoot) {
619
670
  }
620
671
 
621
672
  function restampSiblingPaths(parent) {
673
+ // Radio names are rewritten one item at a time below, so during a move or
674
+ // drag a just-renamed radio can transiently share a name with a
675
+ // not-yet-renamed sibling — and the browser enforces single-selection per
676
+ // group AT THAT INSTANT, permanently unchecking one of them (null extract →
677
+ // the whole-form commit clobbers that field's page value). Snapshot every
678
+ // radio's checked state and re-assert it once all names are final (and
679
+ // unique per field) again.
680
+ const radios = parent.querySelectorAll
681
+ ? Array.from(parent.querySelectorAll('input[type="radio"][data-hcms-field]'), (r) => [r, r.checked])
682
+ : []
622
683
  let i = 0
623
684
  for (const child of parent.children) {
624
685
  if (!child.matches?.('[data-hcms-card], [data-hcms-array-item]')) continue
@@ -630,6 +691,9 @@ function restampSiblingPaths(parent) {
630
691
  if (newPath !== path) restampSubtree(child, path, newPath)
631
692
  i++
632
693
  }
694
+ for (const [r, wasChecked] of radios) {
695
+ if (r.checked !== wasChecked) r.checked = wasChecked
696
+ }
633
697
  }
634
698
 
635
699
  function restampSubtree(root, oldPrefix, newPrefix) {
@@ -643,6 +707,22 @@ function restampSubtree(root, oldPrefix, newPrefix) {
643
707
  el.setAttribute('data-hcms-path', newPrefix + p.slice(oldPrefix.length))
644
708
  }
645
709
  }
710
+ syncRadioGroupNames(root)
711
+ }
712
+
713
+ // Radio group names derive from the field path at build time (populateOptions),
714
+ // so a restamp that moves paths must recompute them. Otherwise a removed-then-
715
+ // re-added sibling gets a fresh name that collides with this item's stale one:
716
+ // the two fields share a browser radio group, checking one unchecks the other,
717
+ // and the whole-form commit clobbers the unchecked field's page value. Only
718
+ // names the builder minted ('hcms-' prefix) are touched — radios in inline or
719
+ // custom templates keep whatever name the author gave them.
720
+ function syncRadioGroupNames(root) {
721
+ for (const input of root.querySelectorAll('input[type="radio"][data-hcms-field]')) {
722
+ if (!input.name || !input.name.startsWith('hcms-')) continue
723
+ const host = input.closest('[data-hcms-path]')
724
+ if (host) input.name = radioGroupName(host.getAttribute('data-hcms-path'))
725
+ }
646
726
  }
647
727
 
648
728
  function stableStringify(value) {
@@ -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 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)
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
- const tpl = resolveTemplate(pathArr, '@scalar-array', doc)
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 resolveTemplate(pathArr, defaultKey, doc) {
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
- (pathStr && findTemplate(doc, pathStr)) ||
188
- (wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
189
- findTemplate(doc, defaultKey)
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 { shapeKindOf, findTemplate, isInlineTemplate, componentForScalarRule } from './templates.js'
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',
@@ -15,6 +21,8 @@ export function fieldPropertyFor(el) {
15
21
  }
16
22
  if (tag === 'TEXTAREA' || tag === 'SELECT') return 'value'
17
23
  if (TAG_PROP_MAP[tag]) return TAG_PROP_MAP[tag]
24
+ // Rich-text surfaces (the @richtext component): the value IS the markup.
25
+ if (el.hasAttribute && el.hasAttribute('contenteditable')) return 'innerHTML'
18
26
  return null
19
27
  }
20
28
 
@@ -79,7 +87,7 @@ export function deriveFormRules(pageRules, doc) {
79
87
  function derive(rule, pathArr) {
80
88
  const kind = shapeKindOf(rule)
81
89
  if (kind === 'scalar') return scalarRule(rule, pathArr)
82
- if (kind === 'scalar-array') return scalarArrayRule(pathArr)
90
+ if (kind === 'scalar-array') return scalarArrayRule(rule, pathArr)
83
91
  if (kind === 'object-array') return objectArrayRule(rule, pathArr)
84
92
  if (kind === 'object') {
85
93
  const out = Object.create(null)
@@ -104,18 +112,24 @@ export function deriveFormRules(pageRules, doc) {
104
112
  // leaf tag, so an @image field reads img@src and an @file field reads
105
113
  // a@href, while a plain scalar still respects a global @scalar override
106
114
  // (e.g. a <textarea>) instead of assuming <input>.
107
- const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc), fieldKey)
115
+ const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc, pathArr), fieldKey)
108
116
  if (shapeEl) return fieldSelectorFor(shapeEl, fieldKey)
109
117
  return `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`
110
118
  }
111
119
 
112
- function scalarArrayRule(pathArr) {
120
+ function scalarArrayRule(rule, pathArr) {
113
121
  // The engine's "selector[]" form is text-only; for an editable form we need
114
122
  // per-item @value reads, so emit the [selector, shape] form with a scalar
115
123
  // shape that targets the inner field's value property. If the site
116
124
  // overrides @scalar-array-item with a non-<input> leaf (textarea, etc.),
117
- // derive the inner-leaf selector from the override.
118
- const shapeEl = findShapeFieldEl('@scalar-array-item', null)
125
+ // derive the inner-leaf selector from the override. A component array
126
+ // (chips) derives from its own item template first — but only when the
127
+ // chips template actually wins at this path (winningScalarArrayComponent
128
+ // is the same verdict buildScalarArray uses, so the derived item selector
129
+ // always matches the rendered item leaf).
130
+ const comp = winningScalarArrayComponent(rule, pathArr, doc)
131
+ const shapeEl =
132
+ (comp && findShapeFieldEl(comp.item, null)) || findShapeFieldEl('@scalar-array-item', null)
119
133
  const itemSel = shapeEl ? keylessFieldSelectorFor(shapeEl) : 'input[data-hcms-field]@value'
120
134
  return [itemSelectorFor(pathArr, '[data-hcms-array-item]'), itemSel]
121
135
  }
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, injectUploadComponents } from './templates.js'
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 {
@@ -20,6 +20,8 @@ import {
20
20
  import { mountShell, setShellStyles, markStylesBundled } from './shell.js'
21
21
  import { refreshForm, installObserver } from './refresh.js'
22
22
  import { warnUnmatchedTemplates } from './diagnostics.js'
23
+ import { enhanceFields, upgradeRichTextRules } from './enhance.js'
24
+ import { maybeInjectToggle } from './toggle.js'
23
25
 
24
26
  export function installStyles(text) {
25
27
  setShellStyles(text)
@@ -100,11 +102,16 @@ export function open(opts = {}) {
100
102
  const what = typeof source === 'string' ? `data-rules-name~="${source}"` : 'the provided rules object'
101
103
  throw new Error(`hypercms: no rules found for ${what}`)
102
104
  }
103
- const pageRules = found.rules
105
+ // Rich-text upgrade (default on): bare scalar rules whose page element
106
+ // contains child elements are rebound through @innerHTML so links and
107
+ // inline formatting survive the round-trip, and their form fields render
108
+ // the @richtext component. Opt out with cms.open({ richText: false }).
109
+ const richText = opts.richText !== false
110
+ const pageRules = richText ? upgradeRichTextRules(found.rules, pageRoot) : found.rules
104
111
  const rulesTagNode = found.tagNode
105
112
 
106
113
  injectDefaults(doc)
107
- injectUploadComponents(doc, pageRules)
114
+ injectComponents(doc, pageRules)
108
115
  warnUnmatchedTemplates(doc, pageRules)
109
116
  const formRules = deriveFormRules(pageRules, doc)
110
117
  const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
@@ -130,6 +137,7 @@ export function open(opts = {}) {
130
137
  formRules,
131
138
  rulesTagNode,
132
139
  rulesSource: source,
140
+ richText,
133
141
  formRoot: shell.formRoot,
134
142
  shellRoot: shell.root,
135
143
  errorEl: shell.errorEl,
@@ -165,6 +173,7 @@ export function open(opts = {}) {
165
173
  try {
166
174
  const fragment = buildForm({ pageRules, formRules, data, doc })
167
175
  shell.formRoot.appendChild(fragment)
176
+ enhanceFields(shell.formRoot, doc)
168
177
 
169
178
  bindEvents(ctx)
170
179
  ctx.updateFingerprint()
@@ -455,6 +464,10 @@ function writeFieldValue(el, value, formRoot, path) {
455
464
  el.href = value == null ? '' : String(value)
456
465
  return
457
466
  }
467
+ if (el.hasAttribute && el.hasAttribute('contenteditable')) {
468
+ el.innerHTML = value == null ? '' : String(value)
469
+ return
470
+ }
458
471
  if ('value' in el) {
459
472
  el.value = value == null ? '' : String(value)
460
473
  return
@@ -511,7 +524,13 @@ function autoOpenReady() {
511
524
  // deadline is hit (warn, never throw).
512
525
  function whenReady(fn) {
513
526
  if (autoOpenReady()) {
514
- fn()
527
+ // Defer one microtask. On the bundled entry (hypercms-bundle.js), installStyles()
528
+ // runs AFTER this module evaluates, so firing open() synchronously here would
529
+ // mount the shell with cssText still empty (an unstyled sidebar). A microtask
530
+ // runs only after the synchronous bundle evaluation completes, by which point
531
+ // styles are installed. The event/backstop paths below already fire post-eval,
532
+ // so only this fast path needed deferring.
533
+ queueMicrotask(fn)
515
534
  return
516
535
  }
517
536
  const deadline = Date.now() + AUTO_OPEN_DEADLINE_MS
@@ -560,6 +579,14 @@ function whenReady(fn) {
560
579
  // window, and the guards above no-op there).
561
580
  maybeAutoOpen()
562
581
 
582
+ // Floating edit-mode toggle at module load (same browser-only no-op in node).
583
+ maybeInjectToggle({
584
+ open,
585
+ close,
586
+ isOpen,
587
+ hasRules: (doc) => !!engine.findRules(doc, 'cms'),
588
+ })
589
+
563
590
  const cms = {
564
591
  open,
565
592
  close,