@panphora/hyper-cms 0.4.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/LICENSE +14 -0
- package/README.md +247 -0
- package/dist/hypercms.min.js +362 -0
- package/package.json +50 -0
- package/src/apply-loop.js +161 -0
- package/src/diagnostics.js +44 -0
- package/src/events.js +457 -0
- package/src/form-builder.js +361 -0
- package/src/form-rules.js +192 -0
- package/src/hypercms-bundle.js +7 -0
- package/src/hypercms.js +321 -0
- package/src/morph.js +10 -0
- package/src/path.js +61 -0
- package/src/refresh.js +57 -0
- package/src/scaffold.js +10 -0
- package/src/shell.js +132 -0
- package/src/styles.css +298 -0
- package/src/templates.js +184 -0
package/src/hypercms.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { engine } from 'hyper-html-api'
|
|
2
|
+
import * as pathUtil from './path.js'
|
|
3
|
+
import { scaffold } from './scaffold.js'
|
|
4
|
+
import { morphForm } from './morph.js'
|
|
5
|
+
import { injectDefaults } from './templates.js'
|
|
6
|
+
import { deriveFormRules } from './form-rules.js'
|
|
7
|
+
import { buildForm } from './form-builder.js'
|
|
8
|
+
import {
|
|
9
|
+
bindEvents,
|
|
10
|
+
commit,
|
|
11
|
+
commitWithUndo,
|
|
12
|
+
suppressUndo,
|
|
13
|
+
onAdd as evOnAdd,
|
|
14
|
+
onRemove as evOnRemove,
|
|
15
|
+
extractFormData,
|
|
16
|
+
stableStringify,
|
|
17
|
+
restampAllSiblings,
|
|
18
|
+
coerceBooleans,
|
|
19
|
+
} from './events.js'
|
|
20
|
+
import { mountShell, setShellStyles, markStylesBundled } from './shell.js'
|
|
21
|
+
import { refreshForm, installObserver } from './refresh.js'
|
|
22
|
+
import { warnUnmatchedTemplates } from './diagnostics.js'
|
|
23
|
+
|
|
24
|
+
export function installStyles(text) {
|
|
25
|
+
setShellStyles(text)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function markBundledStyles(doc) {
|
|
29
|
+
markStylesBundled(doc)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const state = {
|
|
33
|
+
isOpen: false,
|
|
34
|
+
ctx: null,
|
|
35
|
+
shell: null,
|
|
36
|
+
opts: null,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function open(opts = {}) {
|
|
40
|
+
if (state.isOpen) {
|
|
41
|
+
console.warn('cms.open() called while already open; ignoring')
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const pageRoot = opts.pageRoot || (typeof document !== 'undefined' ? document.body : null)
|
|
45
|
+
if (!pageRoot) throw new Error('hypercms: no pageRoot available')
|
|
46
|
+
const doc = pageRoot.ownerDocument || (typeof document !== 'undefined' ? document : null)
|
|
47
|
+
if (!doc) throw new Error('hypercms: no document available')
|
|
48
|
+
|
|
49
|
+
// Resolve rules via the union: an explicit rules object, a token string, or
|
|
50
|
+
// the default token "cms". findRules is document-scoped, so a head-mounted
|
|
51
|
+
// rules tag is found even though pageRoot defaults to <body>.
|
|
52
|
+
const source = opts.rules !== undefined ? opts.rules : 'cms'
|
|
53
|
+
const found = engine.findRules(doc, source)
|
|
54
|
+
if (!found) {
|
|
55
|
+
const what = typeof source === 'string' ? `data-rules-name~="${source}"` : 'the provided rules object'
|
|
56
|
+
throw new Error(`hypercms: no rules found for ${what}`)
|
|
57
|
+
}
|
|
58
|
+
const pageRules = found.rules
|
|
59
|
+
const rulesTagNode = found.tagNode
|
|
60
|
+
|
|
61
|
+
injectDefaults(doc)
|
|
62
|
+
warnUnmatchedTemplates(doc, pageRules)
|
|
63
|
+
const formRules = deriveFormRules(pageRules, doc)
|
|
64
|
+
const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
|
|
65
|
+
|
|
66
|
+
// Suppress undo around mountShell: it toggles chrome-only classes on
|
|
67
|
+
// document.body (hcms-open, etc.) which would otherwise land as undoable
|
|
68
|
+
// page edits. The shell subtree itself is already filtered via save-ignore.
|
|
69
|
+
const shell = suppressUndo(() => mountShell({
|
|
70
|
+
mountTo: opts.mountTo || doc.body,
|
|
71
|
+
side: opts.side || 'right',
|
|
72
|
+
overlay: !!opts.overlay,
|
|
73
|
+
showSaveButton: !!opts.showSaveButton,
|
|
74
|
+
doc,
|
|
75
|
+
}))
|
|
76
|
+
|
|
77
|
+
const ctx = {
|
|
78
|
+
doc,
|
|
79
|
+
pageRoot,
|
|
80
|
+
pageRules,
|
|
81
|
+
formRules,
|
|
82
|
+
rulesTagNode,
|
|
83
|
+
rulesSource: source,
|
|
84
|
+
formRoot: shell.formRoot,
|
|
85
|
+
shellRoot: shell.root,
|
|
86
|
+
errorEl: shell.errorEl,
|
|
87
|
+
lastFingerprint: null,
|
|
88
|
+
lastData: null,
|
|
89
|
+
observerHandle: null,
|
|
90
|
+
onChange: opts.onChange,
|
|
91
|
+
onError: opts.onError,
|
|
92
|
+
onSave: opts.onSave,
|
|
93
|
+
previouslyFocused: doc.activeElement,
|
|
94
|
+
dispatch(name, detail) {
|
|
95
|
+
const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
|
|
96
|
+
if (!Ctor) return
|
|
97
|
+
const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change' || name === 'hcms:save', detail })
|
|
98
|
+
shell.root.dispatchEvent(ev)
|
|
99
|
+
},
|
|
100
|
+
onCloseRequested() {
|
|
101
|
+
close()
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
ctx.updateFingerprint = () => {
|
|
105
|
+
ctx.lastFingerprint = stableStringify(extractFormData(ctx))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const fragment = buildForm({ pageRules, formRules, data, doc })
|
|
109
|
+
shell.formRoot.appendChild(fragment)
|
|
110
|
+
|
|
111
|
+
bindEvents(ctx)
|
|
112
|
+
ctx.updateFingerprint()
|
|
113
|
+
|
|
114
|
+
ctx.observerHandle = installObserver({
|
|
115
|
+
onRefresh: () => refreshForm(ctx),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// Wire global sortable callback to current ctx (replaced by close()).
|
|
119
|
+
globalCommitTarget.ctx = ctx
|
|
120
|
+
installGlobalSortableCommit(doc)
|
|
121
|
+
|
|
122
|
+
// Move focus into the shell — survives close+restore via previouslyFocused.
|
|
123
|
+
focusFirstIn(shell.root)
|
|
124
|
+
|
|
125
|
+
state.isOpen = true
|
|
126
|
+
state.ctx = ctx
|
|
127
|
+
state.shell = shell
|
|
128
|
+
state.opts = opts
|
|
129
|
+
|
|
130
|
+
ctx.dispatch('hcms:open', { pageRoot })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function focusFirstIn(root) {
|
|
134
|
+
const sel = 'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
135
|
+
const target = root.querySelector(sel)
|
|
136
|
+
if (target && typeof target.focus === 'function') target.focus()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const globalCommitTarget = { ctx: null }
|
|
140
|
+
|
|
141
|
+
function installGlobalSortableCommit(doc) {
|
|
142
|
+
const win = doc.defaultView || (typeof globalThis !== 'undefined' ? globalThis : null)
|
|
143
|
+
if (!win) return
|
|
144
|
+
const hypercmsCommitFn = function hypercmsCommitGlobal() {
|
|
145
|
+
const ctx = globalCommitTarget.ctx
|
|
146
|
+
if (!ctx) return
|
|
147
|
+
restampAllSiblings(ctx.formRoot)
|
|
148
|
+
// Route through commitWithUndo so a drag-reorder lands as a labeled
|
|
149
|
+
// 'Reorder' commit (not a generic idle 'Edit') and a failed apply's
|
|
150
|
+
// mutate+rollback is never recorded as a no-op commit. No-op pass-through
|
|
151
|
+
// when undo isn't loaded.
|
|
152
|
+
return commitWithUndo('Reorder', () =>
|
|
153
|
+
commit(extractFormData(ctx), { path: '', structural: true }, ctx)
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
if (typeof win.hypercmsCommit !== 'function') win.hypercmsCommit = hypercmsCommitFn
|
|
157
|
+
// Also mirror to globalThis so non-window contexts (Node tests, workers)
|
|
158
|
+
// can resolve the bare name from `new Function('hypercmsCommit()')`.
|
|
159
|
+
if (typeof globalThis !== 'undefined' && typeof globalThis.hypercmsCommit !== 'function') {
|
|
160
|
+
globalThis.hypercmsCommit = hypercmsCommitFn
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function close() {
|
|
165
|
+
if (!state.isOpen) return
|
|
166
|
+
const { ctx, shell } = state
|
|
167
|
+
const previouslyFocused = ctx.previouslyFocused
|
|
168
|
+
ctx.dispatch('hcms:close', null)
|
|
169
|
+
ctx.observerHandle?.unsubscribe?.()
|
|
170
|
+
ctx.detachEvents?.()
|
|
171
|
+
// destroy() removes the chrome-only body classes; suppress so the inverse
|
|
172
|
+
// class removal doesn't enter the undo stack either.
|
|
173
|
+
suppressUndo(() => shell.destroy())
|
|
174
|
+
state.isOpen = false
|
|
175
|
+
state.ctx = null
|
|
176
|
+
state.shell = null
|
|
177
|
+
state.opts = null
|
|
178
|
+
globalCommitTarget.ctx = null
|
|
179
|
+
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
|
180
|
+
try { previouslyFocused.focus() } catch (_) {}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function refresh() {
|
|
185
|
+
if (!state.isOpen) return
|
|
186
|
+
refreshForm(state.ctx)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function isOpen() {
|
|
190
|
+
return state.isOpen
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const api = {
|
|
194
|
+
getData() {
|
|
195
|
+
if (!state.isOpen) return null
|
|
196
|
+
return extractFormData(state.ctx)
|
|
197
|
+
},
|
|
198
|
+
setValue(path, value) {
|
|
199
|
+
if (!state.isOpen) throw new Error('hypercms: cms is not open')
|
|
200
|
+
const ctx = state.ctx
|
|
201
|
+
const pathArr = pathUtil.fromString(path)
|
|
202
|
+
const rule = pathUtil.getRuleAtPath(ctx.pageRules, pathArr)
|
|
203
|
+
if (rule === undefined) throw new Error(`hypercms: no rule at path "${path}"`)
|
|
204
|
+
if (typeof rule !== 'string' || rule.endsWith('[]')) {
|
|
205
|
+
throw new Error(`hypercms: setValue requires a leaf scalar path; "${path}" is not a leaf`)
|
|
206
|
+
}
|
|
207
|
+
const field = findLeafField(ctx.formRoot, path)
|
|
208
|
+
if (!field) throw new Error(`hypercms: no field element at path "${path}"`)
|
|
209
|
+
writeFieldValue(field, value, ctx.formRoot, path)
|
|
210
|
+
commit(extractFormData(ctx), { path, structural: false }, ctx)
|
|
211
|
+
},
|
|
212
|
+
addItem(arrayPath) {
|
|
213
|
+
if (!state.isOpen) throw new Error('hypercms: cms is not open')
|
|
214
|
+
evOnAdd(arrayPath, state.ctx)
|
|
215
|
+
},
|
|
216
|
+
removeItem(itemPath) {
|
|
217
|
+
if (!state.isOpen) throw new Error('hypercms: cms is not open')
|
|
218
|
+
const ctx = state.ctx
|
|
219
|
+
const pathArr = pathUtil.fromString(itemPath)
|
|
220
|
+
// Validate path: must be an array item — last segment is a non-negative
|
|
221
|
+
// integer, parent rule must be an array (object-array or scalar-array).
|
|
222
|
+
const lastSeg = pathArr[pathArr.length - 1]
|
|
223
|
+
if (typeof lastSeg !== 'number') {
|
|
224
|
+
throw new Error(`hypercms: removeItem requires an item path; "${itemPath}" is not an array index`)
|
|
225
|
+
}
|
|
226
|
+
const parentRule = pathUtil.getRuleAtPath(ctx.pageRules, pathArr.slice(0, -1))
|
|
227
|
+
const parentIsArray = Array.isArray(parentRule) || (typeof parentRule === 'string' && parentRule.endsWith('[]'))
|
|
228
|
+
if (!parentIsArray) {
|
|
229
|
+
throw new Error(`hypercms: removeItem requires an item path; parent of "${itemPath}" is not an array`)
|
|
230
|
+
}
|
|
231
|
+
const itemEl = ctx.formRoot.querySelector(`[data-hcms-path="${cssEscape(itemPath)}"]`)
|
|
232
|
+
if (!itemEl) throw new Error(`hypercms: no element at path "${itemPath}"`)
|
|
233
|
+
evOnRemove(itemEl, ctx)
|
|
234
|
+
},
|
|
235
|
+
refresh,
|
|
236
|
+
_commit() {
|
|
237
|
+
if (!state.isOpen) return
|
|
238
|
+
const ctx = state.ctx
|
|
239
|
+
restampAllSiblings(ctx.formRoot)
|
|
240
|
+
return commitWithUndo('Update', () =>
|
|
241
|
+
commit(extractFormData(ctx), { path: '', structural: true }, ctx)
|
|
242
|
+
)
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function findLeafField(formRoot, path) {
|
|
247
|
+
const esc = cssEscape(path)
|
|
248
|
+
// Prefer a tag-qualified leaf (input/textarea/select/img/a) — since v0.3
|
|
249
|
+
// stamps data-hcms-field on the wrapping container too, a bare attribute
|
|
250
|
+
// match would resolve to the container and lose the value write.
|
|
251
|
+
const leafSel =
|
|
252
|
+
`[data-hcms-path="${esc}"] input[data-hcms-field], ` +
|
|
253
|
+
`[data-hcms-path="${esc}"] textarea[data-hcms-field], ` +
|
|
254
|
+
`[data-hcms-path="${esc}"] select[data-hcms-field], ` +
|
|
255
|
+
`[data-hcms-path="${esc}"] img[data-hcms-field], ` +
|
|
256
|
+
`[data-hcms-path="${esc}"] a[data-hcms-field], ` +
|
|
257
|
+
`[data-hcms-path="${esc}"] [contenteditable][data-hcms-field], ` +
|
|
258
|
+
// Leaf is the path-stamped element itself (inline-stamped fields):
|
|
259
|
+
`input[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
260
|
+
`textarea[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
261
|
+
`select[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
262
|
+
`img[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
263
|
+
`a[data-hcms-path="${esc}"][data-hcms-field], ` +
|
|
264
|
+
`[contenteditable][data-hcms-path="${esc}"][data-hcms-field]`
|
|
265
|
+
return formRoot.querySelector(leafSel)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function writeFieldValue(el, value, formRoot, path) {
|
|
269
|
+
const tag = (el.tagName || '').toUpperCase()
|
|
270
|
+
const type = (el.getAttribute('type') || '').toLowerCase()
|
|
271
|
+
if (tag === 'INPUT' && type === 'checkbox') {
|
|
272
|
+
el.checked = value === true || value === 'true'
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
if (tag === 'INPUT' && type === 'radio') {
|
|
276
|
+
// Radios sharing the same path act as a group. Toggle the matching option.
|
|
277
|
+
const esc = cssEscape(path)
|
|
278
|
+
const group = formRoot.querySelectorAll(
|
|
279
|
+
`[data-hcms-path="${esc}"][data-hcms-field][type="radio"], [data-hcms-path="${esc}"] [data-hcms-field][type="radio"]`
|
|
280
|
+
)
|
|
281
|
+
if (group.length) {
|
|
282
|
+
group.forEach((r) => { r.checked = String(r.value) === String(value ?? '') })
|
|
283
|
+
} else {
|
|
284
|
+
el.checked = String(el.value) === String(value ?? '')
|
|
285
|
+
}
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
if (tag === 'IMG') {
|
|
289
|
+
el.src = value == null ? '' : String(value)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
if (tag === 'A') {
|
|
293
|
+
el.href = value == null ? '' : String(value)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if ('value' in el) {
|
|
297
|
+
el.value = value == null ? '' : String(value)
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
el.textContent = value == null ? '' : String(value)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const cms = {
|
|
304
|
+
open,
|
|
305
|
+
close,
|
|
306
|
+
refresh,
|
|
307
|
+
api,
|
|
308
|
+
get isOpen() { return state.isOpen },
|
|
309
|
+
// Power-user exports (mostly for testing + advanced integration)
|
|
310
|
+
path: pathUtil,
|
|
311
|
+
scaffold,
|
|
312
|
+
morphForm,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function cssEscape(value) {
|
|
316
|
+
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value)
|
|
317
|
+
return String(value).replace(/[^a-zA-Z0-9_\-.*]/g, (c) => '\\' + c)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export { cms }
|
|
321
|
+
export default { cms }
|
package/src/morph.js
ADDED
package/src/path.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export function toString(path) {
|
|
2
|
+
return path.map(String).join('.')
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function fromString(str) {
|
|
6
|
+
if (str === '') return []
|
|
7
|
+
return str.split('.').map((seg) => (/^\d+$/.test(seg) ? Number(seg) : seg))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getRuleAtPath(rules, path) {
|
|
11
|
+
let node = rules
|
|
12
|
+
for (const seg of path) {
|
|
13
|
+
if (node == null) return undefined
|
|
14
|
+
if (typeof node === 'string') {
|
|
15
|
+
// Scalar-array rules like 'li[]' contain leaf scalars at integer indices.
|
|
16
|
+
// Descend by stripping the trailing []; the leaf is treated as a plain
|
|
17
|
+
// scalar string for path queries.
|
|
18
|
+
if (node.endsWith('[]') && (typeof seg === 'number' || seg === '*')) {
|
|
19
|
+
node = node.slice(0, -2)
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(node)) {
|
|
25
|
+
if (typeof seg !== 'number' && seg !== '*') return undefined
|
|
26
|
+
node = node[1]
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
if (typeof node === 'object') {
|
|
30
|
+
if (typeof seg === 'number') return undefined
|
|
31
|
+
if (!(seg in node)) return undefined
|
|
32
|
+
node = node[seg]
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
return node
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getValueAtPath(data, path) {
|
|
41
|
+
let node = data
|
|
42
|
+
for (const seg of path) {
|
|
43
|
+
if (node == null) return undefined
|
|
44
|
+
node = node[seg]
|
|
45
|
+
}
|
|
46
|
+
return node
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function setAtPath(obj, path, value) {
|
|
50
|
+
if (path.length === 0) return value
|
|
51
|
+
const [k, ...rest] = path
|
|
52
|
+
if (typeof k === 'number') {
|
|
53
|
+
const next = Array.isArray(obj) ? [...obj] : []
|
|
54
|
+
next[k] = setAtPath(next[k], rest, value)
|
|
55
|
+
return next
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
...(obj && typeof obj === 'object' ? obj : {}),
|
|
59
|
+
[k]: setAtPath((obj || {})[k], rest, value),
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/refresh.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { engine } from 'hyper-html-api'
|
|
2
|
+
import { buildForm } from './form-builder.js'
|
|
3
|
+
import { morphForm } from './morph.js'
|
|
4
|
+
import { injectDefaults } from './templates.js'
|
|
5
|
+
import { deriveFormRules } from './form-rules.js'
|
|
6
|
+
import { coerceBooleans, applyErrorState } from './events.js'
|
|
7
|
+
|
|
8
|
+
const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
9
|
+
|
|
10
|
+
export function refreshForm(ctx) {
|
|
11
|
+
// Re-resolve rules every refresh, source-aware: an object source returns the
|
|
12
|
+
// same literal (tagNode null); a token source re-resolves the tag so a
|
|
13
|
+
// livesync-replaced rules tag is picked up. Document-scoped via ctx.doc.
|
|
14
|
+
const found = engine.findRules(ctx.doc, ctx.rulesSource || 'cms')
|
|
15
|
+
if (found) {
|
|
16
|
+
ctx.pageRules = found.rules
|
|
17
|
+
ctx.rulesTagNode = found.tagNode
|
|
18
|
+
}
|
|
19
|
+
// Re-derive formRules so schema changes (rule shape, template overrides) flow through.
|
|
20
|
+
injectDefaults(ctx.doc)
|
|
21
|
+
ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
|
|
22
|
+
|
|
23
|
+
const newData = coerceBooleans(
|
|
24
|
+
engine.extract(ctx.pageRoot, ctx.pageRules, ENGINE_OPTS),
|
|
25
|
+
ctx.pageRules
|
|
26
|
+
)
|
|
27
|
+
const fragment = buildForm({
|
|
28
|
+
pageRules: ctx.pageRules,
|
|
29
|
+
formRules: ctx.formRules,
|
|
30
|
+
data: newData,
|
|
31
|
+
doc: ctx.doc,
|
|
32
|
+
})
|
|
33
|
+
morphForm(ctx.formRoot, fragment)
|
|
34
|
+
// morphForm rebuilds the form structure; inline error slots get wiped. Re-apply
|
|
35
|
+
// the last error state so error messages survive across observer-driven refreshes
|
|
36
|
+
// (e.g., the rollback after a failed add).
|
|
37
|
+
applyErrorState(ctx)
|
|
38
|
+
// Update fingerprint so subsequent commits against the new page state compare correctly.
|
|
39
|
+
if (ctx.updateFingerprint) ctx.updateFingerprint()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function installObserver({ debounce = 100, onRefresh }) {
|
|
43
|
+
const M = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.Mutation) || null
|
|
44
|
+
if (!M || typeof M.onAnyChange !== 'function') {
|
|
45
|
+
throw new Error('hypercms: window.hyperclay.Mutation is required. Load hyperclayjs (or just the mutation utility) before initializing hypercms.')
|
|
46
|
+
}
|
|
47
|
+
let paused = 0
|
|
48
|
+
const unsub = M.onAnyChange({ debounce }, () => {
|
|
49
|
+
if (paused > 0) return
|
|
50
|
+
onRefresh()
|
|
51
|
+
})
|
|
52
|
+
return {
|
|
53
|
+
unsubscribe: typeof unsub === 'function' ? unsub : () => {},
|
|
54
|
+
pause() { paused++ },
|
|
55
|
+
resume() { paused = Math.max(0, paused - 1) },
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/scaffold.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function scaffold(shape) {
|
|
2
|
+
if (typeof shape === 'string') return shape.endsWith('[]') ? [] : ''
|
|
3
|
+
if (Array.isArray(shape)) return []
|
|
4
|
+
if (typeof shape === 'object' && shape !== null) {
|
|
5
|
+
const out = {}
|
|
6
|
+
for (const [k, v] of Object.entries(shape)) out[k] = scaffold(v)
|
|
7
|
+
return out
|
|
8
|
+
}
|
|
9
|
+
return ''
|
|
10
|
+
}
|
package/src/shell.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const SHELL_STYLE_ID = 'hcms-shell-styles'
|
|
2
|
+
const SHELL_BUNDLED_FLAG = 'hcms-bundled-styles-installed'
|
|
3
|
+
const FOCUSABLE = 'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
4
|
+
|
|
5
|
+
const styledDocs = new WeakSet()
|
|
6
|
+
let cssText = ''
|
|
7
|
+
|
|
8
|
+
export function setShellStyles(text) {
|
|
9
|
+
cssText = text
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function markStylesBundled(doc) {
|
|
13
|
+
// Bundler emits a <style data-hcms-bundled-styles> tag and sets this so
|
|
14
|
+
// the link-based path doesn't double-install.
|
|
15
|
+
if (!doc) return
|
|
16
|
+
styledDocs.add(doc)
|
|
17
|
+
doc[SHELL_BUNDLED_FLAG] = true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let titleIdCounter = 0
|
|
21
|
+
|
|
22
|
+
export function mountShell({ mountTo, side = 'right', overlay = false, showSaveButton = false, doc }) {
|
|
23
|
+
ensureStyles(doc)
|
|
24
|
+
const titleId = `hcms-shell-title-${++titleIdCounter}`
|
|
25
|
+
const root = doc.createElement('div')
|
|
26
|
+
root.setAttribute('data-hcms-shell', '')
|
|
27
|
+
root.setAttribute('save-remove', '')
|
|
28
|
+
root.setAttribute('save-ignore', '')
|
|
29
|
+
root.setAttribute('tabindex', '-1')
|
|
30
|
+
// Dialog semantics: focus is trapped + body scrolling locked, so screen
|
|
31
|
+
// readers should announce this as a modal dialog with the shell title as
|
|
32
|
+
// its accessible name.
|
|
33
|
+
root.setAttribute('role', 'dialog')
|
|
34
|
+
root.setAttribute('aria-modal', 'true')
|
|
35
|
+
root.setAttribute('aria-labelledby', titleId)
|
|
36
|
+
root.className = 'hcms-shell hcms-side-' + side + (overlay ? ' hcms-overlay' : '')
|
|
37
|
+
root.innerHTML = `
|
|
38
|
+
<header class="hcms-shell-header">
|
|
39
|
+
<h2 class="hcms-shell-title" id="${titleId}">Edit</h2>
|
|
40
|
+
<button type="button" class="hcms-shell-close" data-hcms-action="close" aria-label="Close">×</button>
|
|
41
|
+
</header>
|
|
42
|
+
<div class="hcms-shell-error" role="alert" hidden></div>
|
|
43
|
+
<div data-hcms-form-root class="hcms-form"></div>
|
|
44
|
+
<footer class="hcms-shell-footer"${showSaveButton ? '' : ' hidden'}>
|
|
45
|
+
<button type="button" class="hcms-shell-save" data-hcms-action="save">Save</button>
|
|
46
|
+
</footer>
|
|
47
|
+
`
|
|
48
|
+
|
|
49
|
+
// mountTo may be a nested element inside pageRoot. Use it as-is for the
|
|
50
|
+
// visual hierarchy; engine reads/writes pass { skip: '[data-hcms-shell]' }
|
|
51
|
+
// so the engine never traverses into the form regardless of where it's mounted.
|
|
52
|
+
const host = mountTo || doc.body
|
|
53
|
+
host.appendChild(root)
|
|
54
|
+
|
|
55
|
+
const body = doc.body
|
|
56
|
+
body.classList.add('hcms-open')
|
|
57
|
+
if (overlay) body.classList.add('hcms-overlay')
|
|
58
|
+
if (side === 'left') body.classList.add('hcms-side-left')
|
|
59
|
+
|
|
60
|
+
const focusTrap = installFocusTrap(root, doc)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
root,
|
|
64
|
+
formRoot: root.querySelector('[data-hcms-form-root]'),
|
|
65
|
+
errorEl: root.querySelector('.hcms-shell-error'),
|
|
66
|
+
saveButton: root.querySelector('.hcms-shell-save'),
|
|
67
|
+
destroy() {
|
|
68
|
+
focusTrap.detach()
|
|
69
|
+
root.remove()
|
|
70
|
+
body.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ensureStyles(doc) {
|
|
76
|
+
if (!doc) return
|
|
77
|
+
if (styledDocs.has(doc)) return
|
|
78
|
+
if (doc[SHELL_BUNDLED_FLAG]) {
|
|
79
|
+
styledDocs.add(doc)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
if (doc.getElementById(SHELL_STYLE_ID) || doc.querySelector('style[data-hcms-bundled-styles]')) {
|
|
83
|
+
styledDocs.add(doc)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
if (cssText) {
|
|
87
|
+
const style = doc.createElement('style')
|
|
88
|
+
style.id = SHELL_STYLE_ID
|
|
89
|
+
style.setAttribute('save-remove', '')
|
|
90
|
+
style.textContent = cssText
|
|
91
|
+
;(doc.head || doc.documentElement).appendChild(style)
|
|
92
|
+
styledDocs.add(doc)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
// No bundled CSS — try resolving the sibling styles.css via import.meta.url.
|
|
96
|
+
// Browsers ESM-resolved imports place a co-located styles.css next to
|
|
97
|
+
// hypercms.js. Falls back silently if resolution fails (e.g., bundlers
|
|
98
|
+
// without an asset emitter).
|
|
99
|
+
try {
|
|
100
|
+
const href = new URL('./styles.css', import.meta.url).href
|
|
101
|
+
const link = doc.createElement('link')
|
|
102
|
+
link.rel = 'stylesheet'
|
|
103
|
+
link.id = SHELL_STYLE_ID
|
|
104
|
+
link.setAttribute('save-remove', '')
|
|
105
|
+
link.href = href
|
|
106
|
+
;(doc.head || doc.documentElement).appendChild(link)
|
|
107
|
+
styledDocs.add(doc)
|
|
108
|
+
} catch (_) {
|
|
109
|
+
// SSR or environments without import.meta.url — author can call
|
|
110
|
+
// installStyles(text) manually.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function installFocusTrap(root, doc) {
|
|
115
|
+
function onKeyDown(e) {
|
|
116
|
+
if (e.key !== 'Tab') return
|
|
117
|
+
if (!root.contains(doc.activeElement)) return
|
|
118
|
+
const focusables = Array.from(root.querySelectorAll(FOCUSABLE))
|
|
119
|
+
if (focusables.length === 0) return
|
|
120
|
+
const first = focusables[0]
|
|
121
|
+
const last = focusables[focusables.length - 1]
|
|
122
|
+
if (e.shiftKey && doc.activeElement === first) {
|
|
123
|
+
e.preventDefault()
|
|
124
|
+
last.focus()
|
|
125
|
+
} else if (!e.shiftKey && doc.activeElement === last) {
|
|
126
|
+
e.preventDefault()
|
|
127
|
+
first.focus()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
doc.addEventListener('keydown', onKeyDown)
|
|
131
|
+
return { detach: () => doc.removeEventListener('keydown', onKeyDown) }
|
|
132
|
+
}
|