canopycms 0.12.0 → 0.12.1
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 +164 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.js +45 -13
- package/dist/api/branch-status.d.ts +12 -0
- package/dist/api/branch-status.js +13 -0
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +176 -0
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +1 -0
- package/dist/api/client.d.ts +14 -1
- package/dist/api/client.js +22 -0
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +226 -0
- package/dist/api/merge-conflicts.js +674 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +7 -3
- package/dist/base-update.d.ts +26 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +51 -0
- package/dist/branch-metadata-file.js +11 -0
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9658 -1499
- package/dist/cli/generate-ai-content.js +354 -244
- package/dist/cli/init.js +6 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +429 -52
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/base-update-wait.d.ts +74 -0
- package/dist/editor/base-update-wait.js +91 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +100 -0
- package/dist/editor/components/ConflictsGroup.js +364 -0
- package/dist/editor/components/EditorHeader.d.ts +16 -1
- package/dist/editor/components/EditorHeader.js +92 -30
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +33 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +90 -0
- package/dist/editor/conflict-cards.js +279 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
- package/dist/editor/hooks/useBaseUpdate.js +269 -0
- package/dist/editor/hooks/useBranchManager.d.ts +6 -1
- package/dist/editor/hooks/useBranchManager.js +28 -7
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +5 -0
- package/dist/editor/hooks/useDraftManager.js +24 -8
- package/dist/editor/hooks/useEntryManager.d.ts +9 -0
- package/dist/editor/hooks/useEntryManager.js +5 -0
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +5 -0
- package/dist/git-manager.js +30 -0
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +875 -0
- package/dist/merge/list-alignment.d.ts +52 -0
- package/dist/merge/list-alignment.js +441 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +5 -0
- package/dist/services.d.ts +16 -1
- package/dist/services.js +82 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +28 -2
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +53 -37
- package/dist/worker/branch-merge.js +139 -54
- package/dist/worker/cms-worker.d.ts +16 -3
- package/dist/worker/cms-worker.js +59 -12
- package/dist/worker/field-merge.d.ts +65 -0
- package/dist/worker/field-merge.js +636 -0
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +7 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +287 -47
- package/dist/worker/github-gateway-http.js +17 -9
- package/dist/worker/github-gateway-wire.d.ts +21 -12
- package/dist/worker/github-gateway-wire.js +5 -1
- package/dist/worker/github-gateway.d.ts +10 -1
- package/dist/worker/github-gateway.js +18 -6
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/rebase.d.ts +5 -4
- package/dist/worker/rebase.js +293 -156
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +14 -2
- package/dist/worker/worker-context.d.ts +18 -2
- package/package.json +1 -1
package/dist/api/types.d.ts
CHANGED
|
@@ -58,7 +58,8 @@ export interface ApiResponse<TData = unknown> {
|
|
|
58
58
|
* `WRITE_OUTCOME_UNKNOWN`: an entry save ran but lost its branch lock mid-write, so it may have
|
|
59
59
|
* landed (a 409); the version the caller holds may be stale, so it must re-read before retrying.
|
|
60
60
|
* `ENTRY_GONE`: an entry update found no file, because the entry was deleted or moved since the
|
|
61
|
-
* caller read it (a 409); it was not saved, and only a create can bring the entry back.
|
|
61
|
+
* caller read it (a 409); it was not saved, and only a create can bring the entry back. A
|
|
62
|
+
* resolve-entry answers it too, for an entry no longer where its conflict was shown.
|
|
62
63
|
* `EDITOR_MODE_MISMATCH`: the editor bundle was built for the other operating mode than the
|
|
63
64
|
* server runs (a 412, see operating-mode/editor-mode-check.ts); only a rebuild fixes it.
|
|
64
65
|
* `SETTINGS_CHANGED`: a groups or permissions save named a version the file has moved on from (a
|
|
@@ -66,6 +67,9 @@ export interface ApiResponse<TData = unknown> {
|
|
|
66
67
|
* The other settings 409, a busy file lock, carries no code. `BRANCH_SYNCING`: an entry save was
|
|
67
68
|
* refused because the branch is mid-rebase (a 409); nothing was saved, and a retry soon can pass.
|
|
68
69
|
* `BRANCH_NAME_REFUSED`: a branch create was refused for the name it gave (a 400 or 409), so only
|
|
69
|
-
* another name can pass; no other create failure carries it.
|
|
70
|
+
* another name can pass; no other create failure carries it. `OPEN_CONFLICTS`: a submit or approval
|
|
71
|
+
* was refused while a merge's conflicts are open on the branch (a 409, merge-conflicts.ts).
|
|
72
|
+
* `CONFLICT_CHANGED`: a resolve-entry named a conflict the record no longer holds as shown (a 409);
|
|
73
|
+
* re-read the branch's conflicts.
|
|
70
74
|
*/
|
|
71
|
-
export type ApiErrorCode = 'SCHEMA_UNAVAILABLE' | 'WORKER_FAILED' | 'WRITE_OUTCOME_UNKNOWN' | 'ENTRY_GONE' | 'EDITOR_MODE_MISMATCH' | 'SETTINGS_CHANGED' | 'BRANCH_SYNCING' | 'BRANCH_NAME_REFUSED';
|
|
75
|
+
export type ApiErrorCode = 'SCHEMA_UNAVAILABLE' | 'WORKER_FAILED' | 'WRITE_OUTCOME_UNKNOWN' | 'ENTRY_GONE' | 'EDITOR_MODE_MISMATCH' | 'SETTINGS_CHANGED' | 'BRANCH_SYNCING' | 'BRANCH_NAME_REFUSED' | 'OPEN_CONFLICTS' | 'CONFLICT_CHANGED';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { BranchMetadata } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Asking for the latest changes: the API records the request on branch.json and queues this action
|
|
4
|
+
* (api/branch-update-from-base.ts), the worker's sync answers it in `lastBaseUpdate`
|
|
5
|
+
* (worker/rebase.ts), and the editor waits while it stands. Dependency-free, so all three share it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SYNC_BRANCH_ACTION = "sync-branch";
|
|
8
|
+
/**
|
|
9
|
+
* How long a request, or an answer that didn't fail, stands for the next request on the branch,
|
|
10
|
+
* which joins it instead (api/branch-update-from-base.ts).
|
|
11
|
+
*/
|
|
12
|
+
export declare const BASE_UPDATE_REQUEST_INTERVAL_MS = 30000;
|
|
13
|
+
/** How long the editor waits on a request, polling for its answer. */
|
|
14
|
+
export declare const BASE_UPDATE_GIVE_UP_MS: number;
|
|
15
|
+
/**
|
|
16
|
+
* How long the worker asks itself for early cycles while a request goes unanswered; its regular
|
|
17
|
+
* cycles answer it after that. Each cycle reads every branch in review from GitHub, so it is short.
|
|
18
|
+
*/
|
|
19
|
+
export declare const BASE_UPDATE_EARLY_RETRY_MS: number;
|
|
20
|
+
/**
|
|
21
|
+
* How far in the future a request time may lie and still count: the Lambdas' clocks keep NTP time,
|
|
22
|
+
* so a later one is corrupt and is never waited on.
|
|
23
|
+
*/
|
|
24
|
+
export declare const BASE_UPDATE_CLOCK_SKEW_MS = 60000;
|
|
25
|
+
/** Whether the branch's latest request for the latest changes is still unanswered. */
|
|
26
|
+
export declare function isBaseUpdatePending(branch: Pick<BranchMetadata, 'baseUpdateRequestedAt' | 'lastBaseUpdate'>): boolean;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asking for the latest changes: the API records the request on branch.json and queues this action
|
|
3
|
+
* (api/branch-update-from-base.ts), the worker's sync answers it in `lastBaseUpdate`
|
|
4
|
+
* (worker/rebase.ts), and the editor waits while it stands. Dependency-free, so all three share it.
|
|
5
|
+
*/
|
|
6
|
+
export const SYNC_BRANCH_ACTION = 'sync-branch';
|
|
7
|
+
/**
|
|
8
|
+
* How long a request, or an answer that didn't fail, stands for the next request on the branch,
|
|
9
|
+
* which joins it instead (api/branch-update-from-base.ts).
|
|
10
|
+
*/
|
|
11
|
+
export const BASE_UPDATE_REQUEST_INTERVAL_MS = 30_000;
|
|
12
|
+
/** How long the editor waits on a request, polling for its answer. */
|
|
13
|
+
export const BASE_UPDATE_GIVE_UP_MS = 10 * 60_000;
|
|
14
|
+
/**
|
|
15
|
+
* How long the worker asks itself for early cycles while a request goes unanswered; its regular
|
|
16
|
+
* cycles answer it after that. Each cycle reads every branch in review from GitHub, so it is short.
|
|
17
|
+
*/
|
|
18
|
+
export const BASE_UPDATE_EARLY_RETRY_MS = 2 * 60_000;
|
|
19
|
+
/**
|
|
20
|
+
* How far in the future a request time may lie and still count: the Lambdas' clocks keep NTP time,
|
|
21
|
+
* so a later one is corrupt and is never waited on.
|
|
22
|
+
*/
|
|
23
|
+
export const BASE_UPDATE_CLOCK_SKEW_MS = 60_000;
|
|
24
|
+
/** Whether the branch's latest request for the latest changes is still unanswered. */
|
|
25
|
+
export function isBaseUpdatePending(branch) {
|
|
26
|
+
const requestedAt = branch.baseUpdateRequestedAt;
|
|
27
|
+
if (requestedAt === undefined)
|
|
28
|
+
return false;
|
|
29
|
+
const answeredAt = branch.lastBaseUpdate?.at;
|
|
30
|
+
return answeredAt === undefined || Date.parse(answeredAt) < Date.parse(requestedAt);
|
|
31
|
+
}
|
|
@@ -136,6 +136,23 @@ export declare const branchMetadataSchema: z.ZodObject<{
|
|
|
136
136
|
name?: string | undefined;
|
|
137
137
|
};
|
|
138
138
|
}>>>;
|
|
139
|
+
baseUpdateRequestedAt: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
140
|
+
lastBaseUpdate: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
141
|
+
at: z.ZodString;
|
|
142
|
+
outcome: z.ZodEnum<["merged", "up-to-date", "conflicts", "failed"]>;
|
|
143
|
+
baseSha: z.ZodOptional<z.ZodString>;
|
|
144
|
+
keptEntries: z.ZodOptional<z.ZodNumber>;
|
|
145
|
+
}, "strip", z.ZodTypeAny, {
|
|
146
|
+
at: string;
|
|
147
|
+
outcome: "merged" | "conflicts" | "up-to-date" | "failed";
|
|
148
|
+
baseSha?: string | undefined;
|
|
149
|
+
keptEntries?: number | undefined;
|
|
150
|
+
}, {
|
|
151
|
+
at: string;
|
|
152
|
+
outcome: "merged" | "conflicts" | "up-to-date" | "failed";
|
|
153
|
+
baseSha?: string | undefined;
|
|
154
|
+
keptEntries?: number | undefined;
|
|
155
|
+
}>>>;
|
|
139
156
|
}, "passthrough", z.ZodTypeAny, z.objectOutputType<{
|
|
140
157
|
name: z.ZodString;
|
|
141
158
|
title: z.ZodOptional<z.ZodString>;
|
|
@@ -252,6 +269,23 @@ export declare const branchMetadataSchema: z.ZodObject<{
|
|
|
252
269
|
name?: string | undefined;
|
|
253
270
|
};
|
|
254
271
|
}>>>;
|
|
272
|
+
baseUpdateRequestedAt: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
273
|
+
lastBaseUpdate: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
274
|
+
at: z.ZodString;
|
|
275
|
+
outcome: z.ZodEnum<["merged", "up-to-date", "conflicts", "failed"]>;
|
|
276
|
+
baseSha: z.ZodOptional<z.ZodString>;
|
|
277
|
+
keptEntries: z.ZodOptional<z.ZodNumber>;
|
|
278
|
+
}, "strip", z.ZodTypeAny, {
|
|
279
|
+
at: string;
|
|
280
|
+
outcome: "merged" | "conflicts" | "up-to-date" | "failed";
|
|
281
|
+
baseSha?: string | undefined;
|
|
282
|
+
keptEntries?: number | undefined;
|
|
283
|
+
}, {
|
|
284
|
+
at: string;
|
|
285
|
+
outcome: "merged" | "conflicts" | "up-to-date" | "failed";
|
|
286
|
+
baseSha?: string | undefined;
|
|
287
|
+
keptEntries?: number | undefined;
|
|
288
|
+
}>>>;
|
|
255
289
|
}, z.ZodTypeAny, "passthrough">, z.objectInputType<{
|
|
256
290
|
name: z.ZodString;
|
|
257
291
|
title: z.ZodOptional<z.ZodString>;
|
|
@@ -368,6 +402,23 @@ export declare const branchMetadataSchema: z.ZodObject<{
|
|
|
368
402
|
name?: string | undefined;
|
|
369
403
|
};
|
|
370
404
|
}>>>;
|
|
405
|
+
baseUpdateRequestedAt: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
406
|
+
lastBaseUpdate: z.ZodCatch<z.ZodOptional<z.ZodObject<{
|
|
407
|
+
at: z.ZodString;
|
|
408
|
+
outcome: z.ZodEnum<["merged", "up-to-date", "conflicts", "failed"]>;
|
|
409
|
+
baseSha: z.ZodOptional<z.ZodString>;
|
|
410
|
+
keptEntries: z.ZodOptional<z.ZodNumber>;
|
|
411
|
+
}, "strip", z.ZodTypeAny, {
|
|
412
|
+
at: string;
|
|
413
|
+
outcome: "merged" | "conflicts" | "up-to-date" | "failed";
|
|
414
|
+
baseSha?: string | undefined;
|
|
415
|
+
keptEntries?: number | undefined;
|
|
416
|
+
}, {
|
|
417
|
+
at: string;
|
|
418
|
+
outcome: "merged" | "conflicts" | "up-to-date" | "failed";
|
|
419
|
+
baseSha?: string | undefined;
|
|
420
|
+
keptEntries?: number | undefined;
|
|
421
|
+
}>>>;
|
|
371
422
|
}, z.ZodTypeAny, "passthrough">>;
|
|
372
423
|
/**
|
|
373
424
|
* Read, parse and validate `branch.json`, with no locking, no OCC and no side
|
|
@@ -108,6 +108,17 @@ const branchMetadataShape = {
|
|
|
108
108
|
})
|
|
109
109
|
.optional()
|
|
110
110
|
.catch(undefined),
|
|
111
|
+
baseUpdateRequestedAt: z.string().optional().catch(undefined),
|
|
112
|
+
// Dropped when malformed: it only ends the editor's wait, which then times out instead.
|
|
113
|
+
lastBaseUpdate: z
|
|
114
|
+
.object({
|
|
115
|
+
at: z.string(),
|
|
116
|
+
outcome: z.enum(['merged', 'up-to-date', 'conflicts', 'failed']),
|
|
117
|
+
baseSha: z.string().optional(),
|
|
118
|
+
keptEntries: z.number().int().nonnegative().optional(),
|
|
119
|
+
})
|
|
120
|
+
.optional()
|
|
121
|
+
.catch(undefined),
|
|
111
122
|
};
|
|
112
123
|
/** The `branch` object, also embedded in the registry snapshot (branch-registry.ts). */
|
|
113
124
|
export const branchMetadataSchema = z.object(branchMetadataShape).passthrough();
|
|
@@ -2,6 +2,11 @@ import type { RootCollectionConfig } from './config/index.js';
|
|
|
2
2
|
import type { FlatSchemaItem } from './config/types.js';
|
|
3
3
|
import type { OperatingMode } from './operating-mode/index.js';
|
|
4
4
|
import type { EntrySchemaRegistry, SchemaIssue, SchemaResolutionResult, UnknownSchemaPolicy } from './schema/types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Bump when BranchSchemaCacheEntry shape changes to auto-invalidate stale caches.
|
|
7
|
+
* @internal Exported for tests.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SCHEMA_CACHE_VERSION = 4;
|
|
5
10
|
/** File name of the schema cache inside {@link schemaCacheDir}. */
|
|
6
11
|
export declare const SCHEMA_CACHE_FILE = "schema-cache.json";
|
|
7
12
|
/** resource-generation.ts resource key for the schema cache's marker. */
|
|
@@ -32,6 +37,13 @@ export interface ResolvedBranchSchema {
|
|
|
32
37
|
flatSchema: FlatSchemaItem[];
|
|
33
38
|
issues: SchemaIssue[];
|
|
34
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The entry types and collections of a branch's last persisted schema snapshot, for a reader with
|
|
42
|
+
* no entry schema registry to resolve one: the worker's merge, which names conflicts by them.
|
|
43
|
+
* Undefined when there is no snapshot of this version. Its freshness is not checked, so it can lag
|
|
44
|
+
* the tree.
|
|
45
|
+
*/
|
|
46
|
+
export declare function readSchemaSnapshot(branchRoot: string): Promise<readonly FlatSchemaItem[] | undefined>;
|
|
35
47
|
/**
|
|
36
48
|
* Per-branch schema cache: one file in {@link schemaCacheDir}, the only state
|
|
37
49
|
* shared across processes. Each process keeps the entries it parsed or wrote,
|
|
@@ -10,8 +10,11 @@ import { bumpResourceGeneration, readResourceGeneration, isGenerationCurrent, }
|
|
|
10
10
|
import { timeRequestPhase } from './utils/request-timing.js';
|
|
11
11
|
import { CANOPY_META_DIR } from './utils/git.js';
|
|
12
12
|
import { canopyLogWarn } from './utils/logger.js';
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Bump when BranchSchemaCacheEntry shape changes to auto-invalidate stale caches.
|
|
15
|
+
* @internal Exported for tests.
|
|
16
|
+
*/
|
|
17
|
+
export const SCHEMA_CACHE_VERSION = 4;
|
|
15
18
|
/** Minimum interval between mtime staleness checks (ms) */
|
|
16
19
|
const MTIME_CHECK_DEBOUNCE_MS = 1000;
|
|
17
20
|
/** Cap on the parsed snapshots one process keeps, counted in bytes of their files. */
|
|
@@ -89,6 +92,28 @@ async function schemaCacheDir(branchRoot) {
|
|
|
89
92
|
const isClone = await fs.stat(gitDir).then((stat) => stat.isDirectory(), () => false);
|
|
90
93
|
return isClone ? path.join(gitDir, 'canopycms') : path.join(branchRoot, CANOPY_META_DIR);
|
|
91
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The entry types and collections of a branch's last persisted schema snapshot, for a reader with
|
|
97
|
+
* no entry schema registry to resolve one: the worker's merge, which names conflicts by them.
|
|
98
|
+
* Undefined when there is no snapshot of this version. Its freshness is not checked, so it can lag
|
|
99
|
+
* the tree.
|
|
100
|
+
*/
|
|
101
|
+
export async function readSchemaSnapshot(branchRoot) {
|
|
102
|
+
try {
|
|
103
|
+
const cachePath = path.join(await schemaCacheDir(branchRoot), SCHEMA_CACHE_FILE);
|
|
104
|
+
const entry = JSON.parse(await fs.readFile(cachePath, 'utf-8'));
|
|
105
|
+
if (typeof entry !== 'object' || entry === null)
|
|
106
|
+
return undefined;
|
|
107
|
+
if (!('version' in entry) || entry.version !== SCHEMA_CACHE_VERSION)
|
|
108
|
+
return undefined;
|
|
109
|
+
if (!('flatSchema' in entry) || !Array.isArray(entry.flatSchema))
|
|
110
|
+
return undefined;
|
|
111
|
+
return entry.flatSchema;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
92
117
|
/**
|
|
93
118
|
* Per-branch schema cache: one file in {@link schemaCacheDir}, the only state
|
|
94
119
|
* shared across processes. Each process keeps the entries it parsed or wrote,
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: npx canopycms backfill-item-keys [--check] [--force] [--app-dir <dir>]
|
|
3
|
+
*
|
|
4
|
+
* Writes the `_key` of every object-list item and block (`item-keys.ts`) into the site's content,
|
|
5
|
+
* so an editor's first save of an entry adds no key lines and a merge sees only real edits.
|
|
6
|
+
*
|
|
7
|
+
* Each entry goes through the editor's own save path, with nothing re-derived:
|
|
8
|
+
* `ContentStore.read` keys the items exactly as the editor's form holds them, and
|
|
9
|
+
* `ContentStore.write` writes that data back with the read's version, through the serialiser,
|
|
10
|
+
* content-write lock and version check a save uses, after the content API's own
|
|
11
|
+
* `entryDataAsSaved`. So a written file is byte for byte what an editor save of the unedited entry
|
|
12
|
+
* leaves, a second run finds every item keyed and writes nothing, and the next editor save adds no
|
|
13
|
+
* lines of its own, a CRLF markdown file aside (safeinsights/canopycms#1234).
|
|
14
|
+
*
|
|
15
|
+
* A file is written only when its items lack keys. The write then also carries whatever else an
|
|
16
|
+
* unedited save normalises (a one-line JSON file comes out pretty-printed, a stray frontmatter key
|
|
17
|
+
* named as the body goes), and those files are listed apart for review. Out of scope, as for the editor: list fields the schema does not
|
|
18
|
+
* describe, `.collection.json`, and entries of a type this code's registry lacks.
|
|
19
|
+
*/
|
|
20
|
+
import type { FlatSchemaItem } from '../config/index.js';
|
|
21
|
+
/** One entry file the backfill keys. */
|
|
22
|
+
interface BackfillFileResult {
|
|
23
|
+
/** Path relative to the project root. */
|
|
24
|
+
path: string;
|
|
25
|
+
/** Items and blocks whose file text gives them no usable key, or one an earlier item holds. */
|
|
26
|
+
itemsKeyed: number;
|
|
27
|
+
/** Lines the write changes besides adding `_key` lines: what else an unedited save normalises. */
|
|
28
|
+
otherLines: number;
|
|
29
|
+
}
|
|
30
|
+
interface BackfillResult {
|
|
31
|
+
/** Files written, or with `check`, files a run would write; by path. */
|
|
32
|
+
changed: BackfillFileResult[];
|
|
33
|
+
/** Entries that could not be read or written, and collections that could not be listed. */
|
|
34
|
+
failed: {
|
|
35
|
+
path: string;
|
|
36
|
+
error: string;
|
|
37
|
+
}[];
|
|
38
|
+
/** Entries left alone because the registry has no schema for their entry type. */
|
|
39
|
+
unavailable: string[];
|
|
40
|
+
/** Files in a collection that look like entries but whose names don't parse as one. */
|
|
41
|
+
malformed: string[];
|
|
42
|
+
}
|
|
43
|
+
interface BackfillOptions {
|
|
44
|
+
/** The project root: the directory holding the content root. */
|
|
45
|
+
root: string;
|
|
46
|
+
flatSchema: FlatSchemaItem[];
|
|
47
|
+
contentRootName: string;
|
|
48
|
+
/** Report what a run would write, and write nothing. */
|
|
49
|
+
check: boolean;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Keys every entry's items under `root`, or with `check`, reports which files a run would write.
|
|
53
|
+
* @internal Exported for tests.
|
|
54
|
+
*/
|
|
55
|
+
export declare function backfillItemKeys(options: BackfillOptions): Promise<BackfillResult>;
|
|
56
|
+
export interface BackfillItemKeysCLIOptions {
|
|
57
|
+
projectDir: string;
|
|
58
|
+
/** Directory holding `schemas.ts`, relative to the project. Default `app`. */
|
|
59
|
+
appDir?: string;
|
|
60
|
+
check?: boolean;
|
|
61
|
+
/** Write even when the content tree has uncommitted changes. */
|
|
62
|
+
force?: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Exit codes. `--check`: 0 when no file needs keys, 1 when some would change. A run: 0 when done,
|
|
66
|
+
* 1 when it would not write: uncommitted content changes, or no git to ask. Both: 2 when the
|
|
67
|
+
* site's config or schema could not be loaded, a collection listed, or an entry read or written.
|
|
68
|
+
* @internal Exported for tests.
|
|
69
|
+
*/
|
|
70
|
+
export declare const BACKFILL_EXIT_CODES: {
|
|
71
|
+
readonly ok: 0;
|
|
72
|
+
readonly changesOrRefused: 1;
|
|
73
|
+
readonly loadError: 2;
|
|
74
|
+
};
|
|
75
|
+
/** Runs the command and returns its exit code. */
|
|
76
|
+
export declare function backfillItemKeysCLI(options: BackfillItemKeysCLIOptions): Promise<number>;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: npx canopycms backfill-item-keys [--check] [--force] [--app-dir <dir>]
|
|
3
|
+
*
|
|
4
|
+
* Writes the `_key` of every object-list item and block (`item-keys.ts`) into the site's content,
|
|
5
|
+
* so an editor's first save of an entry adds no key lines and a merge sees only real edits.
|
|
6
|
+
*
|
|
7
|
+
* Each entry goes through the editor's own save path, with nothing re-derived:
|
|
8
|
+
* `ContentStore.read` keys the items exactly as the editor's form holds them, and
|
|
9
|
+
* `ContentStore.write` writes that data back with the read's version, through the serialiser,
|
|
10
|
+
* content-write lock and version check a save uses, after the content API's own
|
|
11
|
+
* `entryDataAsSaved`. So a written file is byte for byte what an editor save of the unedited entry
|
|
12
|
+
* leaves, a second run finds every item keyed and writes nothing, and the next editor save adds no
|
|
13
|
+
* lines of its own, a CRLF markdown file aside (safeinsights/canopycms#1234).
|
|
14
|
+
*
|
|
15
|
+
* A file is written only when its items lack keys. The write then also carries whatever else an
|
|
16
|
+
* unedited save normalises (a one-line JSON file comes out pretty-printed, a stray frontmatter key
|
|
17
|
+
* named as the body goes), and those files are listed apart for review. Out of scope, as for the editor: list fields the schema does not
|
|
18
|
+
* describe, `.collection.json`, and entries of a type this code's registry lacks.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs/promises';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { diffArrays } from 'diff';
|
|
23
|
+
import { simpleGit } from 'simple-git';
|
|
24
|
+
import { flattenSchema } from '../config/index.js';
|
|
25
|
+
import { contentIndexGenerationPath } from '../content-index-generation.js';
|
|
26
|
+
import { listCollectionEntries, parseEntryParts, } from '../content-listing.js';
|
|
27
|
+
import { entryDataAsSaved } from '../api/content.js';
|
|
28
|
+
import { ContentStore } from '../content-store.js';
|
|
29
|
+
import { ITEM_KEY, withItemKeys } from '../item-keys.js';
|
|
30
|
+
import { isValidSchema, resolveSchema } from '../schema/index.js';
|
|
31
|
+
import { serializeEntry } from '../utils/content-serialize.js';
|
|
32
|
+
import { getErrorMessage, isNodeError } from '../utils/error.js';
|
|
33
|
+
import { filePathExists } from '../utils/fs.js';
|
|
34
|
+
import { CANOPY_META_DIR } from '../utils/git.js';
|
|
35
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
36
|
+
import { importSiteEntrySchemaRegistry, importSiteServerConfig } from './site-config.js';
|
|
37
|
+
/**
|
|
38
|
+
* Keys every entry's items under `root`, or with `check`, reports which files a run would write.
|
|
39
|
+
* @internal Exported for tests.
|
|
40
|
+
*/
|
|
41
|
+
export async function backfillItemKeys(options) {
|
|
42
|
+
const { root, flatSchema, contentRootName, check } = options;
|
|
43
|
+
const store = new ContentStore(root, flatSchema, { contentRootName });
|
|
44
|
+
const result = { changed: [], failed: [], unavailable: [], malformed: [] };
|
|
45
|
+
const collections = flatSchema.filter((item) => item.type === 'collection' && !!item.entries);
|
|
46
|
+
for (const collection of collections) {
|
|
47
|
+
const entryTypes = collection.entries ?? [];
|
|
48
|
+
let listed;
|
|
49
|
+
try {
|
|
50
|
+
listed = await listCollectionEntries(root, collection, (file) => result.malformed.push(path.posix.join(file.collectionPath, file.filename)));
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
result.failed.push({ path: `${collection.logicalPath}/`, error: getErrorMessage(err) });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const item of listed) {
|
|
57
|
+
const entryType = entryTypes.find((type) => type.name === item.entryType);
|
|
58
|
+
if (!entryType || entryType.unavailable) {
|
|
59
|
+
result.unavailable.push(item.physicalPath);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const changed = await backfillEntry(store, root, item, entryType.schema, check);
|
|
64
|
+
if (changed)
|
|
65
|
+
result.changed.push({ path: item.physicalPath, ...changed });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
result.failed.push({ path: item.physicalPath, error: getErrorMessage(err) });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const byPath = (a, b) => a.path.localeCompare(b.path);
|
|
73
|
+
result.changed.sort(byPath);
|
|
74
|
+
result.failed.sort(byPath);
|
|
75
|
+
result.unavailable.sort();
|
|
76
|
+
result.malformed.sort();
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
/** The entry's counts when its items lack keys, after writing it unless `check`; else null. */
|
|
80
|
+
async function backfillEntry(store, root, item, fields, check) {
|
|
81
|
+
const doc = await store.read(item.collectionPath, item.slug, { resolveReferences: false });
|
|
82
|
+
// The store finds a slug's file by name alone, so a slug another file in the collection also
|
|
83
|
+
// carries (another entry type's, or a stray copy) addresses only one of them.
|
|
84
|
+
if (doc.absolutePath !== path.resolve(root, item.physicalPath)) {
|
|
85
|
+
throw new Error(`Another file in its collection has the slug "${item.slug}", so neither a save nor the ` +
|
|
86
|
+
'backfill can address this one. Rename or remove one of them.');
|
|
87
|
+
}
|
|
88
|
+
// After the read: its version was taken before its own read of the file, so a change landing
|
|
89
|
+
// between the two fails the write's version check rather than writing over it.
|
|
90
|
+
const before = await fs.readFile(doc.absolutePath, 'utf8');
|
|
91
|
+
const asWritten = parseEntryParts(before, doc.format).data;
|
|
92
|
+
const keyed = withItemKeys(fields, asWritten, 'content');
|
|
93
|
+
if (keyed === asWritten)
|
|
94
|
+
return null;
|
|
95
|
+
const entry = savedEntry(doc, fields);
|
|
96
|
+
let after;
|
|
97
|
+
if (check) {
|
|
98
|
+
after = serializeEntry(entry, before, fields);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
if (doc.version === undefined)
|
|
102
|
+
throw new Error('The read carried no version to write against.');
|
|
103
|
+
await store.write(item.collectionPath, item.slug, { ...entry, expectedVersion: doc.version }, item.entryType);
|
|
104
|
+
after = await fs.readFile(doc.absolutePath, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
itemsKeyed: countAddedKeys(keyed, asWritten),
|
|
108
|
+
otherLines: countOtherLines(before, after),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** What a save of the entry, opened and saved unedited, hands the serialiser. */
|
|
112
|
+
function savedEntry(doc, fields) {
|
|
113
|
+
const data = entryDataAsSaved(fields, doc.format, doc.data);
|
|
114
|
+
if (doc.format === 'json')
|
|
115
|
+
return { format: 'json', data };
|
|
116
|
+
if (doc.format === 'yaml')
|
|
117
|
+
return { format: 'yaml', data };
|
|
118
|
+
return { format: doc.format, data, body: doc.body };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The `_key`s `keyed` holds that `asWritten` lacks or holds differently. `withItemKeys` returns
|
|
122
|
+
* every subtree it left alone as the same object, so the walk skips those.
|
|
123
|
+
*/
|
|
124
|
+
function countAddedKeys(keyed, asWritten) {
|
|
125
|
+
if (keyed === asWritten)
|
|
126
|
+
return 0;
|
|
127
|
+
if (Array.isArray(keyed)) {
|
|
128
|
+
const was = Array.isArray(asWritten) ? asWritten : [];
|
|
129
|
+
return keyed.reduce((n, value, i) => n + countAddedKeys(value, was[i]), 0);
|
|
130
|
+
}
|
|
131
|
+
if (!isPlainRecord(keyed))
|
|
132
|
+
return 0;
|
|
133
|
+
const was = isPlainRecord(asWritten) ? asWritten : {};
|
|
134
|
+
let count = ITEM_KEY in keyed && keyed[ITEM_KEY] !== was[ITEM_KEY] ? 1 : 0;
|
|
135
|
+
for (const [name, value] of Object.entries(keyed)) {
|
|
136
|
+
if (name !== ITEM_KEY)
|
|
137
|
+
count += countAddedKeys(value, was[name]);
|
|
138
|
+
}
|
|
139
|
+
return count;
|
|
140
|
+
}
|
|
141
|
+
/** A `_key` line as YAML (frontmatter included) or JSON spells it, its trailing comma trimmed. */
|
|
142
|
+
function isKeyLine(line) {
|
|
143
|
+
const text = line.trim().replace(/^-\s/, '').trimStart();
|
|
144
|
+
for (const name of ['_key:', '"_key":']) {
|
|
145
|
+
if (text.startsWith(name))
|
|
146
|
+
return /^\s*\S+$/.test(text.slice(name.length));
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Lines changed between `before` and `after` other than added `_key` lines, a changed line ending
|
|
152
|
+
* included. A trailing comma is ignored, since a JSON key appended to an object adds one to the
|
|
153
|
+
* line above it.
|
|
154
|
+
*/
|
|
155
|
+
function countOtherLines(before, after) {
|
|
156
|
+
const lines = (text) => text.split('\n').map((line) => line.replace(/,(\r?)$/, '$1'));
|
|
157
|
+
let other = 0;
|
|
158
|
+
for (const change of diffArrays(lines(before), lines(after))) {
|
|
159
|
+
if (change.removed)
|
|
160
|
+
other += change.value.length;
|
|
161
|
+
else if (change.added)
|
|
162
|
+
other += change.value.filter((line) => !isKeyLine(line)).length;
|
|
163
|
+
}
|
|
164
|
+
return other;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Exit codes. `--check`: 0 when no file needs keys, 1 when some would change. A run: 0 when done,
|
|
168
|
+
* 1 when it would not write: uncommitted content changes, or no git to ask. Both: 2 when the
|
|
169
|
+
* site's config or schema could not be loaded, a collection listed, or an entry read or written.
|
|
170
|
+
* @internal Exported for tests.
|
|
171
|
+
*/
|
|
172
|
+
export const BACKFILL_EXIT_CODES = { ok: 0, changesOrRefused: 1, loadError: 2 };
|
|
173
|
+
/** Runs the command and returns its exit code. */
|
|
174
|
+
export async function backfillItemKeysCLI(options) {
|
|
175
|
+
const { projectDir, appDir = 'app', check = false, force = false } = options;
|
|
176
|
+
console.log('\nCanopyCMS backfill-item-keys\n');
|
|
177
|
+
let contentRootName;
|
|
178
|
+
let registry;
|
|
179
|
+
try {
|
|
180
|
+
const { server } = await importSiteServerConfig(projectDir);
|
|
181
|
+
const contentRoot = isPlainRecord(server) ? (server.contentRoot ?? 'content') : null;
|
|
182
|
+
if (typeof contentRoot !== 'string') {
|
|
183
|
+
throw new Error('canopycms.config.ts does not export a config made by defineCanopyConfig().');
|
|
184
|
+
}
|
|
185
|
+
contentRootName = contentRoot;
|
|
186
|
+
registry = (await importSiteEntrySchemaRegistry(projectDir, appDir)).registry;
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
console.error(`Could not load the site: ${getErrorMessage(err)}`);
|
|
190
|
+
return BACKFILL_EXIT_CODES.loadError;
|
|
191
|
+
}
|
|
192
|
+
let flatSchema;
|
|
193
|
+
try {
|
|
194
|
+
const contentDir = path.join(projectDir, contentRootName);
|
|
195
|
+
const resolved = await resolveSchema(contentDir, registry, {
|
|
196
|
+
unknownSchema: 'degrade',
|
|
197
|
+
});
|
|
198
|
+
if (!isValidSchema(resolved.schema)) {
|
|
199
|
+
throw new Error(`No .collection.json files describe ${contentRootName}/.`);
|
|
200
|
+
}
|
|
201
|
+
for (const issue of resolved.issues)
|
|
202
|
+
console.warn(` Warning: ${issue.message}`);
|
|
203
|
+
flatSchema = flattenSchema(resolved.schema, contentRootName);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
console.error(`Could not load the schema: ${getErrorMessage(err)}`);
|
|
207
|
+
return BACKFILL_EXIT_CODES.loadError;
|
|
208
|
+
}
|
|
209
|
+
if (!check && !force) {
|
|
210
|
+
const refusal = await uncommittedContent(projectDir, contentRootName);
|
|
211
|
+
if (refusal) {
|
|
212
|
+
console.error(refusal);
|
|
213
|
+
return BACKFILL_EXIT_CODES.changesOrRefused;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const metaDir = path.join(projectDir, CANOPY_META_DIR);
|
|
217
|
+
const metaDirExisted = await filePathExists(metaDir);
|
|
218
|
+
let result;
|
|
219
|
+
try {
|
|
220
|
+
result = await backfillItemKeys({ root: projectDir, flatSchema, contentRootName, check });
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
console.error(`Could not finish: ${getErrorMessage(err)}` +
|
|
224
|
+
(check ? '' : '\nFiles keyed before the error stay written; finish with --force.'));
|
|
225
|
+
return BACKFILL_EXIT_CODES.loadError;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
// The marker tells other processes to rebuild their ID index, and the backfill moves no file,
|
|
229
|
+
// so no index goes stale. An adopter's .gitignore need not cover `.canopy-meta/`, so at a
|
|
230
|
+
// project root that had none, remove what this run created there.
|
|
231
|
+
if (!check && !metaDirExisted)
|
|
232
|
+
await removeOwnMetaDir(projectDir, metaDir);
|
|
233
|
+
}
|
|
234
|
+
printResult(result, check);
|
|
235
|
+
if (result.failed.length > 0)
|
|
236
|
+
return BACKFILL_EXIT_CODES.loadError;
|
|
237
|
+
if (check && result.changed.length > 0)
|
|
238
|
+
return BACKFILL_EXIT_CODES.changesOrRefused;
|
|
239
|
+
return BACKFILL_EXIT_CODES.ok;
|
|
240
|
+
}
|
|
241
|
+
/** Why a run must not write: the content tree has uncommitted changes, or no git to ask. */
|
|
242
|
+
async function uncommittedContent(projectDir, contentRootName) {
|
|
243
|
+
let status;
|
|
244
|
+
try {
|
|
245
|
+
status = await simpleGit({ baseDir: projectDir }).raw([
|
|
246
|
+
'status',
|
|
247
|
+
'--porcelain',
|
|
248
|
+
'--untracked-files=all',
|
|
249
|
+
'--',
|
|
250
|
+
contentRootName,
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
return (`Could not ask git whether ${contentRootName}/ has uncommitted changes ` +
|
|
255
|
+
`(${getErrorMessage(err)}). Run inside the site's git checkout, or pass --force.`);
|
|
256
|
+
}
|
|
257
|
+
const dirty = status.split('\n').filter((line) => line.trim() !== '');
|
|
258
|
+
if (dirty.length === 0)
|
|
259
|
+
return null;
|
|
260
|
+
const shown = dirty.slice(0, 10).map((line) => ` ${line}`);
|
|
261
|
+
if (dirty.length > shown.length)
|
|
262
|
+
shown.push(` … and ${dirty.length - shown.length} more`);
|
|
263
|
+
return (`${contentRootName}/ has uncommitted changes, so the backfill's own changes could not be ` +
|
|
264
|
+
`reviewed apart from them:\n${shown.join('\n')}\n` +
|
|
265
|
+
'Commit or stash them first, or pass --force.');
|
|
266
|
+
}
|
|
267
|
+
async function removeOwnMetaDir(projectDir, metaDir) {
|
|
268
|
+
try {
|
|
269
|
+
await fs.rm(contentIndexGenerationPath(projectDir), { force: true });
|
|
270
|
+
await fs.rmdir(metaDir);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
if (isNodeError(err) && err.code === 'ENOENT')
|
|
274
|
+
return;
|
|
275
|
+
console.warn(` Left ${CANOPY_META_DIR}/ in place: ${getErrorMessage(err)}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function printResult(result, check) {
|
|
279
|
+
const plural = (n, word, words = `${word}s`) => `${n} ${n === 1 ? word : words}`;
|
|
280
|
+
const keysOnly = result.changed.filter((file) => file.otherLines === 0);
|
|
281
|
+
const normalised = result.changed.filter((file) => file.otherLines > 0);
|
|
282
|
+
const verb = check ? 'Would key' : 'Keyed';
|
|
283
|
+
if (keysOnly.length > 0) {
|
|
284
|
+
console.log(` ${verb} items in ${plural(keysOnly.length, 'file')}, adding only _key lines:`);
|
|
285
|
+
for (const file of keysOnly)
|
|
286
|
+
console.log(` ${file.path} ${plural(file.itemsKeyed, 'item')}`);
|
|
287
|
+
}
|
|
288
|
+
if (normalised.length > 0) {
|
|
289
|
+
console.log(`\n ${verb} items in ${plural(normalised.length, 'file')} that an unedited save also ` +
|
|
290
|
+
'normalises in other lines. Review these before committing:');
|
|
291
|
+
for (const file of normalised) {
|
|
292
|
+
console.log(` ${file.path} ${plural(file.itemsKeyed, 'item')}, ` +
|
|
293
|
+
`${plural(file.otherLines, 'other line')}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (result.unavailable.length > 0) {
|
|
297
|
+
console.warn(`\n Skipped ${plural(result.unavailable.length, 'entry', 'entries')} whose entry type the schema ` +
|
|
298
|
+
'registry does not have.');
|
|
299
|
+
}
|
|
300
|
+
if (result.malformed.length > 0) {
|
|
301
|
+
console.warn(`\n Skipped ${plural(result.malformed.length, 'file')} that look like entries but whose ` +
|
|
302
|
+
`names don't parse:\n${result.malformed.map((file) => ` ${file}`).join('\n')}`);
|
|
303
|
+
}
|
|
304
|
+
if (result.failed.length > 0) {
|
|
305
|
+
console.error(`\n Could not ${check ? 'check' : 'key'}:`);
|
|
306
|
+
for (const file of result.failed)
|
|
307
|
+
console.error(` ${file.path}: ${file.error}`);
|
|
308
|
+
}
|
|
309
|
+
const skippedAny = result.failed.length + result.unavailable.length + result.malformed.length > 0;
|
|
310
|
+
if (result.changed.length === 0) {
|
|
311
|
+
console.log(` Every ${skippedAny ? 'other ' : ''}entry's items already have their keys.`);
|
|
312
|
+
}
|
|
313
|
+
else if (check) {
|
|
314
|
+
console.log(`\n Run \`canopycms backfill-item-keys\` to write them.`);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
console.log('\n Commit these changes on their own, so the commit holds nothing else.');
|
|
318
|
+
}
|
|
319
|
+
console.log('');
|
|
320
|
+
}
|
package/dist/cli/cli.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Routes commands to their implementations:
|
|
6
6
|
* init, init-deploy, init-github-app, worker, generate-ai-content, collect-asset-refs,
|
|
7
|
-
* materialize-assets, sync, migrate
|
|
7
|
+
* materialize-assets, sync, migrate, backfill-item-keys
|
|
8
8
|
*
|
|
9
9
|
* Command implementations live in separate files (init.ts, sync.ts, etc.)
|
|
10
10
|
* and are dynamically imported to keep startup fast.
|