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
@@ -10,6 +10,7 @@ import { PreviewFrame } from './preview-bridge.js';
10
10
  import { EditorPanes } from './EditorPanes.js';
11
11
  import { CanopyCMSProvider } from './theme.js';
12
12
  import { BranchManager } from './BranchManager.js';
13
+ import { BranchesDrawer } from './components/BranchesDrawer.js';
13
14
  import { CommentsPanel } from './CommentsPanel.js';
14
15
  import { GroupManager } from './GroupManager.js';
15
16
  import { PermissionManager } from './PermissionManager.js';
@@ -24,15 +25,16 @@ import { EditorFooter, EditorHeader, EditorSidebar } from './components/index.js
24
25
  import { RenameEntryModal } from './components/RenameEntryModal.js';
25
26
  import { EntryCreateModal } from './components/EntryCreateModal.js';
26
27
  import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
28
+ import { NoEditPermissionNotice } from './components/NoEditPermissionNotice.js';
27
29
  import { CollectionEditor } from './schema-editor/index.js';
28
- import { AssetContextProvider, useApiClient } from './context/index.js';
30
+ import { AssetContextProvider, authenticatedAssetBase, useApiClient } from './context/index.js';
29
31
  import { EntryLinkContext } from './fields/entry-link/index.js';
30
32
  import { MediaLibrary } from './media/MediaLibrary.js';
31
33
  /**
32
34
  * High-level editor wrapper that wires entry navigation, form rendering,
33
35
  * saving/loading, and preview rendering using entry definitions.
34
36
  */
35
- export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtitle = subtitle, branchName = '', collections, contentRoot, entryLinkUrl, initialSelectedId, initialValues, renderPreview, onCreateEntry, themeOptions, operatingMode, previewBaseByCollection, currentUser = 'current-user', canResolveComments = true, assetBaseUrl, basePath, AccountComponent, onAccountClick, onLogoutClick, customRenderers, }) => {
37
+ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtitle = subtitle, branchName = '', collections, contentRoot, entryLinkUrl, initialSelectedId, initialValues, renderPreview, onCreateEntry, themeOptions, operatingMode, previewBaseByCollection, previewPrefix, currentUser = 'current-user', canResolveComments = true, basePath, AccountComponent, onAccountClick, onLogoutClick, customRenderers, }) => {
36
38
  const [branchesLoading, setBranchesLoading] = useState(false);
37
39
  const [entriesLoading, setEntriesLoading] = useState(false);
38
40
  const [commentsLoading] = useState(false);
@@ -70,7 +72,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
70
72
  // Comments state (shared between useCommentSystem and useBranchManager)
71
73
  const [commentsForBranchSummaries, setCommentsForBranchSummaries] = useState([]);
72
74
  // 1. Branch manager (provides branchNameState, no dependencies)
73
- const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
75
+ const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
74
76
  initialBranch: branchName,
75
77
  operatingMode,
76
78
  setBusy: setBranchesLoading,
@@ -89,15 +91,15 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
89
91
  // loading state during that window anyway.
90
92
  const branchContentLocked = currentBranch?.writeBlocked ?? true;
91
93
  // 2. Entry manager (depends on branchNameState, owns selectedPath)
92
- const { selectedPath, setSelectedPath, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, navigatorOpen, setNavigatorOpen, refreshEntries, handleCreateEntry, renameEntry, loadEntry, saveEntry, getEntryVersion, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
94
+ const { selectedPath, setSelectedPath, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, navigatorOpen, setNavigatorOpen, refreshEntries, handleCreateEntry, renameEntry, loadEntry, readEntryValue, saveEntry, getEntryVersion, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
93
95
  initialEntries: entries,
94
96
  initialSelectedId,
95
97
  branchName: branchNameState,
96
98
  collections,
97
- previewBaseByCollection,
98
99
  resolvePreviewSrc: (entry) => buildPreviewSrc(entry, {
99
100
  branchName: branchNameState,
100
101
  previewBaseByCollection,
102
+ previewPrefix,
101
103
  contentRoot,
102
104
  basePath,
103
105
  }),
@@ -125,13 +127,14 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
125
127
  setPreviewLoadingState({});
126
128
  }, [currentEntry?.contentId]);
127
129
  // 3. Draft manager (depends on branchNameState, selectedPath from useEntryManager)
128
- const { setDrafts, loadedValues, setLoadedValues, effectiveValue, modifiedCount, editedFiles, handleSave, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, isAnyDirty, fieldErrors, } = useDraftManager({
130
+ const { setDrafts, loadedValues, setLoadedValues, effectiveValue, modifiedCount, editedFiles, handleSave, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, resolveUnsaved, fieldErrors, } = useDraftManager({
129
131
  branchName: branchNameState,
130
132
  selectedPath,
131
133
  currentEntry,
132
134
  entries: entriesState,
133
135
  initialValues,
134
136
  loadEntry,
137
+ readEntryValue,
135
138
  saveEntry,
136
139
  getEntryVersion,
137
140
  setBusy: setEntriesLoading,
@@ -139,12 +142,14 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
139
142
  void refreshEntries(branchNameState).catch((err) => console.error('Failed to refresh entries after save', err));
140
143
  },
141
144
  });
142
- // 4. Branch actions (depends on isAnyDirty, setBranchName)
143
- const { handleBranchChange, handleCreateBranch } = useBranchActions({
145
+ // 4. Branch actions (depends on resolveUnsaved, setBranchName)
146
+ const { handleBranchChange, confirmCreate: confirmCreateBranch, handleCreateBranch, confirmOpen: branchConfirmOpen, } = useBranchActions({
144
147
  branchName: branchNameState,
145
148
  setBranchName,
146
- isAnyDirty,
149
+ getUnsaved: resolveUnsaved,
147
150
  onReloadBranches: () => loadBranches(),
151
+ onBranchCreated: addCreatedBranch,
152
+ userId: userContext?.userId,
148
153
  });
149
154
  // 5. Comment system (depends on branchNameState)
150
155
  const { comments, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, } = useCommentSystem({
@@ -224,9 +229,8 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
224
229
  // branch's content. Writing it would (a) show it as the new branch's
225
230
  // content and (b) satisfy the skip gate above, permanently suppressing
226
231
  // the new branch's own load -- which also leaves the new branch's OCC
227
- // token unset, turning the next save into a blind overwrite that can
228
- // never 409. Drop it instead; the new branch has its own load running
229
- // under its own key.
232
+ // token unset, so saveEntry refuses every save of the entry. Drop it
233
+ // instead; the new branch has its own load running under its own key.
230
234
  if (currentBranchRef.current !== requestBranch)
231
235
  return;
232
236
  setLoadedValues((prev) => ({ ...prev, [contentId]: loaded }));
@@ -599,7 +603,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
599
603
  width: '100%',
600
604
  height: '100%',
601
605
  border: '1px solid var(--mantine-color-gray-3)',
602
- }, highlightEnabled: highlightEnabled, onPreviewError: setPreviewError })] })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "white", style: {
606
+ }, highlightEnabled: highlightEnabled, onPreviewError: setPreviewError, assetBase: authenticatedAssetBase(basePath) })] })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "white", style: {
603
607
  display: 'flex',
604
608
  alignItems: 'center',
605
609
  justifyContent: 'center',
@@ -607,12 +611,14 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
607
611
  ? 'Setting up your branch workspace…'
608
612
  : entriesInitializing
609
613
  ? 'Loading content…'
610
- : 'Select an item to start editing.' }) }));
614
+ : currentEntry && !currentEntry.previewSrc
615
+ ? 'No preview for this entry.'
616
+ : 'Select an item to start editing.' }) }));
611
617
  const sidebarWidth = 64;
612
618
  const footerHeight = 40;
613
619
  const headerTitle = currentEntry?.label ?? currentEntry?.slug ?? title;
614
620
  const breadcrumbSegments = useMemo(() => buildBreadcrumbSegments(currentEntry, collectionLabels), [collectionLabels, currentEntry]);
615
- return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { baseUrl: assetBaseUrl, basePath: basePath, children: _jsxs(Box, { bg: "gray.0", style: { minHeight: '100vh', width: '100%' }, children: [_jsx(EditorHeader, { ref: headerRef, siteTitle: siteTitle, siteSubtitle: siteSubtitle, headerTitle: headerTitle, currentEntry: currentEntry, branchName: branchNameState, operatingMode: operatingMode, branchStatus: currentBranch?.status, busy: busy, breadcrumbSegments: breadcrumbSegments, editedFiles: editedFiles, modifiedCount: modifiedCount, unresolvedCommentCount: comments.filter((t) => !t.resolved).length, comments: comments, hasUnsavedChanges: isSelectedDirty(), userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
621
+ return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsxs(Box, { bg: "gray.0", style: { minHeight: '100vh', width: '100%' }, children: [_jsx(EditorHeader, { ref: headerRef, siteTitle: siteTitle, siteSubtitle: siteSubtitle, headerTitle: headerTitle, currentEntry: currentEntry, branchName: branchNameState, operatingMode: operatingMode, branchStatus: currentBranch?.status, busy: busy, breadcrumbSegments: breadcrumbSegments, editedFiles: editedFiles, modifiedCount: modifiedCount, unresolvedCommentCount: comments.filter((t) => !t.resolved).length, comments: comments, hasUnsavedChanges: isSelectedDirty(), userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
616
622
  // Fail-closed for the same reason branchContentLocked above is:
617
623
  // isProtected/writeBlocked gate real actions in EditorHeader (hiding
618
624
  // Submit, disabling Save), so a missing wire value must read as
@@ -644,7 +650,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
644
650
  ? 'Setting up your branch workspace…'
645
651
  : entriesInitializing
646
652
  ? 'Loading content…'
647
- : 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(CenteredMessage, { children: "You don't have permission to edit this content." })) : schema.length > 0 && effectiveValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(FormRenderer, { fields: schema, value: effectiveValue, onChange: (next) => {
653
+ : 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryPath: currentEntry.path })) : schema.length > 0 && effectiveValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(FormRenderer, { fields: schema, value: effectiveValue, onChange: (next) => {
648
654
  const contentId = currentEntry?.contentId;
649
655
  if (contentId) {
650
656
  setDrafts((prev) => ({ ...prev, [contentId]: next }));
@@ -662,7 +668,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
662
668
  })), selectedPath: selectedPath, onSelect: (id) => {
663
669
  setSelectedPath(id);
664
670
  setNavigatorOpen(false);
665
- }, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: handleReorderEntry, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked }) }) })] })] }), _jsx(Drawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), position: "right", title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Branches" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Manage access, status, and lifecycle" })] }), padding: "md", size: 420, overlayProps: { blur: 2 }, children: _jsx(BranchManager, { branches: branchSummaries, mode: operatingMode, user: userContext, onSelect: async (name) => {
671
+ }, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: handleReorderEntry, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked }) }) })] })] }), _jsx(BranchesDrawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), confirmOpen: branchConfirmOpen, children: _jsx(BranchManager, { branches: branchSummaries, mode: operatingMode, user: userContext, onSelect: async (name) => {
666
672
  try {
667
673
  await handleBranchChange(name);
668
674
  setBranchManagerOpen(false);
@@ -671,9 +677,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
671
677
  console.error('Branch change failed or was cancelled:', err);
672
678
  // Don't close branch manager if there was an error or user cancelled
673
679
  }
674
- }, onCreate: (branch) => {
675
- handleCreateBranch(branch).catch((err) => console.error(err));
676
- }, onSubmit: (name) => {
680
+ }, onBeforeCreate: confirmCreateBranch, onCreate: handleCreateBranch, onSubmit: (name) => {
677
681
  handleSubmit(name).catch((err) => console.error(err));
678
682
  }, onWithdraw: (name) => {
679
683
  handleWithdraw(name).catch((err) => console.error(err));
@@ -15,11 +15,24 @@ import { ReferenceField } from './fields/ReferenceField.js';
15
15
  import { CodeField } from './fields/CodeField.js';
16
16
  import { ImageField } from './fields/ImageField.js';
17
17
  import { ObjectField } from './fields/ObjectField.js';
18
+ import { FieldDescription, groupDescriptionProps } from './fields/FieldDescription.js';
18
19
  import { InlineGroupField } from './fields/InlineGroupField.js';
19
20
  import { formatCanopyPath, normalizeCanopyPath } from './canopy-path.js';
20
21
  import { FieldWrapper } from './comments/FieldWrapper.js';
21
22
  import { EntryComments } from './comments/EntryComments.js';
22
23
  import { useReferenceResolution } from './hooks/useReferenceResolution.js';
24
+ /**
25
+ * Card heading for an object-list item: the value of its `itemTitleField` when that is a
26
+ * non-blank string or a number, otherwise `fallback`.
27
+ */
28
+ const listItemTitle = (item, itemTitleField, fallback) => {
29
+ const raw = itemTitleField ? item?.[itemTitleField] : undefined;
30
+ if (typeof raw === 'number' && Number.isFinite(raw))
31
+ return String(raw);
32
+ if (typeof raw === 'string' && raw.trim() !== '')
33
+ return raw.trim();
34
+ return fallback;
35
+ };
23
36
  const normalizeOptions = (options) => {
24
37
  if (!options)
25
38
  return [];
@@ -75,28 +88,28 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
75
88
  switch (field.type) {
76
89
  case 'string':
77
90
  if (field.list) {
78
- return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
91
+ return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
79
92
  }
80
- return wrapWithComments(_jsx(TextField, { id: fieldId, label: label, value: currentValue ?? '', onChange: update, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
93
+ return wrapWithComments(_jsx(TextField, { id: fieldId, label: label, description: field.description, value: currentValue ?? '', onChange: update, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
81
94
  case 'boolean':
82
- return wrapWithComments(_jsx(ToggleField, { id: fieldId, label: label, value: Boolean(currentValue), onChange: (v) => update(Boolean(v)), dataCanopyField: normalizeCanopyPath(path), testId: `field-toggle-${field.name}` }, fieldKey(path)));
95
+ return wrapWithComments(_jsx(ToggleField, { id: fieldId, label: label, description: field.description, value: Boolean(currentValue), onChange: (v) => update(Boolean(v)), dataCanopyField: normalizeCanopyPath(path), testId: `field-toggle-${field.name}` }, fieldKey(path)));
83
96
  case 'number':
84
97
  if (field.list) {
85
- return wrapWithComments(_jsx(NumberListField, { id: fieldId, label: label, value: Array.isArray(currentValue)
98
+ return wrapWithComments(_jsx(NumberListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue)
86
99
  ? currentValue.filter((v) => typeof v === 'number')
87
100
  : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
88
101
  }
89
- return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
102
+ return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
90
103
  case 'datetime':
91
- return wrapWithComments(_jsx(DateTimeField, { id: fieldId, label: label, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
104
+ return wrapWithComments(_jsx(DateTimeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
92
105
  case 'markdown':
93
106
  case 'mdx':
94
- return wrapWithComments(_jsx(MarkdownField, { id: fieldId, label: label, value: currentValue ?? '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
107
+ return wrapWithComments(_jsx(MarkdownField, { id: fieldId, label: label, description: field.description, value: currentValue ?? '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
95
108
  case 'select': {
96
109
  const selectField = field;
97
110
  const options = normalizeOptions(selectField.options);
98
111
  const isMulti = Boolean(selectField.list);
99
- return wrapWithComments(_jsx(SelectField, { id: fieldId, label: label, options: options, value: isMulti
112
+ return wrapWithComments(_jsx(SelectField, { id: fieldId, label: label, description: field.description, options: options, value: isMulti
100
113
  ? Array.isArray(currentValue)
101
114
  ? currentValue
102
115
  : []
@@ -108,7 +121,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
108
121
  ? normalizeOptions(referenceField.options)
109
122
  : undefined;
110
123
  const isMulti = Boolean(referenceField.list);
111
- return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, options: staticOptions?.map((opt) => ({
124
+ return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, description: field.description, options: staticOptions?.map((opt) => ({
112
125
  label: opt.label,
113
126
  value: opt.value,
114
127
  })), collections: referenceField.collections, entryTypes: referenceField.entryTypes, displayField: referenceField.displayField, branch: branch, value: isMulti
@@ -119,7 +132,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
119
132
  }
120
133
  case 'image': {
121
134
  const imageField = field;
122
- return wrapWithComments(_jsx(ImageField, { id: fieldId, label: label, value: currentValue, onChange: (next) => update(next), aspect: imageField.aspect, altOptional: imageField.altOptional, dataCanopyField: canopyPath, errors: {
135
+ return wrapWithComments(_jsx(ImageField, { id: fieldId, label: label, description: field.description, value: currentValue, onChange: (next) => update(next), aspect: imageField.aspect, altOptional: imageField.altOptional, dataCanopyField: canopyPath, errors: {
123
136
  src: fieldErrors?.[`${canopyPath}.src`],
124
137
  alt: fieldErrors?.[`${canopyPath}.alt`],
125
138
  crop: fieldErrors?.[`${canopyPath}.crop`],
@@ -127,7 +140,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
127
140
  }
128
141
  case 'block': {
129
142
  const blockField = field;
130
- return wrapWithComments(_jsx(BlockField, { label: label, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
143
+ return wrapWithComments(_jsx(BlockField, { label: label, description: field.description, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
131
144
  }
132
145
  case 'object': {
133
146
  const objectField = field;
@@ -135,11 +148,14 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
135
148
  const items = Array.isArray(currentValue)
136
149
  ? currentValue
137
150
  : [];
138
- return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => (_jsx(Paper, { withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsxs(Text, { size: "xs", fw: 700, children: [label, " #", idx + 1] }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { label: objectField.label, fields: objectField.fields, value: item, onChange: (next) => {
139
- const nextItems = [...items];
140
- nextItems[idx] = next;
141
- update(nextItems);
142
- }, renderField: renderField, path: [...path, idx], dataCanopyField: normalizeCanopyPath([...path, idx]) })] }) }, fieldKey([...path, idx])))), items.length === 0 && (_jsx(Text, { size: "xs", c: "dimmed", children: "No items yet. Add one to get started." }))] })] }) }, fieldKey(path)));
151
+ return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", ...groupDescriptionProps(fieldId, field.description), children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsx(FieldDescription, { baseId: fieldId, description: field.description }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => {
152
+ const itemTitle = listItemTitle(item, objectField.itemTitleField, `${label} #${idx + 1}`);
153
+ return (_jsx(Paper, { role: "group", "aria-label": itemTitle, withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, children: itemTitle }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { fields: objectField.fields, value: item, onChange: (next) => {
154
+ const nextItems = [...items];
155
+ nextItems[idx] = next;
156
+ update(nextItems);
157
+ }, renderField: renderField, path: [...path, idx], dataCanopyField: normalizeCanopyPath([...path, idx]) })] }) }, fieldKey([...path, idx])));
158
+ }), items.length === 0 && (_jsx(Text, { size: "xs", c: "dimmed", children: "No items yet. Add one to get started." }))] })] }) }, fieldKey(path)));
143
159
  }
144
160
  // Unlike the list branch above (each item has its own Remove button), a
145
161
  // non-list object has no other way back to `undefined`: a required child
@@ -151,10 +167,10 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
151
167
  // doesn't read as deleting the field from the schema.
152
168
  const hasValue = currentValue !== undefined && currentValue !== null;
153
169
  const canClear = objectField.required !== true && hasValue;
154
- return wrapWithComments(_jsx(ObjectField, { label: label, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onRemove: canClear ? () => update(undefined) : undefined, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
170
+ return wrapWithComments(_jsx(ObjectField, { label: label, description: field.description, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onRemove: canClear ? () => update(undefined) : undefined, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
155
171
  }
156
172
  case 'code':
157
- return wrapWithComments(_jsx(CodeField, { id: fieldId, label: label, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
173
+ return wrapWithComments(_jsx(CodeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
158
174
  default:
159
175
  return (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Unsupported field: ", field.type] }, fieldKey(path)));
160
176
  }
@@ -59,7 +59,9 @@ function purgeGateFor(entry) {
59
59
  }
60
60
  return {
61
61
  disabled: lockFresh,
62
- tooltip: lockFresh ? 'Provisioning may be in progress' : undefined,
62
+ tooltip: lockFresh
63
+ ? 'Provisioning, or the worker syncing this branch, may be in progress'
64
+ : undefined,
63
65
  };
64
66
  }
65
67
  function workerLivenessBadge(worker, mode) {
@@ -76,6 +78,33 @@ function workerLivenessBadge(worker, mode) {
76
78
  return { color: 'red', label: 'Worker: absent' };
77
79
  }
78
80
  }
81
+ const BASE_REFRESH_LABELS = {
82
+ refreshed: 'fast-forwarded',
83
+ 'up-to-date': 'up to date',
84
+ 'skipped-dirty': 'refresh skipped (uncommitted changes)',
85
+ 'skipped-locked': 'refresh skipped (workspace busy: provisioning or an admin action)',
86
+ 'skipped-not-provisioned': 'not yet provisioned',
87
+ failed: 'refresh failed',
88
+ };
89
+ /**
90
+ * Why the base branch needs an operator, or null when its last refresh needs
91
+ * nothing. Shared by the overview and the base row's warning tooltip.
92
+ */
93
+ function baseRefreshWarning(report) {
94
+ if (!report)
95
+ return null;
96
+ const lines = [];
97
+ if (report.outcome === 'skipped-dirty' || report.outcome === 'failed') {
98
+ lines.push(`Base branch ${BASE_REFRESH_LABELS[report.outcome]}${report.message ? `: ${report.message}` : ''}`);
99
+ if (report.dirtyFiles?.length)
100
+ lines.push(`Uncommitted: ${report.dirtyFiles.join(', ')}`);
101
+ }
102
+ if (report.trackedCanopyMeta?.length) {
103
+ lines.push(`The site repo tracks canopycms state (${report.trackedCanopyMeta.join(', ')}). ` +
104
+ 'Untrack it with `git rm -r --cached .canopy-meta`, add `.canopy-meta/` to .gitignore, and commit.');
105
+ }
106
+ return lines.length > 0 ? lines.join('\n') : null;
107
+ }
79
108
  // Mirrors BranchManager.tsx's statusColorMap -- kept local (not exported
80
109
  // there) rather than shared, same tiny lookup either way.
81
110
  const branchStatusColorMap = {
@@ -121,9 +150,19 @@ function OverviewTab({ health }) {
121
150
  const liveness = workerLivenessBadge(status.worker, status.mode);
122
151
  const lastFatalError = status.workerStatus?.lastFatalError;
123
152
  const lastGitSync = status.workerStatus?.lastGitSync;
124
- return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Worker crash detected", children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at, " (", lastFatalError.phase, ")"] })] })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), lastGitSync && (_jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Last git sync" }), _jsxs(Text, { size: "xs", c: "dimmed", children: [status.workerStatus?.lastGitSyncAt ?? 'unknown time', " \u00B7 ", lastGitSync.durationMs, "ms \u00B7", ' ', lastGitSync.rebased.length, " rebased \u00B7 ", lastGitSync.skippedDirty.length, " skipped (dirty)", lastGitSync.skippedLocked && lastGitSync.skippedLocked.length > 0
125
- ? ` · ${lastGitSync.skippedLocked.length} skipped (content write in progress)`
126
- : ''] }), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "red", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "md", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0 ? 'red' : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
153
+ const baseWarning = baseRefreshWarning(lastGitSync?.baseRefresh);
154
+ const { build } = status;
155
+ // Absent for a worker that predates the field, which is not evidence of skew;
156
+ // nor is a stale or absent worker's leftover status file, which names no running build.
157
+ const workerVersion = status.workerStatus?.workerVersion || undefined;
158
+ const versionSkew = status.worker.state === 'alive' &&
159
+ workerVersion !== undefined &&
160
+ workerVersion !== build.canopycmsVersion;
161
+ return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Worker crash detected", children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at, " (", lastFatalError.phase, ")"] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] }), _jsxs(Text, { size: "xs", c: status.imageProcessing.available ? 'dimmed' : 'orange', "data-testid": "build-image-processing", children: ["Image processing:", ' ', status.imageProcessing.available
162
+ ? 'available'
163
+ : `unavailable — new editor image sizes and crops fail, and uploads skip decode validation${status.imageProcessing.error ? ` (${status.imageProcessing.error})` : ''}`] })] }), lastGitSync && (_jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Last git sync" }), _jsxs(Text, { size: "xs", c: "dimmed", children: [status.workerStatus?.lastGitSyncAt ?? 'unknown time', " \u00B7 ", lastGitSync.durationMs, "ms \u00B7", ' ', lastGitSync.rebased.length, " rebased \u00B7 ", lastGitSync.skippedDirty.length, " skipped (dirty)", lastGitSync.skippedLocked && lastGitSync.skippedLocked.length > 0
164
+ ? ` · ${lastGitSync.skippedLocked.length} skipped (busy: a content write, provisioning or purge)`
165
+ : ''] }), lastGitSync.baseRefresh && (_jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "base-refresh-outcome", children: ["Base branch: ", BASE_REFRESH_LABELS[lastGitSync.baseRefresh.outcome]] })), baseWarning && (_jsx(Text, { size: "xs", c: "orange", style: { whiteSpace: 'pre-line' }, "data-testid": "base-refresh-warning", children: baseWarning })), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "red", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "md", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0 ? 'red' : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
127
166
  }
128
167
  function TasksTab({ health }) {
129
168
  const { taskStatus, setTaskStatus, tasks, tasksLoading } = health;
@@ -160,6 +199,7 @@ function TasksTab({ health }) {
160
199
  function BranchesTab({ health }) {
161
200
  const { branchHealth, branchHealthLoading } = health;
162
201
  const entries = branchHealth?.entries ?? [];
202
+ const baseWarning = baseRefreshWarning(health.status?.workerStatus?.lastGitSync?.baseRefresh);
163
203
  const handleMarkMergedClick = (branchName) => {
164
204
  modals.openConfirmModal({
165
205
  title: 'Mark branch as merged',
@@ -193,9 +233,9 @@ function BranchesTab({ health }) {
193
233
  if (entries.length === 0) {
194
234
  return (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: "No branch directories found." }));
195
235
  }
196
- return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
236
+ return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
197
237
  }
198
- function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
238
+ function BranchHealthRow({ entry, baseWarning, onMarkMerged, onRepair, onPurge, }) {
199
239
  if (entry.kind === 'healthy' && entry.branch) {
200
240
  const b = entry.branch;
201
241
  // Mirrors the rebase loop's skip logic (worker/rebase.ts): the worker
@@ -207,7 +247,7 @@ function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
207
247
  const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
208
248
  return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusColorMap[b.status] ?? 'neutral', variant: "light", children: b.status }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(Tooltip, { label: b.syncFailureReason
209
249
  ? `${b.syncFailureReason} — retry from the Tasks tab`
210
- : 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
250
+ : 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), baseWarning && (_jsx(Tooltip, { label: baseWarning, multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `base-refresh-warning-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
211
251
  .map((d) => `${d.id}: kept ${d.keptPath}; also on ${d.droppedPaths.join(', ')}`)
212
252
  .join('\n'), multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `duplicate-content-ids-${entry.dirName}`, children: [entry.duplicateContentIds.length, " duplicate ID", entry.duplicateContentIds.length === 1 ? '' : 's'] }) }))] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: b.updatedAt }) }), _jsx(Table.Td, { children: canMarkMerged && (_jsx(Button, { size: "xs", variant: "light", "data-testid": `mark-merged-${b.name}`, onClick: () => onMarkMerged(b.name), children: "Mark merged" })) })] }));
213
253
  }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ export interface BranchesDrawerProps {
3
+ opened: boolean;
4
+ onClose: () => void;
5
+ /** A confirm opened from inside is on screen: the drawer yields Escape to it (Mantine listens for Escape on window, capture phase). */
6
+ confirmOpen: boolean;
7
+ children: React.ReactNode;
8
+ }
9
+ export declare const BranchesDrawer: React.FC<BranchesDrawerProps>;
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Drawer, Text, Title } from '@mantine/core';
4
+ export const BranchesDrawer = ({ opened, onClose, confirmOpen, children, }) => (_jsx(Drawer, { opened: opened, onClose: onClose, closeOnEscape: !confirmOpen, position: "right", title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Branches" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Manage access, status, and lifecycle" })] }), padding: "md", size: 420, overlayProps: { blur: 2 }, children: children }));
@@ -0,0 +1,10 @@
1
+ export interface NoEditPermissionNoticeProps {
2
+ /** Logical path of the entry the user cannot edit. */
3
+ entryPath: string;
4
+ }
5
+ /**
6
+ * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
7
+ * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
8
+ * locked or protected branch shows its own banner in the header and a read-only form instead.
9
+ */
10
+ export declare function NoEditPermissionNotice({ entryPath }: NoEditPermissionNoticeProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from '@mantine/core';
3
+ /**
4
+ * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
5
+ * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
6
+ * locked or protected branch shows its own banner in the header and a read-only form instead.
7
+ */
8
+ export function NoEditPermissionNotice({ entryPath }) {
9
+ return (_jsx("div", { role: "status", "data-testid": "no-edit-permission-notice", style: {
10
+ display: 'flex',
11
+ alignItems: 'center',
12
+ justifyContent: 'center',
13
+ height: '100%',
14
+ padding: '0 var(--mantine-spacing-md)',
15
+ textAlign: 'center',
16
+ }, children: _jsxs(Text, { size: "sm", c: "dimmed", children: ["You don't have edit access to \"", entryPath, "\". Ask a CanopyCMS admin to grant it in Manage Permissions."] }) }));
17
+ }
@@ -1,44 +1,27 @@
1
1
  /**
2
- * Carries `media.publicBaseUrl` (see CanopyClientConfig.assetBaseUrl) down to
3
- * every asset-URL-building component - MediaLibrary, ImageField, and the MDX
4
- * image dialog. A context (rather than a prop) because the MDX dialog is
5
- * rendered deep inside MDXEditor's lazy-loaded internals via `imagePlugin`'s
6
- * `ImageDialog` param, with no prop channel of its own (the same reason
7
- * `EntryLinkContext` exists for the entry-link toolbar button). Unlike
8
- * `ApiClientContext`, reading this without a provider is safe and intentional:
9
- * no provider means root-relative URLs, which is the correct default for the
10
- * common case (editor and site share an origin).
2
+ * Carries the prefix every editor-built asset URL is put behind - MediaLibrary thumbnails,
3
+ * ImageField's preview and crop source, and the MDX editor's image previews. A context (rather
4
+ * than a prop) because the MDX dialog is rendered deep inside MDXEditor's lazy-loaded internals
5
+ * via `imagePlugin`'s `ImageDialog` param, with no prop channel of its own (the same reason
6
+ * `EntryLinkContext` exists for the entry-link toolbar button).
11
7
  */
12
8
  import React from 'react';
9
+ /**
10
+ * Where the editor loads asset bytes from: the authenticated raw route under `basePath`, never the
11
+ * public `/assets` space. An editor asks for derivatives no build has seen (a fresh crop, a
12
+ * thumbnail), which the public path need not serve, and the raw route computes them on demand.
13
+ * Also handed to the live preview, through `PreviewFrame`.
14
+ */
15
+ export declare function authenticatedAssetBase(basePath?: string): string;
13
16
  export interface AssetContextValue {
14
- /** Prefixed onto every asset URL the editor builds. `undefined` (no provider, or no `media.publicBaseUrl` configured) means root-relative - same-origin dev/prod-today behavior. */
15
- baseUrl?: string;
17
+ /** The `baseUrl` to pass to `assetUrl` for every asset URL the editor renders. */
18
+ baseUrl: string;
16
19
  }
17
- export interface AssetContextProviderProps extends AssetContextValue {
20
+ export interface AssetContextProviderProps {
18
21
  children: React.ReactNode;
19
- /**
20
- * `CanopyClientConfig.basePath` - the deployment prefix the host app is served under. Used only
21
- * when `baseUrl` is unset.
22
- */
22
+ /** `CanopyClientConfig.basePath` - the deployment prefix the host app is served under. */
23
23
  basePath?: string;
24
24
  }
25
- /**
26
- * `baseUrl` (`media.publicBaseUrl`) wins; `basePath` is the fallback.
27
- *
28
- * They are ALTERNATIVES, never composed: whichever is used names where `/assets` is mounted, in
29
- * full, and a deployment `basePath` cannot apply on top of it. Where the two differ is topology,
30
- * not precedence - see the asset-mount table in the README.
31
- *
32
- * The fallback is kept only so that existing basePath deployments do not break on upgrade,
33
- * which makes removing it a deliberate decision rather than a cleanup - it is option 1 of
34
- * `.claude/future-tasks/editor-asset-mount-topology.md`, still open.
35
- *
36
- * KNOWN LIMITATION: on a CloudFront/CDK deployment, a basePath does NOT move the asset
37
- * space (behaviors are anchored at the distribution root), so this fallback prefixes
38
- * wrongly — requests still resolve, via `withCanopy`'s auto-prefixed `/assets/:path*`
39
- * rewrite, but bypass the CDN cache and the dedicated transform Lambda. Workaround: set
40
- * `media.publicBaseUrl` to the distribution origin instead, which takes precedence here.
41
- */
42
- export declare function AssetContextProvider({ baseUrl, basePath, children }: AssetContextProviderProps): import("react/jsx-runtime").JSX.Element;
43
- /** Safe to call outside a provider - returns `{ baseUrl: undefined }` (root-relative URLs). */
25
+ export declare function AssetContextProvider({ basePath, children }: AssetContextProviderProps): import("react/jsx-runtime").JSX.Element;
26
+ /** Safe to call outside a provider - see the context's default. */
44
27
  export declare function useAssetContext(): AssetContextValue;
@@ -1,41 +1,32 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  /**
4
- * Carries `media.publicBaseUrl` (see CanopyClientConfig.assetBaseUrl) down to
5
- * every asset-URL-building component - MediaLibrary, ImageField, and the MDX
6
- * image dialog. A context (rather than a prop) because the MDX dialog is
7
- * rendered deep inside MDXEditor's lazy-loaded internals via `imagePlugin`'s
8
- * `ImageDialog` param, with no prop channel of its own (the same reason
9
- * `EntryLinkContext` exists for the entry-link toolbar button). Unlike
10
- * `ApiClientContext`, reading this without a provider is safe and intentional:
11
- * no provider means root-relative URLs, which is the correct default for the
12
- * common case (editor and site share an origin).
4
+ * Carries the prefix every editor-built asset URL is put behind - MediaLibrary thumbnails,
5
+ * ImageField's preview and crop source, and the MDX editor's image previews. A context (rather
6
+ * than a prop) because the MDX dialog is rendered deep inside MDXEditor's lazy-loaded internals
7
+ * via `imagePlugin`'s `ImageDialog` param, with no prop channel of its own (the same reason
8
+ * `EntryLinkContext` exists for the entry-link toolbar button).
13
9
  */
14
10
  import { createContext, useContext, useMemo } from 'react';
15
- const AssetContext = createContext({});
11
+ import { joinUrlPrefix } from '../../utils/url-prefix.js';
12
+ /** The raw asset route (`api/assets.ts`'s `assetRawRoute`) under the default API base. */
13
+ const RAW_ASSET_ROUTE = '/api/canopycms/assets/raw';
16
14
  /**
17
- * `baseUrl` (`media.publicBaseUrl`) wins; `basePath` is the fallback.
18
- *
19
- * They are ALTERNATIVES, never composed: whichever is used names where `/assets` is mounted, in
20
- * full, and a deployment `basePath` cannot apply on top of it. Where the two differ is topology,
21
- * not precedence - see the asset-mount table in the README.
22
- *
23
- * The fallback is kept only so that existing basePath deployments do not break on upgrade,
24
- * which makes removing it a deliberate decision rather than a cleanup - it is option 1 of
25
- * `.claude/future-tasks/editor-asset-mount-topology.md`, still open.
26
- *
27
- * KNOWN LIMITATION: on a CloudFront/CDK deployment, a basePath does NOT move the asset
28
- * space (behaviors are anchored at the distribution root), so this fallback prefixes
29
- * wrongly — requests still resolve, via `withCanopy`'s auto-prefixed `/assets/:path*`
30
- * rewrite, but bypass the CDN cache and the dedicated transform Lambda. Workaround: set
31
- * `media.publicBaseUrl` to the distribution origin instead, which takes precedence here.
15
+ * Where the editor loads asset bytes from: the authenticated raw route under `basePath`, never the
16
+ * public `/assets` space. An editor asks for derivatives no build has seen (a fresh crop, a
17
+ * thumbnail), which the public path need not serve, and the raw route computes them on demand.
18
+ * Also handed to the live preview, through `PreviewFrame`.
32
19
  */
33
- export function AssetContextProvider({ baseUrl, basePath, children }) {
34
- const resolved = baseUrl ?? basePath;
35
- const value = useMemo(() => ({ baseUrl: resolved }), [resolved]);
20
+ export function authenticatedAssetBase(basePath) {
21
+ return joinUrlPrefix(basePath, RAW_ASSET_ROUTE);
22
+ }
23
+ /** No provider means no `basePath`: the API is mounted at the origin root. */
24
+ const AssetContext = createContext({ baseUrl: authenticatedAssetBase() });
25
+ export function AssetContextProvider({ basePath, children }) {
26
+ const value = useMemo(() => ({ baseUrl: authenticatedAssetBase(basePath) }), [basePath]);
36
27
  return _jsx(AssetContext.Provider, { value: value, children: children });
37
28
  }
38
- /** Safe to call outside a provider - returns `{ baseUrl: undefined }` (root-relative URLs). */
29
+ /** Safe to call outside a provider - see the context's default. */
39
30
  export function useAssetContext() {
40
31
  return useContext(AssetContext);
41
32
  }
@@ -1,4 +1,4 @@
1
1
  export { ApiClientProvider, useApiClient, useOptionalApiClient, type ApiClient, type ApiClientProviderProps, } from './ApiClientContext.js';
2
- export { AssetContextProvider, useAssetContext, type AssetContextValue, type AssetContextProviderProps, } from './AssetContext.js';
2
+ export { AssetContextProvider, authenticatedAssetBase, useAssetContext, type AssetContextValue, type AssetContextProviderProps, } from './AssetContext.js';
3
3
  export { SWRProvider, type SWRProviderProps } from './SWRProvider.js';
4
4
  export { useEditorLoading, useEditorModals, useEditorPreview } from './EditorStateContext.js';
@@ -1,4 +1,4 @@
1
1
  export { ApiClientProvider, useApiClient, useOptionalApiClient, } from './ApiClientContext.js';
2
- export { AssetContextProvider, useAssetContext, } from './AssetContext.js';
2
+ export { AssetContextProvider, authenticatedAssetBase, useAssetContext, } from './AssetContext.js';
3
3
  export { SWRProvider } from './SWRProvider.js';
4
4
  export { useEditorLoading, useEditorModals, useEditorPreview } from './EditorStateContext.js';