@panphora/hyper-cms 0.7.1 → 0.8.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 +5 -1
- package/dist/hypercms.min.js +55 -13
- package/package.json +3 -3
- package/src/events.js +149 -34
- package/src/form-builder.js +25 -20
- package/src/form-rules.js +1 -1
- package/src/hypercms.js +4 -0
- package/src/platform.js +58 -7
- package/src/templates.js +68 -18
- package/src/theme/pixel-quiet.overrides.css +42 -0
- package/src/theme.generated.css +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/hyper-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"test:all": "npm run test:node && npm run test:browser"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"hyper-html-api": "^0.6.
|
|
48
|
-
"hyper-morph": "^0.
|
|
47
|
+
"hyper-html-api": "^0.6.4",
|
|
48
|
+
"hyper-morph": "^0.5.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@open-wc/testing": "^4.0.0",
|
package/src/events.js
CHANGED
|
@@ -190,7 +190,12 @@ async function maybeCrop(file, fieldEl) {
|
|
|
190
190
|
const crop = platform('quickcrop')
|
|
191
191
|
if (typeof crop !== 'function') return { file }
|
|
192
192
|
try {
|
|
193
|
-
|
|
193
|
+
// QuickCrop's `auto` modal adapter looks only at window.themodal. clayjs
|
|
194
|
+
// attaches its dialog as clay.modal and deliberately sets no global, so a
|
|
195
|
+
// clayjs page would silently get QuickCrop's own plain dialog. Naming it here
|
|
196
|
+
// keeps QuickCrop free of client knowledge and clayjs free of the global.
|
|
197
|
+
const modal = (typeof window !== 'undefined' && (window.clay?.modal ?? window.themodal)) || 'auto'
|
|
198
|
+
const r = await crop(file, { aspect: parseCropAspect(attr), modal, ...CROP_OUTPUT })
|
|
194
199
|
if (r === null) return null
|
|
195
200
|
return { file: blobToFile(r.blob, file.name), dataURL: r.dataURL }
|
|
196
201
|
} catch (err) {
|
|
@@ -212,8 +217,8 @@ export function parseCropAspect(value) {
|
|
|
212
217
|
return w / h
|
|
213
218
|
}
|
|
214
219
|
|
|
215
|
-
// quickcrop returns a nameless Blob;
|
|
216
|
-
//
|
|
220
|
+
// quickcrop returns a nameless Blob; the legacy uploader rejects raw Blobs and a
|
|
221
|
+
// host keys content-type off the filename extension, so wrap it as a File
|
|
217
222
|
// whose extension matches the encoded mime. Exported for tests.
|
|
218
223
|
export function blobToFile(blob, originalName) {
|
|
219
224
|
const ext = blob.type === 'image/webp' ? '.webp' : blob.type === 'image/jpeg' ? '.jpg' : '.png'
|
|
@@ -225,11 +230,25 @@ export function blobToFile(blob, originalName) {
|
|
|
225
230
|
}
|
|
226
231
|
}
|
|
227
232
|
|
|
228
|
-
// A file was picked in an @file/@image widget. Optionally crop,
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
+
// A file was picked in an @file/@image widget. Optionally crop, ask the host to
|
|
234
|
+
// store the file, write the resulting URL into the bound img.src / a.href leaf,
|
|
235
|
+
// then commit once. The leaf attribute change is an observed page mutation, so
|
|
236
|
+
// undo gets a clean step with no commitWithUndo. Async; failures surface inline
|
|
237
|
+
// and reset the picker.
|
|
238
|
+
//
|
|
239
|
+
// The upload either stores the file or it does not, and those are not the same
|
|
240
|
+
// as success and failure. Three outcomes, deliberately spelled out:
|
|
241
|
+
//
|
|
242
|
+
// stored write the served URL. The page carries a reference.
|
|
243
|
+
// cannot store here embed the bytes. A document open from a plain file server
|
|
244
|
+
// or a desktop app has nowhere to put a file, and embedding
|
|
245
|
+
// is the RIGHT answer there, not a consolation prize.
|
|
246
|
+
// failed write nothing. Embedding a file the host refused for size
|
|
247
|
+
// or type is the disease this capability exists to cure: it
|
|
248
|
+
// is how a 2 MB photo ends up in the document, in every
|
|
249
|
+
// future save, and in every stored version.
|
|
250
|
+
const EMBED_CODES = new Set(['unsupported', 'payment-required'])
|
|
251
|
+
|
|
233
252
|
export async function onUploadChange(inputEl, ctx) {
|
|
234
253
|
const file = inputEl.files && inputEl.files[0]
|
|
235
254
|
if (!file) return
|
|
@@ -246,39 +265,112 @@ export async function onUploadChange(inputEl, ctx) {
|
|
|
246
265
|
const fileLike = cropped.file
|
|
247
266
|
const previewDataUrl = cropped.dataURL || null
|
|
248
267
|
|
|
249
|
-
const upload = platform('
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
const upload = platform('upload')
|
|
269
|
+
if (typeof upload !== 'function') {
|
|
270
|
+
// No client at all: a bare HTML file with the CMS dropped in. Same outcome as
|
|
271
|
+
// a host that answers `unsupported`, and silent for the same reason.
|
|
272
|
+
return finishUpload(inputEl, fieldEl, ctx, pathStr, await embedUrl(fileLike, previewDataUrl, fieldEl), fileLike)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The crop, shown immediately on the thumb's own chrome and NOT on the bound
|
|
276
|
+
// leaf. Writing a two-megabyte data URL to the leaf makes it the field's value,
|
|
277
|
+
// and any commit() landing while the upload is in flight would project it
|
|
278
|
+
// straight into the live page.
|
|
279
|
+
showFieldUploadPreview(fieldEl, previewDataUrl)
|
|
280
|
+
setUploadProgress(fieldEl, 0)
|
|
281
|
+
|
|
282
|
+
const controller = beginUpload(ctx)
|
|
283
|
+
let res
|
|
284
|
+
try {
|
|
285
|
+
res = await upload(fileLike, {
|
|
286
|
+
signal: controller?.signal,
|
|
287
|
+
onProgress: ({ percent }) => setUploadProgress(fieldEl, percent),
|
|
288
|
+
})
|
|
289
|
+
} finally {
|
|
290
|
+
endUpload(ctx, controller)
|
|
291
|
+
clearUploadProgress(fieldEl)
|
|
264
292
|
}
|
|
293
|
+
|
|
265
294
|
// The editor may have been closed while the upload was in flight; bail before
|
|
266
295
|
// writing the (now detached) form leaf or committing to the live page.
|
|
267
296
|
if (ctx.closed) { resetPicker(inputEl); return }
|
|
268
|
-
if (
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
url = previewDataUrl || makeLocalPreviewUrl(fileLike)
|
|
297
|
+
if (res.code === 'aborted') { resetPicker(inputEl); return }
|
|
298
|
+
|
|
299
|
+
if (res.ok) {
|
|
300
|
+
return finishUpload(inputEl, fieldEl, ctx, pathStr, res.uploads[0].url, fileLike)
|
|
273
301
|
}
|
|
274
|
-
if (!url) { resetPicker(inputEl); return }
|
|
275
302
|
|
|
303
|
+
if (!EMBED_CODES.has(res.code)) {
|
|
304
|
+
showFieldUploadError(fieldEl, res.msg || 'Upload failed')
|
|
305
|
+
ctx.dispatch?.('hcms:error', { error: new Error(res.msg || 'Upload failed'), code: res.code, path: pathStr })
|
|
306
|
+
resetPicker(inputEl)
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Embedded, and only `payment-required` says so. A person editing a file on
|
|
311
|
+
// their own desktop does not need to be told their file is a file; a person on
|
|
312
|
+
// a host that WOULD store it, for money, is being told why it did not.
|
|
313
|
+
const note = res.code === 'payment-required'
|
|
314
|
+
? 'This file is stored in the page. Add a paid plan to upload files.'
|
|
315
|
+
: null
|
|
316
|
+
return finishUpload(inputEl, fieldEl, ctx, pathStr, await embedUrl(fileLike, previewDataUrl, fieldEl), fileLike, note)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Write the URL into the leaf and commit once. `url` empty (no FileReader, an
|
|
320
|
+
// unreadable file) means there is nothing to write, and writing '' would clear a
|
|
321
|
+
// picture the person already had.
|
|
322
|
+
//
|
|
323
|
+
// The note goes on AFTER the commit, never before: a successful commit calls
|
|
324
|
+
// setError(ctx, null), which clears every inline slot in the form. Writing the
|
|
325
|
+
// note first leaves the person with an embedded file and no explanation of why.
|
|
326
|
+
function finishUpload(inputEl, fieldEl, ctx, pathStr, url, fileLike, note = null) {
|
|
327
|
+
if (ctx.closed) { resetPicker(inputEl); return }
|
|
328
|
+
showFieldUploadPreview(fieldEl, null)
|
|
276
329
|
showFieldUploadError(fieldEl, null)
|
|
330
|
+
if (!url) { resetPicker(inputEl); return }
|
|
277
331
|
writeUploadLeaf(fieldEl, url, fileLike.name)
|
|
278
332
|
commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
|
|
333
|
+
if (note) showFieldUploadError(fieldEl, note, 'info')
|
|
279
334
|
resetPicker(inputEl)
|
|
280
335
|
}
|
|
281
336
|
|
|
337
|
+
// The crop when there was one, otherwise the file's own bytes. Always a data:
|
|
338
|
+
// URL and never blob:, so both paths degrade identically: a blob: URL is dead the
|
|
339
|
+
// moment the page reloads, so the no-crop path used to commit a picture that
|
|
340
|
+
// looked right until the person came back to it.
|
|
341
|
+
async function embedUrl(fileLike, previewDataUrl, fieldEl) {
|
|
342
|
+
return previewDataUrl || (await fileToDataUrl(fileLike, fieldEl))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// The FileReader comes from the field's own realm, never from globalThis: a form
|
|
346
|
+
// mounted into another document reads a File belonging to THAT window, and a
|
|
347
|
+
// reader from a different realm rejects it.
|
|
348
|
+
function fileToDataUrl(file, fieldEl) {
|
|
349
|
+
const Reader = fieldEl?.ownerDocument?.defaultView?.FileReader || globalThis.FileReader
|
|
350
|
+
if (!Reader) return Promise.resolve('')
|
|
351
|
+
return new Promise((resolve) => {
|
|
352
|
+
const reader = new Reader()
|
|
353
|
+
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '')
|
|
354
|
+
reader.onerror = () => resolve('')
|
|
355
|
+
try { reader.readAsDataURL(file) } catch { resolve('') }
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// In-flight uploads hang off the ctx, so close() tears them down the same way it
|
|
360
|
+
// tears down the observer and the undo subscription. Without this the request
|
|
361
|
+
// keeps running after the editor is gone: harmless to the page, because every
|
|
362
|
+
// write is behind a ctx.closed check, but it holds the connection and the bytes.
|
|
363
|
+
function beginUpload(ctx) {
|
|
364
|
+
if (typeof AbortController !== 'function') return null
|
|
365
|
+
const controller = new AbortController()
|
|
366
|
+
;(ctx.uploads || (ctx.uploads = new Set())).add(controller)
|
|
367
|
+
return controller
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function endUpload(ctx, controller) {
|
|
371
|
+
if (controller) ctx.uploads?.delete(controller)
|
|
372
|
+
}
|
|
373
|
+
|
|
282
374
|
// The × on an upload widget. Clears the bound leaf to empty and commits, so the
|
|
283
375
|
// :has()-driven chrome falls back to the empty state. Removes immediately (no
|
|
284
376
|
// consent gate) per the approved design; distinct from the "remove" action,
|
|
@@ -319,15 +411,37 @@ function resetPicker(inputEl) {
|
|
|
319
411
|
try { inputEl.value = '' } catch { /* some inputs reject programmatic clear */ }
|
|
320
412
|
}
|
|
321
413
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
414
|
+
// The at-upload-time picture, painted on the frame rather than assigned to the
|
|
415
|
+
// leaf, because the leaf IS the field's value. CSS reveals the thumb while
|
|
416
|
+
// [data-hcms-uploading] is set, so the preview shows even on a field that is
|
|
417
|
+
// still empty.
|
|
418
|
+
function showFieldUploadPreview(fieldEl, dataUrl) {
|
|
419
|
+
const frame = fieldEl.querySelector ? fieldEl.querySelector('.mirk-image__frame') : null
|
|
420
|
+
if (!frame) return
|
|
421
|
+
if (dataUrl) frame.style.backgroundImage = `url("${dataUrl.replace(/"/g, '%22')}")`
|
|
422
|
+
else frame.style.removeProperty('background-image')
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Progress where the person is looking. The percent drives a CSS custom property
|
|
426
|
+
// rather than any markup of its own, so the templates keep their exact shape and
|
|
427
|
+
// a half-finished upload cannot leave an orphan node behind in the form.
|
|
428
|
+
function setUploadProgress(fieldEl, percent) {
|
|
429
|
+
const pct = Math.max(0, Math.min(100, Number(percent) || 0))
|
|
430
|
+
fieldEl.setAttribute('data-hcms-uploading', '')
|
|
431
|
+
fieldEl.style?.setProperty?.('--hcms-upload-progress', `${pct}%`)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function clearUploadProgress(fieldEl) {
|
|
435
|
+
fieldEl.removeAttribute('data-hcms-uploading')
|
|
436
|
+
fieldEl.style?.removeProperty?.('--hcms-upload-progress')
|
|
326
437
|
}
|
|
327
438
|
|
|
328
|
-
|
|
439
|
+
// `kind` distinguishes a refusal from a note: 'info' is the file that WAS stored,
|
|
440
|
+
// in the page, and the slot says why rather than shouting about a failure.
|
|
441
|
+
function showFieldUploadError(fieldEl, message, kind = 'error') {
|
|
329
442
|
const slot = fieldEl.querySelector ? fieldEl.querySelector(':scope > .hcms-error') : null
|
|
330
443
|
if (!slot) return
|
|
444
|
+
slot.classList.toggle('hcms-error--info', !!message && kind === 'info')
|
|
331
445
|
if (message) {
|
|
332
446
|
slot.textContent = message
|
|
333
447
|
slot.hidden = false
|
|
@@ -367,6 +481,7 @@ export function onAdd(arrayPath, ctx) {
|
|
|
367
481
|
// Component arrays (chips) record their item-template key at build time so
|
|
368
482
|
// added items keep the same chrome.
|
|
369
483
|
itemKey: arrayEl.getAttribute('data-hcms-item-tpl') || null,
|
|
484
|
+
pageRules,
|
|
370
485
|
})
|
|
371
486
|
slot.appendChild(itemNode)
|
|
372
487
|
enhanceFields(itemNode, ctx.doc)
|
package/src/form-builder.js
CHANGED
|
@@ -28,14 +28,19 @@ export function fileNameFromUrl(url) {
|
|
|
28
28
|
|
|
29
29
|
export function buildForm({ pageRules, formRules: _formRules, data, doc }) {
|
|
30
30
|
const fragment = doc.createDocumentFragment()
|
|
31
|
-
const root = buildNode(pageRules, [], data, doc)
|
|
31
|
+
const root = buildNode(pageRules, [], data, doc, pageRules)
|
|
32
32
|
if (root) fragment.appendChild(root)
|
|
33
33
|
return fragment
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// pageRules rides alongside doc so a scalar bound to its own context element
|
|
37
|
+
// ("." / "@attr") can resolve the row that carries its named control; doc alone
|
|
38
|
+
// cannot answer that, because the row is only knowable from the enclosing array
|
|
39
|
+
// rule. deriveFormRules resolves it from the same pair, which is what keeps the
|
|
40
|
+
// built form and the derived rules in lockstep.
|
|
41
|
+
export function buildItem({ shape, itemShape, pathArr, data, doc, itemKey, pageRules }) {
|
|
37
42
|
if (shape === 'object-array-item') {
|
|
38
|
-
return buildObjectArrayItem(itemShape, pathArr, data, doc)
|
|
43
|
+
return buildObjectArrayItem(itemShape, pathArr, data, doc, pageRules)
|
|
39
44
|
}
|
|
40
45
|
if (shape === 'scalar-array-item') {
|
|
41
46
|
return buildScalarArrayItem(pathArr, data, doc, itemKey || null)
|
|
@@ -43,23 +48,23 @@ export function buildItem({ shape, itemShape, pathArr, data, doc, itemKey }) {
|
|
|
43
48
|
throw new Error(`hypercms: buildItem called with unknown shape "${shape}"`)
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
function buildNode(rule, pathArr, data, doc) {
|
|
51
|
+
function buildNode(rule, pathArr, data, doc, pageRules) {
|
|
47
52
|
const kind = shapeKindOf(rule)
|
|
48
|
-
if (kind === 'scalar') return buildScalar(rule, pathArr, data, doc)
|
|
49
|
-
if (kind === 'object') return buildObject(rule, pathArr, data, doc)
|
|
50
|
-
if (kind === 'object-array') return buildObjectArray(rule, pathArr, data, doc)
|
|
53
|
+
if (kind === 'scalar') return buildScalar(rule, pathArr, data, doc, pageRules)
|
|
54
|
+
if (kind === 'object') return buildObject(rule, pathArr, data, doc, pageRules)
|
|
55
|
+
if (kind === 'object-array') return buildObjectArray(rule, pathArr, data, doc, pageRules)
|
|
51
56
|
if (kind === 'scalar-array') return buildScalarArray(rule, pathArr, data, doc)
|
|
52
57
|
return null
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
function buildScalar(rule, pathArr, data, doc) {
|
|
60
|
+
function buildScalar(rule, pathArr, data, doc, pageRules) {
|
|
56
61
|
// Opt-in components select a richer template off the bound rule (explicit
|
|
57
62
|
// data-hcms-component, then @prop inference); plain scalars stay @scalar.
|
|
58
63
|
// A path-bound template override still wins inside resolveTemplate.
|
|
59
|
-
const componentKey = componentForScalarRule(rule, doc, pathArr)
|
|
64
|
+
const componentKey = componentForScalarRule(rule, doc, pathArr, pageRules)
|
|
60
65
|
const tpl = resolveTemplate(pathArr, componentKey, doc)
|
|
61
66
|
if (!tpl) throw new Error(`hypercms: missing template for scalar at "${pathArr.join('.')}"`)
|
|
62
|
-
const declaredKey = declaredComponentKey(rule, doc)
|
|
67
|
+
const declaredKey = declaredComponentKey(rule, doc, pathArr, pageRules)
|
|
63
68
|
// A value-guard fell back to text: say so, or "where did my number input /
|
|
64
69
|
// checkbox go" has no answer.
|
|
65
70
|
if (declaredKey === '@number' && componentKey === '@scalar') {
|
|
@@ -81,10 +86,10 @@ function buildScalar(rule, pathArr, data, doc) {
|
|
|
81
86
|
// corrupt that UI.
|
|
82
87
|
const winnerKey = tpl.getAttribute?.('data-hcms-tpl')
|
|
83
88
|
if ((componentKey === '@select' || componentKey === '@radio') && winnerKey === componentKey) {
|
|
84
|
-
populateOptions(node, rule, pathArr, data, doc, componentKey)
|
|
89
|
+
populateOptions(node, rule, pathArr, data, doc, componentKey, pageRules)
|
|
85
90
|
}
|
|
86
91
|
if (componentKey === '@image' && winnerKey === '@image') {
|
|
87
|
-
const crop = readCropOverride(rule, doc)
|
|
92
|
+
const crop = readCropOverride(rule, doc, pathArr, pageRules)
|
|
88
93
|
if (crop != null && !node.hasAttribute('data-hcms-crop')) node.setAttribute('data-hcms-crop', crop)
|
|
89
94
|
}
|
|
90
95
|
// Stamp leaf input(s) FIRST so the container's data-hcms-field doesn't
|
|
@@ -119,8 +124,8 @@ function noteShadowedComponent(tpl, declaredKey, pathArr) {
|
|
|
119
124
|
// page value is prepended when it isn't in the list, so opening the editor
|
|
120
125
|
// never silently changes data. Radio inputs get a per-field name derived from
|
|
121
126
|
// 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)
|
|
127
|
+
function populateOptions(node, rule, pathArr, data, doc, componentKey, pageRules) {
|
|
128
|
+
const provided = readOptionsOverride(rule, doc, pathArr, pageRules)
|
|
124
129
|
const values = provided ? [...provided] : []
|
|
125
130
|
const current = data == null ? '' : String(data)
|
|
126
131
|
if (current !== '' && !values.includes(current)) values.unshift(current)
|
|
@@ -192,7 +197,7 @@ function syncFileLeafText(node) {
|
|
|
192
197
|
if (a) a.textContent = fileNameFromUrl(a.getAttribute('href'))
|
|
193
198
|
}
|
|
194
199
|
|
|
195
|
-
function buildObject(rule, pathArr, data, doc) {
|
|
200
|
+
function buildObject(rule, pathArr, data, doc, pageRules) {
|
|
196
201
|
const tpl = resolveTemplate(pathArr, '@object', doc)
|
|
197
202
|
if (!tpl) throw new Error(`hypercms: missing template for object at "${pathArr.join('.')}"`)
|
|
198
203
|
const node = cloneTemplate(tpl, doc)
|
|
@@ -209,13 +214,13 @@ function buildObject(rule, pathArr, data, doc) {
|
|
|
209
214
|
const slot = requireSlot(node, '.hcms-object-fields', tpl, pathArr)
|
|
210
215
|
for (const [k, child] of Object.entries(rule)) {
|
|
211
216
|
const childData = data == null ? null : data[k]
|
|
212
|
-
const childNode = buildNode(child, [...pathArr, k], childData, doc)
|
|
217
|
+
const childNode = buildNode(child, [...pathArr, k], childData, doc, pageRules)
|
|
213
218
|
if (childNode) slot.appendChild(childNode)
|
|
214
219
|
}
|
|
215
220
|
return node
|
|
216
221
|
}
|
|
217
222
|
|
|
218
|
-
function buildObjectArray(rule, pathArr, data, doc) {
|
|
223
|
+
function buildObjectArray(rule, pathArr, data, doc, pageRules) {
|
|
219
224
|
const tpl = resolveTemplate(pathArr, '@object-array', doc)
|
|
220
225
|
if (!tpl) throw new Error(`hypercms: missing template for object-array at "${pathArr.join('.')}"`)
|
|
221
226
|
const node = cloneTemplate(tpl, doc)
|
|
@@ -229,14 +234,14 @@ function buildObjectArray(rule, pathArr, data, doc) {
|
|
|
229
234
|
const [, itemShape] = rule
|
|
230
235
|
const items = Array.isArray(data) ? data : []
|
|
231
236
|
items.forEach((itemData, i) => {
|
|
232
|
-
const itemNode = buildObjectArrayItem(itemShape, [...pathArr, i], itemData, doc)
|
|
237
|
+
const itemNode = buildObjectArrayItem(itemShape, [...pathArr, i], itemData, doc, pageRules)
|
|
233
238
|
if (itemNode) slot.appendChild(itemNode)
|
|
234
239
|
})
|
|
235
240
|
applyConstraintVisibility(node)
|
|
236
241
|
return node
|
|
237
242
|
}
|
|
238
243
|
|
|
239
|
-
function buildObjectArrayItem(itemShape, pathArr, data, doc) {
|
|
244
|
+
function buildObjectArrayItem(itemShape, pathArr, data, doc, pageRules) {
|
|
240
245
|
const tpl = resolveItemTemplate(pathArr, 'object-array-item', doc)
|
|
241
246
|
if (!tpl) throw new Error(`hypercms: missing item template for "${pathArr.join('.')}"`)
|
|
242
247
|
const node = cloneTemplate(tpl, doc)
|
|
@@ -256,7 +261,7 @@ function buildObjectArrayItem(itemShape, pathArr, data, doc) {
|
|
|
256
261
|
if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
|
|
257
262
|
for (const [k, child] of Object.entries(itemShape)) {
|
|
258
263
|
const childData = data == null ? null : data[k]
|
|
259
|
-
const childNode = buildNode(child, [...pathArr, k], childData, doc)
|
|
264
|
+
const childNode = buildNode(child, [...pathArr, k], childData, doc, pageRules)
|
|
260
265
|
if (childNode) slot.appendChild(childNode)
|
|
261
266
|
}
|
|
262
267
|
}
|
package/src/form-rules.js
CHANGED
|
@@ -112,7 +112,7 @@ export function deriveFormRules(pageRules, doc) {
|
|
|
112
112
|
// leaf tag, so an @image field reads img@src and an @file field reads
|
|
113
113
|
// a@href, while a plain scalar still respects a global @scalar override
|
|
114
114
|
// (e.g. a <textarea>) instead of assuming <input>.
|
|
115
|
-
const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc, pathArr), fieldKey)
|
|
115
|
+
const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc, pathArr, pageRules), fieldKey)
|
|
116
116
|
if (shapeEl) return fieldSelectorFor(shapeEl, fieldKey)
|
|
117
117
|
return `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`
|
|
118
118
|
}
|
package/src/hypercms.js
CHANGED
|
@@ -339,6 +339,10 @@ export function close() {
|
|
|
339
339
|
// host uploader) bails instead of mutating the page / firing onChange after
|
|
340
340
|
// teardown. Mirrors the state.ctx !== ctx guards on the undo/livesync paths.
|
|
341
341
|
ctx.closed = true
|
|
342
|
+
// An upload still in flight is torn down like any other subscription. The
|
|
343
|
+
// ctx.closed check above already stops it writing; this stops it running.
|
|
344
|
+
for (const controller of ctx.uploads || []) { try { controller.abort() } catch {} }
|
|
345
|
+
ctx.uploads?.clear()
|
|
342
346
|
const previouslyFocused = ctx.previouslyFocused
|
|
343
347
|
ctx.dispatch('hcms:close', null)
|
|
344
348
|
toggleCloseParam()
|
package/src/platform.js
CHANGED
|
@@ -4,12 +4,11 @@
|
|
|
4
4
|
//
|
|
5
5
|
// TWO clients provide this and they disagree on names. hyperclayjs owns
|
|
6
6
|
// window.hyperclay; clayjs owns window.clay and renamed things on the way past
|
|
7
|
-
// (onPrepareForSave -> addDocumentTransform, consent -> confirm
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// chrome out of every save.
|
|
7
|
+
// (onPrepareForSave -> addDocumentTransform, consent -> confirm, uploadFileBasic ->
|
|
8
|
+
// upload). So this is a capability table rather than a namespace pointer:
|
|
9
|
+
// `window.clay ?? window.hyperclay` would resolve to clay, find no
|
|
10
|
+
// onPrepareForSave, and silently stop stripping the CMS's own chrome out of every
|
|
11
|
+
// save.
|
|
13
12
|
//
|
|
14
13
|
// Reads happen per call, never cached. A client can install a capability after
|
|
15
14
|
// hypercms evaluates — that is the whole reason whenReady() below exists.
|
|
@@ -20,7 +19,59 @@ const CAPABILITIES = {
|
|
|
20
19
|
consent: (clay, hyperclay) => clay?.confirm ?? hyperclay?.consent,
|
|
21
20
|
RichClay: (clay, hyperclay) => clay?.RichClay ?? hyperclay?.RichClay,
|
|
22
21
|
quickcrop: (clay, hyperclay) => clay?.quickcrop ?? hyperclay?.quickcrop,
|
|
23
|
-
|
|
22
|
+
upload: (clay, hyperclay) =>
|
|
23
|
+
clay?.upload ?? (hyperclay?.uploadFileBasic ? adaptLegacyUpload(hyperclay.uploadFileBasic) : null),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The one capability where the two clients differ in SHAPE and not just in name,
|
|
27
|
+
// so it is normalized here rather than at the call site. clay.upload answers one
|
|
28
|
+
// envelope for every outcome and never rejects; uploadFileBasic resolves on
|
|
29
|
+
// success and rejects on failure. A pick handler that had to know which client it
|
|
30
|
+
// was riding would grow two of every branch, and the branch that matters most is
|
|
31
|
+
// the one a rejection reads wrong: "this host does not store files" is not a
|
|
32
|
+
// failure, and a caller that catches it as one stops when it should have embedded.
|
|
33
|
+
//
|
|
34
|
+
// The legacy client has no cancellation, so `signal` is honored on the near side
|
|
35
|
+
// only: an upload already in flight runs to completion, and its result is
|
|
36
|
+
// discarded rather than written. That is the same outcome the caller's own
|
|
37
|
+
// ctx.closed check produces, one branch earlier.
|
|
38
|
+
const LEGACY_STATUS_CODES = {
|
|
39
|
+
402: 'payment-required',
|
|
40
|
+
413: 'too-large',
|
|
41
|
+
415: 'unsupported-type',
|
|
42
|
+
401: 'unauthorized',
|
|
43
|
+
403: 'forbidden',
|
|
44
|
+
404: 'not-found',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const aborted = () => ({ ok: false, msg: 'Upload cancelled', msgType: 'skipped', code: 'aborted', uploads: [] })
|
|
48
|
+
|
|
49
|
+
function adaptLegacyUpload(uploadFileBasic) {
|
|
50
|
+
return async function upload(file, { onProgress, signal } = {}) {
|
|
51
|
+
if (signal?.aborted) return aborted()
|
|
52
|
+
try {
|
|
53
|
+
// uploadFileBasic reports a bare percent; clay reports {loaded,total,percent}.
|
|
54
|
+
// Callers draw from `percent`, which is the only field both can supply.
|
|
55
|
+
const res = await uploadFileBasic(file, {
|
|
56
|
+
onProgress: (percent) => { onProgress?.({ loaded: null, total: null, percent }) },
|
|
57
|
+
})
|
|
58
|
+
if (signal?.aborted) return aborted()
|
|
59
|
+
const uploads = (res && res.uploads) || []
|
|
60
|
+
if (typeof uploads[0]?.url !== 'string') {
|
|
61
|
+
return { ok: false, msg: 'The host accepted the file but did not say where it put it', msgType: 'error', code: 'bad-response', uploads: [] }
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, msg: res.msg || 'Uploaded', msgType: res.msgType || 'success', code: res.code || null, uploads }
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (signal?.aborted) return aborted()
|
|
66
|
+
// The rejection carries the HTTP status and the raw body, so the host's own
|
|
67
|
+
// code survives the round trip and the caller branches on the reason rather
|
|
68
|
+
// than pattern-matching a message.
|
|
69
|
+
let body = {}
|
|
70
|
+
try { body = JSON.parse(err?.response || '{}') } catch { body = {} }
|
|
71
|
+
const code = body.code || LEGACY_STATUS_CODES[err?.status] || 'error'
|
|
72
|
+
return { ok: false, msg: (err && err.message) || 'Upload failed', msgType: 'error', code, uploads: [] }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
24
75
|
}
|
|
25
76
|
|
|
26
77
|
// Pass `win` when the caller already holds a realm (a doc.defaultView from a form
|