@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/README.md +124 -6
- package/dist/hypercms.min.js +607 -50
- package/package.json +1 -1
- package/src/enhance.js +97 -0
- package/src/events.js +89 -9
- package/src/form-builder.js +143 -20
- package/src/form-rules.js +20 -6
- package/src/hypercms.js +31 -4
- package/src/refresh.js +10 -3
- package/src/shell.js +13 -2
- package/src/templates.js +310 -30
- package/src/theme/pixel-quiet.overrides.css +35 -1
- package/src/theme.generated.css +509 -35
- package/src/toggle.js +88 -0
- package/src/vendor/mirk.vendor.js +17 -2
package/package.json
CHANGED
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
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
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) {
|
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',
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|