@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.
- package/README.md +131 -5
- package/dist/hypercms.min.js +1204 -220
- package/package.json +5 -3
- package/src/events.js +298 -14
- package/src/form-builder.js +171 -19
- package/src/form-rules.js +23 -9
- package/src/hypercms-bundle.js +5 -2
- package/src/hypercms.js +197 -4
- package/src/refresh.js +2 -1
- package/src/shell.js +93 -18
- package/src/templates.js +431 -25
- package/src/theme/pixel-quiet.overrides.css +512 -0
- package/src/theme.generated.css +1175 -0
- package/src/vendor/mirk.vendor.js +197 -0
package/src/form-builder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
(
|
|
160
|
-
findTemplate(doc,
|
|
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 {
|
|
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:
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/hypercms-bundle.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import * as core from './hypercms.js'
|
|
2
|
-
import
|
|
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(
|
|
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
|
-
|
|
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'
|
|
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 = () =>
|
|
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(
|