canopycms 0.0.68-int.97 → 0.1.0-int.115

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 (637) hide show
  1. package/CHANGELOG.md +2095 -0
  2. package/README.md +49 -64
  3. package/dist/ai/generate.d.ts +5 -0
  4. package/dist/ai/generate.js +244 -115
  5. package/dist/ai/index.d.ts +1 -1
  6. package/dist/ai/json-to-markdown.d.ts +21 -1
  7. package/dist/ai/json-to-markdown.js +120 -57
  8. package/dist/ai/references.d.ts +72 -0
  9. package/dist/ai/references.js +221 -0
  10. package/dist/ai/resolve-branch.js +9 -2
  11. package/dist/ai/to-plain-text.d.ts +17 -8
  12. package/dist/ai/to-plain-text.js +18 -15
  13. package/dist/ai/types.d.ts +84 -3
  14. package/dist/api/admin-branch-health.d.ts +24 -1
  15. package/dist/api/admin-branch-health.js +52 -20
  16. package/dist/api/admin.d.ts +23 -5
  17. package/dist/api/admin.js +36 -1
  18. package/dist/api/assets.js +86 -76
  19. package/dist/api/branch-create-window.d.ts +12 -0
  20. package/dist/api/branch-create-window.js +13 -0
  21. package/dist/api/branch-merge.js +1 -1
  22. package/dist/api/branch-review.d.ts +3 -3
  23. package/dist/api/branch-review.js +16 -7
  24. package/dist/api/branch-status.d.ts +16 -6
  25. package/dist/api/branch-status.js +57 -12
  26. package/dist/api/branch-withdraw.d.ts +2 -2
  27. package/dist/api/branch-withdraw.js +8 -3
  28. package/dist/api/branch.d.ts +29 -8
  29. package/dist/api/branch.js +179 -106
  30. package/dist/api/client.d.ts +78 -58
  31. package/dist/api/client.js +187 -113
  32. package/dist/api/comments.d.ts +12 -3
  33. package/dist/api/comments.js +118 -9
  34. package/dist/api/content.d.ts +9 -9
  35. package/dist/api/content.js +131 -22
  36. package/dist/api/entries-constants.d.ts +14 -3
  37. package/dist/api/entries-constants.js +14 -3
  38. package/dist/api/entries.d.ts +38 -0
  39. package/dist/api/entries.js +102 -6
  40. package/dist/api/github-sync.d.ts +19 -0
  41. package/dist/api/github-sync.js +50 -3
  42. package/dist/api/groups.d.ts +4 -4
  43. package/dist/api/groups.js +2 -1
  44. package/dist/api/guards.js +23 -3
  45. package/dist/api/index.d.ts +3 -1
  46. package/dist/api/index.js +1 -0
  47. package/dist/api/permissions.d.ts +19 -0
  48. package/dist/api/permissions.js +50 -3
  49. package/dist/api/request-deadline.d.ts +59 -0
  50. package/dist/api/request-deadline.js +73 -0
  51. package/dist/api/resolve-references.js +8 -7
  52. package/dist/api/route-builder.d.ts +14 -0
  53. package/dist/api/route-builder.js +14 -1
  54. package/dist/api/schema.d.ts +11 -9
  55. package/dist/api/schema.js +2 -0
  56. package/dist/api/settings-helpers.d.ts +5 -1
  57. package/dist/api/settings-helpers.js +10 -2
  58. package/dist/api/types.d.ts +17 -0
  59. package/dist/api/users-constants.d.ts +6 -0
  60. package/dist/api/users-constants.js +6 -0
  61. package/dist/assets/asset-url.d.ts +28 -16
  62. package/dist/assets/asset-url.js +51 -25
  63. package/dist/assets/finalize.js +3 -0
  64. package/dist/assets/index.d.ts +1 -1
  65. package/dist/assets/materialize.d.ts +153 -0
  66. package/dist/assets/materialize.js +402 -0
  67. package/dist/assets/store-local.d.ts +19 -5
  68. package/dist/assets/store-local.js +69 -10
  69. package/dist/assets/store-s3.d.ts +38 -7
  70. package/dist/assets/store-s3.js +128 -22
  71. package/dist/assets/transform-directives.d.ts +52 -13
  72. package/dist/assets/transform-directives.js +87 -27
  73. package/dist/assets/transform.d.ts +3 -3
  74. package/dist/assets/transform.js +13 -8
  75. package/dist/assets/types.d.ts +31 -4
  76. package/dist/auth/cache.d.ts +1 -1
  77. package/dist/auth/cache.js +1 -1
  78. package/dist/auth/caching-auth-plugin.d.ts +2 -0
  79. package/dist/auth/caching-auth-plugin.js +12 -2
  80. package/dist/auth/file-based-auth-cache.d.ts +8 -0
  81. package/dist/auth/file-based-auth-cache.js +18 -8
  82. package/dist/auth/person-name.d.ts +5 -0
  83. package/dist/auth/person-name.js +8 -0
  84. package/dist/auth/plugin.d.ts +10 -1
  85. package/dist/auth/types.d.ts +2 -0
  86. package/dist/auth/user-metadata-lookup.d.ts +21 -0
  87. package/dist/auth/user-metadata-lookup.js +116 -0
  88. package/dist/authorization/branch.d.ts +1 -1
  89. package/dist/authorization/branch.js +12 -13
  90. package/dist/authorization/groups/schema.d.ts +4 -4
  91. package/dist/authorization/helpers.d.ts +8 -0
  92. package/dist/authorization/helpers.js +8 -0
  93. package/dist/authorization/index.d.ts +1 -1
  94. package/dist/authorization/index.js +1 -1
  95. package/dist/authorization/protected-branch.d.ts +5 -4
  96. package/dist/authorization/protected-branch.js +7 -5
  97. package/dist/branch-health.d.ts +35 -8
  98. package/dist/branch-health.js +54 -18
  99. package/dist/branch-metadata-error.d.ts +27 -0
  100. package/dist/branch-metadata-error.js +23 -0
  101. package/dist/branch-metadata-file.d.ts +205 -24
  102. package/dist/branch-metadata-file.js +126 -25
  103. package/dist/branch-metadata.d.ts +45 -6
  104. package/dist/branch-metadata.js +142 -50
  105. package/dist/branch-provisioning.d.ts +167 -0
  106. package/dist/branch-provisioning.js +501 -0
  107. package/dist/branch-registry.d.ts +8 -0
  108. package/dist/branch-registry.js +61 -9
  109. package/dist/branch-schema-cache.d.ts +29 -12
  110. package/dist/branch-schema-cache.js +64 -21
  111. package/dist/branch-sparse.d.ts +23 -0
  112. package/dist/branch-sparse.js +118 -0
  113. package/dist/branch-workspace.d.ts +42 -3
  114. package/dist/branch-workspace.js +239 -100
  115. package/dist/build/asset-refs.d.ts +94 -0
  116. package/dist/build/asset-refs.js +267 -0
  117. package/dist/build/generate-ai-content.js +5 -0
  118. package/dist/build/inline-flight.d.ts +37 -0
  119. package/dist/build/inline-flight.js +142 -0
  120. package/dist/cli/asset-refs.d.ts +52 -0
  121. package/dist/cli/asset-refs.js +194 -0
  122. package/dist/cli/cli.d.ts +15 -1
  123. package/dist/cli/cli.js +8532 -3184
  124. package/dist/cli/configured-asset-store.d.ts +12 -0
  125. package/dist/cli/configured-asset-store.js +46 -0
  126. package/dist/cli/generate-ai-content.js +4237 -1067
  127. package/dist/cli/init.js +214 -123
  128. package/dist/cli/project-detect.d.ts +2 -0
  129. package/dist/cli/project-detect.js +2 -0
  130. package/dist/cli/template-files/Dockerfile.cms.template +13 -5
  131. package/dist/cli/template-files/cms-stack.ts.template +20 -14
  132. package/dist/client.d.ts +1 -1
  133. package/dist/client.js +1 -1
  134. package/dist/comment-store.d.ts +25 -0
  135. package/dist/comment-store.js +43 -8
  136. package/dist/config/flatten.js +2 -0
  137. package/dist/config/helpers.js +2 -2
  138. package/dist/config/index.d.ts +1 -1
  139. package/dist/config/schemas/config.d.ts +17 -29
  140. package/dist/config/schemas/config.js +10 -12
  141. package/dist/config/schemas/field.d.ts +3 -0
  142. package/dist/config/schemas/field.js +58 -4
  143. package/dist/config/schemas/media.d.ts +4 -13
  144. package/dist/config/schemas/media.js +5 -8
  145. package/dist/config/schemas/url.d.ts +2 -20
  146. package/dist/config/schemas/url.js +2 -25
  147. package/dist/config/types.d.ts +123 -31
  148. package/dist/config/types.js +3 -9
  149. package/dist/config/validation.d.ts +25 -4
  150. package/dist/config/validation.js +116 -3
  151. package/dist/content-id-index.js +2 -2
  152. package/dist/content-listing.d.ts +10 -1
  153. package/dist/content-listing.js +21 -4
  154. package/dist/content-reader.d.ts +9 -0
  155. package/dist/content-reader.js +15 -10
  156. package/dist/content-store.d.ts +74 -11
  157. package/dist/content-store.js +248 -85
  158. package/dist/content-tree.js +2 -2
  159. package/dist/context.js +5 -2
  160. package/dist/dangling-reference-log.d.ts +17 -0
  161. package/dist/dangling-reference-log.js +29 -0
  162. package/dist/editor/BranchManager.d.ts +26 -8
  163. package/dist/editor/BranchManager.js +142 -65
  164. package/dist/editor/CanopyEditor.d.ts +1 -1
  165. package/dist/editor/CanopyEditor.js +2 -6
  166. package/dist/editor/CommentsPanel.d.ts +14 -0
  167. package/dist/editor/CommentsPanel.js +68 -40
  168. package/dist/editor/Editor.d.ts +13 -4
  169. package/dist/editor/Editor.js +673 -148
  170. package/dist/editor/EditorAuthGate.d.ts +7 -1
  171. package/dist/editor/EditorAuthGate.js +34 -7
  172. package/dist/editor/EditorPanes.d.ts +10 -0
  173. package/dist/editor/EditorPanes.js +39 -13
  174. package/dist/editor/EntryNavigator.d.ts +12 -0
  175. package/dist/editor/EntryNavigator.js +304 -105
  176. package/dist/editor/FormHeader.d.ts +25 -0
  177. package/dist/editor/FormHeader.js +82 -0
  178. package/dist/editor/FormRenderer.d.ts +27 -3
  179. package/dist/editor/FormRenderer.js +171 -76
  180. package/dist/editor/PreviewFrame.d.ts +36 -0
  181. package/dist/editor/PreviewFrame.js +214 -0
  182. package/dist/editor/admin/SystemHealthPanel.d.ts +2 -1
  183. package/dist/editor/admin/SystemHealthPanel.js +163 -33
  184. package/dist/editor/admin/useSystemHealth.d.ts +18 -0
  185. package/dist/editor/admin/useSystemHealth.js +70 -1
  186. package/dist/editor/branch-status.d.ts +17 -0
  187. package/dist/editor/branch-status.js +29 -0
  188. package/dist/editor/canopy-path.d.ts +9 -6
  189. package/dist/editor/canopy-path.js +8 -11
  190. package/dist/editor/client-reference-resolver.d.ts +43 -16
  191. package/dist/editor/client-reference-resolver.js +155 -64
  192. package/dist/editor/comments/BranchComments.d.ts +1 -1
  193. package/dist/editor/comments/BranchComments.js +2 -2
  194. package/dist/editor/comments/CloseRefusedNotice.d.ts +4 -0
  195. package/dist/editor/comments/CloseRefusedNotice.js +7 -0
  196. package/dist/editor/comments/CommentThreadsPopover.d.ts +47 -0
  197. package/dist/editor/comments/CommentThreadsPopover.js +274 -0
  198. package/dist/editor/comments/CommentWriteAlert.d.ts +11 -0
  199. package/dist/editor/comments/CommentWriteAlert.js +13 -0
  200. package/dist/editor/comments/EntryComments.d.ts +10 -4
  201. package/dist/editor/comments/EntryComments.js +14 -17
  202. package/dist/editor/comments/FieldWrapper.d.ts +18 -4
  203. package/dist/editor/comments/FieldWrapper.js +62 -28
  204. package/dist/editor/comments/InlineCommentThread.d.ts +9 -2
  205. package/dist/editor/comments/InlineCommentThread.js +11 -47
  206. package/dist/editor/comments/ThreadCarousel.d.ts +1 -1
  207. package/dist/editor/comments/ThreadCarousel.js +18 -21
  208. package/dist/editor/comments/ThreadReplyControls.d.ts +28 -0
  209. package/dist/editor/comments/ThreadReplyControls.js +92 -0
  210. package/dist/editor/comments/field-comment-context.d.ts +14 -0
  211. package/dist/editor/comments/field-comment-context.js +14 -0
  212. package/dist/editor/comments/form-comments.d.ts +48 -0
  213. package/dist/editor/comments/form-comments.js +177 -0
  214. package/dist/editor/comments/item-anchor-copy.d.ts +6 -0
  215. package/dist/editor/comments/item-anchor-copy.js +6 -0
  216. package/dist/editor/comments/move-focus-if-unmoved.d.ts +6 -0
  217. package/dist/editor/comments/move-focus-if-unmoved.js +11 -0
  218. package/dist/editor/comments/popover-placement.d.ts +18 -0
  219. package/dist/editor/comments/popover-placement.js +58 -0
  220. package/dist/editor/comments/use-tracked-writes.d.ts +16 -0
  221. package/dist/editor/comments/use-tracked-writes.js +53 -0
  222. package/dist/editor/components/BranchAccessList.d.ts +15 -0
  223. package/dist/editor/components/BranchAccessList.js +52 -0
  224. package/dist/editor/components/BranchesDrawer.d.ts +11 -0
  225. package/dist/editor/components/BranchesDrawer.js +5 -0
  226. package/dist/editor/components/ConfirmDeleteModal.d.ts +4 -1
  227. package/dist/editor/components/ConfirmDeleteModal.js +2 -2
  228. package/dist/editor/components/DestructiveMenuItem.d.ts +7 -0
  229. package/dist/editor/components/DestructiveMenuItem.js +8 -0
  230. package/dist/editor/components/EditorCrashScreen.d.ts +17 -0
  231. package/dist/editor/components/EditorCrashScreen.js +22 -0
  232. package/dist/editor/components/EditorErrorBoundary.d.ts +38 -0
  233. package/dist/editor/components/EditorErrorBoundary.js +40 -0
  234. package/dist/editor/components/EditorHeader.d.ts +8 -0
  235. package/dist/editor/components/EditorHeader.js +89 -69
  236. package/dist/editor/components/EditorSidebar.d.ts +14 -11
  237. package/dist/editor/components/EditorSidebar.js +32 -4
  238. package/dist/editor/components/EntryCreateModal.d.ts +12 -7
  239. package/dist/editor/components/EntryCreateModal.js +51 -57
  240. package/dist/editor/components/NoEditPermissionNotice.d.ts +6 -5
  241. package/dist/editor/components/NoEditPermissionNotice.js +5 -4
  242. package/dist/editor/components/QuickOpenPalette.d.ts +22 -0
  243. package/dist/editor/components/QuickOpenPalette.js +65 -0
  244. package/dist/editor/components/ReadOnlyDraftNotice.d.ts +11 -0
  245. package/dist/editor/components/ReadOnlyDraftNotice.js +12 -0
  246. package/dist/editor/components/ReasonedMenuItem.d.ts +15 -0
  247. package/dist/editor/components/ReasonedMenuItem.js +15 -0
  248. package/dist/editor/components/ReferencedByList.d.ts +11 -0
  249. package/dist/editor/components/ReferencedByList.js +28 -0
  250. package/dist/editor/components/RelativeTime.d.ts +10 -0
  251. package/dist/editor/components/RelativeTime.js +10 -0
  252. package/dist/editor/components/RenameEntryModal.d.ts +7 -1
  253. package/dist/editor/components/RenameEntryModal.js +18 -31
  254. package/dist/editor/components/RetryToastMessage.d.ts +9 -0
  255. package/dist/editor/components/RetryToastMessage.js +6 -0
  256. package/dist/editor/components/StagedChangesDrawer.d.ts +39 -0
  257. package/dist/editor/components/StagedChangesDrawer.js +92 -0
  258. package/dist/editor/components/UnavailableEntryNotice.d.ts +6 -0
  259. package/dist/editor/components/UnavailableEntryNotice.js +9 -0
  260. package/dist/editor/components/UnavailableTypeMessage.d.ts +6 -0
  261. package/dist/editor/components/UnavailableTypeMessage.js +9 -0
  262. package/dist/editor/components/UndoToastMessage.d.ts +18 -0
  263. package/dist/editor/components/UndoToastMessage.js +10 -0
  264. package/dist/editor/components/UserBadge.d.ts +2 -5
  265. package/dist/editor/components/UserBadge.js +62 -33
  266. package/dist/editor/components/ValidationSummary.d.ts +14 -0
  267. package/dist/editor/components/ValidationSummary.js +24 -0
  268. package/dist/editor/context/ApiClientContext.d.ts +8 -2
  269. package/dist/editor/context/ApiClientContext.js +19 -8
  270. package/dist/editor/context/AssetContext.d.ts +13 -35
  271. package/dist/editor/context/AssetContext.js +12 -31
  272. package/dist/editor/context/SWRProvider.d.ts +3 -3
  273. package/dist/editor/context/SiteMdxAllowContext.d.ts +4 -0
  274. package/dist/editor/context/SiteMdxAllowContext.js +7 -0
  275. package/dist/editor/context/index.d.ts +2 -1
  276. package/dist/editor/context/index.js +2 -1
  277. package/dist/editor/contrast.d.ts +19 -0
  278. package/dist/editor/contrast.js +41 -0
  279. package/dist/editor/copy.d.ts +15 -0
  280. package/dist/editor/copy.js +15 -0
  281. package/dist/editor/editor-config.d.ts +1 -2
  282. package/dist/editor/editor-config.js +15 -22
  283. package/dist/editor/editor-utils.d.ts +31 -9
  284. package/dist/editor/editor-utils.js +75 -38
  285. package/dist/editor/entry-search.d.ts +20 -0
  286. package/dist/editor/entry-search.js +34 -0
  287. package/dist/editor/field-breadcrumb.d.ts +12 -0
  288. package/dist/editor/field-breadcrumb.js +85 -0
  289. package/dist/editor/field-props.d.ts +86 -0
  290. package/dist/editor/field-props.js +27 -0
  291. package/dist/editor/field-target.d.ts +30 -0
  292. package/dist/editor/field-target.js +77 -0
  293. package/dist/editor/fields/BlockField.d.ts +12 -0
  294. package/dist/editor/fields/BlockField.js +89 -73
  295. package/dist/editor/fields/CodeField.d.ts +2 -0
  296. package/dist/editor/fields/CodeField.js +6 -2
  297. package/dist/editor/fields/DateTimeField.d.ts +4 -29
  298. package/dist/editor/fields/DateTimeField.js +79 -41
  299. package/dist/editor/fields/ExpandAllButton.d.ts +6 -0
  300. package/dist/editor/fields/ExpandAllButton.js +4 -0
  301. package/dist/editor/fields/FieldCrashFallback.d.ts +17 -0
  302. package/dist/editor/fields/FieldCrashFallback.js +30 -0
  303. package/dist/editor/fields/FieldLabel.d.ts +37 -0
  304. package/dist/editor/fields/FieldLabel.js +19 -0
  305. package/dist/editor/fields/ImageField.d.ts +4 -0
  306. package/dist/editor/fields/ImageField.js +29 -14
  307. package/dist/editor/fields/InlineGroupField.js +4 -3
  308. package/dist/editor/fields/ItemList.d.ts +42 -0
  309. package/dist/editor/fields/ItemList.js +130 -0
  310. package/dist/editor/fields/MarkdownField.d.ts +32 -0
  311. package/dist/editor/fields/MarkdownField.js +252 -36
  312. package/dist/editor/fields/MdxImageDialog.js +1 -1
  313. package/dist/editor/fields/NumberField.d.ts +2 -0
  314. package/dist/editor/fields/NumberField.js +8 -5
  315. package/dist/editor/fields/NumberListField.d.ts +2 -0
  316. package/dist/editor/fields/NumberListField.js +19 -16
  317. package/dist/editor/fields/ObjectField.d.ts +2 -7
  318. package/dist/editor/fields/ObjectField.js +15 -5
  319. package/dist/editor/fields/ObjectListField.d.ts +29 -0
  320. package/dist/editor/fields/ObjectListField.js +57 -0
  321. package/dist/editor/fields/ReferenceField.d.ts +2 -0
  322. package/dist/editor/fields/ReferenceField.js +34 -9
  323. package/dist/editor/fields/SelectField.d.ts +2 -0
  324. package/dist/editor/fields/SelectField.js +11 -2
  325. package/dist/editor/fields/StringListField.d.ts +4 -1
  326. package/dist/editor/fields/StringListField.js +22 -8
  327. package/dist/editor/fields/TextField.d.ts +2 -0
  328. package/dist/editor/fields/TextField.js +6 -2
  329. package/dist/editor/fields/ToggleField.d.ts +2 -0
  330. package/dist/editor/fields/ToggleField.js +8 -3
  331. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  332. package/dist/editor/fields/entry-link/InsertEntryLink.js +4 -3
  333. package/dist/editor/fields/form-expansion.d.ts +9 -0
  334. package/dist/editor/fields/form-expansion.js +2 -0
  335. package/dist/editor/fields/item-expansion.d.ts +15 -0
  336. package/dist/editor/fields/item-expansion.js +60 -0
  337. package/dist/editor/fields/list-item-keys.d.ts +8 -0
  338. package/dist/editor/fields/list-item-keys.js +50 -0
  339. package/dist/editor/fields/list-item-title.d.ts +20 -0
  340. package/dist/editor/fields/list-item-title.js +85 -0
  341. package/dist/editor/fields/markdown-export-options.d.ts +2 -0
  342. package/dist/editor/fields/markdown-export-options.js +8 -0
  343. package/dist/editor/fields/markdown-fidelity-visitors.d.ts +11 -0
  344. package/dist/editor/fields/markdown-fidelity-visitors.js +160 -0
  345. package/dist/editor/fields/mdx-jsx-support.d.ts +5 -0
  346. package/dist/editor/fields/mdx-jsx-support.js +225 -0
  347. package/dist/editor/fields/rich-text-failures.d.ts +5 -0
  348. package/dist/editor/fields/rich-text-failures.js +26 -0
  349. package/dist/editor/fields/toolbar-wrap.d.ts +7 -0
  350. package/dist/editor/fields/toolbar-wrap.js +35 -0
  351. package/dist/editor/form-outline.d.ts +13 -0
  352. package/dist/editor/form-outline.js +70 -0
  353. package/dist/editor/group-manager/GroupCard.d.ts +4 -1
  354. package/dist/editor/group-manager/GroupCard.js +12 -3
  355. package/dist/editor/group-manager/GroupForm.js +1 -1
  356. package/dist/editor/group-manager/InternalGroupsTab.d.ts +2 -1
  357. package/dist/editor/group-manager/InternalGroupsTab.js +4 -4
  358. package/dist/editor/group-manager/MemberList.d.ts +2 -1
  359. package/dist/editor/group-manager/MemberList.js +15 -4
  360. package/dist/editor/group-manager/hooks/useGroupState.d.ts +5 -0
  361. package/dist/editor/group-manager/hooks/useGroupState.js +30 -7
  362. package/dist/editor/group-manager/index.js +34 -5
  363. package/dist/editor/group-manager/types.d.ts +5 -2
  364. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  365. package/dist/editor/hooks/create-branch-request.js +54 -0
  366. package/dist/editor/hooks/useBranchActions.d.ts +15 -2
  367. package/dist/editor/hooks/useBranchActions.js +53 -19
  368. package/dist/editor/hooks/useBranchManager.d.ts +32 -0
  369. package/dist/editor/hooks/useBranchManager.js +383 -119
  370. package/dist/editor/hooks/useBranchesData.d.ts +14 -4
  371. package/dist/editor/hooks/useBranchesData.js +15 -7
  372. package/dist/editor/hooks/useCommentSystem.d.ts +12 -2
  373. package/dist/editor/hooks/useCommentSystem.js +76 -55
  374. package/dist/editor/hooks/useCommentsData.d.ts +11 -7
  375. package/dist/editor/hooks/useCommentsData.js +20 -19
  376. package/dist/editor/hooks/useDraftManager.d.ts +54 -3
  377. package/dist/editor/hooks/useDraftManager.js +595 -83
  378. package/dist/editor/hooks/useEditorLayout.d.ts +45 -3
  379. package/dist/editor/hooks/useEditorLayout.js +97 -7
  380. package/dist/editor/hooks/useEntriesData.d.ts +2 -1
  381. package/dist/editor/hooks/useEntriesData.js +19 -5
  382. package/dist/editor/hooks/useEntryManager.d.ts +37 -3
  383. package/dist/editor/hooks/useEntryManager.js +204 -41
  384. package/dist/editor/hooks/useGroupManager.js +13 -16
  385. package/dist/editor/hooks/usePermissionManager.js +14 -6
  386. package/dist/editor/hooks/usePreviewMarks.d.ts +21 -0
  387. package/dist/editor/hooks/usePreviewMarks.js +33 -0
  388. package/dist/editor/hooks/useReferenceResolution.d.ts +10 -3
  389. package/dist/editor/hooks/useReferenceResolution.js +34 -177
  390. package/dist/editor/hooks/useSchemaManager.d.ts +25 -6
  391. package/dist/editor/hooks/useSchemaManager.js +97 -39
  392. package/dist/editor/hooks/useUserMetadata.d.ts +14 -4
  393. package/dist/editor/hooks/useUserMetadata.js +76 -47
  394. package/dist/editor/hooks/user-metadata-batcher.d.ts +8 -0
  395. package/dist/editor/hooks/user-metadata-batcher.js +33 -0
  396. package/dist/editor/media/AssetCard.d.ts +6 -7
  397. package/dist/editor/media/AssetCard.js +24 -31
  398. package/dist/editor/media/AssetListRow.d.ts +16 -0
  399. package/dist/editor/media/AssetListRow.js +41 -0
  400. package/dist/editor/media/CropStep.js +6 -1
  401. package/dist/editor/media/MediaLibrary.d.ts +3 -2
  402. package/dist/editor/media/MediaLibrary.js +11 -4
  403. package/dist/editor/media/MediaLibraryBody.d.ts +3 -3
  404. package/dist/editor/media/MediaLibraryBody.js +195 -80
  405. package/dist/editor/media/MediaLibrarySkeleton.d.ts +6 -0
  406. package/dist/editor/media/MediaLibrarySkeleton.js +14 -0
  407. package/dist/editor/media/crop-math.d.ts +3 -4
  408. package/dist/editor/media/crop-math.js +11 -22
  409. package/dist/editor/media/editor-image-src.d.ts +8 -0
  410. package/dist/editor/media/editor-image-src.js +10 -0
  411. package/dist/editor/media/format-bytes.d.ts +2 -0
  412. package/dist/editor/media/format-bytes.js +23 -0
  413. package/dist/editor/media/media-library-prefs.d.ts +23 -0
  414. package/dist/editor/media/media-library-prefs.js +49 -0
  415. package/dist/editor/media/media-library-view.d.ts +34 -0
  416. package/dist/editor/media/media-library-view.js +40 -0
  417. package/dist/editor/media/useAssetLibrary.d.ts +30 -0
  418. package/dist/editor/media/useAssetLibrary.js +126 -0
  419. package/dist/editor/media/useStableAssetOrder.d.ts +11 -0
  420. package/dist/editor/media/useStableAssetOrder.js +37 -0
  421. package/dist/editor/media/useThumbnailFailure.d.ts +6 -0
  422. package/dist/editor/media/useThumbnailFailure.js +16 -0
  423. package/dist/editor/permission-manager/GroupSelector.js +2 -2
  424. package/dist/editor/permission-manager/PermissionEditor.d.ts +1 -1
  425. package/dist/editor/permission-manager/PermissionEditor.js +15 -4
  426. package/dist/editor/permission-manager/PermissionLevelBadge.js +1 -1
  427. package/dist/editor/permission-manager/PermissionTree.d.ts +1 -1
  428. package/dist/editor/permission-manager/UserSelector.d.ts +1 -1
  429. package/dist/editor/permission-manager/UserSelector.js +3 -3
  430. package/dist/editor/permission-manager/index.js +12 -3
  431. package/dist/editor/permission-manager/types.d.ts +5 -2
  432. package/dist/editor/platform.d.ts +3 -0
  433. package/dist/editor/platform.js +9 -0
  434. package/dist/editor/preview-asset-base.d.ts +9 -0
  435. package/dist/editor/preview-asset-base.js +17 -0
  436. package/dist/editor/preview-bridge.d.ts +51 -27
  437. package/dist/editor/preview-bridge.js +103 -144
  438. package/dist/editor/preview-marks.d.ts +15 -0
  439. package/dist/editor/preview-marks.js +74 -0
  440. package/dist/editor/raw-asset-base.d.ts +5 -0
  441. package/dist/editor/raw-asset-base.js +14 -0
  442. package/dist/editor/relative-time.d.ts +4 -4
  443. package/dist/editor/relative-time.js +16 -8
  444. package/dist/editor/schema-editor/CollectionEditor.d.ts +20 -3
  445. package/dist/editor/schema-editor/CollectionEditor.js +361 -62
  446. package/dist/editor/schema-editor/EntryTypeEditor.d.ts +5 -1
  447. package/dist/editor/schema-editor/EntryTypeEditor.js +11 -6
  448. package/dist/editor/schema-editor/collection-draft.d.ts +22 -0
  449. package/dist/editor/schema-editor/collection-draft.js +40 -0
  450. package/dist/editor/schema-editor/index.d.ts +1 -1
  451. package/dist/editor/schema-label.d.ts +10 -0
  452. package/dist/editor/schema-label.js +17 -0
  453. package/dist/editor/settings-conflict.d.ts +5 -0
  454. package/dist/editor/settings-conflict.js +8 -0
  455. package/dist/editor/slug.d.ts +16 -0
  456. package/dist/editor/slug.js +43 -0
  457. package/dist/editor/theme.d.ts +2 -1
  458. package/dist/editor/theme.js +299 -13
  459. package/dist/editor/unanswered.d.ts +17 -0
  460. package/dist/editor/unanswered.js +26 -0
  461. package/dist/editor/unavailable-entry-type.d.ts +20 -0
  462. package/dist/editor/unavailable-entry-type.js +28 -0
  463. package/dist/editor/undo-removal.d.ts +57 -0
  464. package/dist/editor/undo-removal.js +163 -0
  465. package/dist/editor/user-display.d.ts +4 -0
  466. package/dist/editor/user-display.js +5 -0
  467. package/dist/editor/utils/confirm-modal.d.ts +8 -0
  468. package/dist/editor/utils/confirm-modal.js +12 -0
  469. package/dist/editor/utils/editor-errors.d.ts +19 -0
  470. package/dist/editor/utils/editor-errors.js +32 -0
  471. package/dist/editor/utils/focus-return.d.ts +56 -0
  472. package/dist/editor/utils/focus-return.js +106 -0
  473. package/dist/entry-link-resolver.d.ts +11 -1
  474. package/dist/entry-link-resolver.js +65 -5
  475. package/dist/entry-schema-registry.d.ts +3 -3
  476. package/dist/entry-schema-registry.js +7 -4
  477. package/dist/entry-schema.d.ts +49 -3
  478. package/dist/entry-schema.js +18 -0
  479. package/dist/git-manager.d.ts +172 -10
  480. package/dist/git-manager.js +448 -106
  481. package/dist/github-service.d.ts +39 -2
  482. package/dist/github-service.js +101 -7
  483. package/dist/http/handler.js +74 -9
  484. package/dist/http/types.d.ts +2 -0
  485. package/dist/http/worker-not-ready.d.ts +9 -4
  486. package/dist/http/worker-not-ready.js +59 -6
  487. package/dist/index.d.ts +2 -0
  488. package/dist/index.js +3 -0
  489. package/dist/item-keys.d.ts +46 -0
  490. package/dist/item-keys.js +235 -0
  491. package/dist/operating-mode/editor-mode-check.d.ts +15 -0
  492. package/dist/operating-mode/editor-mode-check.js +24 -0
  493. package/dist/operating-mode/mode-env.d.ts +13 -14
  494. package/dist/operating-mode/mode-env.js +13 -14
  495. package/dist/operating-mode/types.d.ts +1 -1
  496. package/dist/paths/branch.d.ts +0 -1
  497. package/dist/paths/branch.js +1 -8
  498. package/dist/paths/index.d.ts +1 -1
  499. package/dist/paths/index.js +1 -1
  500. package/dist/paths/validation.js +0 -2
  501. package/dist/preview.d.ts +8 -0
  502. package/dist/preview.js +7 -0
  503. package/dist/reference-resolver.js +1 -0
  504. package/dist/schema/meta-loader.d.ts +18 -8
  505. package/dist/schema/meta-loader.js +42 -9
  506. package/dist/schema/registry-fingerprint.d.ts +14 -0
  507. package/dist/schema/registry-fingerprint.js +63 -0
  508. package/dist/schema/resolver.d.ts +6 -3
  509. package/dist/schema/resolver.js +6 -5
  510. package/dist/schema/schema-store.d.ts +6 -9
  511. package/dist/schema/schema-store.js +11 -9
  512. package/dist/schema/schema-unavailable-error.d.ts +11 -0
  513. package/dist/schema/schema-unavailable-error.js +15 -0
  514. package/dist/schema/types.d.ts +22 -0
  515. package/dist/schema-registry-record.d.ts +35 -0
  516. package/dist/schema-registry-record.js +134 -0
  517. package/dist/server.d.ts +79 -9
  518. package/dist/server.js +62 -8
  519. package/dist/services.d.ts +33 -5
  520. package/dist/services.js +155 -37
  521. package/dist/settings-workspace.d.ts +23 -3
  522. package/dist/settings-workspace.js +159 -45
  523. package/dist/stamp-on-publish.d.ts +36 -0
  524. package/dist/stamp-on-publish.js +71 -0
  525. package/dist/static/index.d.ts +58 -5
  526. package/dist/static/index.js +133 -3
  527. package/dist/static/seo.d.ts +5 -5
  528. package/dist/static/seo.js +5 -5
  529. package/dist/submission-attribution.d.ts +8 -0
  530. package/dist/submission-attribution.js +22 -0
  531. package/dist/system-users.d.ts +12 -0
  532. package/dist/system-users.js +12 -0
  533. package/dist/task-queue/cms-task-queue.d.ts +1 -1
  534. package/dist/task-queue/cms-task-queue.js +1 -1
  535. package/dist/task-queue/index.d.ts +1 -1
  536. package/dist/task-queue/index.js +1 -1
  537. package/dist/task-queue/task-queue.d.ts +6 -0
  538. package/dist/task-queue/task-queue.js +29 -0
  539. package/dist/task-queue/worker-status.d.ts +35 -9
  540. package/dist/task-queue/worker-status.js +84 -17
  541. package/dist/types.d.ts +90 -9
  542. package/dist/url-exclusivity-fixtures.d.ts +7 -0
  543. package/dist/url-exclusivity-fixtures.js +10 -0
  544. package/dist/user.js +2 -1
  545. package/dist/utils/atomic-write.js +2 -1
  546. package/dist/utils/base-branch.d.ts +13 -0
  547. package/dist/utils/base-branch.js +23 -0
  548. package/dist/utils/content-serialize.d.ts +15 -7
  549. package/dist/utils/content-serialize.js +353 -51
  550. package/dist/utils/content-write-lock.d.ts +5 -0
  551. package/dist/utils/content-write-lock.js +20 -5
  552. package/dist/utils/error.js +13 -2
  553. package/dist/utils/escape-text.d.ts +7 -0
  554. package/dist/utils/escape-text.js +27 -0
  555. package/dist/utils/fs.d.ts +12 -4
  556. package/dist/utils/fs.js +18 -5
  557. package/dist/utils/git.d.ts +17 -1
  558. package/dist/utils/git.js +36 -3
  559. package/dist/utils/json-source-splice.d.ts +14 -0
  560. package/dist/utils/json-source-splice.js +120 -0
  561. package/dist/utils/markdown-body-splice.d.ts +39 -0
  562. package/dist/utils/markdown-body-splice.js +421 -0
  563. package/dist/utils/occ-json-write.js +18 -12
  564. package/dist/utils/provision-log.d.ts +25 -0
  565. package/dist/utils/provision-log.js +49 -0
  566. package/dist/utils/provisioning-lock.d.ts +32 -1
  567. package/dist/utils/provisioning-lock.js +78 -32
  568. package/dist/utils/sanitize-href.d.ts +9 -23
  569. package/dist/utils/sanitize-href.js +14 -28
  570. package/dist/utils/title-field.d.ts +10 -0
  571. package/dist/utils/title-field.js +47 -0
  572. package/dist/utils/yaml-source-splice.d.ts +58 -0
  573. package/dist/utils/yaml-source-splice.js +515 -0
  574. package/dist/validation/deletion-checker.d.ts +21 -29
  575. package/dist/validation/deletion-checker.js +41 -117
  576. package/dist/validation/entry-validator.d.ts +6 -10
  577. package/dist/validation/entry-validator.js +19 -26
  578. package/dist/validation/field-traversal.d.ts +22 -0
  579. package/dist/validation/field-traversal.js +39 -8
  580. package/dist/validation/markdown-safety.d.ts +68 -0
  581. package/dist/validation/markdown-safety.js +596 -0
  582. package/dist/validation/mdx-allowlist.d.ts +55 -0
  583. package/dist/validation/mdx-allowlist.js +183 -0
  584. package/dist/validation/reference-validator.d.ts +2 -0
  585. package/dist/validation/reference-validator.js +3 -0
  586. package/dist/version.d.ts +1 -1
  587. package/dist/version.js +1 -1
  588. package/dist/worker/branch-policy.d.ts +124 -0
  589. package/dist/worker/branch-policy.js +243 -0
  590. package/dist/worker/bundle-header.d.ts +117 -0
  591. package/dist/worker/bundle-header.js +253 -0
  592. package/dist/worker/cms-worker.d.ts +236 -142
  593. package/dist/worker/cms-worker.js +772 -285
  594. package/dist/worker/gateway-egress.d.ts +43 -0
  595. package/dist/worker/gateway-egress.js +209 -0
  596. package/dist/worker/gateway-instance.d.ts +42 -0
  597. package/dist/worker/gateway-instance.js +129 -0
  598. package/dist/worker/git-process.d.ts +81 -0
  599. package/dist/worker/git-process.js +234 -0
  600. package/dist/worker/git-sync.d.ts +34 -17
  601. package/dist/worker/git-sync.js +394 -131
  602. package/dist/worker/github-auth.d.ts +8 -8
  603. package/dist/worker/github-auth.js +12 -14
  604. package/dist/worker/github-gateway-http.d.ts +62 -0
  605. package/dist/worker/github-gateway-http.js +977 -0
  606. package/dist/worker/github-gateway-wire.d.ts +589 -0
  607. package/dist/worker/github-gateway-wire.js +484 -0
  608. package/dist/worker/github-gateway.d.ts +238 -0
  609. package/dist/worker/github-gateway.js +726 -0
  610. package/dist/worker/github-mirror-upkeep.d.ts +75 -0
  611. package/dist/worker/github-mirror-upkeep.js +266 -0
  612. package/dist/worker/github-mirror.d.ts +364 -0
  613. package/dist/worker/github-mirror.js +1030 -0
  614. package/dist/worker/github-transfer.d.ts +56 -0
  615. package/dist/worker/github-transfer.js +444 -0
  616. package/dist/worker/history-rewrite.d.ts +3 -4
  617. package/dist/worker/history-rewrite.js +11 -16
  618. package/dist/worker/provisioned-workspace.d.ts +5 -5
  619. package/dist/worker/provisioned-workspace.js +5 -5
  620. package/dist/worker/rebase.d.ts +1 -1
  621. package/dist/worker/rebase.js +58 -54
  622. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  623. package/dist/worker/remote-git-maintenance.js +12 -0
  624. package/dist/worker/schema-gate.d.ts +49 -0
  625. package/dist/worker/schema-gate.js +226 -0
  626. package/dist/worker/self-checks.d.ts +107 -0
  627. package/dist/worker/self-checks.js +358 -0
  628. package/dist/worker/shared-repo-git.d.ts +96 -0
  629. package/dist/worker/shared-repo-git.js +459 -0
  630. package/dist/worker/sparse-cone.d.ts +20 -0
  631. package/dist/worker/sparse-cone.js +136 -0
  632. package/dist/worker/stale-mirror.d.ts +82 -0
  633. package/dist/worker/stale-mirror.js +130 -0
  634. package/dist/worker/task-runner.d.ts +5 -3
  635. package/dist/worker/task-runner.js +287 -186
  636. package/dist/worker/worker-context.d.ts +36 -37
  637. package/package.json +31 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,2095 @@
1
+ # Changelog
2
+
3
+ What changed in CanopyCMS, what you must do to adopt it, and **what you can now delete**. The five
4
+ packages release together, at one version.
5
+
6
+ ## This build
7
+
8
+ Version `0.1.0-int.115`, built from [`f8b50b3`](https://github.com/safeinsights/canopycms/tree/f8b50b30170954bf52fb670afe6796d2cb8fa275).
9
+
10
+ ### New since int.114
11
+
12
+ - [AI content can withhold or redirect a link to an entry with no page](#ai-content-can-withhold-or-redirect-a-link-to-an-entry-with-no-page)
13
+ - [toPlainText takes an entry body and keeps prose after a leading --- — behaviour change](#toplaintext-takes-an-entry-body-and-keeps-prose-after-a-leading------behaviour-change)
14
+ - [People without a name show "Name unavailable"](#people-without-a-name-show-name-unavailable)
15
+ - [Branch access lists stay with admins and reviewers — behaviour change](#branch-access-lists-stay-with-admins-and-reviewers--behaviour-change)
16
+ - [A branch lockdown outranks the branch's access list — security fix](#a-branch-lockdown-outranks-the-branchs-access-list--security-fix)
17
+ - [The base-branch conflict notice stays until the branch merges — behaviour change](#the-base-branch-conflict-notice-stays-until-the-branch-merges--behaviour-change)
18
+ - [The editor calls the content drawer "All content"](#the-editor-calls-the-content-drawer-all-content)
19
+ - [The Branches drawer shows one action per branch, and comments reply in place](#the-branches-drawer-shows-one-action-per-branch-and-comments-reply-in-place)
20
+ - [An object's Clear offers Undo, and refusals say why](#an-objects-clear-offers-undo-and-refusals-say-why)
21
+ - [The collection dialog says what saves at once](#the-collection-dialog-says-what-saves-at-once)
22
+ - [Comments and branches name their people for every editor — behaviour change on two APIs](#comments-and-branches-name-their-people-for-every-editor--behaviour-change-on-two-apis)
23
+ - [A date picker, a form header, and a toolbar that wraps](#a-date-picker-a-form-header-and-a-toolbar-that-wraps)
24
+ - [Editor colours reach 4.5:1 in light and dark mode](#editor-colours-reach-451-in-light-and-dark-mode)
25
+ - [Editor contrast, and blocked buttons stay focusable](#editor-contrast-and-blocked-buttons-stay-focusable)
26
+ - [Editor focus rings and checked controls reach 3:1 for any brand](#editor-focus-rings-and-checked-controls-reach-31-for-any-brand)
27
+ - [Editor requests time out instead of hanging](#editor-requests-time-out-instead-of-hanging)
28
+ - [Field editing: descriptions, list-card titles, and public/ images, continued](#field-editing-descriptions-list-card-titles-and-public-images-continued)
29
+ - [List items and blocks collapse, and reorder from one menu](#list-items-and-blocks-collapse-and-reorder-from-one-menu)
30
+ - ["Add entry" follows path permissions; admin panels and labels tidied](#add-entry-follows-path-permissions-admin-panels-and-labels-tidied)
31
+ - [Quick open, a navigator filter, title-first new entries, and media search](#quick-open-a-navigator-filter-title-first-new-entries-and-media-search)
32
+ - [A save never brings back a deleted or moved entry — behaviour change](#a-save-never-brings-back-a-deleted-or-moved-entry--behaviour-change)
33
+ - [Submit names unsaved entries; unanswered writes recover more](#submit-names-unsaved-entries-unanswered-writes-recover-more)
34
+ - [Unlabelled fields and block templates show their names as words](#unlabelled-fields-and-block-templates-show-their-names-as-words)
35
+ - [Stable releases follow the changes they carry, and ship their changelog](#stable-releases-follow-the-changes-they-carry-and-ship-their-changelog)
36
+ - [List items and blocks carry a \_key, and field comments follow it — behaviour change](#list-items-and-blocks-carry-a-_key-and-field-comments-follow-it--behaviour-change)
37
+ - [stampOnPublish dates a new entry at its first submit](#stamponpublish-dates-a-new-entry-at-its-first-submit)
38
+
39
+ If you upgraded from a build on another branch, compare the `<!-- changeset: … -->` slugs in your previous build's `CHANGELOG.md` (`npm pack canopycms@<old>` fetches it) with this one's: entries whose slug your old file lacks are new to you. Legacy entries above the changeset entries carry hand-written int labels.
40
+
41
+ ## How to use this document
42
+
43
+ Each entry has up to three parts:
44
+
45
+ - **What changed**: the package-side change.
46
+ - **To adopt**: what you do in your repo.
47
+ - **Now deletable**: the kind of local code the change supersedes. **This part is the point.**
48
+ Adding the new API without removing what it replaces leaves two implementations to drift apart.
49
+
50
+ **Read the copy your build ships.** From the first `0.1.0` prerelease on, every published version carries this file as
51
+ `node_modules/canopycms/CHANGELOG.md`, assembled for that build: its **This build** section lists
52
+ what is new since the previous int on the same branch, or on a stable release links its entries, and its links point at the commit the build
53
+ came from. The copy in the repository holds released history and the entries from before changesets;
54
+ every entry written since is a file in [`.changeset/`](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/.changeset/README.md) until a stable release
55
+ moves it here.
56
+
57
+ **Find your starting point.** From a stable pin, start at the first version under **Released**
58
+ newer than it, and below `0.0.63` read the notes for `0.0.62 and earlier` too. From an int
59
+ prerelease such as `0.0.68-int.101`, read only the **Unreleased** entries, or labelled parts of
60
+ entries, carrying a higher int number (the table lists later parts in parentheses). Moving from
61
+ an int prerelease to a stable release, read that release's section the same way: its entries keep
62
+ their int labels.
63
+
64
+ **Find your target.** `npm view canopycms version` reports the `latest` dist-tag, the newest
65
+ stable release. On the int channel, the target's own `CHANGELOG.md` is the one to read.
66
+
67
+ **Read every window between the two, oldest first.** Deletable-code lists compound. Where a later
68
+ entry replaces an earlier one's advice, the earlier one points to it.
69
+
70
+ ---
71
+
72
+ ## Unreleased
73
+
74
+ **int** is the first int prerelease carrying an entry, and each part of the entry is labelled
75
+ `(int.N)` the same way; a number in parentheses after a change is a later int that added a part.
76
+ **Action** says who must act; **Template first** means that with `workerCode: { source: 'parameter' }`,
77
+ you deploy the stack template before CI rolls that int's bundle.
78
+
79
+ Nothing in this section is edited by hand: each entry is a `.changeset/` file, which a build
80
+ assembles here and labels with the first int that carries it, and a stable release moves the
81
+ entries under **Released**.
82
+
83
+ <!-- legacy:start -->
84
+
85
+ The entries below predate changesets and keep their hand-written labels.
86
+
87
+ | int | Area | Change | Action |
88
+ | --- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
89
+ | 93 | Branches | [Path rules match logical paths](#path-permission-rules-now-take-effect-below-the-content-root--security-fix-breaking-for-rules-written-with-on-disk-names) | Review rules |
90
+ | 93 | Branches | [`.canopy-meta/` must not be committed](#canopy-meta-must-not-be-committed-and-system-health-says-so) | If tracked |
91
+ | 93 | Branches | [`autoCreateSettingsPR` removed](#settings-saves-only-push-the-settings-branch--breaking-config-autocreatesettingspr-is-removed) | Required |
92
+ | 93 | Reading | [Listings take `branch`; reads report `slug`/`urlPath`](#content-reads-listings-take-a-branch-and-reads-report-slug-and-urlpath) (102) | Index pages |
93
+ | 93 | Editing | [Field descriptions, list-card titles, `public/` images](#field-editing-descriptions-list-card-titles-and-public-images) (96, 103) | None |
94
+ | 93 | Branches | [Commits name editors; submit refuses an empty branch](#commits-name-their-editors-and-submit-refuses-a-branch-with-nothing-to-submit) (108, 110) | Scripts |
95
+ | 94 | Branches | [Reads never create a branch](#reads-never-create-a-requested-branch--security-fix-breaking-for-some-direct-createcontentreader-callers) | Direct callers |
96
+ | 94 | Preview | [Preview URLs: one prefix, `trailingSlash`, own page](#preview-urls-take-one-prefix-follow-trailingslash-and-load-each-entrys-own-page--breaking-env) (98) | Required |
97
+ | 94 | Preview | [`createPreviewPage`, `/preview` entries](#the-preview-route-and-preview-entries--breaking-imports) (95, 101, 103) | Required |
98
+ | 94 | Refs | [Restricted and missing references](#a-reference-resolves-to-its-target-a-restricted-stub-or-a-missing-stub--security-fix-breaking-types-and-build) (106) | Required |
99
+ | 94 | CDK | [`attachTo`, editor response headers](#canopycms-cdk-canopycmsserviceattachto-and-editor-response-headers--behaviour-change-if-you-frame-the-cms) | If hand-wired or framed |
100
+ | 94 | Ops | [System health shows the build](#system-health-shows-which-build-is-running) | Optional |
101
+ | 100 | Branches | [Crash-safe branch creation](#fast-crash-safe-branch-creation--behaviour-change-branch-clones-hold-only-the-content-root) | Upgrade together |
102
+ | 100 | Ops | [Prod workspace defaults to `/mnt/efs`](#the-prod-workspace-defaults-to-mntefs--behaviour-change-if-you-run-prod-without-canopycms_workspace_root) | Non-CDK prod |
103
+ | 100 | Assets | [`AssetSupport` serves from S3 only](#canopycms-cdk-assetsupport-serves-images-from-s3-only--breaking) | Required |
104
+ | 100 | Assets | [`collect-asset-refs`, `materialize-assets`](#collect-asset-refs-and-materialize-assets-store-a-builds-images-before-release) (104) | With S3-only |
105
+ | 100 | Assets | [Editor images via signed-in route](#the-editor-and-its-live-preview-load-images-through-the-signed-in-asset-route) | CSP, origin |
106
+ | 100 | Assets | [`assetUrl` applies crop](#asseturl-applies-an-image-values-crop--behaviour-change-for-cropped-images) | Scale `<img>` |
107
+ | 100 | Assets | [`media.publicBaseUrl` removed; wider limits](#mediapublicbaseurl-is-removed-and-image-limits-are-wider--breaking-config) | Delete key |
108
+ | 101 | Editing | [Saves rewrite only what changed](#a-save-rewrites-only-what-changed) (103) | None |
109
+ | 102 | Auth | [`unauthenticatedStatus: 419`](#unauthenticatedstatus-answer-signed-out-api-calls-with-419) | Basic-auth sites |
110
+ | 105 | Editing | [MDXEditor 4.3](#the-markdown-editor-runs-mdxeditor-43) | Direct deps |
111
+ | 106 | Refs | [AI content links references](#ai-content-links-a-reference-to-its-target--behaviour-change-for-ai-output-and-ai-config-callbacks) | AI config |
112
+ | 106 | Refs | [Deleting a referenced entry asks](#deleting-a-referenced-entry-asks-first--behaviour-change-on-the-delete-api) | Scripts |
113
+ | 107 | Schema | [Unknown schemas cost one entry type](#a-schema-the-running-code-lacks-costs-one-entry-type-and-holds-the-worker--behaviour-change) | Rare |
114
+ | 108 | MDX | [MDX that runs code is refused](#mdx-content-that-runs-code-is-refused-at-save--breaking-behaviour) (109, 110) | Required |
115
+ | 108 | Worker | [On-demand worker, drain; `spotMaxPrice` removed](#canopycms-cdk-the-worker-drains-before-replacement-and-runs-on-demand--breaking-props-spotmaxprice-is-removed-behaviour-and-cost-change) | If set |
116
+ | 109 | Worker | [Hardened worker instance](#canopycms-cdk-the-worker-instance-is-hardened--an-existing-stack-upgrades-in-two-deploys-behaviour-and-cost-change) | Two deploys |
117
+ | 109 | Worker | [Failed or stopped worker says why](#a-failed-or-stopped-worker-says-why--behaviour-change-on-the-not-ready-503-new-worker-apis) | Custom entrypoint |
118
+ | 109 | Worker | [CI worker roll; worker-down alarm](#canopycms-cdk-ci-can-roll-the-worker-with-a-parameter-and-alarm-when-it-stops-syncing--new-opt-in) | Optional |
119
+ | 109 | Preview | [Typed `fieldProps`](#preview-fieldprops-is-typed-with-server-safe-helpers--breaking-types-and-schemas) | Required |
120
+ | 110 | Worker | [Worker needs a state directory](#canopycms-cdk-the-worker-needs-a-state-directory--deploy-the-template-before-the-bundle) | Template first |
121
+ | 110 | Ops | [Prod detects an unset `defaultBaseBranch`](#prod-detects-an-unset-defaultbasebranch-instead-of-assuming-main--behaviour-change-startup-can-fail) | Base ≠ repo default |
122
+ | 111 | CDK | [Example workflow's triggers and checks](#the-aws-example-workflow-gains-the-templates-triggers-and-dependency-checks) | If copied by hand |
123
+ | 111 | Ops | [Duplicate-ID scan only on request](#get-adminbranch-health-scans-for-duplicate-content-ids-only-on-request--behaviour-change) | Admin-API scripts |
124
+ | 111 | Auth | [CMS image builds a prod editor; mismatch blocks](#the-cms-image-builds-a-prod-editor-and-a-mode-mismatch-blocks-the-editor--behaviour-change-a-hand-built-image-can-fail-its-build) | Hand-built images |
125
+ | 111 | Auth | [Auth plugins look users up in batches](#auth-plugins-look-users-up-in-batches) | Custom plugins |
126
+ | 112 | Worker | [Bundle states the template it needs](#canopycms-cdk-a-worker-bundle-states-the-template-it-needs--template-first-for-the-gate-only) | Template first |
127
+ | 112 | Worker | [Poisoned `remote.git` re-clones](#a-poisoned-remotegit-re-clones-itself) | None |
128
+ | 112 | Worker | [Nano worker boots reliably](#canopycms-cdk-a-t4gnano-worker-boots-reliably--behaviour-change-a-deploy-replaces-the-worker) | Deploy |
129
+ | 112 | Worker | [Worker never pushes base or default branch](#the-worker-never-pushes-the-base-or-default-branch--security-fix) (114) | Protect base on GitHub |
130
+ | 112 | Worker | [Worker log is root-owned](#canopycms-cdk-the-workers-log-is-root-owned--security-fix) | Deploy; hand-installed units |
131
+ | 113 | Worker | [Push logs say whether GitHub moved](#the-workers-push-logs-say-whether-github-moved) | None |
132
+ | 113 | Editing | [Unsaved ACL changes show; closing asks](#unsaved-group-and-permission-changes-show-and-closing-asks-first) | None |
133
+ | 113 | Worker | [Credential in a per-command git config](#the-worker-hands-git-its-github-credential-in-a-per-command-file--security-fix-dev-behaviour-change) | Dev: proxy or CA in env |
134
+ | 114 | Worker | [GitHub objects cross as bundles](#the-worker-moves-objects-between-github-and-remotegit-as-bundles) | None |
135
+ | 114 | AI | [`noindex` entries leave AI content](#ai-content-leaves-out-noindex-entries--behaviour-change-for-ai-output) | `exclude.noindex: false` |
136
+ | 114 | Worker | [Worker follows a rebased or deleted branch](#the-worker-follows-a-rebased-or-deleted-github-branch) | None |
137
+ | 114 | Worker | [Worker holds no secret (contract 2)](#canopycms-cdk-the-worker-holds-no-secret--security-fix-template-first-contract-2) | Template first |
138
+ | 115 | AI | [referenceLink decides where AI-export entry links point](#ai-content-can-withhold-or-redirect-a-link-to-an-entry-with-no-page) | Optional |
139
+ | 115 | AI | [toPlainText no longer strips front matter](#toplaintext-takes-an-entry-body-and-keeps-prose-after-a-leading------behaviour-change) | Whole-file callers split first |
140
+ | 115 | Auth | [Clerk users without a name no longer show their id](#people-without-a-name-show-name-unavailable) | None |
141
+ | 115 | Branches | [Non-privileged callers get only their own branch ACL entries](#branch-access-lists-stay-with-admins-and-reviewers--behaviour-change) | None |
142
+ | 115 | Branches | [managerOrAdminAllowed now refuses editors an access list names](#a-branch-lockdown-outranks-the-branchs-access-list--security-fix) | Optional |
143
+ | 115 | Branches | [Conflict notice persists until merge](#the-base-branch-conflict-notice-stays-until-the-branch-merges--behaviour-change) | None |
144
+ | 115 | Editing | [Breadcrumb root and file menu say "All content"](#the-editor-calls-the-content-drawer-all-content) | Editor UI tests |
145
+ | 115 | Editing | [Branches drawer and comment replies](#the-branches-drawer-shows-one-action-per-branch-and-comments-reply-in-place) | Editor UI tests |
146
+ | 115 | Editing | [Clear offers Undo; refusals and conflicts explain themselves](#an-objects-clear-offers-undo-and-refusals-say-why) | None |
147
+ | 115 | Editing | [Collection dialog says what saves at once](#the-collection-dialog-says-what-saves-at-once) | Editor UI tests |
148
+ | 115 | Editing | [Comments and branches name their people](#comments-and-branches-name-their-people-for-every-editor--behaviour-change-on-two-apis) | Direct API callers |
149
+ | 115 | Editing | [Date picker, form header, wrapping toolbar](#a-date-picker-a-form-header-and-a-toolbar-that-wraps) | Editor UI tests |
150
+ | 115 | Editing | [Editor colours reach contrast in both schemes](#editor-colours-reach-451-in-light-and-dark-mode) | Visual snapshot tests |
151
+ | 115 | Editing | [Editor contrast; blocked buttons stay focusable](#editor-contrast-and-blocked-buttons-stay-focusable) | Browser tests |
152
+ | 115 | Editing | [Focus ring, control borders, light badges, sentence-case badges](#editor-focus-rings-and-checked-controls-reach-31-for-any-brand) | Visual snapshot tests |
153
+ | 115 | Editing | [Editor requests time out](#editor-requests-time-out-instead-of-hanging) | Slow-API browser tests |
154
+ | 115 | Editing | [Field descriptions, list-card titles, `public/` images, continued](#field-editing-descriptions-list-card-titles-and-public-images-continued) | None |
155
+ | 115 | Editing | [List items and blocks collapse](#list-items-and-blocks-collapse-and-reorder-from-one-menu) | Editor UI tests |
156
+ | 115 | Editing | [Navigator hides "Add entry" where path permissions refuse it](#add-entry-follows-path-permissions-admin-panels-and-labels-tidied) | None |
157
+ | 115 | Editing | [Quick open, navigator filter, title-first entries, media search](#quick-open-a-navigator-filter-title-first-new-entries-and-media-search) | Editor UI tests |
158
+ | 115 | Editing | [Saving a deleted or moved entry is refused](#a-save-never-brings-back-a-deleted-or-moved-entry--behaviour-change) | None |
159
+ | 115 | Editing | [Submit warns about unsaved entries; unanswered writes keep input](#submit-names-unsaved-entries-unanswered-writes-recover-more) | None |
160
+ | 115 | Editing | [Unlabelled field names are humanised](#unlabelled-fields-and-block-templates-show-their-names-as-words) | Editor UI tests |
161
+ | 115 | Ops | [Stable versions follow changesets; CHANGELOG.md ships](#stable-releases-follow-the-changes-they-carry-and-ship-their-changelog) | None |
162
+ | 115 | Reading | [List items and blocks gain a persisted `_key`](#list-items-and-blocks-carry-a-_key-and-field-comments-follow-it--behaviour-change) | Rename fields named _key |
163
+ | 115 | Schema | [Date new entries at their first submit](#stamponpublish-dates-a-new-entry-at-its-first-submit) | Optional |
164
+
165
+ ### Preview URLs take one prefix, follow `trailingSlash`, and load each entry's own page — **breaking (env)**
166
+
167
+ **What changed.**
168
+
169
+ - (int.94) `editor.previewPrefix` (a path or `https://` URL) goes in front of every preview URL,
170
+ site-relative `previewBase` routes included. Preview URLs follow `trailingSlash`, and the preview
171
+ bridge matches a page with or without one. `CANOPY_API_TRAILING_SLASH` is renamed
172
+ `CANOPY_TRAILING_SLASH`.
173
+ - (int.98) The preview pane loads each entry's `urlPath`: `content/about` previews `/about`, not
174
+ `/`. A `previewBase` value of `false` marks an entry with no page, and the pane says so.
175
+
176
+ **To adopt.** Set `editor: { previewPrefix: '/preview' }` once that route exists (next entry), and
177
+ drop the prefix from any `previewBase` value that spells it. Rename a `CANOPY_API_TRAILING_SLASH`
178
+ you set yourself. Key an entry without a page `false` (`'content/settings': false`). A root entry
179
+ served at `/` without an `index` slug needs `'content/<slug>': '/'`, or re-model it as a root index
180
+ ([0.0.64](#sitemap-pathfor-and-modelling-a-page-served-at--as-a-root-index-entry)).
181
+
182
+ **Now deletable.** `previewBase` keys that only added a shared prefix or restate a root entry's own
183
+ URL.
184
+
185
+ ### The preview route and `/preview` entries — **breaking (imports)**
186
+
187
+ **What changed.**
188
+
189
+ - (int.94) The context's `createPreviewPage({ views })` serves a `[[...path]]` route at
190
+ `editor.previewPrefix`, rendering `?branch=` through `views[entryType]`, so a static-export site
191
+ can preview a branch.
192
+ - (int.95) `previewView({ view, load })` feeds a view server-read `extras`.
193
+ - (int.101) `withCanopyPreview` and `CanopyPreviewViewProps` come from `canopycms-next/preview`, and
194
+ preview hooks like `useCanopyPreview` from `canopycms/preview`. The `/client` entries are the
195
+ editor: they put Mantine's unlayered CSS over the site's styles in the preview, and ship the
196
+ editor to public pages.
197
+ - (int.103) On that route a `withCanopyPreview` view is not server-rendered. It renders right after
198
+ hydration with its `/assets/t/` URLs behind the signed-in asset route, so a crop no build made
199
+ never returns 403; server components from `load` get the same prefix. Until hydration the view's
200
+ area is empty, so content below it can shift. A view that throws fails in the browser, and its
201
+ effects run after the preview's ready message. Pages calling `useCanopyPreview` themselves are
202
+ unchanged.
203
+
204
+ **To adopt.**
205
+
206
+ 1. Import preview APIs, and point their test mocks, at the `/preview` entries. A
207
+ `vi.mock('canopycms-next/client', …)` silently stops applying.
208
+ 2. For a branch preview: wrap views with `withCanopyPreview` in a `'use client'` module, add
209
+ `app/preview/[[...path]]/page.server.tsx`, set `editor.previewPrefix: '/preview'`, and serve that
210
+ route with `frame-ancestors 'self'`, not `X-Frame-Options: DENY` (see
211
+ [Live Preview](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#live-preview)).
212
+ 3. A test reading view content from the route's server HTML must load the page in a browser.
213
+ Server output rendered during a preview request carries the signed-in asset prefix; keep it out
214
+ of cross-request caches (`unstable_cache`, module memos).
215
+
216
+ **Now deletable.** A hand-built branch-preview route, and CSS layer or specificity workarounds that
217
+ kept site styles ahead of Mantine's in the preview.
218
+
219
+ ### Preview `fieldProps` is typed, with server-safe helpers — **breaking (types and schemas)**
220
+
221
+ **What changed.** (int.109) `fieldProps` checks each path against the view's content type, and
222
+ `canopycms` exports `FieldProps`, `FieldAttrs`, `fieldAttrs` and `scopeFieldProps`, callable from
223
+ server components. A string path marks the form's spelling (`'a.0.b'` marks `a[0].b`), and an empty
224
+ path marks nothing. The editor counts and logs marks naming no field. `createEntrySchemaRegistry`
225
+ refuses a field name that is empty, all digits or contains `.`, `[` or `]`. See
226
+ [Live Preview](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#live-preview).
227
+
228
+ **To adopt.** Fix each path that stops compiling; the error lists valid ones. Rewrite computed paths
229
+ as literal segments, or type that component's prop as plain `FieldProps`. Pass `fieldProps` as
230
+ `undefined` on public pages. Rename refused fields and their content keys.
231
+
232
+ **Now deletable.** A local `FieldProps` type, `fieldAttrs`/`scopeFieldProps` helpers and no-op
233
+ `fieldProps` defaults; schema-walking tests for misspelled mark paths, once components are typed
234
+ (the editor flags another block template's field).
235
+
236
+ ### A reference resolves to its target, a restricted stub, or a missing stub — **security fix; breaking (types and build)**
237
+
238
+ **What changed.**
239
+
240
+ - (int.94) A target the reader may not read resolves to
241
+ `{ id, slug, collection, urlPath, title, unavailable: true, reason: 'restricted' }`
242
+ (`RestrictedReference`), never its data. Static builds are unchanged. A schema may not declare a
243
+ top-level field named `unavailable`.
244
+ - (int.106) An id naming no entry resolves to `{ id, unavailable: true, reason: 'missing' }`
245
+ (`MissingReference`), not `null`, and a production build fails on one, naming entry, field path
246
+ and id. Saving the referring entry keeps the id; a save adding a new dangling id is refused.
247
+ - (int.106) The live preview resolves references inside objects, object lists and blocks too. One
248
+ still resolving is `null`, even in an entry's first draft. `isLoading` mirrors the data at depth,
249
+ typed `PreviewLoadingState<T>`, so a reference's entry is `boolean | undefined`.
250
+
251
+ A `resolvedSchema` reference infers as `Target | UnavailableReference | null` (`RestrictedReference`
252
+ before int.106).
253
+
254
+ **To adopt.** Narrow on `unavailable`, then on `reason`; only a restricted reference has a title
255
+ and URL:
256
+
257
+ ```tsx
258
+ if (!ref) return null
259
+ if (ref.unavailable) {
260
+ return ref.reason === 'restricted' ? <a href={ref.urlPath}>{ref.title}</a> : null // 'missing'
261
+ }
262
+ ```
263
+
264
+ `isResolvedReference(ref)` (from `canopycms`) is true only for the target itself. Test
265
+ `reason === 'missing'` where you tested `null`, and write `Exclude<…, UnavailableReference>` where
266
+ you wrote `Exclude<…, RestrictedReference>`. Rename any `unavailable` field. Repoint or clear each
267
+ reference the build lists, or set `danglingReferences: 'warn'`. Make a prop fed from `isLoading`
268
+ optional if it was required.
269
+
270
+ **Now deletable.** Hand-written reference narrowing (string id, `null`, `unavailable` checks), a
271
+ content-integrity test checking reference fields against entry ids, and top-level-only reference
272
+ fields kept for the preview's sake.
273
+
274
+ ### AI content links a reference to its target — **behaviour change for `/ai` output and AI config callbacks**
275
+
276
+ **What changed.** (int.106) A `reference` field renders as a link to its target, not the stored id:
277
+ a byline reading `**Author:** 5NVkkrB1MJUv` now links the name `Alice` to that entry's page, then to
278
+ its markdown copy under the AI config's new `mountPath` (default `/ai`). A gone target renders as
279
+ `(missing entry <id>)`, one left out by `exclude` as `(unavailable entry <id>)`. An md/mdx entry's
280
+ object or block field renders as a section, not `[object Object]`.
281
+
282
+ `where` predicates and transforms receive an `AIReferenceValue` (from `canopycms/ai`), not the id:
283
+
284
+ ```ts
285
+ // before
286
+ where: (entry) => entry.data.author === ALICE_ID
287
+ // after
288
+ where: (entry) => (entry.data.author as AIReferenceValue | undefined)?.id === ALICE_ID
289
+ ```
290
+
291
+ **To adopt.** `entry.data` is untyped, so an old comparison still compiles and silently stops
292
+ matching: search your AI config for each reference field's name.
293
+
294
+ **Now deletable.** A `fieldTransforms` entry that looked up a reference id to print its title.
295
+
296
+ ### AI content leaves out `noindex` entries — **behaviour change for `/ai` output**
297
+
298
+ **What changed.** (int.114) AI content leaves out entries marked `noindex`, both from
299
+ `generate-ai-content` and from the `/ai` route, using the same predicate as the sitemap and robots
300
+ metadata. An inline body `entry:` link to an entry the export leaves out (by `exclude`, `noindex`
301
+ or a failed render) renders as its plain text, with no URL; any other form of the link points at
302
+ `#`.
303
+
304
+ **To adopt.** To keep exporting `noindex` entries, set `exclude: { noindex: false }`. If your SEO
305
+ fields are nested or renamed, pass the AI config's `seo` the same object you pass
306
+ `createNextCanopyContext({ seo })`, so the sitemap and AI content read the flag from the same
307
+ place. A field that links to a hidden section by plain URL needs a field transform; the README
308
+ shows [the recipe](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#ai-content-configuration).
309
+
310
+ **Now deletable.** An `exclude.where` that re-read the `noindex` flag by hand.
311
+
312
+ ### Deleting a referenced entry asks first — **behaviour change on the delete API**
313
+
314
+ **What changed.** (int.106) Deleting an entry that other entries reference (by field or `entry:`
315
+ link) returns 409 with `data.referencedBy`: the referencing entries the user may read, and a
316
+ `hiddenCount` of the rest. `?confirmReferenced=true` deletes anyway; the editor's dialog offers
317
+ **Delete anyway**.
318
+
319
+ **To adopt.** Only scripts calling the delete endpoint: pass the flag or handle the 409.
320
+
321
+ ### `mdx` content that runs code is refused at save — **breaking (behaviour)**
322
+
323
+ **What changed.** (int.108) An `mdx` field or body refuses `{…}` expressions other than comments and
324
+ plain values, `import`/`export`, and tags, attributes and URL schemes outside a safe set; other
325
+ markdown refuses those URLs. New: field option `executable`; type `MarkdownFieldConfig`. (int.109)
326
+ A `markdown` field with `renderAs: 'mdx'` is checked as MDX, and `mdxAllow` narrows the safe set per
327
+ field or site-wide. New: field options `renderAs`, `mdxAllow`; config key `mdxAllow`; type
328
+ `MdxAllowlist`. (int.110) A component prop allowance can be `'string'` (or
329
+ `{ type: 'string', maxLength }`): a quoted value only, never bare or `{…}`. See
330
+ [MDX content cannot run code](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#mdx-content-cannot-run-code).
331
+
332
+ **To adopt.** Set `renderAs: 'mdx'` on each `markdown` field your site compiles as MDX, and
333
+ `mdxAllow` to what your renderer takes. Set `executable: true` only on a field whose editors you
334
+ trust as code authors; an entry type with no `isBody` field needs one declared to opt its body out.
335
+ Content already there is kept, with a warning, while its field is saved unchanged; a production
336
+ build lists it.
337
+
338
+ **Now deletable.** A `validateEntry` rule refusing expressions, ESM, tags, components or
339
+ `javascript:` links in MDX or markdown rendered as MDX, and the path-prefix matching behind its
340
+ entry-type gate. Keep a rule checking that the body compiles.
341
+
342
+ ### A save rewrites only what changed
343
+
344
+ **What changed.**
345
+
346
+ - (int.101) In YAML and JSON entries and md/mdx frontmatter, untouched values, comments, blank
347
+ lines and (in `.yaml` and `.json` files) CRLF line endings stay as written, and an edited `>-`,
348
+ `|` or quoted value keeps that style where it can. Anchors, aliases and rare layouts still
349
+ re-serialise the whole file. This extends [0.0.64's comment
350
+ preservation](#editor-saves-keep-comments-and-report-content-keys-the-schema-does-not-define-29).
351
+ - (int.103) An md/mdx body save keeps every untouched block verbatim (markers, escapes, JSX, blank
352
+ lines, CRLF), so `prettier --check` passes. New and edited blocks use Prettier's markers (`-`,
353
+ `_emphasis_`, `---`); a new entry's body starts after a blank line. A hard break or a bare URL
354
+ before punctuation still changes on the first edit. A body with text after a nested list in a
355
+ list item opens as source.
356
+
357
+ **To adopt.** Nothing.
358
+
359
+ **Now deletable.** A formatter pass or `.prettierignore` entry over CMS-written content that only
360
+ undoes the editor's re-folding, restyling or date rewriting.
361
+
362
+ ### The markdown editor runs MDXEditor 4.3
363
+
364
+ **What changed.** (int.105) `@mdxeditor/editor` is `^4.3.2` (Lexical 0.48), up from `^3.52.4`.
365
+ Two-paragraph list items, multi-block quotes and code fences in any language edit in rich text.
366
+
367
+ **To adopt.** Nothing, unless your app depends on `@mdxeditor/editor` or `lexical` directly: align
368
+ those versions so one copy loads.
369
+
370
+ ### Unsaved group and permission changes show, and closing asks first
371
+
372
+ **What changed.** (int.113) Manage Groups and Manage Permissions still save as one batch, but now
373
+ mark unsaved changes, keep the save bar in view, and ask before closing or leaving drops them. The
374
+ group dialog's buttons read "Add" and "Apply", since neither saves.
375
+
376
+ **To adopt.** Nothing.
377
+
378
+ ### Field editing: descriptions, list-card titles, and `public/` images
379
+
380
+ **What changed.**
381
+
382
+ - (int.93) An `object` field with `list: true` takes `itemTitleField`, naming a direct `string` or
383
+ `number` child whose value titles each card (else `<label> #N`). Cards no longer repeat the label
384
+ as an inner legend.
385
+ - (int.96) A field's `description` renders under its label for every type; a list field shows it
386
+ once, never per item. A block template's `description` still does not render.
387
+ - (int.103) An `image` field whose `src` is outside `/assets/`, such as `/logos/x.svg`, previews at
388
+ that src and has no Crop button: a crop never applies outside the asset store.
389
+
390
+ **To adopt.** Nothing. Read your existing descriptions, which editors now see. To crop a `public/`
391
+ image, upload a raster copy to the media library. For card titles:
392
+ `{ type: 'object', list: true, itemTitleField: 'label', fields: [...] }`.
393
+
394
+ **Now deletable.** Editor hints worked into a field's `label` because `description` never showed.
395
+
396
+ ### Content reads: listings take a `branch`, and reads report `slug` and `urlPath`
397
+
398
+ **What changed.**
399
+
400
+ - (int.93) `listEntries()`/`buildContentTree()` accept `branch`, like `read()`.
401
+ - (int.102) `read()`/`readByUrlPath()` return `meta.slug` and `meta.urlPath`, and a preview `load`
402
+ gets `entry.slug` and `entry.urlPath`, each computed as `listEntries` computes them.
403
+
404
+ **To adopt.** Index pages pass `searchParams`' `branch` through, so editor previews list that
405
+ branch.
406
+
407
+ **Now deletable.** Code parsing a slug or URL back out of `path` or `entry.path`.
408
+
409
+ ### Path-permission rules now take effect below the content root — **security fix; breaking for rules written with on-disk names**
410
+
411
+ **What changed.** (int.93) Rules match an entry's logical path (`content/blog/my-post`). Enforcement
412
+ checked the id-suffixed on-disk path, so collection and entry rules matched nothing; only a glob
413
+ that also matched on-disk names, such as `content/**`, worked. Renaming also requires edit access
414
+ at the new path.
415
+
416
+ **To adopt.** Review your rules (Settings → Manage Permissions) before upgrading: collection rules
417
+ that never took effect will start to. Rewrite any rule naming an id-suffixed directory or a file
418
+ extension in logical form (`content/blog/**`, `content/about`).
419
+
420
+ **Now deletable.** A content-root grant working around inert collection grants.
421
+
422
+ ### Reads never create a requested branch — **security fix; breaking for some direct `createContentReader` callers**
423
+
424
+ **What changed.** (int.94) A branch other than the active one must exist and be readable by the
425
+ user, or `read()`/`readByUrlPath()` treat it as not found; no workspace is created per `?branch=`
426
+ value. `createContentReader` defaults `allowCreateBranch` to `false`.
427
+
428
+ **To adopt.** Nothing via `getCanopy()`. A script creating a branch through `createContentReader`
429
+ passes `allowCreateBranch: true`, never with a request's branch.
430
+
431
+ **Now deletable.** Checks that allow-list `?branch=` before it reaches `read()`.
432
+
433
+ ### Fast, crash-safe branch creation — **behaviour change: branch clones hold only the content root**
434
+
435
+ **What changed.** (int.100) A branch is built under a staging name and appears only when complete.
436
+ The worker repairs branch directories an interrupted create or delete left wedged, and owns
437
+ `remote.git`'s gc config and repacking. A content branch's clone checks out only the content root,
438
+ `.canopy-meta` and root-level files, so request-time reads elsewhere in it find nothing. The editor
439
+ recovers a create that timed out instead of reporting an HTTP 504.
440
+
441
+ **To adopt.** Upgrade `canopycms` and `canopycms-cdk` together; no config, no deploy order. A
442
+ changed `contentRoot` reaches existing clones within a worker cycle of the API restarting.
443
+
444
+ **Now deletable.** Manual EFS cleanup of provisioning locks or half-made branch directories, and
445
+ `remote.git` gc scripts.
446
+
447
+ ### `.canopy-meta/` must not be committed, and System health says so
448
+
449
+ **What changed.** (int.93) The schema cache lives in each branch clone's `.git/canopycms/`. Sync,
450
+ editor submits and `canopycms sync` ignore `.canopy-meta/`, and System health shows the base
451
+ branch's refresh outcome and warns when your repo tracks `.canopy-meta/`.
452
+
453
+ **To adopt.** If `git ls-files .canopy-meta` lists anything, run `git rm -r --cached .canopy-meta`,
454
+ add `.canopy-meta/` to `.gitignore`, and commit. The base branch's workspace follows by itself; an
455
+ editing branch whose copy of that state changed shows a rebase failure until its workspace is
456
+ repaired or re-created.
457
+
458
+ **Now deletable.** Any local step that resets or reformats `.canopy-meta/` files before a commit or
459
+ a format check.
460
+
461
+ ### Commits name their editors, and submit refuses a branch with nothing to submit
462
+
463
+ **What changed.**
464
+
465
+ - (int.93) Submit commits gain an `Edited-by: Name (id)` trailer; PR bodies gain a section that
466
+ re-submits replace, keeping human text. Options: `gitEditedByTrailers`, `gitCoAuthoredByTrailers`
467
+ ([reference](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#definecanopyconfig-options)).
468
+ - (int.110) Submit-commit trailers name every user who saved since the last submit commit, and
469
+ the PR-body section every user who edited the branch, not only the submitter; settings commits
470
+ (permissions, groups) carry the acting user's trailer.
471
+ - (int.108) Submit answers 400 when a branch's saved content matches its base. `BranchMetadata`
472
+ gains optional `submittedAt` and `pushedToGitHubAt`.
473
+
474
+ **To adopt.** Scripts calling submit: save a change first, or handle the 400.
475
+
476
+ ### Settings saves only push the settings branch — **breaking (config): `autoCreateSettingsPR` is removed**
477
+
478
+ **What changed.** (int.93) The orphan settings branch can never get a PR, so groups and permissions
479
+ saves only push the branch, and no failing PR task lands in System health.
480
+
481
+ **To adopt.** Delete `autoCreateSettingsPR` from `canopycms.config.ts`.
482
+
483
+ **Now deletable.** Filters that hid the failed settings-PR tasks.
484
+
485
+ ### A schema the running code lacks costs one entry type, and holds the worker — **behaviour change**
486
+
487
+ **What changed.** (int.107) When synced content names an entry schema the running code lacks, only
488
+ that entry type goes unavailable: the editor says so, and its entries answer 503 with
489
+ `code: 'SCHEMA_UNAVAILABLE'`. Builds, static deploys and `generate-ai-content` still fail. The
490
+ worker holds the base branch for up to 30 minutes while that lasts; see
491
+ [New schemas wait for the editor deploy](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#new-schemas-wait-for-the-editor-deploy).
492
+ Public types gain optional `unavailable` on `EntryTypeConfig` and `FlatSchemaItem`, optional
493
+ `ApiResponse.code`, and a required `SchemaResolutionResult.issues`.
494
+
495
+ **To adopt.** Code that builds a `SchemaResolutionResult` adds `issues`. A custom worker entrypoint
496
+ can pass `schemaHoldMaxMs` to `CmsWorker`.
497
+
498
+ ### `canopycms-cdk`: the worker drains before replacement and runs on-demand — **breaking (props): `spotMaxPrice` is removed; behaviour and cost change**
499
+
500
+ **What changed.** (int.108) The worker is one on-demand `t4g.nano` by default (about $3 a month,
501
+ against spot's $1–2); a spot shortage could leave no worker: on a first deploy the editor's API answers 503 "CMS worker not
502
+ ready", and later publishes wait. Spot is
503
+ opt-in: `workerCapacity: { type: 'spot' }`, a mixed-instances policy that still cannot guarantee a
504
+ worker. A terminating lifecycle hook (`canopycms-worker-drain`, heartbeat
505
+ `workerTerminationHeartbeat`, default 5 minutes) lets the old worker finish in-flight work for up
506
+ to 90 seconds and requeue the rest with no retry spent. The systemd unit gains `KillMode=mixed`,
507
+ `TimeoutStopSec=120` and exit status 75 handling; the worker role may complete its own group's hook.
508
+
509
+ **To adopt.** Replace `spotMaxPrice: '…'` with `workerCapacity: { type: 'spot', maxPrice: '…' }`,
510
+ or drop it. A hand-installed unit copies the new lines from `worker/canopy-worker.service`. The
511
+ drain applies from the deploy after this one.
512
+
513
+ **Now deletable.** Any override stripping `InstanceMarketOptions` from the worker's launch template.
514
+
515
+ ### `canopycms-cdk`: the worker instance is hardened — **an existing stack upgrades in two deploys; behaviour and cost change**
516
+
517
+ **Upgrade an existing stack in two deploys:**
518
+
519
+ 1. Deploy with `efsEnforceIamAndTls: false` on `CanopyCmsService`. The worker is replaced by one
520
+ that mounts EFS with IAM, and no file-system policy exists yet.
521
+ 2. Remove the prop and deploy again. The policy lands while every client already uses IAM and TLS.
522
+
523
+ In a single deploy, CloudFormation updates the file system's policy before it replaces the worker.
524
+ The outgoing worker still holds an anonymous mount then, and if its connection drops, its NFS calls
525
+ hang until the drain heartbeat lets termination continue. Its in-flight task is retried later. A
526
+ new stack needs no steps.
527
+
528
+ **What changed.** (int.109) Detailed in
529
+ [The worker instance](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#the-worker-instance): IMDSv2 with a hop limit of 1; the
530
+ bundle is one file whose sha256 user data checks, readable as that one object; a file-system policy
531
+ refusing clients without TLS or IAM, replacing the worker's
532
+ `AmazonElasticFileSystemClientReadWriteAccess`; an encrypted gp3 root volume; a boot-time upgrade to
533
+ the latest AL2023 release; a weekly replacement (`workerMaxInstanceLifetime`); a sandboxed systemd
534
+ unit; and daily EFS backups (`efsBackup`, billed per GB-month).
535
+
536
+ **To adopt.** The two deploys above. Anything else that mounts this file system needs `tls,iam` and
537
+ `elasticfilesystem:ClientMount`. A hand-installed unit copies the sandbox lines from
538
+ `worker/canopy-worker.service`. If your account encrypts EBS by default with a customer-managed
539
+ key, grant the Auto Scaling service-linked role on it. Pass `workerMaxInstanceLifetime: null` or
540
+ `efsBackup: false` to opt out.
541
+
542
+ **Now deletable.** Any override adding `MetadataOptions`, an encrypted root volume or an EFS
543
+ `FileSystemPolicy`, or narrowing the worker role's asset-bucket grant.
544
+
545
+ ### `canopycms-cdk`: the worker needs a state directory — **deploy the template before the bundle**
546
+
547
+ **What changed.** (int.110) The worker's git keeps the GitHub credential in a private mirror
548
+ under the unit's `StateDirectory=`, and the worker does not start without it.
549
+
550
+ **To adopt.** Default `workerCode`: nothing. `workerCode: { source: 'parameter' }`: `cdk deploy` the
551
+ new template before CI rolls an int.110 or later bundle; a parameter-only change set never applies
552
+ the new unit, so the new bundle exits at start. A hand-installed unit needs
553
+ `StateDirectory=canopy-worker` under `[Service]`.
554
+
555
+ ### A poisoned `remote.git` re-clones itself
556
+
557
+ **What changed.** (int.112) A `remote.git` missing its base branch is re-cloned unless an unpushed ref would be
558
+ lost; the refusal names those refs. A tampered config still refuses and needs an operator.
559
+
560
+ **To adopt.** Nothing.
561
+
562
+ ### A failed or stopped worker says why — **behaviour change on the not-ready 503; new worker APIs**
563
+
564
+ **What changed.** (int.109) While the latest start's recorded failure stands, the prod not-ready 503
565
+ is `WORKER_FAILED`: no `Retry-After`, the failure named to admins, account ids masked.
566
+ `CmsWorker.selfStopped` settles when the worker stops itself (a lost EFS lock); the `canopycms-cdk`
567
+ entrypoint then exits 69. `recordWorkerStartupFailure` records a failure before `start()`.
568
+
569
+ **To adopt.** A hand-written entrypoint calls `recordWorkerStartupFailure` on a pre-`start()`
570
+ failure and exits non-zero on `selfStopped`, with a code outside `RestartPreventExitStatus=`.
571
+
572
+ **Now deletable.** A watchdog that restarts an idle worker process.
573
+
574
+ ### `canopycms-cdk`: CI can roll the worker with a parameter, and alarm when it stops syncing — **new, opt-in**
575
+
576
+ **What changed.** (int.109)
577
+
578
+ - `workerCode: { source: 'parameter' }` on `CanopyCmsService` selects the worker bundle by a
579
+ sha256 template parameter (its logical id is the `WorkerBundleSha256ParameterName` stack output), from a bucket the construct creates. The package ships the bundle
580
+ with its hash, as `worker/dist/index.js` and `index.js.sha256`. The default, `'asset'`, is
581
+ unchanged.
582
+ - `CanopyCmsService` alarms when the worker logs no git sync for 30 minutes. See
583
+ [Worker-down alarm](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#worker-down-alarm).
584
+
585
+ **To adopt.** Optional. For parameter-only change sets, see
586
+ [Rolling the worker from CI](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#rolling-the-worker-from-ci). For the alarm, pass
587
+ `alarmTopic`.
588
+
589
+ **Now deletable.** A manual `cdk deploy` after each canopycms bump whose only purpose is moving the
590
+ worker, and a hand-built alarm on the worker log group.
591
+
592
+ ### `canopycms-cdk`: a `t4g.nano` worker boots reliably — **behaviour change: a deploy replaces the worker**
593
+
594
+ **What changed.** (int.112) The boot's `dnf upgrade` could be OOM-killed on every retry. A 1 GiB
595
+ swap file now goes on first; an upgrade that still fails starts the worker unpatched and notifies
596
+ `alarmTopic` if set; a retried step names itself when it gives up. See
597
+ [The worker instance](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#the-worker-instance).
598
+
599
+ **To adopt.** Deploy, which replaces the worker instance. The bundle needs none of this, so it is
600
+ not Template first and the worker contract stays at 1.
601
+
602
+ **Now deletable.** An instance type above `t4g.nano` chosen only to get the boot through.
603
+
604
+ ### `canopycms-cdk`: the worker's log is root-owned — **security fix**
605
+
606
+ **What changed.** (int.112) `/var/log/canopy-worker` and `worker.log` are root-owned, and the unit
607
+ drops `LogsDirectory=`. systemd opens the log as root, following symlinks, before the sandbox
608
+ applies, so a worker owning the directory could swap `worker.log` for a symlink and have its
609
+ output appended to any file on the instance. The log stays readable without `sudo`.
610
+
611
+ **To adopt.** Deploy, which replaces the worker instance. It is not Template first, and the worker
612
+ contract is unchanged. A hand-installed unit needs the same change:
613
+
614
+ 1. Delete its `LogsDirectory=` line, which chowns the directory on every start.
615
+ 2. `systemctl daemon-reload`.
616
+ 3. `chown root:root` the directory and `worker.log*`.
617
+ 4. `chmod 0755` the directory.
618
+ 5. Restart.
619
+
620
+ **Now deletable.** Nothing.
621
+
622
+ ### The worker never pushes the base or default branch — **security fix**
623
+
624
+ **What changed.** (int.112) The worker refuses to push, plainly or replacing history, to the base
625
+ branch or GitHub's default branch, whatever a task asks, and fails the task permanently.
626
+ CanopyCMS never queues such a push, but a compromised CMS Lambda can write the task queue.
627
+
628
+ (int.114) It protects what the construct's `protectedBranches` prop lists (at most 1024, no settings
629
+ branch), never deletes or opens a pull request from a protected or settings branch, and re-reads
630
+ GitHub's default branch every 10 minutes.
631
+
632
+ **To adopt.** Upgrade, and roll the worker. Protect your base branch on GitHub with a rule or
633
+ ruleset CanopyCMS's credential cannot bypass. A repository admin's classic token often can: check
634
+ its bypass list. Set `CANOPYCMS_BASE_BRANCH` (the construct's `baseBranch`) explicitly: without
635
+ it, the gateway protects only GitHub's default branch and your list; the worker still refuses the
636
+ base it detected, warning when that base is neither.
637
+
638
+ ### The worker's push logs say whether GitHub moved
639
+
640
+ **What changed.** (int.113) `Pushed … to GitHub` is logged only when the branch moved, saying
641
+ where from; an unmoved branch logs at debug level. An unchanged settings branch is not pushed.
642
+
643
+ **To adopt.** Nothing.
644
+
645
+ ### The worker hands git its GitHub credential in a per-command file — **security fix; dev behaviour change**
646
+
647
+ **What changed.** (int.113) The token leaves git URLs: each GitHub fetch and push reads it from a
648
+ `0600` file named by `GIT_CONFIG_GLOBAL`, deleted afterwards. For those commands the file replaces
649
+ your global git config and clears credential helpers, so a global `http.proxy` or `http.sslCAInfo`
650
+ stops applying and no keychain credential is tried.
651
+
652
+ **To adopt.** Nothing in production. In dev, move a global-config proxy or CA to `HTTPS_PROXY` or
653
+ `GIT_SSL_CAINFO`.
654
+
655
+ ### The worker moves objects between GitHub and `remote.git` as bundles
656
+
657
+ **What changed.** (int.114) The worker moves objects between GitHub and
658
+ `remote.git` as git bundles. A new instance's first sync fetches the whole repository from GitHub,
659
+ and operations may reuse GitHub fetches under 30 seconds old.
660
+
661
+ **To adopt.** Nothing. Seeding `remote.git` stages a copy of the repository beside the gateway's
662
+ mirror, so the root volume needs room for two
663
+ ([details](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#the-worker-instance)).
664
+
665
+ ### The worker follows a rebased or deleted GitHub branch
666
+
667
+ **What changed.** (int.114) When a branch is rebased and force-pushed on GitHub, or deleted there, the
668
+ worker now updates or deletes its own copy of it instead of warning about it on every sync. A rebased
669
+ branch's copy that holds editor work is left as it is: the worker warns once, and System
670
+ health lists it with what to do.
671
+
672
+ **To adopt.** Nothing.
673
+
674
+ ### `canopycms-cdk`: a worker bundle states the template it needs — **template first for the gate only**
675
+
676
+ **What changed.** (int.112) The worker unit carries a contract version
677
+ (`Environment=CANOPYCMS_WORKER_CONTRACT=<n>`). Parameter mode outputs it as `WorkerContract`, and
678
+ the package ships the bundle's need as `worker/dist/index.js.contract`. A bundle refuses to start
679
+ under an older unit, logging `template too old for this bundle`; an unstamped unit with
680
+ `StateDirectory=` (int.110 and later) counts as contract 1, so it runs this bundle. A new bundle
681
+ changes the template too (the fallback bundle's key and hash), so only the contract says whether a
682
+ bundle-only roll is safe.
683
+
684
+ **To adopt.** Parameter mode: gate bundle-only rolls on the contract
685
+ ([recipe](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#rolling-the-worker-from-ci)). The gate refuses a stack without the
686
+ `WorkerContract` output, so `cdk deploy` the template once, which adds it. A hand-installed unit
687
+ copies its `Environment=CANOPYCMS_WORKER_CONTRACT=` line from `worker/canopy-worker.service`.
688
+
689
+ **Now deletable.** Deciding from the template diff whether a bundle-only roll is safe.
690
+
691
+ ### `canopycms-cdk`: the worker holds no secret — **security fix; template first (contract 2)**
692
+
693
+ **What changed.** (int.114) The worker runs with no network and no secret. `canopy-github`, a gateway
694
+ service under its own user with no view of EFS, holds the GitHub credential and the Clerk key, and
695
+ is the only CanopyCMS process that can reach the instance's AWS role. Core dumps are off. User data picks the
696
+ units from the bundle's contract: 2 gets the split, an older bundle the single unit.
697
+
698
+ **To adopt.** Default `workerCode`: one `cdk deploy` does both. Parameter mode: `cdk deploy` the
699
+ template, then roll the bundle; on an older template it stops with `template too old for this
700
+ bundle`. Inspect EFS only through `sudo /opt/canopy-worker/canopy-inspect <command>`, never from a root shell. A hand install
701
+ takes `worker/`'s three units and `gateway.env.example`
702
+ ([steps](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#installing-the-units-by-hand)). After the first deploy, run the
703
+ [checklist](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#first-deploy-checklist).
704
+
705
+ ### `canopycms-cdk`: `CanopyCmsService.attachTo`, and editor response headers — **behaviour change if you frame the CMS**
706
+
707
+ **What changed.** (int.94) `cmsService.attachTo(distribution, { viewerRequestFunction?, behaviorOverrides? })`
708
+ adds the editor's behaviors (`/edit`, `/edit/*`, `/api/canopycms/*`) to a distribution you already
709
+ own: OAC, the Lambda's timeout as the origin-read timeout, no caching, `x-forwarded-host`, and a
710
+ response headers policy. `CanopyCmsDistribution` sends the same headers: `frame-ancestors 'self'`,
711
+ `X-Frame-Options: SAMEORIGIN`, `nosniff`, HSTS and `X-Robots-Tag: noindex`. Unless your app sends
712
+ its own framing headers, no other origin can frame the editor or any page the CMS domain serves. Every such page is
713
+ marked `noindex`. The editor's default preview is same-origin and unaffected. See
714
+ [Serving the editor from a distribution you already own](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#serving-the-editor-from-a-distribution-you-already-own).
715
+
716
+ **To adopt.** If you wired the Function URL into your own distribution by hand, delete those
717
+ behaviors and call `attachTo`, passing your CMS build's `assetPrefix` as `editorAssetPrefix` and a
718
+ path `editor.previewPrefix` as `previewPrefix`; synth fails while a hand-wired `/edit*`, `/api/*` or
719
+ asset-prefix behavior still sits ahead of it. Take the editor routes out of any HTTP Basic-auth
720
+ gate, and read the synth warning if your distribution has custom error responses.
721
+
722
+ **Now deletable.** Hand-wired `/edit*`, API, preview and asset-prefix behaviors, the origin, OAC and
723
+ `x-forwarded-host` function made for them, and any response-headers policy added only to stop
724
+ framing.
725
+
726
+ ### `unauthenticatedStatus`: answer signed-out API calls with 419
727
+
728
+ **To adopt.** (int.102) Set `unauthenticatedStatus: 419` if your pages sit behind HTTP Basic auth on
729
+ the editor's origin; the editor still detects sign-out. Otherwise nothing.
730
+
731
+ ### Auth plugins look users up in batches
732
+
733
+ **What changed.** (int.111) `AuthPlugin` gains an optional `getUsersMetadata(userIds)`, and the
734
+ editor batches its user-badge lookups.
735
+
736
+ **To adopt.** Nothing. A custom auth plugin may implement it; otherwise the server falls back to
737
+ bounded single lookups.
738
+
739
+ ### The CMS image builds a prod editor, and a mode mismatch blocks the editor — **behaviour change: a hand-built image can fail its build**
740
+
741
+ **What changed.** (int.111) `Dockerfile.cms` defaults `NEXT_PUBLIC_CANOPY_MODE` to `prod`, not
742
+ `dev`, and fails its build on any value but `prod` or `dev`. An editor built for the other mode than its
743
+ server runs now gets a blocking screen naming that variable, not a sign-in that never succeeds.
744
+
745
+ **To adopt.** A hand-built image running a dev-mode server passes
746
+ `--build-arg NEXT_PUBLIC_CANOPY_MODE=dev`.
747
+
748
+ ### Prod detects an unset `defaultBaseBranch` instead of assuming `main` — **behaviour change: startup can fail**
749
+
750
+ **What changed.** (int.110) Unset, prod reads the base branch from the HEAD of the workspace's `remote.git`,
751
+ which the worker points at the branch it uses. Before the worker creates `remote.git`, requests
752
+ answer the not-ready 503. A HEAD naming no branch, or a network remote, fails service creation with
753
+ an error naming `defaultBaseBranch`. `CanopyCmsService` stamps `CANOPYCMS_BASE_BRANCH` only when
754
+ `baseBranch` is set; unset, the worker uses GitHub's default branch. A stack that relied on the
755
+ implicit `'main'` behaves as before when its repository's default branch is `main`.
756
+
757
+ **To adopt.** If your base branch is not the repository's default, set `defaultBaseBranch`. Prod
758
+ does not follow a later change of GitHub's default branch, so set it before a cutover.
759
+
760
+ **Now deletable.** A `defaultBaseBranch: 'main'` in a repository whose default branch is `main`.
761
+
762
+ ### The prod workspace defaults to `/mnt/efs` — **behaviour change if you run prod without `CANOPYCMS_WORKSPACE_ROOT`**
763
+
764
+ **What changed.** (int.100) With `CANOPYCMS_WORKSPACE_ROOT` unset, prod mode keeps its branches,
765
+ settings, task queue and auth cache under `/mnt/efs` instead of `/mnt/efs/workspace`
766
+ (`DEFAULT_PROD_WORKSPACE`). `CanopyCmsService` sets the variable to `/mnt/efs` in the Lambda and
767
+ the worker, so CDK deployments are unaffected.
768
+
769
+ **To adopt.** If you run prod without the CDK and without `CANOPYCMS_WORKSPACE_ROOT`, set it to
770
+ `/mnt/efs/workspace` to keep your existing workspace, or move that directory's contents up to
771
+ `/mnt/efs`.
772
+
773
+ ### System health shows which build is running
774
+
775
+ **What changed.** (int.94) System health gains a Build section (canopycms version, source revision,
776
+ worker version, media-storage state) and warns when API and worker versions differ.
777
+
778
+ **To adopt.** Optional; without it the revision reads "not set". Copy the template changes
779
+ (`init-deploy aws` overwrites whole files): `Dockerfile.cms` (runner-stage `ARG`/`ENV`/`LABEL` for
780
+ `CANOPY_SOURCE_SHA`), `infrastructure/lib/cms-stack.ts` (`sourceRevision` prop, passed in
781
+ `buildArgs`), `infrastructure/bin/app.ts`, and `.github/workflows/deploy-cms.yml`
782
+ (`CANOPY_SOURCE_SHA: ${{ github.sha }}`).
783
+
784
+ **Now deletable.** Hand-rolled version or commit stamping, or a build-info endpoint.
785
+
786
+ ### The AWS example workflow gains the template's triggers and dependency checks
787
+
788
+ **What changed.** (int.111) Like the one `init-deploy aws` writes, `examples/aws-deployment/deploy-cms.yml`
789
+ now deploys on `next.config.*`, `middleware.ts` and `public/**` changes, and checks `canopycms` and
790
+ `aws-cdk` are installed.
791
+
792
+ **To adopt.** If you copied it by hand, add those `paths:` and packages.
793
+
794
+ ### `GET /admin/branch-health` scans for duplicate content IDs only on request — **behaviour change**
795
+
796
+ **What changed.** (int.111) The duplicate-ID scan runs only with `?duplicates=1`, under a 20 s
797
+ budget. Each healthy entry then carries `duplicateIdScan` (`none`, `found` or `unknown`), replacing
798
+ `duplicateContentIds`, and `duplicateIdScan.truncated` says whether the budget cut the scan short.
799
+
800
+ **To adopt.** Scripts reading `duplicateContentIds`: pass `duplicates=1`, read `duplicateIdScan`.
801
+
802
+ ### `canopycms-cdk`: `AssetSupport` serves images from S3 only — **breaking**
803
+
804
+ **What changed.** (int.100) `/assets/*` and `/assets/t/*` come from the bucket alone, with an
805
+ optional `replicaBucket` for 5xx failover. An unmaterialized URL is a 403; no transform Lambda or
806
+ `assets/t/` expiry exists, and `transformFunction`/`transformLogGroup`/`transformFunctionUrl` are
807
+ `undefined`. `transformRole`, `transformReservedConcurrency`, `transformOutputRetention`,
808
+ `transformLogGroupName` and `transformLogRetention` throw: drop them, or set
809
+ `lazyPublicTransforms: true`.
810
+
811
+ **To adopt.** Until your pipeline runs both steps in the next entry, set
812
+ `lazyPublicTransforms: true`. Only the Lambda's writes expire, tagged `canopy-transform=lazy`; older
813
+ ones never do. On a bucket you pass in, also pass `transformOutputRetention` and filter your expiry
814
+ rule on that tag; cross-account, grant the transform role `s3:PutObjectTagging`. Then remove the
815
+ opt-in and those props, and pass `replicaBucket` if you replicate `assets/`.
816
+
817
+ **Now deletable.** Your `assets/t/` expiry rule, before leaving lazy mode.
818
+
819
+ ### `collect-asset-refs` and `materialize-assets` store a build's images before release
820
+
821
+ **What changed.** (int.100) `canopycms collect-asset-refs <outDir>` writes the image keys a static
822
+ build references to `<outDir>/canopy-asset-refs.json`. `canopycms materialize-assets --refs <file>`
823
+ transforms and stores whichever of them the configured store lacks. (int.104) `collect-asset-refs`
824
+ reads a page's inline RSC payload whole, so a URL Next splits across inline scripts is never
825
+ truncated into a wrong key (`logo.sv`); a script naming `self.__next_f` that is not Next's own push
826
+ fails, naming the file and offset.
827
+
828
+ **To adopt.** Run `collect-asset-refs` after the static build, before any step that lists its files.
829
+ Run `materialize-assets` before the release is served, with the S3 grants the README lists. Every
830
+ `/assets/t/` URL a page can request must appear as text in the build output.
831
+
832
+ **Now deletable.** A pre-release warm-up step that requests image URLs to get them transformed.
833
+
834
+ ### The editor and its live preview load images through the signed-in asset route
835
+
836
+ **What changed.** (int.100) The editor loads asset-store images from
837
+ `GET /api/canopycms/assets/raw/…` under your `basePath`, which transforms on demand and, on S3,
838
+ answers with a short-lived redirect to a presigned S3 read. `CanopyClientConfig.assetBaseUrl` is
839
+ gone. Inside a same-origin live preview, `assetUrl` puts `/assets/t/…` URLs behind that route
840
+ instead of your `baseUrl`.
841
+
842
+ **To adopt.** Usually nothing. The `<img>` requests authenticate with the editor's session cookie,
843
+ so the editor and its preview must share an origin with `/api/canopycms`. A Content-Security-Policy
844
+ on those pages must allow `img-src` from your bucket's S3 endpoint, and a bucket policy denying
845
+ requests that bypass CloudFront blocks the presigned reads. An off-origin preview gets no route
846
+ prefix, so under `AssetSupport`'s S3-only default it shows only stored derivatives; serve it
847
+ same-origin or set `lazyPublicTransforms: true`.
848
+
849
+ **Now deletable.** Code passing `assetBaseUrl` into the editor.
850
+
851
+ ### `assetUrl` applies an image value's crop — **behaviour change for cropped images**
852
+
853
+ **What changed.** (int.100) `assetUrl`/`assetSrcSet` apply an `image` value's `crop`; `opts.crop`
854
+ overrides.
855
+
856
+ **To adopt.** Scale `<img>` `width`/`height` by `crop.w`/`crop.h`.
857
+
858
+ **Now deletable.** Copying `value.crop` into `opts.crop`.
859
+
860
+ ### `media.publicBaseUrl` is removed, and image limits are wider — **breaking (config)**
861
+
862
+ **What changed.** (int.100) Nothing read `media.publicBaseUrl`, and the strict `media` schema fails a
863
+ config that sets it. Uploads go up to 24 MP (6000×4000), up from 16.7 MP. Widths are any integer up
864
+ to 8192 everywhere but the lazy Lambda, whose allowlist gains 32, 48, 64, 96 and 128 and which has
865
+ 2048 MB. An `orig` over 10 MiB is accepted.
866
+
867
+ **To adopt.** Delete `publicBaseUrl`. Pass `baseUrl` to `assetUrl`/`assetSrcSet` to prefix public
868
+ asset URLs.
869
+
870
+ **Now deletable.** Anything that only fed `publicBaseUrl`, and rounding widths to a multiple of 160.
871
+
872
+ <!-- legacy:end -->
873
+
874
+ ### AI content can withhold or redirect a link to an entry with no page
875
+
876
+ <!-- changeset: ai-reference-link-hook -->
877
+
878
+ _First in int.115._
879
+
880
+ **What changed.** The AI export linked every exported reference target to its page URL, even
881
+ when the site gives that entry no page. A new `AIContentConfig.referenceLink(target, { body,
882
+ defaultUrl })` decides where links to an exported entry point. The decision covers both reference
883
+ fields and body `entry:` links, so the two kinds of link agree. Return a URL to link there
884
+ instead. Return `false` when the target has no page: its title then renders as plain text, with
885
+ no page link and no markdown-version link, and an inline `entry:` link becomes its text. Return
886
+ `undefined` to keep the default. `body` is the target's stored MD/MDX body, and none of it reaches the entries that link to it. The callback runs once per target per run, may be async, and never sees a missing target or
887
+ one the exclusions leave out. A throw or any other return is logged and treated as `false`.
888
+
889
+ **To adopt.** If your site's routes, sitemap or bylines decide that some entries have no page,
890
+ apply the same rule here, for example `referenceLink: (target, { body }) =>
891
+ !body?.trim() ? false : undefined` in `defineAIContentConfig` from `canopycms/ai`. See
892
+ [AI Content Configuration](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#ai-content-configuration).
893
+
894
+ **Now deletable.** A `fieldTransforms` entry that re-renders a reference field only to drop or
895
+ change its link.
896
+
897
+ ### `toPlainText` takes an entry body and keeps prose after a leading `---` — **behaviour change**
898
+
899
+ <!-- changeset: to-plain-text-body-only -->
900
+
901
+ _First in int.115._
902
+
903
+ **What changed.** `toPlainText` from `canopycms/ai` no longer parses front matter. It used to,
904
+ so a body that opened with a `---` thematic break was read as the start of a front-matter block
905
+ and everything after it was dropped: `toPlainText('---\n\nA bio.\n')` returned `''`, so a
906
+ "has prose" check judged the entry empty and a search index got no text for it. A body whose
907
+ opening `---` was followed by lines that are not valid YAML made it throw. It now returns
908
+ `'A bio.'`, and never parses YAML. Front matter in the input is read as prose.
909
+
910
+ **To adopt.** Pass the value of the entry's body field, such as `entry.data.body` when the schema
911
+ names that field `body`. The content store has already split it from its front matter. A caller
912
+ holding a whole `.md`/`.mdx` file splits it first:
913
+
914
+ ```ts
915
+ import matter from 'gray-matter'
916
+ import { toPlainText } from 'canopycms/ai'
917
+
918
+ const text = toPlainText(matter(file, {}).content)
919
+ ```
920
+
921
+ ### People without a name show "Name unavailable"
922
+
923
+ <!-- changeset: people-without-a-name-show-name-unavailable -->
924
+
925
+ _First in int.115._
926
+
927
+ **What changed.** The Clerk plugin no longer uses the Clerk user id as a user's name: a user with no full name or username has an empty name, and the editor shows "Name unavailable" wherever that person appears. Core and the editor also treat a blank name, or one equal to the user id, as no name: `/users/:userId`, `/users/batch` and the `users` lists on comments and branches return an empty `name` for that person. The users snapshot the worker writes follows the same rule.
928
+
929
+ ### Branch access lists stay with admins and reviewers — **behaviour change**
930
+
931
+ <!-- changeset: branch-access-list-withheld-from-non-privileged -->
932
+
933
+ _First in int.115._
934
+
935
+ **What changed.** Every branch response (the listing, create, the workflow actions, status and
936
+ access updates) sends a caller who is not an admin or reviewer only the `access.allowedUsers` and
937
+ `access.allowedGroups` entries naming them or their groups, and counts the rest in a new
938
+ `withheldAccess` field. A branch card's access list shows the people the viewer can name, then
939
+ "and N others", instead of a row of "Name unavailable" avatars.
940
+
941
+ ### A branch lockdown outranks the branch's access list — **security fix**
942
+
943
+ <!-- changeset: branch-lockdown-outranks-branch-acl -->
944
+
945
+ _First in int.115._
946
+
947
+ **What changed.** A branch whose `access.managerOrAdminAllowed` is `true` admits only admins and
948
+ reviewers, whatever its `allowedUsers` and `allowedGroups` list. An access list used to switch the
949
+ lockdown off, so a listed editor, or a creator who listed themselves, could read, edit, comment
950
+ on, submit and withdraw a locked branch. The branch list hides a locked branch from editors, and
951
+ the editor's Submit and Withdraw controls follow the same rule.
952
+
953
+ **To adopt.** Nothing, unless a site relied on an access list admitting editors to a locked
954
+ branch: those editors lose access. To give it back, set `managerOrAdminAllowed` to `false` in
955
+ that branch's `branch.json`, and the access list decides again.
956
+
957
+ ### The base-branch conflict notice stays until the branch merges — **behaviour change**
958
+
959
+ <!-- changeset: conflict-notice-persists-until-merge -->
960
+
961
+ _First in int.115._
962
+
963
+ **What changed.** When the CMS brings a branch up to date and keeps the branch's version of an
964
+ entry the base branch also changed, the "updated on the base branch" notice and the branch's
965
+ conflict count now stay until the branch merges, so a reviewer still sees which entries replaced a
966
+ base change. They used to clear on the next sync, about five minutes later. README's new [Drafts
967
+ and Unpublishing](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#drafts-and-unpublishing) section covers long-lived branches and
968
+ unpublishing.
969
+
970
+ ### The editor calls the content drawer "All content"
971
+
972
+ <!-- changeset: all-content-label -->
973
+
974
+ _First in int.115._
975
+
976
+ **What changed.** The first breadcrumb segment and the file menu item that opens the content drawer say "All content" instead of "All entries", since the drawer holds collections as well as entries.
977
+
978
+ **To adopt.** Nothing, unless your browser tests find either by the text "All entries".
979
+
980
+ ### The Branches drawer shows one action per branch, and comments reply in place
981
+
982
+ <!-- changeset: branches-drawer-and-comment-replies -->
983
+
984
+ _First in int.115._
985
+
986
+ **What changed.** The drawer lists the open branch first, with one action per card and the rest
987
+ in a "…" menu, in the editor's vocabulary. A thread's Reply opens a box under it.
988
+
989
+ **To adopt.** Nothing, unless your browser tests click the old drawer buttons or labels. Every
990
+ form has a "Field comments" button, so tests that find buttons by `/comment/i` may match more than
991
+ one; and the editor listens for Ctrl+Alt+M (⌘⌥M on macOS) on the whole document, taking it while a
992
+ form is open.
993
+
994
+ ### An object's Clear offers Undo, and refusals say why
995
+
996
+ <!-- changeset: clear-undo-and-clearer-refusals -->
997
+
998
+ _First in int.115._
999
+
1000
+ **What changed.** An optional object's Clear raises the same Undo toast as a list item's Remove,
1001
+ and Undo returns focus to the object. A Remove toast says when the item's open comment threads moved
1002
+ to "Comment on this entry". A collection your path permissions refuse new entries in says "You can't
1003
+ add entries here." in place of its hidden menu. The group and permission managers show a version
1004
+ conflict in yellow; the API marks it with the new error code `SETTINGS_CHANGED`.
1005
+
1006
+ `sanitizeHref` returns its fallback for `undefined` or `null`, which a live preview's draft can
1007
+ pass for a link field it doesn't hold yet, instead of throwing.
1008
+
1009
+ ### The collection dialog says what saves at once
1010
+
1011
+ <!-- changeset: collection-dialog-says-what-saves -->
1012
+
1013
+ _First in int.115._
1014
+
1015
+ **What changed.** Editing a collection, entry-type changes still save to the branch at once, and
1016
+ the dialog now says so; name, label and slug save from "Save collection details". The dismiss
1017
+ button reads "Discard changes", "Close" or "Cancel" to match what it does. Entry-type results show
1018
+ inline instead of as toasts.
1019
+
1020
+ **To adopt.** Nothing, unless browser tests click the old buttons or wait for those toasts.
1021
+
1022
+ ### Comments and branches name their people for every editor — **behaviour change on two APIs**
1023
+
1024
+ <!-- changeset: comments-and-branches-name-their-people -->
1025
+
1026
+ _First in int.115._
1027
+
1028
+ **What changed.** `GET /:branch/comments` adds `users`: the id, name and avatar URL, never the
1029
+ email, of everyone who wrote or resolved a thread the requester may read. `GET /branches` adds the
1030
+ same for each listed branch's creator, but not its access list. Every editor now sees those names.
1031
+ Your auth plugin answers through the `/users/batch` cache; a failed lookup leaves the names out.
1032
+
1033
+ **To adopt.** Nothing, unless you call either API yourself: read `data.users`.
1034
+
1035
+ ### A date picker, a form header, and a toolbar that wraps
1036
+
1037
+ <!-- changeset: date-picker-form-header-wrapping-toolbar -->
1038
+
1039
+ _First in int.115._
1040
+
1041
+ **What changed.** A `datetime` field is a Mantine `DateTimePicker`, still stored as ISO UTC. The
1042
+ form has a sticky header with the entry's title and type, an "Outline" of its fields and rows to
1043
+ jump to, and Expand or Collapse all lists. The markdown toolbar wraps in a narrow form. canopycms
1044
+ now depends on `@mantine/dates` and `dayjs`, and loads `@mantine/dates/styles.css` with its other
1045
+ editor styles.
1046
+
1047
+ **To adopt.** Nothing. Browser tests that typed into a `datetime-local` input pick a date instead.
1048
+
1049
+ ### Editor colours reach 4.5:1 in light and dark mode
1050
+
1051
+ <!-- changeset: editor-colours-reach-contrast-in-both-schemes -->
1052
+
1053
+ _First in int.115._
1054
+
1055
+ **What changed.** The editor theme draws each colour's text, light and subtle variants at 4.5:1,
1056
+ uses primary shade 7 in light mode, fills primary Buttons and Badges at 4.5:1 under white text in
1057
+ both schemes (`--canopy-primary-fill`), and sets placeholders,
1058
+ dark-mode dimmed text, input borders (3:1), destructive and error text, and editor panels from
1059
+ scheme-aware variables, so `colorScheme="dark"` leaves no light surface behind. Every Tooltip
1060
+ opens on keyboard focus. Date-time fields follow the browser's 12- or 24-hour clock and read a
1061
+ date-only value as local midnight; empty header menus show a focusable item saying why.
1062
+
1063
+ **To adopt.** Nothing, unless visual snapshots or browser tests assert the old colours, or click
1064
+ the navigator's "Delete…" or "Delete collection…", now "Delete entry" and "Delete collection". A
1065
+ `themeOverride` setting `primaryShade` or `components.Tooltip.defaultProps.events` replaces these
1066
+ defaults.
1067
+
1068
+ **Now deletable.** Local overrides that darkened light buttons, badges, placeholders or input
1069
+ borders for contrast.
1070
+
1071
+ ### Editor contrast, and blocked buttons stay focusable
1072
+
1073
+ <!-- changeset: editor-contrast-and-focusable-blocked-buttons -->
1074
+
1075
+ _First in int.115._
1076
+
1077
+ **What changed.** Dimmed editor text is `gray.7`, or `dark.1` in dark mode. Filled red buttons
1078
+ are `red.8`; filled orange, `orange.6` with black text. A blocked Save, Submit, Withdraw, Purge or
1079
+ menu item is `aria-disabled`, not `disabled`, so its reason shows on focus. The header's entry
1080
+ menu says "Reload entry" and "Discard changes".
1081
+
1082
+ **To adopt.** A `themeOverride` setting `components.Button.vars` replaces the editor's filled red
1083
+ and orange Button resolver, and one setting `components.Alert.vars` replaces the resolver that
1084
+ gives a light yellow Alert's title, icon and close button the text colour.
1085
+ Browser tests check `aria-disabled`; Playwright's `toBeDisabled` reads it. Tests that click the
1086
+ old "Reload File" or "Discard File Draft" use the new labels.
1087
+
1088
+ **Now deletable.** Local overrides that darkened the same text or buttons.
1089
+
1090
+ ### Editor focus rings and checked controls reach 3:1 for any brand
1091
+
1092
+ <!-- changeset: editor-focus-ring-and-control-contrast -->
1093
+
1094
+ _First in int.115._
1095
+
1096
+ **What changed.** Focus rings, focused borders, checked controls and the active tab draw in
1097
+ `--canopy-focus-ring`, the first step of your primary colour at 3:1 on the editor's surfaces, so
1098
+ a dark brand such as `#1e3a8a` is lightened in dark mode and a checked control's tick turns black
1099
+ where white would not show. Unchecked Checkbox, Radio and Switch borders take
1100
+ `--canopy-input-border`. Selected options and dates sit on `--canopy-primary-fill`. Badges are
1101
+ sentence case, coloured badges use their light variant, dark-mode dimmed text and input
1102
+ descriptions are italic, the navigator's entry badge shows the entry type's label, count badges show
1103
+ their tint instead of the primary colour, and Markdown popups have a border in dark mode.
1104
+
1105
+ **To adopt.** Nothing, unless visual snapshots or browser tests assert the old colours or badge
1106
+ text, such as the System Health panel's "corrupt metadata", now "Corrupt metadata". A
1107
+ `themeOverride` that sets `primaryShade` moves where the ring starts.
1108
+
1109
+ ### Editor requests time out instead of hanging
1110
+
1111
+ <!-- changeset: editor-requests-time-out -->
1112
+
1113
+ _First in int.115._
1114
+
1115
+ **What changed.** Every editor API request has a deadline: 75 s, 90 s for branch create, and 180
1116
+ s for submit, admin branch scans and repairs, and the proxied upload. Past it the editor says so.
1117
+ A save keeps your changes. A write with no answer, or a gateway's error page, may still have gone
1118
+ through, so the editor says that rather than inviting a blind retry, and reloads what it can. The
1119
+ content and branch-list load failures offer Retry. A comments panel or popover held open by a
1120
+ write in flight says why.
1121
+
1122
+ **To adopt.** Nothing, unless browser tests hold an API response for longer than a deadline to
1123
+ observe a busy state; keep such holds under it.
1124
+
1125
+ ### Field editing: descriptions, list-card titles, and public/ images, continued
1126
+
1127
+ <!-- changeset: list-card-titles-and-block-descriptions -->
1128
+
1129
+ _First in int.115._
1130
+
1131
+ Continues [Field editing: descriptions, list-card titles, and public/ images](#field-editing-descriptions-list-card-titles-and-public-images).
1132
+
1133
+ **What changed.** A list card with no `itemTitleField`, or whose `itemTitleField` value is blank,
1134
+ takes its title from its first non-blank `string` field, then "Item N" (or its `itemLabel`, as in
1135
+ "Team member N"). A block template's `description` now renders; see
1136
+ [List items and blocks collapse](#list-items-and-blocks-collapse-and-reorder-from-one-menu).
1137
+
1138
+ ### List items and blocks collapse, and reorder from one menu
1139
+
1140
+ <!-- changeset: list-items-and-blocks-collapse -->
1141
+
1142
+ _First in int.115._
1143
+
1144
+ **What changed.** Object-list items and block sections are collapsible rows with a drag handle
1145
+ and a "…" menu: Move up, Move down, Duplicate, Remove. Blocks are added from "Add section" or the
1146
+ "+" above any block; both menus show each template's `description`, as does an open block. A
1147
+ `list: true` object takes an optional `itemLabel` ("team member") naming its items.
1148
+
1149
+ **To adopt.** Optionally set `itemLabel`. Browser tests that clicked a row's "Remove", "Move block
1150
+ up" or the "Add block..." select use the row's `Actions for <title>` menu (`Actions for <title>, <secondary>` when the row shows
1151
+ a second value) or "Add section".
1152
+
1153
+ **Now deletable.** Template labels that repeated the template's description.
1154
+
1155
+ ### "Add entry" follows path permissions; admin panels and labels tidied
1156
+
1157
+ <!-- changeset: navigator-add-follows-path-access-and-ux-batch2-rest -->
1158
+
1159
+ _First in int.115._
1160
+
1161
+ **What changed.** The entries listing (`GET /:branch/entries`) gains `collectionAccess`, one
1162
+ `canAddEntry` per listed collection, and the navigator offers "Add entry…" only where it is true.
1163
+ The group and permission managers always name people, never show a raw id, confirm before their
1164
+ save bar's "Discard changes", and keep Delete off the built-in groups. A read-only toggle can be
1165
+ focused and announces its state. The sync-failed badge's reason opens on focus and touch. Editor
1166
+ labels such as "All entries", "Manage permissions" and "Create group" are now in sentence case.
1167
+ Discarding an entry's changes toasts only for a read-only branch's hidden draft, where the form shows
1168
+ no change.
1169
+
1170
+ ### Quick open, a navigator filter, title-first new entries, and media search
1171
+
1172
+ <!-- changeset: quick-open-navigator-filter-media-search -->
1173
+
1174
+ _First in int.115._
1175
+
1176
+ **What changed.** Ctrl+P (⌘P on macOS) opens a quick-open list of entries from anywhere in the
1177
+ editor, even inside a text field, unless another dialog is open (the Content drawer gives way to
1178
+ it), so the browser's print shortcut never prints while the editor
1179
+ is open. The navigator filters by title or slug. "Add entry" asks for the title first, when the
1180
+ entry type has a title field, and derives the slug from it until the slug is edited. The media
1181
+ library searches, sorts and filters all its files, in a grid or a list.
1182
+
1183
+ **To adopt.** Nothing, unless browser tests add an entry by typing only a slug: fill in "Title"
1184
+ too when the entry type has one.
1185
+
1186
+ ### A save never brings back a deleted or moved entry — **behaviour change**
1187
+
1188
+ <!-- changeset: save-never-recreates-deleted-entry -->
1189
+
1190
+ _First in int.115._
1191
+
1192
+ **What changed.** A save of an entry that was deleted or moved since it was opened is now refused
1193
+ with a 409 (code `ENTRY_GONE`) instead of quietly re-creating it, which undid the other editor's
1194
+ delete or left a second copy beside a renamed entry. For a renamed entry the editor opens it under
1195
+ its new name with the changes still in the form, ready to save again. For a deleted one it keeps
1196
+ the changes and offers "Save as new entry", a deliberate create. This also covers an editor's own
1197
+ delete or rename that got no answer. Creating an entry is unchanged.
1198
+
1199
+ ### Submit names unsaved entries; unanswered writes recover more
1200
+
1201
+ <!-- changeset: submit-warns-unsaved-and-unanswered-writes-recover -->
1202
+
1203
+ _First in int.115._
1204
+
1205
+ **What changed.** Submitting a branch with unsaved edits names the entries the submit leaves
1206
+ out, and its confirm reads "Submit anyway". After a write the server never answered, the editor
1207
+ re-reads what it touched: a comment the reload shows counts as posted, otherwise it is a yellow
1208
+ warning; a rename the re-list shows under its new name keeps that entry selected, otherwise the
1209
+ rename dialog warns in yellow; and a collection create or details save fills its dialog in again
1210
+ on the next open, unless a fresh read then shows it landed (a create only once a read rules it
1211
+ out). The save-outcome toasts name their
1212
+ entry. A lookup sent as a POST that times out says to try again. Removing a staged entry type
1213
+ from a new collection goes at once, with Undo in the dialog.
1214
+
1215
+ ### Unlabelled fields and block templates show their names as words
1216
+
1217
+ <!-- changeset: unlabelled-fields-read-as-words -->
1218
+
1219
+ _First in int.115._
1220
+
1221
+ **What changed.** A field, group or block template with no `label` shows its name in sentence case,
1222
+ `metaText` as "Meta text", in its form label, the outline, comment and validation breadcrumbs, the
1223
+ block picker and the Remove toast.
1224
+
1225
+ **To adopt.** Nothing, unless your browser tests find such a field by its raw name. Give it a
1226
+ `label` to choose the words yourself.
1227
+
1228
+ ### Stable releases follow the changes they carry, and ship their changelog
1229
+
1230
+ <!-- changeset: stable-releases-from-changesets -->
1231
+
1232
+ _First in int.115._
1233
+
1234
+ **What changed.** A stable version is now chosen by what it carries: a minor when an entry asks
1235
+ adopters to act, a patch otherwise. The first one is `0.1.0`. Each stable build ships this
1236
+ changelog as `node_modules/canopycms/CHANGELOG.md`, with its links pinned to the release commit,
1237
+ as int builds already do.
1238
+
1239
+ **To adopt.** Nothing. With `0.1.0` and later, a `^0.1.0` range takes patches without action.
1240
+
1241
+ ### List items and blocks carry a `_key`, and field comments follow it — **behaviour change**
1242
+
1243
+ <!-- changeset: list-items-and-blocks-carry-keys -->
1244
+
1245
+ _First in int.115._
1246
+
1247
+ **What changed.** Every object-list item and block now has a `_key`, a short string that names it
1248
+ among its list's items. A move or an edit keeps it; Add, insert and Duplicate get new ones. The
1249
+ editor writes keys into the file on the next save, so the first save of an entry adds a `_key`
1250
+ line to each of its list items and blocks. Until then, reads give an item without one a key made
1251
+ from its content. A comment on a field inside a list item or block now follows that item. A
1252
+ comment whose item was removed stays on the entry, under "Comment on this entry", marked "On a
1253
+ removed item".
1254
+
1255
+ Reads return `_key`, and so does the data a `validateEntry` hook is given: `TypeFromEntrySchema`
1256
+ types it as an optional `_key?: string` on list items and blocks. A block keeps it beside
1257
+ `template`, never inside `value`. The unknown-field warnings never report it.
1258
+
1259
+ **To adopt.** Rename any schema field named `_key`: the config now refuses it. If your code,
1260
+ a `validateEntry` hook included, checks content against a strict schema, allow `_key` on list
1261
+ items and blocks. Don't spread a list item onto a DOM element (`<li {...item}>`), or it renders
1262
+ a `_key` attribute. `_key` also suits React's `key` when rendering a list.
1263
+
1264
+ ### `stampOnPublish` dates a new entry at its first submit
1265
+
1266
+ <!-- changeset: datetime-stamp-on-publish -->
1267
+
1268
+ _First in int.115._
1269
+
1270
+ **What changed.** A top-level `datetime` field can set `stampOnPublish: true`. When Canopy first
1271
+ submits an entry the base branch does not have, it fills that field, if empty, with the submit's
1272
+ time, in the commit it pushes. A set value is never overwritten, and an entry already on the base
1273
+ branch, even renamed or moved, or merged without a Canopy submit, is never stamped. The field's description
1274
+ states the rule, the submit toast counts the entries dated, and after a submit that succeeds the
1275
+ editor carries an unsaved draft of a stamped entry onto its stamped version.
1276
+ The flag is refused off a top-level `datetime`, on `list: true`, and with `required: true`.
1277
+
1278
+ **To adopt.** Add `stampOnPublish: true` to the date field your site sorts or labels by, such as
1279
+ `{ name: 'publishedAt', type: 'datetime', stampOnPublish: true }`. See [Stamping a publication date](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#stamping-a-publication-date).
1280
+
1281
+ **Now deletable.** A build step or hook that writes a missing publication date into new entries.
1282
+
1283
+ ---
1284
+
1285
+ ## Released
1286
+
1287
+ ### 0.0.67
1288
+
1289
+ #### Clerk sign-in works behind CloudFront OAC
1290
+
1291
+ **What changed.** `@clerk/nextjs`'s provider makes every `setActive` (sign-in, account or org
1292
+ switch) wait on a Server Action, whose POST 403s behind `CanopyCmsDistribution`'s OAC, so the wait
1293
+ never ended. `canopycms-auth-clerk` skips it while the editor is mounted, and exports
1294
+ `useSkipClerkSetActiveAction()` for Clerk components elsewhere in the CMS build.
1295
+
1296
+ **To adopt.** Nothing for the editor, if your edit page uses `useClerkAuthConfig()` or
1297
+ `ClerkSignIn`. If your CMS build renders `<SignIn>`, `UserButton` or `OrganizationSwitcher` on your
1298
+ own pages, mount the hook as in
1299
+ [Clerk components on your own pages](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/packages/canopycms-auth-clerk/README.md#clerk-components-on-your-own-pages).
1300
+ Your own Server Actions in the CMS build still 403 behind OAC.
1301
+
1302
+ **Now deletable.** Any local patch of `window.__internal_onBeforeSetActive` (or 6.x's
1303
+ `__unstable__onBeforeSetActive`), and any edge function added only to make Clerk sign-in finish.
1304
+
1305
+ #### The editor handles signed-out users itself, so `clerkMiddleware` is optional
1306
+
1307
+ **What changed.** The editor treats a 401 from the CMS API as signed out and shows the auth
1308
+ provider's sign-in screen, over the open editor when a session ends mid-edit, so unsaved edits
1309
+ survive signing back in. `useClerkAuthConfig()` and `useDevAuthConfig()` supply that screen. The
1310
+ API verifies tokens from `CLERK_JWT_KEY` alone. `init --auth clerk` writes the same passthrough
1311
+ `middleware.ts` as dev auth, with `clerkMiddleware` as a commented opt-in.
1312
+
1313
+ **To adopt.** Nothing, if your edit page passes one of those hooks into `config.client(...)`. A
1314
+ custom auth provider adds `editor.SignInComponent`, which receives `EditorSignInProps`. `init` keeps
1315
+ an existing `middleware.ts` unless you confirm or pass `--force`; either replaces an active
1316
+ `clerkMiddleware`, so re-apply the commented snippet if you want the edge check.
1317
+
1318
+ **Now deletable.**
1319
+
1320
+ - A signed-out gate around the editor page, such as `<SignedOut>` wrappers or a `useAuth()` check
1321
+ rendering `<RedirectToSignIn />`, and reload-on-401 or "session expired" handling.
1322
+ - A `clerkMiddleware` kept only to send signed-out visitors to sign-in, or written by an earlier
1323
+ `init` if you never chose the edge check, and with it `CLERK_SECRET_KEY` in the deployed CMS runtime.
1324
+
1325
+ #### A worker credential can be one field of a JSON secret
1326
+
1327
+ **What changed.** The EC2 worker can read one field of a Secrets Manager secret whose value is a
1328
+ JSON document. Unset, the secret's whole string is the credential, byte for byte; a secret that
1329
+ parses as a JSON object with no field configured logs a warning. With a field set, every miss fails
1330
+ fast naming the ARN, the field and the keys present, never a value.
1331
+
1332
+ | `CanopyCmsService` prop | Worker env var | Names a field in |
1333
+ | ------------------------------- | ------------------------------------------ | ------------------------- |
1334
+ | `githubTokenSecretJsonField` | `CANOPYCMS_GITHUB_TOKEN_SECRET_JSON_FIELD` | `githubTokenSecretArn` |
1335
+ | `clerkSecretKeySecretJsonField` | `CLERK_SECRET_KEY_SECRET_JSON_FIELD` | `clerkSecretKeySecretArn` |
1336
+
1337
+ **To adopt.** Nothing, unless one of those secrets holds a JSON document: then set the matching
1338
+ prop. The scaffolded stack fills them from the repository _variables_
1339
+ `CANOPY_GITHUB_TOKEN_SECRET_JSON_FIELD` and `CLERK_SECRET_KEY_SECRET_JSON_FIELD`
1340
+ ([why the prefix](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#repository-secrets-and-variables)); a stack scaffolded earlier
1341
+ adds the props to `infrastructure/bin/app.ts` and `infrastructure/lib/cms-stack.ts` and the two `env`
1342
+ lines to `.github/workflows/deploy-cms.yml`, or re-runs the generator and diffs. `cdk synth` refuses a `…JsonField` prop without its `…SecretArn`, and an ARN carrying the ECS `:KEY::` suffix
1343
+ (also in `secretsArns`). Don't use CDK's `secretValueFromJson`: it resolves the plaintext into the deployed
1344
+ resource's configuration, readable by anyone who can describe it. Only `CLERK_SECRET_KEY` goes through Secrets Manager; `CLERK_JWT_KEY` and the publishable
1345
+ key are public material ([Security Model](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#security-model)).
1346
+
1347
+ **Now deletable.** A wrapper that fetches the secret, parses it and re-exports one field into the
1348
+ worker's environment, such as a `jq` step in user data or a wrapper entrypoint around
1349
+ `canopy-worker`.
1350
+
1351
+ #### The worker can authenticate to GitHub as an App
1352
+
1353
+ **What changed.** The worker can act as a GitHub App installation instead of using a token.
1354
+ `githubToken` is not deprecated and stays the documented default; `canopycms` neither depends on
1355
+ nor imports `@octokit/auth-app`. Register one App per site, not one shared across repositories:
1356
+ anyone holding an App's key can mint a token for any of its installations
1357
+ ([why](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/ARCHITECTURE.md#why-one-github-app-per-site-not-one-shared-across-an-organisation)).
1358
+
1359
+ **To adopt with `canopycms-cdk`** (existing stacks need no edit). `CanopyCmsServiceProps` takes
1360
+ `githubAppId`, `githubAppInstallationId`, `githubAppPrivateKeySecretArn` and
1361
+ `githubAppPrivateKeySecretJsonField`, and the worker entrypoint builds the credential:
1362
+
1363
+ 1. Register the App under your organisation, install it on the content repository with
1364
+ **Contents: read & write** and **Pull requests: read & write**, and store its PEM private key in
1365
+ Secrets Manager. `canopycms init-github-app create -- <command>` does this from a manifest: it
1366
+ pipes the key to your command's standard input (or `--key-out <path>` writes a `0600` file), and
1367
+ `verify` checks an existing installation's permissions: run it, since an App missing a
1368
+ permission only shows up later, as `sync-failed`. `create` needs an interactive terminal and
1369
+ never edits an existing JSON secret (create one and point the JSON-field prop at it)
1370
+ ([details](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#register-it-with-canopycms-init-github-app)).
1371
+ 2. Set the three props and **remove `githubTokenSecretArn`** (with its JSON field). A partial set
1372
+ of the three is refused at synth, and so is an App alongside a token.
1373
+ 3. In the generated workflow, store them as `CANOPY_GITHUB_APP_ID`,
1374
+ `CANOPY_GITHUB_APP_INSTALLATION_ID` and `CANOPY_GITHUB_APP_PRIVATE_KEY_SECRET_ARN`: GitHub refuses
1375
+ an Actions secret or variable whose name starts with `GITHUB_`. The workflow maps each onto the
1376
+ unprefixed variable the CDK app reads. A workflow, `app.ts` or `cms-stack.ts` scaffolded before
1377
+ 0.0.67 has none of this wiring: re-run `init-deploy aws` and diff.
1378
+
1379
+ The private key is ARN-only; passing the key itself is refused at synth. Its ARN joins the worker's
1380
+ IAM policy automatically, so you need not add it to `secretsArns`. See
1381
+ [Authenticating as a GitHub App](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#authenticating-as-a-github-app).
1382
+
1383
+ ```bash
1384
+ canopycms init-github-app create -- \
1385
+ aws secretsmanager create-secret --name canopycms/github-app-key --secret-string file:///dev/stdin
1386
+ ```
1387
+
1388
+ **To adopt from your own `CmsWorker` entrypoint.** Supply `githubAppAuth` _instead of_
1389
+ `githubToken`; both is rejected.
1390
+
1391
+ ```ts
1392
+ import { createAppAuth } from '@octokit/auth-app' // YOUR dependency, not canopycms's
1393
+ import { CmsWorker, normalizeGitHubAppPrivateKey } from 'canopycms/worker/cms-worker'
1394
+
1395
+ // ONE instance: it holds the installation-token cache, so sharing it keeps the REST
1396
+ // and git halves on the same hourly token.
1397
+ const appAuth = createAppAuth({
1398
+ appId,
1399
+ installationId,
1400
+ privateKey: normalizeGitHubAppPrivateKey(rawPrivateKey),
1401
+ })
1402
+
1403
+ new CmsWorker({
1404
+ ...rest,
1405
+ githubAppAuth: {
1406
+ mintInstallationToken: async () => (await appAuth({ type: 'installation' })).token,
1407
+ // A closure, not `authStrategy: createAppAuth` — that would have Octokit build a
1408
+ // SECOND instance with its own separate cache.
1409
+ octokitAuth: { authStrategy: () => appAuth, auth: {} },
1410
+ },
1411
+ })
1412
+ ```
1413
+
1414
+ `normalizeGitHubAppPrivateKey` repairs `\n` escapes and base64-wrapped PEMs, converts PKCS#1 to
1415
+ PKCS#8, and throws where the key is configured. The worker's git remote and REST client target
1416
+ github.com. `GitHubService` stays static-token-only.
1417
+
1418
+ **Now deletable.** A hand-written entrypoint that existed only to get App auth onto a CDK
1419
+ deployment; user-data steps that fetched the PEM into the worker's environment; your own PEM
1420
+ conversion; code that mints a token at boot and holds it (installation tokens last about an hour);
1421
+ a wrapper that catches and re-throws a mint failure, which drops the HTTP status the task
1422
+ classifier reads, so a bad key burns every publish's retry budget; and a runbook step that
1423
+ downloads the `.pem` from the App's settings page.
1424
+
1425
+ #### A rotated secret reaches the running worker, without an instance replacement
1426
+
1427
+ **What changed.** The worker re-reads a Secrets Manager secret (the GitHub token or the Clerk secret
1428
+ key) when the operation using it fails. Timings, costs and the store-before-revoke order are in
1429
+ [Rotating a secret](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#rotating-a-secret).
1430
+
1431
+ **To adopt.** Nothing for credentials from `*_SECRET_ARN`, which is every scaffolded deployment. A
1432
+ GitHub App private key is still read only at boot: store the new key, replace the instance, and
1433
+ only then delete the old key on GitHub. A plain env var (`CANOPYCMS_GITHUB_TOKEN`,
1434
+ `CLERK_SECRET_KEY`) is never re-read.
1435
+
1436
+ A custom `CmsWorker` entrypoint can pass `refreshGitHubToken?: () => Promise<string | undefined>`,
1437
+ returning the new token or `undefined` for nothing to do. Core calls it after a failed git sync or
1438
+ task, at most once per `refreshGitHubTokenMinIntervalMs` (default `60000`; `0` disables it), and
1439
+ abandons a call still unsettled after `taskTimeoutMs`. Clerk's cache refreshes through
1440
+ `refreshAuthCache`. `packages/canopycms-cdk/worker/credential-refresh.ts` is the worked example.
1441
+
1442
+ **Now deletable.** Automation that runs `cdk deploy` or an instance refresh after rotating a secret
1443
+ only to pick it up, unless it is for a GitHub App private key.
1444
+
1445
+ #### `assetUploadBehavior()` builds the upload route from a bucket alone
1446
+
1447
+ **What changed.** `canopycms-cdk` exports a free function taking the same options as
1448
+ `AssetSupportProps.uploadBehavior` plus the `bucket`:
1449
+
1450
+ ```ts
1451
+ import { assetUploadBehavior } from 'canopycms-cdk'
1452
+
1453
+ const uploads = new cloudfront.Distribution(this, 'AssetUploads', {
1454
+ defaultBehavior: assetUploadBehavior(this, { bucket: assetBucket }),
1455
+ })
1456
+ // media.uploadUrl = `https://${uploads.distributionDomainName}/`
1457
+ ```
1458
+
1459
+ **To adopt.** Optional. Don't move an existing deployment from `AssetSupport.uploadBehavior()` to
1460
+ the function: its three CloudFront resources would get new logical IDs and be replaced.
1461
+
1462
+ **Now deletable.** An `AssetSupport` instantiated only to reach `uploadBehavior()`. Cross-account,
1463
+ also check the bucket policy for its grant.
1464
+
1465
+ #### The CMS image builds without git, `CanopyCmsService` defaults to arm64, and the CDK app is type-checked — **breaking (deploy), for a stack that sets `platform` without `architecture`**
1466
+
1467
+ **What changed.** Mostly for stacks from an earlier `canopycms init-deploy aws` or a hand-copied
1468
+ `Dockerfile.cms.template`:
1469
+
1470
+ 1. `next build` reads content from the build context's working tree, never git, a branch clone or
1471
+ `.canopy-dev`. The generated `Dockerfile.cms` builder stage installs no git, commits no snapshot
1472
+ repository, and sets `ENV CANOPY_BUILD_MODE=true`; the runner stage still installs git.
1473
+ 2. The pnpm Dockerfile copies `pnpm-workspace.yaml` before installing; pnpm 11 keeps its
1474
+ `allowBuilds` decisions there.
1475
+ 3. `init-deploy aws` keeps `infrastructure/` out of the app's `tsconfig.json` and `.dockerignore`,
1476
+ and the generated workflow type-checks it with `tsc --noEmit -p infrastructure` (tsx, which
1477
+ `cdk.json` runs, drops a misspelled prop silently).
1478
+ 4. `CanopyCmsService` defaults to `Architecture.ARM_64` and passes it to the function, from which
1479
+ CDK derives a `fromImageAsset` image's platform; the workflow runs on `ubuntu-24.04-arm`.
1480
+ 5. `withCanopy()` adds sharp's libvips to Next's file tracing (not for a static export) and, on
1481
+ Next 16 and later, sets `turbopack: {}` when your config has neither `turbopack` nor your own
1482
+ `webpack` and it can read your Next version. A webpack build still fails its image transforms
1483
+ ([Dual Build Support](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#dual-build-support)).
1484
+
1485
+ **Breaking:** a stack setting `platform` on `fromImageAsset` without `architecture` gets an arm64
1486
+ function while `platform` still decides the image, so `Platform.LINUX_AMD64` builds an image the
1487
+ function cannot run. A stack setting neither moves to arm64 with a matching image, built under QEMU
1488
+ on an x86 host ([Where the image is built](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#where-the-image-is-built)).
1489
+
1490
+ **To adopt.**
1491
+
1492
+ 1. Delete `platform` from `fromImageAsset` with its `Platform` import; set `architecture` on
1493
+ `CanopyCmsService` only if you want x86_64.
1494
+ 2. Re-run `canopycms init-deploy aws`. It asks before replacing each existing file
1495
+ (`--non-interactive` skips them, `--force` replaces everything, including an edited stack), and
1496
+ always adds `infrastructure/tsconfig.json`, and the `exclude` entry unless `tsconfig.json` is
1497
+ missing, isn't plain JSON, or inherits `exclude` through `extends` (it then tells you to add it). In files you keep, bring
1498
+ across: the workflow's "Type-check the CDK app" step before "Configure AWS credentials", with
1499
+ `tsconfig.json` in `on.push.paths` (`examples/aws-deployment/deploy-cms.yml` has both);
1500
+ `runs-on: ubuntu-24.04-arm`; an `infrastructure` line in `.dockerignore`; and, with pnpm,
1501
+ `pnpm-workspace.yam[l]` in the Dockerfile's first `COPY`.
1502
+ 3. A hand-copied `Dockerfile.cms` builds as before; when convenient, delete the builder's git
1503
+ install and snapshot commit and add `ENV CANOPY_BUILD_MODE=true` before the build command.
1504
+
1505
+ **Now deletable.** The builder's git install and snapshot commit, and any step creating or checking
1506
+ out the base branch so the build could find it; CI steps attaching a detached HEAD for the same
1507
+ reason; `platform` on `fromImageAsset`; and, with `withCanopy()`, a hand-written
1508
+ `outputFileTracingIncludes` entry for libvips and a `turbopack: {}` added only for Next 16's error
1509
+ (unless under Yarn PnP, where `withCanopy()` cannot read your Next version).
1510
+
1511
+ ### 0.0.66
1512
+
1513
+ #### `media.uploadUrl` routes presigned uploads through your own CDN (#44)
1514
+
1515
+ **What changed.** `media.uploadUrl` (s3 adapter; an absolute http(s) URL or a site-relative path)
1516
+ replaces the `url` that `beginUpload()` returns, leaving the presign's `fields` untouched, so
1517
+ uploads can be same-origin. See [Media Configuration](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#media-configuration) for the
1518
+ CloudFront behavior it expects.
1519
+
1520
+ **To adopt.** Optional. Set it from an environment variable: a site-relative value works only where
1521
+ that path routes to the bucket, so it 404s under `next dev`.
1522
+
1523
+ **Now deletable.** The bucket CORS rule naming your editor's origin, once uploads are same-origin.
1524
+ `AssetSupport`'s `editorOrigins` is then unused; standalone mode still needs either it or
1525
+ `uploadBehavior`.
1526
+
1527
+ #### `media` config rejects unknown keys
1528
+
1529
+ **What changed.** Each branch of `mediaSchema` is `.strict()`, so a misspelled key under `media`
1530
+ fails validation, naming it.
1531
+
1532
+ **To adopt.** Fix or remove the key.
1533
+
1534
+ #### `AssetSupport` and `CanopyCmsService` take an execution role, so its ARN is derivable without a construct reference (#42)
1535
+
1536
+ **What changed.** `CanopyCmsServiceProps.lambdaRole?: iam.Role` (and
1537
+ `AssetSupportProps.transformRole`, which since [S3-only assets](#canopycms-cdk-assetsupport-serves-images-from-s3-only--breaking)
1538
+ needs `lazyPublicTransforms: true`) let you pass a deterministically named role, so a cross-account
1539
+ bucket's stack computes `arn:aws:iam::<account>:role/<name>` from literals instead of a
1540
+ `Fn::GetStackOutput` reference only the CDK CLI resolves. The constructs re-attach
1541
+ `AWSLambdaBasicExecutionRole` (and the VPC policy) that `lambda.Function` drops from a role you
1542
+ pass. See [Cross-account asset bucket](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#cross-account-asset-bucket).
1543
+
1544
+ **To adopt.** Optional:
1545
+
1546
+ ```ts
1547
+ const roleName = `canopy-cms-${tier}` // derive it however you name things
1548
+ const role = new iam.Role(this, 'CmsRole', {
1549
+ roleName,
1550
+ assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
1551
+ })
1552
+ new CanopyCmsService(this, 'Cms', { /* ... */ lambdaRole: role })
1553
+
1554
+ // In the bucket's stack, in the other account - no reference, just literals:
1555
+ const principal = new iam.ArnPrincipal(`arn:aws:iam::${tierAccount}:role/${roleName}`)
1556
+ ```
1557
+
1558
+ A named role needs `CAPABILITY_NAMED_IAM` and cannot be replaced in place without a rename. The type
1559
+ is `iam.Role`, not `IRole`: `addManagedPolicy` does nothing on an imported role, so create the role
1560
+ in the compute's stack rather than passing `Role.fromRoleArn`.
1561
+
1562
+ **Now deletable.** A `Fn::GetStackOutput`-producing cross-stack reference, a `CfnOutput`-plus-manual
1563
+ wiring step, or an asset grant scoped to the whole compute account because the role could not be
1564
+ named.
1565
+
1566
+ #### `AssetSupport.attachTo()` and `CanopyCmsDistribution`'s `assetSupport` prop order the asset behaviors for you (#41)
1567
+
1568
+ **What changed.** CloudFront takes the first matching behavior, so `/assets/*` listed before
1569
+ `/assets/t/*` (as alphabetizing does) sends every derivative to the wrong behavior.
1570
+ `AssetSupport.attachTo(distribution, overrides?)` adds `/assets/t/*` then `/assets/*`, merging
1571
+ `overrides` (`Partial<cloudfront.AddBehaviorOptions>`) into both. `CanopyCmsDistribution` takes
1572
+ `assetSupport` and `assetBehaviorOverrides` and calls it for you. Its synth checks refuse
1573
+ `/assets/*` before `/assets/t/*`, the literal `assets`/`assetsTransform` keys spread from
1574
+ `assetBehaviors()`, `assetSupport` while `additionalBehaviors` still lists an asset pattern, and
1575
+ `assetBehaviorOverrides` without `assetSupport`. `assetBehaviors()` is unchanged, and a bespoke
1576
+ `cloudfront.Distribution` calls `attachTo()` directly.
1577
+
1578
+ **To adopt.** Optional, but don't do half of it. Replace a hand-written
1579
+
1580
+ ```typescript
1581
+ additionalBehaviors: {
1582
+ '/assets/t/*': assetSupport.assetBehaviors().assetsTransform,
1583
+ '/assets/*': assetSupport.assetBehaviors().assets,
1584
+ },
1585
+ ```
1586
+
1587
+ with the prop, carrying any per-behavior options (such as a tier's basic-auth viewer-request
1588
+ function, without which `/assets/*` is anonymously readable) as overrides:
1589
+
1590
+ ```ts
1591
+ new CanopyCmsDistribution(this, 'Dist', {
1592
+ ...yourExistingDistributionProps,
1593
+ assetSupport,
1594
+ assetBehaviorOverrides: {
1595
+ functionAssociations: [{ function: tierAuthFn, eventType: FunctionEventType.VIEWER_REQUEST }],
1596
+ },
1597
+ })
1598
+ // on a bespoke distribution, pass the same object as attachTo's second argument
1599
+ ```
1600
+
1601
+ **Now deletable.** The hand-ordered `additionalBehaviors` block, and comments reminding you of the
1602
+ order. Keep `assetBehaviors()` only if the two behaviors must differ, with your own assertion on
1603
+ the synthesized `CacheBehaviors` order.
1604
+
1605
+ #### `canopycms-auth-clerk` supports Clerk Core 3 (`@clerk/nextjs` 7.x, `@clerk/backend` 3.x)
1606
+
1607
+ **What changed.** Peer ranges are `@clerk/nextjs: ^6.0.0 || ^7.0.0` and
1608
+ `@clerk/backend: ^2.0.0 || ^3.0.0`. Networkless `verifyToken` PEM verification still works.
1609
+
1610
+ **To adopt.** Optional. Core 3 needs `<ClerkProvider>` inside `<body>`, not around `<html>`
1611
+ (`apps/example1/app/layout.tsx`); a dual-build editor layout is already inside `<body>`. If you
1612
+ render `AccountComponent` yourself with `afterSignOutUrl`/`signOutUrl`, move them to
1613
+ `ClerkProvider`'s `afterSignOutUrl` or a `SignOutButton`. If you keep `clerkMiddleware`, it needs a non-empty `secretKey`.
1614
+ `CLERK_ENCRYPTION_KEY` does not apply to
1615
+ the scaffold's commented `clerkMiddleware` snippet, which passes only `jwtKey`. Node must be >= 22.12.0 for every CanopyCMS
1616
+ package regardless.
1617
+
1618
+ ```tsx
1619
+ <html lang="en">
1620
+ <body>
1621
+ <ClerkProvider>{children}</ClerkProvider>
1622
+ </body>
1623
+ </html>
1624
+ ```
1625
+
1626
+ #### `CanopyCmsService` gains `settingsBranch`, and the generated stack derives both branches from `canopycms.config.ts` (#39)
1627
+
1628
+ **What changed.** `settingsBranch` sets the worker's `CANOPYCMS_SETTINGS_BRANCH`; it and
1629
+ `baseBranch` are checked against git's branch-name rules at synth. The generated
1630
+ `infrastructure/lib/cms-stack.ts` imports `canopycms.config.ts` and passes
1631
+ `baseBranch: canopyConfig.server.defaultBaseBranch` and
1632
+ `settingsBranch: canopyConfig.server.settingsBranch`.
1633
+
1634
+ **To adopt.** Regenerate `cms-stack.ts` or copy the import and two lines; your own stack sets both
1635
+ props to match `canopycms.config.ts`. **Do this now if your default branch is not `main` or you set
1636
+ `settingsBranch`**: the worker otherwise exits at start or syncs a different settings branch than
1637
+ the Lambda writes. From int.110, prod detects an unset base branch from the repository's
1638
+ default branch ([Prod detects an unset `defaultBaseBranch`](#prod-detects-an-unset-defaultbasebranch-instead-of-assuming-main--behaviour-change-startup-can-fail)).
1639
+
1640
+ **Now deletable.** Runbook steps keeping `CANOPYCMS_BASE_BRANCH` or a settings-branch override in
1641
+ sync with `canopycms.config.ts` by hand.
1642
+
1643
+ #### `CLERK_JWT_KEY` is a repository **variable**, not a secret (#37)
1644
+
1645
+ **What changed.** The generated `deploy-cms.yml` reads `${{ vars.CLERK_JWT_KEY }}`: it is Clerk's
1646
+ public JWKS PEM, used only to verify signatures.
1647
+
1648
+ **To adopt.** If you regenerate or copy the workflow, move `CLERK_JWT_KEY` from repository secrets
1649
+ to variables (Settings → Secrets and variables → Actions → Variables); `bin/app.ts` refuses the
1650
+ deploy at synth while it is unset. Check that `bin/app.ts` passes `CLERK_SECRET_KEY` as
1651
+ `clerkSecretKeySecretArn` (read by the worker), never in the Lambda's `environment`, unless you keep
1652
+ `clerkMiddleware` ([Security Model](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/docs/deploying-to-aws.md#security-model)).
1653
+
1654
+ #### `readByUrlPath` answers only where `listEntries` publishes — **breaking (routing)**
1655
+
1656
+ **What changed.** For every entry, `readByUrlPath(item.urlPath)` reaches it and no other URL does.
1657
+ These now return `null`: `/<collection>/<entryTypeName>` (which reached the collection's index
1658
+ entry) and `/<collection>/<entryTypeName>/<slug>`; and an entry whose type token on disk its
1659
+ collection does not declare (a type renamed without renaming files, or a collection declaring no
1660
+ entry types); such entries were already missing from listings, static params and the sitemap.
1661
+ Those entries stay editable, renameable and deletable. `read({ entryPath: 'content/home' })` still
1662
+ addresses a singleton, defaulting the slug to the entry type's name. A legacy untyped file
1663
+ (`overview.json`) is still readable by URL but invisible to listings; rename it into the
1664
+ `{type}.{slug}.{id}.{ext}` grammar.
1665
+
1666
+ **To adopt.** On a catch-all route, request `/<collection>/<entryTypeName>` and
1667
+ `/<collection>/<entryTypeName>/<some-slug>` for a few type names and confirm a 404 (under
1668
+ `next dev` or `output: 'standalone'` they were served). If you renamed an entry type without
1669
+ renaming its files, rename them or declare the type. A build does not flag them; they are the files missing from
1670
+ `listEntries()`.
1671
+
1672
+ **Now deletable.** Per-route `entryType` gates that only reject a URL that should not have resolved
1673
+ (keep a branch that dispatches between templates), catch-all filters dropping entry-type names, and
1674
+ tests asserting a specific phantom URL returns `null`.
1675
+
1676
+ #### Static exports are reproducible: `CANOPY_BUILD_ID` pins the build id, and the AI manifest stops baking a wall clock — **breaking (type-level)**
1677
+
1678
+ **What changed.** `withCanopy(..., { staticBuild: true })` uses `CANOPY_BUILD_ID` as Next's build id
1679
+ (your own `generateBuildId` still wins; non-static builds ignore it). `generate-ai-content` records
1680
+ `buildId` from it in `public/ai/manifest.json`, and pins `generated` to `SOURCE_DATE_EPOCH`, or
1681
+ omits it when a build id is set without one. `AIManifest.generated` is `string | undefined`.
1682
+
1683
+ **To adopt.** Guard reads of `AIManifest.generated`. To make an export reproducible, export
1684
+ `CANOPY_BUILD_ID` for both `next build` and `generate-ai-content`, and `SOURCE_DATE_EPOCH` to keep
1685
+ the manifest's timestamp. The id is 1-255 characters of `[A-Za-z0-9._-]`, not `.` or `..`; an
1686
+ unusable value is ignored with a warning. Derive it from a tree hash, not a commit SHA or date,
1687
+ which a rebase changes for an identical tree.
1688
+
1689
+ **Now deletable.** A `generateBuildId: () => process.env.<YOUR_VAR> || null` line, **only if** you
1690
+ pass `{ staticBuild: true }` (on other builds it un-pins the id). Written with `??`, such a line
1691
+ ships an empty build id from an empty variable. Also a post-build step rewriting the manifest's
1692
+ `generated`.
1693
+
1694
+ ### 0.0.65
1695
+
1696
+ #### `canopycms-cdk`: CloudFront waits as long as the CMS Lambda, and the worker boots from AL2023's Node 22
1697
+
1698
+ **What changed.**
1699
+
1700
+ - `CanopyCmsDistribution` takes `originReadTimeout`, defaulting to 60 seconds, the CMS Lambda's
1701
+ default `timeout` (`cmsService.timeout` exposes the actual one); synth refuses more than 60
1702
+ seconds. Without a `certificate`, it refuses a stack region other than `us-east-1`, where
1703
+ CloudFront needs its certificate, when the region is known at synth.
1704
+ - The worker installs Node 22 with `dnf` and runs `/usr/bin/node-22`; a failed boot step shuts the
1705
+ instance down so the group replaces it. Its IAM policy covers `githubTokenSecretArn` and
1706
+ `clerkSecretKeySecretArn` as well as `secretsArns`. The bot token never stays in
1707
+ `remote.git`'s config on EFS, and one an earlier worker left there is scrubbed.
1708
+ - An `/assets/t/` URL whose slug is not the asset's own answers 404; `assetUrl` keeps the stored
1709
+ `src`'s slug, so the URLs it builds are unaffected.
1710
+ - `canopycms worker run-once` exits 1 on an unknown `CANOPY_AUTH_MODE`.
1711
+
1712
+ **To adopt.** If you override `CanopyCmsService`'s `timeout`, pass
1713
+ `originReadTimeout: cmsService.timeout` (the generated stack does). A hand-installed worker runs
1714
+ `dnf install -y nodejs22` and sets `ExecStart=/usr/bin/node-22 index.js`. The generated
1715
+ workflow reads the bot-token ARN from the secret `CANOPY_GITHUB_TOKEN_SECRET_ARN` (GitHub refuses
1716
+ `GITHUB_`-prefixed names): store it under that name. In a kept `Dockerfile.cms`, change both
1717
+ `node:20-slim` stages to `node:22-slim`; in `deploy-cms.yml`, set `node-version: 22`.
1718
+
1719
+ #### `canopycms init` scaffold fixes
1720
+
1721
+ **What changed.** `init` writes `middleware.ts` in the app directory's parent (`src/` for
1722
+ `--app-dir src/app`), leaves an existing `next.config.js`/`.mjs` alone and prints the wiring,
1723
+ creates `.gitignore` with `.canopy-dev/` when absent, and the generated workflow deploys on
1724
+ `next.config.*`, `middleware.ts` and `public/**` changes.
1725
+
1726
+ **To adopt.** Check what an earlier `init` left: a `middleware.ts` at the root of a `src/app`
1727
+ project, which Next never loads (move it into `src/`); a `next.config.ts` beside a `.js`/`.mjs`
1728
+ config, which Next ignores (wrap the loaded one in `withCanopy` and delete it); `.canopy-dev/`
1729
+ missing from `.gitignore`; and those three paths missing from `deploy-cms.yml`'s `on.push.paths`.
1730
+
1731
+ ### 0.0.64
1732
+
1733
+ #### `basePath` deployments are supported, and `assetUrl`'s `baseUrl` is now safe for path prefixes (#24)
1734
+
1735
+ **What changed.** `assetUrl()`/`assetSrcSet()`'s `baseUrl` accepts a same-origin path prefix
1736
+ (`'/preview-123'`): an absolute `src` is returned untouched, and a prefix without a leading slash is
1737
+ normalized. A top-level `basePath` config key makes the editor's API calls and preview pane work
1738
+ under a Next.js `basePath`.
1739
+
1740
+ **To adopt.** Under a `basePath`, state it in your Canopy config too (CanopyCMS cannot read
1741
+ `next.config`):
1742
+
1743
+ ```typescript
1744
+ // canopycms.config.ts
1745
+ basePath: process.env.NEXT_PUBLIC_BASE_PATH,
1746
+ ```
1747
+
1748
+ Then pass `baseUrl` to `assetUrl` only if Next serves `/assets` (local adapter, `next dev`, S3 with
1749
+ no distribution): `assetUrl(image, { width: 960, baseUrl: BASE_PATH })`. Assets on CloudFront via
1750
+ `AssetSupport` stay at the distribution root; pass no `baseUrl` (mount table under "Where `/assets`
1751
+ is mounted" in [Media Configuration](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#media-configuration)). Two traps:
1752
+
1753
+ - Don't pass a deployment `basePath` to `contentStaticParams({ basePath })`; that option filters by
1754
+ a nested route prefix and emits zero params, building green.
1755
+ - Body images bypass `assetUrl()`; under a `basePath` they need the README's `img` override, which
1756
+ roots a page-relative src onto the base, so make those root-relative first.
1757
+
1758
+ **Now deletable.** A prefixing wrapper re-exporting `assetUrl`/`assetSrcSet`, and a local "strip
1759
+ trailing slashes" helper (`stripTrailingSlashes` is in `canopycms/server`).
1760
+
1761
+ #### `select` fields infer their own options — **breaking (type-level)**
1762
+
1763
+ **What changed.** `TypeFromEntrySchema` infers a `select` as the literal union of its options'
1764
+ values (`'draft' | 'published'`), not `string | number`. Options must be literals at the type level
1765
+ (`defineEntrySchema` or `as const`); an array typed `SelectOption[]`, or no options, falls back to
1766
+ `string`. `''` is not in the union, though the validator accepts it for any field not marked
1767
+ `required: true`: compare cleared
1768
+ values before they reach the typed surface, or add `''` to the options.
1769
+
1770
+ **To adopt.** Fix comparisons against strings that are not options (now "no overlap" errors), and
1771
+ assignments of a plain `string` into a select value. Rename a custom field type's own `options`
1772
+ property, which is now a reserved key. Grep for `typeof value === 'number'` branches, which now
1773
+ narrow to `never` silently.
1774
+
1775
+ **Now deletable.** Casts or allowlists re-narrowing a select value to the app's own union, and
1776
+ `typeof v === 'string'` guards that only stripped `number`.
1777
+
1778
+ #### Listings resolve references, and a resolved reference carries `urlPath` and optionally its body (#16) — **breaking (type-level)**
1779
+
1780
+ **What changed.**
1781
+
1782
+ - `listEntries()`, `buildContentTree()` and `collectRoutableEntries` take `resolveReferences`
1783
+ (default `false`; `read()`'s is `true`), resolving references at any depth, including in blocks,
1784
+ at one read per distinct target per call. Path ACLs are not applied to resolved targets, as with
1785
+ `read()`.
1786
+ - Every resolved reference carries `urlPath`, the same URL `listEntries` publishes.
1787
+ - `includeBody: true` on a reference field adds the target's body, under the target type's body
1788
+ field name.
1789
+ - `TypeFromEntrySchema` adds `ResolvedReferenceMeta` (`id`, `slug`, `collection`, `urlPath`), which
1790
+ are reserved: they win over target fields of those names; read the target directly for its own
1791
+ field.
1792
+ - A save collapses a resolved reference back to its id. A reference saved through the editor on an
1793
+ earlier version may hold an object instead of a 12-character id (replace it with its own `id`), or
1794
+ `null` where its target had been deleted (the id is only in git history).
1795
+
1796
+ For what a target the reader may not see, or a missing one, resolves to, see
1797
+ [References](#a-reference-resolves-to-its-target-a-restricted-stub-or-a-missing-stub--security-fix-breaking-types-and-build).
1798
+
1799
+ **To adopt.** Turn on `resolveReferences` for surfaces that read inside references (search indexes,
1800
+ feeds); leave it off for static params and sitemaps. Add `& ResolvedReferenceMeta` (from
1801
+ `canopycms`) where an exact-shape type stops compiling. Set `includeBody: true` on fields whose
1802
+ target prose you render, or leave the body field out of `resolvedSchema`.
1803
+
1804
+ ```ts
1805
+ const entries = await ctx.listEntries({ resolveReferences: true })
1806
+ ```
1807
+
1808
+ **Now deletable.** A second `read()` pass over a listing to resolve ids, a surface rebuilt on
1809
+ per-entry `read()` to dodge the gap, a contentId → URL index built by a second content pass,
1810
+ `resolveReferences: false` forced by that index, and a follow-up `read()` only for a target's body.
1811
+
1812
+ #### Editor saves keep comments, and report content keys the schema does not define (#29)
1813
+
1814
+ **What changed.** A save re-serialises onto the file's parsed document, so YAML and frontmatter
1815
+ comments survive ([int.101 extends this to formatting](#a-save-rewrites-only-what-changed)). Unknown
1816
+ content keys come back as `validationWarnings` on save ("Saved with warnings"), and a production
1817
+ build's `collectStaticPaths`/`collectRoutableEntries` print one warning listing them by path. Nothing
1818
+ is rejected or stripped.
1819
+
1820
+ **To adopt.** Expect the first build to list keys you no longer use: add each to the schema or
1821
+ delete it.
1822
+
1823
+ **Now deletable.** Conventions keeping notes out of content files, scripts diffing content keys
1824
+ against a schema, and `?? fallback`s kept because nobody knew whether a field was populated.
1825
+
1826
+ #### An `index` entry answers only at its collection's URL, and a contested URL fails the build — **breaking (routing)**
1827
+
1828
+ **What changed.** `readByUrlPath('/guides/index')` (any case) returns `null`; the index entry
1829
+ answers at `/guides`. A collection named `index` resolves at `/x/index` to its own index entry. A
1830
+ production build fails when two entries compute the same `urlPath`, listing each; creating or
1831
+ renaming an entry into one is refused with 409, renaming a collection into one with 400. An entry
1832
+ beside a same-named sibling collection with no index entry is not contested. A save of an entry
1833
+ already contested still succeeds. `read()`/`readByUrlPath()` `path` collapses an index slug and
1834
+ strips the content root from root-level entries.
1835
+ [0.0.66](#readbyurlpath-answers-only-where-listentries-publishes--breaking-routing) closes the
1836
+ remaining extra URLs.
1837
+
1838
+ **To adopt.** A `shape: 'single'` (`[slug]`) static-params route no longer emits the collection's
1839
+ index entry, so that page can silently stop building: move it to the collection's own route
1840
+ (`app/posts/page.tsx`). For a contested URL, rename or remove one of each pair. A hand-rolled
1841
+ `generateStaticParams` over `listEntries` doesn't fail; call `findDuplicateUrlPaths` there, over
1842
+ `listEntries()` (`collectRoutableEntries()` drops the `entryPath` naming the offenders):
1843
+
1844
+ ```ts
1845
+ import { findDuplicateUrlPaths } from 'canopycms/server'
1846
+
1847
+ const canopy = await getCanopyForBuild()
1848
+ const duplicates = findDuplicateUrlPaths(await canopy.listEntries())
1849
+ ```
1850
+
1851
+ **Now deletable.** Route guards rejecting a `.../index` URL, duplicate-URL integrity tests, and
1852
+ editor-side checks for a contested URL.
1853
+
1854
+ #### Sitemap `pathFor`, and modelling a page served at `/` as a root `index` entry
1855
+
1856
+ **What changed.** An entry with slug `index` answers at its collection's path (`/` at the root). For a URL that
1857
+ can't be modelled, `generateContentSitemap` takes `pathFor: (entry) => string | null`, keeping the
1858
+ entry's `noindex`, `lastModified` and `priority` handling; `null` keeps the structural path, and an
1859
+ empty string throws. `extraUrls` is for URLs with no entry behind them.
1860
+
1861
+ **To adopt.** To re-model a singleton served at its collection's path (such as home at `/`):
1862
+
1863
+ 1. `git mv home.home.<id>.json home.index.<id>.json`; type and id are unchanged.
1864
+ 2. Change `read({ entryPath: 'content/home' })` to `readByUrlPath('/')` (`'/<collection>'`
1865
+ elsewhere), or pass `slug: 'index'`.
1866
+ Otherwise the build is green with a 404 at `/`: check the emitted HTML.
1867
+ 3. Drop the sitemap workaround and check `sitemap.xml`; redirect the old URL if it was indexed.
1868
+
1869
+ ```ts
1870
+ pathFor: (entry) =>
1871
+ entry.entryType === 'article' ? entry.urlPath.replace(/^\/articles\//, '/blog/') : null,
1872
+ ```
1873
+
1874
+ **Now deletable.** An `exclude` plus `extraUrls` pair re-adding a page's real URL, and hand-derived
1875
+ `noindex`/`lastModified` beside an extra URL.
1876
+
1877
+ #### A slug that cannot round-trip through a URL fails the build, and the CMS refuses to create one — **breaking (build)**
1878
+
1879
+ **What changed.** A slug must be lowercase letters, numbers and hyphens, starting with a letter or
1880
+ number, and at most 64 characters. A production build fails listing every entry whose slug isn't (such as
1881
+ `post.getting.started.guide.<id>.md`, which built and then 404'd), and a create or rename to one is
1882
+ refused with 400. Existing entries stay readable and renameable.
1883
+
1884
+ **To adopt.** Build once; rename the slug segment of each listed file
1885
+ (`post.getting-started-guide.<id>.md`), in the editor or with `git mv`, and fix hand-written links.
1886
+ Slugify generated content the same way.
1887
+
1888
+ **Now deletable.** CI checks walking filenames for unservable slugs, and editor-side slug checks.
1889
+
1890
+ ### 0.0.63
1891
+
1892
+ #### `required: false` infers an optional property (#14) — **breaking (type-level)**
1893
+
1894
+ **What changed.** `TypeFromEntrySchema` emits an explicitly `required: false` field as `name?: T`,
1895
+ at every level, instead of `name: T | undefined`. A field omitting `required` is still required.
1896
+
1897
+ **To adopt.** Change hand-written interfaces declaring `subheading: string | undefined` to
1898
+ `subheading?: string` (or derive them from the schema). Audit `Required<…>` on schema-derived types.
1899
+ Under `exactOptionalPropertyTypes: true`, omit keys instead of assigning `undefined`, and use
1900
+ `skipLibCheck: true` (the Next.js default): with `skipLibCheck: false` the package does not compile.
1901
+
1902
+ **Now deletable.** `: undefined,` lines that only satisfied a `required: false` field.
1903
+
1904
+ #### Sitemap and SEO metadata helpers (#10, #10a)
1905
+
1906
+ **What changed.** `collectRoutableEntries`, `extractSeoFields`, `isNoindexEntry`, `resolveSeoUrl`,
1907
+ `withTrailingSlash` and `isAbsoluteUrl` from `canopycms/server`; `defineSeoFieldGroup()` from
1908
+ `canopycms`; `generateContentSitemap` and `entryToMetadata` from `canopycms-next`, bound on
1909
+ `createNextCanopyContext`'s result. See
1910
+ [Sitemap and SEO Metadata](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#sitemap-and-seo-metadata). Every routable entry type is in
1911
+ the sitemap unless `exclude`d or `noindex`; one `noindex` predicate drives both robots metadata and
1912
+ sitemap exclusion. `trailingSlash` is never inferred. `lastModified` defaults to `updatedAt`, a
1913
+ filesystem mtime. An empty SEO field counts as unset. `siteUrl` must be absolute, colliding URLs are
1914
+ deduped with a warning, and a schema-invalid or unparseable content file fails a production build,
1915
+ sitemap included.
1916
+
1917
+ **To adopt.**
1918
+
1919
+ ```ts
1920
+ // app/lib/canopy.ts — bind the sitemap helper once
1921
+ export const contentSitemap = async (options: GenerateContentSitemapOptions) => {
1922
+ const context = await canopyContextPromise
1923
+ return context.generateContentSitemap(options)
1924
+ }
1925
+
1926
+ // app/sitemap.ts
1927
+ export const dynamic = 'force-static' // required for output: 'export'
1928
+ export default () =>
1929
+ contentSitemap({
1930
+ siteUrl: SITE_URL,
1931
+ trailingSlash: true, // match your framework's routing config
1932
+ exclude: (entry) => entry.entryType === 'author', // types with no page of their own
1933
+ })
1934
+
1935
+ // app/posts/[slug]/page.tsx — without the type argument `result.data` is `unknown`
1936
+ const result = await readByUrlPath<PostContent>(`/posts/${slug}`)
1937
+ return entryToMetadata(result?.data, {
1938
+ path: `/posts/${slug}`,
1939
+ siteUrl: SITE_URL,
1940
+ fallbackTitle: result?.data.title,
1941
+ })
1942
+ ```
1943
+
1944
+ Exclude entry types with no page of their own. Pass `seo` once to `createNextCanopyContext`, not
1945
+ per call. With `defineSeoFieldGroup({ group: 'seo' })`, include the same `group` in
1946
+ that `seo` option. Add `defineSeoFieldGroup()` to schemas with SEO fields; map an existing group with
1947
+ `seo: { fields: { title: 'yourName' } }` rather than keeping both. Pass a `lastModified` callback for a
1948
+ real content date, or return `undefined` from it to omit `<lastmod>`. Write `app/robots.ts` yourself.
1949
+
1950
+ **Now deletable.** A sitemap over a hardcoded list of entry types, a hand-written `Metadata` mapper,
1951
+ a local `withTrailingSlash`/`absoluteUrl` pair, and a content walk only for sitemap dates.
1952
+
1953
+ #### The build guard ignores files that were never entry-shaped
1954
+
1955
+ **What changed.** The build fails only on a file that could have been an entry: four or more
1956
+ dot-separated segments, or three whose first names an entry type in that collection. A
1957
+ `README.md` or a `5NVkkrB1MJUv.profile.json` sibling artifact builds clean; dot- and
1958
+ underscore-prefixed files are skipped.
1959
+
1960
+ **To adopt.** Move back, or rename back, a sibling artifact you moved or renamed to dodge the guard;
1961
+ `readSibling` reads it beside its entry.
1962
+
1963
+ #### `canopycms init` scaffolds `defaultBranchAccess: 'deny'` and public read
1964
+
1965
+ **What changed.** New scaffolds write `defaultBranchAccess: 'deny'` and
1966
+ `defaultPathAccess: { read: 'allow' }`
1967
+ ([Public read on server deployments](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#public-read-on-server-deployments)).
1968
+
1969
+ **To adopt.** An older scaffold's `defaultBranchAccess: 'allow'` is wider than recommended;
1970
+ consider `'deny'`. If you deleted `defaultPathAccess: { read: 'allow' }`, anonymous visitors get no content until you
1971
+ restore it: `readByUrlPath` pages 404 and `read()` throws; non-admin signed-in users without a path rule get 403 from
1972
+ the API.
1973
+
1974
+ #### Read and listing helpers replace hand-rolled parsing (#1, #2, #3, #4, #17)
1975
+
1976
+ **What changed.**
1977
+
1978
+ | API | From | Replaces |
1979
+ | ---------------------------------------------------------- | -------------------- | ---------------------------------------------------------- |
1980
+ | `parseTypedFilename(name, entryTypes?)` | `canopycms/server` | splitting `{type}.{slug}.{id}.{ext}` filenames |
1981
+ | `defaultBuildPath(path, root, kind)` | `canopycms/server` | a copy of `buildContentTree`'s default `buildPath` |
1982
+ | `meta.entryType`, `meta.entryId` on `read`/`readByUrlPath` | — | parsing type or id out of `meta.physicalPath` |
1983
+ | `updatedAt` on `listEntries` items | — | an `fs.stat` walk of the content root |
1984
+ | `createBuildCanopy(config, options)` | `canopycms/server` | a script's own services + context + admin-user boot |
1985
+ | `resolveEntryTitle(data, options)` | `canopycms(/server)` | a `title ?? name ?? humanize(slug)` chain |
1986
+ | `toPlainText(markdown)` | `canopycms/ai` | a Markdown/MDX stripper that drops custom components' text |
1987
+
1988
+ `parseTypedFilename` returns `null` for an id that isn't 12-character Base58 (no `0 O I l`).
1989
+ `meta.entryType` comes from the filename: for a legacy `{slug}.{ext}` file it is the collection's
1990
+ default type and `entryId` is `undefined`, and it may name a type the schema no longer declares.
1991
+ `updatedAt` is a filesystem mtime, reset by a fresh clone. `createBuildCanopy` bypasses all ACLs:
1992
+ scripts only, never request handling. `buildPath` still replaces the default rather than composing
1993
+ with it. Composing search documents is left to you; these are the shared pieces. To find a filename
1994
+ parser, look for `.split('.')` or `lastIndexOf('.')` on content filenames, often in link-check or
1995
+ slug-collision tests.
1996
+
1997
+ **To adopt.**
1998
+
1999
+ ```ts
2000
+ import { createBuildCanopy, resolveEntryTitle } from 'canopycms/server'
2001
+ import { toPlainText } from 'canopycms/ai'
2002
+
2003
+ const canopy = await createBuildCanopy(config.server, { entrySchemaRegistry })
2004
+ for (const entry of await canopy.listEntries()) {
2005
+ const title = resolveEntryTitle(entry.data, { schema: entry.schema })
2006
+ const body = typeof entry.data.body === 'string' ? toPlainText(entry.data.body) : ''
2007
+ }
2008
+ ```
2009
+
2010
+ Dispatch on `result.meta.entryType` with a default case.
2011
+
2012
+ **Now deletable.** Each "Replaces" column above, plus route-level guard-and-delegate blocks that
2013
+ re-checked an entry's type (a `switch` on `meta.entryType` can merge them into one catch-all).
2014
+
2015
+ #### `BlockComponentRegistry`: exhaustive block → component types (#13)
2016
+
2017
+ **What changed.** `BlockComponentRegistry<Blocks>` (types only, from `canopycms`; `BlockValueOf<Blocks, 'hero'>`
2018
+ gives one template's value type) requires exactly
2019
+ one component per block template name when written as an object literal. See
2020
+ [Block Component Registries](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#block-component-registries) and
2021
+ `apps/example1/app/components/PostView.tsx`.
2022
+
2023
+ ```ts
2024
+ import type { BlockComponentRegistry } from 'canopycms'
2025
+
2026
+ type Blocks = Page['blocks'][number]
2027
+
2028
+ const blockRegistry: BlockComponentRegistry<Blocks> = {
2029
+ hero: ({ data }) => <HeroSection headline={data.headline} />,
2030
+ cta: ({ data }) => <CtaSection title={data.title} />,
2031
+ // Missing a key here, or a key that doesn't match a template name, is a compile error.
2032
+ }
2033
+ ```
2034
+
2035
+ **Now deletable.** A `switch (block.template)` with a silent `default`, and tests matching handled
2036
+ templates against the schema.
2037
+
2038
+ #### Reusable field fragments, plus `defineFieldFragment()` (#15)
2039
+
2040
+ **What changed.** Spreading a `const` field array into several schemas, and nesting
2041
+ `defineInlineFieldGroup()` inside a block template, are documented
2042
+ ([Reusable Field Fragments](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#reusable-field-fragments)); `defineFieldFragment()` is an
2043
+ identity helper for them.
2044
+
2045
+ ```ts
2046
+ const ctaFields = defineFieldFragment([
2047
+ { name: 'ctaLabel', type: 'string' },
2048
+ { name: 'ctaHref', type: 'string' },
2049
+ ])
2050
+ const heroSchema = defineEntrySchema([{ name: 'headline', type: 'string' }, ...ctaFields])
2051
+ ```
2052
+
2053
+ **Now deletable.** A field cluster spelled out by hand in several schemas; override a differing
2054
+ field by composing from the same `const` field object.
2055
+
2056
+ #### Shared/referenced blocks are documented (#16)
2057
+
2058
+ A block template holding a `reference` field is a shared block; `read()`/`readByUrlPath()` resolve
2059
+ it, and listings do with
2060
+ [`resolveReferences`](#listings-resolve-references-and-a-resolved-reference-carries-urlpath-and-optionally-its-body-16--breaking-type-level)
2061
+ (0.0.64). See [Shared / Referenced Blocks](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#shared--referenced-blocks).
2062
+
2063
+ **Now deletable.** A second `read()` in page code to unwrap a shared block's reference.
2064
+
2065
+ #### `checkPathAccess` removed from `CanopyServices`
2066
+
2067
+ **What changed.** It was bound with an empty rule set, so it always returned the default decision.
2068
+
2069
+ **To adopt.** Replace `context.services.checkPathAccess` with
2070
+ `context.services.createContentAccessChecker(...)`, which resolves the real rules.
2071
+
2072
+ ### 0.0.62 and earlier
2073
+
2074
+ Not retro-documented, apart from these:
2075
+
2076
+ - **`rich-text` was removed** (breaking). Change a `type: 'rich-text'` field to `type: 'markdown'`.
2077
+ - **Content IDs are 12-character Base58** without `0 O I l`. An entry with a hand-rolled ID
2078
+ containing one never loads: it is skipped silently in the editor and `next dev`, and fails a
2079
+ production build. Use `generateId()` from `canopycms/server`.
2080
+
2081
+ #### The registry is keyed by entry-type name (0.0.42)
2082
+
2083
+ **What changed.** `createEntrySchemaRegistry` keys are the entry-type names that `.collection.json` files name in `entry.schema`, and `EntryTypesFromRegistry` derives the typed entry-type map from them. Schema-variable keys (`{ postSchema }`) still work but derive nothing; [README's registry convention](https://github.com/safeinsights/canopycms/blob/f8b50b30170954bf52fb670afe6796d2cb8fa275/README.md#convention-why-key-the-registry-by-entry-type-name) says when to keep them.
2084
+
2085
+ **To adopt.**
2086
+
2087
+ 1. Rename the keys in `schemas.ts`: `{ postSchema, authorSchema }` becomes `{ post: postSchema, author: authorSchema }`.
2088
+ 2. Rename every `entry.schema` string in `content/**/.collection.json` to match (`"schema": "postSchema"` becomes `"schema": "post"`), then confirm nothing is left with `grep -r 'Schema"' content/`.
2089
+ 3. Add `export type EntryTypes = EntryTypesFromRegistry<typeof entrySchemaRegistry>` and derive the per-schema aliases from it (`type PostContent = EntryTypes['post']`).
2090
+ 4. Pass `EntryTypes` as the second generic wherever you call `buildContentTree`, so `meta.indexEntry.data` narrows on `meta.indexEntry.entryType`.
2091
+ 5. Run `pnpm typecheck`. A `.collection.json` still naming an old key fails `next build` with `Schema reference "postSchema" ... not found in registry. Available schemas: ...`; in the editor, that entry type shows as unavailable.
2092
+
2093
+ Content files, frontmatter and `.canopy-meta/` caches are untouched; in dev, editing a `.collection.json` invalidates the schema cache.
2094
+
2095
+ **Now deletable.** A hand-written interface of `TypeFromEntrySchema<typeof xSchema>` members that existed only to type `buildContentTree`'s second generic.