@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/hyper-cms",
3
- "version": "0.6.2",
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.5.2",
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.lastIndexOf('@') >= 0) return 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
- (win && win.hyperclay && win.hyperclay.RichClay) ||
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 = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
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 = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
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 = rule.lastIndexOf('@')
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 = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
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 = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.quickcrop) || null
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 = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.uploadFileBasic) || null
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' && (window.hyperclay?.consent || window.consent)
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 = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.undo) || null
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
- doc.addEventListener('hyperclay:livesync-applied', onLivesync)
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 && !!(window.hyperclay && window.hyperclay.Mutation)
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: hyperclayjs's mutation.js dispatches 'hyperclay:mutation-ready' the
516
- // instant it installs window.hyperclay.Mutation. On loader pages this is
517
- // moot — the loader's first wave evaluates mutation before hypercms, so the
518
- // fast-path wins — but the event catches any late install (the deferred
519
- // `await import()` ordering). The body gate is re-checked in the handler
520
- // because the event can land before <body> is parsed.
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
- document.removeEventListener('hyperclay:mutation-ready', onMutationReady)
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 — the one-shot listener is already consumed,
555
- // so only the backstop remains to catch it once body arrives.
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.addEventListener('hyperclay:mutation-ready', onMutationReady, { once: true })
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 — window.hyperclay.Mutation never appeared. ' +
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)
@@ -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 = (typeof window !== 'undefined' && window.hyperclay && window.hyperclay.Mutation) || null
56
+ const M = platform('Mutation')
52
57
  if (!M || typeof M.onAnyChange !== 'function') {
53
- throw new Error('hypercms: window.hyperclay.Mutation is required. Load hyperclayjs (or just the mutation utility) before initializing 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 (lastIndexOf('@'),
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.lastIndexOf('@')
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.lastIndexOf('@')
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.lastIndexOf('@')
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.lastIndexOf('@')
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 {
@@ -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
+ }