@panphora/hyper-cms 0.4.2 → 0.5.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 +26 -4
- package/dist/hypercms.min.js +1040 -220
- package/package.json +5 -3
- package/src/events.js +221 -14
- package/src/form-builder.js +32 -3
- package/src/form-rules.js +7 -5
- package/src/hypercms-bundle.js +5 -2
- package/src/hypercms.js +191 -4
- package/src/refresh.js +2 -1
- package/src/shell.js +80 -16
- package/src/templates.js +165 -24
- package/src/theme/pixel-quiet.overrides.css +503 -0
- package/src/theme.generated.css +1074 -0
- package/src/vendor/mirk.vendor.js +197 -0
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, injectUploadComponents } 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
|
+
injectUploadComponents(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,104 @@ 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
|
+
fn()
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
const deadline = Date.now() + AUTO_OPEN_DEADLINE_MS
|
|
518
|
+
let done = false
|
|
519
|
+
let timer = null
|
|
520
|
+
const finish = () => {
|
|
521
|
+
if (done) return
|
|
522
|
+
done = true
|
|
523
|
+
if (timer !== null) clearInterval(timer)
|
|
524
|
+
document.removeEventListener('hyperclay:mutation-ready', onMutationReady)
|
|
525
|
+
}
|
|
526
|
+
function onMutationReady() {
|
|
527
|
+
if (state.isOpen) {
|
|
528
|
+
finish()
|
|
529
|
+
return
|
|
530
|
+
}
|
|
531
|
+
if (autoOpenReady()) {
|
|
532
|
+
finish()
|
|
533
|
+
fn()
|
|
534
|
+
}
|
|
535
|
+
// Body may still be pending — the one-shot listener is already consumed,
|
|
536
|
+
// so only the backstop remains to catch it once body arrives.
|
|
537
|
+
}
|
|
538
|
+
document.addEventListener('hyperclay:mutation-ready', onMutationReady, { once: true })
|
|
539
|
+
timer = setInterval(() => {
|
|
540
|
+
if (state.isOpen) {
|
|
541
|
+
finish()
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
if (autoOpenReady()) {
|
|
545
|
+
finish()
|
|
546
|
+
fn()
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
if (Date.now() >= deadline) {
|
|
550
|
+
finish()
|
|
551
|
+
console.warn(
|
|
552
|
+
'hypercms: ?cms=true auto-open gave up — window.hyperclay.Mutation never appeared. ' +
|
|
553
|
+
'Load hyperclayjs (or the mutation utility) so the CMS can initialize.'
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
}, SAFETY_POLL_MS)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Auto-open at module load (browser only — node tests import this without a real
|
|
560
|
+
// window, and the guards above no-op there).
|
|
561
|
+
maybeAutoOpen()
|
|
562
|
+
|
|
376
563
|
const cms = {
|
|
377
564
|
open,
|
|
378
565
|
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, injectUploadComponents } 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
|
+
injectUploadComponents(ctx.doc, ctx.pageRules)
|
|
21
22
|
ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
|
|
22
23
|
|
|
23
24
|
const newData = coerceBooleans(
|
package/src/shell.js
CHANGED
|
@@ -19,7 +19,22 @@ export function markStylesBundled(doc) {
|
|
|
19
19
|
|
|
20
20
|
let titleIdCounter = 0
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
function escapeHtml(s) {
|
|
23
|
+
return String(s).replace(/[&<>"]/g, (c) => (
|
|
24
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"' }[c]
|
|
25
|
+
))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function mountShell({
|
|
29
|
+
mountTo,
|
|
30
|
+
side = 'right',
|
|
31
|
+
overlay = false,
|
|
32
|
+
showSaveButton = false,
|
|
33
|
+
title = 'Page content',
|
|
34
|
+
eyebrow = 'Edit',
|
|
35
|
+
theme = null,
|
|
36
|
+
doc,
|
|
37
|
+
}) {
|
|
23
38
|
ensureStyles(doc)
|
|
24
39
|
const titleId = `hcms-shell-title-${++titleIdCounter}`
|
|
25
40
|
const root = doc.createElement('div')
|
|
@@ -33,17 +48,45 @@ export function mountShell({ mountTo, side = 'right', overlay = false, showSaveB
|
|
|
33
48
|
root.setAttribute('role', 'dialog')
|
|
34
49
|
root.setAttribute('aria-modal', 'true')
|
|
35
50
|
root.setAttribute('aria-labelledby', titleId)
|
|
36
|
-
|
|
51
|
+
// pixel-quiet is the baked-in look; an optional theme pins light/dark
|
|
52
|
+
// (otherwise the panel follows the OS via prefers-color-scheme).
|
|
53
|
+
const themeClass = theme === 'dark' ? ' dark' : theme === 'light' ? ' light' : ''
|
|
54
|
+
root.className =
|
|
55
|
+
'hcms-shell pixel-quiet hcms-side-' + side + (overlay ? ' hcms-overlay' : '') + themeClass
|
|
56
|
+
const titleHtml = escapeHtml(title)
|
|
57
|
+
const eyebrowHtml = escapeHtml(eyebrow)
|
|
58
|
+
// Pixel Quiet shell: a scroll-away header + in-flow Save inside .hcms-shell-body,
|
|
59
|
+
// with a condensed minibar pinned to the panel top once the header scrolls out.
|
|
60
|
+
// Close + Save are real mirk buttons. The .hcms-* hooks stay so the engine and
|
|
61
|
+
// events bind exactly as before; only the chrome shape changed.
|
|
62
|
+
// The Save button has no wiring here: [trigger-save] is the hyperclayjs save
|
|
63
|
+
// attribute — its save system (or its view-mode notice) handles the click.
|
|
64
|
+
// Standalone hosts delegate their own [trigger-save] click listener.
|
|
37
65
|
root.innerHTML = `
|
|
38
|
-
<
|
|
39
|
-
<
|
|
40
|
-
<button type="button" class="hcms-shell-close" data-hcms-action="close" aria-label="Close"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
<
|
|
46
|
-
|
|
66
|
+
<div class="hcms-shell-minibar" aria-hidden="true">
|
|
67
|
+
<span class="hcms-shell-minibar-title">${titleHtml}</span>
|
|
68
|
+
<button type="button" class="hcms-shell-close mirk-button mirk-button--small" data-hcms-action="close" aria-label="Close">
|
|
69
|
+
<span class="mirk-button__label">×</span>
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="hcms-shell-body">
|
|
73
|
+
<header class="hcms-shell-header">
|
|
74
|
+
<div class="hcms-shell-heading">
|
|
75
|
+
<div class="hcms-shell-eyebrow">${eyebrowHtml}</div>
|
|
76
|
+
<h2 class="hcms-shell-title" id="${titleId}">${titleHtml}</h2>
|
|
77
|
+
</div>
|
|
78
|
+
<button type="button" class="hcms-shell-close mirk-button mirk-button--small" data-hcms-action="close" aria-label="Close">
|
|
79
|
+
<span class="mirk-button__label">×</span>
|
|
80
|
+
</button>
|
|
81
|
+
</header>
|
|
82
|
+
<div class="hcms-shell-error" role="alert" hidden></div>
|
|
83
|
+
<div data-hcms-form-root class="hcms-form"></div>
|
|
84
|
+
<footer class="hcms-shell-footer"${showSaveButton ? '' : ' hidden'}>
|
|
85
|
+
<button type="button" class="hcms-shell-save mirk-button" trigger-save>
|
|
86
|
+
<span class="mirk-button__label">Save</span>
|
|
87
|
+
</button>
|
|
88
|
+
</footer>
|
|
89
|
+
</div>
|
|
47
90
|
`
|
|
48
91
|
|
|
49
92
|
// mountTo may be a nested element inside pageRoot. Use it as-is for the
|
|
@@ -58,6 +101,7 @@ export function mountShell({ mountTo, side = 'right', overlay = false, showSaveB
|
|
|
58
101
|
if (side === 'left') body.classList.add('hcms-side-left')
|
|
59
102
|
|
|
60
103
|
const focusTrap = installFocusTrap(root, doc)
|
|
104
|
+
const condense = installCondenseOnScroll(root)
|
|
61
105
|
|
|
62
106
|
return {
|
|
63
107
|
root,
|
|
@@ -66,6 +110,7 @@ export function mountShell({ mountTo, side = 'right', overlay = false, showSaveB
|
|
|
66
110
|
saveButton: root.querySelector('.hcms-shell-save'),
|
|
67
111
|
destroy() {
|
|
68
112
|
focusTrap.detach()
|
|
113
|
+
condense.detach()
|
|
69
114
|
root.remove()
|
|
70
115
|
body.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
|
|
71
116
|
},
|
|
@@ -115,12 +160,12 @@ function ensureStyles(doc) {
|
|
|
115
160
|
styledDocs.add(doc)
|
|
116
161
|
return
|
|
117
162
|
}
|
|
118
|
-
// No bundled CSS — try resolving the sibling
|
|
119
|
-
// Browsers ESM-resolved imports place a co-located
|
|
120
|
-
// hypercms.js. Falls back silently if resolution
|
|
121
|
-
// without an asset emitter).
|
|
163
|
+
// No bundled CSS — try resolving the sibling generated theme via
|
|
164
|
+
// import.meta.url. Browsers ESM-resolved imports place a co-located
|
|
165
|
+
// theme.generated.css next to hypercms.js. Falls back silently if resolution
|
|
166
|
+
// fails (e.g., bundlers without an asset emitter).
|
|
122
167
|
try {
|
|
123
|
-
const href = new URL('./
|
|
168
|
+
const href = new URL('./theme.generated.css', import.meta.url).href
|
|
124
169
|
const link = doc.createElement('link')
|
|
125
170
|
link.rel = 'stylesheet'
|
|
126
171
|
link.id = SHELL_STYLE_ID
|
|
@@ -134,6 +179,25 @@ function ensureStyles(doc) {
|
|
|
134
179
|
}
|
|
135
180
|
}
|
|
136
181
|
|
|
182
|
+
// Reveal the condensed minibar once the full header scrolls out of the body
|
|
183
|
+
// (pixel-quiet decision 0003). Passive listener; toggles .is-condensed on the
|
|
184
|
+
// shell root when scrollTop passes the header height. No-op if the body or
|
|
185
|
+
// header is missing (e.g. a custom mount that omits them).
|
|
186
|
+
function installCondenseOnScroll(root) {
|
|
187
|
+
const bodyEl = root.querySelector('.hcms-shell-body')
|
|
188
|
+
const header = root.querySelector('.hcms-shell-header')
|
|
189
|
+
if (!bodyEl || !header || typeof bodyEl.addEventListener !== 'function') {
|
|
190
|
+
return { detach() {} }
|
|
191
|
+
}
|
|
192
|
+
const reveal = () => {
|
|
193
|
+
const trigger = (header.offsetHeight || 0) - 12
|
|
194
|
+
root.classList.toggle('is-condensed', bodyEl.scrollTop > trigger)
|
|
195
|
+
}
|
|
196
|
+
bodyEl.addEventListener('scroll', reveal, { passive: true })
|
|
197
|
+
reveal()
|
|
198
|
+
return { detach() { bodyEl.removeEventListener('scroll', reveal) } }
|
|
199
|
+
}
|
|
200
|
+
|
|
137
201
|
function installFocusTrap(root, doc) {
|
|
138
202
|
function onKeyDown(e) {
|
|
139
203
|
if (e.key !== 'Tab') return
|
package/src/templates.js
CHANGED
|
@@ -10,11 +10,23 @@ export function humanize(key) {
|
|
|
10
10
|
.replace(/^./, (c) => c.toUpperCase())
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// The default templates emit real mirk-interface markup so the generated form
|
|
14
|
+
// renders in the Pixel Quiet look out of the box. Every data-hcms-* hook
|
|
15
|
+
// (shape, label, field, action, the .hcms-array-items / .hcms-card-fields slots,
|
|
16
|
+
// the .hcms-error inline slots) is preserved exactly, so the engine, events,
|
|
17
|
+
// add/remove/reorder, and inline-error placement bind unchanged. The CSS that
|
|
18
|
+
// dresses these lives in src/theme.generated.css (scoped mirk + pixel-quiet).
|
|
19
|
+
const SORTABLE_GRIP = `<div class="hcms-drag-handle mirk-sortable__grip" aria-hidden="true"><div class="mirk-sortable__dots"><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span><span class="mirk-sortable__dot"></span></div></div>`
|
|
20
|
+
|
|
21
|
+
// Crisp-line × (square caps + crispEdges): a plain pixel-sharp close glyph that
|
|
22
|
+
// stays hard-edged inside the small square corner button on object-array cards.
|
|
23
|
+
const CLOSE_ICON = `<svg class="hcms-x" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square"></path></svg>`
|
|
24
|
+
|
|
13
25
|
const DEFAULT_TEMPLATES = {
|
|
14
26
|
'@scalar': `
|
|
15
27
|
<label class="hcms-field" data-hcms-shape="scalar">
|
|
16
28
|
<span class="hcms-label" data-hcms-label></span>
|
|
17
|
-
<input class="
|
|
29
|
+
<input class="mirk-input" data-hcms-field />
|
|
18
30
|
<div class="hcms-error" hidden></div>
|
|
19
31
|
</label>
|
|
20
32
|
`,
|
|
@@ -32,56 +44,183 @@ const DEFAULT_TEMPLATES = {
|
|
|
32
44
|
</header>
|
|
33
45
|
<ul class="hcms-array-items"></ul>
|
|
34
46
|
<div class="hcms-error" hidden></div>
|
|
35
|
-
<button type="button" class="hcms-add" data-hcms-action="add">+ Add</button>
|
|
47
|
+
<button type="button" class="hcms-add mirk-button mirk-button--small" data-hcms-action="add"><span class="mirk-button__label">+ Add</span></button>
|
|
36
48
|
</section>
|
|
37
49
|
`,
|
|
38
50
|
'@scalar-array-item': `
|
|
39
51
|
<li class="hcms-array-item" draggable="true">
|
|
40
|
-
<
|
|
41
|
-
<input class="hcms-input" data-hcms-field />
|
|
52
|
+
<input class="mirk-input" data-hcms-field />
|
|
42
53
|
<button type="button" class="hcms-move hcms-move-up hcms-sr-only" data-hcms-action="move-up" aria-label="Move up">↑</button>
|
|
43
54
|
<button type="button" class="hcms-move hcms-move-down hcms-sr-only" data-hcms-action="move-down" aria-label="Move down">↓</button>
|
|
44
|
-
<button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove"
|
|
55
|
+
<button type="button" class="hcms-remove" data-hcms-action="remove" aria-label="Remove">×</button>
|
|
45
56
|
<div class="hcms-error" hidden></div>
|
|
46
57
|
</li>
|
|
47
58
|
`,
|
|
48
59
|
'@object-array': `
|
|
49
|
-
<section class="hcms-array hcms-object-array" data-hcms-shape="object-array">
|
|
60
|
+
<section class="hcms-array hcms-object-array hcms-array--cards" data-hcms-shape="object-array">
|
|
50
61
|
<header class="hcms-array-header">
|
|
51
62
|
<h3 class="hcms-array-title" data-hcms-label></h3>
|
|
52
63
|
</header>
|
|
53
64
|
<div class="hcms-array-items"></div>
|
|
54
65
|
<div class="hcms-error" hidden></div>
|
|
55
|
-
<button type="button" class="hcms-add" data-hcms-action="add">+ Add</button>
|
|
66
|
+
<button type="button" class="hcms-add mirk-button mirk-button--small" data-hcms-action="add"><span class="mirk-button__label">+ Add</span></button>
|
|
56
67
|
</section>
|
|
57
68
|
`,
|
|
58
69
|
'@object-array-item': `
|
|
59
|
-
<article class="hcms-card" draggable="true">
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
<article class="hcms-card mirk-sortable__item" draggable="true">
|
|
71
|
+
${SORTABLE_GRIP}
|
|
72
|
+
<div class="hcms-card-body mirk-sortable__body">
|
|
73
|
+
<div class="hcms-card-fields"></div>
|
|
74
|
+
<div class="hcms-card-controls">
|
|
75
|
+
<button type="button" class="hcms-move hcms-move-up hcms-sr-only" data-hcms-action="move-up" aria-label="Move up">↑</button>
|
|
76
|
+
<button type="button" class="hcms-move hcms-move-down hcms-sr-only" data-hcms-action="move-down" aria-label="Move down">↓</button>
|
|
77
|
+
<button type="button" class="hcms-remove hcms-remove--card" data-hcms-action="remove" aria-label="Remove">${CLOSE_ICON}</button>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
67
80
|
<div class="hcms-error" hidden></div>
|
|
68
81
|
</article>
|
|
69
82
|
`,
|
|
83
|
+
// Opt-in upload components. Built on the kit's consolidated mirk chrome (all
|
|
84
|
+
// themed in theme.generated.css), with interactive hooks kept on data-hcms-*
|
|
85
|
+
// so the vendored mirk runtime (which keys off .mirk-*__input / .mirk-*__remove)
|
|
86
|
+
// never double-handles a CMS field. The bound value is the leaf's URL: the
|
|
87
|
+
// <a href> for @file, the <img src> for @image. Empty/filled chrome is driven
|
|
88
|
+
// by that attribute in CSS (.hcms-upload* in pixel-quiet.overrides.css), no JS.
|
|
89
|
+
'@file': `
|
|
90
|
+
<div class="hcms-field hcms-upload hcms-upload--file" data-hcms-shape="scalar">
|
|
91
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
92
|
+
<div class="mirk-file mirk-file--compact mirk-file--round">
|
|
93
|
+
<label class="mirk-button mirk-button--round mirk-button--small">
|
|
94
|
+
<input type="file" data-hcms-upload />
|
|
95
|
+
<span class="mirk-button__label">Choose</span>
|
|
96
|
+
</label>
|
|
97
|
+
<a class="mirk-file__name" data-hcms-field></a>
|
|
98
|
+
<button type="button" class="hcms-upload-clear" data-hcms-action="clear-upload" aria-label="Remove file">${CLOSE_ICON}</button>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="hcms-error" hidden></div>
|
|
101
|
+
</div>
|
|
102
|
+
`,
|
|
103
|
+
'@image': `
|
|
104
|
+
<div class="hcms-field hcms-upload hcms-upload--image" data-hcms-shape="scalar">
|
|
105
|
+
<span class="hcms-label" data-hcms-label></span>
|
|
106
|
+
<div class="mirk-image mirk-image--compact mirk-image--rounded">
|
|
107
|
+
<label class="mirk-button mirk-button--small mirk-image__upload">
|
|
108
|
+
<input type="file" accept="image/*" data-hcms-upload />
|
|
109
|
+
<span class="mirk-button__label">Upload image</span>
|
|
110
|
+
</label>
|
|
111
|
+
<figure class="mirk-image__thumb">
|
|
112
|
+
<span class="mirk-image__frame"><img class="mirk-image__preview" data-hcms-field alt="" /></span>
|
|
113
|
+
<button type="button" class="hcms-upload-clear hcms-upload-clear--badge" data-hcms-action="clear-upload" aria-label="Remove image">${CLOSE_ICON}</button>
|
|
114
|
+
</figure>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="hcms-error" hidden></div>
|
|
117
|
+
</div>
|
|
118
|
+
`,
|
|
70
119
|
}
|
|
71
120
|
|
|
72
|
-
|
|
121
|
+
// Shape templates fill-the-gaps on every managed page. The opt-in upload
|
|
122
|
+
// components (@file/@image) are injected on demand the first time a field
|
|
123
|
+
// selects them (the Slice-2 component seam), so pages using neither stay clean.
|
|
124
|
+
const ALWAYS_INJECT_KEYS = [
|
|
125
|
+
'@scalar',
|
|
126
|
+
'@object',
|
|
127
|
+
'@scalar-array',
|
|
128
|
+
'@scalar-array-item',
|
|
129
|
+
'@object-array',
|
|
130
|
+
'@object-array-item',
|
|
131
|
+
]
|
|
73
132
|
|
|
74
133
|
export function injectDefaults(doc) {
|
|
75
134
|
const head = doc.head || doc.documentElement
|
|
76
135
|
if (!head) return
|
|
77
|
-
for (const key of
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
136
|
+
for (const key of ALWAYS_INJECT_KEYS) injectTemplate(doc, head, key)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Inject one opt-in component template (@file/@image) if the page hasn't
|
|
140
|
+
// already defined its own. Called by the component-selection seam the first
|
|
141
|
+
// time a field resolves to an upload component.
|
|
142
|
+
export function injectComponentTemplate(doc, key) {
|
|
143
|
+
if (!DEFAULT_TEMPLATES[key]) return null
|
|
144
|
+
const head = doc && (doc.head || doc.documentElement)
|
|
145
|
+
if (!head) return null
|
|
146
|
+
return injectTemplate(doc, head, key)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Opt-in upload-component selection. A scalar field upgrades from a plain text
|
|
150
|
+
// input to an image-upload widget when its rule ends in @src (uploading is the
|
|
151
|
+
// dominant image-CMS workflow). @href is NOT inferred: editing a link/URL is far
|
|
152
|
+
// more common than uploading a file, so an a@href rule stays a plain URL field.
|
|
153
|
+
// A file upload (and an image override) is opt-in via data-hcms-component on the
|
|
154
|
+
// page element the rule points at — the only branch that reads the page DOM.
|
|
155
|
+
// Suffix is split like the engine (lastIndexOf('@'), see engine/extract.js).
|
|
156
|
+
// Returns a DEFAULT_TEMPLATES key.
|
|
157
|
+
const PROP_TO_COMPONENT = { src: '@image' }
|
|
158
|
+
const UPLOAD_COMPONENT_KEYS = new Set(['@image', '@file'])
|
|
159
|
+
|
|
160
|
+
export function componentForScalarRule(rule, doc) {
|
|
161
|
+
if (typeof rule !== 'string') return '@scalar'
|
|
162
|
+
const at = rule.lastIndexOf('@')
|
|
163
|
+
if (at >= 0) {
|
|
164
|
+
const prop = rule.slice(at + 1)
|
|
165
|
+
if (PROP_TO_COMPONENT[prop]) return PROP_TO_COMPONENT[prop]
|
|
84
166
|
}
|
|
167
|
+
const override = readComponentOverride(rule, at, doc)
|
|
168
|
+
if (override === 'image') return '@image'
|
|
169
|
+
if (override === 'file') return '@file'
|
|
170
|
+
return '@scalar'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readComponentOverride(rule, at, doc) {
|
|
174
|
+
if (!doc || !doc.querySelector) return null
|
|
175
|
+
const selector = at >= 0 ? rule.slice(0, at) : rule
|
|
176
|
+
if (!selector || selector === '.') return null
|
|
177
|
+
let el = null
|
|
178
|
+
try {
|
|
179
|
+
el = doc.querySelector(selector)
|
|
180
|
+
} catch {
|
|
181
|
+
return null
|
|
182
|
+
}
|
|
183
|
+
return el && el.getAttribute ? el.getAttribute('data-hcms-component') : null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Pre-inject the @file/@image templates this page's rules actually select, so
|
|
187
|
+
// both deriveFormRules (selectors) and buildForm (DOM) resolve them. Walks the
|
|
188
|
+
// same rule shapes as buildTemplateMap; idempotent and author-template-safe.
|
|
189
|
+
export function injectUploadComponents(doc, pageRules) {
|
|
190
|
+
if (!doc || pageRules == null) return
|
|
191
|
+
walk(pageRules)
|
|
192
|
+
|
|
193
|
+
function walk(rule) {
|
|
194
|
+
const kind = shapeKindOf(rule)
|
|
195
|
+
if (kind === 'scalar') {
|
|
196
|
+
const key = componentForScalarRule(rule, doc)
|
|
197
|
+
if (UPLOAD_COMPONENT_KEYS.has(key)) injectComponentTemplate(doc, key)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (kind === 'object') {
|
|
201
|
+
for (const child of Object.values(rule)) walk(child)
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
if (kind === 'object-array') {
|
|
205
|
+
const itemShape = rule[1]
|
|
206
|
+
if (itemShape && typeof itemShape === 'object' && !Array.isArray(itemShape)) {
|
|
207
|
+
for (const child of Object.values(itemShape)) walk(child)
|
|
208
|
+
} else {
|
|
209
|
+
walk(itemShape)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function injectTemplate(doc, head, key) {
|
|
216
|
+
const existing = findTemplate(doc, key)
|
|
217
|
+
if (existing) return existing
|
|
218
|
+
const tpl = doc.createElement('template')
|
|
219
|
+
tpl.setAttribute('data-hcms-tpl', key)
|
|
220
|
+
tpl.setAttribute('save-remove', '')
|
|
221
|
+
tpl.innerHTML = DEFAULT_TEMPLATES[key].trim()
|
|
222
|
+
head.appendChild(tpl)
|
|
223
|
+
return tpl
|
|
85
224
|
}
|
|
86
225
|
|
|
87
226
|
export function findTemplate(doc, key) {
|
|
@@ -136,11 +275,13 @@ export function buildTemplateMap(pageRules, doc) {
|
|
|
136
275
|
const pathStr = pathArr.join('.')
|
|
137
276
|
const wildcardKey = pathArr.map((s) => (typeof s === 'number' ? '*' : s)).join('.')
|
|
138
277
|
const shape = shapeKindOf(rule)
|
|
278
|
+
const defaultKey =
|
|
279
|
+
shape === 'scalar' ? componentForScalarRule(rule, doc) : SHAPE_TO_DEFAULT_KEY[shape] || '@scalar'
|
|
139
280
|
|
|
140
281
|
let tpl =
|
|
141
282
|
(pathStr && findTemplate(doc, pathStr)) ||
|
|
142
283
|
(wildcardKey && wildcardKey !== pathStr && findTemplate(doc, wildcardKey)) ||
|
|
143
|
-
findTemplate(doc,
|
|
284
|
+
findTemplate(doc, defaultKey)
|
|
144
285
|
|
|
145
286
|
map.set(pathStr, tpl)
|
|
146
287
|
|