@panphora/hyper-cms 0.4.2 → 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/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 } from './templates.js'
5
+ import { injectDefaults, injectUploadComponents } from './templates.js'
6
6
  import { deriveFormRules } from './form-rules.js'
7
7
  import { buildForm } from './form-builder.js'
8
8
  import {
@@ -104,6 +104,7 @@ export function open(opts = {}) {
104
104
  const rulesTagNode = found.tagNode
105
105
 
106
106
  injectDefaults(doc)
107
+ injectUploadComponents(doc, pageRules)
107
108
  warnUnmatchedTemplates(doc, pageRules)
108
109
  const formRules = deriveFormRules(pageRules, doc)
109
110
  const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
@@ -116,6 +117,9 @@ export function open(opts = {}) {
116
117
  side: opts.side || 'right',
117
118
  overlay: !!opts.overlay,
118
119
  showSaveButton: !!opts.showSaveButton,
120
+ title: opts.title,
121
+ eyebrow: opts.eyebrow,
122
+ theme: opts.theme,
119
123
  doc,
120
124
  }))
121
125
 
@@ -136,12 +140,12 @@ export function open(opts = {}) {
136
140
  livesyncUnsub: null,
137
141
  onChange: opts.onChange,
138
142
  onError: opts.onError,
139
- onSave: opts.onSave,
143
+ confirmRemove: opts.confirmRemove,
140
144
  previouslyFocused: doc.activeElement,
141
145
  dispatch(name, detail) {
142
146
  const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
143
147
  if (!Ctor) return
144
- const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change' || name === 'hcms:save', detail })
148
+ const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change', detail })
145
149
  shell.root.dispatchEvent(ev)
146
150
  },
147
151
  onCloseRequested() {
@@ -152,6 +156,13 @@ export function open(opts = {}) {
152
156
  ctx.lastFingerprint = stableStringify(extractFormData(ctx))
153
157
  }
154
158
 
159
+ // Everything past the shell mount touches the live DOM, the module-level state,
160
+ // and external subscriptions (observer, undo, livesync). A throw in here — most
161
+ // notably installObserver when window.hyperclay.Mutation isn't loaded — would
162
+ // otherwise strand the mounted shell in the DOM with no way to close it (close()
163
+ // early-returns while state.isOpen is false). Tear down whatever was wired,
164
+ // exactly as close() does, then rethrow so host misuse still fails loudly.
165
+ try {
155
166
  const fragment = buildForm({ pageRules, formRules, data, doc })
156
167
  shell.formRoot.appendChild(fragment)
157
168
 
@@ -169,7 +180,26 @@ export function open(opts = {}) {
169
180
  // the active field. Guarded so it degrades to today's behavior when undo isn't loaded.
170
181
  const u = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
171
182
  if (u && typeof u.on === 'function') {
172
- const onRevert = () => resyncForm(ctx, 'undo')
183
+ const onRevert = () => {
184
+ if (state.ctx !== ctx) return
185
+ resyncForm(ctx, 'undo')
186
+ // A reverted change the consumer persists through onChange (e.g. the
187
+ // collection dashboard PUTs the record, not the page) would otherwise lag
188
+ // the undo: refreshForm re-syncs the form but never fires onChange, and a
189
+ // commit() here would be fingerprint-skipped (refreshForm already updated
190
+ // it). Extract the reverted state from the PAGE (the source of truth after
191
+ // an undo/redo; the form can lag a morph) and fire onChange directly, but
192
+ // only when it actually changed — guards spurious PUTs on undo/redo of
193
+ // unrelated edits elsewhere on the page.
194
+ const data = coerceBooleans(
195
+ engine.extract(ctx.pageRoot, ctx.pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }),
196
+ ctx.pageRules
197
+ )
198
+ if (stableStringify(data) !== stableStringify(ctx.lastData)) {
199
+ ctx.lastData = data
200
+ ctx.onChange?.(data, { path: '', structural: false })
201
+ }
202
+ }
173
203
  u.on('undo', onRevert)
174
204
  u.on('redo', onRevert)
175
205
  ctx.undoUnsub = () => { u.off('undo', onRevert); u.off('redo', onRevert) }
@@ -199,6 +229,19 @@ export function open(opts = {}) {
199
229
  state.opts = opts
200
230
 
201
231
  ctx.dispatch('hcms:open', { pageRoot })
232
+ } catch (err) {
233
+ ctx.observerHandle?.unsubscribe?.()
234
+ ctx.undoUnsub?.()
235
+ ctx.livesyncUnsub?.()
236
+ ctx.detachEvents?.()
237
+ if (globalCommitTarget.ctx === ctx) globalCommitTarget.ctx = null
238
+ suppressUndo(() => shell.destroy())
239
+ state.isOpen = false
240
+ state.ctx = null
241
+ state.shell = null
242
+ state.opts = null
243
+ throw err
244
+ }
202
245
  }
203
246
 
204
247
  function focusFirstIn(root) {
@@ -232,11 +275,57 @@ function installGlobalSortableCommit(doc) {
232
275
  }
233
276
  }
234
277
 
278
+ // The query param that drives ?cms=true auto-open. Closing the CMS rewrites it
279
+ // to cms=false (param kept) so a refresh/share of the post-close URL does not
280
+ // auto-reopen.
281
+ const AUTO_OPEN_PARAM = 'cms'
282
+
283
+ // Pure: given a location.search string, return the search string after a close.
284
+ // Only rewrites when cms=true is present; every other param + ordering is
285
+ // preserved, and a search without cms (or already cms=false) is returned
286
+ // unchanged so we never inject the param into a page that never carried it.
287
+ export function nextSearchAfterClose(search) {
288
+ const str = typeof search === 'string' ? search : ''
289
+ const qIndex = str.indexOf('?')
290
+ const query = qIndex === -1 ? str : str.slice(qIndex + 1)
291
+ if (!query) return str
292
+ const params = new URLSearchParams(query)
293
+ if (params.get(AUTO_OPEN_PARAM) !== 'true') return str
294
+ params.set(AUTO_OPEN_PARAM, 'false')
295
+ return '?' + params.toString()
296
+ }
297
+
298
+ // Pure: should the CMS auto-open for this location.search? Exactly 'true'.
299
+ export function shouldAutoOpenFromSearch(search) {
300
+ const str = typeof search === 'string' ? search : ''
301
+ const qIndex = str.indexOf('?')
302
+ const query = qIndex === -1 ? str : str.slice(qIndex + 1)
303
+ if (!query) return false
304
+ return new URLSearchParams(query).get(AUTO_OPEN_PARAM) === 'true'
305
+ }
306
+
307
+ // Rewrite ?cms=true → ?cms=false via history.replaceState (no reload, hash and
308
+ // every other param preserved). No-op when the param isn't present or the
309
+ // History API is unavailable.
310
+ function toggleCloseParam() {
311
+ if (typeof window === 'undefined' || !window.location || !window.history) return
312
+ if (typeof window.history.replaceState !== 'function') return
313
+ const current = window.location.search
314
+ const next = nextSearchAfterClose(current)
315
+ if (next === current) return
316
+ window.history.replaceState(window.history.state, '', next + window.location.hash)
317
+ }
318
+
235
319
  export function close() {
236
320
  if (!state.isOpen) return
237
321
  const { ctx, shell } = state
322
+ // Mark this ctx dead so any in-flight async work (e.g. an upload awaiting the
323
+ // host uploader) bails instead of mutating the page / firing onChange after
324
+ // teardown. Mirrors the state.ctx !== ctx guards on the undo/livesync paths.
325
+ ctx.closed = true
238
326
  const previouslyFocused = ctx.previouslyFocused
239
327
  ctx.dispatch('hcms:close', null)
328
+ toggleCloseParam()
240
329
  ctx.observerHandle?.unsubscribe?.()
241
330
  ctx.undoUnsub?.()
242
331
  ctx.livesyncUnsub?.()
@@ -373,6 +462,104 @@ function writeFieldValue(el, value, formRoot, path) {
373
462
  el.textContent = value == null ? '' : String(value)
374
463
  }
375
464
 
465
+ // ?cms=true auto-open. When the page URL carries cms=true at load, open the CMS
466
+ // for everyone (no gating — this is a feature showcase; a non-admin's edits stay
467
+ // DOM-local and the host save path shows its view-mode notice). open() is
468
+ // idempotent-safe (it warns + returns if already open), so a host that also calls
469
+ // open() never double-mounts. No special opts — host defaults apply.
470
+ //
471
+ // Patience matters here: this runs at module load from the dist IIFE, which a
472
+ // host typically loads BEFORE installing window.hyperclay.Mutation (the Mutation
473
+ // install often sits behind an `await import(...)` that resumes after this script,
474
+ // even after DOMContentLoaded). open() needs <body> AND Mutation, so we poll for
475
+ // both rather than firing eagerly. Firing without Mutation would throw out of
476
+ // installObserver, escape module evaluation, and abort esbuild's
477
+ // window.hypercms assignment — taking the host's own wiring down with it.
478
+ const SAFETY_POLL_MS = 250
479
+ const AUTO_OPEN_DEADLINE_MS = 10000
480
+ export function maybeAutoOpen() {
481
+ if (typeof window === 'undefined' || typeof document === 'undefined') return
482
+ if (!shouldAutoOpenFromSearch(window.location ? window.location.search : '')) return
483
+ if (state.isOpen) return
484
+ whenReady(() => {
485
+ if (state.isOpen) return
486
+ try {
487
+ open()
488
+ } catch (err) {
489
+ console.warn('hypercms: auto-open failed', err)
490
+ }
491
+ })
492
+ }
493
+
494
+ // Auto-open is ready only once <body> is parsed AND window.hyperclay.Mutation is
495
+ // installed (open() needs both). NEVER throw out of module scope.
496
+ function autoOpenReady() {
497
+ return !!document.body && !!(window.hyperclay && window.hyperclay.Mutation)
498
+ }
499
+
500
+ // Three layers, in order of preference:
501
+ // 1. Sync fast-path: if <body> and Mutation are already here, fire now.
502
+ // 2. Event: hyperclayjs's mutation.js dispatches 'hyperclay:mutation-ready' the
503
+ // instant it installs window.hyperclay.Mutation. On loader pages this is
504
+ // moot — the loader's first wave evaluates mutation before hypercms, so the
505
+ // fast-path wins — but the event catches any late install (the deferred
506
+ // `await import()` ordering). The body gate is re-checked in the handler
507
+ // because the event can land before <body> is parsed.
508
+ // 3. Backstop: a slow self-cancelling 250ms poll for exotic hosts that hand-roll
509
+ // window.hyperclay.Mutation by direct assignment WITHOUT dispatching the
510
+ // event. It cancels the moment the event fires, open succeeds, or the
511
+ // deadline is hit (warn, never throw).
512
+ function whenReady(fn) {
513
+ if (autoOpenReady()) {
514
+ fn()
515
+ return
516
+ }
517
+ const deadline = Date.now() + AUTO_OPEN_DEADLINE_MS
518
+ let done = false
519
+ let timer = null
520
+ const finish = () => {
521
+ if (done) return
522
+ done = true
523
+ if (timer !== null) clearInterval(timer)
524
+ document.removeEventListener('hyperclay:mutation-ready', onMutationReady)
525
+ }
526
+ function onMutationReady() {
527
+ if (state.isOpen) {
528
+ finish()
529
+ return
530
+ }
531
+ if (autoOpenReady()) {
532
+ finish()
533
+ fn()
534
+ }
535
+ // Body may still be pending — the one-shot listener is already consumed,
536
+ // so only the backstop remains to catch it once body arrives.
537
+ }
538
+ document.addEventListener('hyperclay:mutation-ready', onMutationReady, { once: true })
539
+ timer = setInterval(() => {
540
+ if (state.isOpen) {
541
+ finish()
542
+ return
543
+ }
544
+ if (autoOpenReady()) {
545
+ finish()
546
+ fn()
547
+ return
548
+ }
549
+ if (Date.now() >= deadline) {
550
+ finish()
551
+ console.warn(
552
+ 'hypercms: ?cms=true auto-open gave up — window.hyperclay.Mutation never appeared. ' +
553
+ 'Load hyperclayjs (or the mutation utility) so the CMS can initialize.'
554
+ )
555
+ }
556
+ }, SAFETY_POLL_MS)
557
+ }
558
+
559
+ // Auto-open at module load (browser only — node tests import this without a real
560
+ // window, and the guards above no-op there).
561
+ maybeAutoOpen()
562
+
376
563
  const cms = {
377
564
  open,
378
565
  close,
package/src/refresh.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { engine } from 'hyper-html-api'
2
2
  import { buildForm } from './form-builder.js'
3
3
  import { morphForm } from './morph.js'
4
- import { injectDefaults } from './templates.js'
4
+ import { injectDefaults, injectUploadComponents } from './templates.js'
5
5
  import { deriveFormRules } from './form-rules.js'
6
6
  import { coerceBooleans, applyErrorState } from './events.js'
7
7
 
@@ -18,6 +18,7 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
18
18
  }
19
19
  // Re-derive formRules so schema changes (rule shape, template overrides) flow through.
20
20
  injectDefaults(ctx.doc)
21
+ injectUploadComponents(ctx.doc, ctx.pageRules)
21
22
  ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
22
23
 
23
24
  const newData = coerceBooleans(
package/src/shell.js CHANGED
@@ -19,7 +19,22 @@ export function markStylesBundled(doc) {
19
19
 
20
20
  let titleIdCounter = 0
21
21
 
22
- export function mountShell({ mountTo, side = 'right', overlay = false, showSaveButton = false, doc }) {
22
+ function escapeHtml(s) {
23
+ return String(s).replace(/[&<>"]/g, (c) => (
24
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]
25
+ ))
26
+ }
27
+
28
+ export function mountShell({
29
+ mountTo,
30
+ side = 'right',
31
+ overlay = false,
32
+ showSaveButton = false,
33
+ title = 'Page content',
34
+ eyebrow = 'Edit',
35
+ theme = null,
36
+ doc,
37
+ }) {
23
38
  ensureStyles(doc)
24
39
  const titleId = `hcms-shell-title-${++titleIdCounter}`
25
40
  const root = doc.createElement('div')
@@ -33,17 +48,45 @@ export function mountShell({ mountTo, side = 'right', overlay = false, showSaveB
33
48
  root.setAttribute('role', 'dialog')
34
49
  root.setAttribute('aria-modal', 'true')
35
50
  root.setAttribute('aria-labelledby', titleId)
36
- root.className = 'hcms-shell hcms-side-' + side + (overlay ? ' hcms-overlay' : '')
51
+ // pixel-quiet is the baked-in look; an optional theme pins light/dark
52
+ // (otherwise the panel follows the OS via prefers-color-scheme).
53
+ const themeClass = theme === 'dark' ? ' dark' : theme === 'light' ? ' light' : ''
54
+ root.className =
55
+ 'hcms-shell pixel-quiet hcms-side-' + side + (overlay ? ' hcms-overlay' : '') + themeClass
56
+ const titleHtml = escapeHtml(title)
57
+ const eyebrowHtml = escapeHtml(eyebrow)
58
+ // Pixel Quiet shell: a scroll-away header + in-flow Save inside .hcms-shell-body,
59
+ // with a condensed minibar pinned to the panel top once the header scrolls out.
60
+ // Close + Save are real mirk buttons. The .hcms-* hooks stay so the engine and
61
+ // events bind exactly as before; only the chrome shape changed.
62
+ // The Save button has no wiring here: [trigger-save] is the hyperclayjs save
63
+ // attribute — its save system (or its view-mode notice) handles the click.
64
+ // Standalone hosts delegate their own [trigger-save] click listener.
37
65
  root.innerHTML = `
38
- <header class="hcms-shell-header">
39
- <h2 class="hcms-shell-title" id="${titleId}">Edit</h2>
40
- <button type="button" class="hcms-shell-close" data-hcms-action="close" aria-label="Close">×</button>
41
- </header>
42
- <div class="hcms-shell-error" role="alert" hidden></div>
43
- <div data-hcms-form-root class="hcms-form"></div>
44
- <footer class="hcms-shell-footer"${showSaveButton ? '' : ' hidden'}>
45
- <button type="button" class="hcms-shell-save" data-hcms-action="save">Save</button>
46
- </footer>
66
+ <div class="hcms-shell-minibar" aria-hidden="true">
67
+ <span class="hcms-shell-minibar-title">${titleHtml}</span>
68
+ <button type="button" class="hcms-shell-close mirk-button mirk-button--small" data-hcms-action="close" aria-label="Close">
69
+ <span class="mirk-button__label">×</span>
70
+ </button>
71
+ </div>
72
+ <div class="hcms-shell-body">
73
+ <header class="hcms-shell-header">
74
+ <div class="hcms-shell-heading">
75
+ <div class="hcms-shell-eyebrow">${eyebrowHtml}</div>
76
+ <h2 class="hcms-shell-title" id="${titleId}">${titleHtml}</h2>
77
+ </div>
78
+ <button type="button" class="hcms-shell-close mirk-button mirk-button--small" data-hcms-action="close" aria-label="Close">
79
+ <span class="mirk-button__label">×</span>
80
+ </button>
81
+ </header>
82
+ <div class="hcms-shell-error" role="alert" hidden></div>
83
+ <div data-hcms-form-root class="hcms-form"></div>
84
+ <footer class="hcms-shell-footer"${showSaveButton ? '' : ' hidden'}>
85
+ <button type="button" class="hcms-shell-save mirk-button" trigger-save>
86
+ <span class="mirk-button__label">Save</span>
87
+ </button>
88
+ </footer>
89
+ </div>
47
90
  `
48
91
 
49
92
  // mountTo may be a nested element inside pageRoot. Use it as-is for the
@@ -58,6 +101,7 @@ export function mountShell({ mountTo, side = 'right', overlay = false, showSaveB
58
101
  if (side === 'left') body.classList.add('hcms-side-left')
59
102
 
60
103
  const focusTrap = installFocusTrap(root, doc)
104
+ const condense = installCondenseOnScroll(root)
61
105
 
62
106
  return {
63
107
  root,
@@ -66,6 +110,7 @@ export function mountShell({ mountTo, side = 'right', overlay = false, showSaveB
66
110
  saveButton: root.querySelector('.hcms-shell-save'),
67
111
  destroy() {
68
112
  focusTrap.detach()
113
+ condense.detach()
69
114
  root.remove()
70
115
  body.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
71
116
  },
@@ -115,12 +160,12 @@ function ensureStyles(doc) {
115
160
  styledDocs.add(doc)
116
161
  return
117
162
  }
118
- // No bundled CSS — try resolving the sibling styles.css via import.meta.url.
119
- // Browsers ESM-resolved imports place a co-located styles.css next to
120
- // hypercms.js. Falls back silently if resolution fails (e.g., bundlers
121
- // without an asset emitter).
163
+ // No bundled CSS — try resolving the sibling generated theme via
164
+ // import.meta.url. Browsers ESM-resolved imports place a co-located
165
+ // theme.generated.css next to hypercms.js. Falls back silently if resolution
166
+ // fails (e.g., bundlers without an asset emitter).
122
167
  try {
123
- const href = new URL('./styles.css', import.meta.url).href
168
+ const href = new URL('./theme.generated.css', import.meta.url).href
124
169
  const link = doc.createElement('link')
125
170
  link.rel = 'stylesheet'
126
171
  link.id = SHELL_STYLE_ID
@@ -134,6 +179,25 @@ function ensureStyles(doc) {
134
179
  }
135
180
  }
136
181
 
182
+ // Reveal the condensed minibar once the full header scrolls out of the body
183
+ // (pixel-quiet decision 0003). Passive listener; toggles .is-condensed on the
184
+ // shell root when scrollTop passes the header height. No-op if the body or
185
+ // header is missing (e.g. a custom mount that omits them).
186
+ function installCondenseOnScroll(root) {
187
+ const bodyEl = root.querySelector('.hcms-shell-body')
188
+ const header = root.querySelector('.hcms-shell-header')
189
+ if (!bodyEl || !header || typeof bodyEl.addEventListener !== 'function') {
190
+ return { detach() {} }
191
+ }
192
+ const reveal = () => {
193
+ const trigger = (header.offsetHeight || 0) - 12
194
+ root.classList.toggle('is-condensed', bodyEl.scrollTop > trigger)
195
+ }
196
+ bodyEl.addEventListener('scroll', reveal, { passive: true })
197
+ reveal()
198
+ return { detach() { bodyEl.removeEventListener('scroll', reveal) } }
199
+ }
200
+
137
201
  function installFocusTrap(root, doc) {
138
202
  function onKeyDown(e) {
139
203
  if (e.key !== 'Tab') return
package/src/templates.js CHANGED
@@ -10,11 +10,23 @@ export function humanize(key) {
10
10
  .replace(/^./, (c) => c.toUpperCase())
11
11
  }
12
12
 
13
+ // The default templates emit real mirk-interface markup so the generated form
14
+ // renders in the Pixel Quiet look out of the box. Every data-hcms-* hook
15
+ // (shape, label, field, action, the .hcms-array-items / .hcms-card-fields slots,
16
+ // the .hcms-error inline slots) is preserved exactly, so the engine, events,
17
+ // add/remove/reorder, and inline-error placement bind unchanged. The CSS that
18
+ // dresses these lives in src/theme.generated.css (scoped mirk + pixel-quiet).
19
+ const SORTABLE_GRIP = `<div class="hcms-drag-handle mirk-sortable__grip" aria-hidden="true"><div class="mirk-sortable__dots"><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span></div></div>`
20
+
21
+ // Crisp-line × (square caps + crispEdges): a plain pixel-sharp close glyph that
22
+ // stays hard-edged inside the small square corner button on object-array cards.
23
+ const CLOSE_ICON = `<svg class="hcms-x" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square"></path></svg>`
24
+
13
25
  const DEFAULT_TEMPLATES = {
14
26
  '@scalar': `
15
27
  <label class="hcms-field" data-hcms-shape="scalar">
16
28
  <span class="hcms-label" data-hcms-label></span>
17
- <input class="hcms-input" data-hcms-field />
29
+ <input class="mirk-input" data-hcms-field />
18
30
  <div class="hcms-error" hidden></div>
19
31
  </label>
20
32
  `,
@@ -32,56 +44,183 @@ const DEFAULT_TEMPLATES = {
32
44
  </header>
33
45
  <ul class="hcms-array-items"></ul>
34
46
  <div class="hcms-error" hidden></div>
35
- <button type="button" class="hcms-add" data-hcms-action="add">+ Add</button>
47
+ <button type="button" class="hcms-add mirk-button mirk-button--small" data-hcms-action="add"><span class="mirk-button__label">+ Add</span></button>
36
48
  </section>
37
49
  `,
38
50
  '@scalar-array-item': `
39
51
  <li class="hcms-array-item" draggable="true">
40
- <span class="hcms-drag-handle" aria-hidden="true">::</span>
41
- <input class="hcms-input" data-hcms-field />
52
+ <input class="mirk-input" data-hcms-field />
42
53
  <button type="button" class="hcms-move hcms-move-up hcms-sr-only" data-hcms-action="move-up" aria-label="Move up">↑</button>
43
54
  <button type="button" class="hcms-move hcms-move-down hcms-sr-only" data-hcms-action="move-down" aria-label="Move down">↓</button>
44
- <button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">x</button>
55
+ <button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">×</button>
45
56
  <div class="hcms-error" hidden></div>
46
57
  </li>
47
58
  `,
48
59
  '@object-array': `
49
- <section class="hcms-array hcms-object-array" data-hcms-shape="object-array">
60
+ <section class="hcms-array hcms-object-array hcms-array--cards" data-hcms-shape="object-array">
50
61
  <header class="hcms-array-header">
51
62
  <h3 class="hcms-array-title" data-hcms-label></h3>
52
63
  </header>
53
64
  <div class="hcms-array-items"></div>
54
65
  <div class="hcms-error" hidden></div>
55
- <button type="button" class="hcms-add" data-hcms-action="add">+ Add</button>
66
+ <button type="button" class="hcms-add mirk-button mirk-button--small" data-hcms-action="add"><span class="mirk-button__label">+ Add</span></button>
56
67
  </section>
57
68
  `,
58
69
  '@object-array-item': `
59
- <article class="hcms-card" draggable="true">
60
- <header class="hcms-card-header">
61
- <span class="hcms-drag-handle" aria-hidden="true">::</span>
62
- <button type="button" class="hcms-move hcms-move-up hcms-sr-only" data-hcms-action="move-up" aria-label="Move up">↑</button>
63
- <button type="button" class="hcms-move hcms-move-down hcms-sr-only" data-hcms-action="move-down" aria-label="Move down">↓</button>
64
- <button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">x</button>
65
- </header>
66
- <div class="hcms-card-fields"></div>
70
+ <article class="hcms-card mirk-sortable__item" draggable="true">
71
+ ${SORTABLE_GRIP}
72
+ <div class="hcms-card-body mirk-sortable__body">
73
+ <div class="hcms-card-fields"></div>
74
+ <div class="hcms-card-controls">
75
+ <button type="button" class="hcms-move hcms-move-up hcms-sr-only" data-hcms-action="move-up" aria-label="Move up">↑</button>
76
+ <button type="button" class="hcms-move hcms-move-down hcms-sr-only" data-hcms-action="move-down" aria-label="Move down">↓</button>
77
+ <button type="button" class="hcms-remove hcms-remove--card" data-hcms-action="remove" aria-label="Remove">${CLOSE_ICON}</button>
78
+ </div>
79
+ </div>
67
80
  <div class="hcms-error" hidden></div>
68
81
  </article>
69
82
  `,
83
+ // Opt-in upload components. Built on the kit's consolidated mirk chrome (all
84
+ // themed in theme.generated.css), with interactive hooks kept on data-hcms-*
85
+ // so the vendored mirk runtime (which keys off .mirk-*__input / .mirk-*__remove)
86
+ // never double-handles a CMS field. The bound value is the leaf's URL: the
87
+ // <a href> for @file, the <img src> for @image. Empty/filled chrome is driven
88
+ // by that attribute in CSS (.hcms-upload* in pixel-quiet.overrides.css), no JS.
89
+ '@file': `
90
+ <div class="hcms-field hcms-upload hcms-upload--file" data-hcms-shape="scalar">
91
+ <span class="hcms-label" data-hcms-label></span>
92
+ <div class="mirk-file mirk-file--compact mirk-file--round">
93
+ <label class="mirk-button mirk-button--round mirk-button--small">
94
+ <input type="file" data-hcms-upload />
95
+ <span class="mirk-button__label">Choose</span>
96
+ </label>
97
+ <a class="mirk-file__name" data-hcms-field></a>
98
+ <button type="button" class="hcms-upload-clear" data-hcms-action="clear-upload" aria-label="Remove file">${CLOSE_ICON}</button>
99
+ </div>
100
+ <div class="hcms-error" hidden></div>
101
+ </div>
102
+ `,
103
+ '@image': `
104
+ <div class="hcms-field hcms-upload hcms-upload--image" data-hcms-shape="scalar">
105
+ <span class="hcms-label" data-hcms-label></span>
106
+ <div class="mirk-image mirk-image--compact mirk-image--rounded">
107
+ <label class="mirk-button mirk-button--small mirk-image__upload">
108
+ <input type="file" accept="image/*" data-hcms-upload />
109
+ <span class="mirk-button__label">Upload image</span>
110
+ </label>
111
+ <figure class="mirk-image__thumb">
112
+ <span class="mirk-image__frame"><img class="mirk-image__preview" data-hcms-field alt="" /></span>
113
+ <button type="button" class="hcms-upload-clear hcms-upload-clear--badge" data-hcms-action="clear-upload" aria-label="Remove image">${CLOSE_ICON}</button>
114
+ </figure>
115
+ </div>
116
+ <div class="hcms-error" hidden></div>
117
+ </div>
118
+ `,
70
119
  }
71
120
 
72
- const DEFAULT_KEYS = Object.keys(DEFAULT_TEMPLATES)
121
+ // Shape templates fill-the-gaps on every managed page. The opt-in upload
122
+ // components (@file/@image) are injected on demand the first time a field
123
+ // selects them (the Slice-2 component seam), so pages using neither stay clean.
124
+ const ALWAYS_INJECT_KEYS = [
125
+ '@scalar',
126
+ '@object',
127
+ '@scalar-array',
128
+ '@scalar-array-item',
129
+ '@object-array',
130
+ '@object-array-item',
131
+ ]
73
132
 
74
133
  export function injectDefaults(doc) {
75
134
  const head = doc.head || doc.documentElement
76
135
  if (!head) return
77
- for (const key of DEFAULT_KEYS) {
78
- if (findTemplate(doc, key)) continue
79
- const tpl = doc.createElement('template')
80
- tpl.setAttribute('data-hcms-tpl', key)
81
- tpl.setAttribute('save-remove', '')
82
- tpl.innerHTML = DEFAULT_TEMPLATES[key].trim()
83
- head.appendChild(tpl)
136
+ for (const key of ALWAYS_INJECT_KEYS) injectTemplate(doc, head, key)
137
+ }
138
+
139
+ // Inject one opt-in component template (@file/@image) if the page hasn't
140
+ // already defined its own. Called by the component-selection seam the first
141
+ // time a field resolves to an upload component.
142
+ export function injectComponentTemplate(doc, key) {
143
+ if (!DEFAULT_TEMPLATES[key]) return null
144
+ const head = doc && (doc.head || doc.documentElement)
145
+ if (!head) return null
146
+ return injectTemplate(doc, head, key)
147
+ }
148
+
149
+ // Opt-in upload-component selection. A scalar field upgrades from a plain text
150
+ // input to an image-upload widget when its rule ends in @src (uploading is the
151
+ // dominant image-CMS workflow). @href is NOT inferred: editing a link/URL is far
152
+ // more common than uploading a file, so an a@href rule stays a plain URL field.
153
+ // A file upload (and an image override) is opt-in via data-hcms-component on the
154
+ // page element the rule points at — the only branch that reads the page DOM.
155
+ // Suffix is split like the engine (lastIndexOf('@'), see engine/extract.js).
156
+ // Returns a DEFAULT_TEMPLATES key.
157
+ const PROP_TO_COMPONENT = { src: '@image' }
158
+ const UPLOAD_COMPONENT_KEYS = new Set(['@image', '@file'])
159
+
160
+ export function componentForScalarRule(rule, doc) {
161
+ if (typeof rule !== 'string') return '@scalar'
162
+ const at = rule.lastIndexOf('@')
163
+ if (at >= 0) {
164
+ const prop = rule.slice(at + 1)
165
+ if (PROP_TO_COMPONENT[prop]) return PROP_TO_COMPONENT[prop]
84
166
  }
167
+ const override = readComponentOverride(rule, at, doc)
168
+ if (override === 'image') return '@image'
169
+ if (override === 'file') return '@file'
170
+ return '@scalar'
171
+ }
172
+
173
+ function readComponentOverride(rule, at, doc) {
174
+ if (!doc || !doc.querySelector) return null
175
+ const selector = at >= 0 ? rule.slice(0, at) : rule
176
+ if (!selector || selector === '.') return null
177
+ let el = null
178
+ try {
179
+ el = doc.querySelector(selector)
180
+ } catch {
181
+ return null
182
+ }
183
+ return el && el.getAttribute ? el.getAttribute('data-hcms-component') : null
184
+ }
185
+
186
+ // Pre-inject the @file/@image templates this page's rules actually select, so
187
+ // both deriveFormRules (selectors) and buildForm (DOM) resolve them. Walks the
188
+ // same rule shapes as buildTemplateMap; idempotent and author-template-safe.
189
+ export function injectUploadComponents(doc, pageRules) {
190
+ if (!doc || pageRules == null) return
191
+ walk(pageRules)
192
+
193
+ function walk(rule) {
194
+ const kind = shapeKindOf(rule)
195
+ if (kind === 'scalar') {
196
+ const key = componentForScalarRule(rule, doc)
197
+ if (UPLOAD_COMPONENT_KEYS.has(key)) injectComponentTemplate(doc, key)
198
+ return
199
+ }
200
+ if (kind === 'object') {
201
+ for (const child of Object.values(rule)) walk(child)
202
+ return
203
+ }
204
+ if (kind === 'object-array') {
205
+ const itemShape = rule[1]
206
+ if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
207
+ for (const child of Object.values(itemShape)) walk(child)
208
+ } else {
209
+ walk(itemShape)
210
+ }
211
+ }
212
+ }
213
+ }
214
+
215
+ function injectTemplate(doc, head, key) {
216
+ const existing = findTemplate(doc, key)
217
+ if (existing) return existing
218
+ const tpl = doc.createElement('template')
219
+ tpl.setAttribute('data-hcms-tpl', key)
220
+ tpl.setAttribute('save-remove', '')
221
+ tpl.innerHTML = DEFAULT_TEMPLATES[key].trim()
222
+ head.appendChild(tpl)
223
+ return tpl
85
224
  }
86
225
 
87
226
  export function findTemplate(doc, key) {
@@ -136,11 +275,13 @@ export function buildTemplateMap(pageRules, doc) {
136
275
  const pathStr = pathArr.join('.')
137
276
  const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
138
277
  const shape = shapeKindOf(rule)
278
+ const defaultKey =
279
+ shape === 'scalar' ? componentForScalarRule(rule, doc) : SHAPE_TO_DEFAULT_KEY[shape] || '@scalar'
139
280
 
140
281
  let tpl =
141
282
  (pathStr && findTemplate(doc, pathStr)) ||
142
283
  (wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
143
- findTemplate(doc, SHAPE_TO_DEFAULT_KEY[shape] || '@scalar')
284
+ findTemplate(doc, defaultKey)
144
285
 
145
286
  map.set(pathStr, tpl)
146
287