@caretcms/core 0.2.0 → 0.4.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 (256) hide show
  1. package/README.md +195 -12
  2. package/dist/index.d.ts +26 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +113 -12
  5. package/dist/index.js.map +1 -1
  6. package/dist/loader.d.ts.map +1 -1
  7. package/dist/loader.js +35 -3
  8. package/dist/loader.js.map +1 -1
  9. package/dist/markdown/paragraphs.d.ts +14 -0
  10. package/dist/markdown/paragraphs.d.ts.map +1 -0
  11. package/dist/markdown/paragraphs.js +61 -0
  12. package/dist/markdown/paragraphs.js.map +1 -0
  13. package/dist/markdown/remark.d.ts.map +1 -1
  14. package/dist/markdown/remark.js +1 -0
  15. package/dist/markdown/remark.js.map +1 -1
  16. package/dist/markdown/satteri.d.ts.map +1 -1
  17. package/dist/markdown/satteri.js +35 -9
  18. package/dist/markdown/satteri.js.map +1 -1
  19. package/dist/markdown/stamp.d.ts +0 -9
  20. package/dist/markdown/stamp.d.ts.map +1 -1
  21. package/dist/markdown/stamp.js +6 -0
  22. package/dist/markdown/stamp.js.map +1 -1
  23. package/dist/providers/deployment/github.d.ts +27 -0
  24. package/dist/providers/deployment/github.d.ts.map +1 -0
  25. package/dist/providers/deployment/github.js +180 -0
  26. package/dist/providers/deployment/github.js.map +1 -0
  27. package/dist/providers/deployment/simulated.d.ts +10 -0
  28. package/dist/providers/deployment/simulated.d.ts.map +1 -0
  29. package/dist/providers/deployment/simulated.js +29 -0
  30. package/dist/providers/deployment/simulated.js.map +1 -0
  31. package/dist/runtime/auth/session.d.ts +1 -0
  32. package/dist/runtime/auth/session.d.ts.map +1 -1
  33. package/dist/runtime/auth/session.js +16 -0
  34. package/dist/runtime/auth/session.js.map +1 -1
  35. package/dist/runtime/authorization.d.ts +11 -0
  36. package/dist/runtime/authorization.d.ts.map +1 -0
  37. package/dist/runtime/authorization.js +29 -0
  38. package/dist/runtime/authorization.js.map +1 -0
  39. package/dist/runtime/collection-policy.d.ts +11 -0
  40. package/dist/runtime/collection-policy.d.ts.map +1 -0
  41. package/dist/runtime/collection-policy.js +44 -0
  42. package/dist/runtime/collection-policy.js.map +1 -0
  43. package/dist/runtime/config.d.ts +3 -0
  44. package/dist/runtime/config.d.ts.map +1 -1
  45. package/dist/runtime/config.js +4 -1
  46. package/dist/runtime/config.js.map +1 -1
  47. package/dist/runtime/content-errors.d.ts +9 -0
  48. package/dist/runtime/content-errors.d.ts.map +1 -0
  49. package/dist/runtime/content-errors.js +17 -0
  50. package/dist/runtime/content-errors.js.map +1 -0
  51. package/dist/runtime/content.d.ts.map +1 -1
  52. package/dist/runtime/content.js +13 -4
  53. package/dist/runtime/content.js.map +1 -1
  54. package/dist/runtime/deployment-status.d.ts +14 -0
  55. package/dist/runtime/deployment-status.d.ts.map +1 -0
  56. package/dist/runtime/deployment-status.js +57 -0
  57. package/dist/runtime/deployment-status.js.map +1 -0
  58. package/dist/runtime/draft-state.d.ts +16 -0
  59. package/dist/runtime/draft-state.d.ts.map +1 -0
  60. package/dist/runtime/draft-state.js +29 -0
  61. package/dist/runtime/draft-state.js.map +1 -0
  62. package/dist/runtime/i18n.d.ts +97 -0
  63. package/dist/runtime/i18n.d.ts.map +1 -0
  64. package/dist/runtime/i18n.js +190 -0
  65. package/dist/runtime/i18n.js.map +1 -0
  66. package/dist/runtime/index.d.ts +1 -0
  67. package/dist/runtime/index.d.ts.map +1 -1
  68. package/dist/runtime/index.js +1 -0
  69. package/dist/runtime/index.js.map +1 -1
  70. package/dist/runtime/middleware.d.ts.map +1 -1
  71. package/dist/runtime/middleware.js +61 -4
  72. package/dist/runtime/middleware.js.map +1 -1
  73. package/dist/runtime/mutations/contracts.d.ts +12 -0
  74. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  75. package/dist/runtime/mutations/contracts.js +54 -4
  76. package/dist/runtime/mutations/contracts.js.map +1 -1
  77. package/dist/runtime/mutations/engine.d.ts +2 -0
  78. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  79. package/dist/runtime/mutations/engine.js +220 -57
  80. package/dist/runtime/mutations/engine.js.map +1 -1
  81. package/dist/runtime/providers.d.ts +4 -1
  82. package/dist/runtime/providers.d.ts.map +1 -1
  83. package/dist/runtime/providers.js +18 -1
  84. package/dist/runtime/providers.js.map +1 -1
  85. package/dist/runtime/publish-recovery.d.ts +32 -0
  86. package/dist/runtime/publish-recovery.d.ts.map +1 -0
  87. package/dist/runtime/publish-recovery.js +98 -0
  88. package/dist/runtime/publish-recovery.js.map +1 -0
  89. package/dist/runtime/publish.d.ts +10 -4
  90. package/dist/runtime/publish.d.ts.map +1 -1
  91. package/dist/runtime/publish.js +105 -63
  92. package/dist/runtime/publish.js.map +1 -1
  93. package/dist/runtime/rebuild-webhook.d.ts +4 -0
  94. package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
  95. package/dist/runtime/rebuild-webhook.js +12 -2
  96. package/dist/runtime/rebuild-webhook.js.map +1 -1
  97. package/dist/runtime/request-context.d.ts +7 -1
  98. package/dist/runtime/request-context.d.ts.map +1 -1
  99. package/dist/runtime/request-context.js +7 -1
  100. package/dist/runtime/request-context.js.map +1 -1
  101. package/dist/runtime/rewrite.d.ts.map +1 -1
  102. package/dist/runtime/rewrite.js +28 -2
  103. package/dist/runtime/rewrite.js.map +1 -1
  104. package/dist/runtime/rich-allowlist.js +1 -1
  105. package/dist/runtime/rich-allowlist.js.map +1 -1
  106. package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
  107. package/dist/runtime/routes/admin-entry.js +129 -21
  108. package/dist/runtime/routes/admin-entry.js.map +1 -1
  109. package/dist/runtime/routes/admin.d.ts.map +1 -1
  110. package/dist/runtime/routes/admin.js +28 -14
  111. package/dist/runtime/routes/admin.js.map +1 -1
  112. package/dist/runtime/routes/auth-login.d.ts.map +1 -1
  113. package/dist/runtime/routes/auth-login.js +4 -0
  114. package/dist/runtime/routes/auth-login.js.map +1 -1
  115. package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
  116. package/dist/runtime/routes/auth-logout.js +7 -2
  117. package/dist/runtime/routes/auth-logout.js.map +1 -1
  118. package/dist/runtime/routes/auth-session.d.ts.map +1 -1
  119. package/dist/runtime/routes/auth-session.js +10 -2
  120. package/dist/runtime/routes/auth-session.js.map +1 -1
  121. package/dist/runtime/routes/collection-list.d.ts.map +1 -1
  122. package/dist/runtime/routes/collection-list.js +213 -66
  123. package/dist/runtime/routes/collection-list.js.map +1 -1
  124. package/dist/runtime/routes/deployment.d.ts +5 -0
  125. package/dist/runtime/routes/deployment.d.ts.map +1 -0
  126. package/dist/runtime/routes/deployment.js +38 -0
  127. package/dist/runtime/routes/deployment.js.map +1 -0
  128. package/dist/runtime/routes/draft.d.ts.map +1 -1
  129. package/dist/runtime/routes/draft.js +10 -3
  130. package/dist/runtime/routes/draft.js.map +1 -1
  131. package/dist/runtime/routes/entries.d.ts.map +1 -1
  132. package/dist/runtime/routes/entries.js +87 -43
  133. package/dist/runtime/routes/entries.js.map +1 -1
  134. package/dist/runtime/routes/history.d.ts.map +1 -1
  135. package/dist/runtime/routes/history.js +40 -21
  136. package/dist/runtime/routes/history.js.map +1 -1
  137. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  138. package/dist/runtime/routes/mutate.js +4 -1
  139. package/dist/runtime/routes/mutate.js.map +1 -1
  140. package/dist/runtime/routes/publish.d.ts.map +1 -1
  141. package/dist/runtime/routes/publish.js +127 -33
  142. package/dist/runtime/routes/publish.js.map +1 -1
  143. package/dist/runtime/routes/schema.d.ts.map +1 -1
  144. package/dist/runtime/routes/schema.js +11 -2
  145. package/dist/runtime/routes/schema.js.map +1 -1
  146. package/dist/runtime/routes/studio-home.d.ts.map +1 -1
  147. package/dist/runtime/routes/studio-home.js +41 -20
  148. package/dist/runtime/routes/studio-home.js.map +1 -1
  149. package/dist/runtime/routes/upload.d.ts.map +1 -1
  150. package/dist/runtime/routes/upload.js +3 -0
  151. package/dist/runtime/routes/upload.js.map +1 -1
  152. package/dist/runtime/schema-registry.d.ts +8 -1
  153. package/dist/runtime/schema-registry.d.ts.map +1 -1
  154. package/dist/runtime/schema-registry.js +49 -3
  155. package/dist/runtime/schema-registry.js.map +1 -1
  156. package/dist/runtime/storage/entry-commit.d.ts +8 -0
  157. package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
  158. package/dist/runtime/storage/entry-commit.js +45 -0
  159. package/dist/runtime/storage/entry-commit.js.map +1 -0
  160. package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
  161. package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
  162. package/dist/runtime/storage/filesystem-adapter.js +12 -3
  163. package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
  164. package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
  165. package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
  166. package/dist/runtime/storage/frontmatter-codec.js +156 -13
  167. package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
  168. package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
  169. package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
  170. package/dist/runtime/storage/in-memory-adapter.js +6 -0
  171. package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
  172. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
  173. package/dist/runtime/storage/markdown-adapter.js +18 -20
  174. package/dist/runtime/storage/markdown-adapter.js.map +1 -1
  175. package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
  176. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  177. package/dist/runtime/storage/session-overlay-adapter.js +100 -13
  178. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  179. package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
  180. package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
  181. package/dist/runtime/storage/sidecar-meta-store.js +48 -5
  182. package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
  183. package/dist/runtime/themes/preset-tokens.js +4 -4
  184. package/dist/runtime/themes/preset-tokens.js.map +1 -1
  185. package/dist/runtime/utils.d.ts.map +1 -1
  186. package/dist/runtime/utils.js +3 -4
  187. package/dist/runtime/utils.js.map +1 -1
  188. package/dist/runtime/views/studio-layout.d.ts +3 -0
  189. package/dist/runtime/views/studio-layout.d.ts.map +1 -1
  190. package/dist/runtime/views/studio-layout.js +36 -13
  191. package/dist/runtime/views/studio-layout.js.map +1 -1
  192. package/dist/schema-utils.d.ts +7 -0
  193. package/dist/schema-utils.d.ts.map +1 -1
  194. package/dist/schema-utils.js +143 -0
  195. package/dist/schema-utils.js.map +1 -1
  196. package/dist/types.d.ts +165 -1
  197. package/dist/types.d.ts.map +1 -1
  198. package/package.json +12 -3
  199. package/static/cms/admin-entry.css +144 -13
  200. package/static/cms/admin-entry.js +539 -586
  201. package/static/cms/dev-toolbar/app.js +78 -19
  202. package/static/cms/editor/config.js +35 -15
  203. package/static/cms/editor/content-map.css +8 -1
  204. package/static/cms/editor/content-map.js +62 -23
  205. package/static/cms/editor/deployment-status.js +66 -0
  206. package/static/cms/editor/draft-client.js +87 -0
  207. package/static/cms/editor/guards.js +20 -0
  208. package/static/cms/editor/helpers.js +30 -1
  209. package/static/cms/editor/highlight.js +1 -0
  210. package/static/cms/editor/image-edit.js +44 -1
  211. package/static/cms/editor/image-utils.js +23 -7
  212. package/static/cms/editor/link-follow.js +122 -41
  213. package/static/cms/editor/link-ui.css +10 -1
  214. package/static/cms/editor/linkify.js +2 -0
  215. package/static/cms/editor/md-block-edit.js +140 -37
  216. package/static/cms/editor/md-paragraphs.js +98 -0
  217. package/static/cms/editor/md-serialize.js +1 -1
  218. package/static/cms/editor/panel.js +53 -7
  219. package/static/cms/editor/preview-mode.js +44 -0
  220. package/static/cms/editor/publish-result.js +100 -0
  221. package/static/cms/editor/rich-toolbar.js +58 -15
  222. package/static/cms/editor/sanitize.js +6 -2
  223. package/static/cms/editor/save-queue.js +75 -17
  224. package/static/cms/editor/section-controls/activation.js +3 -0
  225. package/static/cms/editor/section-controls/api.js +25 -7
  226. package/static/cms/editor/section-controls/index.js +60 -8
  227. package/static/cms/editor/section-controls/model.js +39 -4
  228. package/static/cms/editor/section-controls/mutations.js +15 -0
  229. package/static/cms/editor/section-controls/page-context.js +9 -0
  230. package/static/cms/editor/section-controls/pickers.js +53 -2
  231. package/static/cms/editor/section-controls/reorder.js +23 -0
  232. package/static/cms/editor/section-controls/selection.js +6 -0
  233. package/static/cms/editor/section-controls/spacing-drag.js +16 -0
  234. package/static/cms/editor/section-controls/ui-elements.js +7 -0
  235. package/static/cms/editor/section-controls/utils.js +7 -0
  236. package/static/cms/editor/section-controls.css +18 -3
  237. package/static/cms/editor/security.js +13 -2
  238. package/static/cms/editor/stega-hydrate.js +17 -4
  239. package/static/cms/editor/sync.js +475 -56
  240. package/static/cms/editor/text-edit.js +37 -3
  241. package/static/cms/editor/text-link-interactions.js +59 -17
  242. package/static/cms/editor/toast.js +23 -9
  243. package/static/cms/editor/tokens-and-text.css +9 -0
  244. package/static/cms/editor/toolbar.css +5 -0
  245. package/static/cms/editor/toolbar.js +91 -101
  246. package/static/cms/editor.css +1 -1
  247. package/static/cms/editor.js +1 -0
  248. package/static/cms/studio/entry-loader.js +102 -0
  249. package/static/cms/studio/field-model.js +110 -0
  250. package/static/cms/studio/fields.js +319 -0
  251. package/static/cms/studio/history-client.js +74 -0
  252. package/static/cms/studio/mutation-client.js +74 -0
  253. package/static/cms/studio/sync-client.js +107 -0
  254. package/static/cms/studio/upload-client.js +109 -0
  255. package/static/cms/studio-tokens.css +4 -4
  256. package/static/cms/studio.css +24 -0
@@ -1,12 +1,26 @@
1
1
  import { escapeHtml } from './linkify.js';
2
2
 
3
+ /** @typedef {HTMLElement & { _caretSkipSave?: boolean }} EditableElement */
4
+ /** @typedef {{ dirtyEls: Set<Element>, linkPopupEl: HTMLElement | null }} TextEditorState */
5
+ /** @typedef {(message: string, kind: 'success' | 'error') => unknown} ShowToast */
6
+ /** @typedef {(url: string | null) => void} LinkApplyCallback */
7
+
8
+ /**
9
+ * @param {object} options
10
+ * @param {TextEditorState} options.state
11
+ * @param {(text: string) => string} options.clientLinkify
12
+ * @param {ShowToast} options.showToast
13
+ * @param {WeakMap<HTMLElement, string>} options.snapshots
14
+ */
3
15
  export function createTextLinkInteractions({
4
16
  state,
5
17
  clientLinkify,
6
18
  showToast,
7
19
  snapshots,
8
20
  }) {
21
+ /** @type {HTMLDivElement | null} */
9
22
  let activePopover = null;
23
+ /** @type {HTMLDivElement | null} */
10
24
  let activeLinkHint = null;
11
25
 
12
26
  function removeLinkHint() {
@@ -24,6 +38,11 @@ export function createTextLinkInteractions({
24
38
  state.linkPopupEl = null;
25
39
  }
26
40
 
41
+ /**
42
+ * @param {DOMRect | DOMRectReadOnly} rect
43
+ * @param {string} initialUrl
44
+ * @param {LinkApplyCallback} onApply
45
+ */
27
46
  function showLinkPopup(rect, initialUrl, onApply) {
28
47
  dismissLinkPopup();
29
48
 
@@ -59,9 +78,15 @@ export function createTextLinkInteractions({
59
78
  popover.style.left = `${left}px`;
60
79
  popover.style.top = `${top}px`;
61
80
 
62
- const input = popover.querySelector('.cms-link-popover-input');
63
- const applyBtn = popover.querySelector('.cms-link-popover-apply');
64
- const cancelBtn = popover.querySelector('.cms-link-popover-cancel');
81
+ const input = /** @type {HTMLInputElement} */ (
82
+ popover.querySelector('.cms-link-popover-input')
83
+ );
84
+ const applyBtn = /** @type {HTMLButtonElement} */ (
85
+ popover.querySelector('.cms-link-popover-apply')
86
+ );
87
+ const cancelBtn = /** @type {HTMLButtonElement} */ (
88
+ popover.querySelector('.cms-link-popover-cancel')
89
+ );
65
90
 
66
91
  requestAnimationFrame(() => input.focus());
67
92
  if (initialUrl) input.select();
@@ -80,7 +105,7 @@ export function createTextLinkInteractions({
80
105
  applyBtn.addEventListener('click', apply);
81
106
  cancelBtn.addEventListener('click', cancel);
82
107
 
83
- input.addEventListener('keydown', (e) => {
108
+ input.addEventListener('keydown', (/** @type {KeyboardEvent} */ e) => {
84
109
  if (e.key === 'Enter') {
85
110
  e.preventDefault();
86
111
  apply();
@@ -92,8 +117,9 @@ export function createTextLinkInteractions({
92
117
  });
93
118
 
94
119
  setTimeout(() => {
120
+ /** @param {MouseEvent} e */
95
121
  function onDocClick(e) {
96
- if (!popover.contains(e.target)) {
122
+ if (!(e.target instanceof Node) || !popover.contains(e.target)) {
97
123
  document.removeEventListener('click', onDocClick, true);
98
124
  cancel();
99
125
  }
@@ -102,6 +128,7 @@ export function createTextLinkInteractions({
102
128
  }, 0);
103
129
  }
104
130
 
131
+ /** @param {HTMLElement} el */
105
132
  function showLinkHint(el) {
106
133
  removeLinkHint();
107
134
  const hint = document.createElement('div');
@@ -115,8 +142,23 @@ export function createTextLinkInteractions({
115
142
  hint.style.left = `${rect.right - hint.offsetWidth}px`;
116
143
  }
117
144
 
145
+ /**
146
+ * @param {Range} range
147
+ * @returns {boolean}
148
+ */
149
+ function restoreSelection(range) {
150
+ const selection = window.getSelection();
151
+ if (!selection) return false;
152
+ selection.removeAllRanges();
153
+ selection.addRange(range);
154
+ return true;
155
+ }
156
+
157
+ /** @param {KeyboardEvent} e */
118
158
  function onKeydown(e) {
119
- const focused = document.activeElement;
159
+ const focused = document.activeElement instanceof HTMLElement
160
+ ? /** @type {EditableElement} */ (document.activeElement)
161
+ : null;
120
162
 
121
163
  // Ctrl+S / Cmd+S — save focused element by triggering blur save
122
164
  if ((e.ctrlKey || e.metaKey) && e.key === 's') {
@@ -148,15 +190,14 @@ export function createTextLinkInteractions({
148
190
  : anchorNode instanceof Element ? anchorNode.closest('a') : null;
149
191
  const initialUrl = existingLink?.getAttribute('href') || '';
150
192
 
151
- state.linkPopupEl = focused;
152
-
153
193
  showLinkPopup(selRect, initialUrl, (url) => {
154
194
  focused.focus();
155
195
 
156
196
  if (url) {
157
- const newSel = window.getSelection();
158
- newSel.removeAllRanges();
159
- newSel.addRange(range);
197
+ if (!restoreSelection(range)) {
198
+ state.linkPopupEl = null;
199
+ return;
200
+ }
160
201
 
161
202
  if (existingLink) {
162
203
  existingLink.setAttribute('href', url);
@@ -179,12 +220,13 @@ export function createTextLinkInteractions({
179
220
 
180
221
  state.dirtyEls.add(focused);
181
222
  } else if (url === '' && existingLink) {
182
- document.execCommand('unlink', false, null);
223
+ document.execCommand('unlink');
183
224
  state.dirtyEls.add(focused);
184
225
  }
185
226
 
186
227
  state.linkPopupEl = null;
187
228
  });
229
+ state.linkPopupEl = focused;
188
230
  return;
189
231
  }
190
232
 
@@ -198,21 +240,21 @@ export function createTextLinkInteractions({
198
240
 
199
241
  const range = sel.getRangeAt(0).cloneRange();
200
242
  const selRect = range.getBoundingClientRect();
201
- state.linkPopupEl = focused;
202
-
203
243
  showLinkPopup(selRect, '', (url) => {
204
244
  focused.focus();
205
245
 
206
246
  if (url) {
207
- const newSel = window.getSelection();
208
- newSel.removeAllRanges();
209
- newSel.addRange(range);
247
+ if (!restoreSelection(range)) {
248
+ state.linkPopupEl = null;
249
+ return;
250
+ }
210
251
  document.execCommand('insertText', false, `[${selectedText}](${url})`);
211
252
  state.dirtyEls.add(focused);
212
253
  }
213
254
 
214
255
  state.linkPopupEl = null;
215
256
  });
257
+ state.linkPopupEl = focused;
216
258
  }
217
259
  return;
218
260
  }
@@ -5,6 +5,13 @@ const ERROR_SVG =
5
5
  const WARNING_SVG =
6
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
7
 
8
+ /** @typedef {'success' | 'error' | 'warning'} ToastType */
9
+ /** @typedef {{ label: string, primary?: boolean, onClick: () => void | Promise<void> }} ToastAction */
10
+ /** @typedef {{ message: string, sticky?: boolean, actions?: ToastAction[], role?: string }} ToastOptions */
11
+ /** @typedef {{ message: string, onKeepMine: () => void | Promise<void>, onLoadLatest: () => void | Promise<void>, keepLabel?: string, latestLabel?: string }} ConflictOptions */
12
+ /** @typedef {((message: string, type?: 'success' | 'error') => HTMLDivElement) & { conflict: (options: ConflictOptions) => HTMLDivElement }} ToastController */
13
+
14
+ /** @param {ToastType} type @returns {string} */
8
15
  function iconFor(type) {
9
16
  if (type === 'success') return SUCCESS_SVG;
10
17
  if (type === 'warning') return WARNING_SVG;
@@ -24,9 +31,13 @@ function iconFor(type) {
24
31
  * server-provided error string can't inject markup into the editor chrome.
25
32
  * - The container is an aria-live region; errors/conflicts are role="alert".
26
33
  */
34
+ /** @returns {ToastController} */
27
35
  export function createToast() {
36
+ /** @type {HTMLDivElement | null} */
28
37
  let container = null;
38
+ const dismissed = new WeakSet();
29
39
 
40
+ /** @returns {HTMLDivElement} */
30
41
  function ensureContainer() {
31
42
  if (container && document.body.contains(container)) return container;
32
43
  container = document.createElement('div');
@@ -36,13 +47,15 @@ export function createToast() {
36
47
  return container;
37
48
  }
38
49
 
50
+ /** @param {HTMLDivElement} toast */
39
51
  function dismiss(toast) {
40
- if (toast._caretDismissed) return;
41
- toast._caretDismissed = true;
52
+ if (dismissed.has(toast)) return;
53
+ dismissed.add(toast);
42
54
  toast.classList.add('cms-toast-out');
43
55
  setTimeout(() => toast.remove(), 300);
44
56
  }
45
57
 
58
+ /** @param {ToastType} type @param {ToastOptions} options @returns {HTMLDivElement} */
46
59
  function render(type, { message, sticky, actions, role }) {
47
60
  const stack = ensureContainer();
48
61
 
@@ -72,11 +85,11 @@ export function createToast() {
72
85
  btn.textContent = action.label;
73
86
  btn.addEventListener('click', () => {
74
87
  dismiss(toast);
75
- try {
76
- action.onClick();
77
- } catch (e) {
78
- /* action errors shouldn't break the toast */
79
- }
88
+ Promise.resolve()
89
+ .then(() => action.onClick())
90
+ .catch(() => {
91
+ /* action errors shouldn't break the toast */
92
+ });
80
93
  });
81
94
  group.appendChild(btn);
82
95
  }
@@ -96,14 +109,15 @@ export function createToast() {
96
109
  return toast;
97
110
  }
98
111
 
99
- function showToast(message, type) {
112
+ /** @type {ToastController} */
113
+ const showToast = (message, type) => {
100
114
  const t = type === 'error' ? 'error' : type || 'success';
101
115
  return render(t, {
102
116
  message,
103
117
  sticky: t === 'error',
104
118
  role: t === 'error' ? 'alert' : undefined,
105
119
  });
106
- }
120
+ };
107
121
 
108
122
  // Two-choice save-conflict prompt. Sticky so the user's edit is never silently
109
123
  // discarded — they explicitly choose to keep theirs or load the latest.
@@ -72,6 +72,15 @@
72
72
  box-shadow: 0 0 0 4px var(--cms-blue-dim);
73
73
  }
74
74
 
75
+ /* Briefly confirms that the paired Studio field was located. Save feedback is
76
+ green, so linked selection keeps the editor's blue navigation language. */
77
+ .cms-linked-selection {
78
+ outline: 2px solid var(--cms-blue) !important;
79
+ outline-offset: 4px;
80
+ box-shadow: 0 0 0 4px var(--cms-blue-dim);
81
+ transition: outline-color 0.35s ease, box-shadow 0.35s ease;
82
+ }
83
+
75
84
  /* ---------------------------------------------------------------------------
76
85
  Unpublished body draft
77
86
  The server rewrite tags blocks whose prose comes from an unpublished draft
@@ -357,3 +357,8 @@ body:has(.cms-toolbar) #back-to-top {
357
357
  transform: translateY(0);
358
358
  }
359
359
  }
360
+
361
+ /* Leave the Astro development dock usable when it opens or receives focus. */
362
+ body:has(astro-dev-toolbar) .cms-toolbar {
363
+ bottom: 72px;
364
+ }
@@ -1,39 +1,34 @@
1
+ import { isPolicyDraftMode } from './config.js';
1
2
  import { toggleHighlight } from './highlight.js';
2
3
  import { buildCmsUrl, isStaticDelivery } from './config.js';
4
+ import { createDeploymentStatusPoller } from './deployment-status.js';
5
+ import { presentPublishResult } from './publish-result.js';
6
+ import { createDraftClient } from './draft-client.js';
7
+ import { createPreviewModeController } from './preview-mode.js';
3
8
 
4
- const PREVIEW_COOKIE = 'caret_preview';
5
-
6
- function previewActive() {
7
- return document.cookie.split('; ').some((c) => c === `${PREVIEW_COOKIE}=1`);
8
- }
9
-
10
- function setPreviewCookie(on) {
11
- document.cookie = on
12
- ? `${PREVIEW_COOKIE}=1; path=/; SameSite=Lax`
13
- : `${PREVIEW_COOKIE}=; path=/; Max-Age=0; SameSite=Lax`;
14
- }
9
+ const previewMode = createPreviewModeController({
10
+ isStaticDelivery,
11
+ isPolicyDraftMode,
12
+ documentRef: document,
13
+ reload: () => window.location.reload(),
14
+ });
15
15
 
16
16
  /** Static delivery always drafts — turn preview on once so saves stay unpublished. */
17
17
  export function ensureStaticPreviewMode() {
18
- if (!isStaticDelivery()) return false;
19
- if (previewActive()) return false;
20
- setPreviewCookie(true);
21
- window.location.reload();
22
- return true;
18
+ return previewMode.ensureStaticPreviewMode();
23
19
  }
24
20
 
25
21
  /** Align preview cookie with delivery mode (static on, server off). */
26
22
  export function normalizePreviewForDelivery() {
27
- if (isStaticDelivery()) return ensureStaticPreviewMode();
28
- if (previewActive()) {
29
- setPreviewCookie(false);
30
- window.location.reload();
31
- return true;
32
- }
33
- return false;
23
+ return previewMode.normalizePreviewForDelivery();
34
24
  }
35
25
 
26
+ /** @typedef {{ href: string, label: string, isActive: boolean }} ToolbarNavLink */
27
+ /** @typedef {'saving' | 'idle' | 'error'} ToolbarStatusState */
28
+
29
+ /** @param {string} pagePath @returns {ToolbarNavLink[]} */
36
30
  function getToolbarNavLinks(pagePath) {
31
+ /** @type {ToolbarNavLink[]} */
37
32
  const links = [];
38
33
  const headerNav = document.querySelector('#main-header nav');
39
34
  if (!headerNav) return links;
@@ -49,13 +44,14 @@ function getToolbarNavLinks(pagePath) {
49
44
  return links;
50
45
  }
51
46
 
47
+ /** @param {boolean} staticDelivery */
52
48
  function renderDraftControls(staticDelivery) {
53
49
  // Static delivery is always drafting, so the controls stay visible. Server
54
50
  // delivery saves field edits straight to the site, but inline body-block
55
51
  // edits are ALWAYS drafts (they splice into source files only at publish), so
56
52
  // the controls render here too — hidden until at least one draft is pending
57
53
  // (toggled by refreshDraftControls) so the toolbar stays quiet otherwise.
58
- const publishTitle = staticDelivery
54
+ const publishTitle = isPolicyDraftMode() ? 'Publish private drafts to shared content' : staticDelivery
59
55
  ? 'Publish drafts; static visitors update after rebuild and deploy'
60
56
  : 'Publish your body-block drafts into the source files';
61
57
  return `
@@ -72,6 +68,7 @@ function renderDraftControls(staticDelivery) {
72
68
  </span>`;
73
69
  }
74
70
 
71
+ /** @param {ToolbarNavLink[]} navLinks @param {boolean} staticDelivery */
75
72
  function renderToolbar(navLinks, staticDelivery) {
76
73
  const navLinksHtml = navLinks
77
74
  .map(
@@ -80,8 +77,8 @@ function renderToolbar(navLinks, staticDelivery) {
80
77
  )
81
78
  .join('');
82
79
 
83
- const badgeLabel = staticDelivery ? 'Draft' : 'Live';
84
- const badgeHint = staticDelivery
80
+ const badgeLabel = staticDelivery || isPolicyDraftMode() ? 'Draft' : 'Live';
81
+ const badgeHint = isPolicyDraftMode() ? 'Changes save to your private draft; publishing requires permission' : staticDelivery
85
82
  ? 'Draft preview; visitors update after publish, rebuild, and deploy'
86
83
  : 'Changes save directly to your site';
87
84
 
@@ -104,6 +101,7 @@ function renderToolbar(navLinks, staticDelivery) {
104
101
  ${navLinksHtml ? `<div class="cms-toolbar-nav">${navLinksHtml}</div>` : ''}
105
102
  <div class="cms-toolbar-right">
106
103
  ${renderDraftControls(staticDelivery)}
104
+ <button type="button" class="cms-retry-rebuild-btn" hidden>Retry deploy</button>
107
105
  <button type="button" class="cms-studio-btn" title="Toggle Content Studio panel">
108
106
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="9" y1="9" x2="21" y2="9"/></svg>
109
107
  Studio
@@ -130,6 +128,13 @@ function renderToolbar(navLinks, staticDelivery) {
130
128
  return toolbar;
131
129
  }
132
130
 
131
+ /**
132
+ * @param {{
133
+ * showToast: (message: string, type: 'success' | 'error') => void,
134
+ * clearDirty: () => void,
135
+ * onLogout: () => void | Promise<void>,
136
+ * }} options
137
+ */
133
138
  export function mountToolbar({ showToast, clearDirty, onLogout }) {
134
139
  const staticDelivery = isStaticDelivery();
135
140
  const navLinks = getToolbarNavLinks(window.location.pathname);
@@ -138,6 +143,7 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
138
143
  const statusDot = toolbar.querySelector('.cms-status-dot');
139
144
  const statusText = toolbar.querySelector('.cms-status-text');
140
145
 
146
+ /** @param {ToolbarStatusState} state @param {string} text */
141
147
  function setStatus(state, text) {
142
148
  if (statusDot) {
143
149
  statusDot.className = `cms-status-dot cms-status-${state}`;
@@ -147,29 +153,36 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
147
153
  }
148
154
  }
149
155
 
150
- const highlightBtn = toolbar.querySelector('.cms-highlight-btn');
156
+ const deploymentStatus = createDeploymentStatusPoller({
157
+ fetchStatus: () => fetch(buildCmsUrl('/deployment'), {
158
+ credentials: 'same-origin',
159
+ headers: { 'x-caret-request': '1' },
160
+ }),
161
+ setStatus,
162
+ setBuildId(buildId) {
163
+ const statusGroup = /** @type {HTMLElement | null} */ (toolbar.querySelector('.cms-status-group'));
164
+ if (statusGroup) {
165
+ statusGroup.title = buildId ? `Build ${buildId}` : '';
166
+ }
167
+ },
168
+ });
169
+ const draftClient = createDraftClient({ buildUrl: path => buildCmsUrl(path) });
170
+
171
+ const highlightBtn = /** @type {HTMLButtonElement | null} */ (toolbar.querySelector('.cms-highlight-btn'));
151
172
  highlightBtn?.addEventListener('click', () =>
152
173
  toggleHighlight(highlightBtn, showToast),
153
174
  );
154
175
 
155
- async function draftRequest(method, path) {
156
- return fetch(buildCmsUrl(path), {
157
- method,
158
- headers: { 'Content-Type': 'application/json', 'x-caret-request': '1' },
159
- credentials: 'same-origin',
160
- body: method === 'POST' ? '{}' : undefined,
161
- });
162
- }
163
-
176
+ let canPublishDrafts = true;
177
+ const retryRebuildButton = /** @type {HTMLButtonElement | null} */ (toolbar.querySelector('.cms-retry-rebuild-btn'));
164
178
  async function fetchDraftCount() {
165
179
  try {
166
- const res = await fetch(buildCmsUrl('/draft'), {
167
- credentials: 'same-origin',
168
- headers: { 'x-caret-request': '1' },
169
- });
170
- if (!res.ok) return 0;
171
- const data = await res.json();
172
- return typeof data.count === 'number' ? data.count : 0;
180
+ const data = await draftClient.getState();
181
+ canPublishDrafts = data.canPublish;
182
+ const publishButton = /** @type {HTMLButtonElement | null} */ (toolbar.querySelector('.cms-publish-btn'));
183
+ if (publishButton) publishButton.hidden = !data.canPublish;
184
+ if (retryRebuildButton) retryRebuildButton.hidden = !data.retryRebuild || !data.canPublish;
185
+ return data.count;
173
186
  } catch {
174
187
  return 0;
175
188
  }
@@ -178,60 +191,25 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
178
191
  async function publishDrafts() {
179
192
  setStatus('saving', 'Publishing…');
180
193
  try {
181
- const res = await draftRequest('POST', '/publish');
182
- if (!res.ok) throw new Error('publish failed');
183
- const data = await res.json();
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
- }
199
- if (data.rebuild?.triggered && data.rebuild.ok === false) {
200
- setStatus('error', 'Published, deploy failed');
201
- showToast(
202
- 'Changes are published, but the deploy webhook failed. Check your CI settings.',
203
- 'error',
204
- );
205
- return false;
206
- }
207
- if (data.rebuild?.triggered) {
208
- showToast(
209
- n > 0
210
- ? `Published ${n} change(s) — rebuild started`
211
- : 'Your site is rebuilding',
212
- 'success',
213
- );
214
- } else {
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
+ const presentation = presentPublishResult(await draftClient.publish());
195
+ if (retryRebuildButton) retryRebuildButton.hidden = !presentation.retryAvailable;
196
+ if (presentation.status) {
197
+ setStatus(presentation.status.state, presentation.status.text);
221
198
  }
222
- window.location.reload();
223
- return true;
199
+ showToast(presentation.toast.message, presentation.toast.kind);
200
+ if (presentation.reload) window.location.reload();
201
+ return presentation.completed;
224
202
  } catch {
225
203
  setStatus('error', 'Publish failed');
226
- showToast('Publish failed', 'error');
204
+ showToast('Publication could not finish. Some content may already be published. Retry Publish to recover.', 'error');
227
205
  return false;
228
206
  }
229
207
  }
230
208
 
231
209
  toolbar.querySelector('.cms-exit-btn')?.addEventListener('click', async () => {
232
- if (staticDelivery) {
210
+ if (staticDelivery || isPolicyDraftMode()) {
233
211
  const count = await fetchDraftCount();
234
- if (count > 0) {
212
+ if (count > 0 && canPublishDrafts) {
235
213
  const goLive = window.confirm(
236
214
  `You have ${count} unpublished change${count === 1 ? '' : 's'}. Publish before signing out?`,
237
215
  );
@@ -244,10 +222,9 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
244
222
  );
245
223
  if (!discard) return;
246
224
  try {
247
- const res = await draftRequest('DELETE', '/draft');
248
- if (!res.ok) throw new Error('discard failed');
225
+ await draftClient.discard();
249
226
  } catch {
250
- showToast('Could not discard drafts', 'error');
227
+ showToast('Some drafts need publish recovery. Retry Publish before discarding.', 'error');
251
228
  return;
252
229
  }
253
230
  }
@@ -257,7 +234,7 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
257
234
  await onLogout();
258
235
  });
259
236
 
260
- const publishBtn = toolbar.querySelector('.cms-publish-btn');
237
+ const publishBtn = /** @type {HTMLButtonElement | null} */ (toolbar.querySelector('.cms-publish-btn'));
261
238
  publishBtn?.addEventListener('click', async () => {
262
239
  if (
263
240
  !window.confirm(
@@ -274,8 +251,7 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
274
251
  toolbar.querySelector('.cms-discard-btn')?.addEventListener('click', async () => {
275
252
  if (!window.confirm('Discard all your draft changes? This cannot be undone.')) return;
276
253
  try {
277
- const res = await draftRequest('DELETE', '/draft');
278
- if (!res.ok) throw new Error('discard failed');
254
+ await draftClient.discard();
279
255
  showToast('Draft discarded', 'success');
280
256
  window.location.reload();
281
257
  } catch {
@@ -287,15 +263,29 @@ export function mountToolbar({ showToast, clearDirty, onLogout }) {
287
263
  // edits save directly (no draft), so the count reflects pending body blocks.
288
264
  // Refresh on mount and whenever a body block is drafted; publish/discard
289
265
  // reload the page, so mount re-evaluates from scratch.
290
- const draftControls = toolbar.querySelector('.cms-draft-controls');
266
+ const draftControls = /** @type {HTMLElement | null} */ (toolbar.querySelector('.cms-draft-controls'));
291
267
  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);
268
+ const count = await fetchDraftCount();
269
+ if (draftControls) draftControls.hidden = !staticDelivery && !isPolicyDraftMode() && count === 0;
298
270
  }
271
+ refreshDraftControls();
272
+ window.addEventListener('cms:draftSaved', refreshDraftControls);
273
+ retryRebuildButton?.addEventListener('click', async () => {
274
+ retryRebuildButton.disabled = true;
275
+ setStatus('saving', 'Retrying deploy…');
276
+ try {
277
+ const data = await draftClient.retryDeployment();
278
+ retryRebuildButton.hidden = true;
279
+ setStatus(data.deploymentTracked ? 'saving' : 'idle', data.deploymentTracked ? 'Deploying…' : 'Deploy requested');
280
+ showToast('Deploy requested. Your published content is unchanged.', 'success');
281
+ if (data.deploymentTracked) deploymentStatus.schedule();
282
+ } catch {
283
+ setStatus('error', 'Published, deploy failed');
284
+ showToast('The deploy hook failed. Check its configuration and retry.', 'error');
285
+ } finally { retryRebuildButton.disabled = false; }
286
+ });
287
+
288
+ void deploymentStatus.refresh();
299
289
 
300
290
  const studioButton = toolbar.querySelector('.cms-studio-btn');
301
291
  const mapButton = toolbar.querySelector('.cms-map-btn');
@@ -1,7 +1,7 @@
1
1
  /* ========================================================================
2
2
  CMS Editor Styles — only loaded when isEditor = true
3
3
  ======================================================================== */
4
- @import "./editor/tokens-and-text.css";
4
+ @import "./editor/tokens-and-text.css?v=studio-upstream-20260819";
5
5
  @import "./editor/image.css";
6
6
  @import "./editor/section-controls.css";
7
7
  @import "./editor/toolbar.css";
@@ -36,6 +36,7 @@ function redirectToEditorLogin() {
36
36
  function boot() {
37
37
  if (normalizePreviewForDelivery()) return;
38
38
 
39
+ /** @type {{ dirtyEls: Set<Element>, linkPopupEl: HTMLElement | null }} */
39
40
  const state = {
40
41
  // Every element with unsaved edits. A Set (not a single element) so a save
41
42
  // completing on field A can't clear the dirty flag for field B the user has