canopycms 0.0.68-int.97 → 0.0.68-int.98

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 (37) hide show
  1. package/README.md +2 -2
  2. package/dist/api/branch.d.ts +10 -2
  3. package/dist/api/branch.js +28 -16
  4. package/dist/api/client.d.ts +2 -2
  5. package/dist/api/client.js +43 -2
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/cli/cli.js +3 -3
  8. package/dist/cli/generate-ai-content.js +1 -1
  9. package/dist/cli/init.js +1 -1
  10. package/dist/cli/template-files/cms-stack.ts.template +4 -1
  11. package/dist/config/schemas/config.d.ts +5 -5
  12. package/dist/config/schemas/config.js +1 -1
  13. package/dist/config/types.d.ts +7 -10
  14. package/dist/editor/BranchManager.d.ts +5 -1
  15. package/dist/editor/BranchManager.js +26 -13
  16. package/dist/editor/CanopyEditor.js +2 -6
  17. package/dist/editor/Editor.d.ts +2 -1
  18. package/dist/editor/Editor.js +6 -6
  19. package/dist/editor/editor-config.d.ts +1 -2
  20. package/dist/editor/editor-config.js +0 -23
  21. package/dist/editor/editor-utils.d.ts +7 -6
  22. package/dist/editor/editor-utils.js +39 -33
  23. package/dist/editor/fields/MarkdownField.js +54 -12
  24. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  25. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  26. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  27. package/dist/editor/hooks/useBranchActions.d.ts +4 -1
  28. package/dist/editor/hooks/useBranchActions.js +14 -3
  29. package/dist/editor/hooks/useBranchManager.d.ts +11 -0
  30. package/dist/editor/hooks/useBranchManager.js +70 -2
  31. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  32. package/dist/editor/hooks/useBranchesData.js +6 -2
  33. package/dist/editor/hooks/useEntriesData.js +1 -1
  34. package/dist/editor/hooks/useEntryManager.d.ts +0 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +1 -1
package/README.md CHANGED
@@ -388,7 +388,7 @@ import config from '../canopycms.config'
388
388
  export default CanopyEditorPage(config)
389
389
  ```
390
390
 
391
- The editor loads entries on the client from `/api/canopycms/[branch]/entries`, so server prefetch is optional. Use `previewBaseByCollection` to control preview URLs per collection.
391
+ The editor loads entries on the client from `/api/canopycms/[branch]/entries`, so server prefetch is optional. Use `editor.previewBase` to override preview URLs.
392
392
 
393
393
  6. **Theme it**
394
394
  Wrap editor surfaces with `CanopyCMSProvider` to load Mantine styles and customize `brand`/`primary`/`neutral`/`accent` colors and color scheme. Pass `themeOptions` into `Editor` if desired.
@@ -402,7 +402,7 @@ _TODO_ show real examples of what to do
402
402
 
403
403
  ### Preview branch awareness
404
404
 
405
- - When building preview URLs, include the current branch as a query param (e.g., `/?branch=feature-foo` or `/posts/hello?branch=feature-foo`) so SSR preview pages read from the same branch workspace the editor is editing. The `Editor` component appends the branch param automatically to `previewBaseByCollection`; your page loaders should read `searchParams.branch` and pass it as the `branch` option to `getCanopy()`'s `read`/`readByUrlPath`.
405
+ - When building preview URLs, include the current branch as a query param (e.g., `/?branch=feature-foo` or `/posts/hello?branch=feature-foo`) so SSR preview pages read from the same branch workspace the editor is editing. The `Editor` component appends the branch param to the preview URLs it builds; your page loaders should read `searchParams.branch` and pass it as the `branch` option to `getCanopy()`'s `read`/`readByUrlPath`.
406
406
  - For public static builds, omit/ignore the branch param; this pattern is only for the editor/preview environment.
407
407
  - Likewise, include `branch` in your editor route (e.g., `/edit?branch=feature-foo`) and have your editor page pass it to `<Editor>` so reloads/links preserve the selected branch. The `Editor` will also reflect branch switches back into the query string.
408
408
 
@@ -45,6 +45,14 @@ export interface BranchListItem extends BranchMetadata {
45
45
  */
46
46
  submitBlocked?: boolean;
47
47
  }
48
+ /**
49
+ * Response type for branch creation. Carries the list-item shape (server-computed
50
+ * flags included) so the editor can insert the branch without waiting for a
51
+ * listing that may lag behind the create.
52
+ */
53
+ export type BranchCreateResponse = ApiResponse<{
54
+ branch: BranchListItem;
55
+ }>;
48
56
  /** Response type for listing branches */
49
57
  export type BranchListResponse = ApiResponse<{
50
58
  branches: BranchListItem[];
@@ -130,7 +138,7 @@ export interface CreateBranchBody {
130
138
  access?: BranchMetadata['access'];
131
139
  }
132
140
  /** @internal Exported for tests. */
133
- export declare const createBranchHandler: (ctx: ApiContext, req: ApiRequest, body: z.infer<typeof createBranchBodySchema>) => Promise<BranchResponse>;
141
+ export declare const createBranchHandler: (ctx: ApiContext, req: ApiRequest, body: z.infer<typeof createBranchBodySchema>) => Promise<BranchCreateResponse>;
134
142
  /** @internal Exported for tests. */
135
143
  export declare const listBranchesHandler: (ctx: ApiContext, req: ApiRequest) => Promise<BranchListResponse>;
136
144
  /** @internal Exported for tests. */
@@ -187,7 +195,7 @@ export declare const BRANCH_ROUTES: {
187
195
  allowedUsers?: string[] | undefined;
188
196
  allowedGroups?: string[] | undefined;
189
197
  } | undefined;
190
- }>, BranchResponse>;
198
+ }>, BranchCreateResponse>;
191
199
  readonly delete: import("./route-builder.js").RouteDefinition<z.ZodObject<{
192
200
  branch: z.ZodType<import("../paths/index.js").BranchName, z.ZodTypeDef, import("../paths/index.js").BranchName>;
193
201
  }, "strip", z.ZodTypeAny, {
@@ -87,6 +87,20 @@ const resolveReadOnlyMirrorPath = (ctx, strategy) => {
87
87
  process.env[remoteUrlConfig.envVarName] ??
88
88
  remoteUrlConfig.autoDetectRemotePath);
89
89
  };
90
+ /**
91
+ * Attach server-computed protected-base-branch flags to a branch. Reads config
92
+ * per call so dev-mode refreshActiveBranch() updates are reflected.
93
+ */
94
+ const toBranchListItem = (config, context) => {
95
+ const protection = getBranchWriteProtection(config, context.branch.name, context.branch.baseBranch, context.branch.status);
96
+ return {
97
+ ...context.branch,
98
+ isProtected: protection.isProtected,
99
+ readOnly: protection.readOnly,
100
+ writeBlocked: protection.writeBlocked,
101
+ submitBlocked: protection.submitBlockedIncludingStatus,
102
+ };
103
+ };
90
104
  /** @internal Exported for tests. */
91
105
  export const createBranchHandler = async (ctx, req, body) => {
92
106
  return log.timed('api', 'createBranch', async () => {
@@ -322,7 +336,11 @@ export const createBranchHandler = async (ctx, req, body) => {
322
336
  access: body.access,
323
337
  });
324
338
  log.debug('api', 'Branch created', { branchName: context.branch.name });
325
- return { ok: true, status: 200, data: { branch: context.branch } };
339
+ return {
340
+ ok: true,
341
+ status: 200,
342
+ data: { branch: toBranchListItem(ctx.services.config, context) },
343
+ };
326
344
  });
327
345
  };
328
346
  /** @internal Exported for tests. */
@@ -345,24 +363,15 @@ export const listBranchesHandler = async (ctx, req) => {
345
363
  // editor matches defaultBranch against registry names, so return the form
346
364
  // that can actually be found there.
347
365
  const defaultBranch = sanitizeBranchName(ctx.services.config.defaultActiveBranch ?? ctx.services.config.defaultBaseBranch ?? 'main');
348
- // Attach server-computed protected-base-branch flags; read config per-request
349
- // so dev-mode refreshActiveBranch() updates are reflected here too.
350
- const toListItem = (context) => {
351
- const protection = getBranchWriteProtection(ctx.services.config, context.branch.name, context.branch.baseBranch, context.branch.status);
352
- return {
353
- ...context.branch,
354
- isProtected: protection.isProtected,
355
- readOnly: protection.readOnly,
356
- writeBlocked: protection.writeBlocked,
357
- submitBlocked: protection.submitBlockedIncludingStatus,
358
- };
359
- };
360
366
  // Admins and Reviewers see all branches
361
367
  if (isPrivileged(req.user.groups)) {
362
368
  return {
363
369
  ok: true,
364
370
  status: 200,
365
- data: { branches: allBranches.map(toListItem), defaultBranch },
371
+ data: {
372
+ branches: allBranches.map((context) => toBranchListItem(ctx.services.config, context)),
373
+ defaultBranch,
374
+ },
366
375
  };
367
376
  }
368
377
  // Regular users only see branches they created or have explicit access to
@@ -390,7 +399,10 @@ export const listBranchesHandler = async (ctx, req) => {
390
399
  return {
391
400
  ok: true,
392
401
  status: 200,
393
- data: { branches: visibleBranches.map(toListItem), defaultBranch },
402
+ data: {
403
+ branches: visibleBranches.map((context) => toBranchListItem(ctx.services.config, context)),
404
+ defaultBranch,
405
+ },
394
406
  };
395
407
  };
396
408
  /** @internal Exported for tests. */
@@ -646,7 +658,7 @@ const createBranch = defineEndpoint({
646
658
  path: '/branches',
647
659
  body: createBranchBodySchema,
648
660
  bodyType: 'CreateBranchBody',
649
- responseType: 'BranchResponse',
661
+ responseType: 'BranchCreateResponse',
650
662
  response: {},
651
663
  defaultMockData: {
652
664
  branch: {
@@ -5,7 +5,7 @@
5
5
  * AUTO-GENERATED by scripts/generate-client.ts; do not edit this file manually
6
6
  * (changes will be overwritten), edit the generator's templates instead.
7
7
  */
8
- import type { BranchDeleteResponse, BranchListResponse, BranchResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
8
+ import type { BranchCreateResponse, BranchDeleteResponse, BranchListResponse, BranchResponse, CreateBranchBody, UpdateBranchAccessBody } from './branch.js';
9
9
  import type { BranchMergeResponse } from './branch-status.js';
10
10
  import type { AddCommentBody, AddCommentResponse, CommentsResponse, ResolveCommentResponse } from './comments.js';
11
11
  import type { ContentReadResponse, ContentWriteResponse, ReferenceValidationResponse, RenameEntryBody, RenameEntryResponse, ValidateReferencesBody, WriteContentBody } from './content.js';
@@ -56,7 +56,7 @@ export declare class CanopyApiClient {
56
56
  /** GET /branches */
57
57
  list: () => Promise<BranchListResponse>;
58
58
  /** POST /branches */
59
- create: (body: CreateBranchBody) => Promise<BranchResponse>;
59
+ create: (body: CreateBranchBody) => Promise<BranchCreateResponse>;
60
60
  /** DELETE /:branch */
61
61
  delete: (params: Record<string, string>) => Promise<BranchDeleteResponse>;
62
62
  /** PATCH /:branch/access */
@@ -326,10 +326,21 @@ export class CanopyApiClient {
326
326
  if (requestBody !== undefined) {
327
327
  init.body = requestBody;
328
328
  }
329
- const response = await this.fetchFn(url, init);
329
+ let response = await this.fetchFn(url, init);
330
330
  // A body from in front of the API (a proxy or CDN error page) may be empty, not JSON, or
331
331
  // not an ApiResponse; every such body becomes an `ok: false` ApiResponse, never a throw.
332
- const parsed = await response.json().catch(() => undefined);
332
+ let parsed = await response.json().catch(() => undefined);
333
+ // A 429 no handler wrote is a throttle in front of the API, such as Lambda's concurrency
334
+ // cap, which answers before the function runs. The request was never handled, so
335
+ // resending it is safe for writes too.
336
+ for (let attempt = 0; response.status === 429 && !isApiResponseBody(parsed); attempt++) {
337
+ const delay = throttleRetryDelayMs(response, attempt);
338
+ if (delay === undefined)
339
+ break;
340
+ await sleep(delay);
341
+ response = await this.fetchFn(url, init);
342
+ parsed = await response.json().catch(() => undefined);
343
+ }
333
344
  if (response.status === 401) {
334
345
  this.onUnauthorized?.();
335
346
  return { ok: false, status: 401, error: errorFromBody(parsed) ?? 'Unauthorized' };
@@ -345,6 +356,36 @@ export class CanopyApiClient {
345
356
  return converted;
346
357
  }
347
358
  }
359
+ /**
360
+ * Base wait before each resend of a throttled request. A throttle from a burst of cold starts
361
+ * clears as they finish, so the waits span a few seconds.
362
+ */
363
+ const THROTTLE_RETRY_DELAYS_MS = [250, 1000, 3000];
364
+ /** The longest `Retry-After` waited; a throttle asking for longer is reported, not resent. */
365
+ const MAX_RETRY_AFTER_MS = 5000;
366
+ /**
367
+ * The wait before resend `attempt`, or undefined when there is to be no resend: the attempts
368
+ * are used up, or `Retry-After` asks for longer than {@link MAX_RETRY_AFTER_MS}. Without a
369
+ * `Retry-After`, the base delay plus up to half again, so throttled requests do not resend in
370
+ * lockstep.
371
+ */
372
+ function throttleRetryDelayMs(response, attempt) {
373
+ const base = THROTTLE_RETRY_DELAYS_MS[attempt];
374
+ if (base === undefined)
375
+ return undefined;
376
+ // Delta-seconds only; an HTTP-date or a blank value falls back to the base delay. A custom
377
+ // `fetch` option may return a Response-like without headers.
378
+ const header = response.headers?.get('retry-after')?.trim();
379
+ const retryAfter = header ? Number(header) : Number.NaN;
380
+ if (Number.isFinite(retryAfter) && retryAfter >= 0) {
381
+ const ms = retryAfter * 1000;
382
+ return ms > MAX_RETRY_AFTER_MS ? undefined : ms;
383
+ }
384
+ return base + Math.random() * base * 0.5;
385
+ }
386
+ function sleep(ms) {
387
+ return new Promise((resolve) => setTimeout(resolve, ms));
388
+ }
348
389
  const convertedResponses = new WeakSet();
349
390
  /**
350
391
  * Whether `result` was converted from a body no CanopyCMS handler wrote (a proxy or CDN
@@ -1,5 +1,5 @@
1
1
  export type { ApiResponse, ApiRequest, ApiContext } from './types.js';
2
- export type { BranchResponse, BranchListResponse, BranchDeleteResponse, CreateBranchBody, UpdateBranchAccessBody, } from './branch.js';
2
+ export type { BranchResponse, BranchCreateResponse, BranchListResponse, BranchDeleteResponse, CreateBranchBody, UpdateBranchAccessBody, } from './branch.js';
3
3
  export type { BranchMergeResponse } from './branch-merge.js';
4
4
  export type { CommentsResponse, AddCommentResponse, ResolveCommentResponse } from './comments.js';
5
5
  export type { EntriesResponse, ListEntriesResponse, CollectionItem, ListEntriesParams, DeleteEntryResponse, } from './entries.js';
package/dist/cli/cli.js CHANGED
@@ -1443,7 +1443,7 @@ var init_init = __esm({
1443
1443
  title: z5.string().optional(),
1444
1444
  subtitle: z5.string().optional(),
1445
1445
  theme: z5.unknown().optional(),
1446
- previewBase: z5.record(z5.string()).optional(),
1446
+ previewBase: z5.record(z5.union([z5.string(), z5.literal(false)])).optional(),
1447
1447
  previewPrefix: previewPrefixSchema.optional(),
1448
1448
  // UI handler functions (runtime only, don't serialize)
1449
1449
  onAccountClick: z5.function().returns(z5.void()).optional(),
@@ -6233,7 +6233,7 @@ var init_generate_ai_content = __esm({
6233
6233
  title: z62.string().optional(),
6234
6234
  subtitle: z62.string().optional(),
6235
6235
  theme: z62.unknown().optional(),
6236
- previewBase: z62.record(z62.string()).optional(),
6236
+ previewBase: z62.record(z62.union([z62.string(), z62.literal(false)])).optional(),
6237
6237
  previewPrefix: previewPrefixSchema2.optional(),
6238
6238
  // UI handler functions (runtime only, don't serialize)
6239
6239
  onAccountClick: z62.function().returns(z62.void()).optional(),
@@ -10902,7 +10902,7 @@ var init_config4 = __esm({
10902
10902
  title: z14.string().optional(),
10903
10903
  subtitle: z14.string().optional(),
10904
10904
  theme: z14.unknown().optional(),
10905
- previewBase: z14.record(z14.string()).optional(),
10905
+ previewBase: z14.record(z14.union([z14.string(), z14.literal(false)])).optional(),
10906
10906
  previewPrefix: previewPrefixSchema3.optional(),
10907
10907
  // UI handler functions (runtime only, don't serialize)
10908
10908
  onAccountClick: z14.function().returns(z14.void()).optional(),
@@ -481,7 +481,7 @@ var init_config = __esm({
481
481
  title: z6.string().optional(),
482
482
  subtitle: z6.string().optional(),
483
483
  theme: z6.unknown().optional(),
484
- previewBase: z6.record(z6.string()).optional(),
484
+ previewBase: z6.record(z6.union([z6.string(), z6.literal(false)])).optional(),
485
485
  previewPrefix: previewPrefixSchema.optional(),
486
486
  // UI handler functions (runtime only, don't serialize)
487
487
  onAccountClick: z6.function().returns(z6.void()).optional(),
package/dist/cli/init.js CHANGED
@@ -334,7 +334,7 @@ var init_config = __esm({
334
334
  title: z5.string().optional(),
335
335
  subtitle: z5.string().optional(),
336
336
  theme: z5.unknown().optional(),
337
- previewBase: z5.record(z5.string()).optional(),
337
+ previewBase: z5.record(z5.union([z5.string(), z5.literal(false)])).optional(),
338
338
  previewPrefix: previewPrefixSchema.optional(),
339
339
  // UI handler functions (runtime only, don't serialize)
340
340
  onAccountClick: z5.function().returns(z5.void()).optional(),
@@ -192,7 +192,10 @@ export class CmsStack extends Stack {
192
192
  },
193
193
 
194
194
  memorySize: 2048,
195
- reservedConcurrency: 10,
195
+ // A ceiling, free while idle. The editor's static chunks count against it
196
+ // until CloudFront caches them, and a cold editor load requests them
197
+ // together, so synth warns below 20. See the prop's doc comment.
198
+ reservedConcurrency: 50,
196
199
  })
197
200
 
198
201
  // Media support (uploads, on-demand image transforms). To enable it:
@@ -84,7 +84,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
84
84
  title: z.ZodOptional<z.ZodString>;
85
85
  subtitle: z.ZodOptional<z.ZodString>;
86
86
  theme: z.ZodOptional<z.ZodUnknown>;
87
- previewBase: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
87
+ previewBase: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<[z.ZodString, z.ZodLiteral<false>]>>>;
88
88
  previewPrefix: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
89
89
  onAccountClick: z.ZodOptional<z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodVoid>>;
90
90
  onLogoutClick: z.ZodOptional<z.ZodFunction<z.ZodTuple<[], z.ZodUnknown>, z.ZodVoid>>;
@@ -94,7 +94,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
94
94
  title?: string | undefined;
95
95
  subtitle?: string | undefined;
96
96
  theme?: unknown;
97
- previewBase?: Record<string, string> | undefined;
97
+ previewBase?: Record<string, string | false> | undefined;
98
98
  previewPrefix?: string | undefined;
99
99
  onAccountClick?: ((...args: unknown[]) => void) | undefined;
100
100
  onLogoutClick?: ((...args: unknown[]) => void) | undefined;
@@ -104,7 +104,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
104
104
  title?: string | undefined;
105
105
  subtitle?: string | undefined;
106
106
  theme?: unknown;
107
- previewBase?: Record<string, string> | undefined;
107
+ previewBase?: Record<string, string | false> | undefined;
108
108
  previewPrefix?: string | undefined;
109
109
  onAccountClick?: ((...args: unknown[]) => void) | undefined;
110
110
  onLogoutClick?: ((...args: unknown[]) => void) | undefined;
@@ -167,7 +167,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
167
167
  title?: string | undefined;
168
168
  subtitle?: string | undefined;
169
169
  theme?: unknown;
170
- previewBase?: Record<string, string> | undefined;
170
+ previewBase?: Record<string, string | false> | undefined;
171
171
  previewPrefix?: string | undefined;
172
172
  onAccountClick?: ((...args: unknown[]) => void) | undefined;
173
173
  onLogoutClick?: ((...args: unknown[]) => void) | undefined;
@@ -223,7 +223,7 @@ export declare const CanopyConfigSchema: z.ZodObject<{
223
223
  title?: string | undefined;
224
224
  subtitle?: string | undefined;
225
225
  theme?: unknown;
226
- previewBase?: Record<string, string> | undefined;
226
+ previewBase?: Record<string, string | false> | undefined;
227
227
  previewPrefix?: string | undefined;
228
228
  onAccountClick?: ((...args: unknown[]) => void) | undefined;
229
229
  onLogoutClick?: ((...args: unknown[]) => void) | undefined;
@@ -51,7 +51,7 @@ const editorConfigSchema = z.object({
51
51
  title: z.string().optional(),
52
52
  subtitle: z.string().optional(),
53
53
  theme: z.unknown().optional(),
54
- previewBase: z.record(z.string()).optional(),
54
+ previewBase: z.record(z.union([z.string(), z.literal(false)])).optional(),
55
55
  previewPrefix: previewPrefixSchema.optional(),
56
56
  // UI handler functions (runtime only, don't serialize)
57
57
  onAccountClick: z.function().returns(z.void()).optional(),
@@ -257,18 +257,15 @@ export interface CanopyEditorConfig {
257
257
  subtitle?: string;
258
258
  theme?: unknown;
259
259
  /**
260
- * Per-collection overrides for the preview pane's URL, keyed by collection path or name
261
- * (e.g. `{ 'content/posts': '/blog' }`) for sites whose routes don't mirror their content tree.
260
+ * Preview-pane routes for sites whose routes don't mirror their content tree; otherwise an entry
261
+ * previews its own `urlPath`. Keyed by a root entry's path (the whole route), else collection
262
+ * path or name (plus the slug). `false` means no page, and the pane says so instead of framing one.
262
263
  *
263
- * Values are **site-relative** and must NOT include the deployment `basePath` — that is applied
264
- * on top of whatever this yields (see `CanopyConfig.basePath`), so including it here would
265
- * prefix it twice. An absolute value (`https://…`) gets only the slug and `?branch=`, with no
266
- * `basePath`, `previewPrefix` or trailing-slash change: the escape hatch for previewing against a
267
- * different origin entirely.
268
- *
269
- * A site-relative value is a route on the host site, so `previewPrefix` is applied to it too.
264
+ * Values are **site-relative**, without the deployment `basePath` (applied on top, so including
265
+ * it prefixes it twice), and get `previewPrefix` too. An absolute value (`https://…`) gets no
266
+ * prefix, only the slug (for a collection key) and `?branch=`: for previewing another origin.
270
267
  */
271
- previewBase?: Record<string, string>;
268
+ previewBase?: Record<string, string | false>;
272
269
  /**
273
270
  * Where the host mounts the pages the preview pane loads, put in front of every entry's preview
274
271
  * URL (`'/preview'` makes `/blog/hello` load `/preview/blog/hello`). Set it when the site's
@@ -62,11 +62,15 @@ export interface BranchManagerProps {
62
62
  /** Current user context for permission checks */
63
63
  user?: UserContext;
64
64
  onSelect?: (name: string) => void;
65
+ /**
66
+ * Resolving `false` means the branch was not created, so the form stays open
67
+ * with the user's values; any other return (including none) closes it.
68
+ */
65
69
  onCreate?: (branch: {
66
70
  name: string;
67
71
  title?: string;
68
72
  description?: string;
69
- }) => void;
73
+ }) => Promise<boolean> | void;
70
74
  onDelete?: (name: string) => void;
71
75
  onSubmit?: (name: string) => void;
72
76
  onWithdraw?: (name: string) => void;
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useState } from 'react';
3
+ import { useRef, useState } from 'react';
4
4
  import { Badge, Button, Group, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
5
5
  import { BranchComments } from './comments/BranchComments.js';
6
6
  import { UserBadge } from './components/UserBadge.js';
@@ -82,20 +82,33 @@ export const BranchManager = ({ branches, mode, user, onSelect, onCreate, onDele
82
82
  const [newBranchName, setNewBranchName] = useState('');
83
83
  const [newBranchTitle, setNewBranchTitle] = useState('');
84
84
  const [newBranchDescription, setNewBranchDescription] = useState('');
85
- const handleCreate = () => {
86
- if (!newBranchName.trim())
85
+ const [creating, setCreating] = useState(false);
86
+ // Read synchronously: state would not yet show a first click to a second one in the same tick.
87
+ const creatingRef = useRef(false);
88
+ const handleCreate = async () => {
89
+ if (creatingRef.current || !newBranchName.trim())
87
90
  return;
88
- onCreate?.({
89
- name: newBranchName.trim(),
90
- title: newBranchTitle.trim() || undefined,
91
- description: newBranchDescription.trim() || undefined,
92
- });
93
- setNewBranchName('');
94
- setNewBranchTitle('');
95
- setNewBranchDescription('');
96
- setShowCreateForm(false);
91
+ creatingRef.current = true;
92
+ setCreating(true);
93
+ try {
94
+ const created = await onCreate?.({
95
+ name: newBranchName.trim(),
96
+ title: newBranchTitle.trim() || undefined,
97
+ description: newBranchDescription.trim() || undefined,
98
+ });
99
+ if (created === false)
100
+ return;
101
+ setNewBranchName('');
102
+ setNewBranchTitle('');
103
+ setNewBranchDescription('');
104
+ setShowCreateForm(false);
105
+ }
106
+ finally {
107
+ creatingRef.current = false;
108
+ setCreating(false);
109
+ }
97
110
  };
98
- return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : 'Create New Branch' }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", radius: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch Name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title (optional)", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description (optional)", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, "data-testid": "branch-description-textarea" }), _jsx(Button, { onClick: handleCreate, disabled: !newBranchName.trim(), fullWidth: true, "data-testid": "create-branch-submit", children: "Create Branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: branches.map((b) => {
111
+ return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : 'Create New Branch' }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", radius: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch Name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title (optional)", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description (optional)", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create Branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: branches.map((b) => {
99
112
  const statusColor = statusColorMap[b.status] ?? {
100
113
  color: 'neutral',
101
114
  variant: 'light',
@@ -4,13 +4,9 @@ import { useMemo } from 'react';
4
4
  import { Editor } from './Editor.js';
5
5
  import { ApiClientProvider } from './context/index.js';
6
6
  import { EditorAuthGate } from './EditorAuthGate.js';
7
- import { buildEditorCollections, buildPreviewBaseByCollection } from './editor-config.js';
7
+ import { buildEditorCollections } from './editor-config.js';
8
8
  export const CanopyEditor = ({ config, entries = [], initialSelectedId, initialValues, renderPreview, onCreateEntry, branchName, customRenderers, }) => {
9
9
  const collections = useMemo(() => buildEditorCollections(config.flatSchema), [config.flatSchema]);
10
- const previewBase = useMemo(() => ({
11
- ...buildPreviewBaseByCollection(config, config.flatSchema),
12
- ...(config.editor?.previewBase ?? {}),
13
- }), [config]);
14
10
  // Empty string = branchless start; useBranchManager adopts the server's
15
11
  // detected default branch on the first branches.list() load.
16
12
  const resolvedBranchName = branchName ?? config.defaultActiveBranch ?? config.defaultBaseBranch ?? '';
@@ -19,6 +15,6 @@ export const CanopyEditor = ({ config, entries = [], initialSelectedId, initialV
19
15
  const resolvedTheme = config.editor?.theme ?? undefined;
20
16
  // The gate owns the SWRProvider (keyed by user id), so it sits between the API client and the
21
17
  // editor: see EditorAuthGate for what a signed-out or lapsed session renders.
22
- return (_jsx(ApiClientProvider, { basePath: config.basePath, children: _jsx(EditorAuthGate, { SignInComponent: config.editor?.SignInComponent, themeOptions: resolvedTheme, children: _jsx(Editor, { entries: entries, title: resolvedTitle, subtitle: resolvedSubtitle, branchName: resolvedBranchName, operatingMode: config.mode, initialSelectedId: initialSelectedId, initialValues: initialValues, renderPreview: renderPreview, onCreateEntry: onCreateEntry, customRenderers: customRenderers, collections: collections, contentRoot: config.contentRoot, entryLinkUrl: config.entryLinkUrl, previewBaseByCollection: previewBase, previewPrefix: config.editor?.previewPrefix, assetBaseUrl: config.assetBaseUrl, basePath: config.basePath, themeOptions: resolvedTheme, AccountComponent: config.editor?.AccountComponent, onAccountClick: config.editor?.onAccountClick, onLogoutClick: config.editor?.onLogoutClick }) }) }));
18
+ return (_jsx(ApiClientProvider, { basePath: config.basePath, children: _jsx(EditorAuthGate, { SignInComponent: config.editor?.SignInComponent, themeOptions: resolvedTheme, children: _jsx(Editor, { entries: entries, title: resolvedTitle, subtitle: resolvedSubtitle, branchName: resolvedBranchName, operatingMode: config.mode, initialSelectedId: initialSelectedId, initialValues: initialValues, renderPreview: renderPreview, onCreateEntry: onCreateEntry, customRenderers: customRenderers, collections: collections, contentRoot: config.contentRoot, entryLinkUrl: config.entryLinkUrl, previewBaseByCollection: config.editor?.previewBase, previewPrefix: config.editor?.previewPrefix, assetBaseUrl: config.assetBaseUrl, basePath: config.basePath, themeOptions: resolvedTheme, AccountComponent: config.editor?.AccountComponent, onAccountClick: config.editor?.onAccountClick, onLogoutClick: config.editor?.onLogoutClick }) }) }));
23
19
  };
24
20
  export default CanopyEditor;
@@ -57,7 +57,8 @@ export interface EditorProps {
57
57
  renderPreview?: (entry: EditorEntry, value: FormValue | undefined) => React.ReactNode;
58
58
  onCreateEntry?: (collectionPath: LogicalPath) => Promise<void> | void;
59
59
  themeOptions?: CanopyThemeOptions;
60
- previewBaseByCollection?: Record<string, string>;
60
+ /** `editor.previewBase` from config (see `buildPreviewSrc`); `false` marks entries with no page. */
61
+ previewBaseByCollection?: Record<string, string | false>;
61
62
  /** `editor.previewPrefix` from config: put in front of every preview iframe `src` (see `buildPreviewSrc`). */
62
63
  previewPrefix?: string;
63
64
  currentUser?: string;
@@ -71,7 +71,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
71
71
  // Comments state (shared between useCommentSystem and useBranchManager)
72
72
  const [commentsForBranchSummaries, setCommentsForBranchSummaries] = useState([]);
73
73
  // 1. Branch manager (provides branchNameState, no dependencies)
74
- const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
74
+ const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
75
75
  initialBranch: branchName,
76
76
  operatingMode,
77
77
  setBusy: setBranchesLoading,
@@ -95,7 +95,6 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
95
95
  initialSelectedId,
96
96
  branchName: branchNameState,
97
97
  collections,
98
- previewBaseByCollection,
99
98
  resolvePreviewSrc: (entry) => buildPreviewSrc(entry, {
100
99
  branchName: branchNameState,
101
100
  previewBaseByCollection,
@@ -147,6 +146,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
147
146
  setBranchName,
148
147
  isAnyDirty,
149
148
  onReloadBranches: () => loadBranches(),
149
+ onBranchCreated: addCreatedBranch,
150
150
  });
151
151
  // 5. Comment system (depends on branchNameState)
152
152
  const { comments, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, } = useCommentSystem({
@@ -608,7 +608,9 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
608
608
  ? 'Setting up your branch workspace…'
609
609
  : entriesInitializing
610
610
  ? 'Loading content…'
611
- : 'Select an item to start editing.' }) }));
611
+ : currentEntry && !currentEntry.previewSrc
612
+ ? 'No preview for this entry.'
613
+ : 'Select an item to start editing.' }) }));
612
614
  const sidebarWidth = 64;
613
615
  const footerHeight = 40;
614
616
  const headerTitle = currentEntry?.label ?? currentEntry?.slug ?? title;
@@ -672,9 +674,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
672
674
  console.error('Branch change failed or was cancelled:', err);
673
675
  // Don't close branch manager if there was an error or user cancelled
674
676
  }
675
- }, onCreate: (branch) => {
676
- handleCreateBranch(branch).catch((err) => console.error(err));
677
- }, onSubmit: (name) => {
677
+ }, onCreate: handleCreateBranch, onSubmit: (name) => {
678
678
  handleSubmit(name).catch((err) => console.error(err));
679
679
  }, onWithdraw: (name) => {
680
680
  handleWithdraw(name).catch((err) => console.error(err));
@@ -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,12 +3,10 @@ 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>;
12
10
  /** `editor.previewPrefix`: where the host mounts the pages the preview pane loads. */
13
11
  previewPrefix?: string;
14
12
  }
@@ -29,12 +27,14 @@ type PreviewEntry = {
29
27
  * The result must equal the framed page's own URL: the `<iframe src>` (`PreviewFrame` in
30
28
  * preview-bridge.tsx) 404s without the prefixes, and a URL the host redirects costs a round trip
31
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
32
  */
33
33
  export declare const buildPreviewSrc: (entry: PreviewEntry, context: PreviewContext & {
34
34
  contentRoot?: string;
35
35
  basePath?: string;
36
36
  trailingSlash?: boolean;
37
- }) => string;
37
+ }) => string | undefined;
38
38
  export declare const normalizeContentPayload: (raw: unknown) => FormValue;
39
39
  export declare const buildWritePayload: (entry: {
40
40
  collectionPath?: string;
@@ -43,7 +43,7 @@ export declare const buildWritePayload: (entry: {
43
43
  }, value: FormValue) => FormValue;
44
44
  interface BuildEntriesFromListParams {
45
45
  response: ListEntriesResponse;
46
- resolvePreviewSrc: (entry: Pick<CollectionItem, 'collectionPath' | 'collectionName' | 'slug' | 'entryType'>) => string;
46
+ resolvePreviewSrc: (entry: Pick<CollectionItem, 'collectionPath' | 'collectionName' | 'slug' | 'entryType'>) => string | undefined;
47
47
  flatSchema: FlatSchemaItem[];
48
48
  }
49
49
  export declare const buildEntriesFromListResponse: ({ response, resolvePreviewSrc, flatSchema, }: BuildEntriesFromListParams) => EditorEntry[];
@@ -65,3 +65,4 @@ export declare const buildBreadcrumbSegments: (currentEntry: EditorEntry | undef
65
65
  * @returns Record<string, boolean> - Expanded state object where keys are collection node values
66
66
  */
67
67
  export declare const calculatePathToEntry: (entryPath: string | undefined, treeData: TreeNodeData[]) => Record<string, boolean>;
68
+ export {};