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.
- package/CHANGELOG.md +79 -47
- package/dist/ai/json-to-markdown.js +2 -0
- package/dist/ai/resolve-branch.js +2 -1
- package/dist/api/branch-entry-changes.js +2 -4
- package/dist/authorization/branch.d.ts +10 -4
- package/dist/authorization/branch.js +24 -4
- package/dist/authorization/types.d.ts +1 -1
- package/dist/cli/cli.js +1984 -1630
- package/dist/cli/configured-asset-store.js +2 -8
- package/dist/cli/generate-ai-content.js +86 -16
- package/dist/cli/init.js +1 -1
- package/dist/cli/site-config.d.ts +9 -0
- package/dist/cli/site-config.js +17 -0
- package/dist/cli/sync.js +226 -74
- package/dist/config/types.d.ts +4 -4
- package/dist/config/types.js +1 -1
- package/dist/config/validation.d.ts +9 -3
- package/dist/config/validation.js +30 -5
- package/dist/content-listing.js +2 -3
- package/dist/content-store.js +5 -5
- package/dist/editor/FormRenderer.js +5 -0
- package/dist/editor/components/EntryChangesDrawer.js +3 -2
- package/dist/editor/entry-diff.js +7 -1
- package/dist/editor/fields/DateField.d.ts +19 -0
- package/dist/editor/fields/DateField.js +53 -0
- package/dist/editor/fields/DateTimeField.d.ts +10 -0
- package/dist/editor/fields/DateTimeField.js +5 -15
- package/dist/editor/fields/list-item-title.js +2 -1
- package/dist/editor/theme.js +12 -4
- package/dist/entry-schema-registry.js +2 -1
- package/dist/services.d.ts +3 -2
- package/dist/static/index.js +5 -2
- package/dist/system-users.d.ts +2 -0
- package/dist/system-users.js +6 -1
- package/dist/utils/calendar-day.d.ts +23 -0
- package/dist/utils/calendar-day.js +102 -0
- package/dist/utils/content-serialize.d.ts +4 -2
- package/dist/utils/content-serialize.js +8 -6
- package/dist/validation/entry-validator.js +7 -1
- package/dist/validation/field-traversal.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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.
|
|
83
|
-
*
|
|
84
|
-
* When autoCreate is true, creates a new workspace
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
167
|
-
if (!
|
|
321
|
+
const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
|
|
322
|
+
if (!ws)
|
|
168
323
|
return { fileCount: 0 };
|
|
169
|
-
const branchPath =
|
|
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
|
|
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: ${
|
|
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
|
|
353
|
-
if (!
|
|
507
|
+
const ws = await selectBranch({ ...options, projectDir, autoCreate: true });
|
|
508
|
+
if (!ws)
|
|
354
509
|
return { pushed: 0, pulled: 0 };
|
|
355
|
-
const branchPath =
|
|
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 {
|
|
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
|
|
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
|
-
|
|
450
|
-
p.log.info(
|
|
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
|
|
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 }
|
|
475
|
-
|
|
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 =
|
|
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;
|
package/dist/config/types.d.ts
CHANGED
|
@@ -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 `
|
|
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
|
*/
|
package/dist/config/types.js
CHANGED
|
@@ -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
|
|
38
|
-
* `number`, the only values the card heading shows as typed (a reference would show its
|
|
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
|
|
114
|
-
* `number`, the only values the card heading shows as typed (a reference would show its
|
|
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
|
|
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.
|
package/dist/content-listing.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/content-store.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
790
|
-
// listing and the write path do (`item-keys.ts`).
|
|
791
|
-
doc.data =
|
|
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.
|
|
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;
|