@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
@@ -1,3 +1,4 @@
1
+ import { mountParagraphGroups, paragraphHtml, pasteParagraphText } from './md-paragraphs.js';
1
2
  /**
2
3
  * Inline editing for markdown body blocks (`data-caret-md`).
3
4
  *
@@ -20,6 +21,15 @@ import { sanitizeHtml } from './sanitize.js';
20
21
  import { buildCmsUrl } from './config.js';
21
22
  import { mutateHeaders } from './security.js';
22
23
 
24
+ /** @typedef {HTMLElement & { _caretSkipSave?: boolean }} MdEditableElement */
25
+ /** @typedef {{ collection: string, id: string, blockPath: string }} MdBinding */
26
+ /** @typedef {{ blockPath: string, src: string, html: string }} ParagraphSource */
27
+ /** @typedef {{ sources: ParagraphSource[], paragraphs: string[] }} ParagraphGroup */
28
+ /** @typedef {{ dirtyEls: Set<Element>, linkPopupEl: HTMLElement | null }} MdEditorState */
29
+ /** @typedef {{ ok: true, skipped?: boolean } | { ok: false, reason: 'unauthorized' | 'stale' | 'error' }} MdSaveResult */
30
+ /** @typedef {{ block: 'heading', level: number } | { block: 'paragraph', nested: boolean }} MdBlockContext */
31
+
32
+ /** @type {WeakMap<HTMLElement, string>} */
23
33
  const snapshots = new WeakMap();
24
34
 
25
35
  // All md-block saves are chained through one queue (same pattern as
@@ -27,9 +37,12 @@ const snapshots = new WeakMap();
27
37
  // the network and land out of order — both return ok, and the older content
28
38
  // silently wins. A per-element generation counter additionally drops a queued
29
39
  // save that a newer blur has superseded.
40
+ /** @type {Promise<unknown>} */
30
41
  let saveChain = Promise.resolve();
42
+ /** @type {WeakMap<HTMLElement, number>} */
31
43
  const generations = new WeakMap();
32
44
 
45
+ /** @param {() => Promise<MdSaveResult> | MdSaveResult} task @returns {Promise<MdSaveResult>} */
33
46
  function enqueueSave(task) {
34
47
  const run = saveChain.then(task, task);
35
48
  saveChain = run.then(
@@ -40,14 +53,52 @@ function enqueueSave(task) {
40
53
  }
41
54
 
42
55
  /** Parse `collection::id::body::blockPath`, or null. */
56
+ /** @param {string | null} raw @returns {MdBinding | null} */
43
57
  function parseMdBinding(raw) {
44
58
  const parts = (raw || '').split('::');
45
59
  if (parts.length !== 4 || parts[2] !== 'body') return null;
60
+ if (!/^[a-z][a-z0-9_-]*$/.test(parts[0]) || !/^[a-z0-9][a-z0-9_-]*$/.test(parts[1])) return null;
46
61
  if (!/^\d+(\.\d+)*$/.test(parts[3])) return null;
47
62
  return { collection: parts[0], id: parts[1], blockPath: parts[3] };
48
63
  }
49
64
 
65
+ /**
66
+ * Parse the server-authored structural paragraph source list. Attribute data
67
+ * is still a browser input boundary: extensions or page scripts can alter it,
68
+ * so malformed data must leave the group read-only instead of crashing mount
69
+ * or being echoed into a mutation.
70
+ * @param {string | null} raw
71
+ * @returns {ParagraphSource[] | null}
72
+ */
73
+ export function parseParagraphSources(raw) {
74
+ if (!raw) return null;
75
+ try {
76
+ const value = JSON.parse(raw);
77
+ if (!Array.isArray(value) || value.length < 1 || value.length > 128) return null;
78
+ /** @type {ParagraphSource[]} */
79
+ const sources = [];
80
+ for (const source of value) {
81
+ if (typeof source !== 'object' || source === null ||
82
+ typeof source.blockPath !== 'string' || !/^\d+$/.test(source.blockPath) ||
83
+ typeof source.src !== 'string' || !/^\d+:\d+:[0-9a-f]{8}$/.test(source.src) ||
84
+ typeof source.html !== 'string') return null;
85
+ const [start, end] = source.src.split(':').map(Number);
86
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= end) return null;
87
+ const previous = sources[sources.length - 1];
88
+ if (previous) {
89
+ const previousEnd = Number(previous.src.split(':')[1]);
90
+ if (Number(source.blockPath) !== Number(previous.blockPath) + 1 || start <= previousEnd) return null;
91
+ }
92
+ sources.push({ blockPath: source.blockPath, src: source.src, html: source.html });
93
+ }
94
+ return sources;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
50
100
  /** Serialization context from the DOM — mirrors the server's derivation. */
101
+ /** @param {HTMLElement} el @returns {MdBlockContext} */
51
102
  function blockContextOf(el) {
52
103
  const heading = /^h([1-6])$/.exec(el.tagName.toLowerCase());
53
104
  if (heading) return { block: 'heading', level: Number(heading[1]) };
@@ -55,26 +106,46 @@ function blockContextOf(el) {
55
106
  return { block: 'paragraph', nested };
56
107
  }
57
108
 
58
- async function saveMdBlock(binding, src, html) {
59
- const res = await fetch(buildCmsUrl('/mutate'), {
60
- method: 'POST',
61
- headers: await mutateHeaders(),
62
- body: JSON.stringify({
63
- type: 'md_block',
64
- collection: binding.collection,
65
- id: binding.id,
66
- blockPath: binding.blockPath,
67
- src,
68
- html,
69
- }),
70
- });
71
- if (res.status === 401) return { ok: false, reason: 'unauthorized' };
72
- if (res.status === 409) return { ok: false, reason: 'stale' };
73
- if (!res.ok) return { ok: false, reason: 'error' };
74
- return { ok: true };
109
+ /**
110
+ * @param {MdBinding} binding
111
+ * @param {string} src
112
+ * @param {string} html
113
+ * @param {ParagraphGroup | undefined} group
114
+ * @returns {Promise<MdSaveResult>}
115
+ */
116
+ async function saveMdBlock(binding, src, html, group) {
117
+ try {
118
+ const res = await fetch(buildCmsUrl('/mutate'), {
119
+ method: 'POST',
120
+ headers: await mutateHeaders(),
121
+ body: JSON.stringify({
122
+ type: 'md_block',
123
+ collection: binding.collection,
124
+ id: binding.id,
125
+ blockPath: binding.blockPath,
126
+ src,
127
+ html,
128
+ ...group,
129
+ }),
130
+ });
131
+ if (res.status === 401) return { ok: false, reason: 'unauthorized' };
132
+ if (res.status === 409) return { ok: false, reason: 'stale' };
133
+ if (!res.ok) return { ok: false, reason: 'error' };
134
+ return { ok: true };
135
+ } catch {
136
+ return { ok: false, reason: 'error' };
137
+ }
75
138
  }
76
139
 
140
+ /**
141
+ * @param {object} options
142
+ * @param {MdEditorState} options.state
143
+ * @param {(element: Element, success: boolean) => void} options.flash
144
+ * @param {(message: string, kind: 'success' | 'error') => unknown} options.showToast
145
+ * @param {() => void} options.onUnauthorized
146
+ */
77
147
  export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized }) {
148
+ mountParagraphGroups();
78
149
  document.querySelectorAll('[data-caret-md]').forEach((el) => {
79
150
  if (!(el instanceof HTMLElement)) return;
80
151
  if (el.dataset.caretMdMounted === 'true') return;
@@ -82,15 +153,26 @@ export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized })
82
153
  const binding = parseMdBinding(el.getAttribute('data-caret-md'));
83
154
  const src = el.getAttribute('data-caret-md-src') || '';
84
155
  if (!binding || !src) return;
85
- el.dataset.caretMdMounted = 'true';
86
-
87
156
  const ctx = blockContextOf(el);
157
+ const sourcesRaw = el.getAttribute('data-caret-md-sources');
158
+ const sources = sourcesRaw === null ? null : parseParagraphSources(sourcesRaw);
159
+ // A structural group with missing or malformed source metadata cannot be
160
+ // saved safely as one block. Keep it visible but do not make it editable.
161
+ if (sourcesRaw !== null && sources === null) return;
162
+ if (sources && (sources[0].blockPath !== binding.blockPath || sources[0].src !== src)) return;
163
+ el.dataset.caretMdMounted = 'true';
164
+ if (sources) {
165
+ el.classList.add('cms-md-paragraphs');
166
+ el.addEventListener('paste', pasteParagraphText);
167
+ // A drop can import arbitrary block structure outside the paste path.
168
+ el.addEventListener('drop', event => event.preventDefault());
169
+ }
88
170
 
89
171
  el.setAttribute('contenteditable', 'true');
90
172
  el.classList.add('cms-editable', 'cms-md-block');
91
173
 
92
174
  el.addEventListener('keydown', (event) => {
93
- if (event.key !== 'Enter') return;
175
+ if (sources || event.key !== 'Enter') return;
94
176
  if (event.shiftKey && ctx.block === 'paragraph' && !ctx.nested) {
95
177
  return; // hard break in a top-level paragraph — allowed
96
178
  }
@@ -98,11 +180,20 @@ export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized })
98
180
  showToast(
99
181
  event.shiftKey
100
182
  ? 'Line breaks are not supported in this block'
101
- : 'Adding new blocks is coming in a future version',
183
+ : 'Paragraph changes are not supported in this block',
102
184
  'error',
103
185
  );
104
186
  });
105
187
 
188
+ el.addEventListener('beforeinput', (rawEvent) => {
189
+ const event = /** @type {InputEvent} */ (rawEvent);
190
+ if (!sources && (event.inputType === 'insertParagraph' ||
191
+ (event.inputType === 'insertLineBreak' && (ctx.block !== 'paragraph' || ctx.nested)))) {
192
+ event.preventDefault();
193
+ showToast('Paragraph changes are not supported in this block', 'error');
194
+ }
195
+ });
196
+
106
197
  el.addEventListener('focus', () => {
107
198
  snapshots.set(el, el.innerHTML);
108
199
  });
@@ -113,23 +204,34 @@ export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized })
113
204
  });
114
205
 
115
206
  el.addEventListener('blur', async () => {
116
- if (el._caretSkipSave) {
117
- el._caretSkipSave = false;
207
+ if (state.linkPopupEl === el) return;
208
+
209
+ const editableEl = /** @type {MdEditableElement} */ (el);
210
+ if (editableEl._caretSkipSave) {
211
+ editableEl._caretSkipSave = false;
118
212
  state.dirtyEls.delete(el);
119
213
  el.innerHTML = snapshots.get(el) || '';
120
214
  return;
121
215
  }
122
216
 
123
217
  const snapshot = snapshots.get(el) ?? '';
124
- const clean = sanitizeHtml(el.innerHTML);
125
- el.innerHTML = clean; // normalize what the user sees to what will save
126
- if (clean === snapshot) return;
218
+ const submitted = el.innerHTML;
219
+ const paragraphs = sources ? paragraphHtml(el) : null;
220
+ const clean = paragraphs ? paragraphs.map(p => `<p>${p || '<br>'}</p>`).join('') : sanitizeHtml(submitted);
221
+ if (!sources) el.innerHTML = clean;
222
+ if (submitted === snapshot && !state.dirtyEls.has(el)) return;
127
223
 
128
224
  // Pre-flight the serialization locally so unrepresentable content fails
129
225
  // with an immediate message instead of a server round-trip. The server
130
226
  // re-derives independently — this is UX, not the trust boundary.
131
227
  try {
132
- serializeBlock(domToSNodes(el), ctx);
228
+ if (paragraphs) {
229
+ for (const html of paragraphs) {
230
+ const paragraph = document.createElement('p');
231
+ paragraph.innerHTML = html;
232
+ serializeBlock(domToSNodes(paragraph), { block: 'paragraph', nested: false });
233
+ }
234
+ } else serializeBlock(domToSNodes(el), ctx);
133
235
  } catch (error) {
134
236
  if (error instanceof SerializeError) {
135
237
  showToast('This formatting cannot be saved in a markdown block', 'error');
@@ -147,29 +249,30 @@ export function mountMdBlockEditors({ state, flash, showToast, onUnauthorized })
147
249
  // A newer blur queued behind us supersedes this content — skip so the
148
250
  // older payload can't overwrite the newer draft.
149
251
  if (generations.get(el) !== generation) return { ok: true, skipped: true };
150
- return saveMdBlock(binding, src, clean);
252
+ const group = sources && paragraphs ? { sources, paragraphs } : undefined;
253
+ return saveMdBlock(binding, src, sources ? '' : clean, group);
151
254
  });
152
255
  el.classList.remove('cms-saving');
153
- if (result.skipped) return;
256
+ if (('skipped' in result && result.skipped) || generations.get(el) !== generation) return;
154
257
  flash(el, result.ok);
155
258
 
156
259
  if (result.ok) {
157
- state.dirtyEls.delete(el);
158
- snapshots.set(el, clean);
260
+ if (el.innerHTML === (sources ? submitted : clean)) {
261
+ state.dirtyEls.delete(el);
262
+ snapshots.set(el, el.innerHTML);
263
+ }
159
264
  showToast('Draft saved — publish to update the file', 'success');
160
265
  // Signal the toolbar so server-delivery mode reveals its Publish/Discard
161
266
  // controls (hidden until a body draft is pending).
162
267
  window.dispatchEvent(new CustomEvent('cms:draftSaved'));
163
268
  } else if (result.reason === 'stale') {
164
- state.dirtyEls.delete(el);
165
- el.innerHTML = snapshot;
166
- showToast('This content changed since the page loaded. Reload to edit it.', 'error');
269
+ state.dirtyEls.add(el);
270
+ showToast('This content changed. Copy your edits, then reload before saving.', 'error');
167
271
  } else if (result.reason === 'unauthorized') {
168
272
  onUnauthorized();
169
273
  } else {
170
- state.dirtyEls.delete(el);
171
- el.innerHTML = snapshot;
172
- showToast('Failed to save. Changes reverted.', 'error');
274
+ state.dirtyEls.add(el);
275
+ showToast('Failed to save. Your edits are kept here; focus and leave the block to retry.', 'error');
173
276
  }
174
277
  });
175
278
  });
@@ -0,0 +1,98 @@
1
+ /** Native contenteditable provides paragraph split/merge, selection deletion,
2
+ * IME and undo. Each host owns only consecutive stamped top-level paragraphs. */
3
+ import { sanitizeHtml } from './sanitize.js';
4
+
5
+ /** @typedef {{ binding: string, prefix: string, blockPath: string, src: string }} ParagraphBinding */
6
+ /** @typedef {{ blockPath: string, src: string, html: string }} ParagraphSource */
7
+
8
+ /** @param {Element | null} element @returns {element is HTMLElement} */
9
+ function isParagraphCandidate(element) {
10
+ return element instanceof HTMLElement && element.matches('p[data-caret-md-paragraph="true"]') &&
11
+ [...element.querySelectorAll('*')].every(child => /^(B|STRONG|I|EM|A|BR|CODE)$/.test(child.tagName));
12
+ }
13
+
14
+ /** @param {HTMLElement} element @returns {ParagraphBinding | null} */
15
+ function readParagraphBinding(element) {
16
+ const binding = element.getAttribute('data-caret-md');
17
+ const src = element.getAttribute('data-caret-md-src');
18
+ if (!binding || !src) return null;
19
+ const parts = binding.split('::');
20
+ if (parts.length !== 4 || parts[2] !== 'body' || !/^\d+$/.test(parts[3])) return null;
21
+ if (!/^[a-z][a-z0-9_-]*$/.test(parts[0]) || !/^[a-z0-9][a-z0-9_-]*$/.test(parts[1])) return null;
22
+ if (!/^\d+:\d+:[0-9a-f]{8}$/.test(src)) return null;
23
+ const [start, end] = src.split(':').map(Number);
24
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start >= end) return null;
25
+ return { binding, prefix: parts.slice(0, 3).join('::'), blockPath: parts[3], src };
26
+ }
27
+
28
+ export function mountParagraphGroups() {
29
+ document.querySelectorAll('p[data-caret-md-paragraph="true"]').forEach(first => {
30
+ if (!isParagraphCandidate(first) || !first.isConnected || first.closest('li, blockquote, [data-caret-md-sources]')) return;
31
+ const firstBinding = readParagraphBinding(first);
32
+ if (!firstBinding) return;
33
+ const firstIndex = Number(firstBinding.blockPath);
34
+ /** @type {ParagraphSource[]} */
35
+ const sources = [];
36
+ /** @type {HTMLElement[]} */
37
+ const paragraphs = [];
38
+ /** @type {HTMLElement | null} */
39
+ let next = first;
40
+ let size = 0;
41
+ while (isParagraphCandidate(next)) {
42
+ const binding = readParagraphBinding(next);
43
+ if (!binding || binding.prefix !== firstBinding.prefix || Number(binding.blockPath) !== firstIndex + sources.length) break;
44
+ const html = next.getAttribute('data-caret-md-original') ?? next.innerHTML;
45
+ if (sources.length >= 128 || size + html.length * 2 > 48 * 1024) break;
46
+ size += html.length * 2;
47
+ sources.push({ blockPath: binding.blockPath, src: binding.src, html });
48
+ paragraphs.push(next);
49
+ // Only whitespace between adjacent elements may be crossed.
50
+ /** @type {ChildNode | null} */
51
+ let sibling = next.nextSibling;
52
+ while (sibling?.nodeType === Node.TEXT_NODE && !(sibling.textContent ?? '').trim()) sibling = sibling.nextSibling;
53
+ next = sibling instanceof HTMLElement ? sibling : null;
54
+ }
55
+ if (!sources.length) return;
56
+ const host = document.createElement('div');
57
+ host.setAttribute('data-caret-md', firstBinding.binding);
58
+ host.setAttribute('data-caret-md-src', sources[0].src);
59
+ host.setAttribute('data-caret-md-sources', JSON.stringify(sources));
60
+ first.before(host);
61
+ for (const paragraph of paragraphs) {
62
+ for (const attr of [...paragraph.attributes]) if (attr.name.startsWith('data-caret-md')) paragraph.removeAttribute(attr.name);
63
+ host.append(paragraph);
64
+ }
65
+ });
66
+ }
67
+
68
+ /** Normalize browser p/div wrappers; inline content goes through the existing
69
+ * sanitizer and serializer, independently for each paragraph. */
70
+ /** @param {HTMLElement} root @returns {string[]} */
71
+ export function paragraphHtml(root) {
72
+ /** @type {string[]} */
73
+ const result = [];
74
+ let inline = document.createElement('div');
75
+ const flush = () => {
76
+ if (inline.childNodes.length) { result.push(sanitizeHtml(inline.innerHTML)); inline = document.createElement('div'); }
77
+ };
78
+ for (const node of root.childNodes) {
79
+ if (node instanceof HTMLElement && /^(P|DIV)$/.test(node.tagName)) {
80
+ flush();
81
+ if (node.querySelector('p, div')) result.push(...paragraphHtml(node));
82
+ else result.push(sanitizeHtml(node.innerHTML));
83
+ } else inline.append(node.cloneNode(true));
84
+ }
85
+ flush();
86
+ return result.map(html => /^<br\s*\/?\s*>$/i.test(html) ? '' : html);
87
+ }
88
+
89
+ /** @param {ClipboardEvent} event */
90
+ export function pasteParagraphText(event) {
91
+ if (!event.clipboardData) return;
92
+ event.preventDefault();
93
+ const text = event.clipboardData.getData('text/plain');
94
+ const html = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
95
+ .replace(/\r\n?/g, '\n').split(/\n\s*\n/).map((p) => `<p>${p.replace(/\n/g, '<br>') || '<br>'}</p>`).join('');
96
+ // Editing command keeps paste in the browser's undo stack.
97
+ document.execCommand('insertHTML', false, html);
98
+ }
@@ -40,7 +40,7 @@ function escapeText(value) {
40
40
  * @returns {string}
41
41
  */
42
42
  function escapeLineStart(md) {
43
- const ws = /^\s*/.exec(md)[0];
43
+ const ws = /^\s*/.exec(md)?.[0] ?? '';
44
44
  const rest = md.slice(ws.length);
45
45
 
46
46
  const ordered = /^(\d{1,9})([.)])(\s|$)/.exec(rest);
@@ -1,5 +1,24 @@
1
1
  import { STUDIO_PATH } from './config.js';
2
2
 
3
+ /** @param {string} key @returns {string | null} */
4
+ function readSessionValue(key) {
5
+ try {
6
+ return sessionStorage.getItem(key);
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ /** @param {string} key @param {string} value */
13
+ function writeSessionValue(key, value) {
14
+ try {
15
+ sessionStorage.setItem(key, value);
16
+ } catch {
17
+ // Session storage is optional.
18
+ }
19
+ }
20
+
21
+ /** @param {{ studioButton: Element | null }} options */
3
22
  export function mountStudioPanel({ studioButton }) {
4
23
  if (!STUDIO_PATH) {
5
24
  studioButton?.setAttribute('hidden', 'hidden');
@@ -13,6 +32,10 @@ export function mountStudioPanel({ studioButton }) {
13
32
  panel.setAttribute('role', 'dialog');
14
33
  panel.setAttribute('aria-label', 'Content Studio');
15
34
  panel.setAttribute('aria-hidden', 'true');
35
+ panel.id = 'cms-studio-panel';
36
+ studioButton?.setAttribute('aria-controls', panel.id);
37
+ studioButton?.setAttribute('aria-haspopup', 'dialog');
38
+ studioButton?.setAttribute('aria-expanded', 'false');
16
39
 
17
40
  // Loading skeleton shown until the iframe reports load — the iframe is blank
18
41
  // until then, which reads as "broken".
@@ -25,23 +48,22 @@ export function mountStudioPanel({ studioButton }) {
25
48
 
26
49
  const iframe = document.createElement('iframe');
27
50
  let rememberedPath = '';
28
- try {
29
- const storedPath = sessionStorage.getItem('cms-panel-path') || '';
30
- if (storedPath === STUDIO_PATH || storedPath.startsWith(`${STUDIO_PATH}/`)) {
31
- rememberedPath = storedPath;
32
- }
33
- } catch {
34
- // Session storage is optional; the Studio root remains a safe fallback.
51
+ const storedPath = readSessionValue('cms-panel-path') || '';
52
+ if (storedPath === STUDIO_PATH || storedPath.startsWith(`${STUDIO_PATH}/`)) {
53
+ rememberedPath = storedPath;
35
54
  }
36
55
  iframe.src = rememberedPath || STUDIO_PATH;
37
56
  iframe.title = 'Content Studio';
38
57
  iframe.addEventListener('load', () => {
39
58
  loading.setAttribute('hidden', 'hidden');
40
59
  try {
41
- const currentPath = `${iframe.contentWindow.location.pathname}${iframe.contentWindow.location.search}`;
60
+ const iframeWindow = iframe.contentWindow;
61
+ if (!iframeWindow) return;
62
+ const currentPath = `${iframeWindow.location.pathname}${iframeWindow.location.search}`;
42
63
  if (currentPath === STUDIO_PATH || currentPath.startsWith(`${STUDIO_PATH}/`)) {
43
- sessionStorage.setItem('cms-panel-path', currentPath);
64
+ writeSessionValue('cms-panel-path', currentPath);
44
65
  }
66
+ iframeWindow.document.addEventListener('keydown', handleEscape);
45
67
  } catch {
46
68
  // The configured Studio may be cross-origin, in which case its route is
47
69
  // intentionally unavailable to the parent page.
@@ -51,8 +73,10 @@ export function mountStudioPanel({ studioButton }) {
51
73
  document.body.appendChild(panel);
52
74
 
53
75
  // Remember what had focus so we can restore it on close.
76
+ /** @type {HTMLElement | null} */
54
77
  let lastFocused = null;
55
78
 
79
+ /** @param {boolean} [animate] */
56
80
  function openPanel(animate = true) {
57
81
  lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
58
82
  if (!animate) {
@@ -64,7 +88,7 @@ export function mountStudioPanel({ studioButton }) {
64
88
  document.body.classList.add('cms-panel-open');
65
89
  studioButton?.classList.add('active');
66
90
  studioButton?.setAttribute('aria-expanded', 'true');
67
- sessionStorage.setItem('cms-panel-open', '1');
91
+ writeSessionValue('cms-panel-open', '1');
68
92
  // Move focus into the drawer so keyboard users aren't left behind the
69
93
  // covering panel. The iframe is focusable and hands focus to its own content.
70
94
  if (animate) {
@@ -88,7 +112,7 @@ export function mountStudioPanel({ studioButton }) {
88
112
  document.body.classList.remove('cms-panel-open');
89
113
  studioButton?.classList.remove('active');
90
114
  studioButton?.setAttribute('aria-expanded', 'false');
91
- sessionStorage.setItem('cms-panel-open', '0');
115
+ writeSessionValue('cms-panel-open', '0');
92
116
  // Return focus to the trigger (or wherever it was) so it isn't lost.
93
117
  const restore = lastFocused || studioButton;
94
118
  if (restore instanceof HTMLElement) {
@@ -110,18 +134,22 @@ export function mountStudioPanel({ studioButton }) {
110
134
  studioButton?.addEventListener('click', togglePanel);
111
135
 
112
136
  // Restore panel state from sessionStorage (without animation)
113
- if (sessionStorage.getItem('cms-panel-open') === '1') {
137
+ if (readSessionValue('cms-panel-open') === '1') {
114
138
  openPanel(false);
115
139
  }
116
140
 
117
- // Escape key closes panel (only when no CMS element is focused)
118
- document.addEventListener('keydown', (e) => {
141
+ /** @param {KeyboardEvent} e */
142
+ function handleEscape(e) {
119
143
  if (e.key !== 'Escape' || !panel.classList.contains('open')) return;
120
144
  const focused = document.activeElement;
121
145
  if (!focused || !focused.matches('[data-caret]')) {
122
146
  closePanel();
123
147
  }
124
- });
148
+ }
149
+
150
+ // Listen in both the page and same-origin Studio document. Keyboard events
151
+ // do not cross an iframe boundary after focus moves into the Studio.
152
+ document.addEventListener('keydown', handleEscape);
125
153
 
126
154
  return { iframe, closePanel };
127
155
  }
@@ -0,0 +1,44 @@
1
+ const PREVIEW_COOKIE = 'caret_preview';
2
+
3
+ /**
4
+ * @typedef {object} PreviewModeOptions
5
+ * @property {() => boolean} isStaticDelivery
6
+ * @property {() => boolean} isPolicyDraftMode
7
+ * @property {Pick<Document, 'cookie'>} documentRef
8
+ * @property {() => void} reload
9
+ */
10
+
11
+ /**
12
+ * Keep the preview cookie aligned with the active delivery mode.
13
+ *
14
+ * @param {PreviewModeOptions} options
15
+ */
16
+ export function createPreviewModeController(options) {
17
+ const { isStaticDelivery, isPolicyDraftMode, documentRef, reload } = options;
18
+
19
+ function previewActive() {
20
+ return documentRef.cookie
21
+ .split('; ')
22
+ .some(cookie => cookie === `${PREVIEW_COOKIE}=1`);
23
+ }
24
+
25
+ /** Static delivery always drafts, so enable preview before mounting the editor. */
26
+ function ensureStaticPreviewMode() {
27
+ if (!isStaticDelivery() || previewActive()) return false;
28
+ documentRef.cookie = `${PREVIEW_COOKIE}=1; path=/; SameSite=Lax`;
29
+ reload();
30
+ return true;
31
+ }
32
+
33
+ function normalizePreviewForDelivery() {
34
+ if (isPolicyDraftMode()) return false;
35
+ if (isStaticDelivery()) return ensureStaticPreviewMode();
36
+ if (!previewActive()) return false;
37
+
38
+ documentRef.cookie = `${PREVIEW_COOKIE}=; path=/; Max-Age=0; SameSite=Lax`;
39
+ reload();
40
+ return true;
41
+ }
42
+
43
+ return { ensureStaticPreviewMode, normalizePreviewForDelivery };
44
+ }
@@ -0,0 +1,100 @@
1
+ /** @typedef {'idle' | 'saving' | 'error'} ToolbarStatusState */
2
+ /** @typedef {'success' | 'error'} ToastKind */
3
+ /**
4
+ * @typedef {object} PublishPresentation
5
+ * @property {boolean} completed
6
+ * @property {boolean} reload
7
+ * @property {boolean} retryAvailable
8
+ * @property {{ state: ToolbarStatusState, text: string } | null} status
9
+ * @property {{ kind: ToastKind, message: string }} toast
10
+ */
11
+
12
+ /** @param {unknown} value */
13
+ function record(value) {
14
+ return value && typeof value === 'object' && !Array.isArray(value)
15
+ ? /** @type {Record<string, unknown>} */ (value)
16
+ : {};
17
+ }
18
+
19
+ /** @param {unknown} value */
20
+ function entries(value) {
21
+ return Array.isArray(value) ? value.map(record) : [];
22
+ }
23
+
24
+ /**
25
+ * Turn the publish API's mutually exclusive outcomes into one toolbar action.
26
+ * Keeping this pure makes recovery/conflict/deploy wording testable without DOM.
27
+ *
28
+ * @param {unknown} input
29
+ * @returns {PublishPresentation}
30
+ */
31
+ export function presentPublishResult(input) {
32
+ const data = record(input);
33
+ const published = entries(data.published);
34
+ const conflicts = entries(data.conflicts);
35
+ const failed = entries(data.failed);
36
+ const rebuild = record(data.rebuild);
37
+ const retryAvailable = data.retryAvailable === true;
38
+
39
+ if (failed.length > 0) {
40
+ return {
41
+ completed: false,
42
+ reload: false,
43
+ retryAvailable,
44
+ status: { state: 'error', text: 'Publish needs recovery' },
45
+ toast: {
46
+ kind: 'error',
47
+ message: `Finished ${published.length} change(s); ${failed.length} need recovery. Some content may already be published. Retry Publish after checking storage. Drafts are kept.`,
48
+ },
49
+ };
50
+ }
51
+
52
+ if (conflicts.length > 0) {
53
+ const labels = conflicts.map(entry => {
54
+ const collection = typeof entry.collection === 'string' ? entry.collection : 'unknown';
55
+ const id = typeof entry.id === 'string' ? entry.id : 'unknown';
56
+ return `${collection}/${id}`;
57
+ }).join(', ');
58
+ return {
59
+ completed: false,
60
+ reload: false,
61
+ retryAvailable,
62
+ status: { state: 'error', text: 'Draft conflict' },
63
+ toast: {
64
+ kind: 'error',
65
+ message: `Drafts kept for ${labels}. Published content changed, or the draft predates conflict tracking. Copy your edits before using Discard (which removes all your drafts), then reload and reapply them.`,
66
+ },
67
+ };
68
+ }
69
+
70
+ if (rebuild.triggered === true && rebuild.ok === false) {
71
+ return {
72
+ completed: false,
73
+ reload: false,
74
+ retryAvailable,
75
+ status: { state: 'error', text: 'Published, deploy failed' },
76
+ toast: {
77
+ kind: 'error',
78
+ message: 'Changes are published, but the deploy webhook failed. Check your CI settings.',
79
+ },
80
+ };
81
+ }
82
+
83
+ const rebuildTriggered = rebuild.triggered === true;
84
+ return {
85
+ completed: true,
86
+ reload: true,
87
+ retryAvailable,
88
+ status: null,
89
+ toast: {
90
+ kind: 'success',
91
+ message: rebuildTriggered
92
+ ? published.length > 0
93
+ ? `Published ${published.length} change(s) — rebuild started`
94
+ : 'Your site is rebuilding'
95
+ : published.length > 0
96
+ ? `Published ${published.length} change(s) — rebuild and deploy to update visitors`
97
+ : 'Already up to date',
98
+ },
99
+ };
100
+ }