@caretcms/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (256) hide show
  1. package/README.md +195 -12
  2. package/dist/index.d.ts +26 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +113 -12
  5. package/dist/index.js.map +1 -1
  6. package/dist/loader.d.ts.map +1 -1
  7. package/dist/loader.js +35 -3
  8. package/dist/loader.js.map +1 -1
  9. package/dist/markdown/paragraphs.d.ts +14 -0
  10. package/dist/markdown/paragraphs.d.ts.map +1 -0
  11. package/dist/markdown/paragraphs.js +61 -0
  12. package/dist/markdown/paragraphs.js.map +1 -0
  13. package/dist/markdown/remark.d.ts.map +1 -1
  14. package/dist/markdown/remark.js +1 -0
  15. package/dist/markdown/remark.js.map +1 -1
  16. package/dist/markdown/satteri.d.ts.map +1 -1
  17. package/dist/markdown/satteri.js +35 -9
  18. package/dist/markdown/satteri.js.map +1 -1
  19. package/dist/markdown/stamp.d.ts +0 -9
  20. package/dist/markdown/stamp.d.ts.map +1 -1
  21. package/dist/markdown/stamp.js +6 -0
  22. package/dist/markdown/stamp.js.map +1 -1
  23. package/dist/providers/deployment/github.d.ts +27 -0
  24. package/dist/providers/deployment/github.d.ts.map +1 -0
  25. package/dist/providers/deployment/github.js +180 -0
  26. package/dist/providers/deployment/github.js.map +1 -0
  27. package/dist/providers/deployment/simulated.d.ts +10 -0
  28. package/dist/providers/deployment/simulated.d.ts.map +1 -0
  29. package/dist/providers/deployment/simulated.js +29 -0
  30. package/dist/providers/deployment/simulated.js.map +1 -0
  31. package/dist/runtime/auth/session.d.ts +1 -0
  32. package/dist/runtime/auth/session.d.ts.map +1 -1
  33. package/dist/runtime/auth/session.js +16 -0
  34. package/dist/runtime/auth/session.js.map +1 -1
  35. package/dist/runtime/authorization.d.ts +11 -0
  36. package/dist/runtime/authorization.d.ts.map +1 -0
  37. package/dist/runtime/authorization.js +29 -0
  38. package/dist/runtime/authorization.js.map +1 -0
  39. package/dist/runtime/collection-policy.d.ts +11 -0
  40. package/dist/runtime/collection-policy.d.ts.map +1 -0
  41. package/dist/runtime/collection-policy.js +44 -0
  42. package/dist/runtime/collection-policy.js.map +1 -0
  43. package/dist/runtime/config.d.ts +3 -0
  44. package/dist/runtime/config.d.ts.map +1 -1
  45. package/dist/runtime/config.js +4 -1
  46. package/dist/runtime/config.js.map +1 -1
  47. package/dist/runtime/content-errors.d.ts +9 -0
  48. package/dist/runtime/content-errors.d.ts.map +1 -0
  49. package/dist/runtime/content-errors.js +17 -0
  50. package/dist/runtime/content-errors.js.map +1 -0
  51. package/dist/runtime/content.d.ts.map +1 -1
  52. package/dist/runtime/content.js +13 -4
  53. package/dist/runtime/content.js.map +1 -1
  54. package/dist/runtime/deployment-status.d.ts +14 -0
  55. package/dist/runtime/deployment-status.d.ts.map +1 -0
  56. package/dist/runtime/deployment-status.js +57 -0
  57. package/dist/runtime/deployment-status.js.map +1 -0
  58. package/dist/runtime/draft-state.d.ts +16 -0
  59. package/dist/runtime/draft-state.d.ts.map +1 -0
  60. package/dist/runtime/draft-state.js +29 -0
  61. package/dist/runtime/draft-state.js.map +1 -0
  62. package/dist/runtime/i18n.d.ts +97 -0
  63. package/dist/runtime/i18n.d.ts.map +1 -0
  64. package/dist/runtime/i18n.js +190 -0
  65. package/dist/runtime/i18n.js.map +1 -0
  66. package/dist/runtime/index.d.ts +1 -0
  67. package/dist/runtime/index.d.ts.map +1 -1
  68. package/dist/runtime/index.js +1 -0
  69. package/dist/runtime/index.js.map +1 -1
  70. package/dist/runtime/middleware.d.ts.map +1 -1
  71. package/dist/runtime/middleware.js +61 -4
  72. package/dist/runtime/middleware.js.map +1 -1
  73. package/dist/runtime/mutations/contracts.d.ts +12 -0
  74. package/dist/runtime/mutations/contracts.d.ts.map +1 -1
  75. package/dist/runtime/mutations/contracts.js +54 -4
  76. package/dist/runtime/mutations/contracts.js.map +1 -1
  77. package/dist/runtime/mutations/engine.d.ts +2 -0
  78. package/dist/runtime/mutations/engine.d.ts.map +1 -1
  79. package/dist/runtime/mutations/engine.js +220 -57
  80. package/dist/runtime/mutations/engine.js.map +1 -1
  81. package/dist/runtime/providers.d.ts +4 -1
  82. package/dist/runtime/providers.d.ts.map +1 -1
  83. package/dist/runtime/providers.js +18 -1
  84. package/dist/runtime/providers.js.map +1 -1
  85. package/dist/runtime/publish-recovery.d.ts +32 -0
  86. package/dist/runtime/publish-recovery.d.ts.map +1 -0
  87. package/dist/runtime/publish-recovery.js +98 -0
  88. package/dist/runtime/publish-recovery.js.map +1 -0
  89. package/dist/runtime/publish.d.ts +10 -4
  90. package/dist/runtime/publish.d.ts.map +1 -1
  91. package/dist/runtime/publish.js +105 -63
  92. package/dist/runtime/publish.js.map +1 -1
  93. package/dist/runtime/rebuild-webhook.d.ts +4 -0
  94. package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
  95. package/dist/runtime/rebuild-webhook.js +12 -2
  96. package/dist/runtime/rebuild-webhook.js.map +1 -1
  97. package/dist/runtime/request-context.d.ts +7 -1
  98. package/dist/runtime/request-context.d.ts.map +1 -1
  99. package/dist/runtime/request-context.js +7 -1
  100. package/dist/runtime/request-context.js.map +1 -1
  101. package/dist/runtime/rewrite.d.ts.map +1 -1
  102. package/dist/runtime/rewrite.js +28 -2
  103. package/dist/runtime/rewrite.js.map +1 -1
  104. package/dist/runtime/rich-allowlist.js +1 -1
  105. package/dist/runtime/rich-allowlist.js.map +1 -1
  106. package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
  107. package/dist/runtime/routes/admin-entry.js +129 -21
  108. package/dist/runtime/routes/admin-entry.js.map +1 -1
  109. package/dist/runtime/routes/admin.d.ts.map +1 -1
  110. package/dist/runtime/routes/admin.js +28 -14
  111. package/dist/runtime/routes/admin.js.map +1 -1
  112. package/dist/runtime/routes/auth-login.d.ts.map +1 -1
  113. package/dist/runtime/routes/auth-login.js +4 -0
  114. package/dist/runtime/routes/auth-login.js.map +1 -1
  115. package/dist/runtime/routes/auth-logout.d.ts.map +1 -1
  116. package/dist/runtime/routes/auth-logout.js +7 -2
  117. package/dist/runtime/routes/auth-logout.js.map +1 -1
  118. package/dist/runtime/routes/auth-session.d.ts.map +1 -1
  119. package/dist/runtime/routes/auth-session.js +10 -2
  120. package/dist/runtime/routes/auth-session.js.map +1 -1
  121. package/dist/runtime/routes/collection-list.d.ts.map +1 -1
  122. package/dist/runtime/routes/collection-list.js +213 -66
  123. package/dist/runtime/routes/collection-list.js.map +1 -1
  124. package/dist/runtime/routes/deployment.d.ts +5 -0
  125. package/dist/runtime/routes/deployment.d.ts.map +1 -0
  126. package/dist/runtime/routes/deployment.js +38 -0
  127. package/dist/runtime/routes/deployment.js.map +1 -0
  128. package/dist/runtime/routes/draft.d.ts.map +1 -1
  129. package/dist/runtime/routes/draft.js +10 -3
  130. package/dist/runtime/routes/draft.js.map +1 -1
  131. package/dist/runtime/routes/entries.d.ts.map +1 -1
  132. package/dist/runtime/routes/entries.js +87 -43
  133. package/dist/runtime/routes/entries.js.map +1 -1
  134. package/dist/runtime/routes/history.d.ts.map +1 -1
  135. package/dist/runtime/routes/history.js +40 -21
  136. package/dist/runtime/routes/history.js.map +1 -1
  137. package/dist/runtime/routes/mutate.d.ts.map +1 -1
  138. package/dist/runtime/routes/mutate.js +4 -1
  139. package/dist/runtime/routes/mutate.js.map +1 -1
  140. package/dist/runtime/routes/publish.d.ts.map +1 -1
  141. package/dist/runtime/routes/publish.js +127 -33
  142. package/dist/runtime/routes/publish.js.map +1 -1
  143. package/dist/runtime/routes/schema.d.ts.map +1 -1
  144. package/dist/runtime/routes/schema.js +11 -2
  145. package/dist/runtime/routes/schema.js.map +1 -1
  146. package/dist/runtime/routes/studio-home.d.ts.map +1 -1
  147. package/dist/runtime/routes/studio-home.js +41 -20
  148. package/dist/runtime/routes/studio-home.js.map +1 -1
  149. package/dist/runtime/routes/upload.d.ts.map +1 -1
  150. package/dist/runtime/routes/upload.js +3 -0
  151. package/dist/runtime/routes/upload.js.map +1 -1
  152. package/dist/runtime/schema-registry.d.ts +8 -1
  153. package/dist/runtime/schema-registry.d.ts.map +1 -1
  154. package/dist/runtime/schema-registry.js +49 -3
  155. package/dist/runtime/schema-registry.js.map +1 -1
  156. package/dist/runtime/storage/entry-commit.d.ts +8 -0
  157. package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
  158. package/dist/runtime/storage/entry-commit.js +45 -0
  159. package/dist/runtime/storage/entry-commit.js.map +1 -0
  160. package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
  161. package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
  162. package/dist/runtime/storage/filesystem-adapter.js +12 -3
  163. package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
  164. package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
  165. package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
  166. package/dist/runtime/storage/frontmatter-codec.js +156 -13
  167. package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
  168. package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
  169. package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
  170. package/dist/runtime/storage/in-memory-adapter.js +6 -0
  171. package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
  172. package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
  173. package/dist/runtime/storage/markdown-adapter.js +18 -20
  174. package/dist/runtime/storage/markdown-adapter.js.map +1 -1
  175. package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
  176. package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
  177. package/dist/runtime/storage/session-overlay-adapter.js +100 -13
  178. package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
  179. package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
  180. package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
  181. package/dist/runtime/storage/sidecar-meta-store.js +48 -5
  182. package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
  183. package/dist/runtime/themes/preset-tokens.js +4 -4
  184. package/dist/runtime/themes/preset-tokens.js.map +1 -1
  185. package/dist/runtime/utils.d.ts.map +1 -1
  186. package/dist/runtime/utils.js +3 -4
  187. package/dist/runtime/utils.js.map +1 -1
  188. package/dist/runtime/views/studio-layout.d.ts +3 -0
  189. package/dist/runtime/views/studio-layout.d.ts.map +1 -1
  190. package/dist/runtime/views/studio-layout.js +36 -13
  191. package/dist/runtime/views/studio-layout.js.map +1 -1
  192. package/dist/schema-utils.d.ts +7 -0
  193. package/dist/schema-utils.d.ts.map +1 -1
  194. package/dist/schema-utils.js +143 -0
  195. package/dist/schema-utils.js.map +1 -1
  196. package/dist/types.d.ts +165 -1
  197. package/dist/types.d.ts.map +1 -1
  198. package/package.json +12 -3
  199. package/static/cms/admin-entry.css +144 -13
  200. package/static/cms/admin-entry.js +539 -586
  201. package/static/cms/dev-toolbar/app.js +78 -19
  202. package/static/cms/editor/config.js +35 -15
  203. package/static/cms/editor/content-map.css +8 -1
  204. package/static/cms/editor/content-map.js +62 -23
  205. package/static/cms/editor/deployment-status.js +66 -0
  206. package/static/cms/editor/draft-client.js +87 -0
  207. package/static/cms/editor/guards.js +20 -0
  208. package/static/cms/editor/helpers.js +30 -1
  209. package/static/cms/editor/highlight.js +1 -0
  210. package/static/cms/editor/image-edit.js +44 -1
  211. package/static/cms/editor/image-utils.js +23 -7
  212. package/static/cms/editor/link-follow.js +122 -41
  213. package/static/cms/editor/link-ui.css +10 -1
  214. package/static/cms/editor/linkify.js +2 -0
  215. package/static/cms/editor/md-block-edit.js +140 -37
  216. package/static/cms/editor/md-paragraphs.js +98 -0
  217. package/static/cms/editor/md-serialize.js +1 -1
  218. package/static/cms/editor/panel.js +53 -7
  219. package/static/cms/editor/preview-mode.js +44 -0
  220. package/static/cms/editor/publish-result.js +100 -0
  221. package/static/cms/editor/rich-toolbar.js +58 -15
  222. package/static/cms/editor/sanitize.js +6 -2
  223. package/static/cms/editor/save-queue.js +75 -17
  224. package/static/cms/editor/section-controls/activation.js +3 -0
  225. package/static/cms/editor/section-controls/api.js +25 -7
  226. package/static/cms/editor/section-controls/index.js +60 -8
  227. package/static/cms/editor/section-controls/model.js +39 -4
  228. package/static/cms/editor/section-controls/mutations.js +15 -0
  229. package/static/cms/editor/section-controls/page-context.js +9 -0
  230. package/static/cms/editor/section-controls/pickers.js +53 -2
  231. package/static/cms/editor/section-controls/reorder.js +23 -0
  232. package/static/cms/editor/section-controls/selection.js +6 -0
  233. package/static/cms/editor/section-controls/spacing-drag.js +16 -0
  234. package/static/cms/editor/section-controls/ui-elements.js +7 -0
  235. package/static/cms/editor/section-controls/utils.js +7 -0
  236. package/static/cms/editor/section-controls.css +18 -3
  237. package/static/cms/editor/security.js +13 -2
  238. package/static/cms/editor/stega-hydrate.js +17 -4
  239. package/static/cms/editor/sync.js +475 -56
  240. package/static/cms/editor/text-edit.js +37 -3
  241. package/static/cms/editor/text-link-interactions.js +59 -17
  242. package/static/cms/editor/toast.js +23 -9
  243. package/static/cms/editor/tokens-and-text.css +9 -0
  244. package/static/cms/editor/toolbar.css +5 -0
  245. package/static/cms/editor/toolbar.js +91 -101
  246. package/static/cms/editor.css +1 -1
  247. package/static/cms/editor.js +1 -0
  248. package/static/cms/studio/entry-loader.js +102 -0
  249. package/static/cms/studio/field-model.js +110 -0
  250. package/static/cms/studio/fields.js +319 -0
  251. package/static/cms/studio/history-client.js +74 -0
  252. package/static/cms/studio/mutation-client.js +74 -0
  253. package/static/cms/studio/sync-client.js +107 -0
  254. package/static/cms/studio/upload-client.js +109 -0
  255. package/static/cms/studio-tokens.css +4 -4
  256. package/static/cms/studio.css +24 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @typedef {object} DraftState
3
+ * @property {number} count
4
+ * @property {boolean} canPublish
5
+ * @property {boolean} retryRebuild
6
+ */
7
+ /**
8
+ * @typedef {object} RetryDeploymentResult
9
+ * @property {boolean} deploymentTracked
10
+ */
11
+
12
+ export class CmsDraftRequestError extends Error {}
13
+
14
+ /** @param {Response} response */
15
+ async function requireOk(response) {
16
+ if (!response.ok) {
17
+ throw new CmsDraftRequestError(`CMS request failed (${response.status})`);
18
+ }
19
+ return response;
20
+ }
21
+
22
+ /** @param {unknown} value */
23
+ function record(value) {
24
+ return value && typeof value === 'object' && !Array.isArray(value)
25
+ ? /** @type {Record<string, unknown>} */ (value)
26
+ : {};
27
+ }
28
+
29
+ /**
30
+ * HTTP client for editor-specific draft and deploy-retry operations.
31
+ *
32
+ * @param {{
33
+ * buildUrl: (path: string) => string,
34
+ * fetchImpl?: typeof fetch,
35
+ * }} options
36
+ */
37
+ export function createDraftClient(options) {
38
+ const fetchImpl = options.fetchImpl ?? fetch;
39
+ const headers = { 'Content-Type': 'application/json', 'x-caret-request': '1' };
40
+
41
+ /** @param {'GET' | 'POST' | 'DELETE'} method @param {string} path @param {string | undefined} body */
42
+ function request(method, path, body) {
43
+ return fetchImpl(options.buildUrl(path), {
44
+ method,
45
+ headers,
46
+ credentials: 'same-origin',
47
+ ...(body === undefined ? {} : { body }),
48
+ });
49
+ }
50
+
51
+ async function getState() {
52
+ const response = await requireOk(await request('GET', '/draft', undefined));
53
+ const data = record(await response.json());
54
+ return /** @type {DraftState} */ ({
55
+ count: typeof data.count === 'number' && Number.isFinite(data.count)
56
+ ? Math.max(0, Math.trunc(data.count))
57
+ : 0,
58
+ canPublish: data.canPublish !== false,
59
+ retryRebuild: data.retryRebuild === true,
60
+ });
61
+ }
62
+
63
+ async function publish() {
64
+ const response = await requireOk(await request('POST', '/publish', '{}'));
65
+ return /** @type {unknown} */ (await response.json());
66
+ }
67
+
68
+ async function discard() {
69
+ await requireOk(await request('DELETE', '/draft', undefined));
70
+ }
71
+
72
+ async function retryDeployment() {
73
+ const response = await requireOk(await request(
74
+ 'POST',
75
+ '/publish',
76
+ JSON.stringify({ retryRebuild: true }),
77
+ ));
78
+ const data = record(await response.json());
79
+ const rebuild = record(data.rebuild);
80
+ if (rebuild.ok !== true) throw new CmsDraftRequestError('Deployment retry failed');
81
+ return /** @type {RetryDeploymentResult} */ ({
82
+ deploymentTracked: data.deploymentTracked === true,
83
+ });
84
+ }
85
+
86
+ return { getState, publish, discard, retryDeployment };
87
+ }
@@ -1,3 +1,4 @@
1
+ /** @param {{ dirtyEls: Set<Element> }} state */
1
2
  export function mountEditorGuards(state) {
2
3
  document.body.classList.add('cms-edit-mode');
3
4
 
@@ -15,6 +16,25 @@ export function mountEditorGuards(state) {
15
16
  const target = e.target;
16
17
  if (!(target instanceof Element)) return;
17
18
 
19
+ // Preserve the browser's native open-in-new-tab gesture for editable
20
+ // links. A normal click still enters editing below.
21
+ if (e.metaKey || e.ctrlKey) {
22
+ const modifierLink = target.closest('a');
23
+ if (modifierLink?.matches('[data-caret]') || modifierLink?.querySelector('[data-caret]')) {
24
+ e.preventDefault();
25
+ e.stopPropagation();
26
+ try {
27
+ const destination = new URL(modifierLink.href, window.location.href);
28
+ if (['http:', 'https:', 'mailto:', 'tel:'].includes(destination.protocol)) {
29
+ window.open(destination.href, '_blank', 'noopener,noreferrer');
30
+ }
31
+ } catch {
32
+ // Malformed destinations never execute from edit mode.
33
+ }
34
+ return;
35
+ }
36
+ }
37
+
18
38
  // Allow clicks inside the CMS toolbar
19
39
  if (target.closest('.cms-toolbar')) return;
20
40
 
@@ -1,3 +1,21 @@
1
+ /**
2
+ * @typedef {object} ParsedCaretBinding
3
+ * @property {string | null} collection
4
+ * @property {string | null} id
5
+ * @property {string} field
6
+ */
7
+
8
+ /**
9
+ * @typedef {object} ResolvedCaretBinding
10
+ * @property {string} collection
11
+ * @property {string} id
12
+ * @property {string} field
13
+ */
14
+
15
+ /**
16
+ * @param {string} attr
17
+ * @returns {ParsedCaretBinding | null}
18
+ */
1
19
  export function parseCaretAttr(attr) {
2
20
  const parts = attr.split('::');
3
21
  if (parts.length === 3) return { collection: parts[0], id: parts[1], field: parts[2] };
@@ -11,6 +29,9 @@ export function parseCaretAttr(attr) {
11
29
  * If the binding already has collection+id, returns as-is.
12
30
  * If field-only, walks up to nearest [data-caret-scope] ancestor.
13
31
  * Returns null if scope cannot be resolved.
32
+ * @param {Element} el
33
+ * @param {ParsedCaretBinding | null} parsed
34
+ * @returns {ResolvedCaretBinding | null}
14
35
  */
15
36
  export function resolveBinding(el, parsed) {
16
37
  if (!parsed) return null;
@@ -18,7 +39,9 @@ export function resolveBinding(el, parsed) {
18
39
  // segment ("::x::y") must stay a (rejected) triple like the server treats
19
40
  // it, not silently fall back to scope resolution and save elsewhere.
20
41
  // Held to the rewrite engine by tests/unit/caret-parser-parity.test.ts.
21
- if (parsed.collection !== null && parsed.id !== null) return parsed;
42
+ if (parsed.collection !== null && parsed.id !== null) {
43
+ return { collection: parsed.collection, id: parsed.id, field: parsed.field };
44
+ }
22
45
 
23
46
  // Walk up to find nearest data-caret-scope
24
47
  let node = el.parentElement;
@@ -38,6 +61,8 @@ export function resolveBinding(el, parsed) {
38
61
  /**
39
62
  * Get the full data-caret key for an element, resolving scope if needed.
40
63
  * Returns "collection::id::field" or null if unresolvable.
64
+ * @param {Element} el
65
+ * @returns {string | null}
41
66
  */
42
67
  export function getResolvedKey(el) {
43
68
  const attr = el.getAttribute('data-caret');
@@ -48,6 +73,10 @@ export function getResolvedKey(el) {
48
73
  return `${resolved.collection}::${resolved.id}::${resolved.field}`;
49
74
  }
50
75
 
76
+ /**
77
+ * @param {Element} el
78
+ * @param {boolean} success
79
+ */
51
80
  export function flash(el, success) {
52
81
  const cls = success ? 'cms-saved' : 'cms-error';
53
82
  el.classList.add(cls);
@@ -14,6 +14,7 @@
14
14
 
15
15
  let highlightActive = false;
16
16
 
17
+ /** @param {Element} highlightBtn @param {(message: string, type: 'success' | 'error') => void} showToast */
17
18
  export function toggleHighlight(highlightBtn, showToast) {
18
19
  highlightActive = !highlightActive;
19
20
  document.body.classList.toggle('cms-highlight-all', highlightActive);
@@ -2,6 +2,40 @@ import { buildCmsUrl } from './config.js';
2
2
  import { uploadHeaders } from './security.js';
3
3
  import { resolveBinding } from './helpers.js';
4
4
 
5
+ /** @typedef {{ collection: string, id: string, field: string }} ResolvedCaretBinding */
6
+ /** @typedef {{ collection: string | null, id: string | null, field: string }} ParsedCaretBinding */
7
+ /** @typedef {{ ok: true, revision?: number } | { ok: false, reason: 'conflict' | 'unauthorized' | 'error', latestValue?: unknown }} SaveResult */
8
+ /**
9
+ * @typedef {((message: string, kind: 'success' | 'error') => unknown) & {
10
+ * conflict: (options: {
11
+ * message: string,
12
+ * onKeepMine: () => void | Promise<void>,
13
+ * onLoadLatest: () => void,
14
+ * }) => unknown,
15
+ * }} ConflictToast
16
+ */
17
+
18
+ /**
19
+ * @param {unknown} value
20
+ * @returns {string | null}
21
+ */
22
+ export function readUploadUrl(value) {
23
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
24
+ const url = /** @type {Record<string, unknown>} */ (value).url;
25
+ return typeof url === 'string' && url.trim() ? url.trim() : null;
26
+ }
27
+
28
+ /**
29
+ * @param {object} options
30
+ * @param {(attr: string) => ParsedCaretBinding | null} options.parseCaretAttr
31
+ * @param {(element: Element, success: boolean) => void} options.flash
32
+ * @param {(file: File) => Promise<File>} options.compressImage
33
+ * @param {(image: HTMLImageElement, url: string) => void} options.updateEmblaCarousel
34
+ * @param {(collection: string, id: string, field: string, value: string) => Promise<SaveResult>} options.saveField
35
+ * @param {(state: 'saving' | 'error', text: string) => void} options.setStatus
36
+ * @param {ConflictToast} options.showToast
37
+ * @param {() => void} options.onUnauthorized
38
+ */
5
39
  export function mountImageEditors({
6
40
  parseCaretAttr,
7
41
  flash,
@@ -12,6 +46,7 @@ export function mountImageEditors({
12
46
  showToast,
13
47
  onUnauthorized,
14
48
  }) {
49
+ /** @param {string} marker @param {string} src */
15
50
  const syncBoundImages = (marker, src) => {
16
51
  document.querySelectorAll('[data-caret]').forEach((node) => {
17
52
  if (!(node instanceof HTMLImageElement)) return;
@@ -72,6 +107,7 @@ export function mountImageEditors({
72
107
  const input = document.createElement('input');
73
108
  input.type = 'file';
74
109
  input.accept = 'image/jpeg,image/png,image/webp,image/avif';
110
+ input.setAttribute('aria-label', 'Upload replacement image');
75
111
  input.style.display = 'none';
76
112
  wrapper.appendChild(input);
77
113
 
@@ -84,6 +120,12 @@ export function mountImageEditors({
84
120
  // (we hold the stored `mineUrl`); only writing it into the entry conflicted.
85
121
  // Keep the uploaded image shown and let the user choose, rather than
86
122
  // silently swapping in whatever landed elsewhere.
123
+ /**
124
+ * @param {ResolvedCaretBinding} resolved
125
+ * @param {string} marker
126
+ * @param {string} mineUrl
127
+ * @param {unknown} latestValue
128
+ */
87
129
  const runImageConflict = (resolved, marker, mineUrl, latestValue) => {
88
130
  showToast.conflict({
89
131
  message: 'This image changed elsewhere while you were editing.',
@@ -145,7 +187,8 @@ export function mountImageEditors({
145
187
  return;
146
188
  }
147
189
  if (!uploadRes.ok) throw new Error('Upload failed');
148
- const { url } = await uploadRes.json();
190
+ const url = readUploadUrl(await uploadRes.json());
191
+ if (!url) throw new Error('Invalid upload response');
149
192
 
150
193
  // Point at the durable uploaded URL now, so the shown image survives the
151
194
  // optimistic blob being revoked in `finally` (and any conflict prompt).
@@ -1,3 +1,9 @@
1
+ /**
2
+ * @param {File} file
3
+ * @param {number} [maxWidth]
4
+ * @param {number} [quality]
5
+ * @returns {Promise<File>}
6
+ */
1
7
  export async function compressImage(file, maxWidth = 1600, quality = 0.82) {
2
8
  if (file.size < 200000 && file.type === 'image/webp') return file;
3
9
 
@@ -22,6 +28,10 @@ export async function compressImage(file, maxWidth = 1600, quality = 0.82) {
22
28
  canvas.width = width;
23
29
  canvas.height = height;
24
30
  const ctx = canvas.getContext('2d');
31
+ if (!ctx) {
32
+ bitmap.close();
33
+ return file;
34
+ }
25
35
  ctx.drawImage(bitmap, 0, 0, width, height);
26
36
  bitmap.close();
27
37
 
@@ -29,7 +39,7 @@ export async function compressImage(file, maxWidth = 1600, quality = 0.82) {
29
39
  const isPng = file.type === 'image/png';
30
40
  const outType = isPng ? 'image/png' : 'image/webp';
31
41
 
32
- const blob = await new Promise((resolve) =>
42
+ const blob = await new Promise((/** @type {(value: Blob | null) => void} */ resolve) =>
33
43
  canvas.toBlob((b) => resolve(b), outType, isPng ? undefined : quality),
34
44
  );
35
45
 
@@ -40,6 +50,10 @@ export async function compressImage(file, maxWidth = 1600, quality = 0.82) {
40
50
  return new File([blob], name, { type: outType });
41
51
  }
42
52
 
53
+ /**
54
+ * @param {HTMLImageElement} img
55
+ * @param {string} newUrl
56
+ */
43
57
  export function updateEmblaCarousel(img, newUrl) {
44
58
  // Find the closest Embla container
45
59
  const emblaContainer = img.closest('.embla');
@@ -47,10 +61,12 @@ export function updateEmblaCarousel(img, newUrl) {
47
61
 
48
62
  // Find the slide this image belongs to
49
63
  const slide = img.closest('.embla__slide');
50
- if (!slide) return;
64
+ if (!(slide instanceof HTMLElement)) return;
51
65
 
52
- const slideIndex = parseInt(slide.dataset.index, 10);
53
- if (isNaN(slideIndex)) return;
66
+ const index = slide.dataset.index;
67
+ if (!index) return;
68
+ const slideIndex = Number.parseInt(index, 10);
69
+ if (Number.isNaN(slideIndex)) return;
54
70
 
55
71
  // Update the data-full attribute for lightbox
56
72
  slide.dataset.full = newUrl;
@@ -59,7 +75,7 @@ export function updateEmblaCarousel(img, newUrl) {
59
75
  const thumbBtn = document.querySelector(`.thumb-btn[data-index="${slideIndex}"]`);
60
76
  if (thumbBtn) {
61
77
  const thumbImg = thumbBtn.querySelector('img');
62
- if (thumbImg) {
78
+ if (thumbImg instanceof HTMLImageElement) {
63
79
  thumbImg.src = newUrl;
64
80
  // Add a flash effect to show it updated
65
81
  thumbBtn.classList.add('cms-thumb-updated');
@@ -69,10 +85,10 @@ export function updateEmblaCarousel(img, newUrl) {
69
85
 
70
86
  // Update lightbox thumbnails if visible
71
87
  const lightboxThumb = document.querySelector(`.lightbox-thumb[data-index="${slideIndex}"]`);
72
- if (lightboxThumb) {
88
+ if (lightboxThumb instanceof HTMLElement) {
73
89
  lightboxThumb.dataset.src = newUrl;
74
90
  const lbImg = lightboxThumb.querySelector('img');
75
- if (lbImg) lbImg.src = newUrl;
91
+ if (lbImg instanceof HTMLImageElement) lbImg.src = newUrl;
76
92
  }
77
93
 
78
94
  // Dispatch custom event that the product page can listen to
@@ -5,22 +5,32 @@
5
5
  * module restores a visible way to follow the link without leaving edit mode.
6
6
  */
7
7
 
8
+ /** @type {HTMLAnchorElement | null} */
8
9
  let activeBtn = null;
10
+ /** @type {HTMLAnchorElement | null} */
9
11
  let activeAnchor = null;
12
+ /** @type {number | null} */
10
13
  let hideTimer = null;
11
14
 
15
+ /** @param {string | null} href @returns {boolean} */
12
16
  function isFollowableHref(href) {
13
17
  if (!href) return false;
14
18
  const trimmed = href.trim();
15
19
  if (!trimmed || trimmed === "#") return false;
16
- if (trimmed.startsWith("javascript:")) return false;
17
- return true;
20
+ try {
21
+ const url = new URL(trimmed, window.location.href);
22
+ return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol);
23
+ } catch {
24
+ return false;
25
+ }
18
26
  }
19
27
 
28
+ /** @param {string} href @returns {boolean} */
20
29
  function isExternal(href) {
21
30
  return /^https?:\/\//i.test(href);
22
31
  }
23
32
 
33
+ /** @param {string} href @returns {string} */
24
34
  function shortLabel(href) {
25
35
  if (isExternal(href)) {
26
36
  try {
@@ -32,45 +42,94 @@ function shortLabel(href) {
32
42
  return href;
33
43
  }
34
44
 
45
+ function cancelHide() {
46
+ if (hideTimer !== null) {
47
+ clearTimeout(hideTimer);
48
+ hideTimer = null;
49
+ }
50
+ }
51
+
35
52
  function removeBtn() {
36
53
  if (activeBtn) {
37
54
  activeBtn.remove();
38
55
  activeBtn = null;
39
56
  activeAnchor = null;
40
57
  }
41
- if (hideTimer) {
42
- clearTimeout(hideTimer);
43
- hideTimer = null;
44
- }
58
+ cancelHide();
45
59
  }
46
60
 
61
+ /** @param {HTMLAnchorElement} anchor @param {HTMLAnchorElement} btn */
47
62
  function position(anchor, btn) {
63
+ if (!anchor.isConnected || !btn.isConnected) {
64
+ removeBtn();
65
+ return;
66
+ }
48
67
  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`;
68
+ if (rect.bottom < 0 || rect.top > window.innerHeight) {
69
+ removeBtn();
70
+ return;
71
+ }
72
+
73
+ const gap = 8;
74
+ const margin = 8;
75
+ const width = btn.offsetWidth;
76
+ const height = btn.offsetHeight;
77
+ const candidates = [
78
+ { left: rect.right + gap, top: rect.top + (rect.height - height) / 2 },
79
+ { left: rect.left, top: rect.bottom + gap },
80
+ { left: rect.left, top: rect.top - height - gap },
81
+ { left: rect.left - width - gap, top: rect.top + (rect.height - height) / 2 },
82
+ ];
83
+ const otherEditables = Array.from(document.querySelectorAll('[data-caret]'))
84
+ .filter((el) => !anchor.contains(el))
85
+ .map((el) => el.getBoundingClientRect());
86
+
87
+ /** @param {{ left: number, top: number }} candidate */
88
+ function overlapsEditable(candidate) {
89
+ const box = {
90
+ left: candidate.left,
91
+ top: candidate.top,
92
+ right: candidate.left + width,
93
+ bottom: candidate.top + height,
94
+ };
95
+ return otherEditables.some((other) =>
96
+ box.left < other.right && box.right > other.left && box.top < other.bottom && box.bottom > other.top
97
+ );
98
+ }
99
+
100
+ const placed = candidates.find((candidate) =>
101
+ candidate.left >= margin
102
+ && candidate.top >= margin
103
+ && candidate.left + width <= window.innerWidth - margin
104
+ && candidate.top + height <= window.innerHeight - margin
105
+ && !overlapsEditable(candidate)
106
+ );
107
+
108
+ btn.classList.toggle('cms-link-follow-compact', !placed);
109
+ const fallback = {
110
+ left: Math.min(window.innerWidth - width - margin, Math.max(margin, rect.right - width)),
111
+ top: Math.min(window.innerHeight - height - margin, Math.max(margin, rect.top)),
112
+ };
113
+ const target = placed || fallback;
114
+ btn.style.top = `${target.top}px`;
115
+ btn.style.left = `${target.left}px`;
55
116
  }
56
117
 
57
118
  function scheduleHide() {
58
- if (hideTimer) clearTimeout(hideTimer);
119
+ cancelHide();
59
120
  hideTimer = window.setTimeout(removeBtn, 180);
60
121
  }
61
122
 
123
+ /** @param {HTMLAnchorElement} anchor */
62
124
  function show(anchor) {
63
125
  if (activeAnchor === anchor && activeBtn) {
64
- if (hideTimer) {
65
- clearTimeout(hideTimer);
66
- hideTimer = null;
67
- }
126
+ cancelHide();
68
127
  return;
69
128
  }
70
129
  removeBtn();
71
130
 
72
131
  const href = anchor.getAttribute("href");
73
- if (!isFollowableHref(href)) return;
132
+ if (!isFollowableHref(href) || typeof href !== 'string') return;
74
133
 
75
134
  const external = isExternal(href);
76
135
  const btn = document.createElement("a");
@@ -81,20 +140,20 @@ function show(anchor) {
81
140
  btn.target = "_blank";
82
141
  btn.rel = "noopener noreferrer";
83
142
  }
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
- `;
143
+ btn.setAttribute('aria-label', `Open ${external ? "link" : "page"}: ${shortLabel(href)}`);
144
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
145
+ icon.setAttribute('viewBox', '0 0 16 16');
146
+ icon.setAttribute('fill', 'none');
147
+ icon.setAttribute('aria-hidden', 'true');
148
+ icon.innerHTML = '<path d="M5 11 L11 5" stroke="currentColor" stroke-width="1.4" stroke-linecap="square"/><path d="M6 5 L11 5 L11 10" stroke="currentColor" stroke-width="1.4" stroke-linecap="square"/>';
149
+ const label = document.createElement('span');
150
+ label.textContent = `Open ${external ? "link" : "page"}`;
151
+ const destination = document.createElement('em');
152
+ destination.textContent = shortLabel(href);
153
+ btn.append(icon, label, destination);
92
154
 
93
155
  btn.addEventListener("mouseenter", () => {
94
- if (hideTimer) {
95
- clearTimeout(hideTimer);
96
- hideTimer = null;
97
- }
156
+ cancelHide();
98
157
  });
99
158
  btn.addEventListener("mouseleave", scheduleHide);
100
159
 
@@ -105,18 +164,27 @@ function show(anchor) {
105
164
  }
106
165
 
107
166
  export function mountLinkFollowAffordances() {
167
+ /** @param {Element} target @returns {Element | null} */
168
+ function editableFromTarget(target) {
169
+ const direct = target.closest('[data-caret]');
170
+ if (direct) return direct;
171
+ return target.closest('.cms-img-wrapper')?.querySelector('img[data-caret]') || null;
172
+ }
173
+
174
+ /** @param {Element} editable @returns {HTMLAnchorElement | null} */
175
+ function anchorFromEditable(editable) {
176
+ return editable instanceof HTMLAnchorElement ? editable : editable.closest('a');
177
+ }
178
+
108
179
  document.addEventListener("mouseover", (e) => {
109
180
  const target = e.target;
110
181
  if (!(target instanceof Element)) return;
111
182
  if (target.closest(".cms-link-follow")) return;
112
183
 
113
- const editable = target.closest("[data-caret]");
184
+ const editable = editableFromTarget(target);
114
185
  if (!editable) return;
115
186
 
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");
187
+ const anchor = anchorFromEditable(editable);
120
188
  if (!anchor) return;
121
189
 
122
190
  show(anchor);
@@ -127,12 +195,8 @@ export function mountLinkFollowAffordances() {
127
195
  if (!(target instanceof Element)) return;
128
196
  if (target.closest(".cms-link-follow")) return;
129
197
 
130
- const editable = target.closest("[data-caret]");
131
- const anchor = editable
132
- ? editable.matches("a")
133
- ? editable
134
- : editable.closest("a")
135
- : null;
198
+ const editable = editableFromTarget(target);
199
+ const anchor = editable ? anchorFromEditable(editable) : null;
136
200
  if (anchor === activeAnchor) scheduleHide();
137
201
  });
138
202
 
@@ -141,4 +205,21 @@ export function mountLinkFollowAffordances() {
141
205
  };
142
206
  window.addEventListener("scroll", reposition, { passive: true });
143
207
  window.addEventListener("resize", reposition);
208
+
209
+ document.addEventListener('focusin', (e) => {
210
+ const target = e.target;
211
+ if (!(target instanceof Element)) return;
212
+ if (target.closest('.cms-link-follow')) {
213
+ cancelHide();
214
+ return;
215
+ }
216
+ const editable = editableFromTarget(target);
217
+ const anchor = editable ? anchorFromEditable(editable) : null;
218
+ if (anchor) show(anchor);
219
+ });
220
+ document.addEventListener('focusout', (e) => {
221
+ const target = e.target;
222
+ if (!(target instanceof Element)) return;
223
+ if (editableFromTarget(target)) scheduleHide();
224
+ });
144
225
  }
@@ -148,7 +148,7 @@
148
148
  --------------------------------------------------------------------------- */
149
149
 
150
150
  .cms-link-follow {
151
- position: absolute;
151
+ position: fixed;
152
152
  z-index: 100000;
153
153
  display: inline-flex;
154
154
  align-items: center;
@@ -169,6 +169,15 @@
169
169
  animation: cms-link-follow-in 0.16s ease-out;
170
170
  }
171
171
 
172
+ .cms-link-follow.cms-link-follow-compact span,
173
+ .cms-link-follow.cms-link-follow-compact em {
174
+ display: none;
175
+ }
176
+
177
+ .cms-link-follow.cms-link-follow-compact {
178
+ padding: 7px;
179
+ }
180
+
172
181
  .cms-link-follow:hover {
173
182
  background: rgba(59, 130, 246, 0.18);
174
183
  border-color: var(--cms-accent);
@@ -1,3 +1,4 @@
1
+ /** @param {string} s */
1
2
  export function escapeHtml(s) {
2
3
  return s
3
4
  .replace(/&/g, '&amp;')
@@ -6,6 +7,7 @@ export function escapeHtml(s) {
6
7
  .replace(/"/g, '&quot;');
7
8
  }
8
9
 
10
+ /** @param {string} text */
9
11
  export function clientLinkify(text) {
10
12
  if (!text) return '';
11
13