@caretcms/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/README.md +195 -12
  2. package/dist/index.d.ts +26 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +113 -12
  5. package/dist/index.js.map +1 -1
  6. package/dist/loader.d.ts.map +1 -1
  7. package/dist/loader.js +35 -3
  8. package/dist/loader.js.map +1 -1
  9. package/dist/markdown/paragraphs.d.ts +14 -0
  10. package/dist/markdown/paragraphs.d.ts.map +1 -0
  11. package/dist/markdown/paragraphs.js +61 -0
  12. package/dist/markdown/paragraphs.js.map +1 -0
  13. package/dist/markdown/remark.d.ts.map +1 -1
  14. package/dist/markdown/remark.js +1 -0
  15. package/dist/markdown/remark.js.map +1 -1
  16. package/dist/markdown/satteri.d.ts.map +1 -1
  17. package/dist/markdown/satteri.js +35 -9
  18. package/dist/markdown/satteri.js.map +1 -1
  19. package/dist/markdown/stamp.d.ts +0 -9
  20. package/dist/markdown/stamp.d.ts.map +1 -1
  21. package/dist/markdown/stamp.js +6 -0
  22. package/dist/markdown/stamp.js.map +1 -1
  23. package/dist/providers/deployment/github.d.ts +27 -0
  24. package/dist/providers/deployment/github.d.ts.map +1 -0
  25. package/dist/providers/deployment/github.js +180 -0
  26. package/dist/providers/deployment/github.js.map +1 -0
  27. package/dist/providers/deployment/simulated.d.ts +10 -0
  28. package/dist/providers/deployment/simulated.d.ts.map +1 -0
  29. package/dist/providers/deployment/simulated.js +29 -0
  30. package/dist/providers/deployment/simulated.js.map +1 -0
  31. package/dist/runtime/auth/session.d.ts +1 -0
  32. package/dist/runtime/auth/session.d.ts.map +1 -1
  33. package/dist/runtime/auth/session.js +16 -0
  34. package/dist/runtime/auth/session.js.map +1 -1
  35. package/dist/runtime/authorization.d.ts +11 -0
  36. package/dist/runtime/authorization.d.ts.map +1 -0
  37. package/dist/runtime/authorization.js +29 -0
  38. package/dist/runtime/authorization.js.map +1 -0
  39. package/dist/runtime/collection-policy.d.ts +11 -0
  40. package/dist/runtime/collection-policy.d.ts.map +1 -0
  41. package/dist/runtime/collection-policy.js +44 -0
  42. package/dist/runtime/collection-policy.js.map +1 -0
  43. package/dist/runtime/config.d.ts +3 -0
  44. package/dist/runtime/config.d.ts.map +1 -1
  45. package/dist/runtime/config.js +4 -1
  46. package/dist/runtime/config.js.map +1 -1
  47. package/dist/runtime/content-errors.d.ts +9 -0
  48. package/dist/runtime/content-errors.d.ts.map +1 -0
  49. package/dist/runtime/content-errors.js +17 -0
  50. package/dist/runtime/content-errors.js.map +1 -0
  51. package/dist/runtime/content.d.ts.map +1 -1
  52. package/dist/runtime/content.js +13 -4
  53. package/dist/runtime/content.js.map +1 -1
  54. package/dist/runtime/deployment-status.d.ts +14 -0
  55. package/dist/runtime/deployment-status.d.ts.map +1 -0
  56. package/dist/runtime/deployment-status.js +57 -0
  57. package/dist/runtime/deployment-status.js.map +1 -0
  58. package/dist/runtime/draft-state.d.ts +16 -0
  59. package/dist/runtime/draft-state.d.ts.map +1 -0
  60. package/dist/runtime/draft-state.js +29 -0
  61. package/dist/runtime/draft-state.js.map +1 -0
  62. package/dist/runtime/i18n.d.ts +97 -0
  63. package/dist/runtime/i18n.d.ts.map +1 -0
  64. package/dist/runtime/i18n.js +190 -0
  65. package/dist/runtime/i18n.js.map +1 -0
  66. package/dist/runtime/index.d.ts +1 -0
  67. package/dist/runtime/index.d.ts.map +1 -1
  68. package/dist/runtime/index.js +1 -0
  69. package/dist/runtime/index.js.map +1 -1
  70. package/dist/runtime/middleware.d.ts.map +1 -1
  71. package/dist/runtime/middleware.js +61 -4
  72. package/dist/runtime/middleware.js.map +1 -1
  73. package/dist/runtime/mutations/contracts.d.ts +12 -0
  74. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  75. package/dist/runtime/mutations/contracts.js +54 -4
  76. package/dist/runtime/mutations/contracts.js.map +1 -1
  77. package/dist/runtime/mutations/engine.d.ts +2 -0
  78. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  79. package/dist/runtime/mutations/engine.js +220 -57
  80. package/dist/runtime/mutations/engine.js.map +1 -1
  81. package/dist/runtime/providers.d.ts +4 -1
  82. package/dist/runtime/providers.d.ts.map +1 -1
  83. package/dist/runtime/providers.js +18 -1
  84. package/dist/runtime/providers.js.map +1 -1
  85. package/dist/runtime/publish-recovery.d.ts +32 -0
  86. package/dist/runtime/publish-recovery.d.ts.map +1 -0
  87. package/dist/runtime/publish-recovery.js +98 -0
  88. package/dist/runtime/publish-recovery.js.map +1 -0
  89. package/dist/runtime/publish.d.ts +10 -4
  90. package/dist/runtime/publish.d.ts.map +1 -1
  91. package/dist/runtime/publish.js +105 -63
  92. package/dist/runtime/publish.js.map +1 -1
  93. package/dist/runtime/rebuild-webhook.d.ts +4 -0
  94. package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
  95. package/dist/runtime/rebuild-webhook.js +12 -2
  96. package/dist/runtime/rebuild-webhook.js.map +1 -1
  97. package/dist/runtime/request-context.d.ts +7 -1
  98. package/dist/runtime/request-context.d.ts.map +1 -1
  99. package/dist/runtime/request-context.js +7 -1
  100. package/dist/runtime/request-context.js.map +1 -1
  101. package/dist/runtime/rewrite.d.ts.map +1 -1
  102. package/dist/runtime/rewrite.js +28 -2
  103. package/dist/runtime/rewrite.js.map +1 -1
  104. package/dist/runtime/rich-allowlist.js +1 -1
  105. package/dist/runtime/rich-allowlist.js.map +1 -1
  106. package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
  107. package/dist/runtime/routes/admin-entry.js +129 -21
  108. package/dist/runtime/routes/admin-entry.js.map +1 -1
  109. package/dist/runtime/routes/admin.d.ts.map +1 -1
  110. package/dist/runtime/routes/admin.js +28 -14
  111. package/dist/runtime/routes/admin.js.map +1 -1
  112. package/dist/runtime/routes/auth-login.d.ts.map +1 -1
  113. package/dist/runtime/routes/auth-login.js +4 -0
  114. package/dist/runtime/routes/auth-login.js.map +1 -1
  115. package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
  116. package/dist/runtime/routes/auth-logout.js +7 -2
  117. package/dist/runtime/routes/auth-logout.js.map +1 -1
  118. package/dist/runtime/routes/auth-session.d.ts.map +1 -1
  119. package/dist/runtime/routes/auth-session.js +10 -2
  120. package/dist/runtime/routes/auth-session.js.map +1 -1
  121. package/dist/runtime/routes/collection-list.d.ts.map +1 -1
  122. package/dist/runtime/routes/collection-list.js +213 -66
  123. package/dist/runtime/routes/collection-list.js.map +1 -1
  124. package/dist/runtime/routes/deployment.d.ts +5 -0
  125. package/dist/runtime/routes/deployment.d.ts.map +1 -0
  126. package/dist/runtime/routes/deployment.js +38 -0
  127. package/dist/runtime/routes/deployment.js.map +1 -0
  128. package/dist/runtime/routes/draft.d.ts.map +1 -1
  129. package/dist/runtime/routes/draft.js +10 -3
  130. package/dist/runtime/routes/draft.js.map +1 -1
  131. package/dist/runtime/routes/entries.d.ts.map +1 -1
  132. package/dist/runtime/routes/entries.js +87 -43
  133. package/dist/runtime/routes/entries.js.map +1 -1
  134. package/dist/runtime/routes/history.d.ts.map +1 -1
  135. package/dist/runtime/routes/history.js +40 -21
  136. package/dist/runtime/routes/history.js.map +1 -1
  137. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  138. package/dist/runtime/routes/mutate.js +4 -1
  139. package/dist/runtime/routes/mutate.js.map +1 -1
  140. package/dist/runtime/routes/publish.d.ts.map +1 -1
  141. package/dist/runtime/routes/publish.js +127 -33
  142. package/dist/runtime/routes/publish.js.map +1 -1
  143. package/dist/runtime/routes/schema.d.ts.map +1 -1
  144. package/dist/runtime/routes/schema.js +11 -2
  145. package/dist/runtime/routes/schema.js.map +1 -1
  146. package/dist/runtime/routes/studio-home.d.ts.map +1 -1
  147. package/dist/runtime/routes/studio-home.js +41 -20
  148. package/dist/runtime/routes/studio-home.js.map +1 -1
  149. package/dist/runtime/routes/upload.d.ts.map +1 -1
  150. package/dist/runtime/routes/upload.js +3 -0
  151. package/dist/runtime/routes/upload.js.map +1 -1
  152. package/dist/runtime/schema-registry.d.ts +8 -1
  153. package/dist/runtime/schema-registry.d.ts.map +1 -1
  154. package/dist/runtime/schema-registry.js +49 -3
  155. package/dist/runtime/schema-registry.js.map +1 -1
  156. package/dist/runtime/storage/entry-commit.d.ts +8 -0
  157. package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
  158. package/dist/runtime/storage/entry-commit.js +45 -0
  159. package/dist/runtime/storage/entry-commit.js.map +1 -0
  160. package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
  161. package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
  162. package/dist/runtime/storage/filesystem-adapter.js +12 -3
  163. package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
  164. package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
  165. package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
  166. package/dist/runtime/storage/frontmatter-codec.js +156 -13
  167. package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
  168. package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
  169. package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
  170. package/dist/runtime/storage/in-memory-adapter.js +6 -0
  171. package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
  172. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
  173. package/dist/runtime/storage/markdown-adapter.js +18 -20
  174. package/dist/runtime/storage/markdown-adapter.js.map +1 -1
  175. package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
  176. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  177. package/dist/runtime/storage/session-overlay-adapter.js +100 -13
  178. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  179. package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
  180. package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
  181. package/dist/runtime/storage/sidecar-meta-store.js +48 -5
  182. package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
  183. package/dist/runtime/themes/preset-tokens.js +4 -4
  184. package/dist/runtime/themes/preset-tokens.js.map +1 -1
  185. package/dist/runtime/utils.d.ts.map +1 -1
  186. package/dist/runtime/utils.js +3 -4
  187. package/dist/runtime/utils.js.map +1 -1
  188. package/dist/runtime/views/studio-layout.d.ts +3 -0
  189. package/dist/runtime/views/studio-layout.d.ts.map +1 -1
  190. package/dist/runtime/views/studio-layout.js +36 -13
  191. package/dist/runtime/views/studio-layout.js.map +1 -1
  192. package/dist/schema-utils.d.ts +7 -0
  193. package/dist/schema-utils.d.ts.map +1 -1
  194. package/dist/schema-utils.js +143 -0
  195. package/dist/schema-utils.js.map +1 -1
  196. package/dist/types.d.ts +165 -1
  197. package/dist/types.d.ts.map +1 -1
  198. package/package.json +12 -3
  199. package/static/cms/admin-entry.css +144 -13
  200. package/static/cms/admin-entry.js +539 -586
  201. package/static/cms/dev-toolbar/app.js +78 -19
  202. package/static/cms/editor/config.js +35 -15
  203. package/static/cms/editor/content-map.css +8 -1
  204. package/static/cms/editor/content-map.js +62 -23
  205. package/static/cms/editor/deployment-status.js +66 -0
  206. package/static/cms/editor/draft-client.js +87 -0
  207. package/static/cms/editor/guards.js +20 -0
  208. package/static/cms/editor/helpers.js +30 -1
  209. package/static/cms/editor/highlight.js +1 -0
  210. package/static/cms/editor/image-edit.js +44 -1
  211. package/static/cms/editor/image-utils.js +23 -7
  212. package/static/cms/editor/link-follow.js +122 -41
  213. package/static/cms/editor/link-ui.css +10 -1
  214. package/static/cms/editor/linkify.js +2 -0
  215. package/static/cms/editor/md-block-edit.js +140 -37
  216. package/static/cms/editor/md-paragraphs.js +98 -0
  217. package/static/cms/editor/md-serialize.js +1 -1
  218. package/static/cms/editor/panel.js +53 -7
  219. package/static/cms/editor/preview-mode.js +44 -0
  220. package/static/cms/editor/publish-result.js +100 -0
  221. package/static/cms/editor/rich-toolbar.js +58 -15
  222. package/static/cms/editor/sanitize.js +6 -2
  223. package/static/cms/editor/save-queue.js +75 -17
  224. package/static/cms/editor/section-controls/activation.js +3 -0
  225. package/static/cms/editor/section-controls/api.js +25 -7
  226. package/static/cms/editor/section-controls/index.js +60 -8
  227. package/static/cms/editor/section-controls/model.js +39 -4
  228. package/static/cms/editor/section-controls/mutations.js +15 -0
  229. package/static/cms/editor/section-controls/page-context.js +9 -0
  230. package/static/cms/editor/section-controls/pickers.js +53 -2
  231. package/static/cms/editor/section-controls/reorder.js +23 -0
  232. package/static/cms/editor/section-controls/selection.js +6 -0
  233. package/static/cms/editor/section-controls/spacing-drag.js +16 -0
  234. package/static/cms/editor/section-controls/ui-elements.js +7 -0
  235. package/static/cms/editor/section-controls/utils.js +7 -0
  236. package/static/cms/editor/section-controls.css +18 -3
  237. package/static/cms/editor/security.js +13 -2
  238. package/static/cms/editor/stega-hydrate.js +17 -4
  239. package/static/cms/editor/sync.js +475 -56
  240. package/static/cms/editor/text-edit.js +37 -3
  241. package/static/cms/editor/text-link-interactions.js +59 -17
  242. package/static/cms/editor/toast.js +23 -9
  243. package/static/cms/editor/tokens-and-text.css +9 -0
  244. package/static/cms/editor/toolbar.css +5 -0
  245. package/static/cms/editor/toolbar.js +91 -101
  246. package/static/cms/editor.css +1 -1
  247. package/static/cms/editor.js +1 -0
  248. package/static/cms/studio/entry-loader.js +102 -0
  249. package/static/cms/studio/field-model.js +110 -0
  250. package/static/cms/studio/fields.js +319 -0
  251. package/static/cms/studio/history-client.js +74 -0
  252. package/static/cms/studio/mutation-client.js +74 -0
  253. package/static/cms/studio/sync-client.js +107 -0
  254. package/static/cms/studio/upload-client.js +109 -0
  255. package/static/cms/studio-tokens.css +4 -4
  256. package/static/cms/studio.css +24 -0
@@ -1,72 +1,491 @@
1
1
  import { sanitizeHtml } from './sanitize.js';
2
+ import { parseCaretAttr, resolveBinding } from './helpers.js';
3
+ import { STUDIO_PATH } from './config.js';
2
4
 
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
+ }
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
+ */
3
120
  export function mountStudioSync({ studioIframe, state, showToast, clientLinkify }) {
4
- if (!studioIframe) return;
121
+ let refreshTimer = 0;
122
+ /** @type {Element | null} */
123
+ let selectedInlineEl = null;
124
+ let linkedSelectionTimer = 0;
125
+ /** @type {SelectionMessage | null} */
126
+ let pendingStudioSelection = null;
127
+ /** @type {SelectionMessage | null} */
128
+ let activeStudioSelection = null;
5
129
 
6
- window.addEventListener('message', (e) => {
7
- // Only accept messages from our iframe
8
- if (e.source !== studioIframe.contentWindow) return;
9
- const msg = e.data;
10
- if (!msg || typeof msg.type !== 'string') return;
130
+ /**
131
+ * @param {string} collection
132
+ * @param {string} id
133
+ * @param {string} field
134
+ * @returns {Element[]}
135
+ */
136
+ function matchingElements(collection, id, field) {
137
+ const fullKey = `${collection}::${id}::${field}`;
138
+ const matches = Array.from(document.querySelectorAll(`[data-caret="${CSS.escape(fullKey)}"]`));
139
+ document.querySelectorAll(`[data-caret="${CSS.escape(field)}"]`).forEach((el) => {
140
+ const scope = el.closest('[data-caret-scope]')?.getAttribute('data-caret-scope');
141
+ if (scope === `${collection}::${id}` && !matches.includes(el)) matches.push(el);
142
+ });
143
+ return matches;
144
+ }
11
145
 
12
- if (msg.type === 'cms:saved') {
13
- const { collection, id, data } = msg;
14
- if (!data || typeof data !== 'object') return;
15
-
16
- let updated = 0;
17
-
18
- function walkData(obj, prefix) {
19
- for (const [key, value] of Object.entries(obj)) {
20
- const path = prefix ? `${prefix}.${key}` : key;
21
- const selector = `${collection}::${id}::${path}`;
22
-
23
- if (typeof value === 'string') {
24
- // Text and image fields — unified under [data-caret]
25
- const els = document.querySelectorAll(`[data-caret="${selector}"]`);
26
- els.forEach((el) => {
27
- // Skip elements being actively edited to prevent overwriting unsaved work
28
- if (state.dirtyEls.has(el) || el === document.activeElement) return;
29
-
30
- if (el instanceof HTMLImageElement) {
31
- // Image element
32
- if (el.src !== value && el.getAttribute('src') !== value) {
33
- el.src = value;
34
- updated++;
35
- }
36
- } else if (el.hasAttribute('data-caret-rich')) {
37
- const sanitized = sanitizeHtml(value);
38
- if (el.innerHTML !== sanitized) {
39
- el.innerHTML = sanitized;
40
- updated++;
41
- }
42
- } else if (el.hasAttribute('data-caret-raw')) {
43
- if (el.getAttribute('data-caret-raw') !== value) {
44
- el.setAttribute('data-caret-raw', value);
45
- el.innerHTML = clientLinkify(value);
46
- updated++;
47
- }
48
- } else if (el.textContent !== value) {
49
- el.textContent = value;
146
+ /**
147
+ * @param {string} collection
148
+ * @param {string} id
149
+ * @param {string} field
150
+ * @param {boolean} shouldScroll
151
+ */
152
+ function selectInlineField(collection, id, field, shouldScroll) {
153
+ const target = matchingElements(collection, id, field)[0];
154
+ if (!target) return false;
155
+
156
+ window.clearTimeout(linkedSelectionTimer);
157
+ if (selectedInlineEl && selectedInlineEl !== target) {
158
+ selectedInlineEl.classList.remove('cms-linked-selection');
159
+ }
160
+ selectedInlineEl = target;
161
+ target.classList.add('cms-linked-selection');
162
+ if (shouldScroll) {
163
+ target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
164
+ }
165
+
166
+ // Blue confirms that the paired field was located. Green remains the
167
+ // distinct confirmation that an edit was actually saved.
168
+ linkedSelectionTimer = window.setTimeout(() => {
169
+ if (selectedInlineEl !== target) return;
170
+ target.classList.remove('cms-linked-selection');
171
+ selectedInlineEl = null;
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
+ }
185
+ }
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 */
212
+ function publishSelection(message) {
213
+ if (!('BroadcastChannel' in window)) return;
214
+ try {
215
+ const channel = new BroadcastChannel(SYNC_CHANNEL);
216
+ channel.postMessage(message);
217
+ window.setTimeout(() => channel.close(), 0);
218
+ } catch {
219
+ // Cross-tab selection linking is a progressive enhancement.
220
+ }
221
+ }
222
+
223
+ /** @param {SelectionMessage} message */
224
+ function studioEntryPath(message) {
225
+ if (!STUDIO_PATH) return null;
226
+ return `${STUDIO_PATH}/${encodeURIComponent(message.collection)}/${encodeURIComponent(message.id)}`;
227
+ }
228
+
229
+ /** @param {SelectionMessage} message */
230
+ function postEmbeddedSelection(message) {
231
+ if (!studioIframe?.contentWindow) return;
232
+ try {
233
+ studioIframe.contentWindow.postMessage({ ...message, embedded: true }, window.location.origin);
234
+ } catch {
235
+ // Embedded field linking is a progressive enhancement.
236
+ }
237
+ }
238
+
239
+ /** @param {SelectionMessage} message */
240
+ function deliverEmbeddedSelection(message) {
241
+ if (!studioIframe?.contentWindow) return;
242
+ const targetPath = studioEntryPath(message);
243
+ if (!targetPath) return;
244
+
245
+ // A normal mouse interaction can emit pointerdown, focusin, and click.
246
+ // Keep the latest field without restarting the same iframe navigation for
247
+ // each event in that sequence.
248
+ if (pendingStudioSelection && studioIframe.getAttribute('src') === targetPath) {
249
+ pendingStudioSelection = message;
250
+ return;
251
+ }
252
+
253
+ try {
254
+ if (studioIframe.contentWindow.location.pathname !== targetPath) {
255
+ pendingStudioSelection = message;
256
+ studioIframe.src = targetPath;
257
+ try {
258
+ sessionStorage.setItem('cms-panel-path', targetPath);
259
+ } catch {
260
+ // Remembering the route is optional; the current navigation is not.
261
+ }
262
+ return;
263
+ }
264
+ } catch {
265
+ // Cross-origin Studio panels cannot be inspected or navigated safely;
266
+ // preserve the existing postMessage-only behavior for those setups.
267
+ }
268
+
269
+ postEmbeddedSelection(message);
270
+ }
271
+
272
+ studioIframe?.addEventListener('load', () => {
273
+ if (!pendingStudioSelection) return;
274
+ const message = pendingStudioSelection;
275
+ pendingStudioSelection = null;
276
+ window.requestAnimationFrame(() => postEmbeddedSelection(message));
277
+ });
278
+
279
+ /** @param {Element} el */
280
+ function selectStudioField(el) {
281
+ const resolved = resolveBinding(el, parseCaretAttr(el.getAttribute('data-caret') || ''));
282
+ if (!resolved) return;
283
+
284
+ selectInlineField(resolved.collection, resolved.id, resolved.field, false);
285
+ /** @type {SelectionMessage} */
286
+ const message = {
287
+ type: 'cms:field-selected',
288
+ collection: resolved.collection,
289
+ id: resolved.id,
290
+ field: resolved.field,
291
+ source: 'inline',
292
+ };
293
+
294
+ activeStudioSelection = message;
295
+ deliverEmbeddedSelection(message);
296
+ publishSelection(message);
297
+ }
298
+
299
+ // Pointerdown covers image-like bindings; focusin covers keyboard navigation
300
+ // and text editing. Click catches non-focusable bound elements.
301
+ document.addEventListener('pointerdown', (event) => {
302
+ const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
303
+ if (el) selectStudioField(el);
304
+ });
305
+ document.addEventListener('click', (event) => {
306
+ const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
307
+ if (el) selectStudioField(el);
308
+ });
309
+ document.addEventListener('focusin', (event) => {
310
+ const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
311
+ if (el) selectStudioField(el);
312
+ });
313
+
314
+ /** @param {SyncMessage} msg */
315
+ function rememberStudioPath(msg) {
316
+ if (!('studioPath' in msg) || typeof msg.studioPath !== 'string') return;
317
+ try {
318
+ sessionStorage.setItem('cms-panel-path', msg.studioPath);
319
+ } catch {
320
+ // Remembering the current Studio route is a progressive enhancement.
321
+ }
322
+ }
323
+
324
+ /** @param {SyncMessage} msg */
325
+ function scheduleEmbeddedRefresh(msg) {
326
+ rememberStudioPath(msg);
327
+
328
+ if (state.dirtyEls.size > 0) {
329
+ showToast('Saved in Studio — finish your inline edit before refreshing', 'success');
330
+ return;
331
+ }
332
+
333
+ showToast('Saved — refreshing visual preview…', 'success');
334
+ window.clearTimeout(refreshTimer);
335
+ refreshTimer = window.setTimeout(() => window.location.reload(), 280);
336
+ }
337
+
338
+ /** @param {ChangeMessage} msg */
339
+ function applySavedMessage(msg) {
340
+ const { collection, id, data } = msg;
341
+ if (!data || typeof data !== 'object') return 0;
342
+
343
+ let updated = 0;
344
+
345
+ /**
346
+ * @param {string} selector
347
+ * @param {string} path
348
+ * @returns {Element[]}
349
+ */
350
+ function boundElements(selector, path) {
351
+ const matches = Array.from(document.querySelectorAll(`[data-caret="${CSS.escape(selector)}"]`));
352
+ document.querySelectorAll(`[data-caret="${CSS.escape(path)}"]`).forEach((el) => {
353
+ const scope = el.closest('[data-caret-scope]')?.getAttribute('data-caret-scope');
354
+ if (scope === `${collection}::${id}` && !matches.includes(el)) matches.push(el);
355
+ });
356
+ return matches;
357
+ }
358
+
359
+ /**
360
+ * @param {Record<string, unknown>} obj
361
+ * @param {string} prefix
362
+ */
363
+ function walkData(obj, prefix) {
364
+ for (const [key, value] of Object.entries(obj)) {
365
+ const path = prefix ? `${prefix}.${key}` : key;
366
+ const selector = `${collection}::${id}::${path}`;
367
+
368
+ if (typeof value === 'string') {
369
+ const els = boundElements(selector, path);
370
+ els.forEach((el) => {
371
+ // Skip elements being actively edited to prevent overwriting unsaved work
372
+ if (state.dirtyEls.has(el) || el === document.activeElement) return;
373
+
374
+ if (el instanceof HTMLImageElement) {
375
+ if (el.src !== value && el.getAttribute('src') !== value) {
376
+ el.src = value;
50
377
  updated++;
51
378
  }
52
- });
53
- } else if (typeof value === 'object' && value !== null) {
54
- walkData(value, path);
55
- }
379
+ } else if (el.hasAttribute('data-caret-rich')) {
380
+ const sanitized = sanitizeHtml(value);
381
+ if (el.innerHTML !== sanitized) {
382
+ el.innerHTML = sanitized;
383
+ updated++;
384
+ }
385
+ } else if (el.hasAttribute('data-caret-raw')) {
386
+ if (el.getAttribute('data-caret-raw') !== value) {
387
+ el.setAttribute('data-caret-raw', value);
388
+ el.innerHTML = clientLinkify(value);
389
+ updated++;
390
+ }
391
+ } else if (el.textContent !== value) {
392
+ el.textContent = value;
393
+ updated++;
394
+ }
395
+ });
396
+ } else if (isRecord(value)) {
397
+ walkData(value, path);
56
398
  }
57
399
  }
400
+ }
58
401
 
59
- walkData(data, '');
402
+ walkData(data, '');
403
+ return updated;
404
+ }
60
405
 
61
- if (updated > 0) {
62
- showToast(`Updated ${updated} element${updated > 1 ? 's' : ''} on page`, 'success');
63
- } else {
64
- showToast('Saved refresh to see changes', 'success');
65
- }
406
+ window.addEventListener('message', (e) => {
407
+ // Only accept messages from our iframe
408
+ if (!studioIframe || e.source !== studioIframe.contentWindow) return;
409
+ const msg = normalizeSyncMessage(e.data);
410
+ if (!msg) return;
411
+
412
+ if (
413
+ msg.type === 'cms:entry-ready'
414
+ && activeStudioSelection
415
+ && msg.collection === activeStudioSelection.collection
416
+ && msg.id === activeStudioSelection.id
417
+ ) {
418
+ postEmbeddedSelection(activeStudioSelection);
419
+ return;
420
+ }
421
+
422
+ if (msg.type === 'cms:preview') {
423
+ applySavedMessage(msg);
424
+ return;
425
+ }
426
+
427
+ if (msg.type === 'cms:field-selected') {
428
+ selectOrNavigate(msg);
429
+ return;
430
+ }
431
+
432
+ if (msg.type === 'cms:saved') {
433
+ applySavedMessage(msg);
434
+ scheduleEmbeddedRefresh(msg);
66
435
  }
67
436
 
68
- if (msg.type === 'cms:created') {
69
- showToast('New entry created — refresh to see changes', 'success');
437
+ if (msg.type === 'cms:created' || msg.type === 'cms:deleted') {
438
+ scheduleEmbeddedRefresh(msg);
70
439
  }
71
440
  });
441
+
442
+ // Standalone Studio pages live in a separate tab, so postMessage cannot
443
+ // reach the site preview. BroadcastChannel provides a same-origin bridge.
444
+ // Reload only after a confirmed mutation. The server remains the source of
445
+ // truth for lists, routing, and derived UI; sites can make the refresh feel
446
+ // continuous with their own cross-document view transitions.
447
+ if ('BroadcastChannel' in window) {
448
+ try {
449
+ const channel = new BroadcastChannel(SYNC_CHANNEL);
450
+ channel.addEventListener('message', (event) => {
451
+ const msg = normalizeSyncMessage(event.data);
452
+ if (!msg || msg.embedded === true) return;
453
+
454
+ if (msg.type === 'cms:field-selected') {
455
+ selectOrNavigate(msg);
456
+ return;
457
+ }
458
+ if (!['cms:saved', 'cms:created', 'cms:deleted'].includes(msg.type)) return;
459
+
460
+ if (state.dirtyEls.size > 0) {
461
+ showToast('Saved in Studio — finish your inline edit before refreshing', 'success');
462
+ return;
463
+ }
464
+
465
+ if (msg.type === 'cms:saved') applySavedMessage(msg);
466
+ showToast('Saved — refreshing preview…', 'success');
467
+ window.clearTimeout(refreshTimer);
468
+ refreshTimer = window.setTimeout(() => window.location.reload(), 280);
469
+ });
470
+ window.addEventListener('pagehide', () => channel.close(), { once: true });
471
+ } catch {
472
+ // Cross-tab preview sync is progressive enhancement; iframe sync above
473
+ // remains available in browsers without BroadcastChannel.
474
+ }
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
+ }
72
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) || '';