@caretcms/core 0.1.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 (249) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +191 -0
  3. package/dist/browser-runtime.d.ts +12 -0
  4. package/dist/browser-runtime.d.ts.map +1 -0
  5. package/dist/browser-runtime.js +298 -0
  6. package/dist/browser-runtime.js.map +1 -0
  7. package/dist/index.d.ts +81 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +343 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/loader.d.ts +61 -0
  12. package/dist/loader.d.ts.map +1 -0
  13. package/dist/loader.js +80 -0
  14. package/dist/loader.js.map +1 -0
  15. package/dist/providers/storage/filesystem.d.ts +7 -0
  16. package/dist/providers/storage/filesystem.d.ts.map +1 -0
  17. package/dist/providers/storage/filesystem.js +6 -0
  18. package/dist/providers/storage/filesystem.js.map +1 -0
  19. package/dist/providers/uploads/local.d.ts +6 -0
  20. package/dist/providers/uploads/local.d.ts.map +1 -0
  21. package/dist/providers/uploads/local.js +6 -0
  22. package/dist/providers/uploads/local.js.map +1 -0
  23. package/dist/runtime/auth/cookie-utils.d.ts +3 -0
  24. package/dist/runtime/auth/cookie-utils.d.ts.map +1 -0
  25. package/dist/runtime/auth/cookie-utils.js +33 -0
  26. package/dist/runtime/auth/cookie-utils.js.map +1 -0
  27. package/dist/runtime/auth/demo-session.d.ts +24 -0
  28. package/dist/runtime/auth/demo-session.d.ts.map +1 -0
  29. package/dist/runtime/auth/demo-session.js +42 -0
  30. package/dist/runtime/auth/demo-session.js.map +1 -0
  31. package/dist/runtime/auth/rate-limiter.d.ts +26 -0
  32. package/dist/runtime/auth/rate-limiter.d.ts.map +1 -0
  33. package/dist/runtime/auth/rate-limiter.js +65 -0
  34. package/dist/runtime/auth/rate-limiter.js.map +1 -0
  35. package/dist/runtime/auth/session.d.ts +14 -0
  36. package/dist/runtime/auth/session.d.ts.map +1 -0
  37. package/dist/runtime/auth/session.js +128 -0
  38. package/dist/runtime/auth/session.js.map +1 -0
  39. package/dist/runtime/bind.d.ts +14 -0
  40. package/dist/runtime/bind.d.ts.map +1 -0
  41. package/dist/runtime/bind.js +19 -0
  42. package/dist/runtime/bind.js.map +1 -0
  43. package/dist/runtime/config.d.ts +20 -0
  44. package/dist/runtime/config.d.ts.map +1 -0
  45. package/dist/runtime/config.js +40 -0
  46. package/dist/runtime/config.js.map +1 -0
  47. package/dist/runtime/content.d.ts +28 -0
  48. package/dist/runtime/content.d.ts.map +1 -0
  49. package/dist/runtime/content.js +37 -0
  50. package/dist/runtime/content.js.map +1 -0
  51. package/dist/runtime/index.d.ts +14 -0
  52. package/dist/runtime/index.d.ts.map +1 -0
  53. package/dist/runtime/index.js +16 -0
  54. package/dist/runtime/index.js.map +1 -0
  55. package/dist/runtime/middleware.d.ts +14 -0
  56. package/dist/runtime/middleware.d.ts.map +1 -0
  57. package/dist/runtime/middleware.js +80 -0
  58. package/dist/runtime/middleware.js.map +1 -0
  59. package/dist/runtime/mutations/contracts.d.ts +91 -0
  60. package/dist/runtime/mutations/contracts.d.ts.map +1 -0
  61. package/dist/runtime/mutations/contracts.js +320 -0
  62. package/dist/runtime/mutations/contracts.js.map +1 -0
  63. package/dist/runtime/mutations/engine.d.ts +23 -0
  64. package/dist/runtime/mutations/engine.d.ts.map +1 -0
  65. package/dist/runtime/mutations/engine.js +255 -0
  66. package/dist/runtime/mutations/engine.js.map +1 -0
  67. package/dist/runtime/providers.d.ts +9 -0
  68. package/dist/runtime/providers.d.ts.map +1 -0
  69. package/dist/runtime/providers.js +44 -0
  70. package/dist/runtime/providers.js.map +1 -0
  71. package/dist/runtime/request-context.d.ts +11 -0
  72. package/dist/runtime/request-context.d.ts.map +1 -0
  73. package/dist/runtime/request-context.js +16 -0
  74. package/dist/runtime/request-context.js.map +1 -0
  75. package/dist/runtime/rewrite.d.ts +13 -0
  76. package/dist/runtime/rewrite.d.ts.map +1 -0
  77. package/dist/runtime/rewrite.js +253 -0
  78. package/dist/runtime/rewrite.js.map +1 -0
  79. package/dist/runtime/routes/_helpers.d.ts +17 -0
  80. package/dist/runtime/routes/_helpers.d.ts.map +1 -0
  81. package/dist/runtime/routes/_helpers.js +38 -0
  82. package/dist/runtime/routes/_helpers.js.map +1 -0
  83. package/dist/runtime/routes/admin-entry.d.ts +3 -0
  84. package/dist/runtime/routes/admin-entry.d.ts.map +1 -0
  85. package/dist/runtime/routes/admin-entry.js +170 -0
  86. package/dist/runtime/routes/admin-entry.js.map +1 -0
  87. package/dist/runtime/routes/admin.d.ts +3 -0
  88. package/dist/runtime/routes/admin.d.ts.map +1 -0
  89. package/dist/runtime/routes/admin.js +189 -0
  90. package/dist/runtime/routes/admin.js.map +1 -0
  91. package/dist/runtime/routes/auth-login.d.ts +4 -0
  92. package/dist/runtime/routes/auth-login.d.ts.map +1 -0
  93. package/dist/runtime/routes/auth-login.js +71 -0
  94. package/dist/runtime/routes/auth-login.js.map +1 -0
  95. package/dist/runtime/routes/auth-logout.d.ts +4 -0
  96. package/dist/runtime/routes/auth-logout.d.ts.map +1 -0
  97. package/dist/runtime/routes/auth-logout.js +47 -0
  98. package/dist/runtime/routes/auth-logout.js.map +1 -0
  99. package/dist/runtime/routes/auth-session.d.ts +3 -0
  100. package/dist/runtime/routes/auth-session.d.ts.map +1 -0
  101. package/dist/runtime/routes/auth-session.js +8 -0
  102. package/dist/runtime/routes/auth-session.js.map +1 -0
  103. package/dist/runtime/routes/collection-list.d.ts +3 -0
  104. package/dist/runtime/routes/collection-list.d.ts.map +1 -0
  105. package/dist/runtime/routes/collection-list.js +420 -0
  106. package/dist/runtime/routes/collection-list.js.map +1 -0
  107. package/dist/runtime/routes/collections-metadata.d.ts +3 -0
  108. package/dist/runtime/routes/collections-metadata.d.ts.map +1 -0
  109. package/dist/runtime/routes/collections-metadata.js +17 -0
  110. package/dist/runtime/routes/collections-metadata.js.map +1 -0
  111. package/dist/runtime/routes/editor-assets.d.ts +3 -0
  112. package/dist/runtime/routes/editor-assets.d.ts.map +1 -0
  113. package/dist/runtime/routes/editor-assets.js +73 -0
  114. package/dist/runtime/routes/editor-assets.js.map +1 -0
  115. package/dist/runtime/routes/entries.d.ts +3 -0
  116. package/dist/runtime/routes/entries.d.ts.map +1 -0
  117. package/dist/runtime/routes/entries.js +96 -0
  118. package/dist/runtime/routes/entries.js.map +1 -0
  119. package/dist/runtime/routes/history.d.ts +4 -0
  120. package/dist/runtime/routes/history.d.ts.map +1 -0
  121. package/dist/runtime/routes/history.js +54 -0
  122. package/dist/runtime/routes/history.js.map +1 -0
  123. package/dist/runtime/routes/mutate.d.ts +4 -0
  124. package/dist/runtime/routes/mutate.d.ts.map +1 -0
  125. package/dist/runtime/routes/mutate.js +38 -0
  126. package/dist/runtime/routes/mutate.js.map +1 -0
  127. package/dist/runtime/routes/schema.d.ts +3 -0
  128. package/dist/runtime/routes/schema.d.ts.map +1 -0
  129. package/dist/runtime/routes/schema.js +58 -0
  130. package/dist/runtime/routes/schema.js.map +1 -0
  131. package/dist/runtime/routes/studio-home.d.ts +3 -0
  132. package/dist/runtime/routes/studio-home.d.ts.map +1 -0
  133. package/dist/runtime/routes/studio-home.js +99 -0
  134. package/dist/runtime/routes/studio-home.js.map +1 -0
  135. package/dist/runtime/routes/theme-stylesheet.d.ts +3 -0
  136. package/dist/runtime/routes/theme-stylesheet.d.ts.map +1 -0
  137. package/dist/runtime/routes/theme-stylesheet.js +32 -0
  138. package/dist/runtime/routes/theme-stylesheet.js.map +1 -0
  139. package/dist/runtime/routes/upload.d.ts +3 -0
  140. package/dist/runtime/routes/upload.d.ts.map +1 -0
  141. package/dist/runtime/routes/upload.js +33 -0
  142. package/dist/runtime/routes/upload.js.map +1 -0
  143. package/dist/runtime/sanitize-html.d.ts +8 -0
  144. package/dist/runtime/sanitize-html.d.ts.map +1 -0
  145. package/dist/runtime/sanitize-html.js +86 -0
  146. package/dist/runtime/sanitize-html.js.map +1 -0
  147. package/dist/runtime/schema/template.d.ts +15 -0
  148. package/dist/runtime/schema/template.d.ts.map +1 -0
  149. package/dist/runtime/schema/template.js +38 -0
  150. package/dist/runtime/schema/template.js.map +1 -0
  151. package/dist/runtime/schema-registry.d.ts +17 -0
  152. package/dist/runtime/schema-registry.d.ts.map +1 -0
  153. package/dist/runtime/schema-registry.js +16 -0
  154. package/dist/runtime/schema-registry.js.map +1 -0
  155. package/dist/runtime/storage/filesystem-adapter.d.ts +36 -0
  156. package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -0
  157. package/dist/runtime/storage/filesystem-adapter.js +291 -0
  158. package/dist/runtime/storage/filesystem-adapter.js.map +1 -0
  159. package/dist/runtime/storage/filesystem-upload-handler.d.ts +11 -0
  160. package/dist/runtime/storage/filesystem-upload-handler.d.ts.map +1 -0
  161. package/dist/runtime/storage/filesystem-upload-handler.js +20 -0
  162. package/dist/runtime/storage/filesystem-upload-handler.js.map +1 -0
  163. package/dist/runtime/storage/image-validation.d.ts +28 -0
  164. package/dist/runtime/storage/image-validation.d.ts.map +1 -0
  165. package/dist/runtime/storage/image-validation.js +94 -0
  166. package/dist/runtime/storage/image-validation.js.map +1 -0
  167. package/dist/runtime/storage/in-memory-adapter.d.ts +29 -0
  168. package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -0
  169. package/dist/runtime/storage/in-memory-adapter.js +110 -0
  170. package/dist/runtime/storage/in-memory-adapter.js.map +1 -0
  171. package/dist/runtime/storage/quota-upload-handler.d.ts +27 -0
  172. package/dist/runtime/storage/quota-upload-handler.d.ts.map +1 -0
  173. package/dist/runtime/storage/quota-upload-handler.js +34 -0
  174. package/dist/runtime/storage/quota-upload-handler.js.map +1 -0
  175. package/dist/runtime/storage/session-overlay-adapter.d.ts +33 -0
  176. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -0
  177. package/dist/runtime/storage/session-overlay-adapter.js +121 -0
  178. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -0
  179. package/dist/runtime/themes/preset-tokens.d.ts +17 -0
  180. package/dist/runtime/themes/preset-tokens.d.ts.map +1 -0
  181. package/dist/runtime/themes/preset-tokens.js +47 -0
  182. package/dist/runtime/themes/preset-tokens.js.map +1 -0
  183. package/dist/runtime/utils.d.ts +6 -0
  184. package/dist/runtime/utils.d.ts.map +1 -0
  185. package/dist/runtime/utils.js +29 -0
  186. package/dist/runtime/utils.js.map +1 -0
  187. package/dist/runtime/views/studio-layout.d.ts +25 -0
  188. package/dist/runtime/views/studio-layout.d.ts.map +1 -0
  189. package/dist/runtime/views/studio-layout.js +108 -0
  190. package/dist/runtime/views/studio-layout.js.map +1 -0
  191. package/dist/schema-utils.d.ts +16 -0
  192. package/dist/schema-utils.d.ts.map +1 -0
  193. package/dist/schema-utils.js +62 -0
  194. package/dist/schema-utils.js.map +1 -0
  195. package/dist/types.d.ts +76 -0
  196. package/dist/types.d.ts.map +1 -0
  197. package/dist/types.js +2 -0
  198. package/dist/types.js.map +1 -0
  199. package/editor-runtime.d.ts +6 -0
  200. package/editor-runtime.js +2 -0
  201. package/package.json +88 -0
  202. package/static/cms/admin-entry.css +207 -0
  203. package/static/cms/admin-entry.js +1083 -0
  204. package/static/cms/editor/config.js +118 -0
  205. package/static/cms/editor/content-map.css +141 -0
  206. package/static/cms/editor/content-map.js +172 -0
  207. package/static/cms/editor/guards.js +72 -0
  208. package/static/cms/editor/helpers.js +51 -0
  209. package/static/cms/editor/image-edit.js +151 -0
  210. package/static/cms/editor/image-utils.js +84 -0
  211. package/static/cms/editor/image.css +133 -0
  212. package/static/cms/editor/link-follow.js +144 -0
  213. package/static/cms/editor/link-ui.css +199 -0
  214. package/static/cms/editor/linkify.js +55 -0
  215. package/static/cms/editor/panel.css +91 -0
  216. package/static/cms/editor/panel.js +70 -0
  217. package/static/cms/editor/rich-toolbar.css +67 -0
  218. package/static/cms/editor/rich-toolbar.js +200 -0
  219. package/static/cms/editor/sanitize.js +89 -0
  220. package/static/cms/editor/save-queue.js +166 -0
  221. package/static/cms/editor/section-controls/activation.js +10 -0
  222. package/static/cms/editor/section-controls/api.js +87 -0
  223. package/static/cms/editor/section-controls/constants.js +24 -0
  224. package/static/cms/editor/section-controls/index.js +622 -0
  225. package/static/cms/editor/section-controls/model.js +76 -0
  226. package/static/cms/editor/section-controls/mutations.js +112 -0
  227. package/static/cms/editor/section-controls/page-context.js +34 -0
  228. package/static/cms/editor/section-controls/pickers.js +196 -0
  229. package/static/cms/editor/section-controls/reorder.js +92 -0
  230. package/static/cms/editor/section-controls/selection.js +54 -0
  231. package/static/cms/editor/section-controls/spacing-drag.js +83 -0
  232. package/static/cms/editor/section-controls/ui-elements.js +54 -0
  233. package/static/cms/editor/section-controls/utils.js +35 -0
  234. package/static/cms/editor/section-controls.css +349 -0
  235. package/static/cms/editor/section-controls.js +1 -0
  236. package/static/cms/editor/security.js +48 -0
  237. package/static/cms/editor/sync.js +72 -0
  238. package/static/cms/editor/text-edit.js +151 -0
  239. package/static/cms/editor/text-link-interactions.js +254 -0
  240. package/static/cms/editor/toast.css +50 -0
  241. package/static/cms/editor/toast.js +29 -0
  242. package/static/cms/editor/tokens-and-text.css +93 -0
  243. package/static/cms/editor/toolbar.css +249 -0
  244. package/static/cms/editor/toolbar.js +117 -0
  245. package/static/cms/editor.css +12 -0
  246. package/static/cms/editor.js +116 -0
  247. package/static/cms/studio-login.css +300 -0
  248. package/static/cms/studio-tokens.css +85 -0
  249. package/static/cms/studio.css +437 -0
@@ -0,0 +1,70 @@
1
+ import { STUDIO_PATH } from './config.js';
2
+
3
+ export function mountStudioPanel({ studioButton }) {
4
+ if (!STUDIO_PATH) {
5
+ studioButton?.setAttribute('hidden', 'hidden');
6
+ studioButton?.setAttribute('aria-hidden', 'true');
7
+ return { iframe: null, closePanel() {} };
8
+ }
9
+
10
+ const panel = document.createElement('div');
11
+ panel.className = 'cms-studio-panel';
12
+
13
+ const iframe = document.createElement('iframe');
14
+ iframe.src = STUDIO_PATH;
15
+ iframe.title = 'Content Studio';
16
+ panel.appendChild(iframe);
17
+ document.body.appendChild(panel);
18
+
19
+ function openPanel(animate = true) {
20
+ if (!animate) {
21
+ panel.classList.add('no-transition');
22
+ document.body.classList.add('no-transition');
23
+ }
24
+ panel.classList.add('open');
25
+ document.body.classList.add('cms-panel-open');
26
+ studioButton?.classList.add('active');
27
+ sessionStorage.setItem('cms-panel-open', '1');
28
+ if (!animate) {
29
+ requestAnimationFrame(() => {
30
+ requestAnimationFrame(() => {
31
+ panel.classList.remove('no-transition');
32
+ document.body.classList.remove('no-transition');
33
+ });
34
+ });
35
+ }
36
+ }
37
+
38
+ function closePanel() {
39
+ panel.classList.remove('open');
40
+ document.body.classList.remove('cms-panel-open');
41
+ studioButton?.classList.remove('active');
42
+ sessionStorage.setItem('cms-panel-open', '0');
43
+ }
44
+
45
+ function togglePanel() {
46
+ if (panel.classList.contains('open')) {
47
+ closePanel();
48
+ } else {
49
+ openPanel();
50
+ }
51
+ }
52
+
53
+ studioButton?.addEventListener('click', togglePanel);
54
+
55
+ // Restore panel state from sessionStorage (without animation)
56
+ if (sessionStorage.getItem('cms-panel-open') === '1') {
57
+ openPanel(false);
58
+ }
59
+
60
+ // Escape key closes panel (only when no CMS element is focused)
61
+ document.addEventListener('keydown', (e) => {
62
+ if (e.key !== 'Escape' || !panel.classList.contains('open')) return;
63
+ const focused = document.activeElement;
64
+ if (!focused || !focused.matches('[data-caret]')) {
65
+ closePanel();
66
+ }
67
+ });
68
+
69
+ return { iframe, closePanel };
70
+ }
@@ -0,0 +1,67 @@
1
+ /* ---------------------------------------------------------------------------
2
+ Rich text toolbar — floating format bar for [data-caret-rich] elements
3
+ --------------------------------------------------------------------------- */
4
+
5
+ .cms-rich-toolbar {
6
+ position: fixed;
7
+ z-index: 100002;
8
+ display: flex;
9
+ align-items: center;
10
+ gap: 2px;
11
+ background: var(--cms-surface);
12
+ border: 1px solid rgba(59, 130, 246, 0.15);
13
+ border-radius: 4px;
14
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
15
+ padding: 4px;
16
+ font-family: var(--cms-font);
17
+ animation: cms-popover-in 0.15s ease-out;
18
+ pointer-events: auto;
19
+ }
20
+
21
+ .cms-rich-toolbar-btn {
22
+ display: flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ width: 28px;
26
+ height: 28px;
27
+ border: 1px solid transparent;
28
+ border-radius: 3px;
29
+ background: transparent;
30
+ color: rgba(255, 255, 255, 0.6);
31
+ font-family: var(--cms-font);
32
+ font-size: 13px;
33
+ font-weight: 600;
34
+ cursor: pointer;
35
+ transition: all 0.15s;
36
+ padding: 0;
37
+ line-height: 1;
38
+ }
39
+
40
+ .cms-rich-toolbar-btn:hover {
41
+ color: white;
42
+ background: rgba(255, 255, 255, 0.08);
43
+ border-color: rgba(255, 255, 255, 0.1);
44
+ }
45
+
46
+ .cms-rich-toolbar-btn.active {
47
+ color: var(--cms-accent);
48
+ background: var(--cms-accent-soft);
49
+ border-color: rgba(59, 130, 246, 0.2);
50
+ }
51
+
52
+ .cms-rich-toolbar-sep {
53
+ width: 1px;
54
+ height: 18px;
55
+ background: rgba(255, 255, 255, 0.08);
56
+ margin: 0 2px;
57
+ }
58
+
59
+ /* Rich text editable visual indicator */
60
+ [data-caret-rich].cms-editable:hover {
61
+ outline-color: rgba(59, 130, 246, 0.3);
62
+ }
63
+
64
+ [data-caret-rich].cms-editable:focus {
65
+ outline-color: var(--cms-accent);
66
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
67
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Floating rich text format toolbar for [data-caret-rich] elements.
3
+ *
4
+ * Singleton toolbar appears on text selection within rich-text fields.
5
+ * Uses document.execCommand() for formatting (still the most reliable
6
+ * cross-browser approach for contenteditable formatting).
7
+ */
8
+
9
+ import { sanitizeHtml } from './sanitize.js';
10
+
11
+ let toolbar = null;
12
+ let currentRichEl = null;
13
+ let showLinkPopupFn = null;
14
+
15
+ const BUTTONS = [
16
+ { cmd: 'bold', label: 'B', title: 'Bold (Ctrl+B)', style: 'font-weight:700' },
17
+ { cmd: 'italic', label: 'I', title: 'Italic (Ctrl+I)', style: 'font-style:italic' },
18
+ { sep: true },
19
+ { cmd: 'createLink', label: '\u{1F517}', title: 'Link (Ctrl+K)', isLink: true },
20
+ { cmd: 'removeFormat', label: 'T\u2093', title: 'Clear formatting' },
21
+ ];
22
+
23
+ function createToolbar() {
24
+ const el = document.createElement('div');
25
+ el.className = 'cms-rich-toolbar';
26
+ el.style.display = 'none';
27
+
28
+ for (const btn of BUTTONS) {
29
+ if (btn.sep) {
30
+ const sep = document.createElement('div');
31
+ sep.className = 'cms-rich-toolbar-sep';
32
+ el.appendChild(sep);
33
+ continue;
34
+ }
35
+
36
+ const button = document.createElement('button');
37
+ button.type = 'button';
38
+ button.className = 'cms-rich-toolbar-btn';
39
+ button.title = btn.title;
40
+ button.dataset.cmd = btn.cmd;
41
+ if (btn.style) button.style.cssText = btn.style;
42
+
43
+ // Use text for simple labels, innerHTML for the link icon
44
+ if (btn.isLink) {
45
+ button.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>`;
46
+ } else {
47
+ button.textContent = btn.label;
48
+ }
49
+
50
+ button.addEventListener('mousedown', (e) => {
51
+ e.preventDefault(); // Prevent blur on the rich element
52
+ if (btn.isLink) {
53
+ handleLinkButton();
54
+ } else {
55
+ document.execCommand(btn.cmd, false, null);
56
+ updateActiveStates();
57
+ }
58
+ });
59
+
60
+ el.appendChild(button);
61
+ }
62
+
63
+ // Prevent toolbar clicks from stealing focus
64
+ el.addEventListener('mousedown', (e) => e.preventDefault());
65
+
66
+ document.body.appendChild(el);
67
+ return el;
68
+ }
69
+
70
+ function handleLinkButton() {
71
+ const sel = window.getSelection();
72
+ if (!sel || sel.isCollapsed || !currentRichEl) return;
73
+
74
+ const range = sel.getRangeAt(0).cloneRange();
75
+ const selRect = range.getBoundingClientRect();
76
+
77
+ // Check if selection is already a link
78
+ const anchorNode = sel.anchorNode;
79
+ const existingLink = anchorNode?.nodeType === Node.TEXT_NODE
80
+ ? anchorNode.parentElement?.closest('a')
81
+ : anchorNode instanceof Element ? anchorNode.closest('a') : null;
82
+ const initialUrl = existingLink?.getAttribute('href') || '';
83
+
84
+ if (showLinkPopupFn) {
85
+ showLinkPopupFn(selRect, initialUrl, (url) => {
86
+ currentRichEl?.focus();
87
+
88
+ if (url) {
89
+ const newSel = window.getSelection();
90
+ newSel.removeAllRanges();
91
+ newSel.addRange(range);
92
+
93
+ if (existingLink) {
94
+ // Update existing link
95
+ existingLink.setAttribute('href', url);
96
+ } else {
97
+ document.execCommand('createLink', false, url);
98
+ }
99
+
100
+ // Set security attrs on external links
101
+ const freshSel = window.getSelection();
102
+ if (freshSel && freshSel.anchorNode) {
103
+ const textNode = freshSel.anchorNode;
104
+ const newLink = textNode.nodeType === Node.TEXT_NODE
105
+ ? textNode.parentElement?.closest('a')
106
+ : textNode instanceof Element ? textNode.closest('a') : null;
107
+ if (newLink && /^https?:/i.test(url)) {
108
+ newLink.setAttribute('target', '_blank');
109
+ newLink.setAttribute('rel', 'noopener noreferrer');
110
+ }
111
+ }
112
+ } else if (url === '' && existingLink) {
113
+ // Empty URL = unlink
114
+ document.execCommand('unlink', false, null);
115
+ }
116
+
117
+ hide();
118
+ });
119
+ }
120
+ }
121
+
122
+ function updateActiveStates() {
123
+ if (!toolbar) return;
124
+ const buttons = toolbar.querySelectorAll('.cms-rich-toolbar-btn[data-cmd]');
125
+ for (const btn of buttons) {
126
+ const cmd = btn.dataset.cmd;
127
+ if (cmd === 'createLink' || cmd === 'removeFormat') continue;
128
+ try {
129
+ btn.classList.toggle('active', document.queryCommandState(cmd));
130
+ } catch {
131
+ // queryCommandState can throw for unsupported commands
132
+ }
133
+ }
134
+ }
135
+
136
+ function position(sel) {
137
+ if (!toolbar || !sel || sel.isCollapsed) return;
138
+
139
+ const range = sel.getRangeAt(0);
140
+ const rect = range.getBoundingClientRect();
141
+ if (!rect.width && !rect.height) return;
142
+
143
+ const tbRect = toolbar.getBoundingClientRect();
144
+ let left = rect.left + rect.width / 2 - tbRect.width / 2;
145
+ left = Math.max(8, Math.min(left, window.innerWidth - tbRect.width - 8));
146
+
147
+ // Prefer above selection, flip below if near top
148
+ let top = rect.top - tbRect.height - 8;
149
+ if (top < 8) {
150
+ top = rect.bottom + 8;
151
+ }
152
+
153
+ toolbar.style.left = `${left}px`;
154
+ toolbar.style.top = `${top}px`;
155
+ }
156
+
157
+ function show(sel) {
158
+ if (!toolbar) toolbar = createToolbar();
159
+ toolbar.style.display = 'flex';
160
+ updateActiveStates();
161
+ position(sel);
162
+ }
163
+
164
+ function hide() {
165
+ if (toolbar) {
166
+ toolbar.style.display = 'none';
167
+ }
168
+ currentRichEl = null;
169
+ }
170
+
171
+ function onSelectionChange() {
172
+ const sel = window.getSelection();
173
+ if (!sel || sel.isCollapsed) {
174
+ hide();
175
+ return;
176
+ }
177
+
178
+ // Check if selection is inside a rich element
179
+ const anchor = sel.anchorNode;
180
+ const richEl = anchor instanceof Element
181
+ ? anchor.closest('[data-caret-rich]')
182
+ : anchor?.parentElement?.closest('[data-caret-rich]');
183
+
184
+ if (!richEl) {
185
+ hide();
186
+ return;
187
+ }
188
+
189
+ currentRichEl = richEl;
190
+ show(sel);
191
+ }
192
+
193
+ /**
194
+ * Mount the rich text toolbar. Call once during editor boot.
195
+ * @param {{ state: object, showToast: Function, showLinkPopup?: Function }} opts
196
+ */
197
+ export function mountRichToolbar({ state, showToast, showLinkPopup }) {
198
+ showLinkPopupFn = showLinkPopup || null;
199
+ document.addEventListener('selectionchange', onSelectionChange);
200
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Client-side HTML sanitizer for rich text fields.
3
+ *
4
+ * Uses DOMParser + TreeWalker to sanitize HTML against an allowlist.
5
+ * Only inline formatting elements are permitted — no block-level tags.
6
+ */
7
+
8
+ const ALLOWED_TAGS = new Set([
9
+ 'b', 'strong', 'i', 'em', 'u', 's', 'a', 'br', 'sub', 'sup',
10
+ ]);
11
+
12
+ const ALLOWED_ATTRS = {
13
+ a: new Set(['href', 'target', 'rel']),
14
+ };
15
+
16
+ const SAFE_HREF_RE = /^(?:https?:|mailto:|tel:|\/)/i;
17
+
18
+ /**
19
+ * Sanitize an HTML string, keeping only allowed inline tags and attributes.
20
+ * @param {string} html
21
+ * @returns {string}
22
+ */
23
+ export function sanitizeHtml(html) {
24
+ if (!html) return '';
25
+
26
+ const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
27
+ sanitizeNode(doc.body);
28
+ return doc.body.innerHTML;
29
+ }
30
+
31
+ /**
32
+ * Recursively sanitize a DOM node in place.
33
+ * @param {Node} parent
34
+ */
35
+ function sanitizeNode(parent) {
36
+ const children = Array.from(parent.childNodes);
37
+
38
+ for (const node of children) {
39
+ if (node.nodeType === Node.TEXT_NODE) continue;
40
+
41
+ if (node.nodeType === Node.COMMENT_NODE) {
42
+ parent.removeChild(node);
43
+ continue;
44
+ }
45
+
46
+ if (node.nodeType !== Node.ELEMENT_NODE) {
47
+ parent.removeChild(node);
48
+ continue;
49
+ }
50
+
51
+ const el = /** @type {Element} */ (node);
52
+ const tag = el.tagName.toLowerCase();
53
+
54
+ if (!ALLOWED_TAGS.has(tag)) {
55
+ // Unwrap: keep children, remove the disallowed element
56
+ const frag = document.createDocumentFragment();
57
+ while (el.firstChild) frag.appendChild(el.firstChild);
58
+ parent.replaceChild(frag, el);
59
+ // Re-sanitize the newly promoted children
60
+ sanitizeNode(parent);
61
+ return;
62
+ }
63
+
64
+ // Strip disallowed attributes
65
+ const allowed = ALLOWED_ATTRS[tag];
66
+ const attrs = Array.from(el.attributes);
67
+ for (const attr of attrs) {
68
+ if (!allowed || !allowed.has(attr.name)) {
69
+ el.removeAttribute(attr.name);
70
+ }
71
+ }
72
+
73
+ // Validate href on <a>
74
+ if (tag === 'a') {
75
+ const href = el.getAttribute('href') || '';
76
+ if (!SAFE_HREF_RE.test(href)) {
77
+ el.removeAttribute('href');
78
+ }
79
+ // Force security attrs on external links
80
+ if (href && /^https?:/i.test(href)) {
81
+ el.setAttribute('target', '_blank');
82
+ el.setAttribute('rel', 'noopener noreferrer');
83
+ }
84
+ }
85
+
86
+ // Recurse into children
87
+ sanitizeNode(el);
88
+ }
89
+ }
@@ -0,0 +1,166 @@
1
+ import { buildCmsUrl } from './config.js';
2
+ import { mutateHeaders, readHeaders } from './security.js';
3
+
4
+ function entryKey(collection, id) {
5
+ return `${collection}::${id}`;
6
+ }
7
+
8
+ function isRecord(value) {
9
+ return typeof value === 'object' && value !== null;
10
+ }
11
+
12
+ function getNestedStringValue(data, path) {
13
+ if (!isRecord(data)) return undefined;
14
+
15
+ let current = data;
16
+ for (const segment of path.split('.')) {
17
+ if (!isRecord(current) && !Array.isArray(current)) return undefined;
18
+ current = current[segment];
19
+ }
20
+
21
+ return typeof current === 'string' ? current : undefined;
22
+ }
23
+
24
+ async function fetchEntrySnapshot(collection, id, onUnauthorized) {
25
+ const res = await fetch(buildCmsUrl('/entries', { collection, id }), {
26
+ headers: readHeaders(),
27
+ });
28
+ if (res.status === 401) {
29
+ onUnauthorized();
30
+ return null;
31
+ }
32
+ if (!res.ok) throw new Error('Failed to load entry');
33
+
34
+ const json = await res.json();
35
+ const entry = Array.isArray(json.entries) ? json.entries[0] : null;
36
+ if (!entry || !isRecord(entry.data)) {
37
+ return { revision: 0, data: {} };
38
+ }
39
+
40
+ return {
41
+ revision: Number.isInteger(entry.revision) ? entry.revision : 0,
42
+ data: entry.data,
43
+ };
44
+ }
45
+
46
+ export function createSaveField({ setStatus, onUnauthorized }) {
47
+ let saveQueue = Promise.resolve();
48
+ const revisionByEntry = new Map();
49
+ const revisionLoadByEntry = new Map();
50
+
51
+ async function ensureRevision(collection, id) {
52
+ const key = entryKey(collection, id);
53
+ if (revisionByEntry.has(key)) {
54
+ return revisionByEntry.get(key);
55
+ }
56
+
57
+ const inFlight = revisionLoadByEntry.get(key);
58
+ if (inFlight) return inFlight;
59
+
60
+ const task = (async () => {
61
+ const snapshot = await fetchEntrySnapshot(collection, id, onUnauthorized);
62
+ if (!snapshot) return null;
63
+ revisionByEntry.set(key, snapshot.revision);
64
+ return snapshot.revision;
65
+ })().finally(() => {
66
+ revisionLoadByEntry.delete(key);
67
+ });
68
+
69
+ revisionLoadByEntry.set(key, task);
70
+ return task;
71
+ }
72
+
73
+ async function readLatestFieldValue(collection, id, field) {
74
+ const snapshot = await fetchEntrySnapshot(collection, id, onUnauthorized);
75
+ if (!snapshot) return { currentRevision: undefined, latestValue: undefined };
76
+
77
+ revisionByEntry.set(entryKey(collection, id), snapshot.revision);
78
+ return {
79
+ currentRevision: snapshot.revision,
80
+ latestValue: getNestedStringValue(snapshot.data, field),
81
+ };
82
+ }
83
+
84
+ async function execSave(collection, id, field, value) {
85
+ setStatus('saving', 'Saving...');
86
+ const key = entryKey(collection, id);
87
+
88
+ try {
89
+ const expectedRevision = await ensureRevision(collection, id);
90
+ if (expectedRevision === null) {
91
+ return { ok: false, reason: 'unauthorized' };
92
+ }
93
+
94
+ const body = { type: 'save_field', collection, id, field, value };
95
+ if (typeof expectedRevision === 'number') {
96
+ body.expectedRevision = expectedRevision;
97
+ }
98
+
99
+ const res = await fetch(buildCmsUrl('/mutate'), {
100
+ method: 'POST',
101
+ headers: await mutateHeaders(),
102
+ body: JSON.stringify(body),
103
+ });
104
+ if (res.status === 401) {
105
+ onUnauthorized();
106
+ return { ok: false, reason: 'unauthorized' };
107
+ }
108
+
109
+ const json = await res.json().catch(() => ({}));
110
+ if (!res.ok) {
111
+ if (res.status === 409) {
112
+ const currentRevision = Number.isInteger(json.currentRevision)
113
+ ? json.currentRevision
114
+ : undefined;
115
+ if (typeof currentRevision === 'number') {
116
+ revisionByEntry.set(key, currentRevision);
117
+ }
118
+
119
+ let latest = { currentRevision: undefined, latestValue: undefined };
120
+ try {
121
+ latest = await readLatestFieldValue(collection, id, field);
122
+ } catch {
123
+ // Keep conflict semantics even if a follow-up refresh fails.
124
+ }
125
+ setStatus('error', 'Revision conflict');
126
+ return {
127
+ ok: false,
128
+ reason: 'conflict',
129
+ currentRevision:
130
+ typeof latest.currentRevision === 'number'
131
+ ? latest.currentRevision
132
+ : currentRevision,
133
+ latestValue: latest.latestValue,
134
+ };
135
+ }
136
+
137
+ setStatus('error', 'Save failed');
138
+ return { ok: false, reason: 'error' };
139
+ }
140
+
141
+ const nextRevision = Number.isInteger(json.revision)
142
+ ? json.revision
143
+ : typeof expectedRevision === 'number'
144
+ ? expectedRevision + 1
145
+ : undefined;
146
+ if (typeof nextRevision === 'number') {
147
+ revisionByEntry.set(key, nextRevision);
148
+ }
149
+
150
+ setStatus('idle', 'Saved');
151
+ return { ok: true, revision: nextRevision };
152
+ } catch {
153
+ setStatus('error', 'Save failed');
154
+ return { ok: false, reason: 'error' };
155
+ }
156
+ }
157
+
158
+ return function saveField(collection, id, field, value) {
159
+ const task = saveQueue.then(() => execSave(collection, id, field, value));
160
+ saveQueue = task.then(
161
+ () => undefined,
162
+ () => undefined,
163
+ );
164
+ return task;
165
+ };
166
+ }
@@ -0,0 +1,10 @@
1
+ export function bindSectionActivation({ sectionNode, sectionId, setActiveSection }) {
2
+ sectionNode.addEventListener('mouseenter', () => setActiveSection(sectionId));
3
+ sectionNode.addEventListener('focusin', () => setActiveSection(sectionId));
4
+ sectionNode.addEventListener('pointerdown', (event) => {
5
+ const target = event.target;
6
+ if (!(target instanceof Element)) return;
7
+ if (target.closest('.cms-section-add-picker')) return;
8
+ setActiveSection(sectionId);
9
+ });
10
+ }
@@ -0,0 +1,87 @@
1
+ import { buildCmsUrl } from '../config.js';
2
+ import { isRecord } from './utils.js';
3
+ import { mutateHeaders, readHeaders } from '../security.js';
4
+
5
+ export async function fetchPageEntry({ collection, id, onUnauthorized }) {
6
+ const res = await fetch(buildCmsUrl('/entries', { collection, id }), {
7
+ headers: readHeaders(),
8
+ });
9
+ if (res.status === 401) {
10
+ onUnauthorized();
11
+ return null;
12
+ }
13
+ if (!res.ok) throw new Error('Failed to load page data');
14
+
15
+ const json = await res.json();
16
+ const entry = Array.isArray(json.entries) ? json.entries[0] : null;
17
+ if (!entry || !isRecord(entry.data)) {
18
+ throw new Error('Page entry not found');
19
+ }
20
+
21
+ return {
22
+ data: entry.data,
23
+ revision: Number.isInteger(entry.revision) ? entry.revision : 0,
24
+ };
25
+ }
26
+
27
+ export async function savePageLayout({
28
+ id,
29
+ sections,
30
+ expectedRevision,
31
+ onUnauthorized,
32
+ }) {
33
+ const res = await fetch(buildCmsUrl('/mutate'), {
34
+ method: 'POST',
35
+ headers: await mutateHeaders(),
36
+ body: JSON.stringify({
37
+ type: 'update_page_layout',
38
+ id,
39
+ sections,
40
+ expectedRevision,
41
+ }),
42
+ });
43
+
44
+ if (res.status === 401) {
45
+ onUnauthorized();
46
+ return { ok: false, reason: 'unauthorized' };
47
+ }
48
+
49
+ const json = await res.json().catch(() => ({}));
50
+ if (!res.ok) {
51
+ if (res.status === 409) {
52
+ return {
53
+ ok: false,
54
+ reason: 'conflict',
55
+ currentRevision: Number.isInteger(json.currentRevision) ? json.currentRevision : null,
56
+ };
57
+ }
58
+ const issue = Array.isArray(json.issues) ? json.issues[0] : null;
59
+ const issueDetail =
60
+ issue &&
61
+ typeof issue === 'object' &&
62
+ typeof issue.path === 'string' &&
63
+ typeof issue.message === 'string'
64
+ ? `${issue.path}: ${issue.message}`
65
+ : null;
66
+ return {
67
+ ok: false,
68
+ reason: 'error',
69
+ error:
70
+ typeof json.error === 'string'
71
+ ? issueDetail
72
+ ? `${json.error} (${issueDetail})`
73
+ : json.error
74
+ : 'Failed to save layout',
75
+ };
76
+ }
77
+
78
+ return {
79
+ ok: true,
80
+ revision:
81
+ Number.isInteger(json.revision)
82
+ ? json.revision
83
+ : Number.isInteger(expectedRevision)
84
+ ? expectedRevision + 1
85
+ : 1,
86
+ };
87
+ }