@panphora/hyper-cms 0.8.1 → 0.11.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,376 +2,161 @@ 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, injectComponents } from './templates.js'
6
- import { deriveFormRules } from './form-rules.js'
7
- import { buildForm } from './form-builder.js'
8
5
  import {
9
- bindEvents,
10
6
  commit,
11
7
  commitWithUndo,
12
- suppressUndo,
13
8
  onAdd as evOnAdd,
14
9
  onRemove as evOnRemove,
15
10
  extractFormData,
16
- stableStringify,
17
11
  restampAllSiblings,
18
- coerceBooleans,
19
12
  } from './events.js'
20
- import { mountShell, setShellStyles, markStylesBundled } from './shell.js'
21
- import { refreshForm, installObserver } from './refresh.js'
22
- import { warnUnmatchedTemplates } from './diagnostics.js'
23
- import { findUnresolved, applyUnresolvedState } from './unresolved.js'
24
- import { enhanceFields, upgradeRichTextRules } from './enhance.js'
13
+ import { setShellStyles, markStylesBundled, reensureStyles } from './shell.js'
14
+ import { createSidebarView } from './views/sidebar.js'
15
+ import { createInlineView } from './views/inline.js'
16
+ import {
17
+ state,
18
+ createSession,
19
+ startSession,
20
+ teardownSession,
21
+ nextSearchAfterClose,
22
+ shouldAutoOpenFromSearch,
23
+ } from './session.js'
24
+ import { findLeafField, writeFieldValue } from './events.js'
25
25
  import { maybeInjectToggle } from './toggle.js'
26
- import { platform, onPlatformEvent, MUTATION_READY, LIVESYNC_APPLIED } from './platform.js'
26
+ import { platform, onPlatformEvent, MUTATION_READY } from './platform.js'
27
+ import { installHooks } from './hooks.js'
28
+
29
+ export { nextSearchAfterClose, shouldAutoOpenFromSearch }
30
+ // The leaf-write pair api.setValue is built on. Exported because the inline
31
+ // view's text commit writes the same form leaf from the page element, and a
32
+ // second copy of this logic is how the two paths drift apart.
33
+ export { findLeafField, writeFieldValue }
27
34
 
28
35
  export function installStyles(text) {
29
36
  setShellStyles(text)
37
+ if (typeof document !== 'undefined') {
38
+ const fallback = document.getElementById('hcms-shell-styles')
39
+ if (fallback?.tagName === 'LINK') fallback.remove()
40
+ reensureStyles(document)
41
+ }
30
42
  }
31
43
 
32
44
  export function markBundledStyles(doc) {
33
45
  markStylesBundled(doc)
34
46
  }
35
47
 
36
- const state = {
37
- isOpen: false,
38
- ctx: null,
39
- shell: null,
40
- opts: null,
41
- }
42
-
43
- // Re-sync the form to the page after a change hypercms did NOT originate.
44
- // `reason` selects whether the field the user is currently typing in snaps:
45
- // 'undo' / 'redo' — the user's own deliberate action, so the focused field
46
- // SHOULD snap to the reverted value (ignoreActiveValue:false).
47
- // 'livesync' — someone else's / the framework's change, so the user's
48
- // in-progress focused field must NOT be yanked away
49
- // (ignoreActiveValue:true). morphForm re-extracts every
50
- // other field to the new page value, killing the stale-form
51
- // clobber, while leaving the active field alone until blur.
52
- // Both reasons route through this one handler so there is a single place to
53
- // maintain the form-resync behavior.
54
- function resyncForm(ctx, reason) {
55
- // open() may have moved on (closed, or reopened with a new ctx) between an
56
- // async morph completing and this firing.
57
- if (state.ctx !== ctx) return
58
- const ignoreActiveValue = reason === 'livesync'
59
- if (reason === 'livesync') {
60
- // A full-document morph can wipe the shell's <head> stylesheet and the
61
- // <body> chrome classes (both live outside the save-ignore shell subtree).
62
- // Re-assert them before re-extracting.
63
- state.shell?.restoreChrome?.()
64
- }
65
- refreshForm(ctx, { ignoreActiveValue })
66
- }
67
-
68
- // Strip hypercms's own body chrome from the SAVE clone so it never reaches disk
69
- // (the shell element itself is [save-remove], but the hcms-open class lives on
70
- // <body>, outside the stripped subtree). Registered once at first open():
71
- // onPrepareForSave runs save-only, on a clone, so it never touches the live DOM,
72
- // and removing absent classes is a no-op when the shell is closed. Guarded so it
73
- // degrades cleanly when the host save pipeline (hyperclayjs) isn't present.
74
- let prepareHookInstalled = false
75
- function installSavePrepareHook() {
76
- if (prepareHookInstalled) return
77
- const onPrepareForSave = platform('onPrepareForSave')
78
- if (typeof onPrepareForSave !== 'function') return
79
- onPrepareForSave((clonedDocEl) => {
80
- const b = clonedDocEl && clonedDocEl.querySelector && clonedDocEl.querySelector('body')
81
- if (b) b.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
82
- })
83
- prepareHookInstalled = true
48
+ // The views a session can be rendered through. A name that is not in here is a
49
+ // caller error, never a fallback: silently opening a sidebar when someone asked
50
+ // for the inline editor would read as the inline editor being broken.
51
+ const VIEWS = {
52
+ sidebar: createSidebarView,
53
+ inline: createInlineView,
84
54
  }
85
55
 
86
56
  export function open(opts = {}) {
87
- if (state.isOpen) {
88
- console.warn('cms.open() called while already open; ignoring')
89
- return
57
+ // Everything that can be rejected is rejected before anything is destroyed. A
58
+ // bad view name or a missing page must not leave a caller with the session
59
+ // they had already torn down.
60
+ // A bare open() means "make sure the editor is up", not "put it in the
61
+ // sidebar". Defaulting to sidebar turned every such call into a view switch.
62
+ const viewName = opts.view || (state.isOpen ? state.ctx.view.name : 'sidebar')
63
+ const makeView = VIEWS[viewName]
64
+ if (!makeView) {
65
+ throw new Error(`hypercms: unknown view "${viewName}" (expected ${Object.keys(VIEWS).join(' or ')})`)
90
66
  }
91
- installSavePrepareHook()
92
- const pageRoot = opts.pageRoot || (typeof document !== 'undefined' ? document.body : null)
67
+ // A switch changes the presentation of one session, not the session. Someone
68
+ // calling open({ view: 'sidebar' }) means "show me this in the sidebar"; they
69
+ // are not restating the pageRoot, the rules and the callbacks they opened
70
+ // with. Resolving those from this call alone silently retargets the CMS at
71
+ // document.body with the default rules tag and stops calling onChange.
72
+ // state.opts holds the active session's options and teardown clears it, so it
73
+ // has to be read here, before anything is torn down.
74
+ const effective = state.isOpen ? { ...state.opts, ...opts, view: viewName } : opts
75
+
76
+ const pageRoot = effective.pageRoot || (typeof document !== 'undefined' ? document.body : null)
93
77
  if (!pageRoot) throw new Error('hypercms: no pageRoot available')
94
78
  const doc = pageRoot.ownerDocument || (typeof document !== 'undefined' ? document : null)
95
79
  if (!doc) throw new Error('hypercms: no document available')
96
80
 
97
- // Resolve rules via the union: an explicit rules object, a token string, or
98
- // the default token "cms". findRules is document-scoped, so a head-mounted
99
- // rules tag is found even though pageRoot defaults to <body>.
100
- const source = opts.rules !== undefined ? opts.rules : 'cms'
101
- const found = engine.findRules(doc, source)
102
- if (!found) {
103
- const what = typeof source === 'string' ? `data-rules-name~="${source}"` : 'the provided rules object'
104
- throw new Error(`hypercms: no rules found for ${what}`)
81
+ // Reopening the view that is already up is a silent no-op, not a warning: a
82
+ // host wiring a button to open({ view }) should be able to call it without
83
+ // first asking what is open. Switching views tears the old one down WITHOUT
84
+ // the close-only side effects — no hcms:close URL rewrite, no focus restore —
85
+ // because a switch is one continuous session from the person's side, not a
86
+ // close followed by an open.
87
+ let carriedFocus = null
88
+ let previous = null
89
+ if (state.isOpen) {
90
+ if (state.ctx.view.name === viewName) return
91
+ carriedFocus = state.ctx.previouslyFocused
92
+ previous = state.ctx.view.name
93
+ teardownSession(state.ctx, { restoreFocus: false, updateUrl: false, reason: 'switch' })
105
94
  }
106
- // Rich-text upgrade (default on): bare scalar rules whose page element
107
- // contains child elements are rebound through @innerHTML so links and
108
- // inline formatting survive the round-trip, and their form fields render
109
- // the @richtext component. Opt out with cms.open({ richText: false }).
110
- const richText = opts.richText !== false
111
- const pageRules = richText ? upgradeRichTextRules(found.rules, pageRoot) : found.rules
112
- const rulesTagNode = found.tagNode
113
-
114
- injectDefaults(doc)
115
- injectComponents(doc, pageRules)
116
- warnUnmatchedTemplates(doc, pageRules)
117
- const formRules = deriveFormRules(pageRules, doc)
118
- // Runs before the first extract so a rule with invalid CSS names its field
119
- // instead of throwing a bare SyntaxError out of querySelectorAll.
120
- const unresolved = findUnresolved(pageRoot, pageRules)
121
- const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
122
95
 
123
- // Suppress undo around mountShell: it toggles chrome-only classes on
124
- // document.body (hcms-open, etc.) which would otherwise land as undoable
125
- // page edits. The shell subtree itself is already filtered via save-ignore.
126
- const shell = suppressUndo(() => mountShell({
127
- mountTo: opts.mountTo || doc.body,
128
- side: opts.side || 'right',
129
- overlay: !!opts.overlay,
130
- showSaveButton: !!opts.showSaveButton,
131
- title: opts.title,
132
- eyebrow: opts.eyebrow,
133
- theme: opts.theme,
134
- doc,
135
- }))
96
+ installHooks()
136
97
 
137
- const ctx = {
98
+ const view = makeView({ doc, pageRoot, opts: effective })
99
+ const ctx = createSession({
100
+ view,
138
101
  doc,
139
102
  pageRoot,
140
- pageRules,
141
- formRules,
142
- rulesTagNode,
143
- rulesSource: source,
144
- richText,
145
- formRoot: shell.formRoot,
146
- shellRoot: shell.root,
147
- errorEl: shell.errorEl,
148
- noticeEl: shell.noticeEl,
149
- unresolved,
150
- lastTwinSignature: null,
151
- lastFingerprint: null,
152
- lastData: null,
153
- observerHandle: null,
154
- undoUnsub: null,
155
- livesyncUnsub: null,
156
- onChange: opts.onChange,
157
- onError: opts.onError,
158
- confirmRemove: opts.confirmRemove,
159
- previouslyFocused: doc.activeElement,
160
- dispatch(name, detail) {
161
- const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
162
- if (!Ctor) return
163
- const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change', detail })
164
- shell.root.dispatchEvent(ev)
165
- },
166
- onCloseRequested() {
167
- close()
168
- },
169
- }
170
- ctx.updateFingerprint = () => {
171
- ctx.lastFingerprint = stableStringify(extractFormData(ctx))
172
- }
103
+ opts: effective,
104
+ onCloseRequested: () => close(),
105
+ onViewRequested: (name) => open({ view: name }),
106
+ })
107
+ // Where focus was before the FIRST open, carried across the switch. Closing
108
+ // the second view should return the person where they started, not to
109
+ // whatever held focus after the first view was pulled out from under them.
110
+ if (carriedFocus) ctx.previouslyFocused = carriedFocus
173
111
 
174
- // Everything past the shell mount touches the live DOM, the module-level state,
175
- // and external subscriptions (observer, undo, livesync). A throw in here — most
112
+ // Everything past here touches the live DOM, the module-level state, and
113
+ // external subscriptions (observer, undo, livesync). A throw in here — most
176
114
  // notably installObserver when window.hyperclay.Mutation isn't loaded — would
177
- // otherwise strand the mounted shell in the DOM with no way to close it (close()
115
+ // otherwise strand the mounted view in the DOM with no way to close it (close()
178
116
  // early-returns while state.isOpen is false). Tear down whatever was wired,
179
- // exactly as close() does, then rethrow so host misuse still fails loudly.
117
+ // without the close-only side effects (no hcms:close, no URL rewrite, no focus
118
+ // restore), then rethrow so host misuse still fails loudly.
180
119
  try {
181
- const fragment = buildForm({ pageRules, formRules, data, doc })
182
- shell.formRoot.appendChild(fragment)
183
- enhanceFields(shell.formRoot, doc)
184
- applyUnresolvedState(ctx)
120
+ view.mount(ctx.initialData)
121
+ startSession(ctx)
122
+ view.focusOnOpen()
185
123
 
186
- bindEvents(ctx)
187
- ctx.updateFingerprint()
124
+ state.isOpen = true
125
+ state.ctx = ctx
126
+ state.opts = effective
188
127
 
189
- ctx.observerHandle = installObserver({
190
- onRefresh: () => refreshForm(ctx),
191
- })
192
-
193
- // Undo/redo is a deliberate local action (unlike livesync or in-flight typing),
194
- // so the focused form field SHOULD snap to the reverted value. The observer above
195
- // is source-blind, so subscribe to the undo scope and force a non-ignoring refresh.
196
- // Fires synchronously before the debounced observer refresh, which then no-ops on
197
- // the active field. Guarded so it degrades to today's behavior when undo isn't loaded.
198
- const u = platform('undo')
199
- if (u && typeof u.on === 'function') {
200
- const onRevert = () => {
201
- if (state.ctx !== ctx) return
202
- resyncForm(ctx, 'undo')
203
- // A reverted change the consumer persists through onChange (e.g. the
204
- // collection dashboard PUTs the record, not the page) would otherwise lag
205
- // the undo: refreshForm re-syncs the form but never fires onChange, and a
206
- // commit() here would be fingerprint-skipped (refreshForm already updated
207
- // it). Extract the reverted state from the PAGE (the source of truth after
208
- // an undo/redo; the form can lag a morph) and fire onChange directly, but
209
- // only when it actually changed — guards spurious PUTs on undo/redo of
210
- // unrelated edits elsewhere on the page.
211
- const data = coerceBooleans(
212
- engine.extract(ctx.pageRoot, ctx.pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }),
213
- ctx.pageRules
214
- )
215
- if (stableStringify(data) !== stableStringify(ctx.lastData)) {
216
- ctx.lastData = data
217
- ctx.onChange?.(data, { path: '', structural: false })
218
- }
219
- }
220
- u.on('undo', onRevert)
221
- u.on('redo', onRevert)
222
- ctx.undoUnsub = () => { u.off('undo', onRevert); u.off('redo', onRevert) }
223
- }
224
-
225
- // Live-sync / version-restore / any framework-applied full-document morph runs
226
- // inside window.hyperclay.Mutation.pause(), which the source-blind observer
227
- // above is deaf to — so without this the form stays stale after a remote edit
228
- // and the next local commit re-applies the stale data, clobbering the remote
229
- // change. Subscribe to the morph-applied signal live-sync emits and re-extract
230
- // with ignoreActiveValue:true (preserve the user's in-progress field). Same
231
- // document live-sync dispatches on; unsubscribed in close().
232
- const onLivesync = () => resyncForm(ctx, 'livesync')
233
- ctx.livesyncUnsub = onPlatformEvent(doc, LIVESYNC_APPLIED, onLivesync)
234
-
235
- // Wire global sortable callback to current ctx (replaced by close()).
236
- globalCommitTarget.ctx = ctx
237
- installGlobalSortableCommit(doc)
238
-
239
- // Move focus into the shell — survives close+restore via previouslyFocused.
240
- focusFirstIn(shell.root)
241
-
242
- state.isOpen = true
243
- state.ctx = ctx
244
- state.shell = shell
245
- state.opts = opts
246
-
247
- ctx.dispatch('hcms:open', { pageRoot })
128
+ ctx.dispatch('hcms:open', { pageRoot, previous })
248
129
  } catch (err) {
249
- ctx.observerHandle?.unsubscribe?.()
250
- ctx.undoUnsub?.()
251
- ctx.livesyncUnsub?.()
252
- ctx.detachEvents?.()
253
- if (globalCommitTarget.ctx === ctx) globalCommitTarget.ctx = null
254
- suppressUndo(() => shell.destroy())
255
- state.isOpen = false
256
- state.ctx = null
257
- state.shell = null
258
- state.opts = null
130
+ // A failed switch already destroyed the view that was up, so nothing is
131
+ // mounted and nothing can be reopened; the least bad outcome is handing
132
+ // focus back where it started. On a first open focus never moved, so
133
+ // restoring it there would be a no-op either way.
134
+ teardownSession(ctx, { dispatch: false, restoreFocus: !!carriedFocus, updateUrl: false })
259
135
  throw err
260
136
  }
261
137
  }
262
138
 
263
- function focusFirstIn(root) {
264
- const sel = 'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
265
- const target = root.querySelector(sel)
266
- if (target && typeof target.focus === 'function') target.focus()
267
- }
268
-
269
- const globalCommitTarget = { ctx: null }
270
-
271
- function installGlobalSortableCommit(doc) {
272
- const win = doc.defaultView || (typeof globalThis !== 'undefined' ? globalThis : null)
273
- if (!win) return
274
- const hypercmsCommitFn = function hypercmsCommitGlobal() {
275
- const ctx = globalCommitTarget.ctx
276
- if (!ctx) return
277
- restampAllSiblings(ctx.formRoot)
278
- // Route through commitWithUndo so a drag-reorder lands as a labeled
279
- // 'Reorder' commit (not a generic idle 'Edit') and a failed apply's
280
- // mutate+rollback is never recorded as a no-op commit. No-op pass-through
281
- // when undo isn't loaded.
282
- return commitWithUndo('Reorder', () =>
283
- commit(extractFormData(ctx), { path: '', structural: true }, ctx)
284
- )
285
- }
286
- if (typeof win.hypercmsCommit !== 'function') win.hypercmsCommit = hypercmsCommitFn
287
- // Also mirror to globalThis so non-window contexts (Node tests, workers)
288
- // can resolve the bare name from `new Function('hypercmsCommit()')`.
289
- if (typeof globalThis !== 'undefined' && typeof globalThis.hypercmsCommit !== 'function') {
290
- globalThis.hypercmsCommit = hypercmsCommitFn
291
- }
292
- }
293
-
294
- // The query param that drives ?cms=true auto-open. Closing the CMS rewrites it
295
- // to cms=false (param kept) so a refresh/share of the post-close URL does not
296
- // auto-reopen.
297
- const AUTO_OPEN_PARAM = 'cms'
298
-
299
- // Pure: given a location.search string, return the search string after a close.
300
- // Only rewrites when cms=true is present; every other param + ordering is
301
- // preserved, and a search without cms (or already cms=false) is returned
302
- // unchanged so we never inject the param into a page that never carried it.
303
- export function nextSearchAfterClose(search) {
304
- const str = typeof search === 'string' ? search : ''
305
- const qIndex = str.indexOf('?')
306
- const query = qIndex === -1 ? str : str.slice(qIndex + 1)
307
- if (!query) return str
308
- const params = new URLSearchParams(query)
309
- if (params.get(AUTO_OPEN_PARAM) !== 'true') return str
310
- params.set(AUTO_OPEN_PARAM, 'false')
311
- return '?' + params.toString()
312
- }
313
-
314
- // Pure: should the CMS auto-open for this location.search? Exactly 'true'.
315
- export function shouldAutoOpenFromSearch(search) {
316
- const str = typeof search === 'string' ? search : ''
317
- const qIndex = str.indexOf('?')
318
- const query = qIndex === -1 ? str : str.slice(qIndex + 1)
319
- if (!query) return false
320
- return new URLSearchParams(query).get(AUTO_OPEN_PARAM) === 'true'
321
- }
322
-
323
- // Rewrite ?cms=true → ?cms=false via history.replaceState (no reload, hash and
324
- // every other param preserved). No-op when the param isn't present or the
325
- // History API is unavailable.
326
- function toggleCloseParam() {
327
- if (typeof window === 'undefined' || !window.location || !window.history) return
328
- if (typeof window.history.replaceState !== 'function') return
329
- const current = window.location.search
330
- const next = nextSearchAfterClose(current)
331
- if (next === current) return
332
- window.history.replaceState(window.history.state, '', next + window.location.hash)
333
- }
334
-
335
139
  export function close() {
336
140
  if (!state.isOpen) return
337
- const { ctx, shell } = state
338
- // Mark this ctx dead so any in-flight async work (e.g. an upload awaiting the
339
- // host uploader) bails instead of mutating the page / firing onChange after
340
- // teardown. Mirrors the state.ctx !== ctx guards on the undo/livesync paths.
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()
346
- const previouslyFocused = ctx.previouslyFocused
347
- ctx.dispatch('hcms:close', null)
348
- toggleCloseParam()
349
- ctx.observerHandle?.unsubscribe?.()
350
- ctx.undoUnsub?.()
351
- ctx.livesyncUnsub?.()
352
- ctx.detachEvents?.()
353
- // destroy() removes the chrome-only body classes; suppress so the inverse
354
- // class removal doesn't enter the undo stack either.
355
- suppressUndo(() => shell.destroy())
356
- state.isOpen = false
357
- state.ctx = null
358
- state.shell = null
359
- state.opts = null
360
- globalCommitTarget.ctx = null
361
- if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
362
- try { previouslyFocused.focus() } catch (_) {}
363
- }
141
+ teardownSession(state.ctx)
364
142
  }
365
143
 
366
144
  export function refresh() {
367
145
  if (!state.isOpen) return
368
- refreshForm(state.ctx)
146
+ state.ctx.view.refresh('api')
369
147
  }
370
148
 
371
149
  export function isOpen() {
372
150
  return state.isOpen
373
151
  }
374
152
 
153
+ // Which view owns the active session. Nothing may infer this from
154
+ // body.hcms-open, which describes only the sidebar's page shift and says nothing
155
+ // about an inline session.
156
+ export function currentView() {
157
+ return state.isOpen && state.ctx ? state.ctx.view.name : null
158
+ }
159
+
375
160
  export const api = {
376
161
  getData() {
377
162
  if (!state.isOpen) return null
@@ -425,72 +210,12 @@ export const api = {
425
210
  },
426
211
  }
427
212
 
428
- function findLeafField(formRoot, path) {
429
- const esc = cssEscape(path)
430
- // Prefer a tag-qualified leaf (input/textarea/select/img/a) — since v0.3
431
- // stamps data-hcms-field on the wrapping container too, a bare attribute
432
- // match would resolve to the container and lose the value write.
433
- const leafSel =
434
- `[data-hcms-path="${esc}"] input[data-hcms-field], ` +
435
- `[data-hcms-path="${esc}"] textarea[data-hcms-field], ` +
436
- `[data-hcms-path="${esc}"] select[data-hcms-field], ` +
437
- `[data-hcms-path="${esc}"] img[data-hcms-field], ` +
438
- `[data-hcms-path="${esc}"] a[data-hcms-field], ` +
439
- `[data-hcms-path="${esc}"] [contenteditable][data-hcms-field], ` +
440
- // Leaf is the path-stamped element itself (inline-stamped fields):
441
- `input[data-hcms-path="${esc}"][data-hcms-field], ` +
442
- `textarea[data-hcms-path="${esc}"][data-hcms-field], ` +
443
- `select[data-hcms-path="${esc}"][data-hcms-field], ` +
444
- `img[data-hcms-path="${esc}"][data-hcms-field], ` +
445
- `a[data-hcms-path="${esc}"][data-hcms-field], ` +
446
- `[contenteditable][data-hcms-path="${esc}"][data-hcms-field]`
447
- return formRoot.querySelector(leafSel)
448
- }
449
-
450
- function writeFieldValue(el, value, formRoot, path) {
451
- const tag = (el.tagName || '').toUpperCase()
452
- const type = (el.getAttribute('type') || '').toLowerCase()
453
- if (tag === 'INPUT' && type === 'checkbox') {
454
- el.checked = value === true || value === 'true'
455
- return
456
- }
457
- if (tag === 'INPUT' && type === 'radio') {
458
- // Radios sharing the same path act as a group. Toggle the matching option.
459
- const esc = cssEscape(path)
460
- const group = formRoot.querySelectorAll(
461
- `[data-hcms-path="${esc}"][data-hcms-field][type="radio"], [data-hcms-path="${esc}"] [data-hcms-field][type="radio"]`
462
- )
463
- if (group.length) {
464
- group.forEach((r) => { r.checked = String(r.value) === String(value ?? '') })
465
- } else {
466
- el.checked = String(el.value) === String(value ?? '')
467
- }
468
- return
469
- }
470
- if (tag === 'IMG') {
471
- el.src = value == null ? '' : String(value)
472
- return
473
- }
474
- if (tag === 'A') {
475
- el.href = value == null ? '' : String(value)
476
- return
477
- }
478
- if (el.hasAttribute && el.hasAttribute('contenteditable')) {
479
- el.innerHTML = value == null ? '' : String(value)
480
- return
481
- }
482
- if ('value' in el) {
483
- el.value = value == null ? '' : String(value)
484
- return
485
- }
486
- el.textContent = value == null ? '' : String(value)
487
- }
488
-
489
213
  // ?cms=true auto-open. When the page URL carries cms=true at load, open the CMS
490
214
  // for everyone (no gating — this is a feature showcase; a non-admin's edits stay
491
215
  // DOM-local and the host save path shows its view-mode notice). open() is
492
- // idempotent-safe (it warns + returns if already open), so a host that also calls
493
- // open() never double-mounts. No special opts — host defaults apply.
216
+ // idempotent-safe (it returns quietly if the same view is already open), so a
217
+ // host that also calls open() never double-mounts. No special opts — host
218
+ // defaults apply.
494
219
  //
495
220
  // Patience matters here: this runs at module load from the dist IIFE, which a
496
221
  // host typically loads BEFORE installing window.hyperclay.Mutation (the Mutation
@@ -599,6 +324,10 @@ maybeInjectToggle({
599
324
  open,
600
325
  close,
601
326
  isOpen,
327
+ getTheme: () => state.opts?.theme,
328
+ // The same map open() resolves a view name against, so the split button
329
+ // offers exactly the views this build can actually render.
330
+ views: Object.keys(VIEWS),
602
331
  hasRules: (doc) => !!engine.findRules(doc, 'cms'),
603
332
  })
604
333
 
@@ -608,6 +337,7 @@ const cms = {
608
337
  refresh,
609
338
  api,
610
339
  get isOpen() { return state.isOpen },
340
+ currentView,
611
341
  // Power-user exports (mostly for testing + advanced integration)
612
342
  path: pathUtil,
613
343
  scaffold,
@@ -0,0 +1,15 @@
1
+ const paths = {
2
+ edit: 'M8 10h2v6H8zm2 4h4v2h-4zm0-6h2v2h-2zm2-2h2v2h-2zm2-2h2v2h-2zm2-2h2v2h-2zm2 2h2v2h-2zm2 2h2v2h-2zm-2 2h2v2h-2zm-2 2h2v2h-2zm-2 2h2v2h-2zm-4 0h2v2h-2z',
3
+ 'move-up': 'M11 4h2v2h2v2h2v2h2v2h-4v-2h-2v10h-2V10H9v2H5v-2h2V8h2V6h2z',
4
+ 'move-down': 'M11 4h2v10h2v-2h4v2h-2v2h-2v2h-2v2h-2v-2H9v-2H7v-2H5v-2h4v2h2z',
5
+ remove: 'M5 5h3v3h3v3h2V8h3V5h3v3h-3v3h-3v2h3v3h3v3h-3v-3h-3v-3h-2v3H8v3H5v-3h3v-3h3v-2H8V8H5z',
6
+ add: 'M10 4h4v6h6v4h-6v6h-4v-6H4v-4h6z',
7
+ }
8
+
9
+ export function inlineIcon(name) {
10
+ const viewBox = name === 'edit' ? '6 0 18 18' : '0 0 24 24'
11
+ const shape = name === 'settings'
12
+ ? '<circle cx="6" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="18" cy="12" r="1.5"/>'
13
+ : `<path d="${paths[name]}"/>`
14
+ return `<svg class="hcms-inline-icon" xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="24" height="24" fill="currentColor" aria-hidden="true" focusable="false">${shape}</svg>`
15
+ }