@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,84 @@
1
+ export async function compressImage(file, maxWidth = 1600, quality = 0.82) {
2
+ if (file.size < 200000 && file.type === 'image/webp') return file;
3
+
4
+ const bitmap = await createImageBitmap(file);
5
+ let width = bitmap.width;
6
+ let height = bitmap.height;
7
+
8
+ const needsResize = width > maxWidth;
9
+
10
+ // PNGs: preserve lossless quality — only process if oversized
11
+ if (file.type === 'image/png' && !needsResize) {
12
+ bitmap.close();
13
+ return file;
14
+ }
15
+
16
+ if (needsResize) {
17
+ height = Math.round((height * maxWidth) / width);
18
+ width = maxWidth;
19
+ }
20
+
21
+ const canvas = document.createElement('canvas');
22
+ canvas.width = width;
23
+ canvas.height = height;
24
+ const ctx = canvas.getContext('2d');
25
+ ctx.drawImage(bitmap, 0, 0, width, height);
26
+ bitmap.close();
27
+
28
+ // Keep PNG format to preserve lossless quality; convert others to lossy WebP
29
+ const isPng = file.type === 'image/png';
30
+ const outType = isPng ? 'image/png' : 'image/webp';
31
+
32
+ const blob = await new Promise((resolve) =>
33
+ canvas.toBlob((b) => resolve(b), outType, isPng ? undefined : quality),
34
+ );
35
+
36
+ if (!blob || blob.size >= file.size) return file;
37
+
38
+ const ext = isPng ? '.png' : '.webp';
39
+ const name = file.name.replace(/\.[^.]+$/, ext);
40
+ return new File([blob], name, { type: outType });
41
+ }
42
+
43
+ export function updateEmblaCarousel(img, newUrl) {
44
+ // Find the closest Embla container
45
+ const emblaContainer = img.closest('.embla');
46
+ if (!emblaContainer) return;
47
+
48
+ // Find the slide this image belongs to
49
+ const slide = img.closest('.embla__slide');
50
+ if (!slide) return;
51
+
52
+ const slideIndex = parseInt(slide.dataset.index, 10);
53
+ if (isNaN(slideIndex)) return;
54
+
55
+ // Update the data-full attribute for lightbox
56
+ slide.dataset.full = newUrl;
57
+
58
+ // Update thumbnail if it exists
59
+ const thumbBtn = document.querySelector(`.thumb-btn[data-index="${slideIndex}"]`);
60
+ if (thumbBtn) {
61
+ const thumbImg = thumbBtn.querySelector('img');
62
+ if (thumbImg) {
63
+ thumbImg.src = newUrl;
64
+ // Add a flash effect to show it updated
65
+ thumbBtn.classList.add('cms-thumb-updated');
66
+ setTimeout(() => thumbBtn.classList.remove('cms-thumb-updated'), 1000);
67
+ }
68
+ }
69
+
70
+ // Update lightbox thumbnails if visible
71
+ const lightboxThumb = document.querySelector(`.lightbox-thumb[data-index="${slideIndex}"]`);
72
+ if (lightboxThumb) {
73
+ lightboxThumb.dataset.src = newUrl;
74
+ const lbImg = lightboxThumb.querySelector('img');
75
+ if (lbImg) lbImg.src = newUrl;
76
+ }
77
+
78
+ // Dispatch custom event that the product page can listen to
79
+ emblaContainer.dispatchEvent(
80
+ new CustomEvent('cms:imageUpdated', {
81
+ detail: { index: slideIndex, url: newUrl },
82
+ }),
83
+ );
84
+ }
@@ -0,0 +1,133 @@
1
+ /* ---------------------------------------------------------------------------
2
+ Image editing overlay
3
+ --------------------------------------------------------------------------- */
4
+
5
+ .cms-img-wrapper {
6
+ position: relative;
7
+ z-index: 10;
8
+ outline: 2px solid transparent;
9
+ outline-offset: 2px;
10
+ transition: outline-color 0.2s;
11
+ }
12
+
13
+ .cms-img-wrapper:hover {
14
+ outline-color: var(--cms-blue);
15
+ }
16
+
17
+ /* Corner edit badge — persistent editability indicator */
18
+ .cms-img-badge {
19
+ position: absolute;
20
+ top: 8px;
21
+ right: 8px;
22
+ width: 26px;
23
+ height: 26px;
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ background: rgba(0, 0, 0, 0.55);
28
+ backdrop-filter: blur(4px);
29
+ color: white;
30
+ border-radius: 4px;
31
+ opacity: 0.6;
32
+ transition: opacity 0.2s, transform 0.2s;
33
+ z-index: 12;
34
+ pointer-events: none;
35
+ }
36
+
37
+ .cms-img-wrapper:hover .cms-img-badge {
38
+ opacity: 0;
39
+ transform: scale(0.8);
40
+ }
41
+
42
+ .cms-img-badge-hidden {
43
+ opacity: 0 !important;
44
+ }
45
+
46
+ /* Full overlay */
47
+ .cms-img-overlay {
48
+ position: absolute;
49
+ inset: 0;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ background: rgba(0, 0, 0, 0.55);
54
+ color: white;
55
+ font-family: var(--cms-font);
56
+ font-size: 13px;
57
+ font-weight: 500;
58
+ letter-spacing: 0.05em;
59
+ text-transform: uppercase;
60
+ cursor: pointer;
61
+ opacity: 0;
62
+ transition: opacity 0.25s ease;
63
+ border: none;
64
+ padding: 0;
65
+ z-index: 11;
66
+ }
67
+
68
+ .cms-img-wrapper:hover .cms-img-overlay {
69
+ opacity: 1;
70
+ }
71
+
72
+ .cms-img-overlay-content {
73
+ display: flex;
74
+ flex-direction: column;
75
+ align-items: center;
76
+ gap: 10px;
77
+ }
78
+
79
+ .cms-img-overlay-icon {
80
+ width: 28px;
81
+ height: 28px;
82
+ opacity: 0.9;
83
+ }
84
+
85
+ .cms-img-overlay-text {
86
+ font-size: 11px;
87
+ letter-spacing: 0.12em;
88
+ }
89
+
90
+ /* Upload state — always visible with spinner */
91
+ .cms-img-state-uploading {
92
+ display: none;
93
+ }
94
+
95
+ .cms-img-overlay.uploading {
96
+ opacity: 1;
97
+ cursor: wait;
98
+ background: rgba(0, 0, 0, 0.7);
99
+ }
100
+
101
+ .cms-img-overlay.uploading .cms-img-state-idle {
102
+ display: none;
103
+ }
104
+
105
+ .cms-img-overlay.uploading .cms-img-state-uploading {
106
+ display: flex;
107
+ flex-direction: column;
108
+ align-items: center;
109
+ gap: 10px;
110
+ }
111
+
112
+ .cms-img-spinner {
113
+ width: 28px;
114
+ height: 28px;
115
+ border: 2.5px solid rgba(255, 255, 255, 0.2);
116
+ border-top-color: white;
117
+ border-radius: 50%;
118
+ animation: cms-spin 0.7s linear infinite;
119
+ }
120
+
121
+ @keyframes cms-spin {
122
+ to { transform: rotate(360deg); }
123
+ }
124
+
125
+ /* Thumbnail update flash */
126
+ .cms-thumb-updated {
127
+ animation: cms-thumb-flash 1s ease-out;
128
+ }
129
+
130
+ @keyframes cms-thumb-flash {
131
+ 0% { outline: 2px solid var(--cms-green); outline-offset: 2px; }
132
+ 100% { outline: 2px solid transparent; outline-offset: 2px; }
133
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Floating "Open page" affordance for editable elements that are or sit inside
3
+ * an <a> with a real href. The editor's click guard blocks navigation on
4
+ * those elements (so a click opens the inline editor, not the link); this
5
+ * module restores a visible way to follow the link without leaving edit mode.
6
+ */
7
+
8
+ let activeBtn = null;
9
+ let activeAnchor = null;
10
+ let hideTimer = null;
11
+
12
+ function isFollowableHref(href) {
13
+ if (!href) return false;
14
+ const trimmed = href.trim();
15
+ if (!trimmed || trimmed === "#") return false;
16
+ if (trimmed.startsWith("javascript:")) return false;
17
+ return true;
18
+ }
19
+
20
+ function isExternal(href) {
21
+ return /^https?:\/\//i.test(href);
22
+ }
23
+
24
+ function shortLabel(href) {
25
+ if (isExternal(href)) {
26
+ try {
27
+ return new URL(href).host.replace(/^www\./, "");
28
+ } catch {
29
+ return "link";
30
+ }
31
+ }
32
+ return href;
33
+ }
34
+
35
+ function removeBtn() {
36
+ if (activeBtn) {
37
+ activeBtn.remove();
38
+ activeBtn = null;
39
+ activeAnchor = null;
40
+ }
41
+ if (hideTimer) {
42
+ clearTimeout(hideTimer);
43
+ hideTimer = null;
44
+ }
45
+ }
46
+
47
+ function position(anchor, btn) {
48
+ const rect = anchor.getBoundingClientRect();
49
+ // Place above the anchor, right-aligned to its right edge so it doesn't
50
+ // overlap the editable text on hover.
51
+ const top = rect.top + window.scrollY - btn.offsetHeight - 8;
52
+ const left = rect.right + window.scrollX - btn.offsetWidth;
53
+ btn.style.top = `${Math.max(8 + window.scrollY, top)}px`;
54
+ btn.style.left = `${Math.max(8, left)}px`;
55
+ }
56
+
57
+ function scheduleHide() {
58
+ if (hideTimer) clearTimeout(hideTimer);
59
+ hideTimer = window.setTimeout(removeBtn, 180);
60
+ }
61
+
62
+ function show(anchor) {
63
+ if (activeAnchor === anchor && activeBtn) {
64
+ if (hideTimer) {
65
+ clearTimeout(hideTimer);
66
+ hideTimer = null;
67
+ }
68
+ return;
69
+ }
70
+ removeBtn();
71
+
72
+ const href = anchor.getAttribute("href");
73
+ if (!isFollowableHref(href)) return;
74
+
75
+ const external = isExternal(href);
76
+ const btn = document.createElement("a");
77
+ btn.className = "cms-link-follow";
78
+ btn.href = href;
79
+ btn.setAttribute("data-cms-skip-guard", "true");
80
+ if (external) {
81
+ btn.target = "_blank";
82
+ btn.rel = "noopener noreferrer";
83
+ }
84
+ btn.innerHTML = `
85
+ <svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
86
+ <path d="M5 11 L11 5" stroke="currentColor" stroke-width="1.4" stroke-linecap="square"/>
87
+ <path d="M6 5 L11 5 L11 10" stroke="currentColor" stroke-width="1.4" stroke-linecap="square"/>
88
+ </svg>
89
+ <span>Open ${external ? "link" : "page"}</span>
90
+ <em>${shortLabel(href)}</em>
91
+ `;
92
+
93
+ btn.addEventListener("mouseenter", () => {
94
+ if (hideTimer) {
95
+ clearTimeout(hideTimer);
96
+ hideTimer = null;
97
+ }
98
+ });
99
+ btn.addEventListener("mouseleave", scheduleHide);
100
+
101
+ document.body.appendChild(btn);
102
+ activeBtn = btn;
103
+ activeAnchor = anchor;
104
+ position(anchor, btn);
105
+ }
106
+
107
+ export function mountLinkFollowAffordances() {
108
+ document.addEventListener("mouseover", (e) => {
109
+ const target = e.target;
110
+ if (!(target instanceof Element)) return;
111
+ if (target.closest(".cms-link-follow")) return;
112
+
113
+ const editable = target.closest("[data-caret]");
114
+ if (!editable) return;
115
+
116
+ // Image editables have their own overlay treatment — skip.
117
+ if (editable instanceof HTMLImageElement) return;
118
+
119
+ const anchor = editable.matches("a") ? editable : editable.closest("a");
120
+ if (!anchor) return;
121
+
122
+ show(anchor);
123
+ });
124
+
125
+ document.addEventListener("mouseout", (e) => {
126
+ const target = e.target;
127
+ if (!(target instanceof Element)) return;
128
+ if (target.closest(".cms-link-follow")) return;
129
+
130
+ const editable = target.closest("[data-caret]");
131
+ const anchor = editable
132
+ ? editable.matches("a")
133
+ ? editable
134
+ : editable.closest("a")
135
+ : null;
136
+ if (anchor === activeAnchor) scheduleHide();
137
+ });
138
+
139
+ const reposition = () => {
140
+ if (activeAnchor && activeBtn) position(activeAnchor, activeBtn);
141
+ };
142
+ window.addEventListener("scroll", reposition, { passive: true });
143
+ window.addEventListener("resize", reposition);
144
+ }
@@ -0,0 +1,199 @@
1
+ /* ---------------------------------------------------------------------------
2
+ Link popover — Ctrl+K popup for hyperlinks
3
+ --------------------------------------------------------------------------- */
4
+
5
+ .cms-link-popover {
6
+ position: fixed;
7
+ z-index: 100001;
8
+ width: 340px;
9
+ background: var(--cms-surface);
10
+ border: 1px solid rgba(59, 130, 246, 0.15);
11
+ border-radius: 4px;
12
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
13
+ padding: 12px;
14
+ font-family: var(--cms-font);
15
+ animation: cms-popover-in 0.15s ease-out;
16
+ }
17
+
18
+ @keyframes cms-popover-in {
19
+ from { opacity: 0; transform: translateY(4px); }
20
+ to { opacity: 1; transform: translateY(0); }
21
+ }
22
+
23
+ .cms-link-popover-row {
24
+ display: flex;
25
+ align-items: center;
26
+ gap: 8px;
27
+ }
28
+
29
+ .cms-link-popover-icon {
30
+ color: var(--cms-accent);
31
+ flex-shrink: 0;
32
+ }
33
+
34
+ .cms-link-popover-input {
35
+ flex: 1;
36
+ background: rgba(255, 255, 255, 0.06);
37
+ border: 1px solid rgba(255, 255, 255, 0.1);
38
+ border-radius: 3px;
39
+ padding: 7px 10px;
40
+ font-family: var(--cms-font);
41
+ font-size: 12px;
42
+ color: white;
43
+ outline: none;
44
+ transition: border-color 0.2s;
45
+ }
46
+
47
+ .cms-link-popover-input:focus {
48
+ border-color: var(--cms-accent);
49
+ }
50
+
51
+ .cms-link-popover-input::placeholder {
52
+ color: rgba(255, 255, 255, 0.3);
53
+ }
54
+
55
+ .cms-link-popover-actions {
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: space-between;
59
+ margin-top: 8px;
60
+ }
61
+
62
+ .cms-link-popover-hint {
63
+ font-size: 10px;
64
+ color: rgba(255, 255, 255, 0.25);
65
+ letter-spacing: 0.05em;
66
+ }
67
+
68
+ .cms-link-popover-btns {
69
+ display: flex;
70
+ gap: 6px;
71
+ }
72
+
73
+ .cms-link-popover-cancel,
74
+ .cms-link-popover-apply {
75
+ padding: 4px 12px;
76
+ font-family: var(--cms-font);
77
+ font-size: 10px;
78
+ font-weight: 500;
79
+ letter-spacing: 0.1em;
80
+ text-transform: uppercase;
81
+ border-radius: 2px;
82
+ cursor: pointer;
83
+ transition: all 0.2s;
84
+ border: 1px solid transparent;
85
+ }
86
+
87
+ .cms-link-popover-cancel {
88
+ background: transparent;
89
+ color: rgba(255, 255, 255, 0.4);
90
+ border-color: rgba(255, 255, 255, 0.08);
91
+ }
92
+
93
+ .cms-link-popover-cancel:hover {
94
+ color: rgba(255, 255, 255, 0.7);
95
+ border-color: rgba(255, 255, 255, 0.2);
96
+ }
97
+
98
+ .cms-link-popover-apply {
99
+ background: var(--cms-accent-soft);
100
+ color: var(--cms-accent);
101
+ border-color: rgba(59, 130, 246, 0.2);
102
+ }
103
+
104
+ .cms-link-popover-apply:hover {
105
+ background: rgba(59, 130, 246, 0.25);
106
+ border-color: rgba(59, 130, 246, 0.4);
107
+ }
108
+
109
+ /* ---------------------------------------------------------------------------
110
+ Link hint badge — shows above focused linkable fields
111
+ --------------------------------------------------------------------------- */
112
+
113
+ .cms-link-hint {
114
+ position: fixed;
115
+ z-index: 100000;
116
+ background: var(--cms-surface);
117
+ color: rgba(255, 255, 255, 0.4);
118
+ font-family: var(--cms-font);
119
+ font-size: 9px;
120
+ letter-spacing: 0.1em;
121
+ text-transform: uppercase;
122
+ padding: 3px 8px;
123
+ border-radius: 2px;
124
+ pointer-events: none;
125
+ animation: cms-hint-in 0.2s ease-out;
126
+ white-space: nowrap;
127
+ }
128
+
129
+ @keyframes cms-hint-in {
130
+ from { opacity: 0; transform: translateY(4px); }
131
+ to { opacity: 1; transform: translateY(0); }
132
+ }
133
+
134
+ /* Linkable field visual indicator */
135
+ [data-caret-raw].cms-editable:hover {
136
+ outline-color: var(--cms-accent-soft);
137
+ }
138
+
139
+ [data-caret-raw].cms-editable:focus {
140
+ outline-color: var(--cms-accent);
141
+ box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
142
+ }
143
+
144
+ /* ---------------------------------------------------------------------------
145
+ Link-follow pill — appears on hover for editable elements that are or sit
146
+ inside an <a> with a real href, so visitors can open the page instead of
147
+ editing the text.
148
+ --------------------------------------------------------------------------- */
149
+
150
+ .cms-link-follow {
151
+ position: absolute;
152
+ z-index: 100000;
153
+ display: inline-flex;
154
+ align-items: center;
155
+ gap: 8px;
156
+ padding: 6px 10px 6px 9px;
157
+ background: var(--cms-surface);
158
+ color: rgba(255, 255, 255, 0.92);
159
+ border: 1px solid rgba(59, 130, 246, 0.35);
160
+ border-radius: 3px;
161
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
162
+ font-family: var(--cms-font);
163
+ font-size: 11px;
164
+ font-weight: 500;
165
+ letter-spacing: 0.02em;
166
+ text-decoration: none;
167
+ white-space: nowrap;
168
+ cursor: pointer;
169
+ animation: cms-link-follow-in 0.16s ease-out;
170
+ }
171
+
172
+ .cms-link-follow:hover {
173
+ background: rgba(59, 130, 246, 0.18);
174
+ border-color: var(--cms-accent);
175
+ color: white;
176
+ }
177
+
178
+ .cms-link-follow svg {
179
+ width: 13px;
180
+ height: 13px;
181
+ color: var(--cms-accent);
182
+ flex-shrink: 0;
183
+ }
184
+
185
+ .cms-link-follow em {
186
+ font-style: normal;
187
+ color: rgba(255, 255, 255, 0.45);
188
+ font-size: 10px;
189
+ font-weight: 400;
190
+ letter-spacing: 0;
191
+ max-width: 22ch;
192
+ overflow: hidden;
193
+ text-overflow: ellipsis;
194
+ }
195
+
196
+ @keyframes cms-link-follow-in {
197
+ from { opacity: 0; transform: translateY(4px); }
198
+ to { opacity: 1; transform: translateY(0); }
199
+ }
@@ -0,0 +1,55 @@
1
+ export function escapeHtml(s) {
2
+ return s
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;');
7
+ }
8
+
9
+ export function clientLinkify(text) {
10
+ if (!text) return '';
11
+
12
+ const MD = /\[([^\]]+)\]\(([^)]+)\)/;
13
+ const EMAIL = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
14
+ const PHONE = /(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/;
15
+ const URL_PAT =
16
+ /\bhttps?:\/\/[^\s<>"')\]]+|\b[\w.-]+\.(?:com|org|net|io|co|dev|app|store|shop|jewelry|diamonds|gold)(?:\/[^\s<>"')\]]*)?/;
17
+
18
+ const combined = new RegExp(
19
+ `(${MD.source})|(${EMAIL.source})|(${PHONE.source})|(${URL_PAT.source})`,
20
+ 'gi',
21
+ );
22
+
23
+ let result = '';
24
+ let lastIndex = 0;
25
+
26
+ for (const m of text.matchAll(combined)) {
27
+ const start = m.index;
28
+ result += escapeHtml(text.slice(lastIndex, start));
29
+
30
+ const [full, mdFull, mdText, mdUrl, email, phone] = m;
31
+
32
+ if (mdFull) {
33
+ const href = /^https?:\/\//i.test(mdUrl)
34
+ ? mdUrl
35
+ : /^[/]|^mailto:|^tel:/.test(mdUrl)
36
+ ? mdUrl
37
+ : 'https://' + mdUrl;
38
+ const ext = /^https?:\/\//i.test(href);
39
+ result += `<a href="${escapeHtml(href)}"${ext ? ' target="_blank" rel="noopener noreferrer"' : ''} class="text-secondary hover:underline">${escapeHtml(mdText)}</a>`;
40
+ } else if (email) {
41
+ result += `<a href="mailto:${escapeHtml(email)}" class="text-secondary hover:underline">${escapeHtml(email)}</a>`;
42
+ } else if (phone) {
43
+ const digits = full.replace(/\D/g, '');
44
+ result += `<a href="tel:${digits}" class="text-secondary hover:underline">${escapeHtml(full)}</a>`;
45
+ } else {
46
+ const href = /^https?:\/\//i.test(full) ? full : 'https://' + full;
47
+ result += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer" class="text-secondary hover:underline">${escapeHtml(full)}</a>`;
48
+ }
49
+
50
+ lastIndex = start + full.length;
51
+ }
52
+
53
+ result += escapeHtml(text.slice(lastIndex));
54
+ return result;
55
+ }
@@ -0,0 +1,91 @@
1
+ /* ---------------------------------------------------------------------------
2
+ Studio Panel — sliding left sidebar with iframe
3
+ --------------------------------------------------------------------------- */
4
+
5
+ .cms-studio-panel {
6
+ position: fixed;
7
+ top: 0;
8
+ left: 0;
9
+ bottom: 0;
10
+ width: 380px;
11
+ z-index: 100000;
12
+ background: #0c0e12;
13
+ border-right: 1px solid rgba(59, 130, 246, 0.12);
14
+ box-shadow: 4px 0 30px rgba(0, 0, 0, 0.3);
15
+ transform: translateX(-100%);
16
+ transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
17
+ }
18
+
19
+ .cms-studio-panel.open {
20
+ transform: translateX(0);
21
+ }
22
+
23
+ .cms-studio-panel.no-transition {
24
+ transition: none;
25
+ }
26
+
27
+ .cms-studio-panel iframe {
28
+ width: 100%;
29
+ height: 100%;
30
+ border: none;
31
+ display: block;
32
+ }
33
+
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);
38
+ }
39
+
40
+ body.cms-panel-open.no-transition {
41
+ transition: none;
42
+ }
43
+
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);
48
+ }
49
+
50
+ body.cms-panel-open.no-transition #main-header {
51
+ transition: none;
52
+ }
53
+
54
+ /* Shift fixed toolbar */
55
+ body.cms-panel-open .cms-toolbar {
56
+ left: 380px;
57
+ transition: left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
58
+ }
59
+
60
+ body.cms-panel-open.no-transition .cms-toolbar {
61
+ transition: none;
62
+ }
63
+
64
+ /* Studio button active state */
65
+ .cms-studio-btn.active {
66
+ background: rgba(59, 130, 246, 0.3);
67
+ border-color: rgba(59, 130, 246, 0.5);
68
+ color: var(--cms-accent);
69
+ box-shadow: 0 0 8px rgba(59, 130, 246, 0.15);
70
+ }
71
+
72
+
73
+ /* Mobile: full-width overlay, no content push */
74
+ @media (max-width: 768px) {
75
+ .cms-studio-panel {
76
+ width: 100%;
77
+ box-shadow: none;
78
+ }
79
+
80
+ body.cms-panel-open {
81
+ padding-left: 0;
82
+ }
83
+
84
+ body.cms-panel-open #main-header {
85
+ left: 0;
86
+ }
87
+
88
+ body.cms-panel-open .cms-toolbar {
89
+ left: 0;
90
+ }
91
+ }