@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,33 +1,40 @@
1
1
  import { SPACING_Y_VALUES } from './constants.js';
2
2
 
3
+ /** @param {string} key @returns {string} */
3
4
  export function humanizeSectionKey(key) {
4
5
  const short = key.startsWith('home.') ? key.slice('home.'.length) : key;
5
6
  return short.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
6
7
  }
7
8
 
9
+ /** @param {unknown} value @returns {string | undefined} */
8
10
  export function normalizeSpacingY(value) {
9
11
  if (typeof value !== 'string') return undefined;
10
12
  return SPACING_Y_VALUES.includes(value) && value !== 'default' ? value : undefined;
11
13
  }
12
14
 
15
+ /** @param {unknown} value @returns {string} */
13
16
  export function spacingToken(value) {
14
17
  return normalizeSpacingY(value) || 'default';
15
18
  }
16
19
 
20
+ /** @param {unknown} value @returns {number} */
17
21
  export function spacingIndex(value) {
18
22
  const idx = SPACING_Y_VALUES.indexOf(spacingToken(value));
19
23
  return idx === -1 ? 0 : idx;
20
24
  }
21
25
 
26
+ /** @param {unknown} value @returns {string} */
22
27
  export function spacingButtonLabel(value) {
23
28
  const token = spacingToken(value);
24
29
  return token === 'default' ? 'Space' : `Space:${token}`;
25
30
  }
26
31
 
32
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
27
33
  export function isRecord(value) {
28
34
  return typeof value === 'object' && value !== null && !Array.isArray(value);
29
35
  }
30
36
 
37
+ /** @param {string} key @returns {string} */
31
38
  export function generateSectionId(key) {
32
39
  return `sec-${key.replace(/\./g, '-')}-${Date.now().toString(36)}-${Math.random()
33
40
  .toString(36)
@@ -25,7 +25,6 @@
25
25
  .cms-section-editable.cms-section-disabled-preview .cms-section-chip {
26
26
  opacity: 1;
27
27
  transform: translateY(0);
28
- pointer-events: auto;
29
28
  }
30
29
 
31
30
  .cms-highlight-all .cms-section-editable {
@@ -57,7 +56,6 @@
57
56
  .cms-section-controls:focus-within {
58
57
  opacity: 1;
59
58
  transform: translateY(0);
60
- pointer-events: auto;
61
59
  }
62
60
 
63
61
  .cms-section-chip {
@@ -112,6 +110,14 @@
112
110
  padding: 5px 7px;
113
111
  cursor: pointer;
114
112
  transition: all 0.16s ease;
113
+ pointer-events: none;
114
+ }
115
+
116
+ .cms-section-editable:hover .cms-section-btn,
117
+ .cms-section-editable.cms-section-active .cms-section-btn,
118
+ .cms-section-controls:focus-within .cms-section-btn,
119
+ .cms-section-editable.cms-section-disabled-preview .cms-section-btn {
120
+ pointer-events: auto;
115
121
  }
116
122
 
117
123
  .cms-section-btn:hover {
@@ -173,7 +179,6 @@ body.cms-section-dragging .cms-section-controls {
173
179
  .cms-section-editable.cms-section-active .cms-section-boundary-actions,
174
180
  body.cms-spacing-dragging .cms-section-boundary-actions {
175
181
  opacity: 1;
176
- pointer-events: auto;
177
182
  }
178
183
 
179
184
  .cms-section-insert-handle {
@@ -189,6 +194,7 @@ body.cms-spacing-dragging .cms-section-boundary-actions {
189
194
  cursor: pointer;
190
195
  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.26);
191
196
  transition: border-color 0.16s ease, transform 0.16s ease, background 0.16s ease;
197
+ pointer-events: none;
192
198
  }
193
199
 
194
200
  .cms-section-insert-plus {
@@ -224,6 +230,15 @@ body.cms-spacing-dragging .cms-section-boundary-actions {
224
230
  cursor: ns-resize;
225
231
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
226
232
  transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease;
233
+ pointer-events: none;
234
+ }
235
+
236
+ .cms-section-editable:hover .cms-section-insert-handle,
237
+ .cms-section-editable.cms-section-active .cms-section-insert-handle,
238
+ .cms-section-editable:hover .cms-section-gap-handle,
239
+ .cms-section-editable.cms-section-active .cms-section-gap-handle,
240
+ body.cms-spacing-dragging .cms-section-gap-handle {
241
+ pointer-events: auto;
227
242
  }
228
243
 
229
244
  .cms-section-gap-grip {
@@ -1,15 +1,24 @@
1
1
  import { CLOUD, getCloudSessionToken, isCloudMode } from './config.js';
2
2
 
3
+ /**
4
+ * @param {string} action
5
+ * @returns {Promise<string | null>}
6
+ */
3
7
  async function getTurnstileToken(action) {
4
- if (typeof window.cmsGetTurnstileToken !== 'function') return null;
8
+ const getToken = /** @type {Window & { cmsGetTurnstileToken?: (action: string) => unknown }} */ (
9
+ window
10
+ ).cmsGetTurnstileToken;
11
+ if (typeof getToken !== 'function') return null;
5
12
  try {
6
- return await window.cmsGetTurnstileToken(action);
13
+ const token = await getToken(action);
14
+ return typeof token === 'string' && token.trim() ? token.trim() : null;
7
15
  } catch {
8
16
  return null;
9
17
  }
10
18
  }
11
19
 
12
20
  export async function mutateHeaders() {
21
+ /** @type {Record<string, string>} */
13
22
  const headers = { 'Content-Type': 'application/json', 'x-caret-request': '1' };
14
23
  if (isCloudMode()) {
15
24
  if (CLOUD?.publicToken) headers['x-caret-token'] = CLOUD.publicToken;
@@ -24,6 +33,7 @@ export async function mutateHeaders() {
24
33
  }
25
34
 
26
35
  export function readHeaders() {
36
+ /** @type {Record<string, string>} */
27
37
  const headers = {};
28
38
  if (isCloudMode()) {
29
39
  if (CLOUD?.publicToken) headers['x-caret-token'] = CLOUD.publicToken;
@@ -34,6 +44,7 @@ export function readHeaders() {
34
44
  }
35
45
 
36
46
  export async function uploadHeaders() {
47
+ /** @type {Record<string, string>} */
37
48
  const headers = { 'x-caret-request': '1' };
38
49
  if (isCloudMode()) {
39
50
  if (CLOUD?.publicToken) headers['x-caret-token'] = CLOUD.publicToken;
@@ -12,6 +12,7 @@
12
12
  * Decode logic mirrors runtime/stega.ts (the editor bundle is hand-authored JS,
13
13
  * separate from the TS runtime, so the few lines are intentionally duplicated).
14
14
  */
15
+ import { parseCaretAttr } from './helpers.js';
15
16
 
16
17
  const TAG_BASE = 0xe0000;
17
18
  const TAG_MAX = 0xe007f;
@@ -19,12 +20,14 @@ const START = String.fromCodePoint(TAG_BASE + 0x00);
19
20
  const END = String.fromCodePoint(TAG_BASE + 0x01);
20
21
  const TAG_RE = /[\u{E0000}-\u{E007F}]/gu;
21
22
 
23
+ /** @param {string} b64 @returns {string} */
22
24
  function fromBase64(b64) {
23
25
  const binary = atob(b64);
24
26
  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
25
27
  return new TextDecoder().decode(bytes);
26
28
  }
27
29
 
30
+ /** @param {string} value @returns {unknown} */
28
31
  export function stegaDecode(value) {
29
32
  const start = value.indexOf(START);
30
33
  if (start === -1) return undefined;
@@ -43,6 +46,7 @@ export function stegaDecode(value) {
43
46
  }
44
47
  }
45
48
 
49
+ /** @param {string} value @returns {string} */
46
50
  export function stegaClean(value) {
47
51
  return value.replace(TAG_RE, '');
48
52
  }
@@ -51,28 +55,37 @@ export function stegaClean(value) {
51
55
  * Find every stega-tagged text node under `root`, decode its key, strip the
52
56
  * invisible characters, and tag the containing element with `data-caret`.
53
57
  * Returns the number of elements newly promoted.
58
+ * @param {Node | null} [root]
59
+ * @returns {number}
54
60
  */
55
61
  export function hydrateStega(root) {
56
62
  const scope = root || document.body;
57
63
  if (!scope) return 0;
58
64
 
59
65
  const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT);
66
+ /** @type {Text[]} */
60
67
  const tagged = [];
61
68
  let node = walker.nextNode();
62
69
  while (node) {
63
- if (node.nodeValue && node.nodeValue.indexOf(START) !== -1) tagged.push(node);
70
+ if (node instanceof Text && node.nodeValue && node.nodeValue.indexOf(START) !== -1) {
71
+ tagged.push(node);
72
+ }
64
73
  node = walker.nextNode();
65
74
  }
66
75
 
67
76
  let promoted = 0;
68
77
  for (const textNode of tagged) {
69
- const key = stegaDecode(textNode.nodeValue);
78
+ const value = textNode.nodeValue || '';
79
+ const key = stegaDecode(value);
70
80
  // Always strip the invisible characters so they never affect layout/copy.
71
- textNode.nodeValue = stegaClean(textNode.nodeValue);
72
- if (typeof key !== 'string' || key.indexOf('::') === -1) continue;
81
+ textNode.nodeValue = stegaClean(value);
82
+ if (typeof key !== 'string') continue;
83
+ const binding = parseCaretAttr(key);
84
+ if (!binding?.collection || !binding.id) continue;
73
85
 
74
86
  const el = textNode.parentElement;
75
87
  if (!el || el.hasAttribute('data-caret')) continue; // explicit attr wins
88
+ if (el.closest('script, style, template, noscript')) continue;
76
89
  el.setAttribute('data-caret', key);
77
90
  promoted++;
78
91
  }
@@ -3,14 +3,136 @@ import { parseCaretAttr, resolveBinding } from './helpers.js';
3
3
  import { STUDIO_PATH } from './config.js';
4
4
 
5
5
  const SYNC_CHANNEL = 'caretcms:content';
6
+ const PENDING_SELECTION_KEY = 'caretcms:pending-preview-selection';
7
+
8
+ /** @typedef {{ dirtyEls: Set<Element> }} InlineEditorState */
9
+ /**
10
+ * @typedef {object} SelectionMessage
11
+ * @property {'cms:field-selected'} type
12
+ * @property {string} collection
13
+ * @property {string} id
14
+ * @property {string} field
15
+ * @property {string} [previewPath]
16
+ * @property {boolean} [embedded]
17
+ * @property {string} [source]
18
+ */
19
+ /**
20
+ * @typedef {object} EntryReadyMessage
21
+ * @property {'cms:entry-ready'} type
22
+ * @property {string} collection
23
+ * @property {string} id
24
+ * @property {boolean} [embedded]
25
+ */
26
+ /**
27
+ * @typedef {object} ChangeMessage
28
+ * @property {'cms:preview' | 'cms:saved' | 'cms:created' | 'cms:deleted'} type
29
+ * @property {string} collection
30
+ * @property {string} id
31
+ * @property {Record<string, unknown> | null} [data]
32
+ * @property {string} [studioPath]
33
+ * @property {boolean} [embedded]
34
+ */
35
+ /** @typedef {SelectionMessage | EntryReadyMessage | ChangeMessage} SyncMessage */
36
+ /**
37
+ * @typedef {object} PendingSelection
38
+ * @property {string} collection
39
+ * @property {string} id
40
+ * @property {string} field
41
+ * @property {number} savedAt
42
+ */
43
+
44
+ /**
45
+ * @param {unknown} value
46
+ * @returns {value is Record<string, unknown>}
47
+ */
48
+ function isRecord(value) {
49
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
50
+ }
51
+
52
+ /** @param {unknown} value @returns {value is string} */
53
+ function isNonEmptyString(value) {
54
+ return typeof value === 'string' && value.length > 0;
55
+ }
56
+
57
+ /**
58
+ * Normalize messages before they reach selectors, navigation, or DOM updates.
59
+ * @param {unknown} value
60
+ * @returns {SyncMessage | null}
61
+ */
62
+ export function normalizeSyncMessage(value) {
63
+ if (!isRecord(value) || typeof value.type !== 'string') return null;
64
+ if (!isNonEmptyString(value.collection) || !isNonEmptyString(value.id)) return null;
65
+
66
+ const common = {
67
+ collection: value.collection,
68
+ id: value.id,
69
+ ...(value.embedded === true ? { embedded: true } : {}),
70
+ };
71
+ if (value.type === 'cms:field-selected') {
72
+ if (!isNonEmptyString(value.field)) return null;
73
+ return {
74
+ ...common,
75
+ type: value.type,
76
+ field: value.field,
77
+ ...(typeof value.previewPath === 'string' ? { previewPath: value.previewPath } : {}),
78
+ ...(typeof value.source === 'string' ? { source: value.source } : {}),
79
+ };
80
+ }
81
+ if (value.type === 'cms:entry-ready') return { ...common, type: value.type };
82
+ if (!['cms:preview', 'cms:saved', 'cms:created', 'cms:deleted'].includes(value.type)) return null;
83
+ if (value.data !== undefined && value.data !== null && !isRecord(value.data)) return null;
84
+ return {
85
+ ...common,
86
+ type: /** @type {ChangeMessage['type']} */ (value.type),
87
+ ...(value.data !== undefined ? { data: value.data } : {}),
88
+ ...(typeof value.studioPath === 'string' ? { studioPath: value.studioPath } : {}),
89
+ };
90
+ }
6
91
 
92
+ /**
93
+ * @param {unknown} value
94
+ * @returns {PendingSelection | null}
95
+ */
96
+ export function normalizePendingSelection(value) {
97
+ if (!isRecord(value)) return null;
98
+ if (
99
+ !isNonEmptyString(value.collection)
100
+ || !isNonEmptyString(value.id)
101
+ || !isNonEmptyString(value.field)
102
+ || typeof value.savedAt !== 'number'
103
+ || !Number.isFinite(value.savedAt)
104
+ ) return null;
105
+ return {
106
+ collection: value.collection,
107
+ id: value.id,
108
+ field: value.field,
109
+ savedAt: value.savedAt,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * @param {object} options
115
+ * @param {HTMLIFrameElement | null} options.studioIframe
116
+ * @param {InlineEditorState} options.state
117
+ * @param {(message: string, kind: 'success' | 'error') => void} options.showToast
118
+ * @param {(text: string) => string} options.clientLinkify
119
+ */
7
120
  export function mountStudioSync({ studioIframe, state, showToast, clientLinkify }) {
8
121
  let refreshTimer = 0;
122
+ /** @type {Element | null} */
9
123
  let selectedInlineEl = null;
10
124
  let linkedSelectionTimer = 0;
125
+ /** @type {SelectionMessage | null} */
11
126
  let pendingStudioSelection = null;
127
+ /** @type {SelectionMessage | null} */
12
128
  let activeStudioSelection = null;
13
129
 
130
+ /**
131
+ * @param {string} collection
132
+ * @param {string} id
133
+ * @param {string} field
134
+ * @returns {Element[]}
135
+ */
14
136
  function matchingElements(collection, id, field) {
15
137
  const fullKey = `${collection}::${id}::${field}`;
16
138
  const matches = Array.from(document.querySelectorAll(`[data-caret="${CSS.escape(fullKey)}"]`));
@@ -21,10 +143,15 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
21
143
  return matches;
22
144
  }
23
145
 
146
+ /**
147
+ * @param {string} collection
148
+ * @param {string} id
149
+ * @param {string} field
150
+ * @param {boolean} shouldScroll
151
+ */
24
152
  function selectInlineField(collection, id, field, shouldScroll) {
25
- if (typeof collection !== 'string' || typeof id !== 'string' || typeof field !== 'string') return;
26
153
  const target = matchingElements(collection, id, field)[0];
27
- if (!target) return;
154
+ if (!target) return false;
28
155
 
29
156
  window.clearTimeout(linkedSelectionTimer);
30
157
  if (selectedInlineEl && selectedInlineEl !== target) {
@@ -43,8 +170,45 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
43
170
  target.classList.remove('cms-linked-selection');
44
171
  selectedInlineEl = null;
45
172
  }, 1500);
173
+ return true;
174
+ }
175
+
176
+ /** @param {unknown} path */
177
+ function previewTarget(path) {
178
+ if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) return null;
179
+ try {
180
+ const target = new URL(path, window.location.origin);
181
+ return target.origin === window.location.origin ? target : null;
182
+ } catch {
183
+ return null;
184
+ }
46
185
  }
47
186
 
187
+ /** @param {SelectionMessage} message */
188
+ function selectOrNavigate(message) {
189
+ if (selectInlineField(message.collection, message.id, message.field, true)) return;
190
+
191
+ const target = previewTarget(message.previewPath);
192
+ if (!target || target.href === window.location.href) return;
193
+ if (state.dirtyEls.size > 0) {
194
+ showToast('Finish or cancel your inline edit before opening another page', 'error');
195
+ return;
196
+ }
197
+
198
+ try {
199
+ sessionStorage.setItem(PENDING_SELECTION_KEY, JSON.stringify({
200
+ collection: message.collection,
201
+ id: message.id,
202
+ field: message.field,
203
+ savedAt: Date.now(),
204
+ }));
205
+ } catch {
206
+ // Navigation still works without the post-load highlight handoff.
207
+ }
208
+ window.location.assign(target.href);
209
+ }
210
+
211
+ /** @param {SelectionMessage} message */
48
212
  function publishSelection(message) {
49
213
  if (!('BroadcastChannel' in window)) return;
50
214
  try {
@@ -56,10 +220,13 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
56
220
  }
57
221
  }
58
222
 
223
+ /** @param {SelectionMessage} message */
59
224
  function studioEntryPath(message) {
225
+ if (!STUDIO_PATH) return null;
60
226
  return `${STUDIO_PATH}/${encodeURIComponent(message.collection)}/${encodeURIComponent(message.id)}`;
61
227
  }
62
228
 
229
+ /** @param {SelectionMessage} message */
63
230
  function postEmbeddedSelection(message) {
64
231
  if (!studioIframe?.contentWindow) return;
65
232
  try {
@@ -69,9 +236,11 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
69
236
  }
70
237
  }
71
238
 
239
+ /** @param {SelectionMessage} message */
72
240
  function deliverEmbeddedSelection(message) {
73
241
  if (!studioIframe?.contentWindow) return;
74
242
  const targetPath = studioEntryPath(message);
243
+ if (!targetPath) return;
75
244
 
76
245
  // A normal mouse interaction can emit pointerdown, focusin, and click.
77
246
  // Keep the latest field without restarting the same iframe navigation for
@@ -107,11 +276,13 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
107
276
  window.requestAnimationFrame(() => postEmbeddedSelection(message));
108
277
  });
109
278
 
279
+ /** @param {Element} el */
110
280
  function selectStudioField(el) {
111
281
  const resolved = resolveBinding(el, parseCaretAttr(el.getAttribute('data-caret') || ''));
112
282
  if (!resolved) return;
113
283
 
114
284
  selectInlineField(resolved.collection, resolved.id, resolved.field, false);
285
+ /** @type {SelectionMessage} */
115
286
  const message = {
116
287
  type: 'cms:field-selected',
117
288
  collection: resolved.collection,
@@ -140,8 +311,9 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
140
311
  if (el) selectStudioField(el);
141
312
  });
142
313
 
314
+ /** @param {SyncMessage} msg */
143
315
  function rememberStudioPath(msg) {
144
- if (typeof msg.studioPath !== 'string') return;
316
+ if (!('studioPath' in msg) || typeof msg.studioPath !== 'string') return;
145
317
  try {
146
318
  sessionStorage.setItem('cms-panel-path', msg.studioPath);
147
319
  } catch {
@@ -149,6 +321,7 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
149
321
  }
150
322
  }
151
323
 
324
+ /** @param {SyncMessage} msg */
152
325
  function scheduleEmbeddedRefresh(msg) {
153
326
  rememberStudioPath(msg);
154
327
 
@@ -162,12 +335,18 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
162
335
  refreshTimer = window.setTimeout(() => window.location.reload(), 280);
163
336
  }
164
337
 
338
+ /** @param {ChangeMessage} msg */
165
339
  function applySavedMessage(msg) {
166
340
  const { collection, id, data } = msg;
167
341
  if (!data || typeof data !== 'object') return 0;
168
342
 
169
343
  let updated = 0;
170
344
 
345
+ /**
346
+ * @param {string} selector
347
+ * @param {string} path
348
+ * @returns {Element[]}
349
+ */
171
350
  function boundElements(selector, path) {
172
351
  const matches = Array.from(document.querySelectorAll(`[data-caret="${CSS.escape(selector)}"]`));
173
352
  document.querySelectorAll(`[data-caret="${CSS.escape(path)}"]`).forEach((el) => {
@@ -177,6 +356,10 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
177
356
  return matches;
178
357
  }
179
358
 
359
+ /**
360
+ * @param {Record<string, unknown>} obj
361
+ * @param {string} prefix
362
+ */
180
363
  function walkData(obj, prefix) {
181
364
  for (const [key, value] of Object.entries(obj)) {
182
365
  const path = prefix ? `${prefix}.${key}` : key;
@@ -210,7 +393,7 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
210
393
  updated++;
211
394
  }
212
395
  });
213
- } else if (typeof value === 'object' && value !== null) {
396
+ } else if (isRecord(value)) {
214
397
  walkData(value, path);
215
398
  }
216
399
  }
@@ -223,8 +406,8 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
223
406
  window.addEventListener('message', (e) => {
224
407
  // Only accept messages from our iframe
225
408
  if (!studioIframe || e.source !== studioIframe.contentWindow) return;
226
- const msg = e.data;
227
- if (!msg || typeof msg.type !== 'string') return;
409
+ const msg = normalizeSyncMessage(e.data);
410
+ if (!msg) return;
228
411
 
229
412
  if (
230
413
  msg.type === 'cms:entry-ready'
@@ -242,7 +425,7 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
242
425
  }
243
426
 
244
427
  if (msg.type === 'cms:field-selected') {
245
- selectInlineField(msg.collection, msg.id, msg.field, true);
428
+ selectOrNavigate(msg);
246
429
  return;
247
430
  }
248
431
 
@@ -265,11 +448,11 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
265
448
  try {
266
449
  const channel = new BroadcastChannel(SYNC_CHANNEL);
267
450
  channel.addEventListener('message', (event) => {
268
- const msg = event.data;
269
- if (!msg || typeof msg.type !== 'string' || msg.embedded === true) return;
451
+ const msg = normalizeSyncMessage(event.data);
452
+ if (!msg || msg.embedded === true) return;
270
453
 
271
454
  if (msg.type === 'cms:field-selected') {
272
- selectInlineField(msg.collection, msg.id, msg.field, true);
455
+ selectOrNavigate(msg);
273
456
  return;
274
457
  }
275
458
  if (!['cms:saved', 'cms:created', 'cms:deleted'].includes(msg.type)) return;
@@ -290,4 +473,19 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
290
473
  // remains available in browsers without BroadcastChannel.
291
474
  }
292
475
  }
476
+
477
+ try {
478
+ const rawPending = sessionStorage.getItem(PENDING_SELECTION_KEY);
479
+ if (rawPending) {
480
+ sessionStorage.removeItem(PENDING_SELECTION_KEY);
481
+ const pending = normalizePendingSelection(JSON.parse(rawPending));
482
+ if (pending && Date.now() - pending.savedAt < 30_000) {
483
+ window.requestAnimationFrame(() => {
484
+ selectInlineField(pending.collection, pending.id, pending.field, true);
485
+ });
486
+ }
487
+ }
488
+ } catch {
489
+ // Pending selection recovery is a progressive enhancement.
490
+ }
293
491
  }
@@ -2,6 +2,22 @@ import { createTextLinkInteractions } from './text-link-interactions.js';
2
2
  import { resolveBinding } from './helpers.js';
3
3
  import { sanitizeHtml } from './sanitize.js';
4
4
 
5
+ /** @typedef {HTMLElement & { _caretSkipSave?: boolean }} EditableElement */
6
+ /** @typedef {{ collection: string, id: string, field: string }} ResolvedCaretBinding */
7
+ /** @typedef {{ collection: string | null, id: string | null, field: string }} ParsedCaretBinding */
8
+ /** @typedef {{ dirtyEls: Set<Element>, linkPopupEl: HTMLElement | null }} TextEditorState */
9
+ /** @typedef {{ ok: true, revision?: number } | { ok: false, reason: 'conflict' | 'unauthorized' | 'error', latestValue?: unknown }} SaveResult */
10
+ /**
11
+ * @typedef {((message: string, kind: 'success' | 'error') => unknown) & {
12
+ * conflict: (options: {
13
+ * message: string,
14
+ * onKeepMine: () => void | Promise<void>,
15
+ * onLoadLatest: () => void,
16
+ * }) => unknown,
17
+ * }} ConflictToast
18
+ */
19
+
20
+ /** @type {WeakMap<HTMLElement, string>} */
5
21
  const snapshots = new WeakMap();
6
22
  let keydownBound = false;
7
23
 
@@ -19,6 +35,15 @@ const PLAINTEXT_ONLY_SUPPORTED = (() => {
19
35
  }
20
36
  })();
21
37
 
38
+ /**
39
+ * @param {object} options
40
+ * @param {TextEditorState} options.state
41
+ * @param {(attr: string) => ParsedCaretBinding | null} options.parseCaretAttr
42
+ * @param {(text: string) => string} options.clientLinkify
43
+ * @param {(collection: string, id: string, field: string, value: string) => Promise<SaveResult>} options.saveField
44
+ * @param {(element: Element, success: boolean) => void} options.flash
45
+ * @param {ConflictToast} options.showToast
46
+ */
22
47
  export function mountTextEditors({
23
48
  state,
24
49
  parseCaretAttr,
@@ -27,6 +52,7 @@ export function mountTextEditors({
27
52
  flash,
28
53
  showToast,
29
54
  }) {
55
+ /** @param {HTMLElement} target @param {string} value */
30
56
  const applyTextValue = (target, value) => {
31
57
  if (target.hasAttribute('data-caret-rich')) {
32
58
  target.innerHTML = sanitizeHtml(value);
@@ -37,6 +63,7 @@ export function mountTextEditors({
37
63
  target.textContent = value;
38
64
  }
39
65
  };
66
+ /** @param {string} attr @param {string} value */
40
67
  const syncBoundText = (attr, value) => {
41
68
  document.querySelectorAll('[data-caret]').forEach((node) => {
42
69
  if (!(node instanceof HTMLElement)) return;
@@ -58,11 +85,13 @@ export function mountTextEditors({
58
85
  }
59
86
 
60
87
  document.querySelectorAll('[data-caret]').forEach((el) => {
61
- if (el instanceof HTMLImageElement) return;
88
+ if (!(el instanceof HTMLElement) || el instanceof HTMLImageElement) return;
89
+ const editableEl = /** @type {EditableElement} */ (el);
62
90
  if (el.dataset.caretTextMounted === 'true') return;
63
91
  el.dataset.caretTextMounted = 'true';
64
92
 
65
93
  const attr = el.getAttribute('data-caret');
94
+ if (!attr) return;
66
95
  const isRich = el.hasAttribute('data-caret-rich');
67
96
  const isLinkable = !isRich && el.hasAttribute('data-caret-raw');
68
97
 
@@ -121,6 +150,11 @@ export function mountTextEditors({
121
150
  // (the save queue refreshed the cached revision on the 409, so the retry
122
151
  // targets the current revision and overwrites); "Load latest" replaces the
123
152
  // field with the server value. The element stays dirty until resolved.
153
+ /**
154
+ * @param {ResolvedCaretBinding} resolved
155
+ * @param {string} mine
156
+ * @param {unknown} latestValue
157
+ */
124
158
  const runConflict = (resolved, mine, latestValue) => {
125
159
  state.dirtyEls.add(el);
126
160
  el.classList.add('cms-conflict');
@@ -161,8 +195,8 @@ export function mountTextEditors({
161
195
  if (state.linkPopupEl === el) return;
162
196
 
163
197
  // Skip save if Escape was pressed
164
- if (el._caretSkipSave) {
165
- el._caretSkipSave = false;
198
+ if (editableEl._caretSkipSave) {
199
+ editableEl._caretSkipSave = false;
166
200
  state.dirtyEls.delete(el);
167
201
  if (isRich) {
168
202
  el.innerHTML = snapshots.get(el) || '';