@panphora/hyper-cms 0.11.6 → 0.12.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,6 +20,11 @@ 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
30
  import { maybeInjectToggle } from './toggle.js'
@@ -53,17 +58,36 @@ const VIEWS = {
53
58
  inline: createInlineView,
54
59
  }
55
60
 
61
+ // The page says how it wants to be edited, on the rules tag the session will
62
+ // use: data-cms-view="inline". No tag to read it from (a literal rules object),
63
+ // no attribute, or "sidebar" means the sidebar. An unknown value is a page
64
+ // authoring mistake, not a caller error, so it warns and opens the sidebar.
65
+ function declaredView(doc, rules) {
66
+ const source = rules !== undefined ? rules : 'cms'
67
+ if (typeof source !== 'string') return 'sidebar'
68
+ const declared = engine.findRules(doc, source)?.tagNode?.getAttribute?.('data-cms-view')
69
+ if (!declared || declared === 'sidebar') return 'sidebar'
70
+ if (VIEWS[declared]) return declared
71
+ console.warn(`hypercms: unknown data-cms-view "${declared}" on the rules tag, opening the sidebar`)
72
+ return 'sidebar'
73
+ }
74
+
56
75
  export function open(opts = {}) {
76
+ return openSession(opts)
77
+ }
78
+
79
+ function modeExit(doc) {
80
+ const win = doc && doc.defaultView
81
+ if (!modeLifecycle(win)) return null
82
+ const setMode = win.clay && win.clay.setMode
83
+ if (typeof setMode !== 'function') return null
84
+ return setMode.call(win.clay, 'view')
85
+ }
86
+
87
+ function openSession(opts = {}, admitted = false) {
57
88
  // Everything that can be rejected is rejected before anything is destroyed. A
58
89
  // bad view name or a missing page must not leave a caller with the session
59
90
  // 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
91
  // A switch changes the presentation of one session, not the session. Someone
68
92
  // calling open({ view: 'sidebar' }) means "show me this in the sidebar"; they
69
93
  // are not restating the pageRoot, the rules and the callbacks they opened
@@ -71,12 +95,23 @@ export function open(opts = {}) {
71
95
  // document.body with the default rules tag and stops calling onChange.
72
96
  // state.opts holds the active session's options and teardown clears it, so it
73
97
  // has to be read here, before anything is torn down.
74
- const effective = state.isOpen ? { ...state.opts, ...opts, view: viewName } : opts
98
+ const merged = state.isOpen ? { ...state.opts, ...opts } : opts
75
99
 
76
- const pageRoot = effective.pageRoot || (typeof document !== 'undefined' ? document.body : null)
100
+ const pageRoot = merged.pageRoot || (typeof document !== 'undefined' ? document : null)
77
101
  if (!pageRoot) throw new Error('hypercms: no pageRoot available')
78
- const doc = pageRoot.ownerDocument || (typeof document !== 'undefined' ? document : null)
102
+ const doc = (pageRoot.nodeType === 9 ? pageRoot : pageRoot.ownerDocument) || (typeof document !== 'undefined' ? document : null)
79
103
  if (!doc) throw new Error('hypercms: no document available')
104
+ if (!admitted && !canWriteNow(doc.defaultView)) return { ok: false, code: 'not-allowed' }
105
+
106
+ // A bare open() means "make sure the editor is up", not "put it in the
107
+ // sidebar": with a session up it keeps that view, otherwise it opens the view
108
+ // the page declares.
109
+ const viewName = opts.view || (state.isOpen ? state.ctx.view.name : declaredView(doc, merged.rules))
110
+ const makeView = VIEWS[viewName]
111
+ if (!makeView) {
112
+ throw new Error(`hypercms: unknown view "${viewName}" (expected ${Object.keys(VIEWS).join(' or ')})`)
113
+ }
114
+ const effective = state.isOpen ? { ...merged, view: viewName } : opts
80
115
 
81
116
  // Reopening the view that is already up is a silent no-op, not a warning: a
82
117
  // host wiring a button to open({ view }) should be able to call it without
@@ -87,7 +122,7 @@ export function open(opts = {}) {
87
122
  let carriedFocus = null
88
123
  let previous = null
89
124
  if (state.isOpen) {
90
- if (state.ctx.view.name === viewName) return
125
+ if (state.ctx.view.name === viewName) return { ok: true, view: viewName }
91
126
  carriedFocus = state.ctx.previouslyFocused
92
127
  previous = state.ctx.view.name
93
128
  teardownSession(state.ctx, { restoreFocus: false, updateUrl: false, reason: 'switch' })
@@ -101,8 +136,8 @@ export function open(opts = {}) {
101
136
  doc,
102
137
  pageRoot,
103
138
  opts: effective,
104
- onCloseRequested: () => close(),
105
- onViewRequested: (name) => open({ view: name }),
139
+ onCloseRequested: () => modeExit(doc) || close(),
140
+ onViewRequested: (name) => openSession({ view: name }, true),
106
141
  })
107
142
  // Where focus was before the FIRST open, carried across the switch. Closing
108
143
  // the second view should return the person where they started, not to
@@ -134,11 +169,19 @@ export function open(opts = {}) {
134
169
  teardownSession(ctx, { dispatch: false, restoreFocus: !!carriedFocus, updateUrl: false })
135
170
  throw err
136
171
  }
172
+ return { ok: true, view: viewName }
137
173
  }
138
174
 
139
175
  export function close() {
140
- if (!state.isOpen) return
176
+ if (!state.isOpen) return { ok: false, code: 'no-session' }
177
+ const doc = state.ctx.doc
178
+ if (modeLifecycle(doc && doc.defaultView)) {
179
+ const pending = pendingWork(state.ctx)
180
+ if (pending.length) return { ok: false, code: 'pending-input', pending }
181
+ if (!canWriteNow(doc && doc.defaultView)) return { ok: false, code: 'busy' }
182
+ }
141
183
  teardownSession(state.ctx)
184
+ return { ok: true }
142
185
  }
143
186
 
144
187
  export function refresh() {
@@ -157,13 +200,58 @@ export function currentView() {
157
200
  return state.isOpen && state.ctx ? state.ctx.view.name : null
158
201
  }
159
202
 
203
+ export function prepareModeLease({ doc, pageRoot, opts = {} } = {}) {
204
+ let owned = null
205
+ let disposed = false
206
+ return {
207
+ ready: Promise.resolve(),
208
+ enable() {
209
+ if (disposed) return
210
+ const target = { ...opts }
211
+ if (pageRoot) target.pageRoot = pageRoot
212
+ if (!target.pageRoot) target.pageRoot = (doc && doc.body) || doc || null
213
+ openSession(target, true)
214
+ owned = state.ctx
215
+ },
216
+ quiesce() {
217
+ if (disposed || !owned || state.ctx !== owned) return { ok: true }
218
+ return quiesceSession(owned)
219
+ },
220
+ resume() {
221
+ if (disposed || !owned || state.ctx !== owned) return
222
+ resumeSession(owned)
223
+ },
224
+ dispose() {
225
+ if (disposed) return
226
+ disposed = true
227
+ if (owned && state.ctx === owned) teardownSession(owned)
228
+ owned = null
229
+ },
230
+ }
231
+ }
232
+
233
+ export function modeParticipant(options = {}) {
234
+ return {
235
+ id: 'hypercms',
236
+ order: 1000,
237
+ prepare() {
238
+ return prepareModeLease(options)
239
+ },
240
+ }
241
+ }
242
+
243
+ function assertAdmitting() {
244
+ if (!state.isOpen) throw new Error('hypercms: cms is not open')
245
+ if (state.ctx.quiesced) throw new Error('hypercms: the CMS is not admitting new changes')
246
+ }
247
+
160
248
  export const api = {
161
249
  getData() {
162
250
  if (!state.isOpen) return null
163
251
  return extractFormData(state.ctx)
164
252
  },
165
253
  setValue(path, value) {
166
- if (!state.isOpen) throw new Error('hypercms: cms is not open')
254
+ assertAdmitting()
167
255
  const ctx = state.ctx
168
256
  const pathArr = pathUtil.fromString(path)
169
257
  const rule = pathUtil.getRuleAtPath(ctx.pageRules, pathArr)
@@ -177,11 +265,11 @@ export const api = {
177
265
  commit(extractFormData(ctx), { path, structural: false }, ctx)
178
266
  },
179
267
  addItem(arrayPath) {
180
- if (!state.isOpen) throw new Error('hypercms: cms is not open')
268
+ assertAdmitting()
181
269
  evOnAdd(arrayPath, state.ctx)
182
270
  },
183
271
  removeItem(itemPath) {
184
- if (!state.isOpen) throw new Error('hypercms: cms is not open')
272
+ assertAdmitting()
185
273
  const ctx = state.ctx
186
274
  const pathArr = pathUtil.fromString(itemPath)
187
275
  // Validate path: must be an array item — last segment is a non-negative
@@ -201,7 +289,7 @@ export const api = {
201
289
  },
202
290
  refresh,
203
291
  _commit() {
204
- if (!state.isOpen) return
292
+ if (!state.isOpen || state.ctx.quiesced) return
205
293
  const ctx = state.ctx
206
294
  restampAllSiblings(ctx.formRoot)
207
295
  return commitWithUndo('Update', () =>
@@ -232,6 +320,7 @@ export function maybeAutoOpen() {
232
320
  if (state.isOpen) return
233
321
  whenReady(() => {
234
322
  if (state.isOpen) return
323
+ if (!canWriteNow()) return
235
324
  try {
236
325
  open()
237
326
  } catch (err) {
@@ -325,9 +414,6 @@ maybeInjectToggle({
325
414
  close,
326
415
  isOpen,
327
416
  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
417
  hasRules: (doc) => !!engine.findRules(doc, 'cms'),
332
418
  })
333
419
 
@@ -336,6 +422,10 @@ const cms = {
336
422
  close,
337
423
  refresh,
338
424
  api,
425
+ // Whether this page declares CMS rules, so a launcher outside the CMS knows to offer it.
426
+ hasRules: (doc = document) => !!engine.findRules(doc, 'cms'),
427
+ prepareModeLease,
428
+ modeParticipant,
339
429
  get isOpen() { return state.isOpen },
340
430
  currentView,
341
431
  // 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
+ }