@panphora/hyper-cms 0.6.2 → 0.7.1
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/dist/hypercms.min.js +27 -14
- package/package.json +3 -2
- package/src/enhance.js +5 -2
- package/src/events.js +8 -7
- package/src/hypercms.js +29 -18
- package/src/platform.js +68 -0
- package/src/refresh.js +7 -2
- package/src/shell.js +2 -0
- package/src/templates.js +6 -5
- package/src/theme/pixel-quiet.overrides.css +12 -0
- package/src/theme.generated.css +12 -0
- package/src/unresolved.js +142 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/hyper-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Live edit-in-place CMS sidebar for HTML pages with a hyper-html-api rules tag",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"HTML",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"build": "npm run build:theme && npx esbuild src/hypercms-bundle.js --bundle --minify --format=iife --global-name=hypercms --loader:.css=text --outfile=dist/hypercms.min.js",
|
|
36
36
|
"prepublishOnly": "npm run build",
|
|
37
37
|
"copy-to-hyperclayjs": "npm run build && node scripts/copy-to-hyperclayjs.js",
|
|
38
|
+
"copy-to-clayjs": "npm run build && node scripts/copy-to-clayjs.js",
|
|
38
39
|
"test:node": "node --test 'test-node/**/*.test.js'",
|
|
39
40
|
"test": "npm run test:node",
|
|
40
41
|
"demo": "http-server . -p 5694 -c-1 -o /demo/",
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
"test:all": "npm run test:node && npm run test:browser"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"hyper-html-api": "^0.
|
|
47
|
+
"hyper-html-api": "^0.6.1",
|
|
47
48
|
"hyper-morph": "^0.4.0"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
package/src/enhance.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// whose element holds only text are left untouched. Hosts opt out with
|
|
10
10
|
// cms.open({ richText: false }).
|
|
11
11
|
|
|
12
|
+
import { platform } from './platform.js'
|
|
13
|
+
import { ruleAttrIndex } from 'hyper-html-api/engine'
|
|
14
|
+
|
|
12
15
|
export function upgradeRichTextRules(rules, pageRoot) {
|
|
13
16
|
if (!pageRoot || rules == null) return rules
|
|
14
17
|
return walk(rules)
|
|
@@ -16,7 +19,7 @@ export function upgradeRichTextRules(rules, pageRoot) {
|
|
|
16
19
|
function walk(rule) {
|
|
17
20
|
if (typeof rule === 'string') {
|
|
18
21
|
if (rule.endsWith('[]')) return rule
|
|
19
|
-
if (rule
|
|
22
|
+
if (ruleAttrIndex(rule) !== -1) return rule
|
|
20
23
|
let el = null
|
|
21
24
|
try {
|
|
22
25
|
el = pageRoot.querySelector(rule)
|
|
@@ -61,7 +64,7 @@ export function enhanceFields(root, doc) {
|
|
|
61
64
|
const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null)
|
|
62
65
|
const RichClay =
|
|
63
66
|
(win && win.richclay && win.richclay.RichClay) ||
|
|
64
|
-
(
|
|
67
|
+
platform('RichClay', win) ||
|
|
65
68
|
(win && typeof win.RichClay === 'function' ? win.RichClay : null)
|
|
66
69
|
if (!RichClay) return
|
|
67
70
|
|
package/src/events.js
CHANGED
|
@@ -4,6 +4,7 @@ import { fromString as pathFromString, getRuleAtPath } from './path.js'
|
|
|
4
4
|
import { buildItem, fileNameFromUrl, radioGroupName } from './form-builder.js'
|
|
5
5
|
import { scaffold } from './scaffold.js'
|
|
6
6
|
import { autosizeTextarea, enhanceFields } from './enhance.js'
|
|
7
|
+
import { platform } from './platform.js'
|
|
7
8
|
|
|
8
9
|
const BOUND = new WeakSet()
|
|
9
10
|
|
|
@@ -15,7 +16,7 @@ const BOUND = new WeakSet()
|
|
|
15
16
|
//
|
|
16
17
|
// No-op pass-through when window.hyperclay.undo isn't loaded.
|
|
17
18
|
export function commitWithUndo(label, fn) {
|
|
18
|
-
const u = (
|
|
19
|
+
const u = platform('undo')
|
|
19
20
|
if (!u) return fn()
|
|
20
21
|
u.pause()
|
|
21
22
|
try {
|
|
@@ -36,7 +37,7 @@ export function commitWithUndo(label, fn) {
|
|
|
36
37
|
// on mount/unmount) never enter the undo stack. No-op pass-through when undo
|
|
37
38
|
// isn't loaded.
|
|
38
39
|
export function suppressUndo(fn) {
|
|
39
|
-
const u = (
|
|
40
|
+
const u = platform('undo')
|
|
40
41
|
if (!u) return fn()
|
|
41
42
|
u.pause()
|
|
42
43
|
try {
|
|
@@ -148,7 +149,7 @@ function resolveUnobservedProjection(ctx, pathStr) {
|
|
|
148
149
|
if (fullPath.some((seg) => typeof seg === 'number' || seg === '*')) return null
|
|
149
150
|
const rule = getRuleAtPath(ctx.pageRules, fullPath)
|
|
150
151
|
if (typeof rule !== 'string') return null
|
|
151
|
-
const at =
|
|
152
|
+
const at = engine.ruleAttrIndex(rule)
|
|
152
153
|
if (at === -1) return null // text projection — observed
|
|
153
154
|
const prop = rule.slice(at + 1)
|
|
154
155
|
if (!UNOBSERVED_FIELD_PROPS.has(prop)) return null
|
|
@@ -167,7 +168,7 @@ export function onScalarChange(target, ctx) {
|
|
|
167
168
|
const proj = resolveUnobservedProjection(ctx, pathStr)
|
|
168
169
|
commit(extractFormData(ctx), { path: pathStr, structural: false }, ctx)
|
|
169
170
|
if (proj) {
|
|
170
|
-
const u = (
|
|
171
|
+
const u = platform('undo')
|
|
171
172
|
if (u && typeof u.recordValue === 'function') {
|
|
172
173
|
u.recordValue(proj.el, { prop: proj.prop, oldValue: proj.oldValue, newValue: proj.el[proj.prop] })
|
|
173
174
|
}
|
|
@@ -186,7 +187,7 @@ const CROP_OUTPUT = { type: 'image/webp', quality: 0.85, maxWidth: 2048, maxHeig
|
|
|
186
187
|
async function maybeCrop(file, fieldEl) {
|
|
187
188
|
const attr = fieldEl && fieldEl.getAttribute ? fieldEl.getAttribute('data-hcms-crop') : null
|
|
188
189
|
if (attr == null) return { file }
|
|
189
|
-
const crop = (
|
|
190
|
+
const crop = platform('quickcrop')
|
|
190
191
|
if (typeof crop !== 'function') return { file }
|
|
191
192
|
try {
|
|
192
193
|
const r = await crop(file, { aspect: parseCropAspect(attr), ...CROP_OUTPUT })
|
|
@@ -245,7 +246,7 @@ export async function onUploadChange(inputEl, ctx) {
|
|
|
245
246
|
const fileLike = cropped.file
|
|
246
247
|
const previewDataUrl = cropped.dataURL || null
|
|
247
248
|
|
|
248
|
-
const upload = (
|
|
249
|
+
const upload = platform('uploadFileBasic')
|
|
249
250
|
let url = null
|
|
250
251
|
if (typeof upload === 'function') {
|
|
251
252
|
try {
|
|
@@ -440,7 +441,7 @@ export function requestRemove(itemEl, ctx) {
|
|
|
440
441
|
const arrayEl = itemEl.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]')
|
|
441
442
|
const message = resolveRemoveConfirm(arrayEl, ctx)
|
|
442
443
|
if (message == null) return onRemove(itemEl, ctx)
|
|
443
|
-
const consent = typeof window !== 'undefined' &&
|
|
444
|
+
const consent = platform('consent') || (typeof window !== 'undefined' && window.consent)
|
|
444
445
|
if (typeof consent === 'function') {
|
|
445
446
|
Promise.resolve(consent(message)).then(() => onRemove(itemEl, ctx), () => {})
|
|
446
447
|
} else if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
package/src/hypercms.js
CHANGED
|
@@ -20,8 +20,10 @@ import {
|
|
|
20
20
|
import { mountShell, setShellStyles, markStylesBundled } from './shell.js'
|
|
21
21
|
import { refreshForm, installObserver } from './refresh.js'
|
|
22
22
|
import { warnUnmatchedTemplates } from './diagnostics.js'
|
|
23
|
+
import { findUnresolved, applyUnresolvedState } from './unresolved.js'
|
|
23
24
|
import { enhanceFields, upgradeRichTextRules } from './enhance.js'
|
|
24
25
|
import { maybeInjectToggle } from './toggle.js'
|
|
26
|
+
import { platform, onPlatformEvent, MUTATION_READY, LIVESYNC_APPLIED } from './platform.js'
|
|
25
27
|
|
|
26
28
|
export function installStyles(text) {
|
|
27
29
|
setShellStyles(text)
|
|
@@ -72,8 +74,7 @@ function resyncForm(ctx, reason) {
|
|
|
72
74
|
let prepareHookInstalled = false
|
|
73
75
|
function installSavePrepareHook() {
|
|
74
76
|
if (prepareHookInstalled) return
|
|
75
|
-
const onPrepareForSave =
|
|
76
|
-
(typeof window !== 'undefined' && window.hyperclay && window.hyperclay.onPrepareForSave) || null
|
|
77
|
+
const onPrepareForSave = platform('onPrepareForSave')
|
|
77
78
|
if (typeof onPrepareForSave !== 'function') return
|
|
78
79
|
onPrepareForSave((clonedDocEl) => {
|
|
79
80
|
const b = clonedDocEl && clonedDocEl.querySelector && clonedDocEl.querySelector('body')
|
|
@@ -114,6 +115,9 @@ export function open(opts = {}) {
|
|
|
114
115
|
injectComponents(doc, pageRules)
|
|
115
116
|
warnUnmatchedTemplates(doc, pageRules)
|
|
116
117
|
const formRules = deriveFormRules(pageRules, doc)
|
|
118
|
+
// Runs before the first extract so a rule with invalid CSS names its field
|
|
119
|
+
// instead of throwing a bare SyntaxError out of querySelectorAll.
|
|
120
|
+
const unresolved = findUnresolved(pageRoot, pageRules)
|
|
117
121
|
const data = coerceBooleans(engine.extract(pageRoot, pageRules, { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }), pageRules)
|
|
118
122
|
|
|
119
123
|
// Suppress undo around mountShell: it toggles chrome-only classes on
|
|
@@ -141,6 +145,9 @@ export function open(opts = {}) {
|
|
|
141
145
|
formRoot: shell.formRoot,
|
|
142
146
|
shellRoot: shell.root,
|
|
143
147
|
errorEl: shell.errorEl,
|
|
148
|
+
noticeEl: shell.noticeEl,
|
|
149
|
+
unresolved,
|
|
150
|
+
lastTwinSignature: null,
|
|
144
151
|
lastFingerprint: null,
|
|
145
152
|
lastData: null,
|
|
146
153
|
observerHandle: null,
|
|
@@ -174,6 +181,7 @@ export function open(opts = {}) {
|
|
|
174
181
|
const fragment = buildForm({ pageRules, formRules, data, doc })
|
|
175
182
|
shell.formRoot.appendChild(fragment)
|
|
176
183
|
enhanceFields(shell.formRoot, doc)
|
|
184
|
+
applyUnresolvedState(ctx)
|
|
177
185
|
|
|
178
186
|
bindEvents(ctx)
|
|
179
187
|
ctx.updateFingerprint()
|
|
@@ -187,7 +195,7 @@ export function open(opts = {}) {
|
|
|
187
195
|
// is source-blind, so subscribe to the undo scope and force a non-ignoring refresh.
|
|
188
196
|
// Fires synchronously before the debounced observer refresh, which then no-ops on
|
|
189
197
|
// the active field. Guarded so it degrades to today's behavior when undo isn't loaded.
|
|
190
|
-
const u = (
|
|
198
|
+
const u = platform('undo')
|
|
191
199
|
if (u && typeof u.on === 'function') {
|
|
192
200
|
const onRevert = () => {
|
|
193
201
|
if (state.ctx !== ctx) return
|
|
@@ -222,8 +230,7 @@ export function open(opts = {}) {
|
|
|
222
230
|
// with ignoreActiveValue:true (preserve the user's in-progress field). Same
|
|
223
231
|
// document live-sync dispatches on; unsubscribed in close().
|
|
224
232
|
const onLivesync = () => resyncForm(ctx, 'livesync')
|
|
225
|
-
|
|
226
|
-
ctx.livesyncUnsub = () => doc.removeEventListener('hyperclay:livesync-applied', onLivesync)
|
|
233
|
+
ctx.livesyncUnsub = onPlatformEvent(doc, LIVESYNC_APPLIED, onLivesync)
|
|
227
234
|
|
|
228
235
|
// Wire global sortable callback to current ctx (replaced by close()).
|
|
229
236
|
globalCommitTarget.ctx = ctx
|
|
@@ -507,17 +514,19 @@ export function maybeAutoOpen() {
|
|
|
507
514
|
// Auto-open is ready only once <body> is parsed AND window.hyperclay.Mutation is
|
|
508
515
|
// installed (open() needs both). NEVER throw out of module scope.
|
|
509
516
|
function autoOpenReady() {
|
|
510
|
-
return !!document.body && !!(
|
|
517
|
+
return !!document.body && !!platform('Mutation')
|
|
511
518
|
}
|
|
512
519
|
|
|
513
520
|
// Three layers, in order of preference:
|
|
514
521
|
// 1. Sync fast-path: if <body> and Mutation are already here, fire now.
|
|
515
|
-
// 2. Event:
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
522
|
+
// 2. Event: both clients dispatch a mutation-ready signal the instant they
|
|
523
|
+
// install the mutation hub, under two spellings ('clay:mutation-ready' and
|
|
524
|
+
// 'hyperclay:mutation-ready'), so hypercms listens for both and dedups. On
|
|
525
|
+
// loader pages this is moot — the loader's first wave evaluates mutation
|
|
526
|
+
// before hypercms, so the fast-path wins — but the event catches any late
|
|
527
|
+
// install (the deferred `await import()` ordering). The body gate is
|
|
528
|
+
// re-checked in the handler because the event can land before <body> is
|
|
529
|
+
// parsed.
|
|
521
530
|
// 3. Backstop: a slow self-cancelling 250ms poll for exotic hosts that hand-roll
|
|
522
531
|
// window.hyperclay.Mutation by direct assignment WITHOUT dispatching the
|
|
523
532
|
// event. It cancels the moment the event fires, open succeeds, or the
|
|
@@ -536,11 +545,12 @@ function whenReady(fn) {
|
|
|
536
545
|
const deadline = Date.now() + AUTO_OPEN_DEADLINE_MS
|
|
537
546
|
let done = false
|
|
538
547
|
let timer = null
|
|
548
|
+
let offMutationReady = null
|
|
539
549
|
const finish = () => {
|
|
540
550
|
if (done) return
|
|
541
551
|
done = true
|
|
542
552
|
if (timer !== null) clearInterval(timer)
|
|
543
|
-
|
|
553
|
+
if (offMutationReady) offMutationReady()
|
|
544
554
|
}
|
|
545
555
|
function onMutationReady() {
|
|
546
556
|
if (state.isOpen) {
|
|
@@ -551,10 +561,11 @@ function whenReady(fn) {
|
|
|
551
561
|
finish()
|
|
552
562
|
fn()
|
|
553
563
|
}
|
|
554
|
-
// Body may still be pending
|
|
555
|
-
// so
|
|
564
|
+
// Body may still be pending. Unlike the old one-shot listener this stays
|
|
565
|
+
// subscribed, so a later dispatch is still caught and the backstop is no
|
|
566
|
+
// longer the only remaining path.
|
|
556
567
|
}
|
|
557
|
-
document
|
|
568
|
+
offMutationReady = onPlatformEvent(document, MUTATION_READY, onMutationReady)
|
|
558
569
|
timer = setInterval(() => {
|
|
559
570
|
if (state.isOpen) {
|
|
560
571
|
finish()
|
|
@@ -568,8 +579,8 @@ function whenReady(fn) {
|
|
|
568
579
|
if (Date.now() >= deadline) {
|
|
569
580
|
finish()
|
|
570
581
|
console.warn(
|
|
571
|
-
'hypercms: ?cms=true auto-open gave up —
|
|
572
|
-
'Load hyperclayjs (or the mutation utility) so the CMS can initialize.'
|
|
582
|
+
'hypercms: ?cms=true auto-open gave up — no mutation hub appeared. ' +
|
|
583
|
+
'Load clayjs or hyperclayjs (or just the mutation utility) so the CMS can initialize.'
|
|
573
584
|
)
|
|
574
585
|
}
|
|
575
586
|
}, SAFETY_POLL_MS)
|
package/src/platform.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// The optional platform hypercms rides: the mutation hub, undo, the save-prepare
|
|
2
|
+
// hook, rich text, and the two upload helpers. Every one degrades to null, so the
|
|
3
|
+
// CMS still runs in a bare HTML file with no client at all.
|
|
4
|
+
//
|
|
5
|
+
// TWO clients provide this and they disagree on names. hyperclayjs owns
|
|
6
|
+
// window.hyperclay; clayjs owns window.clay and renamed things on the way past
|
|
7
|
+
// (onPrepareForSave -> addDocumentTransform, consent -> confirm). clay.RichClay exists,
|
|
8
|
+
// but quickcrop and uploadFileBasic have no clay equivalent at all, so image crop and
|
|
9
|
+
// file upload silently degrade under clayjs. So this is a capability table
|
|
10
|
+
// rather than a namespace pointer: `window.clay ?? window.hyperclay` would resolve
|
|
11
|
+
// to clay, find no onPrepareForSave, and silently stop stripping the CMS's own
|
|
12
|
+
// chrome out of every save.
|
|
13
|
+
//
|
|
14
|
+
// Reads happen per call, never cached. A client can install a capability after
|
|
15
|
+
// hypercms evaluates — that is the whole reason whenReady() below exists.
|
|
16
|
+
const CAPABILITIES = {
|
|
17
|
+
Mutation: (clay, hyperclay) => clay?.Mutation ?? hyperclay?.Mutation,
|
|
18
|
+
undo: (clay, hyperclay) => clay?.undo ?? hyperclay?.undo,
|
|
19
|
+
onPrepareForSave: (clay, hyperclay) => clay?.addDocumentTransform ?? hyperclay?.onPrepareForSave,
|
|
20
|
+
consent: (clay, hyperclay) => clay?.confirm ?? hyperclay?.consent,
|
|
21
|
+
RichClay: (clay, hyperclay) => clay?.RichClay ?? hyperclay?.RichClay,
|
|
22
|
+
quickcrop: (clay, hyperclay) => clay?.quickcrop ?? hyperclay?.quickcrop,
|
|
23
|
+
uploadFileBasic: (clay, hyperclay) => clay?.uploadFileBasic ?? hyperclay?.uploadFileBasic,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Pass `win` when the caller already holds a realm (a doc.defaultView from a form
|
|
27
|
+
// mounted into another document); it defaults to the ambient one.
|
|
28
|
+
export function platform(name, win) {
|
|
29
|
+
const read = CAPABILITIES[name]
|
|
30
|
+
if (!read) throw new Error(`hypercms: unknown platform capability "${name}"`)
|
|
31
|
+
const scope = win || (typeof window !== 'undefined' ? window : null)
|
|
32
|
+
if (!scope) return null
|
|
33
|
+
return read(scope.clay, scope.hyperclay) || null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Both spellings of the same signal. Each client fires exactly one name: clayjs
|
|
37
|
+
// its own, hyperclayjs the legacy one. Listening for both is what lets one build
|
|
38
|
+
// of hypercms ride either client. The name-keyed guard below stays regardless, so
|
|
39
|
+
// a listener on both names still can't double-fire if a client ever dispatches
|
|
40
|
+
// the pair again.
|
|
41
|
+
export const MUTATION_READY = ['clay:mutation-ready', 'hyperclay:mutation-ready']
|
|
42
|
+
export const LIVESYNC_APPLIED = ['clay:sync-applied', 'hyperclay:livesync-applied']
|
|
43
|
+
|
|
44
|
+
// Subscribe to every spelling of one signal, delivering the handler ONCE per
|
|
45
|
+
// occurrence. No client dispatches the pair today, but one that did would make a
|
|
46
|
+
// plain listener on each name fire the handler twice for one event: two form
|
|
47
|
+
// re-extracts, or two open() calls.
|
|
48
|
+
//
|
|
49
|
+
// The first name to arrive claims the tick, and only a DIFFERENT name is suppressed
|
|
50
|
+
// while it holds the claim. That distinction is what makes this exact rather than a
|
|
51
|
+
// heuristic: no client dispatches one name twice for one occurrence, so a repeat of
|
|
52
|
+
// the claiming name is always a second occurrence and always gets through — even
|
|
53
|
+
// when it lands in the same tick, which is precisely what a caller doing two
|
|
54
|
+
// synchronous applies looks like. Guarding on "have I run at all this tick" would
|
|
55
|
+
// swallow that.
|
|
56
|
+
//
|
|
57
|
+
// Returns an unsubscribe that removes every name.
|
|
58
|
+
export function onPlatformEvent(target, names, handler) {
|
|
59
|
+
let claimedBy = null
|
|
60
|
+
const wrapped = (event) => {
|
|
61
|
+
if (claimedBy !== null && claimedBy !== event.type) return
|
|
62
|
+
claimedBy = event.type
|
|
63
|
+
queueMicrotask(() => { claimedBy = null })
|
|
64
|
+
handler(event)
|
|
65
|
+
}
|
|
66
|
+
for (const name of names) target.addEventListener(name, wrapped)
|
|
67
|
+
return () => { for (const name of names) target.removeEventListener(name, wrapped) }
|
|
68
|
+
}
|
package/src/refresh.js
CHANGED
|
@@ -4,7 +4,9 @@ import { morphForm } from './morph.js'
|
|
|
4
4
|
import { injectDefaults, injectComponents } from './templates.js'
|
|
5
5
|
import { deriveFormRules } from './form-rules.js'
|
|
6
6
|
import { coerceBooleans, applyErrorState } from './events.js'
|
|
7
|
+
import { findUnresolved, applyUnresolvedState } from './unresolved.js'
|
|
7
8
|
import { enhanceFields, upgradeRichTextRules } from './enhance.js'
|
|
9
|
+
import { platform } from './platform.js'
|
|
8
10
|
|
|
9
11
|
const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
10
12
|
|
|
@@ -24,6 +26,8 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
|
|
|
24
26
|
injectDefaults(ctx.doc)
|
|
25
27
|
injectComponents(ctx.doc, ctx.pageRules)
|
|
26
28
|
ctx.formRules = deriveFormRules(ctx.pageRules, ctx.doc)
|
|
29
|
+
// Before the extract below, for the same readable-failure reason as open().
|
|
30
|
+
ctx.unresolved = findUnresolved(ctx.pageRoot, ctx.pageRules)
|
|
27
31
|
|
|
28
32
|
const newData = coerceBooleans(
|
|
29
33
|
engine.extract(ctx.pageRoot, ctx.pageRules, ENGINE_OPTS),
|
|
@@ -43,14 +47,15 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
|
|
|
43
47
|
// the last error state so error messages survive across observer-driven refreshes
|
|
44
48
|
// (e.g., the rollback after a failed add).
|
|
45
49
|
applyErrorState(ctx)
|
|
50
|
+
applyUnresolvedState(ctx)
|
|
46
51
|
// Update fingerprint so subsequent commits against the new page state compare correctly.
|
|
47
52
|
if (ctx.updateFingerprint) ctx.updateFingerprint()
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
export function installObserver({ debounce = 100, onRefresh }) {
|
|
51
|
-
const M = (
|
|
56
|
+
const M = platform('Mutation')
|
|
52
57
|
if (!M || typeof M.onAnyChange !== 'function') {
|
|
53
|
-
throw new Error('hypercms:
|
|
58
|
+
throw new Error('hypercms: a mutation hub is required (clay.Mutation or hyperclay.Mutation). Load clayjs or hyperclayjs, or just the mutation utility, before initializing hypercms.')
|
|
54
59
|
}
|
|
55
60
|
let paused = 0
|
|
56
61
|
const unsub = M.onAnyChange({ debounce }, () => {
|
package/src/shell.js
CHANGED
|
@@ -79,6 +79,7 @@ export function mountShell({
|
|
|
79
79
|
<span class="mirk-button__label">×</span>
|
|
80
80
|
</button>
|
|
81
81
|
</header>
|
|
82
|
+
<div class="hcms-shell-notice" role="status" hidden></div>
|
|
82
83
|
<div class="hcms-shell-error" role="alert" hidden></div>
|
|
83
84
|
<div data-hcms-form-root class="hcms-form"></div>
|
|
84
85
|
<footer class="hcms-shell-footer"${showSaveButton ? '' : ' hidden'}>
|
|
@@ -106,6 +107,7 @@ export function mountShell({
|
|
|
106
107
|
return {
|
|
107
108
|
root,
|
|
108
109
|
formRoot: root.querySelector('[data-hcms-form-root]'),
|
|
110
|
+
noticeEl: root.querySelector('.hcms-shell-notice'),
|
|
109
111
|
errorEl: root.querySelector('.hcms-shell-error'),
|
|
110
112
|
saveButton: root.querySelector('.hcms-shell-save'),
|
|
111
113
|
destroy() {
|
package/src/templates.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ruleAttrIndex } from 'hyper-html-api/engine'
|
|
1
2
|
import { getRuleAtPath } from './path.js'
|
|
2
3
|
|
|
3
4
|
export function humanize(key) {
|
|
@@ -245,7 +246,7 @@ export function injectComponentTemplate(doc, key) {
|
|
|
245
246
|
// workflow) and @checked means checkbox (a text input showing the literal word
|
|
246
247
|
// "true" helps nobody). @href is NOT inferred: editing a link/URL is far more
|
|
247
248
|
// common than uploading a file, so an a@href rule stays a plain URL field and
|
|
248
|
-
// the file upload is opt-in. Suffix is split like the engine (
|
|
249
|
+
// the file upload is opt-in. Suffix is split like the engine (ruleAttrIndex,
|
|
249
250
|
// see engine/extract.js). Returns a DEFAULT_TEMPLATES key.
|
|
250
251
|
const PROP_TO_COMPONENT = { src: '@image', checked: '@checkbox', innerHTML: '@richtext' }
|
|
251
252
|
// data-hcms-component values for scalar rules. "chips" is array-only (below).
|
|
@@ -270,7 +271,7 @@ const ON_DEMAND_COMPONENT_KEYS = new Set([
|
|
|
270
271
|
|
|
271
272
|
export function componentForScalarRule(rule, doc, pathArr) {
|
|
272
273
|
if (typeof rule !== 'string') return '@scalar'
|
|
273
|
-
const at = rule
|
|
274
|
+
const at = ruleAttrIndex(rule)
|
|
274
275
|
const override = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
|
|
275
276
|
if (override && SCALAR_COMPONENT_BY_NAME[override]) {
|
|
276
277
|
const key = SCALAR_COMPONENT_BY_NAME[override]
|
|
@@ -304,7 +305,7 @@ export function componentForScalarRule(rule, doc, pathArr) {
|
|
|
304
305
|
// logging shadowing notices.
|
|
305
306
|
export function declaredComponentKey(rule, doc) {
|
|
306
307
|
if (typeof rule !== 'string') return null
|
|
307
|
-
const at = rule
|
|
308
|
+
const at = ruleAttrIndex(rule)
|
|
308
309
|
const name = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-component')
|
|
309
310
|
return (name && SCALAR_COMPONENT_BY_NAME[name]) || null
|
|
310
311
|
}
|
|
@@ -414,7 +415,7 @@ export function winningScalarArrayComponent(rule, pathArr, doc) {
|
|
|
414
415
|
// data-rules-name idiom); labels derive via humanize() like every other label.
|
|
415
416
|
export function readOptionsOverride(rule, doc) {
|
|
416
417
|
if (typeof rule !== 'string') return null
|
|
417
|
-
const at = rule
|
|
418
|
+
const at = ruleAttrIndex(rule)
|
|
418
419
|
const raw = readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-options')
|
|
419
420
|
if (raw == null) return null
|
|
420
421
|
const tokens = raw.trim().split(/\s+/).filter(Boolean)
|
|
@@ -426,7 +427,7 @@ export function readOptionsOverride(rule, doc) {
|
|
|
426
427
|
// reads it without a second page lookup.
|
|
427
428
|
export function readCropOverride(rule, doc) {
|
|
428
429
|
if (typeof rule !== 'string') return null
|
|
429
|
-
const at = rule
|
|
430
|
+
const at = ruleAttrIndex(rule)
|
|
430
431
|
return readElementAttr(scalarSelectorOf(rule, at), doc, 'data-hcms-crop')
|
|
431
432
|
}
|
|
432
433
|
|
|
@@ -484,6 +484,18 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
484
484
|
.hcms-upload-clear--badge:hover { color: var(--mirk-destructive); border-color: var(--mirk-destructive); }
|
|
485
485
|
.hcms-upload-clear--badge .hcms-x { width: 10px; height: 10px; }
|
|
486
486
|
|
|
487
|
+
/* ---------- UNRESOLVED-FIELDS NOTICE ---------- */
|
|
488
|
+
.hcms-shell-notice {
|
|
489
|
+
font-size: 12px;
|
|
490
|
+
line-height: 1.45;
|
|
491
|
+
color: var(--mirk-mark-fg);
|
|
492
|
+
background: var(--mirk-bevel-bg);
|
|
493
|
+
border: 1px solid var(--mirk-input-border);
|
|
494
|
+
padding: 8px 12px;
|
|
495
|
+
margin: 0 24px 8px;
|
|
496
|
+
}
|
|
497
|
+
.hcms-shell-notice[hidden] { display: none; }
|
|
498
|
+
|
|
487
499
|
/* ---------- ERROR BANNER + inline errors ---------- */
|
|
488
500
|
.hcms-shell-error,
|
|
489
501
|
.hcms-error {
|
package/src/theme.generated.css
CHANGED
|
@@ -1495,6 +1495,18 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
|
|
|
1495
1495
|
.hcms-upload-clear--badge:hover { color: var(--mirk-destructive); border-color: var(--mirk-destructive); }
|
|
1496
1496
|
.hcms-upload-clear--badge .hcms-x { width: 10px; height: 10px; }
|
|
1497
1497
|
|
|
1498
|
+
/* ---------- UNRESOLVED-FIELDS NOTICE ---------- */
|
|
1499
|
+
.hcms-shell-notice {
|
|
1500
|
+
font-size: 12px;
|
|
1501
|
+
line-height: 1.45;
|
|
1502
|
+
color: var(--mirk-mark-fg);
|
|
1503
|
+
background: var(--mirk-bevel-bg);
|
|
1504
|
+
border: 1px solid var(--mirk-input-border);
|
|
1505
|
+
padding: 8px 12px;
|
|
1506
|
+
margin: 0 24px 8px;
|
|
1507
|
+
}
|
|
1508
|
+
.hcms-shell-notice[hidden] { display: none; }
|
|
1509
|
+
|
|
1498
1510
|
/* ---------- ERROR BANNER + inline errors ---------- */
|
|
1499
1511
|
.hcms-shell-error,
|
|
1500
1512
|
.hcms-error {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import domAdapter from 'hyper-html-api/dom'
|
|
2
|
+
import { ruleAttrIndex } from 'hyper-html-api/engine'
|
|
3
|
+
|
|
4
|
+
const PREFIX = '[hypercms]'
|
|
5
|
+
|
|
6
|
+
// The opts every other engine call in hypercms uses: never traverse into the
|
|
7
|
+
// sidebar's own form, never count a [cms-template] seed as content.
|
|
8
|
+
const FIND_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
|
|
9
|
+
// templateAttr:null includes seeds. An empty list still holding its seed can be
|
|
10
|
+
// grown back from it, so it is not dead.
|
|
11
|
+
const SEED_OPTS = { skip: '[data-hcms-shell]', templateAttr: null }
|
|
12
|
+
|
|
13
|
+
export class InvalidRuleSelector extends Error {
|
|
14
|
+
constructor(path, selector, cause) {
|
|
15
|
+
super(`hypercms: rule at "${path}" has an invalid CSS selector: "${selector}"`)
|
|
16
|
+
this.name = 'InvalidRuleSelector'
|
|
17
|
+
this.path = path
|
|
18
|
+
this.selector = selector
|
|
19
|
+
this.cause = cause
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Report a field only when writing to it would be silently discarded, which is
|
|
24
|
+
// exactly when apply() finds no element and returns having done nothing.
|
|
25
|
+
// An extracted null is NOT that test: a present-but-empty attribute reads null
|
|
26
|
+
// too (extract.js readPropOrAttr), and that field saves perfectly. So this
|
|
27
|
+
// resolves each rule's selector and counts matches instead of reading values.
|
|
28
|
+
export function findUnresolved(root, rules) {
|
|
29
|
+
const missing = []
|
|
30
|
+
const twins = []
|
|
31
|
+
walk(root, rules, [], missing, twins)
|
|
32
|
+
return { missing: unique(missing), twins: uniqueTwins(twins) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function walk(ctx, rule, path, missing, twins) {
|
|
36
|
+
if (typeof rule === 'string') {
|
|
37
|
+
const selector = selectorOf(rule)
|
|
38
|
+
if (!selector) return
|
|
39
|
+
const matches = find(ctx, selector, FIND_OPTS, path)
|
|
40
|
+
if (rule.endsWith('[]')) {
|
|
41
|
+
if (matches.length === 0 && find(ctx, selector, SEED_OPTS, path).length === 0) {
|
|
42
|
+
missing.push(pathStr(path))
|
|
43
|
+
}
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
if (matches.length === 0) missing.push(pathStr(path))
|
|
47
|
+
else if (matches.length > 1) twins.push({ path: pathStr(path), count: matches.length })
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (Array.isArray(rule)) {
|
|
52
|
+
const [selector, shape] = rule
|
|
53
|
+
if (typeof selector !== 'string' || !selector) return
|
|
54
|
+
const matches = find(ctx, selector, FIND_OPTS, path)
|
|
55
|
+
if (matches.length === 0) {
|
|
56
|
+
if (find(ctx, selector, SEED_OPTS, path).length === 0) missing.push(pathStr(path))
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
// Rows collapse to one '*' segment, so a field broken on every row is
|
|
60
|
+
// reported once rather than once per row.
|
|
61
|
+
for (const node of matches) walk(node, shape, [...path, '*'], missing, twins)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (rule && typeof rule === 'object') {
|
|
66
|
+
for (const [key, sub] of Object.entries(rule)) walk(ctx, sub, [...path, key], missing, twins)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function find(ctx, selector, opts, path) {
|
|
71
|
+
try {
|
|
72
|
+
return domAdapter.find(ctx, selector, opts)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
throw new InvalidRuleSelector(pathStr(path), selector, err)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Mirrors the engine's scalar parsing: a trailing [] strips, "." and a leading
|
|
79
|
+
// @ read the context node itself so they can never fail to resolve, and
|
|
80
|
+
// otherwise the selector is the part before the separator @ (the last one that
|
|
81
|
+
// is not inside brackets or quotes, so a mailto address stays in the selector).
|
|
82
|
+
function selectorOf(rule) {
|
|
83
|
+
if (rule === '.' || rule.startsWith('@')) return null
|
|
84
|
+
if (rule.endsWith('[]')) return rule.slice(0, -2) || null
|
|
85
|
+
const at = ruleAttrIndex(rule)
|
|
86
|
+
return (at === -1 ? rule : rule.slice(0, at)) || null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function applyUnresolvedState(ctx) {
|
|
90
|
+
renderNotice(ctx)
|
|
91
|
+
warnTwins(ctx)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// States the fact and stops. Regenerating a CMS replaces the rules tag
|
|
95
|
+
// wholesale, so a notice that reads as a repair prompt would walk anyone with a
|
|
96
|
+
// hand-edited config into losing it.
|
|
97
|
+
function renderNotice(ctx) {
|
|
98
|
+
const el = ctx.noticeEl
|
|
99
|
+
if (!el) return
|
|
100
|
+
const missing = (ctx.unresolved && ctx.unresolved.missing) || []
|
|
101
|
+
if (missing.length === 0) {
|
|
102
|
+
el.textContent = ''
|
|
103
|
+
el.hidden = true
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
const count = missing.length === 1
|
|
107
|
+
? '1 field no longer matches this page'
|
|
108
|
+
: `${missing.length} fields no longer match this page`
|
|
109
|
+
el.textContent = `${count}: ${missing.join(', ')}`
|
|
110
|
+
el.hidden = false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Two elements matching one scalar rule still works, since the write lands on
|
|
114
|
+
// the first, so this is a console note rather than a notice. Runtime duplicates
|
|
115
|
+
// (a carousel cloning slides, a dialog holding a second copy) are ordinary.
|
|
116
|
+
// Signature-gated because the walk reruns on every refresh.
|
|
117
|
+
function warnTwins(ctx) {
|
|
118
|
+
const twins = (ctx.unresolved && ctx.unresolved.twins) || []
|
|
119
|
+
const signature = twins.map((t) => `${t.path}:${t.count}`).join('|')
|
|
120
|
+
if (signature === ctx.lastTwinSignature) return
|
|
121
|
+
ctx.lastTwinSignature = signature
|
|
122
|
+
for (const { path, count } of twins) {
|
|
123
|
+
console.warn(`${PREFIX} "${path}" matches ${count} elements; edits go to the first one.`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function unique(list) {
|
|
128
|
+
return [...new Set(list)]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function uniqueTwins(list) {
|
|
132
|
+
const byPath = new Map()
|
|
133
|
+
for (const twin of list) {
|
|
134
|
+
const seen = byPath.get(twin.path)
|
|
135
|
+
if (!seen || twin.count > seen.count) byPath.set(twin.path, twin)
|
|
136
|
+
}
|
|
137
|
+
return [...byPath.values()]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function pathStr(path) {
|
|
141
|
+
return path.length ? path.join('.') : '(whole page)'
|
|
142
|
+
}
|