@panphora/hyper-cms 0.4.1 → 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/README.md +26 -4
- package/dist/hypercms.min.js +1040 -220
- package/package.json +5 -3
- package/src/events.js +221 -14
- package/src/form-builder.js +32 -3
- package/src/form-rules.js +7 -5
- package/src/hypercms-bundle.js +5 -2
- package/src/hypercms.js +249 -4
- package/src/refresh.js +2 -1
- package/src/shell.js +103 -16
- package/src/templates.js +165 -24
- package/src/theme/pixel-quiet.overrides.css +503 -0
- package/src/theme.generated.css +1074 -0
- package/src/vendor/mirk.vendor.js +197 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/hyper-cms",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
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": "
|
|
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
|
|
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'
|
|
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
|
-
|
|
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 }
|
package/src/form-builder.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
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
|
}
|
package/src/hypercms-bundle.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import * as core from './hypercms.js'
|
|
2
|
-
import
|
|
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(
|
|
7
|
+
core.installStyles(themeText)
|
|
5
8
|
|
|
6
9
|
export const cms = core.cms
|
|
7
10
|
export default { cms: core.cms }
|