@caretcms/core 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +28 -10
  2. package/dist/contracts.d.ts +1 -0
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +1 -0
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/index.d.ts +18 -7
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +109 -21
  9. package/dist/index.js.map +1 -1
  10. package/dist/loader.d.ts +17 -6
  11. package/dist/loader.d.ts.map +1 -1
  12. package/dist/loader.js +33 -14
  13. package/dist/loader.js.map +1 -1
  14. package/dist/markdown/block-context.d.ts +21 -0
  15. package/dist/markdown/block-context.d.ts.map +1 -0
  16. package/dist/markdown/block-context.js +27 -0
  17. package/dist/markdown/block-context.js.map +1 -0
  18. package/dist/markdown/canonical-body.d.ts +30 -0
  19. package/dist/markdown/canonical-body.d.ts.map +1 -0
  20. package/dist/markdown/canonical-body.js +54 -0
  21. package/dist/markdown/canonical-body.js.map +1 -0
  22. package/dist/markdown/contracts.d.ts +66 -0
  23. package/dist/markdown/contracts.d.ts.map +1 -0
  24. package/dist/markdown/contracts.js +102 -0
  25. package/dist/markdown/contracts.js.map +1 -0
  26. package/dist/markdown/html-to-snodes.d.ts +26 -0
  27. package/dist/markdown/html-to-snodes.d.ts.map +1 -0
  28. package/dist/markdown/html-to-snodes.js +118 -0
  29. package/dist/markdown/html-to-snodes.js.map +1 -0
  30. package/dist/markdown/remark.d.ts +35 -0
  31. package/dist/markdown/remark.d.ts.map +1 -0
  32. package/dist/markdown/remark.js +66 -0
  33. package/dist/markdown/remark.js.map +1 -0
  34. package/dist/markdown/satteri.d.ts +19 -0
  35. package/dist/markdown/satteri.d.ts.map +1 -0
  36. package/dist/markdown/satteri.js +120 -0
  37. package/dist/markdown/satteri.js.map +1 -0
  38. package/dist/markdown/serialize.d.ts +60 -0
  39. package/dist/markdown/serialize.d.ts.map +1 -0
  40. package/dist/markdown/serialize.js +264 -0
  41. package/dist/markdown/serialize.js.map +1 -0
  42. package/dist/markdown/splice.d.ts +32 -0
  43. package/dist/markdown/splice.d.ts.map +1 -0
  44. package/dist/markdown/splice.js +44 -0
  45. package/dist/markdown/splice.js.map +1 -0
  46. package/dist/markdown/stamp.d.ts +68 -0
  47. package/dist/markdown/stamp.d.ts.map +1 -0
  48. package/dist/markdown/stamp.js +131 -0
  49. package/dist/markdown/stamp.js.map +1 -0
  50. package/dist/runtime/auth/rate-limiter.d.ts.map +1 -1
  51. package/dist/runtime/auth/rate-limiter.js +5 -0
  52. package/dist/runtime/auth/rate-limiter.js.map +1 -1
  53. package/dist/runtime/auth/session.d.ts +2 -3
  54. package/dist/runtime/auth/session.d.ts.map +1 -1
  55. package/dist/runtime/auth/session.js +52 -21
  56. package/dist/runtime/auth/session.js.map +1 -1
  57. package/dist/runtime/content.d.ts.map +1 -1
  58. package/dist/runtime/content.js +4 -2
  59. package/dist/runtime/content.js.map +1 -1
  60. package/dist/runtime/middleware.d.ts.map +1 -1
  61. package/dist/runtime/middleware.js +21 -4
  62. package/dist/runtime/middleware.js.map +1 -1
  63. package/dist/runtime/mutations/contracts.d.ts +25 -1
  64. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  65. package/dist/runtime/mutations/contracts.js +67 -0
  66. package/dist/runtime/mutations/contracts.js.map +1 -1
  67. package/dist/runtime/mutations/engine.d.ts +6 -1
  68. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  69. package/dist/runtime/mutations/engine.js +151 -42
  70. package/dist/runtime/mutations/engine.js.map +1 -1
  71. package/dist/runtime/publish.d.ts +13 -1
  72. package/dist/runtime/publish.d.ts.map +1 -1
  73. package/dist/runtime/publish.js +75 -4
  74. package/dist/runtime/publish.js.map +1 -1
  75. package/dist/runtime/request-context.d.ts +9 -1
  76. package/dist/runtime/request-context.d.ts.map +1 -1
  77. package/dist/runtime/request-context.js.map +1 -1
  78. package/dist/runtime/rewrite.d.ts.map +1 -1
  79. package/dist/runtime/rewrite.js +109 -3
  80. package/dist/runtime/rewrite.js.map +1 -1
  81. package/dist/runtime/rich-allowlist.d.ts.map +1 -1
  82. package/dist/runtime/rich-allowlist.js +5 -1
  83. package/dist/runtime/rich-allowlist.js.map +1 -1
  84. package/dist/runtime/routes/admin.d.ts.map +1 -1
  85. package/dist/runtime/routes/admin.js +6 -2
  86. package/dist/runtime/routes/admin.js.map +1 -1
  87. package/dist/runtime/routes/entries.d.ts.map +1 -1
  88. package/dist/runtime/routes/entries.js +3 -2
  89. package/dist/runtime/routes/entries.js.map +1 -1
  90. package/dist/runtime/routes/history.d.ts.map +1 -1
  91. package/dist/runtime/routes/history.js +46 -21
  92. package/dist/runtime/routes/history.js.map +1 -1
  93. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  94. package/dist/runtime/routes/mutate.js +40 -6
  95. package/dist/runtime/routes/mutate.js.map +1 -1
  96. package/dist/runtime/routes/publish.d.ts.map +1 -1
  97. package/dist/runtime/routes/publish.js +5 -2
  98. package/dist/runtime/routes/publish.js.map +1 -1
  99. package/dist/runtime/routes/schema.d.ts.map +1 -1
  100. package/dist/runtime/routes/schema.js +4 -1
  101. package/dist/runtime/routes/schema.js.map +1 -1
  102. package/dist/runtime/routes/theme-stylesheet.d.ts.map +1 -1
  103. package/dist/runtime/routes/theme-stylesheet.js +11 -1
  104. package/dist/runtime/routes/theme-stylesheet.js.map +1 -1
  105. package/dist/runtime/storage/atomic-write.d.ts.map +1 -1
  106. package/dist/runtime/storage/atomic-write.js +8 -3
  107. package/dist/runtime/storage/atomic-write.js.map +1 -1
  108. package/dist/runtime/storage/markdown-adapter.d.ts +19 -0
  109. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
  110. package/dist/runtime/storage/markdown-adapter.js +41 -0
  111. package/dist/runtime/storage/markdown-adapter.js.map +1 -1
  112. package/dist/runtime/storage/session-overlay-adapter.d.ts +8 -0
  113. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  114. package/dist/runtime/storage/session-overlay-adapter.js +24 -2
  115. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  116. package/dist/runtime/utils.d.ts +10 -0
  117. package/dist/runtime/utils.d.ts.map +1 -1
  118. package/dist/runtime/utils.js +16 -0
  119. package/dist/runtime/utils.js.map +1 -1
  120. package/dist/types.d.ts +40 -0
  121. package/dist/types.d.ts.map +1 -1
  122. package/package.json +2 -2
  123. package/static/cms/admin-entry.js +36 -2
  124. package/static/cms/editor/guards.js +1 -1
  125. package/static/cms/editor/image-edit.js +41 -11
  126. package/static/cms/editor/image.css +7 -1
  127. package/static/cms/editor/md-block-edit.js +176 -0
  128. package/static/cms/editor/md-serialize.js +301 -0
  129. package/static/cms/editor/panel.css +69 -25
  130. package/static/cms/editor/panel.js +39 -0
  131. package/static/cms/editor/rich-toolbar.js +4 -3
  132. package/static/cms/editor/sanitize.js +2 -1
  133. package/static/cms/editor/sync.js +1 -1
  134. package/static/cms/editor/text-edit.js +68 -17
  135. package/static/cms/editor/text-link-interactions.js +4 -4
  136. package/static/cms/editor/toast.css +114 -2
  137. package/static/cms/editor/toast.js +121 -22
  138. package/static/cms/editor/tokens-and-text.css +64 -3
  139. package/static/cms/editor/toolbar.css +19 -6
  140. package/static/cms/editor/toolbar.js +64 -23
  141. package/static/cms/editor.js +13 -2
@@ -1,29 +1,128 @@
1
+ const SUCCESS_SVG =
2
+ '<svg class="cms-toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>';
3
+ const ERROR_SVG =
4
+ '<svg class="cms-toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
5
+ const WARNING_SVG =
6
+ '<svg class="cms-toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
7
+
8
+ function iconFor(type) {
9
+ if (type === 'success') return SUCCESS_SVG;
10
+ if (type === 'warning') return WARNING_SVG;
11
+ return ERROR_SVG;
12
+ }
13
+
14
+ /**
15
+ * Toast controller. Returns a `showToast(message, type)` function with a
16
+ * `.conflict()` method for the two-choice save-conflict prompt.
17
+ *
18
+ * Design notes:
19
+ * - Toasts stack (a container region) instead of one replacing the next, so a
20
+ * burst of saves or an error + a later success don't clobber each other.
21
+ * - Errors and conflict prompts are STICKY and dismissible — a 2.5s auto-hide
22
+ * would let a failure or a data-loss choice vanish before it's read.
23
+ * - The message is always set via textContent, never innerHTML, so a
24
+ * server-provided error string can't inject markup into the editor chrome.
25
+ * - The container is an aria-live region; errors/conflicts are role="alert".
26
+ */
1
27
  export function createToast() {
2
- let activeToast = null;
28
+ let container = null;
3
29
 
4
- return function showToast(message, type) {
5
- if (activeToast) {
6
- activeToast.remove();
7
- }
30
+ function ensureContainer() {
31
+ if (container && document.body.contains(container)) return container;
32
+ container = document.createElement('div');
33
+ container.className = 'cms-toast-stack';
34
+ container.setAttribute('aria-live', 'polite');
35
+ document.body.appendChild(container);
36
+ return container;
37
+ }
38
+
39
+ function dismiss(toast) {
40
+ if (toast._caretDismissed) return;
41
+ toast._caretDismissed = true;
42
+ toast.classList.add('cms-toast-out');
43
+ setTimeout(() => toast.remove(), 300);
44
+ }
45
+
46
+ function render(type, { message, sticky, actions, role }) {
47
+ const stack = ensureContainer();
8
48
 
9
49
  const toast = document.createElement('div');
10
50
  toast.className = `cms-toast cms-toast-${type}`;
51
+ if (role) toast.setAttribute('role', role);
52
+ if (sticky || (actions && actions.length)) toast.classList.add('cms-toast-interactive');
53
+
54
+ const iconWrap = document.createElement('span');
55
+ iconWrap.className = 'cms-toast-icon-wrap';
56
+ iconWrap.setAttribute('aria-hidden', 'true');
57
+ iconWrap.innerHTML = iconFor(type); // trusted constant SVG only
58
+ toast.appendChild(iconWrap);
59
+
60
+ const msg = document.createElement('span');
61
+ msg.className = 'cms-toast-msg';
62
+ msg.textContent = message; // never innerHTML — message may carry server text
63
+ toast.appendChild(msg);
64
+
65
+ if (actions && actions.length) {
66
+ const group = document.createElement('div');
67
+ group.className = 'cms-toast-actions';
68
+ for (const action of actions) {
69
+ const btn = document.createElement('button');
70
+ btn.type = 'button';
71
+ btn.className = `cms-toast-btn${action.primary ? ' cms-toast-btn-primary' : ''}`;
72
+ btn.textContent = action.label;
73
+ btn.addEventListener('click', () => {
74
+ dismiss(toast);
75
+ try {
76
+ action.onClick();
77
+ } catch (e) {
78
+ /* action errors shouldn't break the toast */
79
+ }
80
+ });
81
+ group.appendChild(btn);
82
+ }
83
+ toast.appendChild(group);
84
+ } else if (sticky) {
85
+ const close = document.createElement('button');
86
+ close.type = 'button';
87
+ close.className = 'cms-toast-close';
88
+ close.setAttribute('aria-label', 'Dismiss');
89
+ close.textContent = '×';
90
+ close.addEventListener('click', () => dismiss(toast));
91
+ toast.appendChild(close);
92
+ }
93
+
94
+ stack.appendChild(toast);
95
+ if (!sticky) setTimeout(() => dismiss(toast), 2500);
96
+ return toast;
97
+ }
98
+
99
+ function showToast(message, type) {
100
+ const t = type === 'error' ? 'error' : type || 'success';
101
+ return render(t, {
102
+ message,
103
+ sticky: t === 'error',
104
+ role: t === 'error' ? 'alert' : undefined,
105
+ });
106
+ }
107
+
108
+ // Two-choice save-conflict prompt. Sticky so the user's edit is never silently
109
+ // discarded — they explicitly choose to keep theirs or load the latest.
110
+ showToast.conflict = ({
111
+ message,
112
+ onKeepMine,
113
+ onLoadLatest,
114
+ keepLabel = 'Keep mine',
115
+ latestLabel = 'Load latest',
116
+ }) =>
117
+ render('warning', {
118
+ message,
119
+ sticky: true,
120
+ role: 'alert',
121
+ actions: [
122
+ { label: keepLabel, primary: true, onClick: onKeepMine },
123
+ { label: latestLabel, onClick: onLoadLatest },
124
+ ],
125
+ });
11
126
 
12
- const iconSvg =
13
- type === 'success'
14
- ? '<svg class="cms-toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>'
15
- : '<svg class="cms-toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
16
-
17
- toast.innerHTML = iconSvg + `<span>${message}</span>`;
18
- document.body.appendChild(toast);
19
- activeToast = toast;
20
-
21
- setTimeout(() => {
22
- toast.classList.add('cms-toast-out');
23
- setTimeout(() => {
24
- toast.remove();
25
- if (activeToast === toast) activeToast = null;
26
- }, 300);
27
- }, 2500);
28
- };
127
+ return showToast;
29
128
  }
@@ -16,14 +16,43 @@
16
16
  --cms-green: #22c55e;
17
17
  --cms-red: #ef4444;
18
18
  --cms-yellow: #eab308;
19
+ --cms-amber: #eab308;
19
20
  --cms-font: Inter, system-ui, -apple-system, 'Segoe UI', sans-serif;
20
21
  }
21
22
 
23
+ /* ---------------------------------------------------------------------------
24
+ Host-style insulation
25
+
26
+ The editor chrome is injected as direct <body> children on the HOST site, so
27
+ an aggressive host reset (`* {}`, `body {}`, tag selectors) can bleed into it
28
+ — distorting fonts, line-height, letter-spacing, or box model. Pin the chrome
29
+ roots to sane defaults so they render consistently regardless of the host's
30
+ CSS. (Individual controls set their own type where needed.)
31
+ --------------------------------------------------------------------------- */
32
+ .cms-toolbar,
33
+ .cms-toast-stack,
34
+ .cms-toast,
35
+ .cms-link-popover,
36
+ .cms-content-map,
37
+ .cms-link-follow,
38
+ .cms-rich-toolbar,
39
+ .cms-studio-panel {
40
+ box-sizing: border-box;
41
+ font-family: var(--cms-font);
42
+ line-height: normal;
43
+ letter-spacing: normal;
44
+ text-transform: none;
45
+ text-align: left;
46
+ }
47
+
22
48
  /* ---------------------------------------------------------------------------
23
49
  Text editing affordances — simple blue outline
24
50
  --------------------------------------------------------------------------- */
25
51
 
26
- [data-caret].cms-editable {
52
+ /* Both field bindings (data-caret) and markdown body blocks (data-caret-md)
53
+ carry .cms-editable, so the hover/focus affordances apply to both. */
54
+ [data-caret].cms-editable,
55
+ [data-caret-md].cms-editable {
27
56
  cursor: text;
28
57
  transition: outline 0.2s, box-shadow 0.2s, background 0.4s;
29
58
  outline: 2px dashed transparent;
@@ -31,16 +60,48 @@
31
60
  border-radius: 2px;
32
61
  }
33
62
 
34
- [data-caret].cms-editable:hover {
63
+ [data-caret].cms-editable:hover,
64
+ [data-caret-md].cms-editable:hover {
35
65
  outline-color: var(--cms-blue-hover);
36
66
  }
37
67
 
38
- [data-caret].cms-editable:focus {
68
+ [data-caret].cms-editable:focus,
69
+ [data-caret-md].cms-editable:focus {
39
70
  outline: 2px solid var(--cms-blue);
40
71
  outline-offset: 4px;
41
72
  box-shadow: 0 0 0 4px var(--cms-blue-dim);
42
73
  }
43
74
 
75
+ /* ---------------------------------------------------------------------------
76
+ Unpublished body draft
77
+ The server rewrite tags blocks whose prose comes from an unpublished draft
78
+ (editor preview only — the public reads the base and never receives this).
79
+ Flag them so a reloaded preview is visibly distinct from live, published
80
+ prose and the editor knows a Publish is still pending.
81
+ --------------------------------------------------------------------------- */
82
+
83
+ [data-caret-md-draft] {
84
+ position: relative;
85
+ background: var(--cms-blue-dim);
86
+ box-shadow: -0.6em 0 0 0 var(--cms-blue-dim);
87
+ border-radius: 2px;
88
+ }
89
+
90
+ [data-caret-md-draft]::after {
91
+ content: 'Draft';
92
+ position: absolute;
93
+ top: -0.6em;
94
+ right: 0;
95
+ font: 600 10px / 1 var(--cms-font);
96
+ letter-spacing: 0.08em;
97
+ text-transform: uppercase;
98
+ color: #fff;
99
+ background: var(--cms-yellow);
100
+ padding: 3px 6px;
101
+ border-radius: 3px;
102
+ pointer-events: none;
103
+ }
104
+
44
105
  /* ---------------------------------------------------------------------------
45
106
  Link/button click prevention in editor mode
46
107
  --------------------------------------------------------------------------- */
@@ -133,7 +133,7 @@
133
133
  letter-spacing: 0.08em;
134
134
  text-transform: uppercase;
135
135
  background: transparent;
136
- color: rgba(255, 255, 255, 0.5);
136
+ color: rgba(255, 255, 255, 0.72);
137
137
  border: 1px solid rgba(255, 255, 255, 0.1);
138
138
  border-radius: 2px;
139
139
  cursor: pointer;
@@ -189,7 +189,7 @@
189
189
  }
190
190
 
191
191
  .cms-status-text {
192
- color: rgba(255, 255, 255, 0.5);
192
+ color: rgba(255, 255, 255, 0.72);
193
193
  font-size: 11px;
194
194
  font-weight: 400;
195
195
  }
@@ -220,6 +220,17 @@
220
220
  display: none;
221
221
  }
222
222
 
223
+ /* Draft controls wrapper is transparent to the toolbar's flex layout so its
224
+ buttons keep the row's gap; [hidden] (server delivery, no pending draft)
225
+ still collapses the whole group. */
226
+ .cms-draft-controls {
227
+ display: contents;
228
+ }
229
+
230
+ .cms-draft-controls[hidden] {
231
+ display: none;
232
+ }
233
+
223
234
  /* Draft preview active — amber, signalling "unpublished" */
224
235
  .cms-toolbar button.cms-preview-btn-active {
225
236
  background: rgba(234, 179, 8, 0.12);
@@ -227,7 +238,7 @@
227
238
  color: var(--cms-yellow);
228
239
  }
229
240
 
230
- /* Go live — primary action on static delivery */
241
+ /* Publish — primary action on static delivery */
231
242
  .cms-toolbar button.cms-go-live-btn {
232
243
  background: rgba(34, 197, 94, 0.12);
233
244
  border-color: rgba(34, 197, 94, 0.3);
@@ -240,7 +251,7 @@
240
251
  color: var(--cms-green);
241
252
  }
242
253
 
243
- /* Publish — green (server delivery when preview is manually enabled) */
254
+ /* Publish — green */
244
255
  .cms-toolbar button.cms-publish-btn {
245
256
  background: rgba(34, 197, 94, 0.12);
246
257
  border-color: rgba(34, 197, 94, 0.3);
@@ -260,8 +271,9 @@
260
271
  color: var(--cms-red);
261
272
  }
262
273
 
263
- /* Highlight all mode — show every editable region */
264
- .cms-highlight-all [data-caret].cms-editable {
274
+ /* Highlight all mode — show every editable region (fields AND body blocks) */
275
+ .cms-highlight-all [data-caret].cms-editable,
276
+ .cms-highlight-all [data-caret-md].cms-editable {
265
277
  outline-color: var(--cms-blue-hover);
266
278
  background: rgba(59, 130, 246, 0.04);
267
279
  }
@@ -275,6 +287,7 @@
275
287
  transition — motion is suppressed, the information is not. */
276
288
  @media (prefers-reduced-motion: reduce) {
277
289
  .cms-highlight-all [data-caret].cms-editable,
290
+ .cms-highlight-all [data-caret-md].cms-editable,
278
291
  .cms-highlight-all img[data-caret] {
279
292
  transition: none;
280
293
  }
@@ -50,18 +50,26 @@ function getToolbarNavLinks(pagePath) {
50
50
  }
51
51
 
52
52
  function renderDraftControls(staticDelivery) {
53
- if (!staticDelivery) return '';
54
-
53
+ // Static delivery is always drafting, so the controls stay visible. Server
54
+ // delivery saves field edits straight to the site, but inline body-block
55
+ // edits are ALWAYS drafts (they splice into source files only at publish), so
56
+ // the controls render here too — hidden until at least one draft is pending
57
+ // (toggled by refreshDraftControls) so the toolbar stays quiet otherwise.
58
+ const publishTitle = staticDelivery
59
+ ? 'Publish drafts; static visitors update after rebuild and deploy'
60
+ : 'Publish your body-block drafts into the source files';
55
61
  return `
56
- <button class="cms-publish-btn cms-go-live-btn" type="button" title="Push your draft changes to the live site">
57
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><polyline points="5 12 12 5 19 12"/></svg>
58
- <span class="cms-go-live-label">Go live</span>
59
- </button>
60
- <button class="cms-discard-btn" type="button" title="Discard your draft changes">
61
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
62
- Discard
63
- </button>
64
- <div class="cms-toolbar-divider"></div>`;
62
+ <span class="cms-draft-controls"${staticDelivery ? '' : ' hidden'}>
63
+ <button class="cms-publish-btn cms-go-live-btn" type="button" title="${publishTitle}">
64
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><polyline points="5 12 12 5 19 12"/></svg>
65
+ <span class="cms-go-live-label">Publish</span>
66
+ </button>
67
+ <button class="cms-discard-btn" type="button" title="Discard your draft changes">
68
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
69
+ Discard
70
+ </button>
71
+ <div class="cms-toolbar-divider"></div>
72
+ </span>`;
65
73
  }
66
74
 
67
75
  function renderToolbar(navLinks, staticDelivery) {
@@ -74,7 +82,7 @@ function renderToolbar(navLinks, staticDelivery) {
74
82
 
75
83
  const badgeLabel = staticDelivery ? 'Draft' : 'Live';
76
84
  const badgeHint = staticDelivery
77
- ? 'Not on your site until you go live'
85
+ ? 'Draft preview; visitors update after publish, rebuild, and deploy'
78
86
  : 'Changes save directly to your site';
79
87
 
80
88
  const toolbar = document.createElement('div');
@@ -88,8 +96,8 @@ function renderToolbar(navLinks, staticDelivery) {
88
96
  <span class="cms-toolbar-badge-text">${badgeLabel}</span>
89
97
  </div>
90
98
  <div class="cms-toolbar-divider"></div>
91
- <div class="cms-status-group">
92
- <span class="cms-status-dot cms-status-idle"></span>
99
+ <div class="cms-status-group" role="status" aria-live="polite">
100
+ <span class="cms-status-dot cms-status-idle" aria-hidden="true"></span>
93
101
  <span class="cms-status-text">Ready</span>
94
102
  </div>
95
103
  </div>
@@ -168,16 +176,30 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
168
176
  }
169
177
 
170
178
  async function publishDrafts() {
171
- setStatus('saving', 'Going live…');
179
+ setStatus('saving', 'Publishing…');
172
180
  try {
173
181
  const res = await draftRequest('POST', '/publish');
174
182
  if (!res.ok) throw new Error('publish failed');
175
183
  const data = await res.json();
176
184
  const n = Array.isArray(data.published) ? data.published.length : 0;
185
+ const conflicts = Array.isArray(data.conflicts) ? data.conflicts.length : 0;
186
+ if (conflicts > 0) {
187
+ // Stale body drafts: preserved server-side, NOT published. Reload so
188
+ // fresh stamps arrive and the editor can re-apply them.
189
+ setStatus('error', 'Some drafts conflicted');
190
+ showToast(
191
+ n > 0
192
+ ? `Published ${n} change(s); ${conflicts} draft(s) conflicted with newer content — reloading so you can re-apply.`
193
+ : `${conflicts} draft(s) conflicted with newer content — reloading so you can re-apply.`,
194
+ 'error',
195
+ );
196
+ window.location.reload();
197
+ return n > 0;
198
+ }
177
199
  if (data.rebuild?.triggered && data.rebuild.ok === false) {
178
- setStatus('error', 'Live, but deploy failed');
200
+ setStatus('error', 'Published, deploy failed');
179
201
  showToast(
180
- 'Changes are saved, but the deploy webhook failed. Check your CI settings.',
202
+ 'Changes are published, but the deploy webhook failed. Check your CI settings.',
181
203
  'error',
182
204
  );
183
205
  return false;
@@ -185,18 +207,23 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
185
207
  if (data.rebuild?.triggered) {
186
208
  showToast(
187
209
  n > 0
188
- ? `Went live with ${n} change(s) — your site is rebuilding`
210
+ ? `Published ${n} change(s) — rebuild started`
189
211
  : 'Your site is rebuilding',
190
212
  'success',
191
213
  );
192
214
  } else {
193
- showToast(n > 0 ? `Went live with ${n} change(s)` : 'Already up to date', 'success');
215
+ showToast(
216
+ n > 0
217
+ ? `Published ${n} change(s) — rebuild and deploy to update visitors`
218
+ : 'Already up to date',
219
+ 'success',
220
+ );
194
221
  }
195
222
  window.location.reload();
196
223
  return true;
197
224
  } catch {
198
- setStatus('error', 'Go live failed');
199
- showToast('Go live failed', 'error');
225
+ setStatus('error', 'Publish failed');
226
+ showToast('Publish failed', 'error');
200
227
  return false;
201
228
  }
202
229
  }
@@ -206,7 +233,7 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
206
233
  const count = await fetchDraftCount();
207
234
  if (count > 0) {
208
235
  const goLive = window.confirm(
209
- `You have ${count} unpublished change${count === 1 ? '' : 's'}. Go live before signing out?`,
236
+ `You have ${count} unpublished change${count === 1 ? '' : 's'}. Publish before signing out?`,
210
237
  );
211
238
  if (goLive) {
212
239
  await publishDrafts();
@@ -235,7 +262,7 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
235
262
  if (
236
263
  !window.confirm(
237
264
  staticDelivery
238
- ? 'Push all your draft changes to the live site?'
265
+ ? 'Publish all draft changes? Static visitors update after rebuild and deploy.'
239
266
  : 'Publish all your draft changes to the live site?',
240
267
  )
241
268
  ) {
@@ -256,6 +283,20 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
256
283
  }
257
284
  });
258
285
 
286
+ // Server delivery hides the draft controls until a body draft exists. Field
287
+ // edits save directly (no draft), so the count reflects pending body blocks.
288
+ // Refresh on mount and whenever a body block is drafted; publish/discard
289
+ // reload the page, so mount re-evaluates from scratch.
290
+ const draftControls = toolbar.querySelector('.cms-draft-controls');
291
+ async function refreshDraftControls() {
292
+ if (staticDelivery || !draftControls) return;
293
+ draftControls.hidden = (await fetchDraftCount()) === 0;
294
+ }
295
+ if (!staticDelivery) {
296
+ refreshDraftControls();
297
+ window.addEventListener('cms:draftSaved', refreshDraftControls);
298
+ }
299
+
259
300
  const studioButton = toolbar.querySelector('.cms-studio-btn');
260
301
  const mapButton = toolbar.querySelector('.cms-map-btn');
261
302
  return {
@@ -24,6 +24,7 @@ import { mountImageEditors } from './editor/image-edit.js';
24
24
  import { mountSectionControls } from './editor/section-controls.js';
25
25
  import { mountContentMap } from './editor/content-map.js';
26
26
  import { mountRichToolbar } from './editor/rich-toolbar.js';
27
+ import { mountMdBlockEditors } from './editor/md-block-edit.js';
27
28
  import { mountLinkFollowAffordances } from './editor/link-follow.js';
28
29
  import { revealAndFade } from './editor/highlight.js';
29
30
  import { hydrateStega } from './editor/stega-hydrate.js';
@@ -36,7 +37,10 @@ function boot() {
36
37
  if (normalizePreviewForDelivery()) return;
37
38
 
38
39
  const state = {
39
- dirtyEl: null,
40
+ // Every element with unsaved edits. A Set (not a single element) so a save
41
+ // completing on field A can't clear the dirty flag for field B the user has
42
+ // since moved to — that would let the beforeunload guard miss B's edits.
43
+ dirtyEls: new Set(),
40
44
  linkPopupEl: null,
41
45
  };
42
46
 
@@ -52,7 +56,7 @@ function boot() {
52
56
  const { studioButton, mapButton, setStatus } = mountToolbar({
53
57
  showToast,
54
58
  clearDirty: () => {
55
- state.dirtyEl = null;
59
+ state.dirtyEls.clear();
56
60
  },
57
61
  onLogout: async () => {
58
62
  await fetch(buildCmsUrl('/auth/logout'), { method: 'POST' });
@@ -94,6 +98,13 @@ function boot() {
94
98
  richToolbarMounted = true;
95
99
  }
96
100
 
101
+ mountMdBlockEditors({
102
+ state,
103
+ flash,
104
+ showToast,
105
+ onUnauthorized: redirectToEditorLogin,
106
+ });
107
+
97
108
  mountImageEditors({
98
109
  parseCaretAttr,
99
110
  flash,