canopycms 0.0.67 → 0.0.68-int.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -0,0 +1,10 @@
1
+ export interface NoEditPermissionNoticeProps {
2
+ /** Logical path of the entry the user cannot edit. */
3
+ entryPath: string;
4
+ }
5
+ /**
6
+ * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
7
+ * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
8
+ * locked or protected branch shows its own banner in the header and a read-only form instead.
9
+ */
10
+ export declare function NoEditPermissionNotice({ entryPath }: NoEditPermissionNoticeProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from '@mantine/core';
3
+ /**
4
+ * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
5
+ * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives; a
6
+ * locked or protected branch shows its own banner in the header and a read-only form instead.
7
+ */
8
+ export function NoEditPermissionNotice({ entryPath }) {
9
+ return (_jsx("div", { role: "status", "data-testid": "no-edit-permission-notice", style: {
10
+ display: 'flex',
11
+ alignItems: 'center',
12
+ justifyContent: 'center',
13
+ height: '100%',
14
+ padding: '0 var(--mantine-spacing-md)',
15
+ textAlign: 'center',
16
+ }, children: _jsxs(Text, { size: "sm", c: "dimmed", children: ["You don't have edit access to \"", entryPath, "\". Ask a CanopyCMS admin to grant it in Manage Permissions."] }) }));
17
+ }
@@ -1,44 +1,27 @@
1
1
  /**
2
- * Carries `media.publicBaseUrl` (see CanopyClientConfig.assetBaseUrl) down to
3
- * every asset-URL-building component - MediaLibrary, ImageField, and the MDX
4
- * image dialog. A context (rather than a prop) because the MDX dialog is
5
- * rendered deep inside MDXEditor's lazy-loaded internals via `imagePlugin`'s
6
- * `ImageDialog` param, with no prop channel of its own (the same reason
7
- * `EntryLinkContext` exists for the entry-link toolbar button). Unlike
8
- * `ApiClientContext`, reading this without a provider is safe and intentional:
9
- * no provider means root-relative URLs, which is the correct default for the
10
- * common case (editor and site share an origin).
2
+ * Carries the prefix every editor-built asset URL is put behind - MediaLibrary thumbnails,
3
+ * ImageField's preview and crop source, and the MDX editor's image previews. A context (rather
4
+ * than a prop) because the MDX dialog is rendered deep inside MDXEditor's lazy-loaded internals
5
+ * via `imagePlugin`'s `ImageDialog` param, with no prop channel of its own (the same reason
6
+ * `EntryLinkContext` exists for the entry-link toolbar button).
11
7
  */
12
8
  import React from 'react';
9
+ /**
10
+ * Where the editor loads asset bytes from: the authenticated raw route under `basePath`, never the
11
+ * public `/assets` space. An editor asks for derivatives no build has seen (a fresh crop, a
12
+ * thumbnail), which the public path need not serve, and the raw route computes them on demand.
13
+ * Also handed to the live preview, through `PreviewFrame`.
14
+ */
15
+ export declare function authenticatedAssetBase(basePath?: string): string;
13
16
  export interface AssetContextValue {
14
- /** Prefixed onto every asset URL the editor builds. `undefined` (no provider, or no `media.publicBaseUrl` configured) means root-relative - same-origin dev/prod-today behavior. */
15
- baseUrl?: string;
17
+ /** The `baseUrl` to pass to `assetUrl` for every asset URL the editor renders. */
18
+ baseUrl: string;
16
19
  }
17
- export interface AssetContextProviderProps extends AssetContextValue {
20
+ export interface AssetContextProviderProps {
18
21
  children: React.ReactNode;
19
- /**
20
- * `CanopyClientConfig.basePath` - the deployment prefix the host app is served under. Used only
21
- * when `baseUrl` is unset.
22
- */
22
+ /** `CanopyClientConfig.basePath` - the deployment prefix the host app is served under. */
23
23
  basePath?: string;
24
24
  }
25
- /**
26
- * `baseUrl` (`media.publicBaseUrl`) wins; `basePath` is the fallback.
27
- *
28
- * They are ALTERNATIVES, never composed: whichever is used names where `/assets` is mounted, in
29
- * full, and a deployment `basePath` cannot apply on top of it. Where the two differ is topology,
30
- * not precedence - see the asset-mount table in the README.
31
- *
32
- * The fallback is kept only so that existing basePath deployments do not break on upgrade,
33
- * which makes removing it a deliberate decision rather than a cleanup - it is option 1 of
34
- * `.claude/future-tasks/editor-asset-mount-topology.md`, still open.
35
- *
36
- * KNOWN LIMITATION: on a CloudFront/CDK deployment, a basePath does NOT move the asset
37
- * space (behaviors are anchored at the distribution root), so this fallback prefixes
38
- * wrongly — requests still resolve, via `withCanopy`'s auto-prefixed `/assets/:path*`
39
- * rewrite, but bypass the CDN cache and the dedicated transform Lambda. Workaround: set
40
- * `media.publicBaseUrl` to the distribution origin instead, which takes precedence here.
41
- */
42
- export declare function AssetContextProvider({ baseUrl, basePath, children }: AssetContextProviderProps): import("react/jsx-runtime").JSX.Element;
43
- /** Safe to call outside a provider - returns `{ baseUrl: undefined }` (root-relative URLs). */
25
+ export declare function AssetContextProvider({ basePath, children }: AssetContextProviderProps): import("react/jsx-runtime").JSX.Element;
26
+ /** Safe to call outside a provider - see the context's default. */
44
27
  export declare function useAssetContext(): AssetContextValue;
@@ -1,41 +1,32 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  /**
4
- * Carries `media.publicBaseUrl` (see CanopyClientConfig.assetBaseUrl) down to
5
- * every asset-URL-building component - MediaLibrary, ImageField, and the MDX
6
- * image dialog. A context (rather than a prop) because the MDX dialog is
7
- * rendered deep inside MDXEditor's lazy-loaded internals via `imagePlugin`'s
8
- * `ImageDialog` param, with no prop channel of its own (the same reason
9
- * `EntryLinkContext` exists for the entry-link toolbar button). Unlike
10
- * `ApiClientContext`, reading this without a provider is safe and intentional:
11
- * no provider means root-relative URLs, which is the correct default for the
12
- * common case (editor and site share an origin).
4
+ * Carries the prefix every editor-built asset URL is put behind - MediaLibrary thumbnails,
5
+ * ImageField's preview and crop source, and the MDX editor's image previews. A context (rather
6
+ * than a prop) because the MDX dialog is rendered deep inside MDXEditor's lazy-loaded internals
7
+ * via `imagePlugin`'s `ImageDialog` param, with no prop channel of its own (the same reason
8
+ * `EntryLinkContext` exists for the entry-link toolbar button).
13
9
  */
14
10
  import { createContext, useContext, useMemo } from 'react';
15
- const AssetContext = createContext({});
11
+ import { joinUrlPrefix } from '../../utils/url-prefix.js';
12
+ /** The raw asset route (`api/assets.ts`'s `assetRawRoute`) under the default API base. */
13
+ const RAW_ASSET_ROUTE = '/api/canopycms/assets/raw';
16
14
  /**
17
- * `baseUrl` (`media.publicBaseUrl`) wins; `basePath` is the fallback.
18
- *
19
- * They are ALTERNATIVES, never composed: whichever is used names where `/assets` is mounted, in
20
- * full, and a deployment `basePath` cannot apply on top of it. Where the two differ is topology,
21
- * not precedence - see the asset-mount table in the README.
22
- *
23
- * The fallback is kept only so that existing basePath deployments do not break on upgrade,
24
- * which makes removing it a deliberate decision rather than a cleanup - it is option 1 of
25
- * `.claude/future-tasks/editor-asset-mount-topology.md`, still open.
26
- *
27
- * KNOWN LIMITATION: on a CloudFront/CDK deployment, a basePath does NOT move the asset
28
- * space (behaviors are anchored at the distribution root), so this fallback prefixes
29
- * wrongly — requests still resolve, via `withCanopy`'s auto-prefixed `/assets/:path*`
30
- * rewrite, but bypass the CDN cache and the dedicated transform Lambda. Workaround: set
31
- * `media.publicBaseUrl` to the distribution origin instead, which takes precedence here.
15
+ * Where the editor loads asset bytes from: the authenticated raw route under `basePath`, never the
16
+ * public `/assets` space. An editor asks for derivatives no build has seen (a fresh crop, a
17
+ * thumbnail), which the public path need not serve, and the raw route computes them on demand.
18
+ * Also handed to the live preview, through `PreviewFrame`.
32
19
  */
33
- export function AssetContextProvider({ baseUrl, basePath, children }) {
34
- const resolved = baseUrl ?? basePath;
35
- const value = useMemo(() => ({ baseUrl: resolved }), [resolved]);
20
+ export function authenticatedAssetBase(basePath) {
21
+ return joinUrlPrefix(basePath, RAW_ASSET_ROUTE);
22
+ }
23
+ /** No provider means no `basePath`: the API is mounted at the origin root. */
24
+ const AssetContext = createContext({ baseUrl: authenticatedAssetBase() });
25
+ export function AssetContextProvider({ basePath, children }) {
26
+ const value = useMemo(() => ({ baseUrl: authenticatedAssetBase(basePath) }), [basePath]);
36
27
  return _jsx(AssetContext.Provider, { value: value, children: children });
37
28
  }
38
- /** Safe to call outside a provider - returns `{ baseUrl: undefined }` (root-relative URLs). */
29
+ /** Safe to call outside a provider - see the context's default. */
39
30
  export function useAssetContext() {
40
31
  return useContext(AssetContext);
41
32
  }
@@ -1,4 +1,4 @@
1
1
  export { ApiClientProvider, useApiClient, useOptionalApiClient, type ApiClient, type ApiClientProviderProps, } from './ApiClientContext.js';
2
- export { AssetContextProvider, useAssetContext, type AssetContextValue, type AssetContextProviderProps, } from './AssetContext.js';
2
+ export { AssetContextProvider, authenticatedAssetBase, useAssetContext, type AssetContextValue, type AssetContextProviderProps, } from './AssetContext.js';
3
3
  export { SWRProvider, type SWRProviderProps } from './SWRProvider.js';
4
4
  export { useEditorLoading, useEditorModals, useEditorPreview } from './EditorStateContext.js';
@@ -1,4 +1,4 @@
1
1
  export { ApiClientProvider, useApiClient, useOptionalApiClient, } from './ApiClientContext.js';
2
- export { AssetContextProvider, useAssetContext, } from './AssetContext.js';
2
+ export { AssetContextProvider, authenticatedAssetBase, useAssetContext, } from './AssetContext.js';
3
3
  export { SWRProvider } from './SWRProvider.js';
4
4
  export { useEditorLoading, useEditorModals, useEditorPreview } from './EditorStateContext.js';
@@ -1,4 +1,4 @@
1
- import type { CanopyConfig, FlatSchemaItem } from '../config/index.js';
1
+ import type { FlatSchemaItem } from '../config/index.js';
2
2
  import type { EditorCollection } from './Editor.js';
3
3
  /**
4
4
  * Build hierarchical editor collections from the flattened schema.
@@ -10,4 +10,3 @@ import type { EditorCollection } from './Editor.js';
10
10
  * shown as separate nodes in the navigation tree.
11
11
  */
12
12
  export declare const buildEditorCollections: (flatSchema: FlatSchemaItem[]) => EditorCollection[];
13
- export declare const buildPreviewBaseByCollection: (config: Pick<CanopyConfig, "contentRoot">, flatSchema: FlatSchemaItem[]) => Record<string, string>;
@@ -1,14 +1,3 @@
1
- import { trimSlashes } from '../paths/normalize.js';
2
- const normalizeContentRoot = (value) => {
3
- return trimSlashes(value ?? 'content');
4
- };
5
- const stripContentRoot = (logicalPath, contentRoot) => {
6
- const prefix = contentRoot ? `${contentRoot}/` : '';
7
- if (prefix && logicalPath.startsWith(prefix)) {
8
- return logicalPath.slice(prefix.length);
9
- }
10
- return logicalPath;
11
- };
12
1
  /**
13
2
  * Build hierarchical editor collections from the flattened schema.
14
3
  * Uses logicalPath as IDs to match API responses.
@@ -64,15 +53,3 @@ export const buildEditorCollections = (flatSchema) => {
64
53
  // This now includes the content root collection itself
65
54
  return buildTree(undefined);
66
55
  };
67
- export const buildPreviewBaseByCollection = (config, flatSchema) => {
68
- const contentRoot = normalizeContentRoot(config.contentRoot);
69
- const flat = flatSchema;
70
- const map = {};
71
- for (const item of flat) {
72
- // Strip content root from all items and normalize to preview URL
73
- const base = stripContentRoot(item.logicalPath, contentRoot);
74
- const normalizedBase = base ? `/${base}` : '/';
75
- map[item.logicalPath] = normalizedBase;
76
- }
77
- return map;
78
- };
@@ -3,36 +3,38 @@ import type { ContentFormat, FlatSchemaItem } from '../config/index.js';
3
3
  import type { FormValue } from './FormRenderer.js';
4
4
  import type { EditorEntry, EditorCollection } from './Editor.js';
5
5
  import type { TreeNodeData } from '@mantine/core';
6
- import { normalizeCollectionPath } from '../paths/normalize.js';
7
- /** @internal Exported for tests. */
8
- export { normalizeCollectionPath };
9
6
  export interface PreviewContext {
10
7
  branchName?: string;
11
- previewBaseByCollection?: Record<string, string>;
8
+ /** `editor.previewBase`: routes by root entry path, collection path or name; `false` is no page. */
9
+ previewBaseByCollection?: Record<string, string | false>;
10
+ /** `editor.previewPrefix`: where the host mounts the pages the preview pane loads. */
11
+ previewPrefix?: string;
12
12
  }
13
- /**
14
- * Builds the preview iframe `src` for an entry, prefixed with the deployment `basePath`
15
- * (`CanopyClientConfig.basePath`, e.g. `/preview-123`) when configured.
16
- *
17
- * This matters twice: the raw `<iframe src>` (`PreviewFrame` in preview-bridge.tsx) 404s
18
- * without the prefix under a basePath, and `resolvePreviewPath` there compares the same
19
- * string against `window.location.pathname` -- which browsers report WITH the basePath --
20
- * so an unprefixed `previewSrc` also breaks draft sync / click-to-focus even when the
21
- * iframe itself resolves.
22
- *
23
- * Applied via `joinUrlPrefix`: a no-op when `basePath` is unset, and passes an
24
- * already-absolute `previewSrc` (a cross-origin override) through untouched.
25
- */
26
- export declare const buildPreviewSrc: (entry: {
13
+ type PreviewEntry = {
27
14
  collectionPath?: string;
28
15
  collectionName?: string;
29
16
  slug?: string;
30
17
  itemType?: string;
31
18
  previewSrc?: string;
32
- }, context: PreviewContext & {
19
+ };
20
+ /**
21
+ * Builds the preview iframe `src` for an entry: its route under `previewPrefix`, under the
22
+ * deployment `basePath` (`CanopyClientConfig.basePath`, e.g. `/preview-123`), in the host's
23
+ * trailing-slash form, with `?branch=`. An absolute prefix skips the `basePath`. An absolute route
24
+ * (from `previewBaseByCollection`) skips all three and gets only `?branch=`. An entry's own
25
+ * `previewSrc` gets only the `basePath`.
26
+ *
27
+ * The result must equal the framed page's own URL: the `<iframe src>` (`PreviewFrame.tsx`)
28
+ * 404s without the prefixes, and a URL the host redirects costs a round trip
29
+ * on every load. `trailingSlash` defaults to the value `withCanopy` inlines at build time.
30
+ *
31
+ * `undefined` means the entry has no page, so the editor frames nothing rather than another page.
32
+ */
33
+ export declare const buildPreviewSrc: (entry: PreviewEntry, context: PreviewContext & {
33
34
  contentRoot?: string;
34
35
  basePath?: string;
35
- }) => string;
36
+ trailingSlash?: boolean;
37
+ }) => string | undefined;
36
38
  export declare const normalizeContentPayload: (raw: unknown) => FormValue;
37
39
  export declare const buildWritePayload: (entry: {
38
40
  collectionPath?: string;
@@ -41,7 +43,7 @@ export declare const buildWritePayload: (entry: {
41
43
  }, value: FormValue) => FormValue;
42
44
  interface BuildEntriesFromListParams {
43
45
  response: ListEntriesResponse;
44
- resolvePreviewSrc: (entry: Pick<CollectionItem, 'collectionPath' | 'collectionName' | 'slug' | 'entryType'>) => string;
46
+ resolvePreviewSrc: (entry: Pick<CollectionItem, 'collectionPath' | 'collectionName' | 'slug' | 'entryType'>) => string | undefined;
45
47
  flatSchema: FlatSchemaItem[];
46
48
  }
47
49
  export declare const buildEntriesFromListResponse: ({ response, resolvePreviewSrc, flatSchema, }: BuildEntriesFromListParams) => EditorEntry[];
@@ -63,3 +65,4 @@ export declare const buildBreadcrumbSegments: (currentEntry: EditorEntry | undef
63
65
  * @returns Record<string, boolean> - Expanded state object where keys are collection node values
64
66
  */
65
67
  export declare const calculatePathToEntry: (entryPath: string | undefined, treeData: TreeNodeData[]) => Record<string, boolean>;
68
+ export {};
@@ -1,84 +1,97 @@
1
1
  // Import directly from normalize to avoid pulling in server-only branch.ts
2
- import { normalizeCollectionPath } from '../paths/normalize.js';
3
- import { isIndexSlug } from '../utils/entry-url.js';
2
+ import { normalizeFilesystemPath } from '../paths/normalize.js';
3
+ import { computeEntryUrl, isIndexSlug } from '../utils/entry-url.js';
4
4
  import { isDataOnlyFormat } from '../utils/format.js';
5
- import { joinUrlPrefix } from '../utils/url-prefix.js';
6
- /** @internal Exported for tests. */
7
- export { normalizeCollectionPath };
5
+ import { isAbsoluteUrl, joinUrlPrefix, matchTrailingSlash, readTrailingSlashEnv, stripTrailingSlashes, } from '../utils/url-prefix.js';
8
6
  /**
9
- * The slug portion of a preview URL, or '' for an index entry -- `resolveUrlPathCandidates`
7
+ * The slug a `previewBase` route is extended by, or '' for an index entry: an index entry's URL
8
+ * is its collection's, the collapse `computeEntryUrl` applies, and `resolveUrlPathCandidates`
10
9
  * refuses `/x/index`, so a preview built that way 404s.
11
- *
12
- * An index entry's URL is its COLLECTION's path, the same collapse `computeEntryUrl`,
13
- * `listEntries`, and `defaultBuildPath` apply.
14
- *
15
- * Kept separate from `computeEntryUrl`, which shares only the index decision: this builder
16
- * must percent-encode each segment and must NOT lowercase, since a preview base is
17
- * adopter-supplied and case-sensitive.
18
10
  */
19
- const encodePreviewSlug = (slug) => (isIndexSlug(slug) ? '' : encodeSlug(slug));
20
- const encodeSlug = (value) => (value ?? '')
11
+ const encodePreviewSlug = (slug) => (isIndexSlug(slug) ? '' : encodeSegments(slug));
12
+ const encodeSegments = (value) => (value ?? '')
21
13
  .split('/')
22
14
  .filter(Boolean)
23
15
  .map((segment) => encodeURIComponent(segment))
24
16
  .join('/');
17
+ /** Splits `url` before its query or fragment, so a path can be extended without entering either. */
18
+ const splitPathSuffix = (url) => {
19
+ const index = url.search(/[?#]/);
20
+ return index === -1 ? [url, ''] : [url.slice(0, index), url.slice(index)];
21
+ };
22
+ /** Adds `?branch=` to the query, ahead of any fragment. */
23
+ const appendBranch = (url, branchName) => {
24
+ if (!branchName)
25
+ return url;
26
+ const hashIndex = url.indexOf('#');
27
+ const beforeHash = hashIndex === -1 ? url : url.slice(0, hashIndex);
28
+ const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
29
+ const separator = beforeHash.includes('?') ? '&' : '?';
30
+ return `${beforeHash}${separator}branch=${encodeURIComponent(branchName)}${hash}`;
31
+ };
25
32
  /**
26
- * Builds the (unprefixed) preview URL -- see `buildPreviewSrc` below, which wraps this with the
27
- * deployment `basePath`. Split out so that prefixing happens exactly once, at the end, uniformly
28
- * across every branch (including the `entry.previewSrc` escape hatch).
33
+ * The `previewBase` value under `key`, or `undefined` for none. An empty value counts as none, and
34
+ * so does anything `Object.prototype` supplies.
29
35
  */
30
- const buildRawPreviewSrc = (entry, { branchName, previewBaseByCollection, contentRoot }) => {
31
- if (entry.previewSrc)
32
- return entry.previewSrc;
33
- const appendBranch = (url) => {
34
- if (!branchName)
35
- return url;
36
- const separator = url.includes('?') ? '&' : '?';
37
- return `${url}${separator}branch=${encodeURIComponent(branchName)}`;
38
- };
39
- // Root-level entries have collectionPath === contentRoot (e.g., 'content')
40
- const isRootEntry = contentRoot && entry.collectionPath === contentRoot;
41
- if (isRootEntry) {
42
- const customPreview = previewBaseByCollection?.[`${contentRoot}/${entry.slug}`];
43
- if (customPreview) {
44
- return appendBranch(customPreview);
45
- }
46
- return appendBranch('/');
47
- }
48
- const base = (entry.collectionPath && previewBaseByCollection?.[entry.collectionPath]) ??
49
- (entry.collectionName && previewBaseByCollection?.[entry.collectionName]);
50
- if (!base) {
51
- // Pass contentRoot through so a non-default (or multi-segment, e.g.
52
- // "cms/content") configured root is stripped too; normalizeCollectionPath
53
- // defaults to 'content' when contentRoot is undefined.
54
- const collectionPath = entry.collectionPath
55
- ? normalizeCollectionPath(entry.collectionPath, contentRoot)
56
- : '';
57
- const encoded = encodePreviewSlug(entry.slug);
58
- const segments = [collectionPath, encoded].filter(Boolean);
59
- const url = segments.length > 0 ? `/${segments.join('/')}` : '/';
60
- return appendBranch(url);
36
+ const previewBaseFor = (bases, key) => bases && key && Object.prototype.hasOwnProperty.call(bases, key) && bases[key] !== ''
37
+ ? bases[key]
38
+ : undefined;
39
+ /**
40
+ * The entry's route on the host site, or `undefined` when it has no page. The first
41
+ * `previewBaseByCollection` key present decides: for a root entry its own path
42
+ * (`<contentRoot>/<slug>`, used as-is), then its collection path, then its collection name (both
43
+ * with the slug appended). The spelling `<parent>/<name>` names both an entry and a same-named
44
+ * sibling collection (a landing entry beside its folder), so below the root it is read only as a
45
+ * collection key, and at the root one key routes both.
46
+ * A `false` value means no page. With no key, the route is the entry's `urlPath`, by the rule
47
+ * `listEntries` publishes it, so a root entry previews at `/<slug>` and only a root index at `/`.
48
+ * Site-relative unless a matching value is absolute.
49
+ */
50
+ const buildPreviewRoute = (entry, { previewBaseByCollection, contentRoot = 'content' }) => {
51
+ const collectionPath = normalizeFilesystemPath(entry.collectionPath ?? '');
52
+ const isRootEntry = collectionPath === normalizeFilesystemPath(contentRoot);
53
+ const entryRoute = previewBaseFor(previewBaseByCollection, isRootEntry && entry.slug ? `${collectionPath}/${entry.slug}` : undefined);
54
+ if (entryRoute !== undefined)
55
+ return entryRoute === false ? undefined : entryRoute;
56
+ const pathBase = previewBaseFor(previewBaseByCollection, collectionPath);
57
+ const base = pathBase ?? previewBaseFor(previewBaseByCollection, entry.collectionName);
58
+ if (base === false)
59
+ return undefined;
60
+ if (base === undefined) {
61
+ const urlPath = computeEntryUrl(collectionPath, entry.slug ?? '', contentRoot);
62
+ return `/${encodeSegments(urlPath)}`;
61
63
  }
62
- const trimmed = base.endsWith('/') ? base.slice(0, -1) : base;
63
64
  const encoded = encodePreviewSlug(entry.slug);
64
- const url = encoded ? `${trimmed}/${encoded}` : trimmed || '/';
65
- return appendBranch(url);
65
+ const [basePathPart, suffix] = splitPathSuffix(base);
66
+ const trimmed = stripTrailingSlashes(basePathPart);
67
+ return `${encoded ? `${trimmed}/${encoded}` : basePathPart || '/'}${suffix}`;
66
68
  };
67
69
  /**
68
- * Builds the preview iframe `src` for an entry, prefixed with the deployment `basePath`
69
- * (`CanopyClientConfig.basePath`, e.g. `/preview-123`) when configured.
70
+ * Builds the preview iframe `src` for an entry: its route under `previewPrefix`, under the
71
+ * deployment `basePath` (`CanopyClientConfig.basePath`, e.g. `/preview-123`), in the host's
72
+ * trailing-slash form, with `?branch=`. An absolute prefix skips the `basePath`. An absolute route
73
+ * (from `previewBaseByCollection`) skips all three and gets only `?branch=`. An entry's own
74
+ * `previewSrc` gets only the `basePath`.
70
75
  *
71
- * This matters twice: the raw `<iframe src>` (`PreviewFrame` in preview-bridge.tsx) 404s
72
- * without the prefix under a basePath, and `resolvePreviewPath` there compares the same
73
- * string against `window.location.pathname` -- which browsers report WITH the basePath --
74
- * so an unprefixed `previewSrc` also breaks draft sync / click-to-focus even when the
75
- * iframe itself resolves.
76
+ * The result must equal the framed page's own URL: the `<iframe src>` (`PreviewFrame.tsx`)
77
+ * 404s without the prefixes, and a URL the host redirects costs a round trip
78
+ * on every load. `trailingSlash` defaults to the value `withCanopy` inlines at build time.
76
79
  *
77
- * Applied via `joinUrlPrefix`: a no-op when `basePath` is unset, and passes an
78
- * already-absolute `previewSrc` (a cross-origin override) through untouched.
80
+ * `undefined` means the entry has no page, so the editor frames nothing rather than another page.
79
81
  */
80
82
  export const buildPreviewSrc = (entry, context) => {
81
- return joinUrlPrefix(context.basePath, buildRawPreviewSrc(entry, context));
83
+ if (entry.previewSrc)
84
+ return joinUrlPrefix(context.basePath, entry.previewSrc);
85
+ const route = buildPreviewRoute(entry, context);
86
+ if (route === undefined)
87
+ return undefined;
88
+ // An absolute route is another site's URL, so the host's trailing-slash form says nothing
89
+ // about it.
90
+ if (isAbsoluteUrl(route))
91
+ return appendBranch(route, context.branchName);
92
+ const mounted = joinUrlPrefix(context.basePath, joinUrlPrefix(context.previewPrefix, route));
93
+ const shaped = matchTrailingSlash(mounted, context.trailingSlash ?? readTrailingSlashEnv());
94
+ return appendBranch(shaped, context.branchName);
82
95
  };
83
96
  export const normalizeContentPayload = (raw) => {
84
97
  const candidate = raw;
@@ -7,6 +7,7 @@ export interface BlockInstance {
7
7
  type RenderField = (field: FieldConfig, value: unknown, onChange: (v: unknown) => void, path: Array<string | number>) => React.ReactNode;
8
8
  export interface BlockFieldProps {
9
9
  label?: string;
10
+ description?: string;
10
11
  templates: BlockConfig[];
11
12
  value: BlockInstance[];
12
13
  onChange: (blocks: BlockInstance[]) => void;
@@ -1,12 +1,13 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import React, { useMemo, useState } from 'react';
3
+ import React, { useId, useMemo, useState } from 'react';
4
4
  let blockKeyCounter = 0;
5
5
  import { ActionIcon, Button, Group, Paper, Select, Stack, Text } from '@mantine/core';
6
6
  import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core';
7
7
  import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable';
8
8
  import { CSS } from '@dnd-kit/utilities';
9
9
  import { formatCanopyPath } from '../canopy-path.js';
10
+ import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
10
11
  const findTemplate = (templates, name) => templates.find((t) => t.name === name);
11
12
  const SortableBlock = ({ id, children }) => {
12
13
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -19,7 +20,8 @@ const SortableBlock = ({ id, children }) => {
19
20
  };
20
21
  return (_jsx(Paper, { ref: setNodeRef, withBorder: true, radius: "md", p: "sm", shadow: "xs", style: style, children: _jsxs(Group, { align: "flex-start", gap: "sm", children: [_jsx(ActionIcon, { variant: "subtle", "aria-label": "Drag to reorder", ...attributes, ...listeners, style: { cursor: 'grab' }, children: "\u21C5" }, "drag-handle"), _jsx("div", { style: { flex: 1, minWidth: 0, width: '100%' }, children: children }, "content")] }) }));
21
22
  };
22
- export const BlockField = ({ label, templates, value, onChange, renderField, path, dataCanopyField, }) => {
23
+ export const BlockField = ({ label, description, templates, value, onChange, renderField, path, dataCanopyField, }) => {
24
+ const descriptionBaseId = useId();
23
25
  const [itemKeys, setItemKeys] = useState(() => value.map(() => `block-${blockKeyCounter++}`));
24
26
  const [pendingTemplate, setPendingTemplate] = useState(null);
25
27
  // Keeps itemKeys in sync with value's length; setState runs directly in render, not an effect.
@@ -69,12 +71,12 @@ export const BlockField = ({ label, templates, value, onChange, renderField, pat
69
71
  moveBlock(oldIndex, newIndex);
70
72
  };
71
73
  const selectableTemplates = useMemo(() => templates.map((t) => ({ value: t.name, label: t.label ?? t.name })), [templates]);
72
- return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label ?? 'Blocks' }), _jsx(Select, { "aria-label": "Add block", placeholder: "Add block...", data: selectableTemplates, value: pendingTemplate, onChange: (next) => {
74
+ return (_jsx(Paper, { withBorder: true, radius: "md", p: "md", bg: "gray.0", "data-canopy-field": dataCanopyField ?? formatCanopyPath(path), shadow: "xs", ...groupDescriptionProps(descriptionBaseId, description), children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, c: "dimmed", children: label ?? 'Blocks' }), _jsx(Select, { "aria-label": "Add block", placeholder: "Add block...", data: selectableTemplates, value: pendingTemplate, onChange: (next) => {
73
75
  if (next) {
74
76
  addBlock(next);
75
77
  }
76
78
  setPendingTemplate(null);
77
- }, allowDeselect: true, size: "xs", w: 180 })] }), _jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: itemKeys, strategy: verticalListSortingStrategy, children: _jsx(Stack, { gap: "sm", children: value.map((block, idx) => {
79
+ }, allowDeselect: true, size: "xs", w: 180 })] }), _jsx(FieldDescription, { baseId: descriptionBaseId, description: description }), _jsx(DndContext, { sensors: sensors, onDragEnd: handleDragEnd, children: _jsx(SortableContext, { items: itemKeys, strategy: verticalListSortingStrategy, children: _jsx(Stack, { gap: "sm", children: value.map((block, idx) => {
78
80
  const template = findTemplate(templates, block.template);
79
81
  const currentPath = [...path, idx];
80
82
  return (_jsx(SortableBlock, { id: itemKeys[idx], children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "flex-start", children: [_jsx(Text, { size: "sm", fw: 600, children: template?.label ?? block.template ?? 'Unknown block' }), _jsxs(Group, { gap: 4, children: [_jsx(ActionIcon, { variant: "light", "aria-label": "Move block up", disabled: idx === 0, onClick: () => moveBlock(idx, idx - 1), children: "\u2191" }), _jsx(ActionIcon, { variant: "light", "aria-label": "Move block down", disabled: idx === value.length - 1, onClick: () => moveBlock(idx, idx + 1), children: "\u2193" }), _jsx(Button, { variant: "subtle", color: "red", size: "xs", onClick: () => removeBlock(idx), children: "Remove" })] })] }), template ? (_jsx(Stack, { gap: "sm", children: template.fields.map((f) => (_jsx(React.Fragment, { children: renderField(f, block.value?.[f.name], (next) => updateBlockValue(idx, {
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export interface CodeFieldProps {
3
3
  id?: string;
4
4
  label?: string;
5
+ description?: string;
5
6
  value: string;
6
7
  onChange: (value: string) => void;
7
8
  language?: string;
@@ -2,8 +2,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
3
  import { Textarea } from '@mantine/core';
4
4
  // Placeholder for Monaco integration; host app can provide custom renderer for production.
5
- export const CodeField = ({ id, label, value, onChange, language, dataCanopyField, }) => {
5
+ export const CodeField = ({ id, label, description, value, onChange, language, dataCanopyField, }) => {
6
6
  const generatedId = useId();
7
7
  const inputId = id ?? generatedId;
8
- return (_jsx(Textarea, { id: inputId, label: label, value: value, onChange: (e) => onChange(e.currentTarget.value), placeholder: language ? `Code (${language})` : 'Code', autosize: true, minRows: 6, size: "sm", "data-canopy-field": dataCanopyField, styles: { input: { fontFamily: 'Menlo, Consolas, monospace' } } }));
8
+ return (_jsx(Textarea, { id: inputId, label: label, description: description, value: value, onChange: (e) => onChange(e.currentTarget.value), placeholder: language ? `Code (${language})` : 'Code', autosize: true, minRows: 6, size: "sm", "data-canopy-field": dataCanopyField, styles: { input: { fontFamily: 'Menlo, Consolas, monospace' } } }));
9
9
  };
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  export interface DateTimeFieldProps {
3
3
  id?: string;
4
4
  label?: string;
5
+ description?: string;
5
6
  value: string;
6
7
  onChange: (value: string) => void;
7
8
  dataCanopyField?: string;
@@ -49,8 +49,8 @@ export function datetimeLocalValueToIso(local) {
49
49
  * round-trip; sub-second precision is not preserved (no milliseconds field),
50
50
  * so a value with non-zero milliseconds is zeroed out once resaved.
51
51
  */
52
- export const DateTimeField = ({ id, label, value, onChange, dataCanopyField, }) => {
52
+ export const DateTimeField = ({ id, label, description, value, onChange, dataCanopyField, }) => {
53
53
  const generatedId = useId();
54
54
  const inputId = id ?? generatedId;
55
- return (_jsx(Input.Wrapper, { id: inputId, label: label, size: "sm", children: _jsx(Input, { type: "datetime-local", step: 1, size: "sm", value: isoToDatetimeLocalValue(value), onChange: (e) => onChange(datetimeLocalValueToIso(e.currentTarget.value)), "data-canopy-field": dataCanopyField }) }));
55
+ return (_jsx(Input.Wrapper, { id: inputId, label: label, description: description, size: "sm", children: _jsx(Input, { type: "datetime-local", step: 1, size: "sm", value: isoToDatetimeLocalValue(value), onChange: (e) => onChange(datetimeLocalValueToIso(e.currentTarget.value)), "data-canopy-field": dataCanopyField }) }));
56
56
  };
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ /** The id Mantine gives a native input's description, so custom fields match it. */
3
+ export declare const fieldDescriptionId: (baseId: string) => string;
4
+ /**
5
+ * Guidance text for fields that don't use a Mantine input's native `description` prop.
6
+ * Renders nothing for an absent or empty description.
7
+ */
8
+ export declare const FieldDescription: React.FC<{
9
+ baseId: string;
10
+ description?: string;
11
+ }>;
12
+ /** Props that tie a custom field's container to its `FieldDescription`; empty without one. */
13
+ export declare const groupDescriptionProps: (baseId: string, description: string | undefined) => {
14
+ role?: "group";
15
+ "aria-describedby"?: string;
16
+ };
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Input } from '@mantine/core';
3
+ /** The id Mantine gives a native input's description, so custom fields match it. */
4
+ export const fieldDescriptionId = (baseId) => `${baseId}-description`;
5
+ /**
6
+ * Guidance text for fields that don't use a Mantine input's native `description` prop.
7
+ * Renders nothing for an absent or empty description.
8
+ */
9
+ export const FieldDescription = ({ baseId, description, }) => description ? (_jsx(Input.Description, { id: fieldDescriptionId(baseId), children: description })) : null;
10
+ /** Props that tie a custom field's container to its `FieldDescription`; empty without one. */
11
+ export const groupDescriptionProps = (baseId, description) => description ? { role: 'group', 'aria-describedby': fieldDescriptionId(baseId) } : {};
@@ -8,6 +8,7 @@ interface ImageFieldErrors {
8
8
  export interface ImageFieldProps {
9
9
  id?: string;
10
10
  label?: string;
11
+ description?: string;
11
12
  value: ImageFieldValue | undefined;
12
13
  onChange: (value: ImageFieldValue | undefined) => void;
13
14
  /** "W:H" aspect ratio - when set, picking/uploading a new image opens the crop step before the value commits, and a "Crop" button appears on the filled state. */
@@ -11,8 +11,9 @@ import { parseAspectRatio } from '../media/crop-math.js';
11
11
  import { MediaLibrary } from '../media/MediaLibrary.js';
12
12
  import { useAssetUpload } from '../media/useAssetUpload.js';
13
13
  import { ACCEPTED_IMAGE_MIME_TYPES, MAX_UPLOAD_BYTES } from '../media/upload-constants.js';
14
+ import { FieldDescription, groupDescriptionProps } from './FieldDescription.js';
14
15
  const PREVIEW_WIDTH = 320;
15
- export const ImageField = ({ id, label, value, onChange, aspect, altOptional, dataCanopyField, errors, }) => {
16
+ export const ImageField = ({ id, label, description, value, onChange, aspect, altOptional, dataCanopyField, errors, }) => {
16
17
  const generatedId = useId();
17
18
  const inputId = id ?? generatedId;
18
19
  const { baseUrl } = useAssetContext();
@@ -101,7 +102,7 @@ export const ImageField = ({ id, label, value, onChange, aspect, altOptional, da
101
102
  ? assetUrl({ src: value.src }, { baseUrl })
102
103
  : '';
103
104
  const cropInitial = cropRequest?.kind === 'existing' ? value?.crop : undefined;
104
- return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `image-field-${dataCanopyField}`, children: [label && (_jsx(Text, { size: "sm", fw: 500, children: label })), !hasValue ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Dropzone, { onDrop: (files) => void handleDrop(files), onReject: handleReject, maxSize: MAX_UPLOAD_BYTES, accept: ACCEPTED_IMAGE_MIME_TYPES, loading: upload.uploading, multiple: false, "data-testid": `image-field-dropzone-${dataCanopyField}`, children: _jsxs(Group, { justify: "center", gap: "xs", py: "xs", style: { pointerEvents: 'none' }, children: [_jsx(IconUpload, { size: 18 }), _jsx(Text, { size: "xs", children: "Drop an image here, or click to browse" })] }) }), _jsx(Group, { justify: "center", children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPhoto, { size: 14 }), onClick: () => setPickerOpen(true), "data-testid": `image-field-browse-library-${dataCanopyField}`, children: "Browse library" }) }), (upload.error || dropError) && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", children: upload.error || dropError })), errors?.src && (_jsx(Text, { size: "xs", c: "red", children: errors.src }))] })) : (_jsxs(Stack, { gap: "xs", children: [previewFailed ? (_jsxs("div", { "data-testid": `image-field-preview-fallback-${dataCanopyField}`, style: {
105
+ return (_jsxs(Stack, { gap: 4, "data-canopy-field": dataCanopyField, "data-testid": `image-field-${dataCanopyField}`, ...groupDescriptionProps(inputId, description), children: [label && (_jsx(Text, { size: "sm", fw: 500, children: label })), _jsx(FieldDescription, { baseId: inputId, description: description }), !hasValue ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Dropzone, { onDrop: (files) => void handleDrop(files), onReject: handleReject, maxSize: MAX_UPLOAD_BYTES, accept: ACCEPTED_IMAGE_MIME_TYPES, loading: upload.uploading, multiple: false, "data-testid": `image-field-dropzone-${dataCanopyField}`, children: _jsxs(Group, { justify: "center", gap: "xs", py: "xs", style: { pointerEvents: 'none' }, children: [_jsx(IconUpload, { size: 18 }), _jsx(Text, { size: "xs", children: "Drop an image here, or click to browse" })] }) }), _jsx(Group, { justify: "center", children: _jsx(Button, { variant: "light", size: "xs", leftSection: _jsx(IconPhoto, { size: 14 }), onClick: () => setPickerOpen(true), "data-testid": `image-field-browse-library-${dataCanopyField}`, children: "Browse library" }) }), (upload.error || dropError) && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", children: upload.error || dropError })), errors?.src && (_jsx(Text, { size: "xs", c: "red", children: errors.src }))] })) : (_jsxs(Stack, { gap: "xs", children: [previewFailed ? (_jsxs("div", { "data-testid": `image-field-preview-fallback-${dataCanopyField}`, style: {
105
106
  width: PREVIEW_WIDTH,
106
107
  height: 160,
107
108
  display: 'flex',