@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/hyper-cms",
3
- "version": "0.7.1",
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.1",
48
- "hyper-morph": "^0.4.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
- const r = await crop(file, { aspect: parseCropAspect(attr), ...CROP_OUTPUT })
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; uploadFileBasic rejects raw Blobs and the
216
- // server keys content-type off the filename extension, so wrap it as a File
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, upload via the
229
- // host's uploadFileBasic (or fall back to a local object-URL preview), write the
230
- // resulting URL into the bound img.src / a.href leaf, then commit once. The leaf
231
- // attribute change is an observed page mutation, so undo gets a clean step with
232
- // no commitWithUndo. Async; failures surface inline and reset the picker.
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('uploadFileBasic')
250
- let url = null
251
- if (typeof upload === 'function') {
252
- try {
253
- const res = await upload(fileLike)
254
- // uploadFileBasic resolves the full envelope { uploads:[{name,nodeId,url}], … };
255
- // the served URL is uploads[0].url, NOT res.url.
256
- url = res && res.uploads && res.uploads[0] && res.uploads[0].url
257
- } catch (err) {
258
- if (ctx.closed) { resetPicker(inputEl); return }
259
- showFieldUploadError(fieldEl, (err && err.message) || 'Upload failed')
260
- ctx.dispatch?.('hcms:error', { error: err, path: pathStr })
261
- resetPicker(inputEl)
262
- return
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 (!url) {
269
- // No host uploader (or it returned nothing): local preview only, which the
270
- // commit persists into the page as a blob:/data: URL — non-persistent, by
271
- // design (documented), so the widget still works in a bare page / demo.
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
- function makeLocalPreviewUrl(file) {
323
- const U = (typeof URL !== 'undefined' && URL.createObjectURL) ? URL : null
324
- if (!U) return ''
325
- try { return U.createObjectURL(file) } catch { return '' }
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
- function showFieldUploadError(fieldEl, message) {
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)
@@ -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
- export function buildItem({ shape, itemShape, pathArr, data, doc, itemKey }) {
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). clay.RichClay exists,
8
- // but quickcrop and uploadFileBasic have no clay equivalent at all, so image crop and
9
- // file upload silently degrade under clayjs. So this is a capability table
10
- // rather than a namespace pointer: `window.clay ?? window.hyperclay` would resolve
11
- // to clay, find no onPrepareForSave, and silently stop stripping the CMS's own
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
- uploadFileBasic: (clay, hyperclay) => clay?.uploadFileBasic ?? hyperclay?.uploadFileBasic,
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