@panphora/hyper-cms 0.10.0 → 0.11.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.10.0",
3
+ "version": "0.11.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",
@@ -24,6 +24,7 @@
24
24
  "./theme.css": "./src/theme.generated.css"
25
25
  },
26
26
  "files": [
27
+ "packed-contract.json",
27
28
  "LICENSE",
28
29
  "README.md",
29
30
  "src/**/*.js",
@@ -32,10 +33,12 @@
32
33
  ],
33
34
  "scripts": {
34
35
  "build:theme": "node scripts/build-theme.js",
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
+ "build:js": "node scripts/build-js.js --standalone",
37
+ "build:preview": "node scripts/build-js.js --standalone --hyper-morph-source=../hyper-morph/src/index.js --hyper-html-api-source=../hyper-html-api",
38
+ "build": "npm run build:theme && npm run build:js",
36
39
  "prepublishOnly": "npm run build",
37
40
  "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",
41
+ "copy-to-clayjs": "npm run build:theme && node scripts/copy-to-clayjs.js",
39
42
  "test:node": "node --test 'test-node/**/*.test.js'",
40
43
  "test": "npm run test:node",
41
44
  "demo": "http-server . -p 5694 -c-1 -o /demo/",
@@ -44,8 +47,8 @@
44
47
  "test:all": "npm run test:node && npm run test:browser"
45
48
  },
46
49
  "dependencies": {
47
- "hyper-html-api": "^0.8.0",
48
- "hyper-morph": "^0.5.0"
50
+ "hyper-html-api": "^0.9.0",
51
+ "hyper-morph": "^1.0.0"
49
52
  },
50
53
  "devDependencies": {
51
54
  "@open-wc/testing": "^4.0.0",
@@ -0,0 +1 @@
1
+ {"load":["@panphora/hyper-cms"],"members":["src/theme.generated.css"]}
package/src/anchor.js ADDED
@@ -0,0 +1,24 @@
1
+ // Can the inline view hang a control on this page element right now?
2
+ //
3
+ // Deliberately a question about the CURRENT state, never a cached verdict. A
4
+ // target inside a closed <details> or an inactive tab answers false now and
5
+ // true the moment the page reveals it, which is why the inline view re-runs
6
+ // this on every visibility change instead of classifying once at open.
7
+ //
8
+ // The area floor is not cosmetic padding and it is not redundant with
9
+ // IntersectionObserver. Measured in Chrome: an empty inline <span> reports
10
+ // isIntersecting true, ratio 1, on a zero-width box. Without the floor that
11
+ // draws a camera handle on a 0px target.
12
+ export const MIN_ANCHOR_PX = 8
13
+
14
+ // [cms-template] is the hidden list seed; [data-hcms-shell] is the CMS's own
15
+ // chrome, which both the sidebar panel and the inline host carry. Neither is
16
+ // page content, so neither ever gets a control.
17
+ const NEVER_ANCHOR = '[cms-template], [data-hcms-shell]'
18
+
19
+ export function isAnchorable(el) {
20
+ if (!el || typeof el.getBoundingClientRect !== 'function') return false
21
+ if (typeof el.closest === 'function' && el.closest(NEVER_ANCHOR)) return false
22
+ const r = el.getBoundingClientRect()
23
+ return r.width >= MIN_ANCHOR_PX && r.height >= MIN_ANCHOR_PX
24
+ }
package/src/apply-loop.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { engine } from 'hyper-html-api'
2
+ import { morph } from 'hyper-morph'
2
3
  import { fromString as pathFromString, getRuleAtPath } from './path.js'
3
4
  import { rowIdentityHooks } from './row-identity.js'
4
5
 
5
6
  const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
7
+ const ROLLBACK_UI = 'data-hcms-rollback-ui'
8
+ let rollbackUiId = 0
6
9
 
7
10
  // Engine reads/writes against pageRoot pass skip + templateAttr so the
8
11
  // engine never traverses into the form's own DOM, and so [cms-template]
@@ -15,42 +18,41 @@ const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
15
18
  // rollback restores only the failing subtree; listeners and state elsewhere
16
19
  // on the page survive.
17
20
  export function applyWithRollback(pageRoot, pageRules, newData, options = {}) {
18
- const { observerHandle, shellRoot, structural, structuralPath, formRoot } = options
21
+ return applyPage(pageRoot, pageRules, newData, options)
22
+ }
23
+
24
+ function applyPage(pageRoot, pageRules, newData, options) {
25
+ const { shellRoot, structural, structuralPath, formRoot } = options
19
26
  // The form's own row elements are the only stable handle across an apply, so
20
27
  // when we have the form we let it say which page row each item is. Without it
21
28
  // the engine matches by content, which is exact except between two rows that
22
29
  // read identically.
23
30
  const engineOpts = formRoot ? { ...ENGINE_OPTS, ...rowIdentityHooks(formRoot) } : ENGINE_OPTS
24
- observerHandle?.pause?.()
25
- try {
26
- if (!structural) {
27
- try {
28
- engine.apply(pageRoot, pageRules, newData, engineOpts)
29
- return { ok: true }
30
- } catch (err) {
31
- return { ok: false, error: err }
32
- }
33
- }
34
-
35
- // Structural path: snapshot the smallest container that the change
36
- // touches, falling back to non-shell page content if we can't resolve
37
- // a specific container.
38
- const target = resolveStructuralTarget(pageRoot, pageRules, structuralPath)
39
- const subtreeSnapshot = target ? captureChildren(target) : null
40
- const pageSnapshot = target ? null : captureNonShellSnapshot(pageRoot, shellRoot)
31
+ if (!structural) {
41
32
  try {
42
33
  engine.apply(pageRoot, pageRules, newData, engineOpts)
43
34
  return { ok: true }
44
35
  } catch (err) {
45
- if (subtreeSnapshot) {
46
- restoreChildren(target, subtreeSnapshot)
47
- } else if (pageSnapshot) {
48
- restoreNonShellSnapshot(pageRoot, shellRoot, pageSnapshot)
49
- }
50
36
  return { ok: false, error: err }
51
37
  }
52
- } finally {
53
- observerHandle?.resume?.()
38
+ }
39
+
40
+ // Structural path: snapshot the smallest container that the change
41
+ // touches, falling back to non-shell page content if we can't resolve
42
+ // a specific container.
43
+ const target = resolveStructuralTarget(pageRoot, pageRules, structuralPath)
44
+ const subtreeSnapshot = target ? captureChildren(target) : null
45
+ const pageSnapshot = target ? null : captureNonShellSnapshot(pageRoot, shellRoot)
46
+ try {
47
+ engine.apply(pageRoot, pageRules, newData, engineOpts)
48
+ return { ok: true }
49
+ } catch (err) {
50
+ if (subtreeSnapshot) {
51
+ restoreChildren(target, subtreeSnapshot)
52
+ } else if (pageSnapshot) {
53
+ restoreNonShellSnapshot(pageRoot, shellRoot, pageSnapshot)
54
+ }
55
+ return { ok: false, error: err }
54
56
  }
55
57
  }
56
58
 
@@ -126,42 +128,67 @@ function resolveContainerByPath(pageRoot, pageRules, arrPath) {
126
128
  }
127
129
 
128
130
  function captureChildren(parent) {
129
- const clones = []
131
+ const nodes = []
132
+ const retained = []
130
133
  for (const child of Array.from(parent.childNodes)) {
131
- clones.push(child.cloneNode(true))
134
+ nodes.push(cloneForRollback(child, retained))
132
135
  }
133
- return clones
136
+ return { nodes, retained }
134
137
  }
135
138
 
136
- function restoreChildren(parent, clones) {
137
- while (parent.firstChild) parent.removeChild(parent.firstChild)
138
- for (const clone of clones) parent.appendChild(clone)
139
+ function restoreChildren(parent, snapshot) {
140
+ const source = parent.cloneNode(false)
141
+ for (const clone of snapshot.nodes) source.appendChild(clone)
142
+ morph(parent, Array.from(source.childNodes), rollbackMorphOptions())
143
+ restoreRetainedUi(parent, snapshot.retained)
139
144
  }
140
145
 
141
146
  function captureNonShellSnapshot(pageRoot, shellRoot) {
142
- const clones = []
147
+ const nodes = []
148
+ const retained = []
143
149
  for (const child of Array.from(pageRoot.childNodes)) {
144
150
  if (child === shellRoot || (shellRoot && child.contains?.(shellRoot))) continue
145
- clones.push(child.cloneNode(true))
151
+ nodes.push(cloneForRollback(child, retained))
146
152
  }
147
- return clones
153
+ return { nodes, retained }
148
154
  }
149
155
 
150
- function restoreNonShellSnapshot(pageRoot, shellRoot, clones) {
151
- for (const child of Array.from(pageRoot.childNodes)) {
152
- if (child === shellRoot || (shellRoot && child.contains?.(shellRoot))) continue
153
- pageRoot.removeChild(child)
156
+ function restoreNonShellSnapshot(pageRoot, shellRoot, snapshot) {
157
+ const source = pageRoot.cloneNode(false)
158
+ for (const clone of snapshot.nodes) source.appendChild(clone)
159
+ morph(pageRoot, Array.from(source.childNodes), rollbackMorphOptions())
160
+ restoreRetainedUi(pageRoot, snapshot.retained)
161
+ }
162
+
163
+ function cloneForRollback(source, retained) {
164
+ const clone = source.cloneNode(false)
165
+ if (source.nodeType === 1 && source.matches('[editor-ui],[clay~="editor-ui"]')) {
166
+ const id = String(++rollbackUiId)
167
+ clone.setAttribute(ROLLBACK_UI, id)
168
+ retained.push({ id, node: source })
154
169
  }
155
- const host = findShellHostChild(pageRoot, shellRoot)
156
- for (const clone of clones) {
157
- pageRoot.insertBefore(clone, host || null)
170
+ const sourceChildren = source.nodeType === 1 && source.tagName === 'TEMPLATE' ? source.content : source
171
+ const cloneChildren = clone.nodeType === 1 && clone.tagName === 'TEMPLATE' ? clone.content : clone
172
+ for (const child of Array.from(sourceChildren.childNodes || [])) {
173
+ cloneChildren.appendChild(cloneForRollback(child, retained))
158
174
  }
175
+ return clone
159
176
  }
160
177
 
161
- function findShellHostChild(pageRoot, shellRoot) {
162
- if (!shellRoot) return null
163
- for (const child of Array.from(pageRoot.childNodes)) {
164
- if (child === shellRoot || child.contains?.(shellRoot)) return child
178
+ function restoreRetainedUi(root, retained) {
179
+ for (const { id, node } of retained) {
180
+ const placeholder = root.querySelector(`[${ROLLBACK_UI}="${id}"]`)
181
+ if (placeholder === node) node.removeAttribute(ROLLBACK_UI)
182
+ else if (node.isConnected) placeholder?.remove()
183
+ else placeholder?.replaceWith(node)
184
+ }
185
+ }
186
+
187
+ function rollbackMorphOptions() {
188
+ return {
189
+ morphStyle: 'innerHTML',
190
+ policy: 'raw',
191
+ restoreFocus: false,
192
+ scripts: { handle: false, merge: false },
165
193
  }
166
- return null
167
194
  }
package/src/enhance.js CHANGED
@@ -9,7 +9,7 @@
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'
12
+ import { BOUND_ATTR, resolveRichClay } from './richclay-bridge.js'
13
13
  import { ruleAttrIndex } from 'hyper-html-api/engine'
14
14
 
15
15
  export function upgradeRichTextRules(rules, pageRoot) {
@@ -41,6 +41,73 @@ export function upgradeRichTextRules(rules, pageRoot) {
41
41
  }
42
42
  }
43
43
 
44
+ // The inline view's upgrade. Identical to the one above for a scalar or an
45
+ // object rule, and different for the one case that one deliberately refuses:
46
+ // the rules inside an array.
47
+ //
48
+ // The narrow version cannot answer "does THIS row's element hold markup" from a
49
+ // document-level querySelector, so it leaves array subtrees alone. Here the item
50
+ // elements are resolved first, so the question IS answerable — once per rule
51
+ // rather than once per row, because the rule language has no per-row rule. ANY
52
+ // row holding markup upgrades the rule for every row, and that costs nothing:
53
+ // @innerHTML round-trips plain text unchanged.
54
+ //
55
+ // It matters because the inline view binds rich text to the row element itself.
56
+ // A row left on textContent loses its links and its bold on the first commit.
57
+ export function upgradeInlineTextRules(rules, pageRoot) {
58
+ if (!pageRoot || rules == null) return rules
59
+ return walk(rules, [pageRoot])
60
+
61
+ function walk(rule, contexts) {
62
+ if (typeof rule === 'string') {
63
+ if (rule.endsWith('[]')) return rule
64
+ if (ruleAttrIndex(rule) !== -1) return rule
65
+ return contexts.some((ctx) => holdsMarkup(ctx, rule)) ? rule + '@innerHTML' : rule
66
+ }
67
+ if (Array.isArray(rule)) {
68
+ const [selector, shape] = rule
69
+ if (typeof selector !== 'string' || !selector) return rule
70
+ const items = queryAll(pageRoot, selector)
71
+ if (!items.length) return rule
72
+ return [selector, walk(shape, items)]
73
+ }
74
+ if (rule && typeof rule === 'object') {
75
+ const out = Object.create(null)
76
+ for (const [k, v] of Object.entries(rule)) out[k] = walk(v, contexts)
77
+ return out
78
+ }
79
+ return rule
80
+ }
81
+ }
82
+
83
+ function holdsMarkup(context, selector) {
84
+ let el = null
85
+ try {
86
+ el = context.querySelector(selector)
87
+ } catch (_) {
88
+ return false
89
+ }
90
+ if (!el) return false
91
+ // An element hypercms has an editor on cannot answer this from its live DOM.
92
+ // Squire wraps the content of a block it binds in a <div>, so a plain
93
+ // paragraph grows a child the instant someone clicks it, and reading that
94
+ // back as author markup upgrades the rule to @innerHTML and turns a
95
+ // plain-text field into a rich-text one just by being clicked. The marker
96
+ // carries the verdict from before the editor arrived, which is the only
97
+ // moment the question had an honest answer. Measured in Chrome: the wrapper
98
+ // lands at t+0 and the upgrade followed on the next observer batch.
99
+ if (el.hasAttribute(BOUND_ATTR)) return el.getAttribute(BOUND_ATTR) === 'rich'
100
+ return el.children.length > 0
101
+ }
102
+
103
+ function queryAll(root, selector) {
104
+ try {
105
+ return [...root.querySelectorAll(selector)]
106
+ } catch (_) {
107
+ return []
108
+ }
109
+ }
110
+
44
111
  // Grow a form textarea to fit its content. Browsers with `field-sizing:
45
112
  // content` handle this in CSS (the theme sets it); everywhere else the
46
113
  // height is pinned to scrollHeight on populate and on every input.
@@ -57,15 +124,21 @@ export function autosizeTextarea(el) {
57
124
  // page ships it (window.richclay / window.hyperclay.RichClay). Without
58
125
  // richclay the surface stays a plain contenteditable bound via @innerHTML,
59
126
  // which still round-trips markup — richclay only adds the editing chrome.
60
- export function enhanceFields(root, doc) {
127
+ //
128
+ // `formRichText` is the view's, read off view.enhanceFormRichText. The inline
129
+ // view passes false: its form fields are never focused (rich text is edited on
130
+ // the page element itself), and each instance installs five document-level
131
+ // capture listeners that test event.target against their element on every
132
+ // keydown, beforeinput, cut, paste and drop. Twenty rich fields is a hundred
133
+ // dead listeners. The autosize pass runs either way — the popover shows those
134
+ // same fields for every non-text target.
135
+ export function enhanceFields(root, doc, formRichText = true) {
61
136
  if (!root || !root.querySelectorAll) return
62
137
  root.querySelectorAll('textarea[data-hcms-field]').forEach(autosizeTextarea)
138
+ if (formRichText === false) return
63
139
 
64
140
  const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null)
65
- const RichClay =
66
- (win && win.richclay && win.richclay.RichClay) ||
67
- platform('RichClay', win) ||
68
- (win && typeof win.RichClay === 'function' ? win.RichClay : null)
141
+ const RichClay = resolveRichClay(win)
69
142
  if (!RichClay) return
70
143
 
71
144
  root.querySelectorAll('[contenteditable][data-hcms-field]').forEach((el) => {
package/src/events.js CHANGED
@@ -484,7 +484,7 @@ export function onAdd(arrayPath, ctx) {
484
484
  pageRules,
485
485
  })
486
486
  slot.appendChild(itemNode)
487
- enhanceFields(itemNode, ctx.doc)
487
+ enhanceFields(itemNode, ctx.doc, ctx.view?.enhanceFormRichText !== false)
488
488
  updateArrayButtonsVisibility(arrayEl)
489
489
  return commitWithUndo(`Add ${arrayPath}`, () =>
490
490
  commit(extractFormData(ctx), { path: arrayPath, structural: true }, ctx)
@@ -558,7 +558,15 @@ export function requestRemove(itemEl, ctx) {
558
558
  if (message == null) return onRemove(itemEl, ctx)
559
559
  const consent = platform('consent') || (typeof window !== 'undefined' && window.consent)
560
560
  if (typeof consent === 'function') {
561
- Promise.resolve(consent(message)).then(() => onRemove(itemEl, ctx), () => {})
561
+ Promise.resolve(consent(message)).then(() => {
562
+ // The editor can be closed, or switched to another view, while the
563
+ // confirm is still on screen. ctx is dead by then, but itemEl still
564
+ // points into the detached form and commit would apply that stale form
565
+ // over the live page: the card goes, and every other field reverts to
566
+ // whatever it held when the dialog opened.
567
+ if (ctx.closed) return
568
+ onRemove(itemEl, ctx)
569
+ }, () => {})
562
570
  } else if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
563
571
  if (window.confirm(message)) onRemove(itemEl, ctx)
564
572
  } else {
@@ -589,6 +597,15 @@ export function onRemove(itemEl, ctx) {
589
597
  }
590
598
 
591
599
  export function commit(newData, info, ctx) {
600
+ // The last line of defence for any deferred path that reaches a torn-down
601
+ // session: a resolved confirmation, an upload that finished after close, a
602
+ // queued handler. ctx.pageRoot is still the live page.
603
+ //
604
+ // ok:false is deliberate and load-bearing — commitWithUndo records an undo
605
+ // entry on a truthy ok, so returning ok:true here would put a commit that
606
+ // never happened onto the undo stack. Returning before the error branch below
607
+ // is equally deliberate: setError would write into a detached shell.
608
+ if (ctx.closed) return { ok: false, skipped: true, closed: true }
592
609
  const fingerprint = stableStringify(newData)
593
610
  // Only a plain edit can be skipped on an unchanged fingerprint. A structural
594
611
  // operation is never a no-op even when the data is identical: reordering two
@@ -599,8 +616,13 @@ export function commit(newData, info, ctx) {
599
616
  return { ok: true, skipped: true }
600
617
  }
601
618
 
602
- const result = applyWithRollback(ctx.pageRoot, ctx.pageRules, newData, {
603
- observerHandle: ctx.observerHandle,
619
+ // undefined is a legitimate value here: a rules tree that is nothing but a
620
+ // read-only projection strips to it, and engine.apply writes nothing when the
621
+ // rules are undefined. Only a ctx that never had the field at all — the
622
+ // hand-built stubs in older tests — falls back to the unstripped rules.
623
+ const writeRules = Object.hasOwn(ctx, 'writeRules') ? ctx.writeRules : ctx.pageRules
624
+
625
+ const result = applyWithRollback(ctx.pageRoot, writeRules, newData, {
604
626
  shellRoot: ctx.shellRoot,
605
627
  structural: !!info.structural,
606
628
  structuralPath: info.path || null,
@@ -620,8 +642,72 @@ export function commit(newData, info, ctx) {
620
642
  return result
621
643
  }
622
644
 
645
+ // Resolve one form leaf by path, and write a value into it. Shared by
646
+ // cms.api.setValue and by the inline view's text commit, which reads the page
647
+ // element richclay just edited and mirrors it into the form before committing.
648
+ export function findLeafField(formRoot, path) {
649
+ const esc = cssEscape(path)
650
+ // Prefer a tag-qualified leaf (input/textarea/select/img/a) — since v0.3
651
+ // stamps data-hcms-field on the wrapping container too, a bare attribute
652
+ // match would resolve to the container and lose the value write.
653
+ const leafSel =
654
+ `[data-hcms-path="${esc}"] input[data-hcms-field], ` +
655
+ `[data-hcms-path="${esc}"] textarea[data-hcms-field], ` +
656
+ `[data-hcms-path="${esc}"] select[data-hcms-field], ` +
657
+ `[data-hcms-path="${esc}"] img[data-hcms-field], ` +
658
+ `[data-hcms-path="${esc}"] a[data-hcms-field], ` +
659
+ `[data-hcms-path="${esc}"] [contenteditable][data-hcms-field], ` +
660
+ // Leaf is the path-stamped element itself (inline-stamped fields):
661
+ `input[data-hcms-path="${esc}"][data-hcms-field], ` +
662
+ `textarea[data-hcms-path="${esc}"][data-hcms-field], ` +
663
+ `select[data-hcms-path="${esc}"][data-hcms-field], ` +
664
+ `img[data-hcms-path="${esc}"][data-hcms-field], ` +
665
+ `a[data-hcms-path="${esc}"][data-hcms-field], ` +
666
+ `[contenteditable][data-hcms-path="${esc}"][data-hcms-field]`
667
+ return formRoot.querySelector(leafSel)
668
+ }
669
+
670
+ export function writeFieldValue(el, value, formRoot, path) {
671
+ const tag = (el.tagName || '').toUpperCase()
672
+ const type = (el.getAttribute('type') || '').toLowerCase()
673
+ if (tag === 'INPUT' && type === 'checkbox') {
674
+ el.checked = value === true || value === 'true'
675
+ return
676
+ }
677
+ if (tag === 'INPUT' && type === 'radio') {
678
+ // Radios sharing the same path act as a group. Toggle the matching option.
679
+ const esc = cssEscape(path)
680
+ const group = formRoot.querySelectorAll(
681
+ `[data-hcms-path="${esc}"][data-hcms-field][type="radio"], [data-hcms-path="${esc}"] [data-hcms-field][type="radio"]`
682
+ )
683
+ if (group.length) {
684
+ group.forEach((r) => { r.checked = String(r.value) === String(value ?? '') })
685
+ } else {
686
+ el.checked = String(el.value) === String(value ?? '')
687
+ }
688
+ return
689
+ }
690
+ if (tag === 'IMG') {
691
+ el.src = value == null ? '' : String(value)
692
+ return
693
+ }
694
+ if (tag === 'A') {
695
+ el.href = value == null ? '' : String(value)
696
+ return
697
+ }
698
+ if (el.hasAttribute && el.hasAttribute('contenteditable')) {
699
+ el.innerHTML = value == null ? '' : String(value)
700
+ return
701
+ }
702
+ if ('value' in el) {
703
+ el.value = value == null ? '' : String(value)
704
+ return
705
+ }
706
+ el.textContent = value == null ? '' : String(value)
707
+ }
708
+
623
709
  export function extractFormData(ctx) {
624
- const raw = engine.extract(ctx.formRoot, ctx.formRules)
710
+ const raw = engine.extract(ctx.formRoot, ctx.formRules, { exclude: null })
625
711
  return coerceBooleans(raw, ctx.formRules)
626
712
  }
627
713
 
@@ -862,7 +948,7 @@ function stableStringify(value) {
862
948
  })
863
949
  }
864
950
 
865
- function cssEscape(value) {
951
+ export function cssEscape(value) {
866
952
  if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value)
867
953
  return String(value).replace(/[^a-zA-Z0-9_\-.*]/g, (c) => '\\' + c)
868
954
  }
package/src/hooks.js ADDED
@@ -0,0 +1,100 @@
1
+ // The two clone hooks hypercms installs on the host client, and the retry that
2
+ // covers a client arriving after hypercms does.
3
+ //
4
+ // Their own module rather than private state inside hypercms.js, because the
5
+ // inline view has to install the snapshot hook too: by the time someone clicks
6
+ // a heading the client is certainly loaded, which makes the bind the reliable
7
+ // second trigger for a page that lost the readiness race. Importing that back
8
+ // out of the entry module would have made the entry module part of a cycle.
9
+
10
+ import { platform, onPlatformEvent, PLATFORM_READY } from './platform.js'
11
+ import { BOUND_ID_ATTR, cleanRichClayFromSnapshot, pristineMarkupFor } from './richclay-bridge.js'
12
+ import { SESSION_OPEN_CLASS } from './shell.js'
13
+
14
+ // Strip hypercms's own body chrome from the SAVE clone so it never reaches disk
15
+ // (the shell element itself is [save-remove], but the hcms-open class lives on
16
+ // <body>, outside the stripped subtree). Registered once at first open():
17
+ // onPrepareForSave runs save-only, on a clone, so it never touches the live DOM,
18
+ // and removing absent classes is a no-op when the shell is closed. Guarded so it
19
+ // degrades cleanly when the host save pipeline (hyperclayjs) isn't present.
20
+ let prepareHookInstalled = false
21
+ export function installSavePrepareHook() {
22
+ if (prepareHookInstalled) return
23
+ const onPrepareForSave = platform('onPrepareForSave')
24
+ if (typeof onPrepareForSave !== 'function') return
25
+ onPrepareForSave((clonedDocEl) => {
26
+ restorePristineMarkup(clonedDocEl)
27
+ stripSessionChrome(clonedDocEl)
28
+ })
29
+ prepareHookInstalled = true
30
+ }
31
+
32
+ // hypercms's own chrome lives on <body>, outside the [save-remove] shell, so it
33
+ // has to be taken off the clone by hand. Both hooks call this: the save hook
34
+ // because a client may offer onPrepareForSave and no onSnapshot, and the
35
+ // snapshot hook because that is the only one live sync reaches, and without it
36
+ // the session's own classes are broadcast to every other browser. Removing an
37
+ // absent class is a no-op, so running twice on a save costs nothing.
38
+ function stripSessionChrome(clonedDocEl) {
39
+ const b = clonedDocEl && clonedDocEl.querySelector && clonedDocEl.querySelector('body')
40
+ if (b) b.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left', SESSION_OPEN_CLASS)
41
+ }
42
+
43
+ // A save can land while somebody is mid-session with an editor open on a
44
+ // heading. richclay rewrites a block's markup simply by being attached to it, so
45
+ // without this the file records that rewriting as though the author had typed
46
+ // it. The live editors are deliberately untouched: this is the clone, and the
47
+ // person editing keeps their caret and their selection.
48
+ function restorePristineMarkup(clonedDocEl) {
49
+ if (!clonedDocEl || typeof clonedDocEl.querySelectorAll !== 'function') return
50
+ for (const el of clonedDocEl.querySelectorAll(`[${BOUND_ID_ATTR}]`)) {
51
+ const html = pristineMarkupFor(el)
52
+ if (html !== null) el.innerHTML = html
53
+ el.removeAttribute(BOUND_ID_ATTR)
54
+ }
55
+ }
56
+
57
+ // Clean up after the richclay instances hypercms itself creates on page
58
+ // elements. Installed once at first open, alongside the save-prepare hook above,
59
+ // and for the same reason: the host client may not be loaded yet at module
60
+ // evaluation. onSnapshot rather than onPrepareForSave, because this has to run
61
+ // for live sync too — a hook on the save-only path fires after the editor's
62
+ // leftovers have already been broadcast to every other browser.
63
+ let snapshotHookInstalled = false
64
+ export function installSnapshotHook() {
65
+ if (snapshotHookInstalled) return
66
+ const onSnapshot = platform('onSnapshot')
67
+ if (typeof onSnapshot !== 'function') return
68
+ onSnapshot((clonedDocEl) => {
69
+ restorePristineMarkup(clonedDocEl)
70
+ cleanRichClayFromSnapshot(clonedDocEl, typeof window !== 'undefined' ? window : null)
71
+ stripSessionChrome(clonedDocEl)
72
+ })
73
+ snapshotHookInstalled = true
74
+ }
75
+
76
+ // What open() calls: install both, and arm the retry when either could not be
77
+ // installed yet.
78
+ export function installHooks() {
79
+ installSavePrepareHook()
80
+ installSnapshotHook()
81
+ if (!prepareHookInstalled || !snapshotHookInstalled) armHookRetry()
82
+ }
83
+
84
+ // Both hooks read a capability that can arrive after hypercms does, and both
85
+ // return without recording success when it is missing. Retry on the clients'
86
+ // readiness pair and disarm once both are in, so a page that lost the import
87
+ // race still gets its clone cleanup.
88
+ let offPlatformReady = null
89
+ function armHookRetry() {
90
+ if (offPlatformReady || typeof document === 'undefined') return
91
+ const retry = () => {
92
+ installSavePrepareHook()
93
+ installSnapshotHook()
94
+ if (prepareHookInstalled && snapshotHookInstalled) {
95
+ offPlatformReady?.()
96
+ offPlatformReady = null
97
+ }
98
+ }
99
+ offPlatformReady = onPlatformEvent(document, PLATFORM_READY, retry)
100
+ }
@@ -0,0 +1,10 @@
1
+ import * as hypercms from './hypercms-bundle.js'
2
+
3
+ if (typeof window !== 'undefined' && !window.__hyperclayNoAutoExport) {
4
+ window.hyperclay = window.hyperclay || {}
5
+ window.hyperclay.hypercms = hypercms.cms
6
+ window.h = window.hyperclay
7
+ }
8
+
9
+ export const cms = hypercms.cms
10
+ export default hypercms