canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -13,6 +13,7 @@
13
13
  * `mutate()` to revalidate.
14
14
  */
15
15
  import useSWR from 'swr';
16
+ import { isNonApiResponse } from '../../api/client.js';
16
17
  /** Cache key for the branches list. */
17
18
  export const BRANCHES_KEY = 'canopy:branches';
18
19
  /**
@@ -21,15 +22,20 @@ export const BRANCHES_KEY = 'canopy:branches';
21
22
  */
22
23
  export async function fetchBranches(apiClient) {
23
24
  const result = await apiClient.branches.list();
24
- if (result.status === 404) {
25
+ if (result.status === 404 && !isNonApiResponse(result)) {
25
26
  // No branch endpoint available; stay branchless rather than erroring --
26
27
  // the branch dropdown stays clickable so the user can retry from there.
27
- return { branches: [] };
28
+ // A proxy's 404 page means the API was not reached, so it is an error.
29
+ return { branches: [], receivedAt: Date.now() };
28
30
  }
29
31
  if (!result.ok) {
30
32
  throw new Error(result.error ?? `Failed to load branches: ${result.status}`);
31
33
  }
32
- return { branches: result.data?.branches ?? [], defaultBranch: result.data?.defaultBranch };
34
+ return {
35
+ branches: result.data?.branches ?? [],
36
+ defaultBranch: result.data?.defaultBranch,
37
+ receivedAt: Date.now(),
38
+ };
33
39
  }
34
40
  /**
35
41
  * How often to re-poll while at least one branch is mid-flight, in ms.
@@ -4,6 +4,7 @@ import { notifications } from '@mantine/notifications';
4
4
  import { normalizeCanopyPath } from '../canopy-path.js';
5
5
  import { useApiClient } from '../context/index.js';
6
6
  import { resolveMessageOrigin } from '../preview-bridge.js';
7
+ import { isSamePreviewPath } from '../preview-path.js';
7
8
  import { commentsKey, fetchComments, useCommentsData } from './useCommentsData.js';
8
9
  /**
9
10
  * Custom hook for managing the comment system.
@@ -112,6 +113,16 @@ export function useCommentSystem(options) {
112
113
  }, [activeCommentContext, options.selectedPath, options.branchName]);
113
114
  // Listen for field focus messages from preview frame
114
115
  useEffect(() => {
116
+ // Delayed undo steps (highlight restore, focus clear). Cleanup runs them early rather than
117
+ // dropping them, so an entry switch never leaves a field highlighted or focused.
118
+ const pending = new Map();
119
+ const later = (undo, ms) => {
120
+ const id = window.setTimeout(() => {
121
+ pending.delete(id);
122
+ undo();
123
+ }, ms);
124
+ pending.set(id, undo);
125
+ };
115
126
  const handleFocus = (event) => {
116
127
  // Only accept messages from the preview's origin (same-origin when previewSrc is
117
128
  // relative). Origin-only by design: this hook has no handle on the preview iframe
@@ -121,8 +132,11 @@ export function useCommentSystem(options) {
121
132
  const msg = event.data;
122
133
  if (msg?.type !== 'canopycms:preview:focus')
123
134
  return;
135
+ const currentPath = options.currentEntry?.previewSrc ?? options.currentEntry?.path;
124
136
  if (msg.entryPath &&
125
- msg.entryPath !== (options.currentEntry?.previewSrc ?? options.currentEntry?.path))
137
+ (typeof msg.entryPath !== 'string' ||
138
+ currentPath === undefined ||
139
+ !isSamePreviewPath(msg.entryPath, currentPath)))
126
140
  return;
127
141
  const normalizedPath = msg.fieldPath ? normalizeCanopyPath(msg.fieldPath) : undefined;
128
142
  const target = normalizedPath
@@ -132,21 +146,26 @@ export function useCommentSystem(options) {
132
146
  target.scrollIntoView({ behavior: 'smooth', block: 'center' });
133
147
  const previous = target.style.boxShadow;
134
148
  target.style.boxShadow = '0 0 0 3px rgba(79, 70, 229, 0.35)';
135
- window.setTimeout(() => {
149
+ later(() => {
136
150
  target.style.boxShadow = previous;
137
151
  }, 1200);
138
152
  // Set focused field path to trigger FieldWrapper auto-focus
139
153
  if (normalizedPath) {
140
154
  setFocusedFieldPath(normalizedPath);
141
155
  // Clear after brief delay to allow FieldWrapper to detect the change
142
- window.setTimeout(() => {
143
- setFocusedFieldPath(undefined);
144
- }, 100);
156
+ later(() => setFocusedFieldPath(undefined), 100);
145
157
  }
146
158
  }
147
159
  };
148
160
  window.addEventListener('message', handleFocus);
149
- return () => window.removeEventListener('message', handleFocus);
161
+ return () => {
162
+ window.removeEventListener('message', handleFocus);
163
+ for (const [id, undo] of pending) {
164
+ window.clearTimeout(id);
165
+ undo();
166
+ }
167
+ pending.clear();
168
+ };
150
169
  }, [options.currentEntry]);
151
170
  const handleJumpToField = (entryPath, canopyPath, threadId) => {
152
171
  if (entryPath !== options.selectedPath) {
@@ -8,13 +8,19 @@ export interface UseDraftManagerOptions {
8
8
  entries: EditorEntry[];
9
9
  initialValues?: Record<string, FormValue>;
10
10
  loadEntry: (entry: EditorEntry) => Promise<FormValue>;
11
+ /**
12
+ * Server read that records no OCC token (useEntryManager's `readEntryValue`),
13
+ * for checking restored drafts of unopened entries. Without it they stay
14
+ * unverified and count as dirty.
15
+ */
16
+ readEntryValue?: (entry: EditorEntry) => Promise<FormValue>;
11
17
  saveEntry: (entry: EditorEntry, value: FormValue) => Promise<FormValue>;
12
18
  /**
13
19
  * The OCC version token currently held for an entry on the branch being
14
20
  * shown (useEntryManager's `getEntryVersion`). Stamps each draft with the
15
21
  * version it was based on, and detects at save time that the token has
16
22
  * since moved on. Optional: without it no base versions are recorded and
17
- * conflict detection falls back entirely to the server's 409.
23
+ * conflict detection falls back entirely to saveEntry's token check.
18
24
  */
19
25
  getEntryVersion?: (contentId: string) => number | undefined;
20
26
  setBusy: (busy: boolean) => void;
@@ -26,6 +32,11 @@ export interface UseDraftManagerOptions {
26
32
  */
27
33
  onSaved?: () => void;
28
34
  }
35
+ /** The unsaved work a branch-level action would leave behind. `labels` names the entries (it may be shorter than `count`). */
36
+ export interface UnsavedSummary {
37
+ count: number;
38
+ labels: string[];
39
+ }
29
40
  export interface UseDraftManagerReturn {
30
41
  drafts: Record<string, FormValue>;
31
42
  setDrafts: React.Dispatch<React.SetStateAction<Record<string, FormValue>>>;
@@ -46,6 +57,10 @@ export interface UseDraftManagerReturn {
46
57
  isDirtyForEntry: (entryPath: string) => boolean;
47
58
  isSelectedDirty: () => boolean;
48
59
  isAnyDirty: () => boolean;
60
+ /** Resolves once restored drafts have been checked against the server (or a read failed), capped so a hung read cannot block. */
61
+ whenDraftsVerified: () => Promise<void>;
62
+ /** `whenDraftsVerified`, then the entries still counted dirty. */
63
+ resolveUnsaved: () => Promise<UnsavedSummary>;
49
64
  /**
50
65
  * Per-field validation errors for the selected entry, keyed by canonical
51
66
  * canopy path (e.g. `blocks[0].title`). Populated when a save is blocked by
@@ -63,6 +63,9 @@ const parsePersistedDrafts = (raw) => {
63
63
  baseVersions[id] = null;
64
64
  return { drafts, baseVersions };
65
65
  };
66
+ const VERIFY_CONCURRENCY = 4;
67
+ /** How long `whenDraftsVerified` waits before answering with whatever is known. */
68
+ const VERIFY_WAIT_CAP_MS = 3000;
66
69
  /**
67
70
  * Custom hook for managing draft state (localStorage persistence, save/discard).
68
71
  */
@@ -103,40 +106,79 @@ export function useDraftManager(options) {
103
106
  const loadedValue = currentId ? loadedValues[currentId] : undefined;
104
107
  const effectiveValue = selectedValue ?? loadedValue;
105
108
  const fieldErrors = useMemo(() => (errorState && errorState.entryId === currentId ? errorState.errors : {}), [errorState, currentId]);
106
- // Number of draft entries that differ from their loaded server value.
109
+ // Server values read for restored drafts of unopened entries that differ from
110
+ // the draft. Not `loadedValues`: that map gates the entry load and OCC token.
111
+ const [verifiedBaselines, setVerifiedBaselines] = useState({});
112
+ // Ids whose verification read failed: still dirty, but no longer awaited.
113
+ const [unreadableIds, setUnreadableIds] = useState(() => new Set());
114
+ const requestedIdsRef = useRef(new Set());
115
+ const verifyQueueRef = useRef([]);
116
+ const verifyActiveRef = useRef(0);
117
+ const verifyWaitersRef = useRef([]);
118
+ const entryById = useMemo(() => new Map(options.entries.map((e) => [e.contentId, e])), [options.entries]);
119
+ // Empty while a branch switch loads, so only a non-empty list can say an entry is gone.
120
+ const entriesKnown = options.entries.length > 0;
121
+ // The draft ids that count as unsaved work:
107
122
  //
108
- // Two intentional behaviors worth noting:
123
+ // 1. A draft with no baseline (loaded or verified value) counts as dirty: it
124
+ // cannot be proven to match the server, and counting it keeps the
125
+ // branch-switch guard from silently discarding restored drafts.
126
+ // Verification (below) removes the ones that turn out pristine.
109
127
  //
110
- // 1. A draft without a corresponding `loadedValues` entry (e.g. a localStorage-restored
111
- // draft whose entry has not been opened in this session) is counted as dirty. We
112
- // cannot prove such a draft matches server state, so we conservatively treat it
113
- // as unsaved work — this is what keeps the branch-switch guard from silently
114
- // discarding restored drafts.
128
+ // 2. A draft for an entry that no longer exists can never be loaded or saved,
129
+ // so it is not counted. It stays in storage.
115
130
  //
116
- // 2. The comparison uses `fast-deep-equal`, a value-based deep equality
117
- // check -- not property-order sensitive the way `JSON.stringify`
118
- // comparison was. A rehydrated draft whose keys were serialized in a
119
- // different order than the server-loaded object no longer shows as
120
- // dirty when the values are semantically identical.
121
- const modifiedCount = useMemo(() => Object.keys(drafts).filter((id) => !loadedValues[id] || !equal(drafts[id], loadedValues[id]))
122
- .length, [drafts, loadedValues]);
123
- const editedFiles = useMemo(() => {
124
- const draftIds = Object.keys(drafts);
125
- if (draftIds.length === 0)
126
- return [];
127
- return draftIds
128
- .map((id) => {
129
- const entry = options.entries.find((e) => e.contentId === id);
130
- return entry ? { path: entry.path, label: entry.label } : null;
131
- })
132
- .filter((x) => x !== null);
133
- }, [drafts, options.entries]);
131
+ // 3. The comparison is `fast-deep-equal`, so key insertion order of a
132
+ // rehydrated draft does not make it dirty.
133
+ const dirtyIds = useMemo(() => Object.keys(drafts).filter((id) => {
134
+ if (entriesKnown && !entryById.has(id))
135
+ return false;
136
+ const baseline = loadedValues[id] ?? verifiedBaselines[id];
137
+ return baseline === undefined || !equal(drafts[id], baseline);
138
+ }), [drafts, loadedValues, verifiedBaselines, entriesKnown, entryById]);
139
+ const modifiedCount = dirtyIds.length;
140
+ const editedFiles = useMemo(() => dirtyIds
141
+ .map((id) => {
142
+ const entry = entryById.get(id);
143
+ return entry ? { path: entry.path, label: entry.label } : null;
144
+ })
145
+ .filter((x) => x !== null), [dirtyIds, entryById]);
146
+ // Draft ids verification can still settle.
147
+ const unresolvedIds = useMemo(() => options.readEntryValue && entriesKnown
148
+ ? Object.keys(drafts).filter((id) => entryById.has(id) &&
149
+ loadedValues[id] === undefined &&
150
+ verifiedBaselines[id] === undefined &&
151
+ !unreadableIds.has(id))
152
+ : [], [
153
+ drafts,
154
+ loadedValues,
155
+ verifiedBaselines,
156
+ unreadableIds,
157
+ entriesKnown,
158
+ entryById,
159
+ options.readEntryValue,
160
+ ]);
161
+ // Async callbacks outlive the render that created them; these give them current values.
162
+ const latestRef = useRef({ drafts, loadedValues, unresolvedCount: 0, modifiedCount, editedFiles });
163
+ latestRef.current = {
164
+ drafts,
165
+ loadedValues,
166
+ unresolvedCount: unresolvedIds.length,
167
+ modifiedCount,
168
+ editedFiles,
169
+ };
170
+ const latestOptionsRef = useRef(options);
171
+ latestOptionsRef.current = options;
134
172
  // Clear drafts when branch changes (before localStorage restore)
135
173
  const prevBranchRef = useRef(options.branchName);
136
174
  useEffect(() => {
137
175
  if (prevBranchRef.current && prevBranchRef.current !== options.branchName) {
138
176
  setDrafts({});
139
177
  setLoadedValues({});
178
+ setVerifiedBaselines({});
179
+ setUnreadableIds(new Set());
180
+ requestedIdsRef.current = new Set();
181
+ verifyQueueRef.current = [];
140
182
  // Base versions are file mtimes, i.e. inherently per-branch -- carrying
141
183
  // them across a switch would compare one branch's version against
142
184
  // another's.
@@ -261,6 +303,102 @@ export function useDraftManager(options) {
261
303
  console.warn('Failed to persist drafts', err);
262
304
  }
263
305
  }, [drafts, storageKey]);
306
+ // A draft equal to its entry's loaded value is not an edit. Drops it so it
307
+ // neither counts as dirty nor lingers in storage.
308
+ useEffect(() => {
309
+ const redundant = Object.keys(drafts).filter((id) => loadedValues[id] !== undefined && equal(drafts[id], loadedValues[id]));
310
+ if (redundant.length === 0)
311
+ return;
312
+ setDrafts((prev) => {
313
+ const next = { ...prev };
314
+ let changed = false;
315
+ for (const id of redundant) {
316
+ if (id in next && equal(next[id], loadedValues[id])) {
317
+ delete next[id];
318
+ changed = true;
319
+ }
320
+ }
321
+ return changed ? next : prev;
322
+ });
323
+ }, [drafts, loadedValues]);
324
+ // Checks one restored draft against the server. The branch is re-read at
325
+ // settle time: a switch mid-read makes the result another branch's value.
326
+ const verifyDraft = async (job) => {
327
+ const id = job.entry.contentId;
328
+ const read = latestOptionsRef.current.readEntryValue;
329
+ if (!read)
330
+ return;
331
+ try {
332
+ const server = await read(job.entry);
333
+ if (latestOptionsRef.current.branchName !== job.branch)
334
+ return;
335
+ const { drafts: latestDrafts, loadedValues: latestLoaded } = latestRef.current;
336
+ // Gone, or the entry's own load has taken over as the baseline.
337
+ if (latestDrafts[id] === undefined || latestLoaded[id] !== undefined)
338
+ return;
339
+ if (equal(latestDrafts[id], server)) {
340
+ setDrafts((prev) => {
341
+ if (!(id in prev) || !equal(prev[id], server))
342
+ return prev;
343
+ const next = { ...prev };
344
+ delete next[id];
345
+ return next;
346
+ });
347
+ }
348
+ else {
349
+ setVerifiedBaselines((prev) => ({ ...prev, [id]: server }));
350
+ }
351
+ }
352
+ catch {
353
+ // Stays unverified, so the draft keeps counting as dirty.
354
+ if (latestOptionsRef.current.branchName !== job.branch)
355
+ return;
356
+ setUnreadableIds((prev) => new Set(prev).add(id));
357
+ }
358
+ };
359
+ const pumpVerification = () => {
360
+ while (verifyActiveRef.current < VERIFY_CONCURRENCY && verifyQueueRef.current.length > 0) {
361
+ const job = verifyQueueRef.current.shift();
362
+ if (!job)
363
+ break;
364
+ verifyActiveRef.current++;
365
+ void verifyDraft(job).finally(() => {
366
+ verifyActiveRef.current--;
367
+ pumpVerification();
368
+ });
369
+ }
370
+ };
371
+ // Once per branch session, reads the server value of every restored draft
372
+ // whose entry has not loaded. The selected entry is left to its own load,
373
+ // which fills `loadedValues` and lets the effect above drop a pristine draft.
374
+ useEffect(() => {
375
+ if (!options.readEntryValue || !entriesKnown)
376
+ return;
377
+ // `drafts` lags a branch switch by a render (see draftsStorageKeyRef); reading
378
+ // the old branch's drafts under the new branch's name would be wrong.
379
+ if (draftsStorageKeyRef.current !== storageKey)
380
+ return;
381
+ const todo = unresolvedIds.filter((id) => id !== currentId && !requestedIdsRef.current.has(id));
382
+ if (todo.length === 0)
383
+ return;
384
+ for (const id of todo) {
385
+ requestedIdsRef.current.add(id);
386
+ const entry = entryById.get(id);
387
+ if (entry)
388
+ verifyQueueRef.current.push({ entry, branch: options.branchName });
389
+ }
390
+ pumpVerification();
391
+ // pumpVerification/verifyDraft only touch refs and stable setters.
392
+ }, [unresolvedIds, currentId, storageKey]);
393
+ // Wakes `whenDraftsVerified` callers once nothing is left to settle.
394
+ useEffect(() => {
395
+ if (unresolvedIds.length > 0)
396
+ return;
397
+ const waiters = verifyWaitersRef.current;
398
+ verifyWaitersRef.current = [];
399
+ for (const wake of waiters)
400
+ wake();
401
+ }, [unresolvedIds]);
264
402
  // Recomputes field errors as the user edits, or as the selected entry's
265
403
  // schema/format changes while it stays open, so each error clears when its
266
404
  // field is fixed or no longer required. Errors for a different entry are
@@ -316,7 +454,8 @@ export function useDraftManager(options) {
316
454
  return 'ok';
317
455
  const currentVersion = options.getEntryVersion?.(currentId);
318
456
  // No server version known for this entry at all -- nothing to compare
319
- // against, so this check has no opinion (the server's 409 still applies).
457
+ // against, so this check has no opinion: saveEntry refuses a save without
458
+ // a token, and the server refuses a version-less update.
320
459
  if (currentVersion === undefined)
321
460
  return 'ok';
322
461
  const base = draftBaseVersionsRef.current[currentId];
@@ -406,9 +545,8 @@ export function useDraftManager(options) {
406
545
  // to become `saved` below, so removing the draft key is a no-op for
407
546
  // the rendered value while fixing the "phantom dirty" bug: a draft
408
547
  // that lingers forever is what made every fresh page load show Save
409
- // enabled with zero real edits (see modifiedCount's doc comment above
410
- // — a draft without a matching loadedValues entry is conservatively
411
- // treated as dirty).
548
+ // enabled with zero real edits (see `dirtyIds` above: a draft with no
549
+ // baseline is conservatively treated as dirty).
412
550
  setDrafts((prev) => {
413
551
  if (!(currentId in prev))
414
552
  return prev;
@@ -627,7 +765,8 @@ export function useDraftManager(options) {
627
765
  const id = entry.contentId;
628
766
  if (!drafts[id])
629
767
  return false;
630
- return !loadedValues[id] || !equal(drafts[id], loadedValues[id]);
768
+ const baseline = loadedValues[id] ?? verifiedBaselines[id];
769
+ return baseline === undefined || !equal(drafts[id], baseline);
631
770
  };
632
771
  const isSelectedDirty = () => {
633
772
  if (!currentId)
@@ -636,13 +775,29 @@ export function useDraftManager(options) {
636
775
  return false;
637
776
  return !loadedValues[currentId] || !equal(drafts[currentId], loadedValues[currentId]);
638
777
  };
639
- // Returns true if ANY draft entry differs from its loaded value.
640
- //
641
- // Used for branch-switch guards so unsaved work in non-selected entries is not
642
- // silently discarded. Derived from `modifiedCount`, so its semantics note
643
- // above also applies: localStorage-restored drafts without a loaded value
644
- // count as dirty.
778
+ // True if ANY counted draft is dirty (see `dirtyIds`). Used for branch-level
779
+ // guards so unsaved work in non-selected entries is not silently discarded.
645
780
  const isAnyDirty = () => modifiedCount > 0;
781
+ const whenDraftsVerified = () => {
782
+ if (latestRef.current.unresolvedCount === 0)
783
+ return Promise.resolve();
784
+ return new Promise((resolve) => {
785
+ const wake = () => {
786
+ clearTimeout(timer);
787
+ resolve();
788
+ };
789
+ const timer = setTimeout(() => {
790
+ verifyWaitersRef.current = verifyWaitersRef.current.filter((w) => w !== wake);
791
+ resolve();
792
+ }, VERIFY_WAIT_CAP_MS);
793
+ verifyWaitersRef.current.push(wake);
794
+ });
795
+ };
796
+ const resolveUnsaved = async () => {
797
+ await whenDraftsVerified();
798
+ const { modifiedCount: count, editedFiles: files } = latestRef.current;
799
+ return { count, labels: files.map((f) => f.label) };
800
+ };
646
801
  return {
647
802
  drafts,
648
803
  setDrafts,
@@ -660,6 +815,8 @@ export function useDraftManager(options) {
660
815
  isDirtyForEntry,
661
816
  isSelectedDirty,
662
817
  isAnyDirty,
818
+ whenDraftsVerified,
819
+ resolveUnsaved,
663
820
  fieldErrors,
664
821
  };
665
822
  }
@@ -40,7 +40,7 @@ export async function listAllEntries(apiClient, branch) {
40
40
  ...(cursor !== undefined ? { cursor } : {}),
41
41
  });
42
42
  if (!result.ok || !result.data)
43
- throw new Error(`Refresh failed: ${result.status}`);
43
+ throw new Error(`Refresh failed: ${result.status}${result.error ? ` — ${result.error}` : ''}`);
44
44
  const data = result.data;
45
45
  for (const entry of data.entries)
46
46
  byPath.set(entry.logicalPath, entry);
@@ -60,7 +60,7 @@ export async function fetchEntriesAndSchema(apiClient, branch, params) {
60
60
  // Fetch schema from schema API
61
61
  const schemaResult = await apiClient.schema.get({ branch });
62
62
  if (!schemaResult.ok || !schemaResult.data) {
63
- throw new Error(`Schema fetch failed: ${schemaResult.status}`);
63
+ throw new Error(`Schema fetch failed: ${schemaResult.status}${schemaResult.error ? ` — ${schemaResult.error}` : ''}`);
64
64
  }
65
65
  // Hydrate wire flatSchema: resolve schemaRef -> schema from entrySchemas dict
66
66
  const { entrySchemas } = schemaResult.data;
@@ -85,7 +85,7 @@ export async function fetchEntriesAndSchema(apiClient, branch, params) {
85
85
  entries: allEntries,
86
86
  pagination: { hasMore: false, limit: ENTRIES_PAGE_LIMIT },
87
87
  },
88
- resolvePreviewSrc: (entry) => params.resolvePreviewSrc(entry) ?? '',
88
+ resolvePreviewSrc: params.resolvePreviewSrc,
89
89
  flatSchema: hydratedFlatSchema,
90
90
  });
91
91
  return { collections, entries, availableSchemas: Object.keys(entrySchemas) };
@@ -20,7 +20,6 @@ export interface UseEntryManagerOptions {
20
20
  initialSelectedId?: string;
21
21
  branchName: string;
22
22
  collections?: EditorCollection[];
23
- previewBaseByCollection?: Record<string, string>;
24
23
  resolvePreviewSrc: (entry: Partial<EditorEntry>) => string | undefined;
25
24
  setBusy: (busy: boolean) => void;
26
25
  }
@@ -46,6 +45,8 @@ export interface UseEntryManagerReturn {
46
45
  handleCreateEntry: (collectionPath: LogicalPath, entryTypeName?: string) => Promise<void>;
47
46
  renameEntry: (path: string, newSlug: string) => Promise<void>;
48
47
  loadEntry: (entry: EditorEntry) => Promise<FormValue>;
48
+ /** Reads an entry's server value without recording the OCC token that `loadEntry` records. */
49
+ readEntryValue: (entry: EditorEntry) => Promise<FormValue>;
49
50
  saveEntry: (entry: EditorEntry, value: FormValue) => Promise<FormValue>;
50
51
  /**
51
52
  * The OCC version token currently held for `contentId` ON THE BRANCH BEING
@@ -50,11 +50,9 @@ export function useEntryManager(options) {
50
50
  // case -- any first visit to a branch, for the whole duration of its fetch),
51
51
  // or a stale SWR slot. The editor then auto-selected one of those stale
52
52
  // entries (see the selection effect below), and a save could file its OCC
53
- // token under one contentId and look it up under another, silently skipping
54
- // conflict detection -- `content-store.ts` only compares mtimes when
55
- // `expectedVersion !== undefined`, so the write became a blind overwrite.
56
- // Deriving from a stamped record fixes that structurally instead of relying
57
- // on every code path remembering to clear.
53
+ // token under one contentId and look it up under another. Deriving from a
54
+ // stamped record fixes that structurally instead of relying on every code
55
+ // path remembering to clear.
58
56
  const [view, setView] = useState(() => ({
59
57
  branch: options.branchName,
60
58
  entries: options.initialEntries,
@@ -86,6 +84,9 @@ export function useEntryManager(options) {
86
84
  // branch can repopulate the map after the branch-change clear() below and
87
85
  // poison the next save with the old branch's mtime — a deterministic 409
88
86
  // ("modified by another editor") on save-after-switch, proven by e2e trace.
87
+ // The contentId half matches how useDraftManager keys drafts and loaded
88
+ // values, so a form value and its token always belong to the same file even
89
+ // when a path is deleted and recreated; it also survives a rename.
89
90
  const entryVersionsRef = useRef(new Map());
90
91
  const versionKey = (branch, contentId) => `${branch}:${contentId}`;
91
92
  // PER-BRANCH monotonic tokens guarding every commit of the fetched
@@ -177,14 +178,11 @@ export function useEntryManager(options) {
177
178
  .filter((e) => e.collectionPath === createModalCollection.path && e.slug)
178
179
  .map((e) => e.slug));
179
180
  }, [entriesState, createModalCollection]);
180
- const loadEntry = async (entry) => {
181
+ // The read both entry paths share; returns the raw payload so `loadEntry` can take its version.
182
+ const readEntryPayload = async (entry, requestBranch) => {
181
183
  if (!entry.collectionPath) {
182
184
  throw new Error('Entry missing collectionPath');
183
185
  }
184
- // Pin the branch this request targets: if the user switches branches
185
- // while the read is in flight, the token must be recorded under the
186
- // branch that actually served it, not the current one.
187
- const requestBranch = options.branchName;
188
186
  // Build path from collectionPath and slug (if it's a collection entry)
189
187
  const path = entry.slug ? `${entry.collectionPath}/${entry.slug}` : entry.collectionPath;
190
188
  const result = await apiClient.content.read({
@@ -192,13 +190,24 @@ export function useEntryManager(options) {
192
190
  path,
193
191
  });
194
192
  if (!result.ok)
195
- throw new Error(`Load failed: ${result.status}`);
193
+ throw new Error(`Load failed: ${result.status}${result.error ? ` — ${result.error}` : ''}`);
194
+ return result.data;
195
+ };
196
+ const loadEntry = async (entry) => {
197
+ // Pin the branch this request targets: if the user switches branches
198
+ // while the read is in flight, the token must be recorded under the
199
+ // branch that actually served it, not the current one.
200
+ const requestBranch = options.branchName;
201
+ const data = await readEntryPayload(entry, requestBranch);
196
202
  // Capture OCC version token for next save
197
- if (entry.contentId && typeof result.data?.version === 'number') {
198
- entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
203
+ if (entry.contentId && typeof data?.version === 'number') {
204
+ entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), data.version);
199
205
  }
200
- return normalizeContentPayload(result.data);
206
+ return normalizeContentPayload(data);
201
207
  };
208
+ // The same read as `loadEntry`, minus the token capture. A value read this way can be
209
+ // compared against but never saved against: only `loadEntry` makes an entry saveable.
210
+ const readEntryValue = async (entry) => normalizeContentPayload(await readEntryPayload(entry, options.branchName));
202
211
  // Read side of the same branch-qualified token map `saveEntry` writes/reads.
203
212
  // Deliberately keyed off the CURRENT branch (not a pinned one): callers use
204
213
  // it to reason about the entry they are looking at right now.
@@ -220,18 +229,23 @@ export function useEntryManager(options) {
220
229
  };
221
230
  if (entry.entryType)
222
231
  writeParams.entryType = entry.entryType;
223
- const expectedVersion = entry.contentId
224
- ? entryVersionsRef.current.get(versionKey(requestBranch, entry.contentId))
225
- : undefined;
232
+ const expectedVersion = entryVersionsRef.current.get(versionKey(requestBranch, entry.contentId));
233
+ // Every entry saved here already exists (creates go through handleCreateModalSubmit), so
234
+ // no token means this entry was never successfully read on this branch and nothing could check the save
235
+ // against other editors' work. Refused as a conflict; the server refuses it too.
236
+ if (expectedVersion === undefined) {
237
+ throw new SaveApiError(409, 'This entry has not been loaded from the server, so the save cannot be checked ' +
238
+ "against other editors' changes. Reload it and try again.");
239
+ }
226
240
  const writeBody = {
227
241
  ...payload,
228
- ...(expectedVersion !== undefined ? { expectedVersion } : {}),
242
+ expectedVersion,
229
243
  };
230
244
  const result = await apiClient.content.write(writeParams, writeBody);
231
245
  if (!result.ok)
232
246
  throw new SaveApiError(result.status, result.error, result.fieldErrors);
233
247
  // Update stored version token from write response
234
- if (entry.contentId && typeof result.data?.version === 'number') {
248
+ if (typeof result.data?.version === 'number') {
235
249
  entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
236
250
  }
237
251
  // Warning-level issues from the adopter's validateEntry hook: saved, but surface them
@@ -558,6 +572,7 @@ export function useEntryManager(options) {
558
572
  handleCreateEntry,
559
573
  renameEntry,
560
574
  loadEntry,
575
+ readEntryValue,
561
576
  saveEntry,
562
577
  getEntryVersion,
563
578
  collectionByPath: collectionByPath,
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import type { AssetRecord } from '../../api/index.js';
3
3
  export interface AssetCardProps {
4
4
  asset: AssetRecord;
5
- /** `media.publicBaseUrl` - see AssetContext. */
5
+ /** `useAssetContext().baseUrl`, the authenticated raw route - see AssetContext. */
6
6
  baseUrl?: string;
7
7
  /** Present in picker mode - clicking the card selects the asset. */
8
8
  onSelect?: (asset: AssetRecord) => void;
@@ -16,10 +16,9 @@ export interface CropAreaPercent {
16
16
  }
17
17
  /**
18
18
  * Convert react-easy-crop's `onCropComplete(croppedArea, croppedAreaPixels)`
19
- * percentage area into a normalized `CropRect`, rounded to 4 decimals and
20
- * clamped to fit within bounds. Rounding can push `x+w`/`y+h` a hair over 1
21
- * (e.g. x=0.6667, w=0.3334 -> 1.0001) - shrink the extent to the remaining
22
- * space rather than reject a rect that was valid before rounding.
19
+ * percentage area into a normalized `CropRect`, clamped to [0,1] and then
20
+ * rounded by `roundCropRect` — the same rounding the transform URL's canonical
21
+ * form applies, so the stored crop is already canonical.
23
22
  *
24
23
  * Returns `null` if the input can't be coerced into a valid rect (e.g. a
25
24
  * zero-area selection).