@caretcms/core 0.1.2 → 0.3.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 (190) hide show
  1. package/README.md +109 -13
  2. package/dist/contracts.d.ts +1 -0
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +1 -0
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/index.d.ts +31 -9
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +146 -30
  9. package/dist/index.js.map +1 -1
  10. package/dist/loader.d.ts +17 -6
  11. package/dist/loader.d.ts.map +1 -1
  12. package/dist/loader.js +57 -14
  13. package/dist/loader.js.map +1 -1
  14. package/dist/markdown/block-context.d.ts +21 -0
  15. package/dist/markdown/block-context.d.ts.map +1 -0
  16. package/dist/markdown/block-context.js +27 -0
  17. package/dist/markdown/block-context.js.map +1 -0
  18. package/dist/markdown/canonical-body.d.ts +30 -0
  19. package/dist/markdown/canonical-body.d.ts.map +1 -0
  20. package/dist/markdown/canonical-body.js +54 -0
  21. package/dist/markdown/canonical-body.js.map +1 -0
  22. package/dist/markdown/contracts.d.ts +66 -0
  23. package/dist/markdown/contracts.d.ts.map +1 -0
  24. package/dist/markdown/contracts.js +102 -0
  25. package/dist/markdown/contracts.js.map +1 -0
  26. package/dist/markdown/html-to-snodes.d.ts +26 -0
  27. package/dist/markdown/html-to-snodes.d.ts.map +1 -0
  28. package/dist/markdown/html-to-snodes.js +118 -0
  29. package/dist/markdown/html-to-snodes.js.map +1 -0
  30. package/dist/markdown/remark.d.ts +35 -0
  31. package/dist/markdown/remark.d.ts.map +1 -0
  32. package/dist/markdown/remark.js +66 -0
  33. package/dist/markdown/remark.js.map +1 -0
  34. package/dist/markdown/satteri.d.ts +19 -0
  35. package/dist/markdown/satteri.d.ts.map +1 -0
  36. package/dist/markdown/satteri.js +144 -0
  37. package/dist/markdown/satteri.js.map +1 -0
  38. package/dist/markdown/serialize.d.ts +60 -0
  39. package/dist/markdown/serialize.d.ts.map +1 -0
  40. package/dist/markdown/serialize.js +264 -0
  41. package/dist/markdown/serialize.js.map +1 -0
  42. package/dist/markdown/splice.d.ts +32 -0
  43. package/dist/markdown/splice.d.ts.map +1 -0
  44. package/dist/markdown/splice.js +44 -0
  45. package/dist/markdown/splice.js.map +1 -0
  46. package/dist/markdown/stamp.d.ts +68 -0
  47. package/dist/markdown/stamp.d.ts.map +1 -0
  48. package/dist/markdown/stamp.js +131 -0
  49. package/dist/markdown/stamp.js.map +1 -0
  50. package/dist/runtime/auth/rate-limiter.d.ts.map +1 -1
  51. package/dist/runtime/auth/rate-limiter.js +5 -0
  52. package/dist/runtime/auth/rate-limiter.js.map +1 -1
  53. package/dist/runtime/auth/session.d.ts +3 -3
  54. package/dist/runtime/auth/session.d.ts.map +1 -1
  55. package/dist/runtime/auth/session.js +68 -21
  56. package/dist/runtime/auth/session.js.map +1 -1
  57. package/dist/runtime/config.d.ts +3 -0
  58. package/dist/runtime/config.d.ts.map +1 -1
  59. package/dist/runtime/config.js +4 -1
  60. package/dist/runtime/config.js.map +1 -1
  61. package/dist/runtime/content.d.ts.map +1 -1
  62. package/dist/runtime/content.js +4 -2
  63. package/dist/runtime/content.js.map +1 -1
  64. package/dist/runtime/i18n.d.ts +86 -0
  65. package/dist/runtime/i18n.d.ts.map +1 -0
  66. package/dist/runtime/i18n.js +168 -0
  67. package/dist/runtime/i18n.js.map +1 -0
  68. package/dist/runtime/middleware.d.ts.map +1 -1
  69. package/dist/runtime/middleware.js +61 -6
  70. package/dist/runtime/middleware.js.map +1 -1
  71. package/dist/runtime/mutations/contracts.d.ts +28 -1
  72. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  73. package/dist/runtime/mutations/contracts.js +76 -0
  74. package/dist/runtime/mutations/contracts.js.map +1 -1
  75. package/dist/runtime/mutations/engine.d.ts +6 -1
  76. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  77. package/dist/runtime/mutations/engine.js +271 -51
  78. package/dist/runtime/mutations/engine.js.map +1 -1
  79. package/dist/runtime/providers.d.ts +2 -1
  80. package/dist/runtime/providers.d.ts.map +1 -1
  81. package/dist/runtime/providers.js +7 -1
  82. package/dist/runtime/providers.js.map +1 -1
  83. package/dist/runtime/publish.d.ts +13 -1
  84. package/dist/runtime/publish.d.ts.map +1 -1
  85. package/dist/runtime/publish.js +80 -4
  86. package/dist/runtime/publish.js.map +1 -1
  87. package/dist/runtime/request-context.d.ts +14 -2
  88. package/dist/runtime/request-context.d.ts.map +1 -1
  89. package/dist/runtime/request-context.js +7 -1
  90. package/dist/runtime/request-context.js.map +1 -1
  91. package/dist/runtime/rewrite.d.ts.map +1 -1
  92. package/dist/runtime/rewrite.js +109 -3
  93. package/dist/runtime/rewrite.js.map +1 -1
  94. package/dist/runtime/rich-allowlist.d.ts.map +1 -1
  95. package/dist/runtime/rich-allowlist.js +5 -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 +114 -18
  99. package/dist/runtime/routes/admin-entry.js.map +1 -1
  100. package/dist/runtime/routes/admin.d.ts.map +1 -1
  101. package/dist/runtime/routes/admin.js +33 -15
  102. package/dist/runtime/routes/admin.js.map +1 -1
  103. package/dist/runtime/routes/auth-login.d.ts.map +1 -1
  104. package/dist/runtime/routes/auth-login.js +4 -0
  105. package/dist/runtime/routes/auth-login.js.map +1 -1
  106. package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
  107. package/dist/runtime/routes/auth-logout.js +7 -2
  108. package/dist/runtime/routes/auth-logout.js.map +1 -1
  109. package/dist/runtime/routes/auth-session.d.ts.map +1 -1
  110. package/dist/runtime/routes/auth-session.js +4 -2
  111. package/dist/runtime/routes/auth-session.js.map +1 -1
  112. package/dist/runtime/routes/collection-list.d.ts.map +1 -1
  113. package/dist/runtime/routes/collection-list.js +64 -41
  114. package/dist/runtime/routes/collection-list.js.map +1 -1
  115. package/dist/runtime/routes/entries.d.ts.map +1 -1
  116. package/dist/runtime/routes/entries.js +16 -6
  117. package/dist/runtime/routes/entries.js.map +1 -1
  118. package/dist/runtime/routes/history.d.ts.map +1 -1
  119. package/dist/runtime/routes/history.js +50 -22
  120. package/dist/runtime/routes/history.js.map +1 -1
  121. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  122. package/dist/runtime/routes/mutate.js +40 -6
  123. package/dist/runtime/routes/mutate.js.map +1 -1
  124. package/dist/runtime/routes/publish.d.ts.map +1 -1
  125. package/dist/runtime/routes/publish.js +5 -2
  126. package/dist/runtime/routes/publish.js.map +1 -1
  127. package/dist/runtime/routes/schema.d.ts.map +1 -1
  128. package/dist/runtime/routes/schema.js +11 -3
  129. package/dist/runtime/routes/schema.js.map +1 -1
  130. package/dist/runtime/routes/studio-home.d.ts.map +1 -1
  131. package/dist/runtime/routes/studio-home.js +38 -19
  132. package/dist/runtime/routes/studio-home.js.map +1 -1
  133. package/dist/runtime/routes/theme-stylesheet.d.ts.map +1 -1
  134. package/dist/runtime/routes/theme-stylesheet.js +11 -1
  135. package/dist/runtime/routes/theme-stylesheet.js.map +1 -1
  136. package/dist/runtime/schema-registry.d.ts +8 -1
  137. package/dist/runtime/schema-registry.d.ts.map +1 -1
  138. package/dist/runtime/schema-registry.js +47 -3
  139. package/dist/runtime/schema-registry.js.map +1 -1
  140. package/dist/runtime/storage/atomic-write.d.ts.map +1 -1
  141. package/dist/runtime/storage/atomic-write.js +8 -3
  142. package/dist/runtime/storage/atomic-write.js.map +1 -1
  143. package/dist/runtime/storage/markdown-adapter.d.ts +19 -0
  144. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
  145. package/dist/runtime/storage/markdown-adapter.js +41 -0
  146. package/dist/runtime/storage/markdown-adapter.js.map +1 -1
  147. package/dist/runtime/storage/session-overlay-adapter.d.ts +8 -0
  148. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  149. package/dist/runtime/storage/session-overlay-adapter.js +24 -2
  150. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  151. package/dist/runtime/themes/preset-tokens.js +4 -4
  152. package/dist/runtime/themes/preset-tokens.js.map +1 -1
  153. package/dist/runtime/utils.d.ts +10 -0
  154. package/dist/runtime/utils.d.ts.map +1 -1
  155. package/dist/runtime/utils.js +16 -0
  156. package/dist/runtime/utils.js.map +1 -1
  157. package/dist/runtime/views/studio-layout.d.ts +3 -0
  158. package/dist/runtime/views/studio-layout.d.ts.map +1 -1
  159. package/dist/runtime/views/studio-layout.js +36 -13
  160. package/dist/runtime/views/studio-layout.js.map +1 -1
  161. package/dist/schema-utils.d.ts +7 -0
  162. package/dist/schema-utils.d.ts.map +1 -1
  163. package/dist/schema-utils.js +143 -0
  164. package/dist/schema-utils.js.map +1 -1
  165. package/dist/types.d.ts +83 -1
  166. package/dist/types.d.ts.map +1 -1
  167. package/package.json +2 -2
  168. package/static/cms/admin-entry.css +135 -13
  169. package/static/cms/admin-entry.js +509 -86
  170. package/static/cms/editor/guards.js +1 -1
  171. package/static/cms/editor/image-edit.js +42 -11
  172. package/static/cms/editor/image.css +7 -1
  173. package/static/cms/editor/md-block-edit.js +176 -0
  174. package/static/cms/editor/md-serialize.js +301 -0
  175. package/static/cms/editor/panel.css +69 -25
  176. package/static/cms/editor/panel.js +58 -1
  177. package/static/cms/editor/rich-toolbar.js +4 -3
  178. package/static/cms/editor/sanitize.js +2 -1
  179. package/static/cms/editor/sync.js +277 -56
  180. package/static/cms/editor/text-edit.js +68 -17
  181. package/static/cms/editor/text-link-interactions.js +4 -4
  182. package/static/cms/editor/toast.css +114 -2
  183. package/static/cms/editor/toast.js +121 -22
  184. package/static/cms/editor/tokens-and-text.css +73 -3
  185. package/static/cms/editor/toolbar.css +19 -6
  186. package/static/cms/editor/toolbar.js +64 -23
  187. package/static/cms/editor.css +1 -1
  188. package/static/cms/editor.js +14 -3
  189. package/static/cms/studio-tokens.css +4 -4
  190. package/static/cms/studio.css +24 -0
@@ -1,13 +1,27 @@
1
1
  /* ---------------------------------------------------------------------------
2
- Studio Panel — sliding left sidebar with iframe
2
+ Studio Panel — sliding left drawer with iframe
3
+
4
+ The panel is an OVERLAY drawer: it slides over the page and does not reflow
5
+ host layout. Earlier versions pushed `body` and shifted a hard-coded
6
+ `#main-header`, which silently broke on any site not using that selector or
7
+ using absolute/transformed layout. The only element still shifted is the CMS
8
+ toolbar, which is our own chrome (always present) — no host coupling.
9
+
10
+ Hosts that DO want content pushed aside (rather than overlaid) can opt in by
11
+ adding the `caret-push-content` class to <body>. See docs.
3
12
  --------------------------------------------------------------------------- */
4
13
 
14
+ :root {
15
+ --cms-panel-width: 380px;
16
+ }
17
+
5
18
  .cms-studio-panel {
6
19
  position: fixed;
7
20
  top: 0;
8
21
  left: 0;
9
22
  bottom: 0;
10
- width: 380px;
23
+ width: var(--cms-panel-width);
24
+ max-width: 100vw;
11
25
  z-index: 100000;
12
26
  background: #0c0e12;
13
27
  border-right: 1px solid rgba(59, 130, 246, 0.12);
@@ -31,29 +45,46 @@
31
45
  display: block;
32
46
  }
33
47
 
34
- /* Push body content when panel is open */
35
- body.cms-panel-open {
36
- padding-left: 380px;
37
- transition: padding-left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
48
+ /* Loading skeleton shown until the Studio iframe reports load. */
49
+ .cms-studio-panel-loading {
50
+ position: absolute;
51
+ inset: 0;
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ background: #0c0e12;
56
+ color: rgba(255, 255, 255, 0.5);
57
+ font-family: var(--cms-font);
58
+ font-size: 12px;
59
+ z-index: 1;
60
+ transition: opacity 0.3s ease;
38
61
  }
39
62
 
40
- body.cms-panel-open.no-transition {
41
- transition: none;
63
+ .cms-studio-panel-loading[hidden] {
64
+ opacity: 0;
65
+ pointer-events: none;
42
66
  }
43
67
 
44
- /* Shift fixed header */
45
- body.cms-panel-open #main-header {
46
- left: 380px;
47
- transition: left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
68
+ .cms-studio-panel-loading-text {
69
+ margin-left: 10px;
48
70
  }
49
71
 
50
- body.cms-panel-open.no-transition #main-header {
51
- transition: none;
72
+ .cms-studio-panel-spinner {
73
+ width: 22px;
74
+ height: 22px;
75
+ border: 2px solid rgba(255, 255, 255, 0.15);
76
+ border-top-color: var(--cms-accent, #3b82f6);
77
+ border-radius: 50%;
78
+ animation: cms-panel-spin 0.7s linear infinite;
52
79
  }
53
80
 
54
- /* Shift fixed toolbar */
81
+ @keyframes cms-panel-spin {
82
+ to { transform: rotate(360deg); }
83
+ }
84
+
85
+ /* Shift the CMS toolbar (our chrome) so the drawer doesn't cover it. */
55
86
  body.cms-panel-open .cms-toolbar {
56
- left: 380px;
87
+ left: var(--cms-panel-width);
57
88
  transition: left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
58
89
  }
59
90
 
@@ -61,6 +92,16 @@ body.cms-panel-open.no-transition .cms-toolbar {
61
92
  transition: none;
62
93
  }
63
94
 
95
+ /* Opt-in: push host content aside instead of overlaying it. */
96
+ body.cms-panel-open.caret-push-content {
97
+ padding-left: var(--cms-panel-width);
98
+ transition: padding-left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
99
+ }
100
+
101
+ body.cms-panel-open.caret-push-content.no-transition {
102
+ transition: none;
103
+ }
104
+
64
105
  /* Studio button active state */
65
106
  .cms-studio-btn.active {
66
107
  background: rgba(59, 130, 246, 0.3);
@@ -69,23 +110,26 @@ body.cms-panel-open.no-transition .cms-toolbar {
69
110
  box-shadow: 0 0 8px rgba(59, 130, 246, 0.15);
70
111
  }
71
112
 
72
-
73
- /* Mobile: full-width overlay, no content push */
113
+ /* Mobile: full-width overlay, no toolbar shift. */
74
114
  @media (max-width: 768px) {
75
115
  .cms-studio-panel {
76
116
  width: 100%;
77
117
  box-shadow: none;
78
118
  }
79
119
 
80
- body.cms-panel-open {
81
- padding-left: 0;
82
- }
83
-
84
- body.cms-panel-open #main-header {
120
+ body.cms-panel-open .cms-toolbar,
121
+ body.cms-panel-open.caret-push-content {
85
122
  left: 0;
123
+ padding-left: 0;
86
124
  }
125
+ }
87
126
 
88
- body.cms-panel-open .cms-toolbar {
89
- left: 0;
127
+ @media (prefers-reduced-motion: reduce) {
128
+ .cms-studio-panel,
129
+ body.cms-panel-open .cms-toolbar,
130
+ body.cms-panel-open.caret-push-content,
131
+ .cms-studio-panel-spinner {
132
+ transition: none;
133
+ animation: none;
90
134
  }
91
135
  }
@@ -9,22 +9,69 @@ export function mountStudioPanel({ studioButton }) {
9
9
 
10
10
  const panel = document.createElement('div');
11
11
  panel.className = 'cms-studio-panel';
12
+ // A dialog-like region so AT users understand the drawer context.
13
+ panel.setAttribute('role', 'dialog');
14
+ panel.setAttribute('aria-label', 'Content Studio');
15
+ panel.setAttribute('aria-hidden', 'true');
16
+
17
+ // Loading skeleton shown until the iframe reports load — the iframe is blank
18
+ // until then, which reads as "broken".
19
+ const loading = document.createElement('div');
20
+ loading.className = 'cms-studio-panel-loading';
21
+ loading.innerHTML =
22
+ '<span class="cms-studio-panel-spinner" aria-hidden="true"></span>' +
23
+ '<span class="cms-studio-panel-loading-text">Loading Studio…</span>';
24
+ panel.appendChild(loading);
12
25
 
13
26
  const iframe = document.createElement('iframe');
14
- iframe.src = STUDIO_PATH;
27
+ 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.
35
+ }
36
+ iframe.src = rememberedPath || STUDIO_PATH;
15
37
  iframe.title = 'Content Studio';
38
+ iframe.addEventListener('load', () => {
39
+ loading.setAttribute('hidden', 'hidden');
40
+ try {
41
+ const currentPath = `${iframe.contentWindow.location.pathname}${iframe.contentWindow.location.search}`;
42
+ if (currentPath === STUDIO_PATH || currentPath.startsWith(`${STUDIO_PATH}/`)) {
43
+ sessionStorage.setItem('cms-panel-path', currentPath);
44
+ }
45
+ } catch {
46
+ // The configured Studio may be cross-origin, in which case its route is
47
+ // intentionally unavailable to the parent page.
48
+ }
49
+ });
16
50
  panel.appendChild(iframe);
17
51
  document.body.appendChild(panel);
18
52
 
53
+ // Remember what had focus so we can restore it on close.
54
+ let lastFocused = null;
55
+
19
56
  function openPanel(animate = true) {
57
+ lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
20
58
  if (!animate) {
21
59
  panel.classList.add('no-transition');
22
60
  document.body.classList.add('no-transition');
23
61
  }
24
62
  panel.classList.add('open');
63
+ panel.setAttribute('aria-hidden', 'false');
25
64
  document.body.classList.add('cms-panel-open');
26
65
  studioButton?.classList.add('active');
66
+ studioButton?.setAttribute('aria-expanded', 'true');
27
67
  sessionStorage.setItem('cms-panel-open', '1');
68
+ // Move focus into the drawer so keyboard users aren't left behind the
69
+ // covering panel. The iframe is focusable and hands focus to its own content.
70
+ if (animate) {
71
+ try {
72
+ iframe.focus({ preventScroll: true });
73
+ } catch (e) {}
74
+ }
28
75
  if (!animate) {
29
76
  requestAnimationFrame(() => {
30
77
  requestAnimationFrame(() => {
@@ -37,9 +84,19 @@ export function mountStudioPanel({ studioButton }) {
37
84
 
38
85
  function closePanel() {
39
86
  panel.classList.remove('open');
87
+ panel.setAttribute('aria-hidden', 'true');
40
88
  document.body.classList.remove('cms-panel-open');
41
89
  studioButton?.classList.remove('active');
90
+ studioButton?.setAttribute('aria-expanded', 'false');
42
91
  sessionStorage.setItem('cms-panel-open', '0');
92
+ // Return focus to the trigger (or wherever it was) so it isn't lost.
93
+ const restore = lastFocused || studioButton;
94
+ if (restore instanceof HTMLElement) {
95
+ try {
96
+ restore.focus({ preventScroll: true });
97
+ } catch (e) {}
98
+ }
99
+ lastFocused = null;
43
100
  }
44
101
 
45
102
  function togglePanel() {
@@ -175,11 +175,12 @@ function onSelectionChange() {
175
175
  return;
176
176
  }
177
177
 
178
- // Check if selection is inside a rich element
178
+ // Check if selection is inside a rich element (or a markdown body block,
179
+ // which shares the same inline formatting commands)
179
180
  const anchor = sel.anchorNode;
180
181
  const richEl = anchor instanceof Element
181
- ? anchor.closest('[data-caret-rich]')
182
- : anchor?.parentElement?.closest('[data-caret-rich]');
182
+ ? anchor.closest('[data-caret-rich], [data-caret-md]')
183
+ : anchor?.parentElement?.closest('[data-caret-rich], [data-caret-md]');
183
184
 
184
185
  if (!richEl) {
185
186
  hide();
@@ -17,7 +17,8 @@ export const ALLOWED_ATTRS = {
17
17
  a: new Set(['href', 'target', 'rel']),
18
18
  };
19
19
 
20
- export const SAFE_HREF_RE = /^(?:https?:|mailto:|tel:|\/)/i;
20
+ // `(?!\/)` rejects protocol-relative URLs (`//evil.com`). Mirrors core exactly.
21
+ export const SAFE_HREF_RE = /^(?:https?:|mailto:|tel:|\/(?!\/))/i;
21
22
 
22
23
  /**
23
24
  * Does `cls` match any pattern? (exact, `prefix-*`, or lone `*`)
@@ -1,72 +1,293 @@
1
1
  import { sanitizeHtml } from './sanitize.js';
2
+ import { parseCaretAttr, resolveBinding } from './helpers.js';
3
+ import { STUDIO_PATH } from './config.js';
4
+
5
+ const SYNC_CHANNEL = 'caretcms:content';
2
6
 
3
7
  export function mountStudioSync({ studioIframe, state, showToast, clientLinkify }) {
4
- if (!studioIframe) return;
8
+ let refreshTimer = 0;
9
+ let selectedInlineEl = null;
10
+ let linkedSelectionTimer = 0;
11
+ let pendingStudioSelection = null;
12
+ let activeStudioSelection = null;
5
13
 
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;
14
+ function matchingElements(collection, id, field) {
15
+ const fullKey = `${collection}::${id}::${field}`;
16
+ const matches = Array.from(document.querySelectorAll(`[data-caret="${CSS.escape(fullKey)}"]`));
17
+ document.querySelectorAll(`[data-caret="${CSS.escape(field)}"]`).forEach((el) => {
18
+ const scope = el.closest('[data-caret-scope]')?.getAttribute('data-caret-scope');
19
+ if (scope === `${collection}::${id}` && !matches.includes(el)) matches.push(el);
20
+ });
21
+ return matches;
22
+ }
11
23
 
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 (el === state.dirtyEl || 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;
24
+ function selectInlineField(collection, id, field, shouldScroll) {
25
+ if (typeof collection !== 'string' || typeof id !== 'string' || typeof field !== 'string') return;
26
+ const target = matchingElements(collection, id, field)[0];
27
+ if (!target) return;
28
+
29
+ window.clearTimeout(linkedSelectionTimer);
30
+ if (selectedInlineEl && selectedInlineEl !== target) {
31
+ selectedInlineEl.classList.remove('cms-linked-selection');
32
+ }
33
+ selectedInlineEl = target;
34
+ target.classList.add('cms-linked-selection');
35
+ if (shouldScroll) {
36
+ target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
37
+ }
38
+
39
+ // Blue confirms that the paired field was located. Green remains the
40
+ // distinct confirmation that an edit was actually saved.
41
+ linkedSelectionTimer = window.setTimeout(() => {
42
+ if (selectedInlineEl !== target) return;
43
+ target.classList.remove('cms-linked-selection');
44
+ selectedInlineEl = null;
45
+ }, 1500);
46
+ }
47
+
48
+ function publishSelection(message) {
49
+ if (!('BroadcastChannel' in window)) return;
50
+ try {
51
+ const channel = new BroadcastChannel(SYNC_CHANNEL);
52
+ channel.postMessage(message);
53
+ window.setTimeout(() => channel.close(), 0);
54
+ } catch {
55
+ // Cross-tab selection linking is a progressive enhancement.
56
+ }
57
+ }
58
+
59
+ function studioEntryPath(message) {
60
+ return `${STUDIO_PATH}/${encodeURIComponent(message.collection)}/${encodeURIComponent(message.id)}`;
61
+ }
62
+
63
+ function postEmbeddedSelection(message) {
64
+ if (!studioIframe?.contentWindow) return;
65
+ try {
66
+ studioIframe.contentWindow.postMessage({ ...message, embedded: true }, window.location.origin);
67
+ } catch {
68
+ // Embedded field linking is a progressive enhancement.
69
+ }
70
+ }
71
+
72
+ function deliverEmbeddedSelection(message) {
73
+ if (!studioIframe?.contentWindow) return;
74
+ const targetPath = studioEntryPath(message);
75
+
76
+ // A normal mouse interaction can emit pointerdown, focusin, and click.
77
+ // Keep the latest field without restarting the same iframe navigation for
78
+ // each event in that sequence.
79
+ if (pendingStudioSelection && studioIframe.getAttribute('src') === targetPath) {
80
+ pendingStudioSelection = message;
81
+ return;
82
+ }
83
+
84
+ try {
85
+ if (studioIframe.contentWindow.location.pathname !== targetPath) {
86
+ pendingStudioSelection = message;
87
+ studioIframe.src = targetPath;
88
+ try {
89
+ sessionStorage.setItem('cms-panel-path', targetPath);
90
+ } catch {
91
+ // Remembering the route is optional; the current navigation is not.
92
+ }
93
+ return;
94
+ }
95
+ } catch {
96
+ // Cross-origin Studio panels cannot be inspected or navigated safely;
97
+ // preserve the existing postMessage-only behavior for those setups.
98
+ }
99
+
100
+ postEmbeddedSelection(message);
101
+ }
102
+
103
+ studioIframe?.addEventListener('load', () => {
104
+ if (!pendingStudioSelection) return;
105
+ const message = pendingStudioSelection;
106
+ pendingStudioSelection = null;
107
+ window.requestAnimationFrame(() => postEmbeddedSelection(message));
108
+ });
109
+
110
+ function selectStudioField(el) {
111
+ const resolved = resolveBinding(el, parseCaretAttr(el.getAttribute('data-caret') || ''));
112
+ if (!resolved) return;
113
+
114
+ selectInlineField(resolved.collection, resolved.id, resolved.field, false);
115
+ const message = {
116
+ type: 'cms:field-selected',
117
+ collection: resolved.collection,
118
+ id: resolved.id,
119
+ field: resolved.field,
120
+ source: 'inline',
121
+ };
122
+
123
+ activeStudioSelection = message;
124
+ deliverEmbeddedSelection(message);
125
+ publishSelection(message);
126
+ }
127
+
128
+ // Pointerdown covers image-like bindings; focusin covers keyboard navigation
129
+ // and text editing. Click catches non-focusable bound elements.
130
+ document.addEventListener('pointerdown', (event) => {
131
+ const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
132
+ if (el) selectStudioField(el);
133
+ });
134
+ document.addEventListener('click', (event) => {
135
+ const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
136
+ if (el) selectStudioField(el);
137
+ });
138
+ document.addEventListener('focusin', (event) => {
139
+ const el = event.target instanceof Element ? event.target.closest('[data-caret]') : null;
140
+ if (el) selectStudioField(el);
141
+ });
142
+
143
+ function rememberStudioPath(msg) {
144
+ if (typeof msg.studioPath !== 'string') return;
145
+ try {
146
+ sessionStorage.setItem('cms-panel-path', msg.studioPath);
147
+ } catch {
148
+ // Remembering the current Studio route is a progressive enhancement.
149
+ }
150
+ }
151
+
152
+ function scheduleEmbeddedRefresh(msg) {
153
+ rememberStudioPath(msg);
154
+
155
+ if (state.dirtyEls.size > 0) {
156
+ showToast('Saved in Studio — finish your inline edit before refreshing', 'success');
157
+ return;
158
+ }
159
+
160
+ showToast('Saved — refreshing visual preview…', 'success');
161
+ window.clearTimeout(refreshTimer);
162
+ refreshTimer = window.setTimeout(() => window.location.reload(), 280);
163
+ }
164
+
165
+ function applySavedMessage(msg) {
166
+ const { collection, id, data } = msg;
167
+ if (!data || typeof data !== 'object') return 0;
168
+
169
+ let updated = 0;
170
+
171
+ function boundElements(selector, path) {
172
+ const matches = Array.from(document.querySelectorAll(`[data-caret="${CSS.escape(selector)}"]`));
173
+ document.querySelectorAll(`[data-caret="${CSS.escape(path)}"]`).forEach((el) => {
174
+ const scope = el.closest('[data-caret-scope]')?.getAttribute('data-caret-scope');
175
+ if (scope === `${collection}::${id}` && !matches.includes(el)) matches.push(el);
176
+ });
177
+ return matches;
178
+ }
179
+
180
+ function walkData(obj, prefix) {
181
+ for (const [key, value] of Object.entries(obj)) {
182
+ const path = prefix ? `${prefix}.${key}` : key;
183
+ const selector = `${collection}::${id}::${path}`;
184
+
185
+ if (typeof value === 'string') {
186
+ const els = boundElements(selector, path);
187
+ els.forEach((el) => {
188
+ // Skip elements being actively edited to prevent overwriting unsaved work
189
+ if (state.dirtyEls.has(el) || el === document.activeElement) return;
190
+
191
+ if (el instanceof HTMLImageElement) {
192
+ if (el.src !== value && el.getAttribute('src') !== value) {
193
+ el.src = value;
194
+ updated++;
195
+ }
196
+ } else if (el.hasAttribute('data-caret-rich')) {
197
+ const sanitized = sanitizeHtml(value);
198
+ if (el.innerHTML !== sanitized) {
199
+ el.innerHTML = sanitized;
200
+ updated++;
201
+ }
202
+ } else if (el.hasAttribute('data-caret-raw')) {
203
+ if (el.getAttribute('data-caret-raw') !== value) {
204
+ el.setAttribute('data-caret-raw', value);
205
+ el.innerHTML = clientLinkify(value);
50
206
  updated++;
51
207
  }
52
- });
53
- } else if (typeof value === 'object' && value !== null) {
54
- walkData(value, path);
55
- }
208
+ } else if (el.textContent !== value) {
209
+ el.textContent = value;
210
+ updated++;
211
+ }
212
+ });
213
+ } else if (typeof value === 'object' && value !== null) {
214
+ walkData(value, path);
56
215
  }
57
216
  }
217
+ }
58
218
 
59
- walkData(data, '');
219
+ walkData(data, '');
220
+ return updated;
221
+ }
60
222
 
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
- }
223
+ window.addEventListener('message', (e) => {
224
+ // Only accept messages from our iframe
225
+ if (!studioIframe || e.source !== studioIframe.contentWindow) return;
226
+ const msg = e.data;
227
+ if (!msg || typeof msg.type !== 'string') return;
228
+
229
+ if (
230
+ msg.type === 'cms:entry-ready'
231
+ && activeStudioSelection
232
+ && msg.collection === activeStudioSelection.collection
233
+ && msg.id === activeStudioSelection.id
234
+ ) {
235
+ postEmbeddedSelection(activeStudioSelection);
236
+ return;
237
+ }
238
+
239
+ if (msg.type === 'cms:preview') {
240
+ applySavedMessage(msg);
241
+ return;
66
242
  }
67
243
 
68
- if (msg.type === 'cms:created') {
69
- showToast('New entry created — refresh to see changes', 'success');
244
+ if (msg.type === 'cms:field-selected') {
245
+ selectInlineField(msg.collection, msg.id, msg.field, true);
246
+ return;
247
+ }
248
+
249
+ if (msg.type === 'cms:saved') {
250
+ applySavedMessage(msg);
251
+ scheduleEmbeddedRefresh(msg);
252
+ }
253
+
254
+ if (msg.type === 'cms:created' || msg.type === 'cms:deleted') {
255
+ scheduleEmbeddedRefresh(msg);
70
256
  }
71
257
  });
258
+
259
+ // Standalone Studio pages live in a separate tab, so postMessage cannot
260
+ // reach the site preview. BroadcastChannel provides a same-origin bridge.
261
+ // Reload only after a confirmed mutation. The server remains the source of
262
+ // truth for lists, routing, and derived UI; sites can make the refresh feel
263
+ // continuous with their own cross-document view transitions.
264
+ if ('BroadcastChannel' in window) {
265
+ try {
266
+ const channel = new BroadcastChannel(SYNC_CHANNEL);
267
+ channel.addEventListener('message', (event) => {
268
+ const msg = event.data;
269
+ if (!msg || typeof msg.type !== 'string' || msg.embedded === true) return;
270
+
271
+ if (msg.type === 'cms:field-selected') {
272
+ selectInlineField(msg.collection, msg.id, msg.field, true);
273
+ return;
274
+ }
275
+ if (!['cms:saved', 'cms:created', 'cms:deleted'].includes(msg.type)) return;
276
+
277
+ if (state.dirtyEls.size > 0) {
278
+ showToast('Saved in Studio — finish your inline edit before refreshing', 'success');
279
+ return;
280
+ }
281
+
282
+ if (msg.type === 'cms:saved') applySavedMessage(msg);
283
+ showToast('Saved — refreshing preview…', 'success');
284
+ window.clearTimeout(refreshTimer);
285
+ refreshTimer = window.setTimeout(() => window.location.reload(), 280);
286
+ });
287
+ window.addEventListener('pagehide', () => channel.close(), { once: true });
288
+ } catch {
289
+ // Cross-tab preview sync is progressive enhancement; iframe sync above
290
+ // remains available in browsers without BroadcastChannel.
291
+ }
292
+ }
72
293
  }