@panphora/hyper-cms 0.4.2 → 0.6.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.
@@ -3,9 +3,29 @@ import {
3
3
  isInlineTemplate,
4
4
  humanize,
5
5
  shapeKindOf,
6
+ componentForScalarRule,
7
+ componentForScalarArrayRule,
8
+ winningScalarArrayComponent,
9
+ resolveTemplate,
10
+ declaredComponentKey,
11
+ readOptionsOverride,
12
+ readCropOverride,
6
13
  } from './templates.js'
7
14
  import { fieldPropertyFor } from './form-rules.js'
8
15
 
16
+ // The visible label for a file field is the basename of its URL value; the
17
+ // bound leaf still carries the full URL in href so it round-trips byte-stable.
18
+ export function fileNameFromUrl(url) {
19
+ if (!url) return ''
20
+ const clean = String(url).split(/[?#]/)[0]
21
+ const base = clean.split('/').pop() || clean
22
+ try {
23
+ return decodeURIComponent(base)
24
+ } catch {
25
+ return base
26
+ }
27
+ }
28
+
9
29
  export function buildForm({ pageRules, formRules: _formRules, data, doc }) {
10
30
  const fragment = doc.createDocumentFragment()
11
31
  const root = buildNode(pageRules, [], data, doc)
@@ -13,30 +33,60 @@ export function buildForm({ pageRules, formRules: _formRules, data, doc }) {
13
33
  return fragment
14
34
  }
15
35
 
16
- export function buildItem({ shape, itemShape, pathArr, data, doc }) {
36
+ export function buildItem({ shape, itemShape, pathArr, data, doc, itemKey }) {
17
37
  if (shape === 'object-array-item') {
18
38
  return buildObjectArrayItem(itemShape, pathArr, data, doc)
19
39
  }
20
40
  if (shape === 'scalar-array-item') {
21
- return buildScalarArrayItem(pathArr, data, doc)
41
+ return buildScalarArrayItem(pathArr, data, doc, itemKey || null)
22
42
  }
23
43
  throw new Error(`hypercms: buildItem called with unknown shape "${shape}"`)
24
44
  }
25
45
 
26
46
  function buildNode(rule, pathArr, data, doc) {
27
47
  const kind = shapeKindOf(rule)
28
- if (kind === 'scalar') return buildScalar(pathArr, data, doc)
48
+ if (kind === 'scalar') return buildScalar(rule, pathArr, data, doc)
29
49
  if (kind === 'object') return buildObject(rule, pathArr, data, doc)
30
50
  if (kind === 'object-array') return buildObjectArray(rule, pathArr, data, doc)
31
51
  if (kind === 'scalar-array') return buildScalarArray(rule, pathArr, data, doc)
32
52
  return null
33
53
  }
34
54
 
35
- function buildScalar(pathArr, data, doc) {
36
- const tpl = resolveTemplate(pathArr, '@scalar', doc)
55
+ function buildScalar(rule, pathArr, data, doc) {
56
+ // Opt-in components select a richer template off the bound rule (explicit
57
+ // data-hcms-component, then @prop inference); plain scalars stay @scalar.
58
+ // A path-bound template override still wins inside resolveTemplate.
59
+ const componentKey = componentForScalarRule(rule, doc, pathArr)
60
+ const tpl = resolveTemplate(pathArr, componentKey, doc)
37
61
  if (!tpl) throw new Error(`hypercms: missing template for scalar at "${pathArr.join('.')}"`)
62
+ const declaredKey = declaredComponentKey(rule, doc)
63
+ // A value-guard fell back to text: say so, or "where did my number input /
64
+ // checkbox go" has no answer.
65
+ if (declaredKey === '@number' && componentKey === '@scalar') {
66
+ console.info(
67
+ `[hypercms] field "${pathArr.join('.')}" declares component "@number" but its value isn't a plain number; rendering a text input so the value is preserved`
68
+ )
69
+ }
70
+ if ((declaredKey === '@checkbox' || declaredKey === '@toggle') && componentKey === '@scalar') {
71
+ console.info(
72
+ `[hypercms] field "${pathArr.join('.')}" declares component "${declaredKey}" but its value isn't true/false; rendering a text input so the value is preserved`
73
+ )
74
+ }
75
+ noteShadowedComponent(tpl, declaredKey === componentKey ? declaredKey : null, pathArr)
38
76
  const node = cloneTemplate(tpl, doc)
39
77
  stampPath(node, pathArr)
78
+ // Component-specific setup runs only when the component's own template won —
79
+ // a custom path/wildcard template that shadows it is the author's UI, and
80
+ // injecting options, dropping radio rows, or stamping crop into it would
81
+ // corrupt that UI.
82
+ const winnerKey = tpl.getAttribute?.('data-hcms-tpl')
83
+ if ((componentKey === '@select' || componentKey === '@radio') && winnerKey === componentKey) {
84
+ populateOptions(node, rule, pathArr, data, doc, componentKey)
85
+ }
86
+ if (componentKey === '@image' && winnerKey === '@image') {
87
+ const crop = readCropOverride(rule, doc)
88
+ if (crop != null && !node.hasAttribute('data-hcms-crop')) node.setAttribute('data-hcms-crop', crop)
89
+ }
40
90
  // Stamp leaf input(s) FIRST so the container's data-hcms-field doesn't
41
91
  // make stampScalarField early-return (which would leave the input without
42
92
  // a key).
@@ -45,9 +95,103 @@ function buildScalar(pathArr, data, doc) {
45
95
  stampContainerField(node, lastKey(pathArr))
46
96
  setLabel(node, lastKey(pathArr))
47
97
  populateScalarValue(node, data)
98
+ if (componentKey === '@file') syncFileLeafText(node)
48
99
  return node
49
100
  }
50
101
 
102
+ // A custom template (exact or wildcard path) outranks a named component, by
103
+ // design: the author's template is the stronger signal. Surface the shadowing
104
+ // so "my data-hcms-component does nothing" has a visible answer — but only for
105
+ // EXPLICITLY declared components (declaredKey non-null): a custom template
106
+ // quietly outranking @src/@checked inference is unremarkable.
107
+ function noteShadowedComponent(tpl, declaredKey, pathArr) {
108
+ if (!declaredKey) return
109
+ const winner = tpl.getAttribute?.('data-hcms-tpl')
110
+ if (winner && winner !== declaredKey) {
111
+ console.info(
112
+ `[hypercms] field "${pathArr.join('.')}" declares component "${declaredKey}" but custom template "${winner}" wins`
113
+ )
114
+ }
115
+ }
116
+
117
+ // Fill a @select's <select> with <option>s, or clone a @radio's prototype row
118
+ // per option, from the page element's data-hcms-options tokens. The current
119
+ // page value is prepended when it isn't in the list, so opening the editor
120
+ // never silently changes data. Radio inputs get a per-field name derived from
121
+ // the path so sibling fields (and array items) never cross-group.
122
+ function populateOptions(node, rule, pathArr, data, doc, componentKey) {
123
+ const provided = readOptionsOverride(rule, doc)
124
+ const values = provided ? [...provided] : []
125
+ const current = data == null ? '' : String(data)
126
+ if (current !== '' && !values.includes(current)) values.unshift(current)
127
+ if (!provided) {
128
+ // Missing data-hcms-options is an authoring error: surface it ALWAYS, not
129
+ // just when the page value is empty too. When a value exists, fall through
130
+ // and render it as the single option so the data still round-trips while
131
+ // the attribute is missing.
132
+ setFieldError(node, 'data-hcms-options required (space-separated values)')
133
+ if (values.length === 0) {
134
+ // No options AND no value: drop the radio prototype so no phantom empty
135
+ // row renders. The null/'' extract this leaves behind applies as ''
136
+ // (writePropOrAttr), matching the already empty page value, so no data
137
+ // moves.
138
+ node.querySelector('.mirk-radio')?.remove()
139
+ return
140
+ }
141
+ }
142
+ if (componentKey === '@select') {
143
+ const select = node.querySelector('select[data-hcms-field]')
144
+ if (!select) return
145
+ for (const v of values) {
146
+ const opt = doc.createElement('option')
147
+ opt.value = v
148
+ opt.textContent = humanize(v)
149
+ select.appendChild(opt)
150
+ }
151
+ return
152
+ }
153
+ const proto = node.querySelector('.mirk-radio')
154
+ if (!proto || !proto.parentNode) return
155
+ const name = radioGroupName(pathArr.join('.'))
156
+ for (const v of values) {
157
+ const row = proto.cloneNode(true)
158
+ const input = row.querySelector('input[type="radio"]')
159
+ if (input) {
160
+ input.value = v
161
+ input.name = name
162
+ }
163
+ const label = row.querySelector('.mirk-radio__label')
164
+ if (label) label.textContent = humanize(v)
165
+ proto.parentNode.insertBefore(row, proto)
166
+ }
167
+ proto.remove()
168
+ }
169
+
170
+ // The radio group name a field at this dot-path gets. Shared with the restamp
171
+ // machinery in events.js: names derive from paths, so when a remove/move
172
+ // restamps paths, the names must be recomputed with the same formula or
173
+ // sibling items end up sharing a group.
174
+ export function radioGroupName(pathStr) {
175
+ return 'hcms-' + String(pathStr).replace(/[^A-Za-z0-9_-]/g, '-')
176
+ }
177
+
178
+ function setFieldError(node, message) {
179
+ const slot = node.querySelector ? node.querySelector('.hcms-error') : null
180
+ if (!slot) return
181
+ slot.textContent = message
182
+ slot.hidden = false
183
+ }
184
+
185
+ // The @file leaf is an <a> whose value is the href; populateScalarValue sets
186
+ // href but not the visible text, so derive the filename here. Empty href leaves
187
+ // the anchor empty, which the theme's :empty placeholder dresses as "No file
188
+ // chosen". Scoped to the default chrome's .mirk-file__name so author overrides
189
+ // keep their own text.
190
+ function syncFileLeafText(node) {
191
+ const a = node.querySelector ? node.querySelector('a.mirk-file__name[data-hcms-field]') : null
192
+ if (a) a.textContent = fileNameFromUrl(a.getAttribute('href'))
193
+ }
194
+
51
195
  function buildObject(rule, pathArr, data, doc) {
52
196
  const tpl = resolveTemplate(pathArr, '@object', doc)
53
197
  if (!tpl) throw new Error(`hypercms: missing template for object at "${pathArr.join('.')}"`)
@@ -120,27 +264,41 @@ function buildObjectArrayItem(itemShape, pathArr, data, doc) {
120
264
  }
121
265
 
122
266
  function buildScalarArray(rule, pathArr, data, doc) {
123
- const tpl = resolveTemplate(pathArr, '@scalar-array', doc)
267
+ // The chips component swaps the scalar-array chrome (data-hcms-component=
268
+ // "chips" on the list container or an item ancestor). A path-bound template
269
+ // still wins inside resolveTemplate — and when it does, `comp` is null
270
+ // (winningScalarArrayComponent shares that verdict with deriveFormRules, so
271
+ // the item chrome and the derived item selector can never diverge) and the
272
+ // custom template's own UI is left untouched.
273
+ const declared = componentForScalarArrayRule(rule, doc)
274
+ const comp = winningScalarArrayComponent(rule, pathArr, doc)
275
+ const defaultKey = declared ? declared.array : '@scalar-array'
276
+ const tpl = resolveTemplate(pathArr, defaultKey, doc)
124
277
  if (!tpl) throw new Error(`hypercms: missing template for scalar-array at "${pathArr.join('.')}"`)
278
+ // chips is always an explicit declaration (attr-driven), so always notable.
279
+ noteShadowedComponent(tpl, declared ? declared.array : null, pathArr)
125
280
  const node = cloneTemplate(tpl, doc)
126
281
  stampPath(node, pathArr)
127
282
  stampContainerField(node, lastKey(pathArr))
128
283
  setLabel(node, lastKey(pathArr))
129
284
  copyArrayConstraintAttrs(node, tpl)
130
285
  wireSortable(node, tpl, pathArr)
286
+ // Record the resolved item-template key so onAdd (which has no rule context)
287
+ // builds new items with the same component chrome.
288
+ if (comp) node.setAttribute('data-hcms-item-tpl', comp.item)
131
289
 
132
290
  const slot = requireSlot(node, '.hcms-array-items', tpl, pathArr)
133
291
  const items = Array.isArray(data) ? data : []
134
292
  items.forEach((itemValue, i) => {
135
- const itemNode = buildScalarArrayItem([...pathArr, i], itemValue, doc)
293
+ const itemNode = buildScalarArrayItem([...pathArr, i], itemValue, doc, comp ? comp.item : null)
136
294
  if (itemNode) slot.appendChild(itemNode)
137
295
  })
138
296
  applyConstraintVisibility(node)
139
297
  return node
140
298
  }
141
299
 
142
- function buildScalarArrayItem(pathArr, value, doc) {
143
- const tpl = resolveItemTemplate(pathArr, 'scalar-array-item', doc)
300
+ function buildScalarArrayItem(pathArr, value, doc, itemKey) {
301
+ const tpl = resolveItemTemplate(pathArr, 'scalar-array-item', doc, itemKey)
144
302
  if (!tpl) throw new Error(`hypercms: missing item template for "${pathArr.join('.')}"`)
145
303
  const node = cloneTemplate(tpl, doc)
146
304
  node.setAttribute('data-hcms-array-item', '')
@@ -151,21 +309,15 @@ function buildScalarArrayItem(pathArr, value, doc) {
151
309
  return node
152
310
  }
153
311
 
154
- function resolveTemplate(pathArr, defaultKey, doc) {
155
- const pathStr = pathArr.join('.')
312
+ function resolveItemTemplate(pathArr, defaultShape, doc, itemKey) {
156
313
  const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
157
314
  return (
158
- (pathStr && findTemplate(doc, pathStr)) ||
159
- (wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
160
- findTemplate(doc, defaultKey)
315
+ findTemplate(doc, wildcardKey) ||
316
+ (itemKey && findTemplate(doc, itemKey)) ||
317
+ findTemplate(doc, '@' + defaultShape)
161
318
  )
162
319
  }
163
320
 
164
- function resolveItemTemplate(pathArr, defaultShape, doc) {
165
- const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
166
- return findTemplate(doc, wildcardKey) || findTemplate(doc, '@' + defaultShape)
167
- }
168
-
169
321
  function cloneTemplate(tpl, doc) {
170
322
  const content = tpl.content || tpl
171
323
  const wrapper = doc.createElement('div')
package/src/form-rules.js CHANGED
@@ -1,4 +1,10 @@
1
- import { shapeKindOf, findTemplate, isInlineTemplate } from './templates.js'
1
+ import {
2
+ shapeKindOf,
3
+ findTemplate,
4
+ isInlineTemplate,
5
+ componentForScalarRule,
6
+ winningScalarArrayComponent,
7
+ } from './templates.js'
2
8
 
3
9
  const TAG_PROP_MAP = {
4
10
  IMG: 'src',
@@ -79,7 +85,7 @@ export function deriveFormRules(pageRules, doc) {
79
85
  function derive(rule, pathArr) {
80
86
  const kind = shapeKindOf(rule)
81
87
  if (kind === 'scalar') return scalarRule(rule, pathArr)
82
- if (kind === 'scalar-array') return scalarArrayRule(pathArr)
88
+ if (kind === 'scalar-array') return scalarArrayRule(rule, pathArr)
83
89
  if (kind === 'object-array') return objectArrayRule(rule, pathArr)
84
90
  if (kind === 'object') {
85
91
  const out = Object.create(null)
@@ -99,21 +105,29 @@ export function deriveFormRules(pageRules, doc) {
99
105
  const fieldKey = typeof key === 'string' ? key : '__value'
100
106
  const inlineEl = findInlineFieldEl(pathArr, fieldKey)
101
107
  if (inlineEl) return fieldSelectorFor(inlineEl, fieldKey)
102
- // No path-bound override: look up the actual @scalar template (which
103
- // sites may override globally with e.g. <textarea>) and derive the
104
- // selector from its leaf tag instead of assuming <input>.
105
- const shapeEl = findShapeFieldEl('@scalar', fieldKey)
108
+ // No path-bound override: resolve the component this rule opts into
109
+ // (@scalar / @image / @file) and derive the selector from THAT template's
110
+ // leaf tag, so an @image field reads img@src and an @file field reads
111
+ // a@href, while a plain scalar still respects a global @scalar override
112
+ // (e.g. a <textarea>) instead of assuming <input>.
113
+ const shapeEl = findShapeFieldEl(componentForScalarRule(rule, doc, pathArr), fieldKey)
106
114
  if (shapeEl) return fieldSelectorFor(shapeEl, fieldKey)
107
115
  return `input[data-hcms-field="${cssEscape(fieldKey)}"]@value`
108
116
  }
109
117
 
110
- function scalarArrayRule(pathArr) {
118
+ function scalarArrayRule(rule, pathArr) {
111
119
  // The engine's "selector[]" form is text-only; for an editable form we need
112
120
  // per-item @value reads, so emit the [selector, shape] form with a scalar
113
121
  // shape that targets the inner field's value property. If the site
114
122
  // overrides @scalar-array-item with a non-<input> leaf (textarea, etc.),
115
- // derive the inner-leaf selector from the override.
116
- const shapeEl = findShapeFieldEl('@scalar-array-item', null)
123
+ // derive the inner-leaf selector from the override. A component array
124
+ // (chips) derives from its own item template first — but only when the
125
+ // chips template actually wins at this path (winningScalarArrayComponent
126
+ // is the same verdict buildScalarArray uses, so the derived item selector
127
+ // always matches the rendered item leaf).
128
+ const comp = winningScalarArrayComponent(rule, pathArr, doc)
129
+ const shapeEl =
130
+ (comp && findShapeFieldEl(comp.item, null)) || findShapeFieldEl('@scalar-array-item', null)
117
131
  const itemSel = shapeEl ? keylessFieldSelectorFor(shapeEl) : 'input[data-hcms-field]@value'
118
132
  return [itemSelectorFor(pathArr, '[data-hcms-array-item]'), itemSel]
119
133
  }
@@ -1,7 +1,10 @@
1
1
  import * as core from './hypercms.js'
2
- import stylesText from './styles.css'
2
+ import themeText from './theme.generated.css'
3
+ // Install mirk's delegated component runtime (tags chips, number stepper, etc.)
4
+ // as a side effect. Idempotent + document-delegated, so safe to include once.
5
+ import './vendor/mirk.vendor.js'
3
6
 
4
- core.installStyles(stylesText)
7
+ core.installStyles(themeText)
5
8
 
6
9
  export const cms = core.cms
7
10
  export default { cms: core.cms }
package/src/hypercms.js CHANGED
@@ -2,7 +2,7 @@ import { engine } from 'hyper-html-api'
2
2
  import * as pathUtil from './path.js'
3
3
  import { scaffold } from './scaffold.js'
4
4
  import { morphForm } from './morph.js'
5
- import { injectDefaults } from './templates.js'
5
+ import { injectDefaults, injectComponents } from './templates.js'
6
6
  import { deriveFormRules } from './form-rules.js'
7
7
  import { buildForm } from './form-builder.js'
8
8
  import {
@@ -104,6 +104,7 @@ export function open(opts = {}) {
104
104
  const rulesTagNode = found.tagNode
105
105
 
106
106
  injectDefaults(doc)
107
+ injectComponents(doc, pageRules)
107
108
  warnUnmatchedTemplates(doc, pageRules)
108
109
  const formRules = deriveFormRules(pageRules, doc)
109
110
  const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
@@ -116,6 +117,9 @@ export function open(opts = {}) {
116
117
  side: opts.side || 'right',
117
118
  overlay: !!opts.overlay,
118
119
  showSaveButton: !!opts.showSaveButton,
120
+ title: opts.title,
121
+ eyebrow: opts.eyebrow,
122
+ theme: opts.theme,
119
123
  doc,
120
124
  }))
121
125
 
@@ -136,12 +140,12 @@ export function open(opts = {}) {
136
140
  livesyncUnsub: null,
137
141
  onChange: opts.onChange,
138
142
  onError: opts.onError,
139
- onSave: opts.onSave,
143
+ confirmRemove: opts.confirmRemove,
140
144
  previouslyFocused: doc.activeElement,
141
145
  dispatch(name, detail) {
142
146
  const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
143
147
  if (!Ctor) return
144
- const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change' || name === 'hcms:save', detail })
148
+ const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change', detail })
145
149
  shell.root.dispatchEvent(ev)
146
150
  },
147
151
  onCloseRequested() {
@@ -152,6 +156,13 @@ export function open(opts = {}) {
152
156
  ctx.lastFingerprint = stableStringify(extractFormData(ctx))
153
157
  }
154
158
 
159
+ // Everything past the shell mount touches the live DOM, the module-level state,
160
+ // and external subscriptions (observer, undo, livesync). A throw in here — most
161
+ // notably installObserver when window.hyperclay.Mutation isn't loaded — would
162
+ // otherwise strand the mounted shell in the DOM with no way to close it (close()
163
+ // early-returns while state.isOpen is false). Tear down whatever was wired,
164
+ // exactly as close() does, then rethrow so host misuse still fails loudly.
165
+ try {
155
166
  const fragment = buildForm({ pageRules, formRules, data, doc })
156
167
  shell.formRoot.appendChild(fragment)
157
168
 
@@ -169,7 +180,26 @@ export function open(opts = {}) {
169
180
  // the active field. Guarded so it degrades to today's behavior when undo isn't loaded.
170
181
  const u = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
171
182
  if (u && typeof u.on === 'function') {
172
- const onRevert = () => resyncForm(ctx, 'undo')
183
+ const onRevert = () => {
184
+ if (state.ctx !== ctx) return
185
+ resyncForm(ctx, 'undo')
186
+ // A reverted change the consumer persists through onChange (e.g. the
187
+ // collection dashboard PUTs the record, not the page) would otherwise lag
188
+ // the undo: refreshForm re-syncs the form but never fires onChange, and a
189
+ // commit() here would be fingerprint-skipped (refreshForm already updated
190
+ // it). Extract the reverted state from the PAGE (the source of truth after
191
+ // an undo/redo; the form can lag a morph) and fire onChange directly, but
192
+ // only when it actually changed — guards spurious PUTs on undo/redo of
193
+ // unrelated edits elsewhere on the page.
194
+ const data = coerceBooleans(
195
+ engine.extract(ctx.pageRoot, ctx.pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }),
196
+ ctx.pageRules
197
+ )
198
+ if (stableStringify(data) !== stableStringify(ctx.lastData)) {
199
+ ctx.lastData = data
200
+ ctx.onChange?.(data, { path: '', structural: false })
201
+ }
202
+ }
173
203
  u.on('undo', onRevert)
174
204
  u.on('redo', onRevert)
175
205
  ctx.undoUnsub = () => { u.off('undo', onRevert); u.off('redo', onRevert) }
@@ -199,6 +229,19 @@ export function open(opts = {}) {
199
229
  state.opts = opts
200
230
 
201
231
  ctx.dispatch('hcms:open', { pageRoot })
232
+ } catch (err) {
233
+ ctx.observerHandle?.unsubscribe?.()
234
+ ctx.undoUnsub?.()
235
+ ctx.livesyncUnsub?.()
236
+ ctx.detachEvents?.()
237
+ if (globalCommitTarget.ctx === ctx) globalCommitTarget.ctx = null
238
+ suppressUndo(() => shell.destroy())
239
+ state.isOpen = false
240
+ state.ctx = null
241
+ state.shell = null
242
+ state.opts = null
243
+ throw err
244
+ }
202
245
  }
203
246
 
204
247
  function focusFirstIn(root) {
@@ -232,11 +275,57 @@ function installGlobalSortableCommit(doc) {
232
275
  }
233
276
  }
234
277
 
278
+ // The query param that drives ?cms=true auto-open. Closing the CMS rewrites it
279
+ // to cms=false (param kept) so a refresh/share of the post-close URL does not
280
+ // auto-reopen.
281
+ const AUTO_OPEN_PARAM = 'cms'
282
+
283
+ // Pure: given a location.search string, return the search string after a close.
284
+ // Only rewrites when cms=true is present; every other param + ordering is
285
+ // preserved, and a search without cms (or already cms=false) is returned
286
+ // unchanged so we never inject the param into a page that never carried it.
287
+ export function nextSearchAfterClose(search) {
288
+ const str = typeof search === 'string' ? search : ''
289
+ const qIndex = str.indexOf('?')
290
+ const query = qIndex === -1 ? str : str.slice(qIndex + 1)
291
+ if (!query) return str
292
+ const params = new URLSearchParams(query)
293
+ if (params.get(AUTO_OPEN_PARAM) !== 'true') return str
294
+ params.set(AUTO_OPEN_PARAM, 'false')
295
+ return '?' + params.toString()
296
+ }
297
+
298
+ // Pure: should the CMS auto-open for this location.search? Exactly 'true'.
299
+ export function shouldAutoOpenFromSearch(search) {
300
+ const str = typeof search === 'string' ? search : ''
301
+ const qIndex = str.indexOf('?')
302
+ const query = qIndex === -1 ? str : str.slice(qIndex + 1)
303
+ if (!query) return false
304
+ return new URLSearchParams(query).get(AUTO_OPEN_PARAM) === 'true'
305
+ }
306
+
307
+ // Rewrite ?cms=true → ?cms=false via history.replaceState (no reload, hash and
308
+ // every other param preserved). No-op when the param isn't present or the
309
+ // History API is unavailable.
310
+ function toggleCloseParam() {
311
+ if (typeof window === 'undefined' || !window.location || !window.history) return
312
+ if (typeof window.history.replaceState !== 'function') return
313
+ const current = window.location.search
314
+ const next = nextSearchAfterClose(current)
315
+ if (next === current) return
316
+ window.history.replaceState(window.history.state, '', next + window.location.hash)
317
+ }
318
+
235
319
  export function close() {
236
320
  if (!state.isOpen) return
237
321
  const { ctx, shell } = state
322
+ // Mark this ctx dead so any in-flight async work (e.g. an upload awaiting the
323
+ // host uploader) bails instead of mutating the page / firing onChange after
324
+ // teardown. Mirrors the state.ctx !== ctx guards on the undo/livesync paths.
325
+ ctx.closed = true
238
326
  const previouslyFocused = ctx.previouslyFocused
239
327
  ctx.dispatch('hcms:close', null)
328
+ toggleCloseParam()
240
329
  ctx.observerHandle?.unsubscribe?.()
241
330
  ctx.undoUnsub?.()
242
331
  ctx.livesyncUnsub?.()
@@ -373,6 +462,110 @@ function writeFieldValue(el, value, formRoot, path) {
373
462
  el.textContent = value == null ? '' : String(value)
374
463
  }
375
464
 
465
+ // ?cms=true auto-open. When the page URL carries cms=true at load, open the CMS
466
+ // for everyone (no gating — this is a feature showcase; a non-admin's edits stay
467
+ // DOM-local and the host save path shows its view-mode notice). open() is
468
+ // idempotent-safe (it warns + returns if already open), so a host that also calls
469
+ // open() never double-mounts. No special opts — host defaults apply.
470
+ //
471
+ // Patience matters here: this runs at module load from the dist IIFE, which a
472
+ // host typically loads BEFORE installing window.hyperclay.Mutation (the Mutation
473
+ // install often sits behind an `await import(...)` that resumes after this script,
474
+ // even after DOMContentLoaded). open() needs <body> AND Mutation, so we poll for
475
+ // both rather than firing eagerly. Firing without Mutation would throw out of
476
+ // installObserver, escape module evaluation, and abort esbuild's
477
+ // window.hypercms assignment — taking the host's own wiring down with it.
478
+ const SAFETY_POLL_MS = 250
479
+ const AUTO_OPEN_DEADLINE_MS = 10000
480
+ export function maybeAutoOpen() {
481
+ if (typeof window === 'undefined' || typeof document === 'undefined') return
482
+ if (!shouldAutoOpenFromSearch(window.location ? window.location.search : '')) return
483
+ if (state.isOpen) return
484
+ whenReady(() => {
485
+ if (state.isOpen) return
486
+ try {
487
+ open()
488
+ } catch (err) {
489
+ console.warn('hypercms: auto-open failed', err)
490
+ }
491
+ })
492
+ }
493
+
494
+ // Auto-open is ready only once <body> is parsed AND window.hyperclay.Mutation is
495
+ // installed (open() needs both). NEVER throw out of module scope.
496
+ function autoOpenReady() {
497
+ return !!document.body && !!(window.hyperclay && window.hyperclay.Mutation)
498
+ }
499
+
500
+ // Three layers, in order of preference:
501
+ // 1. Sync fast-path: if <body> and Mutation are already here, fire now.
502
+ // 2. Event: hyperclayjs's mutation.js dispatches 'hyperclay:mutation-ready' the
503
+ // instant it installs window.hyperclay.Mutation. On loader pages this is
504
+ // moot — the loader's first wave evaluates mutation before hypercms, so the
505
+ // fast-path wins — but the event catches any late install (the deferred
506
+ // `await import()` ordering). The body gate is re-checked in the handler
507
+ // because the event can land before <body> is parsed.
508
+ // 3. Backstop: a slow self-cancelling 250ms poll for exotic hosts that hand-roll
509
+ // window.hyperclay.Mutation by direct assignment WITHOUT dispatching the
510
+ // event. It cancels the moment the event fires, open succeeds, or the
511
+ // deadline is hit (warn, never throw).
512
+ function whenReady(fn) {
513
+ if (autoOpenReady()) {
514
+ // Defer one microtask. On the bundled entry (hypercms-bundle.js), installStyles()
515
+ // runs AFTER this module evaluates, so firing open() synchronously here would
516
+ // mount the shell with cssText still empty (an unstyled sidebar). A microtask
517
+ // runs only after the synchronous bundle evaluation completes, by which point
518
+ // styles are installed. The event/backstop paths below already fire post-eval,
519
+ // so only this fast path needed deferring.
520
+ queueMicrotask(fn)
521
+ return
522
+ }
523
+ const deadline = Date.now() + AUTO_OPEN_DEADLINE_MS
524
+ let done = false
525
+ let timer = null
526
+ const finish = () => {
527
+ if (done) return
528
+ done = true
529
+ if (timer !== null) clearInterval(timer)
530
+ document.removeEventListener('hyperclay:mutation-ready', onMutationReady)
531
+ }
532
+ function onMutationReady() {
533
+ if (state.isOpen) {
534
+ finish()
535
+ return
536
+ }
537
+ if (autoOpenReady()) {
538
+ finish()
539
+ fn()
540
+ }
541
+ // Body may still be pending — the one-shot listener is already consumed,
542
+ // so only the backstop remains to catch it once body arrives.
543
+ }
544
+ document.addEventListener('hyperclay:mutation-ready', onMutationReady, { once: true })
545
+ timer = setInterval(() => {
546
+ if (state.isOpen) {
547
+ finish()
548
+ return
549
+ }
550
+ if (autoOpenReady()) {
551
+ finish()
552
+ fn()
553
+ return
554
+ }
555
+ if (Date.now() >= deadline) {
556
+ finish()
557
+ console.warn(
558
+ 'hypercms: ?cms=true auto-open gave up — window.hyperclay.Mutation never appeared. ' +
559
+ 'Load hyperclayjs (or the mutation utility) so the CMS can initialize.'
560
+ )
561
+ }
562
+ }, SAFETY_POLL_MS)
563
+ }
564
+
565
+ // Auto-open at module load (browser only — node tests import this without a real
566
+ // window, and the guards above no-op there).
567
+ maybeAutoOpen()
568
+
376
569
  const cms = {
377
570
  open,
378
571
  close,
package/src/refresh.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { engine } from 'hyper-html-api'
2
2
  import { buildForm } from './form-builder.js'
3
3
  import { morphForm } from './morph.js'
4
- import { injectDefaults } from './templates.js'
4
+ import { injectDefaults, injectComponents } from './templates.js'
5
5
  import { deriveFormRules } from './form-rules.js'
6
6
  import { coerceBooleans, applyErrorState } from './events.js'
7
7
 
@@ -18,6 +18,7 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
18
18
  }
19
19
  // Re-derive formRules so schema changes (rule shape, template overrides) flow through.
20
20
  injectDefaults(ctx.doc)
21
+ injectComponents(ctx.doc, ctx.pageRules)
21
22
  ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
22
23
 
23
24
  const newData = coerceBooleans(