@panphora/hyper-cms 0.6.0 → 0.6.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.0",
3
+ "version": "0.6.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",
package/src/enhance.js ADDED
@@ -0,0 +1,97 @@
1
+ // Post-mount field enhancements + the rich-text rule upgrade.
2
+ //
3
+ // Bare scalar rules bind textContent, which flattens inline HTML (links,
4
+ // bold, code) on extract and destroys it on the first apply. When the bound
5
+ // page element actually contains child elements, the rule is upgraded to
6
+ // @innerHTML so the round-trip preserves markup and the form renders the
7
+ // @richtext component (a contenteditable surface, richclay-powered when the
8
+ // host page ships richclay). Explicit @prop rules, array rules, and rules
9
+ // whose element holds only text are left untouched. Hosts opt out with
10
+ // cms.open({ richText: false }).
11
+
12
+ export function upgradeRichTextRules(rules, pageRoot) {
13
+ if (!pageRoot || rules == null) return rules
14
+ return walk(rules)
15
+
16
+ function walk(rule) {
17
+ if (typeof rule === 'string') {
18
+ if (rule.endsWith('[]')) return rule
19
+ if (rule.lastIndexOf('@') >= 0) return rule
20
+ let el = null
21
+ try {
22
+ el = pageRoot.querySelector(rule)
23
+ } catch (_) {
24
+ return rule
25
+ }
26
+ return el && el.children.length > 0 ? rule + '@innerHTML' : rule
27
+ }
28
+ // Array rules ([itemSelector, shape]) scope their inner selectors per
29
+ // item; a document-level querySelector can't answer "does THIS item's
30
+ // element hold markup", so array subtrees are never upgraded.
31
+ if (Array.isArray(rule)) return rule
32
+ if (rule && typeof rule === 'object') {
33
+ const out = Object.create(null)
34
+ for (const [k, v] of Object.entries(rule)) out[k] = walk(v)
35
+ return out
36
+ }
37
+ return rule
38
+ }
39
+ }
40
+
41
+ // Grow a form textarea to fit its content. Browsers with `field-sizing:
42
+ // content` handle this in CSS (the theme sets it); everywhere else the
43
+ // height is pinned to scrollHeight on populate and on every input.
44
+ export function autosizeTextarea(el) {
45
+ if (!el || el.tagName !== 'TEXTAREA') return
46
+ const win = el.ownerDocument.defaultView || (typeof window !== 'undefined' ? window : null)
47
+ if (win && win.CSS && win.CSS.supports && win.CSS.supports('field-sizing: content')) return
48
+ el.style.height = 'auto'
49
+ el.style.height = el.scrollHeight + 'px'
50
+ }
51
+
52
+ // Runs after any form (or form fragment) lands in the live DOM: sizes the
53
+ // autosize fallback and mounts richclay on rich-text surfaces when the host
54
+ // page ships it (window.richclay / window.hyperclay.RichClay). Without
55
+ // richclay the surface stays a plain contenteditable bound via @innerHTML,
56
+ // which still round-trips markup — richclay only adds the editing chrome.
57
+ export function enhanceFields(root, doc) {
58
+ if (!root || !root.querySelectorAll) return
59
+ root.querySelectorAll('textarea[data-hcms-field]').forEach(autosizeTextarea)
60
+
61
+ const win = (doc && doc.defaultView) || (typeof window !== 'undefined' ? window : null)
62
+ const RichClay =
63
+ (win && win.richclay && win.richclay.RichClay) ||
64
+ (win && win.hyperclay && win.hyperclay.RichClay) ||
65
+ (win && typeof win.RichClay === 'function' ? win.RichClay : null)
66
+ if (!RichClay) return
67
+
68
+ root.querySelectorAll('[contenteditable][data-hcms-field]').forEach((el) => {
69
+ if (el.__hcmsRichclay) return
70
+ let editor
71
+ try {
72
+ // hyperclay:false keeps the form field out of the page's save/undo
73
+ // machinery (page writes go through commit) and activates the editor
74
+ // regardless of the host's edit-mode signal.
75
+ editor = new RichClay(el, {
76
+ inline: true,
77
+ hyperclay: false,
78
+ toolbar: ['bold', 'italic', 'link', 'undo', 'redo'],
79
+ })
80
+ } catch (err) {
81
+ console.warn('[hypercms] richclay activation failed; field stays plain contenteditable', err)
82
+ return
83
+ }
84
+ el.__hcmsRichclay = editor
85
+ // Toolbar commands mutate the DOM through squire, which doesn't always
86
+ // fire a native input event; mirror squire's own input signal so the
87
+ // form's delegated listener commits the change. Duplicate events are
88
+ // harmless — commits are fingerprinted.
89
+ const squire = editor && editor.squire
90
+ if (squire && typeof squire.addEventListener === 'function') {
91
+ squire.addEventListener('input', () => {
92
+ const Ev = (win && win.Event) || Event
93
+ el.dispatchEvent(new Ev('input', { bubbles: true }))
94
+ })
95
+ }
96
+ })
97
+ }
package/src/events.js CHANGED
@@ -3,6 +3,7 @@ import { applyWithRollback } from './apply-loop.js'
3
3
  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
+ import { autosizeTextarea, enhanceFields } from './enhance.js'
6
7
 
7
8
  const BOUND = new WeakSet()
8
9
 
@@ -55,7 +56,8 @@ export function bindEvents(ctx) {
55
56
  const target = e.target
56
57
  if (!target || !target.closest) return
57
58
  if (!target.closest('[data-hcms-form-root]')) return
58
- if (!target.matches('input, textarea, select')) return
59
+ if (!target.matches('input, textarea, select, [contenteditable][data-hcms-field]')) return
60
+ if (target.tagName === 'TEXTAREA') autosizeTextarea(target)
59
61
  // The upload picker fires `input` too, but its .value is the OS fake path
60
62
  // (C:\fakepath\…); it's handled on `change` via onUploadChange. The wrapper
61
63
  // carries data-hcms-field, so without this guard the closest() below would
@@ -366,6 +368,7 @@ export function onAdd(arrayPath, ctx) {
366
368
  itemKey: arrayEl.getAttribute('data-hcms-item-tpl') || null,
367
369
  })
368
370
  slot.appendChild(itemNode)
371
+ enhanceFields(itemNode, ctx.doc)
369
372
  updateArrayButtonsVisibility(arrayEl)
370
373
  return commitWithUndo(`Add ${arrayPath}`, () =>
371
374
  commit(extractFormData(ctx), { path: arrayPath, structural: true }, ctx)
package/src/form-rules.js CHANGED
@@ -21,6 +21,8 @@ export function fieldPropertyFor(el) {
21
21
  }
22
22
  if (tag === 'TEXTAREA' || tag === 'SELECT') return 'value'
23
23
  if (TAG_PROP_MAP[tag]) return TAG_PROP_MAP[tag]
24
+ // Rich-text surfaces (the @richtext component): the value IS the markup.
25
+ if (el.hasAttribute && el.hasAttribute('contenteditable')) return 'innerHTML'
24
26
  return null
25
27
  }
26
28
 
package/src/hypercms.js CHANGED
@@ -20,6 +20,8 @@ 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 { enhanceFields, upgradeRichTextRules } from './enhance.js'
24
+ import { maybeInjectToggle } from './toggle.js'
23
25
 
24
26
  export function installStyles(text) {
25
27
  setShellStyles(text)
@@ -100,7 +102,12 @@ export function open(opts = {}) {
100
102
  const what = typeof source === 'string' ? `data-rules-name~="${source}"` : 'the provided rules object'
101
103
  throw new Error(`hypercms: no rules found for ${what}`)
102
104
  }
103
- const pageRules = found.rules
105
+ // Rich-text upgrade (default on): bare scalar rules whose page element
106
+ // contains child elements are rebound through @innerHTML so links and
107
+ // inline formatting survive the round-trip, and their form fields render
108
+ // the @richtext component. Opt out with cms.open({ richText: false }).
109
+ const richText = opts.richText !== false
110
+ const pageRules = richText ? upgradeRichTextRules(found.rules, pageRoot) : found.rules
104
111
  const rulesTagNode = found.tagNode
105
112
 
106
113
  injectDefaults(doc)
@@ -130,6 +137,7 @@ export function open(opts = {}) {
130
137
  formRules,
131
138
  rulesTagNode,
132
139
  rulesSource: source,
140
+ richText,
133
141
  formRoot: shell.formRoot,
134
142
  shellRoot: shell.root,
135
143
  errorEl: shell.errorEl,
@@ -165,6 +173,7 @@ export function open(opts = {}) {
165
173
  try {
166
174
  const fragment = buildForm({ pageRules, formRules, data, doc })
167
175
  shell.formRoot.appendChild(fragment)
176
+ enhanceFields(shell.formRoot, doc)
168
177
 
169
178
  bindEvents(ctx)
170
179
  ctx.updateFingerprint()
@@ -455,6 +464,10 @@ function writeFieldValue(el, value, formRoot, path) {
455
464
  el.href = value == null ? '' : String(value)
456
465
  return
457
466
  }
467
+ if (el.hasAttribute && el.hasAttribute('contenteditable')) {
468
+ el.innerHTML = value == null ? '' : String(value)
469
+ return
470
+ }
458
471
  if ('value' in el) {
459
472
  el.value = value == null ? '' : String(value)
460
473
  return
@@ -566,6 +579,14 @@ function whenReady(fn) {
566
579
  // window, and the guards above no-op there).
567
580
  maybeAutoOpen()
568
581
 
582
+ // Floating edit-mode toggle at module load (same browser-only no-op in node).
583
+ maybeInjectToggle({
584
+ open,
585
+ close,
586
+ isOpen,
587
+ hasRules: (doc) => !!engine.findRules(doc, 'cms'),
588
+ })
589
+
569
590
  const cms = {
570
591
  open,
571
592
  close,
package/src/refresh.js CHANGED
@@ -4,6 +4,7 @@ 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 { enhanceFields, upgradeRichTextRules } from './enhance.js'
7
8
 
8
9
  const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
9
10
 
@@ -13,7 +14,10 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
13
14
  // livesync-replaced rules tag is picked up. Document-scoped via ctx.doc.
14
15
  const found = engine.findRules(ctx.doc, ctx.rulesSource || 'cms')
15
16
  if (found) {
16
- ctx.pageRules = found.rules
17
+ // Re-apply the rich-text upgrade open() performed: the tag holds the
18
+ // author's bare rules, and using them raw would rebind upgraded fields
19
+ // back to textContent mid-session.
20
+ ctx.pageRules = ctx.richText ? upgradeRichTextRules(found.rules, ctx.pageRoot) : found.rules
17
21
  ctx.rulesTagNode = found.tagNode
18
22
  }
19
23
  // Re-derive formRules so schema changes (rule shape, template overrides) flow through.
@@ -32,6 +36,9 @@ export function refreshForm(ctx, { ignoreActiveValue } = {}) {
32
36
  doc: ctx.doc,
33
37
  })
34
38
  morphForm(ctx.formRoot, fragment, { ignoreActiveValue })
39
+ // The morph can rebuild field nodes, dropping autosize heights and richclay
40
+ // instances (instances are per-element); re-enhance whatever is new.
41
+ enhanceFields(ctx.formRoot, ctx.doc)
35
42
  // morphForm rebuilds the form structure; inline error slots get wiped. Re-apply
36
43
  // the last error state so error messages survive across observer-driven refreshes
37
44
  // (e.g., the rollback after a failed add).
package/src/templates.js CHANGED
@@ -23,10 +23,13 @@ const SORTABLE_GRIP = `<div class="hcms-drag-handle mirk-sortable__grip" aria-hi
23
23
  const CLOSE_ICON = `<svg class="hcms-x" viewBox="0 0 16 16" shape-rendering="crispEdges" aria-hidden="true"><path d="M4 4 L12 12 M12 4 L4 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square"></path></svg>`
24
24
 
25
25
  const DEFAULT_TEMPLATES = {
26
+ // The default scalar is a one-row auto-growing textarea, not an <input>:
27
+ // page copy is prose and routinely wraps. Growth comes from field-sizing:
28
+ // content in the theme, with a scrollHeight fallback (enhance.js).
26
29
  '@scalar': `
27
30
  <label class="hcms-field" data-hcms-shape="scalar">
28
31
  <span class="hcms-label" data-hcms-label></span>
29
- <input class="mirk-input" data-hcms-field />
32
+ <textarea class="mirk-textarea" rows="1" data-hcms-field></textarea>
30
33
  <div class="hcms-error" hidden></div>
31
34
  </label>
32
35
  `,
@@ -172,6 +175,17 @@ const DEFAULT_TEMPLATES = {
172
175
  <div class="hcms-error" hidden></div>
173
176
  </label>
174
177
  `,
178
+ // Rich-text surface for @innerHTML-bound rules: a contenteditable div whose
179
+ // value interface is innerHTML (fieldPropertyFor), so links and inline
180
+ // formatting survive the round-trip. richclay mounts on it when the host
181
+ // page ships it (enhance.js); otherwise it stays a plain contenteditable.
182
+ '@richtext': `
183
+ <div class="hcms-field" data-hcms-shape="scalar">
184
+ <span class="hcms-label" data-hcms-label></span>
185
+ <div class="mirk-textarea hcms-richtext" contenteditable="true" data-hcms-field></div>
186
+ <div class="hcms-error" hidden></div>
187
+ </div>
188
+ `,
175
189
  '@number': `
176
190
  <label class="hcms-field" data-hcms-shape="scalar">
177
191
  <span class="hcms-label" data-hcms-label></span>
@@ -233,7 +247,7 @@ export function injectComponentTemplate(doc, key) {
233
247
  // common than uploading a file, so an a@href rule stays a plain URL field and
234
248
  // the file upload is opt-in. Suffix is split like the engine (lastIndexOf('@'),
235
249
  // see engine/extract.js). Returns a DEFAULT_TEMPLATES key.
236
- const PROP_TO_COMPONENT = { src: '@image', checked: '@checkbox' }
250
+ const PROP_TO_COMPONENT = { src: '@image', checked: '@checkbox', innerHTML: '@richtext' }
237
251
  // data-hcms-component values for scalar rules. "chips" is array-only (below).
238
252
  const SCALAR_COMPONENT_BY_NAME = {
239
253
  image: '@image',
@@ -244,6 +258,7 @@ const SCALAR_COMPONENT_BY_NAME = {
244
258
  radio: '@radio',
245
259
  textarea: '@textarea',
246
260
  number: '@number',
261
+ richtext: '@richtext',
247
262
  }
248
263
  // Every opt-in component injects on demand; only the 6 shape templates are
249
264
  // always present.
@@ -227,7 +227,7 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
227
227
  display: flex;
228
228
  flex-direction: column;
229
229
  gap: 26px;
230
- padding: 24px 24px 28px;
230
+ padding: 12px 24px 28px;
231
231
  }
232
232
 
233
233
  /* one labeled scalar field */
@@ -253,6 +253,31 @@ body.hcms-open.hcms-overlay { overflow: hidden; }
253
253
  padding: 9px 14px 10px;
254
254
  }
255
255
  .hcms-shell.pixel-quiet textarea.mirk-input { min-height: 76px; resize: vertical; }
256
+
257
+ /* default scalar fields: one-row textareas that grow with their content.
258
+ Browsers without field-sizing get a scrollHeight fallback (enhance.js). */
259
+ .hcms-shell.pixel-quiet .hcms-form textarea.mirk-textarea {
260
+ field-sizing: content;
261
+ resize: none;
262
+ overflow: hidden;
263
+ min-height: 0;
264
+ }
265
+
266
+ /* rich-text surface (@richtext): a contenteditable styled like a textarea */
267
+ .hcms-shell.pixel-quiet .hcms-richtext {
268
+ min-height: 2.5em;
269
+ cursor: text;
270
+ overflow-wrap: break-word;
271
+ }
272
+ .hcms-shell.pixel-quiet .hcms-richtext a {
273
+ color: inherit;
274
+ text-decoration: underline;
275
+ text-underline-offset: 2px;
276
+ }
277
+ .hcms-shell.pixel-quiet .hcms-richtext:empty::before {
278
+ content: attr(data-hcms-placeholder);
279
+ color: var(--mirk-placeholder-color);
280
+ }
256
281
  .hcms-shell.pixel-quiet .mirk-select__field {
257
282
  font-size: 15px;
258
283
  padding: 8px 40px 9px 14px;