canopycms 0.0.67 → 0.0.68-int.93

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 (107) hide show
  1. package/dist/ai/handler.js +8 -0
  2. package/dist/api/admin-branch-health.js +12 -7
  3. package/dist/api/admin.d.ts +8 -8
  4. package/dist/api/branch-status.js +6 -2
  5. package/dist/api/client.d.ts +12 -0
  6. package/dist/api/client.js +35 -11
  7. package/dist/api/content.d.ts +6 -5
  8. package/dist/api/content.js +38 -39
  9. package/dist/api/entries.js +6 -8
  10. package/dist/api/github-sync.d.ts +10 -1
  11. package/dist/api/github-sync.js +20 -10
  12. package/dist/api/reference-options.js +1 -1
  13. package/dist/api/request-url.d.ts +8 -0
  14. package/dist/api/request-url.js +15 -0
  15. package/dist/api/resolve-references.js +2 -2
  16. package/dist/api/settings-helpers.d.ts +1 -1
  17. package/dist/api/settings-helpers.js +1 -4
  18. package/dist/authorization/content.d.ts +5 -4
  19. package/dist/authorization/content.js +5 -5
  20. package/dist/authorization/path.d.ts +11 -6
  21. package/dist/authorization/path.js +11 -6
  22. package/dist/authorization/types.d.ts +2 -2
  23. package/dist/branch-health.js +3 -3
  24. package/dist/branch-schema-cache.d.ts +5 -3
  25. package/dist/branch-schema-cache.js +29 -11
  26. package/dist/branch-workspace.js +2 -2
  27. package/dist/cli/cli.js +285 -168
  28. package/dist/cli/generate-ai-content.js +190 -109
  29. package/dist/cli/github-app-manifest.d.ts +4 -3
  30. package/dist/cli/github-app-manifest.js +4 -3
  31. package/dist/cli/init.js +15 -10
  32. package/dist/cli/sync.js +15 -13
  33. package/dist/config/schemas/config.d.ts +6 -3
  34. package/dist/config/schemas/config.js +3 -1
  35. package/dist/config/schemas/field.js +1 -0
  36. package/dist/config/types.d.ts +11 -2
  37. package/dist/config/validation.d.ts +6 -0
  38. package/dist/config/validation.js +37 -0
  39. package/dist/content-listing.d.ts +2 -2
  40. package/dist/content-listing.js +3 -3
  41. package/dist/content-reader.js +4 -4
  42. package/dist/content-store.d.ts +2 -0
  43. package/dist/content-store.js +2 -1
  44. package/dist/content-tree.js +1 -1
  45. package/dist/context.d.ts +16 -7
  46. package/dist/context.js +116 -67
  47. package/dist/editor/Editor.js +4 -4
  48. package/dist/editor/FormRenderer.js +20 -5
  49. package/dist/editor/admin/SystemHealthPanel.js +37 -6
  50. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  51. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  52. package/dist/editor/hooks/useBranchesData.js +3 -1
  53. package/dist/editor/hooks/useDraftManager.d.ts +1 -1
  54. package/dist/editor/hooks/useDraftManager.js +2 -1
  55. package/dist/editor/hooks/useEntriesData.js +2 -2
  56. package/dist/editor/hooks/useEntryManager.js +17 -11
  57. package/dist/entry-schema-registry.js +2 -1
  58. package/dist/git-manager.d.ts +20 -0
  59. package/dist/git-manager.js +50 -4
  60. package/dist/github-service.d.ts +8 -0
  61. package/dist/github-service.js +5 -1
  62. package/dist/http/handler.js +24 -9
  63. package/dist/http/index.d.ts +2 -0
  64. package/dist/http/index.js +2 -0
  65. package/dist/http/router.d.ts +2 -0
  66. package/dist/http/router.js +2 -1
  67. package/dist/http/worker-not-ready.d.ts +9 -0
  68. package/dist/http/worker-not-ready.js +17 -0
  69. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  70. package/dist/operating-mode/types.d.ts +0 -3
  71. package/dist/paths/index.d.ts +1 -1
  72. package/dist/paths/index.js +1 -1
  73. package/dist/paths/normalize.d.ts +7 -0
  74. package/dist/paths/normalize.js +9 -0
  75. package/dist/reference-resolver.d.ts +3 -3
  76. package/dist/reference-resolver.js +3 -2
  77. package/dist/resolve-canopy-user.js +2 -1
  78. package/dist/services.d.ts +12 -5
  79. package/dist/services.js +56 -56
  80. package/dist/settings-workspace.js +36 -0
  81. package/dist/static/seo.d.ts +2 -14
  82. package/dist/static/seo.js +2 -25
  83. package/dist/submission-attribution.d.ts +73 -0
  84. package/dist/submission-attribution.js +221 -0
  85. package/dist/sync-core.js +7 -8
  86. package/dist/types.d.ts +23 -2
  87. package/dist/utils/content-write-lock.d.ts +3 -7
  88. package/dist/utils/content-write-lock.js +6 -4
  89. package/dist/utils/debug.d.ts +8 -0
  90. package/dist/utils/debug.js +10 -2
  91. package/dist/utils/git.d.ts +28 -0
  92. package/dist/utils/git.js +38 -0
  93. package/dist/utils/provisioning-lock.d.ts +15 -5
  94. package/dist/utils/provisioning-lock.js +31 -11
  95. package/dist/utils/request-timing.d.ts +25 -0
  96. package/dist/utils/request-timing.js +101 -0
  97. package/dist/utils/url-prefix.d.ts +15 -0
  98. package/dist/utils/url-prefix.js +26 -0
  99. package/dist/worker/canopy-state.d.ts +45 -0
  100. package/dist/worker/canopy-state.js +75 -0
  101. package/dist/worker/git-sync.d.ts +4 -2
  102. package/dist/worker/git-sync.js +111 -24
  103. package/dist/worker/provisioned-workspace.d.ts +35 -0
  104. package/dist/worker/provisioned-workspace.js +50 -0
  105. package/dist/worker/rebase.js +75 -12
  106. package/dist/worker/task-runner.js +39 -4
  107. package/package.json +1 -1
@@ -24,9 +24,10 @@ export type PermissionSet = Readonly<Record<string, PermissionLevel>>;
24
24
  * - `metadata: read` — implied by any repository permission; declared so
25
25
  * this object states the whole surface, not just the non-automatic part.
26
26
  * Nothing else: no `issues`, `administration`, `actions`, or organisation
27
- * permissions. No `workflows` either, though GitHub may refuse to push
28
- * rebased history touching `.github/workflows/` — see
29
- * .claude/future-tasks/worker-push-refused-when-base-changes-workflows.md.
27
+ * permissions. No `workflows` either: GitHub refuses only workflow content it
28
+ * does not already hold, so rebasing onto a base that changed a workflow is
29
+ * accepted. Content that is new to GitHub fails the push task with the file
30
+ * named (`throwIfWorkflowRefusal` in worker/task-runner.ts).
30
31
  */
31
32
  export declare const CANOPY_APP_PERMISSIONS: PermissionSet;
32
33
  /**
@@ -22,9 +22,10 @@ const PERMISSION_LEVELS = ['read', 'write', 'admin'];
22
22
  * - `metadata: read` — implied by any repository permission; declared so
23
23
  * this object states the whole surface, not just the non-automatic part.
24
24
  * Nothing else: no `issues`, `administration`, `actions`, or organisation
25
- * permissions. No `workflows` either, though GitHub may refuse to push
26
- * rebased history touching `.github/workflows/` — see
27
- * .claude/future-tasks/worker-push-refused-when-base-changes-workflows.md.
25
+ * permissions. No `workflows` either: GitHub refuses only workflow content it
26
+ * does not already hold, so rebasing onto a base that changed a workflow is
27
+ * accepted. Content that is new to GitHub fails the push task with the file
28
+ * named (`throwIfWorkflowRefusal` in worker/task-runner.ts).
28
29
  */
29
30
  export const CANOPY_APP_PERMISSIONS = {
30
31
  contents: 'write',
package/dist/cli/init.js CHANGED
@@ -115,7 +115,8 @@ var init_field = __esm({
115
115
  });
116
116
  objectFieldSchema = fieldBaseSchema.extend({
117
117
  type: z.literal("object"),
118
- fields: z.array(z.lazy(() => fieldHolder[0])).min(1)
118
+ fields: z.array(z.lazy(() => fieldHolder[0])).min(1),
119
+ itemTitleField: z.string().min(1).optional()
119
120
  });
120
121
  inlineGroupFieldSchema = z.object({
121
122
  type: z.literal("group"),
@@ -357,6 +358,9 @@ var init_config = __esm({
357
358
  defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
358
359
  gitBotAuthorName: gitBotAuthorNameSchema,
359
360
  gitBotAuthorEmail: gitBotAuthorEmailSchema,
361
+ // Unset reads as on for Edited-by and off for Co-authored-by (services.ts submitBranch).
362
+ gitEditedByTrailers: z5.boolean().optional(),
363
+ gitCoAuthoredByTrailers: z5.boolean().optional(),
360
364
  githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
361
365
  // Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
362
366
  // validation loudly rather than silently running header-trusting dev auth semantics.
@@ -368,7 +372,6 @@ var init_config = __esm({
368
372
  // Default false/unset — the standard AWS Lambda+worker topology must leave this unset.
369
373
  allowNetworkRemoteInProd: z5.boolean().optional(),
370
374
  settingsBranch: z5.string().optional(),
371
- autoCreateSettingsPR: z5.boolean().optional(),
372
375
  // `.optional()`, NOT bare `deploymentNameSchema`: its own `.default('prod')` would make
373
376
  // `parse(undefined)` resolve to 'prod' instead of staying `undefined`, collapsing the
374
377
  // env > config > modeDefault precedence chain `resolveDeploymentName`
@@ -889,6 +892,10 @@ var init_debug = __esm({
889
892
  throw new Error(errorMsg);
890
893
  }
891
894
  }
895
+ /**
896
+ * Keyed by label alone, so two overlapping timers with one label overwrite each other;
897
+ * anything that can run concurrently uses {@link timed}.
898
+ */
892
899
  time(label) {
893
900
  this.timers.set(label, Date.now());
894
901
  }
@@ -903,12 +910,16 @@ var init_debug = __esm({
903
910
  this.debug(category, `${label} completed`, { durationMs: duration });
904
911
  return duration;
905
912
  }
913
+ /**
914
+ * Log `<label> completed {durationMs}` once `fn` settles. The start time is local to the
915
+ * call, so concurrent spans with the same label on one logger never overwrite each other.
916
+ */
906
917
  async timed(category, label, fn) {
907
- this.time(label);
918
+ const start = Date.now();
908
919
  try {
909
920
  return await fn();
910
921
  } finally {
911
- this.timeEnd(category, label);
922
+ this.debug(category, `${label} completed`, { durationMs: Date.now() - start });
912
923
  }
913
924
  }
914
925
  };
@@ -1525,9 +1536,6 @@ var ProdStrategy = class extends ProdClientSafeStrategy {
1525
1536
  throw new Error("gitBotAuthorName and gitBotAuthorEmail are required in prod mode");
1526
1537
  }
1527
1538
  }
1528
- shouldCreateSettingsPR(config) {
1529
- return config.autoCreateSettingsPR ?? true;
1530
- }
1531
1539
  };
1532
1540
  var DevStrategy = class extends DevClientSafeStrategy {
1533
1541
  getWorkspaceRoot(sourceRoot) {
@@ -1574,9 +1582,6 @@ var DevStrategy = class extends DevClientSafeStrategy {
1574
1582
  }
1575
1583
  validateConfig(_config) {
1576
1584
  }
1577
- shouldCreateSettingsPR(_config) {
1578
- return false;
1579
- }
1580
1585
  };
1581
1586
  var strategyCache = /* @__PURE__ */ new Map();
1582
1587
  function operatingStrategy(mode) {
package/dist/cli/sync.js CHANGED
@@ -17,6 +17,7 @@ import path from 'node:path';
17
17
  import { simpleGit } from 'simple-git';
18
18
  import * as p from '@clack/prompts';
19
19
  import { filePathExists } from '../utils/fs.js';
20
+ import { isCanopyInternalPath, stageAllExceptCanopyState } from '../utils/git.js';
20
21
  import { assertWithinDir, copyDir, listFilesRecursive, pushContentToWorkspace, safeReplaceDir, SYNC_BASE_TAG, } from '../sync-core.js';
21
22
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
22
23
  import { ensureGitExcludePattern } from '../git-manager.js';
@@ -149,13 +150,14 @@ async function syncPush(options) {
149
150
  }
150
151
  return { fileCount: 0 };
151
152
  }
152
- if (status.files.length > 0 && !options.force) {
153
- p.log.warn(`Branch workspace has ${status.files.length} uncommitted change(s) that will be committed to history then overwritten:`);
154
- for (const file of status.files.slice(0, 10)) {
153
+ const editorChanges = status.files.filter((f) => !isCanopyInternalPath(f.path));
154
+ if (editorChanges.length > 0 && !options.force) {
155
+ p.log.warn(`Branch workspace has ${editorChanges.length} uncommitted change(s) that will be committed to history then overwritten:`);
156
+ for (const file of editorChanges.slice(0, 10)) {
155
157
  p.log.warn(` ${file.path}`);
156
158
  }
157
- if (status.files.length > 10) {
158
- p.log.warn(` ... and ${status.files.length - 10} more`);
159
+ if (editorChanges.length > 10) {
160
+ p.log.warn(` ... and ${editorChanges.length - 10} more`);
159
161
  }
160
162
  const confirm = await p.confirm({
161
163
  message: 'Continue? Editor changes will be preserved in git history.',
@@ -167,8 +169,8 @@ async function syncPush(options) {
167
169
  }
168
170
  }
169
171
  // Auto-commit uncommitted workspace changes to preserve in history
170
- if (status.files.length > 0) {
171
- await wsGit.add('-A');
172
+ if (editorChanges.length > 0) {
173
+ await stageAllExceptCanopyState(wsGit);
172
174
  await wsGit.commit('sync: save editor state before push');
173
175
  p.log.info('Committed editor changes to history before push');
174
176
  }
@@ -329,8 +331,8 @@ async function syncBoth(options) {
329
331
  return { pushed: 0, pulled: 0 };
330
332
  }
331
333
  // Auto-commit uncommitted workspace changes (preserves editor work for the merge)
332
- if (status.files.length > 0) {
333
- await wsGit.add('-A');
334
+ if (status.files.some((f) => !isCanopyInternalPath(f.path))) {
335
+ await stageAllExceptCanopyState(wsGit);
334
336
  await wsGit.commit('sync: save editor state before merge');
335
337
  p.log.info('Committed editor changes before merge');
336
338
  }
@@ -365,9 +367,9 @@ async function syncBoth(options) {
365
367
  await wsGit.raw(['branch', '-D', incomingBranch]).catch(() => { });
366
368
  throw err;
367
369
  }
368
- await wsGit.add('-A');
369
- const incomingStatus = await wsGit.status();
370
- if (incomingStatus.files.length === 0) {
370
+ await stageAllExceptCanopyState(wsGit);
371
+ const incomingFiles = (await wsGit.status()).files.filter((f) => !isCanopyInternalPath(f.path));
372
+ if (incomingFiles.length === 0) {
371
373
  await wsGit.checkout(currentBranch);
372
374
  await wsGit.raw(['branch', '-D', incomingBranch]);
373
375
  p.log.info('No working-tree changes to merge — pulling editor changes only');
@@ -410,7 +412,7 @@ async function syncBoth(options) {
410
412
  await wsGit.tag(['-f', SYNC_BASE_TAG]);
411
413
  p.log.success('Merged working-tree changes with editor changes');
412
414
  const pullResult = await syncPull({ ...options, branch: branchName, force: true });
413
- return { pushed: incomingStatus.files.length, pulled: pullResult.fileCount };
415
+ return { pushed: incomingFiles.length, pulled: pullResult.fileCount };
414
416
  }
415
417
  finally {
416
418
  await invalidateBranchContentCaches(branchPath);
@@ -69,12 +69,13 @@ export declare const CanopyConfigSchema: z.ZodObject<{
69
69
  defaultRemoteUrl: z.ZodOptional<z.ZodString>;
70
70
  gitBotAuthorName: z.ZodString;
71
71
  gitBotAuthorEmail: z.ZodString;
72
+ gitEditedByTrailers: z.ZodOptional<z.ZodBoolean>;
73
+ gitCoAuthoredByTrailers: z.ZodOptional<z.ZodBoolean>;
72
74
  githubTokenEnvVar: z.ZodOptional<z.ZodDefault<z.ZodString>>;
73
75
  mode: z.ZodEnum<["prod", "dev"]>;
74
76
  deployedAs: z.ZodDefault<z.ZodEnum<["static", "server"]>>;
75
77
  allowNetworkRemoteInProd: z.ZodOptional<z.ZodBoolean>;
76
78
  settingsBranch: z.ZodOptional<z.ZodString>;
77
- autoCreateSettingsPR: z.ZodOptional<z.ZodBoolean>;
78
79
  deploymentName: z.ZodOptional<z.ZodDefault<z.ZodString>>;
79
80
  contentRoot: z.ZodDefault<z.ZodDefault<z.ZodEffects<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>, string, string>>>;
80
81
  sourceRoot: z.ZodOptional<z.ZodOptional<z.ZodString>>;
@@ -152,9 +153,10 @@ export declare const CanopyConfigSchema: z.ZodObject<{
152
153
  defaultActiveBranch?: string | undefined;
153
154
  defaultRemoteName?: string | undefined;
154
155
  defaultRemoteUrl?: string | undefined;
156
+ gitEditedByTrailers?: boolean | undefined;
157
+ gitCoAuthoredByTrailers?: boolean | undefined;
155
158
  githubTokenEnvVar?: string | undefined;
156
159
  allowNetworkRemoteInProd?: boolean | undefined;
157
- autoCreateSettingsPR?: boolean | undefined;
158
160
  deploymentName?: string | undefined;
159
161
  sourceRoot?: string | undefined;
160
162
  basePath?: string | undefined;
@@ -204,10 +206,11 @@ export declare const CanopyConfigSchema: z.ZodObject<{
204
206
  defaultActiveBranch?: string | undefined;
205
207
  defaultRemoteName?: string | undefined;
206
208
  defaultRemoteUrl?: string | undefined;
209
+ gitEditedByTrailers?: boolean | undefined;
210
+ gitCoAuthoredByTrailers?: boolean | undefined;
207
211
  githubTokenEnvVar?: string | undefined;
208
212
  deployedAs?: "static" | "server" | undefined;
209
213
  allowNetworkRemoteInProd?: boolean | undefined;
210
- autoCreateSettingsPR?: boolean | undefined;
211
214
  deploymentName?: string | undefined;
212
215
  contentRoot?: string | undefined;
213
216
  sourceRoot?: string | undefined;
@@ -79,6 +79,9 @@ export const CanopyConfigSchema = z
79
79
  defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
80
80
  gitBotAuthorName: gitBotAuthorNameSchema,
81
81
  gitBotAuthorEmail: gitBotAuthorEmailSchema,
82
+ // Unset reads as on for Edited-by and off for Co-authored-by (services.ts submitBranch).
83
+ gitEditedByTrailers: z.boolean().optional(),
84
+ gitCoAuthoredByTrailers: z.boolean().optional(),
82
85
  githubTokenEnvVar: githubTokenEnvVarSchema.optional(),
83
86
  // Required by design (follow-up to SEC-C1): a prod deploy that omits `mode` must fail
84
87
  // validation loudly rather than silently running header-trusting dev auth semantics.
@@ -89,7 +92,6 @@ export const CanopyConfigSchema = z
89
92
  // Default false/unset — the standard AWS Lambda+worker topology must leave this unset.
90
93
  allowNetworkRemoteInProd: z.boolean().optional(),
91
94
  settingsBranch: z.string().optional(),
92
- autoCreateSettingsPR: z.boolean().optional(),
93
95
  // `.optional()`, NOT bare `deploymentNameSchema`: its own `.default('prod')` would make
94
96
  // `parse(undefined)` resolve to 'prod' instead of staying `undefined`, collapsing the
95
97
  // env > config > modeDefault precedence chain `resolveDeploymentName`
@@ -81,6 +81,7 @@ const blockFieldSchema = fieldBaseSchema.extend({
81
81
  const objectFieldSchema = fieldBaseSchema.extend({
82
82
  type: z.literal('object'),
83
83
  fields: z.array(z.lazy(() => fieldHolder[0])).min(1),
84
+ itemTitleField: z.string().min(1).optional(),
84
85
  });
85
86
  // Inline group field: visual grouping only, no data nesting
86
87
  const inlineGroupFieldSchema = z.object({
@@ -132,6 +132,11 @@ export interface BlockFieldConfig extends BaseFieldConfig {
132
132
  export interface ObjectFieldConfig extends BaseFieldConfig {
133
133
  type: 'object';
134
134
  fields: FieldConfig[];
135
+ /**
136
+ * On a `list: true` object, a direct `string` or `number` child whose value titles each
137
+ * item's card; a blank or non-finite value falls back to "<label> #N".
138
+ */
139
+ itemTitleField?: string;
135
140
  }
136
141
  /**
137
142
  * Inline group field config: visually groups fields in the editor without creating
@@ -346,6 +351,8 @@ export interface CanopyConfig {
346
351
  defaultRemoteUrl?: DefaultRemoteUrl;
347
352
  gitBotAuthorName: GitBotAuthorName;
348
353
  gitBotAuthorEmail: GitBotAuthorEmail;
354
+ gitEditedByTrailers?: boolean;
355
+ gitCoAuthoredByTrailers?: boolean;
349
356
  githubTokenEnvVar?: GithubTokenEnvVar;
350
357
  mode: CanopyOperatingMode;
351
358
  /** How this build is deployed — see {@link CanopyConfigInput.deployedAs}. */
@@ -353,7 +360,6 @@ export interface CanopyConfig {
353
360
  /** Escape hatch for a prod host with real internet access — see {@link CanopyConfigInput.allowNetworkRemoteInProd}. */
354
361
  allowNetworkRemoteInProd?: boolean;
355
362
  settingsBranch?: string;
356
- autoCreateSettingsPR?: boolean;
357
363
  deploymentName?: string;
358
364
  contentRoot: ContentRoot;
359
365
  sourceRoot?: SourceRoot;
@@ -382,6 +388,10 @@ export interface CanopyConfigInput {
382
388
  defaultRemoteUrl?: string;
383
389
  gitBotAuthorName: string;
384
390
  gitBotAuthorEmail: string;
391
+ /** Add `Edited-by: Name (user id)` for the submitter to submit commits. Default true. */
392
+ gitEditedByTrailers?: boolean;
393
+ /** Also add `Co-authored-by: Name <email>`. Default false: emails in public history are public. */
394
+ gitCoAuthoredByTrailers?: boolean;
385
395
  githubTokenEnvVar?: string;
386
396
  /**
387
397
  * Operating mode: 'prod' or 'dev'. Required — no default (SEC-C1). A prod deploy that
@@ -402,7 +412,6 @@ export interface CanopyConfigInput {
402
412
  */
403
413
  allowNetworkRemoteInProd?: boolean;
404
414
  settingsBranch?: string;
405
- autoCreateSettingsPR?: boolean;
406
415
  deploymentName?: string;
407
416
  contentRoot?: string;
408
417
  sourceRoot?: string;
@@ -25,6 +25,12 @@ export declare const ensureReferenceFieldsHaveScope: (fields: unknown) => void;
25
25
  * can run at config-validation and schema-load time, before any entry exists.
26
26
  */
27
27
  export declare const forEachReferenceField: (fields: unknown, visit: (field: Record<string, unknown>) => void) => void;
28
+ /**
29
+ * Check that every `itemTitleField` sits on a `list: true` object and names a direct child
30
+ * of type `string` or `number`, the only values the card heading shows as typed (a reference
31
+ * would show its raw id, a select its stored value). Throws an error when it does not.
32
+ */
33
+ export declare const ensureItemTitleFieldsExist: (fields: unknown) => void;
28
34
  /**
29
35
  * Validate that inline groups don't cause field name collisions within the same scope.
30
36
  * Because inline groups flatten their children into the parent scope, a field name used
@@ -77,6 +77,43 @@ export const forEachReferenceField = (fields, visit) => {
77
77
  }
78
78
  }
79
79
  };
80
+ /**
81
+ * Check that every `itemTitleField` sits on a `list: true` object and names a direct child
82
+ * of type `string` or `number`, the only values the card heading shows as typed (a reference
83
+ * would show its raw id, a select its stored value). Throws an error when it does not.
84
+ */
85
+ export const ensureItemTitleFieldsExist = (fields) => {
86
+ if (!Array.isArray(fields))
87
+ return;
88
+ for (const field of fields) {
89
+ const f = field;
90
+ if (f?.type === 'object') {
91
+ const children = Array.isArray(f.fields) ? f.fields : [];
92
+ if (f.itemTitleField !== undefined) {
93
+ const label = `Object field "${f.name ?? 'unknown'}" has itemTitleField "${String(f.itemTitleField)}"`;
94
+ if (f.list !== true) {
95
+ throw new Error(`${label}, but itemTitleField applies only to list: true objects`);
96
+ }
97
+ const child = children.find((c) => c?.name === f.itemTitleField);
98
+ if (typeof f.itemTitleField !== 'string' || !child) {
99
+ throw new Error(`${label}, which is not one of its child fields`);
100
+ }
101
+ if (child.type !== 'string' && child.type !== 'number') {
102
+ throw new Error(`${label}, which must name a string or number field`);
103
+ }
104
+ }
105
+ ensureItemTitleFieldsExist(children);
106
+ }
107
+ else if (f?.type === 'group') {
108
+ ensureItemTitleFieldsExist(f.fields);
109
+ }
110
+ else if (f?.type === 'block' && Array.isArray(f.templates)) {
111
+ for (const template of f.templates) {
112
+ ensureItemTitleFieldsExist(template.fields);
113
+ }
114
+ }
115
+ }
116
+ };
80
117
  /**
81
118
  * Validate that inline groups don't cause field name collisions within the same scope.
82
119
  * Because inline groups flatten their children into the parent scope, a field name used
@@ -123,8 +123,8 @@ export interface ListEntriesOptions<T = Record<string, unknown>> {
123
123
  * unfiltered exactly as build-time callers want.
124
124
  */
125
125
  export interface ContentVisibilityOptions {
126
- /** Return false to drop an entry. Receives the entry's branch-root-relative physical path. */
127
- shouldInclude?: (physicalPath: PhysicalPath) => boolean;
126
+ /** Return false to drop an entry. Receives the entry's logical path (see `entryLogicalPath`). */
127
+ shouldInclude?: (logicalPath: LogicalPath) => boolean;
128
128
  }
129
129
  /** A collection node from the flattened schema. */
130
130
  export type CollectionSchemaItem = Extract<FlatSchemaItem, {
@@ -9,7 +9,7 @@ import { findBodyFieldName } from './utils/body-field.js';
9
9
  import { computeEntryUrl } from './utils/entry-url.js';
10
10
  import { asRecord, getFormatExtension } from './utils/format.js';
11
11
  import { resolveCollectionPath } from './content-id-index.js';
12
- import { validateAndNormalizePath } from './paths/index.js';
12
+ import { entryLogicalPath, validateAndNormalizePath } from './paths/index.js';
13
13
  import { isNotFoundError, getErrorMessage } from './utils/error.js';
14
14
  import { createDebugLogger } from './utils/debug.js';
15
15
  import { ContentStore, ContentStoreError, createReferenceResolveCache, } from './content-store.js';
@@ -139,7 +139,7 @@ export async function listEntries(branchRoot, flatSchema, contentRootName, optio
139
139
  : null;
140
140
  const collectionResults = await Promise.all(collections.map(async (collection) => {
141
141
  const entries = await listCollectionEntries(branchRoot, collection, (file) => skippedFiles.push(file));
142
- const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.physicalPath)) : entries;
142
+ const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.logicalPath)) : entries;
143
143
  // Resolve AFTER the visibility filter (a denied entry is never resolved, so its
144
144
  // references cost nothing and leak nothing) and BEFORE the mapping below, so
145
145
  // `extract` and `filter` both see resolved data — which is the entire point.
@@ -330,7 +330,7 @@ export const listCollectionEntries = async (root, collection, onSkip) => {
330
330
  readEntryData(absolutePath, format, bodyField),
331
331
  ]);
332
332
  const item = {
333
- logicalPath: `${collection.logicalPath}/${slug}`,
333
+ logicalPath: entryLogicalPath(collection.logicalPath, slug),
334
334
  contentId,
335
335
  slug,
336
336
  collectionPath: collection.logicalPath,
@@ -128,10 +128,10 @@ export const createContentReader = (options) => {
128
128
  throw new ContentStoreError(`Path is not a collection: ${entryPath}`, 'NO_SCHEMA_ITEM');
129
129
  }
130
130
  // Get the path WITHOUT reading the file
131
- let relativePath;
131
+ let logicalPath;
132
132
  // Absolute filesystem path to the entry file. Surfaced on read() / readByUrlPath()
133
133
  // for server-side colocated-artifact reads (e.g. a sibling profile.json). Already
134
- // computed here for permission checks; just carried through.
134
+ // computed here by path resolution; just carried through.
135
135
  let physicalPath;
136
136
  // Entry type + content ID come out of path resolution (buildPaths() derives both from the
137
137
  // schema item / filename), surfaced on read() / readByUrlPath() so callers can route or
@@ -140,7 +140,7 @@ export const createContentReader = (options) => {
140
140
  let entryId;
141
141
  try {
142
142
  const resolved = await store.resolveDocumentPath(entryPath, slug ?? '');
143
- relativePath = resolved.relativePath;
143
+ logicalPath = resolved.logicalPath;
144
144
  physicalPath = resolved.absolutePath;
145
145
  // resolveDocumentPath() always sets entryTypeName for a valid schema item, so no
146
146
  // further fallback is needed here; `id` is the one field that can be legitimately
@@ -164,7 +164,7 @@ export const createContentReader = (options) => {
164
164
  // Check permissions BEFORE reading the file (security)
165
165
  const shouldCheckPermissions = !(isDeployedStatic(services.config) || isBuildMode());
166
166
  if (shouldCheckPermissions) {
167
- const access = await services.checkContentAccess(context, branchRoot, relativePath, user, 'read');
167
+ const access = await services.checkContentAccess(context, branchRoot, logicalPath, user, 'read');
168
168
  if (!access.allowed) {
169
169
  if (services.config.mode !== 'prod') {
170
170
  const reasons = [];
@@ -347,6 +347,8 @@ export declare class ContentStore {
347
347
  resolveDocumentPath(schemaPath: LogicalPath, slug?: string): Promise<{
348
348
  absolutePath: string;
349
349
  relativePath: PhysicalPath;
350
+ /** The entry's logical path, the form path-permission rules match (`entryLogicalPath`). */
351
+ logicalPath: LogicalPath;
350
352
  id?: string;
351
353
  /**
352
354
  * Always populated for a valid schema item: the collection branch below resolves a name
@@ -30,7 +30,7 @@ import { generateId } from './id.js';
30
30
  import { isNodeError } from './utils/error.js';
31
31
  import { filePathExists, readFileIfExists } from './utils/fs.js';
32
32
  import { asRecord, getFormatExtension } from './utils/format.js';
33
- import { normalizeFilesystemPath, parseSlug, } from './paths/index.js';
33
+ import { entryLogicalPath, normalizeFilesystemPath, parseSlug, } from './paths/index.js';
34
34
  /**
35
35
  * Acquire `keys` in canonical (sorted) order before running `fn`, ruling out AB-BA
36
36
  * deadlocks between callers with overlapping key sets. Only renameEntry() needs two
@@ -602,6 +602,7 @@ export class ContentStore {
602
602
  return {
603
603
  absolutePath: resolved,
604
604
  relativePath: path.relative(this.root, resolved),
605
+ logicalPath: entryLogicalPath(schemaItem.logicalPath, safeSlug),
605
606
  id,
606
607
  entryTypeName: finalEntryTypeName,
607
608
  existed,
@@ -74,7 +74,7 @@ export async function buildContentTree(branchRoot, flatSchema, contentRootName,
74
74
  : null;
75
75
  const listVisibleEntries = async (collection) => {
76
76
  const entries = await listCollectionEntries(branchRoot, collection);
77
- const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.physicalPath)) : entries;
77
+ const visible = shouldInclude ? entries.filter((e) => shouldInclude(e.logicalPath)) : entries;
78
78
  // Resolved here rather than at the two node-building sites so BOTH inherit it — the same
79
79
  // reason the ACL filter lives here. A denied entry is filtered above and never resolved.
80
80
  return resolver ? resolveCollectionItemReferences(visible, collection, resolver) : visible;
package/dist/context.d.ts CHANGED
@@ -58,8 +58,12 @@ export interface CanopyBuildContext {
58
58
  * `meta.indexEntry` handed to `extract`, and a collection whose children are
59
59
  * all filtered out is pruned. On the build context and on static deployments
60
60
  * nothing is filtered — the synthetic admin sees everything.
61
+ *
62
+ * Branch: the `branch` option behaves as on `listEntries`.
61
63
  */
62
- buildContentTree: <T = unknown, TEntryTypes = DefaultEntryTypes>(options?: BuildContentTreeOptions<T, TEntryTypes>) => Promise<ContentTreeNode<T>[]>;
64
+ buildContentTree: <T = unknown, TEntryTypes = DefaultEntryTypes>(options?: BuildContentTreeOptions<T, TEntryTypes> & {
65
+ branch?: string;
66
+ }) => Promise<ContentTreeNode<T>[]>;
63
67
  /**
64
68
  * Every content entry as a flat array.
65
69
  *
@@ -67,13 +71,18 @@ export interface CanopyBuildContext {
67
71
  * cannot `read` are omitted before `extract` runs; on the build context and
68
72
  * static deployments nothing is filtered.
69
73
  *
70
- * Branch: unlike `read`/`readByUrlPath` this takes no `branch` option, always
71
- * listing `defaultActiveBranch ?? defaultBaseBranch ?? 'main'`. In `dev` that
72
- * tracks git HEAD via `refreshActiveBranch()`; in `prod` the refresh is a
73
- * no-op, so it always reads the base branch. See
74
- * `.claude/future-tasks/context-listing-branch-pinning.md`.
74
+ * Branch: with no `branch` it lists the active branch
75
+ * (`defaultActiveBranch ?? defaultBaseBranch`): git HEAD in `dev` when that is
76
+ * unset, otherwise usually the base branch in `prod`. Pass the preview
77
+ * iframe's `?branch=` as `branch`, as for `read`, so an index page previewed
78
+ * on a content branch lists that branch. Any other branch must already exist
79
+ * and, on the request-scoped context, be readable by the user; otherwise, or
80
+ * for a non-string value, the result is empty. It selects nothing at build
81
+ * time or on static deployments.
75
82
  */
76
- listEntries: <T = Record<string, unknown>>(options?: ListEntriesOptions<T>) => Promise<ListEntriesItem<T>[]>;
83
+ listEntries: <T = Record<string, unknown>>(options?: ListEntriesOptions<T> & {
84
+ branch?: string;
85
+ }) => Promise<ListEntriesItem<T>[]>;
77
86
  /**
78
87
  * Content reader, with the auth context applied automatically (admin at build
79
88
  * time).