canopycms 0.0.56 → 0.0.58
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/dist/ai/json-to-markdown.js +74 -3
- package/dist/ai/json-to-markdown.js.map +1 -1
- package/dist/api/__test__/mock-client.d.ts +9 -5
- package/dist/api/__test__/mock-client.d.ts.map +1 -1
- package/dist/api/__test__/mock-client.js +15 -7
- package/dist/api/__test__/mock-client.js.map +1 -1
- package/dist/api/assets.d.ts +100 -36
- package/dist/api/assets.d.ts.map +1 -1
- package/dist/api/assets.js +338 -51
- package/dist/api/assets.js.map +1 -1
- package/dist/api/branch.d.ts +6 -0
- package/dist/api/branch.d.ts.map +1 -1
- package/dist/api/branch.js +63 -19
- package/dist/api/branch.js.map +1 -1
- package/dist/api/client.d.ts +16 -5
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +24 -6
- package/dist/api/client.js.map +1 -1
- package/dist/api/content.d.ts +2 -2
- package/dist/api/entries.d.ts +4 -4
- package/dist/api/entries.d.ts.map +1 -1
- package/dist/api/entries.js +10 -2
- package/dist/api/entries.js.map +1 -1
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js.map +1 -1
- package/dist/api/route-builder.d.ts +9 -0
- package/dist/api/route-builder.d.ts.map +1 -1
- package/dist/api/route-builder.js +1 -0
- package/dist/api/route-builder.js.map +1 -1
- package/dist/api/schema.d.ts +4 -4
- package/dist/api/types.d.ts +11 -1
- package/dist/api/types.d.ts.map +1 -1
- package/dist/assets/asset-prefixes.d.ts +31 -0
- package/dist/assets/asset-prefixes.d.ts.map +1 -0
- package/dist/assets/asset-prefixes.js +20 -0
- package/dist/assets/asset-prefixes.js.map +1 -0
- package/dist/assets/asset-src.d.ts +23 -0
- package/dist/assets/asset-src.d.ts.map +1 -0
- package/dist/assets/asset-src.js +28 -0
- package/dist/assets/asset-src.js.map +1 -0
- package/dist/assets/asset-url.d.ts +35 -0
- package/dist/assets/asset-url.d.ts.map +1 -0
- package/dist/assets/asset-url.js +77 -0
- package/dist/assets/asset-url.js.map +1 -0
- package/dist/assets/factory.d.ts +18 -0
- package/dist/assets/factory.d.ts.map +1 -0
- package/dist/assets/factory.js +42 -0
- package/dist/assets/factory.js.map +1 -0
- package/dist/assets/finalize.d.ts +36 -0
- package/dist/assets/finalize.d.ts.map +1 -0
- package/dist/assets/finalize.js +72 -0
- package/dist/assets/finalize.js.map +1 -0
- package/dist/assets/index.d.ts +20 -0
- package/dist/assets/index.d.ts.map +1 -0
- package/dist/assets/index.js +19 -0
- package/dist/assets/index.js.map +1 -0
- package/dist/assets/keys.d.ts +56 -0
- package/dist/assets/keys.d.ts.map +1 -0
- package/dist/assets/keys.js +88 -0
- package/dist/assets/keys.js.map +1 -0
- package/dist/assets/pipeline.d.ts +50 -0
- package/dist/assets/pipeline.d.ts.map +1 -0
- package/dist/assets/pipeline.js +202 -0
- package/dist/assets/pipeline.js.map +1 -0
- package/dist/assets/store-local.d.ts +84 -0
- package/dist/assets/store-local.d.ts.map +1 -0
- package/dist/assets/store-local.js +254 -0
- package/dist/assets/store-local.js.map +1 -0
- package/dist/assets/store-s3.d.ts +71 -0
- package/dist/assets/store-s3.d.ts.map +1 -0
- package/dist/assets/store-s3.js +183 -0
- package/dist/assets/store-s3.js.map +1 -0
- package/dist/assets/svg-sanitizer.d.ts +37 -0
- package/dist/assets/svg-sanitizer.d.ts.map +1 -0
- package/dist/assets/svg-sanitizer.js +222 -0
- package/dist/assets/svg-sanitizer.js.map +1 -0
- package/dist/assets/transform-directives.d.ts +97 -0
- package/dist/assets/transform-directives.d.ts.map +1 -0
- package/dist/assets/transform-directives.js +267 -0
- package/dist/assets/transform-directives.js.map +1 -0
- package/dist/assets/transform.d.ts +51 -0
- package/dist/assets/transform.d.ts.map +1 -0
- package/dist/assets/transform.js +190 -0
- package/dist/assets/transform.js.map +1 -0
- package/dist/assets/types.d.ts +82 -0
- package/dist/assets/types.d.ts.map +1 -0
- package/dist/assets/types.js +10 -0
- package/dist/assets/types.js.map +1 -0
- package/dist/authorization/groups/schema.d.ts +2 -2
- package/dist/authorization/permissions/schema.d.ts +2 -2
- package/dist/branch-metadata.d.ts +71 -6
- package/dist/branch-metadata.d.ts.map +1 -1
- package/dist/branch-metadata.js +132 -116
- package/dist/branch-metadata.js.map +1 -1
- package/dist/branch-registry.d.ts +78 -9
- package/dist/branch-registry.d.ts.map +1 -1
- package/dist/branch-registry.js +155 -29
- package/dist/branch-registry.js.map +1 -1
- package/dist/branch-schema-cache.d.ts +126 -11
- package/dist/branch-schema-cache.d.ts.map +1 -1
- package/dist/branch-schema-cache.js +208 -58
- package/dist/branch-schema-cache.js.map +1 -1
- package/dist/cli/generate-ai-content.js +1993 -1506
- package/dist/cli/init.js +16 -4
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +25 -0
- package/dist/cli/migrate.js.map +1 -1
- package/dist/cli/sync.js +5 -5
- package/dist/cli/sync.js.map +1 -1
- package/dist/comment-store.d.ts +45 -15
- package/dist/comment-store.d.ts.map +1 -1
- package/dist/comment-store.js +80 -101
- package/dist/comment-store.js.map +1 -1
- package/dist/config/helpers.d.ts.map +1 -1
- package/dist/config/helpers.js +2 -1
- package/dist/config/helpers.js.map +1 -1
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/schemas/config.d.ts +10 -0
- package/dist/config/schemas/config.d.ts.map +1 -1
- package/dist/config/schemas/field.d.ts +38 -3
- package/dist/config/schemas/field.d.ts.map +1 -1
- package/dist/config/schemas/field.js +18 -0
- package/dist/config/schemas/field.js.map +1 -1
- package/dist/config/schemas/media.d.ts +8 -0
- package/dist/config/schemas/media.d.ts.map +1 -1
- package/dist/config/schemas/media.js +9 -0
- package/dist/config/schemas/media.js.map +1 -1
- package/dist/config/types.d.ts +43 -3
- package/dist/config/types.d.ts.map +1 -1
- package/dist/config/types.js +4 -1
- package/dist/config/types.js.map +1 -1
- package/dist/content-index-generation.d.ts +40 -4
- package/dist/content-index-generation.d.ts.map +1 -1
- package/dist/content-index-generation.js +70 -73
- package/dist/content-index-generation.js.map +1 -1
- package/dist/content-store.d.ts +63 -0
- package/dist/content-store.d.ts.map +1 -1
- package/dist/content-store.js +421 -194
- package/dist/content-store.js.map +1 -1
- package/dist/editor/CanopyEditor.d.ts.map +1 -1
- package/dist/editor/CanopyEditor.js +1 -1
- package/dist/editor/CanopyEditor.js.map +1 -1
- package/dist/editor/Editor.d.ts +2 -0
- package/dist/editor/Editor.d.ts.map +1 -1
- package/dist/editor/Editor.js +82 -80
- package/dist/editor/Editor.js.map +1 -1
- package/dist/editor/FormRenderer.d.ts.map +1 -1
- package/dist/editor/FormRenderer.js +9 -0
- package/dist/editor/FormRenderer.js.map +1 -1
- package/dist/editor/components/EditorSidebar.d.ts +6 -1
- package/dist/editor/components/EditorSidebar.d.ts.map +1 -1
- package/dist/editor/components/EditorSidebar.js +4 -3
- package/dist/editor/components/EditorSidebar.js.map +1 -1
- package/dist/editor/context/AssetContext.d.ts +25 -0
- package/dist/editor/context/AssetContext.d.ts.map +1 -0
- package/dist/editor/context/AssetContext.js +26 -0
- package/dist/editor/context/AssetContext.js.map +1 -0
- package/dist/editor/context/index.d.ts +1 -0
- package/dist/editor/context/index.d.ts.map +1 -1
- package/dist/editor/context/index.js +1 -0
- package/dist/editor/context/index.js.map +1 -1
- package/dist/editor/fields/ImageField.d.ts +22 -0
- package/dist/editor/fields/ImageField.d.ts.map +1 -0
- package/dist/editor/fields/ImageField.js +100 -0
- package/dist/editor/fields/ImageField.js.map +1 -0
- package/dist/editor/fields/MarkdownField.d.ts.map +1 -1
- package/dist/editor/fields/MarkdownField.js +30 -4
- package/dist/editor/fields/MarkdownField.js.map +1 -1
- package/dist/editor/fields/MdxImageDialog.d.ts +16 -0
- package/dist/editor/fields/MdxImageDialog.d.ts.map +1 -0
- package/dist/editor/fields/MdxImageDialog.js +73 -0
- package/dist/editor/fields/MdxImageDialog.js.map +1 -0
- package/dist/editor/media/AssetCard.d.ts +21 -0
- package/dist/editor/media/AssetCard.d.ts.map +1 -0
- package/dist/editor/media/AssetCard.js +41 -0
- package/dist/editor/media/AssetCard.js.map +1 -0
- package/dist/editor/media/CropStep.d.ts +26 -0
- package/dist/editor/media/CropStep.d.ts.map +1 -0
- package/dist/editor/media/CropStep.js +38 -0
- package/dist/editor/media/CropStep.js.map +1 -0
- package/dist/editor/media/MediaLibrary.d.ts +22 -0
- package/dist/editor/media/MediaLibrary.d.ts.map +1 -0
- package/dist/editor/media/MediaLibrary.js +17 -0
- package/dist/editor/media/MediaLibrary.js.map +1 -0
- package/dist/editor/media/MediaLibraryBody.d.ts +17 -0
- package/dist/editor/media/MediaLibraryBody.d.ts.map +1 -0
- package/dist/editor/media/MediaLibraryBody.js +133 -0
- package/dist/editor/media/MediaLibraryBody.js.map +1 -0
- package/dist/editor/media/crop-math.d.ts +37 -0
- package/dist/editor/media/crop-math.d.ts.map +1 -0
- package/dist/editor/media/crop-math.js +62 -0
- package/dist/editor/media/crop-math.js.map +1 -0
- package/dist/editor/media/upload-asset.d.ts +31 -0
- package/dist/editor/media/upload-asset.d.ts.map +1 -0
- package/dist/editor/media/upload-asset.js +56 -0
- package/dist/editor/media/upload-asset.js.map +1 -0
- package/dist/editor/media/upload-constants.d.ts +27 -0
- package/dist/editor/media/upload-constants.d.ts.map +1 -0
- package/dist/editor/media/upload-constants.js +40 -0
- package/dist/editor/media/upload-constants.js.map +1 -0
- package/dist/editor/media/useAssetUpload.d.ts +19 -0
- package/dist/editor/media/useAssetUpload.d.ts.map +1 -0
- package/dist/editor/media/useAssetUpload.js +53 -0
- package/dist/editor/media/useAssetUpload.js.map +1 -0
- package/dist/editor/media/xhr-upload.d.ts +20 -0
- package/dist/editor/media/xhr-upload.d.ts.map +1 -0
- package/dist/editor/media/xhr-upload.js +42 -0
- package/dist/editor/media/xhr-upload.js.map +1 -0
- package/dist/editor/theme.d.ts +2 -1
- package/dist/editor/theme.d.ts.map +1 -1
- package/dist/editor/theme.js +1 -0
- package/dist/editor/theme.js.map +1 -1
- package/dist/entry-schema.d.ts +19 -0
- package/dist/entry-schema.d.ts.map +1 -1
- package/dist/entry-schema.js.map +1 -1
- package/dist/git-manager.d.ts +15 -10
- package/dist/git-manager.d.ts.map +1 -1
- package/dist/git-manager.js +16 -11
- package/dist/git-manager.js.map +1 -1
- package/dist/http/handler.d.ts +5 -2
- package/dist/http/handler.d.ts.map +1 -1
- package/dist/http/handler.js +13 -4
- package/dist/http/handler.js.map +1 -1
- package/dist/http/index.d.ts +2 -2
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +1 -1
- package/dist/http/index.js.map +1 -1
- package/dist/http/router.d.ts +14 -1
- package/dist/http/router.d.ts.map +1 -1
- package/dist/http/router.js +4 -1
- package/dist/http/router.js.map +1 -1
- package/dist/http/types.d.ts +40 -0
- package/dist/http/types.d.ts.map +1 -1
- package/dist/http/types.js +9 -0
- package/dist/http/types.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/resource-generation.d.ts +54 -0
- package/dist/resource-generation.d.ts.map +1 -0
- package/dist/resource-generation.js +156 -0
- package/dist/resource-generation.js.map +1 -0
- package/dist/schema/schema-store.d.ts +31 -4
- package/dist/schema/schema-store.d.ts.map +1 -1
- package/dist/schema/schema-store.js +47 -8
- package/dist/schema/schema-store.js.map +1 -1
- package/dist/server.d.ts +45 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +41 -0
- package/dist/server.js.map +1 -1
- package/dist/sync-core.js +2 -2
- package/dist/sync-core.js.map +1 -1
- package/dist/utils/occ-json-write.d.ts +100 -0
- package/dist/utils/occ-json-write.d.ts.map +1 -0
- package/dist/utils/occ-json-write.js +234 -0
- package/dist/utils/occ-json-write.js.map +1 -0
- package/dist/validation/entry-validator.d.ts.map +1 -1
- package/dist/validation/entry-validator.js +56 -1
- package/dist/validation/entry-validator.js.map +1 -1
- package/dist/worker/cms-worker.d.ts.map +1 -1
- package/dist/worker/cms-worker.js +21 -3
- package/dist/worker/cms-worker.js.map +1 -1
- package/package.json +13 -1
- package/dist/asset-store.d.ts +0 -32
- package/dist/asset-store.d.ts.map +0 -1
- package/dist/asset-store.js +0 -85
- package/dist/asset-store.js.map +0 -1
|
@@ -9,11 +9,11 @@ var __export = (target, all) => {
|
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
// dist/paths/normalize.js
|
|
12
|
-
function normalizeFilesystemPath(
|
|
13
|
-
return
|
|
12
|
+
function normalizeFilesystemPath(path19) {
|
|
13
|
+
return path19.split(/[\\/]+/).filter(Boolean).join("/");
|
|
14
14
|
}
|
|
15
|
-
function hasTraversalSequence(
|
|
16
|
-
const normalized = normalizeFilesystemPath(
|
|
15
|
+
function hasTraversalSequence(path19) {
|
|
16
|
+
const normalized = normalizeFilesystemPath(path19);
|
|
17
17
|
return normalized.includes("..");
|
|
18
18
|
}
|
|
19
19
|
function createLogicalPath(...segments) {
|
|
@@ -23,14 +23,14 @@ function createLogicalPath(...segments) {
|
|
|
23
23
|
}
|
|
24
24
|
return normalized;
|
|
25
25
|
}
|
|
26
|
-
function trimSlashes(
|
|
26
|
+
function trimSlashes(path19) {
|
|
27
27
|
let start = 0;
|
|
28
|
-
let end =
|
|
29
|
-
while (start < end &&
|
|
28
|
+
let end = path19.length;
|
|
29
|
+
while (start < end && path19[start] === "/")
|
|
30
30
|
start++;
|
|
31
|
-
while (end > start &&
|
|
31
|
+
while (end > start && path19[end - 1] === "/")
|
|
32
32
|
end--;
|
|
33
|
-
return
|
|
33
|
+
return path19.slice(start, end);
|
|
34
34
|
}
|
|
35
35
|
var init_normalize = __esm({
|
|
36
36
|
"dist/paths/normalize.js"() {
|
|
@@ -38,6 +38,118 @@ var init_normalize = __esm({
|
|
|
38
38
|
}
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
// dist/paths/types.js
|
|
42
|
+
var ROOT_COLLECTION_ID;
|
|
43
|
+
var init_types = __esm({
|
|
44
|
+
"dist/paths/types.js"() {
|
|
45
|
+
"use strict";
|
|
46
|
+
ROOT_COLLECTION_ID = "__rootcoll__";
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// dist/config/flatten.js
|
|
51
|
+
import { join as join2, normalize } from "pathe";
|
|
52
|
+
var normalizePathValue, flattenSchema;
|
|
53
|
+
var init_flatten = __esm({
|
|
54
|
+
"dist/config/flatten.js"() {
|
|
55
|
+
"use strict";
|
|
56
|
+
init_normalize();
|
|
57
|
+
init_types();
|
|
58
|
+
normalizePathValue = (val) => normalize(val).split("/").filter(Boolean).join("/");
|
|
59
|
+
flattenSchema = (root, basePath = "") => {
|
|
60
|
+
const flat = [];
|
|
61
|
+
const base = normalizePathValue(basePath || "");
|
|
62
|
+
const walkCollection = (collection, parentPath) => {
|
|
63
|
+
const normalizedPath = normalizePathValue(collection.path);
|
|
64
|
+
let logicalPath;
|
|
65
|
+
if (base) {
|
|
66
|
+
logicalPath = join2(base, normalizedPath);
|
|
67
|
+
} else {
|
|
68
|
+
logicalPath = normalizedPath;
|
|
69
|
+
}
|
|
70
|
+
const normalizedFull = normalizePathValue(logicalPath);
|
|
71
|
+
flat.push({
|
|
72
|
+
type: "collection",
|
|
73
|
+
logicalPath: createLogicalPath(normalizedFull),
|
|
74
|
+
name: collection.name,
|
|
75
|
+
label: collection.label,
|
|
76
|
+
description: collection.description,
|
|
77
|
+
contentId: collection.contentId,
|
|
78
|
+
parentPath: parentPath ? createLogicalPath(parentPath) : void 0,
|
|
79
|
+
entries: collection.entries,
|
|
80
|
+
collections: collection.collections,
|
|
81
|
+
order: collection.order
|
|
82
|
+
});
|
|
83
|
+
if (collection.entries) {
|
|
84
|
+
for (const entryType of collection.entries) {
|
|
85
|
+
const entryTypePath = join2(normalizedFull, entryType.name);
|
|
86
|
+
flat.push({
|
|
87
|
+
type: "entry-type",
|
|
88
|
+
logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
|
|
89
|
+
name: entryType.name,
|
|
90
|
+
label: entryType.label,
|
|
91
|
+
description: entryType.description,
|
|
92
|
+
parentPath: createLogicalPath(normalizedFull),
|
|
93
|
+
format: entryType.format,
|
|
94
|
+
schema: entryType.schema,
|
|
95
|
+
schemaRef: entryType.schemaRef,
|
|
96
|
+
default: entryType.default,
|
|
97
|
+
maxItems: entryType.maxItems
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (collection.collections) {
|
|
102
|
+
for (const child of collection.collections) {
|
|
103
|
+
walkCollection(child, normalizedFull);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
if (base) {
|
|
108
|
+
flat.push({
|
|
109
|
+
type: "collection",
|
|
110
|
+
logicalPath: createLogicalPath(base),
|
|
111
|
+
name: base,
|
|
112
|
+
// Use base path as the name (e.g., 'content')
|
|
113
|
+
label: void 0,
|
|
114
|
+
// Root collection has no label
|
|
115
|
+
contentId: ROOT_COLLECTION_ID,
|
|
116
|
+
// Sentinel — root dir has no embedded ID
|
|
117
|
+
parentPath: void 0,
|
|
118
|
+
// No parent - this is the root
|
|
119
|
+
entries: root.entries,
|
|
120
|
+
collections: root.collections,
|
|
121
|
+
order: root.order
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (root.entries) {
|
|
125
|
+
for (const entryType of root.entries) {
|
|
126
|
+
const entryTypePath = base ? join2(base, entryType.name) : entryType.name;
|
|
127
|
+
flat.push({
|
|
128
|
+
type: "entry-type",
|
|
129
|
+
logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
|
|
130
|
+
name: entryType.name,
|
|
131
|
+
label: entryType.label,
|
|
132
|
+
description: entryType.description,
|
|
133
|
+
parentPath: base ? createLogicalPath(base) : createLogicalPath(""),
|
|
134
|
+
// Now references the root collection (e.g., 'content')
|
|
135
|
+
format: entryType.format,
|
|
136
|
+
schema: entryType.schema,
|
|
137
|
+
schemaRef: entryType.schemaRef,
|
|
138
|
+
default: entryType.default,
|
|
139
|
+
maxItems: entryType.maxItems
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (root.collections) {
|
|
144
|
+
for (const collection of root.collections) {
|
|
145
|
+
walkCollection(collection, base || "");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return flat;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
41
153
|
// dist/operating-mode/client-safe-strategy.js
|
|
42
154
|
function clientOperatingStrategy(mode) {
|
|
43
155
|
const cached = clientStrategyCache.get(mode);
|
|
@@ -134,7 +246,7 @@ var init_client_safe_strategy = __esm({
|
|
|
134
246
|
|
|
135
247
|
// dist/config/types.js
|
|
136
248
|
var primitiveFieldTypes, fieldTypes;
|
|
137
|
-
var
|
|
249
|
+
var init_types2 = __esm({
|
|
138
250
|
"dist/config/types.js"() {
|
|
139
251
|
"use strict";
|
|
140
252
|
primitiveFieldTypes = [
|
|
@@ -145,11 +257,11 @@ var init_types = __esm({
|
|
|
145
257
|
"rich-text",
|
|
146
258
|
"markdown",
|
|
147
259
|
"mdx",
|
|
148
|
-
"image",
|
|
149
260
|
"code"
|
|
150
261
|
];
|
|
151
262
|
fieldTypes = [
|
|
152
263
|
...primitiveFieldTypes,
|
|
264
|
+
"image",
|
|
153
265
|
"select",
|
|
154
266
|
"reference",
|
|
155
267
|
"object",
|
|
@@ -160,117 +272,124 @@ var init_types = __esm({
|
|
|
160
272
|
});
|
|
161
273
|
|
|
162
274
|
// dist/config/schemas/field.js
|
|
163
|
-
import { z } from "zod";
|
|
164
|
-
var fieldBaseSchema, selectOptionSchema, referenceOptionSchema, primitiveFieldSchema, selectFieldSchema, referenceFieldSchema, fieldHolder, blockSchema, blockFieldSchema, objectFieldSchema, inlineGroupFieldSchema, customFieldSchema, knownFieldSchema, fieldSchema;
|
|
275
|
+
import { z as z2 } from "zod";
|
|
276
|
+
var fieldBaseSchema, selectOptionSchema, referenceOptionSchema, primitiveFieldSchema, ASPECT_RATIO_RE, imageFieldSchema, selectFieldSchema, referenceFieldSchema, fieldHolder, blockSchema, blockFieldSchema, objectFieldSchema, inlineGroupFieldSchema, customFieldSchema, knownFieldSchema, fieldSchema;
|
|
165
277
|
var init_field = __esm({
|
|
166
278
|
"dist/config/schemas/field.js"() {
|
|
167
279
|
"use strict";
|
|
168
|
-
|
|
169
|
-
fieldBaseSchema =
|
|
170
|
-
name:
|
|
171
|
-
label:
|
|
172
|
-
description:
|
|
173
|
-
required:
|
|
174
|
-
list:
|
|
175
|
-
isTitle:
|
|
176
|
-
isBody:
|
|
280
|
+
init_types2();
|
|
281
|
+
fieldBaseSchema = z2.object({
|
|
282
|
+
name: z2.string().min(1),
|
|
283
|
+
label: z2.string().optional(),
|
|
284
|
+
description: z2.string().optional(),
|
|
285
|
+
required: z2.boolean().optional(),
|
|
286
|
+
list: z2.boolean().optional(),
|
|
287
|
+
isTitle: z2.boolean().optional(),
|
|
288
|
+
isBody: z2.boolean().optional()
|
|
177
289
|
});
|
|
178
|
-
selectOptionSchema =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
label:
|
|
182
|
-
value:
|
|
290
|
+
selectOptionSchema = z2.union([
|
|
291
|
+
z2.string(),
|
|
292
|
+
z2.object({
|
|
293
|
+
label: z2.string().min(1),
|
|
294
|
+
value: z2.string().min(1)
|
|
183
295
|
})
|
|
184
296
|
]);
|
|
185
|
-
referenceOptionSchema =
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
label:
|
|
189
|
-
value:
|
|
297
|
+
referenceOptionSchema = z2.union([
|
|
298
|
+
z2.string(),
|
|
299
|
+
z2.object({
|
|
300
|
+
label: z2.string().min(1),
|
|
301
|
+
value: z2.string().min(1)
|
|
190
302
|
})
|
|
191
303
|
]);
|
|
192
304
|
primitiveFieldSchema = fieldBaseSchema.extend({
|
|
193
|
-
type:
|
|
305
|
+
type: z2.enum(primitiveFieldTypes)
|
|
306
|
+
});
|
|
307
|
+
ASPECT_RATIO_RE = /^[1-9][0-9]*:[1-9][0-9]*$/;
|
|
308
|
+
imageFieldSchema = fieldBaseSchema.extend({
|
|
309
|
+
type: z2.literal("image"),
|
|
310
|
+
aspect: z2.string().regex(ASPECT_RATIO_RE, 'aspect must be "W:H" with positive integers (e.g. "16:9")').optional(),
|
|
311
|
+
altOptional: z2.boolean().optional()
|
|
194
312
|
});
|
|
195
313
|
selectFieldSchema = fieldBaseSchema.extend({
|
|
196
|
-
type:
|
|
197
|
-
options:
|
|
314
|
+
type: z2.literal("select"),
|
|
315
|
+
options: z2.array(selectOptionSchema).min(1)
|
|
198
316
|
});
|
|
199
317
|
referenceFieldSchema = fieldBaseSchema.extend({
|
|
200
|
-
type:
|
|
201
|
-
collections:
|
|
202
|
-
entryTypes:
|
|
203
|
-
displayField:
|
|
204
|
-
options:
|
|
318
|
+
type: z2.literal("reference"),
|
|
319
|
+
collections: z2.array(z2.string().min(1)).min(1).optional(),
|
|
320
|
+
entryTypes: z2.array(z2.string().min(1)).min(1).optional(),
|
|
321
|
+
displayField: z2.string().min(1).optional(),
|
|
322
|
+
options: z2.array(referenceOptionSchema).optional()
|
|
205
323
|
});
|
|
206
|
-
fieldHolder = [
|
|
207
|
-
blockSchema =
|
|
208
|
-
name:
|
|
209
|
-
label:
|
|
210
|
-
description:
|
|
211
|
-
fields:
|
|
324
|
+
fieldHolder = [z2.never()];
|
|
325
|
+
blockSchema = z2.object({
|
|
326
|
+
name: z2.string().min(1),
|
|
327
|
+
label: z2.string().optional(),
|
|
328
|
+
description: z2.string().optional(),
|
|
329
|
+
fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1)
|
|
212
330
|
});
|
|
213
331
|
blockFieldSchema = fieldBaseSchema.extend({
|
|
214
|
-
type:
|
|
215
|
-
templates:
|
|
332
|
+
type: z2.literal("block"),
|
|
333
|
+
templates: z2.array(blockSchema).min(1)
|
|
216
334
|
});
|
|
217
335
|
objectFieldSchema = fieldBaseSchema.extend({
|
|
218
|
-
type:
|
|
219
|
-
fields:
|
|
336
|
+
type: z2.literal("object"),
|
|
337
|
+
fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1)
|
|
220
338
|
});
|
|
221
|
-
inlineGroupFieldSchema =
|
|
222
|
-
type:
|
|
223
|
-
name:
|
|
224
|
-
label:
|
|
225
|
-
description:
|
|
226
|
-
fields:
|
|
339
|
+
inlineGroupFieldSchema = z2.object({
|
|
340
|
+
type: z2.literal("group"),
|
|
341
|
+
name: z2.string().min(1),
|
|
342
|
+
label: z2.string().optional(),
|
|
343
|
+
description: z2.string().optional(),
|
|
344
|
+
fields: z2.array(z2.lazy(() => fieldHolder[0])).min(1)
|
|
227
345
|
});
|
|
228
|
-
customFieldSchema =
|
|
229
|
-
type:
|
|
346
|
+
customFieldSchema = z2.lazy(() => fieldBaseSchema.extend({
|
|
347
|
+
type: z2.string().min(1).refine((val) => !fieldTypes.includes(val), {
|
|
230
348
|
message: "Custom field types must not conflict with built-in types"
|
|
231
349
|
})
|
|
232
350
|
}).passthrough());
|
|
233
|
-
knownFieldSchema =
|
|
351
|
+
knownFieldSchema = z2.discriminatedUnion("type", [
|
|
234
352
|
primitiveFieldSchema,
|
|
235
353
|
selectFieldSchema,
|
|
236
354
|
referenceFieldSchema,
|
|
355
|
+
imageFieldSchema,
|
|
237
356
|
objectFieldSchema,
|
|
238
357
|
blockFieldSchema,
|
|
239
358
|
inlineGroupFieldSchema
|
|
240
359
|
]);
|
|
241
|
-
fieldSchema =
|
|
360
|
+
fieldSchema = z2.lazy(() => z2.union([knownFieldSchema, customFieldSchema]));
|
|
242
361
|
fieldHolder[0] = fieldSchema;
|
|
243
362
|
}
|
|
244
363
|
});
|
|
245
364
|
|
|
246
365
|
// dist/config/schemas/collection.js
|
|
247
|
-
import { z as
|
|
366
|
+
import { z as z3 } from "zod";
|
|
248
367
|
import { isAbsolute } from "pathe";
|
|
249
368
|
var relativePathSchema, entryTypeSchema, collectionSchema;
|
|
250
369
|
var init_collection = __esm({
|
|
251
370
|
"dist/config/schemas/collection.js"() {
|
|
252
371
|
"use strict";
|
|
253
372
|
init_field();
|
|
254
|
-
relativePathSchema =
|
|
373
|
+
relativePathSchema = z3.string().min(1).refine((val) => !isAbsolute(val), { message: "Path must be relative" }).refine((val) => !val.split(/[\\/]+/).includes(".."), {
|
|
255
374
|
message: 'Path must not contain ".."'
|
|
256
375
|
}).transform((val) => val.split(/[\\/]+/).filter(Boolean).join("/"));
|
|
257
|
-
entryTypeSchema =
|
|
258
|
-
name:
|
|
259
|
-
format:
|
|
260
|
-
schema:
|
|
261
|
-
label:
|
|
262
|
-
description:
|
|
263
|
-
default:
|
|
264
|
-
maxItems:
|
|
376
|
+
entryTypeSchema = z3.object({
|
|
377
|
+
name: z3.string().min(1),
|
|
378
|
+
format: z3.enum(["md", "mdx", "json", "yaml"]),
|
|
379
|
+
schema: z3.array(z3.lazy(() => fieldSchema)).min(1),
|
|
380
|
+
label: z3.string().optional(),
|
|
381
|
+
description: z3.string().optional(),
|
|
382
|
+
default: z3.boolean().optional(),
|
|
383
|
+
maxItems: z3.number().int().positive().optional()
|
|
265
384
|
});
|
|
266
|
-
collectionSchema =
|
|
267
|
-
name:
|
|
385
|
+
collectionSchema = z3.lazy(() => z3.object({
|
|
386
|
+
name: z3.string().min(1),
|
|
268
387
|
path: relativePathSchema,
|
|
269
|
-
label:
|
|
270
|
-
description:
|
|
271
|
-
entries:
|
|
272
|
-
collections:
|
|
273
|
-
order:
|
|
388
|
+
label: z3.string().optional(),
|
|
389
|
+
description: z3.string().optional(),
|
|
390
|
+
entries: z3.array(entryTypeSchema).optional(),
|
|
391
|
+
collections: z3.array(collectionSchema).optional(),
|
|
392
|
+
order: z3.array(z3.string()).optional()
|
|
274
393
|
// Embedded IDs for ordering items
|
|
275
394
|
}).refine((data) => data.entries || data.collections, {
|
|
276
395
|
message: "Collection must have entries or collections"
|
|
@@ -279,67 +398,72 @@ var init_collection = __esm({
|
|
|
279
398
|
});
|
|
280
399
|
|
|
281
400
|
// dist/config/schemas/media.js
|
|
282
|
-
import { z as
|
|
401
|
+
import { z as z4 } from "zod";
|
|
283
402
|
var mediaSchema;
|
|
284
403
|
var init_media = __esm({
|
|
285
404
|
"dist/config/schemas/media.js"() {
|
|
286
405
|
"use strict";
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
406
|
+
init_collection();
|
|
407
|
+
mediaSchema = z4.discriminatedUnion("adapter", [
|
|
408
|
+
z4.object({
|
|
409
|
+
adapter: z4.literal("local"),
|
|
410
|
+
publicBaseUrl: z4.string().url().optional(),
|
|
411
|
+
/** Root directory for local asset storage. Defaults to the caller's dev-assets dir. */
|
|
412
|
+
directory: relativePathSchema.optional()
|
|
291
413
|
}),
|
|
292
|
-
|
|
293
|
-
adapter:
|
|
294
|
-
bucket:
|
|
295
|
-
region:
|
|
296
|
-
publicBaseUrl:
|
|
414
|
+
z4.object({
|
|
415
|
+
adapter: z4.literal("s3"),
|
|
416
|
+
bucket: z4.string().min(1),
|
|
417
|
+
region: z4.string().min(1),
|
|
418
|
+
publicBaseUrl: z4.string().url().optional(),
|
|
419
|
+
/** Max upload size in bytes for presigned direct uploads. Defaults to 50 MiB. */
|
|
420
|
+
maxUploadBytes: z4.number().int().positive().optional()
|
|
297
421
|
}),
|
|
298
|
-
|
|
299
|
-
adapter:
|
|
300
|
-
publicBaseUrl:
|
|
422
|
+
z4.object({
|
|
423
|
+
adapter: z4.literal("lfs"),
|
|
424
|
+
publicBaseUrl: z4.string().url().optional()
|
|
301
425
|
})
|
|
302
426
|
]);
|
|
303
427
|
}
|
|
304
428
|
});
|
|
305
429
|
|
|
306
430
|
// dist/config/schemas/config.js
|
|
307
|
-
import { z as
|
|
431
|
+
import { z as z5 } from "zod";
|
|
308
432
|
var defaultBranchAccessSchema, defaultPathAccessSchema, defaultBaseBranchSchema, defaultRemoteNameSchema, defaultRemoteUrlSchema, gitBotAuthorNameSchema, gitBotAuthorEmailSchema, githubTokenEnvVarSchema, operatingModeSchema, deployedAsSchema, contentRootSchema, sourceRootSchema, deploymentNameSchema, devContentSyncSchema, devConfigSchema, editorConfigSchema, CanopyConfigSchema, DEFAULT_PROD_WORKSPACE;
|
|
309
433
|
var init_config = __esm({
|
|
310
434
|
"dist/config/schemas/config.js"() {
|
|
311
435
|
"use strict";
|
|
312
436
|
init_collection();
|
|
313
437
|
init_media();
|
|
314
|
-
defaultBranchAccessSchema =
|
|
315
|
-
defaultPathAccessSchema =
|
|
316
|
-
defaultBaseBranchSchema =
|
|
317
|
-
defaultRemoteNameSchema =
|
|
318
|
-
defaultRemoteUrlSchema =
|
|
319
|
-
gitBotAuthorNameSchema =
|
|
320
|
-
gitBotAuthorEmailSchema =
|
|
321
|
-
githubTokenEnvVarSchema =
|
|
322
|
-
operatingModeSchema =
|
|
323
|
-
deployedAsSchema =
|
|
438
|
+
defaultBranchAccessSchema = z5.enum(["allow", "deny"]).default("deny");
|
|
439
|
+
defaultPathAccessSchema = z5.enum(["allow", "deny"]).default("deny");
|
|
440
|
+
defaultBaseBranchSchema = z5.string().default("main");
|
|
441
|
+
defaultRemoteNameSchema = z5.string().default("origin");
|
|
442
|
+
defaultRemoteUrlSchema = z5.string().min(1);
|
|
443
|
+
gitBotAuthorNameSchema = z5.string().min(1);
|
|
444
|
+
gitBotAuthorEmailSchema = z5.string().email();
|
|
445
|
+
githubTokenEnvVarSchema = z5.string().default("GITHUB_BOT_TOKEN");
|
|
446
|
+
operatingModeSchema = z5.enum(["prod", "dev"]);
|
|
447
|
+
deployedAsSchema = z5.enum(["static", "server"]).default("server");
|
|
324
448
|
contentRootSchema = relativePathSchema.default("content");
|
|
325
|
-
sourceRootSchema =
|
|
326
|
-
deploymentNameSchema =
|
|
327
|
-
devContentSyncSchema =
|
|
328
|
-
devConfigSchema =
|
|
449
|
+
sourceRootSchema = z5.string().min(1).optional();
|
|
450
|
+
deploymentNameSchema = z5.string().default("prod");
|
|
451
|
+
devContentSyncSchema = z5.enum(["off", "warn"]).default("warn");
|
|
452
|
+
devConfigSchema = z5.object({
|
|
329
453
|
contentSync: devContentSyncSchema.optional()
|
|
330
454
|
});
|
|
331
|
-
editorConfigSchema =
|
|
332
|
-
title:
|
|
333
|
-
subtitle:
|
|
334
|
-
theme:
|
|
335
|
-
previewBase:
|
|
455
|
+
editorConfigSchema = z5.object({
|
|
456
|
+
title: z5.string().optional(),
|
|
457
|
+
subtitle: z5.string().optional(),
|
|
458
|
+
theme: z5.unknown().optional(),
|
|
459
|
+
previewBase: z5.record(z5.string()).optional(),
|
|
336
460
|
// UI handler functions (runtime only, don't serialize)
|
|
337
|
-
onAccountClick:
|
|
338
|
-
onLogoutClick:
|
|
461
|
+
onAccountClick: z5.function().returns(z5.void()).optional(),
|
|
462
|
+
onLogoutClick: z5.function().returns(z5.void()).optional(),
|
|
339
463
|
// Optional: custom account component (e.g., Clerk's UserButton)
|
|
340
|
-
AccountComponent:
|
|
464
|
+
AccountComponent: z5.custom().optional()
|
|
341
465
|
});
|
|
342
|
-
CanopyConfigSchema =
|
|
466
|
+
CanopyConfigSchema = z5.object({
|
|
343
467
|
media: mediaSchema.optional(),
|
|
344
468
|
// No outer .optional() here: defaultBranchAccessSchema/defaultPathAccessSchema already
|
|
345
469
|
// have .default('deny'), so the field is optional on input but always resolves to
|
|
@@ -351,7 +475,7 @@ var init_config = __esm({
|
|
|
351
475
|
// unset must stay undefined after parsing so dev mode can detect the fork
|
|
352
476
|
// point from git HEAD (see resolveBaseBranch in utils/git.ts).
|
|
353
477
|
defaultBaseBranch: defaultBaseBranchSchema.optional(),
|
|
354
|
-
defaultActiveBranch:
|
|
478
|
+
defaultActiveBranch: z5.string().min(1).optional(),
|
|
355
479
|
defaultRemoteName: defaultRemoteNameSchema.optional(),
|
|
356
480
|
defaultRemoteUrl: defaultRemoteUrlSchema.optional(),
|
|
357
481
|
gitBotAuthorName: gitBotAuthorNameSchema,
|
|
@@ -362,15 +486,15 @@ var init_config = __esm({
|
|
|
362
486
|
mode: operatingModeSchema,
|
|
363
487
|
deployedAs: deployedAsSchema,
|
|
364
488
|
// Has .default('server'), so always present after validation
|
|
365
|
-
settingsBranch:
|
|
366
|
-
autoCreateSettingsPR:
|
|
489
|
+
settingsBranch: z5.string().optional(),
|
|
490
|
+
autoCreateSettingsPR: z5.boolean().optional(),
|
|
367
491
|
deploymentName: deploymentNameSchema.optional(),
|
|
368
492
|
contentRoot: contentRootSchema.default("content"),
|
|
369
493
|
sourceRoot: sourceRootSchema.optional(),
|
|
370
494
|
editor: editorConfigSchema.optional(),
|
|
371
|
-
authPlugin:
|
|
372
|
-
entryLinkUrl:
|
|
373
|
-
validateEntry:
|
|
495
|
+
authPlugin: z5.custom().optional(),
|
|
496
|
+
entryLinkUrl: z5.custom().optional(),
|
|
497
|
+
validateEntry: z5.custom().optional(),
|
|
374
498
|
dev: devConfigSchema.optional()
|
|
375
499
|
}).strict();
|
|
376
500
|
DEFAULT_PROD_WORKSPACE = "/mnt/efs/workspace";
|
|
@@ -378,17 +502,17 @@ var init_config = __esm({
|
|
|
378
502
|
});
|
|
379
503
|
|
|
380
504
|
// dist/config/schemas/permissions.js
|
|
381
|
-
import { z as
|
|
505
|
+
import { z as z6 } from "zod";
|
|
382
506
|
var permissionTargetSchema, pathPermissionSchema;
|
|
383
507
|
var init_permissions = __esm({
|
|
384
508
|
"dist/config/schemas/permissions.js"() {
|
|
385
509
|
"use strict";
|
|
386
|
-
permissionTargetSchema =
|
|
387
|
-
allowedUsers:
|
|
388
|
-
allowedGroups:
|
|
510
|
+
permissionTargetSchema = z6.object({
|
|
511
|
+
allowedUsers: z6.array(z6.string()).optional(),
|
|
512
|
+
allowedGroups: z6.array(z6.string()).optional()
|
|
389
513
|
});
|
|
390
|
-
pathPermissionSchema =
|
|
391
|
-
path:
|
|
514
|
+
pathPermissionSchema = z6.object({
|
|
515
|
+
path: z6.string().min(1),
|
|
392
516
|
read: permissionTargetSchema.optional(),
|
|
393
517
|
edit: permissionTargetSchema.optional(),
|
|
394
518
|
review: permissionTargetSchema.optional()
|
|
@@ -396,118 +520,6 @@ var init_permissions = __esm({
|
|
|
396
520
|
}
|
|
397
521
|
});
|
|
398
522
|
|
|
399
|
-
// dist/paths/types.js
|
|
400
|
-
var ROOT_COLLECTION_ID;
|
|
401
|
-
var init_types2 = __esm({
|
|
402
|
-
"dist/paths/types.js"() {
|
|
403
|
-
"use strict";
|
|
404
|
-
ROOT_COLLECTION_ID = "__rootcoll__";
|
|
405
|
-
}
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
// dist/config/flatten.js
|
|
409
|
-
import { join, normalize } from "pathe";
|
|
410
|
-
var normalizePathValue, flattenSchema;
|
|
411
|
-
var init_flatten = __esm({
|
|
412
|
-
"dist/config/flatten.js"() {
|
|
413
|
-
"use strict";
|
|
414
|
-
init_normalize();
|
|
415
|
-
init_types2();
|
|
416
|
-
normalizePathValue = (val) => normalize(val).split("/").filter(Boolean).join("/");
|
|
417
|
-
flattenSchema = (root, basePath = "") => {
|
|
418
|
-
const flat = [];
|
|
419
|
-
const base = normalizePathValue(basePath || "");
|
|
420
|
-
const walkCollection = (collection, parentPath) => {
|
|
421
|
-
const normalizedPath = normalizePathValue(collection.path);
|
|
422
|
-
let logicalPath;
|
|
423
|
-
if (base) {
|
|
424
|
-
logicalPath = join(base, normalizedPath);
|
|
425
|
-
} else {
|
|
426
|
-
logicalPath = normalizedPath;
|
|
427
|
-
}
|
|
428
|
-
const normalizedFull = normalizePathValue(logicalPath);
|
|
429
|
-
flat.push({
|
|
430
|
-
type: "collection",
|
|
431
|
-
logicalPath: createLogicalPath(normalizedFull),
|
|
432
|
-
name: collection.name,
|
|
433
|
-
label: collection.label,
|
|
434
|
-
description: collection.description,
|
|
435
|
-
contentId: collection.contentId,
|
|
436
|
-
parentPath: parentPath ? createLogicalPath(parentPath) : void 0,
|
|
437
|
-
entries: collection.entries,
|
|
438
|
-
collections: collection.collections,
|
|
439
|
-
order: collection.order
|
|
440
|
-
});
|
|
441
|
-
if (collection.entries) {
|
|
442
|
-
for (const entryType of collection.entries) {
|
|
443
|
-
const entryTypePath = join(normalizedFull, entryType.name);
|
|
444
|
-
flat.push({
|
|
445
|
-
type: "entry-type",
|
|
446
|
-
logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
|
|
447
|
-
name: entryType.name,
|
|
448
|
-
label: entryType.label,
|
|
449
|
-
description: entryType.description,
|
|
450
|
-
parentPath: createLogicalPath(normalizedFull),
|
|
451
|
-
format: entryType.format,
|
|
452
|
-
schema: entryType.schema,
|
|
453
|
-
schemaRef: entryType.schemaRef,
|
|
454
|
-
default: entryType.default,
|
|
455
|
-
maxItems: entryType.maxItems
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
if (collection.collections) {
|
|
460
|
-
for (const child of collection.collections) {
|
|
461
|
-
walkCollection(child, normalizedFull);
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
};
|
|
465
|
-
if (base) {
|
|
466
|
-
flat.push({
|
|
467
|
-
type: "collection",
|
|
468
|
-
logicalPath: createLogicalPath(base),
|
|
469
|
-
name: base,
|
|
470
|
-
// Use base path as the name (e.g., 'content')
|
|
471
|
-
label: void 0,
|
|
472
|
-
// Root collection has no label
|
|
473
|
-
contentId: ROOT_COLLECTION_ID,
|
|
474
|
-
// Sentinel — root dir has no embedded ID
|
|
475
|
-
parentPath: void 0,
|
|
476
|
-
// No parent - this is the root
|
|
477
|
-
entries: root.entries,
|
|
478
|
-
collections: root.collections,
|
|
479
|
-
order: root.order
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
if (root.entries) {
|
|
483
|
-
for (const entryType of root.entries) {
|
|
484
|
-
const entryTypePath = base ? join(base, entryType.name) : entryType.name;
|
|
485
|
-
flat.push({
|
|
486
|
-
type: "entry-type",
|
|
487
|
-
logicalPath: createLogicalPath(normalizePathValue(entryTypePath)),
|
|
488
|
-
name: entryType.name,
|
|
489
|
-
label: entryType.label,
|
|
490
|
-
description: entryType.description,
|
|
491
|
-
parentPath: base ? createLogicalPath(base) : createLogicalPath(""),
|
|
492
|
-
// Now references the root collection (e.g., 'content')
|
|
493
|
-
format: entryType.format,
|
|
494
|
-
schema: entryType.schema,
|
|
495
|
-
schemaRef: entryType.schemaRef,
|
|
496
|
-
default: entryType.default,
|
|
497
|
-
maxItems: entryType.maxItems
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
if (root.collections) {
|
|
502
|
-
for (const collection of root.collections) {
|
|
503
|
-
walkCollection(collection, base || "");
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
return flat;
|
|
507
|
-
};
|
|
508
|
-
}
|
|
509
|
-
});
|
|
510
|
-
|
|
511
523
|
// dist/config/validation.js
|
|
512
524
|
var init_validation = __esm({
|
|
513
525
|
"dist/config/validation.js"() {
|
|
@@ -529,7 +541,7 @@ var init_helpers = __esm({
|
|
|
529
541
|
var init_config2 = __esm({
|
|
530
542
|
"dist/config/index.js"() {
|
|
531
543
|
"use strict";
|
|
532
|
-
|
|
544
|
+
init_types2();
|
|
533
545
|
init_config();
|
|
534
546
|
init_field();
|
|
535
547
|
init_collection();
|
|
@@ -542,7 +554,7 @@ var init_config2 = __esm({
|
|
|
542
554
|
});
|
|
543
555
|
|
|
544
556
|
// dist/operating-mode/client-unsafe-strategy.js
|
|
545
|
-
import
|
|
557
|
+
import path6 from "node:path";
|
|
546
558
|
function operatingStrategy(mode) {
|
|
547
559
|
const cached = strategyCache.get(mode);
|
|
548
560
|
if (cached)
|
|
@@ -579,32 +591,32 @@ var init_client_unsafe_strategy = __esm({
|
|
|
579
591
|
// - shouldCommit(), shouldPush()
|
|
580
592
|
// Add client-unsafe methods (use Node.js APIs)
|
|
581
593
|
getWorkspaceRoot(_sourceRoot) {
|
|
582
|
-
return
|
|
594
|
+
return path6.resolve(process.env.CANOPYCMS_WORKSPACE_ROOT ?? DEFAULT_PROD_WORKSPACE);
|
|
583
595
|
}
|
|
584
596
|
getContentRoot(sourceRoot) {
|
|
585
|
-
return
|
|
597
|
+
return path6.resolve(sourceRoot ?? process.cwd(), "content");
|
|
586
598
|
}
|
|
587
599
|
getContentBranchesRoot(sourceRoot) {
|
|
588
|
-
return
|
|
600
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
|
|
589
601
|
}
|
|
590
602
|
getContentBranchRoot(branchName, sourceRoot) {
|
|
591
|
-
return
|
|
603
|
+
return path6.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
|
|
592
604
|
}
|
|
593
605
|
getGitExcludePattern() {
|
|
594
606
|
return ".canopy-meta/";
|
|
595
607
|
}
|
|
596
608
|
getPermissionsFilePath(root) {
|
|
597
|
-
return
|
|
609
|
+
return path6.join(root, this.getPermissionsFileName());
|
|
598
610
|
}
|
|
599
611
|
getGroupsFilePath(root) {
|
|
600
|
-
return
|
|
612
|
+
return path6.join(root, this.getGroupsFileName());
|
|
601
613
|
}
|
|
602
614
|
getRemoteUrlConfig() {
|
|
603
615
|
return {
|
|
604
616
|
shouldAutoInitLocal: false,
|
|
605
617
|
defaultRemotePath: "",
|
|
606
618
|
envVarName: "CANOPYCMS_REMOTE_URL",
|
|
607
|
-
autoDetectRemotePath:
|
|
619
|
+
autoDetectRemotePath: path6.join(this.getWorkspaceRoot(), "remote.git")
|
|
608
620
|
};
|
|
609
621
|
}
|
|
610
622
|
requiresExistingRepo() {
|
|
@@ -617,7 +629,7 @@ var init_client_unsafe_strategy = __esm({
|
|
|
617
629
|
return `canopycms-settings-${deploymentName}`;
|
|
618
630
|
}
|
|
619
631
|
getSettingsRoot(sourceRoot) {
|
|
620
|
-
return
|
|
632
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "settings");
|
|
621
633
|
}
|
|
622
634
|
usesSeparateSettingsBranch() {
|
|
623
635
|
return true;
|
|
@@ -634,25 +646,25 @@ var init_client_unsafe_strategy = __esm({
|
|
|
634
646
|
DevStrategy = class extends DevClientSafeStrategy {
|
|
635
647
|
// Inherits client-safe methods from DevClientSafeStrategy
|
|
636
648
|
getWorkspaceRoot(sourceRoot) {
|
|
637
|
-
return
|
|
649
|
+
return path6.resolve(sourceRoot ?? process.cwd(), ".canopy-dev");
|
|
638
650
|
}
|
|
639
651
|
getContentRoot(sourceRoot) {
|
|
640
|
-
return
|
|
652
|
+
return path6.resolve(sourceRoot ?? process.cwd(), "content");
|
|
641
653
|
}
|
|
642
654
|
getContentBranchesRoot(sourceRoot) {
|
|
643
|
-
return
|
|
655
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "content-branches");
|
|
644
656
|
}
|
|
645
657
|
getContentBranchRoot(branchName, sourceRoot) {
|
|
646
|
-
return
|
|
658
|
+
return path6.resolve(this.getContentBranchesRoot(sourceRoot), branchName);
|
|
647
659
|
}
|
|
648
660
|
getGitExcludePattern() {
|
|
649
661
|
return ".canopy-meta/";
|
|
650
662
|
}
|
|
651
663
|
getPermissionsFilePath(root) {
|
|
652
|
-
return
|
|
664
|
+
return path6.join(root, this.getPermissionsFileName());
|
|
653
665
|
}
|
|
654
666
|
getGroupsFilePath(root) {
|
|
655
|
-
return
|
|
667
|
+
return path6.join(root, this.getGroupsFileName());
|
|
656
668
|
}
|
|
657
669
|
getRemoteUrlConfig() {
|
|
658
670
|
return {
|
|
@@ -671,7 +683,7 @@ var init_client_unsafe_strategy = __esm({
|
|
|
671
683
|
return `canopycms-settings-${deploymentName}`;
|
|
672
684
|
}
|
|
673
685
|
getSettingsRoot(sourceRoot) {
|
|
674
|
-
return
|
|
686
|
+
return path6.join(this.getWorkspaceRoot(sourceRoot), "settings");
|
|
675
687
|
}
|
|
676
688
|
usesSeparateSettingsBranch() {
|
|
677
689
|
return true;
|
|
@@ -703,16 +715,16 @@ var init_operating_mode = __esm({
|
|
|
703
715
|
});
|
|
704
716
|
|
|
705
717
|
// dist/cli/generate-ai-content.js
|
|
706
|
-
import
|
|
718
|
+
import path18 from "node:path";
|
|
707
719
|
import { createJiti } from "jiti";
|
|
708
720
|
|
|
709
721
|
// dist/build/generate-ai-content.js
|
|
710
|
-
import
|
|
711
|
-
import
|
|
722
|
+
import fs16 from "node:fs/promises";
|
|
723
|
+
import path17 from "node:path";
|
|
712
724
|
|
|
713
725
|
// dist/content-store.js
|
|
714
|
-
import
|
|
715
|
-
import
|
|
726
|
+
import fs8 from "node:fs/promises";
|
|
727
|
+
import path8 from "node:path";
|
|
716
728
|
import matter from "gray-matter";
|
|
717
729
|
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
718
730
|
|
|
@@ -1077,13 +1089,13 @@ function extractIdFromFilename(filename) {
|
|
|
1077
1089
|
return null;
|
|
1078
1090
|
}
|
|
1079
1091
|
async function resolveCollectionPath(root, logicalPath) {
|
|
1080
|
-
const
|
|
1081
|
-
const
|
|
1092
|
+
const fs17 = await import("node:fs/promises");
|
|
1093
|
+
const path19 = await import("node:path");
|
|
1082
1094
|
const segments = logicalPath.split("/").filter(Boolean);
|
|
1083
1095
|
let currentPath = root;
|
|
1084
1096
|
for (const segment of segments) {
|
|
1085
1097
|
try {
|
|
1086
|
-
const entries = await
|
|
1098
|
+
const entries = await fs17.readdir(currentPath, { withFileTypes: true });
|
|
1087
1099
|
const matchingDir = entries.find((entry) => {
|
|
1088
1100
|
if (!entry.isDirectory())
|
|
1089
1101
|
return false;
|
|
@@ -1091,7 +1103,7 @@ async function resolveCollectionPath(root, logicalPath) {
|
|
|
1091
1103
|
return logicalName === segment.toLowerCase();
|
|
1092
1104
|
});
|
|
1093
1105
|
if (matchingDir) {
|
|
1094
|
-
currentPath =
|
|
1106
|
+
currentPath = path19.join(currentPath, matchingDir.name);
|
|
1095
1107
|
} else {
|
|
1096
1108
|
return null;
|
|
1097
1109
|
}
|
|
@@ -1182,7 +1194,7 @@ function invalidateContentIndexesForRoot(root) {
|
|
|
1182
1194
|
}
|
|
1183
1195
|
}
|
|
1184
1196
|
|
|
1185
|
-
// dist/
|
|
1197
|
+
// dist/resource-generation.js
|
|
1186
1198
|
import fs3 from "node:fs/promises";
|
|
1187
1199
|
import path4 from "node:path";
|
|
1188
1200
|
import { randomUUID } from "node:crypto";
|
|
@@ -1278,369 +1290,843 @@ var testLogger = createDebugLogger({
|
|
|
1278
1290
|
throwOnError: false
|
|
1279
1291
|
});
|
|
1280
1292
|
|
|
1281
|
-
// dist/
|
|
1282
|
-
var log = createDebugLogger({ prefix: "
|
|
1293
|
+
// dist/resource-generation.js
|
|
1294
|
+
var log = createDebugLogger({ prefix: "ResourceGeneration" });
|
|
1283
1295
|
var META_DIR = ".canopy-meta";
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
return path4.join(path4.resolve(root), META_DIR, GENERATION_FILE);
|
|
1296
|
+
function resourceGenerationPath(root, resource) {
|
|
1297
|
+
return path4.resolve(root, META_DIR, `${resource}.generation`);
|
|
1287
1298
|
}
|
|
1288
|
-
async function
|
|
1299
|
+
async function bumpResourceGeneration(root, resource, options) {
|
|
1289
1300
|
const token = randomUUID();
|
|
1290
1301
|
try {
|
|
1291
|
-
await atomicWriteFile(
|
|
1302
|
+
await atomicWriteFile(resourceGenerationPath(root, resource), token);
|
|
1292
1303
|
return token;
|
|
1293
1304
|
} catch (err) {
|
|
1294
|
-
log.warn("generation", `Failed to bump
|
|
1305
|
+
log.warn("generation", `Failed to bump ${resource} generation for ${root}`, {
|
|
1295
1306
|
error: getErrorMessage(err)
|
|
1296
1307
|
});
|
|
1308
|
+
if (options?.mustSucceed)
|
|
1309
|
+
throw err;
|
|
1297
1310
|
return null;
|
|
1298
1311
|
}
|
|
1299
1312
|
}
|
|
1300
|
-
async function
|
|
1313
|
+
async function readResourceGeneration(root, resource) {
|
|
1301
1314
|
try {
|
|
1302
|
-
|
|
1315
|
+
const token = await fs3.readFile(resourceGenerationPath(root, resource), "utf-8");
|
|
1316
|
+
return { ok: true, token };
|
|
1303
1317
|
} catch (err) {
|
|
1304
1318
|
if (isNodeError(err) && err.code === "ENOENT")
|
|
1305
|
-
return null;
|
|
1306
|
-
log.warn("generation", `Failed to read
|
|
1319
|
+
return { ok: true, token: null };
|
|
1320
|
+
log.warn("generation", `Failed to read ${resource} generation for ${root}`, {
|
|
1307
1321
|
error: getErrorMessage(err)
|
|
1308
1322
|
});
|
|
1309
|
-
return
|
|
1323
|
+
return { ok: false };
|
|
1310
1324
|
}
|
|
1311
1325
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
invalidateContentIndexesForRoot(root);
|
|
1326
|
+
function isGenerationCurrent(snapshotToken, read) {
|
|
1327
|
+
return read.ok && read.token === snapshotToken;
|
|
1315
1328
|
}
|
|
1316
1329
|
|
|
1317
|
-
// dist/
|
|
1318
|
-
import
|
|
1319
|
-
|
|
1330
|
+
// dist/branch-schema-cache.js
|
|
1331
|
+
import fs5 from "node:fs/promises";
|
|
1332
|
+
import path5 from "node:path";
|
|
1333
|
+
|
|
1334
|
+
// dist/schema/meta-loader.js
|
|
1335
|
+
import { promises as fs4 } from "fs";
|
|
1336
|
+
import { join } from "pathe";
|
|
1337
|
+
import { z } from "zod";
|
|
1338
|
+
import chokidar from "chokidar";
|
|
1339
|
+
var entryTypeMetaSchema = z.object({
|
|
1340
|
+
name: z.string().min(1),
|
|
1341
|
+
format: z.enum(["md", "mdx", "json", "yaml"]),
|
|
1342
|
+
schema: z.string().min(1),
|
|
1343
|
+
// Entry schema registry key (validated at resolution time)
|
|
1344
|
+
label: z.string().optional(),
|
|
1345
|
+
description: z.string().optional(),
|
|
1346
|
+
default: z.boolean().optional(),
|
|
1347
|
+
maxItems: z.number().int().positive().optional()
|
|
1348
|
+
});
|
|
1349
|
+
var collectionMetaSchema = z.object({
|
|
1350
|
+
name: z.string().min(1),
|
|
1351
|
+
label: z.string().optional(),
|
|
1352
|
+
description: z.string().optional(),
|
|
1353
|
+
entries: z.array(entryTypeMetaSchema).optional(),
|
|
1354
|
+
order: z.array(z.string()).default([])
|
|
1355
|
+
// Embedded IDs for ordering; optional in the file, [] = alphabetical
|
|
1356
|
+
}).refine((data) => data.entries && data.entries.length > 0, {
|
|
1357
|
+
message: "Collection must have at least one entry type"
|
|
1358
|
+
});
|
|
1359
|
+
var rootCollectionMetaSchema = z.object({
|
|
1360
|
+
label: z.string().optional(),
|
|
1361
|
+
entries: z.array(entryTypeMetaSchema).optional(),
|
|
1362
|
+
order: z.array(z.string()).optional()
|
|
1363
|
+
// Embedded IDs for ordering items
|
|
1364
|
+
});
|
|
1365
|
+
function stripEmbeddedIdFromName(name) {
|
|
1366
|
+
return extractSlugFromFilename(name);
|
|
1367
|
+
}
|
|
1368
|
+
async function scanForCollectionMeta(baseDir, relativePath = "") {
|
|
1369
|
+
const collections = [];
|
|
1320
1370
|
try {
|
|
1321
|
-
await fs4.
|
|
1322
|
-
|
|
1371
|
+
const entries = await fs4.readdir(baseDir, { withFileTypes: true });
|
|
1372
|
+
for (const entry of entries) {
|
|
1373
|
+
if (!entry.isDirectory())
|
|
1374
|
+
continue;
|
|
1375
|
+
const folderName = entry.name;
|
|
1376
|
+
const logicalName = stripEmbeddedIdFromName(folderName);
|
|
1377
|
+
const collectionContentId = extractIdFromFilename(folderName) ?? void 0;
|
|
1378
|
+
const folderPath = relativePath ? `${relativePath}/${logicalName}` : logicalName;
|
|
1379
|
+
const absolutePath = join(baseDir, folderName);
|
|
1380
|
+
const metaPath = join(absolutePath, ".collection.json");
|
|
1381
|
+
try {
|
|
1382
|
+
await fs4.access(metaPath);
|
|
1383
|
+
const content = await fs4.readFile(metaPath, "utf-8");
|
|
1384
|
+
const parsed = JSON.parse(content);
|
|
1385
|
+
const meta = collectionMetaSchema.parse(parsed);
|
|
1386
|
+
collections.push({
|
|
1387
|
+
...meta,
|
|
1388
|
+
path: folderPath,
|
|
1389
|
+
// Path derived from folder name
|
|
1390
|
+
contentId: collectionContentId
|
|
1391
|
+
});
|
|
1392
|
+
const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
|
|
1393
|
+
collections.push(...nestedCollections);
|
|
1394
|
+
} catch (err) {
|
|
1395
|
+
if (err.code !== "ENOENT") {
|
|
1396
|
+
console.error(`Error loading ${metaPath}:`, err);
|
|
1397
|
+
throw new Error(`Invalid .collection.json in ${folderPath}: ${err.message}`);
|
|
1398
|
+
}
|
|
1399
|
+
const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
|
|
1400
|
+
collections.push(...nestedCollections);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
return collections;
|
|
1323
1404
|
} catch (err) {
|
|
1324
|
-
if (
|
|
1325
|
-
return
|
|
1405
|
+
if (err.code === "ENOENT") {
|
|
1406
|
+
return [];
|
|
1407
|
+
}
|
|
1326
1408
|
throw err;
|
|
1327
1409
|
}
|
|
1328
1410
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
}
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
const
|
|
1351
|
-
|
|
1352
|
-
|
|
1411
|
+
async function loadCollectionMetaFiles(contentRoot) {
|
|
1412
|
+
let root = null;
|
|
1413
|
+
const rootMetaPath = join(contentRoot, ".collection.json");
|
|
1414
|
+
try {
|
|
1415
|
+
await fs4.access(rootMetaPath);
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
if (err.code === "ENOENT") {
|
|
1418
|
+
} else {
|
|
1419
|
+
throw err;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
try {
|
|
1423
|
+
const content = await fs4.readFile(rootMetaPath, "utf-8");
|
|
1424
|
+
const parsed = JSON.parse(content);
|
|
1425
|
+
root = rootCollectionMetaSchema.parse(parsed);
|
|
1426
|
+
} catch (err) {
|
|
1427
|
+
const errno = err.code;
|
|
1428
|
+
if (errno !== "ENOENT") {
|
|
1429
|
+
throw new Error(`Invalid root .collection.json`);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
const collections = await scanForCollectionMeta(contentRoot);
|
|
1433
|
+
return { root, collections };
|
|
1434
|
+
}
|
|
1435
|
+
function resolveEntryTypes(entryTypes, entrySchemaRegistry, contextName) {
|
|
1436
|
+
return entryTypes.map((entryType) => {
|
|
1437
|
+
const resolvedSchema = entrySchemaRegistry[entryType.schema];
|
|
1438
|
+
if (!resolvedSchema) {
|
|
1439
|
+
throw new Error(`Schema reference "${entryType.schema}" in entry type "${entryType.name}" (${contextName}) not found in registry. Available schemas: ${Object.keys(entrySchemaRegistry).join(", ")}`);
|
|
1440
|
+
}
|
|
1353
1441
|
return {
|
|
1354
|
-
|
|
1355
|
-
|
|
1442
|
+
name: entryType.name,
|
|
1443
|
+
label: entryType.label,
|
|
1444
|
+
format: entryType.format,
|
|
1445
|
+
schema: resolvedSchema,
|
|
1446
|
+
schemaRef: entryType.schema,
|
|
1447
|
+
default: entryType.default,
|
|
1448
|
+
maxItems: entryType.maxItems
|
|
1356
1449
|
};
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
function resolveCollectionMeta(meta, entrySchemaRegistry, allCollections) {
|
|
1453
|
+
const entries = meta.entries && meta.entries.length > 0 ? resolveEntryTypes(meta.entries, entrySchemaRegistry, `collection "${meta.name}"`) : void 0;
|
|
1454
|
+
const nestedCollections = allCollections.filter((col) => {
|
|
1455
|
+
return col.path.startsWith(`${meta.path}/`) && col.path.split("/").length === meta.path.split("/").length + 1;
|
|
1456
|
+
});
|
|
1457
|
+
const collections = nestedCollections.length > 0 ? nestedCollections.map((nestedMeta) => resolveCollectionMeta(nestedMeta, entrySchemaRegistry, allCollections)) : void 0;
|
|
1359
1458
|
return {
|
|
1360
|
-
|
|
1361
|
-
|
|
1459
|
+
name: meta.name,
|
|
1460
|
+
label: meta.label,
|
|
1461
|
+
path: meta.path,
|
|
1462
|
+
contentId: meta.contentId,
|
|
1463
|
+
...entries && { entries },
|
|
1464
|
+
...meta.order && { order: meta.order },
|
|
1465
|
+
...collections && { collections }
|
|
1362
1466
|
};
|
|
1363
1467
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
import path6 from "node:path";
|
|
1369
|
-
var BranchPathError = class extends Error {
|
|
1370
|
-
};
|
|
1371
|
-
function sanitizeBranchName(branchName) {
|
|
1372
|
-
const replaced = branchName.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
1373
|
-
const squashed = replaced.replace(/-+/g, "-");
|
|
1374
|
-
const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
|
|
1375
|
-
return trimmedDots || "branch";
|
|
1376
|
-
}
|
|
1377
|
-
var resolveContentBranchesRoot = (mode, override) => {
|
|
1378
|
-
return operatingStrategy(mode).getContentBranchesRoot(override);
|
|
1379
|
-
};
|
|
1380
|
-
function resolveBranchPath(options) {
|
|
1381
|
-
if (options.branchName.includes("..")) {
|
|
1382
|
-
throw new BranchPathError("Branch name cannot contain traversal segments");
|
|
1468
|
+
function resolveCollectionReferences(metaFiles, entrySchemaRegistry) {
|
|
1469
|
+
const result = {};
|
|
1470
|
+
if (metaFiles.root?.label) {
|
|
1471
|
+
result.label = metaFiles.root.label;
|
|
1383
1472
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
const
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
};
|
|
1394
|
-
if (!withinBase(branchRoot)) {
|
|
1395
|
-
throw new BranchPathError("Branch path resolves outside the base root");
|
|
1473
|
+
if (metaFiles.root?.entries && metaFiles.root.entries.length > 0) {
|
|
1474
|
+
result.entries = resolveEntryTypes(metaFiles.root.entries, entrySchemaRegistry, "root collection");
|
|
1475
|
+
}
|
|
1476
|
+
if (metaFiles.root?.order) {
|
|
1477
|
+
result.order = metaFiles.root.order;
|
|
1478
|
+
}
|
|
1479
|
+
const topLevelCollections = metaFiles.collections.filter((meta) => !meta.path.includes("/"));
|
|
1480
|
+
if (topLevelCollections.length > 0) {
|
|
1481
|
+
result.collections = topLevelCollections.map((meta) => resolveCollectionMeta(meta, entrySchemaRegistry, metaFiles.collections));
|
|
1396
1482
|
}
|
|
1397
|
-
return { branchRoot, baseRoot: normalizedBase, branchName: safeBranch };
|
|
1398
|
-
}
|
|
1399
|
-
async function ensureBranchRoot(options) {
|
|
1400
|
-
const result = resolveBranchPath(options);
|
|
1401
|
-
await fs5.mkdir(result.branchRoot, { recursive: true });
|
|
1402
1483
|
return result;
|
|
1403
1484
|
}
|
|
1404
1485
|
|
|
1405
|
-
// dist/
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1486
|
+
// dist/schema/resolver.js
|
|
1487
|
+
async function resolveSchema(contentRoot, entrySchemaRegistry) {
|
|
1488
|
+
const metaFiles = await loadCollectionMetaFiles(contentRoot);
|
|
1489
|
+
const sources = [];
|
|
1490
|
+
if (metaFiles.root) {
|
|
1491
|
+
sources.push({
|
|
1492
|
+
path: ".collection.json",
|
|
1493
|
+
type: "root",
|
|
1494
|
+
collections: []
|
|
1495
|
+
});
|
|
1410
1496
|
}
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1497
|
+
for (const collection of metaFiles.collections) {
|
|
1498
|
+
sources.push({
|
|
1499
|
+
path: `${collection.path}/.collection.json`,
|
|
1500
|
+
type: "collection",
|
|
1501
|
+
collections: [collection.name]
|
|
1502
|
+
});
|
|
1416
1503
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
if (!entries || entries.length === 0)
|
|
1420
|
-
return void 0;
|
|
1421
|
-
return entries.find((e) => e.default) || entries[0];
|
|
1504
|
+
const schema = resolveCollectionReferences(metaFiles, entrySchemaRegistry);
|
|
1505
|
+
return { schema, sources };
|
|
1422
1506
|
}
|
|
1423
|
-
function
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
if (slug.includes("\\")) {
|
|
1428
|
-
throw new ContentStoreError("Slugs cannot contain backslashes. Use nested collections instead.", "VALIDATION");
|
|
1429
|
-
}
|
|
1507
|
+
function isValidSchema(schema) {
|
|
1508
|
+
const hasEntries = !!(schema.entries && schema.entries.length > 0);
|
|
1509
|
+
const hasCollections = !!(schema.collections && schema.collections.length > 0);
|
|
1510
|
+
return hasEntries || hasCollections;
|
|
1430
1511
|
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1512
|
+
|
|
1513
|
+
// dist/branch-schema-cache.js
|
|
1514
|
+
init_flatten();
|
|
1515
|
+
|
|
1516
|
+
// dist/build-mode.js
|
|
1517
|
+
var isDeployedStatic = (config) => {
|
|
1518
|
+
return config.deployedAs === "static";
|
|
1519
|
+
};
|
|
1520
|
+
var isBuildMode = () => {
|
|
1521
|
+
if (process.env.NEXT_PHASE === "phase-production-build")
|
|
1522
|
+
return true;
|
|
1523
|
+
if (process.env.CANOPY_BUILD_MODE === "true")
|
|
1524
|
+
return true;
|
|
1525
|
+
return false;
|
|
1526
|
+
};
|
|
1527
|
+
var STATIC_DEPLOY_USER = Object.freeze({
|
|
1528
|
+
type: "authenticated",
|
|
1529
|
+
userId: "__static_deploy__",
|
|
1530
|
+
groups: ["Admins"],
|
|
1531
|
+
email: "static-deploy@canopycms",
|
|
1532
|
+
name: "Static Deploy"
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
// dist/branch-schema-cache.js
|
|
1536
|
+
var SCHEMA_CACHE_VERSION = 3;
|
|
1537
|
+
var MTIME_CHECK_DEBOUNCE_MS = 1e3;
|
|
1538
|
+
var SCHEMA_GENERATION_RESOURCE = "schema";
|
|
1539
|
+
async function isStaleByMtime(dir, cachedAt) {
|
|
1540
|
+
let entries;
|
|
1541
|
+
try {
|
|
1542
|
+
entries = await fs5.readdir(dir, { recursive: true, encoding: "utf-8" });
|
|
1543
|
+
} catch {
|
|
1544
|
+
return true;
|
|
1460
1545
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
if (diskToken !== this.loadedDiskGeneration) {
|
|
1472
|
-
this.indexGeneration++;
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
while (this.loadedIndexGeneration !== this.indexGeneration) {
|
|
1476
|
-
if (this.indexBuild) {
|
|
1477
|
-
await this.indexBuild;
|
|
1478
|
-
continue;
|
|
1479
|
-
}
|
|
1480
|
-
const generation = this.indexGeneration;
|
|
1481
|
-
const build = (async () => {
|
|
1482
|
-
const diskToken = await readContentIndexGeneration(this.root);
|
|
1483
|
-
const fresh = new ContentIdIndex(this.root);
|
|
1484
|
-
await fresh.buildFromFilenames("content");
|
|
1485
|
-
return { diskToken, fresh };
|
|
1486
|
-
})();
|
|
1487
|
-
this.indexBuild = build;
|
|
1488
|
-
let result;
|
|
1489
|
-
try {
|
|
1490
|
-
result = await build;
|
|
1491
|
-
} finally {
|
|
1492
|
-
this.indexBuild = null;
|
|
1493
|
-
}
|
|
1494
|
-
this._idIndex = result.fresh;
|
|
1495
|
-
this.loadedIndexGeneration = generation;
|
|
1496
|
-
this.loadedDiskGeneration = result.diskToken;
|
|
1497
|
-
this.lastDiskProbeMs = Date.now();
|
|
1546
|
+
for (const entry of entries) {
|
|
1547
|
+
if (!entry.endsWith(".collection.json"))
|
|
1548
|
+
continue;
|
|
1549
|
+
const full = path5.join(dir, entry);
|
|
1550
|
+
try {
|
|
1551
|
+
const stat = await fs5.stat(full);
|
|
1552
|
+
if (stat.mtimeMs > cachedAt.getTime())
|
|
1553
|
+
return true;
|
|
1554
|
+
} catch {
|
|
1555
|
+
return true;
|
|
1498
1556
|
}
|
|
1499
|
-
|
|
1557
|
+
}
|
|
1558
|
+
return false;
|
|
1559
|
+
}
|
|
1560
|
+
var BranchSchemaCache = class {
|
|
1561
|
+
constructor(mode = "prod") {
|
|
1562
|
+
this.lastMtimeCheck = /* @__PURE__ */ new Map();
|
|
1563
|
+
this.devMode = mode === "dev";
|
|
1500
1564
|
}
|
|
1501
1565
|
/**
|
|
1502
|
-
*
|
|
1503
|
-
*
|
|
1504
|
-
*
|
|
1566
|
+
* Whether to skip the on-disk cache for this branchRoot.
|
|
1567
|
+
*
|
|
1568
|
+
* branchRoot equals the project root (process.cwd()) only in the synthetic
|
|
1569
|
+
* contexts used by static deployments and build phases — real branch roots are
|
|
1570
|
+
* always nested under the workspace (.canopy-dev/content-branches/<name> in dev,
|
|
1571
|
+
* <workspaceRoot>/content-branches/<name> in prod). Never write .canopy-meta/ at
|
|
1572
|
+
* the project root, regardless of which entrypoint produced the cwd branchRoot.
|
|
1505
1573
|
*/
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
if (now - this.lastDiskProbeMs < this.indexFreshnessIntervalMs)
|
|
1509
|
-
return false;
|
|
1510
|
-
this.lastDiskProbeMs = now;
|
|
1511
|
-
return true;
|
|
1574
|
+
skipDiskCache(branchRoot) {
|
|
1575
|
+
return isBuildMode() || path5.resolve(branchRoot) === path5.resolve(process.cwd());
|
|
1512
1576
|
}
|
|
1513
1577
|
/**
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1519
|
-
*
|
|
1520
|
-
* we leave the recorded token older and let the next probe observe our bump
|
|
1521
|
-
* and trigger the healing rebuild.
|
|
1578
|
+
* Get schema for a branch (loads from cache or resolves fresh).
|
|
1579
|
+
*
|
|
1580
|
+
* @param branchRoot - Root directory of the branch (e.g., .canopy-dev/content-branches/main)
|
|
1581
|
+
* @param entrySchemaRegistry - Map of schema names to field definitions
|
|
1582
|
+
* @param contentRootName - Name of content directory (e.g., "content") from config
|
|
1583
|
+
* @returns Resolved schema tree and flattened schema
|
|
1522
1584
|
*/
|
|
1523
|
-
async
|
|
1524
|
-
|
|
1525
|
-
if (token !== null && this.indexBuild === null && this.loadedIndexGeneration === this.indexGeneration && this._idIndex === updatedIndex) {
|
|
1526
|
-
this.loadedDiskGeneration = token;
|
|
1527
|
-
}
|
|
1585
|
+
async getSchema(branchRoot, entrySchemaRegistry, contentRootName = "content") {
|
|
1586
|
+
return this.loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName);
|
|
1528
1587
|
}
|
|
1529
1588
|
/**
|
|
1530
|
-
*
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
1533
|
-
*
|
|
1534
|
-
* throttle how often a caller can force one. Time-boxed so genuinely
|
|
1535
|
-
* dangling IDs cost at most one rescan per window. Returns true if a
|
|
1536
|
-
* refresh was performed by THIS call; callers that lose the throttle race
|
|
1537
|
-
* still benefit — idIndex() dedupes concurrent builds, so a caller that won
|
|
1538
|
-
* the race rebuilds the index for everyone, and callers should still retry
|
|
1539
|
-
* their lookup against the live index regardless of this return value.
|
|
1589
|
+
* Resolve the schema from disk. Wrapped in a protected method (rather than
|
|
1590
|
+
* calling the imported resolveSchema directly) so tests can subclass and
|
|
1591
|
+
* override with a deferred/blocking implementation to simulate cross-process
|
|
1592
|
+
* interleavings — mirrors BranchRegistry's scanBranchDirectories() hook.
|
|
1540
1593
|
*/
|
|
1541
|
-
async
|
|
1542
|
-
|
|
1543
|
-
if (now - this.lastForcedRefreshMs < FORCED_REFRESH_MIN_INTERVAL_MS)
|
|
1544
|
-
return false;
|
|
1545
|
-
this.lastForcedRefreshMs = now;
|
|
1546
|
-
this.invalidateIndex();
|
|
1547
|
-
await this.idIndex();
|
|
1548
|
-
return true;
|
|
1594
|
+
async resolveFresh(contentRoot, entrySchemaRegistry) {
|
|
1595
|
+
return resolveSchema(contentRoot, entrySchemaRegistry);
|
|
1549
1596
|
}
|
|
1550
1597
|
/**
|
|
1551
|
-
*
|
|
1552
|
-
* Returns its root-relative path, or null if no such file (or no such dir).
|
|
1598
|
+
* Load schema from cache or resolve fresh if cache is missing or stale.
|
|
1553
1599
|
*/
|
|
1554
|
-
async
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1600
|
+
async loadFromCacheOrResolve(branchRoot, entrySchemaRegistry, contentRootName) {
|
|
1601
|
+
const contentRoot = path5.join(branchRoot, contentRootName);
|
|
1602
|
+
const skipDiskCache = this.skipDiskCache(branchRoot);
|
|
1603
|
+
if (!skipDiskCache) {
|
|
1604
|
+
const cacheDir = path5.join(branchRoot, ".canopy-meta");
|
|
1605
|
+
const cachePath = path5.join(cacheDir, "schema-cache.json");
|
|
1606
|
+
let cacheData = null;
|
|
1607
|
+
try {
|
|
1608
|
+
const cacheContent = await fs5.readFile(cachePath, "utf-8");
|
|
1609
|
+
cacheData = JSON.parse(cacheContent);
|
|
1610
|
+
} catch {
|
|
1611
|
+
cacheData = null;
|
|
1612
|
+
}
|
|
1613
|
+
if (cacheData && cacheData.version === SCHEMA_CACHE_VERSION) {
|
|
1614
|
+
const read = await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
|
|
1615
|
+
if (isGenerationCurrent(cacheData.generation, read)) {
|
|
1616
|
+
const now = Date.now();
|
|
1617
|
+
const lastCheck = this.lastMtimeCheck.get(contentRoot) ?? 0;
|
|
1618
|
+
if (this.devMode && now - lastCheck >= MTIME_CHECK_DEBOUNCE_MS && await isStaleByMtime(contentRoot, new Date(cacheData.cachedAt))) {
|
|
1619
|
+
this.lastMtimeCheck.set(contentRoot, now);
|
|
1620
|
+
cacheData = null;
|
|
1621
|
+
} else {
|
|
1622
|
+
if (this.devMode)
|
|
1623
|
+
this.lastMtimeCheck.set(contentRoot, now);
|
|
1624
|
+
return { schema: cacheData.schema, flatSchema: cacheData.flatSchema };
|
|
1625
|
+
}
|
|
1626
|
+
} else {
|
|
1627
|
+
cacheData = null;
|
|
1628
|
+
}
|
|
1629
|
+
} else {
|
|
1630
|
+
cacheData = null;
|
|
1568
1631
|
}
|
|
1569
1632
|
}
|
|
1570
|
-
return
|
|
1633
|
+
return this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
|
|
1634
|
+
skipDiskCache
|
|
1635
|
+
});
|
|
1571
1636
|
}
|
|
1572
1637
|
/**
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
1638
|
+
* Resolve the schema fresh from disk and persist it (subject to the
|
|
1639
|
+
* skip-persist rule below), UNCONDITIONALLY -- never through the cache-read
|
|
1640
|
+
* fast path in {@link loadFromCacheOrResolve}. Shared by that method's
|
|
1641
|
+
* cache-miss fallback and by {@link resolveAndPersist}.
|
|
1575
1642
|
*/
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
const
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
throw new ContentStoreError(`Unknown schema item: ${path18}`, "NO_SCHEMA_ITEM");
|
|
1643
|
+
async resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, options) {
|
|
1644
|
+
const { skipDiskCache } = options;
|
|
1645
|
+
const contentRoot = path5.join(branchRoot, contentRootName);
|
|
1646
|
+
const read = skipDiskCache ? null : await readResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE);
|
|
1647
|
+
const result = await this.resolveFresh(contentRoot, entrySchemaRegistry);
|
|
1648
|
+
if (!isValidSchema(result.schema)) {
|
|
1649
|
+
throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
|
|
1584
1650
|
}
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1651
|
+
const flatSchema = flattenSchema(result.schema, contentRootName);
|
|
1652
|
+
if (!skipDiskCache) {
|
|
1653
|
+
const cacheDir = path5.join(branchRoot, ".canopy-meta");
|
|
1654
|
+
const cachePath = path5.join(cacheDir, "schema-cache.json");
|
|
1655
|
+
await fs5.unlink(path5.join(cacheDir, "schema-cache.stale")).catch(() => {
|
|
1656
|
+
});
|
|
1657
|
+
if (read && read.ok) {
|
|
1658
|
+
const newCache = {
|
|
1659
|
+
version: SCHEMA_CACHE_VERSION,
|
|
1660
|
+
schema: result.schema,
|
|
1661
|
+
flatSchema,
|
|
1662
|
+
cachedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1663
|
+
generation: read.token
|
|
1664
|
+
};
|
|
1665
|
+
await fs5.mkdir(cacheDir, { recursive: true });
|
|
1666
|
+
const tmpPath = path5.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
|
|
1667
|
+
await fs5.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
|
|
1668
|
+
try {
|
|
1669
|
+
await fs5.rename(tmpPath, cachePath);
|
|
1670
|
+
} catch (err) {
|
|
1671
|
+
await fs5.unlink(tmpPath).catch(() => {
|
|
1672
|
+
});
|
|
1673
|
+
throw err;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1591
1676
|
}
|
|
1592
|
-
return
|
|
1677
|
+
return { schema: result.schema, flatSchema };
|
|
1593
1678
|
}
|
|
1594
1679
|
/**
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1680
|
+
* Resolve the schema fresh from disk and persist it, SKIPPING the cache
|
|
1681
|
+
* read entirely -- unlike {@link getSchema}, which can short-circuit
|
|
1682
|
+
* through a cache HIT. That distinction matters for eager re-resolve after
|
|
1683
|
+
* invalidation (see {@link invalidate}'s doc comment and
|
|
1684
|
+
* `SchemaOps.invalidateSchemaCache()` in schema/schema-store.ts, the sole
|
|
1685
|
+
* intended caller): the whole point of an eager re-resolve is to guarantee
|
|
1686
|
+
* ONE scan that is coherent with the mutation this host just made. A plain
|
|
1687
|
+
* `getSchema()` call right after `invalidate()` is NOT that guarantee --
|
|
1688
|
+
* `getSchema()`'s cache-read fast path (`loadFromCacheOrResolve`) can still
|
|
1689
|
+
* return a snapshot written by a DIFFERENT, concurrent host: if that
|
|
1690
|
+
* foreign host's own eager re-resolve raced this one and embedded the
|
|
1691
|
+
* (now-current) marker token over ITS OWN stale-NFS-cache scan (the
|
|
1692
|
+
* window-E case this class's doc comment describes), this host's
|
|
1693
|
+
* `getSchema()` would happily accept that foreign snapshot as "current"
|
|
1694
|
+
* and skip the one scan this call was specifically trying to guarantee.
|
|
1695
|
+
* `resolveAndPersist()` never reads the cache file at all, so it cannot be
|
|
1696
|
+
* short-circuited that way.
|
|
1697
|
+
*/
|
|
1698
|
+
async resolveAndPersist(branchRoot, entrySchemaRegistry, contentRootName = "content") {
|
|
1699
|
+
const skipDiskCache = this.skipDiskCache(branchRoot);
|
|
1700
|
+
return this.resolveFreshAndPersist(branchRoot, entrySchemaRegistry, contentRootName, {
|
|
1701
|
+
skipDiskCache
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Invalidate cache for a branch by bumping the cross-process generation
|
|
1706
|
+
* marker (resource-generation.ts). Every process sharing this branchRoot
|
|
1707
|
+
* will re-resolve at its next read.
|
|
1597
1708
|
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1601
|
-
* 3. Using path.resolve to normalize paths before validation
|
|
1709
|
+
* The bump must succeed: a swallowed failure here would leave the schema
|
|
1710
|
+
* cache stale indefinitely with no bounding backstop (unlike
|
|
1711
|
+
* BranchRegistry, there is no get-miss backstop for a resolved schema).
|
|
1602
1712
|
*
|
|
1603
|
-
*
|
|
1604
|
-
*
|
|
1713
|
+
* No eager regeneration here (unlike BranchRegistry.invalidate()): this
|
|
1714
|
+
* method isn't given the entrySchemaRegistry/contentRootName resolveSchema
|
|
1715
|
+
* needs. See the class doc comment for why the mutating request's own
|
|
1716
|
+
* follow-up schema read closes the same window-E gap instead.
|
|
1605
1717
|
*
|
|
1606
|
-
* @param
|
|
1607
|
-
* @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
|
|
1718
|
+
* @param branchRoot - Root directory of the branch
|
|
1608
1719
|
*/
|
|
1609
|
-
async
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1720
|
+
async invalidate(branchRoot) {
|
|
1721
|
+
if (this.skipDiskCache(branchRoot))
|
|
1722
|
+
return;
|
|
1723
|
+
await bumpResourceGeneration(branchRoot, SCHEMA_GENERATION_RESOURCE, { mustSucceed: true });
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
|
|
1727
|
+
// dist/content-index-generation.js
|
|
1728
|
+
var RESOURCE = "content-index";
|
|
1729
|
+
async function bumpContentIndexGeneration(root) {
|
|
1730
|
+
return bumpResourceGeneration(root, RESOURCE);
|
|
1731
|
+
}
|
|
1732
|
+
async function readContentIndexGeneration(root) {
|
|
1733
|
+
const result = await readResourceGeneration(root, RESOURCE);
|
|
1734
|
+
return result.ok ? result.token : null;
|
|
1735
|
+
}
|
|
1736
|
+
async function invalidateBranchContentCaches(root) {
|
|
1737
|
+
await bumpContentIndexGeneration(root);
|
|
1738
|
+
await bumpResourceGeneration(root, SCHEMA_GENERATION_RESOURCE);
|
|
1739
|
+
invalidateContentIndexesForRoot(root);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// dist/utils/fs.js
|
|
1743
|
+
import fs6 from "node:fs/promises";
|
|
1744
|
+
async function filePathExists(filePath) {
|
|
1745
|
+
try {
|
|
1746
|
+
await fs6.stat(filePath);
|
|
1747
|
+
return true;
|
|
1748
|
+
} catch (err) {
|
|
1749
|
+
if (isNotFoundError(err))
|
|
1750
|
+
return false;
|
|
1751
|
+
throw err;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// dist/utils/format.js
|
|
1756
|
+
var getFormatExtension = (format) => {
|
|
1757
|
+
if (format === "md")
|
|
1758
|
+
return ".md";
|
|
1759
|
+
if (format === "mdx")
|
|
1760
|
+
return ".mdx";
|
|
1761
|
+
if (format === "yaml")
|
|
1762
|
+
return ".yaml";
|
|
1763
|
+
return ".json";
|
|
1764
|
+
};
|
|
1765
|
+
var isDataOnlyFormat = (format) => format === "json" || format === "yaml";
|
|
1766
|
+
var asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1767
|
+
|
|
1768
|
+
// dist/paths/index.js
|
|
1769
|
+
init_normalize();
|
|
1770
|
+
|
|
1771
|
+
// dist/paths/normalize-server.js
|
|
1772
|
+
import { sep, resolve, relative } from "node:path";
|
|
1773
|
+
function validateAndNormalizePath(root, target) {
|
|
1774
|
+
const resolvedRoot = resolve(root);
|
|
1775
|
+
const withSep = resolvedRoot.endsWith(sep) ? resolvedRoot : `${resolvedRoot}${sep}`;
|
|
1776
|
+
const resolvedTarget = resolve(target);
|
|
1777
|
+
if (!resolvedTarget.startsWith(withSep) && resolvedTarget !== resolvedRoot) {
|
|
1778
|
+
return {
|
|
1779
|
+
valid: false,
|
|
1780
|
+
error: "Path traversal detected"
|
|
1781
|
+
};
|
|
1782
|
+
}
|
|
1783
|
+
const relativePath = relative(resolvedRoot, resolvedTarget).split(sep).join("/");
|
|
1784
|
+
return {
|
|
1785
|
+
valid: true,
|
|
1786
|
+
normalizedPath: relativePath
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// dist/paths/branch.js
|
|
1791
|
+
init_operating_mode();
|
|
1792
|
+
import fs7 from "node:fs/promises";
|
|
1793
|
+
import path7 from "node:path";
|
|
1794
|
+
var BranchPathError = class extends Error {
|
|
1795
|
+
};
|
|
1796
|
+
function sanitizeBranchName(branchName) {
|
|
1797
|
+
const replaced = branchName.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
1798
|
+
const squashed = replaced.replace(/-+/g, "-");
|
|
1799
|
+
const trimmedDots = squashed.replace(/^\.+/, "").replace(/(?<!\.)\.+$/, "");
|
|
1800
|
+
return trimmedDots || "branch";
|
|
1801
|
+
}
|
|
1802
|
+
var resolveContentBranchesRoot = (mode, override) => {
|
|
1803
|
+
return operatingStrategy(mode).getContentBranchesRoot(override);
|
|
1804
|
+
};
|
|
1805
|
+
function resolveBranchPath(options) {
|
|
1806
|
+
if (options.branchName.includes("..")) {
|
|
1807
|
+
throw new BranchPathError("Branch name cannot contain traversal segments");
|
|
1808
|
+
}
|
|
1809
|
+
const safeBranch = sanitizeBranchName(options.branchName);
|
|
1810
|
+
const strategy = operatingStrategy(options.mode);
|
|
1811
|
+
const baseRoot = resolveContentBranchesRoot(options.mode, options.basePathOverride);
|
|
1812
|
+
const normalizedBase = path7.resolve(baseRoot);
|
|
1813
|
+
const baseWithSep = normalizedBase.endsWith(path7.sep) ? normalizedBase : `${normalizedBase}${path7.sep}`;
|
|
1814
|
+
const branchRoot = strategy.getContentBranchRoot(safeBranch, options.basePathOverride);
|
|
1815
|
+
const withinBase = (target) => {
|
|
1816
|
+
const resolved = path7.resolve(target);
|
|
1817
|
+
return resolved === normalizedBase || resolved.startsWith(baseWithSep);
|
|
1818
|
+
};
|
|
1819
|
+
if (!withinBase(branchRoot)) {
|
|
1820
|
+
throw new BranchPathError("Branch path resolves outside the base root");
|
|
1821
|
+
}
|
|
1822
|
+
return { branchRoot, baseRoot: normalizedBase, branchName: safeBranch };
|
|
1823
|
+
}
|
|
1824
|
+
async function ensureBranchRoot(options) {
|
|
1825
|
+
const result = resolveBranchPath(options);
|
|
1826
|
+
await fs7.mkdir(result.branchRoot, { recursive: true });
|
|
1827
|
+
return result;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// dist/content-store.js
|
|
1831
|
+
async function withLocks(keys, fn) {
|
|
1832
|
+
const sorted = Array.from(new Set(keys)).sort();
|
|
1833
|
+
const acquireFrom = (index) => index === sorted.length ? fn() : withLock(sorted[index], () => acquireFrom(index + 1));
|
|
1834
|
+
return acquireFrom(0);
|
|
1835
|
+
}
|
|
1836
|
+
var ContentStoreError = class extends Error {
|
|
1837
|
+
constructor(message, code) {
|
|
1838
|
+
super(message);
|
|
1839
|
+
this.code = code;
|
|
1840
|
+
}
|
|
1841
|
+
};
|
|
1842
|
+
var ContentConflictError = class extends Error {
|
|
1843
|
+
constructor() {
|
|
1844
|
+
super("Content was modified by another editor");
|
|
1845
|
+
this.name = "ContentConflictError";
|
|
1846
|
+
}
|
|
1847
|
+
};
|
|
1848
|
+
function getDefaultEntryType(entries) {
|
|
1849
|
+
if (!entries || entries.length === 0)
|
|
1850
|
+
return void 0;
|
|
1851
|
+
return entries.find((e) => e.default) || entries[0];
|
|
1852
|
+
}
|
|
1853
|
+
function validateSlug(slug) {
|
|
1854
|
+
if (slug.includes("/")) {
|
|
1855
|
+
throw new ContentStoreError("Slugs cannot contain forward slashes. Use nested collections instead.", "VALIDATION");
|
|
1856
|
+
}
|
|
1857
|
+
if (slug.includes("\\")) {
|
|
1858
|
+
throw new ContentStoreError("Slugs cannot contain backslashes. Use nested collections instead.", "VALIDATION");
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
var DEFAULT_INDEX_FRESHNESS_INTERVAL_MS = 1e3;
|
|
1862
|
+
var FORCED_REFRESH_MIN_INTERVAL_MS = 5e3;
|
|
1863
|
+
var STALE_LOOKUP = Symbol("stale-index-lookup");
|
|
1864
|
+
var ContentStore = class {
|
|
1865
|
+
constructor(root, flatSchema, options = {}) {
|
|
1866
|
+
this.indexGeneration = 0;
|
|
1867
|
+
this.loadedIndexGeneration = -1;
|
|
1868
|
+
this.loadedDiskGeneration = void 0;
|
|
1869
|
+
this.lastDiskProbeMs = 0;
|
|
1870
|
+
this.lastForcedRefreshMs = 0;
|
|
1871
|
+
this.indexBuild = null;
|
|
1872
|
+
this.root = path8.resolve(root);
|
|
1873
|
+
this.indexFreshnessIntervalMs = options.indexFreshnessIntervalMs ?? DEFAULT_INDEX_FRESHNESS_INTERVAL_MS;
|
|
1874
|
+
this.schemaIndex = new Map(flatSchema.map((item) => [item.logicalPath, item]));
|
|
1875
|
+
this._idIndex = new ContentIdIndex(this.root);
|
|
1876
|
+
registerContentIndexForInvalidation(this.root, this);
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Mark the ID index stale so the next idIndex() access rebuilds it from disk.
|
|
1880
|
+
*
|
|
1881
|
+
* Called (via content-index-registry) after in-process operations that change the
|
|
1882
|
+
* working tree underneath this store — git checkout/merge/rebase, the worker's
|
|
1883
|
+
* rebase loop, and sync-core's content replacement. Without this, ID→path lookups
|
|
1884
|
+
* keep resolving to pre-mutation paths and saves can target moved/deleted files.
|
|
1885
|
+
*
|
|
1886
|
+
* Cheap: only bumps a generation counter; the rebuild happens lazily.
|
|
1887
|
+
*/
|
|
1888
|
+
invalidateIndex() {
|
|
1889
|
+
this.indexGeneration++;
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Get the ID index, ensuring it's loaded and current first.
|
|
1893
|
+
* Loads lazily on first access and rebuilds after invalidateIndex(); repeated
|
|
1894
|
+
* accesses with no intervening invalidation reuse the already-built index,
|
|
1895
|
+
* except for a throttled probe of the on-disk generation marker that detects
|
|
1896
|
+
* mutations made by OTHER processes sharing this root (e.g. on EFS).
|
|
1897
|
+
*/
|
|
1898
|
+
async idIndex() {
|
|
1899
|
+
if (this.loadedIndexGeneration === this.indexGeneration && this.shouldProbeDiskGeneration()) {
|
|
1900
|
+
const diskToken = await readContentIndexGeneration(this.root);
|
|
1901
|
+
if (diskToken !== this.loadedDiskGeneration) {
|
|
1902
|
+
this.indexGeneration++;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
while (this.loadedIndexGeneration !== this.indexGeneration) {
|
|
1906
|
+
if (this.indexBuild) {
|
|
1907
|
+
await this.indexBuild;
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
const generation = this.indexGeneration;
|
|
1911
|
+
const build = (async () => {
|
|
1912
|
+
const diskToken = await readContentIndexGeneration(this.root);
|
|
1913
|
+
const fresh = new ContentIdIndex(this.root);
|
|
1914
|
+
await fresh.buildFromFilenames("content");
|
|
1915
|
+
return { diskToken, fresh };
|
|
1916
|
+
})();
|
|
1917
|
+
this.indexBuild = build;
|
|
1918
|
+
let result;
|
|
1919
|
+
try {
|
|
1920
|
+
result = await build;
|
|
1921
|
+
} finally {
|
|
1922
|
+
this.indexBuild = null;
|
|
1923
|
+
}
|
|
1924
|
+
this._idIndex = result.fresh;
|
|
1925
|
+
this.loadedIndexGeneration = generation;
|
|
1926
|
+
this.loadedDiskGeneration = result.diskToken;
|
|
1927
|
+
this.lastDiskProbeMs = Date.now();
|
|
1928
|
+
}
|
|
1929
|
+
return this._idIndex;
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Throttle for the on-disk marker probe. Stamps the clock synchronously
|
|
1933
|
+
* before the caller's readFile so concurrent idIndex() callers don't
|
|
1934
|
+
* double-probe (and double-rebuild) on the same token change.
|
|
1935
|
+
*/
|
|
1936
|
+
shouldProbeDiskGeneration() {
|
|
1937
|
+
const now = Date.now();
|
|
1938
|
+
if (now - this.lastDiskProbeMs < this.indexFreshnessIntervalMs)
|
|
1939
|
+
return false;
|
|
1940
|
+
this.lastDiskProbeMs = now;
|
|
1941
|
+
return true;
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* After one of this store's own successful mutations: publish the change to
|
|
1945
|
+
* other processes (bump the on-disk marker) and adopt the written token,
|
|
1946
|
+
* since the in-memory index was already updated incrementally — avoiding a
|
|
1947
|
+
* pointless self-rescan on the next probe. Adoption is skipped unless the
|
|
1948
|
+
* index is quiescent and `updatedIndex` is still the live instance: if a
|
|
1949
|
+
* rebuild raced with the mutation, its scan may predate our file change, so
|
|
1950
|
+
* we leave the recorded token older and let the next probe observe our bump
|
|
1951
|
+
* and trigger the healing rebuild.
|
|
1952
|
+
*/
|
|
1953
|
+
async recordOwnMutation(updatedIndex) {
|
|
1954
|
+
const token = await bumpContentIndexGeneration(this.root);
|
|
1955
|
+
if (token !== null && this.indexBuild === null && this.loadedIndexGeneration === this.indexGeneration && this._idIndex === updatedIndex) {
|
|
1956
|
+
this.loadedDiskGeneration = token;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Backstop for the residual staleness windows of the cross-process marker
|
|
1961
|
+
* (NFS attribute caching, probe throttle, self-adoption — see
|
|
1962
|
+
* content-index-generation.ts): force one rebuild in response to a
|
|
1963
|
+
* suspicious lookup (an ID miss, or an index hit whose file is gone), but
|
|
1964
|
+
* throttle how often a caller can force one. Time-boxed so genuinely
|
|
1965
|
+
* dangling IDs cost at most one rescan per window. Returns true if a
|
|
1966
|
+
* refresh was performed by THIS call; callers that lose the throttle race
|
|
1967
|
+
* still benefit — idIndex() dedupes concurrent builds, so a caller that won
|
|
1968
|
+
* the race rebuilds the index for everyone, and callers should still retry
|
|
1969
|
+
* their lookup against the live index regardless of this return value.
|
|
1970
|
+
*/
|
|
1971
|
+
async refreshIndexForSuspiciousLookup() {
|
|
1972
|
+
const now = Date.now();
|
|
1973
|
+
if (now - this.lastForcedRefreshMs < FORCED_REFRESH_MIN_INTERVAL_MS)
|
|
1974
|
+
return false;
|
|
1975
|
+
this.lastForcedRefreshMs = now;
|
|
1976
|
+
this.invalidateIndex();
|
|
1977
|
+
await this.idIndex();
|
|
1978
|
+
return true;
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Find the file in `dir` whose filename embeds `id`.
|
|
1982
|
+
* Returns its root-relative path, or null if no such file (or no such dir).
|
|
1983
|
+
*/
|
|
1984
|
+
async findEntryPathById(dir, id) {
|
|
1985
|
+
let entries;
|
|
1986
|
+
try {
|
|
1987
|
+
entries = await fs8.readdir(dir, { withFileTypes: true });
|
|
1988
|
+
} catch (err) {
|
|
1989
|
+
if (isNodeError(err) && err.code === "ENOENT")
|
|
1990
|
+
return null;
|
|
1991
|
+
throw err;
|
|
1992
|
+
}
|
|
1993
|
+
for (const entry of entries) {
|
|
1994
|
+
if (entry.isDirectory())
|
|
1995
|
+
continue;
|
|
1996
|
+
if (extractIdFromFilename(entry.name) === id) {
|
|
1997
|
+
return path8.relative(this.root, path8.join(dir, entry.name));
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return null;
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Get all schema items for iteration.
|
|
2004
|
+
* Used internally by ReferenceResolver for path matching.
|
|
2005
|
+
*/
|
|
2006
|
+
getSchemaItems() {
|
|
2007
|
+
return this.schemaIndex.values();
|
|
2008
|
+
}
|
|
2009
|
+
assertSchemaItem(path19) {
|
|
2010
|
+
const normalized = normalizeFilesystemPath(path19);
|
|
2011
|
+
const item = this.schemaIndex.get(normalized);
|
|
2012
|
+
if (!item) {
|
|
2013
|
+
throw new ContentStoreError(`Unknown schema item: ${path19}`, "NO_SCHEMA_ITEM");
|
|
2014
|
+
}
|
|
2015
|
+
return item;
|
|
2016
|
+
}
|
|
2017
|
+
assertCollection(collectionPath) {
|
|
2018
|
+
const item = this.assertSchemaItem(collectionPath);
|
|
2019
|
+
if (item.type !== "collection") {
|
|
2020
|
+
throw new ContentStoreError(`Path is not a collection: ${collectionPath}`, "NO_SCHEMA_ITEM");
|
|
2021
|
+
}
|
|
2022
|
+
return item;
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Lock key for an existing entry, addressed by its permanent content ID
|
|
2026
|
+
* (stable across renames -- the whole point of this locking scheme; see
|
|
2027
|
+
* .claude/future-tasks/resolved/content-store-lock-key.md). Namespaced by store
|
|
2028
|
+
* root: the same content ID exists in every clone of a branch, and one
|
|
2029
|
+
* process can hold ContentStore instances on several clones (dev
|
|
2030
|
+
* workspace roots, prod branch clones) at once, so the root prefix keeps
|
|
2031
|
+
* the keyspace scoped per-store. It also keeps this ID keyspace disjoint
|
|
2032
|
+
* from other modules' raw-path-keyed locks sharing the same withLock()
|
|
2033
|
+
* map (comment-store, branch-metadata, etc).
|
|
2034
|
+
*/
|
|
2035
|
+
idLockKey(id) {
|
|
2036
|
+
return `${this.root}:id:${id}`;
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* Stable collection+slug identifier for lock-key namespacing. Mirrors the
|
|
2040
|
+
* schemaItem resolution buildPaths() performs for entry-type delegation:
|
|
2041
|
+
* an entry-type item delegates to its parent collection with the slug
|
|
2042
|
+
* defaulting to the entry type's own name (see buildPaths()'s
|
|
2043
|
+
* `schemaItem.type === 'entry-type'` branch).
|
|
2044
|
+
*/
|
|
2045
|
+
collectionSlugKey(schemaItem, slug) {
|
|
2046
|
+
if (schemaItem.type === "entry-type") {
|
|
2047
|
+
return `${schemaItem.parentPath}/${slug || schemaItem.name}`;
|
|
2048
|
+
}
|
|
2049
|
+
return `${schemaItem.logicalPath}/${slug}`;
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Lock key for a not-yet-existing entry (a create). Serializes concurrent
|
|
2053
|
+
* same-slug creates WITHIN this process: the second call's in-lock
|
|
2054
|
+
* buildPaths() re-resolution will find the first call's just-written file
|
|
2055
|
+
* and fold in as an edit (see write()). A concurrent create racing from a
|
|
2056
|
+
* DIFFERENT process is NOT covered by this in-process mutex -- accepted
|
|
2057
|
+
* per the epic's design review: each writer mints its own fresh ID and
|
|
2058
|
+
* writes its own distinct filename, so both writes succeed and the result
|
|
2059
|
+
* is two same-slug files with different IDs (a slug-uniqueness violation
|
|
2060
|
+
* surfaced on the next listing/lookup), never a duplicate-ID collision
|
|
2061
|
+
* that would poison index rebuilds.
|
|
2062
|
+
*/
|
|
2063
|
+
createLockKey(schemaItem, slug) {
|
|
2064
|
+
return `${this.root}:create:${this.collectionSlugKey(schemaItem, slug)}`;
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Lock key for a write()/delete() pre-pass classification. Existing
|
|
2068
|
+
* entries lock on their content ID (idLockKey()). Legacy entries with no
|
|
2069
|
+
* embedded ID (pre-ID-era filenames -- renameEntry() already refuses to
|
|
2070
|
+
* touch these, via its four-part filename check, so there is no rename
|
|
2071
|
+
* race to guard against for them) fall back to the physical path, matching
|
|
2072
|
+
* this store's original locking behavior for that narrow case. Not-yet-
|
|
2073
|
+
* existing entries lock on a per-slug create-key (createLockKey()).
|
|
2074
|
+
*/
|
|
2075
|
+
entryLockKey(schemaItem, slug, prePass) {
|
|
2076
|
+
if (!prePass.existed)
|
|
2077
|
+
return this.createLockKey(schemaItem, slug);
|
|
2078
|
+
return prePass.id ? this.idLockKey(prePass.id) : prePass.absolutePath;
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Build absolute and relative paths with security validation.
|
|
2082
|
+
* All entries use the unified filename pattern: {type}.{slug}.{id}.{ext}
|
|
2083
|
+
*
|
|
2084
|
+
* SECURITY BOUNDARY: This method prevents path traversal attacks by:
|
|
2085
|
+
* 1. Validating that resolved paths stay within the content root
|
|
2086
|
+
* 2. Checking slugs for malicious patterns (via validateSlug)
|
|
2087
|
+
* 3. Using path.resolve to normalize paths before validation
|
|
2088
|
+
*
|
|
2089
|
+
* This validation is performed BEFORE file I/O in resolveDocumentPath(),
|
|
2090
|
+
* ensuring permission checks happen before any file system access.
|
|
2091
|
+
*
|
|
2092
|
+
* @param options.existingId - Optional ID to use (for edits). If not provided, generates new ID.
|
|
2093
|
+
* @param options.entryTypeName - For collections with multiple entry types, specify which one to use. Defaults to the default entry type.
|
|
2094
|
+
*/
|
|
2095
|
+
async buildPaths(schemaItem, slug, options = {}) {
|
|
2096
|
+
const rootWithSep = this.root.endsWith(path8.sep) ? this.root : `${this.root}${path8.sep}`;
|
|
2097
|
+
if (schemaItem.type === "entry-type") {
|
|
2098
|
+
const parentPath = schemaItem.parentPath || "";
|
|
2099
|
+
const parentCollection = this.schemaIndex.get(parentPath);
|
|
2100
|
+
if (!parentCollection || parentCollection.type !== "collection") {
|
|
2101
|
+
throw new ContentStoreError(`Parent collection not found for entry type: ${schemaItem.name}`, "NO_SCHEMA_ITEM");
|
|
2102
|
+
}
|
|
2103
|
+
const effectiveSlug = slug || schemaItem.name;
|
|
2104
|
+
return this.buildPaths(parentCollection, effectiveSlug, {
|
|
2105
|
+
...options,
|
|
2106
|
+
entryTypeName: schemaItem.name
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
if (schemaItem.type === "collection") {
|
|
2110
|
+
const safeSlug = slug.replace(/^\/+/, "").toLowerCase();
|
|
2111
|
+
if (!safeSlug) {
|
|
2112
|
+
throw new ContentStoreError("Slug is required for collection entries", "VALIDATION");
|
|
2113
|
+
}
|
|
2114
|
+
validateSlug(safeSlug);
|
|
2115
|
+
let entryTypeConfig;
|
|
2116
|
+
if (options.entryTypeName) {
|
|
2117
|
+
entryTypeConfig = schemaItem.entries?.find((e) => e.name === options.entryTypeName);
|
|
2118
|
+
if (!entryTypeConfig) {
|
|
2119
|
+
throw new ContentStoreError(`Entry type '${options.entryTypeName}' not found in collection`, "NO_SCHEMA_ITEM");
|
|
2120
|
+
}
|
|
2121
|
+
} else {
|
|
2122
|
+
entryTypeConfig = getDefaultEntryType(schemaItem.entries);
|
|
2123
|
+
}
|
|
2124
|
+
const format = entryTypeConfig?.format || "json";
|
|
2125
|
+
const ext = getFormatExtension(format);
|
|
1640
2126
|
const entryTypeName = entryTypeConfig?.name || "entry";
|
|
1641
2127
|
let collectionRoot = await resolveCollectionPath(this.root, schemaItem.logicalPath);
|
|
1642
2128
|
if (!collectionRoot) {
|
|
1643
|
-
collectionRoot =
|
|
2129
|
+
collectionRoot = path8.resolve(this.root, schemaItem.logicalPath);
|
|
1644
2130
|
}
|
|
1645
2131
|
if (!collectionRoot.startsWith(rootWithSep)) {
|
|
1646
2132
|
throw new ContentStoreError("Path traversal detected", "VALIDATION");
|
|
@@ -1648,8 +2134,9 @@ var ContentStore = class {
|
|
|
1648
2134
|
let id = options.existingId;
|
|
1649
2135
|
let existingFilename;
|
|
1650
2136
|
let existingEntryType;
|
|
2137
|
+
let foundExisting = false;
|
|
1651
2138
|
if (!id) {
|
|
1652
|
-
const entries = await
|
|
2139
|
+
const entries = await fs8.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
1653
2140
|
const existingFile = entries.find((entry) => {
|
|
1654
2141
|
if (entry.isDirectory())
|
|
1655
2142
|
return false;
|
|
@@ -1661,8 +2148,10 @@ var ContentStore = class {
|
|
|
1661
2148
|
id = extractIdFromFilename(existingFile.name) || void 0;
|
|
1662
2149
|
existingFilename = existingFile.name;
|
|
1663
2150
|
existingEntryType = extractEntryTypeFromFilename(existingFile.name) || void 0;
|
|
2151
|
+
foundExisting = true;
|
|
1664
2152
|
}
|
|
1665
2153
|
}
|
|
2154
|
+
const existed = foundExisting || Boolean(options.existingId);
|
|
1666
2155
|
const finalEntryTypeName = existingEntryType || entryTypeName;
|
|
1667
2156
|
let filename;
|
|
1668
2157
|
if (existingFilename) {
|
|
@@ -1673,16 +2162,17 @@ var ContentStore = class {
|
|
|
1673
2162
|
}
|
|
1674
2163
|
filename = `${finalEntryTypeName}.${safeSlug}.${id}${ext}`;
|
|
1675
2164
|
}
|
|
1676
|
-
const resolved =
|
|
1677
|
-
const collectionRootWithSep = collectionRoot.endsWith(
|
|
2165
|
+
const resolved = path8.resolve(collectionRoot, filename);
|
|
2166
|
+
const collectionRootWithSep = collectionRoot.endsWith(path8.sep) ? collectionRoot : `${collectionRoot}${path8.sep}`;
|
|
1678
2167
|
if (!resolved.startsWith(collectionRootWithSep)) {
|
|
1679
2168
|
throw new ContentStoreError("Path traversal detected", "VALIDATION");
|
|
1680
2169
|
}
|
|
1681
2170
|
return {
|
|
1682
2171
|
absolutePath: resolved,
|
|
1683
|
-
relativePath:
|
|
2172
|
+
relativePath: path8.relative(this.root, resolved),
|
|
1684
2173
|
id,
|
|
1685
|
-
entryTypeName: finalEntryTypeName
|
|
2174
|
+
entryTypeName: finalEntryTypeName,
|
|
2175
|
+
existed
|
|
1686
2176
|
};
|
|
1687
2177
|
}
|
|
1688
2178
|
throw new ContentStoreError("Invalid schema item type", "VALIDATION");
|
|
@@ -1715,8 +2205,8 @@ var ContentStore = class {
|
|
|
1715
2205
|
async read(collectionPath, slug = "", options = {}) {
|
|
1716
2206
|
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
1717
2207
|
const { absolutePath, relativePath, entryTypeName: resolvedEntryTypeName } = await this.buildPaths(schemaItem, slug);
|
|
1718
|
-
const stat = await
|
|
1719
|
-
const raw = await
|
|
2208
|
+
const stat = await fs8.stat(absolutePath);
|
|
2209
|
+
const raw = await fs8.readFile(absolutePath, "utf8");
|
|
1720
2210
|
let doc;
|
|
1721
2211
|
let format;
|
|
1722
2212
|
let fields;
|
|
@@ -1791,743 +2281,481 @@ var ContentStore = class {
|
|
|
1791
2281
|
} else {
|
|
1792
2282
|
entryTypeConfig = getDefaultEntryType(schemaItem.entries);
|
|
1793
2283
|
}
|
|
1794
|
-
expectedFormat = entryTypeConfig?.format || "json";
|
|
1795
|
-
fields = entryTypeConfig?.schema || [];
|
|
1796
|
-
}
|
|
1797
|
-
if (expectedFormat !== input.format) {
|
|
1798
|
-
throw new ContentStoreError(`Format mismatch: expects ${expectedFormat}, got ${input.format}`, "VALIDATION");
|
|
1799
|
-
}
|
|
1800
|
-
const
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
if (err instanceof ContentConflictError)
|
|
1814
|
-
throw err;
|
|
1815
|
-
if (isNodeError(err) && err.code === "ENOENT") {
|
|
1816
|
-
} else {
|
|
1817
|
-
throw err;
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
}
|
|
1821
|
-
if (existingId && !await filePathExists(absolutePath)) {
|
|
1822
|
-
const actualRelPath = await this.findEntryPathById(path7.dirname(absolutePath), existingId);
|
|
1823
|
-
const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
|
|
1824
|
-
if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
|
|
1825
|
-
throw new ContentConflictError();
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
let content;
|
|
1829
|
-
if (input.format === "json") {
|
|
1830
|
-
content = `${JSON.stringify(input.data ?? {}, null, 2)}
|
|
1831
|
-
`;
|
|
1832
|
-
} else if (input.format === "yaml") {
|
|
1833
|
-
content = yamlStringify(input.data ?? {});
|
|
1834
|
-
} else {
|
|
1835
|
-
content = matter.stringify(input.body, input.data ?? {});
|
|
1836
|
-
}
|
|
1837
|
-
await atomicWriteFile(absolutePath, content);
|
|
1838
|
-
const liveIndex = this._idIndex;
|
|
1839
|
-
let staleOldAbsPath = null;
|
|
1840
|
-
if (id) {
|
|
1841
|
-
const existing = liveIndex.findById(id);
|
|
1842
|
-
if (existing) {
|
|
1843
|
-
if (existing.relativePath !== relativePath) {
|
|
1844
|
-
staleOldAbsPath = path7.join(this.root, existing.relativePath);
|
|
1845
|
-
liveIndex.updatePath(existing.id, relativePath);
|
|
1846
|
-
}
|
|
1847
|
-
} else {
|
|
1848
|
-
liveIndex.add({
|
|
1849
|
-
type: "entry",
|
|
1850
|
-
relativePath,
|
|
1851
|
-
collection: collectionPath,
|
|
1852
|
-
slug: slug || void 0
|
|
1853
|
-
});
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
if (staleOldAbsPath) {
|
|
1857
|
-
await fs6.unlink(staleOldAbsPath).catch((err) => {
|
|
1858
|
-
if (!isNodeError(err) || err.code !== "ENOENT")
|
|
1859
|
-
throw err;
|
|
1860
|
-
});
|
|
1861
|
-
}
|
|
1862
|
-
await this.recordOwnMutation(liveIndex);
|
|
1863
|
-
const afterStat = await fs6.stat(absolutePath);
|
|
1864
|
-
const base = {
|
|
1865
|
-
collection: schemaItem.logicalPath,
|
|
1866
|
-
collectionName: schemaItem.name,
|
|
1867
|
-
relativePath,
|
|
1868
|
-
absolutePath,
|
|
1869
|
-
version: afterStat.mtimeMs
|
|
1870
|
-
};
|
|
1871
|
-
if (input.format === "json") {
|
|
1872
|
-
return { ...base, format: "json", data: input.data ?? {} };
|
|
1873
|
-
}
|
|
1874
|
-
if (input.format === "yaml") {
|
|
1875
|
-
return { ...base, format: "yaml", data: input.data ?? {} };
|
|
1876
|
-
}
|
|
1877
|
-
return {
|
|
1878
|
-
...base,
|
|
1879
|
-
format: input.format,
|
|
1880
|
-
data: input.data ?? {},
|
|
1881
|
-
body: input.body,
|
|
1882
|
-
bodyFieldName: findBodyFieldName(fields)
|
|
1883
|
-
};
|
|
1884
|
-
});
|
|
1885
|
-
}
|
|
1886
|
-
/**
|
|
1887
|
-
* Read an entry by its ID (UUID).
|
|
1888
|
-
* Returns null if the ID doesn't exist or points to a collection.
|
|
1889
|
-
*
|
|
1890
|
-
* Suspicious results (ID missing from the index, or an index hit whose file
|
|
1891
|
-
* is gone) trigger one forced index refresh and a retry — self-healing for
|
|
1892
|
-
* mutations by other processes inside the marker's residual windows.
|
|
1893
|
-
*/
|
|
1894
|
-
async readById(id) {
|
|
1895
|
-
const first = await this.readByIdOnce(id);
|
|
1896
|
-
if (first !== STALE_LOOKUP)
|
|
1897
|
-
return first;
|
|
1898
|
-
if (!await this.refreshIndexForSuspiciousLookup())
|
|
1899
|
-
return null;
|
|
1900
|
-
const second = await this.readByIdOnce(id);
|
|
1901
|
-
return second === STALE_LOOKUP ? null : second;
|
|
1902
|
-
}
|
|
1903
|
-
async readByIdOnce(id) {
|
|
1904
|
-
const idIndex = await this.idIndex();
|
|
1905
|
-
const location = idIndex.findById(id);
|
|
1906
|
-
if (!location)
|
|
1907
|
-
return STALE_LOOKUP;
|
|
1908
|
-
if (location.type !== "entry")
|
|
1909
|
-
return null;
|
|
1910
|
-
try {
|
|
1911
|
-
return await this.read(location.collection, location.slug);
|
|
1912
|
-
} catch (err) {
|
|
1913
|
-
if (isNodeError(err) && err.code === "ENOENT")
|
|
1914
|
-
return STALE_LOOKUP;
|
|
1915
|
-
throw err;
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
/**
|
|
1919
|
-
* Get the ID for an entry given its collection and slug.
|
|
1920
|
-
* Returns null if no ID exists yet.
|
|
1921
|
-
*/
|
|
1922
|
-
async getIdForEntry(collectionPath, slug) {
|
|
1923
|
-
const idIndex = await this.idIndex();
|
|
1924
|
-
const { relativePath } = await this.buildPaths(this.assertCollection(collectionPath), slug);
|
|
1925
|
-
return idIndex.findByPath(relativePath);
|
|
1926
|
-
}
|
|
1927
|
-
/**
|
|
1928
|
-
* Check whether a document already exists on disk for this collection+slug.
|
|
1929
|
-
* Used by the write boundary to distinguish creates from edits (create
|
|
1930
|
-
* scaffolds and maxItems enforcement).
|
|
1931
|
-
*/
|
|
1932
|
-
async documentExists(collectionPath, slug = "") {
|
|
1933
|
-
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
1934
|
-
const { absolutePath } = await this.buildPaths(schemaItem, slug);
|
|
1935
|
-
try {
|
|
1936
|
-
await fs6.access(absolutePath);
|
|
1937
|
-
return true;
|
|
1938
|
-
} catch (err) {
|
|
1939
|
-
if (isNodeError(err) && err.code === "ENOENT")
|
|
1940
|
-
return false;
|
|
1941
|
-
throw err;
|
|
1942
|
-
}
|
|
1943
|
-
}
|
|
1944
|
-
/**
|
|
1945
|
-
* Resolve the actual on-disk entry type of an existing collection entry, by
|
|
1946
|
-
* inspecting its filename directly. Entry filenames embed their type
|
|
1947
|
-
* (`{type}.{slug}.{id}.{ext}`) and it is immutable after creation — see the
|
|
1948
|
-
* existing-file lookup in buildPaths(), which this mirrors. Returns
|
|
1949
|
-
* undefined if no entry exists yet at this slug.
|
|
1950
|
-
*
|
|
1951
|
-
* Used at the API write boundary (api/content.ts) to validate an existing
|
|
1952
|
-
* entry's payload against ITS real schema rather than a caller-supplied (or
|
|
1953
|
-
* omitted/spoofed) `entryType` param — ContentStore.write() preserves the
|
|
1954
|
-
* on-disk type regardless of what's requested, so validation must agree
|
|
1955
|
-
* with what will actually be written (post-review M2).
|
|
1956
|
-
*
|
|
1957
|
-
* Cheap: one buildPaths() resolution plus a stat, no file content is read —
|
|
1958
|
-
* same cost class as documentExists().
|
|
1959
|
-
*/
|
|
1960
|
-
async getExistingEntryType(collectionPath, slug = "") {
|
|
1961
|
-
const schemaItem = this.assertCollection(collectionPath);
|
|
1962
|
-
const { absolutePath, entryTypeName } = await this.buildPaths(schemaItem, slug);
|
|
1963
|
-
return await filePathExists(absolutePath) ? entryTypeName : void 0;
|
|
1964
|
-
}
|
|
1965
|
-
/**
|
|
1966
|
-
* Count existing entries of a given entry type in a collection, by filename
|
|
1967
|
-
* (entry filenames embed their type: `{type}.{slug}.{id}.{ext}`). Used to
|
|
1968
|
-
* enforce `EntryTypeConfig.maxItems` server-side at the create boundary.
|
|
1969
|
-
*/
|
|
1970
|
-
async countEntriesOfType(collectionPath, entryTypeName) {
|
|
1971
|
-
this.assertCollection(collectionPath);
|
|
1972
|
-
const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
|
|
1973
|
-
if (!collectionRoot)
|
|
1974
|
-
return 0;
|
|
1975
|
-
const entries = await fs6.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
1976
|
-
return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
|
|
1977
|
-
}
|
|
1978
|
-
/**
|
|
1979
|
-
* Delete an entry and remove it from the index.
|
|
1980
|
-
*/
|
|
1981
|
-
async delete(collectionPath, slug) {
|
|
1982
|
-
await this.idIndex();
|
|
1983
|
-
const collection = this.assertCollection(collectionPath);
|
|
1984
|
-
const { absolutePath, relativePath } = await this.buildPaths(collection, slug);
|
|
1985
|
-
return withLock(absolutePath, async () => {
|
|
1986
|
-
await fs6.unlink(absolutePath);
|
|
1987
|
-
const liveIndex = this._idIndex;
|
|
1988
|
-
const id = liveIndex.findByPath(relativePath);
|
|
1989
|
-
if (id) {
|
|
1990
|
-
liveIndex.remove(id);
|
|
1991
|
-
}
|
|
1992
|
-
await this.recordOwnMutation(liveIndex);
|
|
1993
|
-
});
|
|
1994
|
-
}
|
|
1995
|
-
/**
|
|
1996
|
-
* Rename an entry by changing its slug (middle segment of filename).
|
|
1997
|
-
* Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
|
|
1998
|
-
*
|
|
1999
|
-
* @param collectionPath - Logical path to the collection
|
|
2000
|
-
* @param currentSlug - Current slug of the entry
|
|
2001
|
-
* @param newSlug - New slug (must be unique within collection)
|
|
2002
|
-
* @returns Object with new logical path
|
|
2003
|
-
* @throws ContentStoreError if entry doesn't exist, new slug conflicts, or validation fails
|
|
2004
|
-
*/
|
|
2005
|
-
async renameEntry(collectionPath, currentSlug, newSlug) {
|
|
2006
|
-
await this.idIndex();
|
|
2007
|
-
const collection = this.assertCollection(collectionPath);
|
|
2008
|
-
validateSlug(newSlug);
|
|
2009
|
-
const safeNewSlug = newSlug.replace(/^\/+/, "");
|
|
2010
|
-
if (!safeNewSlug) {
|
|
2011
|
-
throw new ContentStoreError("New slug cannot be empty", "VALIDATION");
|
|
2012
|
-
}
|
|
2013
|
-
if (currentSlug === safeNewSlug) {
|
|
2014
|
-
return { newPath: `${collectionPath}/${currentSlug}` };
|
|
2015
|
-
}
|
|
2016
|
-
const { absolutePath: currentPath, relativePath: currentRelPath } = await this.buildPaths(collection, currentSlug);
|
|
2017
|
-
return withLock(currentPath, async () => {
|
|
2018
|
-
try {
|
|
2019
|
-
await fs6.access(currentPath);
|
|
2020
|
-
} catch {
|
|
2021
|
-
throw new ContentStoreError(`Entry not found: ${currentSlug}`, "NOT_FOUND");
|
|
2022
|
-
}
|
|
2023
|
-
const currentFilename = path7.basename(currentPath);
|
|
2024
|
-
const parts = currentFilename.split(".");
|
|
2025
|
-
if (parts.length < 4) {
|
|
2026
|
-
throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, "VALIDATION");
|
|
2027
|
-
}
|
|
2028
|
-
const entryTypeName = parts[0];
|
|
2029
|
-
const contentId = parts[parts.length - 2];
|
|
2030
|
-
const ext = `.${parts[parts.length - 1]}`;
|
|
2031
|
-
const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
|
|
2032
|
-
const parentDir = path7.dirname(currentPath);
|
|
2033
|
-
const newPath = path7.join(parentDir, newFilename);
|
|
2034
|
-
try {
|
|
2035
|
-
const entries = await fs6.readdir(parentDir, { withFileTypes: true });
|
|
2036
|
-
for (const entry of entries) {
|
|
2037
|
-
if (entry.isDirectory())
|
|
2038
|
-
continue;
|
|
2039
|
-
const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
|
|
2040
|
-
if (existingSlug === safeNewSlug) {
|
|
2041
|
-
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
|
|
2042
|
-
}
|
|
2043
|
-
}
|
|
2044
|
-
} catch (err) {
|
|
2045
|
-
if (err instanceof ContentStoreError)
|
|
2046
|
-
throw err;
|
|
2047
|
-
}
|
|
2048
|
-
try {
|
|
2049
|
-
await fs6.link(currentPath, newPath);
|
|
2050
|
-
} catch (err) {
|
|
2051
|
-
if (isNodeError(err) && err.code === "EEXIST") {
|
|
2052
|
-
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
|
|
2053
|
-
}
|
|
2054
|
-
throw err;
|
|
2055
|
-
}
|
|
2056
|
-
await fs6.unlink(currentPath);
|
|
2057
|
-
const newRelativePath = path7.relative(this.root, newPath);
|
|
2058
|
-
const liveIndex = this._idIndex;
|
|
2059
|
-
const entryId = liveIndex.findByPath(currentRelPath);
|
|
2060
|
-
if (entryId) {
|
|
2061
|
-
liveIndex.updatePath(entryId, newRelativePath);
|
|
2062
|
-
}
|
|
2063
|
-
await this.recordOwnMutation(liveIndex);
|
|
2064
|
-
return { newPath: `${collectionPath}/${safeNewSlug}` };
|
|
2065
|
-
});
|
|
2066
|
-
}
|
|
2067
|
-
/**
|
|
2068
|
-
* List all entries in a collection tree (including subcollections).
|
|
2069
|
-
* For example, passing 'content/data-catalog' returns entries from
|
|
2070
|
-
* 'content/data-catalog', 'content/data-catalog/partner-a', etc.
|
|
2071
|
-
* Returns array of entry metadata (relativePath, collection, slug).
|
|
2072
|
-
* Returns empty array if the collection doesn't exist.
|
|
2073
|
-
*/
|
|
2074
|
-
async getCollectionEntryPaths(collectionPath) {
|
|
2075
|
-
const idIndex = await this.idIndex();
|
|
2076
|
-
const normalized = normalizeFilesystemPath(collectionPath);
|
|
2077
|
-
let item = this.schemaIndex.get(normalized);
|
|
2078
|
-
if (!item) {
|
|
2079
|
-
for (const schemaItem of this.schemaIndex.values()) {
|
|
2080
|
-
if (schemaItem.type === "collection") {
|
|
2081
|
-
const lastSegment = schemaItem.logicalPath.split("/").pop();
|
|
2082
|
-
if (lastSegment === collectionPath) {
|
|
2083
|
-
item = schemaItem;
|
|
2084
|
-
break;
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
}
|
|
2089
|
-
if (!item || item.type !== "collection") {
|
|
2090
|
-
return [];
|
|
2091
|
-
}
|
|
2092
|
-
const collection = item;
|
|
2093
|
-
const treeEntries = idIndex.getEntriesInCollectionTree(collection.logicalPath);
|
|
2094
|
-
const entries = [];
|
|
2095
|
-
for (const location of treeEntries) {
|
|
2096
|
-
if (location.type === "entry" && location.slug && location.collection) {
|
|
2097
|
-
entries.push({
|
|
2098
|
-
relativePath: location.relativePath,
|
|
2099
|
-
collection: location.collection,
|
|
2100
|
-
slug: location.slug
|
|
2101
|
-
});
|
|
2102
|
-
}
|
|
2103
|
-
}
|
|
2104
|
-
return entries;
|
|
2105
|
-
}
|
|
2106
|
-
/**
|
|
2107
|
-
* Recursively resolve reference fields in data.
|
|
2108
|
-
* This traverses objects, arrays, and blocks to find and resolve all reference fields.
|
|
2109
|
-
*/
|
|
2110
|
-
async resolveReferencesInData(data, fields) {
|
|
2111
|
-
const resolved = { ...data };
|
|
2112
|
-
const idIndex = await this.idIndex();
|
|
2113
|
-
for (const field of fields) {
|
|
2114
|
-
if (field.type === "group") {
|
|
2115
|
-
const groupResolved = await this.resolveReferencesInData(resolved, field.fields);
|
|
2116
|
-
Object.assign(resolved, groupResolved);
|
|
2117
|
-
continue;
|
|
2118
|
-
}
|
|
2119
|
-
const value = data[field.name];
|
|
2120
|
-
if (field.type === "reference") {
|
|
2121
|
-
if (typeof value === "string" && value) {
|
|
2122
|
-
resolved[field.name] = await this.resolveSingleReference(value, idIndex);
|
|
2123
|
-
} else if (field.list && Array.isArray(value)) {
|
|
2124
|
-
resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex) : null));
|
|
2284
|
+
expectedFormat = entryTypeConfig?.format || "json";
|
|
2285
|
+
fields = entryTypeConfig?.schema || [];
|
|
2286
|
+
}
|
|
2287
|
+
if (expectedFormat !== input.format) {
|
|
2288
|
+
throw new ContentStoreError(`Format mismatch: expects ${expectedFormat}, got ${input.format}`, "VALIDATION");
|
|
2289
|
+
}
|
|
2290
|
+
const prePass = await this.buildPaths(schemaItem, slug, { entryTypeName, existingId });
|
|
2291
|
+
let lockKey = this.entryLockKey(schemaItem, slug, prePass);
|
|
2292
|
+
const RETRY_KEY = Symbol("retry-with-new-lock-key");
|
|
2293
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
2294
|
+
const outcome = await withLock(lockKey, async () => {
|
|
2295
|
+
const inLock = await this.buildPaths(schemaItem, slug, {
|
|
2296
|
+
entryTypeName,
|
|
2297
|
+
existingId
|
|
2298
|
+
});
|
|
2299
|
+
const currentKey = this.entryLockKey(schemaItem, slug, inLock);
|
|
2300
|
+
if (currentKey !== lockKey) {
|
|
2301
|
+
lockKey = currentKey;
|
|
2302
|
+
return RETRY_KEY;
|
|
2125
2303
|
}
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
if (
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2304
|
+
const { absolutePath, relativePath, id } = inLock;
|
|
2305
|
+
await fs8.mkdir(path8.dirname(absolutePath), { recursive: true });
|
|
2306
|
+
if (input.expectedVersion !== void 0) {
|
|
2307
|
+
try {
|
|
2308
|
+
const existing = await fs8.stat(absolutePath);
|
|
2309
|
+
if (existing.mtimeMs !== input.expectedVersion) {
|
|
2310
|
+
throw new ContentConflictError();
|
|
2311
|
+
}
|
|
2312
|
+
} catch (err) {
|
|
2313
|
+
if (err instanceof ContentConflictError)
|
|
2314
|
+
throw err;
|
|
2315
|
+
if (isNodeError(err) && err.code === "ENOENT") {
|
|
2316
|
+
} else {
|
|
2317
|
+
throw err;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2134
2320
|
}
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
};
|
|
2148
|
-
}
|
|
2149
|
-
|
|
2321
|
+
if (existingId && !await filePathExists(absolutePath)) {
|
|
2322
|
+
const actualRelPath = await this.findEntryPathById(path8.dirname(absolutePath), existingId);
|
|
2323
|
+
const indexedRelPath = this._idIndex.findById(existingId)?.relativePath ?? null;
|
|
2324
|
+
if (actualRelPath !== null && actualRelPath !== indexedRelPath) {
|
|
2325
|
+
throw new ContentConflictError();
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
let content;
|
|
2329
|
+
if (input.format === "json") {
|
|
2330
|
+
content = `${JSON.stringify(input.data ?? {}, null, 2)}
|
|
2331
|
+
`;
|
|
2332
|
+
} else if (input.format === "yaml") {
|
|
2333
|
+
content = yamlStringify(input.data ?? {});
|
|
2334
|
+
} else {
|
|
2335
|
+
content = matter.stringify(input.body, input.data ?? {});
|
|
2336
|
+
}
|
|
2337
|
+
await atomicWriteFile(absolutePath, content);
|
|
2338
|
+
const liveIndex = this._idIndex;
|
|
2339
|
+
let staleOldAbsPath = null;
|
|
2340
|
+
if (id) {
|
|
2341
|
+
const existing = liveIndex.findById(id);
|
|
2342
|
+
if (existing) {
|
|
2343
|
+
if (existing.relativePath !== relativePath) {
|
|
2344
|
+
staleOldAbsPath = path8.join(this.root, existing.relativePath);
|
|
2345
|
+
liveIndex.updatePath(existing.id, relativePath);
|
|
2346
|
+
}
|
|
2347
|
+
} else {
|
|
2348
|
+
liveIndex.add({
|
|
2349
|
+
type: "entry",
|
|
2350
|
+
relativePath,
|
|
2351
|
+
collection: collectionPath,
|
|
2352
|
+
slug: slug || void 0
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
if (staleOldAbsPath) {
|
|
2357
|
+
await fs8.unlink(staleOldAbsPath).catch((err) => {
|
|
2358
|
+
if (!isNodeError(err) || err.code !== "ENOENT")
|
|
2359
|
+
throw err;
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
await this.recordOwnMutation(liveIndex);
|
|
2363
|
+
const afterStat = await fs8.stat(absolutePath);
|
|
2364
|
+
const base = {
|
|
2365
|
+
collection: schemaItem.logicalPath,
|
|
2366
|
+
collectionName: schemaItem.name,
|
|
2367
|
+
relativePath,
|
|
2368
|
+
absolutePath,
|
|
2369
|
+
version: afterStat.mtimeMs
|
|
2370
|
+
};
|
|
2371
|
+
if (input.format === "json") {
|
|
2372
|
+
return { ...base, format: "json", data: input.data ?? {} };
|
|
2373
|
+
}
|
|
2374
|
+
if (input.format === "yaml") {
|
|
2375
|
+
return { ...base, format: "yaml", data: input.data ?? {} };
|
|
2376
|
+
}
|
|
2377
|
+
return {
|
|
2378
|
+
...base,
|
|
2379
|
+
format: input.format,
|
|
2380
|
+
data: input.data ?? {},
|
|
2381
|
+
body: input.body,
|
|
2382
|
+
bodyFieldName: findBodyFieldName(fields)
|
|
2383
|
+
};
|
|
2384
|
+
});
|
|
2385
|
+
if (typeof outcome !== "symbol")
|
|
2386
|
+
return outcome;
|
|
2150
2387
|
}
|
|
2151
|
-
|
|
2388
|
+
throw new ContentConflictError();
|
|
2152
2389
|
}
|
|
2153
2390
|
/**
|
|
2154
|
-
*
|
|
2155
|
-
* Returns null if the
|
|
2156
|
-
* Includes id, slug, and collection fields for debugging.
|
|
2391
|
+
* Read an entry by its ID (UUID).
|
|
2392
|
+
* Returns null if the ID doesn't exist or points to a collection.
|
|
2157
2393
|
*
|
|
2158
2394
|
* Suspicious results (ID missing from the index, or an index hit whose file
|
|
2159
2395
|
* is gone) trigger one forced index refresh and a retry — self-healing for
|
|
2160
|
-
* mutations by other processes inside the marker's residual windows.
|
|
2161
|
-
* forced refresh itself is throttled (see refreshIndexForSuspiciousLookup),
|
|
2162
|
-
* but the retry against the live index always runs regardless of whether
|
|
2163
|
-
* this call won the throttle race: when a `list: true` reference array is
|
|
2164
|
-
* resolved via Promise.all, every miss shares the same stale snapshot, and
|
|
2165
|
-
* idIndex() dedupes concurrent builds — so a sibling call that wins the
|
|
2166
|
-
* throttle and rebuilds heals every other miss in the same batch, not just
|
|
2167
|
-
* the first.
|
|
2396
|
+
* mutations by other processes inside the marker's residual windows.
|
|
2168
2397
|
*/
|
|
2169
|
-
async
|
|
2170
|
-
const first = await this.
|
|
2398
|
+
async readById(id) {
|
|
2399
|
+
const first = await this.readByIdOnce(id);
|
|
2171
2400
|
if (first !== STALE_LOOKUP)
|
|
2172
2401
|
return first;
|
|
2173
|
-
await this.refreshIndexForSuspiciousLookup()
|
|
2174
|
-
const second = await this.resolveSingleReferenceOnce(id, await this.idIndex());
|
|
2175
|
-
return second === STALE_LOOKUP ? null : second;
|
|
2176
|
-
}
|
|
2177
|
-
async resolveSingleReferenceOnce(id, idIndex) {
|
|
2178
|
-
try {
|
|
2179
|
-
const location = idIndex.findById(id);
|
|
2180
|
-
if (!location)
|
|
2181
|
-
return STALE_LOOKUP;
|
|
2182
|
-
if (location.type !== "entry" || !location.collection || !location.slug) {
|
|
2183
|
-
return null;
|
|
2184
|
-
}
|
|
2185
|
-
const doc = await this.read(location.collection, location.slug, {
|
|
2186
|
-
resolveReferences: false
|
|
2187
|
-
});
|
|
2188
|
-
return {
|
|
2189
|
-
id,
|
|
2190
|
-
slug: location.slug,
|
|
2191
|
-
collection: location.collection,
|
|
2192
|
-
...doc.data
|
|
2193
|
-
};
|
|
2194
|
-
} catch (error) {
|
|
2195
|
-
if (isNodeError(error) && error.code === "ENOENT")
|
|
2196
|
-
return STALE_LOOKUP;
|
|
2197
|
-
console.error(`Failed to resolve reference ${id}:`, error);
|
|
2402
|
+
if (!await this.refreshIndexForSuspiciousLookup())
|
|
2198
2403
|
return null;
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
};
|
|
2202
|
-
|
|
2203
|
-
// dist/branch-schema-cache.js
|
|
2204
|
-
import fs8 from "node:fs/promises";
|
|
2205
|
-
import path8 from "node:path";
|
|
2206
|
-
|
|
2207
|
-
// dist/schema/meta-loader.js
|
|
2208
|
-
import { promises as fs7 } from "fs";
|
|
2209
|
-
import { join as join2 } from "pathe";
|
|
2210
|
-
import { z as z6 } from "zod";
|
|
2211
|
-
import chokidar from "chokidar";
|
|
2212
|
-
var entryTypeMetaSchema = z6.object({
|
|
2213
|
-
name: z6.string().min(1),
|
|
2214
|
-
format: z6.enum(["md", "mdx", "json", "yaml"]),
|
|
2215
|
-
schema: z6.string().min(1),
|
|
2216
|
-
// Entry schema registry key (validated at resolution time)
|
|
2217
|
-
label: z6.string().optional(),
|
|
2218
|
-
description: z6.string().optional(),
|
|
2219
|
-
default: z6.boolean().optional(),
|
|
2220
|
-
maxItems: z6.number().int().positive().optional()
|
|
2221
|
-
});
|
|
2222
|
-
var collectionMetaSchema = z6.object({
|
|
2223
|
-
name: z6.string().min(1),
|
|
2224
|
-
label: z6.string().optional(),
|
|
2225
|
-
description: z6.string().optional(),
|
|
2226
|
-
entries: z6.array(entryTypeMetaSchema).optional(),
|
|
2227
|
-
order: z6.array(z6.string()).default([])
|
|
2228
|
-
// Embedded IDs for ordering; optional in the file, [] = alphabetical
|
|
2229
|
-
}).refine((data) => data.entries && data.entries.length > 0, {
|
|
2230
|
-
message: "Collection must have at least one entry type"
|
|
2231
|
-
});
|
|
2232
|
-
var rootCollectionMetaSchema = z6.object({
|
|
2233
|
-
label: z6.string().optional(),
|
|
2234
|
-
entries: z6.array(entryTypeMetaSchema).optional(),
|
|
2235
|
-
order: z6.array(z6.string()).optional()
|
|
2236
|
-
// Embedded IDs for ordering items
|
|
2237
|
-
});
|
|
2238
|
-
function stripEmbeddedIdFromName(name) {
|
|
2239
|
-
return extractSlugFromFilename(name);
|
|
2240
|
-
}
|
|
2241
|
-
async function scanForCollectionMeta(baseDir, relativePath = "") {
|
|
2242
|
-
const collections = [];
|
|
2243
|
-
try {
|
|
2244
|
-
const entries = await fs7.readdir(baseDir, { withFileTypes: true });
|
|
2245
|
-
for (const entry of entries) {
|
|
2246
|
-
if (!entry.isDirectory())
|
|
2247
|
-
continue;
|
|
2248
|
-
const folderName = entry.name;
|
|
2249
|
-
const logicalName = stripEmbeddedIdFromName(folderName);
|
|
2250
|
-
const collectionContentId = extractIdFromFilename(folderName) ?? void 0;
|
|
2251
|
-
const folderPath = relativePath ? `${relativePath}/${logicalName}` : logicalName;
|
|
2252
|
-
const absolutePath = join2(baseDir, folderName);
|
|
2253
|
-
const metaPath = join2(absolutePath, ".collection.json");
|
|
2254
|
-
try {
|
|
2255
|
-
await fs7.access(metaPath);
|
|
2256
|
-
const content = await fs7.readFile(metaPath, "utf-8");
|
|
2257
|
-
const parsed = JSON.parse(content);
|
|
2258
|
-
const meta = collectionMetaSchema.parse(parsed);
|
|
2259
|
-
collections.push({
|
|
2260
|
-
...meta,
|
|
2261
|
-
path: folderPath,
|
|
2262
|
-
// Path derived from folder name
|
|
2263
|
-
contentId: collectionContentId
|
|
2264
|
-
});
|
|
2265
|
-
const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
|
|
2266
|
-
collections.push(...nestedCollections);
|
|
2267
|
-
} catch (err) {
|
|
2268
|
-
if (err.code !== "ENOENT") {
|
|
2269
|
-
console.error(`Error loading ${metaPath}:`, err);
|
|
2270
|
-
throw new Error(`Invalid .collection.json in ${folderPath}: ${err.message}`);
|
|
2271
|
-
}
|
|
2272
|
-
const nestedCollections = await scanForCollectionMeta(absolutePath, folderPath);
|
|
2273
|
-
collections.push(...nestedCollections);
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
return collections;
|
|
2277
|
-
} catch (err) {
|
|
2278
|
-
if (err.code === "ENOENT") {
|
|
2279
|
-
return [];
|
|
2280
|
-
}
|
|
2281
|
-
throw err;
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
async function loadCollectionMetaFiles(contentRoot) {
|
|
2285
|
-
let root = null;
|
|
2286
|
-
const rootMetaPath = join2(contentRoot, ".collection.json");
|
|
2287
|
-
try {
|
|
2288
|
-
await fs7.access(rootMetaPath);
|
|
2289
|
-
} catch (err) {
|
|
2290
|
-
if (err.code === "ENOENT") {
|
|
2291
|
-
} else {
|
|
2292
|
-
throw err;
|
|
2293
|
-
}
|
|
2294
|
-
}
|
|
2295
|
-
try {
|
|
2296
|
-
const content = await fs7.readFile(rootMetaPath, "utf-8");
|
|
2297
|
-
const parsed = JSON.parse(content);
|
|
2298
|
-
root = rootCollectionMetaSchema.parse(parsed);
|
|
2299
|
-
} catch (err) {
|
|
2300
|
-
const errno = err.code;
|
|
2301
|
-
if (errno !== "ENOENT") {
|
|
2302
|
-
throw new Error(`Invalid root .collection.json`);
|
|
2303
|
-
}
|
|
2304
|
-
}
|
|
2305
|
-
const collections = await scanForCollectionMeta(contentRoot);
|
|
2306
|
-
return { root, collections };
|
|
2307
|
-
}
|
|
2308
|
-
function resolveEntryTypes(entryTypes, entrySchemaRegistry, contextName) {
|
|
2309
|
-
return entryTypes.map((entryType) => {
|
|
2310
|
-
const resolvedSchema = entrySchemaRegistry[entryType.schema];
|
|
2311
|
-
if (!resolvedSchema) {
|
|
2312
|
-
throw new Error(`Schema reference "${entryType.schema}" in entry type "${entryType.name}" (${contextName}) not found in registry. Available schemas: ${Object.keys(entrySchemaRegistry).join(", ")}`);
|
|
2313
|
-
}
|
|
2314
|
-
return {
|
|
2315
|
-
name: entryType.name,
|
|
2316
|
-
label: entryType.label,
|
|
2317
|
-
format: entryType.format,
|
|
2318
|
-
schema: resolvedSchema,
|
|
2319
|
-
schemaRef: entryType.schema,
|
|
2320
|
-
default: entryType.default,
|
|
2321
|
-
maxItems: entryType.maxItems
|
|
2322
|
-
};
|
|
2323
|
-
});
|
|
2324
|
-
}
|
|
2325
|
-
function resolveCollectionMeta(meta, entrySchemaRegistry, allCollections) {
|
|
2326
|
-
const entries = meta.entries && meta.entries.length > 0 ? resolveEntryTypes(meta.entries, entrySchemaRegistry, `collection "${meta.name}"`) : void 0;
|
|
2327
|
-
const nestedCollections = allCollections.filter((col) => {
|
|
2328
|
-
return col.path.startsWith(`${meta.path}/`) && col.path.split("/").length === meta.path.split("/").length + 1;
|
|
2329
|
-
});
|
|
2330
|
-
const collections = nestedCollections.length > 0 ? nestedCollections.map((nestedMeta) => resolveCollectionMeta(nestedMeta, entrySchemaRegistry, allCollections)) : void 0;
|
|
2331
|
-
return {
|
|
2332
|
-
name: meta.name,
|
|
2333
|
-
label: meta.label,
|
|
2334
|
-
path: meta.path,
|
|
2335
|
-
contentId: meta.contentId,
|
|
2336
|
-
...entries && { entries },
|
|
2337
|
-
...meta.order && { order: meta.order },
|
|
2338
|
-
...collections && { collections }
|
|
2339
|
-
};
|
|
2340
|
-
}
|
|
2341
|
-
function resolveCollectionReferences(metaFiles, entrySchemaRegistry) {
|
|
2342
|
-
const result = {};
|
|
2343
|
-
if (metaFiles.root?.label) {
|
|
2344
|
-
result.label = metaFiles.root.label;
|
|
2345
|
-
}
|
|
2346
|
-
if (metaFiles.root?.entries && metaFiles.root.entries.length > 0) {
|
|
2347
|
-
result.entries = resolveEntryTypes(metaFiles.root.entries, entrySchemaRegistry, "root collection");
|
|
2348
|
-
}
|
|
2349
|
-
if (metaFiles.root?.order) {
|
|
2350
|
-
result.order = metaFiles.root.order;
|
|
2351
|
-
}
|
|
2352
|
-
const topLevelCollections = metaFiles.collections.filter((meta) => !meta.path.includes("/"));
|
|
2353
|
-
if (topLevelCollections.length > 0) {
|
|
2354
|
-
result.collections = topLevelCollections.map((meta) => resolveCollectionMeta(meta, entrySchemaRegistry, metaFiles.collections));
|
|
2355
|
-
}
|
|
2356
|
-
return result;
|
|
2357
|
-
}
|
|
2358
|
-
|
|
2359
|
-
// dist/schema/resolver.js
|
|
2360
|
-
async function resolveSchema(contentRoot, entrySchemaRegistry) {
|
|
2361
|
-
const metaFiles = await loadCollectionMetaFiles(contentRoot);
|
|
2362
|
-
const sources = [];
|
|
2363
|
-
if (metaFiles.root) {
|
|
2364
|
-
sources.push({
|
|
2365
|
-
path: ".collection.json",
|
|
2366
|
-
type: "root",
|
|
2367
|
-
collections: []
|
|
2368
|
-
});
|
|
2404
|
+
const second = await this.readByIdOnce(id);
|
|
2405
|
+
return second === STALE_LOOKUP ? null : second;
|
|
2369
2406
|
}
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2407
|
+
async readByIdOnce(id) {
|
|
2408
|
+
const idIndex = await this.idIndex();
|
|
2409
|
+
const location = idIndex.findById(id);
|
|
2410
|
+
if (!location)
|
|
2411
|
+
return STALE_LOOKUP;
|
|
2412
|
+
if (location.type !== "entry")
|
|
2413
|
+
return null;
|
|
2414
|
+
try {
|
|
2415
|
+
return await this.read(location.collection, location.slug);
|
|
2416
|
+
} catch (err) {
|
|
2417
|
+
if (isNodeError(err) && err.code === "ENOENT")
|
|
2418
|
+
return STALE_LOOKUP;
|
|
2419
|
+
throw err;
|
|
2420
|
+
}
|
|
2376
2421
|
}
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
// dist/branch-schema-cache.js
|
|
2387
|
-
init_flatten();
|
|
2388
|
-
|
|
2389
|
-
// dist/build-mode.js
|
|
2390
|
-
var isDeployedStatic = (config) => {
|
|
2391
|
-
return config.deployedAs === "static";
|
|
2392
|
-
};
|
|
2393
|
-
var isBuildMode = () => {
|
|
2394
|
-
if (process.env.NEXT_PHASE === "phase-production-build")
|
|
2395
|
-
return true;
|
|
2396
|
-
if (process.env.CANOPY_BUILD_MODE === "true")
|
|
2397
|
-
return true;
|
|
2398
|
-
return false;
|
|
2399
|
-
};
|
|
2400
|
-
var STATIC_DEPLOY_USER = Object.freeze({
|
|
2401
|
-
type: "authenticated",
|
|
2402
|
-
userId: "__static_deploy__",
|
|
2403
|
-
groups: ["Admins"],
|
|
2404
|
-
email: "static-deploy@canopycms",
|
|
2405
|
-
name: "Static Deploy"
|
|
2406
|
-
});
|
|
2407
|
-
|
|
2408
|
-
// dist/branch-schema-cache.js
|
|
2409
|
-
var SCHEMA_CACHE_VERSION = 2;
|
|
2410
|
-
var MTIME_CHECK_DEBOUNCE_MS = 1e3;
|
|
2411
|
-
async function isStaleByMtime(dir, cachedAt) {
|
|
2412
|
-
let entries;
|
|
2413
|
-
try {
|
|
2414
|
-
entries = await fs8.readdir(dir, { recursive: true, encoding: "utf-8" });
|
|
2415
|
-
} catch {
|
|
2416
|
-
return true;
|
|
2422
|
+
/**
|
|
2423
|
+
* Get the ID for an entry given its collection and slug.
|
|
2424
|
+
* Returns null if no ID exists yet.
|
|
2425
|
+
*/
|
|
2426
|
+
async getIdForEntry(collectionPath, slug) {
|
|
2427
|
+
const idIndex = await this.idIndex();
|
|
2428
|
+
const { relativePath } = await this.buildPaths(this.assertCollection(collectionPath), slug);
|
|
2429
|
+
return idIndex.findByPath(relativePath);
|
|
2417
2430
|
}
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2431
|
+
/**
|
|
2432
|
+
* Check whether a document already exists on disk for this collection+slug.
|
|
2433
|
+
* Used by the write boundary to distinguish creates from edits (create
|
|
2434
|
+
* scaffolds and maxItems enforcement).
|
|
2435
|
+
*/
|
|
2436
|
+
async documentExists(collectionPath, slug = "") {
|
|
2437
|
+
const schemaItem = this.assertSchemaItem(collectionPath);
|
|
2438
|
+
const { absolutePath } = await this.buildPaths(schemaItem, slug);
|
|
2422
2439
|
try {
|
|
2423
|
-
|
|
2424
|
-
if (stat.mtimeMs > cachedAt.getTime())
|
|
2425
|
-
return true;
|
|
2426
|
-
} catch {
|
|
2440
|
+
await fs8.access(absolutePath);
|
|
2427
2441
|
return true;
|
|
2442
|
+
} catch (err) {
|
|
2443
|
+
if (isNodeError(err) && err.code === "ENOENT")
|
|
2444
|
+
return false;
|
|
2445
|
+
throw err;
|
|
2428
2446
|
}
|
|
2429
2447
|
}
|
|
2430
|
-
return false;
|
|
2431
|
-
}
|
|
2432
|
-
var BranchSchemaCache = class {
|
|
2433
|
-
constructor(mode = "prod") {
|
|
2434
|
-
this.lastMtimeCheck = /* @__PURE__ */ new Map();
|
|
2435
|
-
this.devMode = mode === "dev";
|
|
2436
|
-
}
|
|
2437
2448
|
/**
|
|
2438
|
-
*
|
|
2449
|
+
* Resolve the actual on-disk entry type of an existing collection entry, by
|
|
2450
|
+
* inspecting its filename directly. Entry filenames embed their type
|
|
2451
|
+
* (`{type}.{slug}.{id}.{ext}`) and it is immutable after creation — see the
|
|
2452
|
+
* existing-file lookup in buildPaths(), which this mirrors. Returns
|
|
2453
|
+
* undefined if no entry exists yet at this slug.
|
|
2439
2454
|
*
|
|
2440
|
-
*
|
|
2441
|
-
*
|
|
2442
|
-
*
|
|
2443
|
-
*
|
|
2444
|
-
*
|
|
2455
|
+
* Used at the API write boundary (api/content.ts) to validate an existing
|
|
2456
|
+
* entry's payload against ITS real schema rather than a caller-supplied (or
|
|
2457
|
+
* omitted/spoofed) `entryType` param — ContentStore.write() preserves the
|
|
2458
|
+
* on-disk type regardless of what's requested, so validation must agree
|
|
2459
|
+
* with what will actually be written (post-review M2).
|
|
2460
|
+
*
|
|
2461
|
+
* Cheap: one buildPaths() resolution plus a stat, no file content is read —
|
|
2462
|
+
* same cost class as documentExists().
|
|
2445
2463
|
*/
|
|
2446
|
-
|
|
2447
|
-
|
|
2464
|
+
async getExistingEntryType(collectionPath, slug = "") {
|
|
2465
|
+
const schemaItem = this.assertCollection(collectionPath);
|
|
2466
|
+
const { absolutePath, entryTypeName } = await this.buildPaths(schemaItem, slug);
|
|
2467
|
+
return await filePathExists(absolutePath) ? entryTypeName : void 0;
|
|
2448
2468
|
}
|
|
2449
2469
|
/**
|
|
2450
|
-
*
|
|
2470
|
+
* Count existing entries of a given entry type in a collection, by filename
|
|
2471
|
+
* (entry filenames embed their type: `{type}.{slug}.{id}.{ext}`). Used to
|
|
2472
|
+
* enforce `EntryTypeConfig.maxItems` server-side at the create boundary.
|
|
2473
|
+
*/
|
|
2474
|
+
async countEntriesOfType(collectionPath, entryTypeName) {
|
|
2475
|
+
this.assertCollection(collectionPath);
|
|
2476
|
+
const collectionRoot = await resolveCollectionPath(this.root, collectionPath);
|
|
2477
|
+
if (!collectionRoot)
|
|
2478
|
+
return 0;
|
|
2479
|
+
const entries = await fs8.readdir(collectionRoot, { withFileTypes: true }).catch(() => []);
|
|
2480
|
+
return entries.filter((entry) => !entry.isDirectory() && extractEntryTypeFromFilename(entry.name) === entryTypeName).length;
|
|
2481
|
+
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Test-only seam: awaited right after the pre-pass buildPaths() resolves
|
|
2484
|
+
* in delete() and renameEntry(), before the lock key is used to acquire
|
|
2485
|
+
* anything. No-op in production. A test subclass can override this to
|
|
2486
|
+
* inject a controlled pause in that exact window, letting a test
|
|
2487
|
+
* deterministically run OTHER mutations (which don't hold this call's
|
|
2488
|
+
* not-yet-acquired lock) to completion before this call proceeds to
|
|
2489
|
+
* acquire its (possibly now-stale) lock key -- see the "Deterministic
|
|
2490
|
+
* interleavings" testing pattern in docs/concurrency.md and
|
|
2491
|
+
* branch-registry.test.ts's `BlockingRegistry` for the same idiom.
|
|
2492
|
+
*/
|
|
2493
|
+
async afterPrePassForTesting() {
|
|
2494
|
+
}
|
|
2495
|
+
/**
|
|
2496
|
+
* Delete an entry and remove it from the index.
|
|
2497
|
+
*/
|
|
2498
|
+
async delete(collectionPath, slug) {
|
|
2499
|
+
await this.idIndex();
|
|
2500
|
+
const collection = this.assertCollection(collectionPath);
|
|
2501
|
+
const prePass = await this.buildPaths(collection, slug);
|
|
2502
|
+
if (!prePass.existed) {
|
|
2503
|
+
await fs8.unlink(prePass.absolutePath);
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
let lockKey = this.entryLockKey(collection, slug, prePass);
|
|
2507
|
+
await this.afterPrePassForTesting();
|
|
2508
|
+
const RETRY_KEY = Symbol("retry-with-new-lock-key");
|
|
2509
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
2510
|
+
const outcome = await withLock(lockKey, async () => {
|
|
2511
|
+
const inLock = await this.buildPaths(collection, slug);
|
|
2512
|
+
const currentKey = this.entryLockKey(collection, slug, inLock);
|
|
2513
|
+
if (currentKey !== lockKey) {
|
|
2514
|
+
lockKey = currentKey;
|
|
2515
|
+
return RETRY_KEY;
|
|
2516
|
+
}
|
|
2517
|
+
const { absolutePath, relativePath } = inLock;
|
|
2518
|
+
await fs8.unlink(absolutePath);
|
|
2519
|
+
const liveIndex = this._idIndex;
|
|
2520
|
+
const id = liveIndex.findByPath(relativePath);
|
|
2521
|
+
if (id) {
|
|
2522
|
+
liveIndex.remove(id);
|
|
2523
|
+
}
|
|
2524
|
+
await this.recordOwnMutation(liveIndex);
|
|
2525
|
+
return void 0;
|
|
2526
|
+
});
|
|
2527
|
+
if (outcome !== RETRY_KEY)
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
throw new ContentConflictError();
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* Rename an entry by changing its slug (middle segment of filename).
|
|
2534
|
+
* Entry filename pattern: {entryTypeName}.{slug}.{id}.{ext}
|
|
2451
2535
|
*
|
|
2452
|
-
* @param
|
|
2453
|
-
* @param
|
|
2454
|
-
* @param
|
|
2455
|
-
* @returns
|
|
2536
|
+
* @param collectionPath - Logical path to the collection
|
|
2537
|
+
* @param currentSlug - Current slug of the entry
|
|
2538
|
+
* @param newSlug - New slug (must be unique within collection)
|
|
2539
|
+
* @returns Object with new logical path
|
|
2540
|
+
* @throws ContentStoreError if entry doesn't exist, new slug conflicts, or validation fails
|
|
2456
2541
|
*/
|
|
2457
|
-
async
|
|
2458
|
-
|
|
2542
|
+
async renameEntry(collectionPath, currentSlug, newSlug) {
|
|
2543
|
+
await this.idIndex();
|
|
2544
|
+
const collection = this.assertCollection(collectionPath);
|
|
2545
|
+
validateSlug(newSlug);
|
|
2546
|
+
const safeNewSlug = newSlug.replace(/^\/+/, "");
|
|
2547
|
+
if (!safeNewSlug) {
|
|
2548
|
+
throw new ContentStoreError("New slug cannot be empty", "VALIDATION");
|
|
2549
|
+
}
|
|
2550
|
+
if (currentSlug === safeNewSlug) {
|
|
2551
|
+
return { newPath: `${collectionPath}/${currentSlug}` };
|
|
2552
|
+
}
|
|
2553
|
+
const prePass = await this.buildPaths(collection, currentSlug);
|
|
2554
|
+
if (!prePass.existed) {
|
|
2555
|
+
throw new ContentStoreError(`Entry not found: ${currentSlug}`, "NOT_FOUND");
|
|
2556
|
+
}
|
|
2557
|
+
let sourceLockKey = this.entryLockKey(collection, currentSlug, prePass);
|
|
2558
|
+
let sourceId = prePass.id;
|
|
2559
|
+
let destLockKey = this.createLockKey(collection, safeNewSlug);
|
|
2560
|
+
await this.afterPrePassForTesting();
|
|
2561
|
+
const RETRY_KEY = Symbol("retry-with-new-lock-key");
|
|
2562
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
2563
|
+
const outcome = await withLocks([sourceLockKey, destLockKey], async () => {
|
|
2564
|
+
const inLock = await this.buildPaths(collection, currentSlug);
|
|
2565
|
+
if (!inLock.existed) {
|
|
2566
|
+
throw new ContentStoreError(`Entry not found: ${currentSlug}`, "NOT_FOUND");
|
|
2567
|
+
}
|
|
2568
|
+
const currentSourceKey = this.entryLockKey(collection, currentSlug, inLock);
|
|
2569
|
+
const currentDestKey = this.createLockKey(collection, safeNewSlug);
|
|
2570
|
+
if (currentSourceKey !== sourceLockKey || inLock.id !== sourceId) {
|
|
2571
|
+
sourceLockKey = currentSourceKey;
|
|
2572
|
+
destLockKey = currentDestKey;
|
|
2573
|
+
sourceId = inLock.id;
|
|
2574
|
+
return RETRY_KEY;
|
|
2575
|
+
}
|
|
2576
|
+
destLockKey = currentDestKey;
|
|
2577
|
+
const { absolutePath: currentPath, relativePath: currentRelPath } = inLock;
|
|
2578
|
+
const currentFilename = path8.basename(currentPath);
|
|
2579
|
+
const parts = currentFilename.split(".");
|
|
2580
|
+
if (parts.length < 4) {
|
|
2581
|
+
throw new ContentStoreError(`Invalid entry filename format: ${currentFilename}`, "VALIDATION");
|
|
2582
|
+
}
|
|
2583
|
+
const entryTypeName = parts[0];
|
|
2584
|
+
const contentId = parts[parts.length - 2];
|
|
2585
|
+
const ext = `.${parts[parts.length - 1]}`;
|
|
2586
|
+
const newFilename = `${entryTypeName}.${safeNewSlug}.${contentId}${ext}`;
|
|
2587
|
+
const parentDir = path8.dirname(currentPath);
|
|
2588
|
+
const newPath = path8.join(parentDir, newFilename);
|
|
2589
|
+
try {
|
|
2590
|
+
const entries = await fs8.readdir(parentDir, { withFileTypes: true });
|
|
2591
|
+
for (const entry of entries) {
|
|
2592
|
+
if (entry.isDirectory())
|
|
2593
|
+
continue;
|
|
2594
|
+
const existingSlug = extractSlugFromFilename(entry.name, entryTypeName);
|
|
2595
|
+
if (existingSlug === safeNewSlug) {
|
|
2596
|
+
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
} catch (err) {
|
|
2600
|
+
if (err instanceof ContentStoreError)
|
|
2601
|
+
throw err;
|
|
2602
|
+
}
|
|
2603
|
+
try {
|
|
2604
|
+
await fs8.link(currentPath, newPath);
|
|
2605
|
+
} catch (err) {
|
|
2606
|
+
if (isNodeError(err) && err.code === "EEXIST") {
|
|
2607
|
+
throw new ContentStoreError(`Entry with slug "${safeNewSlug}" already exists in collection "${collectionPath}"`, "VALIDATION");
|
|
2608
|
+
}
|
|
2609
|
+
throw err;
|
|
2610
|
+
}
|
|
2611
|
+
await fs8.unlink(currentPath);
|
|
2612
|
+
const newRelativePath = path8.relative(this.root, newPath);
|
|
2613
|
+
const liveIndex = this._idIndex;
|
|
2614
|
+
const entryId = liveIndex.findByPath(currentRelPath);
|
|
2615
|
+
if (entryId) {
|
|
2616
|
+
liveIndex.updatePath(entryId, newRelativePath);
|
|
2617
|
+
}
|
|
2618
|
+
await this.recordOwnMutation(liveIndex);
|
|
2619
|
+
return { newPath: `${collectionPath}/${safeNewSlug}` };
|
|
2620
|
+
});
|
|
2621
|
+
if (typeof outcome !== "symbol")
|
|
2622
|
+
return outcome;
|
|
2623
|
+
}
|
|
2624
|
+
throw new ContentConflictError();
|
|
2459
2625
|
}
|
|
2460
2626
|
/**
|
|
2461
|
-
*
|
|
2627
|
+
* List all entries in a collection tree (including subcollections).
|
|
2628
|
+
* For example, passing 'content/data-catalog' returns entries from
|
|
2629
|
+
* 'content/data-catalog', 'content/data-catalog/partner-a', etc.
|
|
2630
|
+
* Returns array of entry metadata (relativePath, collection, slug).
|
|
2631
|
+
* Returns empty array if the collection doesn't exist.
|
|
2462
2632
|
*/
|
|
2463
|
-
async
|
|
2464
|
-
const
|
|
2465
|
-
const
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
const
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
cacheData = JSON.parse(cacheContent);
|
|
2633
|
+
async getCollectionEntryPaths(collectionPath) {
|
|
2634
|
+
const idIndex = await this.idIndex();
|
|
2635
|
+
const normalized = normalizeFilesystemPath(collectionPath);
|
|
2636
|
+
let item = this.schemaIndex.get(normalized);
|
|
2637
|
+
if (!item) {
|
|
2638
|
+
for (const schemaItem of this.schemaIndex.values()) {
|
|
2639
|
+
if (schemaItem.type === "collection") {
|
|
2640
|
+
const lastSegment = schemaItem.logicalPath.split("/").pop();
|
|
2641
|
+
if (lastSegment === collectionPath) {
|
|
2642
|
+
item = schemaItem;
|
|
2643
|
+
break;
|
|
2644
|
+
}
|
|
2476
2645
|
}
|
|
2477
|
-
} catch {
|
|
2478
|
-
cacheData = null;
|
|
2479
2646
|
}
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2647
|
+
}
|
|
2648
|
+
if (!item || item.type !== "collection") {
|
|
2649
|
+
return [];
|
|
2650
|
+
}
|
|
2651
|
+
const collection = item;
|
|
2652
|
+
const treeEntries = idIndex.getEntriesInCollectionTree(collection.logicalPath);
|
|
2653
|
+
const entries = [];
|
|
2654
|
+
for (const location of treeEntries) {
|
|
2655
|
+
if (location.type === "entry" && location.slug && location.collection) {
|
|
2656
|
+
entries.push({
|
|
2657
|
+
relativePath: location.relativePath,
|
|
2658
|
+
collection: location.collection,
|
|
2659
|
+
slug: location.slug
|
|
2660
|
+
});
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
return entries;
|
|
2664
|
+
}
|
|
2665
|
+
/**
|
|
2666
|
+
* Recursively resolve reference fields in data.
|
|
2667
|
+
* This traverses objects, arrays, and blocks to find and resolve all reference fields.
|
|
2668
|
+
*/
|
|
2669
|
+
async resolveReferencesInData(data, fields) {
|
|
2670
|
+
const resolved = { ...data };
|
|
2671
|
+
const idIndex = await this.idIndex();
|
|
2672
|
+
for (const field of fields) {
|
|
2673
|
+
if (field.type === "group") {
|
|
2674
|
+
const groupResolved = await this.resolveReferencesInData(resolved, field.fields);
|
|
2675
|
+
Object.assign(resolved, groupResolved);
|
|
2676
|
+
continue;
|
|
2677
|
+
}
|
|
2678
|
+
const value = data[field.name];
|
|
2679
|
+
if (field.type === "reference") {
|
|
2680
|
+
if (typeof value === "string" && value) {
|
|
2681
|
+
resolved[field.name] = await this.resolveSingleReference(value, idIndex);
|
|
2682
|
+
} else if (field.list && Array.isArray(value)) {
|
|
2683
|
+
resolved[field.name] = await Promise.all(value.map((id) => typeof id === "string" ? this.resolveSingleReference(id, idIndex) : null));
|
|
2684
|
+
}
|
|
2685
|
+
} else if (field.type === "object" && value) {
|
|
2686
|
+
const objectField = field;
|
|
2687
|
+
if (!objectField.fields)
|
|
2688
|
+
continue;
|
|
2689
|
+
if (objectField.list && Array.isArray(value)) {
|
|
2690
|
+
resolved[field.name] = await Promise.all(value.map((item) => typeof item === "object" && item !== null ? this.resolveReferencesInData(item, objectField.fields) : item));
|
|
2691
|
+
} else if (typeof value === "object") {
|
|
2692
|
+
resolved[field.name] = await this.resolveReferencesInData(value, objectField.fields);
|
|
2490
2693
|
}
|
|
2694
|
+
} else if (field.type === "block" && Array.isArray(value)) {
|
|
2695
|
+
const blockField = field;
|
|
2696
|
+
resolved[field.name] = await Promise.all(value.map(async (block) => {
|
|
2697
|
+
const b = block;
|
|
2698
|
+
if (!b || typeof b.value !== "object")
|
|
2699
|
+
return block;
|
|
2700
|
+
const template = blockField.templates.find((t) => t.name === b.template);
|
|
2701
|
+
if (!template)
|
|
2702
|
+
return block;
|
|
2703
|
+
return {
|
|
2704
|
+
...b,
|
|
2705
|
+
value: await this.resolveReferencesInData(b.value, template.fields)
|
|
2706
|
+
};
|
|
2707
|
+
}));
|
|
2491
2708
|
}
|
|
2492
2709
|
}
|
|
2493
|
-
|
|
2494
|
-
if (!isValidSchema(result.schema)) {
|
|
2495
|
-
throw new Error(`No schema found in ${contentRoot}. Create .collection.json files with references to field schemas defined in your entry schema registry.`);
|
|
2496
|
-
}
|
|
2497
|
-
const flatSchema = flattenSchema(result.schema, contentRootName);
|
|
2498
|
-
if (!skipDiskCache) {
|
|
2499
|
-
const cacheDir = path8.join(branchRoot, ".canopy-meta");
|
|
2500
|
-
const cachePath = path8.join(cacheDir, "schema-cache.json");
|
|
2501
|
-
const stalePath = path8.join(cacheDir, "schema-cache.stale");
|
|
2502
|
-
await fs8.mkdir(cacheDir, { recursive: true });
|
|
2503
|
-
const newCache = {
|
|
2504
|
-
version: SCHEMA_CACHE_VERSION,
|
|
2505
|
-
schema: result.schema,
|
|
2506
|
-
flatSchema,
|
|
2507
|
-
cachedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2508
|
-
};
|
|
2509
|
-
const tmpPath = path8.join(cacheDir, `schema-cache.tmp.${Date.now()}.${Math.random()}.json`);
|
|
2510
|
-
await fs8.writeFile(tmpPath, JSON.stringify(newCache, null, 2), "utf-8");
|
|
2511
|
-
await fs8.rename(tmpPath, cachePath);
|
|
2512
|
-
try {
|
|
2513
|
-
await fs8.unlink(stalePath);
|
|
2514
|
-
} catch {
|
|
2515
|
-
}
|
|
2516
|
-
}
|
|
2517
|
-
return { schema: result.schema, flatSchema };
|
|
2710
|
+
return resolved;
|
|
2518
2711
|
}
|
|
2519
2712
|
/**
|
|
2520
|
-
*
|
|
2713
|
+
* Resolve a single reference ID to full entry data.
|
|
2714
|
+
* Returns null if the reference is invalid or missing.
|
|
2715
|
+
* Includes id, slug, and collection fields for debugging.
|
|
2521
2716
|
*
|
|
2522
|
-
*
|
|
2717
|
+
* Suspicious results (ID missing from the index, or an index hit whose file
|
|
2718
|
+
* is gone) trigger one forced index refresh and a retry — self-healing for
|
|
2719
|
+
* mutations by other processes inside the marker's residual windows. The
|
|
2720
|
+
* forced refresh itself is throttled (see refreshIndexForSuspiciousLookup),
|
|
2721
|
+
* but the retry against the live index always runs regardless of whether
|
|
2722
|
+
* this call won the throttle race: when a `list: true` reference array is
|
|
2723
|
+
* resolved via Promise.all, every miss shares the same stale snapshot, and
|
|
2724
|
+
* idIndex() dedupes concurrent builds — so a sibling call that wins the
|
|
2725
|
+
* throttle and rebuilds heals every other miss in the same batch, not just
|
|
2726
|
+
* the first.
|
|
2523
2727
|
*/
|
|
2524
|
-
async
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
await
|
|
2530
|
-
|
|
2728
|
+
async resolveSingleReference(id, idIndex) {
|
|
2729
|
+
const first = await this.resolveSingleReferenceOnce(id, idIndex);
|
|
2730
|
+
if (first !== STALE_LOOKUP)
|
|
2731
|
+
return first;
|
|
2732
|
+
await this.refreshIndexForSuspiciousLookup();
|
|
2733
|
+
const second = await this.resolveSingleReferenceOnce(id, await this.idIndex());
|
|
2734
|
+
return second === STALE_LOOKUP ? null : second;
|
|
2735
|
+
}
|
|
2736
|
+
async resolveSingleReferenceOnce(id, idIndex) {
|
|
2737
|
+
try {
|
|
2738
|
+
const location = idIndex.findById(id);
|
|
2739
|
+
if (!location)
|
|
2740
|
+
return STALE_LOOKUP;
|
|
2741
|
+
if (location.type !== "entry" || !location.collection || !location.slug) {
|
|
2742
|
+
return null;
|
|
2743
|
+
}
|
|
2744
|
+
const doc = await this.read(location.collection, location.slug, {
|
|
2745
|
+
resolveReferences: false
|
|
2746
|
+
});
|
|
2747
|
+
return {
|
|
2748
|
+
id,
|
|
2749
|
+
slug: location.slug,
|
|
2750
|
+
collection: location.collection,
|
|
2751
|
+
...doc.data
|
|
2752
|
+
};
|
|
2753
|
+
} catch (error) {
|
|
2754
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
2755
|
+
return STALE_LOOKUP;
|
|
2756
|
+
console.error(`Failed to resolve reference ${id}:`, error);
|
|
2757
|
+
return null;
|
|
2758
|
+
}
|
|
2531
2759
|
}
|
|
2532
2760
|
};
|
|
2533
2761
|
|
|
@@ -2809,7 +3037,7 @@ ${String(value)}`;
|
|
|
2809
3037
|
case "image":
|
|
2810
3038
|
return `${heading} ${label}${descriptionLine}
|
|
2811
3039
|
|
|
2812
|
-
|
|
3040
|
+
${formatImageMarkdown(value, label)}`;
|
|
2813
3041
|
case "code":
|
|
2814
3042
|
return `${heading} ${label}${descriptionLine}
|
|
2815
3043
|
|
|
@@ -2911,7 +3139,7 @@ function formatCellValue(field, value) {
|
|
|
2911
3139
|
case "select":
|
|
2912
3140
|
return Array.isArray(value) ? value.map((v) => resolveSelectLabel(field, v)).join(", ") : resolveSelectLabel(field, value);
|
|
2913
3141
|
case "image":
|
|
2914
|
-
return
|
|
3142
|
+
return formatImageMarkdown(value, "");
|
|
2915
3143
|
default:
|
|
2916
3144
|
return String(value);
|
|
2917
3145
|
}
|
|
@@ -2953,6 +3181,22 @@ ${items}`;
|
|
|
2953
3181
|
|
|
2954
3182
|
${formatReference(value)}`;
|
|
2955
3183
|
}
|
|
3184
|
+
function isImageValueLike(value) {
|
|
3185
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.src === "string";
|
|
3186
|
+
}
|
|
3187
|
+
function sanitizeMarkdownAltText(text) {
|
|
3188
|
+
return text.replace(/[[\]\\]/g, "").replace(/\r?\n+/g, " ").trim();
|
|
3189
|
+
}
|
|
3190
|
+
function encodeMarkdownLinkDestination(src) {
|
|
3191
|
+
return src.replace(/[\r\n]+/g, "").replace(/ /g, "%20").replace(/\(/g, "%28").replace(/\)/g, "%29");
|
|
3192
|
+
}
|
|
3193
|
+
function formatImageMarkdown(value, altFallback) {
|
|
3194
|
+
if (!isImageValueLike(value)) {
|
|
3195
|
+
return String(value);
|
|
3196
|
+
}
|
|
3197
|
+
const alt = typeof value.alt === "string" && value.alt.trim() !== "" ? value.alt : altFallback;
|
|
3198
|
+
return `})`;
|
|
3199
|
+
}
|
|
2956
3200
|
function formatReference(value) {
|
|
2957
3201
|
if (typeof value === "object" && value !== null) {
|
|
2958
3202
|
const ref = value;
|
|
@@ -3030,6 +3274,8 @@ function formatInlineValue(field, value) {
|
|
|
3030
3274
|
return value ? "Yes" : "No";
|
|
3031
3275
|
if (field.type === "reference")
|
|
3032
3276
|
return formatReference(value);
|
|
3277
|
+
if (field.type === "image")
|
|
3278
|
+
return formatImageMarkdown(value, field.label || field.name);
|
|
3033
3279
|
return String(value);
|
|
3034
3280
|
}
|
|
3035
3281
|
function yamlValue(value) {
|
|
@@ -3053,8 +3299,8 @@ function computeEntryUrl(collection, slug, contentRoot) {
|
|
|
3053
3299
|
if (slug && slug !== "index") {
|
|
3054
3300
|
segments.push(slug);
|
|
3055
3301
|
}
|
|
3056
|
-
const
|
|
3057
|
-
return
|
|
3302
|
+
const path19 = segments.length > 0 ? `/${segments.join("/")}` : "/";
|
|
3303
|
+
return path19.toLowerCase();
|
|
3058
3304
|
}
|
|
3059
3305
|
|
|
3060
3306
|
// dist/entry-link-resolver.js
|
|
@@ -3427,77 +3673,139 @@ function matchesBundleFilter(entry, filter, contentRoot) {
|
|
|
3427
3673
|
}
|
|
3428
3674
|
|
|
3429
3675
|
// dist/branch-workspace.js
|
|
3430
|
-
import
|
|
3676
|
+
import path15 from "node:path";
|
|
3431
3677
|
|
|
3432
3678
|
// dist/branch-metadata.js
|
|
3433
|
-
import
|
|
3434
|
-
import
|
|
3435
|
-
import path11 from "node:path";
|
|
3679
|
+
import fs12 from "node:fs/promises";
|
|
3680
|
+
import path12 from "node:path";
|
|
3436
3681
|
|
|
3437
3682
|
// dist/branch-registry.js
|
|
3438
3683
|
import fs10 from "node:fs/promises";
|
|
3439
3684
|
import path10 from "node:path";
|
|
3685
|
+
var log3 = createDebugLogger({ prefix: "BranchRegistry" });
|
|
3440
3686
|
var REGISTRY_FILE = "branches.json";
|
|
3441
3687
|
var REGISTRY_STALE_FILE = "branches.stale.json";
|
|
3442
3688
|
var REGISTRY_TEMP_FILE = "branches.tmp.json";
|
|
3443
|
-
var REGISTRY_VERSION =
|
|
3689
|
+
var REGISTRY_VERSION = 2;
|
|
3690
|
+
var RESOURCE2 = "branch-registry";
|
|
3691
|
+
var GET_MISS_REFRESH_MIN_INTERVAL_MS = 5e3;
|
|
3444
3692
|
var BranchRegistry = class {
|
|
3445
3693
|
constructor(root) {
|
|
3694
|
+
this.regenInFlight = null;
|
|
3695
|
+
this.lastForcedRefreshMs = 0;
|
|
3446
3696
|
this.root = path10.resolve(root);
|
|
3447
3697
|
this.registryPath = path10.join(this.root, REGISTRY_FILE);
|
|
3448
3698
|
this.stalePath = path10.join(this.root, REGISTRY_STALE_FILE);
|
|
3449
3699
|
this.tempPath = path10.join(this.root, REGISTRY_TEMP_FILE);
|
|
3450
3700
|
}
|
|
3451
3701
|
/**
|
|
3452
|
-
* Returns all branches. Uses
|
|
3702
|
+
* Returns all branches. Uses the cached snapshot if its embedded generation
|
|
3703
|
+
* token matches the live marker, regenerates otherwise.
|
|
3453
3704
|
*/
|
|
3454
3705
|
async list() {
|
|
3706
|
+
let parsed;
|
|
3455
3707
|
try {
|
|
3456
3708
|
const raw = await fs10.readFile(this.registryPath, "utf8");
|
|
3457
|
-
|
|
3458
|
-
if (!parsed.version || !Array.isArray(parsed.branches)) {
|
|
3459
|
-
return await this.regenerate();
|
|
3460
|
-
}
|
|
3461
|
-
return parsed.branches;
|
|
3709
|
+
parsed = JSON.parse(raw);
|
|
3462
3710
|
} catch (err) {
|
|
3463
3711
|
if (isNotFoundError(err)) {
|
|
3464
3712
|
return await this.regenerate();
|
|
3465
3713
|
}
|
|
3466
3714
|
throw err;
|
|
3467
3715
|
}
|
|
3716
|
+
if (parsed.version !== REGISTRY_VERSION || !Array.isArray(parsed.branches)) {
|
|
3717
|
+
return await this.regenerate();
|
|
3718
|
+
}
|
|
3719
|
+
const read = await readResourceGeneration(this.root, RESOURCE2);
|
|
3720
|
+
if (isGenerationCurrent(parsed.generation, read)) {
|
|
3721
|
+
return parsed.branches;
|
|
3722
|
+
}
|
|
3723
|
+
return await this.regenerate();
|
|
3468
3724
|
}
|
|
3469
3725
|
/**
|
|
3470
3726
|
* Returns a single branch by name. Uses cache if available.
|
|
3727
|
+
*
|
|
3728
|
+
* Suspicious-miss backstop: if `name` isn't in the (possibly cached) list,
|
|
3729
|
+
* that's a signal the snapshot may be stale in the specific direction where
|
|
3730
|
+
* a branch that now exists is missing from it - force one fresh
|
|
3731
|
+
* regeneration and re-search before giving up, throttled per instance so a
|
|
3732
|
+
* genuinely-absent branch costs at most one extra scan per throttle window.
|
|
3733
|
+
* This bounds durable window-E staleness for the "branch exists but
|
|
3734
|
+
* snapshot predates it" direction; see the class doc comment.
|
|
3471
3735
|
*/
|
|
3472
3736
|
async get(name) {
|
|
3473
3737
|
const branches = await this.list();
|
|
3474
|
-
|
|
3738
|
+
const found = branches.find((b) => b.branch.name === name);
|
|
3739
|
+
if (found)
|
|
3740
|
+
return found;
|
|
3741
|
+
if (!this.shouldForceRefresh())
|
|
3742
|
+
return void 0;
|
|
3743
|
+
const refreshed = await this.regenerate();
|
|
3744
|
+
return refreshed.find((b) => b.branch.name === name);
|
|
3745
|
+
}
|
|
3746
|
+
shouldForceRefresh() {
|
|
3747
|
+
const now = Date.now();
|
|
3748
|
+
if (now - this.lastForcedRefreshMs < GET_MISS_REFRESH_MIN_INTERVAL_MS)
|
|
3749
|
+
return false;
|
|
3750
|
+
this.lastForcedRefreshMs = now;
|
|
3751
|
+
return true;
|
|
3475
3752
|
}
|
|
3476
3753
|
/**
|
|
3477
|
-
* Marks the cache as stale
|
|
3478
|
-
*
|
|
3754
|
+
* Marks the cache as stale for every process sharing this root by bumping
|
|
3755
|
+
* the cross-process generation marker, then eager-regenerates on this host
|
|
3756
|
+
* (see class doc comment for why). The bump must succeed: a swallowed
|
|
3757
|
+
* failure here would leave the registry stale indefinitely with no
|
|
3758
|
+
* bounding backstop, unlike a failed eager regen below, which only forgoes
|
|
3759
|
+
* closing window (E) early - the bump alone already restored correctness
|
|
3760
|
+
* for every future reader. A failed eager regen therefore must not fail the
|
|
3761
|
+
* caller's save/delete.
|
|
3479
3762
|
*/
|
|
3480
3763
|
async invalidate() {
|
|
3764
|
+
await bumpResourceGeneration(this.root, RESOURCE2, { mustSucceed: true });
|
|
3481
3765
|
try {
|
|
3482
|
-
|
|
3766
|
+
if (this.regenInFlight)
|
|
3767
|
+
await this.regenInFlight.catch(() => {
|
|
3768
|
+
});
|
|
3769
|
+
await this.regenerate();
|
|
3483
3770
|
} catch (err) {
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
}
|
|
3771
|
+
log3.warn("invalidate", "Eager regeneration after invalidate() failed", {
|
|
3772
|
+
error: getErrorMessage(err)
|
|
3773
|
+
});
|
|
3487
3774
|
}
|
|
3488
3775
|
}
|
|
3489
3776
|
/**
|
|
3490
|
-
* Scans branch directories and rebuilds the cache
|
|
3491
|
-
*
|
|
3777
|
+
* Scans branch directories and rebuilds the cache, deduping concurrent
|
|
3778
|
+
* callers on this instance to a single underlying scan. Never loops waiting
|
|
3779
|
+
* for the snapshot's embedded token to match the marker - under a bump
|
|
3780
|
+
* storm that could livelock; a caller that wants the very latest state
|
|
3781
|
+
* after a concurrent bump should call list() again after this resolves.
|
|
3492
3782
|
*/
|
|
3493
3783
|
async regenerate() {
|
|
3784
|
+
if (this.regenInFlight)
|
|
3785
|
+
return this.regenInFlight;
|
|
3786
|
+
const run = this.performRegenerate();
|
|
3787
|
+
this.regenInFlight = run;
|
|
3788
|
+
try {
|
|
3789
|
+
return await run;
|
|
3790
|
+
} finally {
|
|
3791
|
+
this.regenInFlight = null;
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3794
|
+
async performRegenerate() {
|
|
3795
|
+
const read = await readResourceGeneration(this.root, RESOURCE2);
|
|
3494
3796
|
const branches = await this.scanBranchDirectories();
|
|
3495
|
-
|
|
3496
|
-
|
|
3797
|
+
await fs10.unlink(this.stalePath).catch(() => {
|
|
3798
|
+
});
|
|
3799
|
+
if (!read.ok) {
|
|
3800
|
+
return branches;
|
|
3801
|
+
}
|
|
3497
3802
|
const snapshot = {
|
|
3498
3803
|
version: REGISTRY_VERSION,
|
|
3499
|
-
branches
|
|
3804
|
+
branches,
|
|
3805
|
+
generation: read.token
|
|
3500
3806
|
};
|
|
3807
|
+
const uniqueTempPath = `${this.tempPath}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
3808
|
+
await fs10.mkdir(this.root, { recursive: true });
|
|
3501
3809
|
await fs10.writeFile(uniqueTempPath, JSON.stringify(snapshot, null, 2) + "\n", "utf8");
|
|
3502
3810
|
try {
|
|
3503
3811
|
await fs10.rename(uniqueTempPath, this.registryPath);
|
|
@@ -3506,12 +3814,12 @@ var BranchRegistry = class {
|
|
|
3506
3814
|
});
|
|
3507
3815
|
throw err;
|
|
3508
3816
|
}
|
|
3509
|
-
await fs10.unlink(this.stalePath).catch(() => {
|
|
3510
|
-
});
|
|
3511
3817
|
return branches;
|
|
3512
3818
|
}
|
|
3513
3819
|
/**
|
|
3514
3820
|
* Scans the root directory for branch subdirectories with valid branch.json files.
|
|
3821
|
+
* Protected (rather than exported as a test hook) so tests can subclass and
|
|
3822
|
+
* override to simulate cross-process interleavings.
|
|
3515
3823
|
*/
|
|
3516
3824
|
async scanBranchDirectories() {
|
|
3517
3825
|
const branches = [];
|
|
@@ -3541,30 +3849,156 @@ var BranchRegistry = class {
|
|
|
3541
3849
|
}
|
|
3542
3850
|
};
|
|
3543
3851
|
|
|
3852
|
+
// dist/utils/occ-json-write.js
|
|
3853
|
+
import fs11 from "node:fs/promises";
|
|
3854
|
+
import path11 from "node:path";
|
|
3855
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
3856
|
+
import lockfile from "proper-lockfile";
|
|
3857
|
+
var log4 = createDebugLogger({ prefix: "OccJsonWrite" });
|
|
3858
|
+
var OccWriteConflictError = class extends Error {
|
|
3859
|
+
constructor(message = "Concurrent modification detected") {
|
|
3860
|
+
super(message);
|
|
3861
|
+
this.name = "OccWriteConflictError";
|
|
3862
|
+
}
|
|
3863
|
+
};
|
|
3864
|
+
async function writeOccJsonFile(filePath, payload, options) {
|
|
3865
|
+
const { expectedVersion, settleMs = 50, trailingNewline = false } = options;
|
|
3866
|
+
const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
|
|
3867
|
+
const writeId = randomUUID2();
|
|
3868
|
+
const data = { ...payload, version: newVersion, writeId };
|
|
3869
|
+
const content = JSON.stringify(data, null, 2) + (trailingNewline ? "\n" : "");
|
|
3870
|
+
const dir = path11.dirname(filePath);
|
|
3871
|
+
await fs11.mkdir(dir, { recursive: true });
|
|
3872
|
+
const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
3873
|
+
await fs11.writeFile(tempPath, content, "utf-8");
|
|
3874
|
+
if (expectedVersion === null) {
|
|
3875
|
+
try {
|
|
3876
|
+
await fs11.link(tempPath, filePath);
|
|
3877
|
+
} catch (err) {
|
|
3878
|
+
await fs11.unlink(tempPath).catch(() => {
|
|
3879
|
+
});
|
|
3880
|
+
if (isFileExistsError(err)) {
|
|
3881
|
+
throw new OccWriteConflictError();
|
|
3882
|
+
}
|
|
3883
|
+
throw err;
|
|
3884
|
+
}
|
|
3885
|
+
await fs11.unlink(tempPath).catch(() => {
|
|
3886
|
+
});
|
|
3887
|
+
return { version: newVersion, writeId };
|
|
3888
|
+
}
|
|
3889
|
+
try {
|
|
3890
|
+
let currentVersion = null;
|
|
3891
|
+
try {
|
|
3892
|
+
const current = JSON.parse(await fs11.readFile(filePath, "utf-8"));
|
|
3893
|
+
currentVersion = current.version ?? 0;
|
|
3894
|
+
} catch {
|
|
3895
|
+
currentVersion = null;
|
|
3896
|
+
}
|
|
3897
|
+
if (currentVersion !== expectedVersion) {
|
|
3898
|
+
throw new OccWriteConflictError();
|
|
3899
|
+
}
|
|
3900
|
+
await fs11.rename(tempPath, filePath);
|
|
3901
|
+
if (settleMs > 0) {
|
|
3902
|
+
await new Promise((resolve2) => setTimeout(resolve2, settleMs));
|
|
3903
|
+
}
|
|
3904
|
+
const afterWrite = JSON.parse(await fs11.readFile(filePath, "utf-8"));
|
|
3905
|
+
if (afterWrite.writeId !== writeId) {
|
|
3906
|
+
throw new OccWriteConflictError();
|
|
3907
|
+
}
|
|
3908
|
+
} catch (err) {
|
|
3909
|
+
await fs11.unlink(tempPath).catch(() => {
|
|
3910
|
+
});
|
|
3911
|
+
throw err;
|
|
3912
|
+
}
|
|
3913
|
+
return { version: newVersion, writeId };
|
|
3914
|
+
}
|
|
3915
|
+
async function withOccRetry(operation, options) {
|
|
3916
|
+
const maxAttempts = options?.maxAttempts ?? 10;
|
|
3917
|
+
const isRetryable = options?.isRetryable;
|
|
3918
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3919
|
+
try {
|
|
3920
|
+
return await operation();
|
|
3921
|
+
} catch (err) {
|
|
3922
|
+
const retryable = err instanceof OccWriteConflictError || (isRetryable?.(err) ?? false);
|
|
3923
|
+
if (retryable && attempt < maxAttempts) {
|
|
3924
|
+
const baseDelay = Math.min(10 * Math.pow(2, attempt - 1), 100);
|
|
3925
|
+
const jitter = Math.random() * baseDelay;
|
|
3926
|
+
await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
|
|
3927
|
+
continue;
|
|
3928
|
+
}
|
|
3929
|
+
throw err;
|
|
3930
|
+
}
|
|
3931
|
+
}
|
|
3932
|
+
throw new Error("Unreachable");
|
|
3933
|
+
}
|
|
3934
|
+
async function withOccFileLock(filePath, fn) {
|
|
3935
|
+
const dir = path11.dirname(filePath);
|
|
3936
|
+
await fs11.mkdir(dir, { recursive: true });
|
|
3937
|
+
const MAX_ATTEMPTS = 14;
|
|
3938
|
+
let release = null;
|
|
3939
|
+
for (let attempt = 1; release === null; attempt++) {
|
|
3940
|
+
try {
|
|
3941
|
+
release = await lockfile.lock(filePath, {
|
|
3942
|
+
lockfilePath: `${filePath}.lock`,
|
|
3943
|
+
realpath: false,
|
|
3944
|
+
stale: 1e4,
|
|
3945
|
+
retries: 0,
|
|
3946
|
+
// A compromised lock (the lock dir vanished or refresh failed
|
|
3947
|
+
// mid-hold, e.g. the branch directory containing it was deleted)
|
|
3948
|
+
// must not crash the process, which is proper-lockfile's default.
|
|
3949
|
+
// Our critical sections are short and idempotent-on-conflict (OCC
|
|
3950
|
+
// verify inside); log and let the section finish.
|
|
3951
|
+
onCompromised: (err) => {
|
|
3952
|
+
log4.warn("lock", `Lock compromised mid-hold for ${filePath}`, {
|
|
3953
|
+
error: getErrorMessage(err)
|
|
3954
|
+
});
|
|
3955
|
+
}
|
|
3956
|
+
});
|
|
3957
|
+
} catch (err) {
|
|
3958
|
+
const code = isNodeError(err) ? err.code : void 0;
|
|
3959
|
+
const contended = code === "ELOCKED";
|
|
3960
|
+
if (!contended || attempt >= MAX_ATTEMPTS) {
|
|
3961
|
+
throw new OccWriteConflictError(`Could not acquire file lock: ${getErrorMessage(err)}`);
|
|
3962
|
+
}
|
|
3963
|
+
const baseDelay = Math.min(50 * Math.pow(1.6, attempt - 1), 2e3);
|
|
3964
|
+
const jitter = Math.random() * baseDelay;
|
|
3965
|
+
await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
try {
|
|
3969
|
+
return await fn();
|
|
3970
|
+
} finally {
|
|
3971
|
+
await release().catch((err) => {
|
|
3972
|
+
log4.warn("lock", `Failed to release lock for ${filePath}`, { error: getErrorMessage(err) });
|
|
3973
|
+
});
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3544
3977
|
// dist/branch-metadata.js
|
|
3545
3978
|
var BRANCH_META_DIR = ".canopy-meta";
|
|
3546
3979
|
var BRANCH_META_FILE = "branch.json";
|
|
3547
3980
|
var CURRENT_SCHEMA_VERSION = 1;
|
|
3548
3981
|
var BranchMetadataConflictError = class extends Error {
|
|
3549
|
-
constructor() {
|
|
3550
|
-
super(
|
|
3982
|
+
constructor(message = "Concurrent modification detected in branch metadata") {
|
|
3983
|
+
super(message);
|
|
3551
3984
|
this.name = "BranchMetadataConflictError";
|
|
3552
3985
|
}
|
|
3553
3986
|
};
|
|
3554
3987
|
var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
3555
|
-
constructor(branchRoot, baseRoot) {
|
|
3556
|
-
this.branchRoot =
|
|
3557
|
-
this.filePath =
|
|
3988
|
+
constructor(branchRoot, baseRoot, options) {
|
|
3989
|
+
this.branchRoot = path12.resolve(branchRoot);
|
|
3990
|
+
this.filePath = path12.join(this.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
|
|
3558
3991
|
this.baseRoot = baseRoot;
|
|
3992
|
+
this.settleMs = options?.settleMs;
|
|
3559
3993
|
}
|
|
3560
3994
|
/**
|
|
3561
3995
|
* Load branch metadata without requiring baseRoot.
|
|
3562
3996
|
* Use this for read-only access (e.g., in registry scanning or loadBranchContext).
|
|
3563
3997
|
*/
|
|
3564
3998
|
static async loadOnly(branchRoot) {
|
|
3565
|
-
const filePath =
|
|
3999
|
+
const filePath = path12.join(path12.resolve(branchRoot), BRANCH_META_DIR, BRANCH_META_FILE);
|
|
3566
4000
|
try {
|
|
3567
|
-
const raw = await
|
|
4001
|
+
const raw = await fs12.readFile(filePath, "utf8");
|
|
3568
4002
|
return JSON.parse(raw);
|
|
3569
4003
|
} catch (err) {
|
|
3570
4004
|
if (isNotFoundError(err)) {
|
|
@@ -3577,12 +4011,12 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
3577
4011
|
* Get a BranchMetadataFileManager instance configured for registry invalidation.
|
|
3578
4012
|
* Use this in API handlers to ensure registry cache is invalidated on updates.
|
|
3579
4013
|
*/
|
|
3580
|
-
static get(branchRoot, baseRoot) {
|
|
3581
|
-
return new _BranchMetadataFileManager(branchRoot, baseRoot);
|
|
4014
|
+
static get(branchRoot, baseRoot, options) {
|
|
4015
|
+
return new _BranchMetadataFileManager(branchRoot, baseRoot, options);
|
|
3582
4016
|
}
|
|
3583
4017
|
async load() {
|
|
3584
4018
|
try {
|
|
3585
|
-
const raw = await
|
|
4019
|
+
const raw = await fs12.readFile(this.filePath, "utf8");
|
|
3586
4020
|
const parsed = JSON.parse(raw);
|
|
3587
4021
|
const version = parsed.version ?? 0;
|
|
3588
4022
|
return { meta: parsed, version };
|
|
@@ -3594,114 +4028,107 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
3594
4028
|
}
|
|
3595
4029
|
}
|
|
3596
4030
|
/**
|
|
3597
|
-
*
|
|
3598
|
-
*
|
|
4031
|
+
* Write branch.json via the shared OCC helper, with the schemaVersion
|
|
4032
|
+
* default applied here (payload shaping stays branch-metadata's concern).
|
|
4033
|
+
*
|
|
4034
|
+
* Throws the helper's raw {@link OccWriteConflictError} so the surrounding
|
|
4035
|
+
* {@link withOccRetry} in save() recognizes and retries it; translation to
|
|
4036
|
+
* the public `BranchMetadataConflictError` contract happens at the save()
|
|
4037
|
+
* boundary, after retries are exhausted (translating earlier would make
|
|
4038
|
+
* withOccRetry's default predicate miss it, since it only recognizes the
|
|
4039
|
+
* raw error type).
|
|
4040
|
+
*
|
|
4041
|
+
* branch-metadata historically writes WITH a trailing newline, unlike
|
|
4042
|
+
* comment-store; `trailingNewline: true` preserves that.
|
|
3599
4043
|
*/
|
|
3600
4044
|
async write(meta, expectedVersion) {
|
|
3601
|
-
const newVersion = expectedVersion === null ? 1 : expectedVersion + 1;
|
|
3602
|
-
const writeId = randomUUID2();
|
|
3603
4045
|
const payload = {
|
|
3604
4046
|
...meta,
|
|
3605
|
-
schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION
|
|
3606
|
-
version: newVersion,
|
|
3607
|
-
writeId
|
|
4047
|
+
schemaVersion: meta.schemaVersion ?? CURRENT_SCHEMA_VERSION
|
|
3608
4048
|
};
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
4049
|
+
return writeOccJsonFile(this.filePath, payload, {
|
|
4050
|
+
expectedVersion,
|
|
4051
|
+
settleMs: this.settleMs,
|
|
4052
|
+
trailingNewline: true
|
|
4053
|
+
});
|
|
4054
|
+
}
|
|
4055
|
+
/**
|
|
4056
|
+
* Run a save cycle under the full lock + OCC-retry stack described in the
|
|
4057
|
+
* class doc comment. A conflict that survives every retry surfaces as the
|
|
4058
|
+
* public `BranchMetadataConflictError`.
|
|
4059
|
+
*
|
|
4060
|
+
* Guards against a phantom-resurrection race with branch deletion: a
|
|
4061
|
+
* caller's branchContext can be resolved BEFORE a concurrent
|
|
4062
|
+
* deleteBranchHandler removes the branch directory, but this save() call
|
|
4063
|
+
* only reaches here (and its own mkdir({recursive:true}) inside write())
|
|
4064
|
+
* AFTER the removal. Without this check, that mkdir would silently
|
|
4065
|
+
* recreate `.canopy-meta/` (and this save would recreate branch.json from
|
|
4066
|
+
* defaults) inside a directory tree that no longer exists anywhere else --
|
|
4067
|
+
* a registry entry with no clone behind it. Checking BEFORE the lock stack
|
|
4068
|
+
* (rather than after) fails fast without paying for a lock acquisition on
|
|
4069
|
+
* a doomed save.
|
|
4070
|
+
*
|
|
4071
|
+
* Residual window (accepted): a save that passes this check can still
|
|
4072
|
+
* race a `rm` that starts moments later and is still mid-flight when this
|
|
4073
|
+
* save's write lands, resurrecting the tree. Closing that fully would
|
|
4074
|
+
* need a tombstone OUTSIDE the tree being removed -- the lockfile
|
|
4075
|
+
* (`withOccFileLock`) this save takes next lives INSIDE `branchRoot`, so
|
|
4076
|
+
* it cannot itself provide a wider guarantee than "the directory existed
|
|
4077
|
+
* a moment ago."
|
|
4078
|
+
*/
|
|
4079
|
+
async save(incoming) {
|
|
4080
|
+
try {
|
|
4081
|
+
await fs12.stat(this.branchRoot);
|
|
4082
|
+
} catch (err) {
|
|
4083
|
+
if (isNotFoundError(err)) {
|
|
4084
|
+
throw new BranchMetadataConflictError("Branch no longer exists");
|
|
3623
4085
|
}
|
|
3624
|
-
|
|
3625
|
-
});
|
|
3626
|
-
return { version: newVersion, writeId };
|
|
4086
|
+
throw err;
|
|
3627
4087
|
}
|
|
3628
|
-
|
|
3629
|
-
await fs11.writeFile(tempPath, content, "utf-8");
|
|
4088
|
+
let saved;
|
|
3630
4089
|
try {
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
const
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
4090
|
+
saved = await withLock(this.filePath, () => withOccFileLock(this.filePath, () => withOccRetry(async () => {
|
|
4091
|
+
const { meta: existing, version } = await this.load();
|
|
4092
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4093
|
+
const defaults = {
|
|
4094
|
+
name: "unknown",
|
|
4095
|
+
status: "editing",
|
|
4096
|
+
access: {},
|
|
4097
|
+
createdBy: "unknown",
|
|
4098
|
+
createdAt: now,
|
|
4099
|
+
updatedAt: now
|
|
4100
|
+
};
|
|
4101
|
+
const merged = {
|
|
4102
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
4103
|
+
version: version ?? 0,
|
|
4104
|
+
branch: {
|
|
4105
|
+
...defaults,
|
|
4106
|
+
...existing?.branch,
|
|
4107
|
+
...incoming.branch,
|
|
4108
|
+
access: {
|
|
4109
|
+
...existing?.branch?.access,
|
|
4110
|
+
...incoming.branch?.access
|
|
4111
|
+
},
|
|
4112
|
+
// Immutable after creation
|
|
4113
|
+
createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
|
|
4114
|
+
createdAt: existing?.branch.createdAt ?? defaults.createdAt,
|
|
4115
|
+
// Fork point is recorded once at creation; later saves must not move it
|
|
4116
|
+
baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch
|
|
4117
|
+
}
|
|
4118
|
+
};
|
|
4119
|
+
const written = await this.write(merged, version);
|
|
4120
|
+
merged.version = written.version;
|
|
4121
|
+
merged.writeId = written.writeId;
|
|
4122
|
+
return merged;
|
|
4123
|
+
})));
|
|
4124
|
+
} catch (err) {
|
|
4125
|
+
if (err instanceof OccWriteConflictError) {
|
|
3644
4126
|
throw new BranchMetadataConflictError();
|
|
3645
4127
|
}
|
|
3646
|
-
} catch (err) {
|
|
3647
|
-
await fs11.unlink(tempPath).catch(() => {
|
|
3648
|
-
});
|
|
3649
4128
|
throw err;
|
|
3650
4129
|
}
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
async withRetry(operation, maxAttempts = 5) {
|
|
3654
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
3655
|
-
try {
|
|
3656
|
-
return await operation();
|
|
3657
|
-
} catch (err) {
|
|
3658
|
-
if (err instanceof BranchMetadataConflictError && attempt < maxAttempts) {
|
|
3659
|
-
const baseDelay = Math.min(10 * Math.pow(2, attempt - 1), 100);
|
|
3660
|
-
const jitter = Math.random() * baseDelay;
|
|
3661
|
-
await new Promise((resolve2) => setTimeout(resolve2, baseDelay + jitter));
|
|
3662
|
-
continue;
|
|
3663
|
-
}
|
|
3664
|
-
throw err;
|
|
3665
|
-
}
|
|
3666
|
-
}
|
|
3667
|
-
throw new Error("Unreachable");
|
|
3668
|
-
}
|
|
3669
|
-
async save(incoming) {
|
|
3670
|
-
return withLock(this.filePath, () => this.withRetry(async () => {
|
|
3671
|
-
const { meta: existing, version } = await this.load();
|
|
3672
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3673
|
-
const defaults = {
|
|
3674
|
-
name: "unknown",
|
|
3675
|
-
status: "editing",
|
|
3676
|
-
access: {},
|
|
3677
|
-
createdBy: "unknown",
|
|
3678
|
-
createdAt: now,
|
|
3679
|
-
updatedAt: now
|
|
3680
|
-
};
|
|
3681
|
-
const merged = {
|
|
3682
|
-
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
3683
|
-
version: version ?? 0,
|
|
3684
|
-
branch: {
|
|
3685
|
-
...defaults,
|
|
3686
|
-
...existing?.branch,
|
|
3687
|
-
...incoming.branch,
|
|
3688
|
-
access: {
|
|
3689
|
-
...existing?.branch?.access,
|
|
3690
|
-
...incoming.branch?.access
|
|
3691
|
-
},
|
|
3692
|
-
// Immutable after creation
|
|
3693
|
-
createdBy: existing?.branch.createdBy ?? incoming.branch?.createdBy ?? defaults.createdBy,
|
|
3694
|
-
createdAt: existing?.branch.createdAt ?? defaults.createdAt,
|
|
3695
|
-
// Fork point is recorded once at creation; later saves must not move it
|
|
3696
|
-
baseBranch: existing?.branch.baseBranch ?? incoming.branch?.baseBranch
|
|
3697
|
-
}
|
|
3698
|
-
};
|
|
3699
|
-
const written = await this.write(merged, version);
|
|
3700
|
-
merged.version = written.version;
|
|
3701
|
-
merged.writeId = written.writeId;
|
|
3702
|
-
await this.invalidateRegistry();
|
|
3703
|
-
return merged;
|
|
3704
|
-
}));
|
|
4130
|
+
await this.invalidateRegistry();
|
|
4131
|
+
return saved;
|
|
3705
4132
|
}
|
|
3706
4133
|
/**
|
|
3707
4134
|
* Invalidates the registry cache so next list() call regenerates from branch.json files.
|
|
@@ -3711,8 +4138,8 @@ var BranchMetadataFileManager = class _BranchMetadataFileManager {
|
|
|
3711
4138
|
await registry2.invalidate();
|
|
3712
4139
|
}
|
|
3713
4140
|
};
|
|
3714
|
-
var getBranchMetadataFileManager = (branchRoot, baseRoot) => {
|
|
3715
|
-
return BranchMetadataFileManager.get(branchRoot, baseRoot);
|
|
4141
|
+
var getBranchMetadataFileManager = (branchRoot, baseRoot, options) => {
|
|
4142
|
+
return BranchMetadataFileManager.get(branchRoot, baseRoot, options);
|
|
3716
4143
|
};
|
|
3717
4144
|
var loadBranchContext = async (options) => {
|
|
3718
4145
|
const { branchRoot, baseRoot } = resolveBranchPath({
|
|
@@ -3735,8 +4162,8 @@ var loadBranchContext = async (options) => {
|
|
|
3735
4162
|
init_operating_mode();
|
|
3736
4163
|
|
|
3737
4164
|
// dist/git-manager.js
|
|
3738
|
-
import
|
|
3739
|
-
import
|
|
4165
|
+
import fs14 from "node:fs/promises";
|
|
4166
|
+
import path14 from "node:path";
|
|
3740
4167
|
import { simpleGit as simpleGit2 } from "simple-git";
|
|
3741
4168
|
|
|
3742
4169
|
// dist/utils/git.js
|
|
@@ -3760,40 +4187,40 @@ async function resolveBaseBranch(options) {
|
|
|
3760
4187
|
}
|
|
3761
4188
|
|
|
3762
4189
|
// dist/utils/provisioning-lock.js
|
|
3763
|
-
import
|
|
3764
|
-
import
|
|
3765
|
-
import
|
|
4190
|
+
import fs13 from "node:fs/promises";
|
|
4191
|
+
import path13 from "node:path";
|
|
4192
|
+
import lockfile2 from "proper-lockfile";
|
|
3766
4193
|
async function acquireProvisioningLock(lockTargetDir, lockName) {
|
|
3767
|
-
await
|
|
3768
|
-
return
|
|
3769
|
-
lockfilePath:
|
|
4194
|
+
await fs13.mkdir(lockTargetDir, { recursive: true });
|
|
4195
|
+
return lockfile2.lock(lockTargetDir, {
|
|
4196
|
+
lockfilePath: path13.join(lockTargetDir, lockName),
|
|
3770
4197
|
retries: { retries: 600, factor: 1, minTimeout: 300, maxTimeout: 800, randomize: true },
|
|
3771
4198
|
stale: 3e4
|
|
3772
4199
|
});
|
|
3773
4200
|
}
|
|
3774
4201
|
|
|
3775
4202
|
// dist/git-manager.js
|
|
3776
|
-
var
|
|
4203
|
+
var log5 = createDebugLogger({ prefix: "GitManager" });
|
|
3777
4204
|
var remoteInitLocks = /* @__PURE__ */ new Map();
|
|
3778
4205
|
async function ensureGitExcludePattern(repoPath, pattern) {
|
|
3779
|
-
const excludePath =
|
|
3780
|
-
await
|
|
4206
|
+
const excludePath = path14.join(repoPath, ".git", "info", "exclude");
|
|
4207
|
+
await fs14.mkdir(path14.dirname(excludePath), { recursive: true });
|
|
3781
4208
|
let content = "";
|
|
3782
4209
|
try {
|
|
3783
|
-
content = await
|
|
4210
|
+
content = await fs14.readFile(excludePath, "utf-8");
|
|
3784
4211
|
} catch (err) {
|
|
3785
4212
|
if (!isNotFoundError(err))
|
|
3786
4213
|
throw err;
|
|
3787
4214
|
}
|
|
3788
4215
|
const lines = content.split("\n");
|
|
3789
4216
|
if (lines.some((line) => line.trim() === pattern)) {
|
|
3790
|
-
|
|
4217
|
+
log5.debug("git", "Pattern already in .git/info/exclude", { pattern });
|
|
3791
4218
|
return;
|
|
3792
4219
|
}
|
|
3793
4220
|
const needsLeadingNewline = content.length > 0 && !content.endsWith("\n");
|
|
3794
4221
|
const newContent = content + (needsLeadingNewline ? "\n" : "") + pattern + "\n";
|
|
3795
|
-
await
|
|
3796
|
-
|
|
4222
|
+
await fs14.writeFile(excludePath, newContent, "utf-8");
|
|
4223
|
+
log5.debug("git", "Added pattern to .git/info/exclude", { pattern });
|
|
3797
4224
|
}
|
|
3798
4225
|
var GitConflictError = class extends Error {
|
|
3799
4226
|
constructor(conflictedFiles) {
|
|
@@ -3804,22 +4231,22 @@ var GitConflictError = class extends Error {
|
|
|
3804
4231
|
};
|
|
3805
4232
|
var GitManager = class _GitManager {
|
|
3806
4233
|
constructor(options, gitOptions) {
|
|
3807
|
-
this.repoPath =
|
|
4234
|
+
this.repoPath = path14.resolve(options.repoPath);
|
|
3808
4235
|
this.baseBranch = options.baseBranch ?? "main";
|
|
3809
4236
|
this.remote = options.remote ?? "origin";
|
|
3810
4237
|
this.skipIndexMarker = options.skipIndexMarker ?? false;
|
|
3811
4238
|
this.git = simpleGit2({ baseDir: this.repoPath, ...gitOptions });
|
|
3812
|
-
this.git.env("GIT_CEILING_DIRECTORIES",
|
|
4239
|
+
this.git.env("GIT_CEILING_DIRECTORIES", path14.dirname(this.repoPath));
|
|
3813
4240
|
}
|
|
3814
4241
|
static async cloneRepo(remoteUrl, targetPath, baseBranch = "main") {
|
|
3815
|
-
|
|
4242
|
+
log5.debug("git", "Cloning repository", {
|
|
3816
4243
|
remoteUrl,
|
|
3817
4244
|
targetPath,
|
|
3818
4245
|
baseBranch
|
|
3819
4246
|
});
|
|
3820
4247
|
const git = simpleGit2();
|
|
3821
4248
|
await git.clone(remoteUrl, targetPath, ["--branch", baseBranch, "--single-branch"]);
|
|
3822
|
-
|
|
4249
|
+
log5.debug("git", "Clone complete");
|
|
3823
4250
|
}
|
|
3824
4251
|
/**
|
|
3825
4252
|
* Initializes a local bare git repository to simulate a remote for dev mode.
|
|
@@ -3839,30 +4266,30 @@ var GitManager = class _GitManager {
|
|
|
3839
4266
|
static async ensureLocalSimulatedRemote(options) {
|
|
3840
4267
|
const existingLock = remoteInitLocks.get(options.remotePath);
|
|
3841
4268
|
if (existingLock) {
|
|
3842
|
-
|
|
4269
|
+
log5.debug("git", "Waiting for existing remote initialization", {
|
|
3843
4270
|
remotePath: options.remotePath
|
|
3844
4271
|
});
|
|
3845
4272
|
await existingLock;
|
|
3846
4273
|
}
|
|
3847
|
-
const lockPromise =
|
|
4274
|
+
const lockPromise = log5.timed("git", "ensureLocalSimulatedRemote", async () => {
|
|
3848
4275
|
let releaseLock;
|
|
3849
4276
|
try {
|
|
3850
|
-
|
|
4277
|
+
log5.debug("git", "Initializing local simulated remote", {
|
|
3851
4278
|
remotePath: options.remotePath,
|
|
3852
4279
|
baseBranch: options.baseBranch
|
|
3853
4280
|
});
|
|
3854
|
-
const remoteParent =
|
|
4281
|
+
const remoteParent = path14.dirname(options.remotePath);
|
|
3855
4282
|
releaseLock = await acquireProvisioningLock(remoteParent, ".remote-init.lock");
|
|
3856
4283
|
let remoteExists = false;
|
|
3857
4284
|
try {
|
|
3858
|
-
const stat = await
|
|
4285
|
+
const stat = await fs14.stat(options.remotePath);
|
|
3859
4286
|
remoteExists = stat.isDirectory();
|
|
3860
4287
|
} catch (err) {
|
|
3861
4288
|
if (!isNotFoundError(err))
|
|
3862
4289
|
throw err;
|
|
3863
4290
|
}
|
|
3864
4291
|
if (remoteExists && await _GitManager.bareRemoteHasBranch(options.remotePath, options.baseBranch)) {
|
|
3865
|
-
|
|
4292
|
+
log5.debug("git", "Remote already has base branch, skipping");
|
|
3866
4293
|
return;
|
|
3867
4294
|
}
|
|
3868
4295
|
let gitRoot = options.sourcePath;
|
|
@@ -3881,8 +4308,8 @@ var GitManager = class _GitManager {
|
|
|
3881
4308
|
}
|
|
3882
4309
|
let hasCommits = false;
|
|
3883
4310
|
try {
|
|
3884
|
-
const
|
|
3885
|
-
hasCommits =
|
|
4311
|
+
const log8 = await sourceGit.log(["-1"]);
|
|
4312
|
+
hasCommits = log8.total > 0;
|
|
3886
4313
|
} catch {
|
|
3887
4314
|
hasCommits = false;
|
|
3888
4315
|
}
|
|
@@ -3894,12 +4321,12 @@ var GitManager = class _GitManager {
|
|
|
3894
4321
|
throw new Error(`Cannot initialize local simulated remote: base branch '${options.baseBranch}' does not exist locally. Please checkout '${options.baseBranch}' first or provide an explicit remoteUrl.`);
|
|
3895
4322
|
}
|
|
3896
4323
|
if (remoteExists) {
|
|
3897
|
-
|
|
4324
|
+
log5.debug("git", "Existing remote is missing base branch \u2014 pushing it from source", {
|
|
3898
4325
|
remotePath: options.remotePath,
|
|
3899
4326
|
baseBranch: options.baseBranch
|
|
3900
4327
|
});
|
|
3901
4328
|
} else {
|
|
3902
|
-
|
|
4329
|
+
log5.debug("git", "Creating bare remote repository");
|
|
3903
4330
|
await simpleGit2().raw([
|
|
3904
4331
|
"init",
|
|
3905
4332
|
"--bare",
|
|
@@ -3913,13 +4340,13 @@ var GitManager = class _GitManager {
|
|
|
3913
4340
|
baseBranch: options.baseBranch,
|
|
3914
4341
|
subdirectory: options.subdirectory
|
|
3915
4342
|
});
|
|
3916
|
-
|
|
4343
|
+
log5.debug("git", "Remote initialization complete");
|
|
3917
4344
|
} finally {
|
|
3918
4345
|
if (releaseLock) {
|
|
3919
4346
|
try {
|
|
3920
4347
|
await releaseLock();
|
|
3921
4348
|
} catch (err) {
|
|
3922
|
-
|
|
4349
|
+
log5.debug("git", "Failed to release remote-init lock", { err });
|
|
3923
4350
|
}
|
|
3924
4351
|
}
|
|
3925
4352
|
remoteInitLocks.delete(options.remotePath);
|
|
@@ -4018,7 +4445,7 @@ var GitManager = class _GitManager {
|
|
|
4018
4445
|
*/
|
|
4019
4446
|
static async validateGitRepoExists(repoPath) {
|
|
4020
4447
|
try {
|
|
4021
|
-
const stat = await
|
|
4448
|
+
const stat = await fs14.stat(path14.join(repoPath, ".git"));
|
|
4022
4449
|
if (!stat.isDirectory()) {
|
|
4023
4450
|
throw new Error(`Expected git repo at ${repoPath}`);
|
|
4024
4451
|
}
|
|
@@ -4056,9 +4483,9 @@ var GitManager = class _GitManager {
|
|
|
4056
4483
|
return process.env[config.envVarName];
|
|
4057
4484
|
if (config.autoDetectRemotePath) {
|
|
4058
4485
|
try {
|
|
4059
|
-
const stat = await
|
|
4486
|
+
const stat = await fs14.stat(config.autoDetectRemotePath);
|
|
4060
4487
|
if (stat.isDirectory()) {
|
|
4061
|
-
|
|
4488
|
+
log5.debug("git", "Auto-detected local remote", {
|
|
4062
4489
|
path: config.autoDetectRemotePath
|
|
4063
4490
|
});
|
|
4064
4491
|
return config.autoDetectRemotePath;
|
|
@@ -4069,8 +4496,8 @@ var GitManager = class _GitManager {
|
|
|
4069
4496
|
if (config.shouldAutoInitLocal) {
|
|
4070
4497
|
const gitRoot = await this.findGitRoot();
|
|
4071
4498
|
const sourceRoot = options.sourceRoot;
|
|
4072
|
-
const sourcePath = sourceRoot ?
|
|
4073
|
-
const localRemotePath =
|
|
4499
|
+
const sourcePath = sourceRoot ? path14.resolve(gitRoot, sourceRoot) : gitRoot;
|
|
4500
|
+
const localRemotePath = path14.join(sourcePath, config.defaultRemotePath);
|
|
4074
4501
|
await this.ensureLocalSimulatedRemote({
|
|
4075
4502
|
remotePath: localRemotePath,
|
|
4076
4503
|
sourcePath: gitRoot,
|
|
@@ -4096,24 +4523,24 @@ var GitManager = class _GitManager {
|
|
|
4096
4523
|
const baseBranch = await resolveBaseBranch({
|
|
4097
4524
|
defaultBaseBranch: options.baseBranch,
|
|
4098
4525
|
mode: options.mode,
|
|
4099
|
-
detectFrom: options.sourceRoot ?
|
|
4526
|
+
detectFrom: options.sourceRoot ? path14.resolve(process.cwd(), options.sourceRoot) : process.cwd()
|
|
4100
4527
|
});
|
|
4101
4528
|
const remoteName = options.remoteName ?? "origin";
|
|
4102
4529
|
let repoExists = false;
|
|
4103
4530
|
try {
|
|
4104
4531
|
const checkGit = simpleGit2({ baseDir: options.workspacePath });
|
|
4105
|
-
checkGit.env("GIT_CEILING_DIRECTORIES",
|
|
4532
|
+
checkGit.env("GIT_CEILING_DIRECTORIES", path14.dirname(options.workspacePath));
|
|
4106
4533
|
await checkGit.raw(["rev-parse", "--git-dir"]);
|
|
4107
4534
|
repoExists = true;
|
|
4108
4535
|
} catch {
|
|
4109
|
-
const gitPath =
|
|
4536
|
+
const gitPath = path14.join(options.workspacePath, ".git");
|
|
4110
4537
|
try {
|
|
4111
|
-
const stat = await
|
|
4538
|
+
const stat = await fs14.stat(gitPath);
|
|
4112
4539
|
if (stat.isDirectory()) {
|
|
4113
|
-
|
|
4540
|
+
log5.debug("git", "Removing corrupt .git directory", {
|
|
4114
4541
|
workspacePath: options.workspacePath
|
|
4115
4542
|
});
|
|
4116
|
-
await
|
|
4543
|
+
await fs14.rm(gitPath, { recursive: true });
|
|
4117
4544
|
}
|
|
4118
4545
|
} catch (cleanupErr) {
|
|
4119
4546
|
if (!isNotFoundError(cleanupErr))
|
|
@@ -4139,7 +4566,7 @@ var GitManager = class _GitManager {
|
|
|
4139
4566
|
}
|
|
4140
4567
|
justCloned = true;
|
|
4141
4568
|
const freshGit = simpleGit2({ baseDir: options.workspacePath });
|
|
4142
|
-
freshGit.env("GIT_CEILING_DIRECTORIES",
|
|
4569
|
+
freshGit.env("GIT_CEILING_DIRECTORIES", path14.dirname(options.workspacePath));
|
|
4143
4570
|
await freshGit.addConfig("canopycms.managed", "true");
|
|
4144
4571
|
await freshGit.addConfig("user.name", options.gitBotAuthorName);
|
|
4145
4572
|
await freshGit.addConfig("user.email", options.gitBotAuthorEmail);
|
|
@@ -4153,7 +4580,7 @@ var GitManager = class _GitManager {
|
|
|
4153
4580
|
await git.git.addConfig("canopycms.managed", "true");
|
|
4154
4581
|
await git.git.addConfig("user.name", options.gitBotAuthorName);
|
|
4155
4582
|
await git.git.addConfig("user.email", options.gitBotAuthorEmail);
|
|
4156
|
-
|
|
4583
|
+
log5.debug("git", "Marked workspace as CanopyCMS-managed", {
|
|
4157
4584
|
workspacePath: options.workspacePath
|
|
4158
4585
|
});
|
|
4159
4586
|
if (!justCloned) {
|
|
@@ -4189,23 +4616,28 @@ var GitManager = class _GitManager {
|
|
|
4189
4616
|
};
|
|
4190
4617
|
}
|
|
4191
4618
|
/**
|
|
4192
|
-
* Mark ContentStore ID indexes rooted at (or
|
|
4193
|
-
* Called after operations that mutate the
|
|
4194
|
-
* ID→path lookups don't keep
|
|
4195
|
-
*
|
|
4196
|
-
*
|
|
4619
|
+
* Mark ContentStore ID indexes AND the resolved-schema cache rooted at (or
|
|
4620
|
+
* under) this repo as stale. Called after operations that mutate the
|
|
4621
|
+
* working tree (checkout/merge/rebase) so ID→path lookups don't keep
|
|
4622
|
+
* resolving to pre-mutation paths, and so a rebase/checkout that pulled in
|
|
4623
|
+
* upstream `.collection.json` changes doesn't leave the schema cache
|
|
4624
|
+
* pinned to the pre-mutation schema. Invoked in `finally` blocks because
|
|
4625
|
+
* even failed merges/rebases may have touched the tree before aborting;
|
|
4626
|
+
* over-invalidating is safe.
|
|
4197
4627
|
*
|
|
4198
|
-
* Covers both scopes: in-process stores via
|
|
4199
|
-
* processes sharing the filesystem (worker vs Lambda on
|
|
4200
|
-
* generation
|
|
4201
|
-
* (skipIndexMarker), where only the free in-process
|
|
4628
|
+
* Covers both scopes: in-process stores/caches via their registries, and
|
|
4629
|
+
* consumers in OTHER processes sharing the filesystem (worker vs Lambda on
|
|
4630
|
+
* EFS) via the on-disk generation markers — unless this manager targets a
|
|
4631
|
+
* settings workspace (skipIndexMarker), where only the free in-process
|
|
4632
|
+
* content-index invalidation runs and NEITHER marker is bumped (settings
|
|
4633
|
+
* workspaces have no schema cache of their own either).
|
|
4202
4634
|
*/
|
|
4203
4635
|
async invalidateContentIndexes() {
|
|
4204
4636
|
if (this.skipIndexMarker) {
|
|
4205
4637
|
invalidateContentIndexesForRoot(this.repoPath);
|
|
4206
4638
|
return;
|
|
4207
4639
|
}
|
|
4208
|
-
await
|
|
4640
|
+
await invalidateBranchContentCaches(this.repoPath);
|
|
4209
4641
|
}
|
|
4210
4642
|
async checkoutBranch(branch) {
|
|
4211
4643
|
try {
|
|
@@ -4431,10 +4863,10 @@ var GitManager = class _GitManager {
|
|
|
4431
4863
|
}
|
|
4432
4864
|
}
|
|
4433
4865
|
async createOrphanSettingsBranchInner(branchName, initialFiles) {
|
|
4434
|
-
|
|
4866
|
+
log5.debug("git", "Creating orphan settings branch", { branchName });
|
|
4435
4867
|
const branches = await this.git.branch();
|
|
4436
4868
|
if (branches.all.includes(branchName)) {
|
|
4437
|
-
|
|
4869
|
+
log5.debug("git", "Orphan branch already exists", { branchName });
|
|
4438
4870
|
await this.git.checkout(branchName);
|
|
4439
4871
|
return;
|
|
4440
4872
|
}
|
|
@@ -4444,25 +4876,25 @@ var GitManager = class _GitManager {
|
|
|
4444
4876
|
} catch {
|
|
4445
4877
|
}
|
|
4446
4878
|
for (const [filePath, content] of Object.entries(initialFiles)) {
|
|
4447
|
-
const absolutePath =
|
|
4448
|
-
await
|
|
4449
|
-
await
|
|
4879
|
+
const absolutePath = path14.join(this.repoPath, filePath);
|
|
4880
|
+
await fs14.mkdir(path14.dirname(absolutePath), { recursive: true });
|
|
4881
|
+
await fs14.writeFile(absolutePath, content, "utf-8");
|
|
4450
4882
|
await this.git.add(filePath);
|
|
4451
4883
|
}
|
|
4452
4884
|
await this.git.commit("Initialize settings branch", ["--allow-empty"]);
|
|
4453
|
-
|
|
4885
|
+
log5.debug("git", "Orphan settings branch created", { branchName });
|
|
4454
4886
|
}
|
|
4455
4887
|
};
|
|
4456
4888
|
|
|
4457
4889
|
// dist/branch-workspace.js
|
|
4458
|
-
var
|
|
4890
|
+
var log6 = createDebugLogger({ prefix: "BranchWorkspace" });
|
|
4459
4891
|
var workspaceInitLocks = /* @__PURE__ */ new Map();
|
|
4460
4892
|
var BranchWorkspaceManager = class {
|
|
4461
4893
|
constructor(config) {
|
|
4462
4894
|
this.config = config;
|
|
4463
4895
|
}
|
|
4464
4896
|
async ensureGitWorkspace(options) {
|
|
4465
|
-
return
|
|
4897
|
+
return log6.timed("workspace", "ensureGitWorkspace", async () => {
|
|
4466
4898
|
const existingLock = workspaceInitLocks.get(options.branchRoot);
|
|
4467
4899
|
if (existingLock) {
|
|
4468
4900
|
await existingLock;
|
|
@@ -4471,11 +4903,11 @@ var BranchWorkspaceManager = class {
|
|
|
4471
4903
|
const lockPromise = (async () => {
|
|
4472
4904
|
let releaseLock;
|
|
4473
4905
|
try {
|
|
4474
|
-
|
|
4906
|
+
log6.debug("workspace", "Ensuring git workspace", {
|
|
4475
4907
|
branchName: options.branchName,
|
|
4476
4908
|
mode: options.mode
|
|
4477
4909
|
});
|
|
4478
|
-
releaseLock = await acquireProvisioningLock(
|
|
4910
|
+
releaseLock = await acquireProvisioningLock(path15.dirname(options.branchRoot), `.${path15.basename(options.branchRoot)}.init.lock`);
|
|
4479
4911
|
await GitManager.initializeWorkspace({
|
|
4480
4912
|
workspacePath: options.branchRoot,
|
|
4481
4913
|
branchName: options.branchName,
|
|
@@ -4495,7 +4927,7 @@ var BranchWorkspaceManager = class {
|
|
|
4495
4927
|
try {
|
|
4496
4928
|
await releaseLock();
|
|
4497
4929
|
} catch (err) {
|
|
4498
|
-
|
|
4930
|
+
log6.debug("workspace", "Failed to release workspace-init lock", { err });
|
|
4499
4931
|
}
|
|
4500
4932
|
}
|
|
4501
4933
|
workspaceInitLocks.delete(options.branchRoot);
|
|
@@ -4515,7 +4947,7 @@ var BranchWorkspaceManager = class {
|
|
|
4515
4947
|
const baseBranch = await resolveBaseBranch({
|
|
4516
4948
|
defaultBaseBranch: this.config.defaultBaseBranch,
|
|
4517
4949
|
mode,
|
|
4518
|
-
detectFrom: this.config.sourceRoot ?
|
|
4950
|
+
detectFrom: this.config.sourceRoot ? path15.resolve(process.cwd(), this.config.sourceRoot) : void 0
|
|
4519
4951
|
});
|
|
4520
4952
|
await this.ensureGitWorkspace({
|
|
4521
4953
|
branchRoot,
|
|
@@ -4599,11 +5031,11 @@ async function resolveBranchRoot(config) {
|
|
|
4599
5031
|
}
|
|
4600
5032
|
|
|
4601
5033
|
// dist/content-listing.js
|
|
4602
|
-
import
|
|
4603
|
-
import
|
|
5034
|
+
import fs15 from "node:fs/promises";
|
|
5035
|
+
import path16 from "node:path";
|
|
4604
5036
|
import matter2 from "gray-matter";
|
|
4605
5037
|
import { parse as yamlParse2 } from "yaml";
|
|
4606
|
-
var
|
|
5038
|
+
var log7 = createDebugLogger({ prefix: "ContentListing" });
|
|
4607
5039
|
var normalizePath = (root, target) => {
|
|
4608
5040
|
const result = validateAndNormalizePath(root, target);
|
|
4609
5041
|
if (!result.valid) {
|
|
@@ -4613,7 +5045,7 @@ var normalizePath = (root, target) => {
|
|
|
4613
5045
|
};
|
|
4614
5046
|
var readEntryData = async (filePath, format, bodyFieldName = "body") => {
|
|
4615
5047
|
try {
|
|
4616
|
-
const raw = await
|
|
5048
|
+
const raw = await fs15.readFile(filePath, "utf8");
|
|
4617
5049
|
if (format === "json") {
|
|
4618
5050
|
return asRecord(JSON.parse(raw));
|
|
4619
5051
|
}
|
|
@@ -4629,7 +5061,7 @@ var readEntryData = async (filePath, format, bodyFieldName = "body") => {
|
|
|
4629
5061
|
} catch (err) {
|
|
4630
5062
|
if (isNotFoundError(err))
|
|
4631
5063
|
return {};
|
|
4632
|
-
|
|
5064
|
+
log7.warn("readEntryData", `Failed to read entry data from ${filePath}: ${getErrorMessage(err)}`);
|
|
4633
5065
|
return {};
|
|
4634
5066
|
}
|
|
4635
5067
|
};
|
|
@@ -4726,7 +5158,7 @@ var listCollectionEntries = async (root, collection) => {
|
|
|
4726
5158
|
normalizePath(root, collectionRoot);
|
|
4727
5159
|
let dirents;
|
|
4728
5160
|
try {
|
|
4729
|
-
dirents = await
|
|
5161
|
+
dirents = await fs15.readdir(collectionRoot, { withFileTypes: true });
|
|
4730
5162
|
} catch (err) {
|
|
4731
5163
|
if (isNotFoundError(err))
|
|
4732
5164
|
return [];
|
|
@@ -4734,11 +5166,11 @@ var listCollectionEntries = async (root, collection) => {
|
|
|
4734
5166
|
}
|
|
4735
5167
|
const files = dirents.filter((d) => d.isFile() && validExts.some((ext) => d.name.endsWith(ext)) && d.name !== ".collection.json").sort((a, b) => a.name.localeCompare(b.name));
|
|
4736
5168
|
const entries = await Promise.all(files.map(async (file) => {
|
|
4737
|
-
const absolutePath =
|
|
5169
|
+
const absolutePath = path16.join(collectionRoot, file.name);
|
|
4738
5170
|
const relativePath = normalizePath(root, absolutePath);
|
|
4739
5171
|
const parsed = parseTypedFilename(file.name, entryTypes);
|
|
4740
5172
|
if (!parsed) {
|
|
4741
|
-
|
|
5173
|
+
log7.warn("listCollectionEntries", `Skipping file with unrecognized filename format: ${file.name} (expected {type}.{slug}.{id}.{ext} with a known entry type and valid 12-char Base58 ID)`);
|
|
4742
5174
|
return null;
|
|
4743
5175
|
}
|
|
4744
5176
|
const { type: entryTypeName, slug, id: contentId } = parsed;
|
|
@@ -4746,7 +5178,7 @@ var listCollectionEntries = async (root, collection) => {
|
|
|
4746
5178
|
const format = entryType?.format || "json";
|
|
4747
5179
|
const bodyField = entryType?.schema ? findBodyFieldName(entryType.schema) : "body";
|
|
4748
5180
|
const [stats, data] = await Promise.all([
|
|
4749
|
-
|
|
5181
|
+
fs15.stat(absolutePath),
|
|
4750
5182
|
readEntryData(absolutePath, format, bodyField)
|
|
4751
5183
|
]);
|
|
4752
5184
|
const item = {
|
|
@@ -4769,6 +5201,20 @@ var listCollectionEntries = async (root, collection) => {
|
|
|
4769
5201
|
// dist/validation/entry-validator.js
|
|
4770
5202
|
init_config2();
|
|
4771
5203
|
|
|
5204
|
+
// dist/assets/transform-directives.js
|
|
5205
|
+
var MAX_INPUT_PIXELS = 4096 * 4096;
|
|
5206
|
+
function isValidCropRect(x, y, w, h) {
|
|
5207
|
+
for (const n of [x, y, w, h]) {
|
|
5208
|
+
if (!Number.isFinite(n) || n < 0 || n > 1)
|
|
5209
|
+
return false;
|
|
5210
|
+
}
|
|
5211
|
+
if (w <= 0 || h <= 0)
|
|
5212
|
+
return false;
|
|
5213
|
+
if (x + w > 1 || y + h > 1)
|
|
5214
|
+
return false;
|
|
5215
|
+
return true;
|
|
5216
|
+
}
|
|
5217
|
+
|
|
4772
5218
|
// dist/validation/field-traversal.js
|
|
4773
5219
|
function isPlainRecord(value) {
|
|
4774
5220
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -4786,10 +5232,49 @@ function resolveBlockItem(blockField, item) {
|
|
|
4786
5232
|
|
|
4787
5233
|
// dist/validation/entry-validator.js
|
|
4788
5234
|
var KNOWN_FIELD_TYPES = new Set(fieldTypes);
|
|
4789
|
-
var STRING_FIELD_TYPES = /* @__PURE__ */ new Set(["string", "rich-text", "markdown", "mdx", "
|
|
5235
|
+
var STRING_FIELD_TYPES = /* @__PURE__ */ new Set(["string", "rich-text", "markdown", "mdx", "code"]);
|
|
4790
5236
|
function isPlainRecord2(value) {
|
|
4791
5237
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4792
5238
|
}
|
|
5239
|
+
function isPositiveInt(value) {
|
|
5240
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
5241
|
+
}
|
|
5242
|
+
function isValidImageCropValue(value) {
|
|
5243
|
+
if (!isPlainRecord2(value))
|
|
5244
|
+
return false;
|
|
5245
|
+
const { x, y, w, h } = value;
|
|
5246
|
+
if (typeof x !== "number" || typeof y !== "number" || typeof w !== "number" || typeof h !== "number") {
|
|
5247
|
+
return false;
|
|
5248
|
+
}
|
|
5249
|
+
return isValidCropRect(x, y, w, h);
|
|
5250
|
+
}
|
|
5251
|
+
function validateImageValue(field, value, path19) {
|
|
5252
|
+
if (!isPlainRecord2(value)) {
|
|
5253
|
+
return [{ fieldPath: path19, message: "Expected an image object with { src, alt }" }];
|
|
5254
|
+
}
|
|
5255
|
+
const errors = [];
|
|
5256
|
+
if (typeof value.src !== "string" || value.src.trim() === "") {
|
|
5257
|
+
errors.push({ fieldPath: `${path19}.src`, message: "Image src is required" });
|
|
5258
|
+
}
|
|
5259
|
+
if (typeof value.alt !== "string") {
|
|
5260
|
+
errors.push({ fieldPath: `${path19}.alt`, message: "Image alt text is required" });
|
|
5261
|
+
} else if (value.alt.trim() === "" && field.altOptional !== true) {
|
|
5262
|
+
errors.push({ fieldPath: `${path19}.alt`, message: "Image alt text is required" });
|
|
5263
|
+
}
|
|
5264
|
+
if (value.width !== void 0 && !isPositiveInt(value.width)) {
|
|
5265
|
+
errors.push({ fieldPath: `${path19}.width`, message: "Image width must be a positive integer" });
|
|
5266
|
+
}
|
|
5267
|
+
if (value.height !== void 0 && !isPositiveInt(value.height)) {
|
|
5268
|
+
errors.push({
|
|
5269
|
+
fieldPath: `${path19}.height`,
|
|
5270
|
+
message: "Image height must be a positive integer"
|
|
5271
|
+
});
|
|
5272
|
+
}
|
|
5273
|
+
if (value.crop !== void 0 && !isValidImageCropValue(value.crop)) {
|
|
5274
|
+
errors.push({ fieldPath: `${path19}.crop`, message: "Invalid image crop rect" });
|
|
5275
|
+
}
|
|
5276
|
+
return errors;
|
|
5277
|
+
}
|
|
4793
5278
|
function referenceValueId(value) {
|
|
4794
5279
|
if (typeof value === "string")
|
|
4795
5280
|
return value;
|
|
@@ -4798,42 +5283,44 @@ function referenceValueId(value) {
|
|
|
4798
5283
|
return void 0;
|
|
4799
5284
|
}
|
|
4800
5285
|
var selectOptionValues = (field) => (field.options ?? []).map((opt) => typeof opt === "string" ? opt : opt.value);
|
|
4801
|
-
function validateScalar(field, value,
|
|
5286
|
+
function validateScalar(field, value, path19) {
|
|
4802
5287
|
switch (field.type) {
|
|
4803
5288
|
case "number":
|
|
4804
5289
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
4805
|
-
return [{ fieldPath:
|
|
5290
|
+
return [{ fieldPath: path19, message: "Expected a number" }];
|
|
4806
5291
|
}
|
|
4807
5292
|
return [];
|
|
4808
5293
|
case "boolean":
|
|
4809
5294
|
if (typeof value !== "boolean") {
|
|
4810
|
-
return [{ fieldPath:
|
|
5295
|
+
return [{ fieldPath: path19, message: "Expected true or false" }];
|
|
4811
5296
|
}
|
|
4812
5297
|
return [];
|
|
4813
5298
|
case "datetime":
|
|
4814
5299
|
if (typeof value !== "string" || value !== "" && Number.isNaN(Date.parse(value))) {
|
|
4815
|
-
return [{ fieldPath:
|
|
5300
|
+
return [{ fieldPath: path19, message: "Expected a valid date/time string" }];
|
|
4816
5301
|
}
|
|
4817
5302
|
return [];
|
|
4818
5303
|
case "select": {
|
|
4819
5304
|
if (typeof value !== "string") {
|
|
4820
|
-
return [{ fieldPath:
|
|
5305
|
+
return [{ fieldPath: path19, message: "Expected a selection" }];
|
|
4821
5306
|
}
|
|
4822
5307
|
const options = selectOptionValues(field);
|
|
4823
5308
|
if (value !== "" && !options.includes(value)) {
|
|
4824
|
-
return [{ fieldPath:
|
|
5309
|
+
return [{ fieldPath: path19, message: `Must be one of: ${options.join(", ")}` }];
|
|
4825
5310
|
}
|
|
4826
5311
|
return [];
|
|
4827
5312
|
}
|
|
4828
5313
|
case "reference":
|
|
4829
5314
|
if (referenceValueId(value) === void 0) {
|
|
4830
|
-
return [{ fieldPath:
|
|
5315
|
+
return [{ fieldPath: path19, message: "Expected a reference id" }];
|
|
4831
5316
|
}
|
|
4832
5317
|
return [];
|
|
5318
|
+
case "image":
|
|
5319
|
+
return validateImageValue(field, value, path19);
|
|
4833
5320
|
default:
|
|
4834
5321
|
if (STRING_FIELD_TYPES.has(field.type)) {
|
|
4835
5322
|
if (typeof value !== "string") {
|
|
4836
|
-
return [{ fieldPath:
|
|
5323
|
+
return [{ fieldPath: path19, message: "Expected text" }];
|
|
4837
5324
|
}
|
|
4838
5325
|
}
|
|
4839
5326
|
return [];
|
|
@@ -4856,12 +5343,12 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
4856
5343
|
errors.push(...validateEntryData(field.fields, data, pathPrefix));
|
|
4857
5344
|
continue;
|
|
4858
5345
|
}
|
|
4859
|
-
const
|
|
5346
|
+
const path19 = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
|
|
4860
5347
|
const value = data[field.name];
|
|
4861
5348
|
const isRequired = "required" in field && field.required === true;
|
|
4862
5349
|
if (isRequired && isEmptyForRequired(field, value)) {
|
|
4863
5350
|
const message = Array.isArray(value) ? "At least one item is required" : "This field is required";
|
|
4864
|
-
errors.push({ fieldPath:
|
|
5351
|
+
errors.push({ fieldPath: path19, message });
|
|
4865
5352
|
continue;
|
|
4866
5353
|
}
|
|
4867
5354
|
if (value === void 0 || value === null)
|
|
@@ -4869,11 +5356,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
4869
5356
|
if (field.type === "block") {
|
|
4870
5357
|
const blockField = field;
|
|
4871
5358
|
if (!Array.isArray(value)) {
|
|
4872
|
-
errors.push({ fieldPath:
|
|
5359
|
+
errors.push({ fieldPath: path19, message: "Expected a list of blocks" });
|
|
4873
5360
|
continue;
|
|
4874
5361
|
}
|
|
4875
5362
|
value.forEach((item, index) => {
|
|
4876
|
-
const itemPath = `${
|
|
5363
|
+
const itemPath = `${path19}[${index}]`;
|
|
4877
5364
|
if (!isPlainRecord2(item)) {
|
|
4878
5365
|
errors.push({ fieldPath: itemPath, message: "Expected a block object" });
|
|
4879
5366
|
return;
|
|
@@ -4895,11 +5382,11 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
4895
5382
|
const objectField = field;
|
|
4896
5383
|
if (objectField.list) {
|
|
4897
5384
|
if (!Array.isArray(value)) {
|
|
4898
|
-
errors.push({ fieldPath:
|
|
5385
|
+
errors.push({ fieldPath: path19, message: "Expected a list of items" });
|
|
4899
5386
|
continue;
|
|
4900
5387
|
}
|
|
4901
5388
|
value.forEach((item, index) => {
|
|
4902
|
-
const itemPath = `${
|
|
5389
|
+
const itemPath = `${path19}[${index}]`;
|
|
4903
5390
|
if (!isPlainRecord2(item)) {
|
|
4904
5391
|
errors.push({ fieldPath: itemPath, message: "Expected an object" });
|
|
4905
5392
|
return;
|
|
@@ -4909,27 +5396,27 @@ function validateEntryData(fields, data, pathPrefix = "") {
|
|
|
4909
5396
|
}
|
|
4910
5397
|
});
|
|
4911
5398
|
} else if (!isPlainRecord2(value)) {
|
|
4912
|
-
errors.push({ fieldPath:
|
|
5399
|
+
errors.push({ fieldPath: path19, message: "Expected an object" });
|
|
4913
5400
|
} else if (objectField.fields) {
|
|
4914
|
-
errors.push(...validateEntryData(objectField.fields, value,
|
|
5401
|
+
errors.push(...validateEntryData(objectField.fields, value, path19));
|
|
4915
5402
|
}
|
|
4916
5403
|
continue;
|
|
4917
5404
|
}
|
|
4918
5405
|
if ("list" in field && field.list) {
|
|
4919
5406
|
if (!Array.isArray(value)) {
|
|
4920
|
-
errors.push({ fieldPath:
|
|
5407
|
+
errors.push({ fieldPath: path19, message: "Expected a list of values" });
|
|
4921
5408
|
continue;
|
|
4922
5409
|
}
|
|
4923
5410
|
value.forEach((item, index) => {
|
|
4924
|
-
errors.push(...validateScalar(field, item, `${
|
|
5411
|
+
errors.push(...validateScalar(field, item, `${path19}[${index}]`));
|
|
4925
5412
|
});
|
|
4926
5413
|
continue;
|
|
4927
5414
|
}
|
|
4928
5415
|
if (Array.isArray(value) && KNOWN_FIELD_TYPES.has(field.type)) {
|
|
4929
|
-
errors.push({ fieldPath:
|
|
5416
|
+
errors.push({ fieldPath: path19, message: "Expected a single value, not a list" });
|
|
4930
5417
|
continue;
|
|
4931
5418
|
}
|
|
4932
|
-
errors.push(...validateScalar(field, value,
|
|
5419
|
+
errors.push(...validateScalar(field, value, path19));
|
|
4933
5420
|
}
|
|
4934
5421
|
return errors;
|
|
4935
5422
|
}
|
|
@@ -4999,15 +5486,15 @@ async function generateAIContentFiles(options) {
|
|
|
4999
5486
|
config: aiConfig,
|
|
5000
5487
|
entryLinkUrl: config.entryLinkUrl
|
|
5001
5488
|
});
|
|
5002
|
-
const absoluteOutputDir =
|
|
5489
|
+
const absoluteOutputDir = path17.resolve(outputDir) + path17.sep;
|
|
5003
5490
|
let fileCount = 0;
|
|
5004
5491
|
for (const [filePath, content] of result.files) {
|
|
5005
|
-
const absolutePath =
|
|
5492
|
+
const absolutePath = path17.resolve(path17.join(absoluteOutputDir, filePath));
|
|
5006
5493
|
if (!absolutePath.startsWith(absoluteOutputDir)) {
|
|
5007
5494
|
throw new Error(`Path traversal detected in AI content output: ${filePath}`);
|
|
5008
5495
|
}
|
|
5009
|
-
await
|
|
5010
|
-
await
|
|
5496
|
+
await fs16.mkdir(path17.dirname(absolutePath), { recursive: true });
|
|
5497
|
+
await fs16.writeFile(absolutePath, content, "utf-8");
|
|
5011
5498
|
fileCount++;
|
|
5012
5499
|
}
|
|
5013
5500
|
return { fileCount, outputDir: absoluteOutputDir };
|
|
@@ -5018,7 +5505,7 @@ var jiti = createJiti(import.meta.url);
|
|
|
5018
5505
|
async function generateAIContentCLI(options) {
|
|
5019
5506
|
const { projectDir, outputDir = "public/ai", configPath, appDir = "app" } = options;
|
|
5020
5507
|
console.log("\nCanopyCMS generate-ai-content\n");
|
|
5021
|
-
const canopyConfigPath =
|
|
5508
|
+
const canopyConfigPath = path18.join(projectDir, "canopycms.config.ts");
|
|
5022
5509
|
let canopyConfigModule;
|
|
5023
5510
|
try {
|
|
5024
5511
|
canopyConfigModule = await jiti.import(canopyConfigPath);
|
|
@@ -5029,7 +5516,7 @@ async function generateAIContentCLI(options) {
|
|
|
5029
5516
|
}
|
|
5030
5517
|
const configExport = canopyConfigModule.default ?? canopyConfigModule.config ?? canopyConfigModule;
|
|
5031
5518
|
const serverConfig = typeof configExport === "object" && configExport !== null && "server" in configExport ? configExport.server : configExport;
|
|
5032
|
-
const schemasPath =
|
|
5519
|
+
const schemasPath = path18.join(projectDir, appDir, "schemas.ts");
|
|
5033
5520
|
let entrySchemaRegistry = {};
|
|
5034
5521
|
try {
|
|
5035
5522
|
const schemasModule = await jiti.import(schemasPath);
|
|
@@ -5040,7 +5527,7 @@ async function generateAIContentCLI(options) {
|
|
|
5040
5527
|
let aiConfig;
|
|
5041
5528
|
if (configPath) {
|
|
5042
5529
|
try {
|
|
5043
|
-
const aiConfigModule = await jiti.import(
|
|
5530
|
+
const aiConfigModule = await jiti.import(path18.resolve(configPath));
|
|
5044
5531
|
aiConfig = aiConfigModule.aiContentConfig ?? aiConfigModule.default ?? aiConfigModule.config;
|
|
5045
5532
|
} catch (err) {
|
|
5046
5533
|
console.error(`Could not load AI config from ${configPath}`);
|
|
@@ -5057,7 +5544,7 @@ async function generateAIContentCLI(options) {
|
|
|
5057
5544
|
console.error("Make sure canopycms.config.ts uses defineCanopyConfig().");
|
|
5058
5545
|
process.exit(1);
|
|
5059
5546
|
}
|
|
5060
|
-
const resolvedOutput =
|
|
5547
|
+
const resolvedOutput = path18.resolve(projectDir, outputDir);
|
|
5061
5548
|
console.log(` Output: ${resolvedOutput}`);
|
|
5062
5549
|
console.log(` Mode: ${serverConfig.mode ?? "dev"}`);
|
|
5063
5550
|
const result = await generateAIContentFiles({
|