canopycms 0.8.2 → 0.9.0

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 (43) hide show
  1. package/CHANGELOG.md +79 -47
  2. package/dist/ai/json-to-markdown.js +2 -0
  3. package/dist/ai/resolve-branch.js +2 -1
  4. package/dist/api/branch-entry-changes.js +2 -4
  5. package/dist/authorization/branch.d.ts +10 -4
  6. package/dist/authorization/branch.js +24 -4
  7. package/dist/authorization/types.d.ts +1 -1
  8. package/dist/cli/cli.js +1984 -1630
  9. package/dist/cli/configured-asset-store.js +2 -8
  10. package/dist/cli/generate-ai-content.js +86 -16
  11. package/dist/cli/init.js +1 -1
  12. package/dist/cli/site-config.d.ts +9 -0
  13. package/dist/cli/site-config.js +17 -0
  14. package/dist/cli/sync.js +226 -74
  15. package/dist/config/types.d.ts +4 -4
  16. package/dist/config/types.js +1 -1
  17. package/dist/config/validation.d.ts +9 -3
  18. package/dist/config/validation.js +30 -5
  19. package/dist/content-listing.js +2 -3
  20. package/dist/content-store.js +5 -5
  21. package/dist/editor/FormRenderer.js +5 -0
  22. package/dist/editor/components/EntryChangesDrawer.js +3 -2
  23. package/dist/editor/entry-diff.js +7 -1
  24. package/dist/editor/fields/DateField.d.ts +19 -0
  25. package/dist/editor/fields/DateField.js +53 -0
  26. package/dist/editor/fields/DateTimeField.d.ts +10 -0
  27. package/dist/editor/fields/DateTimeField.js +5 -15
  28. package/dist/editor/fields/list-item-title.js +2 -1
  29. package/dist/editor/theme.js +12 -4
  30. package/dist/entry-schema-registry.js +2 -1
  31. package/dist/services.d.ts +3 -2
  32. package/dist/static/index.js +5 -2
  33. package/dist/system-users.d.ts +2 -0
  34. package/dist/system-users.js +6 -1
  35. package/dist/utils/calendar-day.d.ts +23 -0
  36. package/dist/utils/calendar-day.js +102 -0
  37. package/dist/utils/content-serialize.d.ts +4 -2
  38. package/dist/utils/content-serialize.js +8 -6
  39. package/dist/validation/entry-validator.js +7 -1
  40. package/dist/validation/field-traversal.js +1 -1
  41. package/dist/version.d.ts +1 -1
  42. package/dist/version.js +1 -1
  43. package/package.json +1 -1
package/dist/cli/sync.js CHANGED
@@ -16,13 +16,20 @@ import fs from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import { simpleGit } from 'simple-git';
18
18
  import * as p from '@clack/prompts';
19
+ import { z } from 'zod';
20
+ import { resolveBranchParam } from '../branch-param.js';
21
+ import { readBranchMetadataFile } from '../branch-metadata-file.js';
22
+ import { namesNoWorkspace, resolveBranchPath } from '../paths/branch.js';
23
+ import { exactBranchName, exactBranchSegment, isExactBranchForm, parseBranchRef, } from '../paths/validation.js';
24
+ import { getErrorMessage } from '../utils/error.js';
19
25
  import { filePathExists } from '../utils/fs.js';
20
- import { isCanopyInternalPath, stageAllExceptCanopyState } from '../utils/git.js';
26
+ import { detectHeadBranch, isCanopyInternalPath, stageAllExceptCanopyState } from '../utils/git.js';
21
27
  import { assertWithinDir, copyDir, listFilesRecursive, pushContentToWorkspace, safeReplaceDir, SYNC_BASE_TAG, } from '../sync-core.js';
22
28
  import { invalidateBranchContentCaches } from '../content-index-generation.js';
23
29
  import { ContentWriteLockBusyError, withContentWriteLock } from '../utils/content-write-lock.js';
24
30
  import { ensureGitExcludePattern } from '../git-manager.js';
25
31
  import { operatingStrategy } from '../operating-mode/index.js';
32
+ import { importSiteServerConfig } from './site-config.js';
26
33
  /**
27
34
  * Sync precondition failure (missing content dir, unknown branch, …).
28
35
  * Thrown so the CLI exits non-zero — scripts/CI must not sail past a
@@ -78,12 +85,183 @@ async function detectCurrentBranch(projectDir) {
78
85
  throw new Error('Detached HEAD — use --branch to specify a branch name');
79
86
  return branch;
80
87
  }
88
+ /** The site config fields that decide which workspace a branch name names. */
89
+ const branchConfigSchema = z.object({
90
+ branchNamespace: z.string().optional(),
91
+ deploymentName: z.string().optional(),
92
+ defaultBaseBranch: z.string().optional(),
93
+ defaultActiveBranch: z.string().optional(),
94
+ });
95
+ /** {@link branchConfigSchema}'s fields from the site config: none, with a warning, when unusable. */
96
+ async function readBranchConfigFields(projectDir) {
97
+ if (!(await filePathExists(path.join(projectDir, 'canopycms.config.ts'))))
98
+ return {};
99
+ let why;
100
+ try {
101
+ const parsed = branchConfigSchema.safeParse((await importSiteServerConfig(projectDir)).server);
102
+ if (parsed.success)
103
+ return parsed.data;
104
+ why = parsed.error.message;
105
+ }
106
+ catch (err) {
107
+ why = getErrorMessage(err);
108
+ }
109
+ p.log.warn(`Could not use canopycms.config.ts (${why}); branch names resolve against the default branch settings.`);
110
+ return {};
111
+ }
112
+ /**
113
+ * The config {@link resolveBranchParam} resolves names against, built as the dev server builds
114
+ * it: dev mode, the site config's branch fields, and an unset base or active branch taken from the
115
+ * project's git HEAD (services.ts).
116
+ */
117
+ async function loadBranchParamConfig(projectDir) {
118
+ const fields = await readBranchConfigFields(projectDir);
119
+ const head = await detectHeadBranch(projectDir, fields.defaultBaseBranch ?? 'main');
120
+ return {
121
+ ...fields,
122
+ mode: 'dev',
123
+ defaultBaseBranch: fields.defaultBaseBranch ?? head,
124
+ defaultActiveBranch: fields.defaultActiveBranch ?? head,
125
+ };
126
+ }
127
+ /**
128
+ * Refuses a directory that is not the top of its own git repository. Git run there would walk up
129
+ * to the developer's own repository and commit and tag in it.
130
+ */
131
+ async function assertOwnRepository(branchPath) {
132
+ const top = await simpleGit({ baseDir: branchPath })
133
+ .revparse(['--show-toplevel'])
134
+ .then((out) => out.trim())
135
+ .catch(() => '');
136
+ if (top && (await fs.realpath(top)) === (await fs.realpath(branchPath)))
137
+ return;
138
+ throw new SyncError(`"${path.basename(branchPath)}" under content-branches/ is not a branch workspace: git does not report it as the top of its own repository.`);
139
+ }
140
+ /** The branch a workspace's HEAD is on (an unborn one included), or null when it is detached. */
141
+ async function workspaceHead(branchPath) {
142
+ return simpleGit({ baseDir: branchPath })
143
+ .raw(['symbolic-ref', '--short', '-q', 'HEAD'])
144
+ .then((out) => out.trim() || null)
145
+ .catch(() => null);
146
+ }
147
+ /** The workspace at directory `key` under content-branches/, named by its branch.json or HEAD. */
148
+ async function keyWorkspace(key, projectDir) {
149
+ const branchPath = path.join(operatingStrategy('dev').getContentBranchesRoot(projectDir), key);
150
+ await assertOwnRepository(branchPath);
151
+ const meta = await readBranchMetadataFile(branchPath);
152
+ return { branchName: meta?.branch.name ?? (await workspaceHead(branchPath)) ?? key, branchPath };
153
+ }
154
+ /** The ref `name` gives: decoded from the exact form (`~fix~x`), else `name` itself. */
155
+ function refOf(name) {
156
+ if (!isExactBranchForm(name))
157
+ return name;
158
+ const ref = exactBranchName(name);
159
+ if (ref === null)
160
+ throw new SyncError(`"${name}" is not a valid branch name.`);
161
+ return ref;
162
+ }
163
+ /**
164
+ * The workspace `name` names, as the dev server resolves it, or null:
165
+ * 1. what the runtime resolves it to (`resolveBranchParam`): the ref, a namespace branch's short
166
+ * name, or the exact form;
167
+ * 2. a workspace key, the directory itself;
168
+ * 3. the directory the ref keys to, when it holds a workspace sync built (no branch.json) on that
169
+ * ref. Two refs can sanitize to one key, so one whose branch.json or HEAD names another branch
170
+ * is refused.
171
+ * A `name` read from git HEAD (`byRef`) is a ref, never a short name or key: it resolves in its
172
+ * exact form and skips step 2, so a local branch never lands in an editor's branch or in another
173
+ * ref's workspace that shares its key.
174
+ */
175
+ async function resolveSyncWorkspace(name, projectDir, keys, byRef) {
176
+ const config = await loadBranchParamConfig(projectDir);
177
+ const param = byRef ? exactBranchSegment(name) : name;
178
+ const resolved = await resolveBranchParam(param, config, { basePathOverride: projectDir }).catch((err) => {
179
+ if (namesNoWorkspace(err))
180
+ return null;
181
+ throw err;
182
+ });
183
+ if (resolved) {
184
+ // The base and active branch resolve by directory alone, without branch.json's name.
185
+ const ref = refOf(name);
186
+ if (resolved.branch.name !== ref && resolved.branchRoot === refKeyPath(ref, projectDir)) {
187
+ throw holdsAnotherBranch(resolved.branchRoot, resolved.branch.name, ref);
188
+ }
189
+ await assertOwnRepository(resolved.branchRoot);
190
+ return { branchName: resolved.branch.name, branchPath: resolved.branchRoot };
191
+ }
192
+ if (!byRef && keys.includes(name))
193
+ return keyWorkspace(name, projectDir);
194
+ const ref = refOf(name);
195
+ const branchPath = refKeyPath(ref, projectDir);
196
+ const present = await filePathExists(branchPath).catch((err) => {
197
+ if (namesNoWorkspace(err))
198
+ throw new SyncError(`"${ref}" cannot name a branch workspace.`);
199
+ throw err;
200
+ });
201
+ if (!present)
202
+ return null;
203
+ await assertOwnRepository(branchPath);
204
+ const meta = await readBranchMetadataFile(branchPath);
205
+ const holder = meta ? meta.branch.name : await workspaceHead(branchPath);
206
+ if (holder !== ref)
207
+ throw holdsAnotherBranch(branchPath, holder, ref);
208
+ return { branchName: ref, branchPath };
209
+ }
210
+ /** The refusal for a workspace another ref, sharing `ref`'s key, holds (`holder`; null: detached). */
211
+ function holdsAnotherBranch(branchPath, holder, ref) {
212
+ // mergeIntoWorkspace's temporary branch, left checked out when `both` was interrupted.
213
+ const recovery = holder?.startsWith('sync-incoming-')
214
+ ? ` An interrupted "sync both" left it there: run "git checkout ${ref}" in that workspace.`
215
+ : '';
216
+ return new SyncError(`Branch workspace "${path.basename(branchPath)}" holds ${holder === null ? 'a detached HEAD' : `branch "${holder}"`}, not "${ref}".${recovery}`);
217
+ }
218
+ /** The workspace directory the runtime keys ref `name` to (`resolveBranchPath`). */
219
+ function refKeyPath(name, projectDir) {
220
+ try {
221
+ return resolveBranchPath({ mode: 'dev', branchName: name, basePathOverride: projectDir })
222
+ .branchRoot;
223
+ }
224
+ catch (err) {
225
+ if (namesNoWorkspace(err))
226
+ throw new SyncError(`"${name}" cannot name a branch workspace.`);
227
+ throw err;
228
+ }
229
+ }
230
+ /**
231
+ * Auto-create a minimal workspace for the branch `name` gives (git init + empty commit) so push
232
+ * can copy content in, at the directory the dev server reads for that ref. This is lighter than
233
+ * BranchWorkspaceManager's clone-based setup -- no remote config, content structure or
234
+ * branch.json -- and the dev server adopts it in place when it provisions that ref
235
+ * (BranchWorkspaceManager.provisionBranch).
236
+ */
237
+ async function createSyncWorkspace(name, projectDir) {
238
+ const ref = refOf(name);
239
+ const parsed = parseBranchRef(ref);
240
+ if (!parsed.ok)
241
+ throw new SyncError(`"${ref}" is not a valid branch name: ${parsed.error}`);
242
+ const branchPath = refKeyPath(ref, projectDir);
243
+ await fs.mkdir(branchPath, { recursive: true });
244
+ const wsGit = simpleGit({ baseDir: branchPath });
245
+ await wsGit.init();
246
+ await wsGit.checkoutLocalBranch(ref);
247
+ await wsGit.raw(['commit', '--allow-empty', '-m', 'init: workspace created by sync']);
248
+ // Runtime metadata (.canopy-meta/: branch metadata, comments, content-index
249
+ // marker) must never be staged by sync's `add -A`. Fully provisioned workspaces
250
+ // get this exclude from GitManager.initializeWorkspace; this minimal one needs it
251
+ // too. Sync is dev-mode-only, so this asks the dev strategy for the pattern
252
+ // rather than hardcoding it.
253
+ await ensureGitExcludePattern(branchPath, operatingStrategy('dev').getGitExcludePattern());
254
+ p.log.info(`Created branch workspace: ${path.basename(branchPath)}`);
255
+ return { branchName: ref, branchPath };
256
+ }
81
257
  /**
82
- * Select a branch workspace. Auto-selects if only one exists, prompts if
83
- * multiple, or uses the --branch flag (defaulting to the current git branch).
84
- * When autoCreate is true, creates a new workspace if the branch doesn't exist.
258
+ * Select a branch workspace. Uses the --branch flag (defaulting to the current git branch),
259
+ * resolved as the dev server resolves it ({@link resolveSyncWorkspace}); else auto-selects the
260
+ * only workspace directory, or prompts for one. When autoCreate is true, creates a new workspace
261
+ * if a named branch has none.
85
262
  */
86
- async function selectBranch(options, branchesDir) {
263
+ async function selectBranch(options) {
264
+ const branchesDir = operatingStrategy('dev').getContentBranchesRoot(options.projectDir);
87
265
  let branches = [];
88
266
  if (await filePathExists(branchesDir)) {
89
267
  const entries = await fs.readdir(branchesDir, { withFileTypes: true });
@@ -93,6 +271,7 @@ async function selectBranch(options, branchesDir) {
93
271
  branches = entries.filter((e) => e.isDirectory() && !e.name.startsWith('.')).map((e) => e.name);
94
272
  }
95
273
  let branchName = options.branch;
274
+ const byRef = !branchName;
96
275
  if (!branchName) {
97
276
  try {
98
277
  branchName = await detectCurrentBranch(options.projectDir);
@@ -105,48 +284,25 @@ async function selectBranch(options, branchesDir) {
105
284
  if (branches.length === 0) {
106
285
  throw new SyncError('No branch workspaces found and could not detect current git branch. Use --branch to specify.');
107
286
  }
108
- if (branches.length === 1) {
109
- branchName = branches[0];
110
- }
111
- else {
112
- const result = await p.select({
113
- message: 'Which branch?',
114
- options: branches.map((b) => ({ value: b, label: b })),
115
- });
116
- if (p.isCancel(result)) {
117
- p.cancel('Sync cancelled.');
118
- return null;
119
- }
120
- branchName = result;
121
- }
122
- }
123
- // Validate branch name does not escape the branches directory
124
- const branchPath = path.join(branchesDir, branchName);
125
- assertWithinDir(branchPath, branchesDir, '--branch');
126
- if (!branches.includes(branchName)) {
127
- if (options.autoCreate) {
128
- // Auto-create a minimal workspace (git init + empty commit) so push can
129
- // copy content in. This is lighter than BranchWorkspaceManager's clone-based
130
- // setup — no remote config or content structure — but sufficient for sync.
131
- await fs.mkdir(branchPath, { recursive: true });
132
- const wsGit = simpleGit({ baseDir: branchPath });
133
- await wsGit.init();
134
- await wsGit.checkoutLocalBranch(branchName);
135
- await wsGit.raw(['commit', '--allow-empty', '-m', 'init: workspace created by sync']);
136
- // Runtime metadata (.canopy-meta/: branch metadata, comments, content-index
137
- // marker) must never be staged by sync's `add -A`. Fully provisioned workspaces
138
- // get this exclude from GitManager.initializeWorkspace; this minimal one needs it
139
- // too. Sync is dev-mode-only (see branchesDir), so this asks the dev strategy for
140
- // the pattern rather than hardcoding it.
141
- await ensureGitExcludePattern(branchPath, operatingStrategy('dev').getGitExcludePattern());
142
- p.log.info(`Created branch workspace: ${branchName}`);
143
- }
144
- else {
145
- const available = branches.length > 0 ? ` Available branches: ${branches.join(', ')}` : '';
146
- throw new SyncError(`Branch workspace "${branchName}" not found.${available}`);
287
+ if (branches.length === 1)
288
+ return keyWorkspace(branches[0], options.projectDir);
289
+ const result = await p.select({
290
+ message: 'Which branch?',
291
+ options: branches.map((b) => ({ value: b, label: b })),
292
+ });
293
+ if (p.isCancel(result)) {
294
+ p.cancel('Sync cancelled.');
295
+ return null;
147
296
  }
148
- }
149
- return branchName;
297
+ return keyWorkspace(result, options.projectDir);
298
+ }
299
+ const existing = await resolveSyncWorkspace(branchName, options.projectDir, branches, byRef);
300
+ if (existing)
301
+ return existing;
302
+ if (options.autoCreate)
303
+ return createSyncWorkspace(branchName, options.projectDir);
304
+ const available = branches.length > 0 ? ` Available branches: ${branches.join(', ')}` : '';
305
+ throw new SyncError(`Branch workspace "${branchName}" not found.${available}`);
150
306
  }
151
307
  /**
152
308
  * Push: copy working-tree content into a branch workspace and commit.
@@ -155,7 +311,6 @@ async function selectBranch(options, branchesDir) {
155
311
  async function syncPush(options) {
156
312
  const { projectDir } = options;
157
313
  const contentRoot = options.contentRoot || 'content';
158
- const branchesDir = path.join(projectDir, '.canopy-dev', 'content-branches');
159
314
  // Validate source content BEFORE selectBranch, which may create a workspace —
160
315
  // a push with nothing to push must not leave side effects behind.
161
316
  const srcContentDir = path.join(projectDir, contentRoot);
@@ -163,11 +318,10 @@ async function syncPush(options) {
163
318
  if (!(await filePathExists(srcContentDir))) {
164
319
  throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
165
320
  }
166
- const branchName = await selectBranch({ ...options, projectDir, autoCreate: true }, branchesDir);
167
- if (!branchName)
321
+ const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
322
+ if (!ws)
168
323
  return { fileCount: 0 };
169
- const branchPath = path.join(branchesDir, branchName);
170
- assertWithinDir(branchPath, branchesDir, '--branch');
324
+ const { branchName, branchPath } = ws;
171
325
  const wsGit = simpleGit({ baseDir: branchPath });
172
326
  const status = await wsGit.status();
173
327
  if (status.conflicted.length > 0) {
@@ -235,18 +389,20 @@ async function syncPush(options) {
235
389
  * Pull: copy content from a branch workspace back into the working tree.
236
390
  */
237
391
  async function syncPull(options) {
392
+ const ws = await selectBranch({ ...options, projectDir: options.projectDir });
393
+ if (!ws)
394
+ return { fileCount: 0 };
395
+ return pullFromWorkspace(options, ws);
396
+ }
397
+ /** The body of {@link syncPull}, once its workspace is chosen. */
398
+ async function pullFromWorkspace(options, ws) {
238
399
  const { projectDir } = options;
239
400
  const contentRoot = options.contentRoot || 'content';
240
- const branchesDir = path.join(projectDir, '.canopy-dev', 'content-branches');
241
- const branchName = await selectBranch({ ...options, projectDir }, branchesDir);
242
- if (!branchName)
243
- return { fileCount: 0 };
244
- const branchPath = path.join(branchesDir, branchName);
245
- assertWithinDir(branchPath, branchesDir, '--branch');
401
+ const { branchName, branchPath } = ws;
246
402
  const branchContentDir = path.join(branchPath, contentRoot);
247
403
  assertWithinDir(branchContentDir, branchPath, '--content-root');
248
404
  if (!(await filePathExists(branchContentDir))) {
249
- throw new SyncError(`Content directory not found in branch workspace: ${branchName}/${contentRoot}`);
405
+ throw new SyncError(`Content directory not found in branch workspace: ${path.basename(branchPath)}/${contentRoot}`);
250
406
  }
251
407
  p.log.step(`Pulling content from branch: ${branchName}`);
252
408
  const destContentDir = path.join(projectDir, contentRoot);
@@ -342,18 +498,16 @@ async function syncPull(options) {
342
498
  async function syncBoth(options) {
343
499
  const { projectDir } = options;
344
500
  const contentRoot = options.contentRoot || 'content';
345
- const branchesDir = path.join(projectDir, '.canopy-dev', 'content-branches');
346
501
  // Validate source content BEFORE selectBranch, which may create a workspace
347
502
  const srcContentDir = path.join(projectDir, contentRoot);
348
503
  assertWithinDir(srcContentDir, projectDir, '--content-root');
349
504
  if (!(await filePathExists(srcContentDir))) {
350
505
  throw new SyncError(`Content directory not found: ${contentRoot}/ (expected at ${srcContentDir})`);
351
506
  }
352
- const branchName = await selectBranch({ ...options, projectDir, autoCreate: true }, branchesDir);
353
- if (!branchName)
507
+ const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
508
+ if (!ws)
354
509
  return { pushed: 0, pulled: 0 };
355
- const branchPath = path.join(branchesDir, branchName);
356
- assertWithinDir(branchPath, branchesDir, '--branch');
510
+ const { branchName, branchPath } = ws;
357
511
  const wsGit = simpleGit({ baseDir: branchPath });
358
512
  const status = await wsGit.status();
359
513
  if (status.conflicted.length > 0) {
@@ -375,7 +529,7 @@ async function syncBoth(options) {
375
529
  }
376
530
  /** The locked body of {@link syncBoth}: everything that rewrites the workspace. */
377
531
  async function mergeIntoWorkspace(options, ws) {
378
- const { branchName, branchPath, srcContentDir, contentRoot } = ws;
532
+ const { branchPath, srcContentDir, contentRoot } = ws;
379
533
  const wsGit = simpleGit({ baseDir: branchPath });
380
534
  const status = await wsGit.status();
381
535
  // Auto-commit uncommitted workspace changes (preserves editor work for the merge)
@@ -421,7 +575,7 @@ async function mergeIntoWorkspace(options, ws) {
421
575
  await wsGit.checkout(currentBranch);
422
576
  await wsGit.raw(['branch', '-D', incomingBranch]);
423
577
  p.log.info('No working-tree changes to merge — pulling editor changes only');
424
- const pullResult = await syncPull({ ...options, branch: branchName, force: true });
578
+ const pullResult = await pullFromWorkspace({ ...options, force: true }, ws);
425
579
  return { pushed: 0, pulled: pullResult.fileCount };
426
580
  }
427
581
  await wsGit.commit('sync: incoming working-tree changes');
@@ -446,8 +600,9 @@ async function mergeIntoWorkspace(options, ws) {
446
600
  p.log.error(` ${file}`);
447
601
  }
448
602
  p.log.info('The branch workspace is now in a merge state.');
449
- p.log.info('Resolve the conflicts in the workspace, then run: canopycms sync pull');
450
- p.log.info('Or abort the merge with: canopycms sync abort');
603
+ const branchFlag = options.branch ? ` --branch ${options.branch}` : '';
604
+ p.log.info(`Resolve the conflicts in the workspace, then run: canopycms sync pull${branchFlag}`);
605
+ p.log.info(`Or abort the merge with: canopycms sync abort${branchFlag}`);
451
606
  // Clean up the incoming branch (leave merge state for user resolution)
452
607
  await wsGit.raw(['branch', '-D', incomingBranch]).catch(() => { });
453
608
  return { pushed: 0, pulled: 0 };
@@ -459,7 +614,7 @@ async function mergeIntoWorkspace(options, ws) {
459
614
  await wsGit.raw(['branch', '-D', incomingBranch]);
460
615
  await wsGit.tag(['-f', SYNC_BASE_TAG]);
461
616
  p.log.success('Merged working-tree changes with editor changes');
462
- const pullResult = await syncPull({ ...options, branch: branchName, force: true });
617
+ const pullResult = await pullFromWorkspace({ ...options, force: true }, ws);
463
618
  return { pushed: incomingFiles.length, pulled: pullResult.fileCount };
464
619
  }
465
620
  finally {
@@ -471,13 +626,10 @@ async function mergeIntoWorkspace(options, ws) {
471
626
  * Restores the workspace to its pre-merge state.
472
627
  */
473
628
  async function syncAbort(options) {
474
- const { projectDir } = options;
475
- const branchesDir = path.join(projectDir, '.canopy-dev', 'content-branches');
476
- const branchName = await selectBranch({ ...options, projectDir }, branchesDir);
477
- if (!branchName)
629
+ const ws = await selectBranch({ ...options, projectDir: options.projectDir });
630
+ if (!ws)
478
631
  return;
479
- const branchPath = path.join(branchesDir, branchName);
480
- assertWithinDir(branchPath, branchesDir, '--branch');
632
+ const { branchName, branchPath } = ws;
481
633
  if (!(await abortWorkspaceMerge(branchPath, branchName))) {
482
634
  p.log.info(`Branch workspace "${branchName}" is not in a merge state — nothing to abort.`);
483
635
  return;
@@ -10,10 +10,10 @@ import type { PermissionPath } from '../authorization/types.js';
10
10
  import type { EntryLinkUrlResolver } from '../entry-link-resolver.js';
11
11
  import type { CropRect } from '../assets/transform-directives.js';
12
12
  import type { SeoFieldLocation } from '../static/seo.js';
13
- declare const scalarFieldTypes: readonly ["string", "number", "boolean", "datetime", "code"];
13
+ declare const scalarFieldTypes: readonly ["string", "number", "boolean", "date", "datetime", "code"];
14
14
  export declare const markdownFieldTypes: readonly ["markdown", "mdx"];
15
- export declare const primitiveFieldTypes: readonly ["string", "number", "boolean", "datetime", "code", "markdown", "mdx"];
16
- export declare const fieldTypes: readonly ["string", "number", "boolean", "datetime", "code", "markdown", "mdx", "image", "select", "reference", "object", "block", "group"];
15
+ export declare const primitiveFieldTypes: readonly ["string", "number", "boolean", "date", "datetime", "code", "markdown", "mdx"];
16
+ export declare const fieldTypes: readonly ["string", "number", "boolean", "date", "datetime", "code", "markdown", "mdx", "image", "select", "reference", "object", "block", "group"];
17
17
  export type PrimitiveFieldType = (typeof primitiveFieldTypes)[number];
18
18
  export type FieldType = (typeof fieldTypes)[number];
19
19
  export type ContentFormat = 'md' | 'mdx' | 'json' | 'yaml';
@@ -211,7 +211,7 @@ export interface ObjectFieldConfig extends BaseFieldConfig {
211
211
  type: 'object';
212
212
  fields: FieldConfig[];
213
213
  /**
214
- * On a `list: true` object, a direct `string` or `number` child whose value titles each
214
+ * On a `list: true` object, a direct `string`, `number` or `date` child whose value titles each
215
215
  * item's card; a blank or non-finite value falls back to the item's first non-blank `string`
216
216
  * field, then "Item N".
217
217
  */
@@ -2,7 +2,7 @@
2
2
  * Type definitions for CanopyCMS configuration.
3
3
  * These are pure TypeScript types - Zod schemas are in ./schemas/
4
4
  */
5
- const scalarFieldTypes = ['string', 'number', 'boolean', 'datetime', 'code'];
5
+ const scalarFieldTypes = ['string', 'number', 'boolean', 'date', 'datetime', 'code'];
6
6
  export const markdownFieldTypes = ['markdown', 'mdx'];
7
7
  export const primitiveFieldTypes = [...scalarFieldTypes, ...markdownFieldTypes];
8
8
  // 'image' is not a primitive: it has a structured value (ImageFieldValue) and
@@ -34,9 +34,9 @@ export declare const ensureReferenceFieldsHaveScope: (fields: unknown) => void;
34
34
  export declare const forEachReferenceField: (fields: unknown, visit: (field: Record<string, unknown>) => void) => void;
35
35
  /**
36
36
  * Check that every `itemLabel` is non-blank text and, like every `itemTitleField`, sits on a
37
- * `list: true` object, and that every `itemTitleField` names a direct child of type `string` or
38
- * `number`, the only values the card heading shows as typed (a reference would show its raw id, a
39
- * select its stored value). Throws an error when it does not. Entry schemas reach the editor
37
+ * `list: true` object, and that every `itemTitleField` names a direct child of type `string`,
38
+ * `number` or `date`, the only values the card heading shows as typed (a reference would show its
39
+ * raw id, a select its stored value). Throws an error when it does not. Entry schemas reach the editor
40
40
  * through these checks, not the zod field schema.
41
41
  */
42
42
  export declare const ensureItemTitleFieldsExist: (fields: unknown) => void;
@@ -68,6 +68,12 @@ export declare const ensureStampOnPublishPlacement: (fields: unknown, scopeLabel
68
68
  * `ensureStampOnPublishPlacement`. Throws on the first problem.
69
69
  */
70
70
  export declare const ensureMaxItemsPlacement: (fields: unknown, scopeLabel?: string) => void;
71
+ /**
72
+ * Check that no `date` field is `list: true`: the editor has one picker for one day, and an object
73
+ * list with a `date` child gives a list of days its rows. Recurses into groups, objects and block
74
+ * templates like `ensureStampOnPublishPlacement`. Throws on the first problem.
75
+ */
76
+ export declare const ensureSingleDateFields: (fields: unknown, scopeLabel?: string) => void;
71
77
  /**
72
78
  * Check every markdown and mdx field's `mdxAllow` against its schema and its `renderAs`,
73
79
  * `mdxAllow` and `executable` against each other. Throws on the first problem.
@@ -110,9 +110,9 @@ export const forEachReferenceField = (fields, visit) => {
110
110
  };
111
111
  /**
112
112
  * Check that every `itemLabel` is non-blank text and, like every `itemTitleField`, sits on a
113
- * `list: true` object, and that every `itemTitleField` names a direct child of type `string` or
114
- * `number`, the only values the card heading shows as typed (a reference would show its raw id, a
115
- * select its stored value). Throws an error when it does not. Entry schemas reach the editor
113
+ * `list: true` object, and that every `itemTitleField` names a direct child of type `string`,
114
+ * `number` or `date`, the only values the card heading shows as typed (a reference would show its
115
+ * raw id, a select its stored value). Throws an error when it does not. Entry schemas reach the editor
116
116
  * through these checks, not the zod field schema.
117
117
  */
118
118
  export const ensureItemTitleFieldsExist = (fields) => {
@@ -140,8 +140,8 @@ export const ensureItemTitleFieldsExist = (fields) => {
140
140
  if (typeof f.itemTitleField !== 'string' || !child) {
141
141
  throw new Error(`${label}, which is not one of its child fields`);
142
142
  }
143
- if (child.type !== 'string' && child.type !== 'number') {
144
- throw new Error(`${label}, which must name a string or number field`);
143
+ if (child.type !== 'string' && child.type !== 'number' && child.type !== 'date') {
144
+ throw new Error(`${label}, which must name a string, number or date field`);
145
145
  }
146
146
  }
147
147
  ensureItemTitleFieldsExist(children);
@@ -327,6 +327,31 @@ export const ensureMaxItemsPlacement = (fields, scopeLabel = 'entry schema') =>
327
327
  };
328
328
  check(fields);
329
329
  };
330
+ /**
331
+ * Check that no `date` field is `list: true`: the editor has one picker for one day, and an object
332
+ * list with a `date` child gives a list of days its rows. Recurses into groups, objects and block
333
+ * templates like `ensureStampOnPublishPlacement`. Throws on the first problem.
334
+ */
335
+ export const ensureSingleDateFields = (fields, scopeLabel = 'entry schema') => {
336
+ const check = (scopeFields) => {
337
+ if (!Array.isArray(scopeFields))
338
+ return;
339
+ for (const field of scopeFields) {
340
+ const f = field;
341
+ if (f?.type === 'date' && f.list === true) {
342
+ throw new Error(`${scopeLabel}: field "${String(f.name)}" is a date with list: true; use an object list with a date field inside`);
343
+ }
344
+ if (f?.type === 'group' || f?.type === 'object')
345
+ check(f.fields);
346
+ if (f?.type === 'block' && Array.isArray(f.templates)) {
347
+ for (const template of f.templates) {
348
+ check(template.fields);
349
+ }
350
+ }
351
+ }
352
+ };
353
+ check(fields);
354
+ };
330
355
  /**
331
356
  * Check every markdown and mdx field's `mdxAllow` against its schema and its `renderAs`,
332
357
  * `mdxAllow` and `executable` against each other. Throws on the first problem.
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import matter from 'gray-matter';
7
7
  import { parse as yamlParse } from 'yaml';
8
8
  import { findBodyFieldName } from './utils/body-field.js';
9
+ import { entryAsRead } from './utils/calendar-day.js';
9
10
  import { computeEntryUrl } from './utils/entry-url.js';
10
11
  import { asRecord, getFormatExtension } from './utils/format.js';
11
12
  import { resolveCollectionPath, toLogicalCollectionPath } from './content-id-index.js';
@@ -15,7 +16,6 @@ import { isNotFoundError, getErrorMessage } from './utils/error.js';
15
16
  import { createDebugLogger } from './utils/debug.js';
16
17
  import { ContentStore, ContentStoreError, createReferenceResolveCache, } from './content-store.js';
17
18
  import { isBuildMode } from './build-mode.js';
18
- import { withItemKeys } from './item-keys.js';
19
19
  const log = createDebugLogger({ prefix: 'ContentListing' });
20
20
  /** Validate and normalize `target` under `root`; throws ContentStoreError on a traversal. */
21
21
  const normalizePath = (root, target) => {
@@ -444,8 +444,7 @@ export const listCollectionEntries = async (root, collection, onSkip, readsId) =
444
444
  fs.stat(absolutePath),
445
445
  readEntryData(absolutePath, format, bodyField),
446
446
  ]);
447
- // Keyed as `ContentStore.read` keys it, so every reader names an item alike.
448
- const data = withItemKeys(entryType?.schema ?? [], rawData, 'content');
447
+ const data = entryAsRead(entryType?.schema ?? [], rawData);
449
448
  const item = {
450
449
  logicalPath: entryLogicalPath(collection.logicalPath, slug),
451
450
  contentId,
@@ -21,7 +21,7 @@ import { serializeJson } from './utils/json-source-splice.js';
21
21
  import { withLock } from './utils/async-mutex.js';
22
22
  import { assertContentWriteLockHeld, ContentWriteLockBusyError, DEFAULT_CONTENT_WRITE_LOCK_WAIT_MS, withContentWriteLock, } from './utils/content-write-lock.js';
23
23
  import { findBodyFieldName, linkOnlyReferenceData } from './utils/body-field.js';
24
- import { withItemKeys } from './item-keys.js';
24
+ import { entryAsRead } from './utils/calendar-day.js';
25
25
  import { buildMissingReference, buildResolvedReference, buildRestrictedReference, } from './entry-schema.js';
26
26
  import { warnDanglingReference } from './dangling-reference-log.js';
27
27
  import { canopyLogError } from './utils/logger.js';
@@ -786,9 +786,9 @@ export class ContentStore {
786
786
  absolutePath,
787
787
  };
788
788
  }
789
- // Keys an item saved without one by its content, before references resolve into it, as the
790
- // listing and the write path do (`item-keys.ts`).
791
- doc.data = withItemKeys(fields, doc.data, 'content');
789
+ // Before references resolve into it. An item saved without a key is keyed by its content, as
790
+ // the listing and the write path do (`item-keys.ts`).
791
+ doc.data = entryAsRead(fields, doc.data);
792
792
  if (options.resolveReferences !== false) {
793
793
  doc.data = await this.resolveReferencesInData(doc.data, fields, undefined, options.referenceAccess, {
794
794
  entry: slug ? entryLogicalPath(schemaItem.logicalPath, slug) : schemaItem.logicalPath,
@@ -1031,7 +1031,7 @@ export class ContentStore {
1031
1031
  content = serializeYaml(input.data ?? {}, existingRaw);
1032
1032
  }
1033
1033
  else {
1034
- content = serializeFrontmatter(input.body, input.data ?? {}, existingRaw, input.format);
1034
+ content = serializeFrontmatter(input.body, input.data ?? {}, existingRaw, input.format, fields);
1035
1035
  }
1036
1036
  await atomicWriteFile(absolutePath, content);
1037
1037
  // Update the ID index after a successful write. Look up and mutate the LIVE index
@@ -12,6 +12,7 @@ import { TextField } from './fields/TextField.js';
12
12
  import { ToggleField } from './fields/ToggleField.js';
13
13
  import { NumberField } from './fields/NumberField.js';
14
14
  import { NumberListField } from './fields/NumberListField.js';
15
+ import { DateField } from './fields/DateField.js';
15
16
  import { DateTimeField } from './fields/DateTimeField.js';
16
17
  import { BlockField } from './fields/BlockField.js';
17
18
  import { SelectField } from './fields/SelectField.js';
@@ -53,6 +54,8 @@ const normalizeOptions = (options) => {
53
54
  return options.map((opt) => (typeof opt === 'string' ? { label: opt, value: opt } : opt));
54
55
  };
55
56
  const fieldKey = (path) => formatCanopyPath(path);
57
+ /** A value of another type as text, for `DateField` to quote: the form cannot save it. */
58
+ const dateFieldText = (value) => typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value);
56
59
  const noop = () => { };
57
60
  const FENCE_STYLE = { border: 0, padding: 0, margin: 0, minWidth: 0 };
58
61
  const READ_ONLY_FORM_CLASS = 'canopy-read-only-form';
@@ -214,6 +217,8 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
214
217
  : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, maxItems: field.maxItems }, fieldKey(path)));
215
218
  }
216
219
  return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
220
+ case 'date':
221
+ return wrapWithComments(_jsx(DateField, { id: fieldId, label: label, description: field.description, value: dateFieldText(currentValue), onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, required: field.required === true }, fieldKey(path)));
217
222
  case 'datetime':
218
223
  return wrapWithComments(_jsx(DateTimeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, stampOnPublish: 'stampOnPublish' in field && field.stampOnPublish === true }, fieldKey(path)));
219
224
  case 'markdown':
@@ -266,9 +266,10 @@ const Value = ({ value, alt, }) => {
266
266
  };
267
267
  /**
268
268
  * A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
269
- * place the editor shows a date absolute first and relative after.
269
+ * place the editor shows a date absolute first and relative after. A day has no relative form: it
270
+ * may be a future event, and a past one is not hours ago.
270
271
  */
271
- const DateValue = ({ value, text }) => (_jsxs(Stack, { gap: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: text ?? value.text }), _jsx(Text, { size: "xs", c: "dimmed", children: formatRelativeTime(value.dateOnly ? `${value.iso}T00:00:00` : value.iso) })] }));
272
+ const DateValue = ({ value, text }) => (_jsxs(Stack, { gap: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: text ?? value.text }), !value.dateOnly && (_jsx(Text, { size: "xs", c: "dimmed", children: formatRelativeTime(value.iso) }))] }));
272
273
  const SideColumn = ({ label, change, side, }) => {
273
274
  const value = change[side] ?? { kind: 'empty' };
274
275
  const words = change.text?.mode === 'words' ? change.text : undefined;