canopycms 0.0.67 → 0.0.68-int.100

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 (268) hide show
  1. package/README.md +21 -14
  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 +111 -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 +10 -1
  23. package/dist/api/github-sync.js +20 -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/index.d.ts +1 -1
  36. package/dist/assets/materialize.d.ts +122 -0
  37. package/dist/assets/materialize.js +305 -0
  38. package/dist/assets/store-local.d.ts +4 -0
  39. package/dist/assets/store-local.js +11 -0
  40. package/dist/assets/store-s3.d.ts +10 -1
  41. package/dist/assets/store-s3.js +55 -3
  42. package/dist/assets/transform-directives.d.ts +52 -13
  43. package/dist/assets/transform-directives.js +87 -27
  44. package/dist/assets/transform.d.ts +3 -3
  45. package/dist/assets/transform.js +13 -8
  46. package/dist/assets/types.d.ts +14 -1
  47. package/dist/auth/file-based-auth-cache.js +7 -8
  48. package/dist/authorization/content.d.ts +5 -4
  49. package/dist/authorization/content.js +5 -5
  50. package/dist/authorization/path.d.ts +11 -6
  51. package/dist/authorization/path.js +11 -6
  52. package/dist/authorization/types.d.ts +2 -2
  53. package/dist/branch-health.d.ts +7 -1
  54. package/dist/branch-health.js +8 -3
  55. package/dist/branch-metadata.d.ts +5 -5
  56. package/dist/branch-metadata.js +44 -36
  57. package/dist/branch-provisioning.d.ts +167 -0
  58. package/dist/branch-provisioning.js +501 -0
  59. package/dist/branch-schema-cache.d.ts +5 -3
  60. package/dist/branch-schema-cache.js +29 -11
  61. package/dist/branch-sparse.d.ts +23 -0
  62. package/dist/branch-sparse.js +118 -0
  63. package/dist/branch-workspace.d.ts +42 -3
  64. package/dist/branch-workspace.js +240 -94
  65. package/dist/build/asset-refs.d.ts +93 -0
  66. package/dist/build/asset-refs.js +251 -0
  67. package/dist/build-identity.d.ts +3 -0
  68. package/dist/build-identity.js +13 -0
  69. package/dist/cli/asset-refs.d.ts +30 -0
  70. package/dist/cli/asset-refs.js +168 -0
  71. package/dist/cli/cli.d.ts +2 -1
  72. package/dist/cli/cli.js +4843 -1870
  73. package/dist/cli/generate-ai-content.js +1584 -444
  74. package/dist/cli/github-app-manifest.d.ts +4 -3
  75. package/dist/cli/github-app-manifest.js +4 -3
  76. package/dist/cli/init.js +30 -27
  77. package/dist/cli/migrate.js +12 -8
  78. package/dist/cli/sync.js +83 -37
  79. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  80. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  81. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  82. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  83. package/dist/config/helpers.js +1 -2
  84. package/dist/config/schemas/config.d.ts +19 -32
  85. package/dist/config/schemas/config.js +9 -11
  86. package/dist/config/schemas/field.js +1 -0
  87. package/dist/config/schemas/media.d.ts +4 -13
  88. package/dist/config/schemas/media.js +5 -8
  89. package/dist/config/schemas/url.d.ts +4 -20
  90. package/dist/config/schemas/url.js +6 -22
  91. package/dist/config/types.d.ts +30 -20
  92. package/dist/config/validation.d.ts +6 -0
  93. package/dist/config/validation.js +37 -0
  94. package/dist/content-listing.d.ts +12 -12
  95. package/dist/content-listing.js +10 -6
  96. package/dist/content-reader.d.ts +11 -2
  97. package/dist/content-reader.js +22 -14
  98. package/dist/content-store.d.ts +37 -9
  99. package/dist/content-store.js +70 -43
  100. package/dist/content-tree.d.ts +1 -1
  101. package/dist/content-tree.js +2 -2
  102. package/dist/context.d.ts +27 -9
  103. package/dist/context.js +154 -70
  104. package/dist/editor/BranchManager.d.ts +7 -1
  105. package/dist/editor/BranchManager.js +29 -14
  106. package/dist/editor/CanopyEditor.d.ts +1 -1
  107. package/dist/editor/CanopyEditor.js +2 -6
  108. package/dist/editor/Editor.d.ts +4 -3
  109. package/dist/editor/Editor.js +24 -20
  110. package/dist/editor/FormRenderer.js +34 -18
  111. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  112. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  113. package/dist/editor/components/BranchesDrawer.js +4 -0
  114. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  115. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  116. package/dist/editor/context/AssetContext.d.ts +18 -35
  117. package/dist/editor/context/AssetContext.js +20 -29
  118. package/dist/editor/context/index.d.ts +1 -1
  119. package/dist/editor/context/index.js +1 -1
  120. package/dist/editor/editor-config.d.ts +1 -2
  121. package/dist/editor/editor-config.js +0 -23
  122. package/dist/editor/editor-utils.d.ts +24 -21
  123. package/dist/editor/editor-utils.js +75 -62
  124. package/dist/editor/fields/BlockField.d.ts +1 -0
  125. package/dist/editor/fields/BlockField.js +6 -4
  126. package/dist/editor/fields/CodeField.d.ts +1 -0
  127. package/dist/editor/fields/CodeField.js +2 -2
  128. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  129. package/dist/editor/fields/DateTimeField.js +2 -2
  130. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  131. package/dist/editor/fields/FieldDescription.js +11 -0
  132. package/dist/editor/fields/ImageField.d.ts +1 -0
  133. package/dist/editor/fields/ImageField.js +3 -2
  134. package/dist/editor/fields/InlineGroupField.js +4 -1
  135. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  136. package/dist/editor/fields/MarkdownField.js +115 -20
  137. package/dist/editor/fields/NumberField.d.ts +1 -0
  138. package/dist/editor/fields/NumberField.js +2 -2
  139. package/dist/editor/fields/NumberListField.d.ts +1 -0
  140. package/dist/editor/fields/NumberListField.js +2 -2
  141. package/dist/editor/fields/ObjectField.d.ts +1 -0
  142. package/dist/editor/fields/ObjectField.js +5 -2
  143. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  144. package/dist/editor/fields/ReferenceField.js +5 -4
  145. package/dist/editor/fields/SelectField.d.ts +1 -0
  146. package/dist/editor/fields/SelectField.js +2 -2
  147. package/dist/editor/fields/StringListField.d.ts +1 -0
  148. package/dist/editor/fields/StringListField.js +2 -2
  149. package/dist/editor/fields/TextField.d.ts +1 -0
  150. package/dist/editor/fields/TextField.js +2 -2
  151. package/dist/editor/fields/ToggleField.d.ts +1 -0
  152. package/dist/editor/fields/ToggleField.js +7 -4
  153. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  154. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  155. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  156. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  157. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  158. package/dist/editor/hooks/create-branch-request.js +52 -0
  159. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  160. package/dist/editor/hooks/useBranchActions.js +48 -17
  161. package/dist/editor/hooks/useBranchManager.d.ts +11 -0
  162. package/dist/editor/hooks/useBranchManager.js +80 -2
  163. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  164. package/dist/editor/hooks/useBranchesData.js +9 -3
  165. package/dist/editor/hooks/useCommentSystem.js +25 -6
  166. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  167. package/dist/editor/hooks/useDraftManager.js +193 -36
  168. package/dist/editor/hooks/useEntriesData.js +3 -3
  169. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  170. package/dist/editor/hooks/useEntryManager.js +34 -19
  171. package/dist/editor/media/AssetCard.d.ts +1 -1
  172. package/dist/editor/media/crop-math.d.ts +3 -4
  173. package/dist/editor/media/crop-math.js +11 -22
  174. package/dist/editor/media/editor-image-src.d.ts +7 -0
  175. package/dist/editor/media/editor-image-src.js +12 -0
  176. package/dist/editor/preview-asset-base.d.ts +8 -0
  177. package/dist/editor/preview-asset-base.js +13 -0
  178. package/dist/editor/preview-bridge.d.ts +8 -1
  179. package/dist/editor/preview-bridge.js +15 -2
  180. package/dist/editor/preview-path.d.ts +10 -0
  181. package/dist/editor/preview-path.js +20 -0
  182. package/dist/editor/theme.js +2 -2
  183. package/dist/entry-schema-registry.d.ts +4 -4
  184. package/dist/entry-schema-registry.js +10 -6
  185. package/dist/entry-schema.d.ts +27 -4
  186. package/dist/entry-schema.js +20 -3
  187. package/dist/git-manager.d.ts +173 -6
  188. package/dist/git-manager.js +594 -76
  189. package/dist/github-service.d.ts +8 -0
  190. package/dist/github-service.js +5 -1
  191. package/dist/http/handler.js +37 -13
  192. package/dist/http/index.d.ts +2 -0
  193. package/dist/http/index.js +2 -0
  194. package/dist/http/router.d.ts +2 -0
  195. package/dist/http/router.js +2 -1
  196. package/dist/http/types.d.ts +2 -0
  197. package/dist/http/worker-not-ready.d.ts +10 -0
  198. package/dist/http/worker-not-ready.js +24 -0
  199. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  200. package/dist/operating-mode/types.d.ts +1 -4
  201. package/dist/paths/branch-name.d.ts +5 -0
  202. package/dist/paths/branch-name.js +9 -0
  203. package/dist/paths/branch.d.ts +6 -2
  204. package/dist/paths/branch.js +16 -10
  205. package/dist/paths/index.d.ts +3 -3
  206. package/dist/paths/index.js +3 -3
  207. package/dist/paths/normalize.d.ts +7 -0
  208. package/dist/paths/normalize.js +9 -0
  209. package/dist/paths/validation.js +0 -2
  210. package/dist/reference-resolver.d.ts +5 -21
  211. package/dist/reference-resolver.js +9 -44
  212. package/dist/resolve-canopy-user.js +2 -1
  213. package/dist/schema/schema-store.d.ts +54 -31
  214. package/dist/schema/schema-store.js +77 -31
  215. package/dist/server.d.ts +24 -9
  216. package/dist/server.js +22 -8
  217. package/dist/services.d.ts +18 -6
  218. package/dist/services.js +85 -70
  219. package/dist/settings-workspace.d.ts +23 -3
  220. package/dist/settings-workspace.js +197 -45
  221. package/dist/static/seo.d.ts +2 -14
  222. package/dist/static/seo.js +2 -25
  223. package/dist/submission-attribution.d.ts +73 -0
  224. package/dist/submission-attribution.js +221 -0
  225. package/dist/sync-core.d.ts +12 -1
  226. package/dist/sync-core.js +31 -13
  227. package/dist/task-queue/worker-status.d.ts +8 -0
  228. package/dist/task-queue/worker-status.js +17 -0
  229. package/dist/types.d.ts +31 -2
  230. package/dist/utils/content-write-lock.d.ts +18 -7
  231. package/dist/utils/content-write-lock.js +18 -9
  232. package/dist/utils/debug.d.ts +8 -0
  233. package/dist/utils/debug.js +10 -2
  234. package/dist/utils/git.d.ts +32 -0
  235. package/dist/utils/git.js +42 -0
  236. package/dist/utils/occ-json-write.js +10 -2
  237. package/dist/utils/provision-log.d.ts +25 -0
  238. package/dist/utils/provision-log.js +49 -0
  239. package/dist/utils/provisioning-lock.d.ts +35 -5
  240. package/dist/utils/provisioning-lock.js +73 -12
  241. package/dist/utils/request-timing.d.ts +25 -0
  242. package/dist/utils/request-timing.js +101 -0
  243. package/dist/utils/sanitize-href.d.ts +8 -22
  244. package/dist/utils/sanitize-href.js +11 -28
  245. package/dist/utils/url-prefix.d.ts +30 -0
  246. package/dist/utils/url-prefix.js +61 -0
  247. package/dist/validation/entry-validator.js +9 -3
  248. package/dist/version.d.ts +1 -0
  249. package/dist/version.js +3 -0
  250. package/dist/worker/canopy-state.d.ts +45 -0
  251. package/dist/worker/canopy-state.js +75 -0
  252. package/dist/worker/cms-worker.d.ts +8 -3
  253. package/dist/worker/cms-worker.js +38 -2
  254. package/dist/worker/git-sync.d.ts +30 -12
  255. package/dist/worker/git-sync.js +253 -58
  256. package/dist/worker/history-rewrite.d.ts +1 -1
  257. package/dist/worker/history-rewrite.js +1 -1
  258. package/dist/worker/provisioned-workspace.d.ts +35 -0
  259. package/dist/worker/provisioned-workspace.js +50 -0
  260. package/dist/worker/rebase.d.ts +1 -1
  261. package/dist/worker/rebase.js +95 -23
  262. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  263. package/dist/worker/remote-git-maintenance.js +11 -0
  264. package/dist/worker/sparse-cone.d.ts +20 -0
  265. package/dist/worker/sparse-cone.js +132 -0
  266. package/dist/worker/task-runner.js +39 -4
  267. package/dist/worker/worker-context.d.ts +6 -1
  268. package/package.json +2 -1
@@ -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).
@@ -6,13 +6,7 @@
6
6
  * (no react-easy-crop import) so it's trivially unit-testable and reusable
7
7
  * without pulling in the cropper UI library.
8
8
  */
9
- import { isValidCropRect } from '../../assets/transform-directives.js';
10
- /** Matches transform-directives.ts's CROP_PRECISION so a crop rect round-trips through the URL directive unchanged. */
11
- const CROP_PRECISION = 4;
12
- function roundTo(value, decimals) {
13
- const factor = 10 ** decimals;
14
- return Math.round(value * factor) / factor;
15
- }
9
+ import { isValidCropRect, roundCropRect } from '../../assets/transform-directives.js';
16
10
  function clamp01(value) {
17
11
  if (!Number.isFinite(value))
18
12
  return 0;
@@ -20,26 +14,21 @@ function clamp01(value) {
20
14
  }
21
15
  /**
22
16
  * Convert react-easy-crop's `onCropComplete(croppedArea, croppedAreaPixels)`
23
- * percentage area into a normalized `CropRect`, rounded to 4 decimals and
24
- * clamped to fit within bounds. Rounding can push `x+w`/`y+h` a hair over 1
25
- * (e.g. x=0.6667, w=0.3334 -> 1.0001) - shrink the extent to the remaining
26
- * space rather than reject a rect that was valid before rounding.
17
+ * percentage area into a normalized `CropRect`, clamped to [0,1] and then
18
+ * rounded by `roundCropRect` — the same rounding the transform URL's canonical
19
+ * form applies, so the stored crop is already canonical.
27
20
  *
28
21
  * Returns `null` if the input can't be coerced into a valid rect (e.g. a
29
22
  * zero-area selection).
30
23
  */
31
24
  export function cropAreaPercentToRect(area) {
32
- const x = roundTo(clamp01(area.x / 100), CROP_PRECISION);
33
- const y = roundTo(clamp01(area.y / 100), CROP_PRECISION);
34
- let w = roundTo(clamp01(area.width / 100), CROP_PRECISION);
35
- let h = roundTo(clamp01(area.height / 100), CROP_PRECISION);
36
- if (x + w > 1)
37
- w = roundTo(1 - x, CROP_PRECISION);
38
- if (y + h > 1)
39
- h = roundTo(1 - y, CROP_PRECISION);
40
- if (!isValidCropRect(x, y, w, h))
41
- return null;
42
- return { x, y, w, h };
25
+ const rect = roundCropRect({
26
+ x: clamp01(area.x / 100),
27
+ y: clamp01(area.y / 100),
28
+ w: clamp01(area.width / 100),
29
+ h: clamp01(area.height / 100),
30
+ });
31
+ return isValidCropRect(rect.x, rect.y, rect.w, rect.h) ? rect : null;
43
32
  }
44
33
  /**
45
34
  * Inverse of `cropAreaPercentToRect` - seeds react-easy-crop's
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The URL the editor displays a content image at. A src in canopycms's own `/assets` space is put
3
+ * behind `baseUrl` (the authenticated raw route, see `AssetContext`); any other src - a site's own
4
+ * static file, an off-site URL, a `data:` URI - is shown exactly as written, because the raw route
5
+ * serves only the asset store.
6
+ */
7
+ export declare function editorImageSrc(src: string, baseUrl: string): string;
@@ -0,0 +1,12 @@
1
+ import { assetUrl } from '../../assets/asset-url.js';
2
+ import { ASSET_PREFIXES } from '../../assets/asset-prefixes.js';
3
+ const ASSET_SPACE = `/${ASSET_PREFIXES.public}/`;
4
+ /**
5
+ * The URL the editor displays a content image at. A src in canopycms's own `/assets` space is put
6
+ * behind `baseUrl` (the authenticated raw route, see `AssetContext`); any other src - a site's own
7
+ * static file, an off-site URL, a `data:` URI - is shown exactly as written, because the raw route
8
+ * serves only the asset store.
9
+ */
10
+ export function editorImageSrc(src, baseUrl) {
11
+ return src.startsWith(ASSET_SPACE) ? assetUrl({ src }, { baseUrl }) : src;
12
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The asset prefix a framing editor hands its live preview, held for `assets/asset-url.ts`. Its
3
+ * only writer outside tests is `usePreviewData`, on a trusted draft message; the read is gated on `window` too,
4
+ * because `assetUrl` also runs in server renders and static builds. Imports nothing.
5
+ */
6
+ export declare function setPreviewAssetBase(base: string | undefined): void;
7
+ /** The stored prefix in a browser; always `undefined` on the server. */
8
+ export declare function getPreviewAssetBase(): string | undefined;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The asset prefix a framing editor hands its live preview, held for `assets/asset-url.ts`. Its
3
+ * only writer outside tests is `usePreviewData`, on a trusted draft message; the read is gated on `window` too,
4
+ * because `assetUrl` also runs in server renders and static builds. Imports nothing.
5
+ */
6
+ let previewAssetBase;
7
+ export function setPreviewAssetBase(base) {
8
+ previewAssetBase = base;
9
+ }
10
+ /** The stored prefix in a browser; always `undefined` on the server. */
11
+ export function getPreviewAssetBase() {
12
+ return typeof window === 'undefined' ? undefined : previewAssetBase;
13
+ }
@@ -11,6 +11,8 @@ export interface DraftUpdateMessage {
11
11
  path: string;
12
12
  data?: unknown;
13
13
  isLoading?: unknown;
14
+ /** The editor's authenticated asset route; see `PreviewFrame`'s `assetBase`. */
15
+ assetBase?: unknown;
14
16
  }
15
17
  /**
16
18
  * Resolve a (possibly relative) URL to an origin for postMessage targeting.
@@ -92,7 +94,7 @@ export declare const usePreviewFocusEmitter: (entryPath: string, opts?: {
92
94
  * Lightweight iframe wrapper to keep the preview in sync with form state.
93
95
  * It posts the latest draft data to the iframe after load and when data changes.
94
96
  */
95
- export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, }: {
97
+ export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, assetBase, }: {
96
98
  src: string;
97
99
  path: string;
98
100
  data?: unknown;
@@ -100,6 +102,11 @@ export declare const PreviewFrame: ({ src, path, data, isLoading, className, sty
100
102
  className?: string;
101
103
  style?: CSSProperties;
102
104
  highlightEnabled?: boolean;
105
+ /**
106
+ * The editor's authenticated asset route, a same-origin path, so it is sent with drafts only to
107
+ * a same-origin preview: another origin would resolve it against itself.
108
+ */
109
+ assetBase?: string;
103
110
  /** Called when the preview reports a draft compile/render error; null clears it. */
104
111
  onPreviewError?: (error: {
105
112
  message: string;