@caretcms/core 0.3.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 (223) hide show
  1. package/README.md +116 -11
  2. package/dist/index.d.ts +15 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +78 -5
  5. package/dist/index.js.map +1 -1
  6. package/dist/loader.d.ts.map +1 -1
  7. package/dist/loader.js +8 -0
  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 +3 -1
  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/authorization.d.ts +11 -0
  32. package/dist/runtime/authorization.d.ts.map +1 -0
  33. package/dist/runtime/authorization.js +29 -0
  34. package/dist/runtime/authorization.js.map +1 -0
  35. package/dist/runtime/collection-policy.d.ts +11 -0
  36. package/dist/runtime/collection-policy.d.ts.map +1 -0
  37. package/dist/runtime/collection-policy.js +44 -0
  38. package/dist/runtime/collection-policy.js.map +1 -0
  39. package/dist/runtime/content-errors.d.ts +9 -0
  40. package/dist/runtime/content-errors.d.ts.map +1 -0
  41. package/dist/runtime/content-errors.js +17 -0
  42. package/dist/runtime/content-errors.js.map +1 -0
  43. package/dist/runtime/content.d.ts.map +1 -1
  44. package/dist/runtime/content.js +13 -4
  45. package/dist/runtime/content.js.map +1 -1
  46. package/dist/runtime/deployment-status.d.ts +14 -0
  47. package/dist/runtime/deployment-status.d.ts.map +1 -0
  48. package/dist/runtime/deployment-status.js +57 -0
  49. package/dist/runtime/deployment-status.js.map +1 -0
  50. package/dist/runtime/draft-state.d.ts +16 -0
  51. package/dist/runtime/draft-state.d.ts.map +1 -0
  52. package/dist/runtime/draft-state.js +29 -0
  53. package/dist/runtime/draft-state.js.map +1 -0
  54. package/dist/runtime/i18n.d.ts +13 -2
  55. package/dist/runtime/i18n.d.ts.map +1 -1
  56. package/dist/runtime/i18n.js +26 -4
  57. package/dist/runtime/i18n.js.map +1 -1
  58. package/dist/runtime/index.d.ts +1 -0
  59. package/dist/runtime/index.d.ts.map +1 -1
  60. package/dist/runtime/index.js +1 -0
  61. package/dist/runtime/index.js.map +1 -1
  62. package/dist/runtime/middleware.d.ts.map +1 -1
  63. package/dist/runtime/middleware.js +21 -2
  64. package/dist/runtime/middleware.js.map +1 -1
  65. package/dist/runtime/mutations/contracts.d.ts +9 -0
  66. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  67. package/dist/runtime/mutations/contracts.js +45 -4
  68. package/dist/runtime/mutations/contracts.js.map +1 -1
  69. package/dist/runtime/mutations/engine.d.ts +2 -0
  70. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  71. package/dist/runtime/mutations/engine.js +115 -63
  72. package/dist/runtime/mutations/engine.js.map +1 -1
  73. package/dist/runtime/providers.d.ts +3 -1
  74. package/dist/runtime/providers.d.ts.map +1 -1
  75. package/dist/runtime/providers.js +12 -1
  76. package/dist/runtime/providers.js.map +1 -1
  77. package/dist/runtime/publish-recovery.d.ts +32 -0
  78. package/dist/runtime/publish-recovery.d.ts.map +1 -0
  79. package/dist/runtime/publish-recovery.js +98 -0
  80. package/dist/runtime/publish-recovery.js.map +1 -0
  81. package/dist/runtime/publish.d.ts +10 -4
  82. package/dist/runtime/publish.d.ts.map +1 -1
  83. package/dist/runtime/publish.js +104 -67
  84. package/dist/runtime/publish.js.map +1 -1
  85. package/dist/runtime/rebuild-webhook.d.ts +4 -0
  86. package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
  87. package/dist/runtime/rebuild-webhook.js +12 -2
  88. package/dist/runtime/rebuild-webhook.js.map +1 -1
  89. package/dist/runtime/request-context.d.ts +3 -1
  90. package/dist/runtime/request-context.d.ts.map +1 -1
  91. package/dist/runtime/request-context.js.map +1 -1
  92. package/dist/runtime/rewrite.d.ts.map +1 -1
  93. package/dist/runtime/rewrite.js +28 -2
  94. package/dist/runtime/rewrite.js.map +1 -1
  95. package/dist/runtime/rich-allowlist.js +1 -1
  96. package/dist/runtime/rich-allowlist.js.map +1 -1
  97. package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
  98. package/dist/runtime/routes/admin-entry.js +24 -12
  99. package/dist/runtime/routes/admin-entry.js.map +1 -1
  100. package/dist/runtime/routes/auth-session.d.ts.map +1 -1
  101. package/dist/runtime/routes/auth-session.js +6 -0
  102. package/dist/runtime/routes/auth-session.js.map +1 -1
  103. package/dist/runtime/routes/collection-list.d.ts.map +1 -1
  104. package/dist/runtime/routes/collection-list.js +164 -40
  105. package/dist/runtime/routes/collection-list.js.map +1 -1
  106. package/dist/runtime/routes/deployment.d.ts +5 -0
  107. package/dist/runtime/routes/deployment.d.ts.map +1 -0
  108. package/dist/runtime/routes/deployment.js +38 -0
  109. package/dist/runtime/routes/deployment.js.map +1 -0
  110. package/dist/runtime/routes/draft.d.ts.map +1 -1
  111. package/dist/runtime/routes/draft.js +10 -3
  112. package/dist/runtime/routes/draft.js.map +1 -1
  113. package/dist/runtime/routes/entries.d.ts.map +1 -1
  114. package/dist/runtime/routes/entries.js +74 -39
  115. package/dist/runtime/routes/entries.js.map +1 -1
  116. package/dist/runtime/routes/history.d.ts.map +1 -1
  117. package/dist/runtime/routes/history.js +37 -21
  118. package/dist/runtime/routes/history.js.map +1 -1
  119. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  120. package/dist/runtime/routes/mutate.js +4 -1
  121. package/dist/runtime/routes/mutate.js.map +1 -1
  122. package/dist/runtime/routes/publish.d.ts.map +1 -1
  123. package/dist/runtime/routes/publish.js +127 -33
  124. package/dist/runtime/routes/publish.js.map +1 -1
  125. package/dist/runtime/routes/schema.d.ts.map +1 -1
  126. package/dist/runtime/routes/schema.js +4 -0
  127. package/dist/runtime/routes/schema.js.map +1 -1
  128. package/dist/runtime/routes/studio-home.d.ts.map +1 -1
  129. package/dist/runtime/routes/studio-home.js +3 -1
  130. package/dist/runtime/routes/studio-home.js.map +1 -1
  131. package/dist/runtime/routes/upload.d.ts.map +1 -1
  132. package/dist/runtime/routes/upload.js +3 -0
  133. package/dist/runtime/routes/upload.js.map +1 -1
  134. package/dist/runtime/schema-registry.d.ts.map +1 -1
  135. package/dist/runtime/schema-registry.js +2 -0
  136. package/dist/runtime/schema-registry.js.map +1 -1
  137. package/dist/runtime/storage/entry-commit.d.ts +8 -0
  138. package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
  139. package/dist/runtime/storage/entry-commit.js +45 -0
  140. package/dist/runtime/storage/entry-commit.js.map +1 -0
  141. package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
  142. package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
  143. package/dist/runtime/storage/filesystem-adapter.js +12 -3
  144. package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
  145. package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
  146. package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
  147. package/dist/runtime/storage/frontmatter-codec.js +156 -13
  148. package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
  149. package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
  150. package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
  151. package/dist/runtime/storage/in-memory-adapter.js +6 -0
  152. package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
  153. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
  154. package/dist/runtime/storage/markdown-adapter.js +18 -20
  155. package/dist/runtime/storage/markdown-adapter.js.map +1 -1
  156. package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
  157. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  158. package/dist/runtime/storage/session-overlay-adapter.js +100 -13
  159. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  160. package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
  161. package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
  162. package/dist/runtime/storage/sidecar-meta-store.js +48 -5
  163. package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
  164. package/dist/runtime/utils.d.ts.map +1 -1
  165. package/dist/runtime/utils.js +3 -4
  166. package/dist/runtime/utils.js.map +1 -1
  167. package/dist/types.d.ts +123 -1
  168. package/dist/types.d.ts.map +1 -1
  169. package/package.json +12 -3
  170. package/static/cms/admin-entry.css +9 -0
  171. package/static/cms/admin-entry.js +251 -687
  172. package/static/cms/dev-toolbar/app.js +78 -19
  173. package/static/cms/editor/config.js +35 -15
  174. package/static/cms/editor/content-map.css +8 -1
  175. package/static/cms/editor/content-map.js +62 -23
  176. package/static/cms/editor/deployment-status.js +66 -0
  177. package/static/cms/editor/draft-client.js +87 -0
  178. package/static/cms/editor/guards.js +20 -0
  179. package/static/cms/editor/helpers.js +30 -1
  180. package/static/cms/editor/highlight.js +1 -0
  181. package/static/cms/editor/image-edit.js +43 -1
  182. package/static/cms/editor/image-utils.js +23 -7
  183. package/static/cms/editor/link-follow.js +122 -41
  184. package/static/cms/editor/link-ui.css +10 -1
  185. package/static/cms/editor/linkify.js +2 -0
  186. package/static/cms/editor/md-block-edit.js +140 -37
  187. package/static/cms/editor/md-paragraphs.js +98 -0
  188. package/static/cms/editor/md-serialize.js +1 -1
  189. package/static/cms/editor/panel.js +43 -15
  190. package/static/cms/editor/preview-mode.js +44 -0
  191. package/static/cms/editor/publish-result.js +100 -0
  192. package/static/cms/editor/rich-toolbar.js +58 -15
  193. package/static/cms/editor/sanitize.js +6 -2
  194. package/static/cms/editor/save-queue.js +75 -17
  195. package/static/cms/editor/section-controls/activation.js +3 -0
  196. package/static/cms/editor/section-controls/api.js +25 -7
  197. package/static/cms/editor/section-controls/index.js +60 -8
  198. package/static/cms/editor/section-controls/model.js +39 -4
  199. package/static/cms/editor/section-controls/mutations.js +15 -0
  200. package/static/cms/editor/section-controls/page-context.js +9 -0
  201. package/static/cms/editor/section-controls/pickers.js +53 -2
  202. package/static/cms/editor/section-controls/reorder.js +23 -0
  203. package/static/cms/editor/section-controls/selection.js +6 -0
  204. package/static/cms/editor/section-controls/spacing-drag.js +16 -0
  205. package/static/cms/editor/section-controls/ui-elements.js +7 -0
  206. package/static/cms/editor/section-controls/utils.js +7 -0
  207. package/static/cms/editor/section-controls.css +18 -3
  208. package/static/cms/editor/security.js +13 -2
  209. package/static/cms/editor/stega-hydrate.js +17 -4
  210. package/static/cms/editor/sync.js +208 -10
  211. package/static/cms/editor/text-edit.js +37 -3
  212. package/static/cms/editor/text-link-interactions.js +59 -17
  213. package/static/cms/editor/toast.js +23 -9
  214. package/static/cms/editor/toolbar.css +5 -0
  215. package/static/cms/editor/toolbar.js +91 -101
  216. package/static/cms/editor.js +2 -1
  217. package/static/cms/studio/entry-loader.js +102 -0
  218. package/static/cms/studio/field-model.js +110 -0
  219. package/static/cms/studio/fields.js +319 -0
  220. package/static/cms/studio/history-client.js +74 -0
  221. package/static/cms/studio/mutation-client.js +74 -0
  222. package/static/cms/studio/sync-client.js +107 -0
  223. package/static/cms/studio/upload-client.js +109 -0
@@ -6,12 +6,21 @@
6
6
  * cross-browser approach for contenteditable formatting).
7
7
  */
8
8
 
9
- import { sanitizeHtml } from './sanitize.js';
9
+ /** @typedef {{ dirtyEls: Set<Element>, linkPopupEl: HTMLElement | null }} RichEditorState */
10
+ /** @typedef {(url: string | null) => void} LinkApplyCallback */
11
+ /** @typedef {(rect: DOMRect | DOMRectReadOnly, initialUrl: string, onApply: LinkApplyCallback) => void} ShowLinkPopup */
12
+ /** @typedef {{ sep?: boolean, cmd?: string, label?: string, title?: string, style?: string, isLink?: boolean }} ToolbarItem */
10
13
 
14
+ /** @type {HTMLDivElement | null} */
11
15
  let toolbar = null;
16
+ /** @type {HTMLElement | null} */
12
17
  let currentRichEl = null;
18
+ /** @type {ShowLinkPopup | null} */
13
19
  let showLinkPopupFn = null;
20
+ /** @type {RichEditorState | null} */
21
+ let editorState = null;
14
22
 
23
+ /** @type {ToolbarItem[]} */
15
24
  const BUTTONS = [
16
25
  { cmd: 'bold', label: 'B', title: 'Bold (Ctrl+B)', style: 'font-weight:700' },
17
26
  { cmd: 'italic', label: 'I', title: 'Italic (Ctrl+I)', style: 'font-style:italic' },
@@ -20,6 +29,7 @@ const BUTTONS = [
20
29
  { cmd: 'removeFormat', label: 'T\u2093', title: 'Clear formatting' },
21
30
  ];
22
31
 
32
+ /** @returns {HTMLDivElement} */
23
33
  function createToolbar() {
24
34
  const el = document.createElement('div');
25
35
  el.className = 'cms-rich-toolbar';
@@ -32,19 +42,21 @@ function createToolbar() {
32
42
  el.appendChild(sep);
33
43
  continue;
34
44
  }
45
+ if (!btn.cmd) continue;
46
+ const command = btn.cmd;
35
47
 
36
48
  const button = document.createElement('button');
37
49
  button.type = 'button';
38
50
  button.className = 'cms-rich-toolbar-btn';
39
- button.title = btn.title;
40
- button.dataset.cmd = btn.cmd;
51
+ button.title = btn.title || '';
52
+ button.dataset.cmd = command;
41
53
  if (btn.style) button.style.cssText = btn.style;
42
54
 
43
55
  // Use text for simple labels, innerHTML for the link icon
44
56
  if (btn.isLink) {
45
57
  button.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>`;
46
58
  } else {
47
- button.textContent = btn.label;
59
+ button.textContent = btn.label || '';
48
60
  }
49
61
 
50
62
  button.addEventListener('mousedown', (e) => {
@@ -52,7 +64,8 @@ function createToolbar() {
52
64
  if (btn.isLink) {
53
65
  handleLinkButton();
54
66
  } else {
55
- document.execCommand(btn.cmd, false, null);
67
+ document.execCommand(command);
68
+ if (currentRichEl) editorState?.dirtyEls.add(currentRichEl);
56
69
  updateActiveStates();
57
70
  }
58
71
  });
@@ -69,10 +82,12 @@ function createToolbar() {
69
82
 
70
83
  function handleLinkButton() {
71
84
  const sel = window.getSelection();
72
- if (!sel || sel.isCollapsed || !currentRichEl) return;
85
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0 || !currentRichEl) return;
73
86
 
74
87
  const range = sel.getRangeAt(0).cloneRange();
75
88
  const selRect = range.getBoundingClientRect();
89
+ const target = currentRichEl;
90
+ const selectedText = sel.toString();
76
91
 
77
92
  // Check if selection is already a link
78
93
  const anchorNode = sel.anchorNode;
@@ -81,18 +96,32 @@ function handleLinkButton() {
81
96
  : anchorNode instanceof Element ? anchorNode.closest('a') : null;
82
97
  const initialUrl = existingLink?.getAttribute('href') || '';
83
98
 
99
+ function liveExistingLink() {
100
+ if (!existingLink) return null;
101
+ if (target.contains(existingLink)) return existingLink;
102
+ return Array.from(target.querySelectorAll('a')).find((link) =>
103
+ link.getAttribute('href') === initialUrl && link.textContent === selectedText
104
+ ) || null;
105
+ }
106
+
84
107
  if (showLinkPopupFn) {
85
108
  showLinkPopupFn(selRect, initialUrl, (url) => {
86
- currentRichEl?.focus();
109
+ target.focus();
110
+ const link = liveExistingLink();
87
111
 
88
112
  if (url) {
89
113
  const newSel = window.getSelection();
114
+ if (!newSel) {
115
+ if (editorState) editorState.linkPopupEl = null;
116
+ hide();
117
+ return;
118
+ }
90
119
  newSel.removeAllRanges();
91
120
  newSel.addRange(range);
92
121
 
93
- if (existingLink) {
122
+ if (link) {
94
123
  // Update existing link
95
- existingLink.setAttribute('href', url);
124
+ link.setAttribute('href', url);
96
125
  } else {
97
126
  document.execCommand('createLink', false, url);
98
127
  }
@@ -109,13 +138,19 @@ function handleLinkButton() {
109
138
  newLink.setAttribute('rel', 'noopener noreferrer');
110
139
  }
111
140
  }
112
- } else if (url === '' && existingLink) {
141
+ editorState?.dirtyEls.add(target);
142
+ } else if (url === '' && link) {
113
143
  // Empty URL = unlink
114
- document.execCommand('unlink', false, null);
144
+ document.execCommand('unlink');
145
+ editorState?.dirtyEls.add(target);
115
146
  }
116
147
 
148
+ if (editorState) editorState.linkPopupEl = null;
117
149
  hide();
118
150
  });
151
+ // showLinkPopup dismisses any prior popover first, which clears this state.
152
+ // Claim the new popover only after it has completed that cleanup.
153
+ if (editorState) editorState.linkPopupEl = target;
119
154
  }
120
155
  }
121
156
 
@@ -123,8 +158,9 @@ function updateActiveStates() {
123
158
  if (!toolbar) return;
124
159
  const buttons = toolbar.querySelectorAll('.cms-rich-toolbar-btn[data-cmd]');
125
160
  for (const btn of buttons) {
161
+ if (!(btn instanceof HTMLButtonElement)) continue;
126
162
  const cmd = btn.dataset.cmd;
127
- if (cmd === 'createLink' || cmd === 'removeFormat') continue;
163
+ if (!cmd || cmd === 'createLink' || cmd === 'removeFormat') continue;
128
164
  try {
129
165
  btn.classList.toggle('active', document.queryCommandState(cmd));
130
166
  } catch {
@@ -133,8 +169,9 @@ function updateActiveStates() {
133
169
  }
134
170
  }
135
171
 
172
+ /** @param {Selection} sel */
136
173
  function position(sel) {
137
- if (!toolbar || !sel || sel.isCollapsed) return;
174
+ if (!toolbar || sel.isCollapsed || sel.rangeCount === 0) return;
138
175
 
139
176
  const range = sel.getRangeAt(0);
140
177
  const rect = range.getBoundingClientRect();
@@ -154,6 +191,7 @@ function position(sel) {
154
191
  toolbar.style.top = `${top}px`;
155
192
  }
156
193
 
194
+ /** @param {Selection} sel */
157
195
  function show(sel) {
158
196
  if (!toolbar) toolbar = createToolbar();
159
197
  toolbar.style.display = 'flex';
@@ -182,7 +220,7 @@ function onSelectionChange() {
182
220
  ? anchor.closest('[data-caret-rich], [data-caret-md]')
183
221
  : anchor?.parentElement?.closest('[data-caret-rich], [data-caret-md]');
184
222
 
185
- if (!richEl) {
223
+ if (!(richEl instanceof HTMLElement)) {
186
224
  hide();
187
225
  return;
188
226
  }
@@ -193,9 +231,14 @@ function onSelectionChange() {
193
231
 
194
232
  /**
195
233
  * Mount the rich text toolbar. Call once during editor boot.
196
- * @param {{ state: object, showToast: Function, showLinkPopup?: Function }} opts
234
+ * @param {object} options
235
+ * @param {RichEditorState} options.state
236
+ * @param {(message: string, kind: 'success' | 'error') => unknown} options.showToast
237
+ * @param {ShowLinkPopup} [options.showLinkPopup]
197
238
  */
198
239
  export function mountRichToolbar({ state, showToast, showLinkPopup }) {
240
+ editorState = state;
241
+ void showToast;
199
242
  showLinkPopupFn = showLinkPopup || null;
200
243
  document.addEventListener('selectionchange', onSelectionChange);
201
244
  }
@@ -10,9 +10,10 @@
10
10
  // import it). They are `export`ed so tests/unit/sanitizer-parity.test.ts can
11
11
  // assert they never drift from the source of truth — CI fails on divergence.
12
12
  export const ALLOWED_TAGS = new Set([
13
- 'b', 'strong', 'i', 'em', 'u', 's', 'a', 'br', 'sub', 'sup', 'span',
13
+ 'b', 'strong', 'i', 'em', 'u', 's', 'a', 'br', 'sub', 'sup', 'span', 'code',
14
14
  ]);
15
15
 
16
+ /** @type {Readonly<Record<string, ReadonlySet<string>>>} */
16
17
  export const ALLOWED_ATTRS = {
17
18
  a: new Set(['href', 'target', 'rel']),
18
19
  };
@@ -50,9 +51,12 @@ export function classAllowed(cls, patterns) {
50
51
  export function sanitizeHtml(html, options) {
51
52
  if (!html) return '';
52
53
 
54
+ const injected = typeof window !== 'undefined'
55
+ ? /** @type {Window & { __CARET__?: { allowedClasses?: Record<string, readonly string[]> } }} */ (window).__CARET__
56
+ : undefined;
53
57
  const allowedClasses =
54
58
  options?.allowedClasses ??
55
- (typeof window !== 'undefined' ? window.__CARET__?.allowedClasses : undefined) ??
59
+ injected?.allowedClasses ??
56
60
  null;
57
61
 
58
62
  const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
@@ -1,26 +1,55 @@
1
- import { buildCmsUrl } from './config.js';
1
+ import { buildCmsUrl, isPolicyDraftMode } from './config.js';
2
2
  import { mutateHeaders, readHeaders } from './security.js';
3
3
 
4
+ /** @typedef {{ revision: number, data: Record<string, unknown> }} EntrySnapshot */
5
+ /**
6
+ * @typedef {{ ok: true, revision?: number }
7
+ * | { ok: false, reason: 'conflict', currentRevision?: number, latestValue?: string }
8
+ * | { ok: false, reason: 'unauthorized' | 'error' }} SaveResult
9
+ */
10
+ /** @typedef {'saving' | 'idle' | 'error'} SaveStatus */
11
+ /** @typedef {{ type: 'save_field', collection: string, id: string, field: string, value: string, expectedRevision?: number }} SaveFieldPayload */
12
+
13
+ /** @param {string} collection @param {string} id @returns {string} */
4
14
  function entryKey(collection, id) {
5
15
  return `${collection}::${id}`;
6
16
  }
7
17
 
18
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
8
19
  function isRecord(value) {
9
- return typeof value === 'object' && value !== null;
20
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ }
22
+
23
+ /** @param {unknown} value @returns {value is number} */
24
+ function isRevision(value) {
25
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
10
26
  }
11
27
 
28
+ /** @param {unknown} data @param {string} path @returns {string | undefined} */
12
29
  function getNestedStringValue(data, path) {
13
30
  if (!isRecord(data)) return undefined;
14
31
 
32
+ /** @type {unknown} */
15
33
  let current = data;
16
34
  for (const segment of path.split('.')) {
17
- if (!isRecord(current) && !Array.isArray(current)) return undefined;
18
- current = current[segment];
35
+ if (Array.isArray(current)) {
36
+ if (!/^\d+$/.test(segment)) return undefined;
37
+ current = current[Number(segment)];
38
+ } else if (isRecord(current)) {
39
+ if (!Object.hasOwn(current, segment)) return undefined;
40
+ current = current[segment];
41
+ } else return undefined;
19
42
  }
20
43
 
21
44
  return typeof current === 'string' ? current : undefined;
22
45
  }
23
46
 
47
+ /**
48
+ * @param {string} collection
49
+ * @param {string} id
50
+ * @param {() => void} onUnauthorized
51
+ * @returns {Promise<EntrySnapshot | null>}
52
+ */
24
53
  async function fetchEntrySnapshot(collection, id, onUnauthorized) {
25
54
  const res = await fetch(buildCmsUrl('/entries', { collection, id }), {
26
55
  headers: readHeaders(),
@@ -32,27 +61,42 @@ async function fetchEntrySnapshot(collection, id, onUnauthorized) {
32
61
  if (!res.ok) throw new Error('Failed to load entry');
33
62
 
34
63
  const json = await res.json();
35
- const entry = Array.isArray(json.entries) ? json.entries[0] : null;
36
- if (!entry || !isRecord(entry.data)) {
64
+ if (!isRecord(json) || !Array.isArray(json.entries)) {
65
+ throw new Error('Invalid entry response');
66
+ }
67
+ const entry = json.entries[0];
68
+ if (entry === undefined) {
37
69
  return { revision: 0, data: {} };
38
70
  }
71
+ if (!isRecord(entry) || !isRecord(entry.data) || !isRevision(entry.revision)) {
72
+ throw new Error('Invalid entry response');
73
+ }
39
74
 
40
75
  return {
41
- revision: Number.isInteger(entry.revision) ? entry.revision : 0,
76
+ revision: entry.revision,
42
77
  data: entry.data,
43
78
  };
44
79
  }
45
80
 
81
+ /**
82
+ * @param {object} options
83
+ * @param {(status: SaveStatus, message: string) => void} options.setStatus
84
+ * @param {() => void} options.onUnauthorized
85
+ * @returns {(collection: string, id: string, field: string, value: string) => Promise<SaveResult>}
86
+ */
46
87
  export function createSaveField({ setStatus, onUnauthorized }) {
88
+ /** @type {Promise<unknown>} */
47
89
  let saveQueue = Promise.resolve();
90
+ /** @type {Map<string, number>} */
48
91
  const revisionByEntry = new Map();
92
+ /** @type {Map<string, Promise<number | null>>} */
49
93
  const revisionLoadByEntry = new Map();
50
94
 
95
+ /** @param {string} collection @param {string} id @returns {Promise<number | null>} */
51
96
  async function ensureRevision(collection, id) {
52
97
  const key = entryKey(collection, id);
53
- if (revisionByEntry.has(key)) {
54
- return revisionByEntry.get(key);
55
- }
98
+ const cached = revisionByEntry.get(key);
99
+ if (cached !== undefined) return cached;
56
100
 
57
101
  const inFlight = revisionLoadByEntry.get(key);
58
102
  if (inFlight) return inFlight;
@@ -70,6 +114,10 @@ export function createSaveField({ setStatus, onUnauthorized }) {
70
114
  return task;
71
115
  }
72
116
 
117
+ /**
118
+ * @param {string} collection @param {string} id @param {string} field
119
+ * @returns {Promise<{ currentRevision: number | undefined, latestValue: string | undefined }>}
120
+ */
73
121
  async function readLatestFieldValue(collection, id, field) {
74
122
  const snapshot = await fetchEntrySnapshot(collection, id, onUnauthorized);
75
123
  if (!snapshot) return { currentRevision: undefined, latestValue: undefined };
@@ -81,6 +129,10 @@ export function createSaveField({ setStatus, onUnauthorized }) {
81
129
  };
82
130
  }
83
131
 
132
+ /**
133
+ * @param {string} collection @param {string} id @param {string} field @param {string} value
134
+ * @returns {Promise<SaveResult>}
135
+ */
84
136
  async function execSave(collection, id, field, value) {
85
137
  setStatus('saving', 'Saving...');
86
138
  const key = entryKey(collection, id);
@@ -91,6 +143,7 @@ export function createSaveField({ setStatus, onUnauthorized }) {
91
143
  return { ok: false, reason: 'unauthorized' };
92
144
  }
93
145
 
146
+ /** @type {SaveFieldPayload} */
94
147
  const body = { type: 'save_field', collection, id, field, value };
95
148
  if (typeof expectedRevision === 'number') {
96
149
  body.expectedRevision = expectedRevision;
@@ -106,16 +159,18 @@ export function createSaveField({ setStatus, onUnauthorized }) {
106
159
  return { ok: false, reason: 'unauthorized' };
107
160
  }
108
161
 
109
- const json = await res.json().catch(() => ({}));
162
+ const parsed = await res.json().catch(() => null);
163
+ const json = isRecord(parsed) ? parsed : {};
110
164
  if (!res.ok) {
111
165
  if (res.status === 409) {
112
- const currentRevision = Number.isInteger(json.currentRevision)
166
+ const currentRevision = isRevision(json.currentRevision)
113
167
  ? json.currentRevision
114
168
  : undefined;
115
169
  if (typeof currentRevision === 'number') {
116
170
  revisionByEntry.set(key, currentRevision);
117
171
  }
118
172
 
173
+ /** @type {{ currentRevision: number | undefined, latestValue: string | undefined }} */
119
174
  let latest = { currentRevision: undefined, latestValue: undefined };
120
175
  try {
121
176
  latest = await readLatestFieldValue(collection, id, field);
@@ -138,16 +193,18 @@ export function createSaveField({ setStatus, onUnauthorized }) {
138
193
  return { ok: false, reason: 'error' };
139
194
  }
140
195
 
141
- const nextRevision = Number.isInteger(json.revision)
196
+ const fallbackRevision = typeof expectedRevision === 'number' && expectedRevision < Number.MAX_SAFE_INTEGER
197
+ ? expectedRevision + 1
198
+ : undefined;
199
+ const nextRevision = isRevision(json.revision)
142
200
  ? json.revision
143
- : typeof expectedRevision === 'number'
144
- ? expectedRevision + 1
145
- : undefined;
201
+ : fallbackRevision;
146
202
  if (typeof nextRevision === 'number') {
147
203
  revisionByEntry.set(key, nextRevision);
148
204
  }
149
205
 
150
- setStatus('idle', 'Saved');
206
+ setStatus('idle', isPolicyDraftMode() ? 'Draft saved' : 'Saved');
207
+ if (isPolicyDraftMode()) window.dispatchEvent(new CustomEvent('cms:draftSaved'));
151
208
  return { ok: true, revision: nextRevision };
152
209
  } catch {
153
210
  setStatus('error', 'Save failed');
@@ -155,6 +212,7 @@ export function createSaveField({ setStatus, onUnauthorized }) {
155
212
  }
156
213
  }
157
214
 
215
+ /** @param {string} collection @param {string} id @param {string} field @param {string} value */
158
216
  return function saveField(collection, id, field, value) {
159
217
  const task = saveQueue.then(() => execSave(collection, id, field, value));
160
218
  saveQueue = task.then(
@@ -1,3 +1,6 @@
1
+ /**
2
+ * @param {{ sectionNode: HTMLElement, sectionId: string, setActiveSection: (sectionId: string) => void }} options
3
+ */
1
4
  export function bindSectionActivation({ sectionNode, sectionId, setActiveSection }) {
2
5
  sectionNode.addEventListener('mouseenter', () => setActiveSection(sectionId));
3
6
  sectionNode.addEventListener('focusin', () => setActiveSection(sectionId));
@@ -2,6 +2,17 @@ import { buildCmsUrl } from '../config.js';
2
2
  import { isRecord } from './utils.js';
3
3
  import { mutateHeaders, readHeaders } from '../security.js';
4
4
 
5
+ /** @typedef {import('./model.js').Section} Section */
6
+
7
+ /** @param {unknown} value @returns {value is number} */
8
+ function isRevision(value) {
9
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
10
+ }
11
+
12
+ /**
13
+ * @param {{ collection: string, id: string, onUnauthorized: () => void }} options
14
+ * @returns {Promise<{ data: Record<string, unknown>, revision: number } | null>}
15
+ */
5
16
  export async function fetchPageEntry({ collection, id, onUnauthorized }) {
6
17
  const res = await fetch(buildCmsUrl('/entries', { collection, id }), {
7
18
  headers: readHeaders(),
@@ -13,18 +24,23 @@ export async function fetchPageEntry({ collection, id, onUnauthorized }) {
13
24
  if (!res.ok) throw new Error('Failed to load page data');
14
25
 
15
26
  const json = await res.json();
16
- const entry = Array.isArray(json.entries) ? json.entries[0] : null;
17
- if (!entry || !isRecord(entry.data)) {
27
+ const entry = isRecord(json) && Array.isArray(json.entries) ? json.entries[0] : null;
28
+ if (!isRecord(entry) || !isRecord(entry.data)) {
18
29
  throw new Error('Page entry not found');
19
30
  }
20
31
 
21
32
  return {
22
33
  data: entry.data,
23
- revision: Number.isInteger(entry.revision) ? entry.revision : 0,
34
+ revision: isRevision(entry.revision) ? entry.revision : 0,
24
35
  };
25
36
  }
26
37
 
38
+ /**
39
+ * @param {{ collection: string, id: string, sections: Section[], expectedRevision: number, onUnauthorized: () => void }} options
40
+ * @returns {Promise<{ ok: true, revision: number } | { ok: false, reason: 'unauthorized' | 'conflict' | 'error', currentRevision?: number | null, error?: string }>}
41
+ */
27
42
  export async function savePageLayout({
43
+ collection,
28
44
  id,
29
45
  sections,
30
46
  expectedRevision,
@@ -35,6 +51,7 @@ export async function savePageLayout({
35
51
  headers: await mutateHeaders(),
36
52
  body: JSON.stringify({
37
53
  type: 'update_page_layout',
54
+ collection,
38
55
  id,
39
56
  sections,
40
57
  expectedRevision,
@@ -46,13 +63,14 @@ export async function savePageLayout({
46
63
  return { ok: false, reason: 'unauthorized' };
47
64
  }
48
65
 
49
- const json = await res.json().catch(() => ({}));
66
+ const rawJson = await res.json().catch(() => ({}));
67
+ const json = isRecord(rawJson) ? rawJson : {};
50
68
  if (!res.ok) {
51
69
  if (res.status === 409) {
52
70
  return {
53
71
  ok: false,
54
72
  reason: 'conflict',
55
- currentRevision: Number.isInteger(json.currentRevision) ? json.currentRevision : null,
73
+ currentRevision: isRevision(json.currentRevision) ? json.currentRevision : null,
56
74
  };
57
75
  }
58
76
  const issue = Array.isArray(json.issues) ? json.issues[0] : null;
@@ -78,9 +96,9 @@ export async function savePageLayout({
78
96
  return {
79
97
  ok: true,
80
98
  revision:
81
- Number.isInteger(json.revision)
99
+ isRevision(json.revision)
82
100
  ? json.revision
83
- : Number.isInteger(expectedRevision)
101
+ : isRevision(expectedRevision)
84
102
  ? expectedRevision + 1
85
103
  : 1,
86
104
  };