@panphora/hyper-cms 0.11.6 → 0.13.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/form-rules.js CHANGED
@@ -5,12 +5,19 @@ import {
5
5
  componentForScalarRule,
6
6
  winningScalarArrayComponent,
7
7
  } from './templates.js'
8
+ import { getRuleAtPath } from './path.js'
8
9
 
9
10
  const TAG_PROP_MAP = {
10
11
  IMG: 'src',
11
12
  A: 'href',
12
13
  }
13
14
 
15
+ // The path with every row index replaced by "*": what a field IS, whichever
16
+ // row it sits in. Form rules scope repeated keys by it.
17
+ export function shapeOf(pathArr) {
18
+ return pathArr.map((seg) => (typeof seg === 'number' || /^[0-9]+$/.test(String(seg)) ? '*' : String(seg))).join('.')
19
+ }
20
+
14
21
  export function fieldPropertyFor(el) {
15
22
  if (!el) return 'value'
16
23
  const tag = (el.tagName || '').toUpperCase()
@@ -81,7 +88,29 @@ function cssEscape(value) {
81
88
 
82
89
  const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
83
90
 
91
+ // How many scalar leaves, anywhere in the rules, end in each key.
92
+ function countLeafKeys(pageRules) {
93
+ const counts = new Map()
94
+ walk(pageRules, null)
95
+ return counts
96
+
97
+ function walk(rule, key) {
98
+ if (typeof rule === 'string') {
99
+ if (typeof key === 'string' && !rule.endsWith('[]')) counts.set(key, (counts.get(key) || 0) + 1)
100
+ return
101
+ }
102
+ if (Array.isArray(rule)) {
103
+ walk(rule[1], null)
104
+ return
105
+ }
106
+ if (rule && typeof rule === 'object') {
107
+ for (const [k, child] of Object.entries(rule)) walk(child, k)
108
+ }
109
+ }
110
+ }
111
+
84
112
  export function deriveFormRules(pageRules, doc) {
113
+ const leafKeyCounts = countLeafKeys(pageRules)
85
114
  return derive(pageRules, [])
86
115
 
87
116
  function derive(rule, pathArr) {
@@ -106,15 +135,30 @@ export function deriveFormRules(pageRules, doc) {
106
135
  const key = pathArr.length ? pathArr[pathArr.length - 1] : null
107
136
  const fieldKey = typeof key === 'string' ? key : '__value'
108
137
  const inlineEl = findInlineFieldEl(pathArr, fieldKey)
109
- if (inlineEl) return fieldSelectorFor(inlineEl, fieldKey)
138
+ if (inlineEl) return scopeRepeatedKey(pathArr, fieldKey, fieldSelectorFor(inlineEl, fieldKey))
110
139
  // No path-bound override: resolve the component this rule opts into
111
140
  // (@scalar / @image / @file) and derive the selector from THAT template's
112
141
  // leaf tag, so an @image field reads img@src and an @file field reads
113
142
  // a@href, while a plain scalar still respects a global @scalar override
114
143
  // (e.g. a <textarea>) instead of assuming <input>.
115
144
  const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc, pathArr, pageRules), fieldKey)
116
- if (shapeEl) return fieldSelectorFor(shapeEl, fieldKey)
117
- return `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`
145
+ if (shapeEl) return scopeRepeatedKey(pathArr, fieldKey, fieldSelectorFor(shapeEl, fieldKey))
146
+ return scopeRepeatedKey(pathArr, fieldKey, `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`)
147
+ }
148
+
149
+ // Two scalars with the same last key (hero.background, features.background,
150
+ // or a card's title and its links' titles) stamp the same data-hcms-field, so
151
+ // a bare key selector reads the first one for all of them and a commit copies
152
+ // one field's value into another. A repeated key is scoped by the field's
153
+ // shape (its path with row indices as "*"), matched exactly, on the field
154
+ // itself or on the wrapper around it.
155
+ function scopeRepeatedKey(pathArr, fieldKey, selector) {
156
+ if ((leafKeyCounts.get(fieldKey) || 0) < 2) return selector
157
+ const scope = `[data-hcms-path-shape="${cssEscape(shapeOf(pathArr))}"]`
158
+ const at = selector.lastIndexOf('@')
159
+ const sel = at === -1 ? selector : selector.slice(0, at)
160
+ const prop = at === -1 ? '' : selector.slice(at)
161
+ return `${scope} ${sel}, ${sel}${scope}${prop}`
118
162
  }
119
163
 
120
164
  function scalarArrayRule(rule, pathArr) {
@@ -190,6 +234,7 @@ export function deriveFormRules(pageRules, doc) {
190
234
  const candidates = [pathStr, wildcardKey]
191
235
  for (let i = pathArr.length - 1; i >= 0; i--) {
192
236
  const slice = pathArr.slice(0, i).map((s) => (typeof s === 'number' ? '*' : s))
237
+ if (shapeKindOf(getRuleAtPath(pageRules, slice)) === 'object') candidates.push(slice.join('.'))
193
238
  slice.push('*')
194
239
  candidates.push(slice.join('.'))
195
240
  }
package/src/hypercms.js CHANGED
@@ -20,9 +20,13 @@ import {
20
20
  teardownSession,
21
21
  nextSearchAfterClose,
22
22
  shouldAutoOpenFromSearch,
23
+ modeLifecycle,
24
+ canWriteNow,
25
+ pendingWork,
26
+ quiesceSession,
27
+ resumeSession,
23
28
  } from './session.js'
24
29
  import { findLeafField, writeFieldValue } from './events.js'
25
- import { maybeInjectToggle } from './toggle.js'
26
30
  import { platform, onPlatformEvent, MUTATION_READY } from './platform.js'
27
31
  import { installHooks } from './hooks.js'
28
32
 
@@ -53,17 +57,36 @@ const VIEWS = {
53
57
  inline: createInlineView,
54
58
  }
55
59
 
60
+ // The page says how it wants to be edited, on the rules tag the session will
61
+ // use: data-cms-view="inline". No tag to read it from (a literal rules object),
62
+ // no attribute, or "sidebar" means the sidebar. An unknown value is a page
63
+ // authoring mistake, not a caller error, so it warns and opens the sidebar.
64
+ function declaredView(doc, rules) {
65
+ const source = rules !== undefined ? rules : 'cms'
66
+ if (typeof source !== 'string') return 'sidebar'
67
+ const declared = engine.findRules(doc, source)?.tagNode?.getAttribute?.('data-cms-view')
68
+ if (!declared || declared === 'sidebar') return 'sidebar'
69
+ if (VIEWS[declared]) return declared
70
+ console.warn(`hypercms: unknown data-cms-view "${declared}" on the rules tag, opening the sidebar`)
71
+ return 'sidebar'
72
+ }
73
+
56
74
  export function open(opts = {}) {
75
+ return openSession(opts)
76
+ }
77
+
78
+ function modeExit(doc) {
79
+ const win = doc && doc.defaultView
80
+ if (!modeLifecycle(win)) return null
81
+ const setMode = win.clay && win.clay.setMode
82
+ if (typeof setMode !== 'function') return null
83
+ return setMode.call(win.clay, 'view')
84
+ }
85
+
86
+ function openSession(opts = {}, admitted = false) {
57
87
  // Everything that can be rejected is rejected before anything is destroyed. A
58
88
  // bad view name or a missing page must not leave a caller with the session
59
89
  // 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 ')})`)
66
- }
67
90
  // A switch changes the presentation of one session, not the session. Someone
68
91
  // calling open({ view: 'sidebar' }) means "show me this in the sidebar"; they
69
92
  // are not restating the pageRoot, the rules and the callbacks they opened
@@ -71,12 +94,23 @@ export function open(opts = {}) {
71
94
  // document.body with the default rules tag and stops calling onChange.
72
95
  // state.opts holds the active session's options and teardown clears it, so it
73
96
  // has to be read here, before anything is torn down.
74
- const effective = state.isOpen ? { ...state.opts, ...opts, view: viewName } : opts
97
+ const merged = state.isOpen ? { ...state.opts, ...opts } : opts
75
98
 
76
- const pageRoot = effective.pageRoot || (typeof document !== 'undefined' ? document.body : null)
99
+ const pageRoot = merged.pageRoot || (typeof document !== 'undefined' ? document : null)
77
100
  if (!pageRoot) throw new Error('hypercms: no pageRoot available')
78
- const doc = pageRoot.ownerDocument || (typeof document !== 'undefined' ? document : null)
101
+ const doc = (pageRoot.nodeType === 9 ? pageRoot : pageRoot.ownerDocument) || (typeof document !== 'undefined' ? document : null)
79
102
  if (!doc) throw new Error('hypercms: no document available')
103
+ if (!admitted && !canWriteNow(doc.defaultView)) return { ok: false, code: 'not-allowed' }
104
+
105
+ // A bare open() means "make sure the editor is up", not "put it in the
106
+ // sidebar": with a session up it keeps that view, otherwise it opens the view
107
+ // the page declares.
108
+ const viewName = opts.view || (state.isOpen ? state.ctx.view.name : declaredView(doc, merged.rules))
109
+ const makeView = VIEWS[viewName]
110
+ if (!makeView) {
111
+ throw new Error(`hypercms: unknown view "${viewName}" (expected ${Object.keys(VIEWS).join(' or ')})`)
112
+ }
113
+ const effective = state.isOpen ? { ...merged, view: viewName } : opts
80
114
 
81
115
  // Reopening the view that is already up is a silent no-op, not a warning: a
82
116
  // host wiring a button to open({ view }) should be able to call it without
@@ -87,7 +121,7 @@ export function open(opts = {}) {
87
121
  let carriedFocus = null
88
122
  let previous = null
89
123
  if (state.isOpen) {
90
- if (state.ctx.view.name === viewName) return
124
+ if (state.ctx.view.name === viewName) return { ok: true, view: viewName }
91
125
  carriedFocus = state.ctx.previouslyFocused
92
126
  previous = state.ctx.view.name
93
127
  teardownSession(state.ctx, { restoreFocus: false, updateUrl: false, reason: 'switch' })
@@ -101,8 +135,8 @@ export function open(opts = {}) {
101
135
  doc,
102
136
  pageRoot,
103
137
  opts: effective,
104
- onCloseRequested: () => close(),
105
- onViewRequested: (name) => open({ view: name }),
138
+ onCloseRequested: () => modeExit(doc) || close(),
139
+ onViewRequested: (name) => openSession({ view: name }, true),
106
140
  })
107
141
  // Where focus was before the FIRST open, carried across the switch. Closing
108
142
  // the second view should return the person where they started, not to
@@ -134,11 +168,19 @@ export function open(opts = {}) {
134
168
  teardownSession(ctx, { dispatch: false, restoreFocus: !!carriedFocus, updateUrl: false })
135
169
  throw err
136
170
  }
171
+ return { ok: true, view: viewName }
137
172
  }
138
173
 
139
174
  export function close() {
140
- if (!state.isOpen) return
175
+ if (!state.isOpen) return { ok: false, code: 'no-session' }
176
+ const doc = state.ctx.doc
177
+ if (modeLifecycle(doc && doc.defaultView)) {
178
+ const pending = pendingWork(state.ctx)
179
+ if (pending.length) return { ok: false, code: 'pending-input', pending }
180
+ if (!canWriteNow(doc && doc.defaultView)) return { ok: false, code: 'busy' }
181
+ }
141
182
  teardownSession(state.ctx)
183
+ return { ok: true }
142
184
  }
143
185
 
144
186
  export function refresh() {
@@ -157,13 +199,58 @@ export function currentView() {
157
199
  return state.isOpen && state.ctx ? state.ctx.view.name : null
158
200
  }
159
201
 
202
+ export function prepareModeLease({ doc, pageRoot, opts = {} } = {}) {
203
+ let owned = null
204
+ let disposed = false
205
+ return {
206
+ ready: Promise.resolve(),
207
+ enable() {
208
+ if (disposed) return
209
+ const target = { ...opts }
210
+ if (pageRoot) target.pageRoot = pageRoot
211
+ if (!target.pageRoot) target.pageRoot = (doc && doc.body) || doc || null
212
+ openSession(target, true)
213
+ owned = state.ctx
214
+ },
215
+ quiesce() {
216
+ if (disposed || !owned || state.ctx !== owned) return { ok: true }
217
+ return quiesceSession(owned)
218
+ },
219
+ resume() {
220
+ if (disposed || !owned || state.ctx !== owned) return
221
+ resumeSession(owned)
222
+ },
223
+ dispose() {
224
+ if (disposed) return
225
+ disposed = true
226
+ if (owned && state.ctx === owned) teardownSession(owned)
227
+ owned = null
228
+ },
229
+ }
230
+ }
231
+
232
+ export function modeParticipant(options = {}) {
233
+ return {
234
+ id: 'hypercms',
235
+ order: 1000,
236
+ prepare() {
237
+ return prepareModeLease(options)
238
+ },
239
+ }
240
+ }
241
+
242
+ function assertAdmitting() {
243
+ if (!state.isOpen) throw new Error('hypercms: cms is not open')
244
+ if (state.ctx.quiesced) throw new Error('hypercms: the CMS is not admitting new changes')
245
+ }
246
+
160
247
  export const api = {
161
248
  getData() {
162
249
  if (!state.isOpen) return null
163
250
  return extractFormData(state.ctx)
164
251
  },
165
252
  setValue(path, value) {
166
- if (!state.isOpen) throw new Error('hypercms: cms is not open')
253
+ assertAdmitting()
167
254
  const ctx = state.ctx
168
255
  const pathArr = pathUtil.fromString(path)
169
256
  const rule = pathUtil.getRuleAtPath(ctx.pageRules, pathArr)
@@ -177,11 +264,11 @@ export const api = {
177
264
  commit(extractFormData(ctx), { path, structural: false }, ctx)
178
265
  },
179
266
  addItem(arrayPath) {
180
- if (!state.isOpen) throw new Error('hypercms: cms is not open')
267
+ assertAdmitting()
181
268
  evOnAdd(arrayPath, state.ctx)
182
269
  },
183
270
  removeItem(itemPath) {
184
- if (!state.isOpen) throw new Error('hypercms: cms is not open')
271
+ assertAdmitting()
185
272
  const ctx = state.ctx
186
273
  const pathArr = pathUtil.fromString(itemPath)
187
274
  // Validate path: must be an array item — last segment is a non-negative
@@ -201,7 +288,7 @@ export const api = {
201
288
  },
202
289
  refresh,
203
290
  _commit() {
204
- if (!state.isOpen) return
291
+ if (!state.isOpen || state.ctx.quiesced) return
205
292
  const ctx = state.ctx
206
293
  restampAllSiblings(ctx.formRoot)
207
294
  return commitWithUndo('Update', () =>
@@ -232,6 +319,7 @@ export function maybeAutoOpen() {
232
319
  if (state.isOpen) return
233
320
  whenReady(() => {
234
321
  if (state.isOpen) return
322
+ if (!canWriteNow()) return
235
323
  try {
236
324
  open()
237
325
  } catch (err) {
@@ -319,23 +407,15 @@ function whenReady(fn) {
319
407
  // window, and the guards above no-op there).
320
408
  maybeAutoOpen()
321
409
 
322
- // Floating edit-mode toggle at module load (same browser-only no-op in node).
323
- maybeInjectToggle({
324
- open,
325
- close,
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),
331
- hasRules: (doc) => !!engine.findRules(doc, 'cms'),
332
- })
333
-
334
410
  const cms = {
335
411
  open,
336
412
  close,
337
413
  refresh,
338
414
  api,
415
+ // Whether this page declares CMS rules, so a launcher outside the CMS knows to offer it.
416
+ hasRules: (doc = document) => !!engine.findRules(doc, 'cms'),
417
+ prepareModeLease,
418
+ modeParticipant,
339
419
  get isOpen() { return state.isOpen },
340
420
  currentView,
341
421
  // Power-user exports (mostly for testing + advanced integration)
@@ -0,0 +1,28 @@
1
+ // The CMS reads and writes the whole document, so a rule can bind <html> (the
2
+ // page's theme properties) or the <head>. Everything that needs an element to
3
+ // stand for the page (event payloads and dispatch, structural rollback, the
4
+ // inline view's page listeners) keeps using <body>, where the content lives.
5
+ export function contentRoot(pageRoot) {
6
+ if (pageRoot && pageRoot.nodeType === 9) return pageRoot.body || pageRoot.documentElement
7
+ return pageRoot
8
+ }
9
+
10
+ // A rule with no selector ("@data-mode", ".") addresses the page root itself.
11
+ // A Document has no attributes or text of its own, so outside a list those
12
+ // rules address <body>, which is what they addressed when <body> was the
13
+ // default root. Inside a list the row is the context and nothing changes.
14
+ export function rootRules(rules, pageRoot) {
15
+ if (!pageRoot || pageRoot.nodeType !== 9) return rules
16
+ return walk(rules)
17
+ function walk(rule) {
18
+ if (typeof rule === 'string') {
19
+ if (rule === '.') return 'body'
20
+ if (rule.startsWith('@')) return `body${rule}`
21
+ return rule
22
+ }
23
+ if (Array.isArray(rule) || !rule || typeof rule !== 'object') return rule
24
+ const out = {}
25
+ for (const [k, v] of Object.entries(rule)) out[k] = walk(v)
26
+ return out
27
+ }
28
+ }
package/src/refresh.js CHANGED
@@ -8,6 +8,8 @@ import { findUnresolved, applyUnresolvedState, stripReadOnly } from './unresolve
8
8
  import { enhanceFields } from './enhance.js'
9
9
  import { platform } from './platform.js'
10
10
  import { rowIdentitySeeder } from './row-identity.js'
11
+ import { reconcileFontsLink } from './fonts-link.js'
12
+ import { rootRules } from './page-root.js'
11
13
 
12
14
  const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
13
15
 
@@ -27,7 +29,7 @@ function refreshPageForm(ctx, { ignoreActiveValue }) {
27
29
  // (session.js hands found.rules to view.prepareRules) rather than
28
30
  // hardcoding one view's upgrade here — that undid a wider upgrade on the
29
31
  // first refresh, silently flattening rich text back to plain.
30
- ctx.pageRules = ctx.view.prepareRules(found.rules)
32
+ ctx.pageRules = ctx.view.prepareRules(rootRules(found.rules, ctx.pageRoot))
31
33
  ctx.rulesTagNode = found.tagNode
32
34
  }
33
35
  // Re-derive formRules so schema changes (rule shape, template overrides) flow through.
@@ -54,6 +56,7 @@ function refreshPageForm(ctx, { ignoreActiveValue }) {
54
56
  doc: ctx.doc,
55
57
  })
56
58
  morphForm(ctx.formRoot, fragment, { ignoreActiveValue })
59
+ reconcileFontsLink(ctx, newData)
57
60
  seeder.seed(ctx.formRoot)
58
61
  // The morph can rebuild field nodes, dropping autosize heights and richclay
59
62
  // instances (instances are per-element); re-enhance whatever is new.
package/src/session.js CHANGED
@@ -10,6 +10,7 @@ import { deriveFormRules } from './form-rules.js'
10
10
  import { warnUnmatchedTemplates } from './diagnostics.js'
11
11
  import { findUnresolved, stripReadOnly } from './unresolved.js'
12
12
  import { rowIdentitySeeder } from './row-identity.js'
13
+ import { contentRoot, rootRules } from './page-root.js'
13
14
  import {
14
15
  commit,
15
16
  commitWithUndo,
@@ -21,6 +22,7 @@ import {
21
22
  } from './events.js'
22
23
  import { installObserver } from './refresh.js'
23
24
  import { platform, onPlatformEvent, LIVESYNC_APPLIED } from './platform.js'
25
+ import { reconcileFontsLink } from './fonts-link.js'
24
26
 
25
27
  const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
26
28
 
@@ -48,7 +50,7 @@ export function createSession({ view, doc, pageRoot, opts = {}, onCloseRequested
48
50
  // bare scalar rules whose page element contains child elements are rebound
49
51
  // through @innerHTML so links and inline formatting survive the round-trip, and
50
52
  // their form fields render the @richtext component.
51
- const pageRules = view.prepareRules(found.rules)
53
+ const pageRules = view.prepareRules(rootRules(found.rules, pageRoot))
52
54
  const rulesTagNode = found.tagNode
53
55
 
54
56
  injectDefaults(doc)
@@ -95,6 +97,7 @@ export function createSession({ view, doc, pageRoot, opts = {}, onCloseRequested
95
97
  onError: opts.onError,
96
98
  confirmRemove: opts.confirmRemove,
97
99
  previouslyFocused: doc.activeElement,
100
+ quiesced: false,
98
101
  dispatch(name, detail) {
99
102
  const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
100
103
  if (!Ctor) return
@@ -103,13 +106,13 @@ export function createSession({ view, doc, pageRoot, opts = {}, onCloseRequested
103
106
  // inline one without inspecting the DOM. Merged into the caller's detail
104
107
  // rather than replacing it: hcms:change still carries .data, hcms:error
105
108
  // still carries .error.
106
- const payload = { ...(detail || {}), pageRoot, view: view.name }
109
+ const payload = { ...(detail || {}), pageRoot: contentRoot(pageRoot), view: view.name }
107
110
  const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change', detail: payload })
108
111
  // A full-document morph (live sync, a version restore) can replace the
109
112
  // view root between mount and dispatch. Falling back to the page keeps the
110
113
  // event on the document instead of firing it into a detached tree where no
111
114
  // listener will ever see it.
112
- const target = view.root && view.root.isConnected !== false ? view.root : pageRoot
115
+ const target = view.root && view.root.isConnected !== false ? view.root : contentRoot(pageRoot)
113
116
  target.dispatchEvent(ev)
114
117
  },
115
118
  onCloseRequested,
@@ -132,6 +135,10 @@ export function createSession({ view, doc, pageRoot, opts = {}, onCloseRequested
132
135
  export function startSession(ctx) {
133
136
  ctx.updateFingerprint()
134
137
 
138
+ // A font set outside the CMS (the JSON API, a hand edit) left the fonts link
139
+ // behind; opening the CMS brings it back in step.
140
+ reconcileFontsLink(ctx, coerceBooleans(engine.extract(ctx.pageRoot, ctx.pageRules, ENGINE_OPTS), ctx.pageRules))
141
+
135
142
  ctx.observerHandle = installObserver({
136
143
  onRefresh: (changes) => ctx.view.refresh('observer', changes),
137
144
  })
@@ -236,6 +243,47 @@ function clearState() {
236
243
  globalCommitTarget.ctx = null
237
244
  }
238
245
 
246
+ export function modeLifecycle(win) {
247
+ const scope = win || (typeof window !== 'undefined' ? window : null)
248
+ const lifecycle = scope && scope.clay ? scope.clay.modeLifecycle : null
249
+ return lifecycle && typeof lifecycle.canWrite === 'function' ? lifecycle : null
250
+ }
251
+
252
+ export function canWriteNow(win) {
253
+ const lifecycle = modeLifecycle(win)
254
+ if (!lifecycle) return true
255
+ try {
256
+ return lifecycle.canWrite() !== false
257
+ } catch (_) {
258
+ return false
259
+ }
260
+ }
261
+
262
+ const UPLOADING_SELECTOR = '[data-hcms-uploading]'
263
+
264
+ export function pendingWork(ctx) {
265
+ if (!ctx) return []
266
+ const pending = []
267
+ if (ctx.uploads && ctx.uploads.size > 0) pending.push('upload')
268
+ if (ctx.pageRoot && typeof ctx.pageRoot.querySelector === 'function' && ctx.pageRoot.querySelector(UPLOADING_SELECTOR)) {
269
+ pending.push('preview')
270
+ }
271
+ return pending
272
+ }
273
+
274
+ export function quiesceSession(ctx) {
275
+ if (!ctx || ctx.closed) return { ok: true }
276
+ const pending = pendingWork(ctx)
277
+ if (pending.length) return { ok: false, code: 'pending-input', pending }
278
+ ctx.quiesced = true
279
+ return { ok: true }
280
+ }
281
+
282
+ export function resumeSession(ctx) {
283
+ if (!ctx || ctx.closed) return
284
+ ctx.quiesced = false
285
+ }
286
+
239
287
  const globalCommitTarget = { ctx: null }
240
288
 
241
289
  function installGlobalSortableCommit(doc) {
package/src/targets.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { createDomAdapter } from 'hyper-html-api/dom'
12
12
  import { ruleAttrIndex } from 'hyper-html-api/engine'
13
+ import { styleRole } from './templates.js'
13
14
 
14
15
  const FIND_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
15
16
  // templateAttr:null includes the hidden [cms-template] seed. A list that has been
@@ -44,22 +45,21 @@ const NATIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])
44
45
  const NON_TEXT_TAGS = new Set([
45
46
  'IMG', 'INPUT', 'TEXTAREA', 'SELECT', 'OPTION', 'BR', 'HR', 'VIDEO', 'AUDIO',
46
47
  'IFRAME', 'EMBED', 'OBJECT', 'CANVAS', 'SOURCE', 'TRACK', 'AREA', 'COL', 'PARAM',
47
- 'BUTTON',
48
+ 'BUTTON', 'STYLE', 'SCRIPT', 'LINK', 'META',
48
49
  ])
49
50
 
50
- export function resolveTargets(root, rules) {
51
- return resolvePageTargets(root, rules)
52
- }
53
-
54
- function resolvePageTargets(root, rules) {
51
+ // `doc` lets each scalar target say whether it is a style field (`style`:
52
+ // 'page', 'section' or null). Without it every target is content.
53
+ export function resolveTargets(root, rules, doc = null) {
55
54
  const targets = []
56
55
  const lists = []
57
56
  const adapter = createDomAdapter(root)
58
- walk(adapter, root, rules, [], targets, lists)
57
+ const roleOf = doc ? (rule, path) => styleRole(rule, doc, path, rules) : () => null
58
+ walk(adapter, root, rules, [], targets, lists, roleOf)
59
59
  return { targets, lists }
60
60
  }
61
61
 
62
- function walk(adapter, ctx, rule, path, targets, lists) {
62
+ function walk(adapter, ctx, rule, path, targets, lists, roleOf) {
63
63
  if (typeof rule === 'string') {
64
64
  // A scalar array ("ul.tags li[]"): every match is its own editable item, and
65
65
  // the run of them is a list that can be added to and removed from.
@@ -79,7 +79,7 @@ function walk(adapter, ctx, rule, path, targets, lists) {
79
79
  // "." and a bare "@attr" both address the context node itself, so they can
80
80
  // never fail to resolve and there is nothing to query.
81
81
  const el = !selector || selector === '.' ? ctx : find(adapter, ctx, selector)[0]
82
- if (el) targets.push(describe(path, el, rule, attr))
82
+ if (el) targets.push(describe(path, el, rule, attr, roleOf(rule, path)))
83
83
  return
84
84
  }
85
85
 
@@ -87,13 +87,13 @@ function walk(adapter, ctx, rule, path, targets, lists) {
87
87
  const [selector, shape] = rule
88
88
  if (typeof selector !== 'string' || !selector) return
89
89
  const items = find(adapter, ctx, selector)
90
- items.forEach((el, i) => walk(adapter, el, shape, [...path, i], targets, lists))
90
+ items.forEach((el, i) => walk(adapter, el, shape, [...path, i], targets, lists, roleOf))
91
91
  lists.push(makeList(adapter, ctx, path, selector, items, typeof shape === 'string'))
92
92
  return
93
93
  }
94
94
 
95
95
  if (rule && typeof rule === 'object') {
96
- for (const [key, sub] of Object.entries(rule)) walk(adapter, ctx, sub, [...path, key], targets, lists)
96
+ for (const [key, sub] of Object.entries(rule)) walk(adapter, ctx, sub, [...path, key], targets, lists, roleOf)
97
97
  }
98
98
  }
99
99
 
@@ -109,8 +109,8 @@ function makeList(adapter, ctx, path, selector, items, scalar) {
109
109
  return { path, items, container, scalar }
110
110
  }
111
111
 
112
- function describe(path, el, rule, attr) {
113
- return { path, el, rule, attr, kind: kindOf(el, attr), icon: iconOf(el, attr) }
112
+ function describe(path, el, rule, attr, style = null) {
113
+ return { path, el, rule, attr, kind: kindOf(el, attr), icon: iconOf(el, attr), style }
114
114
  }
115
115
 
116
116
  function kindOf(el, attr) {
@@ -147,3 +147,45 @@ function find(adapter, ctx, selector, opts = FIND_OPTS) {
147
147
  return []
148
148
  }
149
149
  }
150
+
151
+ // Style fields grouped by what they style: every page-level one together, the
152
+ // ones inside a list row with that row, any other with the element it sits on.
153
+ export function styleGroups(targets, lists) {
154
+ const groups = []
155
+ const byEl = new Map()
156
+ let page = null
157
+ for (const target of targets) {
158
+ if (!target.style) continue
159
+ const path = target.path.join('.')
160
+ if (target.style === 'page') {
161
+ if (!page) {
162
+ page = { scope: 'page', el: target.el, list: null, paths: [] }
163
+ groups.push(page)
164
+ }
165
+ page.paths.push(path)
166
+ continue
167
+ }
168
+ const owner = rowOf(target.path, lists)
169
+ const el = owner ? owner.row : target.el
170
+ let group = byEl.get(el)
171
+ if (!group) {
172
+ group = { scope: owner ? 'row' : 'section', el, list: owner ? owner.list : null, paths: [] }
173
+ byEl.set(el, group)
174
+ groups.push(group)
175
+ }
176
+ group.paths.push(path)
177
+ }
178
+ return groups
179
+ }
180
+
181
+ // The innermost list row a target path runs through, as { list, row }.
182
+ function rowOf(path, lists) {
183
+ for (let i = path.length - 1; i >= 0; i--) {
184
+ if (typeof path[i] !== 'number') continue
185
+ const prefix = path.slice(0, i).join('.')
186
+ const list = lists.find((candidate) => candidate.path.join('.') === prefix)
187
+ const row = list && list.items[path[i]]
188
+ if (row) return { list, row }
189
+ }
190
+ return null
191
+ }