@panphora/hyper-cms 0.10.0 → 0.11.1

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,383 +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 { rowIdentitySeeder } from './row-identity.js'
25
- 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'
26
25
  import { maybeInjectToggle } from './toggle.js'
27
- 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 }
28
34
 
29
35
  export function installStyles(text) {
30
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
+ }
31
42
  }
32
43
 
33
44
  export function markBundledStyles(doc) {
34
45
  markStylesBundled(doc)
35
46
  }
36
47
 
37
- const state = {
38
- isOpen: false,
39
- ctx: null,
40
- shell: null,
41
- opts: null,
42
- }
43
-
44
- // Re-sync the form to the page after a change hypercms did NOT originate.
45
- // `reason` selects whether the field the user is currently typing in snaps:
46
- // 'undo' / 'redo' — the user's own deliberate action, so the focused field
47
- // SHOULD snap to the reverted value (ignoreActiveValue:false).
48
- // 'livesync' — someone else's / the framework's change, so the user's
49
- // in-progress focused field must NOT be yanked away
50
- // (ignoreActiveValue:true). morphForm re-extracts every
51
- // other field to the new page value, killing the stale-form
52
- // clobber, while leaving the active field alone until blur.
53
- // Both reasons route through this one handler so there is a single place to
54
- // maintain the form-resync behavior.
55
- function resyncForm(ctx, reason) {
56
- // open() may have moved on (closed, or reopened with a new ctx) between an
57
- // async morph completing and this firing.
58
- if (state.ctx !== ctx) return
59
- const ignoreActiveValue = reason === 'livesync'
60
- if (reason === 'livesync') {
61
- // A full-document morph can wipe the shell's <head> stylesheet and the
62
- // <body> chrome classes (both live outside the save-ignore shell subtree).
63
- // Re-assert them before re-extracting.
64
- state.shell?.restoreChrome?.()
65
- }
66
- refreshForm(ctx, { ignoreActiveValue })
67
- }
68
-
69
- // Strip hypercms's own body chrome from the SAVE clone so it never reaches disk
70
- // (the shell element itself is [save-remove], but the hcms-open class lives on
71
- // <body>, outside the stripped subtree). Registered once at first open():
72
- // onPrepareForSave runs save-only, on a clone, so it never touches the live DOM,
73
- // and removing absent classes is a no-op when the shell is closed. Guarded so it
74
- // degrades cleanly when the host save pipeline (hyperclayjs) isn't present.
75
- let prepareHookInstalled = false
76
- function installSavePrepareHook() {
77
- if (prepareHookInstalled) return
78
- const onPrepareForSave = platform('onPrepareForSave')
79
- if (typeof onPrepareForSave !== 'function') return
80
- onPrepareForSave((clonedDocEl) => {
81
- const b = clonedDocEl && clonedDocEl.querySelector && clonedDocEl.querySelector('body')
82
- if (b) b.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
83
- })
84
- 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,
85
54
  }
86
55
 
87
56
  export function open(opts = {}) {
88
- if (state.isOpen) {
89
- console.warn('cms.open() called while already open; ignoring')
90
- 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 ')})`)
91
66
  }
92
- installSavePrepareHook()
93
- 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)
94
77
  if (!pageRoot) throw new Error('hypercms: no pageRoot available')
95
78
  const doc = pageRoot.ownerDocument || (typeof document !== 'undefined' ? document : null)
96
79
  if (!doc) throw new Error('hypercms: no document available')
97
80
 
98
- // Resolve rules via the union: an explicit rules object, a token string, or
99
- // the default token "cms". findRules is document-scoped, so a head-mounted
100
- // rules tag is found even though pageRoot defaults to <body>.
101
- const source = opts.rules !== undefined ? opts.rules : 'cms'
102
- const found = engine.findRules(doc, source)
103
- if (!found) {
104
- const what = typeof source === 'string' ? `data-rules-name~="${source}"` : 'the provided rules object'
105
- 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' })
106
94
  }
107
- // Rich-text upgrade (default on): bare scalar rules whose page element
108
- // contains child elements are rebound through @innerHTML so links and
109
- // inline formatting survive the round-trip, and their form fields render
110
- // the @richtext component. Opt out with cms.open({ richText: false }).
111
- const richText = opts.richText !== false
112
- const pageRules = richText ? upgradeRichTextRules(found.rules, pageRoot) : found.rules
113
- const rulesTagNode = found.tagNode
114
-
115
- injectDefaults(doc)
116
- injectComponents(doc, pageRules)
117
- warnUnmatchedTemplates(doc, pageRules)
118
- const formRules = deriveFormRules(pageRules, doc)
119
- // Runs before the first extract so a rule with invalid CSS names its field
120
- // instead of throwing a bare SyntaxError out of querySelectorAll.
121
- const unresolved = findUnresolved(pageRoot, pageRules)
122
- // Bind each form row to the page row it stands for while the two are still
123
- // known to correspond, which is now: the form below is built from exactly this
124
- // read. Without it the first structural operation of a session has no identity
125
- // to go on and falls back to content matching.
126
- const seeder = rowIdentitySeeder()
127
- const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template', ...seeder.hooks }), pageRules)
128
95
 
129
- // Suppress undo around mountShell: it toggles chrome-only classes on
130
- // document.body (hcms-open, etc.) which would otherwise land as undoable
131
- // page edits. The shell subtree itself is already filtered via save-ignore.
132
- const shell = suppressUndo(() => mountShell({
133
- mountTo: opts.mountTo || doc.body,
134
- side: opts.side || 'right',
135
- overlay: !!opts.overlay,
136
- showSaveButton: !!opts.showSaveButton,
137
- title: opts.title,
138
- eyebrow: opts.eyebrow,
139
- theme: opts.theme,
140
- doc,
141
- }))
96
+ installHooks()
142
97
 
143
- const ctx = {
98
+ const view = makeView({ doc, pageRoot, opts: effective })
99
+ const ctx = createSession({
100
+ view,
144
101
  doc,
145
102
  pageRoot,
146
- pageRules,
147
- formRules,
148
- rulesTagNode,
149
- rulesSource: source,
150
- richText,
151
- formRoot: shell.formRoot,
152
- shellRoot: shell.root,
153
- errorEl: shell.errorEl,
154
- noticeEl: shell.noticeEl,
155
- unresolved,
156
- lastTwinSignature: null,
157
- lastFingerprint: null,
158
- lastData: null,
159
- observerHandle: null,
160
- undoUnsub: null,
161
- livesyncUnsub: null,
162
- onChange: opts.onChange,
163
- onError: opts.onError,
164
- confirmRemove: opts.confirmRemove,
165
- previouslyFocused: doc.activeElement,
166
- dispatch(name, detail) {
167
- const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
168
- if (!Ctor) return
169
- const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change', detail })
170
- shell.root.dispatchEvent(ev)
171
- },
172
- onCloseRequested() {
173
- close()
174
- },
175
- }
176
- ctx.updateFingerprint = () => {
177
- ctx.lastFingerprint = stableStringify(extractFormData(ctx))
178
- }
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
179
111
 
180
- // Everything past the shell mount touches the live DOM, the module-level state,
181
- // 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
182
114
  // notably installObserver when window.hyperclay.Mutation isn't loaded — would
183
- // 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()
184
116
  // early-returns while state.isOpen is false). Tear down whatever was wired,
185
- // 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.
186
119
  try {
187
- const fragment = buildForm({ pageRules, formRules, data, doc })
188
- shell.formRoot.appendChild(fragment)
189
- seeder.seed(shell.formRoot)
190
- enhanceFields(shell.formRoot, doc)
191
- applyUnresolvedState(ctx)
120
+ view.mount(ctx.initialData)
121
+ startSession(ctx)
122
+ view.focusOnOpen()
192
123
 
193
- bindEvents(ctx)
194
- ctx.updateFingerprint()
124
+ state.isOpen = true
125
+ state.ctx = ctx
126
+ state.opts = effective
195
127
 
196
- ctx.observerHandle = installObserver({
197
- onRefresh: () => refreshForm(ctx),
198
- })
199
-
200
- // Undo/redo is a deliberate local action (unlike livesync or in-flight typing),
201
- // so the focused form field SHOULD snap to the reverted value. The observer above
202
- // is source-blind, so subscribe to the undo scope and force a non-ignoring refresh.
203
- // Fires synchronously before the debounced observer refresh, which then no-ops on
204
- // the active field. Guarded so it degrades to today's behavior when undo isn't loaded.
205
- const u = platform('undo')
206
- if (u && typeof u.on === 'function') {
207
- const onRevert = () => {
208
- if (state.ctx !== ctx) return
209
- resyncForm(ctx, 'undo')
210
- // A reverted change the consumer persists through onChange (e.g. the
211
- // collection dashboard PUTs the record, not the page) would otherwise lag
212
- // the undo: refreshForm re-syncs the form but never fires onChange, and a
213
- // commit() here would be fingerprint-skipped (refreshForm already updated
214
- // it). Extract the reverted state from the PAGE (the source of truth after
215
- // an undo/redo; the form can lag a morph) and fire onChange directly, but
216
- // only when it actually changed — guards spurious PUTs on undo/redo of
217
- // unrelated edits elsewhere on the page.
218
- const data = coerceBooleans(
219
- engine.extract(ctx.pageRoot, ctx.pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }),
220
- ctx.pageRules
221
- )
222
- if (stableStringify(data) !== stableStringify(ctx.lastData)) {
223
- ctx.lastData = data
224
- ctx.onChange?.(data, { path: '', structural: false })
225
- }
226
- }
227
- u.on('undo', onRevert)
228
- u.on('redo', onRevert)
229
- ctx.undoUnsub = () => { u.off('undo', onRevert); u.off('redo', onRevert) }
230
- }
231
-
232
- // Live-sync / version-restore / any framework-applied full-document morph runs
233
- // inside window.hyperclay.Mutation.pause(), which the source-blind observer
234
- // above is deaf to — so without this the form stays stale after a remote edit
235
- // and the next local commit re-applies the stale data, clobbering the remote
236
- // change. Subscribe to the morph-applied signal live-sync emits and re-extract
237
- // with ignoreActiveValue:true (preserve the user's in-progress field). Same
238
- // document live-sync dispatches on; unsubscribed in close().
239
- const onLivesync = () => resyncForm(ctx, 'livesync')
240
- ctx.livesyncUnsub = onPlatformEvent(doc, LIVESYNC_APPLIED, onLivesync)
241
-
242
- // Wire global sortable callback to current ctx (replaced by close()).
243
- globalCommitTarget.ctx = ctx
244
- installGlobalSortableCommit(doc)
245
-
246
- // Move focus into the shell — survives close+restore via previouslyFocused.
247
- focusFirstIn(shell.root)
248
-
249
- state.isOpen = true
250
- state.ctx = ctx
251
- state.shell = shell
252
- state.opts = opts
253
-
254
- ctx.dispatch('hcms:open', { pageRoot })
128
+ ctx.dispatch('hcms:open', { pageRoot, previous })
255
129
  } catch (err) {
256
- ctx.observerHandle?.unsubscribe?.()
257
- ctx.undoUnsub?.()
258
- ctx.livesyncUnsub?.()
259
- ctx.detachEvents?.()
260
- if (globalCommitTarget.ctx === ctx) globalCommitTarget.ctx = null
261
- suppressUndo(() => shell.destroy())
262
- state.isOpen = false
263
- state.ctx = null
264
- state.shell = null
265
- 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 })
266
135
  throw err
267
136
  }
268
137
  }
269
138
 
270
- function focusFirstIn(root) {
271
- const sel = 'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
272
- const target = root.querySelector(sel)
273
- if (target && typeof target.focus === 'function') target.focus()
274
- }
275
-
276
- const globalCommitTarget = { ctx: null }
277
-
278
- function installGlobalSortableCommit(doc) {
279
- const win = doc.defaultView || (typeof globalThis !== 'undefined' ? globalThis : null)
280
- if (!win) return
281
- const hypercmsCommitFn = function hypercmsCommitGlobal() {
282
- const ctx = globalCommitTarget.ctx
283
- if (!ctx) return
284
- restampAllSiblings(ctx.formRoot)
285
- // Route through commitWithUndo so a drag-reorder lands as a labeled
286
- // 'Reorder' commit (not a generic idle 'Edit') and a failed apply's
287
- // mutate+rollback is never recorded as a no-op commit. No-op pass-through
288
- // when undo isn't loaded.
289
- return commitWithUndo('Reorder', () =>
290
- commit(extractFormData(ctx), { path: '', structural: true }, ctx)
291
- )
292
- }
293
- if (typeof win.hypercmsCommit !== 'function') win.hypercmsCommit = hypercmsCommitFn
294
- // Also mirror to globalThis so non-window contexts (Node tests, workers)
295
- // can resolve the bare name from `new Function('hypercmsCommit()')`.
296
- if (typeof globalThis !== 'undefined' && typeof globalThis.hypercmsCommit !== 'function') {
297
- globalThis.hypercmsCommit = hypercmsCommitFn
298
- }
299
- }
300
-
301
- // The query param that drives ?cms=true auto-open. Closing the CMS rewrites it
302
- // to cms=false (param kept) so a refresh/share of the post-close URL does not
303
- // auto-reopen.
304
- const AUTO_OPEN_PARAM = 'cms'
305
-
306
- // Pure: given a location.search string, return the search string after a close.
307
- // Only rewrites when cms=true is present; every other param + ordering is
308
- // preserved, and a search without cms (or already cms=false) is returned
309
- // unchanged so we never inject the param into a page that never carried it.
310
- export function nextSearchAfterClose(search) {
311
- const str = typeof search === 'string' ? search : ''
312
- const qIndex = str.indexOf('?')
313
- const query = qIndex === -1 ? str : str.slice(qIndex + 1)
314
- if (!query) return str
315
- const params = new URLSearchParams(query)
316
- if (params.get(AUTO_OPEN_PARAM) !== 'true') return str
317
- params.set(AUTO_OPEN_PARAM, 'false')
318
- return '?' + params.toString()
319
- }
320
-
321
- // Pure: should the CMS auto-open for this location.search? Exactly 'true'.
322
- export function shouldAutoOpenFromSearch(search) {
323
- const str = typeof search === 'string' ? search : ''
324
- const qIndex = str.indexOf('?')
325
- const query = qIndex === -1 ? str : str.slice(qIndex + 1)
326
- if (!query) return false
327
- return new URLSearchParams(query).get(AUTO_OPEN_PARAM) === 'true'
328
- }
329
-
330
- // Rewrite ?cms=true → ?cms=false via history.replaceState (no reload, hash and
331
- // every other param preserved). No-op when the param isn't present or the
332
- // History API is unavailable.
333
- function toggleCloseParam() {
334
- if (typeof window === 'undefined' || !window.location || !window.history) return
335
- if (typeof window.history.replaceState !== 'function') return
336
- const current = window.location.search
337
- const next = nextSearchAfterClose(current)
338
- if (next === current) return
339
- window.history.replaceState(window.history.state, '', next + window.location.hash)
340
- }
341
-
342
139
  export function close() {
343
140
  if (!state.isOpen) return
344
- const { ctx, shell } = state
345
- // Mark this ctx dead so any in-flight async work (e.g. an upload awaiting the
346
- // host uploader) bails instead of mutating the page / firing onChange after
347
- // teardown. Mirrors the state.ctx !== ctx guards on the undo/livesync paths.
348
- ctx.closed = true
349
- // An upload still in flight is torn down like any other subscription. The
350
- // ctx.closed check above already stops it writing; this stops it running.
351
- for (const controller of ctx.uploads || []) { try { controller.abort() } catch {} }
352
- ctx.uploads?.clear()
353
- const previouslyFocused = ctx.previouslyFocused
354
- ctx.dispatch('hcms:close', null)
355
- toggleCloseParam()
356
- ctx.observerHandle?.unsubscribe?.()
357
- ctx.undoUnsub?.()
358
- ctx.livesyncUnsub?.()
359
- ctx.detachEvents?.()
360
- // destroy() removes the chrome-only body classes; suppress so the inverse
361
- // class removal doesn't enter the undo stack either.
362
- suppressUndo(() => shell.destroy())
363
- state.isOpen = false
364
- state.ctx = null
365
- state.shell = null
366
- state.opts = null
367
- globalCommitTarget.ctx = null
368
- if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
369
- try { previouslyFocused.focus() } catch (_) {}
370
- }
141
+ teardownSession(state.ctx)
371
142
  }
372
143
 
373
144
  export function refresh() {
374
145
  if (!state.isOpen) return
375
- refreshForm(state.ctx)
146
+ state.ctx.view.refresh('api')
376
147
  }
377
148
 
378
149
  export function isOpen() {
379
150
  return state.isOpen
380
151
  }
381
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
+
382
160
  export const api = {
383
161
  getData() {
384
162
  if (!state.isOpen) return null
@@ -432,72 +210,12 @@ export const api = {
432
210
  },
433
211
  }
434
212
 
435
- function findLeafField(formRoot, path) {
436
- const esc = cssEscape(path)
437
- // Prefer a tag-qualified leaf (input/textarea/select/img/a) — since v0.3
438
- // stamps data-hcms-field on the wrapping container too, a bare attribute
439
- // match would resolve to the container and lose the value write.
440
- const leafSel =
441
- `[data-hcms-path="${esc}"] input[data-hcms-field], ` +
442
- `[data-hcms-path="${esc}"] textarea[data-hcms-field], ` +
443
- `[data-hcms-path="${esc}"] select[data-hcms-field], ` +
444
- `[data-hcms-path="${esc}"] img[data-hcms-field], ` +
445
- `[data-hcms-path="${esc}"] a[data-hcms-field], ` +
446
- `[data-hcms-path="${esc}"] [contenteditable][data-hcms-field], ` +
447
- // Leaf is the path-stamped element itself (inline-stamped fields):
448
- `input[data-hcms-path="${esc}"][data-hcms-field], ` +
449
- `textarea[data-hcms-path="${esc}"][data-hcms-field], ` +
450
- `select[data-hcms-path="${esc}"][data-hcms-field], ` +
451
- `img[data-hcms-path="${esc}"][data-hcms-field], ` +
452
- `a[data-hcms-path="${esc}"][data-hcms-field], ` +
453
- `[contenteditable][data-hcms-path="${esc}"][data-hcms-field]`
454
- return formRoot.querySelector(leafSel)
455
- }
456
-
457
- function writeFieldValue(el, value, formRoot, path) {
458
- const tag = (el.tagName || '').toUpperCase()
459
- const type = (el.getAttribute('type') || '').toLowerCase()
460
- if (tag === 'INPUT' && type === 'checkbox') {
461
- el.checked = value === true || value === 'true'
462
- return
463
- }
464
- if (tag === 'INPUT' && type === 'radio') {
465
- // Radios sharing the same path act as a group. Toggle the matching option.
466
- const esc = cssEscape(path)
467
- const group = formRoot.querySelectorAll(
468
- `[data-hcms-path="${esc}"][data-hcms-field][type="radio"], [data-hcms-path="${esc}"] [data-hcms-field][type="radio"]`
469
- )
470
- if (group.length) {
471
- group.forEach((r) => { r.checked = String(r.value) === String(value ?? '') })
472
- } else {
473
- el.checked = String(el.value) === String(value ?? '')
474
- }
475
- return
476
- }
477
- if (tag === 'IMG') {
478
- el.src = value == null ? '' : String(value)
479
- return
480
- }
481
- if (tag === 'A') {
482
- el.href = value == null ? '' : String(value)
483
- return
484
- }
485
- if (el.hasAttribute && el.hasAttribute('contenteditable')) {
486
- el.innerHTML = value == null ? '' : String(value)
487
- return
488
- }
489
- if ('value' in el) {
490
- el.value = value == null ? '' : String(value)
491
- return
492
- }
493
- el.textContent = value == null ? '' : String(value)
494
- }
495
-
496
213
  // ?cms=true auto-open. When the page URL carries cms=true at load, open the CMS
497
214
  // for everyone (no gating — this is a feature showcase; a non-admin's edits stay
498
215
  // DOM-local and the host save path shows its view-mode notice). open() is
499
- // idempotent-safe (it warns + returns if already open), so a host that also calls
500
- // 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.
501
219
  //
502
220
  // Patience matters here: this runs at module load from the dist IIFE, which a
503
221
  // host typically loads BEFORE installing window.hyperclay.Mutation (the Mutation
@@ -606,6 +324,10 @@ maybeInjectToggle({
606
324
  open,
607
325
  close,
608
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),
609
331
  hasRules: (doc) => !!engine.findRules(doc, 'cms'),
610
332
  })
611
333
 
@@ -615,6 +337,7 @@ const cms = {
615
337
  refresh,
616
338
  api,
617
339
  get isOpen() { return state.isOpen },
340
+ currentView,
618
341
  // Power-user exports (mostly for testing + advanced integration)
619
342
  path: pathUtil,
620
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
+ }